Python code
Precedence — the rule closer to the code wins. These are plugin-level defaults. Where a project states its own convention that conflicts with anything here — in its
CLAUDE.md, its ownrules/, a linter/formatter config it enforces, or a consistent established style in the surrounding code — the project's convention wins. Follow it, don't "correct" the codebase toward this file. Apply these rules only where the project is silent. Project convention looks like an oversight rather than a decision → say so once, still follow the project.
Docstring Style¶
-
Google style (Napoleon) — no exceptions unless user explicitly requests otherwise
-
Never switch NumPy style based on project type, existing code, or own judgement
-
Every public function/class/module needs docstring; at least one
Examplessection per public function -
Omit only when user explicitly says skip examples (e.g., "no examples needed", "skip the Examples section")
-
Brevity request or "minimal" docstring does NOT qualify
-
Docstrings only where Python binds them: module (first statement), function, class, method. A triple-quoted string trailing a module- or class-level variable is not a docstring — Python attaches it to nothing,
__doc__stays unset. Use a#:comment directly above the assignment instead (Sphinxautodocreads it as the attribute doc):
#: Per-directory memo of the (width, extension) pair that last matched.
_FRAME_LAYOUT_HINTS: dict[Path, tuple[int, str]] = {}
- Opening line states purpose in plain English — move formulas, assignments, config literals, call notation, and other code-shaped detail into the description or a section below. First line says what the object is for, not how it is computed.
Deprecation¶
Use pyDeprecate, never warnings.warn. Import from deprecate (not pyDeprecate). Not installed → add it, don't fall back.
| Target | API |
|---|---|
| function / method | @deprecated(target=new_fn, deprecated_in="X.Y", remove_in="Z.W") |
| class (incl. Enum, dataclass) | @deprecated_class(target=NewClass, ...) — v0.6.0+ |
| instance | deprecated_instance(new_obj, ...) — v0.6.0+ |
- Never
@deprecatedon a class — it emitsUserWarningand silently delegates;deprecated_classis the correct API (transparent proxy: attribute access, calls,isinstance(), instantiation all forward withFutureWarning). - Lifecycle: deprecate in minor → keep ≥1 minor cycle → remove in next major.
- Check the installed version before writing the code (
deprecate.__version__); the API differs across versions, training memory isn't evidence (§Library API Awareness). Below v0.6.0 with upgrading blocked,deprecated_class/deprecated_instancedon't exist — ask before upgrading, never upgrade silently.
Python Version Policy¶
- Python 3.10 reaches EOL Oct 2026 — minimum for new projects is 3.11 (Python 3.11 reaches EOL Oct 2027; check endoflife.date/python for current schedule)
- Before writing any Python code: read
pyproject.toml(orsetup.cfg/setup.py) to findrequires-python; use only syntax/APIs available in that minimum version - Version-gated features — read pyproject.toml first if any of these requested:
matchstatement (3.10+)TypeAlias(3.10+)typing.ParamSpec(3.10+)tomllib(3.11+) — usetomlibackport if requires-python < 3.11ExceptionGroup(3.11+)Selftype (3.11+)- Use
target-version = "py311"in ruff/mypy configs for new projects
Library API Awareness¶
Applies to every code-touching agent, not foundry:sw-engineer alone. Training data has fixed cutoff — training memory of what a package exposes is never evidence, regardless of version. Silent-drift APIs (renamed params, moved symbols, changed defaults) look plausible and pass a glance.
Before using any third-party library feature — always, not only when version looks new:
- Check installed version:
python -c "import <pkg>; print(<pkg>.__version__)"orpip show <pkg> - Fetch the actual top-level public API —
python -c "import <pkg>; print([n for n in dir(<pkg>) if not n.startswith('_')])", thenhelp(<pkg>.<thing>)/ read source / read versioned docs for the specific symbols used. Do this even when confident from memory — confirm the symbol, its signature, and its defaults against the installed package before writing the call - Use API matching the installed version — don't assume training knowledge current
- Never fabricate a symbol/kwarg from memory; unverified against installed pkg = don't write it
Never suggest upgrading library solely because Claude doesn't recognise newer API. Project has version pinned for a reason — learn that version's API from docs, don't force updates on stable/stale projects.
PyTorch AMP¶
torch.cuda.amp.autocastdeprecated since PyTorch 2.4 — usetorch.amp.autocast('cuda', ...)insteadtorch.cuda.amp.GradScalerdeprecated since PyTorch 2.4 — usetorch.amp.GradScaler('cuda')instead- Verify current stable release at pytorch.org when citing specific version numbers
Multi-OS Executables — a POSIX Assumption is a Defect¶
Applies when the project supports more than one OS — declared by a CI OS matrix, packaging classifiers, or project docs; a single-OS project may skip it. There, scripts, hooks, bin/ entry points, and CI steps run on every supported OS (typically Linux, macOS, and native Windows). Fix a portability break at its source; never skip a supported platform. (Test-side rules in python-testing.md §Cross-OS Tests.)
pathlibthroughout:Path(p).is_absolute(), neverstartswith("/");PurePath(p).as_posix()before hashing, serializing, or comparing — native separators change the digest. POSIX-absolute literals are unportable fixtures:/host/xresolves toD:\host\xon Windows.- Serialized telemetry or provenance paths are cross-host coordinates, not local paths. Preserve the exact string; recognize declared POSIX and Windows absolute forms with
PurePosixPathandPureWindowsPath; never convert through hostPathbefore an exact comparison. Regressions exercise both forms on every host. - Byte-asserted or hashed writes use
newline="\n"or bytes — text mode emits CRLF on Windows. - Sanitized subprocess
env=keepsSystemRoot,SYSTEMROOT,COMSPEC,PATHEXT,TEMP, andTMPon win32, or the child Python aborts before running with_Py_HashRandomization_Init: failed to get random numbers. Temp dirs viaos.environ.get("TMPDIR") or tempfile.gettempdir(), never a hardcoded/tmp; a path shared with shell${TMPDIR:-/tmp}falls back to/tmpon POSIX. - A CI
run:step invoking a.shfile needs an explicitshell: bash— the Windows default shell dot-sources it and exits zero, running nothing (false green). - Symlinks, file modes, and uid checks are capabilities: degrade in production code first.
- Green macOS is absence of regression, not Windows support. Prove Windows semantics with
PureWindowsPathorntpath; monkeypatchingos.namedoes not changepathlib.
Security¶
pickle.load/torch.loadon external data requireweights_only=True
Code Quality Rules¶
- Type annotations on all public interfaces
- No mutable default arguments
- No broad
except:without re-raising or logging - No
import *— always explicit imports - No global mutable state — use dependency injection
__all__in__init__.pyto define public API surface- Never
__all__in private module (_foo.py) — filename already marks whole module internal;__all__there fakes a public surface on something that has none - Prefer composition over deep inheritance
Structured Data — never a bare dict¶
A dict with known, fixed keys is the same failure as a bare string with fixed values: d["retires"] raises only at runtime, d.get("retires") silently returns None, no tool flags a renamed key. Pick by what the data must do:
| Need | Use |
|---|---|
| Behaviour + validation, mutable, methods | @dataclass (slots=True, frozen=True when immutable) |
Existing dict-shaped payload (JSON, API, **kwargs) — annotate without changing runtime type |
TypedDict |
| Immutable, tuple-unpacked, hashable | NamedTuple |
| Field coercion/validation from untrusted input | pydantic.BaseModel — only where the dep already exists |
dataclassis the default — reach forTypedDictonly when the value must stay a realdictat runtime (already-parsed JSON, kwargs blob you don't own).frozen=Truefor anything used as a key, shared across threads, or passed to code you don't control.slots=Trueon dataclasses instantiated in loops or held in large collections.- Boundary mirrors §Closed Option Sets — parse into the type once at the edge (
Config(**raw)), pass the typed object inward; serialize back to plain dict only on the way out (dataclasses.asdict). - 3+ positional args of the same type ⇒ the call site is unreadable and mis-orderable: make it a dataclass or force keyword-only (
*). - Plain
dictstays right for dynamic keys, external JSON, and simple mappings — the typed record is for reused, fixed-shape internal records, where it clarifies the contract and cuts field-name mistakes. - A shared types module must reduce real complexity, not just relocate definitions.
- Preserve runtime validation, behaviour, and serialized schemas when introducing a record type — annotations alone do not enforce types.
Closed Option Sets — never bare strings¶
Fixed, mutually exclusive options (severity, mode, status, kind, action, direction) = named type, declared once. Bare str re-states the set at every comparison: typo "WANR" evaluates false instead of raising; renamed member leaves stale literals unflagged.
Signals (any one ⇒ closed set): docstring says One of "a", "b" · argparse choices=(...) whose value is branched on internally · same literals compared in 2+ places · dataclass field str with enumerable legal values.
| Case | Use |
|---|---|
| Branched on, carries behaviour, crosses module boundary | class X(str, Enum) — X("bad") raises |
| Local single-use annotation only | X = Literal["a", "b"] — type-check only |
(str, Enum)notStrEnumwhilerequires-python < 3.11— mixin keepsSeverity.FAIL == "FAIL"true, so serialization and migration-era string comparisons still work. Checkpyproject.toml(§Python Version Policy) before assuming.- Enum inside, string at boundary — CLI/JSON/file formats stay plain; convert once at edge (
Severity(raw)), pass enum inward. ==neveris—isfails silently when value arrived as plain string from a boundary..valuein f-strings — baref"{Severity.WARN}"rendersSeverity.WARNon non-mixin Enum.- Derive
argparse choices=from the enum ([s.value for s in Severity]) — hand-repeated literals drift.
Complexity Thresholds¶
Applies to every Python function or class written or reviewed by any agent — check before delivering. Enforce via ruff C901 + PLR rules (see foundry:linting-expert for config). Hard limits per function:
| Metric | Limit | ruff rule | Refactor signal |
|---|---|---|---|
| Cyclomatic complexity (McCabe) | ≤12 | C901 |
extract sub-functions, guard clauses |
| Required arguments (no default) | ≤7 | style rule | primary rule — enforced in review; more than 7 required = introduce config dataclass |
| All arguments (incl. kwargs w/ defaults) | ≤12 | PLR0913 |
blunt ruff gate — kwargs with defaults may exceed 7 freely; ≤12 catches extreme cases |
Branches (if/elif/match) |
≤12 | PLR0912 |
dispatch table, strategy pattern |
| Statements | ≤50 | PLR0915 |
split responsibility |
| Return points | ≤6 | PLR0911 |
consolidate early-return paths |
When a function exceeds any limit: refactor first. Adding # noqa: PLR... / # noqa: C901 allowed only when refactoring genuinely impossible (generated code, parser output, protocol-mandated signature) — always add inline comment explaining why. Verify: ruff check --select C901,PLR (through the pinned hook where the project pins ruff — §Lint/Format). Per-limit rationale: _full/quality-gates.md §Python Code Complexity.
Lint/Format — Pinned Hooks, Not Bare Tools¶
Project pins lint/format tools via .pre-commit-config.yaml (ruff, eslint, mdformat, prettier, codespell, …) → never invoke those tools directly (ruff check, ruff format, eslint, mdformat, …) — version/config drift vs CI. Invoke the specific hook: pre-commit run <hook-id> --files <path> (single hook, targeted files) · pre-commit run --all-files (full sweep) · pre-commit run <hook-id> --all-files (single hook, repo-wide). Hook ids come from that config.
- Applies to ad-hoc checks during edits — not just the commit-time run
- Hook missing/needed and not yet in config → add it to
.pre-commit-config.yamlrather than shelling out around it - No pre-commit pin in the project → direct tool use is fine