Exemple #1
0
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
Exemple #2
0
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)
Exemple #3
0
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
Exemple #4
0
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
Exemple #5
0
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
Exemple #6
0
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)
Exemple #7
0
 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)
Exemple #8
0
    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)
Exemple #9
0
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"))