Source code for magodo._todo

"""Contains the basic / standard Todo class definition."""

# pylint: disable=format-string-without-interpolation

from __future__ import annotations

import abc
import datetime as dt
from functools import total_ordering
import re
from typing import Any, Dict, Final, Generic, List, Optional, Tuple, cast
import uuid

from eris import ErisError, Err, Ok, Result
from metaman import cname

from ._common import DEFAULT_PRIORITY, PUNCTUATION
from .dates import from_date, to_date
from .tags import (
    CONTEXT_PREFIX,
    EPIC_PREFIX,
    PROJECT_PREFIX,
    is_metadata_tag,
    is_prefix_tag,
)
from .types import Metadata, Priority, T


RE_DATE: Final = r"[1-9][0-9][0-9][0-9]-[0-1][0-9]-[0-3][0-9]"
RE_TODO: Final = r"""
(?P<x>x[ ]+)?                        # optional 'x'
(?:\((?P<priority>[A-Z])\)[ ]+)?     # priority
(?:
    (?:(?P<done_date>{0})[ ]+)?      # optional date of completion
    (?:(?P<create_date>{0})[ ]+)     # optional date of creation
)?
(?P<desc>[A-Za-z0-9+@].*)            # description
""".format(
    RE_DATE
)


class TodoMixin(Generic[T], abc.ABC):
    """Implements standard Todo-like behaviors.."""

    @property
    def ident(self) -> str:
        """Unique identifier."""
        key = "_uuid_ident"
        if not hasattr(self, key):
            setattr(self, key, uuid.uuid4())

        result: str = getattr(self, key)
        return result

    def __repr__(self: T) -> str:  # noqa: D105
        kwargs: Dict[str, Any] = {}

        if self.contexts:
            kwargs["contexts"] = self.contexts

        if self.create_date is not None:
            kwargs["create_date"] = self.create_date

        if self.done_date is not None:
            kwargs["done_date"] = self.done_date

        if self.done:
            kwargs["done"] = self.done

        if self.epics:
            kwargs["epics"] = self.epics

        if self.metadata:
            kwargs["metadata"] = self.metadata

        if self.priority != DEFAULT_PRIORITY:
            kwargs["priority"] = repr(self.priority)

        if self.projects:
            kwargs["projects"] = self.projects

        if kwargs:
            pretty_kwargs = ", " + ", ".join(
                f"{k}={v}" for (k, v) in kwargs.items()
            )
        else:
            pretty_kwargs = ""

        return f"{cname(self)}(desc={self.desc!r}{pretty_kwargs})"

    def __eq__(self: T, other: object) -> bool:  # noqa: D105
        if not isinstance(other, type(self)):  # pragma: no cover
            return False

        return all(
            [
                self.contexts == other.contexts,
                self.create_date == other.create_date,
                self.desc == other.desc,
                self.done == other.done,
                self.done_date == other.done_date,
                self.epics == other.epics,
                self.metadata == other.metadata,
                self.priority == other.priority,
                self.projects == other.projects,
            ]
        )

    def __lt__(self: T, other: object) -> bool:  # noqa: D105
        if not isinstance(other, type(self)):
            raise ValueError(
                f"Unable to compare '{type(other)}' object with 'Todo' object."
            )

        if self.done != other.done:
            return not self.done and other.done

        if self.priority != other.priority:
            return self.priority < other.priority

        if self.done_date is None and other.done_date is not None:
            return True

        if other.done_date is None and self.done_date is not None:
            return False

        if self.done_date is not None and other.done_date is not None:
            if self.done_date != other.done_date:
                return self.done_date < other.done_date
            elif (
                (self_dtime := self.metadata.get("dtime", None))
                and (other_dtime := other.metadata.get("dtime", None))
                and self_dtime != other_dtime
            ):
                return self_dtime < other_dtime

        if self.create_date and other.create_date:
            if self.create_date == other.create_date:
                if (
                    (self_ctime := self.metadata.get("ctime", None))
                    and (other_ctime := other.metadata.get("ctime", None))
                    and self_ctime != other_ctime
                ):
                    return self_ctime < other_ctime
            else:
                return self.create_date < other.create_date

        if (self_id := self.metadata.get("id", None)) and (
            other_id := other.metadata.get("id", None)
        ):
            return self_id < other_id

        return self.desc < other.desc


[docs]@total_ordering class Todo(TodoMixin): """Represents a single task in a todo list.""" def __init__( self, desc: str, *, contexts: Tuple[str, ...] = (), create_date: dt.date = None, done_date: dt.date = None, done: bool = False, epics: Tuple[str, ...] = (), metadata: Metadata = None, priority: Priority = DEFAULT_PRIORITY, projects: Tuple[str, ...] = (), ): if create_date is None: create_date = dt.date.today() if metadata is None: metadata = {} if done and done_date is None: done_date = dt.date.today() time_keys = ["ctime"] if done: time_keys.append("dtime") for key in time_keys: if key not in metadata: now = dt.datetime.now() hhmm = f"{now.hour:0>2}{now.minute:0>2}" metadata[key] = hhmm self.contexts = tuple(sorted(contexts)) self.create_date = create_date self.desc = desc self.done_date = done_date self.done = done self.epics = tuple(sorted(epics)) self.metadata = metadata self.priority = priority self.projects = tuple(sorted(projects))
[docs] @classmethod def from_line(cls, line: str) -> Result[Todo, ErisError]: """Contructs a Todo object from a string (usually a line in a file). Args: line: The line to use to construct our new Todo object. """ line = line.strip() re_todo_match = re.match(RE_TODO, line, re.VERBOSE) if re_todo_match is None: return Err( f"The provided string ({line!r}) does not appear to properly" " adhere to the todo.txt format. See" " https://github.com/todotxt/todo.txt for the specification." ) done: bool = False if re_todo_match.group("x"): done = True priority: Priority = DEFAULT_PRIORITY if grp := re_todo_match.group("priority"): priority = cast(Priority, grp) create_date: Optional[dt.date] = None if grp := re_todo_match.group("create_date"): create_date = to_date(grp) done_date: Optional[dt.date] = None if grp := re_todo_match.group("done_date"): done_date = to_date(grp) desc = re_todo_match.group("desc") all_words = desc.split(" ") project_list: List[str] = [] context_list: List[str] = [] epics_list: List[str] = [] for some_list, prefix in [ (project_list, PROJECT_PREFIX), (context_list, CONTEXT_PREFIX), (epics_list, EPIC_PREFIX), ]: for word in all_words: if is_prefix_tag(prefix, word): value = word[len(prefix) :] value = _clean_value(value) if value not in some_list: some_list.append(value) projects = tuple(project_list) contexts = tuple(context_list) epics = tuple(epics_list) metadata: Metadata = {} for word in all_words: if is_metadata_tag(word): kv = word.split(":", maxsplit=1) key, value = kv value = _clean_value(value) if key in metadata: continue metadata[key] = value todo = cls( contexts=contexts, create_date=create_date, desc=desc, done_date=done_date, done=done, epics=epics, metadata=metadata, priority=priority, projects=projects, ) return Ok(todo)
[docs] def to_line(self) -> str: """Converts this Todo object back to a line.""" result = "" if self.done: result += "x " if self.priority != DEFAULT_PRIORITY: result += f"({self.priority}) " if self.done_date is not None: result += from_date(self.done_date) + " " if self.create_date is not None: result += from_date(self.create_date) + " " result += self.desc return result
[docs] def new(self, **kwargs: Any) -> Todo: """Creates a new Todo using the current Todo's attrs as defaults.""" contexts = kwargs.get("contexts", self.contexts) create_date = kwargs.get("create_date", self.create_date) desc = kwargs.get("desc", self.desc) done_date = kwargs.get("done_date", self.done_date) done = kwargs.get("done", self.done) epics = kwargs.get("epics", self.epics) metadata = kwargs.get("metadata", self.metadata) priority: Priority = kwargs.get("priority", self.priority) projects = kwargs.get("projects", self.projects) return Todo( contexts=contexts, create_date=create_date, desc=desc, done_date=done_date, done=done, epics=epics, metadata=metadata, priority=priority, projects=projects, )
def _clean_value(word: str) -> str: """Cleanup context, metadata, or project value. Makes the following changes to `word`: - Strips any punctuation from the right-side of `word`. - Removes any possesive apostrophe at the end of `word`. NOTE: Will not strip punctuation if `word` is composed ONLY of punctuation characters. """ result = word.rstrip(PUNCTUATION) if not result: result = word result = result.split("'", maxsplit=1)[0] return result