# Python Programmer Modern Python. Type-strict, stack-first, async-correct. ## Philosophy The type checker is your compiler. Make illegal states unrepresentable. Parse at boundaries. Own resources explicitly. Every function has a contract; the type system enforces it. ## Hard rules These are deliberate project choices. Violations are always wrong, not "style preferences". ### Tooling | Category | Use | Never | |---|---|---| | Package manager | `uv` | pip, poetry, conda, pipenv | | Type checker | `basedpyright` (`typeCheckingMode = "all"`) | pyright, mypy | | Linter + formatter | `ruff` (`select = ["ALL"]`) | flake8, black, isort, autopep8 | | Async runtime | `anyio` | `import asyncio` | | Data | `polars` + `duckdb` + `numpy` | pandas | | Web framework | FastAPI + Pydantic v2 | Flask, Django REST | | ORM | SQLAlchemy 2.x async | Django ORM, Tortoise | | HTTP client | [`httpx2`](https://github.com/pydantic/httpx2) | requests, aiohttp, httpx | | Testing | `pytest` | unittest | | CLI | `typer` + `rich` | argparse, click, fire | ### The iron list 1. **Frozen by default** — `@dataclass(frozen=True, slots=True)`. Pydantic: `model_config = ConfigDict(frozen=True)`. Mutable only when mutation is the documented purpose. 2. **NewType for distinct IDs** — `UserId = NewType("UserId", int)`. Never pass raw `int` where a branded type exists. 3. **`match` only for variants, `if` only for booleans** — **NEVER** use `if/elif/else` to discriminate on type (`isinstance`), enum value, or literal variant. `match/case` is mandatory for these — non-negotiable. **ALWAYS** end with `case unreachable: assert_never(unreachable)` — bare `case _: pass` and `case _: raise ValueError` are banned (they silently swallow new variants). `if/else` is fine only for boolean expressions, range checks, and predicate calls that aren't variant discrimination. See "Why `if/elif` on variants is banned" below for examples. 4. **Protocol over ABC** — `typing.Protocol` for interfaces. ABC only when you need shared method implementation. 5. **No raw dicts in signatures** — params and returns use `TypedDict`, `dataclass`, or Pydantic model. Internal scratch dicts are fine. 6. **Parse, don't validate** — constructors produce typed objects or raise. Never pass unvalidated data deeper into the call stack. 7. **Typed errors** — error types are dataclasses or exceptions with typed fields. Never `raise ValueError("something")` with a bare string. Use union returns when the caller is within 1-2 call levels and must handle the outcome (repository → service). Use exceptions when the error should propagate up many layers to a boundary handler (service → HTTP handler). 8. **Final for constants** — module-level constants use `Final`. Mutable module globals are a code smell. 9. **Explicit None** — annotate `-> X | None`. Never return `None` from a function whose signature omits it. 10. **Context managers for resources** — files, DB connections, HTTP clients, locks. No manual `.close()`. 11. **No Any, no object** — both are banned as type annotations. `object` erases all structural information (zero callable attributes, zero narrowing). Use `Protocol` (structural typing), `TypeVar` (generic pass-through), explicit union (known variants), or `TypedDict` (dict shapes). 12. **No cast** — `cast()` is banned. Redesign the types. 13. **No type: ignore** — fix the type error. The checker is right; you are wrong. 14. **No broad except** — `except Exception` and `except BaseException` are banned. Catch the **specific** exception you expect. A broad catch swallows bugs you need to see — `KeyError`, `AttributeError`, `TypeError` all vanish silently. If you genuinely need a catch-all at a top-level boundary (CLI entry, HTTP handler), use `# noqa: BROAD_EXCEPT_OK` and log + re-raise. ### Typing and safety - `basedpyright` in `typeCheckingMode = "all"`. Every public function has full annotations. Internal helpers: annotate return type; parameter types may be inferred. - `ruff` with `select = ["ALL"]`. Override specific rules per project in `pyproject.toml`, never globally disable the strict baseline. - Every new function must have a `docstring` unless its name + signature makes it completely obvious (e.g. `def full_name(first: str, last: str) -> str:`). - Use `X | Y` union syntax (PEP 604), never `Union[X, Y]` or `Optional[X]`. ### Why `object` is banned `object` pretends to be safe ("it's the top type!") but gives **zero** narrowing and **zero** attributes. Even `Any` is more honest — it admits the boundary is untyped. ```python # BANNED def process(data: object) -> object: ... def store(items: list[object]) -> None: ... results: dict[str, object] = {} # GOOD — Protocol for structural typing class Serializable(Protocol): def serialize(self) -> bytes: ... def process(data: Serializable) -> ProcessResult: ... # GOOD — TypeVar for generic pass-through def identity[T](x: T) -> T: ... def first[T](items: Sequence[T]) -> T: ... # GOOD — explicit union for known variants def parse(raw: str | bytes) -> Document: ... ``` ### Why `if/elif` on variants is banned `if/elif/else` chains on type, enum, or literal values lose compile-time exhaustiveness. When a new variant is added, nothing warns you. `match/case` + `assert_never` does. ```python # BANNED — if/elif for type discrimination if isinstance(event, Click): handle_click(event.x, event.y) elif isinstance(event, Scroll): handle_scroll(event.delta) else: raise ValueError(f"Unknown: {event}") # runtime bomb # BANNED — if/elif for enum discrimination if status == Status.PENDING: start_review() elif status == Status.ACTIVE: continue_processing() elif status == Status.CLOSED: archive() # BANNED — non-exhaustive match (swallows new variants) match event: case Click(x, y): handle_click(x, y) case _: pass # GOOD — exhaustive match with assert_never match event: case Click(x=x, y=y): handle_click(x, y) case Scroll(delta=delta): handle_scroll(delta) case unreachable: assert_never(unreachable) # GOOD — enum match match status: case Status.PENDING: start_review() case Status.ACTIVE: continue_processing() case Status.CLOSED: archive() case unreachable: assert_never(unreachable) ``` `if/else` is fine for boolean conditions and range checks — things that aren't variant discrimination: ```python # FINE — boolean, not variant if age >= 18: grant_access() else: deny_access() ``` ### Why broad `except` is banned `except Exception` catches **every** non-system exception — `KeyError`, `TypeError`, `AttributeError`, `ValueError` all vanish. You lose the stack trace that would have told you exactly what went wrong. The fix is always to name the exception you expect. ```python # BANNED — swallows bugs try: result = api.fetch(url) except Exception as e: logger.error(e) return None # BANNED — catch-and-ignore try: parse(data) except Exception: pass # GOOD — catch what you expect try: result = api.fetch(url) except httpx.HTTPStatusError as e: logger.error("API %d: %s", e.response.status_code, e.request.url) return None except httpx.ConnectError: raise ServiceUnavailableError(service="api") from None # GOOD — top-level boundary (only place broad catch is acceptable) def main() -> int: # noqa: BROAD_EXCEPT_OK try: return run() except Exception: logger.exception("unhandled error") return 1 ``` ### Async - `import asyncio` is **BANNED**. Use `import anyio`. - For background tasks, use `anyio.create_task_group`. Never fire-and-forget with `asyncio.create_task`. - For concurrency gates, use `anyio.CapacityLimiter` (not `asyncio.Semaphore`). - Load `async-anyio.md` when writing async code for the full pattern library. ### Data modeling — which container, when All model fields carry type annotations. No `Any`, no untyped dicts in public APIs. Use `polars` + `duckdb` for data. pandas is never the right answer in this stack. | Situation | Use | |---|---| | User input, API request/response | `Pydantic BaseModel (frozen=True)` | | Internal value object (no I/O) | `@dataclass(frozen=True, slots=True)` | | Function with multiple outcomes | Union of frozen dataclasses + `match` | | Dict shape for JSON compat / `**kwargs` | `TypedDict` | | Fixed constants | `StrEnum` / `IntEnum` | | Distinct primitive (UserId vs MovieId) | `NewType` | | Contract / capability | `Protocol` | | Contract + shared implementation | `ABC` | | ORM model (SQLAlchemy) | `Mapped[]` — inherently mutable, `# noqa: MUTABLE_OK` | | Config from env vars | `pydantic-settings BaseSettings` | **The one rule**: data crosses trust boundary → Pydantic. Everything else → dataclass. Load `data-modeling.md` for the full decision flowchart and comparison matrix. ### When frozen=True does not apply - **ORM models** — SQLAlchemy `Mapped[]` requires mutation. Use `# noqa: MUTABLE_OK`. - **Builder / accumulator** — object exists to be mutated (counter, buffer, state machine). Docstring must explain why. - **Pydantic Settings** — tests override fields. Mutable is acceptable. If you need `# noqa: MUTABLE_OK`, the class docstring must say why mutation is required. ### Libraries Canonical defaults (override only if `pyproject.toml` explicitly picks something else): | Domain | Library | Reason | |---|---|---| | CLI | `typer` | Type-annotated CLI from function sigs | | Pretty output | `rich` | Tables, progress, tracebacks, markdown | | HTTP client | [`httpx2`](https://github.com/pydantic/httpx2) | Next-gen HTTP client (Pydantic stewardship), HTTP/2, brotli+zstd. Always `httpx2[http2,brotli,zstd]`. See `httpx2-optimization.md` | | Validation | `pydantic` v2 | Fast native validator, JSON Schema | | Web API | `fastapi` | Async, Pydantic-native, OpenAPI | | ORM | `sqlalchemy` 2.x async | `Mapped[]` types, async sessions | | DB driver (Postgres) | `asyncpg` (via SQLAlchemy) | Fastest PG driver | | AI agents | `pydantic-ai` | Typed deps, structured output | | TUI | `textual` | Rich-based, CSS layout, widgets | | Logging | `rich.logging.RichHandler` | Pretty; swap to `structlog` in prod | ## pyproject.toml — the one true config Scaffold a new project with all strict defaults pre-configured: ```bash uv run ../../scripts/python/new-project.py myproject uv run ../../scripts/python/new-project.py myproject --path ./workspace uv run ../../scripts/python/new-project.py myproject --lib # publishable library ``` Creates via `uv init`, then injects basedpyright `typeCheckingMode = "all"` + ruff `select = ["ALL"]` + pytest strict. Cross-platform (macOS, Linux, Windows). For manual setup: `uv init --app myproject`, then load `pyproject-strict.md`. ## PEP 723 — inline script metadata (mandatory for ALL scripts) Every `.py` script — even throwaway — MUST use PEP 723 inline metadata with the `# ─── How to run ───` comment block. No venv, no `requirements.txt`. The script IS the environment spec. A script without the usage comment block is incomplete. Scaffold with: `uv run ../../scripts/python/new-script.py --deps "httpx2[http2,brotli,zstd]"` (writes to temp dir by default, `--output` for specific path). Load `one-liners.md` for full patterns, examples, and anti-patterns. ## Reference loading Load on demand — not all at once. | Need | Load | |---|---| | Full pyproject.toml config | `pyproject-strict.md` | | Type patterns (NewType, Final, enums, narrowing) | `type-patterns.md` | | Data modeling (container choice, frozen, parse-don't-validate) | `data-modeling.md` | | Error handling (typed errors, union returns, exhaustive match) | `error-handling.md` | | Async patterns (anyio) | `async-anyio.md` | | Data processing (polars / duckdb) | `data-processing.md` | | FastAPI + SQLAlchemy stack | `fastapi-stack.md` | | Library decision tree | `libraries.md` | | **httpx2 optimization** (MUST load for any network code) | `httpx2-optimization.md` | | **orjson** (when JSON is in the hot path; FastAPI/Pydantic v2 integration) | `orjson-stack.md` | | One-liner scripts (PEP 723) | `one-liners.md` | | PydanticAI agents | `pydantic-ai.md` | | Textual TUI | `textual-tui.md` | ## httpx2 — mandatory for ALL network requests Every outgoing HTTP call MUST use [`httpx2`](https://github.com/pydantic/httpx2) (`httpx2[http2,brotli,zstd]`). Never `requests`, never `aiohttp`, never the original `httpx`. **ALL optimizations are ON by default — not optional, not progressive, not "nice to have".** A bare `httpx2.AsyncClient()` is a bug — treat it like a lint violation. The correct way is the factory pattern in `httpx2-optimization.md` with: HTTP/2 enabled, tuned connection pool (200/40/30s), split timeouts (5/30/10/10), transport retries (3), TCP_NODELAY, follow_redirects, and event hooks for observability. When writing or reviewing ANY network code, **ALWAYS load `httpx2-optimization.md`** and use the factory pattern verbatim. No exceptions. ## No-excuse audit Violations caught by `../../scripts/python/check-no-excuse-rules.py`. Run after every edit session. | Rule ID | Catches | Opt-out | |---|---|---| | `cast-any` | `cast(Any, ...)` | None — redesign types | | `type-ignore` | `# type: ignore` | None — fix the type | | `pyright-ignore` | `# pyright: ignore` | None — fix the type | | `bare-except` | `except:` with no class | None — name the exception | | `silent-except` | `except X: pass` / `except X: ...` | None — handle or re-raise | | `no-asyncio` | `import asyncio` | `# noqa: ANYIO_OK` | | `no-pandas` | `import pandas` | `# noqa: PANDAS_OK` | | `mutable-dataclass` | `@dataclass` without `frozen=True` | `# noqa: MUTABLE_OK` | | `missing-slots` | `@dataclass` without `slots=True` | `# noqa: SLOTS_OK` | | `raw-dict-return` | `-> dict` in function return type | `# noqa: DICT_OK` | | `missing-assert-never` | `match` block without `assert_never` default | `# noqa: MATCH_OK` | | `generic-exception` | `raise ValueError("...")` / `raise TypeError("...")` with bare string | `# noqa: GENERIC_ERR_OK` | | `no-object` | `object` used as type annotation (param, return, generic arg) | `# noqa: OBJECT_OK` | | `if-elif-on-variant` | `if isinstance()`/`if x == Enum.V` chain that should be `match/case` | `# noqa: IF_VARIANT_OK` | | `oversized-module` | File exceeds 250 pure LOC (non-blank, non-comment) | `# noqa: SIZE_OK` | | `broad-except` | `except Exception` / `except BaseException` (too broad) | `# noqa: BROAD_EXCEPT_OK` | Fix every violation before declaring work done. basedpyright + ruff strict config catches the rest. ## In tests Tests are strict too, with these exceptions (already configured in `pyproject.toml` per-file-ignores): | In tests you may | Why | |---|---| | Use `assert` | That's how pytest works (`S101` ignored) | | Use magic numbers | Test data (`PLR2004` ignored) | | Access `_private` members | Testing internals (`SLF001` ignored) | | Skip docstrings | Test names are the docs (`D` ignored) | | Have unused function args | Fixtures (`ARG` ignored) | Tests still follow the iron list — frozen dataclasses, typed errors, exhaustive match. If test fixtures need mutable state, use `# noqa: MUTABLE_OK` on the fixture class. ## Existing codebases When editing an existing file that doesn't follow these rules: **write new code in strict style, don't refactor existing code in the same change.** Mixing feature work with style migration makes reviews harder and bugs likelier. ## Activation This skill activates whenever you are writing or modifying any `.py` file. Even one-off scripts get the strict treatment — that is the whole point of PEP 723 + uv: production hygiene with throwaway ergonomics.