Python
Python services and tooling.
What this is
Python services and tooling. The BLOCKER tier is mostly injection and safety footguns Python makes easy to write by accident — mutable default arguments, bare except clauses, SQL built by string interpolation, shell=True subprocess calls, blocking I/O inside async def. The HIGH tier pushes toward a typed, timezone-aware, resource-safe style: type hints on new public functions, context managers instead of manual cleanup, timezone-aware datetimes, HTTP calls with timeouts.
How to onboard it
A repository picks up this stack by onboarding on a profile that includes it. redline init detects the profile from what is in the repository, so in most cases this is automatic:
$ npx redlinegate init # detects the profile $ npx redlinegate init --profile service-python # or name one
One profile pulls these rules in: service-python.
Once onboarded, your files match this stack when they fit any of these globs:
**/*.py
How to use it — 28 rules
Nothing to run. Once your profile includes Python, redline init renders these rules into your repository's AI tooling and the reviewer applies them on every pull request. When a review comment cites one of these ids, this table is where to look up what it catches and why.
| Rule id | Severity | Catches |
|---|---|---|
python/mutable-default-argument | BLOCKER | Mutable default arguments (`def f(items=[])`) — shared across calls; use `None` + assign inside. |
python/bare-except | BLOCKER | Bare `except:` or `except Exception: pass` — catch specific exceptions; never silence. Bare `except` also eats `KeyboardInterrupt`/`SystemExit`. |
python/sql-string-interpolation | BLOCKER | SQL built with f-strings/`%`/`.format` from external input — parameterized queries only. |
python/subprocess-shell-injection | BLOCKER | `subprocess` with `shell=True` on anything derived from external input; prefer list-form argv always. |
python/dynamic-code-execution | BLOCKER | `eval`/`exec`/`pickle.loads` on external data. |
python/blocking-in-async | BLOCKER | Blocking calls inside `async def`: `requests`, `time.sleep`, sync DB drivers — use `httpx`/`aiohttp`, `asyncio.sleep`, async drivers, or `run_in_executor`. |
python/secrets-not-centralised | BLOCKER | Secrets hardcoded or read ad hoc — central config module, validated at startup. |
python/call-in-default-argument | BLOCKER | A function call as a default argument (`def f(at=datetime.now())`, `def f(cache=make_cache())`). It runs once, at definition, so every call shares one stale timestamp or one mutable object. Default to `None` and call it inside. Not for framework markers built for this (FastAPI `Depends`/`Query`, pydantic `Field`, dataclass `field(...)`) or calls returning immutable values (`timedelta(...)`). |
python/return-in-finally | BLOCKER | `return`, `break` or `continue` inside `finally`. It discards any exception raised in the `try`/`except`, so `charge()` raising `PaymentDeclined` still returns `{"ok": True}`. `finally` releases resources; move the jump out. |
python/missing-type-hints | HIGH | Missing type hints on new public functions — new code is typed; assume mypy/pyright strict. |
python/missing-context-manager | HIGH | Files/connections/locks without context managers (`with`) — leaks on exception paths. |
python/module-level-mutable-state | HIGH | Module-level mutable state used as implicit singleton across requests (Lambda warm starts share it — intentional caching must be explicit and documented). |
python/broad-except-in-loop | HIGH | Broad `except Exception` that logs and continues in loops — one poisoned item must not silently vanish; dead-letter or re-raise policy required. |
python/naive-datetime | HIGH | Datetime handling: naive `datetime.now()` in new code — `datetime.now(timezone.utc)` and timezone-aware throughout. |
python/assert-for-validation | HIGH | `assert` for runtime validation — stripped under `-O`; raise proper exceptions. |
python/boto3-client-per-call | HIGH | Boto3 clients created per call inside handlers/loops — create at module scope (Lambda) or inject. |
python/http-call-without-timeout | HIGH | Unbounded `requests`/`httpx` calls without timeout. |
python/loop-variable-closure | HIGH | A `lambda` or nested `def` created in a loop that reads the loop variable and runs after the iteration (stored, registered, scheduled). Closures look the name up when called, so every one sees the last value — `[lambda: send(u) for u in users]` sends to the last user N times. Bind it at creation (`lambda u=u: …`, `functools.partial`). |
python/zip-without-strict | HIGH | `zip()` over two or more iterables without `strict=`. It stops silently at the shortest, so `dict(zip(headers, row))` on a short CSV row drops columns. Pass `strict=True`, or `strict=False` where unequal lengths are intended. Not on Python below 3.10, or when one argument is infinite (`itertools.count`). |
python/quadratic-accumulation | HIGH | Building a sequence by copying it each step — `sum(lists, [])`, `reduce(lambda a, b: a + b, lists)`, `acc = acc + [x]` or `acc = {acc, k: v}` in a loop. Each step allocates a new container, so the work grows quadratically. Use `itertools.chain.from_iterable`, a comprehension, or mutate a local accumulator. |
python/blanket-suppression | HIGH | A `# type: ignore`, `# pyright: ignore` or `# noqa` with no error code. It silences every error on the line, including ones introduced later — an `arg-type` bug added next month is hidden by an ignore meant for an untyped import. Name the code (`# type: ignore[import-untyped]`, `# noqa: F401`). In addition to `core/type-checker-suppression`: a ticket does not excuse a missing code. |
python/error-log-without-traceback | HIGH | `logger.error(...)` in an `except` block with no `exc_info=True` and no re-raise. The traceback is lost, so a `KeyError` from any of a dozen reads logs as the same one-line message. Use `logger.exception(...)` or pass `exc_info=True`. |
python/broad-exception-assertion | HIGH | `pytest.raises(Exception)` / `assertRaises(Exception)` (or `BaseException`) with no `match=`. The test passes on any error — a `TypeError` from a typo included — so it proves nothing about the behaviour it names. Assert the specific type, or add a non-empty `match=`. |
python/module-patching | HIGH | Patching by dotted import path in tests — `patch("app.billing.client.post")`, `mocker.patch("…")`, `monkeypatch.setattr("pkg.mod.attr", …)`. The test replaces the import graph, so it stays green when the real wiring breaks and goes red on a harmless move. Inject the dependency and pass a faithful test implementation. Not `patch.object` on an injected collaborator, `patch.dict(os.environ)` or `monkeypatch.setenv`. |
python/prefer-typed-models | SUGGESTION | Dataclasses/pydantic models over dict-shaped data crossing function boundaries. |
python/prefer-pathlib | SUGGESTION | `pathlib` over `os.path` string juggling in new code. |
python/prefer-f-strings | SUGGESTION | f-strings over `%`/`.format`. |
python/prefer-comprehensions | SUGGESTION | Comprehensions over `map`/`filter` with lambdas; but no nested comprehensions beyond two levels. |
Expected output
A finding from this file, and every finding Redline produces, opens with a machine-readable first line — severity, then the rule id in brackets, then the problem in one line:
Redline/BLOCKER [python/mutable-default-argument]: <one-line problem>Ids are aggregated per rule, which is how the organisation finds out which rules earn their place and which only generate noise — so a finding without a valid id cannot be measured and counts as untagged. Of the 28 rules here, 9 BLOCKER, 15 HIGH and 4 SUGGESTION. Only a BLOCKER must not merge; a SUGGESTION may be dismissed without justification, and is never upgraded to get attention.
If a rule here fires constantly on code your team has deliberately decided to allow, that is the signal to raise with the standards owner — the rule id is what makes that conversation measurable — not to argue it away comment by comment.
How to edit it
Rules are edited in standards/stacks/python.md and nowhere else. The rendered copies in AGENTS.md, .github/copilot-instructions.md and .github/instructions/ are generated and are overwritten by the next render. A change here propagates to every onboarded repository as a pull request, so treat it as a production change.
- Edit the markdownstandards/ is the only place a human edits a rule. Everything under AGENTS.md, .github/copilot-instructions.md and .github/instructions/ is rendered from it and is overwritten by the next render.
- node scripts/assign-rule-ids.mjsAssigns a permanent <stack>/<slug> id to any new rule bullet and rewrites the file in place. Do not invent an id by hand. CI runs the same script with --check and fails if a rule is missing one.
- node scripts/render-self.mjsRe-renders this repository's own artifacts from the edited source. CI runs it with --check, so stale checked-in output fails the build.
- Bump standards/manifest.json → versionRequired in the same pull request as the rule change. Sync pull requests quote the version, so a repository's rendered artifacts always name where they came from.
- Add a CHANGELOG.md entryAlso in the same pull request. A standards change with no measurement is an opinion — record the seed score alongside it.
- node scripts/validate.mjsThe bundle self-check CI runs: manifest integrity, well-formed rule ids, the severity output contract surviving your edit, glob portability.
Rule ids are permanent. Rewording a rule is fine and keeps its id; renaming or removing an id orphans every historical telemetry record that cited it.
The full file
# Python Review Rules
## BLOCKER — request changes
- `python/mutable-default-argument` — **Mutable default arguments** (`def f(items=[])`) — shared across calls; use `None` + assign inside.
- `python/bare-except` — **Bare `except:` or `except Exception: pass`** — catch specific exceptions; never silence. Bare `except` also eats `KeyboardInterrupt`/`SystemExit`.
- `python/sql-string-interpolation` — **SQL built with f-strings/`%`/`.format` from external input** — parameterized queries only.
- `python/subprocess-shell-injection` — **`subprocess` with `shell=True`** on anything derived from external input; prefer list-form argv always.
- `python/dynamic-code-execution` — **`eval`/`exec`/`pickle.loads` on external data.**
- `python/blocking-in-async` — **Blocking calls inside `async def`**: `requests`, `time.sleep`, sync DB drivers — use `httpx`/`aiohttp`, `asyncio.sleep`, async drivers, or `run_in_executor`.
- `python/secrets-not-centralised` — **Secrets hardcoded or read ad hoc** — central config module, validated at startup.
- `python/call-in-default-argument` — **A function call as a default argument** (`def f(at=datetime.now())`,
`def f(cache=make_cache())`). It runs once, at definition, so every call shares one stale timestamp or one
mutable object. Default to `None` and call it inside. Not for framework markers built for this (FastAPI
`Depends`/`Query`, pydantic `Field`, dataclass `field(...)`) or calls returning immutable values
(`timedelta(...)`).
- `python/return-in-finally` — **`return`, `break` or `continue` inside `finally`.** It discards any exception
raised in the `try`/`except`, so `charge()` raising `PaymentDeclined` still returns `{"ok": True}`. `finally`
releases resources; move the jump out.
## HIGH
- `python/missing-type-hints` — Missing type hints on new public functions — new code is typed; assume mypy/pyright strict.
- `python/missing-context-manager` — Files/connections/locks without context managers (`with`) — leaks on exception paths.
- `python/module-level-mutable-state` — Module-level mutable state used as implicit singleton across requests (Lambda warm starts share it — intentional caching must be explicit and documented).
- `python/broad-except-in-loop` — Broad `except Exception` that logs and continues in loops — one poisoned item must not silently vanish; dead-letter or re-raise policy required.
- `python/naive-datetime` — Datetime handling: naive `datetime.now()` in new code — `datetime.now(timezone.utc)` and timezone-aware throughout.
- `python/assert-for-validation` — `assert` for runtime validation — stripped under `-O`; raise proper exceptions.
- `python/boto3-client-per-call` — Boto3 clients created per call inside handlers/loops — create at module scope (Lambda) or inject.
- `python/http-call-without-timeout` — Unbounded `requests`/`httpx` calls without timeout.
- `python/loop-variable-closure` — **A `lambda` or nested `def` created in a loop that reads the loop variable
and runs after the iteration** (stored, registered, scheduled). Closures look the name up when called, so
every one sees the last value — `[lambda: send(u) for u in users]` sends to the last user N times. Bind it at
creation (`lambda u=u: …`, `functools.partial`).
- `python/zip-without-strict` — **`zip()` over two or more iterables without `strict=`.** It stops silently at
the shortest, so `dict(zip(headers, row))` on a short CSV row drops columns. Pass `strict=True`, or
`strict=False` where unequal lengths are intended. Not on Python below 3.10, or when one argument is infinite
(`itertools.count`).
- `python/quadratic-accumulation` — **Building a sequence by copying it each step** — `sum(lists, [])`,
`reduce(lambda a, b: a + b, lists)`, `acc = acc + [x]` or `acc = {**acc, k: v}` in a loop. Each step allocates
a new container, so the work grows quadratically. Use `itertools.chain.from_iterable`, a comprehension, or
mutate a local accumulator.
- `python/blanket-suppression` — **A `# type: ignore`, `# pyright: ignore` or `# noqa` with no error code.** It
silences every error on the line, including ones introduced later — an `arg-type` bug added next month is
hidden by an ignore meant for an untyped import. Name the code (`# type: ignore[import-untyped]`, `# noqa:
F401`). In addition to `core/type-checker-suppression`: a ticket does not excuse a missing code.
- `python/error-log-without-traceback` — **`logger.error(...)` in an `except` block with no `exc_info=True` and
no re-raise.** The traceback is lost, so a `KeyError` from any of a dozen reads logs as the same one-line
message. Use `logger.exception(...)` or pass `exc_info=True`.
- `python/broad-exception-assertion` — **`pytest.raises(Exception)` / `assertRaises(Exception)` (or
`BaseException`) with no `match=`.** The test passes on any error — a `TypeError` from a typo included — so it
proves nothing about the behaviour it names. Assert the specific type, or add a non-empty `match=`.
- `python/module-patching` — **Patching by dotted import path in tests** — `patch("app.billing.client.post")`,
`mocker.patch("…")`, `monkeypatch.setattr("pkg.mod.attr", …)`. The test replaces the import graph, so it stays
green when the real wiring breaks and goes red on a harmless move. Inject the dependency and pass a faithful
test implementation. Not `patch.object` on an injected collaborator, `patch.dict(os.environ)` or
`monkeypatch.setenv`.
## SUGGESTION
- `python/prefer-typed-models` — Dataclasses/pydantic models over dict-shaped data crossing function boundaries.
- `python/prefer-pathlib` — `pathlib` over `os.path` string juggling in new code.
- `python/prefer-f-strings` — f-strings over `%`/`.format`.
- `python/prefer-comprehensions` — Comprehensions over `map`/`filter` with lambdas; but no nested comprehensions beyond two levels.