Part 6 · Professional Python · Module 42

Code Quality & Tooling

Professional teams don't rely on remembering the rules. They automate them, so every commit is formatted, linted, type-checked, tested, and logged the same way, whoever wrote it and however tired they were. This module builds that harness piece by piece, using the configuration of this very repository.

⏱ ~2.5 hoursPrerequisites: M16 Typing, M17 Testing, M41 Pixi

Why automate quality?

Humans are bad at consistency and good at judgement. Code review time is expensive, so it shouldn't be spent on "missing blank line" or "unused import". A quality harness is a set of tools that run automatically and refuse to let a change through until it meets the bar. This leaves reviewers free to ask the questions that need a person: is this the right design, is it correct, is it secure?

The harness runs at three points, from fastest to slowest:

flowchart LR
  E[Editor
ruff + mypy on save] --> P[pre-commit
on git commit] P --> L[pixi run ci
full local gate] L --> G[GitHub Actions
matrix CI on every PR] G --> M[Merge to main]

This repository's gate is one Pixi task, ordered so the quickest failures surface first:

fmt = "ruff format ."
lint = "ruff check ."
check = "mypy src/"
cov = "pytest tests/ --cov=src --cov-report=term-missing --cov-fail-under=90"
build = "python -m build --no-isolation"
pre-commit-all = "pre-commit run --all-files"
ci = { depends-on = ["pre-commit-all", "build", "check", "lint", "cov"] }

Remember: automate everything a machine can check, run the fast checks first, and save human review for design and correctness.

ruff: linting and formatting

A linter reads your code without running it and reports likely bugs and style problems. A formatter rewrites layout (spacing, line breaks, quotes) into one canonical style, which ends formatting debates for good. ruff does both. It's written in Rust and is fast enough to run on every keystroke, and it reimplements hundreds of rules from older tools (flake8, isort, pyupgrade, pydocstyle, Black).

Rules are grouped by a letter prefix. This repo selects seven groups:

CodeOriginCatchesExample
EpycodestylePEP 8 errorsE722 bare except:
FPyflakesReal bugs: undefined names, unused importsF401 unused import, F821 undefined name
IisortImport orderingI001 unsorted imports
UPpyupgradeOutdated syntax for your target PythonUP006 List[str] → list[str]
Bflake8-bugbearLikely bugs and design problemsB006 mutable default argument
RUFruff itselfRuff-specific checksRUF012 mutable class attribute
DpydocstyleDocstrings (Google convention here)D103 missing docstring
[tool.ruff]
line-length = 120
src = ["src", "tests"]
extend-exclude = ["work"]          # learners' scratch copies are not linted

[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "RUF", "D"]
ignore = ["E501"]                  # the formatter owns line length

[tool.ruff.lint.per-file-ignores]
"tests/**" = ["D100", "D103", "D104"]   # tests don't need module/function docstrings
"projects/**/tests/**" = ["D100", "D103", "D104"]

[tool.ruff.lint.pydocstyle]
convention = "google"

[tool.ruff.lint.isort]
known-first-party = ["practicum"]

Here's real ruff output on a small file with several common mistakes:

"""Inventory helpers."""
import os, sys
from typing import List, Optional


def add_item(item, bucket=[]):
    bucket.append(item)
    return bucket


def find(items: List[str], name: str) -> Optional[str]:
    """Find an item."""
    for i in range(len(items)):
        if items[i] == name:
            return items[i]
    try:
        return None
    except:
        pass
$ pixi run ruff check inventory.py --output-format concise
inventory.py:2:1: E401 [*] Multiple imports on one line
inventory.py:2:1: I001 [*] Import block is un-sorted or un-formatted
inventory.py:2:8: F401 [*] `os` imported but unused
inventory.py:2:12: F401 [*] `sys` imported but unused
inventory.py:3:1: UP035 `typing.List` is deprecated, use `list` instead
inventory.py:6:5: D103 Missing docstring in public function
inventory.py:6:27: B006 Do not use mutable data structures for argument defaults
inventory.py:11:17: UP006 [*] Use `list` instead of `List` for type annotation
inventory.py:11:42: UP045 [*] Use `X | None` for type annotations
inventory.py:18:5: E722 Do not use bare `except`
Found 10 errors.
[*] 6 fixable with `--fix` (1 unsafe fix available with `--fix --unsafe-fixes`).

$ pixi run ruff check --fix .     # apply the safe fixes ([*])
$ pixi run ruff format .          # reformat everything
$ pixi run ruff rule B006         # read the full explanation of a rule

B006 isn't about style. It's a real bug, and you can see it happen:

A linter is "just" a program that walks the abstract syntax tree (AST). This toy one, written with the standard ast module, detects B006 and F401. Try adding more violations:

Remember: ruff check finds problems, --fix repairs the safe ones, and ruff format ends style debates. Choose rule groups with select and scope any exceptions with per-file-ignores.

mypy in strict mode

Python ignores type hints at runtime (M16). mypy is a static type checker: it reads the hints and proves, before anything runs, that values flow consistently. Plain mypy is lenient, and it skips functions without annotations entirely. Strict mode turns on the flags that make the guarantee mean something:

[tool.mypy]
python_version = "3.12"   # the oldest supported Python, so newer-only APIs are flagged
strict = true
warn_return_any = true
warn_unused_configs = true
files = ["src"]
$ pixi run mypy --strict inventory.py
inventory.py:6: error: Function is missing a type annotation  [no-untyped-def]
inventory.py:11: error: Missing return statement  [return]
inventory.py:24: error: Incompatible return value type (got "float", expected "int")  [return-value]
Found 3 errors in 1 file (checked 1 source file)

The third error is a bug that no test may have caught yet. total() claims to return int but returns a float. The interpreter doesn't care at all, which is exactly why you want a checker:

Remember: hints are only as trustworthy as the checker that enforces them. Strict mypy in CI turns type hints into guarantees.

pre-commit hooks and the restage trap

Git can run scripts at certain moments. A pre-commit hook runs before a commit is recorded, and if it fails, the commit is refused. The pre-commit framework manages these hooks: it installs each tool at a pinned version in its own environment and runs it on the files you're committing.

repos:
  - repo: https://github.com/compilerla/conventional-pre-commit
    rev: v4.4.0
    hooks:
      - id: conventional-pre-commit
        stages: [commit-msg]          # checks the message, not the code
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.15.17
    hooks:
      - id: ruff
        args: [--fix]
      - id: ruff-format
  - repo: https://github.com/pre-commit/mirrors-mypy
    rev: v2.1.0
    hooks:
      - id: mypy
        files: ^src/
        additional_dependencies: [structlog]
pixi run bootstrap                 # pre-commit install (+ the commit-msg hook type)
pixi run pre-commit-all            # pre-commit run --all-files
pixi run pre-commit autoupdate     # bump every rev to the latest release (review the diff!)

The auto-fix restage trap. Git commits what's in the staging area (the index), not what's in your working files. When a hook fixes a file (ruff --fix removing an import, ruff-format re-wrapping a line), it writes the fix to your working copy and then fails with "files were modified by this hook". The staged version is still the unfixed one. If you just run git commit again without restaging, you're committing the same broken content and you'll get the same failure.

Working tree fixed by hook Staging area still unfixed Commit refused git add (the missing step) Fix → git add the changed files → commit again
Hooks fix your working files, but the commit takes its content from the staging area.

Remember: pre-commit runs pinned tools on staged files. When a hook auto-fixes something, git add the fixed files and commit again.

Structured logging with structlog

In beginner code, print() is how you see what's happening. In production code, never print. Here's why:

The stdlib logging module solves levels and destinations, but its records are still format strings, its configuration is global and fiddly, and attaching context needs adapters and filters. This course uses structlog. Every log call is an event plus key-value pairs, and it flows through a chain of processors (plain functions) that enrich it and finally render it as JSON or key=value text.

A processor has the signature processor(logger, method_name, event_dict) -> event_dict. The last one returns a string. Because the whole design is just functions passing dicts along, you can rebuild it in a few lines and watch it work. structlog itself isn't available in the browser, so this cell is a faithful miniature:

The real thing, as configured in this repository's src/practicum/log.py, renders key=value for humans and JSON for machines:

def configure_logging(*, json: bool = False) -> None:
    """Configure structlog for key=value console output or JSON output."""
    renderer: structlog.typing.Processor = (
        structlog.processors.JSONRenderer() if json else structlog.dev.ConsoleRenderer(colors=sys.stderr.isatty())
    )
    structlog.configure(
        processors=[
            structlog.processors.add_log_level,
            structlog.processors.TimeStamper(fmt="iso"),
            renderer,
        ],
        logger_factory=structlog.PrintLoggerFactory(file=sys.stderr),   # logs go to stderr, never stdout
        cache_logger_on_first_use=False,
    )

A fuller production setup adds context variables (so a request id set once appears on every line in that request), level filtering, and exceptions rendered as structured data:

{"user_id": 42, "order_id": 1001, "total": 59.9, "event": "order_placed", "request_id": "r-81", "level": "info", "timestamp": "2026-10-15T01:06:53.648248Z"}
{"user_id": 42, "event": "payment_failed", "request_id": "r-81", "level": "error", "timestamp": "...", "exception": [{"exc_type": "ZeroDivisionError", "exc_value": "division by zero", "frames": [...]}]}

Remember: production code logs events with key-value data through a processor chain, configured once in main(). Render JSON in production and readable text in development, and never use print().

Conventional Commits and git-cliff changelogs

Conventional Commits is a small grammar for commit messages that both humans and tools can read:

type(scope)!: short description in the imperative

optional body explaining what and why

BREAKING CHANGE: optional footer describing the incompatibility
Refs: #123
TypeMeaningVersion bump
featA new user-visible featureminor
fixA bug fixpatch
docs, test, refactor, perf, chore, ci, buildEverything elsenone (by default)
any type with ! or a BREAKING CHANGE: footerAn incompatible changemajor
feat(cli): add --json output to textstats
fix(grade): report a missing project id instead of crashing
docs: explain the restage trap in CONTRIBUTING
refactor(log)!: rename configure() to configure_logging()

This repo enforces the format with the conventional-pre-commit hook at the commit-msg stage, and generates CHANGELOG.md with git-cliff (pixi run changelog). Its configuration lives in pyproject.toml:

[tool.git-cliff.git]
conventional_commits = true
filter_unconventional = true
commit_parsers = [
    { message = "^feat", group = "Features" },
    { message = "^fix", group = "Bug Fixes" },
    { message = "^doc", group = "Documentation" },
    { message = "^perf", group = "Performance" },
    { message = "^refactor", group = "Refactoring" },
    { message = "^chore", skip = true },
]

The parsers are regular expressions tried in order: the first match decides the group. Here's the same logic in Python:

Remember: use the form type(scope): imperative description. With that grammar, tools can write the changelog for you and work out the next version number from the history.

Branches and pull-request hygiene

main should always be releasable. Work happens on short-lived branches whose name says what kind of change it is, and each change reaches main through a pull request (PR) that CI and a reviewer approve:

BranchUseCommit type
feature/add-json-outputNew behaviourfeat:
bugfix/csv-export-crashA bug found in normal developmentfix:
hotfix/payment-null-pointerAn urgent production fix, released immediatelyfix:
git switch main && git pull --ff-only
git switch -c feature/add-json-output
# ... edit, pixi run test, pixi run fmt, pixi run lint && pixi run check ...
pixi run ci                                  # must exit 0
git add -p                                   # stage deliberately, hunk by hunk
git commit -m "feat(cli): add --json output to textstats"
git push -u origin feature/add-json-output
gh pr create --fill
gh pr checks --watch                         # wait for every check to go green
gh pr merge --squash --delete-branch
git switch main && git pull --ff-only && git fetch --prune

Small and focused

One concern per PR. A 200-line PR gets a real review, while a 2,000-line PR gets a rubber stamp.

Describe the why

Say what changed, why, and how you verified it. Link the issue.

Green before review

Don't ask people to review code the machines have already rejected.

Never rewrite shared history

Don't amend or force-push commits others may have pulled. Add a new commit instead.

Remember: keep main releasable, use one branch and one PR per concern, and merge only on green CI.

GitHub Actions CI, act, and coverage gates

Continuous integration (CI) runs the harness on a clean machine for every push and PR, so "it passed on my laptop" is no longer the evidence. GitHub Actions reads YAML workflows from .github/workflows/. The official prefix-dev/setup-pixi action installs Pixi, can cache environments, and installs from your lockfile.

name: CI
on:
  push:
    branches: ["main"]
  pull_request:
    branches: ["main"]

jobs:
  unit:
    name: Unit tests (${{ matrix.environment }})
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false                 # let every version report, even if one fails
      matrix:
        environment: [py312, py313, py314]
    steps:
      - uses: actions/checkout@v4
      - uses: prefix-dev/setup-pixi@v0.8.1
        with:
          environments: ${{ matrix.environment }}
          locked: true                 # fail if pixi.lock is stale
          cache: true
      - run: pixi run -e ${{ matrix.environment }} test

  full:
    name: Full CI gate
    runs-on: ubuntu-latest
    needs: unit
    steps:
      - uses: actions/checkout@v4
      - uses: prefix-dev/setup-pixi@v0.8.1
        with:
          locked: true
          cache: true
      - run: pixi run ci

Running CI locally with act. act (installed here through the dev feature) reads the same workflow files and runs the jobs in Docker containers, so you can debug a workflow without push-and-pray:

pixi run act                        # act --container-architecture linux/amd64 (needed on Apple Silicon)
pixi run act -- -j unit             # one job
pixi run act -- pull_request -l     # list the jobs a pull_request event would run

Coverage gates. pytest-cov measures which lines your tests executed. --cov-fail-under=90 makes the run fail below 90%, so coverage can't slowly erode. Coverage tells you what's untested. It can't tell you what's well tested, because a test that runs a line without asserting anything still counts.

$ pixi run cov
Name                      Stmts   Miss  Cover   Missing
-------------------------------------------------------
src/practicum/cli.py         70      4    94%   88-91
src/practicum/site.py       150      9    94%   140-148
-------------------------------------------------------
TOTAL                       610     38    94%
Required test coverage of 90% reached. Total coverage: 93.77%

Remember: CI is the harness run on a clean machine, installed from the locked environment. Make each matrix entry a Pixi environment, debug workflows locally with act, and let the coverage gate stop gradual decline.

Code review checklists

Once the machines have checked formatting, lint, types, and coverage, a reviewer's attention goes to what only a person can judge. A checklist keeps reviews consistent:

Remember: machines check style, so people review correctness, tests, security, and design.

Practice

Write parse_commit(message). Look at the message's first line. If it matches type(scope)!: description (scope and ! optional, type is lowercase letters), return a dict with keys type, scope (or None), breaking (bool), and description. breaking is also True when any later line starts with BREAKING CHANGE:. Return None for unconventional messages.

Hint

A regex like ^([a-z]+)(?:\(([^)]+)\))?(!)?: (.+)$ captures all four parts. Use message.splitlines().

Show solution
import re

PATTERN = re.compile(r"^([a-z]+)(?:\(([^)]+)\))?(!)?: (.+)$")

def parse_commit(message: str) -> dict | None:
    lines = message.splitlines()
    if not lines:
        return None
    match = PATTERN.match(lines[0])
    if match is None:
        return None
    kind, scope, bang, description = match.groups()
    footer_break = any(line.startswith("BREAKING CHANGE:") for line in lines[1:])
    return {"type": kind, "scope": scope, "breaking": bool(bang) or footer_break, "description": description}

Write next_version(current, messages). current is "MAJOR.MINOR.PATCH" and messages is a list of commit messages since that release. Any breaking change bumps major. Otherwise any feat bumps minor. Otherwise any fix or perf bumps patch. Otherwise return current unchanged. Ignore unconventional messages. (Reuse your parse_commit; a copy is in the starter.)

Hint

Parse every message, drop the Nones, then check for the most significant bump first.

Show solution
import re

PATTERN = re.compile(r"^([a-z]+)(?:\(([^)]+)\))?(!)?: (.+)$")

def parse_commit(message):
    lines = message.splitlines()
    match = PATTERN.match(lines[0]) if lines else None
    if match is None:
        return None
    kind, scope, bang, desc = match.groups()
    brk = bool(bang) or any(l.startswith("BREAKING CHANGE:") for l in lines[1:])
    return {"type": kind, "scope": scope, "breaking": brk, "description": desc}

def next_version(current: str, messages: list[str]) -> str:
    major, minor, patch = (int(p) for p in current.split("."))
    commits = [c for c in map(parse_commit, messages) if c is not None]
    if any(c["breaking"] for c in commits):
        return f"{major + 1}.0.0"
    if any(c["type"] == "feat" for c in commits):
        return f"{major}.{minor + 1}.0"
    if any(c["type"] in {"fix", "perf"} for c in commits):
        return f"{major}.{minor}.{patch + 1}"
    return current

Write render_changelog(version, messages) that returns Markdown like git-cliff with this repo's parsers. The first line is ## {version}. Then, for each group that has commits, in the order Features, Bug Fixes, Documentation, Performance, Refactoring: a blank line, ### {group}, then one - {Description} line per commit in input order, with the description's first letter upper-cased. Use the first-line description only. feat → Features, fix → Bug Fixes, docs → Documentation, perf → Performance, refactor → Refactoring. Skip every other type and every unconventional message. Lines are joined with "\n", with no trailing newline.

Hint

Collect into a dict of lists keyed by group name, then iterate over GROUPS.values() to get the fixed order.

Show solution
import re

PATTERN = re.compile(r"^([a-z]+)(?:\(([^)]+)\))?(!)?: (.+)$")
GROUPS = {"feat": "Features", "fix": "Bug Fixes", "docs": "Documentation",
          "perf": "Performance", "refactor": "Refactoring"}

def render_changelog(version: str, messages: list[str]) -> str:
    collected: dict[str, list[str]] = {group: [] for group in GROUPS.values()}
    for message in messages:
        first = message.splitlines()[0] if message else ""
        match = PATTERN.match(first)
        if match is None or match.group(1) not in GROUPS:
            continue
        desc = match.group(4)
        collected[GROUPS[match.group(1)]].append(desc[0].upper() + desc[1:])
    lines = [f"## {version}"]
    for group, items in collected.items():
        if items:
            lines += ["", f"### {group}"] + [f"- {item}" for item in items]
    return "\n".join(lines)

Write the processor render_kv(logger, method_name, event_dict), matching structlog's KeyValueRenderer(key_order=["event"], sort_keys=True). Output event first (if present), then every other key in sorted order. Each pair is key=repr(value), separated by single spaces. Don't modify the input dict.

Example: {"user_id": 42, "event": "user login", "level": "info"} renders as event='user login' level='info' user_id=42.

Hint

Build a list of keys: ["event"] if present, then sorted(k for k in event_dict if k != "event").

Show solution
def render_kv(logger, method_name, event_dict):
    keys = ["event"] if "event" in event_dict else []
    keys += sorted(k for k in event_dict if k != "event")
    return " ".join(f"{k}={event_dict[k]!r}" for k in keys)

This function reports progress with print:

def import_rows(rows):
    ok = 0
    for i, row in enumerate(rows):
        if "id" not in row:
            print(f"Skipping row {i}: no id")
            continue
        ok += 1
    print(f"Imported {ok} of {len(rows)} rows")
    return ok

Rewrite it as import_rows(rows, log), where log is a structlog-style logger. For each bad row call log.warning("row_skipped", index=i, reason="missing_id"), and at the end call log.info("import_finished", imported=ok, total=len(rows)). Return ok. It must print nothing.

Hint

Replace each print with a log call. The variables you were formatting into the string become keyword arguments.

Show solution
def import_rows(rows, log):
    ok = 0
    for i, row in enumerate(rows):
        if "id" not in row:
            log.warning("row_skipped", index=i, reason="missing_id")
            continue
        ok += 1
    log.info("import_finished", imported=ok, total=len(rows))
    return ok

Mini-project: add a full quality harness

Mini-projectHarden an earlier project

Pick a project you built earlier. The textstats CLI from M41 works well, as does your P01 budget ledger. Give it the same harness this repository uses. The goal is that a stranger can clone it, run pixi run bootstrap and pixi run ci, and get a green result or a clear error.

Steps

  1. Create a branch: git switch -c feature/quality-harness.
  2. pixi add --feature dev ruff mypy pytest pytest-cov pre-commit git-cliff structlog (structlog as a runtime dependency: pixi add structlog).
  3. Add [tool.ruff] (line length 120, select E, F, I, UP, B, RUF, D, Google docstrings, per-file-ignores for tests) and [tool.mypy] strict = true to pyproject.toml. Fix every finding by hand. Don't silence them.
  4. Replace every print in library code with structlog events. Add a configure_logging(json: bool) called from main(), and a --json flag.
  5. Write .pre-commit-config.yaml with conventional-pre-commit, ruff, ruff-format, and mypy, and add a bootstrap task.
  6. Add tasks fmt, lint, check, test, cov (--cov-fail-under=90), changelog, and ci, which uses depends-on, fast checks first.
  7. Add .github/workflows/ci.yml with setup-pixi and a py312/py313/py314 environment matrix. Run it locally with pixi run act.
  8. Commit with Conventional Commits, open a PR, and generate CHANGELOG.md.
  • pixi run lint and pixi run check pass with zero ignores added just to pass
  • grep -rn "print(" src/ finds nothing outside main()'s user-facing output
  • Logs go to stderr. --json emits one JSON object per line with level and timestamp
  • A commit message like "updated stuff" is rejected by the commit-msg hook
  • You triggered the restage trap once on purpose and recovered by restaging
  • pixi run cov enforces 90% and passes
  • The CI matrix really tests three Python versions (check the job logs for each version)
  • CHANGELOG.md is generated by pixi run changelog

Stretch goals

  • Add a structlog processor that redacts keys named password, token, or secret, with a test.
  • Add branch = true to [tool.coverage.run] and see how the number changes.
  • Enable another ruff group (SIM, PTH, or S for security) and justify each ignore.
  • Add a release job that computes the next version with git cliff --bumped-version.

Quiz

Recap

Going further