@robhowley/py-pit-skills 3.1.1

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.
@@ -0,0 +1,356 @@
1
+ ---
2
+ name: fastapi-init
3
+ description: Scaffold a complete, production-ready FastAPI project from scratch. Use this skill whenever the user wants to create, initialize, start, or bootstrap a FastAPI service, REST API, or Python web service — even if they just say "new service", "new API", or "new microservice". Handles uv setup, standard FastAPI directory layout, uvicorn runner, click CLI entry point, and a full pytest suite with DI overrides, TestClient, and SQLite fixtures. Always invoke for new Python API projects.
4
+ ---
5
+
6
+ # fastapi-init
7
+
8
+ Scaffold a new FastAPI project end-to-end. This skill coordinates with others in the plugin — invoke them at the right steps rather than reinventing what they already encode.
9
+
10
+ ## Prerequisite skills
11
+
12
+ - **uv skill**: use for all `uv add`, `uv run`, and environment commands — never fall back to pip
13
+ - **click-cli skill**: consult if the user wants an extended CLI beyond the basic server entry point
14
+
15
+ ---
16
+
17
+ ## Step 1 — Confirm the service name
18
+
19
+ Ask: **"What's the name of this service?"**
20
+
21
+ Use a single snake_case variable `{pkg_name}` for everything — the project directory, the Python package, the `pyproject.toml` name, and the CLI command (e.g. `my_service`). The `uv run {pkg_name} serve` command will use it directly. Derive `{PkgName}` as the PascalCase form of `{pkg_name}` (e.g. `my_service` → `MyService`) — used only for the exception class name.
22
+
23
+ ---
24
+
25
+ ## Step 2 — Initialize with uv
26
+
27
+ ```bash
28
+ uv init {pkg_name} --app --no-workspace
29
+ cd {pkg_name}
30
+ ```
31
+
32
+ Remove the stub file uv generates (`hello.py`), then add dependencies:
33
+
34
+ ```bash
35
+ uv add fastapi "uvicorn[standard]" sqlalchemy "pydantic-settings" click
36
+ uv add --dev pytest pytest-asyncio httpx
37
+ ```
38
+
39
+ After `uv init`, add the hatchling build backend to `pyproject.toml` so `[project.scripts]` works:
40
+
41
+ ```toml
42
+ [build-system]
43
+ requires = ["hatchling"]
44
+ build-backend = "hatchling.build"
45
+
46
+ [tool.hatch.build.targets.wheel]
47
+ packages = ["{pkg_name}"]
48
+ ```
49
+
50
+ ---
51
+
52
+ ## Step 3 — Directory structure
53
+
54
+ Build this layout under the project root:
55
+
56
+ ```
57
+ {pkg_name}/
58
+ ├── pyproject.toml
59
+ ├── uv.lock
60
+ ├── {pkg_name}/ ← Python package, same name as project root
61
+ │ ├── __init__.py
62
+ │ ├── main.py # FastAPI app + lifespan
63
+ │ ├── cli.py # click entry point
64
+ │ ├── core/
65
+ │ │ ├── __init__.py
66
+ │ │ ├── config.py # pydantic-settings Settings
67
+ │ │ └── exceptions.py # {PkgName}Error base + usage note
68
+ │ ├── api/
69
+ │ │ ├── __init__.py
70
+ │ │ ├── deps.py # shared Annotated Depends providers
71
+ │ │ └── v1/
72
+ │ │ ├── __init__.py
73
+ │ │ └── routes/
74
+ │ │ ├── __init__.py
75
+ │ │ └── health.py
76
+ │ ├── db/
77
+ │ │ ├── __init__.py
78
+ │ │ └── session.py # engine + SessionLocal + get_db
79
+ │ ├── models/
80
+ │ │ └── __init__.py # SQLAlchemy declarative models
81
+ │ └── schemas/
82
+ │ └── __init__.py # Pydantic I/O schemas
83
+ └── tests/
84
+ ├── __init__.py
85
+ ├── conftest.py
86
+ └── api/
87
+ └── v1/
88
+ ├── __init__.py
89
+ └── test_health.py
90
+ ```
91
+
92
+ ---
93
+
94
+ ## Step 4 — File templates
95
+
96
+ ### pyproject.toml
97
+
98
+ After `uv init`, add these sections (the build-system block from Step 2 plus):
99
+
100
+ ```toml
101
+ [project.scripts]
102
+ {pkg_name} = "{pkg_name}.cli:cli"
103
+
104
+ [tool.pytest.ini_options]
105
+ asyncio_mode = "auto"
106
+ testpaths = ["tests"]
107
+ ```
108
+
109
+ ### {pkg_name}/core/config.py
110
+
111
+ ```python
112
+ from pydantic_settings import BaseSettings, SettingsConfigDict
113
+
114
+
115
+ class Settings(BaseSettings):
116
+ model_config = SettingsConfigDict(env_prefix="{PKG_NAME}_", env_file=".env")
117
+
118
+ app_name: str = "{pkg_name}"
119
+ debug: bool = False
120
+ database_url: str = "sqlite:///./app.db"
121
+
122
+
123
+ settings = Settings()
124
+ ```
125
+
126
+ ### {pkg_name}/db/session.py
127
+
128
+ `get_db` is the single canonical source of truth for database sessions. Everything in the app and in tests flows through it — this is what makes DI overrides in tests work cleanly.
129
+
130
+ ```python
131
+ from typing import Generator
132
+
133
+ from sqlalchemy import create_engine
134
+ from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
135
+
136
+ from {pkg_name}.core.config import settings
137
+
138
+
139
+ class Base(DeclarativeBase):
140
+ pass
141
+
142
+
143
+ engine = create_engine(settings.database_url)
144
+ SessionLocal = sessionmaker(bind=engine)
145
+
146
+
147
+ def get_db() -> Generator[Session, None, None]:
148
+ with SessionLocal() as session:
149
+ yield session
150
+ ```
151
+
152
+ ### {pkg_name}/api/deps.py
153
+
154
+ ```python
155
+ from typing import Annotated
156
+
157
+ from fastapi import Depends
158
+ from sqlalchemy.orm import Session
159
+
160
+ from {pkg_name}.db.session import get_db
161
+
162
+ DbSession = Annotated[Session, Depends(get_db)]
163
+ ```
164
+
165
+ Use `DbSession` as a type annotation on route parameters — it's self-documenting and avoids repeating `Depends(get_db)` everywhere.
166
+
167
+ ### {pkg_name}/core/exceptions.py
168
+
169
+ Base class with `status_code` and `detail` as class-level defaults, overridable per-instance. Subclasses only need to override the class attributes — no `__init__` boilerplate needed.
170
+
171
+ ```python
172
+ class {PkgName}Error(Exception):
173
+ status_code: int = 500
174
+ detail: str = "An unexpected error occurred."
175
+
176
+ def __init__(self, detail: str | None = None, status_code: int | None = None):
177
+ self.detail = detail if detail is not None else self.__class__.detail
178
+ self.status_code = status_code if status_code is not None else self.__class__.status_code
179
+ ```
180
+
181
+ Example subclass:
182
+ ```python
183
+ class NotFoundError({PkgName}Error):
184
+ status_code = 404
185
+ detail = "Resource not found."
186
+ ```
187
+
188
+ ### {pkg_name}/main.py
189
+
190
+ Use the lifespan pattern — `on_event` is deprecated.
191
+
192
+ ```python
193
+ from contextlib import asynccontextmanager
194
+
195
+ from fastapi import FastAPI, Request
196
+ from fastapi.responses import JSONResponse
197
+
198
+ from {pkg_name}.api.v1.routes import health
199
+ from {pkg_name}.core.config import settings
200
+ from {pkg_name}.core.exceptions import {PkgName}Error
201
+
202
+
203
+ @asynccontextmanager
204
+ async def lifespan(app: FastAPI):
205
+ # startup: run migrations, warm caches, etc.
206
+ yield
207
+ # shutdown: close connections, flush buffers, etc.
208
+
209
+
210
+ app = FastAPI(title=settings.app_name, lifespan=lifespan)
211
+ app.include_router(health.router, prefix="/api/v1")
212
+
213
+
214
+ @app.exception_handler({PkgName}Error)
215
+ async def {pkg_name}_error_handler(request: Request, exc: {PkgName}Error) -> JSONResponse:
216
+ return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
217
+ ```
218
+
219
+ ### {pkg_name}/api/v1/routes/health.py
220
+
221
+ ```python
222
+ from fastapi import APIRouter
223
+
224
+ router = APIRouter(tags=["health"])
225
+
226
+
227
+ @router.get("/health")
228
+ async def health_check():
229
+ return {"status": "ok"}
230
+ ```
231
+
232
+ ### {pkg_name}/cli.py
233
+
234
+ ```python
235
+ import click
236
+ import uvicorn
237
+
238
+ from {pkg_name}.core.config import settings
239
+
240
+
241
+ @click.group()
242
+ def cli():
243
+ """{pkg_name} service CLI."""
244
+
245
+
246
+ @cli.command()
247
+ @click.option("--host", default="0.0.0.0", show_default=True, help="Bind host.")
248
+ @click.option("--port", default=8000, show_default=True, type=int, help="Bind port.")
249
+ @click.option("--reload", is_flag=True, default=settings.debug, help="Enable hot reload.")
250
+ def serve(host: str, port: int, reload: bool):
251
+ """Start the uvicorn server."""
252
+ uvicorn.run("{pkg_name}.main:app", host=host, port=port, reload=reload)
253
+ ```
254
+
255
+ ---
256
+
257
+ ## Step 5 — Test setup
258
+
259
+ ### tests/conftest.py
260
+
261
+ The test suite is built around three layered fixtures. The design principle: never hit a real database, never reach outside the process, override DI at the boundary.
262
+
263
+ ```python
264
+ import pytest
265
+ from fastapi.testclient import TestClient
266
+ from sqlalchemy import create_engine
267
+ from sqlalchemy.orm import Session, sessionmaker
268
+
269
+ from {pkg_name}.db.session import Base, get_db
270
+ from {pkg_name}.main import app
271
+
272
+ TEST_DATABASE_URL = "sqlite:///:memory:"
273
+
274
+
275
+ @pytest.fixture(scope="session")
276
+ def engine():
277
+ """One engine for the whole test session — schema created once."""
278
+ eng = create_engine(TEST_DATABASE_URL, connect_args={"check_same_thread": False})
279
+ Base.metadata.create_all(eng)
280
+ yield eng
281
+ Base.metadata.drop_all(eng)
282
+
283
+
284
+ @pytest.fixture
285
+ def db_session(engine) -> Session:
286
+ """Per-test transaction that always rolls back — tests never bleed into each other."""
287
+ connection = engine.connect()
288
+ transaction = connection.begin()
289
+ TestSession = sessionmaker(bind=connection)
290
+ session = TestSession()
291
+ yield session
292
+ session.close()
293
+ transaction.rollback()
294
+ connection.close()
295
+
296
+
297
+ @pytest.fixture
298
+ def client(db_session: Session) -> TestClient:
299
+ """TestClient with the real DB dependency swapped for the test SQLite session."""
300
+ def override_get_db():
301
+ yield db_session
302
+
303
+ app.dependency_overrides[get_db] = override_get_db
304
+ with TestClient(app) as c:
305
+ yield c
306
+ app.dependency_overrides.clear()
307
+ ```
308
+
309
+ ### tests/api/v1/test_health.py
310
+
311
+ ```python
312
+ from fastapi.testclient import TestClient
313
+
314
+
315
+ def test_health_returns_ok(client: TestClient):
316
+ response = client.get("/api/v1/health")
317
+ assert response.status_code == 200
318
+ assert response.json() == {"status": "ok"}
319
+ ```
320
+
321
+ ---
322
+
323
+ ## Test invariants
324
+
325
+ Hold these across every test file in the project:
326
+
327
+ 1. **No test classes** — top-level `test_*` functions only; pytest fixtures handle all setup and teardown
328
+ 2. **No real databases** — all DB-touching tests use the `db_session` fixture; SQLite in-memory only
329
+ 3. **DI overrides, not patches** — swap behavior via `app.dependency_overrides`; don't mock internals
330
+ 4. **Shared fixtures in conftest.py** — test files stay clean; fixtures go in conftest
331
+ 5. **Transaction-per-test isolation** — the rollback in `db_session` ensures tests never affect each other even if they write data
332
+
333
+ ---
334
+
335
+ ## Step 6 — Verify the scaffold
336
+
337
+ ```bash
338
+ uv run pytest
339
+ uv run {pkg_name} serve --help
340
+ ```
341
+
342
+ Both should succeed with no errors before handing the project to the user.
343
+
344
+ ---
345
+
346
+ ## Completion checklist
347
+
348
+ - [ ] `[project.scripts]` entry in pyproject.toml points to `{pkg_name}.cli:cli`
349
+ - [ ] `asyncio_mode = "auto"` set in `[tool.pytest.ini_options]`
350
+ - [ ] hatchling build backend present with `packages = ["{pkg_name}"]`
351
+ - [ ] `get_db` is the single source of truth for DB sessions — routes and tests both go through it
352
+ - [ ] `app.dependency_overrides` is cleared after every test fixture that sets it
353
+ - [ ] No test touches a real database, network socket, or Docker container
354
+ - [ ] All test functions are top-level — no test classes
355
+ - [ ] `{PkgName}Error` base class present in `core/exceptions.py` and handler registered in `main.py`
356
+ - [ ] `uv run pytest` passes from the project root