Working code is only useful when another person—or a deployment server—can run it reliably.
Modern Python tools make the project repeatable. They record its dependencies, check common mistakes, run tests, and stop broken changes before they enter the repository.
Lesson details
- Module
- AI engineering foundations
- Lesson
- 3 · Modern Python tooling
- Difficulty
- Beginner
- Learning time
- 36 minutes
Before you start
- Python functions and type hints
- Python foundations for AI applications
Skills you will gain
- Explain the job of five core Python tools
- Create a reproducible Python project
- Read lint and type-checking feedback
- Write a small pytest test
- Describe an automated pre-commit quality gate
Why this matters
An AI application may work on your laptop and fail in continuous integration or production because a package version changed, a type was wrong, or a previously working behaviour broke.
These tools catch different parts of that problem early, when a fix is cheaper and easier to understand.
The intuition
None of these tools make an idea smarter. They catch mechanical mistakes you can describe in advance: an unused import, a mismatched type, a broken assertion, or an environment that cannot be reproduced.
How it works
uv manages your project’s Python version, its isolated virtual environmentVirtual environmentAn isolated set of installed Python packages, separate from your system Python and from any other project's environment, so two projects that need different versions of the same library never conflict. uv creates and manages one per project automatically., and its dependencies — the jobs that used to need four or five separate tools:
uv init my-project # creates pyproject.toml, a .venv, and a first commit-ready layout
uv add requests # adds requests to pyproject.toml, resolves it, updates uv.lock
uv run python pipeline.py # runs inside the project's own isolated environment, no "activate" step
The file that matters most here is uv.lock. pyproject.toml says “I need requests, roughly this version range” — that’s still an intentional range, so you can pick up compatible patches. uv.lock says exactly which version, and every one of its dependencies’ exact versions, were actually resolved. Commit both. uv sync (or uv run, which syncs first) installs from the lockfile, not the range — so a fresh checkout on any machine gets the identical set of packages the lockfile recorded, which is precisely what the hook’s bug was missing.
ruff reads your code and reports style and correctness issues without running any of it — that’s what makes it a linterLinterA tool that checks source code for style violations, common bugs, and dead code — like an unused import — by reading and analysing the code, without running it. ruff is a linter (and formatter) for Python; it does not check whether your types line up (mypy's job) or whether your code produces the right result (a test's job). rather than a test:
import os # never used below
import requests
def fetch(url):
resp = requests.get(url)
return resp.json()
$ ruff check pipeline.py
pipeline.py:1:8: F401 [*] `os` imported but unused
Found 1 error.
[*] 1 fixable with the `--fix` option.
F401 is ruff’s actual rule code for an unused import — not a generic warning, a specific, addressable finding. ruff format pipeline.py handles the layout side separately (spacing, quote style, line length) the same way black used to, in the same tool.
mypy checks that the types you promised in your type hintsType hintsOptional annotations that declare the expected types of variables, arguments and return values (e.g. `def f(x: int) -> str`). Python does not enforce them at runtime, but tools like mypy, your editor, and libraries like Pydantic read them to catch bugs before the code ever runs. actually line up, without running the code either:
def apply_discount(price: float, pct: float) -> float:
return price - pct
apply_discount(100, "10") # a string where a float was promised
$ mypy pricing.py
pricing.py:4: error: Argument 2 to "apply_discount" has incompatible type "str"; expected "float" [arg-type]
Found 1 error in 1 file (checked 1 source file)
This is exactly the shape of bug type hints exist to catch (Chapter 00-01) — except now something actually checks it for you, instead of it only being a note for a human reader.
pytest is the one tool here that actually runs your code, to check it produces what you said it would. You’ll run it for real in a moment.
A small test you can copy
def estimate_cost(tokens: int, price_per_token: float) -> float:
return tokens * price_per_token
def test_estimate_cost():
result = estimate_cost(1_000, 0.000_002)
assert result == 0.002Important lines
tokens: int- A type hint says tokens should be a whole number.
-> float- The function promises to return a decimal number.
def test_estimate_cost():- pytest discovers functions whose names begin with test_.
assert result == 0.002- The test fails if the actual result differs from the expected behaviour.
Try changing the expected result to 0.02. A useful test should now fail and show the two different values.
pre-commit ties all of this together. A .pre-commit-config.yaml lists the hooks a project runs automatically, right before git creates a commit:
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.8.4
hooks:
- id: ruff
- id: ruff-format
- repo: local
hooks:
- id: pytest
name: pytest
entry: uv run pytest
language: system
pass_filenames: false
pre-commit install (run once per clone) wires this into git itself — after that, git commit runs every listed hook first:
$ git commit -m "add discount logic"
ruff.....................................................................Passed
ruff-format...............................................................Passed
pytest....................................................................Failed
- hook id: pytest
- exit code: 1
FAILED test_pricing.py::test_apply_discount - assert 90.0 == 91.0
The commit did not happen. Nothing reached the repository’s history. You fix the failing assertion or the code it’s checking, and try again — this is the loop the diagram above is showing.
A worked example
Trace one small change through the whole gate. You write:
import os
def apply_discount(price: float, pct: float) -> float:
return price - pct
with a test file asserting apply_discount(100, 10) == 90, and you run git commit. Walk it stage by stage:
| Stage | What it checks | Finds | Commit proceeds? |
|---|---|---|---|
| ruff | style, dead code, common bugs — reads the code, doesn’t run it | F401: os imported but never used |
No — blocked here, cheapest check, fails first |
| mypy | do the declared types actually line up? — reads the code, doesn’t run it | (not reached yet — you fix ruff’s finding first) | — |
| pytest | does the code actually produce the right result? — really runs it | (not reached yet) | — |
You delete the unused import os and commit again. This time ruff passes, mypy passes (the types genuinely line up), and pytest actually executes apply_discount(100, 10) and gets 90 — matching the assertion. All three stages pass, in increasing order of cost, and only then does the commit happen. Notice what each stage could and couldn’t have caught: if the test had asserted == 91 by mistake, ruff and mypy would both have passed cleanly — neither of them runs the code, so neither could have known the expected number was wrong. Only pytest, the one stage that executes anything, would have caught it.
Try it
pytest and mypy are themselves just Python packages, so — unlike uv, ruff, and pre-commit, which are project-management tools that need a real project on a real filesystem — you can run them for real, right here, in the browser.
First, a genuine pytest run against a bug, then fix it and rerun:
Next, a genuine mypy run against a real type mistake:
Production reality
The course you are reading right now runs exactly this pattern: a Quality CI workflow runs a typecheck, a manifest check, and a full Playwright/axe/Lighthouse suite on every push — and, as of the previous chapter, the site’s own deploy is gated on that workflow passing. Nothing about that is special to this project; it is the same gate this chapter describes, just running in CI instead of (or in addition to) on your own machine.
A few things worth knowing before you rely on this in a real team:
- Lockfiles matter most exactly when you need to move fast. A security patch to a dependency needs to reach every environment — laptops, CI, production — identically and quickly. Without a lockfile, “it’s fixed in CI” and “it’s fixed in production” can silently mean two different sets of installed packages.
- ruff is fast enough that teams run it on save, not just before commit — it reads code, it doesn’t execute it, so there’s very little to wait for. That speed is why it replaced several older, slower tools that did similar jobs one at a time.
- Retrofitting mypy onto an old, untyped codebase is a real project, not a switch you flip. Most teams start it on new code only, or run it in a mode that doesn’t yet demand every function be annotated, and tighten the rules gradually. Trying to type an entire large existing codebase in one pass is usually a mistake — it stalls, and nobody finishes it.
- A green pipeline is not the same as correct code. ruff, mypy, and pytest can all pass while the code still does the wrong thing — pytest only checks what you actually wrote a test for, and mypy only checks where you actually wrote a type hint. These tools raise the floor under mistakes you can name in advance; they do not check whether you understood the problem correctly.
Common mistakes
Never running `pre-commit install`, so the hooks in .pre-commit-config.yaml exist in the repo but never actually run on your machine.
Fix — pre-commit install wires the hooks into git itself, once per clone. Without it, the config file is just a document — nothing stops a broken commit locally, and you find out only when CI (or a teammate) rejects it.
Treating a loose dependency spec ("requests", or "requests>=2") as good enough for a shared project.
Fix — A range lets the exact resolved version drift between machines and over time — the hook's bug, exactly. Commit a lockfile (uv.lock) and install from it (uv sync / uv run) in every environment, not just from the range in pyproject.toml.
Silencing a real mypy error with a bare `# type: ignore` instead of fixing the mismatch it found.
Fix — An unscoped ignore hides every future error on that line too, not just the one you meant to suppress. Ignore a specific error code (`# type: ignore[arg-type]`) with a comment explaining why, and treat it as a rare, deliberate exception — not a habit for making mypy quiet.
Writing a test that passes no matter what the code does — asserting something trivially true, or asserting nothing at all.
Fix — A test is only worth trusting if it can actually fail. Before trusting a new test, deliberately break the code it's supposed to check and confirm the test goes red — if it stays green, the test isn't testing anything.
Exercises
A shared project's requirements.txt has the line `requests` with no version at all. Rewrite it as an exact pin using `==`, and explain in one sentence why the pin matters for a project more than one person runs.
Reveal solution
requests==2.32.3
Without a pin, pip install can resolve to whatever the newest requests release happens to be on the day it runs — a different version on your machine than on a teammate's, or than in CI, even though everyone ran the exact same install command against the exact same file.
You have `def divide(a, b): return a / b` and one passing test, `def test_divide(): assert divide(10, 2) == 5`. Add a test for dividing by zero. What do you expect pytest to report, and how would you change `divide` to make that new test pass instead of error?
Reveal solution
def test_divide_by_zero():
assert divide(10, 0) == 0 # or whatever behaviour you decide is correct
Running this against the current divide reports an error, not a failure — 10 / 0 raises ZeroDivisionError before the assert is even reached, and pytest shows it as test_divide_by_zero ERROR with the exception traceback. To make the new test pass, divide has to actually handle the zero case, e.g.:
def divide(a, b):
if b == 0:
return 0
return a / b
The test forced a real decision about behaviour that didn't exist before — that is the point of writing it.
A `.pre-commit-config.yaml` runs ruff and pytest as hooks. In what order should they run, and why does that order matter more as a codebase grows?
Reveal solution
ruff first, pytest last. ruff only reads the code — no imports, no execution — so it finishes in a fraction of a second even on a large codebase. pytest has to actually import and run your code, which is far slower and only gets slower as the test suite grows. Running the cheap, fast check first means a trivial mistake (an unused import, a formatting issue) gets caught and reported in milliseconds, before you pay the cost of a full test run that was never going to matter because the commit would have been blocked by ruff anyway.
Knowledge check
Knowledge check
Practice activity
Practice · 10–20 minutes
Create a tiny tested pricing module
Goal: Add type hints and two useful tests to a small AI-cost function.
- Copy the starter function.
- Add int and float type hints.
- Write one test for a normal token count.
- Write one test for zero tokens.
- Deliberately break the function and confirm a test fails.
Expected result: Two passing tests before the deliberate break, followed by at least one clear failing assertion.
Optional challenge: Reject a negative token count with ValueError and test it using pytest.raises.
Reveal solution
Starter code:
def estimate_cost(tokens, price_per_token):
return tokens * price_per_tokendef estimate_cost(tokens: int, price_per_token: float) -> float:
return tokens * price_per_token
def test_estimate_cost():
assert estimate_cost(1_000, 0.000_002) == 0.002
def test_zero_tokens_cost_nothing():
assert estimate_cost(0, 0.000_002) == 0.0Run uv run pytest. Then change * to + and confirm the tests turn red.
Key takeaways
- uv, ruff, mypy, pytest, and pre-commit each catch one specific, mechanical class of mistake — none of them make your code smarter, and together they close most of the gap between "works on my machine" and "works everywhere, provably."
- A lockfile (uv.lock) is what actually makes an install reproducible — pyproject.toml states an intentional range, uv.lock records and reproduces the exact versions that were resolved.
- ruff and mypy read code without running it (style/dead-code and type mismatches); pytest is the one tool here that actually executes your code and checks what it produces.
- pre-commit runs all of these automatically before a commit is created, in cheapest-first order, and blocks the commit entirely if any one fails — install it once per clone or it never runs.
- A fully green pipeline still only proves what you told it to check; it does not prove the code solves the right problem.
Flashcards
Flashcards
1 of 6Revision and interview questions
- Compare pyproject.toml and uv.lock.
- Why can ruff and mypy pass while a calculation is still wrong?
- What makes a test useful rather than merely green?
- Design a cheapest-first quality gate for a small AI service.
One-page sketchnote summary
Print me · one-page revision sheet
Modern Python tooling
Main idea
Reliable AI projects combine repeatable dependencies with fast automated checks. Each tool protects a different part of the engineering workflow.
Core terms
- uv — environment and dependency manager
- Lockfile — exact resolved versions
- ruff — linter and formatter
- mypy — static type checker
- pytest — behaviour test runner
- pre-commit — automatic local gate
One picture
Code pattern
uv sync
uv run ruff check .
uv run mypy src
uv run pytestWatch out
- Do not leave shared dependencies unpinned
- Do not silence type errors without a reason
- Do not trust a test you never saw fail
- Do not forget pre-commit install
Remember
- Reproduce the environment
- Run cheap checks first
- Test behaviour
- Automate the gate
- Green checks are evidence, not proof
Three quick questions
- Which tool owns each job?
- What does the lockfile record?
- Can your test fail correctly?
Lesson checklist
0 of 7 completeResources
- docsuv — Getting startedThe official docs for the tool this chapter leans on hardest — worth reading end to end once.
- docsRuff — DocumentationThe rule list is the actual reference for what ruff can catch — skim the "Rules" page once so the error codes stop looking like noise.
- docsmypy — DocumentationCovers the parts of type-checking this chapter didn't have room for, like generics and Protocols.
- docspytest — Get StartedThe shortest path from zero to a working test suite, from the project this chapter's live cells actually run.
- docspre-commit — DocumentationHow to write a .pre-commit-config.yaml for your own project once this chapter's example stops being hypothetical.