Repository: pimutils/todoman
Pull request #428 (2021-03-09 19:45:48)

Add some typing hints
**PR Checklist**

* [x] Does the coding style conform to our guidelines? Are there any flake8 errors?
* [x] Are user-facing changes documented?
* [x] Is there an entry for new features or dependencies in CHANGELOG.rst?
* [x] Are you the patch author? Are you listed in AUTHORS.rst?



Patch:
diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml
index ed656c1..3ded1ae 100644
--- a/.github/workflows/checks.yml
+++ b/.github/workflows/checks.yml
@@ -12,8 +12,10 @@ jobs:
       - name: Setup python
         uses: actions/setup-python@v1
         with:
-          python-version: 3.7
+          python-version: 3.8
           architecture: x64
+      - run: pip install wheel
+      - run: python setup.py build
       - run: pip install pre-commit
       - run: pre-commit run --all
   docs:
@@ -25,7 +27,7 @@ jobs:
       - name: Setup python
         uses: actions/setup-python@v1
         with:
-          python-version: 3.7
+          python-version: 3.8
           architecture: x64
       - name: Install test dependencies
         run: pip install tox
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 778ddda..0498b53 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -15,7 +15,7 @@ jobs:
       - name: Setup python
         uses: actions/setup-python@v1
         with:
-          python-version: 3.7
+          python-version: 3.8
           architecture: x64
       - name: Install build dependencies
         run: pip install wheel
@@ -36,7 +36,7 @@ jobs:
       - name: Setup python
         uses: actions/setup-python@v1
         with:
-          python-version: 3.7
+          python-version: 3.8
           architecture: x64
       - name: Install build dependencies
         run: pip install wheel
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 3ba73bc..725a450 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -9,7 +9,7 @@ repos:
       - id: check-added-large-files
       - id: debug-statements
   - repo: https://github.com/asottile/reorder_python_imports
-    rev: v2.3.6
+    rev: v2.4.0
     hooks:
       - id: reorder-python-imports
   - repo: https://github.com/psf/black
@@ -21,4 +21,8 @@ repos:
     hooks:
       - id: flake8
         additional_dependencies:
-          - flake8-bugbear
+          [flake8-comprehensions, flake8-import-order, flake8-bugbear]
+  - repo: https://github.com/pre-commit/mirrors-mypy
+    rev: "v0.812"
+    hooks:
+      - id: mypy
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 5c1263f..af81d2b 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -4,11 +4,11 @@ Changelog
 This file contains a brief summary of new features and dependency changes or
 releases, in reverse chronological order.
 
-v3.10.0
+v4.0.0
 ------
 
-* Dropped support for python 3.5.
 * Added support for python 3.9.
+* Dropped support older Python versions. Only 3.8 and 3.9 are now supported.
 * Fix crash when ``default_command`` has arguments.
 
 v3.9.0
diff --git a/docs/source/conf.py b/docs/source/conf.py
index f861a4a..e497ef2 100644
--- a/docs/source/conf.py
+++ b/docs/source/conf.py
@@ -301,7 +301,7 @@ htmlhelp_basename = "Todomandoc"
 
 # -- Options for LaTeX output ---------------------------------------------
 
-latex_elements = {
+latex_elements: dict = {
     # The paper size ('letterpaper' or 'a4paper').
     #  'papersize': 'letterpaper',
     # The font size ('10pt', '11pt' or '12pt').
diff --git a/docs/source/install.rst b/docs/source/install.rst
index 3d547ea..d49496b 100644
--- a/docs/source/install.rst
+++ b/docs/source/install.rst
@@ -77,7 +77,7 @@ installations.
 Requirements
 ------------
 
-Todoman requires python 3.6 or later. Installation of required libraries can be
+Todoman requires python 3.8 or later. Installation of required libraries can be
 done via pip, or your OS's package manager.
 
 Recent versions also have experimental support for pypy3.
diff --git a/setup.cfg b/setup.cfg
index 9ee485b..2e68367 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,4 +1,5 @@
 [flake8]
+application-import-names=todoman,tests
 max-line-length = 88
 extend-ignore = E203, W503
 
@@ -8,3 +9,8 @@ test=pytest
 [tool:pytest]
 testpaths = tests
 addopts = --cov=todoman --cov-report=term-missing
+
+[mypy]
+ignore_missing_imports = True
+# See https://github.com/python/mypy/issues/7511:
+warn_no_return = False
diff --git a/setup.py b/setup.py
index ff108d6..61f83b2 100644
--- a/setup.py
+++ b/setup.py
@@ -42,8 +42,6 @@ setup(
         "License :: OSI Approved :: ISC License (ISCL)",
         "Operating System :: POSIX",
         "Programming Language :: Python :: 3",
-        "Programming Language :: Python :: 3.6",
-        "Programming Language :: Python :: 3.7",
         "Programming Language :: Python :: 3.8",
         "Programming Language :: Python :: 3.9",
         "Programming Language :: Python :: Implementation :: PyPy",
diff --git a/todoman/cli.py b/todoman/cli.py
index 2368a3e..5e4ee35 100644
--- a/todoman/cli.py
+++ b/todoman/cli.py
@@ -548,7 +548,7 @@ def move(ctx, list, ids):
     for id in ids:
         todo = ctx.db.todo(id)
         click.echo(ctx.formatter.compact(todo))
-        ctx.db.move(todo, list)
+        ctx.db.move(todo, new_list=list, from_list=todo.list)
 
 
 @cli.command()
@@ -632,7 +632,9 @@ def list(ctx, *args, **kwargs):
     The following commands can further filter shown todos, or include those
     omited by default:
     """
-    hide_list = (len([_ for _ in ctx.db.lists()]) == 1) or (len(kwargs["lists"]) == 1)
+    hide_list = (len([_ for _ in ctx.db.lists()]) == 1) or (  # noqa: C416
+        len(kwargs["lists"]) == 1
+    )
 
     todos = ctx.db.todos(**kwargs)
     click.echo(ctx.formatter.compact_multiple(todos, hide_list))
diff --git a/todoman/formatters.py b/todoman/formatters.py
index 7f6d619..a1014a8 100644
--- a/todoman/formatters.py
+++ b/todoman/formatters.py
@@ -227,16 +227,16 @@ class HumanizedFormatter(DefaultFormatter):
 
 class PorcelainFormatter(DefaultFormatter):
     def _todo_as_dict(self, todo):
-        return dict(
-            completed=todo.is_completed,
-            due=self.format_datetime(todo.due),
-            id=todo.id,
-            list=todo.list.name,
-            percent=todo.percent_complete,
-            summary=todo.summary,
-            priority=todo.priority,
-            location=todo.location,
-        )
+        return {
+            "completed": todo.is_completed,
+            "due": self.format_datetime(todo.due),
+            "id": todo.id,
+            "list": todo.list.name,
+            "percent": todo.percent_complete,
+            "summary": todo.summary,
+            "priority": todo.priority,
+            "location": todo.location,
+        }
 
     def compact(self, todo):
         return json.dumps(self._todo_as_dict(todo), indent=4, sort_keys=True)
diff --git a/todoman/model.py b/todoman/model.py
index d692e42..bd453d3 100644
--- a/todoman/model.py
+++ b/todoman/model.py
@@ -1,3 +1,5 @@
+from __future__ import annotations
+
 import logging
 import os
 import socket
@@ -8,6 +10,10 @@ from datetime import time
 from datetime import timedelta
 from os.path import normpath
 from os.path import split
+from typing import Iterable
+from typing import List
+from typing import Optional
+from typing import Tuple
 from uuid import uuid4
 
 import icalendar
@@ -26,7 +32,7 @@ logger = logging.getLogger(name=__name__)
 LOCAL_TIMEZONE = tzlocal()
 
 
-class cached_property:  # noqa
+class cached_property:
     """A read-only @property that is only evaluated once. Only usable on class
     instances' methods.
     """
@@ -56,22 +62,34 @@ class Todo:
     the local system's one.
     """
 
-    def __init__(self, filename=None, mtime=None, new=False, list=None):
+    categories: List[str]
+    completed_at: Optional[datetime]
+    created_at: Optional[datetime]
+    last_modified: Optional[datetime]
+    related: List[Todo]
+    rrule: Optional[str]
+
+    def __init__(
+        self,
+        filename: str = None,
+        mtime: int = None,
+        new: bool = False,
+        list: TodoList = None,
+    ):
         """
         Creates a new todo using `todo` as a source.
 
         :param str filename: The name of the file for this todo. Defaults to
-        the <uid>.ics
+            the <uid>.ics
         :param mtime int: The last modified time for the file backing this
-        Todo.
+            Todo.
         :param bool new: Indicate that a new Todo is being created and should
-        be populated with default values.
-        :param List list: The list to which this Todo belongs.
+            be populated with default values.
+        :param TodoList list: The list to which this Todo belongs.
         """
         self.list = list
         now = datetime.now(LOCAL_TIMEZONE)
         self.uid = f"{uuid4().hex}@{socket.gethostname()}"
-        self.list = list
 
         if new:
             self.created_at = now
@@ -102,7 +120,7 @@ class Todo:
             raise ValueError(f"Must not be an absolute path: {self.filename}")
         self.mtime = mtime or datetime.now()
 
-    def clone(self):
+    def clone(self) -> Todo:
         """
         Returns a clone of this todo
 
@@ -162,7 +180,7 @@ class Todo:
         "NEEDS-ACTION",
     )
 
-    def __setattr__(self, name, value):
+    def __setattr__(self, name: str, value):
         """Check type and avoid setting fields to None"""
         """when that is not a valid attribue."""
 
@@ -203,14 +221,14 @@ class Todo:
         return object.__setattr__(self, name, v)
 
     @property
-    def is_completed(self):
+    def is_completed(self) -> bool:
         return bool(self.completed_at) or self.status in ("CANCELLED", "COMPLETED")
 
     @property
-    def is_recurring(self):
+    def is_recurring(self) -> bool:
         return bool(self.rrule)
 
-    def _apply_recurrence_to_dt(self, dt):
+    def _apply_recurrence_to_dt(self, dt) -> Optional[datetime]:
         if not dt:
             return None
 
@@ -231,7 +249,7 @@ class Todo:
         # TODO: Push copy's alarms.
         return copy
 
-    def complete(self):
+    def complete(self) -> None:
         """
         Immediately completes this todo
 
@@ -252,10 +270,13 @@ class Todo:
         self.status = "COMPLETED"
 
     @cached_property
-    def path(self):
+    def path(self) -> str:
+        if not self.list:
+            raise ValueError("A todo without a list does not have a path.")
+
         return os.path.join(self.list.path, self.filename)
 
-    def cancel(self):
+    def cancel(self) -> None:
         self.status = "CANCELLED"
 
 
@@ -283,10 +304,10 @@ class VtodoWriter:
         "rrule": "rrule",
     }
 
-    def __init__(self, todo):
+    def __init__(self, todo: Todo):
         self.todo = todo
 
-    def normalize_datetime(self, dt):
+    def normalize_datetime(self, dt: date) -> date:
         """
         Eliminate several differences between dates, times and datetimes which
         are hindering comparison:
@@ -307,7 +328,7 @@ class VtodoWriter:
 
         return dt.astimezone(pytz.UTC)
 
-    def serialize_field(self, name, value):
+    def serialize_field(self, name: str, value):
         if name in Todo.RRULE_FIELDS:
             return icalendar.vRecur.from_ical(value)
         if name in Todo.DATETIME_FIELDS:
@@ -321,7 +342,7 @@ class VtodoWriter:
 
         raise Exception(f"Unknown field {name} serialized.")
 
-    def set_field(self, name, value):
+    def set_field(self, name: str, value):
         # If serialized value is None:
         self.vtodo.pop(name)
         if value:
@@ -401,7 +422,7 @@ class Cache:
 
     SCHEMA_VERSION = 7
 
-    def __init__(self, path):
+    def __init__(self, path: str):
         self.cache_path = str(path)
         os.makedirs(os.path.dirname(self.cache_path), exist_ok=True)
 
@@ -411,7 +432,7 @@ class Cache:
 
         self.create_tables()
 
-    def save_to_disk(self):
+    def save_to_disk(self) -> None:
         self._conn.commit()
 
     def is_latest_version(self):
@@ -504,7 +525,7 @@ class Cache:
         os.remove(self.cache_path)
         self._conn = None
 
-    def add_list(self, name, path, colour, mtime):
+    def add_list(self, name: str, path: str, colour: str, mtime: int):
         """
         Inserts a new list into the cache.
 
@@ -541,7 +562,7 @@ class Cache:
 
         return self.add_list(name, path, colour, mtime)
 
-    def add_file(self, list_name, path, mtime):
+    def add_file(self, list_name: str, path: str, mtime: int):
         try:
             self._conn.execute(
                 """
@@ -560,7 +581,11 @@ class Cache:
         except sqlite3.IntegrityError as e:
             raise exceptions.AlreadyExists("file", list_name) from e
 
-    def _serialize_datetime(self, todo, field):
+    def _serialize_datetime(
+        self,
+        todo: icalendar.Todo,
+        field: str,
+    ) -> Tuple[Optional[int], Optional[bool]]:
         """
         Serialize a todo field in two value, the first one is the corresponding
         timestamp, the second one is a boolean indicating if the serialized
@@ -581,21 +606,21 @@ class Cache:
             dt = dt.replace(tzinfo=LOCAL_TIMEZONE)
         return dt.timestamp(), is_date
 
-    def _serialize_rrule(self, todo, field):
+    def _serialize_rrule(self, todo, field) -> Optional[str]:
         rrule = todo.get(field)
         if not rrule:
             return None
 
         return rrule.to_ical().decode()
 
-    def _serialize_categories(self, todo, field):
+    def _serialize_categories(self, todo, field) -> str:
         categories = todo.get(field, [])
         if not categories:
             return ""
 
         return ",".join([str(category) for category in categories.cats])
 
-    def add_vtodo(self, todo, file_path, id=None):
+    def add_vtodo(self, todo: icalendar.Todo, file_path: str, id=None) -> int:
         """
         Adds a todo into the cache.
 
@@ -634,7 +659,7 @@ class Cache:
         if start and due:
             start = None if start >= due else start
 
-        params = (
+        params = [
             file_path,
             todo.get("uid"),
             todo.get("summary"),
@@ -654,10 +679,10 @@ class Cache:
             todo.get("sequence", 1),
             self._serialize_datetime(todo, "last-modified")[0],
             self._serialize_rrule(todo, "rrule"),
-        )
+        ]
 
         if id:
-            params = (id,) + params
+            params = [id] + params
             sql = sql.format("id,\n", "?, ")
         else:
             sql = sql.format("", "")
@@ -687,7 +712,7 @@ class Cache:
             "NEEDS-ACTION",
             "IN-PROCESS",
         ),
-    ):
+    ) -> Iterable[Todo]:
         """
         Returns filtered cached todos, in a specified order.
 
@@ -718,7 +743,7 @@ class Cache:
         :rtype: generator
         """
         extra_where = []
-        params = []
+        params: list = []
 
         if "ANY" not in status:
             extra_where.append(
@@ -728,7 +753,7 @@ class Cache:
 
         if lists:
             lists = [
-                list_.name if isinstance(list_, List) else list_ for list_ in lists
+                list_.name if isinstance(list_, TodoList) else list_ for list_ in lists
             ]
             q = ", ".join(["?"] * len(lists))
             extra_where.append(f"AND files.list_name IN ({q})")
@@ -765,13 +790,13 @@ class Cache:
             extra_where.append("AND (start IS NULL OR start <= ?)")
             params.append(datetime.now().timestamp())
         if sort:
-            order = []
+            order_items = []
             for s in sort:
                 if s.startswith("-"):
-                    order.append(" {} ASC".format(s[1:]))
+                    order_items.append(" {} ASC".format(s[1:]))
                 else:
-                    order.append(f" {s} DESC")
-            order = ",".join(order)
+                    order_items.append(f" {s} DESC")
+            order = ",".join(order_items)
         else:
             order = """
                 completed_at DESC,
@@ -849,7 +874,7 @@ class Cache:
     def lists(self):
         result = self._conn.execute("SELECT * FROM lists")
         for row in result:
-            yield List(
+            yield TodoList(
                 name=row["name"],
                 path=row["path"],
                 colour=row["colour"],
@@ -910,18 +935,18 @@ class Cache:
             if paths_to_mtime.get(path, None) != mtime:
                 self.expire_file(path)
 
-    def expire_file(self, path):
+    def expire_file(self, path: str) -> None:
         self._conn.execute("DELETE FROM files WHERE path = ?", (path,))
 
 
-class List:
-    def __init__(self, name, path, colour=None):
+class TodoList:
+    def __init__(self, name: str, path: str, colour: str = None):
         self.path = path
         self.name = name
         self.colour = colour
 
     @staticmethod
-    def colour_for_path(path):
+    def colour_for_path(path: str) -> Optional[str]:
         try:
             with open(os.path.join(path, "color")) as f:
                 return f.read().strip()
@@ -929,7 +954,7 @@ class List:
             logger.debug("No colour for list %s", path)
 
     @staticmethod
-    def name_for_path(path):
+    def name_for_path(path: str) -> str:
         try:
             with open(os.path.join(path, "displayname")) as f:
                 return f.read().strip()
@@ -937,7 +962,7 @@ class List:
             return split(normpath(path))[1]
 
     @staticmethod
-    def mtime_for_path(path):
+    def mtime_for_path(path: str) -> int:
         colour_file = os.path.join(path, "color")
         display_file = os.path.join(path, "displayname")
 
@@ -953,7 +978,7 @@ class List:
             return 0
 
     def __eq__(self, other):
-        if isinstance(other, List):
+        if isinstance(other, TodoList):
             return self.name == other.name
         return object.__eq__(self, other)
 
@@ -976,7 +1001,7 @@ class Database:
         self.update_cache()
 
     def update_cache(self):
-        paths = {path: List.mtime_for_path(path) for path in self.paths}
+        paths = {path: TodoList.mtime_for_path(path) for path in self.paths}
         self.cache.expire_lists(paths)
 
         paths_to_mtime = {}
@@ -984,9 +1009,9 @@ class Database:
 
         for path in self.paths:
             list_name = self.cache.add_list(
-                List.name_for_path(path),
+                TodoList.name_for_path(path),
                 path,
-                List.colour_for_path(path),
+                TodoList.colour_for_path(path),
                 paths[path],
             )
             for entry in os.listdir(path):
@@ -1019,23 +1044,25 @@ class Database:
 
         self.cache.save_to_disk()
 
-    def todos(self, **kwargs):
+    def todos(self, **kwargs) -> Iterable[Todo]:
         return self.cache.todos(**kwargs)
 
-    def todo(self, id, **kwargs):
+    def todo(self, id, **kwargs) -> Todo:
         return self.cache.todo(id, **kwargs)
 
-    def lists(self):
+    def lists(self) -> Iterable[TodoList]:
         return self.cache.lists()
 
-    def move(self, todo, new_list, from_list=None):
-        from_list = from_list or todo.list
+    def move(self, todo: Todo, new_list: TodoList, from_list: TodoList) -> None:
         orig_path = os.path.join(from_list.path, todo.filename)
         dest_path = os.path.join(new_list.path, todo.filename)
 
         os.rename(orig_path, dest_path)
 
-    def delete(self, todo):
+    def delete(self, todo: Todo) -> None:
+        if not todo.list:
+            raise ValueError("Cannot delete Todo without a list.")
+
         path = os.path.join(todo.list.path, todo.filename)
         os.remove(path)
 
@@ -1048,7 +1075,10 @@ class Database:
         self.cache.clear()
         self.cache = None
 
-    def save(self, todo):
+    def save(self, todo: Todo) -> None:
+        if not todo.list:
+            raise ValueError("Cannot save Todo without a list.")
+
         for related in todo.related:
             self.save(related)
 
@@ -1065,6 +1095,6 @@ class Database:
         self.cache.save_to_disk()
 
 
-def _getmtime(path):
+def _getmtime(path: str) -> int:
     stat = os.stat(path)
     return getattr(stat, "st_mtime_ns", stat.st_mtime)
diff --git a/tox.ini b/tox.ini
index aa8950a..1fb30b7 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,5 +1,5 @@
 [tox]
-envlist = py36, py37, py38, py39 docs
+envlist = py38, py39 docs
 skip_missing_interpreters = True
 
 [testenv]


Test patch:
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 2bc3443..e0b1e58 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -8,7 +8,7 @@ jobs:
     strategy:
       matrix:
         os: [ubuntu-18.04, macOS-latest]
-        python: [ '3.6', '3.7', '3.8', '3.9' ]
+        python: [ '3.8', '3.9' ]
     name: Python ${{ matrix.python }} on ${{ matrix.os }}
     steps:
       - name: Checkout repo
diff --git a/tests/test_cli.py b/tests/test_cli.py
index a46e36e..e609af1 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -828,7 +828,7 @@ def test_invoke_command(runner, tmpdir):
     path.write("default_command = flush\n", "a")
 
     flush = mock.MagicMock()
-    with patch.dict(cli.commands, values=dict(flush=flush)):
+    with patch.dict(cli.commands, values={"flush": flush}):
         result = runner.invoke(cli, catch_exceptions=False)
 
     assert not result.exception
diff --git a/tests/test_formatter.py b/tests/test_formatter.py
index b8531b1..f23de73 100644
--- a/tests/test_formatter.py
+++ b/tests/test_formatter.py
@@ -16,7 +16,7 @@ from todoman.formatters import rgb_to_ansi
 @pytest.mark.parametrize("interval", [(65, "in a minute"), (-10800, "3 hours ago")])
 @pytest.mark.parametrize("tz", ["CET", "HST"])
 @freeze_time("2017-03-25")
-def test_humanized_date(runner, create, interval, now_for_tz, tz):
+def test_humanized_datetime(runner, create, interval, now_for_tz, tz):
     seconds, expected = interval
     due = now_for_tz(tz) + timedelta(seconds=seconds)
     create(
@@ -31,6 +31,25 @@ def test_humanized_date(runner, create, interval, now_for_tz, tz):
     assert expected in result.output
 
 
+@pyicu_sensitive
+@pytest.mark.parametrize("interval", [(65, "today"), (-10800, "today")])
+@pytest.mark.parametrize("tz", ["CET", "HST"])
+@freeze_time("2017-03-25 18:00:00")
+def test_humanized_date(runner, create, interval, now_for_tz, tz):
+    seconds, expected = interval
+    due = now_for_tz(tz) + timedelta(seconds=seconds)
+    create(
+        "test.ics",
+        "SUMMARY:Hi human!\nDUE;VALUE=DATE;TZID={}:{}\n".format(
+            tz, due.strftime("%Y%m%d")
+        ),
+    )
+
+    result = runner.invoke(cli, ["--humanize", "list", "--status", "ANY"])
+    assert not result.exception
+    assert expected in result.output
+
+
 def test_format_priority(default_formatter):
     assert default_formatter.format_priority(None) == "none"
     assert default_formatter.format_priority(0) == "none"
diff --git a/tests/test_model.py b/tests/test_model.py
index c901562..3cd39f3 100644
--- a/tests/test_model.py
+++ b/tests/test_model.py
@@ -12,8 +12,8 @@ from freezegun import freeze_time
 from todoman.exceptions import AlreadyExists
 from todoman.model import cached_property
 from todoman.model import Database
-from todoman.model import List
 from todoman.model import Todo
+from todoman.model import TodoList
 
 
 def test_querying(create, tmpdir):
@@ -269,6 +269,27 @@ def test_save_recurring_related(default_database, todo_factory, todos):
     assert todo.rrule == rrule
 
 
+def test_save_recurring_related_with_date(default_database, todo_factory, todos):
+    now = date.today()
+    original_due = now + timedelta(days=1)
+    rrule = "FREQ=DAILY;UNTIL=20990315"
+    todo = todo_factory(rrule=rrule, due=original_due)
+    todo.complete()
+
+    default_database.save(todo)
+
+    todos = todos(status="ANY")
+    todo = next(todos)
+    assert todo.percent_complete == 100
+    assert todo.is_completed is True
+    assert not todo.rrule
+
+    todo = next(todos)
+    assert todo.percent_complete == 0
+    assert todo.is_completed is False
+    assert todo.rrule == rrule
+
+
 def test_todo_filename_absolute_path():
     Todo(filename="test.ics")
     with pytest.raises(ValueError):
@@ -276,9 +297,9 @@ def test_todo_filename_absolute_path():
 
 
 def test_list_equality(tmpdir):
-    list1 = List(path=str(tmpdir), name="test list")
-    list2 = List(path=str(tmpdir), name="test list")
-    list3 = List(path=str(tmpdir), name="yet another test list")
+    list1 = TodoList(path=str(tmpdir), name="test list")
+    list2 = TodoList(path=str(tmpdir), name="test list")
+    list3 = TodoList(path=str(tmpdir), name="yet another test list")
 
     assert list1 == list2
     assert list1 != list3
@@ -447,3 +468,26 @@ def test_cached_property_property():
             return 0
 
     assert TestClass.a.__class__ == cached_property
+
+
+def test_deleting_todo_without_list_fails(tmpdir, default_database):
+    db = Database([tmpdir.join("default")], tmpdir.join("cache.sqlite3"))
+    todo = Todo()
+
+    with pytest.raises(ValueError, match="Cannot delete Todo without a list."):
+        db.delete(todo)
+
+
+def test_saving_todo_without_list_fails(tmpdir, default_database):
+    db = Database([tmpdir.join("default")], tmpdir.join("cache.sqlite3"))
+    todo = Todo()
+
+    with pytest.raises(ValueError, match="Cannot save Todo without a list."):
+        db.save(todo)
+
+
+def test_todo_path_without_list(tmpdir):
+    todo = Todo()
+
+    with pytest.raises(ValueError, match="A todo without a list does not have a path."):
+        todo.path