Python design and style defaults
Personal, agent-maintained Python projects. Applies when writing new code or reviewing existing code against one of the topics below without an explicit "evaluate alternatives" request — pick the default, don't re-litigate from scratch each session. Deviating is fine when a case genuinely matches one of the named escalation paths — the point is to stop a fresh session/model from silently picking something different for no reason, not to forbid judgment calls.
Two topics were split out on 2026-08-31 and are separate installs: test-suite conventions are in
python-testing-conventions, and the stdio-logging, tool-boundary and tool-docstring rules for MCP
servers are in mcp-python-conventions. The split was measured before it was made — the pieces were
checked against real requests to confirm each wins its own and none steals from the others.
This is design guidance, not tool config. Nothing here tells you which type checker or linter to install, or how to configure one — that belongs to whatever your repo uses to enforce its own standards (basedpyright, ruff, shellcheck/shfmt, dprint and pytest config, in this author's case). This skill is what to reference while writing code; tooling config is what a repo enforces once, at setup.
Each topic below states whether it's overriding your own default instinct or just confirming
one. A capable model already gets a lot of this right without being told — early returns, EAFP for
runtime lookups, reasonable async code, pathlib over os.path. This skill isn't trying to
relitigate those; it exists for the choices where a model left alone drifts (six equally-plausible
data-modeling options, a DRY instinct that over-abstracts, a settings pattern borrowed from the last
framework seen in training data) or where a convention is genuinely non-obvious/project-specific
rather than general Python knowledge (the globals.py singleton shape). Each Model default line
below says which case you're in — skim past the ones that just confirm what you'd already do, weight
the ones that don't.
Data modeling
- Snippet:
references/snippets/data-modeling.py - Default: Pydantic v2,
frozen=True, for anything parsing external/untrusted data (API responses, MCP tool args) or settings/config.@dataclass(frozen=True)for everything else — internal structured data, function returns, records. - Why: two defaults, not six, so agents mimicking existing code have less room to pick the wrong one. Pydantic's Rust core closed the validation-speed gap with v1, but never closed the plain-attribute-access gap with a dataclass — the split isn't about which is "faster" in the abstract, it's boundary-validation vs. everything else.
- Escalate to:
attrs(validators/converters without Pydantic's validation+serialization bundling),NamedTuple(drop-in positional-tuple compatibility, or a small closed record where order is the meaning — never past ~3 fields),msgspec(once Pydantic overhead is a measured bottleneck),TypedDict(value must genuinely stay a plain dict, not become an object). - The NamedTuple clause's third condition — zero validation or behavioural needs — is the one that
bites, and "past ~3 fields" is the weaker test. A two-field
Quantity(amount, unit)looks like a textbook NamedTuple and is a bug: it inherits tuple ordering, so comparingQuantity(5, "ml")againstQuantity(300, "mg")with<returnsTrueinstead of raising, silently succeeding across incompatible units. Free tuple ordering is a hazard, not a convenience, for any value type whose comparisons carry a precondition. - That hazard and NamedTuple's benefit are the same property seen from two sides, so the question
is never "is a NamedTuple nicer here" — it is what is being replaced. A bare tuple ->
NamedTupleis a clear upgrade: names where there were none, and the positional surface already exists and is already being unpacked, so nothing new leaks. A frozen dataclass ->NamedTupleis a downgrade: it adds indexing, unpacking, iteration, and equality with any plain tuple carrying the same values — a second positional API nobody designed — and inserting a field in the middle silently changes whatx[1]means at every call site, with no error anywhere. Confirmed 2026-09-01 auditing a 3,200-line stdlib-only script: 16 anonymous record-shaped tuple returns becameNamedTuples and the 6 frozen dataclasses were deliberately left alone. - Where the dependency is not available — a stdlib-only script, a vendored tool — validating in
__post_init__is what keeps config a frozen dataclass. That is "parse, don't validate" with nothing but the standard library, worth naming by the phrase because the phrase is what makes it findable. Config is read once and every later reader should be able to assume it is well formed. It is also the concrete form of the NamedTuple clause's zero-validation condition:NamedTuplehas no__post_init__, so the same guarantee costs a__new__override or a classmethod factory — more ceremony than the dataclass it was meant to be cheaper than. - A
dictis a mapping, or it is a record that lost its type; the test is whether the keys are data or names you chose. Keys arriving from outside — a config file, a JSON payload — are data, and that stays a dict. Keys the author typed are field names: adirs["store"]read with string literals at 7 sites is a record, and two named fields move a typo from a runtimeKeyErrorto an error the checker catches. The legitimate exception is the serialisation boundary — adict[str, object]built to be handed tojson.dumpsshould stay a dict, where typing it is ceremony in both directions. - Alternative, project-wide: if a project uses Pydantic for anything, it uses Pydantic for everything. A legitimate substitute for the split above, not a divergence from it, and it cuts both ways — it also makes "no Pydantic anywhere" a real answer, rather than letting the dependency arrive one convenient module at a time. Take it when a project already has Pydantic at its boundary and would otherwise run two record idioms, leaving an agent to guess which to mimic; the split's own reasoning (measured plain-attribute-access cost, dependency weight) is what you are trading away, so decide once, at the top of the project, not per module.
- The strongest reason to take it is not consistency. Measured on a project that migrated ~30
frozen dataclasses: a single
Annotatedalias replaced thirteen hand-maintainedobject.__setattr__normalisation sites of that codebase's most load-bearing invariant. Validation that a frozen dataclass can only express in__post_init__gymnastics is the real trade, and it is a better argument than "one idiom, not two". - Model default: overrides. Left alone, a model mixes Pydantic/dataclass/TypedDict/NamedTuple inconsistently across a codebase depending on what it last saw — there's no strong single default instinct here to confirm.
Pydantic traps a dataclass never had
Four measured against pydantic 2.13.5, all of which bite a project migrating dataclasses → Pydantic or following the all-or-nothing alternative above. The last is the one that changes a public API rather than a call site.
model_copy(update=...)performs no validation — its own docstring says so. A frozen model copied withupdate=accepts a naive datetime, or a cross-field violation, that every other construction path rejects. This is a genuine regression fromdataclasses.replace, which revalidates, and it hits mechanically at everyreplace()call site being ported. Use the revalidating helper in the snippet, notmodel_copy.- Lax mode coerces
floatintoDecimal. For any field that isDecimalbecause floats are unacceptable, the default silently removes the guarantee.Annotated[Decimal, Strict()]restores it and keeps the metadata in the annotation where the rest of it lives. - Config belongs in the class declaration.
class Q(BaseModel, frozen=True)is verified equivalent tomodel_config = ConfigDict(frozen=True), composes with other keywords, and reads on the line that names the class rather than as an attribute assignment that looks like data. - A validator's
ValueErroris swallowed and re-raised aspydantic.ValidationError— anything that is not aValueErrorpropagates untouched. Harmless until the project's own exception hierarchy is rooted atValueError, which is a common and well-reasoned choice made so callers handling bad input generically keep working. After the migration every class in that hierarchy stops reaching a caller: a construction that raisedMyValidationErrornow raisespydantic.ValidationError, and the subclass distinction survives only in message text. Measured on a project whose two classes were a validation error and a unit-mismatch error, where telling them apart is the entire point of having both. The fix is to dropValueErrorfrom the base — and to write down the consequence, sincepydantic.ValidationErroris aValueError: catching everything a construction can raise becomes(ProjectError, ValueError), the project's own complaints plus the structural ones pydantic raises on its own.- The inverse is equally right at a parsing boundary: raise plain
ValueErrordeliberately so pydantic does capture it, attach the field's location, and collect several complaints into one report — then render that report back into the project's own error type at the boundary. So the rule is not "never subclassValueError"; it is that the choice now decides whether an error carries a type or a location, and it is made per layer.
- The inverse is equally right at a parsing boundary: raise plain
- Model default: overrides.
model_copy(update=...)reads as the obviousreplaceequivalent and is the natural first reach; nothing about the call site suggests validation was skipped. TheValueErrortrap is worse in the same way — the base class is chosen once, long before the migration, and nothing at the point of the change points back at it.
Dates, times, and timezones
Default: aware datetimes only, normalised to UTC at every boundary. Reject a naive datetime where it enters, never coerce it, and
astimezone(UTC)everything you store or compare. Convert to a local wall clock only at the point of display.zoneinfofor zones (stdlib since 3.9), neverpytz.Why: two aware datetimes that share a non-UTC
tzinfosubtract and compare on their wall clock, silently ignoring any DST transition between them. A "24 hour" trailing window spans 23 real hours across spring-forward; a six-hour minimum interval between two doses elapses an hour early. Worse, near a fold==and-disagree — an ambiguous datetime and its own UTC equivalent subtract to exactly zero while comparing unequal — so any branch on instant equality is wrong. Normalising on the way in is what makes every later comparison mean elapsed time. Measured live 2026-08-27 iningesta, where both failures were real and neither was visible by reading the code.The canonical spelling, for a project that has Pydantic:
Utc = Annotated[datetime, AwareDatetime, AfterValidator(lambda v: v.astimezone(UTC))]Verified: it normalises an aware non-UTC datetime and rejects a naive one, on every construction path the model has. That is the whole rule above as one reusable annotation, and it replaces the hand-maintained
object.__setattr__normalisation a frozen dataclass needs at each field. Without Pydantic, the equivalent is still a__post_init__doing both halves explicitly — reject naive, thenastimezone(UTC)— never one or the other.Resolving a local wall time:
fold(PEP 495) is the whole API, and each case needs a stated policy rather than whateverreplace(tzinfo=...)happens to do. Detect by comparing offsets, not datetimes — intra-zone comparison ignoresfold, so the datetimes compare equal either way.wall.replace(tzinfo=z, fold=0).utcoffset() < ...fold=1...means the time is nonexistent (a spring-forward gap);>means ambiguous (a fall-back). Then choose deliberately: round-tripping a gap time through UTC shifts it past the gap, andfold=0takes the first occurrence of an ambiguous one.Testing: DST correctness needs known-answer tests at real transitions, expected instants worked out by hand and asserted literally. A property test restates the implementation and passes straight through this bug. Normalise in the test helpers too — a property test computing
anchor - durationon zone-aware values has the bug itself and will report correct code as broken, which is exactly what happened before the helpers were fixed.Don't:
datetime.utcnow()— it returns a naive datetime and is deprecated since 3.12; usedatetime.now(UTC). Don't store a float timestamp to sidestep the problem either: an aware UTC datetime is the value, not an encoding of it. ruff'sDTZruleset catches the naive-construction half of this automatically and none of the same-zone-arithmetic half.Model default: overrides. Models use
astimezone/ZoneInfocorrectly in isolation but do not default to normalising at the boundary, and reliably write same-zone arithmetic that is wrong only across a transition — invisible to review, and to every test that doesn't sit on a DST boundary.
Settings and secrets management
- Snippet:
references/snippets/settings.py - Default:
pydantic-settings. BaseSettingsclass with production-safe defaults, one subclass per non-prod environment overriding only what differs, anENVIRONMENT-env-var-driven selector wired at the top of the package's__init__.py, assigned once to a module-level name.frozen=Truethroughout. - Why: this is the module-singleton pattern (below) applied to settings — eager construction fails fast at import time, and a plain (non-Singleton) class means tests can freely construct an isolated instance instead of fighting shared global state.
- Escalate to:
dynaconf— only once a repo genuinely grows a multi-environment deployment matrix (Vault/Redis-backed dynamic sources, non-Python operators hand-editing config). - Don't: FastAPI's
@lru_cache-wrapped settings factory, outside an actual FastAPI app. Its rationale (amortizing repeated.envreads across requests) doesn't transfer to a CLI tool or MCP server, its test-override mechanism is FastAPI-dependency-injection-specific, and it trades away fail-fast-at-import-time for a benefit that doesn't apply here. Full reasoning in the rationale doc. - Model default: overrides. A model trained heavily on FastAPI examples defaults toward the
@lru_cachefactory pattern, not this one — the eager base+subclass+env-selector shape is a specific chosen idiom, not what falls out naturally.
Early returns, guard clauses, fail-fast, and EAFP
- Snippet:
references/snippets/guard-clauses.py - Rule: a guard clause is for the asymmetric case — one happy path, one rare exceptional early-out.
Don't use one to split two co-equal business branches; that's a plain
if/else. Guard clauses validate the caller's contract (argument types/ranges); EAFP handles runtime operations Python already fails loudly on (dict/attr lookups, I/O, network). - Fail-fast:
assertis for internal "can't happen" self-checks only (compiled out underpython -O) — never for input validation. Anything triggerable by bad input or external stateraises a real exception. Never returnNone/a sentinel/(success, result)on failure — falsy values make "empty" and "failed" indistinguishable to the caller. - Exception hierarchy:
references/snippets/exceptions.py— one root exception per package minimum, deeper leaves only once a caller actually needs to discriminate (2–3 levels, matchingrequests/click's own shape). - Model default: mostly confirms. Early-return style and EAFP-for-runtime-lookups are already close to default behavior; the real add is the guard-clause/co-equal-branch nuance and the never-return-None-on-failure rule, which models don't reliably self-apply.
Modularity, testability, DRY, readability, encapsulation
- Default: lean toward duplication over premature abstraction (Fowler's Rule of Three: duplicate once freely, wince at twice, refactor on three) — a wrong abstraction is harder for an agent to safely touch than duplicated code, not easier.
- Architecture: Functional Core, Imperative Shell fits CLI/data-pipeline-shaped code well; strains for code whose entire job is I/O orchestration (MCP servers) — reach for Michael Feathers' "seams" vocabulary there instead.
- Encapsulation: Python has no real privacy (PEP 8: single underscore is a "weak indicator," nothing
more). Internal helper modules public-by-default; reserve
__all__+ underscore discipline for genuine package-public surfaces (MCP tool definitions, CLI entrypoints). - Model default: overrides, actively. A model asked to "clean up" or even just implementing a feature proactively tends to extract shared helpers/abstractions on sight — this is the section most likely to be fought against if skipped, not a minor nudge.
Modules-as-singletons and lazy-loading properties
- Snippet:
references/snippets/settings.py(same pattern, generalized beyond settings) - Default: instantiate a plain class once at module level (the
globals.pypattern) — stdlib-endorsed ("The Global Object Pattern"), not a GoF Singleton, so tests can freely construct a second, isolated instance.@property/cached_propertyfor lazy-loaded fields, but only when the getter is idempotent and side-effect-free — an explicit.load()method otherwise, since a property can't signal cost at the call site. - Caveats to keep in view:
cached_property's thread-safety guarantee (exactly-once under concurrent first access) was removed in Python 3.12 — a correctness change if the getter isn't idempotent, not just a performance one.monkeypatch.setattrin tests must target the module attribute itself (monkeypatch.setattr(config_module, "X", ...)), not a name already pulled in viafrom config import X. - Model default: overrides. A model reaches for dependency injection, a class-based Singleton, or a per-call instantiation before it reaches for a bare module-level instance — this pattern is idiosyncratic to this project family, not a common default.
Statelessness and immutability
- Default:
frozen=Trueon data/value objects crossing a boundary (function args, MCP payloads, config, records) — the same default as the data-modeling table above, not a separate decision. Ordinary local mutation (loop accumulators, building a result before returning it) stays conventionally mutable; don't route around Python's own idioms to avoid it. - Legitimate stateful exceptions, not edge cases to explain away: caches, connection pools, rate limiters. Make the state explicit, scoped, and (if concurrent) protected — not eliminated.
- One gotcha across every immutability mechanism: freezing a container only freezes the container,
never its contents (
obj.items.append(x)works fine on a frozen dataclass with alistfield).Final/ClassVarhave zero runtime enforcement — a type checker actually running is what makes them real (see the scaffolding plan's basedpyright config). - Model default: overrides. Models don't default to
frozen=True— mutable-by-default matches Python's own language default, so this is a deliberate opt-in a model won't reach for unassisted.
Command-line interfaces
- Default: Typer, for anything with subcommands, options, or a
[project.scripts]entry. Annotated types carry the CLI metadata, which is the same rule this skill already applies to data modeling — the metadata rides in the annotation, not on the right-hand side of a default. argparseonly under a genuine standard-library-only constraint, and name the constraint rather than assuming it. The real ones are narrow: a script that must run on a bare interpreter with no install step, a bootstrap that runs before any environment exists, and code shipped somewhere dependencies cannot follow (a Pyodide payload). "It's only a small script" is not one.- Typer over raw Click. Typer is built on Click, so these are not opposites — but a project reaching for raw Click is choosing more boilerplate for the same result. Click stays what it is underneath, and is the escape hatch for a Typer limitation, not a starting point.
- Not a rule about task runners.
invis for repo-local work — seeinvoke-task-conventions— and a CLI is the program a user installs. They do not compete, and a new command belongs to whichever of those two it actually is. - Model default: overrides. Left alone a model writes
argparse, because that is what the standard library offers and what most training data shows.
Type hygiene
- Scope
# type: ignore/# pyright: ignorecomments to a specific error code — never blanket- silence a line. Type real code fully, including throwaway example/snippet code — an untyped snippet reads as license to skip typing elsewhere to a pattern-matching agent. - Model default: mostly confirms. Scoped ignores are already close to default behavior; "type even throwaway snippets" is the real add — the shortcut a model takes when told "just a quick example."
- Testing a type rather than a value:
assert_typecompares the declared type exactly, and a function type carries its parameter names — a decorated task body is(c: Context) -> None, which noCallable[[Context], None]expression can spell, soassert_typecan never match it. Don't read that as the assertion failing; it is the precision doing its job. Use an annotated assignment instead (body: Callable[[Context], None] = obj.body), which still fails if the type degrades toAny(viareportAny) or becomes some other concrete callable. Note that such assertions are checked by the type checker and are no-ops at runtime, so say so in the file — a green pytest run is not evidence about any of them. - Model default: overrides. Reaching for
assert_typeis the natural first move for "prove this type is what I think", and its exactness is easy to misread as a broken assertion rather than a precise one.
Package layout: src/ over flat
- Default: any installable/importable package in this family uses
src/<pkg_name>/, not a flat<pkg_name>/at repo root. A repo's owntasks.py/tasks/invoke entrypoint isn't a package and stays flat at repo root regardless — every repo in this family has one, it's never installed or imported elsewhere, and this convention governs the thing that gets built into a wheel and imported, not that repo's own tooling scripts. (Confirmed live 2026-08-19 onpower-user-linux-setupitself: itstasks/holds ~25 modules of repo-specific invoke tasks, not a distributable library — moving it undersrc/would also collide with invoke's ownFilesystemLoader, which walks upward from cwd for a literaltasks.py/tasks/__init__.pyand never consults an installed copy.) - Why: a flat layout lets
pytest/an import silently resolve to the uninstalled, cwd copy of the package instead of what's actually installed (Python puts the cwd first on the import path) — masking real packaging bugs (a missing sub-package, an unincluded resource file) until a real user installs it.src/makes the project root itself un-importable, so both a test run and an editable install are forced through the same path a real install goes through. Seereferences/rationale.md§14 for the full PyPA/Hynek Schlawack citations. - Escalate to: nothing — this is the default, not an escalation path. A pure script never meant to
be installed/imported elsewhere (a one-off notebook,
tasks.pyitself) is out of scope for this convention entirely, not an exception to it. - Model default: overrides. Flat layout (package directory directly at repo root) is what most
quick/tutorial code — and most models, absent instruction — default to;
src/is a deliberate opt-in.
Async and concurrency
- Snippet:
references/snippets/async-fanout.py - Default: plain
def, notasync def, for MCP tool functions — FastMCP already dispatches sync tools onto a thread pool (anyio.to_thread.run_sync), giving real concurrency for free, and 100% of this family's existing tool code is sync. For genuine fan-out code (an orchestrator calling several MCP clients concurrently), default toasyncio.TaskGroup, notasyncio.gather()— it cancels sibling tasks on the first uncaught exception wheregather()leaves them running orphaned. Reach forgather(return_exceptions=True)-style partial tolerance only via a per-childtry/exceptinside aTaskGroup, not baregather. - Why:
gather()'s siblings-keep-running-after-a-failure behavior is a real resource leak for a deliberately throttled/session-holding "polite" client — one site erroring shouldn't leave other sites' rate-limited calls or browser sessions running unobserved. - Escalate to:
asyncio.Semaphoreto cap concurrent in-flight calls at a fan-out layer — a different axis from a per-site rate limiter (concurrency count vs. request spacing), compose both rather than picking one. If a sync call is unavoidable insideasync defcode,asyncio.to_thread()is the stdlib escape hatch (the same mechanism FastMCP already uses internally for sync tool dispatch). - Don't: run a blocking call (
time.sleep(), athreading.Lock-based throttle, a sync HTTP call) directly insideasync defcode — it freezes the entire event loop for its duration, not just the calling task. - Model default: partial. Models write async syntax correctly; the specific choice of
TaskGroupovergather, and the sync/async tool-function boundary FastMCP imposes, aren't something a model infers without being told the framework's actual dispatch mechanism.
AnyIO as the async API
For a project whose async surface is more than one fan-out — a store, a bot, a service — write
against AnyIO rather than asyncio directly, and run it on the asyncio backend. All four
points confirmed at source.
- The reason is safety and structure, not portability. A task group cannot orphan a task the way
a dropped
create_taskhandle can; cancellation propagates through cancel scopes instead of being reimplemented per call site; and one set of primitives replaces choosing betweengather/wait/as_completed/wait_forwith their differing cancellation semantics. The portability argument is the weakest one and should not be the headline — stdlibasyncio.TaskGroupalready gives the fan-out case above most of the structure, so the AnyIO decision is about the rest of the surface. - asyncio is the backend to be on; trio is a separate choice most projects should decline. The
ecosystem is asyncio: SQLAlchemy's async support is asyncio-specific (its
util/concurrency.pyusesasyncio.Lock/asyncio.Runnerand greenlet, and "trio" appears nowhere in the library), as areasyncpgand Starlette. AnyIO's full API is available on asyncio, so this costs nothing. - The pytest plugin parametrizes over installed backends only. The
anyio_backendfixture usesget_available_backends(), which returns backends that actually import (AnyIO ≥ 4.12) — so with trio absent, every test runs once, on asyncio. Pinning the fixture is a one-line statement of intent, not a fix. Worth knowing both halves, because the docs' phrase "all supported backends" reads as "all backends that exist". - Don't install
pytest-asyncioalongside it — AnyIO's own docs call out the conflict in auto mode, and AnyIO's plugin ships with AnyIO, so there is nothing extra to install. - Bridging a synchronous entrypoint (a CLI) to an async layer:
asyncer'srunnifyis the small wrapper for exactly that, over hand-rolledasyncio.runplumbing at each command. - Model default: overrides. A model reaches for bare
asyncioby default and treats AnyIO as a trio-compatibility library, which inverts the actual reason to adopt it.
HTTP client, sessions, timeouts, and retry/backoff
- Snippet:
references/snippets/http-retry.py - Default:
httpxfor any new plain-HTTP fetch path (requests-compatible API, safer default timeouts, async-ready). OneClient/Sessionper fetcher instance, constructed once and reused for its lifetime — never a new connection per request. An explicit, site-tuned timeout on every request, regardless of client.tenacityfor retry/backoff: exponential with jitter, scoped to a narrow retryable-status set (429/502/503/504) and transient network exceptions, never plain 4xx "real answers" like 404/403 — and honor a response'sRetry-Afterheader when present, in preference to the computed delay. - Why: connection reuse and conservative,
Retry-After-respecting retry aren't just performance — they're direct service to a "polite," rate-limited client's actual mission; retrying aggressively or opening a fresh connection per request is antithetical to it. - Escalate to: nothing — this is the default, not an escalation path. Existing working code using
requestsisn't something to churn to httpx without a concrete driving need (see Modularity's lean-toward-duplication stance). - Don't: assume any HTTP client's own built-in retry covers HTTP-level conditions — httpx's/
requests' built-in retry is connection-level only; a 503 or a
Retry-After-bearing 429 needs an actual retry library or hand-rolled logic layered on top. If a rate limiter's throttle call wraps a retried function, the retry must happen inside the throttled call, not around it, or a slow retry's backoff sleep holds the site-wide rate-limit lock too. - Model default: mostly confirms, one real gap. Models already default to setting a timeout and
reusing a session/client; they do not reliably default to jittered (vs. plain
linear/exponential) backoff or to honoring
Retry-After— the retry-scoping specifics are the real add here.
Full rationale
See references/rationale.md — the full citation trail: sources
consulted, options considered and rejected per topic, and the reasoning behind every branch/
escalation path above. Sections keep their original numbers, so the file has gaps where the testing
and MCP sections were moved out to their own skills on 2026-08-31 — the numbers are stable
references, not a sequence. §1–8 were originally researched and written up in
plans/2026-08-15-python-conventions.md, migrated here once that plan promoted from idea to this
built skill (the plan file has since been retired — see git history if you need it). §12 onward
(async/concurrency, HTTP/retry, and src/-layout) were researched directly against this skill after
that promotion, with no separate plan-file stage.
Starter snippets
references/snippets/ has one real, ruff-clean, self-contained Python file per pattern above — each
directly copy-pasteable rather than a tutorial to adapt. Each topic's "Snippet:" line above links
straight to its own file.
Editing this skill
This file is copied into ~/.agents/skills/python-conventions at install time, never symlinked,
so editing the deployed copy is local drift — the exact thing this skill exists to prevent, and
it reaches no other machine. Edit the source in the repo this was installed from, push, and re-run
skills add <that source> --global --skill python-conventions to refresh every project's copy. If
you installed it from someone else's repo rather than your own fork, the source is theirs: open an
issue or a pull request there instead.