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.

⏱ ~8–12 hoursPrerequisites: M44, M45, M46, M42

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.

FrequencyText formPeriodDue days
Dailydailyone day (key = the day)every day
Weeklyweeklyone ISO week (key = its Monday)every day of the week
Specific weekdaysmon,wed,frione dayonly the listed days

Rules

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.parse and str() round-trip for daily, weekly, and weekday lists
  • period_start, is_due, periods, and previous_period pass
  • current_streak treats today's open period as "not yet missed"
  • longest_streak and completion_rate ignore check-ins before creation and on rest days
  • pixi run practicum grade p06 -k domain is 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.

  • InMemoryRepository passes all twelve contract tests
  • SqliteRepository passes 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 DuplicateHabitError and 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_habit uses the injected clock for created_on
  • check_in is idempotent per period and validates the day
  • undo clears the period containing the day; archive_habit is idempotent
  • stats defaults to the 30-day window ending today
  • export_json and export_csv match 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 HabitError becomes error: ... on stderr and exit code 1, with empty stdout
  • --db, $HABITS_DB, and --today are honored
  • -v shows key=value structlog lines on stderr only
  • pixi run grade p06 reports 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

CriterionPointsWhat earns full marks
Domain correctness25All Milestone 1 tests pass; functions are pure and take "today" as an argument.
Adapters25Both adapters pass the contract; migrations are versioned and atomic; every query is parameterized.
Service layer20Use cases depend only on ports; idempotent check-in; correct defaults and validation.
CLI and logging15Correct exit codes; results on stdout, structlog key=value on stderr; no print() in library code.
Code quality15ruff 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

Reflection quiz