class Note(DictSpace): time: XArrow = Field(default_factory=XArrow.from_absolute, cast=XArrow.from_absolute) content: str = Field(default_factory=str) def __init__(self, note: Mapping) -> None: time = next(iter(note)) content = note[time] super().__init__(time=time, content=content) if not os.getenv('TIMEFRED_REPR', '').lower() in ('no', 'disable'): def __repr__(self): return f'{self.__class__.__qualname__}(content = {self.content!r}, time = {self.time!r})' # @multimethod # def __init__(self, content: str, time: Union[str, XArrow]=None): # self.content = content # if not time: # time = XArrow.now() # self._time = time # # @multimethod # def __init__(self, note: str): # match = NOTE_TIME_RE.fullmatch(note) # if match: # match_groups = match.groups() # self.content = match_groups[0] # self._time = match_groups[1] # else: # self.content = note # self._time = None def __iter__(self): yield self.content yield self.time def __bool__(self): return bool(self.content) def pretty(self, color=True): content = c.b(self.content) if color else self.content if self.time: string = f'{self.time.HHmm} {c.black("|")} {content}' return c.note(string) if color else string return c.note(content) if color else content @multimethod def is_similar(self, other: "Note") -> bool: return self.is_similar(other.content) @multimethod def is_similar(self, other: str) -> bool: other_normalized = normalize_str(other) self_normalized = normalize_str(self.content) return self_normalized in other_normalized or other_normalized in self_normalized
class Colored(StringSpace): brush: Callable = Field(optional=True) # def __init__(self, seq: object='', brush: Callable = None) -> None: # UserString.__init__(self, seq) @property def colored(self): return self.brush(self)
class Entry(AttrDictSpace): start: XArrow = Field(cast=XArrow.from_absolute) end: Optional[XArrow] = Field(optional=True, cast=XArrow.from_absolute) jira: Optional[JiraTicket] = Field(default_factory=JiraTicket) synced: Optional[bool] = Field(optional=True) notes: Optional[list[Note]] = Field(optional=True, cast=list[Note]) tags: Optional[set[Tag]] = Field(optional=True, cast=list[Tag]) # def __new__(cls, mappable=(), **kwargs) -> "Entry": # if mappable: # print() # else: # return super().__new__(cls, mappable, **kwargs) # @Field(optional=True) @cached_property # @property def timespan(self): start = self.start end = self.end timespan = Timespan(start=start, end=end) return timespan if not os.getenv('TIMEFRED_REPR', '').lower() in ('no', 'disable'): def __repr__(self): representation = f'Entry(start={self.start!r}' if self.end: representation += f', end={self.end!r}' if self.jira: representation += f', jira={self.jira!r}' if self.synced: representation += f', synced={self.synced}' if self.tags: representation += f', tags={self.tags}' if self.notes: representation += f', notes={self.notes}' return representation + ')' def __lt__(self, other): return self.start < other.start
class Store(Space): # cache: StoreCache = Field(default_factory=StoreCache) path: Path = Field(cast=Path) encoder: TomlEncoder = Field(default_factory=TomlEncoder) # def __init__(self, path): # # self.encoder = TomlEncoder() # self.path = Path(path) # # super().__init__(path=Path(path)) def load(self) -> Work: # perf: 150ms? # if self.cache.data: # return self.cache.data if self.path.exists(): with self.path.open() as f: data = toml.load(f) if not data: data = {} else: data = {} with self.path.open('w') as f: toml.dump(data, f) # self.cache.data = data return Work(**data) def _backup(self, name_suffix='') -> bool: """Backs up to TIMEFRED_CACHE_DIR = ~/.cache/timefred""" from timefred.config import config destination = (config.cache.path / self.path.name) destination_name = self.path.stem + name_suffix + '.backup' destination = destination.with_name(destination_name) try: shutil.copyfile(self.path, destination) return True except Exception as e: logging.error(f'Failed copying {self.path} to {destination}', exc_info=True) return False def _restore_from_backup(self, name_suffix='') -> bool: from timefred.config import config destination = (config.cache.path / self.path.name).with_name(self.path.stem + name_suffix + '.backup') try: shutil.move(destination, self.path) return True except Exception as e: logging.error(f'Failed moving {destination} to {self.path}', exc_info=True) return False def dump(self, data: Work) -> bool: if getenv('TIMEFRED_DRYRUN', "").lower() in ('1', 'true', 'yes'): print('\n\tDRY RUN, NOT DUMPING\n', data) return True if not self.path.exists(): with self.path.open('w') as f: toml.dump({}, f, self.encoder) if not self._backup(): return False try: with self.path.open('w') as f: toml.dump(data, f, self.encoder) return True except Exception as e: logging.error(e, exc_info=True) if self._restore_from_backup(): print(f'Restored sheet backup', file=sys.stderr) raise
class Activity(TypedListSpace[Entry], default_factory=Entry): """Activity (name=...) [Entry, Entry...]""" name: Colored = Field(cast=ActivityString) def __init__(self, iterable: Iterable = (), **kwargs) -> None: # Necessary because otherwise TypedSpace.__new__ expects (self, default_factory, **kwargs) try: if isinstance(iterable, str): raise TypeError( f'{iterable!r} is a str, so practically not iterable') super().__init__(iterable, **kwargs) except TypeError as e: if not e.args[0].endswith('not iterable'): raise iterable = (dict(start=iterable), ) super().__init__(iterable, **kwargs) if not os.getenv('TIMEFRED_REPR', '').lower() in ('no', 'disable'): def __repr__(self) -> str: name = f'{getattr(self, "name")}' short_id = f'{str(id(self))[-4:]}' # jira = self.jira # representation = f'{self.__class__.__qualname__} ({name=!r}, {jira=!r} <{short_id}>) {list.__repr__(self)}' representation = f'{self.__class__.__qualname__} ({name=!r} <{short_id}>) {list.__repr__(self)}' return representation def shortrepr(self) -> str: """Like repr, but with only the last entry.""" name = f'{getattr(self, "name")}' short_id = f'{str(id(self))[-4:]}' # jira = self.jira last_entry = self.safe_last_entry() self_len = len(self) if self_len > 1: short_entries_repr = f'[... {last_entry}]' elif self_len == 1: short_entries_repr = f'[{last_entry}]' else: short_entries_repr = f'[]' # representation = f'{self.__class__.__qualname__} ({name=!r}, {jira=!r} <{short_id}>) {short_entries_repr}' representation = f'{self.__class__.__qualname__} ({name=!r} <{short_id}>) {short_entries_repr}' return representation def safe_last_entry(self) -> Optional[Entry]: try: return self[-1] except IndexError: return None # @multimethod # def has_similar_name(self, other: 'Entry') -> bool: # return self.has_similar_name(other.name) # # @multimethod def has_similar_name(self, other: str) -> bool: """Compares the activity's lowercase, stripped and non-word-free name to other.""" return normalize_str(self.name) == normalize_str(other) def ongoing(self) -> bool: last_entry = self.safe_last_entry() # return bool(last_entry and not last_entry.end and last_entry.timespan.seconds != 0) return bool(last_entry and not last_entry.end) def stop(self, time: Union[str, XArrow] = None, tag: Union[str, Tag] = None, note: Union[str, Note] = None) -> Entry: """ Returns: Last entry. Raises: ValueError: if the activity is not ongoing """ last_entry = self.safe_last_entry() if not last_entry or last_entry.end: raise ValueError(f'{self.shortrepr()} is not ongoing') if not time: time = XArrow.now() if last_entry.start > time: raise ValueError( f'Cannot stop {self.shortrepr()} before start time (tried to stop at {time!r})' ) last_entry.end = time if tag: last_entry.tags.add(tag) if note: last_entry.notes.append(note) return last_entry def start(self, time: Union[XArrow, str] = None, tag: Union["Tag", str] = None, note=None) -> Entry: """ Raises: ValueError: if the activity is ongoing """ if self.ongoing(): raise ValueError(f'{self.shortrepr()} is already ongoing') entry = Entry(start=time) if tag: entry.tags.add(tag) if note: entry.notes.append(note) self.append(entry) return entry @cached_property # @property def timespans(self) -> list[Timespan]: timespans = [] for sorted_entry in sorted(self): timespan = sorted_entry.timespan timespans.append(timespan) return timespans # sorted_entries = list(map(lambda entry: entry.timespan, sorted(self))) # return sorted_entries @cached_property # @property def seconds(self) -> int: timespans = self.timespans return sum(timespans) @cached_property # @property def human_duration(self) -> str: seconds = self.seconds human = secs2human(seconds) return human def pretty(self, detailed: bool = True, width: int = 24): timespans = self.timespans jira = None if detailed: pretty = "\n \x1b[2m" # * Notes and Jira (build) notes = [] for entry in self: entry_notes = entry.notes entry_nonempty_notes = list(filter(bool, entry_notes)) notes.extend(entry_nonempty_notes) if entry.jira: if jira and entry.jira != jira: log.warning( f"entry.jira, {entry.jira} != jira, {jira}. Ignoring" ) else: jira = entry.jira if jira: pretty += c.brblack(jira) + '\n' if notes or jira: pretty += '\n ' # * Times pretty += c.grey150('Times') for start, end in timespans: if end: pretty += f'\n {c.brblack("·")} {start.HHmmss} → {end.HHmmss} ({end - start})' else: pretty += f'\n {c.brblack("·")} {start.HHmmss}' # * Notes (represent) if notes: pretty += '\n\n ' + c.grey150('Notes') for note in notes: pretty += f'\n {c.brblack("·")} {note.pretty(color=False)}' pretty += '\x1b[0m\n\n' else: earliest_start_time = timespans[0].start pretty = c.i(c.dim(' started ' + earliest_start_time.HHmmss)) # * Activity title if self.ongoing(): title = c.title(self.name) else: title = c.rgb(self.name, 220) title = c.bgrgb(' ', 58, 150, 221) + f' {title}' # * Tags if detailed: tags = set() [tags.update(set(filter(bool, entry.tags))) for entry in self] title += f' {", ".join(c.dim(c.tag2(_tag)) for _tag in tags)}' # * Human duration human_duration = c.black('|') + f' {c.dim(self.human_duration)}' ljustified_title = c.ljust_with_color(title, width) pretty = ' '.join([ljustified_title, human_duration, pretty]) return pretty
class Timespan(DictSpace): # todo: XArrow - XArrow -> Timespan? # inherit from timedelta? # XArrow.span() -> (XArrow, XArrow)? # start = Field(cast=XArrow) start: XArrow = Field(cast=XArrow.from_absolute) # end: Optional[XArrow] = None # end = Field(optional=True, cast=XArrow) end: XArrow = Field(optional=True, cast=XArrow.from_absolute) if not os.getenv('TIMEFRED_REPR', '').lower() in ('no', 'disable'): def __repr__(self): short_id = f'{str(id(self))[-4:]}' start = self.start end = self.end representation = f'{self.__class__.__qualname__} ({start=!r}, {end=!r}) <{short_id}>' return representation @multimethod def __radd__(self, other) -> int: other_timedelta = other.timedelta other_total_seconds = other_timedelta.total_seconds() other_total_seconds_int = int(other_total_seconds) return self.__radd__(other_total_seconds_int) @multimethod def __radd__(self, other: int) -> int: self_seconds = self.seconds return self_seconds + other # try: # return self_seconds + int(other.timedelta().total_seconds()) # except AttributeError: # other is int # return self_seconds + other def __lt__(self, other): return self.start > other.start def __add__(self, other) -> int: self_seconds = self.seconds other_seconds = other.seconds return self_seconds + other_seconds def __bool__(self): return bool(self.start) or bool(self.end) def __iter__(self) -> Iterator[Optional[XArrow]]: yield self.start yield self.end # @cached_property @property def timedelta(self) -> timedelta: if self.end: return self.end - self.start else: return timedelta(seconds=0) # @cached_property @property def seconds(self) -> int: td = self.timedelta td_total_seconds = td.total_seconds() return int(td_total_seconds) # @cached_property @property def human_duration(self) -> str: return secs2human(self.seconds)
class DevCfg(AttrDictSpace): debugger: Optional[str] = Field(default_factory=str) traceback: Optional[str] = Field(default_factory=str) # repr: Optional[str] = Field(default=repr) log_level: Optional[str] = Field(default_factory=str, cast=str.upper)
class TimeCfg(AttrDictSpace): class TimeFormats(AttrDictSpace): date: str = 'DD/MM/YY' short_date: str = 'DD/MM' time: str = 'HH:mm:ss' short_time: str = 'HH:mm' datetime: str = f'{date} {time}' # DD/MM/YY HH:mm:ss shorter_datetime: str = f'{date} {short_time}' # DD/MM/YY HH:mm short_datetime: str = f'{short_date} {short_time}' # DD/MM HH:mm def __init__(self, mappable=(), **kwargs) -> None: super().__init__(mappable, **kwargs) self.date_separator = re.search(r'[^\w]', self.date).group() # e.g '/' self.time_separator = re.search(r'[^\w]', self.time).group() # e.g ':' if self.date.count(self.date_separator) != 2: raise ValueError( f'Invalid date format: {self.date!r}. Needs to signify Day, Month and Year.' ) if self.short_date.count(self.date_separator) != 1: raise ValueError( f'Invalid date format: {self.short_date!r}. Needs to signify Day and Month.' ) if self.time.count(self.time_separator) != 2: raise ValueError( f'Invalid date format: {self.time!r}. Needs to signify Hours, minutes and seconds.' ) if self.short_time.count(self.time_separator) != 1: raise ValueError( f'Invalid date format: {self.short_time!r}. Needs to signify Hours and minutes.' ) self.time_format_re = re.compile( fr'(?P<hour>\d{{1,2}})' fr'({self.time_separator}(?P<minute>\d{{2}}))' fr'(?:{self.time_separator}(?P<second>\d{{2}}))?') """23:31[:56]""" self.date_format_re = re.compile( fr'(?P<day>\d{{1,2}})' fr'({self.date_separator}(?P<month>\d{{2}}))' fr'(?:{self.date_separator}(?P<year>\d{{2,4}}))?') """31/12[/21]""" self.datetime_format_re = re.compile( rf'({self.date_format_re.pattern})?( *{self.time_format_re.pattern})?' ) """[31/12[/21]] [23:31[:56]] Matches: '23/12', '23/12/21', '23/12 11:00', '23/12/21 11:00:59' """ # tz: BaseTzInfo # tz: datetime.timezone = dt.now().astimezone().tzinfo # tz: datetime.tzinfo = dt.now().astimezone().tzinfo # tz = timezone(self.tz) # TODO: have a tzinfo that can by psased to XArrow.now() tz = 'Asia/Jerusalem' first_day_of_week: Literal['sunday', 'monday'] = Field(cast=str.lower) formats: TimeFormats = Field(default_factory=TimeFormats, cast=TimeFormats)
class Config(AttrDictSpace): class TimeCfg(AttrDictSpace): class TimeFormats(AttrDictSpace): date: str = 'DD/MM/YY' short_date: str = 'DD/MM' time: str = 'HH:mm:ss' short_time: str = 'HH:mm' datetime: str = f'{date} {time}' # DD/MM/YY HH:mm:ss shorter_datetime: str = f'{date} {short_time}' # DD/MM/YY HH:mm short_datetime: str = f'{short_date} {short_time}' # DD/MM HH:mm def __init__(self, mappable=(), **kwargs) -> None: super().__init__(mappable, **kwargs) self.date_separator = re.search(r'[^\w]', self.date).group() # e.g '/' self.time_separator = re.search(r'[^\w]', self.time).group() # e.g ':' if self.date.count(self.date_separator) != 2: raise ValueError( f'Invalid date format: {self.date!r}. Needs to signify Day, Month and Year.' ) if self.short_date.count(self.date_separator) != 1: raise ValueError( f'Invalid date format: {self.short_date!r}. Needs to signify Day and Month.' ) if self.time.count(self.time_separator) != 2: raise ValueError( f'Invalid date format: {self.time!r}. Needs to signify Hours, minutes and seconds.' ) if self.short_time.count(self.time_separator) != 1: raise ValueError( f'Invalid date format: {self.short_time!r}. Needs to signify Hours and minutes.' ) self.time_format_re = re.compile( fr'(?P<hour>\d{{1,2}})' fr'({self.time_separator}(?P<minute>\d{{2}}))' fr'(?:{self.time_separator}(?P<second>\d{{2}}))?') """23:31[:56]""" self.date_format_re = re.compile( fr'(?P<day>\d{{1,2}})' fr'({self.date_separator}(?P<month>\d{{2}}))' fr'(?:{self.date_separator}(?P<year>\d{{2,4}}))?') """31/12[/21]""" self.datetime_format_re = re.compile( rf'({self.date_format_re.pattern})?( *{self.time_format_re.pattern})?' ) """[31/12[/21]] [23:31[:56]] Matches: '23/12', '23/12/21', '23/12 11:00', '23/12/21 11:00:59' """ # tz: BaseTzInfo # tz: datetime.timezone = dt.now().astimezone().tzinfo # tz: datetime.tzinfo = dt.now().astimezone().tzinfo # tz = timezone(self.tz) # TODO: have a tzinfo that can by psased to XArrow.now() tz = 'Asia/Jerusalem' first_day_of_week: Literal['sunday', 'monday'] = Field(cast=str.lower) formats: TimeFormats = Field(default_factory=TimeFormats, cast=TimeFormats) class DevCfg(AttrDictSpace): debugger: Optional[str] = Field(default_factory=str) traceback: Optional[str] = Field(default_factory=str) # repr: Optional[str] = Field(default=repr) log_level: Optional[str] = Field(default_factory=str, cast=str.upper) # features: Optional[BaseModel] class Sheet(AttrDictSpace): path: Path = Path( os.path.expanduser( os.environ.get('TIMEFRED_SHEET', "~/timefred-sheet.toml"))) class Cache(AttrDictSpace): path: Path @Field(cast=Path) def path(): # when fixing, note that sometimes we do want self """Defaults to TIMEFRED_CACHE_DIR = ~/.cache/timefred""" path = Path( os.path.expanduser( os.environ.get('TIMEFRED_CACHE_DIR', "~/.cache/timefred"))) if not path.exists(): path.mkdir(exist_ok=True) return path time: TimeCfg = Field(default_factory=TimeCfg, cast=TimeCfg) sheet: Sheet = Field(default_factory=Sheet, cast=Sheet) cache: Cache = Field(default_factory=Cache, cast=Cache) dev: Optional[DevCfg] = Field(default_factory=DevCfg, cast=DevCfg) def __init__(self): cfg_file = Path( os.path.expanduser( os.environ.get('TIMEFRED_CONFIG_PATH', "~/.timefred.toml"))) if cfg_file.exists(): cfg = toml.load(cfg_file.open()) else: self._create_default_config_file(cfg_file) cfg = {} super().__init__(**cfg) if self.dev.debugger: os.environ['PYTHONBREAKPOINT'] = self.dev.debugger if self.dev.traceback: try: if self.dev.traceback == "rich.traceback": from rich.traceback import install install(show_locals=True) else: log.warning(f"Don't support {self.dev.traceback}") except Exception as e: log.error( f'{e.__class__.__qualname__} caught in {self}.__init__: {e}' ) def _create_default_config_file(self, cfg_file: Path): breakpoint() raise NotImplementedError constructed = self.dict() toml.dump(constructed, cfg_file.open(mode="x"))