Standards for writing and editing Python, so every file in a project reads consistently regardless of which session wrote it. Every piece of runnable Python — a demo script, a one-off, an example saved to a file, or code left in the chat to copy-paste and run — is real code the rules below apply to. The one exemption is a snippet written purely to illustrate a concept in prose, with no runnable entry point (e.g. what a decorator looks like); if you can't tell whether something has a runnable entry point, assume it does.
Core Rules
- [NEVER VIOLATE] Functions the user is going to run get complete type hints. Every parameter and the return type. When editing an existing file, this applies to the function you're adding or touching; the rest of the file keeps its signatures as they are unless asked.
-
[NEVER VIOLATE] No global variables. Use instance attributes, closures, or dependency injection instead. The smell this rule targets is a module-level or class-level name that gets mutated after it's defined — whether one function touches it (the
globalkeyword is the textbook case) or several. A name never reassigned or mutated, scalar or container (MAX_RETRIES = 3,DEFAULT_HEADERS = {...}), is a constant and fine, and so is a module-levellogger = logging.getLogger(__name__). -
Use
logging, neverprint(), for anything diagnostic.print()is acceptable only for a CLI tool's actual user-facing output — the thing the program exists to print, not a debug trace left behind. A standalone script callslogging.basicConfig(level=logging.INFO, format=...)once at its entry point (if __name__ == "__main__":); without an explicitlevel=, the root logger defaults to WARNING andlogger.infosilently produces no output. A module meant to be imported leavesbasicConfigto its importer. -
Wrap in try/except, and log the failure, any I/O that can fail for reasons your own code doesn't control: HTTP calls, DB connections, subprocess calls, and file reads/writes. This doesn't cover writing to stdout/stderr (Rule 3's
print()case) — that doesn't fail for external reasons. Log enough to diagnose without a repro — what was called, and the actual exception, not just "failed". -
Comment the WHY, not the WHAT. Only add a comment when it explains something the code itself can't — a hidden constraint, a non-obvious trade-off, a workaround for a specific bug.
-
Tooling:
.venv+requirements.txt, PEP 8, Black. Pin every dependency inrequirements.txtwith PEP 440's compatible-release operator (~=major.minor, e.g.numpy~=2.4) — locks the major version, lets minor/patch float to latest automatically on every install. Not==exact-version(blocks every patch/security update until a human manually bumps it) and not a bare>=(no ceiling — a future major-version bump gets pulled in silently, defeating the point of pinning).^=isn't valid pip/PEP 440 syntax — that's npm/Cargo/Poetry, and pip rejects it as an invalid requirement. A calendar-versioned package (e.g.pytz, whose releases look like2025.2) follows the same rule mechanically — pin~=2025.2and a same-year bump (2025.3) is picked up automatically, but a new year (2026.1) is not and needs a manual pin update.⚠️ A pin cannot help when the platform has no wheel at all: pip says nothing and falls back to building from source, which on a slow machine looks like a hang lasting tens of minutes. Hit twice with compiled packages (
opencv-python,onnxruntime): their newer releases ship wheels only for macOS 13+, installed on an Intel Mac stuck on macOS 12; and the same packages in an Alpine (musl) container, where nomusllinuxwheel exists. Make the install fail fast instead:.venv/bin/python -m pip install --only-binary :all: -r requirements.txtNo matching distribution found for <package>names the one with no wheel. Fix it per environment, keeping the sharedrequirements.txtuntouched: on the old machine, first install that package with a<upper bound at the last release that still ships a wheel for it, then install the rest; in a container, switch to a glibc base image (Debian) rather than pinning. -
Encoding: UTF-8, declared only for a non-UTF-8 file. Save
.pyfiles as UTF-8, with no encoding line in the header: Python 3 already decodes source as UTF-8 (PEP 3120), so a# -*- coding: utf-8 -*-line changes nothing (Ruff flags it asUP009). A PEP 263 declaration earns its place only in a file that must be saved in another encoding, e.g.# -*- coding: latin-1 -*-, on line 1 or 2. An existing file that already carries the UTF-8 line keeps it: removing it is diff noise, not a fix.
Testing
- Every piece of logic you write or change — a new script, a new feature, or a bug fix — needs a test that exercises its business logic — the behaviour a caller depends on, not the fact that the function ran. [NEVER VIOLATE] Never hardcode a return value, or assert a tautology, just to make a test pass — this applies to a probe just as much as a formal test.
- Test infra (e.g.
pytest) already exists → write the test first, then the implementation. - No test infra, but the code lives in an existing project (a repo, a
package — somewhere with a future) → set up
pytestrather than inventing a bespoke runner. - No test infra, and the code really is a standalone one-off with no project to land in → at minimum, write a runnable probe: a short standalone snippet that feeds the new code a known input and checks the actual output against what you expect.
- Project already has its own hand-rolled test/probe setup → write the
test in it. If switching to
pytestlooks worth the churn, suggest it to the user. - A test that passes doesn't prove it catches anything — verify it by
breaking the code it's supposed to guard and confirming the test goes
red, then restore the fix. A common way to write an accidental tautology:
pytest'scaplogfixture attaches to the root logger, so it silently captures nothing against a logger configured withpropagate=False— a logging assertion built oncaplogthere stays green even after the logged level regresses. Assert against a handler attached directly to that logger instead. - An isolated test needs every side-effecting boundary mocked or redirected at once — a real database, files it writes (including incidental ones, like an audit-log dump), and any logger the code under test configures. Missing just one still lets the test corrupt production data or files on a real run.
- Passing tests (and a clean
ast.parse) don't prove a script actually runs — aNameErrorfrom a leftover stale reference, for instance, shows up only when the entry point is actually executed. After editing a module another script imports, run that script for real at least once.
Design Docs
- Design docs, if the project keeps them, go wherever that project's existing
documentation convention already puts them. Don't invent a new location
(e.g. an ad hoc
_doc/folder) alongside a project that already has one.