@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.
- package/LICENSE +21 -0
- package/README.md +42 -0
- package/package.json +20 -0
- package/skills/alembic-migrations/SKILL.md +391 -0
- package/skills/click-cli/SKILL.md +204 -0
- package/skills/click-cli-linter/SKILL.md +192 -0
- package/skills/code-quality/SKILL.md +398 -0
- package/skills/dockerize-service/SKILL.md +280 -0
- package/skills/fastapi-errors/SKILL.md +319 -0
- package/skills/fastapi-init/SKILL.md +356 -0
- package/skills/pydantic-schemas/SKILL.md +500 -0
- package/skills/pytest-service/SKILL.md +216 -0
- package/skills/settings-config/SKILL.md +248 -0
- package/skills/sqlalchemy-models/SKILL.md +433 -0
- package/skills/uv/SKILL.md +310 -0
|
@@ -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
|