A value with rules of its own — a URL, a path, a closed set, an identifier — gets a type of its own. Passing it as a bare string discards those rules at the first function boundary and leaves every consumer re-deriving them. Added as a subsection of Architecture Preferences, with a table mapping the common cases to the type to reach for in any language, the reasoning, and the boundary rule: parse once at the edge, keep the type inward, serialize back only where the wire actually demands a string. Notes the counter-case, so this is not read as a mandate to wrap every string. python.md gains the concrete choices: yarl.URL (already present wherever aiohttp is), pathlib, StrEnum, and NewType for non-interchangeable identifiers.
7.3 KiB
name, description
| name | description |
|---|---|
| python | Use this skill when writing, linting, type-checking, testing, or packaging Python — including async code, CLI tools, bot cogs, and library packages. Covers strict typing with mypy, ruff configuration, virtualenv discipline, async patterns, and packaging. |
Python — Rules & Preferences
Extends coding-dev.md. Everything there applies; this adds Python specifics.
For the surrounding build and deploy scripts, see bash.md.
1. The Gate
ruff format --check .
ruff check .
mypy --strict .
pytest
All four clean before anything is called done. Configure them in pyproject.toml so the
editor, the CLI, and CI all read the same settings.
2. Typing — Strict
Every function gets annotated, including -> None. Type hints are not optional
documentation; they are the checker's input.
[tool.mypy]
strict = true
warn_unreachable = true
warn_return_any = true
disallow_untyped_defs = true
- Never bare
Any. Use aProtocol, aTypeVar, orobject+ narrowing. X | NoneoverOptional[X];list[str]overList[str](modern syntax, notypingimport).TypedDictor a dataclass for structured dicts — notdict[str, Any].LiteralandEnumfor closed sets of values, not bare strings.@dataclass(frozen=True, slots=True)for value objects.- Third-party library missing stubs: install
types-*if it exists, otherwise a narrowly scoped# type: ignore[import-untyped]with a comment — never a blanket module-level ignore.
Value types over primitives
The general rule is in coding-dev.md §7; Python's concrete
choices are:
yarl.URLfor URLs, not f-string assembly. It ships withaiohttp, so any async HTTP project already has it.URL(base) / "api" / "v4" / "users"joins without double slashes, and.with_query({...})escapes parameters that string formatting would corrupt.pathlib.Pathfor paths, neveros.path+str. Ruff'sPTHrules enforce this; keepos.fdopenwhere a descriptor rather than a name is being adopted.enum.StrEnum(3.11+) where a closed set must also serialize as a string, so the wire format stays a string without the code comparing bare literals.typing.NewTypefor identifiers that should not be interchangeable — aUserIdand aRepoIdare bothintand nothing otherwise stops them being swapped.- Parse at the boundary: take
strin the public signature if callers supply one, convert immediately, and keep the real type internal.
3. Linting
ruff is the linter and formatter (it replaces flake8, isort, black, pyupgrade, and most
plugins). Enable a broad rule set rather than the default minimum:
[tool.ruff.lint]
select = ["E", "F", "W", "I", "N", "UP", "B", "A", "C4", "SIM", "RUF", "ASYNC", "S", "PTH"]
S (bandit security rules) and ASYNC are worth the noise. As always: fix, don't suppress —
and when you must suppress, use a specific code (# noqa: S603) with a reason.
4. Environments
- Always a virtualenv or project-managed environment (
uv,poetry,pdm,venv). Never install into the system interpreter, and neverpip install --useras a workaround for an immutable OS — use a container or a venv. uvis the preferred installer/resolver where free choice exists; it is dramatically faster and lockfile-native.- Commit the lockfile. Pin the Python version in
pyproject.toml(requires-python). pathlib.Pathoveros.patheverywhere (ruff'sPTHrules enforce this).
5. Errors & Logging
- Specific exceptions only.
except Exception:needs a reason; bareexcept:is never acceptable (it catchesKeyboardInterruptandSystemExit). - Never swallow silently. If you catch and continue, log at
warningor above. raise ... from errto preserve the chain.- Custom exception types deriving from a single package-level base, so callers can catch the whole family.
loggingmodule, neverprint, in anything that isn't a CLI writing to stdout as its actual output. Onelogger = logging.getLogger(__name__)per module, at module scope.- Never log secrets — tokens, auth headers, message content, personal data.
6. Async
asynciowithasync/await. Never block the event loop — notime.sleep, no synchronousrequests, no blocking file I/O in a coroutine. Useasyncio.to_threadfor genuinely blocking calls.httpx(or the framework's own client) overrequestsin async code.asyncio.TaskGroup(3.11+) over barecreate_task— it propagates exceptions and cancels siblings instead of losing them to an unawaited task.- Every
create_taskresult must be held in a reference, or the garbage collector can cancel it mid-flight. - Cancellation is cooperative: clean up in
finally, and never swallowCancelledError. - Events over polling. Subscribe to the library's event/callback API; do not loop on a timer. Where polling is genuinely unavoidable (no event source exists), use a staggered backoff rather than a fixed interval, and say why polling was necessary.
7. Rate Limiting & External Services
When talking to a rate-limited API (a chat platform, a store, a tracker):
- Funnel outbound calls through a single queue/worker rather than calling from arbitrary call sites. It is the only place a limit can actually be enforced.
- Backoff with jitter on retry; never a tight retry loop.
- Respect
Retry-Afterand documented limits; assume undocumented ones exist. - Be extremely careful scraping real consumer services. Conservative delays, realistic headers, plenty of failsafes, and abort fast when a response looks unexpected. Assume you are one mistake away from a ban.
8. Structure
- Package layout:
src/<package>/, tests intests/. - Shared behavior goes in a real shared module, imported by each consumer — not copy-pasted between sibling packages. When three components grow the same queue/retry/ formatting logic, that is the signal to extract it.
- Reusable, generic functionality (a format parser, a protocol client) becomes its own installable package with its own repo and version, consumed as a dependency.
- Keep persistent state in the framework's own config/storage system so it survives restarts; do not invent a parallel JSON file beside it.
- File and function size limits from the general rules apply. Ruff has no line-based
function-length rule —
PLR0915(too-many-statements, default 50) andC901(cyclomatic complexity) are the closest proxies and are worth enabling, but neither measures lines. Put both the function-length and file-length limits in a pytest case (seecoding-dev.md§4).
9. Testing
pytest, withpytest-asynciofor coroutines.- Fixtures over setup/teardown methods;
tmp_pathfor anything touching the filesystem. - Regression test every fixed bug.
- Mock at the network boundary (
respx,responses), not by monkeypatching your own internals — a test that patches the code under test proves nothing. - Never exercise destructive operations against real accounts or real data. Temporary fixtures only.
pytest -p no:randomlyonly when order genuinely matters, and note why.