python-patterns · git:20260912.a10a790 · 2026-09-12 · sha256 2d21b5de0b3114ba
python-patterns git:20260912.a10a790A
Immutable. This exact content never changes and is served at /api/v1/blob/2d21b5de0b3114ba.
---
name: python-patterns
description: Provides idiomatic modern Python (3.11+) patterns covering type hints, dataclasses and pydantic at boundaries, pathlib, context managers, generators, async basics, explicit error handling, and a uv/ruff/pytest project layout. Use when writing, reviewing, or refactoring Python code or setting up a Python project.
---
# Python Patterns
Readable, explicit, typed. These patterns target Python 3.11 or newer with `uv` for
environments, `ruff` for lint and format, and `pytest` for tests.
## When to use
- Writing new Python modules, packages, scripts, or services.
- Reviewing Python diffs for idiom, typing, and error handling.
- Setting up or modernising a Python project's tooling.
## Core principles
- **Readability over cleverness.** Names say what things hold; comprehension over a three-line loop, a function over a three-clause comprehension.
- **Explicit over implicit.** No import-time side effects, no hidden global configuration.
- **EAFP where the exception is the normal control flow** (`try: d[key] except KeyError`), LBYL where a check is cheaper and clearer (`if path.exists()` before a destructive operation).
- **Immutability by default.** Frozen dataclasses, tuples, and new objects returned from functions; mutate in place only when the API is designed for it.
## Typing
Annotate every function signature. Use built-in generics and the union operator.
```python
from collections.abc import Iterable, Iterator, Callable, Sequence
from typing import Protocol, TypeVar, TypeAlias, Self
def first(items: Sequence[T]) -> T | None:
return items[0] if items else None
JSON: TypeAlias = dict[str, "JSON"] | list["JSON"] | str | int | float | bool | None
class Renderable(Protocol):
def render(self) -> str: ...
def render_all(items: Iterable[Renderable]) -> str:
return "\n".join(item.render() for item in items)
```
- Prefer `collections.abc` types (`Iterable`, `Mapping`, `Sequence`) for parameters and concrete types (`list`, `dict`) for return values.
- `Protocol` for structural typing instead of abstract base classes when callers just need a shape.
- `Self` for fluent methods; `Literal[...]` for closed string sets; `TypedDict` for dict-shaped external payloads you do not control.
- Run `pyright` or `mypy --strict` in CI. Untyped third-party libraries get a `py.typed` stub or a narrow `cast` at the import boundary, not `Any` spreading through the codebase.
## Data at the boundaries
Two tools, two jobs:
- **`dataclass`** for internal data you construct yourself. Frozen unless mutation is the point; `slots=True` when you create many.
- **`pydantic.BaseModel`** at system boundaries: request bodies, config files, API responses, message payloads. It validates and coerces once, on the way in, then the rest of the code trusts the types.
```python
from dataclasses import dataclass, field
from datetime import datetime, UTC
from pydantic import BaseModel, Field, field_validator
@dataclass(frozen=True, slots=True)
class Money:
amount_minor: int
currency: str
def add(self, other: "Money") -> "Money":
if other.currency != self.currency:
raise ValueError(f"currency mismatch: {self.currency} vs {other.currency}")
return Money(self.amount_minor + other.amount_minor, self.currency)
class CreateOrder(BaseModel):
customer_id: str = Field(min_length=1)
items: list[str] = Field(min_length=1)
note: str | None = None
@field_validator("items")
@classmethod
def no_duplicates(cls, items: list[str]) -> list[str]:
if len(set(items)) != len(items):
raise ValueError("duplicate items")
return items
class Settings(BaseModel):
database_url: str
log_level: str = "INFO"
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
```
Parse external input into a model at the edge (`CreateOrder.model_validate(payload)`); raise a
clear error there. Never pass raw `dict[str, Any]` deeper than the boundary. `NamedTuple` remains
fine for tiny immutable records like coordinates.
## Paths and files
`pathlib` everywhere; `os.path` string juggling is legacy.
```python
from pathlib import Path
CONFIG_DIR = Path.home() / ".myapp"
def load_config(path: Path = CONFIG_DIR / "config.toml") -> Settings:
if not path.is_file():
raise ConfigError(f"config not found: {path}")
return Settings.model_validate(tomllib.loads(path.read_text(encoding="utf-8")))
for source in Path("data").glob("*.csv"):
target = source.with_suffix(".parquet")
```
Always pass `encoding="utf-8"` when reading or writing text. Use `Path.resolve()` and check
`is_relative_to()` before writing to a path derived from user input.
## Context managers
Anything that acquires must release, and `with` is how Python says so.
```python
from contextlib import contextmanager
import time
@contextmanager
def timed(label: str) -> Iterator[None]:
start = time.perf_counter()
try:
yield
finally:
logger.info("%s took %.3fs", label, time.perf_counter() - start)
class Transaction:
def __init__(self, conn: Connection) -> None:
self._conn = conn
def __enter__(self) -> Self:
self._conn.begin()
return self
def __exit__(self, exc_type, exc, tb) -> bool:
if exc_type is None:
self._conn.commit()
else:
self._conn.rollback()
return False # never swallow the exception
```
Use `contextlib.ExitStack` when the number of resources is dynamic. Return `False` from `__exit__`
unless suppressing is the whole point.
## Iteration and generators
```python
# Generator: lazy, constant memory
def read_records(path: Path) -> Iterator[Record]:
with path.open(encoding="utf-8") as handle:
for line in handle:
yield Record.parse(line.rstrip("\n"))
total = sum(r.amount for r in read_records(path)) # generator expression, no list
# Comprehension for simple transforms; a named function once it needs two conditions
active_emails = [u.email for u in users if u.is_active]
```
Use `enumerate`, `zip(strict=True)`, `itertools` (`batched`, `groupby`, `chain`) before writing
index arithmetic. Build strings with `"".join(parts)` or an f-string, never `+=` in a loop.
## Async basics
Use `asyncio` for I/O-bound concurrency (HTTP, sockets, database drivers with async support).
Use a thread pool for blocking libraries you cannot change. Use processes for CPU-bound work.
```python
import asyncio
import httpx
async def fetch_json(client: httpx.AsyncClient, url: str) -> JSON:
response = await client.get(url, timeout=10.0)
response.raise_for_status()
return response.json()
async def fetch_all(urls: list[str]) -> list[JSON | BaseException]:
async with httpx.AsyncClient() as client:
async with asyncio.TaskGroup() as group:
tasks = [group.create_task(fetch_json(client, url)) for url in urls]
return [task.result() for task in tasks]
# Blocking call inside async code
data = await asyncio.to_thread(legacy_blocking_read, path)
```
Rules: never call blocking I/O or `time.sleep` inside a coroutine; bound concurrency with
`asyncio.Semaphore` when fanning out to an external service; prefer `TaskGroup` (3.11+) over
bare `gather` so failures cancel siblings and propagate as an `ExceptionGroup`; pass timeouts explicitly.
## Error handling
- Catch specific exceptions. A bare `except:` or `except Exception: pass` is a bug.
- Chain with `raise ... from e` so the original traceback survives.
- Define a small hierarchy rooted at one application exception; callers catch the root when they
want "anything ours", the leaf when they can act on it.
- Log with `logger.exception(...)` at the level that handles the error, once. Do not log and re-raise
at every layer.
- User-facing messages are friendly; logs carry full context.
```python
class AppError(Exception):
"""Base for all errors raised by this application."""
class ConfigError(AppError): ...
class NotFoundError(AppError): ...
def load_user(user_id: str) -> User:
try:
row = repo.get(user_id)
except RepositoryError as e:
raise AppError(f"could not load user {user_id}") from e
if row is None:
raise NotFoundError(f"user not found: {user_id}")
return User.model_validate(row)
```
## Logging and configuration
- `logging.getLogger(__name__)` per module; configure handlers once at the entry point.
- Lazy formatting: `logger.info("user %s logged in", user_id)`, not an f-string, so the string
is built only when the level is enabled.
- Configuration comes from environment variables or a file, validated into a `Settings` model at
startup. Missing required settings fail fast with a clear message. No secrets in source.
## Project layout with uv, ruff, pytest
```
myproject/
pyproject.toml
uv.lock
README.md
src/
mypackage/
__init__.py
__main__.py # python -m mypackage
config.py
orders/ # organise by feature, not by layer
__init__.py
models.py
service.py
repository.py
tests/
conftest.py
orders/
test_service.py
```
```toml
[project]
name = "mypackage"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = ["pydantic>=2", "httpx>=0.27"]
[dependency-groups]
dev = ["pytest>=8", "pytest-cov", "pytest-asyncio", "ruff", "pyright"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.ruff]
line-length = 100
target-version = "py311"
[tool.ruff.lint]
select = ["E", "F", "I", "N", "UP", "B", "SIM", "PTH", "RUF"]
[tool.pyright]
strict = ["src"]
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-q --strict-markers"
asyncio_mode = "auto"
```
Everyday commands:
```bash
uv sync # create .venv and install locked deps
uv add httpx # add a dependency and update the lock
uv add --group dev pytest-cov # dev dependency
uv run pytest # run tests in the project env
uv run ruff check . --fix && uv run ruff format .
uv run pyright
uv run python -m mypackage
```
The `src/` layout guarantees tests import the installed package, not the working directory.
Ruff replaces black, isort, flake8, and pyupgrade; do not run them alongside it.
## Testing conventions
- One test module per source module, mirrored under `tests/`.
- Fixtures in `conftest.py`; prefer small factory functions over deep fixture graphs.
- `pytest.raises` with `match=` for error paths. `tmp_path` for filesystem tests. `monkeypatch`
for environment variables.
- Parametrize instead of copy-pasting near-identical tests.
- Mock at boundaries (HTTP, clock, database) using `respx`, `freezegun`, or a fake repository
class; do not mock your own modules.
## Anti-patterns
```python
def add(item, items=[]): ... # mutable default; use None and create inside
if type(obj) == list: ... # use isinstance
if value == None: ... # use `is None`
from os.path import * # explicit imports only
except: pass # catch specific, handle or re-raise
print(f"debug {x}") # use logging; no print in library code
open(path).read() # no context manager, no encoding
results = []; for x in xs: results.append(f(x)) # [f(x) for x in xs]
```
## Review checklist
- [ ] Every public function is annotated; `Any` appears only at a justified boundary.
- [ ] External input is validated into a model at the edge.
- [ ] Files, sockets, locks, and transactions are opened with `with`.
- [ ] No bare `except`; exceptions are chained; errors are logged once.
- [ ] `pathlib` and `encoding="utf-8"` for all file access.
- [ ] Async code has no blocking calls and bounds its concurrency.
- [ ] `ruff check`, `ruff format --check`, `pyright`, and `pytest` all pass.
## Related skills
- `opm:tdd-workflow` - pytest RED/GREEN cycle with `uv run pytest`.
- `opm:verification-loop` - the uv/ruff/pytest gate sequence.
<!-- Adapted from affaan-m/ecc (MIT) -->