···11+# python
22+33+notes on python patterns - tooling, project structure, and building things.
44+55+## topics
66+77+- [uv](./uv.md) - cargo for python
88+- [project setup](./project-setup.md) - src/ layout, pyproject.toml, justfile
99+- [pydantic-settings](./pydantic-settings.md) - centralized typed configuration
1010+- [tooling](./tooling.md) - ruff, ty, pre-commit
1111+- [mcp](./mcp.md) - building MCP servers with fastmcp
1212+1313+## sources
1414+1515+patterns derived from building:
1616+1717+| project | what it is |
1818+|---------|------------|
1919+| [pdsx](https://github.com/zzstoatzz/pdsx) | ATProto MCP server and CLI |
2020+| [raggy](https://github.com/zzstoatzz/raggy) | document loaders for LLMs |
2121+| [prefect-pack](https://github.com/zzstoatzz/prefect-pack) | prefect utilities |
2222+| [plyr.fm](https://github.com/zzstoatzz/plyr.fm) | music on atproto |
2323+2424+and studying:
2525+2626+| project | what it is |
2727+|---------|------------|
2828+| [fastmcp](https://github.com/jlowin/fastmcp) | MCP server framework |
2929+| [docket](https://github.com/chrisguidry/docket) | distributed task system |
+188
languages/python/mcp.md
···11+# mcp
22+33+MCP (Model Context Protocol) lets you build tools that LLMs can use. fastmcp makes this straightforward.
44+55+## what MCP is
66+77+MCP servers expose:
88+- **tools** - functions LLMs can call (actions, side effects)
99+- **resources** - read-only data (like GET endpoints)
1010+- **prompts** - reusable message templates
1111+1212+clients (like Claude) discover and call these over stdio or HTTP.
1313+1414+## basic server
1515+1616+```python
1717+from fastmcp import FastMCP
1818+1919+mcp = FastMCP("my-server")
2020+2121+@mcp.tool
2222+def add(a: int, b: int) -> int:
2323+ """Add two numbers."""
2424+ return a + b
2525+2626+@mcp.resource("config://version")
2727+def get_version() -> str:
2828+ return "1.0.0"
2929+3030+if __name__ == "__main__":
3131+ mcp.run()
3232+```
3333+3434+fastmcp generates JSON schemas from type hints and docstrings automatically.
3535+3636+## running
3737+3838+```bash
3939+# stdio (default, for local tools)
4040+python server.py
4141+4242+# http (for deployment)
4343+fastmcp run server.py --transport http --port 8000
4444+```
4545+4646+## tools vs resources
4747+4848+**tools** do things:
4949+```python
5050+@mcp.tool
5151+async def create_post(text: str) -> dict:
5252+ """Create a new post."""
5353+ return await api.create(text)
5454+```
5555+5656+**resources** read things:
5757+```python
5858+@mcp.resource("posts://{post_id}")
5959+async def get_post(post_id: str) -> dict:
6060+ """Get a post by ID."""
6161+ return await api.get(post_id)
6262+```
6363+6464+## context
6565+6666+access MCP capabilities within tools:
6767+6868+```python
6969+from fastmcp import FastMCP, Context
7070+7171+mcp = FastMCP("server")
7272+7373+@mcp.tool
7474+async def process(uri: str, ctx: Context) -> str:
7575+ await ctx.info(f"Processing {uri}...")
7676+ data = await ctx.read_resource(uri)
7777+ await ctx.report_progress(50, 100)
7878+ return data
7979+```
8080+8181+## middleware
8282+8383+add authentication or other cross-cutting concerns:
8484+8585+```python
8686+from fastmcp import FastMCP
8787+from fastmcp.server.middleware import Middleware
8888+8989+class AuthMiddleware(Middleware):
9090+ async def on_call_tool(self, context, call_next):
9191+ # extract auth from headers, set context state
9292+ return await call_next(context)
9393+9494+mcp = FastMCP("server")
9595+mcp.add_middleware(AuthMiddleware())
9696+```
9797+9898+## decorator patterns
9999+100100+add parameters dynamically (from pdsx):
101101+102102+```python
103103+import inspect
104104+from functools import wraps
105105+106106+def filterable(fn):
107107+ """Add a _filter parameter for JMESPath filtering."""
108108+ @wraps(fn)
109109+ async def wrapper(*args, _filter: str | None = None, **kwargs):
110110+ result = await fn(*args, **kwargs)
111111+ if _filter:
112112+ import jmespath
113113+ return jmespath.search(_filter, result)
114114+ return result
115115+116116+ # modify signature to include new param
117117+ sig = inspect.signature(fn)
118118+ params = list(sig.parameters.values())
119119+ params.append(inspect.Parameter(
120120+ "_filter",
121121+ inspect.Parameter.KEYWORD_ONLY,
122122+ default=None,
123123+ annotation=str | None,
124124+ ))
125125+ wrapper.__signature__ = sig.replace(parameters=params)
126126+ return wrapper
127127+128128+@mcp.tool
129129+@filterable
130130+async def list_records(collection: str) -> list[dict]:
131131+ ...
132132+```
133133+134134+## response size protection
135135+136136+LLMs have context limits. protect against flooding:
137137+138138+```python
139139+MAX_RESPONSE_CHARS = 30000
140140+141141+def truncate_response(records: list) -> list:
142142+ import json
143143+ serialized = json.dumps(records)
144144+ if len(serialized) <= MAX_RESPONSE_CHARS:
145145+ return records
146146+ # truncate and add message about using _filter
147147+ ...
148148+```
149149+150150+## claude code plugins
151151+152152+structure for Claude Code integration:
153153+154154+```
155155+.claude-plugin/
156156+├── plugin.json # plugin definition
157157+└── marketplace.json # marketplace metadata
158158+159159+skills/
160160+└── domain/
161161+ └── SKILL.md # contextual guidance
162162+```
163163+164164+**plugin.json**:
165165+```json
166166+{
167167+ "name": "myserver",
168168+ "description": "what it does",
169169+ "mcpServers": "./.mcp.json"
170170+}
171171+```
172172+173173+skills are markdown files loaded as context when relevant to the task.
174174+175175+## entry points
176176+177177+expose both CLI and MCP server:
178178+179179+```toml
180180+[project.scripts]
181181+mytool = "mytool.cli:main"
182182+mytool-mcp = "mytool.mcp:main"
183183+```
184184+185185+sources:
186186+- [fastmcp](https://github.com/jlowin/fastmcp)
187187+- [pdsx](https://github.com/zzstoatzz/pdsx)
188188+- [gofastmcp.com](https://gofastmcp.com)
+125
languages/python/project-setup.md
···11+# project setup
22+33+consistent structure across projects: src/ layout, pyproject.toml as single source of truth, justfile for commands.
44+55+## directory structure
66+77+```
88+myproject/
99+├── src/myproject/
1010+│ ├── __init__.py
1111+│ ├── cli.py
1212+│ ├── settings.py
1313+│ └── _internal/ # private implementation
1414+├── tests/
1515+├── pyproject.toml
1616+├── justfile
1717+└── .pre-commit-config.yaml
1818+```
1919+2020+the `src/` layout prevents accidental imports from the working directory. your package is only importable when properly installed.
2121+2222+## pyproject.toml
2323+2424+```toml
2525+[project]
2626+name = "myproject"
2727+description = "what it does"
2828+readme = "README.md"
2929+requires-python = ">=3.10"
3030+dynamic = ["version"]
3131+dependencies = [
3232+ "httpx>=0.27",
3333+ "pydantic>=2.0",
3434+]
3535+3636+[project.scripts]
3737+myproject = "myproject.cli:main"
3838+3939+[build-system]
4040+requires = ["hatchling", "uv-dynamic-versioning"]
4141+build-backend = "hatchling.build"
4242+4343+[tool.hatch.version]
4444+source = "uv-dynamic-versioning"
4545+4646+[tool.uv-dynamic-versioning]
4747+vcs = "git"
4848+style = "pep440"
4949+bump = true
5050+fallback-version = "0.0.0"
5151+5252+[dependency-groups]
5353+dev = [
5454+ "pytest>=8.0",
5555+ "ruff>=0.8",
5656+ "ty>=0.0.1a6",
5757+]
5858+```
5959+6060+key patterns:
6161+- `dynamic = ["version"]` - version comes from git tags, not manual editing
6262+- `[dependency-groups]` - dev deps separate from runtime deps
6363+- `[project.scripts]` - CLI entry points
6464+6565+## versioning from git tags
6666+6767+with `uv-dynamic-versioning`, your version is derived from git:
6868+6969+```bash
7070+git tag v0.1.0
7171+git push --tags
7272+```
7373+7474+no more editing `__version__` or `pyproject.toml` for releases.
7575+7676+## justfile
7777+7878+```makefile
7979+check-uv:
8080+ #!/usr/bin/env sh
8181+ if ! command -v uv >/dev/null 2>&1; then
8282+ echo "uv is not installed. Install: curl -LsSf https://astral.sh/uv/install.sh | sh"
8383+ exit 1
8484+ fi
8585+8686+install: check-uv
8787+ uv sync
8888+8989+test:
9090+ uv run pytest tests/ -xvs
9191+9292+lint:
9393+ uv run ruff format src/ tests/
9494+ uv run ruff check src/ tests/ --fix
9595+9696+check:
9797+ uv run ty check
9898+```
9999+100100+run with `just test`, `just lint`, etc.
101101+102102+## multiple entry points
103103+104104+for projects with both CLI and MCP server:
105105+106106+```toml
107107+[project.scripts]
108108+myproject = "myproject.cli:main"
109109+myproject-mcp = "myproject.mcp:main"
110110+```
111111+112112+## optional dependencies
113113+114114+for features that not everyone needs:
115115+116116+```toml
117117+[project.optional-dependencies]
118118+mcp = ["fastmcp>=2.0"]
119119+```
120120+121121+install with `uv sync --extra mcp` or `uv add 'myproject[mcp]'`.
122122+123123+sources:
124124+- [pdsx/pyproject.toml](https://github.com/zzstoatzz/pdsx/blob/main/pyproject.toml)
125125+- [raggy/pyproject.toml](https://github.com/zzstoatzz/raggy/blob/main/pyproject.toml)
+110
languages/python/pydantic-settings.md
···11+# pydantic-settings
22+33+replace scattered `os.getenv()` calls with a typed, validated settings class.
44+55+## the problem
66+77+```python
88+import os
99+1010+REDIS_HOST = os.getenv("REDIS_HOST")
1111+REDIS_PORT = os.getenv("REDIS_PORT")
1212+1313+if not REDIS_HOST or not REDIS_PORT:
1414+ raise ValueError("REDIS_HOST and REDIS_PORT must be set")
1515+1616+# REDIS_PORT is still a string here
1717+```
1818+1919+issues:
2020+- validation happens where you use the value, not at startup
2121+- no type coercion (port is a string)
2222+- configuration scattered across files
2323+- easy to forget validation
2424+2525+## the solution
2626+2727+```python
2828+from pydantic import Field, SecretStr
2929+from pydantic_settings import BaseSettings, SettingsConfigDict
3030+3131+class Settings(BaseSettings):
3232+ model_config = SettingsConfigDict(
3333+ env_file=".env",
3434+ extra="ignore",
3535+ )
3636+3737+ redis_host: str
3838+ redis_port: int = Field(ge=0)
3939+ openai_api_key: SecretStr
4040+4141+settings = Settings()
4242+```
4343+4444+now:
4545+- missing `REDIS_HOST` fails immediately at import
4646+- `redis_port` is coerced to int and validated >= 0
4747+- `openai_api_key` won't accidentally appear in logs
4848+- all configuration in one place
4949+5050+## field aliases
5151+5252+when env var names don't match your preferred attribute names:
5353+5454+```python
5555+class Settings(BaseSettings):
5656+ current_user: str = Field(alias="USER")
5757+```
5858+5959+reads from `$USER`, accessed as `settings.current_user`.
6060+6161+## secrets
6262+6363+`SecretStr` prevents accidental exposure:
6464+6565+```python
6666+class Settings(BaseSettings):
6767+ api_key: SecretStr
6868+6969+settings = Settings()
7070+print(settings.api_key) # SecretStr('**********')
7171+print(settings.api_key.get_secret_value()) # actual value
7272+```
7373+7474+## contextual serialization
7575+7676+when you need to unmask secrets for subprocesses:
7777+7878+```python
7979+from pydantic import Secret, SerializationInfo
8080+8181+def maybe_unmask(v: Secret[str], info: SerializationInfo) -> str | Secret[str]:
8282+ if info.context and info.context.get("unmask"):
8383+ return v.get_secret_value()
8484+ return v
8585+8686+# usage
8787+settings.model_dump(context={"unmask": True})
8888+```
8989+9090+## nested settings
9191+9292+for larger projects:
9393+9494+```python
9595+class DatabaseSettings(BaseSettings):
9696+ host: str = "localhost"
9797+ port: int = 5432
9898+9999+class Settings(BaseSettings):
100100+ database: DatabaseSettings = Field(default_factory=DatabaseSettings)
101101+```
102102+103103+## why fail-fast matters
104104+105105+with `os.getenv()`, you find out about missing config when the code path executes - maybe in production, at 2am.
106106+107107+with pydantic-settings, invalid configuration fails at startup. deploy fails, not runtime.
108108+109109+sources:
110110+- [how to use pydantic-settings](https://blog.zzstoatzz.io/how-to-use-pydantic-settings/)
+121
languages/python/tooling.md
···11+# tooling
22+33+ruff for linting and formatting. ty for type checking. pre-commit to enforce both.
44+55+## ruff
66+77+replaces black, isort, flake8, and dozens of plugins. one tool, fast.
88+99+```bash
1010+uv run ruff format src/ tests/ # format
1111+uv run ruff check src/ tests/ # lint
1212+uv run ruff check --fix # lint and auto-fix
1313+```
1414+1515+### pyproject.toml config
1616+1717+```toml
1818+[tool.ruff]
1919+line-length = 88
2020+2121+[tool.ruff.lint]
2222+fixable = ["ALL"]
2323+extend-select = [
2424+ "I", # isort
2525+ "B", # flake8-bugbear
2626+ "C4", # flake8-comprehensions
2727+ "UP", # pyupgrade
2828+ "SIM", # flake8-simplify
2929+ "RUF", # ruff-specific
3030+]
3131+ignore = [
3232+ "COM812", # conflicts with formatter
3333+]
3434+3535+[tool.ruff.lint.per-file-ignores]
3636+"__init__.py" = ["F401", "I001"] # unused imports ok in __init__
3737+"tests/**/*.py" = ["S101"] # assert ok in tests
3838+```
3939+4040+## ty
4141+4242+astral's new type checker. still early but fast and improving.
4343+4444+```bash
4545+uv run ty check
4646+```
4747+4848+### pyproject.toml config
4949+5050+```toml
5151+[tool.ty.src]
5252+include = ["src", "tests"]
5353+exclude = ["**/node_modules", "**/__pycache__", ".venv"]
5454+5555+[tool.ty.environment]
5656+python-version = "3.10"
5757+5858+[tool.ty.rules]
5959+# start permissive, tighten over time
6060+unknown-argument = "ignore"
6161+no-matching-overload = "ignore"
6262+```
6363+6464+## pre-commit
6565+6666+enforce standards before commits reach the repo.
6767+6868+### .pre-commit-config.yaml
6969+7070+```yaml
7171+repos:
7272+ - repo: https://github.com/abravalheri/validate-pyproject
7373+ rev: v0.24.1
7474+ hooks:
7575+ - id: validate-pyproject
7676+7777+ - repo: https://github.com/astral-sh/ruff-pre-commit
7878+ rev: v0.8.0
7979+ hooks:
8080+ - id: ruff-check
8181+ args: [--fix, --exit-non-zero-on-fix]
8282+ - id: ruff-format
8383+8484+ - repo: local
8585+ hooks:
8686+ - id: type-check
8787+ name: type check
8888+ entry: uv run ty check
8989+ language: system
9090+ types: [python]
9191+ pass_filenames: false
9292+9393+ - repo: https://github.com/pre-commit/pre-commit-hooks
9494+ rev: v5.0.0
9595+ hooks:
9696+ - id: no-commit-to-branch
9797+ args: [--branch, main]
9898+```
9999+100100+install with:
101101+102102+```bash
103103+uv run pre-commit install
104104+```
105105+106106+never use `--no-verify` to skip hooks. fix the issue instead.
107107+108108+## pytest
109109+110110+```toml
111111+[tool.pytest.ini_options]
112112+asyncio_mode = "auto"
113113+asyncio_default_fixture_loop_scope = "function"
114114+testpaths = ["tests"]
115115+```
116116+117117+`asyncio_mode = "auto"` means async tests just work - no `@pytest.mark.asyncio` needed.
118118+119119+sources:
120120+- [pdsx/.pre-commit-config.yaml](https://github.com/zzstoatzz/pdsx/blob/main/.pre-commit-config.yaml)
121121+- [raggy/pyproject.toml](https://github.com/zzstoatzz/raggy/blob/main/pyproject.toml)
+106
languages/python/uv.md
···11+# uv
22+33+uv isn't "faster pip." it's cargo for python - a unified toolchain that changes what's practical to do.
44+55+## install
66+77+```bash
88+# macOS/Linux
99+curl -LsSf https://astral.sh/uv/install.sh | sh
1010+1111+# Windows
1212+powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
1313+```
1414+1515+## the commands you actually use
1616+1717+```bash
1818+uv sync # install deps from pyproject.toml
1919+uv run pytest # run in project environment
2020+uv add httpx # add a dependency
2121+uvx ruff check # run a tool without installing it
2222+```
2323+2424+never use `uv pip`. that's the escape hatch, not the workflow.
2525+2626+## zero-setup environments
2727+2828+run tools without installing anything:
2929+3030+```bash
3131+uvx flask --help
3232+uvx ruff check .
3333+uvx pytest
3434+```
3535+3636+this creates an ephemeral environment, runs the tool, done. no virtualenv activation, no pip install.
3737+3838+## the repro pattern
3939+4040+testing specific versions without polluting your environment:
4141+4242+```bash
4343+# test against a specific version
4444+uv run --with 'pydantic==2.11.4' repro.py
4545+4646+# test a git branch before it's released
4747+uv run --with pydantic@git+https://github.com/pydantic/pydantic.git@fix-branch repro.py
4848+4949+# combine: released package + unreleased fix
5050+uv run --with prefect==3.1.3 --with pydantic@git+https://github.com/pydantic/pydantic.git@fix repro.py
5151+```
5252+5353+for monorepos with subdirectories:
5454+5555+```bash
5656+uv run --with git+https://github.com/prefecthq/prefect.git@branch#subdirectory=src/integrations/prefect-redis repro.py
5757+```
5858+5959+## shareable one-liners
6060+6161+no file needed:
6262+6363+```bash
6464+uv run --with 'httpx==0.27.0' python -c 'import httpx; print(httpx.get("https://httpbin.org/get").json())'
6565+```
6666+6767+share in github issues, slack, anywhere. anyone with uv can run it.
6868+6969+## stdin execution
7070+7171+pipe code directly:
7272+7373+```bash
7474+echo 'import sys; print(sys.version)' | uv run -
7575+pbpaste | uv run --with pandas -
7676+```
7777+7878+## project workflow
7979+8080+```bash
8181+uv init myproject # create new project
8282+cd myproject
8383+uv add httpx pydantic # add deps
8484+uv sync # install everything
8585+uv run python main.py # run in environment
8686+```
8787+8888+`uv sync` reads `pyproject.toml` and `uv.lock`, installs exactly what's specified.
8989+9090+## why this matters
9191+9292+the old way:
9393+1. install python (which version?)
9494+2. create virtualenv
9595+3. activate it (did you remember?)
9696+4. pip install (hope versions resolve)
9797+5. run your code
9898+9999+the uv way:
100100+1. `uv run your_code.py`
101101+102102+uv handles python versions, environments, and dependencies implicitly. you stop thinking about environment management.
103103+104104+sources:
105105+- [but really, what's so good about uv???](https://blog.zzstoatzz.io/but-really-whats-so-good-about-uv/)
106106+- [running list of repros via uv](https://blog.zzstoatzz.io/running-list-of-repros-via-uv/)