Cards specific to python. Use them with the level page that matches your input. Report each check as PASS, FAIL, or N/A with file:line evidence. A FAIL carries the card's severity. Any blockFAIL means request changes. A should-fix or discussFAIL goes in the review but does not block.
FAIL [block] chg-expand-contract - src/a.py:42 - renames the column and drops the old one in one migration
FAIL [should-fix] chg-one-concern - src/b.py:7 - a logging rewrite rides along with the bug fix
PASS chg-breaking-change-taxonomy - src/c.py:15 - no signature or return shape changes
N/A chg-perf-claim-evidence - the description makes no performance claim
What the badges mean
blockmust be fixed before merge or release.
should-fixfix it, but it need not block.
discussraise it and decide together.
stablevetted; use for verdicts.
draftnot vetted; do not use for a verdict.
Tool gate: what a linter or CI check could enforce instead of a reviewer.
Draft cards
No stable cards yet.
23 draft cards
A draft card is not vetted. Read it for ideas, but do not use it for a verdict.
py-await-check-then-act
blockdraftcorrectness
Applies when: An async def reads state, awaits, then writes based on that read.
Rule. When more than one worker or process can run, make check-then-act an atomic DB statement or constraint, because a Python lock covers one process only.
Why. Core rule: fn-no-check-then-act-across-await. An asyncio.Lock does serialise tasks inside one event loop, but a second worker or host shares no lock, so the guard has to live where the state lives.
FAIL
async with lock: # fine for one process, useless with two workers
bal = await repo.get_balance(acct)
if bal >= amt:
await repo.set_balance(acct, bal - amt)
Counterpoint. "asyncio is single-threaded, so a lock or no lock is enough." Within one process, an asyncio.Lock is correct, and without it every await is a point where another task runs. The rule applies once deployment can run more than one worker, which is the normal case.
py-cancellation
blockdraftreliabilitycorrectness
Applies when: Async code catches exceptions around awaits or uses asyncio.timeout.
Rule. Re-raise CancelledError (do not catch it via bare except or BaseException), and catch TimeoutError outside the asyncio.timeout() block.
Why. TaskGroup and timeouts are built on cancellation, so swallowing it makes shutdown hang and timeouts silently not fire. The conversion to TimeoutError happens at context exit, so a handler inside the block never sees it.
FAIL
async with asyncio.timeout(5):
try: await fetch()
except TimeoutError: ... # never reached
except BaseException: pass # eats cancellation
PASS
try:
async with asyncio.timeout(5):
await fetch()
except TimeoutError:
handle_timeout()
Counterpoint. "Cleanup code must run even on cancel." Clean up in finally, then re-raise, and never suppress. uncancel() is task bookkeeping, not permission to swallow.
Tool gate: Ruff BLE001 / S110 for broad swallow; lint for CancelledError needs custom rule.
Applies when: A Python class has no state, only static methods, or a Protocol with one implementation.
Rule. Give a class real state or a collaborator; otherwise write a module function.
Why. A static-only class is a module with extra typing. A Protocol with one implementation is a speculative interface. A Protocol needs two or more real implementations swapped at runtime; see mod-yagni.
FAIL
class DateUtils:
@staticmethod
def parse(s): ...
class Notifier(Protocol): ... # one impl, ever
PASS
dates.py: def parse(s): ...
# use the concrete class until a second real impl exists
Counterpoint. "Protocols make testing easy." A fake of a concrete class in tests works with duck typing too. The Go review guide takes the same stance: declare interfaces where consumed, and avoid speculative ones.
Applies when: A Python function or class has, or lacks, a docstring.
Rule. Write a Google-style docstring only when it adds what the signature cannot say.
Why. Forced docstrings restate the signature and rot. The ones that matter state units, side effects, and what callers may rely on. The gate never forces presence; it checks format only.
FAIL
def add(a, b):
"""Add a and b. :param a: first number"""
PASS
def charge(order):
"""Charge the card once; safe to retry with the same order id (idempotent)."""
Counterpoint. "Every public function needs a docstring." Ousterhout would sympathise (missing docs cost more than wrong ones), and Martin would not. The compromise here is to require content that adds information, not presence.
Tool gate: ruff D (convention=google, D1xx ignored)
Applies when: A Python package raises its own exceptions.
Rule. Give each package one base exception, with the specific ones in an errors module.
Why. A base class gives callers one safe catch for the package. An inline class X(Exception) next to a raise scatters the hierarchy. Chain with raise ... from err (fn-propagate-dont-swallow).
FAIL
raise Exception("declined")
PASS
# billing/errors.py
class BillingError(Exception): ...
class PaymentDeclined(BillingError): ...
Counterpoint. "Built-in exceptions are enough." For ValueError-like cases, yes. For domain failures, callers need names to catch and a base to catch them all.
py-field-only-when-adds
discussdraftcorrectness
Applies when: A pydantic model or settings class uses Field().
Rule. Use Field() only when it adds something (a constraint, an alias, or API docs), and set plain defaults directly.
Why.Field(default=5.0) is noise that hides which fields are constrained.
FAIL
request_timeout_s: float = Field(default=5.0)
PASS
request_timeout_s: float = 5.0
max_batch_size: int = Field(default=500, gt=0, le=2_000)
Counterpoint. "Uniform Field everywhere is consistent." Consistency that hides the constraints is not worth the scan cost. Every Field should tell the reader something.
py-frozen-dataclass-over-tuple
should-fixdraftcorrectness
Applies when: A Python function returns a tuple whose positions differ in meaning.
Rule. Return a @dataclass(frozen=True, slots=True) instead of a positional tuple.
Why. Core rule: fn-no-anonymous-tuples. In Python, the frozen dataclass also gives hashing, immutability, and a compact slots layout.
FAIL
def parse_error(exc) -> tuple[str, str]: ...
PASS
@dataclass(frozen=True, slots=True)
class ParsedError:
code: str
message: str
Counterpoint. "NamedTuple is lighter." Fine; both name the fields. Use the one your team already uses, but never a bare tuple.
py-httpx-client-lifespan
should-fixdraftperformance
Applies when: A Python service creates an httpx client.
Rule. Pass an explicit httpx.Timeout to every httpx client.
async def fetch(url):
async with httpx.AsyncClient(timeout=None) as c:
return await c.get(url)
PASS
@asynccontextmanager
async def lifespan(app):
async with httpx.AsyncClient(timeout=httpx.Timeout(10.0, connect=2.0)) as c:
app.state.http = c
yield
Counterpoint. "httpx defaults are fine." They are (5s), but set the value explicitly so that a library change cannot silently change behaviour.
py-imports
should-fixdraftmaintainability
Applies when: A Python module has imports.
Rule. Put imports at module top, absolute only.
Why. Local imports hide dependencies and cycles. Relative imports break when files move. Importing a private name couples you to something its owner may change without notice. Never import a _-prefixed name from another module, and use __all__ only in a re-export __init__.py.
FAIL
def handler():
from .helpers import _parse
PASS
from app.billing.parsing import parse
Counterpoint. "Import inside the function avoids a cycle." It hides the cycle instead of fixing it, and the design needs a third module or a dependency inversion. A lazy import for a heavy optional dependency is the one defensible exception; document it with a reason.
Tool gate: ruff I, TID252, PLC0415
py-init-assign-only
should-fixdraftmaintainability
Applies when: A Python class defines __init__ or __post_init__.
Rule. Keep __init__ and __post_init__ to assignments, and do I/O in a @classmethod factory.
Why. Core rule: mod-no-logic-in-constructors. A dataclass's __post_init__ is a constructor too, so the same rule applies there.
FAIL
@dataclass
class PriceList:
path: Path
def __post_init__(self):
self.prices = json.loads(self.path.read_text())
PASS
@dataclass
class PriceList:
prices: dict[str, Decimal]
@classmethod
def from_file(cls, path: Path) -> "PriceList":
raw = json.loads(path.read_text())
return cls({sku: Decimal(p) for sku, p in raw.items()})
Counterpoint. "Dataclasses make __post_init__ normal." It is normal for derived fields and cheap validation, but not for reading files or calling APIs.
py-init-py-empty
should-fixdraftmaintainability
Applies when: The change adds or edits an __init__.py.
Rule. Keep __init__.py empty unless it re-exports the public API.
Why. Logic in __init__.py runs at import (mod-no-import-side-effects) and hides where things live. A re-export file may use __all__. An empty one has no comment or docstring.
# __init__.py (empty)
# or, at a library boundary:
from mylib.client import Client
__all__ = ["Client"]
Counterpoint. "A package docstring helps." Put it in the README or docs. An empty __init__.py says clearly that nothing is hidden here.
py-modern-typing
should-fixdraftcorrectness
Applies when: Annotations use typing aliases, or the type gate is missing.
Rule. Write X | None and builtin generics, not typing aliases.
Why. A tool decides these, so a human comment is waste. Check that the gate exists, and gate new code with mypy --strict and pyright.
FAIL
from typing import Optional, List
def f(x: Optional[List[int]]): ...
PASS
def f(x: list[int] | None) -> None: ...
Counterpoint. "Two type checkers is overkill." They disagree on edge cases, so running both on new code catches different mistakes. Pick at least one as a hard gate.
Tool gate: ruff UP; mypy --strict; pyright
py-no-cast
should-fixdraftcorrectness
Applies when: Code calls typing.cast(...).
Rule. Do not use cast(); parse the boundary into a typed model, or narrow with isinstance.
Why.cast is a promise to the type checker that nothing verifies at runtime. When the promise is wrong, the type checker's output is a lie that spreads.
FAIL
data = cast(dict[str, int], json.loads(raw))
PASS
class Payload(BaseModel):
counts: dict[str, int]
data = Payload.model_validate_json(raw)
Counterpoint. "Sometimes the checker is wrong." Then fix the stub or narrow with isinstance or assert, which check at runtime. A justified type: ignore[code] with a reason is more honest than a cast, because it stays visible.
Tool gate: ruff TID251 (banned-api)
py-no-init-or-model-tests
should-fixdrafttesting
Applies when: The diff adds test_init_* or a test file for a plain model or dataclass.
Rule. Cover __init__ and plain models through the behaviour that uses them.
Why. Such tests restate the class and break on every field change without proving behaviour.
FAIL
def test_init_sets_name(): assert User(name="a").name == "a"
PASS
def test_welcome_email_uses_user_name(): ...
Counterpoint. "Validators on the model need tests." Yes: test the validator's behaviour (valid and invalid input), not that assignment works.
py-no-silenced-diagnostics
should-fixdraftmaintainability
Applies when: A diff adds # noqa or # type: ignore.
Rule. Suppress a Python diagnostic only with a single named code and a reason on the same line.
Why. Core rule: chg-no-silenced-diagnostics. A bare # type: ignore silences every error on the line, including the next one someone introduces.
FAIL
total = compute(order) # type: ignore
x = risky() # noqa
PASS
import numpy_stubs # type: ignore[import-untyped] # no stubs published upstream
except Exception: # noqa: BLE001 # process boundary: log and continue
Counterpoint. "A reason on every line is noise." Only suppressions need one, and they should be rare. A pile of them is the finding.
Tool gate: ruff PGH003, PGH004
py-paths-from-file
should-fixdraftcorrectness
Applies when: Python code builds a path to a data file or fixture.
Rule. Resolve paths from __file__, not the current working directory.
Why. A cwd-relative path works from the repo root and fails from CI, an IDE, or an installed package.
FAIL
Path("tests/fixtures/data.json")
PASS
Path(__file__).parent / "fixtures" / "data.json"
Counterpoint. "Tests always run from the root." That holds until someone runs one test from its directory, or an installer moves it.
py-pydantic-validation
should-fixdraftcorrectness
Applies when: A Python function validates request or config data by hand.
Rule. Validate request data with a pydantic model, not hand-written checks.
Why. Core rule: mod-validate-at-boundary-once. Fields stay snake_case in Python. For camelCase JSON, use an alias generator and dump by alias.
FAIL
def create_order(body: dict):
if body.get("currency") not in {"EUR", "USD"}:
raise ValueError("bad currency")
PASS
from pydantic import BaseModel, ConfigDict, Field
from pydantic.alias_generators import to_camel
class CreateOrder(BaseModel):
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
items: list[LineItemIn] = Field(min_length=1)
currency: Currency # StrEnum: unknown values fail at parse
body = CreateOrder.model_validate(payload)
body.model_dump(by_alias=True)
Counterpoint. "Validators hide logic." They put it next to the field it protects. Hand-rolled checks are where drift starts.
py-pytest-parametrize
should-fixdrafttesting
Applies when: Tests differ only in inputs and expected values.
Rule. Use @pytest.mark.parametrize for test variations.
Why. Copy-pasted tests drift, and an empty parametrization silently runs nothing. Assert that a computed parameter list is not empty first.
Counterpoint. "Separate tests give clearer failures." Parametrize ids give one failure per case, with its inputs. For a computed list, assert cases before parametrising.
Tool gate: ruff PT006/PT007 (style only)
py-ruff-gate
should-fixdraftmaintainability
Applies when: The project is Python and has a linter configuration.
Rule. Enable the Ruff rule sets E, F, I, B, UP, SIM, BLE, TID and D in CI.
Why. The tool owns formatting, import order, the choice between Optional and X | None, bare except, and relative imports. Human review time goes to what tools cannot see. D1xx stays ignored: the gate checks docstring format, not presence. Anything the gate decides is not a review comment; the comment is "add the gate".
Counterpoint. "Strict lint slows contributors." It speeds review: repeated nitpicks are a sign of a missing gate (Google, Pragmatic Engineer notes).
Tool gate: ruff
py-settings-pydantic-settings
should-fixdraftmaintainability
Applies when: Python code reads configuration from the environment.
Rule. Load env config with BaseSettings from pydantic_settings.
Why. Core rule: mod-central-config. os.environ.get returns untyped strings with silent defaults, while BaseSettings parses, validates, and fails at startup. In pydantic v2, BaseSettings is no longer importable from pydantic.
FAIL
timeout = float(os.environ.get("TIMEOUT", "5"))
PASS
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
database_url: str
request_timeout_s: float = 5.0
Counterpoint. "os.environ is stdlib and enough." For one flag, maybe. Past two, you re-implement parsing and error messages at every call site.
Tool gate: ruff TID251 (banned-api) on os.environ
py-strenum-not-strings
should-fixdraftcorrectness
Applies when: Python code compares against string or number literals for a fixed set of values.
Rule. Use StrEnum for fixed string sets and HTTPStatus for HTTP codes.
Why. Core rules: fn-no-magic-values, fn-no-stringly-typed-returns. StrEnum members are still str (JSON-ready, comparable), and a pydantic field typed with one rejects unknown values at parse time.
FAIL
if resp.status_code == 404 or mode == "fast": ...
PASS
class Mode(StrEnum):
FAST = "fast"
if resp.status_code == HTTPStatus.NOT_FOUND or mode == Mode.FAST: ...
Counterpoint. "An enum is heavy for two values." It is one class, and it makes the type checker guard every caller. StrEnum needs Python 3.11 or later; on older versions, use class Mode(str, Enum).
Tool gate: ruff PLR2004 (partial)
py-task-reference
blockdraftcorrectness
Applies when: Python code calls asyncio.create_task or ensure_future.
Rule. Never discard the result of create_task; keep a reference and surface its exceptions, preferably through a TaskGroup.
Why. The event loop holds only weak references, so an unreferenced task can be garbage-collected mid-run. A task that nobody awaits also loses its exception ("never retrieved").
FAIL
asyncio.create_task(send_email(user)) # fire and forget
PASS
async with asyncio.TaskGroup() as tg:
tg.create_task(send_email(user))
# or: t = create_task(...); bg.add(t); t.add_done_callback(bg.discard)
Counterpoint. "The set-and-discard recipe from the docs is enough." It keeps the task alive but still never retrieves the exception, and the docs point to TaskGroup for that. The same reference rule applies to tasks passed to asyncio.shield.
Counterpoint. "Real time catches real bugs." It catches flakes. Test time-dependent logic with a frozen clock, and cover real-network behaviour in a separate, opt-in suite.