Part 6 · Professional Python · Graded project
Project 6: Habit Tracker Service
Build a professional-grade habit tracker the way teams build real services: a pure domain at the center, Protocol ports around it, swappable in-memory and SQLite adapters, a service layer of use cases, and a structlog-instrumented CLI on the outside. Ninety-seven tests tell you when each layer is right.
- Model a domain with frozen dataclasses and pure functions that are trivial to test
- Define ports as
typing.Protocols and write two adapters that satisfy the same contract - Use sqlite3 professionally: versioned migrations, transactions, and parameterized queries
- Write use cases that depend on a
Clockabstraction instead of the real date - Build an argparse CLI that separates results (stdout) from structured logs (stderr)
- Run one contract test suite against several implementations with a parametrized fixture
The scenario
A small product team wants a habit tracker. Users create habits ("Drink water", "Gym on Mon/Wed/Fri", "Weekly review"), check in when they do them, and look at their streaks (how many periods in a row they kept the habit) and completion rate (what fraction of due periods they kept). Today it is a command-line tool backed by a SQLite file. Next quarter the team wants a web API (Module 45's FastAPI) and maybe PostgreSQL. Your job is to build version 1 so that those changes touch only the edges of the code.
That requirement drives the architecture. The rules about dates and streaks are the valuable, subtle part, so they live in a domain module with no I/O at all. Storage is hidden behind a port (an interface), so a SQLite adapter and an in-memory adapter are interchangeable. The service layer expresses each use case once. The CLI is a thin composition root: the one place that picks concrete classes and wires them together.
Learning goals
Design
Layered (hexagonal) architecture, dependency inversion, ports and adapters, and a composition root, straight from M46.
Data
Schema design, migrations tracked in a version table, atomic transactions, and injection-proof parameterized SQL from M44.
Testing
Pure-function unit tests, contract tests over two adapters, fakes instead of
mocks, and CLI tests against real files in tmp_path.
Operations
structlog key=value logs on stderr, clean machine-readable
output on stdout, meaningful exit codes, and CSV/JSON export.
Feature specification
Frequencies and periods
Every habit has a frequency, and every frequency splits time into periods. A period is identified by its key, the date it starts on.
| Frequency | Text form | Period | Due days |
|---|---|---|---|
| Daily | daily | one day (key = the day) | every day |
| Weekly | weekly | one ISO week (key = its Monday) | every day of the week |
| Specific weekdays | mon,wed,fri | one day | only the listed days |
Rules
- Habit ids are slugs of the name:
"Drink Water!"becomesdrink-water. Names are 1–80 characters after stripping; duplicate ids are rejected. - Check-in is idempotent per period. Checking in a weekly habit on Monday and again on Thursday
stores one completion; the second call returns
False. - Check-ins in the future, before the habit was created, on a day that is not due, or on an archived habit
raise
ValidationError. Unknown ids raiseNotFoundError. - Undo removes the check-in for the period that contains the given day (default today).
- Current streak: consecutive satisfied periods ending at the current one. Today's period is still in progress, so leaving it unchecked does not break the streak; a missed finished period does.
- Longest streak: the best run of consecutive satisfied periods from creation until today.
- Completion rate over
start..end: satisfied due periods divided by due periods, ignoring periods before creation (0.0 if there are none). The default window is the 30 days ending today. - Export: JSON (every habit with its list of check-in dates and streaks) and CSV (one row per habit with the check-in count).
The CLI
pixi run python -m habits --db demo.db add "Gym" --frequency mon,wed,fri
created gym (mon,wed,fri)
pixi run python -m habits --db demo.db checkin gym
checked in gym
pixi run python -m habits --db demo.db checkin gym --date 2026-01-05 # backfill
pixi run python -m habits --db demo.db list --all
pixi run python -m habits --db demo.db stats gym --from 2026-01-01 --to 2026-01-31
habit: gym
current_streak: 4
longest_streak: 6
completed: 10/13
completion_rate: 76.9%
pixi run python -m habits --db demo.db export --format json > backup.json
pixi run python -m habits -v --today 2026-02-01 undo gym # -v shows info logs on stderr
Global options: --db PATH (default $HABITS_DB, else habits.db),
--today YYYY-MM-DD (pretend it is that date; great for demos and tests), and -v. Exit
code 0 means success, 1 means a HabitError (message on stderr as error: ...), and 2 is
argparse's usage error.
Architecture
Dependencies point inward. The domain imports nothing from the project. Ports import only the domain. The service imports the domain and the ports, never an adapter. Only the CLI knows that SQLite exists.
flowchart LR
subgraph Outside["Driving side"]
CLI["cli.py
argparse + structlog
(composition root)"]
end
subgraph Core["Application core"]
SVC["service.py
HabitService use cases"]
DOM["domain.py
Habit, Frequency, Completion
streaks, rates"]
PORTS["ports.py
HabitRepository, Clock
(Protocols)"]
end
subgraph Driven["Driven side (adapters)"]
MEM["memory.py
InMemoryRepository"]
SQL["sqlite_repo.py
SqliteRepository
migrations + transactions"]
CLK["SystemClock / FixedClock"]
end
CLI --> SVC
CLI -. builds .-> SQL
CLI -. builds .-> CLK
SVC --> PORTS
SVC --> DOM
PORTS --> DOM
MEM -. satisfies .-> PORTS
SQL -. satisfies .-> PORTS
CLK -. satisfies .-> PORTS
projects/p06_habit_tracker/
starter/habits/ # what you copy with `pixi run start p06`
domain.py ports.py memory.py sqlite_repo.py service.py cli.py logging_config.py __main__.py
tests/
test_habits_domain.py test_habits_repository.py test_habits_service.py test_habits_cli.py
The SQLite schema (given in MIGRATIONS; you write the code that applies it):
erDiagram
HABITS ||--o{ COMPLETIONS : has
HABITS {
TEXT id PK
TEXT name
TEXT frequency
TEXT created_on
INTEGER archived
}
COMPLETIONS {
TEXT habit_id FK
TEXT day
}
SCHEMA_VERSION {
INTEGER version
}
Getting started with Pixi
pixi run projects # list projects and whether you've started them
pixi run start p06 # copy the starter into work/p06_habit_tracker/
pixi run grade p06 # run all 97 tests against your work/ copy
pixi run practicum grade p06 -k domain # only Milestone 1
pixi run practicum grade p06 -k repository # only Milestone 2
pixi run ruff check work/p06_habit_tracker
pixi run mypy --strict work/p06_habit_tracker
The starter has every public class and function with its full signature, type hints, and docstring. Bodies
raise NotImplementedError and carry TODO comments. The Protocols in
ports.py, the structlog setup, the migration SQL, and the argparse parser are given so you can focus
on behavior. At the top of some modules a comment lists the imports the reference solution uses.
Milestones
Milestone 1 · The domain (domain.py, 34 tests)
Implement Frequency (validation, parse, __str__, period math),
slugify, Habit.create, and the streak and rate functions. Everything takes dates as
arguments, so no clock is needed.
Frequency.parseandstr()round-trip for daily, weekly, and weekday listsperiod_start,is_due,periods, andprevious_periodpasscurrent_streaktreats today's open period as "not yet missed"longest_streakandcompletion_rateignore check-ins before creation and on rest dayspixi run practicum grade p06 -k domainis green
Milestone 2 · Adapters (memory.py, sqlite_repo.py, 31 tests)
Make both repositories pass the same contract. The test file defines one fixture with
params=["memory", "sqlite"], so every contract test runs twice. Then implement migrations:
schema_version() and migrate() apply each pending entry of MIGRATIONS
atomically and record the new version.
InMemoryRepositorypasses all twelve contract testsSqliteRepositorypasses the same twelve tests unchanged- A fresh database ends at
LATEST_VERSION; a version-1 database upgrades to it - Every query uses
?placeholders; a hostile name is stored verbatim - A failed insert raises
DuplicateHabitErrorand leaves no partial data
Milestone 3 · The service layer (service.py, ports.py clocks, 20 tests)
Implement FixedClock, SystemClock, and HabitService. Each use case is a
short method: load, apply a domain rule, store, log a structlog event.
create_habituses the injected clock forcreated_oncheck_inis idempotent per period and validates the dayundoclears the period containing the day;archive_habitis idempotentstatsdefaults to the 30-day window ending todayexport_jsonandexport_csvmatch the documented fields
Milestone 4 · The CLI (cli.py, 12 tests)
Implement main() (the composition root) and _run() (dispatch with
match/case). The CLI tests use real SQLite files under tmp_path and
pytest's capsys to check stdout and stderr separately.
- Every subcommand writes its result to stdout and returns 0
- A
HabitErrorbecomeserror: ...on stderr and exit code 1, with empty stdout --db,$HABITS_DB, and--todayare honored-vshowskey=valuestructlog lines on stderr onlypixi run grade p06reports 97 passed; ruff and strict mypy are clean
Hints
Hint: the streak walk
Think in period keys, not dates. Build the set of satisfied keys once
({freq.period_start(c.day) for c in completions if ...}). Then start at the last due period on or
before today and step backwards with previous_period while the key is in the set. The only special
case: if that starting key is today's period and it is not satisfied yet, step back once before
counting.
key = last_due_period(habit, today)
if key not in done and key == freq.period_start(today):
key = freq.previous_period(key) # today is still in progress
streak = 0
while key >= first and key in done:
streak += 1
key = freq.previous_period(key)
Hint: atomic migrations in sqlite3
Python's sqlite3 module implicitly opens a transaction only before INSERT/UPDATE/
DELETE, not before CREATE TABLE. To make "run these DDL statements and bump the
version" all-or-nothing, issue BEGIN yourself inside with conn:; the context manager
commits on success and rolls back on an exception.
for version in range(current, LATEST_VERSION):
with conn:
conn.execute("BEGIN")
for statement in MIGRATIONS[version]:
conn.execute(statement)
conn.execute("UPDATE schema_version SET version = ?", (version + 1,))
Hint: detecting duplicates without a race
Don't SELECT first and then INSERT: another process could insert in between. Let the
database enforce the rule. For habits, catch sqlite3.IntegrityError from the primary key and
re-raise DuplicateHabitError(...) from exc. For completions, INSERT OR IGNORE and check
cursor.rowcount.
Hint: structlog and swapped streams in tests
structlog.PrintLoggerFactory(file=sys.stderr) captures the stream object at configuration time.
pytest's capsys replaces sys.stderr per test and closes the old one, so later log calls
would fail with "I/O operation on closed file". The given configure_logging uses a small factory that
looks up sys.stderr at each call and turns off cache_logger_on_first_use.
Hint: one contract, two adapters
@pytest.fixture(params=["memory", "sqlite"])
def repo(request, tmp_path):
if request.param == "memory":
yield InMemoryRepository()
else:
with SqliteRepository(tmp_path / "habits.db") as sqlite_repo:
yield sqlite_repo
If a contract test passes for memory but fails for SQLite, the difference is usually ordering (add
ORDER BY), type conversion (dates stored as ISO text, booleans as 0/1), or a missing
NotFoundError when UPDATE affects zero rows (check cursor.rowcount).
Rubric
| Criterion | Points | What earns full marks |
|---|---|---|
| Domain correctness | 25 | All Milestone 1 tests pass; functions are pure and take "today" as an argument. |
| Adapters | 25 | Both adapters pass the contract; migrations are versioned and atomic; every query is parameterized. |
| Service layer | 20 | Use cases depend only on ports; idempotent check-in; correct defaults and validation. |
| CLI and logging | 15 | Correct exit codes; results on stdout, structlog key=value on stderr; no print() in library code. |
| Code quality | 15 | ruff check and mypy --strict clean; Google docstrings; no bare except; clear names. |
Passing pixi run grade p06 covers the first four rows. The last row is yours to verify with ruff and
mypy, exactly as a CI pipeline (M42) would.
Stretch goals
- A third migration. Add a
notescolumn and arenameuse case. Prove an old database upgrades by writing a test that builds a version-2 file first. - "N times per week". Add a frequency such as
3x/week. What does a streak mean now? Write the tests before the code. - A third adapter. Write
JsonFileRepositoryand add"json"to the contract fixture's params. If the contract tests pass, the service works with it unchanged. - A web front end. Wrap
HabitServicein a FastAPI app (Module 45) withDependsproviding the service. The domain, ports, and adapters should not change at all. - SQLAlchemy. Implement the repository with SQLAlchemy 2.0 Core and compare it to the raw sqlite3 version.