+11
-15
languages/python/README.md
+11
-15
languages/python/README.md
···
1
1
# python
2
2
3
-
notes on writing python.
3
+
assumes 3.12+.
4
4
5
-
python is dynamically typed but increasingly written with static type hints. it has first-class async support but doesn't force it on you. it has a global interpreter lock but that matters less than people think for I/O-bound work.
5
+
## language
6
6
7
-
these notes assume python 3.12 or later. 3.12 introduced native generic syntax and the `type` statement - the typing story finally feels complete.
8
-
9
-
## the language
10
-
11
-
- [typing](./typing.md) - type hints, generics, Protocol
12
-
- [async](./async.md) - async with, ContextVar, concurrency
13
-
- [patterns](./patterns.md) - class design, decorators, error handling
7
+
- [typing](./language/typing.md)
8
+
- [async](./language/async.md)
9
+
- [patterns](./language/patterns.md)
14
10
15
-
## the ecosystem
11
+
## ecosystem
16
12
17
-
- [uv](./uv.md) - package management that doesn't hurt
18
-
- [project setup](./project-setup.md) - src/ layout, pyproject.toml, justfile
19
-
- [tooling](./tooling.md) - ruff, ty, pre-commit
20
-
- [pydantic](./pydantic.md) - validation at boundaries
21
-
- [mcp](./mcp.md) - building tools for LLMs
13
+
- [uv](./ecosystem/uv.md)
14
+
- [project setup](./ecosystem/project-setup.md)
15
+
- [tooling](./ecosystem/tooling.md)
16
+
- [pydantic](./ecosystem/pydantic.md)
17
+
- [mcp](./ecosystem/mcp.md)
22
18
23
19
## sources
24
20
-60
languages/python/async.md
-60
languages/python/async.md
···
1
-
# async
2
-
3
-
python's `async`/`await` syntax is straightforward. the interesting part is how you structure code around it.
4
-
5
-
## async with
6
-
7
-
the core insight from async python codebases: `async with` is how you manage resources. not try/finally, not callbacks - the context manager protocol.
8
-
9
-
when you open a connection, start a session, or acquire any resource that needs cleanup, you wrap it in an async context manager:
10
-
11
-
```python
12
-
@asynccontextmanager
13
-
async def get_client() -> AsyncIterator[Client]:
14
-
client = Client()
15
-
await client.connect()
16
-
try:
17
-
yield client
18
-
finally:
19
-
await client.close()
20
-
```
21
-
22
-
the caller writes `async with get_client() as c:` and cleanup happens automatically, even if exceptions occur. this pattern appears constantly - database connections, HTTP sessions, file handles, locks.
23
-
24
-
the alternative - manual try/finally blocks scattered through the code, or worse, forgetting cleanup entirely - is why this pattern dominates. you encode the lifecycle once in the context manager, and every use site gets it right by default.
25
-
26
-
## ContextVar
27
-
28
-
python added `contextvars` to solve a specific problem: how do you have request-scoped state in async code without passing it through every function?
29
-
30
-
in sync code, you might use thread-locals. but async tasks can interleave on the same thread, so thread-locals don't work. `ContextVar` gives each task its own copy:
31
-
32
-
```python
33
-
from contextvars import ContextVar
34
-
35
-
_current_request: ContextVar[Request | None] = ContextVar("request", default=None)
36
-
```
37
-
38
-
set it at the start of handling a request, and any code called from that task can access it. this is how frameworks like fastapi and fastmcp pass request context without threading it through every function signature.
39
-
40
-
the pattern: set at the boundary (request handler, task entry), read anywhere inside. reset when you're done.
41
-
42
-
## concurrency control
43
-
44
-
`asyncio.gather()` runs tasks concurrently, but sometimes you need to limit how many run at once - rate limits, connection pools, memory constraints.
45
-
46
-
`asyncio.Semaphore` is the primitive for this. acquire before work, release after. the `async with` syntax makes it clean:
47
-
48
-
```python
49
-
semaphore = asyncio.Semaphore(10)
50
-
51
-
async def limited_work(item):
52
-
async with semaphore:
53
-
return await do_work(item)
54
-
```
55
-
56
-
at most 10 `do_work` calls run concurrently. the rest wait.
57
-
58
-
sources:
59
-
- [pdsx/src/pdsx/mcp/client.py](https://github.com/zzstoatzz/pdsx/blob/main/src/pdsx/mcp/client.py)
60
-
- [docket/src/docket/worker.py](https://github.com/chrisguidry/docket/blob/main/src/docket/worker.py)
+79
languages/python/ecosystem/pydantic.md
+79
languages/python/ecosystem/pydantic.md
···
1
+
# pydantic
2
+
3
+
pydantic is a library, not the language. but it's become foundational enough that it's worth understanding.
4
+
5
+
## the core idea
6
+
7
+
python's type hints don't do anything at runtime by default. `def foo(x: int)` accepts strings, floats, whatever - the annotation is just documentation.
8
+
9
+
pydantic makes them real. define a model with type hints, and pydantic validates and coerces data to match:
10
+
11
+
```python
12
+
from pydantic import BaseModel
13
+
14
+
class User(BaseModel):
15
+
name: str
16
+
age: int
17
+
18
+
user = User(name="alice", age="25") # age coerced to int
19
+
user = User(name="alice", age="not a number") # raises ValidationError
20
+
```
21
+
22
+
this is why pydantic shows up everywhere in python - it bridges the gap between python's dynamic runtime and the desire for validated, typed data.
23
+
24
+
## settings from environment
25
+
26
+
the most common use: replacing `os.getenv()` calls with validated configuration.
27
+
28
+
```python
29
+
from pydantic import Field
30
+
from pydantic_settings import BaseSettings, SettingsConfigDict
31
+
32
+
class Settings(BaseSettings):
33
+
"""settings for atproto cli."""
34
+
35
+
model_config = SettingsConfigDict(
36
+
env_file=str(Path.cwd() / ".env"),
37
+
env_file_encoding="utf-8",
38
+
extra="ignore",
39
+
case_sensitive=False,
40
+
)
41
+
42
+
atproto_pds_url: str = Field(
43
+
default="https://bsky.social",
44
+
description="PDS URL",
45
+
)
46
+
atproto_handle: str = Field(default="", description="Your atproto handle")
47
+
atproto_password: str = Field(default="", description="Your atproto app password")
48
+
49
+
settings = Settings()
50
+
```
51
+
52
+
`model_config` controls where settings come from (environment, .env files) and how to handle unknowns. required fields without defaults fail at import time - not later when you try to use them.
53
+
54
+
from [pdsx/_internal/config.py](https://github.com/zzstoatzz/pdsx/blob/main/src/pdsx/_internal/config.py)
55
+
56
+
## when to use what
57
+
58
+
pydantic models are heavier than they look - they do a lot of work on instantiation. for internal data you control, python's `dataclasses` are simpler:
59
+
60
+
```python
61
+
from dataclasses import dataclass
62
+
63
+
@dataclass
64
+
class BatchResult:
65
+
"""result of a batch operation."""
66
+
successful: list[str]
67
+
failed: list[tuple[str, Exception]]
68
+
69
+
@property
70
+
def total(self) -> int:
71
+
return len(self.successful) + len(self.failed)
72
+
```
73
+
74
+
no validation, no coercion, just a class with fields. use pydantic at boundaries (API input, config files, external data) where you need validation. use dataclasses for internal data structures.
75
+
76
+
from [pdsx/_internal/batch.py](https://github.com/zzstoatzz/pdsx/blob/main/src/pdsx/_internal/batch.py)
77
+
78
+
sources:
79
+
- [how to use pydantic-settings](https://blog.zzstoatzz.io/how-to-use-pydantic-settings/)
+79
languages/python/language/async.md
+79
languages/python/language/async.md
···
1
+
# async
2
+
3
+
python's `async`/`await` syntax is straightforward. the interesting part is how you structure code around it.
4
+
5
+
## async with
6
+
7
+
the core insight from async python codebases: `async with` is how you manage resources. not try/finally, not callbacks - the context manager protocol.
8
+
9
+
when you open a connection, start a session, or acquire any resource that needs cleanup, you wrap it in an async context manager:
10
+
11
+
```python
12
+
@asynccontextmanager
13
+
async def get_atproto_client(
14
+
require_auth: bool = False,
15
+
operation: str = "this operation",
16
+
target_repo: str | None = None,
17
+
) -> AsyncIterator[AsyncClient]:
18
+
"""get an atproto client using credentials from context or environment."""
19
+
client = AsyncClient(pds_url)
20
+
if require_auth and handle and password:
21
+
await client.login(handle, password)
22
+
try:
23
+
yield client
24
+
finally:
25
+
pass # AsyncClient doesn't need explicit cleanup
26
+
```
27
+
28
+
the caller writes `async with get_atproto_client() as client:` and cleanup happens automatically. this pattern appears constantly - database connections, HTTP sessions, file handles, locks.
29
+
30
+
from [pdsx/mcp/client.py](https://github.com/zzstoatzz/pdsx/blob/main/src/pdsx/mcp/client.py)
31
+
32
+
the alternative - manual try/finally blocks scattered through the code, or worse, forgetting cleanup entirely - is why this pattern dominates. you encode the lifecycle once in the context manager, and every use site gets it right by default.
33
+
34
+
## ContextVar
35
+
36
+
python added `contextvars` to solve a specific problem: how do you have request-scoped state in async code without passing it through every function?
37
+
38
+
in sync code, you might use thread-locals. but async tasks can interleave on the same thread, so thread-locals don't work. `ContextVar` gives each task its own copy:
39
+
40
+
```python
41
+
from contextvars import ContextVar
42
+
43
+
_current_docket: ContextVar[Docket | None] = ContextVar("docket", default=None)
44
+
_current_worker: ContextVar[Worker | None] = ContextVar("worker", default=None)
45
+
_current_server: ContextVar[weakref.ref[FastMCP] | None] = ContextVar("server", default=None)
46
+
```
47
+
48
+
set it at the start of handling a request, and any code called from that task can access it. this is how frameworks like fastapi and fastmcp pass request context without threading it through every function signature.
49
+
50
+
the pattern: set at the boundary (request handler, task entry), read anywhere inside. reset when you're done.
51
+
52
+
from [fastmcp/server/dependencies.py](https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/dependencies.py)
53
+
54
+
## concurrency control
55
+
56
+
`asyncio.gather()` runs tasks concurrently, but sometimes you need to limit how many run at once - rate limits, connection pools, memory constraints.
57
+
58
+
`asyncio.Semaphore` is the primitive for this. acquire before work, release after. the `async with` syntax makes it clean:
59
+
60
+
```python
61
+
semaphore = asyncio.Semaphore(concurrency)
62
+
63
+
async def delete_one(uri: str) -> None:
64
+
"""delete a single record with concurrency control."""
65
+
async with semaphore:
66
+
try:
67
+
await delete_record(client, uri)
68
+
successful.append(uri)
69
+
except Exception as e:
70
+
failed.append((uri, e))
71
+
if fail_fast:
72
+
raise
73
+
74
+
await asyncio.gather(*[delete_one(uri) for uri in uris])
75
+
```
76
+
77
+
at most `concurrency` delete operations run at once. the rest wait.
78
+
79
+
from [pdsx/_internal/batch.py](https://github.com/zzstoatzz/pdsx/blob/main/src/pdsx/_internal/batch.py)
+187
languages/python/language/typing.md
+187
languages/python/language/typing.md
···
1
+
# typing
2
+
3
+
notes on type hints as actually used in projects like pdsx and fastmcp.
4
+
5
+
## unions
6
+
7
+
use `|` for unions, not `Optional`:
8
+
9
+
```python
10
+
RecordValue = str | int | float | bool | None | dict[str, Any] | list[Any]
11
+
```
12
+
13
+
from [pdsx/_internal/types.py](https://github.com/zzstoatzz/pdsx/blob/main/src/pdsx/_internal/types.py)
14
+
15
+
## TypedDict
16
+
17
+
for structured dictionaries where you know the shape:
18
+
19
+
```python
20
+
from typing import TypedDict
21
+
22
+
class RecordResponse(TypedDict):
23
+
"""a record returned from list or get operations."""
24
+
uri: str
25
+
cid: str | None
26
+
value: dict
27
+
28
+
class CredentialsContext(TypedDict):
29
+
"""credentials extracted from context or headers."""
30
+
handle: str | None
31
+
password: str | None
32
+
pds_url: str | None
33
+
repo: str | None
34
+
```
35
+
36
+
better than `dict[str, Any]` because the structure is documented and checked.
37
+
38
+
from [pdsx/mcp/_types.py](https://github.com/zzstoatzz/pdsx/blob/main/src/pdsx/mcp/_types.py)
39
+
40
+
## Annotated
41
+
42
+
attach metadata to types. useful for documentation and schema generation:
43
+
44
+
```python
45
+
from typing import Annotated
46
+
from pydantic import Field
47
+
48
+
FilterParam = Annotated[
49
+
str | None,
50
+
Field(
51
+
description=(
52
+
"jmespath expression to filter/project the result. "
53
+
"examples: '[*].{uri: uri, text: value.text}' (select fields), "
54
+
"'[?value.text != null]' (filter items), "
55
+
"'[*].uri' (extract values)"
56
+
),
57
+
),
58
+
]
59
+
```
60
+
61
+
the metadata travels with the type. MCP tools use this for parameter descriptions.
62
+
63
+
from [pdsx/mcp/filterable.py](https://github.com/zzstoatzz/pdsx/blob/main/src/pdsx/mcp/filterable.py)
64
+
65
+
## Protocol
66
+
67
+
define what methods something needs, not what class it is:
68
+
69
+
```python
70
+
from typing import Protocol
71
+
72
+
class ContextSamplingFallbackProtocol(Protocol):
73
+
async def __call__(
74
+
self,
75
+
messages: str | list[str | SamplingMessage],
76
+
system_prompt: str | None = None,
77
+
temperature: float | None = None,
78
+
max_tokens: int | None = None,
79
+
) -> ContentBlock: ...
80
+
```
81
+
82
+
any callable matching this signature satisfies the protocol. no inheritance required.
83
+
84
+
from [fastmcp/utilities/types.py](https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/utilities/types.py)
85
+
86
+
## generics
87
+
88
+
TypeVar for generic functions and classes:
89
+
90
+
```python
91
+
from typing import ParamSpec, TypeVar
92
+
93
+
P = ParamSpec("P")
94
+
R = TypeVar("R")
95
+
96
+
def filterable(
97
+
fn: Callable[P, R] | Callable[P, Awaitable[R]],
98
+
) -> Callable[P, Any] | Callable[P, Awaitable[Any]]:
99
+
...
100
+
```
101
+
102
+
`ParamSpec` captures the full signature (args and kwargs) for decorator typing.
103
+
104
+
from [pdsx/mcp/filterable.py](https://github.com/zzstoatzz/pdsx/blob/main/src/pdsx/mcp/filterable.py)
105
+
106
+
## ParamSpec
107
+
108
+
for decorators that preserve function signatures:
109
+
110
+
```python
111
+
@wraps(fn)
112
+
async def async_wrapper(
113
+
*args: P.args, _filter: str | None = None, **kwargs: P.kwargs
114
+
) -> Any:
115
+
result = await fn(*args, **kwargs)
116
+
return apply_filter(result, _filter)
117
+
```
118
+
119
+
`P.args` and `P.kwargs` carry the original function's parameter types into the wrapper. type checkers see the wrapper with the same signature as the wrapped function (plus any new parameters).
120
+
121
+
from [pdsx/mcp/filterable.py](https://github.com/zzstoatzz/pdsx/blob/main/src/pdsx/mcp/filterable.py)
122
+
123
+
## overload
124
+
125
+
when return type depends on input type:
126
+
127
+
```python
128
+
from typing import overload
129
+
130
+
@overload
131
+
def filterable(
132
+
fn: Callable[P, Awaitable[R]],
133
+
) -> Callable[P, Awaitable[Any]]: ...
134
+
135
+
@overload
136
+
def filterable(
137
+
fn: Callable[P, R],
138
+
) -> Callable[P, Any]: ...
139
+
140
+
def filterable(
141
+
fn: Callable[P, R] | Callable[P, Awaitable[R]],
142
+
) -> Callable[P, Any] | Callable[P, Awaitable[Any]]:
143
+
...
144
+
```
145
+
146
+
type checkers know async functions get async wrappers, sync functions get sync wrappers.
147
+
148
+
from [pdsx/mcp/filterable.py](https://github.com/zzstoatzz/pdsx/blob/main/src/pdsx/mcp/filterable.py)
149
+
150
+
## TYPE_CHECKING
151
+
152
+
avoid runtime import costs for types only needed for hints:
153
+
154
+
```python
155
+
from typing import TYPE_CHECKING
156
+
157
+
if TYPE_CHECKING:
158
+
from docket import Docket
159
+
from docket.execution import Execution
160
+
from fastmcp.tools.tool_transform import ArgTransform, TransformedTool
161
+
```
162
+
163
+
the import doesn't happen at runtime, only when type checkers analyze the code. with `from __future__ import annotations`, you don't need string quotes.
164
+
165
+
from [fastmcp/tools/tool.py](https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/tools/tool.py)
166
+
167
+
## import organization
168
+
169
+
```python
170
+
"""module docstring."""
171
+
172
+
from __future__ import annotations
173
+
174
+
import stdlib_module
175
+
from typing import TYPE_CHECKING
176
+
177
+
from third_party import thing
178
+
179
+
from local_package import helper
180
+
181
+
if TYPE_CHECKING:
182
+
from expensive import Type
183
+
```
184
+
185
+
sources:
186
+
- [pdsx/src/pdsx/_internal/types.py](https://github.com/zzstoatzz/pdsx/blob/main/src/pdsx/_internal/types.py)
187
+
- [fastmcp/src/fastmcp/tools/tool.py](https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/tools/tool.py)
languages/python/mcp.md
languages/python/ecosystem/mcp.md
languages/python/mcp.md
languages/python/ecosystem/mcp.md
+25
-4
languages/python/patterns.md
languages/python/language/patterns.md
+25
-4
languages/python/patterns.md
languages/python/language/patterns.md
···
37
37
38
38
lighter than pydantic when you control the data source.
39
39
40
+
from [pdsx/_internal/batch.py](https://github.com/zzstoatzz/pdsx/blob/main/src/pdsx/_internal/batch.py)
41
+
40
42
## base classes with parallel implementations
41
43
42
44
when you need both sync and async:
···
87
89
```python
88
90
@dataclass
89
91
class URIParts:
92
+
"""parsed components of an AT-URI."""
90
93
repo: str
91
94
collection: str
92
95
rkey: str
93
96
94
97
@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])
98
+
def from_uri(cls, uri: str, client_did: str | None = None) -> URIParts:
99
+
"""parse an AT-URI into its components."""
100
+
uri_without_prefix = uri.replace("at://", "")
101
+
parts = uri_without_prefix.split("/")
102
+
103
+
# shorthand format: collection/rkey
104
+
if len(parts) == 2:
105
+
if not client_did:
106
+
raise ValueError("shorthand URI requires authentication")
107
+
return cls(repo=client_did, collection=parts[0], rkey=parts[1])
108
+
109
+
# full format: did/collection/rkey
110
+
if len(parts) == 3:
111
+
return cls(repo=parts[0], collection=parts[1], rkey=parts[2])
112
+
113
+
raise ValueError(f"invalid URI format: {uri}")
99
114
```
115
+
116
+
from [pdsx/_internal/resolution.py](https://github.com/zzstoatzz/pdsx/blob/main/src/pdsx/_internal/resolution.py)
100
117
101
118
## keyword-only arguments
102
119
···
131
148
```
132
149
133
150
the message tells you what to do, not just what went wrong.
151
+
152
+
from [pdsx/mcp/client.py](https://github.com/zzstoatzz/pdsx/blob/main/src/pdsx/mcp/client.py)
134
153
135
154
## exception hierarchies
136
155
···
180
199
```
181
200
182
201
the wrapper's signature now includes `_filter`. IDEs and schema generators see it.
202
+
203
+
from [pdsx/mcp/filterable.py](https://github.com/zzstoatzz/pdsx/blob/main/src/pdsx/mcp/filterable.py)
183
204
184
205
## argparse for CLIs
185
206
languages/python/project-setup.md
languages/python/ecosystem/project-setup.md
languages/python/project-setup.md
languages/python/ecosystem/project-setup.md
-56
languages/python/pydantic.md
-56
languages/python/pydantic.md
···
1
-
# pydantic
2
-
3
-
pydantic is a library, not the language. but it's become foundational enough that it's worth understanding.
4
-
5
-
## the core idea
6
-
7
-
python's type hints don't do anything at runtime by default. `def foo(x: int)` accepts strings, floats, whatever - the annotation is just documentation.
8
-
9
-
pydantic makes them real. define a model with type hints, and pydantic validates and coerces data to match:
10
-
11
-
```python
12
-
from pydantic import BaseModel
13
-
14
-
class User(BaseModel):
15
-
name: str
16
-
age: int
17
-
18
-
user = User(name="alice", age="25") # age coerced to int
19
-
user = User(name="alice", age="not a number") # raises ValidationError
20
-
```
21
-
22
-
this is why pydantic shows up everywhere in python - it bridges the gap between python's dynamic runtime and the desire for validated, typed data.
23
-
24
-
## settings from environment
25
-
26
-
the most common use: replacing `os.getenv()` calls with validated configuration.
27
-
28
-
```python
29
-
from pydantic_settings import BaseSettings
30
-
31
-
class Settings(BaseSettings):
32
-
database_url: str
33
-
debug: bool = False
34
-
35
-
settings = Settings() # reads from environment
36
-
```
37
-
38
-
if `DATABASE_URL` isn't set, this fails at import time - not later when you try to connect. that fail-fast behavior catches configuration errors before they become runtime surprises.
39
-
40
-
## when to use what
41
-
42
-
pydantic models are heavier than they look - they do a lot of work on instantiation. for internal data you control, python's `dataclasses` are simpler:
43
-
44
-
```python
45
-
from dataclasses import dataclass
46
-
47
-
@dataclass
48
-
class InternalRecord:
49
-
id: str
50
-
value: float
51
-
```
52
-
53
-
no validation, no coercion, just a class with fields. use pydantic at boundaries (API input, config files, external data) where you need validation. use dataclasses for internal data structures.
54
-
55
-
sources:
56
-
- [how to use pydantic-settings](https://blog.zzstoatzz.io/how-to-use-pydantic-settings/)
languages/python/tooling.md
languages/python/ecosystem/tooling.md
languages/python/tooling.md
languages/python/ecosystem/tooling.md
-177
languages/python/typing.md
-177
languages/python/typing.md
···
1
-
# typing
2
-
3
-
python 3.12 made typing feel native. generics have real syntax, type aliases have a keyword, and you rarely need to import from `typing` anymore.
4
-
5
-
## the basics
6
-
7
-
use `|` for unions:
8
-
9
-
```python
10
-
def fetch(url: str, timeout: float | None = None) -> dict[str, Any]: ...
11
-
```
12
-
13
-
`from __future__ import annotations` at the top of files lets you reference types before they're defined and avoids some runtime costs.
14
-
15
-
## type aliases
16
-
17
-
the `type` statement creates type aliases:
18
-
19
-
```python
20
-
type UserId = int
21
-
type Handler = Callable[[Request], Response]
22
-
type Result[T] = T | Error
23
-
```
24
-
25
-
clearer than `TypeAlias` annotations and supports generics directly.
26
-
27
-
## TypedDict
28
-
29
-
for structured dictionaries where you know the shape:
30
-
31
-
```python
32
-
from typing import TypedDict
33
-
34
-
class RecordResponse(TypedDict):
35
-
uri: str
36
-
cid: str | None
37
-
value: dict[str, Any]
38
-
39
-
def get_record(uri: str) -> RecordResponse:
40
-
...
41
-
```
42
-
43
-
better than `dict[str, Any]` because the structure is documented and checked.
44
-
45
-
## Annotated
46
-
47
-
add metadata to types, especially useful with pydantic:
48
-
49
-
```python
50
-
from typing import Annotated
51
-
from pydantic import Field
52
-
53
-
FilterParam = Annotated[
54
-
str | None,
55
-
Field(description="jmespath expression to filter results"),
56
-
]
57
-
58
-
def list_records(collection: str, _filter: FilterParam = None) -> list[dict]:
59
-
...
60
-
```
61
-
62
-
the metadata travels with the type. frameworks like fastmcp use it for schema generation.
63
-
64
-
## Protocol
65
-
66
-
duck typing with type safety. define what methods something needs, not what class it is:
67
-
68
-
```python
69
-
from typing import Protocol, runtime_checkable
70
-
71
-
@runtime_checkable
72
-
class Closeable(Protocol):
73
-
def close(self) -> None: ...
74
-
75
-
def cleanup(resource: Closeable) -> None:
76
-
resource.close()
77
-
```
78
-
79
-
`@runtime_checkable` lets you use `isinstance()` checks.
80
-
81
-
## generics
82
-
83
-
3.12 introduced native syntax for generics. no more `TypeVar`:
84
-
85
-
```python
86
-
def first[T](items: list[T]) -> T | None:
87
-
return items[0] if items else None
88
-
```
89
-
90
-
with bounds:
91
-
92
-
```python
93
-
from pydantic import BaseModel
94
-
95
-
def parse[M: BaseModel](data: dict, model: type[M]) -> M:
96
-
return model.model_validate(data)
97
-
```
98
-
99
-
the `[T]` declares the type parameter inline. `[M: BaseModel]` constrains it. this is real syntax, not a workaround.
100
-
101
-
## ParamSpec
102
-
103
-
for decorators that preserve function signatures:
104
-
105
-
```python
106
-
from typing import ParamSpec, TypeVar, Callable, Awaitable
107
-
from functools import wraps
108
-
109
-
P = ParamSpec("P")
110
-
R = TypeVar("R")
111
-
112
-
def logged(fn: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]:
113
-
@wraps(fn)
114
-
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
115
-
print(f"calling {fn.__name__}")
116
-
return await fn(*args, **kwargs)
117
-
return wrapper
118
-
```
119
-
120
-
this preserves the original function's parameter types in the wrapper.
121
-
122
-
## overload
123
-
124
-
when return type depends on input:
125
-
126
-
```python
127
-
from typing import overload, Literal
128
-
129
-
@overload
130
-
def fetch(url: str, raw: Literal[True]) -> bytes: ...
131
-
@overload
132
-
def fetch(url: str, raw: Literal[False] = False) -> dict: ...
133
-
134
-
def fetch(url: str, raw: bool = False) -> bytes | dict:
135
-
response = httpx.get(url)
136
-
return response.content if raw else response.json()
137
-
```
138
-
139
-
IDEs and type checkers know that `fetch(url, raw=True)` returns `bytes`.
140
-
141
-
## TYPE_CHECKING
142
-
143
-
avoid runtime import costs for types only needed for hints:
144
-
145
-
```python
146
-
from typing import TYPE_CHECKING
147
-
148
-
if TYPE_CHECKING:
149
-
from heavy_module import ExpensiveClass
150
-
151
-
def process(item: "ExpensiveClass") -> None:
152
-
...
153
-
```
154
-
155
-
the string quote is needed because the import doesn't happen at runtime.
156
-
157
-
## import organization
158
-
159
-
```python
160
-
"""module docstring."""
161
-
162
-
from __future__ import annotations
163
-
164
-
import stdlib_module
165
-
from typing import TYPE_CHECKING
166
-
167
-
from third_party import thing
168
-
169
-
from local_package import helper
170
-
171
-
if TYPE_CHECKING:
172
-
from expensive import Type
173
-
```
174
-
175
-
sources:
176
-
- [pdsx/src/pdsx/_internal/types.py](https://github.com/zzstoatzz/pdsx/blob/main/src/pdsx/_internal/types.py)
177
-
- [fastmcp/src/fastmcp/tools/tool.py](https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/tools/tool.py)
languages/python/uv.md
languages/python/ecosystem/uv.md
languages/python/uv.md
languages/python/ecosystem/uv.md