add language pattern notes: typing, async, pydantic, patterns

restructure python notes to cover the language itself, not just tooling:
- typing.md: modern type hints, Protocol, generics, ParamSpec
- async.md: context managers, ContextVar, concurrency patterns
- pydantic.md: models, validators, discriminated unions, settings
- patterns.md: class design, decorators, error handling

rewrite README as friendly introduction with language/ecosystem split

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+15 -5
languages/python/README.md
··· 1 1 # python 2 2 3 - notes on python patterns - tooling, project structure, and building things. 3 + notes on writing python - the language itself, the ecosystem around it, and patterns that work. 4 4 5 - ## topics 5 + python is easy to start but has depth. these notes focus on modern python (3.10+) with strong typing, async patterns, and the tooling that makes it feel like a proper systems language. 6 + 7 + ## the language 8 + 9 + how to write python code: 10 + 11 + - [typing](./typing.md) - modern type hints, Protocol, generics 12 + - [async](./async.md) - context managers, ContextVar, patterns 13 + - [pydantic](./pydantic.md) - models, validation, settings 14 + - [patterns](./patterns.md) - class design, decorators, error handling 15 + 16 + ## the ecosystem 17 + 18 + tooling and project structure: 6 19 7 20 - [uv](./uv.md) - cargo for python 8 21 - [project setup](./project-setup.md) - src/ layout, pyproject.toml, justfile 9 - - [pydantic-settings](./pydantic-settings.md) - centralized typed configuration 10 22 - [tooling](./tooling.md) - ruff, ty, pre-commit 11 23 - [mcp](./mcp.md) - building MCP servers with fastmcp 12 24 ··· 18 30 |---------|------------| 19 31 | [pdsx](https://github.com/zzstoatzz/pdsx) | ATProto MCP server and CLI | 20 32 | [plyr-python-client](https://github.com/zzstoatzz/plyr-python-client) | uv workspace, multi-package repo | 21 - | [pmgfal](https://github.com/zzstoatzz/pmgfal) | Rust+Python with maturin | 22 33 | [prefect-mcp-server-demo](https://github.com/zzstoatzz/prefect-mcp-server-demo) | MCP server patterns | 23 - | [typsht](https://github.com/zzstoatzz/typsht) | parallel type checking tool | 24 34 25 35 and studying: 26 36
+169
languages/python/async.md
··· 1 + # async 2 + 3 + async python patterns - context managers, concurrency, and request-scoped state. 4 + 5 + ## async context managers 6 + 7 + the core pattern for resource lifecycle: 8 + 9 + ```python 10 + from contextlib import asynccontextmanager 11 + from typing import AsyncIterator 12 + 13 + @asynccontextmanager 14 + async def get_client(require_auth: bool = False) -> AsyncIterator[Client]: 15 + """acquire a client, ensure cleanup.""" 16 + client = Client() 17 + 18 + if require_auth: 19 + await client.login() 20 + 21 + try: 22 + yield client 23 + finally: 24 + await client.close() 25 + ``` 26 + 27 + usage: 28 + 29 + ```python 30 + async with get_client(require_auth=True) as client: 31 + await client.do_something() 32 + # client.close() called automatically 33 + ``` 34 + 35 + ## class-based context managers 36 + 37 + when you need state: 38 + 39 + ```python 40 + class AsyncClient: 41 + async def __aenter__(self) -> "AsyncClient": 42 + self._session = await self._create_session() 43 + return self 44 + 45 + async def __aexit__( 46 + self, 47 + exc_type: type[BaseException] | None, 48 + exc_val: BaseException | None, 49 + exc_tb: TracebackType | None, 50 + ) -> None: 51 + await self._session.close() 52 + ``` 53 + 54 + ## AsyncExitStack 55 + 56 + compose multiple context managers: 57 + 58 + ```python 59 + from contextlib import AsyncExitStack 60 + 61 + async def with_dependencies() -> dict[str, Any]: 62 + async with AsyncExitStack() as stack: 63 + db = await stack.enter_async_context(get_database()) 64 + cache = await stack.enter_async_context(get_cache()) 65 + 66 + return {"db": db, "cache": cache} 67 + ``` 68 + 69 + useful when the number of context managers is dynamic. 70 + 71 + ## ContextVar 72 + 73 + request-scoped state without passing through every function: 74 + 75 + ```python 76 + from contextvars import ContextVar 77 + 78 + _current_user: ContextVar[User | None] = ContextVar("current_user", default=None) 79 + 80 + def get_current_user() -> User: 81 + user = _current_user.get() 82 + if user is None: 83 + raise RuntimeError("no user in context") 84 + return user 85 + 86 + async def with_user(user: User): 87 + token = _current_user.set(user) 88 + try: 89 + yield 90 + finally: 91 + _current_user.reset(token) 92 + ``` 93 + 94 + each async task gets its own copy. no global state pollution. 95 + 96 + ## semaphore for concurrency 97 + 98 + limit concurrent operations: 99 + 100 + ```python 101 + import asyncio 102 + 103 + async def batch_process(items: list[str], concurrency: int = 10) -> list[Result]: 104 + semaphore = asyncio.Semaphore(concurrency) 105 + 106 + async def process_one(item: str) -> Result: 107 + async with semaphore: 108 + return await do_work(item) 109 + 110 + return await asyncio.gather(*[process_one(item) for item in items]) 111 + ``` 112 + 113 + ## gather with error handling 114 + 115 + don't let one failure kill everything: 116 + 117 + ```python 118 + results = await asyncio.gather( 119 + *tasks, 120 + return_exceptions=True, 121 + ) 122 + 123 + successes = [r for r in results if not isinstance(r, Exception)] 124 + failures = [r for r in results if isinstance(r, Exception)] 125 + ``` 126 + 127 + ## shield for critical cleanup 128 + 129 + prevent cancellation during important operations: 130 + 131 + ```python 132 + async def __aexit__(self, *args) -> None: 133 + # don't let cancellation interrupt disconnect 134 + await asyncio.shield(self._connection.disconnect()) 135 + ``` 136 + 137 + ## streaming responses 138 + 139 + async iteration over chunks: 140 + 141 + ```python 142 + async with client.stream("GET", url) as response: 143 + async for line in response.aiter_lines(): 144 + if line.startswith("data: "): 145 + yield json.loads(line[6:]) 146 + ``` 147 + 148 + ## background tasks 149 + 150 + fire and forget with cleanup: 151 + 152 + ```python 153 + class Worker: 154 + async def __aenter__(self) -> "Worker": 155 + self._heartbeat_task = asyncio.create_task( 156 + self._heartbeat(), 157 + name="worker.heartbeat", 158 + ) 159 + return self 160 + 161 + async def __aexit__(self, *args) -> None: 162 + self._heartbeat_task.cancel() 163 + with suppress(asyncio.CancelledError): 164 + await self._heartbeat_task 165 + ``` 166 + 167 + sources: 168 + - [pdsx/src/pdsx/mcp/client.py](https://github.com/zzstoatzz/pdsx/blob/main/src/pdsx/mcp/client.py) 169 + - [docket/src/docket/worker.py](https://github.com/chrisguidry/docket/blob/main/src/docket/worker.py)
+227
languages/python/patterns.md
··· 1 + # patterns 2 + 3 + class design, decorators, error handling, and other structural patterns. 4 + 5 + ## private internals 6 + 7 + keep implementation details in `_internal/`: 8 + 9 + ``` 10 + src/mypackage/ 11 + ├── __init__.py # public API 12 + ├── cli.py # entry point 13 + └── _internal/ # implementation details 14 + ├── config.py 15 + ├── operations.py 16 + └── types.py 17 + ``` 18 + 19 + `__init__.py` re-exports the public interface. users import from the package, not from internals. 20 + 21 + ## dataclasses for DTOs 22 + 23 + simple data containers that don't need validation: 24 + 25 + ```python 26 + from dataclasses import dataclass 27 + 28 + @dataclass 29 + class BatchResult: 30 + successful: list[str] 31 + failed: list[tuple[str, Exception]] 32 + 33 + @property 34 + def total(self) -> int: 35 + return len(self.successful) + len(self.failed) 36 + ``` 37 + 38 + lighter than pydantic when you control the data source. 39 + 40 + ## base classes with parallel implementations 41 + 42 + when you need both sync and async: 43 + 44 + ```python 45 + class _BaseClient: 46 + def __init__(self, *, token: str | None = None): 47 + self._token = token or get_settings().token 48 + 49 + class Client(_BaseClient): 50 + def get(self, url: str) -> dict: 51 + return httpx.get(url, headers=self._headers).json() 52 + 53 + class AsyncClient(_BaseClient): 54 + async def get(self, url: str) -> dict: 55 + async with httpx.AsyncClient() as client: 56 + return (await client.get(url, headers=self._headers)).json() 57 + ``` 58 + 59 + shared logic in base, divergent implementation in subclasses. 60 + 61 + ## fluent interfaces 62 + 63 + chainable methods that return `self`: 64 + 65 + ```python 66 + class Track: 67 + def __init__(self, source: str): 68 + self._source = source 69 + self._effects: list[str] = [] 70 + 71 + def volume(self, level: float) -> "Track": 72 + self._effects.append(f"volume={level}") 73 + return self 74 + 75 + def lowpass(self, freq: float) -> "Track": 76 + self._effects.append(f"lowpass=f={freq}") 77 + return self 78 + 79 + # usage 80 + track.volume(0.8).lowpass(600).fade_in(0.5) 81 + ``` 82 + 83 + ## factory classmethods 84 + 85 + alternative constructors: 86 + 87 + ```python 88 + @dataclass 89 + class URIParts: 90 + repo: str 91 + collection: str 92 + rkey: str 93 + 94 + @classmethod 95 + def from_uri(cls, uri: str) -> "URIParts": 96 + # at://did:plc:xxx/app.bsky.feed.post/abc 97 + parts = uri.replace("at://", "").split("/") 98 + return cls(repo=parts[0], collection=parts[1], rkey=parts[2]) 99 + ``` 100 + 101 + ## keyword-only arguments 102 + 103 + force callers to name arguments for clarity: 104 + 105 + ```python 106 + def batch_create( 107 + client: Client, 108 + collection: str, 109 + records: list[dict], 110 + *, # everything after is keyword-only 111 + concurrency: int = 10, 112 + fail_fast: bool = False, 113 + ) -> BatchResult: 114 + ... 115 + 116 + # must use: batch_create(client, "posts", items, concurrency=5) 117 + # not: batch_create(client, "posts", items, 5) 118 + ``` 119 + 120 + ## custom exceptions 121 + 122 + with helpful context: 123 + 124 + ```python 125 + class AuthenticationRequired(Exception): 126 + def __init__(self, operation: str = "this operation"): 127 + super().__init__( 128 + f"{operation} requires authentication.\n\n" 129 + "Set ATPROTO_HANDLE and ATPROTO_PASSWORD environment variables." 130 + ) 131 + ``` 132 + 133 + the message tells you what to do, not just what went wrong. 134 + 135 + ## exception hierarchies 136 + 137 + for structured error handling: 138 + 139 + ```python 140 + class AppError(Exception): 141 + """base for all app errors.""" 142 + 143 + class ValidationError(AppError): 144 + """input validation failed.""" 145 + 146 + class ResourceError(AppError): 147 + """resource operation failed.""" 148 + 149 + # callers can catch AppError for all, or specific types 150 + ``` 151 + 152 + ## decorators that modify signatures 153 + 154 + add parameters dynamically: 155 + 156 + ```python 157 + import inspect 158 + from functools import wraps 159 + 160 + def filterable(fn): 161 + """add _filter parameter for jmespath filtering.""" 162 + @wraps(fn) 163 + async def wrapper(*args, _filter: str | None = None, **kwargs): 164 + result = await fn(*args, **kwargs) 165 + if _filter: 166 + import jmespath 167 + return jmespath.search(_filter, result) 168 + return result 169 + 170 + # modify signature to include new param 171 + sig = inspect.signature(fn) 172 + params = list(sig.parameters.values()) 173 + params.append(inspect.Parameter( 174 + "_filter", 175 + inspect.Parameter.KEYWORD_ONLY, 176 + default=None, 177 + )) 178 + wrapper.__signature__ = sig.replace(parameters=params) 179 + return wrapper 180 + ``` 181 + 182 + the wrapper's signature now includes `_filter`. IDEs and schema generators see it. 183 + 184 + ## module-level singletons 185 + 186 + instantiate once, import everywhere: 187 + 188 + ```python 189 + # config.py 190 + from functools import lru_cache 191 + 192 + @lru_cache 193 + def get_settings() -> Settings: 194 + return Settings() 195 + 196 + settings = get_settings() 197 + 198 + # console.py 199 + from rich.console import Console 200 + 201 + console = Console() 202 + ``` 203 + 204 + ## lowercase aesthetic 205 + 206 + docstrings, comments, and output in lowercase: 207 + 208 + ```python 209 + """list records in a collection.""" 210 + 211 + def list_records(collection: str) -> list[dict]: 212 + """list records from the specified collection. 213 + 214 + args: 215 + collection: the collection to list from 216 + 217 + returns: 218 + list of record dictionaries 219 + """ 220 + ``` 221 + 222 + consistent voice throughout. 223 + 224 + sources: 225 + - [pdsx](https://github.com/zzstoatzz/pdsx) 226 + - [plyr-python-client](https://github.com/zzstoatzz/plyr-python-client) 227 + - [docket](https://github.com/chrisguidry/docket)
-110
languages/python/pydantic-settings.md
··· 1 - # pydantic-settings 2 - 3 - replace scattered `os.getenv()` calls with a typed, validated settings class. 4 - 5 - ## the problem 6 - 7 - ```python 8 - import os 9 - 10 - REDIS_HOST = os.getenv("REDIS_HOST") 11 - REDIS_PORT = os.getenv("REDIS_PORT") 12 - 13 - if not REDIS_HOST or not REDIS_PORT: 14 - raise ValueError("REDIS_HOST and REDIS_PORT must be set") 15 - 16 - # REDIS_PORT is still a string here 17 - ``` 18 - 19 - issues: 20 - - validation happens where you use the value, not at startup 21 - - no type coercion (port is a string) 22 - - configuration scattered across files 23 - - easy to forget validation 24 - 25 - ## the solution 26 - 27 - ```python 28 - from pydantic import Field, SecretStr 29 - from pydantic_settings import BaseSettings, SettingsConfigDict 30 - 31 - class Settings(BaseSettings): 32 - model_config = SettingsConfigDict( 33 - env_file=".env", 34 - extra="ignore", 35 - ) 36 - 37 - redis_host: str 38 - redis_port: int = Field(ge=0) 39 - openai_api_key: SecretStr 40 - 41 - settings = Settings() 42 - ``` 43 - 44 - now: 45 - - missing `REDIS_HOST` fails immediately at import 46 - - `redis_port` is coerced to int and validated >= 0 47 - - `openai_api_key` won't accidentally appear in logs 48 - - all configuration in one place 49 - 50 - ## field aliases 51 - 52 - when env var names don't match your preferred attribute names: 53 - 54 - ```python 55 - class Settings(BaseSettings): 56 - current_user: str = Field(alias="USER") 57 - ``` 58 - 59 - reads from `$USER`, accessed as `settings.current_user`. 60 - 61 - ## secrets 62 - 63 - `SecretStr` prevents accidental exposure: 64 - 65 - ```python 66 - class Settings(BaseSettings): 67 - api_key: SecretStr 68 - 69 - settings = Settings() 70 - print(settings.api_key) # SecretStr('**********') 71 - print(settings.api_key.get_secret_value()) # actual value 72 - ``` 73 - 74 - ## contextual serialization 75 - 76 - when you need to unmask secrets for subprocesses: 77 - 78 - ```python 79 - from pydantic import Secret, SerializationInfo 80 - 81 - def maybe_unmask(v: Secret[str], info: SerializationInfo) -> str | Secret[str]: 82 - if info.context and info.context.get("unmask"): 83 - return v.get_secret_value() 84 - return v 85 - 86 - # usage 87 - settings.model_dump(context={"unmask": True}) 88 - ``` 89 - 90 - ## nested settings 91 - 92 - for larger projects: 93 - 94 - ```python 95 - class DatabaseSettings(BaseSettings): 96 - host: str = "localhost" 97 - port: int = 5432 98 - 99 - class Settings(BaseSettings): 100 - database: DatabaseSettings = Field(default_factory=DatabaseSettings) 101 - ``` 102 - 103 - ## why fail-fast matters 104 - 105 - with `os.getenv()`, you find out about missing config when the code path executes - maybe in production, at 2am. 106 - 107 - with pydantic-settings, invalid configuration fails at startup. deploy fails, not runtime. 108 - 109 - sources: 110 - - [how to use pydantic-settings](https://blog.zzstoatzz.io/how-to-use-pydantic-settings/)
+186
languages/python/pydantic.md
··· 1 + # pydantic 2 + 3 + data validation, serialization, and settings. the foundation for typed python. 4 + 5 + ## BaseModel basics 6 + 7 + ```python 8 + from pydantic import BaseModel, Field, ConfigDict 9 + 10 + class Track(BaseModel): 11 + model_config = ConfigDict(extra="forbid") # reject unknown fields 12 + 13 + title: str 14 + album: str | None = None 15 + tags: list[str] = Field(default_factory=list) 16 + ``` 17 + 18 + `extra="forbid"` catches typos. if someone passes `titl` instead of `title`, it fails. 19 + 20 + ## field aliases 21 + 22 + when external data uses different names: 23 + 24 + ```python 25 + class Track(BaseModel): 26 + model_config = ConfigDict(populate_by_name=True) 27 + 28 + audio_url: str | None = Field(default=None, alias="r2_url") 29 + ``` 30 + 31 + accepts both `audio_url` and `r2_url` as input. 32 + 33 + ## validators 34 + 35 + transform or check data: 36 + 37 + ```python 38 + from pydantic import field_validator, model_validator 39 + from typing import Self 40 + 41 + class Document(BaseModel): 42 + text: str 43 + tokens: int | None = None 44 + 45 + @field_validator("text", mode="before") 46 + @classmethod 47 + def strip_whitespace(cls, v: str) -> str: 48 + return v.strip() 49 + 50 + @model_validator(mode="after") 51 + def compute_tokens(self) -> Self: 52 + if self.tokens is None: 53 + self.tokens = len(self.text.split()) 54 + return self 55 + ``` 56 + 57 + `mode="before"` runs before pydantic's own validation. `mode="after"` runs after the model is constructed. 58 + 59 + ## discriminated unions 60 + 61 + polymorphic types with a type field: 62 + 63 + ```python 64 + from typing import Annotated, Literal 65 + from pydantic import Field 66 + 67 + class TrackResult(BaseModel): 68 + type: Literal["track"] = "track" 69 + title: str 70 + 71 + class ArtistResult(BaseModel): 72 + type: Literal["artist"] = "artist" 73 + name: str 74 + 75 + SearchResult = Annotated[ 76 + TrackResult | ArtistResult, 77 + Field(discriminator="type"), 78 + ] 79 + 80 + def parse_results(data: list[dict]) -> list[SearchResult]: 81 + from pydantic import TypeAdapter 82 + adapter = TypeAdapter(list[SearchResult]) 83 + return adapter.validate_python(data) 84 + ``` 85 + 86 + pydantic looks at `type` to decide which model to use. 87 + 88 + ## TypedDict for loose structures 89 + 90 + when you don't need full model machinery: 91 + 92 + ```python 93 + from typing import TypedDict 94 + 95 + class RecordResponse(TypedDict): 96 + uri: str 97 + cid: str | None 98 + value: dict[str, Any] 99 + ``` 100 + 101 + lighter than BaseModel, still typed. 102 + 103 + ## settings from environment 104 + 105 + replace `os.getenv()` with validated configuration: 106 + 107 + ```python 108 + from pydantic import Field, SecretStr 109 + from pydantic_settings import BaseSettings, SettingsConfigDict 110 + 111 + class Settings(BaseSettings): 112 + model_config = SettingsConfigDict( 113 + env_prefix="APP_", 114 + env_file=".env", 115 + extra="ignore", 116 + ) 117 + 118 + database_url: str 119 + api_key: SecretStr 120 + debug: bool = False 121 + 122 + settings = Settings() 123 + ``` 124 + 125 + - `env_prefix` means `APP_DATABASE_URL` maps to `database_url` 126 + - `SecretStr` hides values in logs 127 + - missing required fields fail at import, not at runtime 128 + 129 + ## cached singleton 130 + 131 + ```python 132 + from functools import lru_cache 133 + 134 + @lru_cache 135 + def get_settings() -> Settings: 136 + return Settings() 137 + ``` 138 + 139 + one instance, created once, reused everywhere. 140 + 141 + ## nested settings 142 + 143 + for complex configuration: 144 + 145 + ```python 146 + class DatabaseSettings(BaseSettings): 147 + host: str = "localhost" 148 + port: int = 5432 149 + 150 + class Settings(BaseSettings): 151 + database: DatabaseSettings = Field(default_factory=DatabaseSettings) 152 + cache: CacheSettings = Field(default_factory=CacheSettings) 153 + ``` 154 + 155 + ## factory methods 156 + 157 + alternative constructors: 158 + 159 + ```python 160 + class Config(BaseModel): 161 + name: str 162 + values: dict[str, Any] 163 + 164 + @classmethod 165 + def from_file(cls, path: Path) -> "Config": 166 + data = json.loads(path.read_text()) 167 + return cls.model_validate(data) 168 + ``` 169 + 170 + ## TypeAdapter for non-model validation 171 + 172 + validate without a full model: 173 + 174 + ```python 175 + from pydantic import TypeAdapter 176 + 177 + adapter = TypeAdapter(list[int]) 178 + result = adapter.validate_python(["1", "2", "3"]) # [1, 2, 3] 179 + ``` 180 + 181 + useful for validating function arguments or API responses. 182 + 183 + sources: 184 + - [pdsx/src/pdsx/_internal/types.py](https://github.com/zzstoatzz/pdsx/blob/main/src/pdsx/_internal/types.py) 185 + - [plyr-python-client/packages/plyrfm/src/plyrfm/_internal/types.py](https://github.com/zzstoatzz/plyr-python-client) 186 + - [how to use pydantic-settings](https://blog.zzstoatzz.io/how-to-use-pydantic-settings/)
+174
languages/python/typing.md
··· 1 + # typing 2 + 3 + modern python type hints - not just for IDEs, but for thinking clearly about code. 4 + 5 + ## basics 6 + 7 + use `|` for unions, not `Optional`: 8 + 9 + ```python 10 + # yes 11 + def fetch(url: str, timeout: float | None = None) -> dict[str, Any]: ... 12 + 13 + # no 14 + from typing import Optional 15 + def fetch(url: str, timeout: Optional[float] = None) -> Dict[str, Any]: ... 16 + ``` 17 + 18 + `from __future__ import annotations` at the top of every file. this defers evaluation so you can reference types before they're defined. 19 + 20 + ## TypedDict 21 + 22 + for structured dictionaries where you know the shape: 23 + 24 + ```python 25 + from typing import TypedDict 26 + 27 + class RecordResponse(TypedDict): 28 + uri: str 29 + cid: str | None 30 + value: dict[str, Any] 31 + 32 + def get_record(uri: str) -> RecordResponse: 33 + ... 34 + ``` 35 + 36 + better than `dict[str, Any]` because the structure is documented and checked. 37 + 38 + ## Annotated 39 + 40 + add metadata to types, especially useful with pydantic: 41 + 42 + ```python 43 + from typing import Annotated 44 + from pydantic import Field 45 + 46 + FilterParam = Annotated[ 47 + str | None, 48 + Field(description="jmespath expression to filter results"), 49 + ] 50 + 51 + def list_records(collection: str, _filter: FilterParam = None) -> list[dict]: 52 + ... 53 + ``` 54 + 55 + the metadata travels with the type. frameworks like fastmcp use it for schema generation. 56 + 57 + ## Protocol 58 + 59 + duck typing with type safety. define what methods something needs, not what class it is: 60 + 61 + ```python 62 + from typing import Protocol, runtime_checkable 63 + 64 + @runtime_checkable 65 + class Closeable(Protocol): 66 + def close(self) -> None: ... 67 + 68 + def cleanup(resource: Closeable) -> None: 69 + resource.close() 70 + ``` 71 + 72 + `@runtime_checkable` lets you use `isinstance()` checks. 73 + 74 + ## generics 75 + 76 + for functions that work with any type: 77 + 78 + ```python 79 + from typing import TypeVar 80 + 81 + T = TypeVar("T") 82 + 83 + def first(items: list[T]) -> T | None: 84 + return items[0] if items else None 85 + ``` 86 + 87 + with bounds: 88 + 89 + ```python 90 + from pydantic import BaseModel 91 + 92 + ModelT = TypeVar("ModelT", bound=BaseModel) 93 + 94 + def parse(data: dict, model: type[ModelT]) -> ModelT: 95 + return model.model_validate(data) 96 + ``` 97 + 98 + ## ParamSpec 99 + 100 + for decorators that preserve function signatures: 101 + 102 + ```python 103 + from typing import ParamSpec, TypeVar, Callable, Awaitable 104 + from functools import wraps 105 + 106 + P = ParamSpec("P") 107 + R = TypeVar("R") 108 + 109 + def logged(fn: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]: 110 + @wraps(fn) 111 + async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: 112 + print(f"calling {fn.__name__}") 113 + return await fn(*args, **kwargs) 114 + return wrapper 115 + ``` 116 + 117 + this preserves the original function's parameter types in the wrapper. 118 + 119 + ## overload 120 + 121 + when return type depends on input: 122 + 123 + ```python 124 + from typing import overload, Literal 125 + 126 + @overload 127 + def fetch(url: str, raw: Literal[True]) -> bytes: ... 128 + @overload 129 + def fetch(url: str, raw: Literal[False] = False) -> dict: ... 130 + 131 + def fetch(url: str, raw: bool = False) -> bytes | dict: 132 + response = httpx.get(url) 133 + return response.content if raw else response.json() 134 + ``` 135 + 136 + IDEs and type checkers know that `fetch(url, raw=True)` returns `bytes`. 137 + 138 + ## TYPE_CHECKING 139 + 140 + avoid runtime import costs for types only needed for hints: 141 + 142 + ```python 143 + from typing import TYPE_CHECKING 144 + 145 + if TYPE_CHECKING: 146 + from heavy_module import ExpensiveClass 147 + 148 + def process(item: "ExpensiveClass") -> None: 149 + ... 150 + ``` 151 + 152 + the string quote is needed because the import doesn't happen at runtime. 153 + 154 + ## import organization 155 + 156 + ```python 157 + """module docstring.""" 158 + 159 + from __future__ import annotations 160 + 161 + import stdlib_module 162 + from typing import TYPE_CHECKING 163 + 164 + from third_party import thing 165 + 166 + from local_package import helper 167 + 168 + if TYPE_CHECKING: 169 + from expensive import Type 170 + ``` 171 + 172 + sources: 173 + - [pdsx/src/pdsx/_internal/types.py](https://github.com/zzstoatzz/pdsx/blob/main/src/pdsx/_internal/types.py) 174 + - [fastmcp/src/fastmcp/tools/tool.py](https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/tools/tool.py)