Repository: pimutils/todoman
Pull request #438 (2021-05-25 17:33:28)

Add typing hints
Add typing hints to most of `model.py` and `formatter.py`.


Patch:
diff --git a/README.rst b/README.rst
index 36b264e..a5f65ff 100644
--- a/README.rst
+++ b/README.rst
@@ -37,6 +37,8 @@ Todoman should run fine on any Unix-like OS. It's been tested on GNU/Linux,
 BSD, and macOS.  We do not support windows, and very basic testing seems to
 indicate it does not work.
 
+Feel free to join the IRC channel: #pimutils on irc.libera.chat.
+
 Features
 --------
 
diff --git a/todoman/cli.py b/todoman/cli.py
index 0bfab0a..2e43e1f 100644
--- a/todoman/cli.py
+++ b/todoman/cli.py
@@ -309,10 +309,14 @@ def cli(click_ctx, colour, porcelain, humanize, config):
 
 
 def invoke_command(click_ctx, command):
-    name, *args = command.split(" ")
+    name, *raw_args = command.split(" ")
     if name not in cli.commands:
         raise click.ClickException("Invalid setting for [default_command]")
-    click_ctx.invoke(cli.commands[name], args)
+    parser = cli.commands[name].make_parser(click_ctx)
+    opts, args, param_order = parser.parse_args(raw_args)
+    for param in param_order:
+        opts[param.name] = param.handle_parse_result(click_ctx, opts, args)[0]
+    click_ctx.invoke(cli.commands[name], *args, **opts)
 
 
 try:  # pragma: no cover
diff --git a/todoman/formatters.py b/todoman/formatters.py
index 1ec179d..9bf1322 100644
--- a/todoman/formatters.py
+++ b/todoman/formatters.py
@@ -1,6 +1,12 @@
-import datetime
 import json
+from datetime import date
+from datetime import datetime
 from time import mktime
+from typing import Iterable
+from typing import List
+from typing import Optional
+from typing import Tuple
+from typing import Union
 
 import click
 import humanize
@@ -9,18 +15,21 @@ import pytz
 from dateutil.tz import tzlocal
 from tabulate import tabulate
 
+from todoman.model import Todo
+from todoman.model import TodoList
 
-def rgb_to_ansi(colour):
+
+def rgb_to_ansi(colour: Optional[str]) -> Optional[str]:
     """
     Convert a string containing an RGB colour to ANSI escapes
     """
     if not colour or not colour.startswith("#"):
-        return
+        return None
 
     r, g, b = colour[1:3], colour[3:5], colour[5:7]
 
     if not len(r) == len(g) == len(b) == 2:
-        return
+        return None
 
     return "\33[38;2;{!s};{!s};{!s}m".format(int(r, 16), int(g, 16), int(b, 16))
 
@@ -41,19 +50,19 @@ class DefaultFormatter:
         )
 
         self.tz = tz_override or tzlocal()
-        self.now = datetime.datetime.now().replace(tzinfo=self.tz)
+        self.now = datetime.now().replace(tzinfo=self.tz)
 
         self._parsedatetime_calendar = parsedatetime.Calendar(
             version=parsedatetime.VERSION_CONTEXT_STYLE,
         )
 
-    def simple_action(self, action, todo):
+    def simple_action(self, action: str, todo: Todo) -> str:
         return f'{action} "{todo.summary}"'
 
-    def compact(self, todo):
+    def compact(self, todo: Todo) -> str:
         return self.compact_multiple([todo])
 
-    def compact_multiple(self, todos, hide_list=False):
+    def compact_multiple(self, todos: Iterable[Todo], hide_list=False) -> str:
         table = []
         for todo in todos:
             completed = "X" if todo.is_completed else " "
@@ -63,11 +72,9 @@ class DefaultFormatter:
             priority = self.format_priority_compact(todo.priority)
 
             due = self.format_datetime(todo.due)
-            now = (
-                self.now if isinstance(todo.due, datetime.datetime) else self.now.date()
-            )
+            now = self.now if isinstance(todo.due, datetime) else self.now.date()
             if todo.due and todo.due <= now and not todo.is_completed:
-                due = click.style(due, fg="red")
+                due = click.style(str(due), fg="red")
 
             recurring = "⟳" if todo.is_recurring else ""
 
@@ -77,6 +84,9 @@ class DefaultFormatter:
                     percent,
                 )
             else:
+                if not todo.list:
+                    raise ValueError("Cannot format todo without a list")
+
                 summary = "{} {}{}".format(
                     todo.summary,
                     self.format_database(todo.list),
@@ -95,31 +105,42 @@ class DefaultFormatter:
 
         return tabulate(table, tablefmt="plain")
 
-    def _columnize_text(self, label, text):
-        """Display text, split text by line-endings, on multiple colums,"""
-        """do nothing if text is empty or None"""
+    def _columnize_text(
+        self,
+        label: str,
+        text: Optional[str],
+    ) -> List[Tuple[Optional[str], str]]:
+        """Display text, split text by line-endings, on multiple colums.
+
+        Do nothing if text is empty or None.
+        """
         lines = text.splitlines() if text else None
 
         return self._columnize_list(label, lines)
 
-    def _columnize_list(self, label, lst):
-        """Display list on multiple columns,"""
-        """do nothing if list is empty or None"""
+    def _columnize_list(
+        self,
+        label: str,
+        lst: Optional[List[str]],
+    ) -> List[Tuple[Optional[str], str]]:
+        """Display list on multiple columns.
 
-        rows = []
+        Do nothing if list is empty or None.
+        """
+
+        rows: List[Tuple[Optional[str], str]] = []
 
         if lst:
-            rows.append([label, lst[0]])
+            rows.append((label, lst[0]))
             for line in lst[1:]:
-                rows.append([None, line])
+                rows.append((None, line))
 
         return rows
 
-    def detailed(self, todo):
-        """
-        Returns a detailed representation of a task.
+    def detailed(self, todo: Todo) -> str:
+        """Returns a detailed representation of a task.
 
-        :param Todo todo: The todo component.
+        :param todo: The todo component.
         """
         extra_rows = []
         extra_rows += self._columnize_text("Description", todo.description)
@@ -131,15 +152,15 @@ class DefaultFormatter:
             )
         return self.compact(todo)
 
-    def format_datetime(self, dt):
+    def format_datetime(self, dt: Optional[date]) -> Union[str, int, None]:
         if not dt:
             return ""
-        elif isinstance(dt, datetime.datetime):
+        elif isinstance(dt, datetime):
             return dt.strftime(self.datetime_format)
-        elif isinstance(dt, datetime.date):
+        elif isinstance(dt, date):
             return dt.strftime(self.date_format)
 
-    def parse_priority(self, priority):
+    def parse_priority(self, priority: Optional[str]) -> Optional[int]:
         if priority is None or priority == "":
             return None
         if priority == "low":
@@ -153,7 +174,7 @@ class DefaultFormatter:
         else:
             raise ValueError("Priority has to be one of low, medium, high or none")
 
-    def format_priority(self, priority):
+    def format_priority(self, priority: Optional[int]) -> str:
         if not priority:
             return "none"
         elif 1 <= priority <= 4:
@@ -163,7 +184,7 @@ class DefaultFormatter:
         elif 6 <= priority <= 9:
             return "low"
 
-    def format_priority_compact(self, priority):
+    def format_priority_compact(self, priority: Optional[int]) -> str:
         if not priority:
             return ""
         elif 1 <= priority <= 4:
@@ -173,28 +194,28 @@ class DefaultFormatter:
         elif 6 <= priority <= 9:
             return "!"
 
-    def parse_datetime(self, dt):
+    def parse_datetime(self, dt: str) -> Optional[date]:
         if not dt:
             return None
 
         rv = self._parse_datetime_naive(dt)
-        return rv.replace(tzinfo=self.tz) if isinstance(rv, datetime.datetime) else rv
+        return rv.replace(tzinfo=self.tz) if isinstance(rv, datetime) else rv
 
-    def _parse_datetime_naive(self, dt):
+    def _parse_datetime_naive(self, dt: str) -> date:
         """Parse dt and returns a naive datetime or a date"""
         try:
-            return datetime.datetime.strptime(dt, self.datetime_format)
+            return datetime.strptime(dt, self.datetime_format)
         except ValueError:
             pass
 
         try:
-            return datetime.datetime.strptime(dt, self.date_format).date()
+            return datetime.strptime(dt, self.date_format).date()
         except ValueError:
             pass
 
         try:
-            return datetime.datetime.combine(
-                self.now.date(), datetime.datetime.strptime(dt, self.time_format).time()
+            return datetime.combine(
+                self.now.date(), datetime.strptime(dt, self.time_format).time()
             )
         except ValueError:
             pass
@@ -202,24 +223,24 @@ class DefaultFormatter:
         rv, pd_ctx = self._parsedatetime_calendar.parse(dt)
         if not pd_ctx.hasDateOrTime:
             raise ValueError(f"Time description not recognized: {dt}")
-        return datetime.datetime.fromtimestamp(mktime(rv))
+        return datetime.fromtimestamp(mktime(rv))
 
-    def format_database(self, database):
+    def format_database(self, database: TodoList):
         return "{}@{}".format(
             rgb_to_ansi(database.colour) or "", click.style(database.name)
         )
 
 
 class HumanizedFormatter(DefaultFormatter):
-    def format_datetime(self, dt):
+    def format_datetime(self, dt: Optional[date]) -> str:
         if not dt:
             return ""
 
-        if isinstance(dt, datetime.datetime):
+        if isinstance(dt, datetime):
             rv = humanize.naturaltime(self.now - dt)
             if " from now" in rv:
                 rv = "in {}".format(rv[:-9])
-        elif isinstance(dt, datetime.date):
+        elif isinstance(dt, date):
             rv = humanize.naturaldate(dt)
 
         return rv
@@ -240,10 +261,10 @@ class PorcelainFormatter(DefaultFormatter):
             "completed_at": self.format_datetime(todo.completed_at),
         }
 
-    def compact(self, todo):
+    def compact(self, todo: Todo) -> str:
         return json.dumps(self._todo_as_dict(todo), indent=4, sort_keys=True)
 
-    def compact_multiple(self, todos, hide_list=False):
+    def compact_multiple(self, todos: Iterable[Todo], hide_list=False) -> str:
         data = [self._todo_as_dict(todo) for todo in todos]
         return json.dumps(data, indent=4, sort_keys=True)
 
@@ -264,16 +285,18 @@ class PorcelainFormatter(DefaultFormatter):
     def detailed(self, todo):
         return self.compact(todo)
 
-    def format_datetime(self, date):
-        if date:
-            if not hasattr(date, "timestamp"):
-                date = datetime.datetime.fromordinal(date.toordinal())
-            return int(date.timestamp())
+    def format_datetime(self, value: Optional[date]) -> Optional[int]:
+        if value:
+            if not isinstance(value, datetime):
+                dt = datetime.fromordinal(value.toordinal())
+            else:
+                dt = value
+            return int(dt.timestamp())
         else:
             return None
 
     def parse_datetime(self, value):
         if value:
-            return datetime.datetime.fromtimestamp(value, tz=pytz.UTC)
+            return datetime.fromtimestamp(value, tz=pytz.UTC)
         else:
             return None
diff --git a/todoman/model.py b/todoman/model.py
index 2685674..dc41137 100644
--- a/todoman/model.py
+++ b/todoman/model.py
@@ -10,6 +10,7 @@ from datetime import time
 from datetime import timedelta
 from os.path import normpath
 from os.path import split
+from typing import Dict
 from typing import Iterable
 from typing import List
 from typing import Optional
@@ -65,9 +66,12 @@ class Todo:
     categories: List[str]
     completed_at: Optional[datetime]
     created_at: Optional[datetime]
+    due: Optional[date]
+    dtstamp: Optional[datetime]
     last_modified: Optional[datetime]
     related: List[Todo]
     rrule: Optional[str]
+    start: Optional[date]
 
     def __init__(
         self,
@@ -841,25 +845,32 @@ class Cache:
             seen_paths.add(path)
             yield todo
 
-    def _dt_from_db(self, dt, is_date=False):
+    def _datetime_from_db(self, dt) -> Optional[datetime]:
         if dt:
-            val = datetime.fromtimestamp(dt, LOCAL_TIMEZONE)
-            if is_date:
-                val = val.date()
-            return val
+            return datetime.fromtimestamp(dt, LOCAL_TIMEZONE)
         return None
 
-    def _todo_from_db(self, row):
+    def _date_from_db(self, dt, is_date=False) -> Optional[date]:
+        """Deserialise a date (possible datetime)."""
+        if not dt:
+            return dt
+
+        if is_date:
+            return datetime.fromtimestamp(dt, LOCAL_TIMEZONE).date()
+        else:
+            return datetime.fromtimestamp(dt, LOCAL_TIMEZONE)
+
+    def _todo_from_db(self, row: dict) -> Todo:
         todo = Todo()
         todo.id = row["id"]
         todo.uid = row["uid"]
         todo.summary = row["summary"]
-        todo.due = self._dt_from_db(row["due"], row["due_dt"])
-        todo.start = self._dt_from_db(row["start"], row["start_dt"])
+        todo.due = self._date_from_db(row["due"], row["due_dt"])
+        todo.start = self._date_from_db(row["start"], row["start_dt"])
         todo.priority = row["priority"]
-        todo.created_at = self._dt_from_db(row["created_at"])
-        todo.completed_at = self._dt_from_db(row["completed_at"])
-        todo.dtstamp = self._dt_from_db(row["dtstamp"])
+        todo.created_at = self._datetime_from_db(row["created_at"])
+        todo.completed_at = self._datetime_from_db(row["completed_at"])
+        todo.dtstamp = self._datetime_from_db(row["dtstamp"])
         todo.percent_complete = row["percent_complete"]
         todo.status = row["status"]
         todo.description = row["description"]
@@ -871,7 +882,7 @@ class Cache:
         todo.rrule = row["rrule"]
         return todo
 
-    def lists(self):
+    def lists(self) -> Iterable[TodoList]:
         result = self._conn.execute("SELECT * FROM lists")
         for row in result:
             yield TodoList(
@@ -881,10 +892,10 @@ class Cache:
             )
 
     @cached_property
-    def lists_map(self):
+    def lists_map(self) -> Dict[str, TodoList]:
         return {list_.name: list_ for list_ in self.lists()}
 
-    def expire_lists(self, paths):
+    def expire_lists(self, paths: Dict[str, int]) -> None:
         results = self._conn.execute("SELECT path, name, mtime from lists")
         for result in results:
             if result["path"] not in paths:
@@ -894,10 +905,10 @@ class Cache:
                 if mtime and mtime > result["mtime"]:
                     self.delete_list(result["name"])
 
-    def delete_list(self, name):
+    def delete_list(self, name: str) -> None:
         self._conn.execute("DELETE FROM lists WHERE lists.name = ?", (name,))
 
-    def todo(self, id, read_only=False):
+    def todo(self, id: int, read_only=False) -> Todo:
         # XXX: DON'T USE READ_ONLY
         result = self._conn.execute(
             """
@@ -927,7 +938,7 @@ class Cache:
 
         return self._todo_from_db(result)
 
-    def expire_files(self, paths_to_mtime):
+    def expire_files(self, paths_to_mtime: Dict[str, int]) -> None:
         """Remove stale cache entries based on the given fresh data."""
         result = self._conn.execute("SELECT path, mtime FROM files")
         for row in result:
@@ -977,12 +988,12 @@ class TodoList:
         else:
             return 0
 
-    def __eq__(self, other):
+    def __eq__(self, other) -> bool:
         if isinstance(other, TodoList):
             return self.name == other.name
         return object.__eq__(self, other)
 
-    def __str__(self):
+    def __str__(self) -> str:
         return self.name
 
 
@@ -1000,7 +1011,7 @@ class Database:
         self.paths = [str(path) for path in paths]
         self.update_cache()
 
-    def update_cache(self):
+    def update_cache(self) -> None:
         paths = {path: TodoList.mtime_for_path(path) for path in self.paths}
         self.cache.expire_lists(paths)
 
@@ -1035,8 +1046,7 @@ class Database:
 
             try:
                 with open(entry_path, "rb") as f:
-                    cal = f.read()
-                    cal = icalendar.Calendar.from_ical(cal)
+                    cal = icalendar.Calendar.from_ical(f.read())
                     for component in cal.walk("VTODO"):
                         self.cache.add_vtodo(component, entry_path)
             except Exception:
@@ -1047,7 +1057,7 @@ class Database:
     def todos(self, **kwargs) -> Iterable[Todo]:
         return self.cache.todos(**kwargs)
 
-    def todo(self, id, **kwargs) -> Todo:
+    def todo(self, id: int, **kwargs) -> Todo:
         return self.cache.todo(id, **kwargs)
 
     def lists(self) -> Iterable[TodoList]:
@@ -1066,7 +1076,7 @@ class Database:
         path = os.path.join(todo.list.path, todo.filename)
         os.remove(path)
 
-    def flush(self):
+    def flush(self) -> Iterable[Todo]:
         for todo in self.todos(status=["ANY"]):
             if todo.is_completed:
                 yield todo


Test patch:
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 0557552..b2d93ac 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -2,6 +2,7 @@ import datetime
 import sys
 from os.path import exists
 from unittest import mock
+from unittest.mock import call
 from unittest.mock import patch
 
 import click
@@ -822,6 +823,9 @@ def test_invoke_command(runner, tmpdir, config):
     config.write('default_command = "flush"\n', "a")
 
     flush = mock.MagicMock()
+    parser = mock.MagicMock()
+    flush.make_parser.return_value = parser
+    parser.parse_args.return_value = {}, [], []
     with patch.dict(cli.commands, values={"flush": flush}):
         result = runner.invoke(cli, catch_exceptions=False)
 
@@ -939,3 +943,28 @@ def test_invalid_default_priority(config, runner, create):
     result = runner.invoke(cli, ["new", "-l", "default", "aaa"])
     assert result.exception
     assert "Error: Invalid `default_priority` settings." in result.output
+
+
+def test_default_command_args(config, runner):
+    config.write(
+        'default_command = "list --sort=due --due 168 ' '--priority low --no-reverse"',
+        "a",
+    )
+
+    with patch("todoman.model.Database.todos", spec=True) as todos:
+        result = runner.invoke(cli)
+
+    assert not result.exception
+    assert todos.call_args == call(
+        sort=["due"],
+        due=168,
+        priority=9,
+        reverse=False,
+        lists=[],
+        location=None,
+        category=None,
+        grep=None,
+        start=None,
+        startable=None,
+        status="NEEDS-ACTION,IN-PROCESS",
+    )
diff --git a/tests/test_formatter.py b/tests/test_formatter.py
index f23de73..ed1ea25 100644
--- a/tests/test_formatter.py
+++ b/tests/test_formatter.py
@@ -161,3 +161,20 @@ def test_rgb_to_ansi():
     assert rgb_to_ansi("#8ab6d2f") == "\x1b[38;2;138;182;210m"
     assert rgb_to_ansi("red") is None
     assert rgb_to_ansi("#8ab6d2") == "\x1b[38;2;138;182;210m"
+
+
+def test_format_multiple_with_list(default_formatter, todo_factory):
+    todo = todo_factory()
+    assert todo.list
+    assert (
+        default_formatter.compact_multiple([todo])
+        == "1  [ ]      YARR! @default\x1b[0m"
+    )
+
+
+def test_format_multiple_without_list(default_formatter, todo_factory):
+    todo = todo_factory()
+    todo.list = None
+    assert not todo.list
+    with pytest.raises(ValueError):
+        default_formatter.compact_multiple([todo])