Files
skills-public/code-languages/python.md
T
Bluscream b08fe50e92 feat(coding-dev): require domain values to be typed, not primitives
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.
2026-09-18 18:22:26 +02:00

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 a Protocol, a TypeVar, or object + narrowing.
  • X | None over Optional[X]; list[str] over List[str] (modern syntax, no typing import).
  • TypedDict or a dataclass for structured dicts — not dict[str, Any].
  • Literal and Enum for 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.URL for URLs, not f-string assembly. It ships with aiohttp, 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.Path for paths, never os.path + str. Ruff's PTH rules enforce this; keep os.fdopen where 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.NewType for identifiers that should not be interchangeable — a UserId and a RepoId are both int and nothing otherwise stops them being swapped.
  • Parse at the boundary: take str in 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 never pip install --user as a workaround for an immutable OS — use a container or a venv.
  • uv is 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.Path over os.path everywhere (ruff's PTH rules enforce this).

5. Errors & Logging

  • Specific exceptions only. except Exception: needs a reason; bare except: is never acceptable (it catches KeyboardInterrupt and SystemExit).
  • Never swallow silently. If you catch and continue, log at warning or above.
  • raise ... from err to preserve the chain.
  • Custom exception types deriving from a single package-level base, so callers can catch the whole family.
  • logging module, never print, in anything that isn't a CLI writing to stdout as its actual output. One logger = logging.getLogger(__name__) per module, at module scope.
  • Never log secrets — tokens, auth headers, message content, personal data.

6. Async

  • asyncio with async/await. Never block the event loop — no time.sleep, no synchronous requests, no blocking file I/O in a coroutine. Use asyncio.to_thread for genuinely blocking calls.
  • httpx (or the framework's own client) over requests in async code.
  • asyncio.TaskGroup (3.11+) over bare create_task — it propagates exceptions and cancels siblings instead of losing them to an unawaited task.
  • Every create_task result must be held in a reference, or the garbage collector can cancel it mid-flight.
  • Cancellation is cooperative: clean up in finally, and never swallow CancelledError.
  • 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-After and 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 in tests/.
  • 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) and C901 (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 (see coding-dev.md §4).

9. Testing

  • pytest, with pytest-asyncio for coroutines.
  • Fixtures over setup/teardown methods; tmp_path for 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:randomly only when order genuinely matters, and note why.