litclaude-ai 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +155 -0
- package/LICENSE +21 -0
- package/README.md +369 -0
- package/README_ko-KR.md +374 -0
- package/RELEASE_CHECKLIST.md +165 -0
- package/bin/litclaude-ai.js +643 -0
- package/cover.png +0 -0
- package/docs/agents.md +67 -0
- package/docs/hooks.md +134 -0
- package/docs/lsp.md +40 -0
- package/docs/migration.md +209 -0
- package/docs/workflow-compatibility-audit.md +119 -0
- package/generate_cover.py +123 -0
- package/package.json +48 -0
- package/plugins/litclaude/.claude-plugin/plugin.json +25 -0
- package/plugins/litclaude/.lsp.json +13 -0
- package/plugins/litclaude/.mcp.json +9 -0
- package/plugins/litclaude/agents/boulder-executor.md +12 -0
- package/plugins/litclaude/agents/librarian-researcher.md +15 -0
- package/plugins/litclaude/agents/oracle-verifier.md +16 -0
- package/plugins/litclaude/agents/prometheus-planner.md +13 -0
- package/plugins/litclaude/agents/qa-runner.md +16 -0
- package/plugins/litclaude/agents/quality-reviewer.md +17 -0
- package/plugins/litclaude/bin/litclaude-hook.js +110 -0
- package/plugins/litclaude/bin/litclaude-hud.js +271 -0
- package/plugins/litclaude/bin/litclaude-lsp-doctor.js +15 -0
- package/plugins/litclaude/bin/litclaude-mcp.js +70 -0
- package/plugins/litclaude/commands/deep-interview.md +21 -0
- package/plugins/litclaude/commands/dynamic-workflow.md +36 -0
- package/plugins/litclaude/commands/lit-loop.md +40 -0
- package/plugins/litclaude/commands/lit-plan.md +35 -0
- package/plugins/litclaude/commands/litgoal.md +30 -0
- package/plugins/litclaude/commands/review-work.md +35 -0
- package/plugins/litclaude/commands/start-work.md +36 -0
- package/plugins/litclaude/hooks/hooks.json +54 -0
- package/plugins/litclaude/lib/context-pressure.mjs +25 -0
- package/plugins/litclaude/lib/hud-accent-palette.mjs +58 -0
- package/plugins/litclaude/lib/litgoal/cli.mjs +266 -0
- package/plugins/litclaude/lib/litgoal/ledger.mjs +16 -0
- package/plugins/litclaude/lib/litgoal/paths.mjs +7 -0
- package/plugins/litclaude/lib/litgoal/state.mjs +67 -0
- package/plugins/litclaude/lib/mutated-file-paths.mjs +63 -0
- package/plugins/litclaude/lib/start-work-continuation.mjs +99 -0
- package/plugins/litclaude/lib/workflow-check.mjs +83 -0
- package/plugins/litclaude/skills/ai-slop-remover/SKILL.md +142 -0
- package/plugins/litclaude/skills/comment-checker/SKILL.md +55 -0
- package/plugins/litclaude/skills/debugging/SKILL.md +70 -0
- package/plugins/litclaude/skills/debugging/references/methodology/00-setup.md +108 -0
- package/plugins/litclaude/skills/debugging/references/methodology/02-investigate.md +126 -0
- package/plugins/litclaude/skills/debugging/references/methodology/04-oracle-triple.md +106 -0
- package/plugins/litclaude/skills/debugging/references/methodology/05-escalate.md +69 -0
- package/plugins/litclaude/skills/debugging/references/methodology/06-fix.md +116 -0
- package/plugins/litclaude/skills/debugging/references/methodology/08-qa.md +94 -0
- package/plugins/litclaude/skills/debugging/references/methodology/09-cleanup.md +164 -0
- package/plugins/litclaude/skills/debugging/references/methodology/partial-runtime-evidence.md +228 -0
- package/plugins/litclaude/skills/debugging/references/runtimes/bundled-js-binary.md +415 -0
- package/plugins/litclaude/skills/debugging/references/runtimes/go.md +252 -0
- package/plugins/litclaude/skills/debugging/references/runtimes/native-binary.md +484 -0
- package/plugins/litclaude/skills/debugging/references/runtimes/node.md +260 -0
- package/plugins/litclaude/skills/debugging/references/runtimes/python.md +248 -0
- package/plugins/litclaude/skills/debugging/references/runtimes/rust.md +234 -0
- package/plugins/litclaude/skills/debugging/references/tools/ghidra.md +212 -0
- package/plugins/litclaude/skills/debugging/references/tools/playwright-cli.md +194 -0
- package/plugins/litclaude/skills/debugging/references/tools/pwndbg.md +263 -0
- package/plugins/litclaude/skills/debugging/references/tools/pwntools.md +265 -0
- package/plugins/litclaude/skills/deep-interview/SKILL.md +323 -0
- package/plugins/litclaude/skills/deep-interview/scripts/render_progress.py +193 -0
- package/plugins/litclaude/skills/frontend-ui-ux/SKILL.md +62 -0
- package/plugins/litclaude/skills/lit-loop/SKILL.md +144 -0
- package/plugins/litclaude/skills/lit-plan/SKILL.md +125 -0
- package/plugins/litclaude/skills/litgoal/SKILL.md +219 -0
- package/plugins/litclaude/skills/lsp/SKILL.md +63 -0
- package/plugins/litclaude/skills/programming/SKILL.md +106 -0
- package/plugins/litclaude/skills/programming/references/go/README.md +90 -0
- package/plugins/litclaude/skills/programming/references/go/backend-stack.md +641 -0
- package/plugins/litclaude/skills/programming/references/go/bootstrap.md +328 -0
- package/plugins/litclaude/skills/programming/references/go/bubbletea-v2.md +360 -0
- package/plugins/litclaude/skills/programming/references/go/cobra-stack.md +468 -0
- package/plugins/litclaude/skills/programming/references/go/concurrency.md +362 -0
- package/plugins/litclaude/skills/programming/references/go/data-modeling.md +329 -0
- package/plugins/litclaude/skills/programming/references/go/error-handling.md +359 -0
- package/plugins/litclaude/skills/programming/references/go/golangci-strict.md +236 -0
- package/plugins/litclaude/skills/programming/references/go/grpc-connect.md +375 -0
- package/plugins/litclaude/skills/programming/references/go/libraries.md +337 -0
- package/plugins/litclaude/skills/programming/references/go/one-liners.md +202 -0
- package/plugins/litclaude/skills/programming/references/go/sqlc-pgx.md +471 -0
- package/plugins/litclaude/skills/programming/references/go/testing.md +467 -0
- package/plugins/litclaude/skills/programming/references/go/type-patterns.md +298 -0
- package/plugins/litclaude/skills/programming/references/python/README.md +314 -0
- package/plugins/litclaude/skills/programming/references/python/async-anyio.md +442 -0
- package/plugins/litclaude/skills/programming/references/python/data-modeling.md +233 -0
- package/plugins/litclaude/skills/programming/references/python/data-processing.md +133 -0
- package/plugins/litclaude/skills/programming/references/python/error-handling.md +218 -0
- package/plugins/litclaude/skills/programming/references/python/fastapi-stack.md +316 -0
- package/plugins/litclaude/skills/programming/references/python/httpx2-optimization.md +360 -0
- package/plugins/litclaude/skills/programming/references/python/libraries.md +307 -0
- package/plugins/litclaude/skills/programming/references/python/one-liners.md +268 -0
- package/plugins/litclaude/skills/programming/references/python/orjson-stack.md +378 -0
- package/plugins/litclaude/skills/programming/references/python/pydantic-ai.md +285 -0
- package/plugins/litclaude/skills/programming/references/python/pyproject-strict.md +232 -0
- package/plugins/litclaude/skills/programming/references/python/textual-tui.md +201 -0
- package/plugins/litclaude/skills/programming/references/python/type-patterns.md +176 -0
- package/plugins/litclaude/skills/programming/references/rust/README.md +317 -0
- package/plugins/litclaude/skills/programming/references/rust/async-tokio.md +299 -0
- package/plugins/litclaude/skills/programming/references/rust/axum-stack.md +467 -0
- package/plugins/litclaude/skills/programming/references/rust/cargo-strict.md +317 -0
- package/plugins/litclaude/skills/programming/references/rust/clap-stack.md +409 -0
- package/plugins/litclaude/skills/programming/references/rust/concurrency.md +375 -0
- package/plugins/litclaude/skills/programming/references/rust/libraries.md +439 -0
- package/plugins/litclaude/skills/programming/references/rust/one-liners.md +291 -0
- package/plugins/litclaude/skills/programming/references/rust/proptest-insta.md +429 -0
- package/plugins/litclaude/skills/programming/references/rust/type-state.md +354 -0
- package/plugins/litclaude/skills/programming/references/rust/unsafe-discipline.md +250 -0
- package/plugins/litclaude/skills/programming/references/rust/zero-cost-safety.md +527 -0
- package/plugins/litclaude/skills/programming/references/rust-ub/README.md +289 -0
- package/plugins/litclaude/skills/programming/references/rust-ub/miri-sanitizers-loom.md +411 -0
- package/plugins/litclaude/skills/programming/references/rust-ub/ub-taxonomy.md +269 -0
- package/plugins/litclaude/skills/programming/references/typescript/README.md +195 -0
- package/plugins/litclaude/skills/programming/references/typescript/backend-hono.md +672 -0
- package/plugins/litclaude/skills/programming/references/typescript/bootstrap.md +199 -0
- package/plugins/litclaude/skills/programming/references/typescript/data-modeling.md +202 -0
- package/plugins/litclaude/skills/programming/references/typescript/error-handling.md +169 -0
- package/plugins/litclaude/skills/programming/references/typescript/tsconfig-strict.md +152 -0
- package/plugins/litclaude/skills/programming/references/typescript/type-patterns.md +196 -0
- package/plugins/litclaude/skills/programming/scripts/go/check-no-excuse-rules.sh +173 -0
- package/plugins/litclaude/skills/programming/scripts/go/new-project.py +138 -0
- package/plugins/litclaude/skills/programming/scripts/go/templates/.editorconfig +13 -0
- package/plugins/litclaude/skills/programming/scripts/go/templates/.golangci.yml +95 -0
- package/plugins/litclaude/skills/programming/scripts/go/templates/AGENTS.md.tmpl +24 -0
- package/plugins/litclaude/skills/programming/scripts/go/templates/README.md.tmpl +12 -0
- package/plugins/litclaude/skills/programming/scripts/go/templates/Taskfile.yml +40 -0
- package/plugins/litclaude/skills/programming/scripts/go/templates/ci.yml +37 -0
- package/plugins/litclaude/skills/programming/scripts/go/templates/config.go +24 -0
- package/plugins/litclaude/skills/programming/scripts/go/templates/gitignore +15 -0
- package/plugins/litclaude/skills/programming/scripts/go/templates/main.go.tmpl +22 -0
- package/plugins/litclaude/skills/programming/scripts/go/templates/run.go +15 -0
- package/plugins/litclaude/skills/programming/scripts/python/check-no-excuse-rules.py +687 -0
- package/plugins/litclaude/skills/programming/scripts/python/new-project.py +172 -0
- package/plugins/litclaude/skills/programming/scripts/python/new-script.py +116 -0
- package/plugins/litclaude/skills/programming/scripts/rust/check-no-excuse-rules.py +296 -0
- package/plugins/litclaude/skills/programming/scripts/rust/check-no-excuse-rules.sh +158 -0
- package/plugins/litclaude/skills/programming/scripts/rust/new-project.py +175 -0
- package/plugins/litclaude/skills/programming/scripts/typescript/check-no-excuse-rules.ts +282 -0
- package/plugins/litclaude/skills/programming/scripts/typescript/new-project.ts +177 -0
- package/plugins/litclaude/skills/refactor/SKILL.md +73 -0
- package/plugins/litclaude/skills/remove-ai-slops/SKILL.md +52 -0
- package/plugins/litclaude/skills/review-work/SKILL.md +331 -0
- package/plugins/litclaude/skills/rules/SKILL.md +66 -0
- package/plugins/litclaude/skills/start-work/SKILL.md +132 -0
- package/scripts/audit-plan-checkboxes.mjs +37 -0
- package/scripts/doctor.mjs +41 -0
- package/scripts/inspect-agent-tools.mjs +27 -0
- package/scripts/postinstall.mjs +50 -0
- package/scripts/qa-claude-plugin-smoke.sh +60 -0
- package/scripts/qa-portable-install.sh +136 -0
- package/scripts/validate-plugin.mjs +72 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# Data Processing — Polars + DuckDB
|
|
2
|
+
|
|
3
|
+
## The rule
|
|
4
|
+
|
|
5
|
+
NEVER pandas. Polars (with numpy) plus DuckDB. Pandas is 10-50x slower, has weaker types, and the modern Python data ecosystem has moved on.
|
|
6
|
+
|
|
7
|
+
## Quick decision tree
|
|
8
|
+
|
|
9
|
+
| Operation | Use | Why |
|
|
10
|
+
|---|---|---|
|
|
11
|
+
| `.csv` / `.parquet` / `.json` direct query | DuckDB | Zero memory load, SQL ergonomics |
|
|
12
|
+
| `.duckdb` file | DuckDB | Native format |
|
|
13
|
+
| Filter (any size) | Polars | 128x faster than DuckDB for filtering |
|
|
14
|
+
| Sort | Polars | 12x faster |
|
|
15
|
+
| Multi-table join | DuckDB | 3x faster, more join types |
|
|
16
|
+
| Heavy GROUP BY aggregation | DuckDB | 4x faster on large datasets |
|
|
17
|
+
| Window function | Polars | 3-5x faster |
|
|
18
|
+
| Pivot / melt / string ops | Polars | 2x faster |
|
|
19
|
+
| Larger than RAM | Polars streaming or DuckDB out-of-core | Both handle OOM |
|
|
20
|
+
| Mixed pipeline | Hybrid (zero-copy via Arrow) | Use each tool's strengths |
|
|
21
|
+
|
|
22
|
+
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.
|
|
23
|
+
|
|
24
|
+
## Standard imports
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
import numpy as np
|
|
28
|
+
import polars as pl
|
|
29
|
+
import duckdb
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## DuckDB direct file query (zero memory load)
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
result = duckdb.sql("""
|
|
36
|
+
SELECT category, SUM(amount) AS total
|
|
37
|
+
FROM 'data.csv'
|
|
38
|
+
WHERE date >= '2026-01-01'
|
|
39
|
+
GROUP BY category
|
|
40
|
+
ORDER BY total DESC
|
|
41
|
+
""").pl() # zero-copy → Polars DataFrame
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`.pl()` returns Polars; `.df()` would return pandas - never use `.df()`.
|
|
45
|
+
|
|
46
|
+
## Polars lazy pipeline
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
result = (
|
|
50
|
+
pl.scan_csv("data.csv") # lazy, no read yet
|
|
51
|
+
.filter(pl.col("amount") > 1000)
|
|
52
|
+
.filter(pl.col("status") == "active")
|
|
53
|
+
.sort("amount", descending=True)
|
|
54
|
+
.head(100)
|
|
55
|
+
.collect() # execute optimised plan
|
|
56
|
+
)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`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).
|
|
60
|
+
|
|
61
|
+
## Streaming for OOM data
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
result = (
|
|
65
|
+
pl.scan_csv("huge.csv")
|
|
66
|
+
.filter(pl.col("active"))
|
|
67
|
+
.group_by("category")
|
|
68
|
+
.agg([
|
|
69
|
+
pl.len().alias("count"),
|
|
70
|
+
pl.sum("amount").alias("total"),
|
|
71
|
+
])
|
|
72
|
+
.collect(streaming=True)
|
|
73
|
+
)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Hybrid pipeline (most realistic shape)
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
# Phase 1: DuckDB for the join (3x faster)
|
|
80
|
+
joined = duckdb.sql("""
|
|
81
|
+
SELECT o.*, c.region, p.category
|
|
82
|
+
FROM 'orders.parquet' o
|
|
83
|
+
JOIN 'customers.parquet' c ON o.customer_id = c.id
|
|
84
|
+
JOIN 'products.parquet' p ON o.product_id = p.id
|
|
85
|
+
""").pl()
|
|
86
|
+
|
|
87
|
+
# Phase 2: Polars for filtering and transformation (128x + 2x faster)
|
|
88
|
+
processed = (
|
|
89
|
+
joined
|
|
90
|
+
.filter(pl.col("amount") > 100)
|
|
91
|
+
.with_columns([
|
|
92
|
+
(pl.col("amount") * 1.1).alias("amount_with_tax"),
|
|
93
|
+
])
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
# Phase 3: DuckDB for final aggregation (4x faster) - register Polars frame by name
|
|
97
|
+
duckdb.register("processed", processed)
|
|
98
|
+
final = duckdb.sql("""
|
|
99
|
+
SELECT region, category, SUM(amount_with_tax) AS revenue
|
|
100
|
+
FROM processed
|
|
101
|
+
GROUP BY region, category
|
|
102
|
+
ORDER BY revenue DESC
|
|
103
|
+
""").pl()
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Type safety with Polars
|
|
107
|
+
|
|
108
|
+
Polars supports schema overrides at read time, and `.cast()` for explicit conversion. Avoid implicit coercion in hot paths.
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
schema = {"id": pl.Int64, "amount": pl.Float64, "date": pl.Date}
|
|
112
|
+
df = pl.read_csv("data.csv", schema_overrides=schema)
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
basedpyright understands `polars-stubs`, which ship with polars itself. No extra type stubs to install.
|
|
116
|
+
|
|
117
|
+
## Things you might miss from pandas (and how to do them in Polars)
|
|
118
|
+
|
|
119
|
+
| pandas | polars |
|
|
120
|
+
|---|---|
|
|
121
|
+
| `df.iloc[5]` | `df.row(5)` (named tuple) or `df[5]` (single-row frame) |
|
|
122
|
+
| `df.loc[df["x"] > 5]` | `df.filter(pl.col("x") > 5)` |
|
|
123
|
+
| `df["x"].apply(fn)` | `df["x"].map_elements(fn)` (slow path) or use native expressions |
|
|
124
|
+
| `df.merge(...)` | `df.join(other, on="key")` |
|
|
125
|
+
| `df.groupby(...).agg(...)` | `df.group_by(...).agg(...)` |
|
|
126
|
+
| `pd.read_csv(...).dtypes` | `pl.read_csv(...).schema` |
|
|
127
|
+
| `df.to_dict("records")` | `df.to_dicts()` |
|
|
128
|
+
|
|
129
|
+
## Sources
|
|
130
|
+
|
|
131
|
+
- Polars docs: <https://docs.pola.rs>
|
|
132
|
+
- DuckDB Python API: <https://duckdb.org/docs/api/python/overview>
|
|
133
|
+
- Cross-reference - this skill set's `data-scientist` skill (load it for the deep version)
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# Error Handling
|
|
2
|
+
|
|
3
|
+
Typed errors, exhaustive matching, union returns, and resource safety.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Typed errors — no bare strings
|
|
8
|
+
|
|
9
|
+
Error types carry structured data. Pattern matching works. Callers know exactly what can go wrong.
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import NewType
|
|
14
|
+
|
|
15
|
+
UserId = NewType("UserId", int)
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True, slots=True)
|
|
18
|
+
class UserNotFoundError(Exception):
|
|
19
|
+
user_id: UserId
|
|
20
|
+
|
|
21
|
+
def __str__(self) -> str: # REQUIRED — see note below
|
|
22
|
+
return f"user {self.user_id} not found"
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True, slots=True)
|
|
25
|
+
class PermissionDeniedError(Exception):
|
|
26
|
+
user_id: UserId
|
|
27
|
+
required_role: str
|
|
28
|
+
|
|
29
|
+
def __str__(self) -> str:
|
|
30
|
+
return f"user {self.user_id} needs role {self.required_role}"
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
**`__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.
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
# BAD
|
|
37
|
+
raise ValueError("user not found")
|
|
38
|
+
raise ValueError("permission denied")
|
|
39
|
+
|
|
40
|
+
# GOOD
|
|
41
|
+
raise UserNotFoundError(user_id=uid)
|
|
42
|
+
raise PermissionDeniedError(user_id=uid, required_role="admin")
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## Union returns — expected failures without exceptions
|
|
48
|
+
|
|
49
|
+
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).
|
|
50
|
+
|
|
51
|
+
### Define the outcome types
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
@dataclass(frozen=True, slots=True)
|
|
55
|
+
class User:
|
|
56
|
+
id: UserId
|
|
57
|
+
name: str
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True, slots=True)
|
|
60
|
+
class UserNotFound:
|
|
61
|
+
id: UserId
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True, slots=True)
|
|
64
|
+
class PermissionDenied:
|
|
65
|
+
id: UserId
|
|
66
|
+
reason: str
|
|
67
|
+
|
|
68
|
+
type GetUserResult = User | UserNotFound | PermissionDenied
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### Handle exhaustively
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
from typing import assert_never
|
|
75
|
+
|
|
76
|
+
def handle_result(result: GetUserResult) -> str:
|
|
77
|
+
match result:
|
|
78
|
+
case User(name=name):
|
|
79
|
+
return f"Found: {name}"
|
|
80
|
+
case UserNotFound(id=uid):
|
|
81
|
+
return f"No user with id {uid}"
|
|
82
|
+
case PermissionDenied(reason=reason):
|
|
83
|
+
return f"Denied: {reason}"
|
|
84
|
+
case _ as unreachable:
|
|
85
|
+
assert_never(unreachable)
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
`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.
|
|
89
|
+
|
|
90
|
+
### When to use which
|
|
91
|
+
|
|
92
|
+
**The heuristic**: caller is 1-2 levels away and MUST handle it → union return. Error should propagate up many layers to a boundary → exception.
|
|
93
|
+
|
|
94
|
+
| Scenario | Pattern | Why |
|
|
95
|
+
|---|---|---|
|
|
96
|
+
| Repository → service (caller handles it) | Union return (`User \| UserNotFound`) | Caller is right there, must handle both |
|
|
97
|
+
| Validation at boundary (parsing input) | Exception (typed, with fields) | Propagates up to HTTP/CLI handler |
|
|
98
|
+
| Infrastructure failure (network, OOM) | Exception | Can't handle locally, must propagate |
|
|
99
|
+
| Service → service (deep internal) | Exception (typed) | Union boilerplate across many layers is worse than exceptions |
|
|
100
|
+
| HTTP handler → response | Catch exceptions, convert to response | Boundary code catches and translates |
|
|
101
|
+
|
|
102
|
+
**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.
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## Exhaustive match — every match needs a default
|
|
107
|
+
|
|
108
|
+
Every `match` statement ends with `case _: assert_never(x)`. No exceptions.
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
from enum import StrEnum
|
|
112
|
+
from typing import assert_never
|
|
113
|
+
|
|
114
|
+
class Status(StrEnum):
|
|
115
|
+
PENDING = "pending"
|
|
116
|
+
ACTIVE = "active"
|
|
117
|
+
DELETED = "deleted"
|
|
118
|
+
|
|
119
|
+
def describe(status: Status) -> str:
|
|
120
|
+
match status:
|
|
121
|
+
case Status.PENDING:
|
|
122
|
+
return "waiting"
|
|
123
|
+
case Status.ACTIVE:
|
|
124
|
+
return "live"
|
|
125
|
+
case Status.DELETED:
|
|
126
|
+
return "gone"
|
|
127
|
+
case _ as unreachable:
|
|
128
|
+
assert_never(unreachable)
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Add a new enum member? The type checker tells you every `match` that needs updating.
|
|
132
|
+
|
|
133
|
+
---
|
|
134
|
+
|
|
135
|
+
## Context managers — resource safety
|
|
136
|
+
|
|
137
|
+
If it has `.close()`, `.shutdown()`, `.disconnect()`, or `.release()`, wrap it in `with`.
|
|
138
|
+
|
|
139
|
+
```python
|
|
140
|
+
# BAD
|
|
141
|
+
f = open("data.txt")
|
|
142
|
+
data = f.read()
|
|
143
|
+
f.close() # forgotten? leaked
|
|
144
|
+
|
|
145
|
+
# GOOD
|
|
146
|
+
from pathlib import Path
|
|
147
|
+
|
|
148
|
+
data = Path("data.txt").read_text()
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### Async resources
|
|
152
|
+
|
|
153
|
+
```python
|
|
154
|
+
import httpx
|
|
155
|
+
|
|
156
|
+
async def fetch_users() -> list[User]:
|
|
157
|
+
async with httpx.AsyncClient() as client:
|
|
158
|
+
response = await client.get("https://api.example.com/users")
|
|
159
|
+
response.raise_for_status()
|
|
160
|
+
return [User(**u) for u in response.json()]
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### Custom context manager
|
|
164
|
+
|
|
165
|
+
```python
|
|
166
|
+
from contextlib import asynccontextmanager
|
|
167
|
+
from collections.abc import AsyncIterator
|
|
168
|
+
|
|
169
|
+
@asynccontextmanager
|
|
170
|
+
async def managed_connection(url: str) -> AsyncIterator[Connection]:
|
|
171
|
+
conn = await connect(url)
|
|
172
|
+
try:
|
|
173
|
+
yield conn
|
|
174
|
+
finally:
|
|
175
|
+
await conn.close()
|
|
176
|
+
|
|
177
|
+
async with managed_connection("postgres://...") as conn:
|
|
178
|
+
await conn.execute("SELECT 1")
|
|
179
|
+
# conn is closed here, guaranteed
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
---
|
|
183
|
+
|
|
184
|
+
## Exception hierarchy — when you do raise
|
|
185
|
+
|
|
186
|
+
Keep exception hierarchies shallow and specific.
|
|
187
|
+
|
|
188
|
+
```python
|
|
189
|
+
class AppError(Exception):
|
|
190
|
+
"""Base for all application errors."""
|
|
191
|
+
|
|
192
|
+
@dataclass(frozen=True, slots=True)
|
|
193
|
+
class NotFoundError(AppError):
|
|
194
|
+
entity: str
|
|
195
|
+
id: int
|
|
196
|
+
|
|
197
|
+
def __str__(self) -> str:
|
|
198
|
+
return f"{self.entity} {self.id} not found"
|
|
199
|
+
|
|
200
|
+
@dataclass(frozen=True, slots=True)
|
|
201
|
+
class ConflictError(AppError):
|
|
202
|
+
entity: str
|
|
203
|
+
field: str
|
|
204
|
+
value: str
|
|
205
|
+
|
|
206
|
+
def __str__(self) -> str:
|
|
207
|
+
return f"{self.entity}.{self.field} = {self.value!r} already exists"
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
Callers catch `AppError` at the boundary, or specific subtypes where they can do something useful.
|
|
211
|
+
|
|
212
|
+
---
|
|
213
|
+
|
|
214
|
+
## Sources
|
|
215
|
+
|
|
216
|
+
- Python docs: [typing — assert_never](https://docs.python.org/3/library/typing.html#typing.assert_never)
|
|
217
|
+
- Python docs: [contextlib](https://docs.python.org/3/library/contextlib.html)
|
|
218
|
+
- Python docs: [match statement](https://docs.python.org/3/reference/compound_stmts.html#the-match-statement)
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
# FastAPI + SQLAlchemy 2.x async + Postgres + Pydantic v2
|
|
2
|
+
|
|
3
|
+
The canonical web API stack. Async end-to-end, type-safe end-to-end, OpenAPI-generated end-to-end.
|
|
4
|
+
|
|
5
|
+
## Project layout
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
myapi/
|
|
9
|
+
├── pyproject.toml
|
|
10
|
+
├── alembic.ini
|
|
11
|
+
├── migrations/
|
|
12
|
+
│ └── env.py
|
|
13
|
+
├── src/
|
|
14
|
+
│ └── myapi/
|
|
15
|
+
│ ├── __init__.py
|
|
16
|
+
│ ├── main.py # FastAPI app + lifespan
|
|
17
|
+
│ ├── config.py # pydantic-settings
|
|
18
|
+
│ ├── db.py # engine, session factory, dependency
|
|
19
|
+
│ ├── models.py # SQLAlchemy declarative models
|
|
20
|
+
│ ├── schemas.py # Pydantic request/response models
|
|
21
|
+
│ └── routers/
|
|
22
|
+
│ ├── __init__.py
|
|
23
|
+
│ └── users.py
|
|
24
|
+
└── tests/
|
|
25
|
+
├── conftest.py
|
|
26
|
+
└── test_users.py
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Dependencies
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
uv add fastapi 'sqlalchemy[asyncio]>=2.0' asyncpg 'pydantic[email]>=2' pydantic-settings 'uvicorn[standard]' orjson
|
|
33
|
+
uv add --dev httpx pytest alembic
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
`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.
|
|
37
|
+
|
|
38
|
+
## Configuration (`config.py`)
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from functools import lru_cache
|
|
42
|
+
|
|
43
|
+
from pydantic import Field, PostgresDsn
|
|
44
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Settings(BaseSettings):
|
|
48
|
+
model_config = SettingsConfigDict(env_file=".env", env_prefix="MYAPI_")
|
|
49
|
+
|
|
50
|
+
database_url: PostgresDsn
|
|
51
|
+
debug: bool = False
|
|
52
|
+
cors_origins: list[str] = Field(default_factory=list)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@lru_cache
|
|
56
|
+
def get_settings() -> Settings:
|
|
57
|
+
return Settings() # type: ignore[call-arg] # pydantic populates from env
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Wait — that comment violates the no-excuse rule. Use proper field defaults instead. Real version:
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
class Settings(BaseSettings):
|
|
64
|
+
model_config = SettingsConfigDict(env_file=".env", env_prefix="MYAPI_")
|
|
65
|
+
database_url: PostgresDsn
|
|
66
|
+
debug: bool = False
|
|
67
|
+
cors_origins: list[str] = Field(default_factory=list)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Construct via `Settings(_env_file=".env")` if needed in tests; in production it reads from env.
|
|
71
|
+
|
|
72
|
+
## Database (`db.py`)
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
from collections.abc import AsyncIterator
|
|
76
|
+
from typing import Annotated
|
|
77
|
+
|
|
78
|
+
from fastapi import Depends
|
|
79
|
+
from sqlalchemy.ext.asyncio import (
|
|
80
|
+
AsyncEngine,
|
|
81
|
+
AsyncSession,
|
|
82
|
+
async_sessionmaker,
|
|
83
|
+
create_async_engine,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
from myapi.config import get_settings
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def make_engine() -> AsyncEngine:
|
|
90
|
+
settings = get_settings()
|
|
91
|
+
return create_async_engine(
|
|
92
|
+
str(settings.database_url),
|
|
93
|
+
echo=settings.debug,
|
|
94
|
+
pool_pre_ping=True,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
_engine = make_engine()
|
|
99
|
+
_SessionFactory = async_sessionmaker(_engine, expire_on_commit=False)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
async def get_session() -> AsyncIterator[AsyncSession]:
|
|
103
|
+
async with _SessionFactory() as session:
|
|
104
|
+
yield session
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
`expire_on_commit=False` is essential for FastAPI - otherwise attribute access after commit triggers an implicit refresh and errors out under async.
|
|
111
|
+
|
|
112
|
+
## Models (`models.py`)
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
from datetime import datetime, UTC
|
|
116
|
+
from sqlalchemy import DateTime, String, func
|
|
117
|
+
from sqlalchemy.orm import (
|
|
118
|
+
DeclarativeBase,
|
|
119
|
+
Mapped,
|
|
120
|
+
MappedAsDataclass,
|
|
121
|
+
mapped_column,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class Base(MappedAsDataclass, DeclarativeBase):
|
|
126
|
+
pass
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class User(Base):
|
|
130
|
+
__tablename__ = "users"
|
|
131
|
+
|
|
132
|
+
id: Mapped[int] = mapped_column(primary_key=True, init=False)
|
|
133
|
+
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
|
|
134
|
+
name: Mapped[str] = mapped_column(String(100))
|
|
135
|
+
created_at: Mapped[datetime] = mapped_column(
|
|
136
|
+
DateTime(timezone=True),
|
|
137
|
+
server_default=func.now(),
|
|
138
|
+
init=False,
|
|
139
|
+
)
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
`MappedAsDataclass` makes `User(email=..., name=...)` work as a real dataclass constructor. `init=False` excludes the auto-generated columns (`id`, `created_at`) from `__init__`.
|
|
143
|
+
|
|
144
|
+
## Schemas (`schemas.py`)
|
|
145
|
+
|
|
146
|
+
```python
|
|
147
|
+
from datetime import datetime
|
|
148
|
+
from pydantic import BaseModel, ConfigDict, EmailStr
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class UserCreate(BaseModel):
|
|
152
|
+
email: EmailStr
|
|
153
|
+
name: str
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class UserRead(BaseModel):
|
|
157
|
+
model_config = ConfigDict(from_attributes=True) # SQLAlchemy → Pydantic
|
|
158
|
+
|
|
159
|
+
id: int
|
|
160
|
+
email: EmailStr
|
|
161
|
+
name: str
|
|
162
|
+
created_at: datetime
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Always have a separate `*Create` (input) and `*Read` (output) model. Never expose your ORM model as the API model.
|
|
166
|
+
|
|
167
|
+
## Routers (`routers/users.py`)
|
|
168
|
+
|
|
169
|
+
```python
|
|
170
|
+
from fastapi import APIRouter, HTTPException, status
|
|
171
|
+
from sqlalchemy import select
|
|
172
|
+
|
|
173
|
+
from myapi.db import SessionDep
|
|
174
|
+
from myapi.models import User
|
|
175
|
+
from myapi.schemas import UserCreate, UserRead
|
|
176
|
+
|
|
177
|
+
router = APIRouter(prefix="/users", tags=["users"])
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@router.post("", response_model=UserRead, status_code=status.HTTP_201_CREATED)
|
|
181
|
+
async def create_user(payload: UserCreate, session: SessionDep) -> User:
|
|
182
|
+
user = User(email=payload.email, name=payload.name)
|
|
183
|
+
session.add(user)
|
|
184
|
+
await session.commit()
|
|
185
|
+
await session.refresh(user)
|
|
186
|
+
return user
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@router.get("/{user_id}", response_model=UserRead)
|
|
190
|
+
async def get_user(user_id: int, session: SessionDep) -> User:
|
|
191
|
+
result = await session.execute(select(User).where(User.id == user_id))
|
|
192
|
+
user = result.scalar_one_or_none()
|
|
193
|
+
if user is None:
|
|
194
|
+
raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found")
|
|
195
|
+
return user
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@router.get("", response_model=list[UserRead])
|
|
199
|
+
async def list_users(session: SessionDep, limit: int = 100) -> list[User]:
|
|
200
|
+
result = await session.execute(select(User).limit(limit))
|
|
201
|
+
return list(result.scalars().all())
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
## Application (`main.py`)
|
|
205
|
+
|
|
206
|
+
```python
|
|
207
|
+
from contextlib import asynccontextmanager
|
|
208
|
+
from collections.abc import AsyncIterator
|
|
209
|
+
|
|
210
|
+
from fastapi import FastAPI
|
|
211
|
+
|
|
212
|
+
from myapi.config import get_settings
|
|
213
|
+
from myapi.routers import users
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@asynccontextmanager
|
|
217
|
+
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
|
218
|
+
# Startup: warm up engine pool, run migrations check, etc.
|
|
219
|
+
yield
|
|
220
|
+
# Shutdown: close engine
|
|
221
|
+
from myapi.db import _engine
|
|
222
|
+
await _engine.dispose()
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def create_app() -> FastAPI:
|
|
226
|
+
settings = get_settings()
|
|
227
|
+
app = FastAPI(
|
|
228
|
+
title="My API",
|
|
229
|
+
debug=settings.debug,
|
|
230
|
+
lifespan=lifespan,
|
|
231
|
+
)
|
|
232
|
+
app.include_router(users.router)
|
|
233
|
+
return app
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
app = create_app()
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
Run with:
|
|
240
|
+
|
|
241
|
+
```bash
|
|
242
|
+
uv run uvicorn myapi.main:app --host 0.0.0.0 --port 8000 --reload
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
## Migrations (Alembic + async)
|
|
246
|
+
|
|
247
|
+
```bash
|
|
248
|
+
uv run alembic init -t async migrations
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
In `migrations/env.py` replace the `target_metadata` line:
|
|
252
|
+
|
|
253
|
+
```python
|
|
254
|
+
from myapi.models import Base
|
|
255
|
+
target_metadata = Base.metadata
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
Set `sqlalchemy.url` in `alembic.ini` to your async URL or override via `env.py`:
|
|
259
|
+
|
|
260
|
+
```python
|
|
261
|
+
from myapi.config import get_settings
|
|
262
|
+
config.set_main_option("sqlalchemy.url", str(get_settings().database_url))
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
Generate and apply:
|
|
266
|
+
|
|
267
|
+
```bash
|
|
268
|
+
uv run alembic revision --autogenerate -m "create users"
|
|
269
|
+
uv run alembic upgrade head
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
## Tests (`tests/test_users.py`)
|
|
273
|
+
|
|
274
|
+
```python
|
|
275
|
+
import pytest
|
|
276
|
+
from httpx import ASGITransport, AsyncClient
|
|
277
|
+
|
|
278
|
+
from myapi.main import app
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
@pytest.mark.anyio
|
|
282
|
+
async def test_create_and_get_user() -> None:
|
|
283
|
+
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
|
284
|
+
create_response = await client.post(
|
|
285
|
+
"/users",
|
|
286
|
+
json={"email": "alice@example.com", "name": "Alice"},
|
|
287
|
+
)
|
|
288
|
+
assert create_response.status_code == 201
|
|
289
|
+
user_id = create_response.json()["id"]
|
|
290
|
+
|
|
291
|
+
get_response = await client.get(f"/users/{user_id}")
|
|
292
|
+
assert get_response.status_code == 200
|
|
293
|
+
assert get_response.json()["email"] == "alice@example.com"
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
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).
|
|
297
|
+
|
|
298
|
+
## Common pitfalls
|
|
299
|
+
|
|
300
|
+
| Pitfall | Fix |
|
|
301
|
+
|---|---|
|
|
302
|
+
| `MissingGreenlet` exception when accessing relationships after commit | `expire_on_commit=False` on the session factory |
|
|
303
|
+
| Connection pool exhausted under load | Set `pool_size`, `max_overflow` in `create_async_engine` |
|
|
304
|
+
| Pydantic v1 syntax (`from pydantic import ...; class X(BaseModel): class Config: orm_mode = True`) | v2 uses `model_config = ConfigDict(from_attributes=True)` |
|
|
305
|
+
| Returning ORM objects without `response_model` | FastAPI serialises with `from_attributes=True` automatically; declare `response_model` so OpenAPI is correct |
|
|
306
|
+
| `await session.execute(...)` returning Sequence | Wrap with `list(result.scalars().all())` to satisfy strict types |
|
|
307
|
+
| `func.now()` returning naive datetime | Use `DateTime(timezone=True)` and `created_at: Mapped[datetime]` with `UTC`-aware default |
|
|
308
|
+
|
|
309
|
+
## Sources
|
|
310
|
+
|
|
311
|
+
- FastAPI: <https://fastapi.tiangolo.com>
|
|
312
|
+
- SQLAlchemy 2.x async: <https://docs.sqlalchemy.org/en/20/orm/extensions/asyncio.html>
|
|
313
|
+
- SQLAlchemy MappedAsDataclass: <https://docs.sqlalchemy.org/en/20/orm/dataclasses.html>
|
|
314
|
+
- asyncpg: <https://magicstack.github.io/asyncpg/current/>
|
|
315
|
+
- Pydantic v2 migration: <https://docs.pydantic.dev/latest/migration/>
|
|
316
|
+
- Alembic async: <https://alembic.sqlalchemy.org/en/latest/cookbook.html#using-asyncio-with-alembic>
|