Files
skills/skills/clean-code/references/python-adaptation.md
T
mathias 5070a738e8
CI / Check / Test (push) Successful in 1s
CI / Tag release (push) Successful in 1s
docs(review): Python adaptation files for clean-code, solid and code-review
The canonical Python stack has been pinned in ~/dev/.context/AGENT.md since
local-dev v0.3.1, and three skills still had Go examples with no Python
counterpart. Following the structure #18 settled on, and the existing
solid/references/go-adaptation.md precedent, each gets a reference file rather
than inline dilution of the primary doc.

The stack table itself is NOT copied into any of them — each cites AGENT.md as
its home. This repo spent today learning what a second copy of a fact costs.

Content is the part a linter cannot give you:

- clean-code: `raise X from e` as the analogue of `%w` error wrapping, dataclass
  over `dict[str, Any]`, and the two traps where the broken version reads
  tidier — mutable default arguments, and `if not amount:` for a value where
  zero is legitimate.
- solid: `Protocol` over `ABC`, defined at the consumer, which is what makes DIP
  real in Python; plus what does NOT carry over from the Go rewrite (return
  structs/accept interfaces, embedding, package-as-deployment-unit).
- code-review: a Python anti-pattern table parallel to the Go one, opening with
  the rule that a finding ruff or mypy already catches is not a review comment,
  it is a question about why the gate is not running.

Refs #20
Bump-Type: minor
2026-08-29 14:10:10 +02:00

4.1 KiB

Clean Code: Python Adaptation

The principles in SKILL.md are language-agnostic. This file covers the places where Python's idioms differ from the Go examples, and where following the Go shape produces bad Python.

The canonical stack (ruff, mypy --strict, pytest, uv, pydantic-settings, src-layout) lives in ~/dev/.context/AGENT.md and is not restated here — one source of truth, and it is not this file.

Reveal intent with types, not comments

mypy --strict is the Python analogue of Go's compiler, and it is non-negotiable for the same reason: a type is a claim the machine checks, and a comment is a claim nobody checks. Two consequences for clean code specifically:

  • Any is a hole in the argument. Each one says "the reader is on their own here". If a value is genuinely polymorphic, say so precisely — Protocol, a union, a TypeVar — rather than surrendering.
  • A # type: ignore needs the error code and a reason: # type: ignore[arg-type] # boto3 stubs lag the API. A bare ignore is an unexplained exception to a rule the rest of the file follows.

Errors: chain them, never swallow them

Go's fmt.Errorf("operation: %w", err) is raise with from:

# Bad — the original cause is gone, and so is the stack that produced it
try:
    row = fetch(invoice_id)
except KeyError:
    raise InvoiceNotFound(invoice_id)

# Good — the chain survives; the traceback shows both
try:
    row = fetch(invoice_id)
except KeyError as e:
    raise InvoiceNotFound(invoice_id) from e

Two rules with no exceptions:

  • Never bare except:. It catches KeyboardInterrupt and SystemExit too, so it turns Ctrl-C into a swallowed error. except Exception if you truly mean everything.
  • Never a silent pass. An exception handler that does nothing is a decision to ignore a failure; if that is genuinely right, the handler needs a comment saying why, and usually a log line. Most of the time it is a bug being stored for later.

Data classes over dicts

A dict[str, Any] passed between functions is Go's map[string]interface{} — every reader has to reconstruct the shape from the code that writes it.

# Bad: shape known only by reading every producer
def total(invoice: dict[str, Any]) -> int: ...

# Good: shape declared once, checked everywhere
@dataclass(frozen=True, slots=True)
class Invoice:
    id: InvoiceId
    lines: tuple[Line, ...]
    currency: str  # ISO 4217

def total(invoice: Invoice) -> Money: ...

frozen=True where the value is conceptually immutable — it makes accidental mutation an error rather than a surprise at a distance. Use pydantic models at the boundary (env config, HTTP payloads) where validation is the point, and dataclasses inside, where validation has already happened.

Two Python-specific traps that read as clean code

Mutable default arguments are evaluated once, at definition:

def add_line(line: Line, lines: list[Line] = []) -> list[Line]:  # shared across calls

Use None and build inside. Ruff catches this (B006); the point of naming it here is that the broken version looks tidier than the correct one.

Truthiness is not presence. if not amount: is true for 0, 0.0, "", [] and None alike. For anything where zero is a legitimate value — money, counts, offsets — write if amount is None:. This is the single most common way a Python function silently mishandles a valid input.

Structure

  • src/ layout, pyproject.toml only. A flat layout lets tests import the working directory instead of the installed package, so the suite passes on a package that cannot actually be installed.
  • Module names are the unit of cohesion, as Go packages are. utils.py is the Python spelling of a god package: it has no reason to change because it has no subject.
  • Prefer functions to classes with one method. A class whose only job is to hold constructor arguments and expose run() is a closure with ceremony.

See also

  • references/clean-code.md — the language-agnostic practices
  • references/code-smells.md — the smell catalogue, all of which apply here
  • tdd/references/python.md — the test-side conventions