@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,280 @@
1
+ ---
2
+ name: dockerize-service
3
+ description: Generate a local-development Docker Compose setup for an existing Python project. Produces a multi-stage Dockerfile, Compose file, env config, and dockerignore based on detected project signals.
4
+ ---
5
+
6
+ # Skill: dockerize-service
7
+
8
+ ## Core position
9
+
10
+ This skill produces a **local-development Docker Compose setup** that reflects the project as-is. It does not introduce backing services the repo doesn't already use, and it does not create a second Docker pattern if one already exists.
11
+
12
+ ---
13
+
14
+ ## Trigger
15
+
16
+ Use this skill when the user wants to:
17
+ - Containerize an existing Python project
18
+ - Add Docker Compose to a project
19
+ - Add a Dockerfile to a project
20
+ - "Dockerize" a service
21
+
22
+ ---
23
+
24
+ ## Variables
25
+
26
+ - `{pkg_name}` — snake_case package name (matches the project directory and Python package)
27
+ - `{PKG_NAME}_` — env var prefix, only used if a pydantic-settings `env_prefix` is detected in the repo (e.g., `MY_SERVICE_`); otherwise plain names are used
28
+
29
+ ---
30
+
31
+ ## Step 1 — Inspect the repo
32
+
33
+ Inspect before asking. Work through each check in order and record findings.
34
+
35
+ **1. Check for existing Docker artifacts**
36
+
37
+ Look for `Dockerfile`, `docker-compose.yml`, `docker-compose.yaml`, `.dockerignore`. If any are present, extend them — do not create a competing setup. Note what was found.
38
+
39
+ **2. Check for repo tooling**
40
+
41
+ - `uv.lock` present → uv-native project; use uv base image in Dockerfile
42
+ - No `uv.lock` → pip-based project; use `python:3.12-slim` with `pip install`
43
+
44
+ **3. Check for service signals**
45
+
46
+ Inspect `pyproject.toml` dependencies and any settings/config files for:
47
+ - `sqlalchemy` + a non-sqlite database URL pattern, or `psycopg`, `psycopg2`, `asyncpg` in deps → Postgres
48
+ - `redis` in deps → Redis
49
+ - No signals → **app-only** (do not add Postgres as a default)
50
+
51
+ If Postgres is signaled, also note which driver is already present (`psycopg2-binary`, `psycopg[binary]`, `asyncpg`, etc.) — this determines what to add in Step 6.
52
+
53
+ **4. Check for an existing health endpoint**
54
+
55
+ Grep routes for `/health`, `/healthz`, `/ping`, `/api/v1/health`, or similar. Record the exact path if found.
56
+
57
+ **5. Detect app entrypoint**
58
+
59
+ Look for the ASGI `app` object in `{pkg_name}/main.py`, `{pkg_name}/app.py`, or `{pkg_name}/application.py`. Record the module path (e.g., `{pkg_name}.main:app`). Also check `[project.scripts]` in `pyproject.toml` for any uvicorn/gunicorn invocations. This becomes the `CMD` in the Dockerfile — default to `{pkg_name}.main:app` only if no entrypoint is found elsewhere.
60
+
61
+ **6. Detect env prefix**
62
+
63
+ Grep for `env_prefix` in `core/config.py`, `config.py`, `settings.py`, or similar. If a pydantic-settings prefix is found (e.g., `env_prefix="MY_SERVICE_"`), use it in `.env.compose`. If no prefix is detected, use plain variable names (`DATABASE_URL`, `REDIS_URL`, `DEBUG`) without a package prefix.
64
+
65
+ **7. Present inference summary**
66
+
67
+ Tell the user what was found and what will be generated. Ask for confirmation only if something is genuinely ambiguous (e.g., multiple config files with conflicting signals, or an existing `Dockerfile` with unclear extension points).
68
+
69
+ ---
70
+
71
+ ## Step 2 — Generate `Dockerfile`
72
+
73
+ Multi-stage build. Base image depends on repo tooling detected in Step 1.
74
+
75
+ **If `uv.lock` is present:**
76
+
77
+ ```dockerfile
78
+ FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS builder
79
+ WORKDIR /app
80
+ COPY pyproject.toml uv.lock ./
81
+ RUN uv sync --frozen --no-dev --no-install-project
82
+ COPY {pkg_name}/ ./{pkg_name}/
83
+ RUN uv sync --frozen --no-dev
84
+
85
+ FROM python:3.12-slim
86
+ WORKDIR /app
87
+ COPY --from=builder /app/.venv /app/.venv
88
+ COPY --from=builder /app/{pkg_name} /app/{pkg_name}
89
+ ENV PATH="/app/.venv/bin:$PATH"
90
+ EXPOSE 8000
91
+ {HEALTHCHECK}
92
+ CMD ["/app/.venv/bin/uvicorn", "{detected_entrypoint|pkg_name.main:app}", "--host", "0.0.0.0", "--port", "8000"]
93
+ ```
94
+
95
+ **If no `uv.lock`:**
96
+
97
+ ```dockerfile
98
+ FROM python:3.12-slim AS builder
99
+ WORKDIR /app
100
+ COPY pyproject.toml ./
101
+ COPY {pkg_name}/ ./{pkg_name}/
102
+ RUN pip install --no-cache-dir --prefix=/install .
103
+
104
+ FROM python:3.12-slim
105
+ WORKDIR /app
106
+ COPY --from=builder /install /usr/local
107
+ EXPOSE 8000
108
+ {HEALTHCHECK}
109
+ CMD ["uvicorn", "{detected_entrypoint|pkg_name.main:app}", "--host", "0.0.0.0", "--port", "8000"]
110
+ ```
111
+
112
+ **HEALTHCHECK selection:**
113
+
114
+ - If a health endpoint was detected in Step 1:
115
+ ```dockerfile
116
+ HEALTHCHECK --interval=10s --timeout=3s --retries=3 \
117
+ CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000{detected_path}')"
118
+ ```
119
+ - If no health endpoint exists, use a TCP liveness check and note that a health endpoint should be added:
120
+ ```dockerfile
121
+ HEALTHCHECK --interval=10s --timeout=3s --retries=3 \
122
+ CMD python -c "import socket; s=socket.socket(); s.settimeout(2); s.connect(('localhost', 8000)); s.close()"
123
+ ```
124
+ > Note: No health route was found. This is a **liveness-only** check — it confirms the server is accepting TCP connections but does not verify app readiness. Add a `/health` endpoint for a proper readiness check.
125
+
126
+ ---
127
+
128
+ ## Step 3 — Generate `docker-compose.yml`
129
+
130
+ Always include the `app` service. Add `db` (Postgres) and/or `cache` (Redis) only based on signals found in Step 1.
131
+
132
+ ```yaml
133
+ # Local development only — not a production deployment target
134
+ services:
135
+ app:
136
+ build: .
137
+ ports:
138
+ - "8000:8000"
139
+ env_file: .env.compose
140
+ depends_on:
141
+ db: # only if postgres signaled
142
+ condition: service_healthy
143
+ cache: # only if redis signaled
144
+ condition: service_healthy
145
+ restart: unless-stopped
146
+
147
+ db: # only if postgres signaled
148
+ image: postgres:16-alpine
149
+ environment:
150
+ POSTGRES_USER: postgres
151
+ POSTGRES_PASSWORD: postgres
152
+ POSTGRES_DB: {pkg_name}
153
+ volumes:
154
+ - postgres_data:/var/lib/postgresql/data
155
+ healthcheck:
156
+ test: ["CMD-SHELL", "pg_isready -U postgres"]
157
+ interval: 5s
158
+ timeout: 5s
159
+ retries: 5
160
+
161
+ cache: # only if redis signaled
162
+ image: redis:7-alpine
163
+ healthcheck:
164
+ test: ["CMD", "redis-cli", "ping"]
165
+ interval: 5s
166
+ timeout: 5s
167
+ retries: 5
168
+
169
+ volumes:
170
+ postgres_data: # only if postgres signaled
171
+ ```
172
+
173
+ ---
174
+
175
+ ## Step 4 — Generate `.env.compose`
176
+
177
+ Environment variables for the running containers. Use the env prefix detected in Step 1 if a pydantic-settings prefix was found; otherwise use plain variable names. Generate **one** `.env.compose` — not both variants.
178
+
179
+ If prefix detected (e.g. `MY_SERVICE_`):
180
+ ```
181
+ # docker compose environment — do not commit real secrets
182
+ MY_SERVICE_DATABASE_URL=postgresql+psycopg2://postgres:postgres@db:5432/{pkg_name} # only if postgres signaled
183
+ MY_SERVICE_REDIS_URL=redis://cache:6379/0 # only if redis signaled
184
+ MY_SERVICE_DEBUG=false
185
+ ```
186
+
187
+ If no prefix detected:
188
+ ```
189
+ # docker compose environment — do not commit real secrets
190
+ DATABASE_URL=postgresql+psycopg2://postgres:postgres@db:5432/{pkg_name} # only if postgres signaled
191
+ REDIS_URL=redis://cache:6379/0 # only if redis signaled
192
+ DEBUG=false
193
+ ```
194
+
195
+ Also create `.env.compose.example` with the same content — this file IS committed to version control as a template.
196
+
197
+ Add `.env.compose` to `.gitignore`:
198
+
199
+ ```
200
+ echo ".env.compose" >> .gitignore
201
+ ```
202
+
203
+ ---
204
+
205
+ ## Step 5 — Generate `.dockerignore`
206
+
207
+ Skip if `.dockerignore` already exists and covers these patterns.
208
+
209
+ ```
210
+ .venv/
211
+ __pycache__/
212
+ *.pyc
213
+ *.pyo
214
+ .pytest_cache/
215
+ .ruff_cache/
216
+ *.egg-info/
217
+ dist/
218
+ .env*
219
+ !.env.compose.example
220
+ ```
221
+
222
+ ---
223
+
224
+ ## Step 6 — Update dependencies if needed
225
+
226
+ **Postgres driver** — only if Postgres was signaled and no postgres driver is already present in `pyproject.toml`:
227
+
228
+ Inspect the existing stack first:
229
+ - Async stack (uses `asyncpg` elsewhere, or `AsyncSession`) → add `asyncpg`
230
+ - Sync stack with modern psycopg → add `psycopg[binary]`
231
+ - Sync stack with legacy psycopg2 → add `psycopg2-binary`
232
+ - Do not introduce a second driver if one already exists
233
+
234
+ Follow the repo's dependency management workflow:
235
+ - `uv.lock` present → `uv add <driver>`
236
+ - No `uv.lock` → add `<driver>` to `[project.dependencies]` in `pyproject.toml` and follow the project's dependency-management workflow
237
+
238
+ **Redis** — only if Redis was signaled and `redis` is not already in deps:
239
+ - `uv.lock` present → `uv add redis`
240
+ - No `uv.lock` → add `redis` to `[project.dependencies]` in `pyproject.toml` and follow the project's dependency-management workflow
241
+
242
+ ---
243
+
244
+ ## Step 7 — Verify
245
+
246
+ ```bash
247
+ docker compose up --build -d
248
+ docker compose ps # all services healthy
249
+ curl http://localhost:8000{detected_health_path_or_omit_if_none}
250
+ docker compose down
251
+ ```
252
+
253
+ If no health endpoint exists, omit the `curl` line and note that one should be added before moving to production.
254
+
255
+ ---
256
+
257
+ ## Hard constraints
258
+
259
+ 1. Always use multi-stage builds — keep the final image lean by excluding build tooling and intermediate artifacts
260
+ 2. Never embed real credentials in `docker-compose.yml` — always use `env_file`
261
+ 3. Always include a `HEALTHCHECK` in the Dockerfile and `healthcheck:` on backing services
262
+ 4. Use `depends_on: condition: service_healthy` — not plain `depends_on`
263
+ 5. Add `.env.compose` to `.gitignore`; provide `.env.compose.example` as a committed template
264
+ 6. Never introduce a backing service (Postgres, Redis) unless the repo or user request shows it is needed — no signals → app-only
265
+ 7. If Docker artifacts already exist, extend them; do not create a parallel competing setup
266
+ 8. Match the project's existing dependency management workflow — do not assume uv if `uv.lock` is absent
267
+ 9. Do not invent or hardcode a health endpoint path — detect from existing routes or use the TCP liveness fallback
268
+
269
+ ---
270
+
271
+ ## Completion checklist
272
+
273
+ - [ ] `Dockerfile` present, multi-stage, appropriate base image for repo tooling
274
+ - [ ] `docker-compose.yml` scoped to inferred services only; no uninferred backing services added
275
+ - [ ] `HEALTHCHECK` uses detected path or TCP liveness fallback — no invented path
276
+ - [ ] `.env.compose` present, `.gitignore` updated, `.env.compose.example` committed
277
+ - [ ] No existing Docker artifacts replaced without user confirmation
278
+ - [ ] No new backing services introduced without repo evidence or explicit user request
279
+ - [ ] No hardcoded credentials in `docker-compose.yml`
280
+ - [ ] `docker compose up --build` succeeds
@@ -0,0 +1,319 @@
1
+ ---
2
+ name: fastapi-errors
3
+ description: Opinionated error architecture for FastAPI services. Enforces a single internal exception hierarchy, constructor-based messages, consistent API error responses, and centralized logging for unexpected failures.
4
+ disable-model-invocation: false
5
+ ---
6
+
7
+ # fastapi-errors
8
+
9
+ Defines a **simple, consistent error architecture** for FastAPI backend services.
10
+
11
+ This skill standardizes the flow:
12
+
13
+ ```text
14
+ service/domain failure
15
+ → application exception
16
+ → global FastAPI exception handler
17
+ → consistent JSON response
18
+ ```
19
+
20
+ The goal is to prevent common FastAPI problems:
21
+
22
+ - `HTTPException` raised deep in service logic
23
+ - inconsistent error response formats
24
+ - duplicated status code logic
25
+ - domain failures implemented with builtin exceptions
26
+ - unexpected exceptions leaking poor diagnostics
27
+
28
+ ---
29
+
30
+ # Apply this Skill When
31
+
32
+ Apply when the user:
33
+
34
+ - asks how to structure FastAPI error handling
35
+ - is adding domain exceptions
36
+ - is designing service-layer failures
37
+ - is implementing API error responses
38
+ - is building a new FastAPI service
39
+ - is adding new domain errors in an existing repo
40
+
41
+ Do **not** apply when:
42
+
43
+ - the task is unrelated to application error handling
44
+ - the user explicitly wants a different architecture
45
+
46
+ ---
47
+
48
+ # Base Exception
49
+
50
+ The service defines one base exception for intentional application failures.
51
+
52
+ Preferred naming pattern:
53
+
54
+ ```text
55
+ <PackageName>Error
56
+ ```
57
+
58
+ Examples:
59
+
60
+ ```text
61
+ BillingError
62
+ UserServiceError
63
+ InventoryError
64
+ ```
65
+
66
+ If the package name cannot be determined, use:
67
+
68
+ ```text
69
+ AppError
70
+ ```
71
+
72
+ Define all exceptions in a single module — `errors.py` or `exceptions.py` at the package root. Do not scatter them across feature files.
73
+
74
+ Example base implementation:
75
+
76
+ ```python
77
+ class AppError(Exception):
78
+ status_code = 500
79
+
80
+ def __init__(self, message: str | None = None, **context):
81
+ self.message = message or "Unhandled application error"
82
+ self.context = context
83
+ super().__init__(self.message)
84
+
85
+ def __str__(self) -> str:
86
+ return self.message
87
+ ```
88
+
89
+ Key characteristics:
90
+
91
+ - default HTTP status = **500**
92
+ - default message provided in the constructor
93
+ - optional context data for logging
94
+ - subclasses override `status_code` or pass formatted messages
95
+
96
+ ---
97
+
98
+ # Domain Exception Pattern
99
+
100
+ Application/domain errors should subclass the base error.
101
+
102
+ Example:
103
+
104
+ ```python
105
+ class UserNotFoundError(AppError):
106
+ status_code = 404
107
+
108
+ def __init__(self, user_id: int):
109
+ super().__init__(f"User {user_id} not found", user_id=user_id)
110
+ ```
111
+
112
+ Example usage in service code:
113
+
114
+ ```python
115
+ if user is None:
116
+ raise UserNotFoundError(user_id)
117
+ ```
118
+
119
+ ---
120
+
121
+ # Global FastAPI Exception Handler
122
+
123
+ The API boundary converts internal exceptions into HTTP responses.
124
+
125
+ Example:
126
+
127
+ ```python
128
+ @app.exception_handler(AppError)
129
+ async def handle_app_error(request: Request, exc: AppError):
130
+ return JSONResponse(
131
+ status_code=exc.status_code,
132
+ content={"error": str(exc)},
133
+ )
134
+ ```
135
+
136
+ Response format is intentionally simple:
137
+
138
+ ```json
139
+ {
140
+ "error": "User 123 not found"
141
+ }
142
+ ```
143
+
144
+ ---
145
+
146
+ # Unexpected Exception Handling
147
+
148
+ Unexpected exceptions should be handled consistently.
149
+
150
+ Use a fallback handler for uncaught exceptions:
151
+
152
+ ```python
153
+ @app.exception_handler(Exception)
154
+ async def handle_unexpected_error(request: Request, exc: Exception):
155
+ wrapped = AppError()
156
+
157
+ logger.exception(
158
+ "Unhandled exception",
159
+ extra={
160
+ "path": str(request.url.path),
161
+ "method": request.method,
162
+ "error_type": type(exc).__name__,
163
+ },
164
+ )
165
+
166
+ return JSONResponse(
167
+ status_code=wrapped.status_code,
168
+ content={"error": str(wrapped)},
169
+ )
170
+ ```
171
+
172
+ This does three things:
173
+
174
+ - preserves a stable client-facing response
175
+ - ensures unexpected failures are logged centrally
176
+ - treats uncaught exceptions consistently through the base application-error contract
177
+
178
+ Do not leak raw internal exception details to clients.
179
+
180
+ Log at minimum: request path, HTTP method, exception type, and correlation ID if the repo uses one. Follow existing structured logging conventions if present.
181
+
182
+ ---
183
+
184
+ # Existing Repository Strategy
185
+
186
+ In existing repositories, **inspect current error patterns before introducing new ones**.
187
+
188
+ Follow these rules:
189
+
190
+ - Look for an existing internal base exception.
191
+ - If one exists and is coherent, **extend it instead of creating a new base class**.
192
+ - Integrate with existing exception handlers when possible.
193
+ - Only introduce the recommended base-exception pattern if the repo lacks a clear contract or the user asks to refactor.
194
+
195
+ Do **not** create parallel exception hierarchies in a mature codebase.
196
+
197
+ ---
198
+
199
+ # Validation Errors
200
+
201
+ FastAPI raises `RequestValidationError` for malformed or invalid request bodies. This is framework-level — it does not subclass your app's base exception. Without a handler, FastAPI emits its default 422 response with a raw Pydantic error blob, which breaks response shape consistency.
202
+
203
+ Add a dedicated handler:
204
+
205
+ ```python
206
+ from fastapi.exceptions import RequestValidationError
207
+
208
+ @app.exception_handler(RequestValidationError)
209
+ async def handle_validation_error(request: Request, exc: RequestValidationError):
210
+ return JSONResponse(
211
+ status_code=422,
212
+ content={"error": "Invalid request data", "details": exc.errors()},
213
+ )
214
+ ```
215
+
216
+ Including `exc.errors()` in a `details` field is recommended — validation errors are client-fixable and surfacing them helps the caller correct the request.
217
+
218
+ ---
219
+
220
+ # Auth Errors
221
+
222
+ Auth handling involves two distinct cases. Don't conflate them.
223
+
224
+ **FastAPI security dependencies** (`HTTPBearer`, `OAuth2PasswordBearer`, `Depends(security_scheme)`, etc.) raise `HTTPException` internally. This is expected framework behavior — do not fight it or try to wrap it in domain exceptions.
225
+
226
+ **Custom auth logic** (token validation, permission checks) belongs in service code and should use domain exceptions:
227
+
228
+ ```python
229
+ class UnauthenticatedError(AppError):
230
+ status_code = 401
231
+
232
+ def __init__(self):
233
+ super().__init__("Authentication required")
234
+
235
+
236
+ class UnauthorizedError(AppError):
237
+ status_code = 403
238
+
239
+ def __init__(self, action: str | None = None):
240
+ msg = f"Not authorized to {action}" if action else "Not authorized"
241
+ super().__init__(msg)
242
+ ```
243
+
244
+ These flow through the global `AppError` handler like any other domain exception.
245
+
246
+ ---
247
+
248
+ # Error Codes (Optional)
249
+
250
+ For APIs consumed by clients that need to branch on error type — public APIs, client SDKs, multi-error workflows — a machine-readable `code` field is useful. Skip this for internal services or simple CRUD APIs where string parsing is acceptable.
251
+
252
+ Pattern: add an optional `code` class attribute to the base exception; domain subclasses override it.
253
+
254
+ ```python
255
+ class AppError(Exception):
256
+ status_code = 500
257
+ code: str | None = None # add this
258
+ ...
259
+
260
+ class UserNotFoundError(AppError):
261
+ status_code = 404
262
+ code = "user_not_found" # subclasses set a value
263
+ ...
264
+ ```
265
+
266
+ Update the global handler to include `code` when present:
267
+
268
+ ```python
269
+ @app.exception_handler(AppError)
270
+ async def handle_app_error(request: Request, exc: AppError):
271
+ content = {"error": str(exc)}
272
+ if exc.code:
273
+ content["code"] = exc.code
274
+ return JSONResponse(status_code=exc.status_code, content=content)
275
+ ```
276
+
277
+ Response shape when `code` is set:
278
+
279
+ ```json
280
+ {
281
+ "error": "User 123 not found",
282
+ "code": "user_not_found"
283
+ }
284
+ ```
285
+
286
+ ---
287
+
288
+ # Hard Rules
289
+
290
+ **MUST**
291
+
292
+ - Define a single internal base exception for application failures.
293
+ - Subclass that base exception for domain errors.
294
+ - Declare `status_code` on exception classes.
295
+ - Handle the internal exception hierarchy in one global FastAPI handler.
296
+ - Return a consistent JSON error response.
297
+ - Log unexpected exceptions centrally in the fallback handler.
298
+ - Add a `RequestValidationError` handler when response shape consistency matters.
299
+
300
+ **MUST NOT**
301
+
302
+ - Raise `HTTPException` in service or domain code. Exception: `HTTPException` raised internally by FastAPI security dependencies (`HTTPBearer`, `OAuth2PasswordBearer`, etc.) is acceptable — that is the framework's designed behavior.
303
+ - Use builtin exceptions (`ValueError`, `RuntimeError`, etc.) for intentional domain failures.
304
+ - Invent new error response formats per endpoint.
305
+ - Duplicate HTTP status logic outside the exception classes.
306
+ - Introduce a second application error hierarchy when one already exists.
307
+ - Leak raw internal exception messages or stack traces to clients.
308
+
309
+ ---
310
+
311
+ # Outcome
312
+
313
+ Applying this skill results in:
314
+
315
+ - one coherent application error hierarchy
316
+ - clear separation between service failures and HTTP responses
317
+ - consistent API error responses
318
+ - centralized logging for true unexpected failures
319
+ - predictable error handling across the entire service