Python task manager from scratch, part 10: Task objects

Until now we've been reading objects from our (primitive, flat-text-file) database and writing to that database and directly treating those objects as our tasks. That is:

  1. Our get_all_tasks() function gives us strings;
  2. Our other functions accept strings;
  3. That's because strings are things our database knows how to persist;
  4. We map between tasks--the real-world things we're trying to manipulate by writing code in the first place--and those strings in our heads, on the fly.

This is a state of sin. (Though it is a very common state of sin: many large, mature codebases, written by hordes of well-respected and thoughtful programmers, in effect require their users to do the moral equivalent of this.)

You will do better:

  1. In part of your code, you'll do your best to represent tasks (and, later, other things) as they really are. Here, you'll mostly put aside questions of how to persist them. Rather, you'll whatever resources you have to create domain-specific objects that behave faithfully. (Faithfully to reality as you need to represent it.)
  2. Elsewhere, you'll write code to handle the persistence of those "domain objects."
  3. Then you'll write code to map between those layers.

You can think about this division functionally: The first layer does things like presenting tasks to users and prompting task objects to do computations. (A task might, e.g., calculate how long until it's due.) The second layer does things like saving and restoring tasks, handling the inevitable fiddly problems that need careful management at these stages.

You can also think about the division morally and/or metaphysically. The first layer is a Platonic realm wherein you create and manage things as they actually are. The second layer manages those objects' connection to a fundamentally dirty and clunky realm of disks and databases and CSV files.

Either way, it's essential to distinguish between these layers and do it early, right here in step 10. In practice, the trick is to do it flexibly enough to make it easy to change later--however carefully we plan, we'll be changing things often--but faithfully enough that our work isn't utterly pointless. (I've made many mistakes in both directions.)

Making a domain layer

  1. Create a objects directory in veery/.
  2. Put a file called there (so it lives at veery/objects/
  3. Put a Task class there:
    from dataclasses import dataclass
    from datetime import datetime

    from typing import Optional

    class Task:
        description: str
        due: Optional[datetime] = None

There's a lot to note here:

  1. Task is a class. We've been using classes like str and list all along. In all modern programming languages, you can make your own classes also. Python is great at this. It's also great at documenting it.
  2. It's also a dataclass. On the one hand, dataclasses are somewhat new feature that many programmers are not familiar with. On the other hand, (i) they're awesome and (ii) they can make class definitions more intuitive for the less experienced.

The basic idea of this class is that: (i) there's such a thing as a Task; (ii) a Task has a description (represented by a string) and, optionally, a time when it's due (represented by a datetime).

If you don't supply the due field, it will automatically be set to the None object (that's what the = None part of the last line is doing).

Adding some tests

We'll be writing at least a few lines of code to make sure that our Task class basically works, so (in the spirit of the last post) why not preserve them as tests?

  1. Make a new file, veery/tests/;
  2. Put some tests there:
    from objects.task import Task

    def test_basic_task_creation():
        assert Task("do ten push-ups")

    def test_task_can_be_created_with_empty_description():
        assert Task(description="")

    def test_task_is_initialized_without_due_date_if_not_provided():
        task_without_due_datetime = Task("foo")
        assert task_without_due_datetime.due is None

Some notes about these:

  1. Again, Pytest will simply find these and run them when you run the pytest command.
  2. The first task is, again, a sort of smoke test. It ensures that the most basic functioning of Task is what we think it is.
  3. The second test largely exists to remind us that Task currently handles a certain edge case a certain way. Maybe we'll want to preserve it, maybe not--but now it's documented and tested, and the test is easy to change later if need be. This sort of thing is an important practical aspect of testing.
  4. The last test ensures that behavior we definitely do want (the ability to have a Task without a due date/time) is there.

Here's the current commit from the veery repository.

Next post: Python task manager from scratch, part 11: First bugfix

Home page