Skip to main content

Dependencies, pyproject & a mini capstone

Level 3: Level 3: Patternshard22 minpyprojectuvpackagingcapstone

Understand pyproject.toml/uv and extend a small, tested multi-file project.

Why one file and one lockfile

A project that only runs on your laptop is a liability. The moment a teammate clones it, CI builds it, or you deploy it, "works on my machine" has to become "installs the same way everywhere." pyproject.toml plus a lockfile is how modern Python gets there. One file declares what the project is and what it needs, and the lockfile pins the exact versions that got resolved, so every install is byte-for-byte identical.

pyproject.toml: the single source of truth

pyproject.toml is the standard, tool-agnostic place to describe a Python project. It replaces the old setup.py plus requirements.txt sprawl.

[project]
name = "todo"
version = "0.1.0"
dependencies = ["httpx>=0.27"]

[project.scripts]
todo = "todo.cli:main"

The [project] table holds metadata: the package name, its version, and its dependencies. Note that dependencies are ranges (httpx>=0.27), a statement of intent, not exact pins. The [project.scripts] table wires a console command (todo) to a function (main in todo.cli), so installing the package gives you a runnable CLI.

Your capstone project is laid out this way: a todo/ package directory holds modules like tasks.py (sample tasks) and report.py (where summary lives), with pyproject.toml at the root naming the package.

uv: resolve, lock, run

uv is a fast package manager that replaces pip, virtualenv, and pip-tools with one tool. The day-to-day loop:

uv add httpx     # add a dep: updates pyproject.toml AND uv.lock
uv sync          # install exactly what uv.lock pins
uv run pytest    # run a command inside the project's .venv
Check yourself
Your pyproject.toml declares dependencies = ['httpx>=0.27']. You committed pyproject.toml but not uv.lock. Six months later a teammate clones the repo and installs. Do they get the same httpx version you have?

The mental model is two tiers. pyproject.toml declares intent as version ranges. uv.lock records the one exact resolution that satisfied those ranges. Commit both files; never commit .venv. That split is what makes builds reproducible: your teammate runs uv sync and gets your exact versions, not "whatever pip resolved today."

Check yourself

You are setting up the repo for this project. Sort each item by whether it goes into version control.

pyproject.toml
uv.lock
.venv/
the todo/ package source
a .env file holding an API key
__pycache__/

The reporting function

summary(tasks) is the kind of function a CLI or API endpoint calls to report state. Given task dicts with a "done" flag, it returns totals in one pass:

def summary(tasks):
    done = sum(1 for task in tasks if task["done"])
    return {"total": len(tasks), "done": done, "pending": len(tasks) - done}

print(summary([{"title": "a", "done": True}, {"title": "b", "done": False}]))
# {'total': 2, 'done': 1, 'pending': 1}

Deriving pending as total - done (rather than a second filtered loop) guarantees the invariant total == done + pending always holds, even if the two filters ever disagreed.

Pitfall: truthiness is not equality

Check yourself
Task flags come from a CSV importer, so some arrive as the string 'false' instead of the boolean False. summary counts with: sum(1 for task in tasks if task['done']). What does a task carrying done='false' count as?

if task["done"] tests truthiness, not "is this the boolean True." A flag of 0 or "" is falsy, but a flag of "false" (a non-empty string) is truthy and counts as done. If your data might carry non-bool flags, be explicit: if task["done"] is True. Silent truthiness bugs like this survive tests that only use clean True/False fixtures.

Check yourself
You replace sum(1 for task in tasks if task['done']) with the shorter sum(task['done'] for task in tasks). On clean boolean data, what does the shorter version return?

Interview nuance: in Python, bool is a subclass of int, so True == 1 and False == 0. That means sum(task["done"] for task in tasks) counts the done tasks without the literal 1, because the booleans add up directly. It is a clean one-pass, O(n) idiom that interviewers like, but flag its fragility: it assumes every flag is a real boolean (or at least an int). On the pitfall's own bad input, a string flag like "false" makes sum raise TypeError instead of counting, while a non-bool number like 2 silently overcounts. Correct behavior here rests on knowing your data's type, not just its shape.

Worked example (Python)
def summary(tasks):
    done = sum(1 for task in tasks if task["done"])
    return {"total": len(tasks), "done": done, "pending": len(tasks) - done}


print(summary([{"title": "a", "done": True}, {"title": "b", "done": False}]))

Apply

Your turn

The task this lesson builds to.

Warm-up (one file): implement summary(tasks). Given a list of task dicts (each with a "done" flag), return {"total": n, "done": d, "pending": p}.

For [{"title": "a", "done": True}, {"title": "b", "done": False}] return {"total": 2, "done": 1, "pending": 1}.

3 hints and 3 automated checks are waiting in the workspace.

Practice

Make it stick

A second problem on the same idea, so it survives past today.

Capstone: implement summary(tasks) in todo/report.py so it returns {"total", "done", "pending"} counts for the project's tasks. Read pyproject.toml and todo/tasks.py for context. Some tests are hidden.

3 hints and 2 automated checks are waiting in the workspace.