AI ENGINEER00-03 · Modern Python tooling
20/90
Sign in

Layer 00 · Foundations · Ch 03

Modern Python tooling

The five tools that close the gap between "works on my machine" and "works everywhere, provably, before you even commit."

36 min readdifficulty beginnerdepth build

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.

FIG 00-03.1 · THE GATE BEFORE A COMMITYOURCODEpre-commitall pass?commityesno → commit blocked, fix and retry
FIG 00-03.1— your code passes through ruff (cheapest, fastest), then mypy, then pytest (slowest, since it actually runs code). pre-commit runs all three; any failure blocks the commit and sends you back to fix it.

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:

FIG 00-03.2 · ONE TOOL REPLACES FIVEBEFORE · FIVE TOOLS, FIVE JOBSWITH UV · ONE TOOL, SAME FIVE JOBSuv
FIG 00-03.2— pip installed packages, venv isolated an environment, pip-tools locked exact versions, pyenv managed Python versions, and pipx ran one-off CLI tools. uv does all five itself, from one config file and one lockfile.
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

PythonTest a model-cost calculation
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.002

Important 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:

LAB 00-03.A · A REAL PYTEST RUN
IDLE⌘↵ to run

Next, a genuine mypy run against a real type mistake:

LAB 00-03.B · A REAL MYPY RUN
IDLE⌘↵ to run

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

warmup · 1

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.

core · 2

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.

stretch · 3

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

1Which file records the exact resolved dependency versions?
2What does ruff primarily do?
3Which tool actually executes your test functions?
4Why should a fast linter run before a slower test suite?

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.

  1. Copy the starter function.
  2. Add int and float type hints.
  3. Write one test for a normal token count.
  4. Write one test for zero tokens.
  5. 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_token
def 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.0

Run 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 6

Revision and interview questions

  1. Compare pyproject.toml and uv.lock.
  2. Why can ruff and mypy pass while a calculation is still wrong?
  3. What makes a test useful rather than merely green?
  4. 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

YOURCODEpre-commitall pass?commityesno → commit blocked, fix and retry

Code pattern

uv sync
uv run ruff check .
uv run mypy src
uv run pytest

Watch 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

  1. Which tool owns each job?
  2. What does the lockfile record?
  3. Can your test fail correctly?

Lesson checklist

0 of 7 complete

Resources

  • 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.
Next up
APIs & services
With a reproducible project and an automatic quality gate in place, next you build the thing that gate is actually protecting: a real service — REST, FastAPI, Pydantic, streaming, auth, and retries.