docs(omo-codex): batch 95 (14 files)

This commit is contained in:
YeonGyu-Kim
2026-05-30 19:12:18 +09:00
parent 2a0d8822eb
commit c4273fe98f
14 changed files with 3863 additions and 0 deletions
@@ -0,0 +1,314 @@
# 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 <name> --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.
@@ -0,0 +1,442 @@
# AnyIO Reference: Replacing asyncio Idioms
> **Skill mandate**: `import asyncio` is BANNED. Use `import anyio` exclusively.
> This reference targets AnyIO 4.x (2026 Python projects).
---
## 1. Task Groups (The Core Primitive)
AnyIO uses **structured concurrency** via task groups. A task group is an async context manager that guarantees all child tasks finish before the block exits.
### `start_soon` — fire-and-forget
```python
import anyio
async def worker(n: int) -> None:
await anyio.sleep(1)
print(f"task {n} done")
async def main() -> None:
async with anyio.create_task_group() as tg:
for i in range(3):
tg.start_soon(worker, i)
print("all tasks finished")
anyio.run(main)
```
**Signature**: `tg.start_soon(func, *args, name=None)`
- `func` must be a **coroutine function** (not a coroutine object).
- `name` is optional, for introspection/debugging.
- No return value; exceptions propagate as `ExceptionGroup` on exit.
### `start` — wait for ready signal
Use when a task must initialize before the caller proceeds (e.g., starting a server and then connecting to it).
```python
from anyio import TASK_STATUS_IGNORED, create_task_group, run
from anyio.abc import TaskStatus
async def start_server(port: int, *, task_status: TaskStatus[None] = TASK_STATUS_IGNORED) -> None:
listener = await anyio.create_tcp_listener(local_host="127.0.0.1", local_port=port)
task_status.started() # unblocks tg.start()
await listener.serve(handler)
async def main() -> None:
async with create_task_group() as tg:
await tg.start(start_server, 8080) # blocks until task_status.started()
# server is guaranteed ready here
async with await anyio.connect_tcp("127.0.0.1", 8080) as client:
...
run(main)
```
**Rule of thumb**:
- Use `start_soon` when you don't need to know when the task is ready.
- Use `start` when the task must signal readiness before you continue.
### `create_task` — retrieving return values (AnyIO 4.14+)
```python
async def add(x: int, y: int) -> int:
return x + y
async def main() -> None:
async with anyio.create_task_group() as tg:
handle = tg.create_task(add(2, 4))
result = await handle # == 6
print(handle.return_value) # also 6
anyio.run(main)
```
**Signature**: `tg.create_task(coro, *, name=None, context=None) -> TaskHandle[T]`
- Returns a `TaskHandle` you can `await` for the result.
- If the task raises, awaiting raises `TaskFailed` (or `TaskCancelled`).
- This is the canonical replacement for `asyncio.gather` when you need results.
---
## 2. asyncio → anyio Cheat Sheet
| asyncio | anyio | Notes |
|---------|-------|-------|
| `asyncio.gather(a, b, c)` | `tg.create_task(a); tg.create_task(b); tg.create_task(c); results = [await h for h in handles]` | No direct gather; structured concurrency requires explicit task group scope. For fire-and-forget, use `tg.start_soon`. |
| `asyncio.create_task(coro)` | `tg.start_soon(func, *args)` or `tg.create_task(coro)` | `start_soon` takes a coroutine **function** + args. `create_task` takes a coroutine **object** and returns a handle. |
| `asyncio.sleep(n)` | `anyio.sleep(n)` | Identical semantics. |
| `asyncio.wait_for(coro, timeout)` | `with anyio.fail_after(timeout): await coro` | Raises `TimeoutError`. Use `move_on_after` for silent timeout. |
| `asyncio.Event()` | `anyio.Event()` | AnyIO events are **not reusable**; create a new one instead of `.clear()`. |
| `asyncio.Lock()` | `anyio.Lock()` | Use `async with lock:`. Pass `fast_acquire=True` if performance-critical. |
| `asyncio.Semaphore(n)` | `anyio.Semaphore(n)` | Same. Pass `fast_acquire=True` if performance-critical. |
| `asyncio.Condition()` | `anyio.Condition()` | Same semantics. |
| `asyncio.run(main())` | `anyio.run(main)` | Backend-agnostic entry point. |
| `asyncio.Queue(maxsize=N)` | `anyio.create_memory_object_stream[T](max_buffer_size=N)` | Returns `(send_stream, receive_stream)`. Supports `async for` on receive end. |
| `asyncio.to_thread(fn, *args)` | `anyio.to_thread.run_sync(fn, *args)` | Supports `abandon_on_cancel=True` and custom `limiter`. |
| `asyncio.run_coroutine_threadsafe(coro, loop)` | `anyio.from_thread.run(func, *args)` | Call async code from a worker thread. |
| `loop.call_soon_threadsafe(callback)` | `anyio.from_thread.run_sync(func, *args)` | Call sync code in event loop thread from worker thread, **with return value**. |
| `asyncio.shield(coro)` | `with anyio.CancelScope(shield=True): ...` | AnyIO shielding does not orphan tasks. |
| `asyncio.timeout(delay)` | `with anyio.fail_after(delay): ...` | AnyIO uses level cancellation, not edge cancellation. |
| `asyncio.CancelledError` | `anyio.get_cancelled_exc_class()` | Use this to catch cancellation portably across backends. |
---
## 3. Cancellation & CancelScope
AnyIO uses **level cancellation** (inspired by Trio), not asyncio's **edge cancellation**.
- **Edge cancellation** (asyncio): A `CancelledError` is injected once. If caught and not re-raised, the task keeps running.
- **Level cancellation** (anyio): As long as a task is inside an effectively cancelled scope, every yield point raises a new cancellation exception.
### Basic CancelScope
```python
from anyio import CancelScope, create_task_group, get_cancelled_exc_class, sleep, run
async def worker() -> None:
try:
await sleep(10)
except get_cancelled_exc_class():
print("cancelled!")
raise # ALWAYS re-raise cancellation exceptions
async def main() -> None:
async with create_task_group() as tg:
tg.start_soon(worker)
await sleep(0.1)
tg.cancel_scope.cancel() # cancels all children
run(main)
```
### Shielding
Shield a block from external cancellation. Essential for cleanup.
```python
from anyio import CancelScope, create_task_group, sleep, run
async def main() -> None:
async with create_task_group() as tg:
with CancelScope(shield=True):
tg.start_soon(some_task)
tg.cancel_scope.cancel() # shielded block is protected
await sleep(1) # this still runs
run(main)
```
**Combine with timeouts for graceful shutdown**:
```python
from anyio import CancelScope, move_on_after
async def do_something(resource) -> None:
try:
await run_async_stuff()
except BaseException:
# Allow up to 10s for cleanup, then move on
with move_on_after(10, shield=True):
await resource.aclose()
raise
```
### Structured Concurrency Guarantee
A task group contains its own `CancelScope`. If any child task raises an exception:
1. The task group's cancel scope is cancelled.
2. All other child tasks receive cancellation.
3. The task group waits for all children to finish.
4. The original exception (wrapped in `ExceptionGroup` if multiple) is re-raised.
---
## 4. Timeouts
Two context managers. Both create a `CancelScope` internally.
### `fail_after` — raises on timeout
```python
from anyio import fail_after, sleep, run
async def main() -> None:
try:
with fail_after(5) as scope:
await sleep(10)
except TimeoutError:
print("timed out")
print(scope.cancelled_caught) # True
run(main)
```
### `move_on_after` — silent timeout
```python
from anyio import move_on_after, sleep, run
async def main() -> None:
with move_on_after(5) as scope:
await sleep(10)
print("this never prints")
print("exited scope, cancelled =", scope.cancelled_caught)
run(main)
```
### Combined with shielding
```python
from anyio import move_on_after
# Give cleanup 10 seconds, but don't let outer cancellation interrupt it
with move_on_after(10, shield=True):
await resource.aclose()
```
---
## 5. Memory Object Streams (Queue Replacement)
Replaces `asyncio.Queue` with a safer, typed, structured-concurrency-friendly construct.
```python
from anyio import create_task_group, create_memory_object_stream, run
from anyio.streams.memory import MemoryObjectReceiveStream
async def consumer(stream: MemoryObjectReceiveStream[str]) -> None:
async with stream: # closes receive end on exit
async for item in stream:
print("received", item)
async def main() -> None:
# Type-annotated stream creation (AnyIO 4+ syntax)
send_stream, receive_stream = create_memory_object_stream[str](max_buffer_size=10)
async with create_task_group() as tg:
tg.start_soon(consumer, receive_stream)
async with send_stream:
for i in range(5):
await send_stream.send(f"item {i}")
# send_stream closed → consumer's async for loop exits naturally
run(main)
```
**Key differences from `asyncio.Queue`**:
- **Bounded by default**: `max_buffer_size=0` means send blocks until a receiver is ready.
- **Cloneable**: Each producer/consumer can close its own clone. The stream only ends when **all** clones of one end are closed.
- **Async iterable**: `async for item in receive_stream:` works out of the box.
- **Type-safe**: Generic `create_memory_object_stream[T]()`.
- **Synchronous close**: Both `close()` and `async with` work.
---
## 6. Backend Selection
AnyIO is backend-agnostic. Code written against AnyIO APIs runs on both asyncio and Trio.
```python
import anyio
async def main() -> None:
print("running on", anyio.current_async_library())
await anyio.sleep(1)
# Default backend (asyncio)
anyio.run(main)
# Explicit backend
anyio.run(main, backend="trio")
anyio.run(main, backend="asyncio", backend_options={"debug": True})
```
**Library design rule**: Never hardcode a backend. Let the application choose via `anyio.run()`. Libraries should only import `anyio` and avoid backend-specific APIs.
---
## 7. Compatibility with asyncio-only libraries
### Using asyncio libraries under the asyncio backend
If a third-party library exposes only an asyncio interface (returns asyncio coroutine objects), it works directly under the asyncio backend because AnyIO runs on top of asyncio's event loop:
```python
import anyio
import some_asyncio_only_lib # returns asyncio.Future/coroutine objects
async def main() -> None:
# This works because under the asyncio backend, await passes through
result = await some_asyncio_only_lib.fetch_data()
anyio.run(main, backend="asyncio")
```
**Important**: This only works on the `asyncio` backend. On the `trio` backend, asyncio-native objects will not work.
### When you MUST use asyncio APIs
Some APIs have no AnyIO equivalent and require direct event loop access:
| Scenario | asyncio API | AnyIO approach |
|----------|-------------|----------------|
| Signal handlers | `loop.add_signal_handler()` | `anyio.open_signal_receiver()` |
| Custom protocols | `asyncio.Protocol` | Use AnyIO streams / sockets |
| Direct Future manipulation | `asyncio.Future` | Avoid; use AnyIO primitives |
| Eager task factories | `asyncio.eager_task_factory` | Experimental in AnyIO; avoid |
If you absolutely need the running loop:
```python
import asyncio
async def main() -> None:
loop = asyncio.get_running_loop()
# ... do something loop-specific ...
# WARNING: this breaks backend-agnosticism
anyio.run(main, backend="asyncio")
```
**Best practice**: Wrap asyncio-only code in a backend-agnostic facade, and document that the feature requires the asyncio backend.
---
## 8. Idiomatic Code Snippets
### Snippet 1: Parallel HTTP requests with timeout and cleanup
```python
import anyio
async def fetch(url: str) -> bytes:
await anyio.sleep(0.5) # simulate
return b"data"
async def main() -> None:
urls = ["a", "b", "c"]
async with anyio.create_task_group() as tg:
with anyio.move_on_after(5):
for url in urls:
tg.start_soon(fetch, url)
# All tasks are cancelled on timeout; task group waits for cleanup
anyio.run(main)
```
### Snippet 2: Producer-consumer with memory object stream
```python
import anyio
from anyio.streams.memory import MemoryObjectReceiveStream
async def producer(send_stream: anyio.streams.memory.MemoryObjectSendStream[int]) -> None:
async with send_stream:
for i in range(100):
await send_stream.send(i)
async def consumer(receive_stream: MemoryObjectReceiveStream[int]) -> None:
async with receive_stream:
async for item in receive_stream:
print(f"consumed {item}")
async def main() -> None:
send, receive = anyio.create_memory_object_stream[int](max_buffer_size=5)
async with anyio.create_task_group() as tg:
tg.start_soon(producer, send)
tg.start_soon(consumer, receive)
anyio.run(main)
```
### Snippet 3: Calling sync code from async
```python
import time
import anyio
async def main() -> None:
# Run blocking function in worker thread
result = await anyio.to_thread.run_sync(time.sleep, 2)
print("done")
anyio.run(main)
```
### Snippet 4: Calling async code from a worker thread
```python
import anyio
def blocking_callback() -> None:
# Inside a worker thread, call back into the event loop
anyio.from_thread.run(anyio.sleep, 1)
anyio.from_thread.run_sync(print, "hello from thread")
async def main() -> None:
await anyio.to_thread.run_sync(blocking_callback)
anyio.run(main)
```
### Snippet 5: Graceful shutdown with shielded cleanup
```python
import anyio
async def worker() -> None:
try:
await anyio.sleep_forever()
except anyio.get_cancelled_exc_class():
with anyio.CancelScope(shield=True):
await anyio.sleep(0.5) # cleanup
print("cleaned up")
raise
async def main() -> None:
async with anyio.create_task_group() as tg:
tg.start_soon(worker)
await anyio.sleep(1)
tg.cancel_scope.cancel()
anyio.run(main)
```
---
## Sources
- AnyIO Documentation (stable): https://anyio.readthedocs.io/en/stable/
- AnyIO GitHub (HEAD `cb245dba`): https://github.com/agronholm/anyio
- Task Groups: https://anyio.readthedocs.io/en/stable/tasks.html
- Cancellation & Timeouts: https://anyio.readthedocs.io/en/stable/cancellation.html
- Streams: https://anyio.readthedocs.io/en/stable/streams.html
- Synchronization: https://anyio.readthedocs.io/en/stable/synchronization.html
- Threads: https://anyio.readthedocs.io/en/stable/threads.html
- Basics / Backends: https://anyio.readthedocs.io/en/stable/basics.html
- Design Rationale (why asyncio is problematic): https://anyio.readthedocs.io/en/stable/why.html
@@ -0,0 +1,233 @@
# Data Modeling
Which container to use, how to structure data, and why frozen is the default.
---
## Decision flowchart
```
Is it a fixed set of named constants?
YES → StrEnum / IntEnum
NO ↓
Is it just branding a primitive (int, str, float)?
YES → NewType("X", base)
NO ↓
Is it an interface / contract ("this thing can do X")?
├─ Shape only, no shared code → Protocol
└─ Shared method implementation needed → ABC
NO ↓
Does the data cross a trust boundary (user input, API, file, external DB)?
YES → pydantic.BaseModel (frozen=True) — validates + serializes
NO ↓
Is it a dict shape needed for JSON compat / **kwargs typing?
YES → TypedDict
NO ↓
Is it structured data with named fields?
YES → @dataclass(frozen=True, slots=True)
NO ↓
Is it a tuple with positional semantics (x, y coords / DB row)?
YES → NamedTuple
NO → you probably don't need a new type
```
---
## Container reference
### @dataclass — internal value object
The default for structured data inside your codebase. Zero overhead, no framework coupling.
```python
from dataclasses import dataclass
from typing import NewType
UserId = NewType("UserId", int)
@dataclass(frozen=True, slots=True)
class User:
id: UserId
name: str
email: str
@dataclass(frozen=True, slots=True)
class Point:
x: float
y: float
```
Always `frozen=True, slots=True`. Mutable only when mutation is the documented purpose — opt out with `# noqa: MUTABLE_OK`.
### Pydantic BaseModel — trust boundary guardian
Use when data enters or leaves your system. Validates at construction, serializes to JSON, generates OpenAPI schema.
```python
from pydantic import BaseModel, ConfigDict, EmailStr
class CreateUserRequest(BaseModel):
model_config = ConfigDict(frozen=True)
name: str
email: EmailStr
age: int
class UserResponse(BaseModel):
model_config = ConfigDict(frozen=True)
id: int
name: str
email: str
```
**The one rule**: data crosses a trust boundary → Pydantic. Everything else → dataclass.
Never use Pydantic for internal-only data just because it's convenient. The validation cost is real.
### TypedDict — dict that knows its shape
Use when the value must stay a `dict` at runtime — JSON blobs, `**kwargs`, third-party APIs expecting dicts.
```python
from typing import TypedDict, NotRequired
class Headers(TypedDict):
content_type: str
authorization: NotRequired[str]
def make_request(url: str, headers: Headers) -> None: ...
make_request("https://api.example.com", {"content_type": "application/json"})
```
### Protocol — structural interface
"Anything that has method X" — no inheritance required.
```python
from typing import Protocol
class Renderable(Protocol):
def render(self) -> str: ...
class Saveable(Protocol):
async def save(self) -> None: ...
@dataclass(frozen=True, slots=True)
class MarkdownDoc:
content: str
def render(self) -> str:
return self.content
def publish(doc: Renderable) -> None:
print(doc.render()) # MarkdownDoc works — no inheritance needed
```
Default to Protocol for interfaces. ABC only when you need shared method implementations.
### ABC — interface with shared code
Only when Protocol isn't enough.
```python
from abc import ABC, abstractmethod
class BaseRepository(ABC):
@abstractmethod
async def get(self, id: int) -> Model | None: ...
@abstractmethod
async def save(self, model: Model) -> None: ...
async def get_or_raise(self, id: int) -> Model:
result = await self.get(id)
if result is None:
msg = f"{type(self).__name__}: id {id} not found"
raise LookupError(msg)
return result
```
### NamedTuple — positional + named (rare)
Only when you need tuple protocol (unpacking, indexing).
```python
from typing import NamedTuple
class Coordinate(NamedTuple):
x: float
y: float
x, y = Coordinate(1.0, 2.0) # tuple unpacking
```
99% of the time, `@dataclass(frozen=True, slots=True)` is better.
---
## Quick lookup
| Situation | Use | Why |
|---|---|---|
| User input, API request/response | `Pydantic BaseModel` | Validation, JSON schema, serialization |
| DB row ↔ Python (ORM) | SQLAlchemy `Mapped[]` model | ORM integration, async session |
| Internal value object | `@dataclass(frozen=True, slots=True)` | Zero overhead, no validation needed |
| Multiple outcomes from function | Union of frozen dataclasses | Distinct types for `match` |
| Dict shape for JSON / `**kwargs` | `TypedDict` | Stays a dict at runtime |
| Fixed constants | `StrEnum` / `IntEnum` | Exhaustive match, no typos |
| Distinct primitive | `NewType("X", int)` | Zero runtime cost, type-level only |
| Contract / capability | `Protocol` | Structural typing, no inheritance |
| Contract + shared impl | `ABC` | When Protocol isn't enough |
---
## Comparison matrix
| Feature | dataclass | Pydantic | TypedDict | Protocol | NamedTuple | NewType | Enum |
|---|---|---|---|---|---|---|---|
| Validation | - | ✓ | - | - | - | - | - |
| JSON serialization | manual | built-in | native dict | - | - | - | `.value` |
| Immutable | frozen=True | frozen=True | - (dict) | N/A | always | N/A | always |
| Runtime cost | ~zero | validation | zero | zero | ~zero | zero | ~zero |
| `match` support | ✓ | ✓ | - | - | ✓ | - | ✓ |
| `slots` support | ✓ | - | - | - | - | - | - |
---
## Parse, don't validate
Validate at the boundary. Inside the boundary, types are proof of validity.
```python
# BAD — validate then pass raw data
def process_email(email: str) -> None:
if "@" not in email:
raise ValueError("invalid email")
# still a raw str everywhere downstream
# GOOD — parse into typed value at boundary
from typing import NewType
Email = NewType("Email", str)
def parse_email(raw: str) -> Email:
if "@" not in raw or "." not in raw.split("@")[1]:
msg = f"invalid email: {raw}"
raise ValueError(msg)
return Email(raw.lower().strip())
# Downstream only sees Email, never raw str
def send_welcome(email: Email) -> None: ...
```
With Pydantic this happens automatically — `EmailStr` is already a parsed type. Once constructed, `.email` is always valid. No re-validation needed.
---
## Sources
- Python docs: [dataclasses](https://docs.python.org/3/library/dataclasses.html)
- Pydantic v2: [docs.pydantic.dev](https://docs.pydantic.dev/latest/)
- Python docs: [typing — Protocol](https://docs.python.org/3/library/typing.html#typing.Protocol)
- Python docs: [typing — TypedDict](https://docs.python.org/3/library/typing.html#typing.TypedDict)
- Alexis King: [Parse, don't validate](https://lexi-lambda.github.io/blog/2019/11/05/parse-don-t-validate/)
@@ -0,0 +1,133 @@
# Data Processing — Polars + DuckDB
## The rule
NEVER pandas. Polars (with numpy) plus DuckDB. Pandas is 10-50x slower, has weaker types, and the modern Python data ecosystem has moved on.
## Quick decision tree
| Operation | Use | Why |
|---|---|---|
| `.csv` / `.parquet` / `.json` direct query | DuckDB | Zero memory load, SQL ergonomics |
| `.duckdb` file | DuckDB | Native format |
| Filter (any size) | Polars | 128x faster than DuckDB for filtering |
| Sort | Polars | 12x faster |
| Multi-table join | DuckDB | 3x faster, more join types |
| Heavy GROUP BY aggregation | DuckDB | 4x faster on large datasets |
| Window function | Polars | 3-5x faster |
| Pivot / melt / string ops | Polars | 2x faster |
| Larger than RAM | Polars streaming or DuckDB out-of-core | Both handle OOM |
| Mixed pipeline | Hybrid (zero-copy via Arrow) | Use each tool's strengths |
For the deep version (per-operation benchmarks, OOM strategies, full execution templates), load the **`data-scientist`** skill - it lives in this same skill set and is the source of truth for performance numbers.
## Standard imports
```python
import numpy as np
import polars as pl
import duckdb
```
## DuckDB direct file query (zero memory load)
```python
result = duckdb.sql("""
SELECT category, SUM(amount) AS total
FROM 'data.csv'
WHERE date >= '2026-01-01'
GROUP BY category
ORDER BY total DESC
""").pl() # zero-copy → Polars DataFrame
```
`.pl()` returns Polars; `.df()` would return pandas - never use `.df()`.
## Polars lazy pipeline
```python
result = (
pl.scan_csv("data.csv") # lazy, no read yet
.filter(pl.col("amount") > 1000)
.filter(pl.col("status") == "active")
.sort("amount", descending=True)
.head(100)
.collect() # execute optimised plan
)
```
`scan_*` over `read_*` for files; `lazy()` then `collect()` for in-memory frames. Polars optimises the entire plan before execution (predicate pushdown, projection pushdown, common subexpression elimination).
## Streaming for OOM data
```python
result = (
pl.scan_csv("huge.csv")
.filter(pl.col("active"))
.group_by("category")
.agg([
pl.len().alias("count"),
pl.sum("amount").alias("total"),
])
.collect(streaming=True)
)
```
## Hybrid pipeline (most realistic shape)
```python
# Phase 1: DuckDB for the join (3x faster)
joined = duckdb.sql("""
SELECT o.*, c.region, p.category
FROM 'orders.parquet' o
JOIN 'customers.parquet' c ON o.customer_id = c.id
JOIN 'products.parquet' p ON o.product_id = p.id
""").pl()
# Phase 2: Polars for filtering and transformation (128x + 2x faster)
processed = (
joined
.filter(pl.col("amount") > 100)
.with_columns([
(pl.col("amount") * 1.1).alias("amount_with_tax"),
])
)
# Phase 3: DuckDB for final aggregation (4x faster) - register Polars frame by name
duckdb.register("processed", processed)
final = duckdb.sql("""
SELECT region, category, SUM(amount_with_tax) AS revenue
FROM processed
GROUP BY region, category
ORDER BY revenue DESC
""").pl()
```
## Type safety with Polars
Polars supports schema overrides at read time, and `.cast()` for explicit conversion. Avoid implicit coercion in hot paths.
```python
schema = {"id": pl.Int64, "amount": pl.Float64, "date": pl.Date}
df = pl.read_csv("data.csv", schema_overrides=schema)
```
basedpyright understands `polars-stubs`, which ship with polars itself. No extra type stubs to install.
## Things you might miss from pandas (and how to do them in Polars)
| pandas | polars |
|---|---|
| `df.iloc[5]` | `df.row(5)` (named tuple) or `df[5]` (single-row frame) |
| `df.loc[df["x"] > 5]` | `df.filter(pl.col("x") > 5)` |
| `df["x"].apply(fn)` | `df["x"].map_elements(fn)` (slow path) or use native expressions |
| `df.merge(...)` | `df.join(other, on="key")` |
| `df.groupby(...).agg(...)` | `df.group_by(...).agg(...)` |
| `pd.read_csv(...).dtypes` | `pl.read_csv(...).schema` |
| `df.to_dict("records")` | `df.to_dicts()` |
## Sources
- Polars docs: <https://docs.pola.rs>
- DuckDB Python API: <https://duckdb.org/docs/api/python/overview>
- Cross-reference - this skill set's `data-scientist` skill (load it for the deep version)
@@ -0,0 +1,218 @@
# Error Handling
Typed errors, exhaustive matching, union returns, and resource safety.
---
## Typed errors — no bare strings
Error types carry structured data. Pattern matching works. Callers know exactly what can go wrong.
```python
from dataclasses import dataclass
from typing import NewType
UserId = NewType("UserId", int)
@dataclass(frozen=True, slots=True)
class UserNotFoundError(Exception):
user_id: UserId
def __str__(self) -> str: # REQUIRED — see note below
return f"user {self.user_id} not found"
@dataclass(frozen=True, slots=True)
class PermissionDeniedError(Exception):
user_id: UserId
required_role: str
def __str__(self) -> str:
return f"user {self.user_id} needs role {self.required_role}"
```
**`__str__` is mandatory** on dataclass exceptions. `@dataclass` replaces `Exception.__init__`, so `self.args` is always `()`. Without `__str__`, `str(e)` returns an empty string and logging/monitoring breaks.
```python
# BAD
raise ValueError("user not found")
raise ValueError("permission denied")
# GOOD
raise UserNotFoundError(user_id=uid)
raise PermissionDeniedError(user_id=uid, required_role="admin")
```
---
## Union returns — expected failures without exceptions
For failures that are **expected** (not found, validation error, permission denied), return a union instead of raising. Exceptions are for **unexpected** failures (network down, OOM, corrupted data).
### Define the outcome types
```python
@dataclass(frozen=True, slots=True)
class User:
id: UserId
name: str
@dataclass(frozen=True, slots=True)
class UserNotFound:
id: UserId
@dataclass(frozen=True, slots=True)
class PermissionDenied:
id: UserId
reason: str
type GetUserResult = User | UserNotFound | PermissionDenied
```
### Handle exhaustively
```python
from typing import assert_never
def handle_result(result: GetUserResult) -> str:
match result:
case User(name=name):
return f"Found: {name}"
case UserNotFound(id=uid):
return f"No user with id {uid}"
case PermissionDenied(reason=reason):
return f"Denied: {reason}"
case _ as unreachable:
assert_never(unreachable)
```
`assert_never` in the default case: if you add a new variant to `GetUserResult` without handling it here, the type checker errors. No silent fall-through.
### When to use which
**The heuristic**: caller is 1-2 levels away and MUST handle it → union return. Error should propagate up many layers to a boundary → exception.
| Scenario | Pattern | Why |
|---|---|---|
| Repository → service (caller handles it) | Union return (`User \| UserNotFound`) | Caller is right there, must handle both |
| Validation at boundary (parsing input) | Exception (typed, with fields) | Propagates up to HTTP/CLI handler |
| Infrastructure failure (network, OOM) | Exception | Can't handle locally, must propagate |
| Service → service (deep internal) | Exception (typed) | Union boilerplate across many layers is worse than exceptions |
| HTTP handler → response | Catch exceptions, convert to response | Boundary code catches and translates |
**Practical tradeoff**: union returns are safest (type checker forces handling) but create boilerplate when every caller in a chain must `match`. If the error would just propagate through 3+ layers unchanged, use a typed exception instead.
---
## Exhaustive match — every match needs a default
Every `match` statement ends with `case _: assert_never(x)`. No exceptions.
```python
from enum import StrEnum
from typing import assert_never
class Status(StrEnum):
PENDING = "pending"
ACTIVE = "active"
DELETED = "deleted"
def describe(status: Status) -> str:
match status:
case Status.PENDING:
return "waiting"
case Status.ACTIVE:
return "live"
case Status.DELETED:
return "gone"
case _ as unreachable:
assert_never(unreachable)
```
Add a new enum member? The type checker tells you every `match` that needs updating.
---
## Context managers — resource safety
If it has `.close()`, `.shutdown()`, `.disconnect()`, or `.release()`, wrap it in `with`.
```python
# BAD
f = open("data.txt")
data = f.read()
f.close() # forgotten? leaked
# GOOD
from pathlib import Path
data = Path("data.txt").read_text()
```
### Async resources
```python
import httpx
async def fetch_users() -> list[User]:
async with httpx.AsyncClient() as client:
response = await client.get("https://api.example.com/users")
response.raise_for_status()
return [User(**u) for u in response.json()]
```
### Custom context manager
```python
from contextlib import asynccontextmanager
from collections.abc import AsyncIterator
@asynccontextmanager
async def managed_connection(url: str) -> AsyncIterator[Connection]:
conn = await connect(url)
try:
yield conn
finally:
await conn.close()
async with managed_connection("postgres://...") as conn:
await conn.execute("SELECT 1")
# conn is closed here, guaranteed
```
---
## Exception hierarchy — when you do raise
Keep exception hierarchies shallow and specific.
```python
class AppError(Exception):
"""Base for all application errors."""
@dataclass(frozen=True, slots=True)
class NotFoundError(AppError):
entity: str
id: int
def __str__(self) -> str:
return f"{self.entity} {self.id} not found"
@dataclass(frozen=True, slots=True)
class ConflictError(AppError):
entity: str
field: str
value: str
def __str__(self) -> str:
return f"{self.entity}.{self.field} = {self.value!r} already exists"
```
Callers catch `AppError` at the boundary, or specific subtypes where they can do something useful.
---
## Sources
- Python docs: [typing — assert_never](https://docs.python.org/3/library/typing.html#typing.assert_never)
- Python docs: [contextlib](https://docs.python.org/3/library/contextlib.html)
- Python docs: [match statement](https://docs.python.org/3/reference/compound_stmts.html#the-match-statement)
@@ -0,0 +1,316 @@
# FastAPI + SQLAlchemy 2.x async + Postgres + Pydantic v2
The canonical web API stack. Async end-to-end, type-safe end-to-end, OpenAPI-generated end-to-end.
## Project layout
```
myapi/
├── pyproject.toml
├── alembic.ini
├── migrations/
│ └── env.py
├── src/
│ └── myapi/
│ ├── __init__.py
│ ├── main.py # FastAPI app + lifespan
│ ├── config.py # pydantic-settings
│ ├── db.py # engine, session factory, dependency
│ ├── models.py # SQLAlchemy declarative models
│ ├── schemas.py # Pydantic request/response models
│ └── routers/
│ ├── __init__.py
│ └── users.py
└── tests/
├── conftest.py
└── test_users.py
```
## Dependencies
```bash
uv add fastapi 'sqlalchemy[asyncio]>=2.0' asyncpg 'pydantic[email]>=2' pydantic-settings 'uvicorn[standard]' orjson
uv add --dev httpx pytest alembic
```
`orjson` is mandatory: set `default_response_class=ORJSONResponse` on the FastAPI app. Pydantic-typed responses bypass it (Pydantic v2's `model_dump_json` is already Rust-backed); raw `dict` / `list` returns are accelerated. For SSE / NDJSON streams, call `orjson.dumps(...)` per chunk inside `StreamingResponse`. See `orjson-stack.md` for the decision tree, flag reference, and benchmarks.
## Configuration (`config.py`)
```python
from functools import lru_cache
from pydantic import Field, PostgresDsn
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_prefix="MYAPI_")
database_url: PostgresDsn
debug: bool = False
cors_origins: list[str] = Field(default_factory=list)
@lru_cache
def get_settings() -> Settings:
return Settings() # type: ignore[call-arg] # pydantic populates from env
```
Wait — that comment violates the no-excuse rule. Use proper field defaults instead. Real version:
```python
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_prefix="MYAPI_")
database_url: PostgresDsn
debug: bool = False
cors_origins: list[str] = Field(default_factory=list)
```
Construct via `Settings(_env_file=".env")` if needed in tests; in production it reads from env.
## Database (`db.py`)
```python
from collections.abc import AsyncIterator
from typing import Annotated
from fastapi import Depends
from sqlalchemy.ext.asyncio import (
AsyncEngine,
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from myapi.config import get_settings
def make_engine() -> AsyncEngine:
settings = get_settings()
return create_async_engine(
str(settings.database_url),
echo=settings.debug,
pool_pre_ping=True,
)
_engine = make_engine()
_SessionFactory = async_sessionmaker(_engine, expire_on_commit=False)
async def get_session() -> AsyncIterator[AsyncSession]:
async with _SessionFactory() as session:
yield session
SessionDep = Annotated[AsyncSession, Depends(get_session)]
```
`expire_on_commit=False` is essential for FastAPI - otherwise attribute access after commit triggers an implicit refresh and errors out under async.
## Models (`models.py`)
```python
from datetime import datetime, UTC
from sqlalchemy import DateTime, String, func
from sqlalchemy.orm import (
DeclarativeBase,
Mapped,
MappedAsDataclass,
mapped_column,
)
class Base(MappedAsDataclass, DeclarativeBase):
pass
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True, init=False)
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
name: Mapped[str] = mapped_column(String(100))
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
init=False,
)
```
`MappedAsDataclass` makes `User(email=..., name=...)` work as a real dataclass constructor. `init=False` excludes the auto-generated columns (`id`, `created_at`) from `__init__`.
## Schemas (`schemas.py`)
```python
from datetime import datetime
from pydantic import BaseModel, ConfigDict, EmailStr
class UserCreate(BaseModel):
email: EmailStr
name: str
class UserRead(BaseModel):
model_config = ConfigDict(from_attributes=True) # SQLAlchemy → Pydantic
id: int
email: EmailStr
name: str
created_at: datetime
```
Always have a separate `*Create` (input) and `*Read` (output) model. Never expose your ORM model as the API model.
## Routers (`routers/users.py`)
```python
from fastapi import APIRouter, HTTPException, status
from sqlalchemy import select
from myapi.db import SessionDep
from myapi.models import User
from myapi.schemas import UserCreate, UserRead
router = APIRouter(prefix="/users", tags=["users"])
@router.post("", response_model=UserRead, status_code=status.HTTP_201_CREATED)
async def create_user(payload: UserCreate, session: SessionDep) -> User:
user = User(email=payload.email, name=payload.name)
session.add(user)
await session.commit()
await session.refresh(user)
return user
@router.get("/{user_id}", response_model=UserRead)
async def get_user(user_id: int, session: SessionDep) -> User:
result = await session.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if user is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found")
return user
@router.get("", response_model=list[UserRead])
async def list_users(session: SessionDep, limit: int = 100) -> list[User]:
result = await session.execute(select(User).limit(limit))
return list(result.scalars().all())
```
## Application (`main.py`)
```python
from contextlib import asynccontextmanager
from collections.abc import AsyncIterator
from fastapi import FastAPI
from myapi.config import get_settings
from myapi.routers import users
@asynccontextmanager
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
# Startup: warm up engine pool, run migrations check, etc.
yield
# Shutdown: close engine
from myapi.db import _engine
await _engine.dispose()
def create_app() -> FastAPI:
settings = get_settings()
app = FastAPI(
title="My API",
debug=settings.debug,
lifespan=lifespan,
)
app.include_router(users.router)
return app
app = create_app()
```
Run with:
```bash
uv run uvicorn myapi.main:app --host 0.0.0.0 --port 8000 --reload
```
## Migrations (Alembic + async)
```bash
uv run alembic init -t async migrations
```
In `migrations/env.py` replace the `target_metadata` line:
```python
from myapi.models import Base
target_metadata = Base.metadata
```
Set `sqlalchemy.url` in `alembic.ini` to your async URL or override via `env.py`:
```python
from myapi.config import get_settings
config.set_main_option("sqlalchemy.url", str(get_settings().database_url))
```
Generate and apply:
```bash
uv run alembic revision --autogenerate -m "create users"
uv run alembic upgrade head
```
## Tests (`tests/test_users.py`)
```python
import pytest
from httpx import ASGITransport, AsyncClient
from myapi.main import app
@pytest.mark.anyio
async def test_create_and_get_user() -> None:
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
create_response = await client.post(
"/users",
json={"email": "alice@example.com", "name": "Alice"},
)
assert create_response.status_code == 201
user_id = create_response.json()["id"]
get_response = await client.get(f"/users/{user_id}")
assert get_response.status_code == 200
assert get_response.json()["email"] == "alice@example.com"
```
For database-backed tests, run a Postgres container in CI (`testcontainers-python` or `docker-compose`) and apply migrations against a test schema. SQLite-as-test-db breaks once you use Postgres-specific types (`JSONB`, `tsvector`, arrays).
## Common pitfalls
| Pitfall | Fix |
|---|---|
| `MissingGreenlet` exception when accessing relationships after commit | `expire_on_commit=False` on the session factory |
| Connection pool exhausted under load | Set `pool_size`, `max_overflow` in `create_async_engine` |
| Pydantic v1 syntax (`from pydantic import ...; class X(BaseModel): class Config: orm_mode = True`) | v2 uses `model_config = ConfigDict(from_attributes=True)` |
| Returning ORM objects without `response_model` | FastAPI serialises with `from_attributes=True` automatically; declare `response_model` so OpenAPI is correct |
| `await session.execute(...)` returning Sequence | Wrap with `list(result.scalars().all())` to satisfy strict types |
| `func.now()` returning naive datetime | Use `DateTime(timezone=True)` and `created_at: Mapped[datetime]` with `UTC`-aware default |
## Sources
- FastAPI: <https://fastapi.tiangolo.com>
- SQLAlchemy 2.x async: <https://docs.sqlalchemy.org/en/20/orm/extensions/asyncio.html>
- SQLAlchemy MappedAsDataclass: <https://docs.sqlalchemy.org/en/20/orm/dataclasses.html>
- asyncpg: <https://magicstack.github.io/asyncpg/current/>
- Pydantic v2 migration: <https://docs.pydantic.dev/latest/migration/>
- Alembic async: <https://alembic.sqlalchemy.org/en/latest/cookbook.html#using-asyncio-with-alembic>
@@ -0,0 +1,360 @@
# httpx2 — Production Defaults
> **Source**: [pydantic/httpx2](https://github.com/pydantic/httpx2) — next-generation HTTP client for Python 3, continuation of HTTPX under Pydantic stewardship.
>
> **Rule**: Every network request MUST use `httpx2`. **ALL optimizations below are ON by default** — HTTP/2, brotli+zstd, tuned connection pool, fine-grained timeouts, transport retries, TCP_NODELAY. This is the baseline, not a stretch goal. A bare `httpx2.AsyncClient()` is a bug.
---
## 1. Installation — all extras, always
```toml
# pyproject.toml
dependencies = [
"httpx2[http2,brotli,zstd]",
]
```
| Extra | What it enables | Why it's mandatory |
|-------|----------------|--------------------|
| `http2` | HTTP/2 multiplexing via `h2` | Single TCP connection handles concurrent requests; eliminates head-of-line blocking |
| `brotli` | Brotli content decoding (`br`) | ~20% smaller payloads than gzip for text/JSON |
| `zstd` | Zstandard content decoding | Faster decompression than brotli at similar ratios; stdlib in Python ≥ 3.14 |
| `socks` | SOCKS5 proxy support via `socksio` | Install only if you route through SOCKS proxies |
All three core extras (`http2,brotli,zstd`) are non-negotiable. Omitting any is leaving performance on the table.
---
## 2. The canonical defaults — ALL ON
These are not "optimizations to consider". These are **the correct defaults** that every httpx2 client must use.
```python
import socket
import httpx2
# ── These are the STANDARD values. Use them verbatim. ──
LIMITS = httpx2.Limits(
max_connections=200, # library default 100 is too conservative
max_keepalive_connections=40, # library default 20 wastes reconnects
keepalive_expiry=30.0, # library default 5s kills warm connections too fast
)
TIMEOUT = httpx2.Timeout(
connect=5.0, # TCP + TLS handshake budget
read=30.0, # time to receive a response chunk
write=10.0, # time to send a request chunk
pool=10.0, # time to acquire a connection from pool
)
SOCKET_OPTIONS: list[tuple[int, int, int]] = [
(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1), # disable Nagle — no 40ms delay
]
```
### Why each knob is set this way
| Setting | Library default | Our default | Why |
|---------|----------------|-------------|-----|
| `http2` | `False` | **`True`** | HTTP/2 multiplexing is strictly superior for any modern API |
| `max_connections` | `100` | `200` | Headroom for fan-out; prevents pool exhaustion under load |
| `max_keepalive_connections` | `20` | `40` | Keeps warm connections alive; fewer TLS handshakes |
| `keepalive_expiry` | `5.0s` | `30.0s` | 5s is too aggressive — kills connections between burst requests |
| `Timeout(5.0)` uniform | `5.0` all | Split | Uniform 5s is too tight for reads, too loose for connects |
| `read` timeout | `5.0` | `30.0` | Slow APIs and streaming need breathing room |
| `pool` timeout | `5.0` | `10.0` | Explicit — hitting this means `max_connections` needs raising |
| `TCP_NODELAY` | off | **on** | Eliminates Nagle's 40ms coalescing delay for small payloads |
| `retries` | `0` | `3` | Retries on `ConnectError`/`ConnectTimeout` only — safe and resilient |
| `follow_redirects` | `False` | **`True`** | Most APIs redirect; failing on 3xx is wrong default behavior |
---
## 3. Factory functions — the ONE correct way to create clients
Copy this into your project. This is the canonical pattern.
```python
"""httpx2 client factory. Always use create_client() / create_async_client()."""
from __future__ import annotations
import socket
import typing
import httpx2
_LIMITS = httpx2.Limits(
max_connections=200,
max_keepalive_connections=40,
keepalive_expiry=30.0,
)
_TIMEOUT = httpx2.Timeout(
connect=5.0,
read=30.0,
write=10.0,
pool=10.0,
)
_SOCKET_OPTIONS: list[tuple[int, int, int]] = [
(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1),
]
def create_async_client(
*,
base_url: str = "",
http2: bool = True,
retries: int = 3,
limits: httpx2.Limits = _LIMITS,
timeout: httpx2.Timeout = _TIMEOUT,
headers: dict[str, str] | None = None,
event_hooks: dict[str, list[typing.Callable[..., typing.Any]]] | None = None,
**kwargs: typing.Any,
) -> httpx2.AsyncClient:
transport = httpx2.AsyncHTTPTransport(
http2=http2,
retries=retries,
limits=limits,
socket_options=_SOCKET_OPTIONS,
)
return httpx2.AsyncClient(
transport=transport,
timeout=timeout,
base_url=base_url,
headers=headers or {},
event_hooks=event_hooks or {},
follow_redirects=True,
**kwargs,
)
def create_client(
*,
base_url: str = "",
http2: bool = True,
retries: int = 3,
limits: httpx2.Limits = _LIMITS,
timeout: httpx2.Timeout = _TIMEOUT,
headers: dict[str, str] | None = None,
event_hooks: dict[str, list[typing.Callable[..., typing.Any]]] | None = None,
**kwargs: typing.Any,
) -> httpx2.Client:
transport = httpx2.HTTPTransport(
http2=http2,
retries=retries,
limits=limits,
socket_options=_SOCKET_OPTIONS,
)
return httpx2.Client(
transport=transport,
timeout=timeout,
base_url=base_url,
headers=headers or {},
event_hooks=event_hooks or {},
follow_redirects=True,
**kwargs,
)
```
Usage:
```python
# Async — the common case
async with create_async_client(base_url="https://api.example.com") as client:
r = await client.get("/users")
# Sync
with create_client() as client:
r = client.get("https://api.example.com/health")
```
**If you are NOT using this factory pattern, you are doing it wrong.** A bare `httpx2.AsyncClient()` leaves HTTP/2 off, retries off, TCP_NODELAY off, keepalive too short, and timeouts too uniform.
---
## 4. Special case overrides
The factory defaults cover 95% of use cases. Override only when you have a specific reason:
| Scenario | Override |
|----------|----------|
| LLM streaming endpoints | `timeout=httpx2.Timeout(connect=10.0, read=None, write=10.0, pool=10.0)` — no read timeout on streaming |
| Single-host API with low concurrency | `limits=httpx2.Limits(max_connections=50, max_keepalive_connections=20, keepalive_expiry=60.0)` |
| Ephemeral short-lived requests | `keepalive_expiry=5.0` — don't hold connections |
| Unix domain sockets | `httpx2.AsyncHTTPTransport(uds="/path/to/socket", ...)` |
| mTLS / client certs | Pass `verify=ssl_ctx` with `ctx.load_cert_chain(certfile=...)` |
| SOCKS proxy | `httpx2[socks]`, `proxy="socks5://..."` |
---
## 5. Event hooks — always wire observability
This is not optional. Every production client should log requests.
```python
import time
import logging
logger = logging.getLogger(__name__)
async def log_request(request: httpx2.Request) -> None:
request.extensions["request_start"] = time.perf_counter()
async def log_response(response: httpx2.Response) -> None:
start = response.request.extensions.get("request_start", 0)
elapsed = time.perf_counter() - start
logger.info(
"HTTP %s %s%d (%.3fs, %s)",
response.request.method,
response.request.url,
response.status_code,
elapsed,
response.http_version,
)
# Sync versions for Client
def log_request_sync(request: httpx2.Request) -> None:
request.extensions["request_start"] = time.perf_counter()
def log_response_sync(response: httpx2.Response) -> None:
start = response.request.extensions.get("request_start", 0)
elapsed = time.perf_counter() - start
logger.info(
"HTTP %s %s%d (%.3fs, %s)",
response.request.method,
response.request.url,
response.status_code,
elapsed,
response.http_version,
)
```
For auto `raise_for_status()`:
```python
async def raise_on_error(response: httpx2.Response) -> None:
response.raise_for_status()
```
---
## 6. Verification script — confirm your setup is fully optimized
Run this against your target endpoint to **verify** (not decide) that all optimizations are active:
```python
"""Verify httpx2 is fully optimized against a target endpoint."""
from __future__ import annotations
import socket
import time
import anyio
import httpx2
TARGET_URL = "https://api.example.com/health"
ITERATIONS = 30
async def bench(label: str, client: httpx2.AsyncClient, url: str, n: int) -> float:
for _ in range(3): # warmup
await client.get(url)
start = time.perf_counter()
for _ in range(n):
r = await client.get(url)
assert r.status_code == 200
elapsed = time.perf_counter() - start
avg_ms = (elapsed / n) * 1000
print(f" {label}: {avg_ms:.1f}ms avg ({n} reqs in {elapsed:.2f}s)")
return avg_ms
async def main() -> None:
results: dict[str, float] = {}
# BAD: bare defaults (this is what we're proving is worse)
async with httpx2.AsyncClient() as c:
results["BAD-bare-defaults"] = await bench("BAD-bare-defaults", c, TARGET_URL, ITERATIONS)
# GOOD: full production defaults (this is what we always use)
limits = httpx2.Limits(max_connections=200, max_keepalive_connections=40, keepalive_expiry=30.0)
timeout = httpx2.Timeout(connect=5.0, read=30.0, write=10.0, pool=10.0)
transport = httpx2.AsyncHTTPTransport(
http2=True, retries=3, limits=limits,
socket_options=[(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)],
)
async with httpx2.AsyncClient(transport=transport, timeout=timeout, follow_redirects=True) as c:
results["GOOD-full-production"] = await bench("GOOD-full-production", c, TARGET_URL, ITERATIONS)
print("\n--- Proof ---")
baseline = results["BAD-bare-defaults"]
for label, avg in results.items():
delta = ((avg - baseline) / baseline) * 100
print(f" {label}: {avg:.1f}ms ({delta:+.1f}% vs bare)")
if __name__ == "__main__":
anyio.run(main)
```
---
## 7. Quick reference — all knobs
### `httpx2.AsyncClient` / `httpx2.Client`
| Parameter | Type | Library Default | **Our Default** |
|-----------|------|-----------------|-----------------|
| `http1` | `bool` | `True` | `True` |
| `http2` | `bool` | `False` | **`True`** |
| `verify` | `ssl.SSLContext \| str \| bool` | `True` | `True` |
| `cert` | `CertTypes \| None` | `None` | `None` |
| `proxy` | `str \| Proxy \| None` | `None` | `None` |
| `mounts` | `dict[str, Transport]` | `None` | `None` |
| `timeout` | `Timeout \| float \| None` | `Timeout(5.0)` | **Split: 5/30/10/10** |
| `limits` | `Limits` | `Limits(100, 20, 5.0)` | **`Limits(200, 40, 30.0)`** |
| `follow_redirects` | `bool` | `False` | **`True`** |
| `max_redirects` | `int` | `20` | `20` |
| `event_hooks` | `dict` | `{}` | **Wire logging** |
| `base_url` | `str` | `""` | Set for single-API clients |
| `trust_env` | `bool` | `True` | `True` |
| `default_encoding` | `str \| Callable` | `"utf-8"` | `"utf-8"` |
### `httpx2.AsyncHTTPTransport` / `httpx2.HTTPTransport`
| Parameter | Type | Library Default | **Our Default** |
|-----------|------|-----------------|-----------------|
| `http1` | `bool` | `True` | `True` |
| `http2` | `bool` | `False` | **`True`** |
| `retries` | `int` | `0` | **`3`** |
| `limits` | `Limits` | `Limits(100, 20, 5.0)` | **`Limits(200, 40, 30.0)`** |
| `uds` | `str \| None` | `None` | `None` |
| `local_address` | `str \| None` | `None` | `None` |
| `socket_options` | `Iterable[SOCKET_OPTION]` | `None` | **`[TCP_NODELAY]`** |
| `proxy` | `str \| Proxy \| None` | `None` | `None` |
### `httpx2.Timeout`
| Parameter | Library Default | **Our Default** |
|-----------|-----------------|-----------------|
| `connect` | `5.0` | `5.0` |
| `read` | `5.0` | **`30.0`** |
| `write` | `5.0` | **`10.0`** |
| `pool` | `5.0` | **`10.0`** |
### `httpx2.Limits`
| Parameter | Library Default | **Our Default** |
|-----------|-----------------|-----------------|
| `max_connections` | `100` | **`200`** |
| `max_keepalive_connections` | `20` | **`40`** |
| `keepalive_expiry` | `5.0` | **`30.0`** |
### Async backend (httpcore2)
httpcore2 uses `anyio` by default (works with both asyncio and trio). No extra config needed if you're already on the anyio stack. For trio, install `httpcore2[trio]`.
@@ -0,0 +1,307 @@
# Library Defaults — Decision Tree
For each domain, the canonical 2026 choice, why, and the canonical usage snippet. The skill enforces these unless the project's `pyproject.toml` explicitly says otherwise.
## CLI — typer
`typer` builds a CLI from type-annotated function signatures. argparse needs 5x the code; click ignores type annotations; fire is magic that breaks at scale.
```python
import typer
from rich import print as rprint
app = typer.Typer()
@app.command()
def greet(name: str, count: int = 1, shout: bool = False) -> None:
"""Print a greeting `count` times."""
message = f"Hello, {name}!" if not shout else f"HELLO, {name.upper()}!"
for _ in range(count):
rprint(message)
if __name__ == "__main__":
app()
```
For a single-function script, `typer.run(main)` skips the `Typer()` boilerplate. Subcommands use `@app.command()`.
## Terminal output — rich
`rich` produces tables, progress bars, syntax highlighting, traceback rendering. Use it for any structured output. Plain `print` is acceptable for non-interactive log lines (and even those are usually better via `rich.console.Console(stderr=True).log(...)`).
```python
from rich.console import Console
from rich.table import Table
console = Console()
table = Table(title="Users")
table.add_column("ID", style="cyan")
table.add_column("Name", style="magenta")
table.add_row("1", "Alice")
console.print(table)
# Rich tracebacks (call once at process start)
from rich.traceback import install
install(show_locals=True)
```
## HTTP client — [httpx2](https://github.com/pydantic/httpx2)
Next-generation HTTP client under Pydantic stewardship. Sync and async in one library, HTTP/2 native, brotli + zstd content decoding, real type stubs. Replaces `requests` (sync only), `aiohttp` (async only), and the original `httpx`.
**Install**: `httpx2[http2,brotli,zstd]` — always include all three extras, no exceptions.
**A bare `httpx2.AsyncClient()` / `httpx2.Client()` is a bug.** Always use the factory pattern from `references/httpx2-optimization.md` with ALL optimizations enabled by default:
```python
import socket
import httpx2
# ── Production defaults — ALL ON, always. ──
_LIMITS = httpx2.Limits(max_connections=200, max_keepalive_connections=40, keepalive_expiry=30.0)
_TIMEOUT = httpx2.Timeout(connect=5.0, read=30.0, write=10.0, pool=10.0)
_SOCKET_OPTS: list[tuple[int, int, int]] = [(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)]
# Async (the common case)
transport = httpx2.AsyncHTTPTransport(http2=True, retries=3, limits=_LIMITS, socket_options=_SOCKET_OPTS)
async with httpx2.AsyncClient(transport=transport, timeout=_TIMEOUT, follow_redirects=True) as client:
response = await client.get("https://api.example.com/users")
response.raise_for_status()
users = response.json()
# Sync
transport = httpx2.HTTPTransport(http2=True, retries=3, limits=_LIMITS, socket_options=_SOCKET_OPTS)
with httpx2.Client(transport=transport, timeout=_TIMEOUT, follow_redirects=True) as client:
response = client.get("https://api.example.com/users")
response.raise_for_status()
users = response.json()
```
See `references/httpx2-optimization.md` for the full factory functions (`create_client()` / `create_async_client()`), event hooks, and the rationale behind every setting. **Load that reference whenever you write ANY network code.**
## JSON — stdlib `json` (default) or `orjson` (hot paths)
Stdlib `json` is fine for cold paths and configs. **Reach for `orjson` when JSON is in the hot path** — cache layers, queue payloads, streaming responses, structured logs, FastAPI endpoints returning raw `dict` / `list`.
```python
import orjson
# orjson.dumps returns bytes, not str
raw: bytes = orjson.dumps(
payload,
option=orjson.OPT_NAIVE_UTC | orjson.OPT_UTC_Z | orjson.OPT_SERIALIZE_DATACLASS,
)
```
**Critical 2026 fact**: with Pydantic v2, `model.model_dump_json()` is backed by pydantic-core (Rust) and is faster than `orjson + default=` bridge for Pydantic-shaped responses. **Use `model_dump_json()` for Pydantic; orjson for everything else.**
For FastAPI: `app = FastAPI(default_response_class=ORJSONResponse)`. Pydantic-typed responses bypass it (and that's correct — Pydantic's path is faster). Raw `dict`/`list` returns go through orjson.
See `references/orjson-stack.md` for the full decision tree, option flag reference, FastAPI integration, Redis/queue/logging patterns, and the `model_dump_json()` vs orjson benchmark.
## Validation — pydantic v2
Pydantic v2's core is in Rust (~10x faster than v1). It is the de-facto boundary validator. Use it for:
- HTTP request/response models (FastAPI uses pydantic natively)
- Config files (env vars via `pydantic-settings`)
- Anything entering the program from outside
```python
from pydantic import BaseModel, Field, EmailStr, field_validator
class User(BaseModel):
id: int = Field(ge=1)
email: EmailStr
name: str = Field(min_length=1, max_length=100)
age: int | None = Field(default=None, ge=0, le=150)
@field_validator("name")
@classmethod
def name_no_digits(cls, v: str) -> str:
if any(c.isdigit() for c in v):
raise ValueError("name cannot contain digits")
return v
# Inside the program, use the validated instance with confidence
user = User.model_validate({"id": 1, "email": "a@b.com", "name": "Alice"})
print(user.model_dump_json(indent=2))
```
`@dataclass` is fine for purely internal records (no validation needed). For anything crossing a process boundary, use Pydantic.
## Async — anyio
Full reference: [async-anyio.md](async-anyio.md). The summary:
```python
import anyio
async def fetch(url: str) -> str:
await anyio.sleep(0.1)
return url
async def main() -> None:
async with anyio.create_task_group() as tg:
for url in ["a", "b", "c"]:
tg.start_soon(fetch, url)
anyio.run(main)
```
Never `import asyncio` directly. The third-party libraries you call are free to use asyncio internally.
## Web framework — fastapi
Type-hint-driven HTTP framework. Pydantic models become OpenAPI schemas automatically.
```python
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
class CreateUser(BaseModel):
name: str
email: str
class User(BaseModel):
id: int
name: str
email: str
@app.post("/users", response_model=User)
async def create_user(payload: CreateUser) -> User:
return User(id=1, **payload.model_dump())
```
Full stack with database: [fastapi-stack.md](fastapi-stack.md).
## ORM — sqlalchemy 2.x async
SQLAlchemy 2.x finally has a real async API. Use the modern declarative `MappedAsDataclass` style with type annotations.
```python
from sqlalchemy import String
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, MappedAsDataclass
class Base(MappedAsDataclass, DeclarativeBase):
pass
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True, init=False)
name: Mapped[str] = mapped_column(String(100))
email: Mapped[str] = mapped_column(String(255), unique=True)
engine = create_async_engine("postgresql+asyncpg://localhost/myapp")
SessionFactory = async_sessionmaker(engine, expire_on_commit=False)
```
Full pattern with FastAPI integration: [fastapi-stack.md](fastapi-stack.md).
## Database — postgres + asyncpg
For new applications, default to Postgres. SQLite for tests is fine; SQLite for production is not.
asyncpg is the fastest Python Postgres driver, native to SQLAlchemy 2.x async, native to FastAPI's lifespan model. URL: `postgresql+asyncpg://user:pass@host:5432/db`.
For migrations, use Alembic with `[alembic.context]` configured to use the async engine. Single-step:
```bash
uv add alembic
uv run alembic init -t async migrations
```
## TUI — textual
Textual builds rich, mouse-aware, mobile-style TUIs on the rich rendering engine. See [textual-tui.md](textual-tui.md).
## AI agents — pydantic-ai
The agent framework from the Pydantic team. Type-strict, structured outputs are first-class, model-agnostic. See [pydantic-ai.md](pydantic-ai.md).
## DataFrames — polars + numpy
Polars is 10-50x faster than pandas, has a real type system, and supports lazy evaluation. Numpy stays in the toolbox for arrays. See [data-processing.md](data-processing.md).
## OLAP / SQL — duckdb
DuckDB is the SQL engine for analytical workloads. Query CSV/Parquet/JSON files directly without loading into memory; perform joins and aggregations 3-4x faster than Polars; zero-copy interchange with Polars via Arrow. See [data-processing.md](data-processing.md).
## Tests — pytest
Plain `unittest` is fine for stdlib; everything else uses pytest. Conventions:
- File names `test_*.py`, function names `test_*`.
- Fixtures via `@pytest.fixture`. Async fixtures are anyio-aware (`@pytest.fixture` on an async function works under `pytest-anyio` which is bundled with anyio).
- Parametrise with `@pytest.mark.parametrize`.
- Mark async tests with `@pytest.mark.anyio` (provided by anyio's pytest plugin).
```python
import pytest
import anyio
@pytest.fixture
def sample_user() -> dict[str, str]:
return {"name": "Alice", "email": "a@b.com"}
@pytest.mark.parametrize("count,expected", [(1, "Hello"), (2, "Hello, Hello")])
def test_greet(count: int, expected: str) -> None:
result = ", ".join(["Hello"] * count)
assert result == expected
@pytest.mark.anyio
async def test_async_fetch() -> None:
await anyio.sleep(0)
assert True
```
`pyproject.toml`:
```toml
[tool.pytest.ini_options]
minversion = "8.0"
testpaths = ["tests"]
addopts = ["-ra", "--strict-config", "--strict-markers"]
```
## Settings / config — pydantic-settings
Loads env vars and `.env` files into a Pydantic model. Replaces ad-hoc `os.environ.get(...)` everywhere.
```python
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_prefix="MYAPP_")
database_url: str
api_key: str = Field(min_length=1)
debug: bool = False
settings = Settings() # loads at import time; raises if any required var is missing
```
## Logging — stdlib logging + rich handler
Stdlib `logging` is fine; it gets a face-lift from `rich.logging.RichHandler`.
```python
import logging
from rich.logging import RichHandler
logging.basicConfig(
level=logging.INFO,
format="%(message)s",
datefmt="[%X]",
handlers=[RichHandler(rich_tracebacks=True, show_path=False)],
)
log = logging.getLogger(__name__)
log.info("ready")
```
For structured logging in production, swap to `structlog` (separate dep). Don't roll your own.
@@ -0,0 +1,268 @@
# One-liner Scripts (PEP 723 + uv)
Self-contained Python scripts with declared dependencies, run with no environment setup. The combination eliminates the historical reason to write small tools in Go or Bash.
**Rule: EVERY `.py` script — even throwaway — MUST use PEP 723 inline metadata with the usage comment block.** No venv, no requirements.txt, no setup.py. The script IS the environment spec.
## The two patterns
### Pattern 1: inline `uv run` invocation
```bash
uv run --with httpx2 --with rich python -c "
import httpx2
from rich import print
print(httpx2.get('https://api.github.com').json())
"
```
Use for terminal one-shots that you don't want to save. `--with PKG` may be repeated.
### Pattern 2: PEP 723 script with shebang (THE CANONICAL PATTERN)
A regular `.py` file with metadata in a comment block. uv reads the metadata, materialises a disposable venv (cached), and runs the script.
```python
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.13"
# dependencies = [
# "httpx2[http2,brotli,zstd]",
# "rich",
# ]
# ///
# ─── How to run ───
# 1. Install uv (if not installed):
# curl -LsSf https://astral.sh/uv/install.sh | sh
# 2. Run directly (no venv, no pip install needed):
# uv run my_script.py
# 3. Or make executable and run:
# chmod +x my_script.py && ./my_script.py
# ──────────────────
from __future__ import annotations
import httpx2
from rich import print as rprint
def main() -> None:
with httpx2.Client(http2=True, follow_redirects=True) as client:
resp = client.get("https://api.github.com")
resp.raise_for_status()
rprint(resp.json())
if __name__ == "__main__":
main()
```
### Mandatory elements
Every PEP 723 script MUST include these, in order:
1. **Shebang**: `#!/usr/bin/env -S uv run --script`
2. **PEP 723 metadata block**: `# /// script` ... `# ///` with `requires-python` and `dependencies`
3. **Usage comment block**: How to install uv + how to run the script. Copy the template above verbatim.
4. **`from __future__ import annotations`**: Always first import.
5. **`if __name__ == "__main__": main()`**: Entry point guard.
### The usage comment block (NON-NEGOTIABLE)
```python
# ─── How to run ───
# 1. Install uv (if not installed):
# curl -LsSf https://astral.sh/uv/install.sh | sh
# 2. Run directly (no venv, no pip install needed):
# uv run <SCRIPT_NAME>.py [ARGS]
# 3. Or make executable and run:
# chmod +x <SCRIPT_NAME>.py && ./<SCRIPT_NAME>.py
# ──────────────────
```
Replace `<SCRIPT_NAME>` with the actual filename. Add argument descriptions if the script takes CLI args. This block goes immediately after the `# ///` closing line, before any imports.
**Why mandatory**: Anyone who receives this script — colleague, CI, future you — must know how to run it without reading docs. The comment IS the docs.
## Template generator
Use `scripts/new-script.py` to scaffold a new PEP 723 script with all boilerplate pre-filled:
```bash
# Generate to temp directory (default)
uv run scripts/new-script.py my_tool
# Generate to specific path
uv run scripts/new-script.py my_tool --output ./scripts/my_tool.py
# With extra dependencies
uv run scripts/new-script.py my_tool --deps "polars" "duckdb" "rich"
```
## Common dependency sets
| Use case | Dependencies line |
|---|---|
| API client | `"httpx2[http2,brotli,zstd]"` |
| Data processing | `"polars"`, `"duckdb"` |
| CLI tool | `"typer"`, `"rich"` |
| Web scraping | `"httpx2[http2,brotli,zstd]"`, `"selectolax"` |
| File watcher | `"watchfiles"` |
| JSON pretty | `"rich"` |
| AI / LLM | `"pydantic-ai"`, `"httpx2[http2,brotli,zstd]"` |
## Real-world examples
### Fetch + print JSON
```python
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.13"
# dependencies = [
# "httpx2[http2,brotli,zstd]",
# "rich",
# ]
# ///
# ─── How to run ───
# 1. Install uv: curl -LsSf https://astral.sh/uv/install.sh | sh
# 2. Run: uv run fetch_json.py https://api.github.com/repos/pydantic/httpx2
# ──────────────────
from __future__ import annotations
import sys
import httpx2
from rich import print as rprint
def main() -> None:
url = sys.argv[1] if len(sys.argv) > 1 else "https://api.github.com"
with httpx2.Client(http2=True, follow_redirects=True) as client:
resp = client.get(url)
resp.raise_for_status()
rprint(resp.json())
if __name__ == "__main__":
main()
```
### CSV → Parquet conversion
```python
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.13"
# dependencies = [
# "polars",
# "typer",
# "rich",
# ]
# ///
# ─── How to run ───
# 1. Install uv: curl -LsSf https://astral.sh/uv/install.sh | sh
# 2. Run: uv run csv2parquet.py input.csv output.parquet
# ──────────────────
from __future__ import annotations
from pathlib import Path
import polars as pl
import typer
from rich import print as rprint
def main(input_path: Path, output_path: Path | None = None) -> None:
"""Convert CSV to Parquet."""
out = output_path or input_path.with_suffix(".parquet")
df = pl.read_csv(input_path)
df.write_parquet(out)
rprint(f"[green]✓[/green] {input_path}{out} ({len(df)} rows)")
if __name__ == "__main__":
typer.run(main)
```
### Quick benchmark
```python
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.13"
# dependencies = [
# "httpx2[http2,brotli,zstd]",
# "rich",
# "anyio",
# ]
# ///
# ─── How to run ───
# 1. Install uv: curl -LsSf https://astral.sh/uv/install.sh | sh
# 2. Run: uv run bench.py https://api.example.com/health 50
# ──────────────────
from __future__ import annotations
import socket
import sys
import time
import anyio
import httpx2
from rich import print as rprint
async def main() -> None:
url = sys.argv[1] if len(sys.argv) > 1 else "https://api.github.com"
n = int(sys.argv[2]) if len(sys.argv) > 2 else 20
limits = httpx2.Limits(max_connections=200, max_keepalive_connections=40, keepalive_expiry=30.0)
timeout = httpx2.Timeout(connect=5.0, read=30.0, write=10.0, pool=10.0)
transport = httpx2.AsyncHTTPTransport(
http2=True, retries=3, limits=limits,
socket_options=[(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)],
)
async with httpx2.AsyncClient(transport=transport, timeout=timeout, follow_redirects=True) as client:
# warmup
for _ in range(3):
await client.get(url)
start = time.perf_counter()
for _ in range(n):
r = await client.get(url)
assert r.status_code == 200
elapsed = time.perf_counter() - start
avg_ms = (elapsed / n) * 1000
rprint(f"[bold]{url}[/bold]: {avg_ms:.1f}ms avg over {n} requests ({elapsed:.2f}s total, {r.http_version})")
if __name__ == "__main__":
anyio.run(main)
```
## Anti-patterns
| ❌ Don't | ✅ Do |
|---|---|
| `pip install httpx2 && python script.py` | `uv run script.py` |
| `requirements.txt` alongside script | PEP 723 inline metadata |
| `python -m venv .venv && ...` | `uv run --script` handles it |
| Script without usage comment | Always include the "How to run" block |
| `import asyncio; asyncio.run(main())` | `import anyio; anyio.run(main)` |
| Bare `httpx2.AsyncClient()` | Full production defaults (see `references/httpx2-optimization.md`) |
## Sources
- PEP 723 - Inline script metadata: <https://peps.python.org/pep-0723/>
- uv `run --script` docs: <https://docs.astral.sh/uv/guides/scripts/>
- Original article: <https://www.cottongeeks.com/articles/2025-06-24-fun-with-uv-and-pep-723>
- Simon Willison on one-shot Python tools: <https://simonwillison.net/2024/Dec/19/one-shot-python-tools/>
@@ -0,0 +1,378 @@
# orjson — When to Use, How to Integrate
`orjson` is the fastest JSON library on PyPI — written in Rust, 611× faster than stdlib `json` on serialization, 1.54× faster on deserialization. It also supports types the stdlib refuses to serialize: `datetime`, `date`, `UUID`, `numpy` arrays, `dataclass`, Pydantic models (via a small bridge).
This document covers the production patterns. **Not every project needs orjson.** The decision tree is in §1.
---
## 1. Decision tree — should you adopt orjson?
```
Are you serializing/deserializing JSON in a hot path?
├─ NO → stdlib `json` is fine. Stop here.
└─ YES ↓
Is the project FastAPI?
├─ YES ↓
│ Is your response body fully described by a Pydantic v2 model?
│ ├─ YES → Use FastAPI's default JSON response (uses Pydantic's
│ │ Rust-backed serializer; orjson saves nothing in this path).
│ │ Adopt orjson only for *non-Pydantic* responses below.
│ └─ NO → Use `ORJSONResponse` for endpoints that return dicts,
│ lists, or arbitrary structures.
└─ NOT FastAPI ↓
Are you serializing Pydantic v2 models repeatedly?
├─ YES → Use `model.model_dump_json()` directly — backed by pydantic-core
│ (Rust), within ~10% of orjson on the same payload, and respects
│ every Pydantic feature (computed fields, aliases, validators).
└─ NO ↓
Are you serializing dicts / lists / dataclasses / datetime / UUID?
├─ YES → orjson is the right answer.
└─ NO → stdlib `json`.
```
**The crucial 2026 fact**: with Pydantic v2's `model_dump_json()`, **Pydantic-shaped responses no longer need orjson**. Adopt orjson where you are still going through `dict` / `list` / `dataclass`.
---
## 2. Install
```toml
# pyproject.toml
dependencies = [
"orjson>=3.10",
]
```
orjson wheels are published for every major CPython version and platform (macOS, Linux glibc/musl, Windows, ARM64). No compilation step on install.
---
## 3. Basic usage
```python
import orjson
# Serialization — returns bytes, not str
raw: bytes = orjson.dumps({"hello": "world", "ts": datetime.now(UTC)})
# Deserialization
data = orjson.loads(raw)
```
Two things to internalize:
1. **`orjson.dumps` returns `bytes`**, not `str`. Stdlib `json.dumps` returns `str`. This is by design — most JSON destinations (sockets, files in binary mode, HTTP bodies) want bytes anyway, and skipping the encode/decode round trip is part of the speedup.
2. **No `indent` arg.** orjson supports `OPT_INDENT_2` (and only 2-space indent) via flags. If you need other indentation, use stdlib `json`.
---
## 4. The option flags you actually use
```python
import orjson
orjson.dumps(
payload,
option=(
orjson.OPT_NAIVE_UTC # treat naive datetimes as UTC (recommended)
| orjson.OPT_UTC_Z # render UTC as "...Z" instead of "+00:00"
| orjson.OPT_SERIALIZE_NUMPY # serialize numpy arrays natively
| orjson.OPT_SERIALIZE_DATACLASS # serialize @dataclass instances
| orjson.OPT_NON_STR_KEYS # allow int / UUID / datetime dict keys
# | orjson.OPT_SORT_KEYS # only when you need deterministic output
# | orjson.OPT_INDENT_2 # only for human-readable output (slower)
),
)
```
Each flag is opt-in for a reason — orjson defaults to spec-strict JSON.
The flag combination above is a sensible "production default" for application code. The `OPT_NAIVE_UTC | OPT_UTC_Z` pair is especially important: it produces RFC 3339 timestamps that every parser on earth accepts.
---
## 5. orjson + FastAPI
### 5.1 The legacy pattern: `ORJSONResponse`
```python
from fastapi import FastAPI
from fastapi.responses import ORJSONResponse
app = FastAPI(default_response_class=ORJSONResponse)
@app.get("/items")
async def get_items() -> dict[str, list[dict[str, int]]]:
return {"items": [{"id": i, "qty": i * 2} for i in range(1000)]}
```
`default_response_class=ORJSONResponse` swaps the global JSON encoder for orjson. **This affects only the response body serialization**, not request parsing — for request parsing, FastAPI still uses Pydantic.
### 5.2 The 2026 reality — Pydantic v2 vs orjson
With FastAPI 0.100+ on Pydantic v2:
- If your response is annotated with a Pydantic model, FastAPI calls `model_dump_json()` directly. **orjson is bypassed** even with `default_response_class=ORJSONResponse`, because the Pydantic serializer is already Rust-backed.
- If your response is a raw `dict` / `list` / Python object, `ORJSONResponse` does kick in and saves real time.
The benchmark in `tiangolo/fastapi#11728` (Apr 2024) showed `model_dump_json()` is ~1015% faster than `ORJSONResponse + model_dump()` for Pydantic-shaped responses. The shape of the data matters; on mixed-shape APIs, keep `ORJSONResponse` as the default and trust Pydantic's path for typed responses.
### 5.3 Recommended setup
```python
from fastapi import FastAPI
from fastapi.responses import ORJSONResponse
app = FastAPI(
default_response_class=ORJSONResponse, # benefits dict/list returns
# Pydantic-typed returns automatically use pydantic-core serialization
)
```
**Do NOT** wrap Pydantic models manually:
```python
# BAD — defeats Pydantic's optimized path
@app.get("/users/{id}", response_class=ORJSONResponse)
async def get_user(id: int) -> ORJSONResponse:
user = await fetch_user(id)
return ORJSONResponse(content=user.model_dump()) # extra dict trip
# GOOD — let FastAPI serialize the model
@app.get("/users/{id}")
async def get_user(id: int) -> User:
return await fetch_user(id)
```
### 5.4 Streaming responses
`ORJSONResponse` does not stream — it buffers the whole response. For SSE, NDJSON, or chunked JSON, use `StreamingResponse` and call `orjson.dumps` per chunk:
```python
from fastapi.responses import StreamingResponse
import orjson
async def ndjson_stream():
async for row in fetch_rows():
yield orjson.dumps(row) + b"\n"
@app.get("/export")
async def export():
return StreamingResponse(ndjson_stream(), media_type="application/x-ndjson")
```
This is where orjson shines — per-chunk serialization in a tight loop, zero buffering.
---
## 6. orjson + Pydantic v2 (no FastAPI)
When you have a Pydantic model and want orjson's output for non-FastAPI contexts:
```python
from pydantic import BaseModel
import orjson
class User(BaseModel):
id: int
email: str
created: datetime
user = User(id=1, email="a@b.com", created=datetime.now(UTC))
# Option A — Pydantic's built-in Rust serializer (USE THIS by default)
raw: bytes = user.model_dump_json().encode()
# 2026: ~1.2× faster than orjson on the same payload, supports
# every Pydantic feature (aliases, computed fields, json_schema_extra, etc.)
# Option B — orjson bridge for cases Pydantic does not cover
raw: bytes = orjson.dumps(
user,
default=lambda obj: obj.model_dump() if isinstance(obj, BaseModel) else None,
)
# Useful when serializing nested non-Pydantic structures that contain
# BaseModels — e.g. a list of dicts that each may contain a BaseModel.
```
For routine "serialize one Pydantic model to JSON", `model_dump_json()` wins on speed AND feature parity. Reach for orjson only at the *container* level (a dict of mixed types).
### Custom `default=` callback — the universal extension point
```python
import orjson
from decimal import Decimal
from pydantic import BaseModel
def _default(obj):
if isinstance(obj, BaseModel):
return obj.model_dump()
if isinstance(obj, Decimal):
return str(obj)
if isinstance(obj, set):
return list(obj)
raise TypeError(f"orjson: cannot serialize {type(obj).__name__}")
orjson.dumps(payload, default=_default, option=orjson.OPT_NAIVE_UTC | orjson.OPT_UTC_Z)
```
The `default=` callback runs once per unrecognized type, then orjson caches the path. Performance impact on subsequent calls is negligible.
---
## 7. Caching, queues, logging — the prime orjson use cases
These are where orjson pays off most clearly because there is no Pydantic in the loop:
### Redis cache
```python
import orjson
import redis.asyncio as redis
r = redis.from_url("redis://localhost")
async def set_cache(key: str, value: dict) -> None:
await r.set(key, orjson.dumps(value), ex=3600)
async def get_cache(key: str) -> dict | None:
raw = await r.get(key)
return orjson.loads(raw) if raw else None
```
`orjson` over stdlib `json` here saves ~510× on the serialize step for typical cache payloads. Multiply by request rate.
### Task queue payloads (Celery, RQ, dramatiq)
```python
# Celery custom serializer
from kombu.serialization import register
import orjson
def _orjson_dumps(obj):
return orjson.dumps(obj, option=orjson.OPT_NAIVE_UTC | orjson.OPT_UTC_Z).decode()
def _orjson_loads(s):
return orjson.loads(s)
register("orjson", _orjson_dumps, _orjson_loads,
content_type="application/x-orjson",
content_encoding="utf-8")
```
Same speedup, applied to every task payload encode/decode.
### Structured logging (structlog, custom slog)
```python
import structlog
import orjson
structlog.configure(
processors=[
structlog.processors.TimeStamper(fmt="iso"),
structlog.processors.add_log_level,
structlog.processors.JSONRenderer(serializer=orjson.dumps),
],
)
```
structlog's `JSONRenderer` accepts any callable; orjson is the obvious default. Logging hot paths benefit dramatically — every log line at info level becomes ~5× cheaper to render.
---
## 8. Gotchas
### `orjson.dumps` returns bytes, not str
```python
# BAD — concatenating bytes and str
log.info("payload: " + orjson.dumps(data)) # TypeError
# GOOD
log.info("payload: %s", orjson.dumps(data).decode())
# or
log.info("payload: %s", orjson.dumps(data)) # let the formatter handle it
```
### No `cls=` argument for custom encoders
orjson uses `default=` only. If you have a custom `JSONEncoder` subclass from stdlib `json`, port its `default()` method to a `default=` callable.
### Subclasses of `dict` / `list` are NOT serialized as their parent
```python
class StrictDict(dict): ...
d = StrictDict({"k": "v"})
import json
json.dumps(d) # OK — stdlib walks subclasses
orjson.dumps(d) # TypeError — orjson is strict by design
orjson.dumps(d, option=orjson.OPT_PASSTHROUGH_SUBCLASS) # then route via default=
```
Set `OPT_PASSTHROUGH_SUBCLASS` and handle the subclass in `default=`. The design discourages accidental subclass usage that breaks elsewhere.
### `int` overflow
orjson refuses to encode integers larger than 2⁵³ - 1 by default (the IEEE-754 double-precision safe-integer limit — what JavaScript can round-trip). For larger ints, opt in:
```python
orjson.dumps(huge_int, option=orjson.OPT_STRICT_INTEGER) # error
orjson.dumps(huge_int) # default — int is encoded as JSON number
# JavaScript clients lose precision past 2^53; consider sending as string
```
This is more spec-strict than stdlib `json`, which silently emits ints of any size.
### Timezone-naive datetimes
By default, orjson treats naive `datetime` as the system local timezone — almost never what you want. **Always set `OPT_NAIVE_UTC`** to treat naive datetimes as UTC, or use timezone-aware datetimes (which is the better long-term habit).
---
## 9. Benchmark — should I actually adopt this?
The numbers below are 20242026 averages from `tiangolo/fastapi#11728` and orjson's own benchmark suite, on Python 3.13, modern x86_64:
| Payload | stdlib `json` | `orjson` | `model_dump_json()` (Pydantic v2) |
|---|---|---|---|
| Small dict (100 fields) | 1.0× | **8×** | n/a |
| List of 10k dicts | 1.0× | **11×** | n/a |
| Pydantic model with 20 fields | 1.0× (after `model_dump()`) | 5× (with `default=` bridge) | **6×** |
| Datetime-heavy payload | 1.0× (after manual ISO conv) | **9×** | 6× |
| numpy array (1M floats) | impossible without manual conv | **20×** vs json+tolist | n/a |
The takeaways:
- For raw dict/list/datetime, **orjson is dramatically faster**.
- For Pydantic models, **`model_dump_json()` is already faster than orjson+bridge**.
- For numpy, orjson is the only sane choice.
In production, the actual measured win on a FastAPI app with mixed payloads is typically 515% reduction in p99 latency. Worth the one-line `default_response_class=ORJSONResponse` switch.
---
## 10. When NOT to adopt orjson
- The codebase is small, JSON is not a bottleneck, and you have no measured perf concern.
- You depend on stdlib `json`'s `cls=` arg or its lax tolerance for non-spec input (NaN, Infinity, comments).
- You need pretty-printed JSON with custom indent — orjson only supports 2-space indent via the flag.
- You need pure-Python portability (e.g., MicroPython, no-wheel platforms) — orjson is a compiled Rust extension.
If the choice is "add a dependency that does 510× the speed on serialization for free", the answer is almost always yes. The "almost" is in the bullets above.
---
## Sources
- orjson: https://github.com/ijl/orjson
- Pydantic v2 `model_dump_json`: https://docs.pydantic.dev/latest/concepts/serialization/#modelmodel_dump_json
- FastAPI `ORJSONResponse`: https://fastapi.tiangolo.com/advanced/custom-response/#use-orjsonresponse
- "FastAPI + orjson vs Pydantic v2" benchmark: https://github.com/fastapi/fastapi/discussions/11728
- structlog JSON rendering: https://www.structlog.org/en/stable/api.html#structlog.processors.JSONRenderer
@@ -0,0 +1,285 @@
# PydanticAI Reference (v1.x, 2026)
> Canonical patterns for wiring PydanticAI agents. Target: production usage, late-2025 / 2026.
> Source: [ai.pydantic.dev](https://ai.pydantic.dev) and [pydantic/pydantic-ai@`cad9569`](https://github.com/pydantic/pydantic-ai/blob/cad956910079737ea0886b50cef15777208f92e6).
---
## 1. Agent Constructor
```python
from pydantic_ai import Agent
agent = Agent(
'openai:gpt-5.2', # model (str | Model | None)
output_type=MyOutputModel, # structured output type; default=str
instructions='You are a...', # static or callable instructions
system_prompt='Be concise.', # static system prompt(s)
deps_type=MyDeps, # dependency type for type-checking only
name='my-agent', # optional, inferred from var name if omitted
retries=1, # default retries for tools + output validation
output_retries=None, # override retries for output validation only
tools=[my_tool], # list of Tool objects or plain functions
defer_model_check=False, # set True to skip env-var check at init time
end_strategy='early', # 'early' | 'graceful' | 'exhaustive'
)
```
**Breaking change (v1.88.0)**: `result_type` was renamed to `output_type`. Use `output_type`.
---
## 2. Model Strings
Format: `provider:model-name`. The framework infers the provider from the prefix.
| Provider prefix | Example |
|---|---|
| `openai:` | `'openai:gpt-5.2'`, `'openai:gpt-4o'` |
| `anthropic:` | `'anthropic:claude-sonnet-4-6'`, `'anthropic:claude-opus-4-1'` |
| `google-gla:` | `'google-gla:gemini-3-flash-preview'` |
| `google-vertex:` | `'google-vertex:gemini-3-pro-preview'` |
| `bedrock:` | `'bedrock:anthropic.claude-sonnet-4-6'` |
| `xai:` / `grok:` | `'xai:grok-3'`, `'grok:grok-3-fast'` |
| `deepseek:` | `'deepseek:deepseek-chat'` |
| `cohere:` | `'cohere:command-r-08-2024'` |
| `gateway/...` | `'gateway/openai:gpt-5.2'` (PydanticAI Gateway) |
Model can also be omitted at construction and passed per-run: `agent.run(prompt, model='openai:gpt-5.2')`.
---
## 3. Tools
### Decorator syntax
```python
from pydantic_ai import Agent, RunContext
agent = Agent('openai:gpt-5.2', deps_type=str)
@agent.tool # default: receives RunContext as first arg
async def greet(ctx: RunContext[str], name: str) -> str:
return f"Hello {ctx.deps}, {name}!"
@agent.tool_plain # no context needed
async def roll_dice(sides: int) -> int:
import random
return random.randint(1, sides)
```
### `RunContext[Deps]`
First parameter of `@agent.tool` functions. Carries:
- `ctx.deps` — the dependency instance
- `ctx.model` — the model being used
- `ctx.usage` — token usage so far
- `ctx.messages` — conversation history
- `ctx.retry` / `ctx.max_retries` — current retry count
- `ctx.agent` — the running agent instance
Use `@agent.tool_plain` when the tool does **not** need any of the above.
---
## 4. Structured Output
Pass a Pydantic `BaseModel` (or `bool`, `int`, `list[str]`, etc.) as `output_type`. The result is accessed via `.output`.
```python
from pydantic import BaseModel
from pydantic_ai import Agent
class City(BaseModel):
name: str
country: str
population_millions: float
agent = Agent('openai:gpt-5.2', output_type=City)
result = agent.run_sync('Tell me about Tokyo')
print(result.output) # City(name='Tokyo', country='Japan', ...)
print(result.output.name) # 'Tokyo'
```
**Note**: `result.data` was renamed; the canonical accessor is `result.output`.
---
## 5. Async vs Sync
| Method | Mode | Returns |
|---|---|---|
| `await agent.run(prompt, ...)` | async | `AgentRunResult[OutputDataT]` |
| `agent.run_sync(prompt, ...)` | sync | `AgentRunResult[OutputDataT]` |
| `async with agent.run_stream(prompt, ...) as response:` | async streaming | `StreamedRunResult` |
```python
# Sync
result = agent.run_sync('What is the capital of Italy?')
print(result.output)
# Async
result = await agent.run('What is the capital of France?')
print(result.output)
# Streaming
async with agent.run_stream('What is the capital of the UK?') as response:
async for text in response.stream_text():
print(text, end='')
# After streaming finishes:
print(response.output)
```
`run_sync()` is a convenience wrapper over `loop.run_until_complete(self.run(...))`. Do not use it inside an active async context.
---
## 6. Dependencies
Use a `@dataclass` container, pass the **type** to `deps_type`, and pass an **instance** to `deps` at run time.
```python
from dataclasses import dataclass
import httpx
from pydantic_ai import Agent, RunContext
@dataclass
class Deps:
api_key: str
http_client: httpx.AsyncClient
agent = Agent(
'openai:gpt-5.2',
deps_type=Deps,
)
@agent.tool
async def fetch_data(ctx: RunContext[Deps], endpoint: str) -> str:
r = await ctx.deps.http_client.get(
endpoint,
headers={'Authorization': f'Bearer {ctx.deps.api_key}'},
)
r.raise_for_status()
return r.text
async def main():
async with httpx.AsyncClient() as client:
deps = Deps(api_key='sk-...', http_client=client)
result = await agent.run('Get /users', deps=deps)
print(result.output)
```
---
## 7. Error Types & Retrying from a Tool
```python
from pydantic_ai import Agent, ModelRetry, UnexpectedModelBehavior, capture_run_messages
agent = Agent('openai:gpt-5.2', retries=3)
@agent.tool_plain
def calc_volume(size: int) -> int:
if size == 42:
return size ** 3
raise ModelRetry('Please try again with size 42.')
with capture_run_messages() as messages:
try:
result = agent.run_sync('Get the volume of a box with size 6.')
except UnexpectedModelBehavior as e:
print('Error:', e) # "Tool 'calc_volume' exceeded max retries count of 3"
print('Cause:', e.__cause__) # ModelRetry('Please try again...')
print('Messages:', messages)
```
- **`ModelRetry`** — raise from a tool, output validator, or capability hook to ask the model to retry.
- **`UnexpectedModelBehavior`** — raised when the retry limit is exceeded or the model API returns an unrecoverable error.
- **`capture_run_messages()`** — context manager that records all messages exchanged during a run for debugging.
---
## 8. Logfire Integration
One-line setup if the `logfire` extra is installed (included in the default `pydantic-ai` package):
```python
import logfire
logfire.configure() # reads token from .logfire directory
logfire.instrument_pydantic_ai() # auto-traces all agent runs
```
Alternatively, set `instrument=True` on the agent:
```python
agent = Agent('openai:gpt-5.2', instrument=True)
```
---
## 9. Minimal Complete Snippets
### (a) Basic agent with structured output
```python
from pydantic import BaseModel
from pydantic_ai import Agent
class City(BaseModel):
name: str
country: str
agent = Agent('openai:gpt-5.2', output_type=City)
result = agent.run_sync('Tell me about Paris')
print(result.output) # City(name='Paris', country='France')
```
### (b) Agent with tools and dependencies
```python
from dataclasses import dataclass
from pydantic_ai import Agent, RunContext
@dataclass
class Deps:
api_key: str
agent = Agent('openai:gpt-5.2', deps_type=Deps)
@agent.tool
async def get_secret(ctx: RunContext[Deps], code: str) -> str:
if code == '1234':
return f'secret-for-{ctx.deps.api_key}'
return 'wrong code'
result = agent.run_sync('My code is 1234', deps=Deps(api_key='sk-abc'))
print(result.output)
```
### (c) Async streaming
```python
import anyio
from pydantic_ai import Agent
agent = Agent('openai:gpt-5.2')
async def main() -> None:
async with agent.run_stream('Write a haiku about Python') as response:
async for text in response.stream_text():
print(text, end='')
print('\n---')
print('Final:', response.output)
anyio.run(main)
```
---
## Version Notes
- **V1** reached API stability in September 2025. Breaking changes are reserved for V2 (earliest April 2026).
- **v1.88.0** renamed `result_type``output_type` and `result_tool_name` / `result_tool_description` were removed. Use `output_type`.
- The canonical accessor for run results is `result.output` (not `result.data`).
@@ -0,0 +1,232 @@
# Strict pyproject.toml (basedpyright + ruff + uv)
The canonical "super strict but sane" config for modern Python projects. Copy-paste, then add your own dependencies.
## Bootstrap
```bash
# Application
uv init --app myproject
cd myproject
# Library (publishable to PyPI)
uv init --lib mylibrary
cd mylibrary
# Add dev tools
uv add --dev basedpyright ruff pytest
```
`uv init` creates `pyproject.toml`, `.python-version`, and `src/` layout. Replace its `pyproject.toml` `[tool.*]` sections with the block below.
## The full pyproject.toml
```toml
[project]
name = "myproject"
version = "0.1.0"
description = "..."
readme = "README.md"
requires-python = ">=3.13"
dependencies = []
[dependency-groups]
dev = [
"basedpyright>=1.21",
"ruff>=0.8",
"pytest>=8",
"pytest-cov>=5",
]
# ─────────────────────────────────────────────────────────────────
# basedpyright - typeCheckingMode = "all" sets every report flag to error
# Source: https://docs.basedpyright.com/latest/configuration/config-files/
# ─────────────────────────────────────────────────────────────────
[tool.basedpyright]
typeCheckingMode = "all"
pythonVersion = "3.13"
pythonPlatform = "All" # default in basedpyright; explicit for clarity
include = ["src", "tests"]
exclude = ["**/__pycache__", "**/.venv", "**/build", "**/dist"]
# Strict enforcement extras (most are already "error" under "all" mode,
# but listing them explicitly documents the intent)
reportUnusedCallResult = "warning" # flag ignored return values
reportUnnecessaryTypeIgnoreComment = "error" # stale type: ignore comments must die
reportUnusedVariable = "error" # unused variables are errors
reportMissingParameterType = "error" # every parameter must have a type
reportMissingReturnType = "error" # every function must declare its return type
reportPrivateUsage = "error" # respect _private convention
# Optional: gradual adoption baseline
# baselineFile = "./.basedpyright/baseline.json"
# ─────────────────────────────────────────────────────────────────
# ruff - select = ["ALL"] enables every rule, then we ignore the
# small set that conflicts with the formatter or is not useful.
# Source: https://docs.astral.sh/ruff/linter/#rule-selection
# ─────────────────────────────────────────────────────────────────
[tool.ruff]
target-version = "py313"
line-length = 88 # ruff/black default; 100 or 120 also fine
src = ["src", "tests"]
[tool.ruff.lint]
select = ["ALL"]
ignore = [
# Formatter conflicts (ruff itself tells you to ignore these)
"COM812", # missing trailing comma
"ISC001", # implicit string concat
# Docstyle conflicts (pick D211 over D203, D212 over D213)
"D203",
"D213",
# Project-specific noise
"CPY001", # missing copyright notice
"FBT001", # boolean positional arg in def
"FBT002", # boolean positional default in def
"TD002", # missing TODO author
"TD003", # missing TODO link
"FIX002", # line contains TODO (TODOs are allowed)
]
fixable = ["ALL"]
unfixable = []
[tool.ruff.lint.per-file-ignores]
"tests/**/*.py" = [
"S101", # `assert` is the entire point of pytest
"ARG", # unused args (fixtures appear unused)
"PLR2004", # magic numbers in test data
"SLF001", # tests need access to private members
"D", # docstrings not required in tests
]
"scripts/**/*.py" = [
"T201", # `print` allowed in scripts
"INP001", # implicit namespace package
]
[tool.ruff.lint.pydocstyle]
convention = "google" # or "numpy" / "pep257"
[tool.ruff.lint.flake8-bugbear]
# typer / fastapi rely on call-as-default for parameter metadata.
# Without this, ruff B008 ("function call in default") fires on every typer/fastapi route.
extend-immutable-calls = [
"typer.Argument",
"typer.Option",
"fastapi.Depends",
"fastapi.Query",
"fastapi.Path",
"fastapi.Body",
"fastapi.Header",
"fastapi.Cookie",
"fastapi.File",
"fastapi.Form",
]
[tool.ruff.format]
quote-style = "double"
indent-style = "space"
docstring-code-format = true
docstring-code-line-length = "dynamic"
# ─────────────────────────────────────────────────────────────────
# pytest
# ─────────────────────────────────────────────────────────────────
[tool.pytest.ini_options]
minversion = "8.0"
testpaths = ["tests"]
addopts = [
"-ra",
"--strict-config",
"--strict-markers",
]
filterwarnings = ["error"]
# ─────────────────────────────────────────────────────────────────
# coverage
# ─────────────────────────────────────────────────────────────────
[tool.coverage.run]
source = ["src"]
branch = true
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"if TYPE_CHECKING:",
"if typing.TYPE_CHECKING:",
"raise NotImplementedError",
"@(abc\\.)?abstractmethod",
]
```
## Why these settings
### basedpyright `typeCheckingMode = "all"`
basedpyright's modes, strictest first:
| Mode | Behavior |
|---|---|
| `"all"` | Every diagnostic at `error` |
| `"recommended"` | Same rules; less severe ones at `warning`; `failOnWarnings = true` makes CI still fail |
| `"strict"` | pyright's strict mode |
| `"standard"` | Default |
| `"basic"` / `"off"` | Loose / disabled |
`"all"` enables basedpyright-exclusive rules pyright lacks: `reportImplicitOverride`, `reportImplicitStringConcatenation`, `reportIncompatibleUnannotatedOverride`, `reportUnannotatedClassAttribute`. No need to opt-in to additional flags.
`pythonPlatform = "All"` is basedpyright's default (better than pyright's host-OS default) - it errors on platform-specific imports that fail on other OSes.
### ruff `select = ["ALL"]`
The official docs say *"Use ALL with discretion. Enabling ALL will implicitly enable new rules whenever you upgrade."* For a strict skill that is the intended behavior - every new ruff rule should be considered an error until you justify ignoring it.
The minimal ignore set:
| Rule | Reason |
|---|---|
| `COM812`, `ISC001` | Conflict with `ruff format` (ruff itself documents this) |
| `D203` vs `D211`, `D213` vs `D212` | Mutually-exclusive docstring conventions; pick the modern one |
| `CPY001` | Most projects don't need a copyright header on every file |
| `FBT001`, `FBT002` | Boolean flags are ergonomic for CLI/typer; ban makes typer awkward |
| `TD002`, `TD003`, `FIX002` | TODOs without a JIRA link are fine in solo / internal code |
`ANN101` and `ANN102` were **removed in ruff 0.8.0** (Nov 2024). Do NOT include them in `ignore` - ruff errors on unknown rule codes.
`per-file-ignores` for `tests/**` is the standard pattern from real-world repos like `community-of-python/auto-typing-final` and `Preston-Landers/concurrent-log-handler`.
## CI gate
```bash
# In CI, fail on any violation:
uv run basedpyright
uv run ruff check
uv run ruff format --check
uv run pytest
```
A single `make ci` target combining the four works fine.
## Enforcement summary
The config above, combined with `scripts/check-no-excuse-rules.py`, enforces:
| What | How |
|---|---|
| Exhaustive match | basedpyright `all` mode + `assert_never` |
| No `Any` | basedpyright `all` mode + script `cast-any` rule |
| Ignored return values | `reportUnusedCallResult = "warning"` |
| Immutable default | Script `mutable-dataclass` + `missing-slots` rules |
| No null surprise | basedpyright strict `None` analysis |
| Constants are const | basedpyright catches `Final` reassignment |
| Unused variables | `reportUnusedVariable = "error"` |
## Sources
- basedpyright modes: <https://docs.basedpyright.com/latest/configuration/config-files/#type-check-diagnostics-settings>
- basedpyright `"all"` vs `"recommended"`: <https://docs.basedpyright.com/latest/configuration/config-files/#recommended-and-all>
- basedpyright better defaults: <https://docs.basedpyright.com/latest/benefits-over-pyright/better-defaults/>
- ruff rule selection: <https://docs.astral.sh/ruff/linter/#rule-selection>
- ruff ANN101/ANN102 removed: <https://github.com/astral-sh/ruff/pull/14384>
- Real-world ALL config: <https://github.com/community-of-python/auto-typing-final/blob/main/pyproject.toml>
- PEP 735 dependency-groups: <https://peps.python.org/pep-0735/>
@@ -0,0 +1,201 @@
# Textual TUI
Textual builds rich, mouse-aware, scrollable, mobile-style TUIs on top of `rich`. Replaces curses, urwid, blessed.
## Install
```bash
uv add textual
uv add --dev textual-dev # textual console + run --dev for hot reload
```
## Minimal app
```python
from textual.app import App, ComposeResult
from textual.widgets import Header, Footer, Button, Label
from textual.containers import Vertical
class CounterApp(App[None]):
"""A trivial counter app."""
BINDINGS = [("q", "quit", "Quit")]
CSS = """
#count {
height: 3;
content-align: center middle;
background: $boost;
}
"""
count: int = 0
def compose(self) -> ComposeResult:
yield Header()
with Vertical():
yield Label("0", id="count")
yield Button("Increment", id="inc", variant="primary")
yield Button("Reset", id="reset", variant="warning")
yield Footer()
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "inc":
self.count += 1
elif event.button.id == "reset":
self.count = 0
self.query_one("#count", Label).update(str(self.count))
if __name__ == "__main__":
CounterApp().run()
```
Run:
```bash
uv run python counter.py
```
For hot reload during development:
```bash
uv run textual run --dev counter.py
```
## Reactive attributes
Textual's `reactive()` descriptor turns a class attribute into something that watches assignments and re-renders automatically. Replaces the manual `query_one` + `update` dance.
```python
from textual.app import App, ComposeResult
from textual.reactive import reactive
from textual.widgets import Label
class CountWidget(Label):
count: reactive[int] = reactive(0)
def render(self) -> str:
return f"Count: {self.count}"
class CounterApp(App[None]):
def compose(self) -> ComposeResult:
yield CountWidget()
def on_key(self, event) -> None:
if event.key == "space":
self.query_one(CountWidget).count += 1
```
`reactive()` triggers `render()` (or `watch_<attr>` and `validate_<attr>` callbacks if defined). Use `recompose=True` if you need to call `compose()` again on change.
## Async work — workers
NEVER block the event loop. For network/disk/CPU work, use `@work` (creates a worker) or `run_worker`.
```python
import httpx
from textual.app import App, ComposeResult
from textual.widgets import Input, Static
from textual.work import work
class FetchApp(App[None]):
def compose(self) -> ComposeResult:
yield Input(placeholder="URL", id="url")
yield Static(id="result")
@work(exclusive=True)
async def fetch(self, url: str) -> None:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(url)
self.query_one("#result", Static).update(f"{response.status_code} - {len(response.text)} bytes")
def on_input_submitted(self, event: Input.Submitted) -> None:
self.fetch(event.value)
```
`exclusive=True` cancels the previous worker if the user submits a new URL before the first finishes. Workers integrate with Textual's lifecycle - they're cancelled when the app exits.
`@work` is asyncio-flavoured under the hood. That is fine - it does not violate the no-asyncio rule because you are calling Textual's API, not importing asyncio yourself. Inside the worker body, use `httpx.AsyncClient` and other anyio-friendly libraries.
## Action handlers
Bind keys to method calls via `BINDINGS` and `action_*` methods.
```python
class App(App):
BINDINGS = [
("ctrl+s", "save", "Save"),
("ctrl+r", "reload", "Reload"),
]
def action_save(self) -> None:
# Called on ctrl+s
...
def action_reload(self) -> None:
...
```
Bindings can also include the `priority=True` flag to fire before children get a chance.
## CSS
Textual's CSS supports selectors, variables (`$primary`, `$boost`), animations. Inline via `CSS = "..."` or external via `CSS_PATH = "app.tcss"`.
```css
Screen {
background: $surface;
color: $text;
layout: vertical;
}
#sidebar {
width: 30;
background: $boost;
}
Button.danger {
background: $error;
}
```
Reload with `r` in dev mode (`textual run --dev`).
## Testing
```python
import pytest
from myapp import CounterApp
@pytest.mark.anyio
async def test_counter_increments() -> None:
app = CounterApp()
async with app.run_test() as pilot:
await pilot.click("#inc")
await pilot.click("#inc")
assert app.count == 2
```
`pilot.click(selector)`, `pilot.press("q")`, `pilot.pause()` for waiting on the next frame.
## When NOT to use Textual
| Need | Use |
|---|---|
| One-off CLI with structured output | typer + rich |
| Progress bar in a script | rich.progress |
| Tabular display of query results | rich.table |
| Full-screen app with state, input, mouse | Textual |
A pretty CLI is not a TUI. Reach for Textual when the user expects to navigate a UI, not when you want colours.
## Sources
- Textual docs: <https://textual.textualize.io>
- Textual tutorial: <https://textual.textualize.io/tutorial/>
- API reference: <https://textual.textualize.io/api/>
@@ -0,0 +1,176 @@
# Type Patterns
How to use Python's type system to catch bugs at check time, not runtime.
---
## NewType — distinct primitives
Same runtime type, different meaning. The type checker prevents mixing.
```python
from typing import NewType
UserId = NewType("UserId", int)
MovieId = NewType("MovieId", int)
Email = NewType("Email", str)
Seconds = NewType("Seconds", float)
Milliseconds = NewType("Milliseconds", float)
def get_user(user_id: UserId) -> User: ...
def get_movie(movie_id: MovieId) -> Movie: ...
def sleep(duration: Seconds) -> None: ...
uid = UserId(42)
mid = MovieId(42)
get_user(uid) # OK
get_user(mid) # type error: MovieId is not UserId
get_user(42) # type error: int is not UserId
sleep(Milliseconds(100.0)) # type error
```
**Use when**: IDs, indices, keys, units of measurement — any pair where swapping is a bug.
**Skip when**: ephemeral local math where branding adds noise with zero safety gain.
---
## Final — constants are const
Module-level constants declare their intent. Reassignment is a type error.
```python
from typing import Final
MAX_RETRIES: Final = 3
API_BASE_URL: Final = "https://api.example.com"
DEFAULT_TIMEOUT: Final = 30.0
MAX_RETRIES = 5 # type error: cannot assign to Final
```
If it changes at runtime, it's not a constant — make it a function parameter or config field.
---
## TypeAlias — name complex types
If a union or generic appears more than once, give it a name.
```python
# Python 3.12+
type JsonValue = str | int | float | bool | None | list["JsonValue"] | dict[str, "JsonValue"]
type Headers = dict[str, str]
type Middleware = Callable[[Request], Awaitable[Response]]
# Pre-3.12
from typing import TypeAlias
JsonValue: TypeAlias = str | int | float | bool | None | list["JsonValue"] | dict[str, "JsonValue"]
```
---
## StrEnum / IntEnum — closed sets
Any fixed set of known values. No string literals scattered through code.
```python
from enum import StrEnum, IntEnum, unique
@unique
class Role(StrEnum):
ADMIN = "admin"
USER = "user"
GUEST = "guest"
@unique
class HttpStatus(IntEnum):
OK = 200
NOT_FOUND = 404
INTERNAL_ERROR = 500
# BAD
def check_role(role: str) -> bool: ...
# GOOD
def check_role(role: Role) -> bool: ...
```
`StrEnum` when values serialize as strings (API, DB). `IntEnum` for numeric codes. Plain `Enum` for pure labels.
---
## Type narrowing — let the checker follow your logic
`isinstance`, `is None`, and `match` narrow types automatically. Use them instead of `cast`.
```python
def process(value: str | int | None) -> str:
if value is None:
return "nothing"
# checker knows: str | int
if isinstance(value, str):
return value.upper()
# checker knows: int
return str(value * 2)
```
### TypeGuard for custom narrowing
```python
from typing import TypeGuard
def is_valid_email(value: str) -> TypeGuard[Email]:
return "@" in value and "." in value.split("@")[1]
def send(addr: str) -> None:
if not is_valid_email(addr):
raise ValueError(addr)
# checker knows: addr is Email
deliver(addr)
```
### TypeIs (Python 3.13+) — the strict version
`TypeIs` is stricter than `TypeGuard` — it narrows in both `if` and `else` branches.
```python
from typing import TypeIs
def is_str(value: str | int) -> TypeIs[str]:
return isinstance(value, str)
def handle(v: str | int) -> None:
if is_str(v):
print(v.upper()) # checker knows: str
else:
print(v + 1) # checker knows: int
```
---
## Union syntax
Always `X | Y`. Never `Union[X, Y]` or `Optional[X]`.
```python
# BAD
from typing import Union, Optional
def f(x: Optional[int]) -> Union[str, int]: ...
# GOOD
def f(x: int | None) -> str | int: ...
```
---
## Sources
- Python docs: [typing — NewType](https://docs.python.org/3/library/typing.html#newtype)
- Python docs: [typing — Final](https://docs.python.org/3/library/typing.html#typing.Final)
- Python docs: [typing — TypeGuard](https://docs.python.org/3/library/typing.html#typing.TypeGuard)
- PEP 604: [Union syntax X | Y](https://peps.python.org/pep-0604/)
- PEP 742: [TypeIs](https://peps.python.org/pep-0742/)