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.
Files changed (156) hide show
  1. package/CHANGELOG.md +155 -0
  2. package/LICENSE +21 -0
  3. package/README.md +369 -0
  4. package/README_ko-KR.md +374 -0
  5. package/RELEASE_CHECKLIST.md +165 -0
  6. package/bin/litclaude-ai.js +643 -0
  7. package/cover.png +0 -0
  8. package/docs/agents.md +67 -0
  9. package/docs/hooks.md +134 -0
  10. package/docs/lsp.md +40 -0
  11. package/docs/migration.md +209 -0
  12. package/docs/workflow-compatibility-audit.md +119 -0
  13. package/generate_cover.py +123 -0
  14. package/package.json +48 -0
  15. package/plugins/litclaude/.claude-plugin/plugin.json +25 -0
  16. package/plugins/litclaude/.lsp.json +13 -0
  17. package/plugins/litclaude/.mcp.json +9 -0
  18. package/plugins/litclaude/agents/boulder-executor.md +12 -0
  19. package/plugins/litclaude/agents/librarian-researcher.md +15 -0
  20. package/plugins/litclaude/agents/oracle-verifier.md +16 -0
  21. package/plugins/litclaude/agents/prometheus-planner.md +13 -0
  22. package/plugins/litclaude/agents/qa-runner.md +16 -0
  23. package/plugins/litclaude/agents/quality-reviewer.md +17 -0
  24. package/plugins/litclaude/bin/litclaude-hook.js +110 -0
  25. package/plugins/litclaude/bin/litclaude-hud.js +271 -0
  26. package/plugins/litclaude/bin/litclaude-lsp-doctor.js +15 -0
  27. package/plugins/litclaude/bin/litclaude-mcp.js +70 -0
  28. package/plugins/litclaude/commands/deep-interview.md +21 -0
  29. package/plugins/litclaude/commands/dynamic-workflow.md +36 -0
  30. package/plugins/litclaude/commands/lit-loop.md +40 -0
  31. package/plugins/litclaude/commands/lit-plan.md +35 -0
  32. package/plugins/litclaude/commands/litgoal.md +30 -0
  33. package/plugins/litclaude/commands/review-work.md +35 -0
  34. package/plugins/litclaude/commands/start-work.md +36 -0
  35. package/plugins/litclaude/hooks/hooks.json +54 -0
  36. package/plugins/litclaude/lib/context-pressure.mjs +25 -0
  37. package/plugins/litclaude/lib/hud-accent-palette.mjs +58 -0
  38. package/plugins/litclaude/lib/litgoal/cli.mjs +266 -0
  39. package/plugins/litclaude/lib/litgoal/ledger.mjs +16 -0
  40. package/plugins/litclaude/lib/litgoal/paths.mjs +7 -0
  41. package/plugins/litclaude/lib/litgoal/state.mjs +67 -0
  42. package/plugins/litclaude/lib/mutated-file-paths.mjs +63 -0
  43. package/plugins/litclaude/lib/start-work-continuation.mjs +99 -0
  44. package/plugins/litclaude/lib/workflow-check.mjs +83 -0
  45. package/plugins/litclaude/skills/ai-slop-remover/SKILL.md +142 -0
  46. package/plugins/litclaude/skills/comment-checker/SKILL.md +55 -0
  47. package/plugins/litclaude/skills/debugging/SKILL.md +70 -0
  48. package/plugins/litclaude/skills/debugging/references/methodology/00-setup.md +108 -0
  49. package/plugins/litclaude/skills/debugging/references/methodology/02-investigate.md +126 -0
  50. package/plugins/litclaude/skills/debugging/references/methodology/04-oracle-triple.md +106 -0
  51. package/plugins/litclaude/skills/debugging/references/methodology/05-escalate.md +69 -0
  52. package/plugins/litclaude/skills/debugging/references/methodology/06-fix.md +116 -0
  53. package/plugins/litclaude/skills/debugging/references/methodology/08-qa.md +94 -0
  54. package/plugins/litclaude/skills/debugging/references/methodology/09-cleanup.md +164 -0
  55. package/plugins/litclaude/skills/debugging/references/methodology/partial-runtime-evidence.md +228 -0
  56. package/plugins/litclaude/skills/debugging/references/runtimes/bundled-js-binary.md +415 -0
  57. package/plugins/litclaude/skills/debugging/references/runtimes/go.md +252 -0
  58. package/plugins/litclaude/skills/debugging/references/runtimes/native-binary.md +484 -0
  59. package/plugins/litclaude/skills/debugging/references/runtimes/node.md +260 -0
  60. package/plugins/litclaude/skills/debugging/references/runtimes/python.md +248 -0
  61. package/plugins/litclaude/skills/debugging/references/runtimes/rust.md +234 -0
  62. package/plugins/litclaude/skills/debugging/references/tools/ghidra.md +212 -0
  63. package/plugins/litclaude/skills/debugging/references/tools/playwright-cli.md +194 -0
  64. package/plugins/litclaude/skills/debugging/references/tools/pwndbg.md +263 -0
  65. package/plugins/litclaude/skills/debugging/references/tools/pwntools.md +265 -0
  66. package/plugins/litclaude/skills/deep-interview/SKILL.md +323 -0
  67. package/plugins/litclaude/skills/deep-interview/scripts/render_progress.py +193 -0
  68. package/plugins/litclaude/skills/frontend-ui-ux/SKILL.md +62 -0
  69. package/plugins/litclaude/skills/lit-loop/SKILL.md +144 -0
  70. package/plugins/litclaude/skills/lit-plan/SKILL.md +125 -0
  71. package/plugins/litclaude/skills/litgoal/SKILL.md +219 -0
  72. package/plugins/litclaude/skills/lsp/SKILL.md +63 -0
  73. package/plugins/litclaude/skills/programming/SKILL.md +106 -0
  74. package/plugins/litclaude/skills/programming/references/go/README.md +90 -0
  75. package/plugins/litclaude/skills/programming/references/go/backend-stack.md +641 -0
  76. package/plugins/litclaude/skills/programming/references/go/bootstrap.md +328 -0
  77. package/plugins/litclaude/skills/programming/references/go/bubbletea-v2.md +360 -0
  78. package/plugins/litclaude/skills/programming/references/go/cobra-stack.md +468 -0
  79. package/plugins/litclaude/skills/programming/references/go/concurrency.md +362 -0
  80. package/plugins/litclaude/skills/programming/references/go/data-modeling.md +329 -0
  81. package/plugins/litclaude/skills/programming/references/go/error-handling.md +359 -0
  82. package/plugins/litclaude/skills/programming/references/go/golangci-strict.md +236 -0
  83. package/plugins/litclaude/skills/programming/references/go/grpc-connect.md +375 -0
  84. package/plugins/litclaude/skills/programming/references/go/libraries.md +337 -0
  85. package/plugins/litclaude/skills/programming/references/go/one-liners.md +202 -0
  86. package/plugins/litclaude/skills/programming/references/go/sqlc-pgx.md +471 -0
  87. package/plugins/litclaude/skills/programming/references/go/testing.md +467 -0
  88. package/plugins/litclaude/skills/programming/references/go/type-patterns.md +298 -0
  89. package/plugins/litclaude/skills/programming/references/python/README.md +314 -0
  90. package/plugins/litclaude/skills/programming/references/python/async-anyio.md +442 -0
  91. package/plugins/litclaude/skills/programming/references/python/data-modeling.md +233 -0
  92. package/plugins/litclaude/skills/programming/references/python/data-processing.md +133 -0
  93. package/plugins/litclaude/skills/programming/references/python/error-handling.md +218 -0
  94. package/plugins/litclaude/skills/programming/references/python/fastapi-stack.md +316 -0
  95. package/plugins/litclaude/skills/programming/references/python/httpx2-optimization.md +360 -0
  96. package/plugins/litclaude/skills/programming/references/python/libraries.md +307 -0
  97. package/plugins/litclaude/skills/programming/references/python/one-liners.md +268 -0
  98. package/plugins/litclaude/skills/programming/references/python/orjson-stack.md +378 -0
  99. package/plugins/litclaude/skills/programming/references/python/pydantic-ai.md +285 -0
  100. package/plugins/litclaude/skills/programming/references/python/pyproject-strict.md +232 -0
  101. package/plugins/litclaude/skills/programming/references/python/textual-tui.md +201 -0
  102. package/plugins/litclaude/skills/programming/references/python/type-patterns.md +176 -0
  103. package/plugins/litclaude/skills/programming/references/rust/README.md +317 -0
  104. package/plugins/litclaude/skills/programming/references/rust/async-tokio.md +299 -0
  105. package/plugins/litclaude/skills/programming/references/rust/axum-stack.md +467 -0
  106. package/plugins/litclaude/skills/programming/references/rust/cargo-strict.md +317 -0
  107. package/plugins/litclaude/skills/programming/references/rust/clap-stack.md +409 -0
  108. package/plugins/litclaude/skills/programming/references/rust/concurrency.md +375 -0
  109. package/plugins/litclaude/skills/programming/references/rust/libraries.md +439 -0
  110. package/plugins/litclaude/skills/programming/references/rust/one-liners.md +291 -0
  111. package/plugins/litclaude/skills/programming/references/rust/proptest-insta.md +429 -0
  112. package/plugins/litclaude/skills/programming/references/rust/type-state.md +354 -0
  113. package/plugins/litclaude/skills/programming/references/rust/unsafe-discipline.md +250 -0
  114. package/plugins/litclaude/skills/programming/references/rust/zero-cost-safety.md +527 -0
  115. package/plugins/litclaude/skills/programming/references/rust-ub/README.md +289 -0
  116. package/plugins/litclaude/skills/programming/references/rust-ub/miri-sanitizers-loom.md +411 -0
  117. package/plugins/litclaude/skills/programming/references/rust-ub/ub-taxonomy.md +269 -0
  118. package/plugins/litclaude/skills/programming/references/typescript/README.md +195 -0
  119. package/plugins/litclaude/skills/programming/references/typescript/backend-hono.md +672 -0
  120. package/plugins/litclaude/skills/programming/references/typescript/bootstrap.md +199 -0
  121. package/plugins/litclaude/skills/programming/references/typescript/data-modeling.md +202 -0
  122. package/plugins/litclaude/skills/programming/references/typescript/error-handling.md +169 -0
  123. package/plugins/litclaude/skills/programming/references/typescript/tsconfig-strict.md +152 -0
  124. package/plugins/litclaude/skills/programming/references/typescript/type-patterns.md +196 -0
  125. package/plugins/litclaude/skills/programming/scripts/go/check-no-excuse-rules.sh +173 -0
  126. package/plugins/litclaude/skills/programming/scripts/go/new-project.py +138 -0
  127. package/plugins/litclaude/skills/programming/scripts/go/templates/.editorconfig +13 -0
  128. package/plugins/litclaude/skills/programming/scripts/go/templates/.golangci.yml +95 -0
  129. package/plugins/litclaude/skills/programming/scripts/go/templates/AGENTS.md.tmpl +24 -0
  130. package/plugins/litclaude/skills/programming/scripts/go/templates/README.md.tmpl +12 -0
  131. package/plugins/litclaude/skills/programming/scripts/go/templates/Taskfile.yml +40 -0
  132. package/plugins/litclaude/skills/programming/scripts/go/templates/ci.yml +37 -0
  133. package/plugins/litclaude/skills/programming/scripts/go/templates/config.go +24 -0
  134. package/plugins/litclaude/skills/programming/scripts/go/templates/gitignore +15 -0
  135. package/plugins/litclaude/skills/programming/scripts/go/templates/main.go.tmpl +22 -0
  136. package/plugins/litclaude/skills/programming/scripts/go/templates/run.go +15 -0
  137. package/plugins/litclaude/skills/programming/scripts/python/check-no-excuse-rules.py +687 -0
  138. package/plugins/litclaude/skills/programming/scripts/python/new-project.py +172 -0
  139. package/plugins/litclaude/skills/programming/scripts/python/new-script.py +116 -0
  140. package/plugins/litclaude/skills/programming/scripts/rust/check-no-excuse-rules.py +296 -0
  141. package/plugins/litclaude/skills/programming/scripts/rust/check-no-excuse-rules.sh +158 -0
  142. package/plugins/litclaude/skills/programming/scripts/rust/new-project.py +175 -0
  143. package/plugins/litclaude/skills/programming/scripts/typescript/check-no-excuse-rules.ts +282 -0
  144. package/plugins/litclaude/skills/programming/scripts/typescript/new-project.ts +177 -0
  145. package/plugins/litclaude/skills/refactor/SKILL.md +73 -0
  146. package/plugins/litclaude/skills/remove-ai-slops/SKILL.md +52 -0
  147. package/plugins/litclaude/skills/review-work/SKILL.md +331 -0
  148. package/plugins/litclaude/skills/rules/SKILL.md +66 -0
  149. package/plugins/litclaude/skills/start-work/SKILL.md +132 -0
  150. package/scripts/audit-plan-checkboxes.mjs +37 -0
  151. package/scripts/doctor.mjs +41 -0
  152. package/scripts/inspect-agent-tools.mjs +27 -0
  153. package/scripts/postinstall.mjs +50 -0
  154. package/scripts/qa-claude-plugin-smoke.sh +60 -0
  155. package/scripts/qa-portable-install.sh +136 -0
  156. 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>