Instruction file imported from percona/SEP (
.github/instructions/python.instructions.md). Copyright stays with the author.
Python — Types, Imports, Style
Pre-commit runs ruff (lint + format), bandit, and addlicense. Don't flag what these catch: import sorting, quoting, line endings, trailing whitespace, ERA001 commented-out code, generic Bandit findings, license headers. Flag manual-judgment items only. Exception — pyproject.toml's extend-exclude list (app/*/migrations/*, payload.py, payload, *_payload, **/payloads/*, data, etc, build, /docs, .github, the framework golden tree) is excluded from ruff lint and format, so neither pre-commit nor CI touches it: there PEP 8 spacing, quote style and import order are a manual obligation and a reviewer finding is the enforcement — match the sibling files. app/*/migrations/* is the live case, since revisions are hand-authored.
Type annotations
- Annotate function parameters, return types, and class-level attributes. Ruff never requires a local-variable annotation (
ANN0/ANN2on,ANN1off), with one exception that does: an empty collection assigned to a local with no element type (specs = [],by_id = {},seen = set()) is a finding, so the "prefer no annotation" default stops at= []/= {}/= set()— there, annotate. Exempt: an empty tuple, and a name already annotated elsewhere in the same function. Loop variables and comprehension targets are never annotated. Don't flag a local annotation by itself; flag only ones that merely restate a type the assignment already makes obvious (e.g.count: int = len(items),name: str = "done"). Leave an annotation alone when it carries type information inference can't: an empty collection (items: list[str] = []), an optional initialized toNone(x: Foo | None = None), or an ambiguous/Anyreturn (payload: dict[str, Any] = response.json()). - Modern syntax only:
str | NonenotOptional[str];list[str]notList[str]. Target is Python 3.11+. - Pick the weakest abstract type that captures every operation the body performs. If the body only iterates or does
x in xsmembership, preferIterable[T]overSequence[T]overlist[T]. Uselist[T]when the body mutates, indexes, callslen(), or iterates more than once. Useset[T]overUniqueList[T]when order is irrelevant. - The loosening stops at multi-pass consumption, and that direction has no safety net:
Iterable[T]where the body consumes twice type-checks, passes review, and fails only when some caller passes a generator — silently, with no exception. Count consumption sites, not visibleforloops: a parameter spread as*xsinto two different calls is consumed twice just as surely, and reads far less like repeated iteration. Two tells that the floor isSequence[T]: the parameter is unpacked or iterated in more than one statement (especially across branches of a conditional), and the sibling parameters on the same signature are alreadySequence. A loneIterableamongSequencesiblings is a smell, not a deliberate loosening. - When two parameters fill the same semantic role (within or across functions), annotate with the same breadth. Asymmetry like
list[str]next toSequence[str]— when neither body mutates — should be flagged. - An annotation wider than every value that reaches it is the defect, whatever it's spelled:
Anywhere every caller passesSnippet,mocker: object, a baredictwhose keys and values are fixed. Name the real type.objectis the same violation asAnyand hides better, because it looks deliberate — and ruff can't help either way, since theANNfamily only checks whether an annotation is present, sox: objectsatisfies it exactly asx: Snippetdoes. Prefer the concrete type the library documents (Dialect,TypeEngine[T],Table,AsyncIterator[T], aTypedDictfor known dict shapes); reserveAnyfor genuinely dynamic input (JSON parsing, user-supplied dicts). - A closed value space belongs in an enum, not in sibling constants. When a group of module constants enumerates every legal value of one concept and that concept is threaded through signatures, mappings, or validation, make it a
StrEnum/IntEnum. The payoff is the annotation: a parameter typedstradmits every string in the language, the same parameter typed as the enum admits exactly the values that exist.StrEnummembers compare and serialize as their plain string value, so payloads, JSON, DB columns and==against raw strings are unaffected. Signals: the names share a prefix/suffix and are only ever used as alternatives; they're collected into a tuple/set/dict elsewhere (that collection is the value space, spelled twice); a function takes one as a barestror branches on which it received. FollowAlertSeverity/CheckSeverity/TaskBackendEnum. - A shape constraint expressible as a regex belongs in the field type, not a validator body. Declare
Annotated[str, StringConstraints(pattern=...)]and let Pydantic reject at parse time; reach for a hand-writtenfield_validatoronly when the check needs what a regex can't carry (cross-field state, a DB lookup, the value's own enum). The difference is totality, not style: a pattern is closed by construction, while a validator body rejects only what its author enumerated. Whitespace is the canonical miss because it's invisible on inspection — a validator splitting"Class.FIELD"on.and checking both halves non-empty accepts"Settings.LOGGING "and"Settings. LOGGING", both well-formed by its own rule and matching nothing downstream. What you give up is aggregation (Pydantic reports one error per entry rather than listing all), which rarely justifies the hand-rolled version. - NEVER wrap custom field types in
str().StrHttpUrl,NonEmptyStr,LowercaseStr,URIPath,UTCDatetime(and the other subclasses inapp/core/utils/fields.py) are already typed — the rule applies to any of them, not just these five. - Bare generic containers erase the element contract —
dict/list/set/tuple/frozensetwith no type args is[Any]. Supply concrete args; only genuinely heterogeneous content may usedict[str, Any]/list[Any]. type[X]bound must be the tightest common base of the expected callers; baretype(=type[object]) is always wrong. When the base can't be imported at runtime, annotate underTYPE_CHECKING.- Callable return signatures: a factory returning a callable with a fixed signature spells the parameter list —
Callable[[str, TaskAPI], Awaitable[Task]], notCallable[..., Awaitable[Task]]. Reserve...for genuinely varying signatures. - Over-nullable fields: an
X | Nonefield whoseafter-validator unconditionally fills it from a non-optional sibling is a defaulting artifact — move the default to abefore-validator and type the field non-optional. Keep| Noneonly whenNoneis a legitimate post-construction value. - Wire format ≠ field type: a response/wrapper model field projecting from a canonical sibling (
BaseSQLModel, an upstream response) mirrors the source's field type (UTCDatetime,NonEmptyStr, anEnumsubclass), not the JSON wire type (str,list[str]).
Imports & suppressions
- Top-level imports only. Inline imports inside function bodies are acceptable for exactly four concrete, verified reasons, each with a one-line marker comment adjacent to the import (not in the docstring, not only in the PR description):
- A circular import. The named chain must close back to the module the comment sits in.
# circular-import: config imports registryon an inline import insidepolicy.pyleaves the reader unable to reconstruct why importing config here closes anything — write the whole path:# circular import: config imports registry imports policy (this module). If you can't state the chain end to end, you haven't verified the cycle. - A forbidden cross-app edge —
sidecar/only. Deferral is no longer a remedy underapp/:tests/app/sep/test_import_boundary.py::test_no_module_defers_an_import_into_another_app_packagerejects a cross-app import deferred into a function body anywhere in theapp/tree, marker or not. Deferring never removed the edge — in the image that strips the package it only relocates theModuleNotFoundErrorfrom import time into a lifespan or a request. Remove the edge, don't move it: derive what you need from the activation list through the existing seam (collect_app_owned_settings_classes()for app-owned settings classes, the app registry for anything else) so the module names no app package at all. There is no allowlist, by design. The guard walksapp/**/*.pyonly, so undersidecar/a deferred cross-app import is still sanctioned, is not a circular import, and takes# import-boundary: <reason>; writing# circular-import:for it is a false citation. That marker may sit on a comment line above a contiguous import block, covering every import in it. - An import that resolves settings. Importing a settings module is its
Settings()construction running, so the import raises on a profile that doesn't validate. A caller whose job is to report that failure rather than die of it defers the import into thetrythat handles it, marked# import-time-settings: <reason>. The test is that hoisting would move the failure earlier than the handler that exists for it — not that the import is slow or the module large. If nothing in the function catches the resolution error, the marker is a false citation. In-repo case:sidecar/grafana_service_account.py, run before supervisord, where PID 1 dying takes the container with it. - An optional third-party driver in a standalone payload. A payload that ships to a host and runs there imports the driver for the backend it was dispatched against; a module-scope
import pymysqlmakes the whole payload fail to load on hosts carrying only the drivers they need. Defer into the branch that uses it, marked# optional-dependency: <backend>. Not defensiveness, and there is no cycle to name —app/tasks/connectivity/payload.pydefers four such drivers, each marked.
- A circular import. The named chain must close back to the module the comment sits in.
- A deferred import must not be called at module scope, and it belongs inside the guarded region that handles its failure, not merely at the top of the function body. (Under
app/this no longer arises for cross-app edges — deferral is retired there — but it binds on every# import-time-settings:/# optional-dependency:deferral and onsidecar/.) A deferred import can raiseModuleNotFoundError— in the image where the package is stripped it will, that being the case the deferral exists for. At the top of the body it raises ahead of the function's own guards, bypassing the early return that declines the work, the branch that records a terminal failure, and the enclosingexcept; the deferral then converts an import-time failure into a worse runtime one instead of a handled one. Generally: position a statement that can raise relative to the destination function's guards, not relative to its first line. - No new
# noqa:or# type: ignore:unless the PR explains why fixing the root cause is impossible. The accepted-suppression registry is exactly four entries covering seven codes, and anoqais exempt only when its code is listed and its site meets the condition:ARG001/ARG002/ARG003/ARG004where the signature is imposed from outside and the body genuinely has no use for the parameter (a parent-class or protocol override, a FastAPI exception handler'srequest, alifespancallback'sapp) — keep the imposed name, never rename to_param;BLE001on a deliberate classified catch-all whose docstring promises totality and re-raises nothing (not a licence to widen a narrowexcept);TRY004on a Pydantic validator raisingValueErrorso the framework surfaces 422 rather than 500 (app/core/utils/fields.pycarries the file-wide form);S603on asubprocesscall whose argv is a list of repo-internal, already-validated values, nevershell=True— pair it with# nosec B603where bandit also scans the file. Deliberately off the registry, because the repo has a better answer:F401on a side-effect-only model import (register it in the track'senv.pyinstead) or a dep re-export in a route module (override the canonical callable), andSLF001against a function whose docstring enumerates its callers (widen the docstring or factor a public wrapper). Treat an author-less# TODOwith# noqa: TD002, TD003as a standards bypass — name an owner. Required TODO format:# TODO(<owner>): <description>; don't add a# <ticket-key>line — Jira keys never go in comments (see below), so suppressing the missing-link rule (TD003) is the accepted path and the ticket reference lives in the PR. per-file-ignores: a[tool.ruff.lint.per-file-ignores]entry keyed on a bare filename ("contract_suite.py") is a basename glob matching every file of that name in the tree — anchor it to the full repo-relative path, or use an inline# noqafor specific lines.exclude/extend-excluderemoves the file from all linting and formatting — never a bare basename there.
Style
- Boolean trap:
process_data(data, True)is opaque. Require keywordvalidate=True. - Sentinel anti-pattern: don't write
result = None; try/except; if result is None— usetry/except/else. - Single-use locals: inline unless the name documents non-obvious intent, the branch body is ≥10 lines, or the expression has a side effect.
- Merge boolean-branch returns: two
if cond: return Xwith the sameXcollapse toif a or b: return X, unless branches have side effects. - Redundant construction: when the same constructor call returns the same value on every path, extract it once at the top.
- Collapse trivial guards: ≤2 cheap preconditions guarding a single-statement body → one combined conditional over a chain of
if not x: returnguards. Likewise a single-condition guard whose body is one barecontinue/return/raisefollowed by the happy path — fold it into a conditional wrapping that block, so the skip-marker disappears and the happy path stops living at the tail. The collapse is the rule; the resulting polarity is an artifact of the input — whether the surviving condition readsif X:orif not X:depends on how the original guard was spelled and carries no weight of its own, so don't reject a collapse for landing on the negative form.x not in yandx is not yare single comparison operators, not negations, and never cost a collapse. Carve-outs: the body is multi-statement and deeply nested, the guard has side effects, there are ≥3 preconditions, or the skipped branch needs its own explicit return value. - Numeric literals: drop trailing zeros on floats —
0.1not0.10. - Empty modules: if a refactor empties a file, delete it.
- Comments: flag NEW inline comments that don't document a non-obvious invariant or workaround; a verbose-but-justified comment is a tighten signal (condense to the core why), not a delete. Never put a Jira key (
SEP-\d+) orfixes #Nin an inline comment (any syntax — Python#, Jinja{# #}, HTML, JS) — that rationale belongs in the PR/ticket. - Every cross-site citation in a comment must resolve against the source — any symbol name, module path, import chain, or file reference naming code the reader can't see from this line. Resolve each before the comment lands: grep the name, open the module, walk the chain. Two failure shapes, both of which pass any review that reads the comment for plausibility rather than resolving it: a wrong name (
# create_annotation returns early when API_KEY is unsetwhere the function iscreate_pmm_annotation— the behavioural claim being correct is what makes it costly, since a reader who verifies the behaviour trusts the name too and then greps for a symbol that doesn't exist), and a truncated chain. Re-resolve when you touch either end. - A comment asserting an out-of-band obligation must have that obligation already met. A carve-out documented as "a table added to this set must have a tracked follow-up" or "remove once upstream fixes X" asserts the obligation holds for every entry already present — satisfy it before the comment lands. An invariant unenforced from its first entry is the weakest starting position for one that depends entirely on discipline, and the next editor reads the unmet precedent as permission.
- No magic HTTP integers:
status.HTTP_404_NOT_FOUND, not404— imported fromfastapi, notstarlette(from fastapi import status). - Custom exceptions: raise
HTTPNotFoundException/HTTPConflictException/HTTPForbiddenException, notfastapi.HTTPException. - Pydantic / SQLModel class body order:
model_configfirst, then__table_args__ontable=Truemodels (table-level config governs the class as a whole and itsIndex/UniqueConstraintentries name columns, so it must precede the fields it constrains), then fields, then@field_validator/@model_validator, then methods (@computed_fieldproperties before regular methods).model_configplaced below the fields hides it from a reader scanning the top of the class. Ordering has no runtime effect — a violation is a consistency finding, not a bug. - Raw-string prefix: use
r/rbonly when the body contains a literal backslash escape; anrprefix on a string with no backslash is misleading noise. - Branches that restate their own condition: when the two branches of a conditional expression differ only in a value the condition already determines, the conditional collapses to that value.
return (masked, False) if masked is not None else (None, True)→return masked, masked is None. This covers tuples, dict literals, and any expression built branch-by-branch. Two common instances: literal-True ternaryTrue if cond else X→cond or X,False if cond else X→not cond and X(only whencondis already boolean-typed — comparison,isinstance, membership). - None-first ternary:
value if x is not None else None→None if x is None else value(don't introduce anotjust to satisfy this). Unlike the collapse rules above this is a genuine ordering rule — the reader should see the trivial base case first — so here the polarity is the point. - Text-mode
open()pinsencoding="utf-8". Anyopen()/Path.open()in text mode passes it explicitly; without it Python uses the host'slocale.getpreferredencoding(), so the same code round-trips differently on a differently-configured host. Binary mode ("rb"/"wb") is exempt.json.dumpdoes not exempt the writer — itsensure_ascii=Truedefault makes the body pure ASCII, which reads like the encoding can't matter, but the argument is about the reader: where the read side decodes as UTF-8 and mapsUnicodeDecodeErrorto a benign value, a locale mismatch degrades into silent data loss rather than a visible error. Pin it on both sides. - A bare string is not a comment. A string expression used to explain why the surrounding code does what it does is a comment — write
#. The one legitimate bare string in that position is the PEP 258 attribute docstring: a string documenting the name just assigned, which Sphinx autodoc reads and which this codebase uses deliberately (app/core/utils/fields.pydocuments every exported type alias that way). Keep those; don't convert them to comments, and don't add a bare string that follows any other statement. __all__placement: in the module header region, before the firstdef/class— never at the end of the file. Whether it sits directly below the docstring or below the import block is not standardized; match the file you're editing and never churn a module just to switch sides.- Stub ellipsis: a
Protocolmethod /@overload/ abstract stub that already has a docstring must not also carry a trailing...— the docstring is the body, and the method is implicitly abstract either way. A bare...stays correct when there is no docstring. Whether ruff'sPIE790reaches stub contexts is not established, so treat this as a reviewer convention rather than assuming a linter catches it. - Buffer scanning: flag a Python
for-loop scanning a byte/string buffer when a C-level builtin (.count(),.find(),.split(),.translate()) expresses the same operation — relevant on potentially large buffers (log chunks, file reads, upload bodies).