Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,7 @@ Domain-expert lenses `hyper` auto-engages when their triggers match the request
</details>

<details>
<summary><strong>🎯 Domain (7)</strong> - specialized skills for specific contexts</summary>
<summary><strong>🎯 Domain (8)</strong> - specialized skills for specific contexts</summary>

| Skill | Role |
|---|---|
Expand All @@ -290,6 +290,7 @@ Domain-expert lenses `hyper` auto-engages when their triggers match the request
| `security-review` | OWASP audits, vulnerability checklists |
| `design-patterns-skill` | Clean Code + Pragmatic Programmer patterns |
| `readme-writer` | Evidence-based README generation (this skill) |
| `python-pro-coder` | 60 FastAPI/Pydantic v2/SQLAlchemy 2.0 rules - layering, schemas, async, dead deps |

</details>

Expand Down
1 change: 1 addition & 0 deletions skills/INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ Categories:
| `design-patterns-skill` | Apply core programming principles and design patterns from Clean Code, The Pragmatic Programmer, Code Complete, Refactor |
| `designer` | |
| `marketing` | Use to do product marketing for any brand - position it, find the message, write the copy ("marketing words"), set brand |
| `python-pro-coder` | Staff-level Python API engineering discipline for FastAPI + Pydantic v2 + SQLAlchemy 2.0 - 60 enforced rules across proj |
| `react-pro-coder` | Staff-level React and Next.js engineering discipline - 56 enforced rules across component design, hooks, state placement |
| `readme-writer` | Writes or rewrites project README files using repository evidence instead of generic filler. Use when creating a new REA |
| `reflect` | Review a product screen OR a feature (shipped or planned) AS a real target-customer persona - short, blunt, moody, marke |
Expand Down
160 changes: 160 additions & 0 deletions skills/python-pro-coder/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
---
name: python-pro-coder
category: domain
description: Staff-level Python API engineering discipline for FastAPI + Pydantic v2 + SQLAlchemy 2.0 - 60 enforced rules across project structure, Pydantic modelling, endpoint design, settings, dependency injection, async and performance, security, error handling, database access, testing, and production observability, plus a version-verified stack file that names the dead dependencies (python-jose, passlib, on_event) and their replacements. Use when writing, reviewing, refactoring, debugging, or auditing FastAPI or Pydantic code, when designing an API surface or its schemas, when choosing async versus sync, or when asked for Python API best practices or an audit.
references:
- references/PROJECT-STRUCTURE.md
- references/PYDANTIC.md
- references/ENDPOINTS.md
- references/CONFIG-VALIDATION.md
- references/DEPENDENCIES.md
- references/ASYNC-PERFORMANCE.md
- references/SECURITY.md
- references/ERRORS.md
- references/DATABASE.md
- references/TESTING.md
- references/OBSERVABILITY.md
- references/REVIEW-CHECKLIST.md
- references/OUTPUT-CONTRACT.md
- references/TEMPLATES.md
- references/STACK-2026.md
---

# Python Pro Coder (SDE-3, FastAPI + Pydantic v2 + SQLAlchemy 2.0)

## The Iron Law

```
NO DEPENDENCY RECOMMENDATION WITHOUT references/STACK-2026.md
```

The Python API ecosystem rots faster than memory updates. `python-jose` is abandoned with a live CVE,
`passlib` breaks on Python 3.13, `@app.on_event` has been deprecated since FastAPI 0.93, and
`AsyncClient(app=app)` is deprecated in httpx. Every one of those is still the first result an agent
recalls. Read the stack file before naming a package or a version, and re-verify anything in it older
than a quarter.

Scope is HTTP API services. Data science, notebooks, CLI tools, and library packaging are out of
scope: the typing, testing, and structure rules transfer, the FastAPI-specific ones do not.

## Rationalization table

| Excuse | Reality |
|---|---|
| "I know the FastAPI patterns" | The patterns you know are the 2023 ones. Three of them are deprecated. |
| "It is one endpoint" | One endpoint without `response_model` is one endpoint leaking `hashed_password`. |
| "The tutorial used `python-jose`" | The tutorial predates the abandonment. FastAPI's own docs moved to PyJWT. |
| "Async is faster, make it all async" | AS-1: one blocking call inside `async def` stalls the whole loop. Consistency beats the label. |
| "Mocking the database is faster" | TQ-1: mocks assert that your code called what you expected. Constraints and dialects are where the bugs are. |
| "Pagination can come later" | AS-4: later is when a tenant reaches a million rows and takes the process down. |
| "I will add the migration after" | DB-3: `create_all` in production has no downgrade and no history. |

## Step 0: Environment gate (always first)

```bash
python -V # 3.10 is the floor for FastAPI 0.141
uv pip list | grep -Ei 'fastapi|pydantic|sqlalchemy|httpx'
ruff check . && ruff format --check .
mypy --strict app/ # or pyright --strict
pytest -q
```

Defaults when the project has not decided:

| Concern | Default |
|---|---|
| Framework | FastAPI, one router per feature |
| Validation | Pydantic v2 + pydantic-settings |
| ORM | SQLAlchemy 2.0 async + Alembic + asyncpg |
| Auth | PyJWT or joserfc + pwdlib, OAuth2 password bearer |
| Tests | pytest + httpx `ASGITransport` + polyfactory + testcontainers |
| Quality | Ruff (lint + format), mypy or pyright strict |
| Packaging | uv |
| Observability | structlog + OpenTelemetry + Sentry |

## Step 1: Task classification (exactly one)

| Class | Trigger words |
|---|---|
| New Feature | add endpoint, new service, create model, scaffold |
| Refactor | restructure, split, extract, migrate, clean up |
| Bug Fix | fix, 500, failing, regression, race, leak |
| Performance | slow, N+1, timeout, throughput, blocking |
| Review/Audit | review, audit, security check, schema check |
| Documentation Only | document, explain, write up |

Unclear class means stop and ask.

## Step 2: Architecture-first order (never skip a layer)

Boundaries, invariants, data ownership, transaction scope, public API surface (routes and schemas),
module layout, files, functions, syntax. The transaction boundary is decided before the first query is
written.

Router to service to repository. Three layers, one direction.

## Step 3: The rule set

| Domain | Rules | Reference |
|---|---|---|
| Project structure | PS-1..PS-5 | `references/PROJECT-STRUCTURE.md` |
| Pydantic v2 | PD-1..PD-8 | `references/PYDANTIC.md` |
| Endpoint design | EP-1..EP-6 | `references/ENDPOINTS.md` |
| Settings and validation | CF-1..CF-5 | `references/CONFIG-VALIDATION.md` |
| Dependency injection | DI-1..DI-4 | `references/DEPENDENCIES.md` |
| Async and performance | AS-1..AS-5 | `references/ASYNC-PERFORMANCE.md` |
| Security | SE-1..SE-6 | `references/SECURITY.md` |
| Error handling | ER-1..ER-4 | `references/ERRORS.md` |
| Database and ORM | DB-1..DB-5 | `references/DATABASE.md` |
| Testing and quality | TQ-1..TQ-5 | `references/TESTING.md` |
| Production and observability | OB-1..OB-7 | `references/OBSERVABILITY.md` |

Read the file for the domain being touched before writing code in it.

## Step 4: Forbidden patterns (hard stops)

| Pattern | Instead |
|---|---|
| `@app.on_event("startup")` | `lifespan` async context manager (PS-4) |
| Pydantic v1 `class Config`, `@validator`, `.dict()`, `.parse_obj()` | `ConfigDict`, `@field_validator`, `.model_dump()`, `.model_validate()` (PD-1) |
| One model for request, response, and ORM row | Separate Create / Update / Response / DB models (PD-2) |
| `Query(default=...)` inside `Annotated` | Default with `=` on the parameter. The other form raises `AssertionError` at import (EP-3) |
| Blocking calls in `async def` | `await`, or `run_in_threadpool` (AS-2) |
| Unbounded list endpoints | Mandatory pagination with a bounded limit (AS-4) |
| `Base.metadata.create_all()` in production | Alembic migration with a downgrade (DB-3) |
| Returning an ORM object from a route | A response schema (SE-6) |
| `python-jose`, `passlib` | PyJWT or joserfc, and pwdlib (SE-1, SE-2) |
| `AsyncClient(app=app)` in tests | `AsyncClient(transport=ASGITransport(app=app))` (TQ-1) |
| `datetime.utcnow()` | `datetime.now(UTC)`, deprecated since Python 3.12 |
| Bare `except:` | `except Exception:` at minimum (DI-3) |
| `os.getenv` scattered through the code | One `BaseSettings` object (CF-1) |

## Step 5: Tests are part of the output

Every endpoint owes a contract test: status code, response validated against the response model, and
the exact key set. Plus the validation failure, the authorization failure, and the not-found case.
Composes with `test-first` for ordering and `ship-gate` for evidence.

## Step 6: Output contract and negative doubt

Follow `references/OUTPUT-CONTRACT.md`. The negative-doubt routine runs before finalizing, and its hard
stop applies: if correctness is still uncertain after the second pass, return the revised design and
the missing inputs instead of code.

## Review and audit mode

`references/REVIEW-CHECKLIST.md` is the pass list, keyed to rule IDs, with the severity scale. Report
findings as `path:line - RULE-ID - problem - fix`. Scaffolds in `references/TEMPLATES.md`.

## Boundaries with other skills

| Concern | Owner |
|---|---|
| Whether the feature should exist | `pm-gate` |
| Vulnerability hunting across a codebase | `security-review`. SE-1..SE-6 are build-time rules, not an audit |
| Algorithmic complexity of a hot path | `optimizer` |
| Completion claims and verification evidence | `ship-gate` |
| Frontend consuming this API | `react-pro-coder` |

This skill owns the service-side engineering decision: layering, schemas, transaction boundaries, async
model, error surface, and what the API is allowed to return.
74 changes: 74 additions & 0 deletions skills/python-pro-coder/references/ASYNC-PERFORMANCE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# Async and Performance (AS-1 .. AS-5)

## AS-1: Pick one execution model per route and hold it

FastAPI runs `def` routes in a threadpool and `async def` routes on the event loop. Both are correct.
Mixing them wrongly is what hurts: an `async def` route that calls blocking code stalls the loop for
every other request in the process.

| Route | Database driver | HTTP client | Verdict |
|---|---|---|---|
| `async def` | asyncpg via SQLAlchemy async | httpx async | correct |
| `def` | psycopg sync | requests | correct, runs in the threadpool |
| `async def` | sync session or `requests` | any | broken, blocks the loop |

## AS-2: Never block inside `async def`

No `time.sleep`, no synchronous `requests`, no blocking file IO, no CPU-heavy loop.

```python
from fastapi.concurrency import run_in_threadpool

@router.post("/render")
async def render(payload: RenderRequest):
return await run_in_threadpool(expensive_sync_render, payload)
```

CPU-bound work does not belong in the web process at all past a certain size; the threadpool is a
bridge for library calls that have no async form, not a substitute for a worker.

## AS-3: Faster JSON when payloads justify it

```python
from fastapi.responses import ORJSONResponse

app = FastAPI(default_response_class=ORJSONResponse)
```

Worth it for large or hot payloads. Measure first: for a 2 KB response the serializer is not the
bottleneck, and the change costs a dependency plus a subtle difference in how some types serialize.

## AS-4: Pagination is mandatory on list endpoints

```python
@router.get("/", response_model=Page[UserResponse])
async def list_users(pagination: Pagination, service: UserServiceDep) -> Page[UserResponse]:
...
```

An unbounded list endpoint is a denial of service with a friendly name: it works for a year, then one
tenant reaches a million rows and the endpoint takes the process down. Bound the limit in the schema
(DI-2) so the bound cannot be bypassed by a query string.

Keyset pagination beats offset for large or frequently mutated tables: offset scans everything it
skips, and rows shift under a paging client.

## AS-5: Kill N+1 at the query, not in a loop

```python
# BAD: one query for users, then one per user
users = (await db.execute(select(User))).scalars().all()
for user in users:
_ = user.posts

# GOOD
stmt = select(User).options(selectinload(User.posts))
users = (await db.execute(stmt)).scalars().all()
```

`selectinload` issues a second query with an `IN` clause and is the default choice for collections.
`joinedload` uses one query with a join and suits many-to-one. Lazy loading on an async session raises
rather than silently emitting IO, which is a feature: it turns an N+1 into an error during development.

Log or assert query counts in tests for the endpoints that matter. N+1 regressions arrive through
unrelated changes.
63 changes: 63 additions & 0 deletions skills/python-pro-coder/references/CONFIG-VALIDATION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# Settings and Validation (CF-1 .. CF-5)

## CF-1: One `BaseSettings` object, no scattered `os.getenv`

```python
from pydantic import Field, PostgresDsn
from pydantic_settings import BaseSettings, SettingsConfigDict

class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="forbid")

database_url: PostgresDsn
secret_key: str = Field(min_length=32)
debug: bool = False
```

A missing or malformed variable fails at construction with the field name, not at 3am inside a request
handler. `extra="forbid"` catches the typo'd variable name that would otherwise be silently ignored.

## CF-2: Cache settings behind a dependency

```python
from functools import lru_cache

@lru_cache
def get_settings() -> Settings:
return Settings()

SettingsDep = Annotated[Settings, Depends(get_settings)]
```

Pick this or a module-level singleton, not both. The dependency form is preferred because a test can
override it with `app.dependency_overrides[get_settings]`, which a module-level `settings = Settings()`
cannot offer without monkeypatching.

## CF-3: Validate at the edge, trust inside

Pydantic validates at the HTTP boundary and at every other input boundary: queue messages, webhook
payloads, external API responses, config files. Past that boundary the types are true, and a service
that re-checks `if not isinstance(...)` is admitting it does not believe its own signature.

The corollary: anything that enters without passing a model is not validated. Parse it into a model
first.

## CF-4: Forbid extra fields on input

```python
class UserCreate(BaseModel):
model_config = ConfigDict(extra="forbid")
```

Default Pydantic behavior ignores unknown fields, so a client sending `{"email": ..., "is_admin": true}`
gets a silent success and the field vanishes. `extra="forbid"` turns that into a 422. Response models
are the opposite case: they should not forbid, they should simply not contain what must not leave.

## CF-5: Use the precise type, not `str`

`EmailStr`, `HttpUrl`, `AnyUrl`, `PostgresDsn`, `UUID`, `Decimal` for money, `datetime` with timezone
awareness, `SecretStr` for anything that must not appear in a log or a repr. Each one is a validation
rule and a piece of documentation obtained for free, and `SecretStr` in particular prevents the class
of incident where a settings dump lands in a log line.

Money is `Decimal` with explicit `max_digits` and `decimal_places`. Never `float`.
67 changes: 67 additions & 0 deletions skills/python-pro-coder/references/DATABASE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Database and ORM (DB-1 .. DB-5)

## DB-1: SQLAlchemy 2.0 style

```python
from sqlalchemy.orm import Mapped, mapped_column

class User(Base):
__tablename__ = "users"

id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4)
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
created_at: Mapped[datetime] = mapped_column(default=lambda: datetime.now(UTC))
```

`Mapped[...]` plus `mapped_column` gives the type checker real column types and makes nullability
explicit in the annotation. Queries use `select()` with `await db.execute(...)`, not the legacy
`Query` API.

## DB-2: One persistence pattern per codebase

Either SQLAlchemy models with separate Pydantic schemas, or SQLModel unifying both. Both work. Three
half-migrated patterns in one repository means every developer must guess which one applies to the file
they opened.

SQLModel is maintained but still pre-1.0; the separate-model shape stays the default when the table and
the API schema diverge, which they do as soon as there is a computed field, a hidden column, or a
different name on the wire.

## DB-3: Alembic migrations, always

`Base.metadata.create_all()` is for a test fixture and nothing else. It cannot alter, cannot backfill,
cannot roll back, and cannot tell two environments apart.

Every migration is reviewed like code: it has a downgrade, it does not lock a large table for minutes,
and index creation on a busy Postgres table is concurrent. A migration that drops a column ships after
the code that stopped reading it, never with it.

## DB-4: Repository per aggregate for testable queries

```python
class UserRepository:
def __init__(self, db: AsyncSession) -> None:
self.db = db

async def get_by_email(self, email: str) -> User | None:
return (await self.db.execute(select(User).where(User.email == email))).scalar_one_or_none()
```

The repository holds query construction so the service reads as business rules and the queries can be
exercised directly. It does not commit and does not decide policy.

## DB-5: The service owns the transaction

The unit of work is a business operation, not an HTTP request and not a single query. The service
opens, commits, and rolls back; the dependency provides the session (DI-3); the repository never
commits.

```python
async def transfer(self, src: UUID, dst: UUID, amount: Decimal) -> None:
async with self.db.begin():
await self.accounts.debit(src, amount)
await self.accounts.credit(dst, amount)
```

When two writes must both happen or neither, that fact is expressed once, in the service, and is
visible to a reader.
Loading
Loading