generate-speakmanai-code-python
Internal LANG_CONFIG reference for Python/FastAPI projects, read automatically by the generate-speakmanai-code router (Step 1c). Not a standalone workflow — do not trigger this on its own; use generat
SKILL.md.claude/skills/generate-speakmanai-code-python/SKILL.md
GitHubgenerate-speakmanai-code-python — Python / FastAPI Variant
This file is read by the generate-speakmanai-code router (Step 1c) to extract LANG_CONFIG
for Python / FastAPI projects. It is not invoked directly.
Architecture: FastAPI monolith serving REST API, MCP SSE, and Admin UI (Jinja2 + HTMX)
from a single process. No BFF. No separate frontend service. Cloud Run Jobs share the same
src/ codebase via a separate entrypoint. Node.js exists only as a co-bundled subprocess
for PDF rendering, invoked via asyncio.create_subprocess_exec.
LANG_CONFIG
CODING_STANDARDS
- Style: PEP 8; enforced via
ruff check+ruff format(configured inpyproject.toml) - Indent: 4 spaces
- Type hints: required on all function signatures and class attributes — no bare
Any - Async: FastAPI routes, application use cases, and infrastructure methods are all
async def - Domain layer: synchronous only — no
async, noawait, no I/O imports anywhere insrc/domain/ - Domain objects:
@dataclass(frozen=True)for entities and value objects — immutable by construction - API schemas: Pydantic
BaseModelfor request/response shapes only — never in domain or application layers - Errors:
raise DomainError(code='VALIDATION_ERROR', message='...')— neverHTTPExceptionfrom domain or application layers - Naming:
- Python identifiers and JSON fields:
snake_case(per NAMING_DICTcanonical_name/json_field) - DB columns:
snake_case(per NAMING_DICTsql_column) - All canonical names in NAMING_DICT take precedence over any inferred name
- Python identifiers and JSON fields:
- Imports: absolute from
src/root (e.g.from domain.todo.entity import TodoItem) - Secrets: read from
os.environat call time inside functions — never cached at module level - Boolean fields: validate with
isinstance(value, bool)— neverbool(value)which coerces strings
DIR_SKELETON_LAYOUT
{REPO_PATH}/
src/
domain/
shared/
errors.py ← DomainError class (single shared definition)
{aggregate}/ ← one directory per aggregate root in DOMAIN_MODEL
entity.py ← @dataclass(frozen=True) aggregate and child entities
value_objects.py ← @dataclass(frozen=True) value objects
repository.py ← Protocol interface for this aggregate's repository
application/
use_cases/
{uc_name}.py ← one file per UC-XX
infrastructure/
database/
connection.py ← async DB connection + lifespan helper
migrations.py ← idempotent DDL runner
{aggregate}_repository.py ← one per aggregate root
{external_service}/ ← one directory per external service in API_SCHEMA tags
api/
routers/
{aggregate}.py ← FastAPI APIRouter per aggregate
schemas/
{aggregate}.py ← Pydantic request/response models per aggregate
dependencies.py ← FastAPI Depends() factories (get_db, get_repo, require_auth)
middleware.py ← security headers, CORS
app.py ← create_app() factory function
mcp/
server.py ← FastMCP instance and mount point
tools.py ← @mcp.tool() definitions
admin/
routes.py ← Jinja2 + HTMX admin APIRouter
templates/ ← Jinja2 .html template files
base.html
{section}/
jobs/
{job_name}.py ← Cloud Run Job entrypoints (same src/, different __main__)
migration/
migrate.py ← data migration script (run once; source → target schema)
tests/
conftest.py ← shared fixtures: db, client, test token factory
domain/ ← pure unit tests; synchronous; no pytest-asyncio needed
application/ ← async unit tests; AsyncMock repositories
infrastructure/ ← async integration tests; real :memory: DB
api/ ← async integration tests; httpx.AsyncClient + ASGITransport
e2e/ ← async journey tests; full stack, no seeding at seams
pdf_renderer/ ← co-bundled Node.js PDF binary (NOT a web service)
package.json ← {"type":"module","dependencies":{...}}
index.js ← reads JSON from stdin, writes PDF bytes to stdout
main.py ← uvicorn entry point: `uvicorn src.api.app:app`
pyproject.toml ← ruff config, pytest config, package metadata
requirements.txt ← production dependencies
requirements-dev.txt ← dev/test dependencies
.env.example
Dockerfile
src/mcp/ and src/admin/ are always present — omit only if the workflow has no MCP tools
or no admin interface. pdf_renderer/ is present whenever PDF export is in scope.
DEPENDENCIES_DERIVATION
Derive packages from the contracts:
requirements.txt (production)
| Source | Package |
|---|---|
| Always | fastapi[standard] (includes uvicorn, python-multipart) |
| Always | python-dotenv |
| Always | aiosqlite (default dev DB; swap driver for production) |
| API_SCHEMA BearerAuth security scheme | PyJWT |
| DOMAIN_MODEL or FIELD_DEFS: password hashing | bcrypt |
| Admin UI in scope | jinja2 (included in fastapi[standard]) |
| MCP tools in scope | fastmcp |
| External service in API_SCHEMA tags | service-specific SDK (e.g. google-cloud-storage, stripe, motor for MongoDB) |
| Embeddings in scope | google-cloud-aiplatform or openai |
| TECH_STACK specifies MongoDB | motor (async MongoDB driver) |
| TECH_STACK specifies PostgreSQL | asyncpg + sqlalchemy[asyncio] |
requirements-dev.txt (dev + test)
| Source | Package |
|---|---|
| Always | pytest |
| Always | pytest-asyncio |
| Always | httpx (async test client via ASGITransport) |
| Always | ruff |
| TEST_STRATEGY tooling column | any additional listed tools |
pdf_renderer/package.json (only if PDF rendering is in scope):
{
"type": "module",
"dependencies": {
"@react-pdf/renderer": "^3.0.0",
"react": "^18.0.0",
"react-dom": "^18.0.0"
}
}
SCAFFOLD_CONFIG
pyproject.toml:
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
pythonpath = ["src"]
[tool.ruff]
line-length = 100
target-version = "py312"
[tool.ruff.lint]
select = ["E", "F", "I", "UP"]
ignore = ["E501"]
requirements.txt (generated from DEPENDENCIES_DERIVATION — one package per line, no version pins at scaffold time; pin after first successful run):
fastapi[standard]
python-dotenv
aiosqlite
PyJWT
bcrypt
fastmcp
requirements-dev.txt:
pytest
pytest-asyncio
httpx
ruff
SCAFFOLD_FILES
src/domain/shared/errors.py — create at scaffold time; used everywhere:
class DomainError(Exception):
def __init__(self, code: str, message: str) -> None:
self.code = code
self.message = message
super().__init__(message)
src/api/app.py — app factory; required before any routes can be written:
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from domain.shared.errors import DomainError
def create_app(db=None) -> FastAPI:
@asynccontextmanager
async def lifespan(app: FastAPI):
if db is None:
from infrastructure.database.connection import connect_db, close_db
app.state.db = await connect_db()
yield
await close_db(app.state.db)
else:
app.state.db = db
yield
app = FastAPI(lifespan=lifespan)
@app.exception_handler(DomainError)
async def domain_error_handler(request: Request, exc: DomainError) -> JSONResponse:
STATUS_MAP = {
'NOT_FOUND': 404, 'UNAUTHORIZED': 401,
'CONFLICT': 409, 'VALIDATION_ERROR': 422,
}
status = STATUS_MAP.get(exc.code, 500)
return JSONResponse(
status_code=status,
content={'error': exc.code, 'message': exc.message if status != 500 else 'Internal server error'},
)
@app.exception_handler(Exception)
async def unhandled_error_handler(request: Request, exc: Exception) -> JSONResponse:
import logging
logging.getLogger(__name__).error('Unhandled error', exc_info=exc)
return JSONResponse(status_code=500, content={'error': 'INTERNAL_ERROR', 'message': 'Internal server error'})
@app.get('/health', include_in_schema=False)
async def health() -> dict:
return {'status': 'ok'}
return app
main.py — uvicorn entry point:
import uvicorn
from src.api.app import create_app
app = create_app()
if __name__ == '__main__':
uvicorn.run('main:app', host='0.0.0.0', port=8000, reload=True)
tests/conftest.py — shared test fixtures:
import pytest
import aiosqlite
import httpx
import jwt
import os
from infrastructure.database.migrations import run_migrations
from api.app import create_app
@pytest.fixture
async def db():
async with aiosqlite.connect(':memory:') as conn:
conn.row_factory = aiosqlite.Row
await run_migrations(conn)
yield conn
@pytest.fixture
async def client(db):
app = create_app(db)
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url='http://test') as c:
yield c
def make_test_token(user_id: str = 'test-user', role: str = 'user') -> str:
return jwt.encode({'sub': user_id, 'role': role}, os.environ.get('JWT_SECRET', 'test-secret'), algorithm='HS256')
GITIGNORE_ENTRIES
__pycache__/
*.pyc
*.pyo
.venv/
venv/
.env
*.db
*.sqlite
dist/
.ruff_cache/
.pytest_cache/
.coverage
htmlcov/
pdf_renderer/node_modules/
SECRET_GENERATION_CMD
python -c "import secrets; print(secrets.token_hex(32))"
Use this to generate JWT_SECRET and DB_ENCRYPTION_KEY in the dev .env file.
VALIDATION_GATE
pytest
Run from repo root. pythonpath = ["src"] in pyproject.toml ensures imports resolve
without a package install step.
MILESTONE_GATES
install: pip install -r requirements.txt -r requirements-dev.txt
domain: pytest tests/domain/ -v
application: pytest tests/application/ -v
infrastructure: pytest tests/infrastructure/ -v
interface: pytest tests/api/ -v
e2e: pytest tests/e2e/ -v
ui_build: N/A — FastAPI serves Jinja2 templates directly; no build step
uv users: replace pip install with uv pip install.
LOCAL_DB_TEST
SQLite in-memory: pass ':memory:' to aiosqlite.connect().
async with aiosqlite.connect(':memory:') as conn:
conn.row_factory = aiosqlite.Row
await run_migrations(conn)
# conn is a real database — no mocks
The db fixture in tests/conftest.py wraps this pattern — use it in all infrastructure
and API tests.
DOMAIN_IMPLEMENTATION_GUIDE
Implement each aggregate root, child entity, and value object as a @dataclass(frozen=True).
Validation runs in __post_init__. No async, no I/O, no imports from outside src/domain/.
Value Object pattern (src/domain/{aggregate}/value_objects.py):
from dataclasses import dataclass
from domain.shared.errors import DomainError
@dataclass(frozen=True)
class Email:
value: str
def __post_init__(self) -> None:
if not self.value or '@' not in self.value:
raise DomainError(code='VALIDATION_ERROR', message='Email must be a valid email address')
object.__setattr__(self, 'value', self.value.lower())
Aggregate Root pattern (src/domain/{aggregate}/entity.py):
from dataclasses import dataclass, field
from domain.shared.errors import DomainError
@dataclass(frozen=True)
class TodoItem:
id: str
title: str
is_completed: bool = False
def __post_init__(self) -> None:
if not self.id:
raise DomainError(code='VALIDATION_ERROR', message='id is required')
if not self.title or not self.title.strip():
raise DomainError(code='VALIDATION_ERROR', message='title is required')
if len(self.title) > 500:
raise DomainError(code='VALIDATION_ERROR', message='title must be 500 characters or fewer')
Rules:
- All invariant violations raise
DomainError— neverValueError,AssertionError, orHTTPException object.__setattr__is the only way to mutate a frozen dataclass field (use for normalisation in__post_init__only)- No
asyncmethods. No imports fromapplication/,infrastructure/, orapi/
REPO_INTERFACE_PATTERN
Define repository interfaces as Protocol in src/domain/{aggregate}/repository.py.
from typing import Protocol
from domain.todo.entity import TodoItem
class TodoRepository(Protocol):
async def save(self, item: TodoItem) -> None: ...
async def find_by_id(self, id: str) -> TodoItem | None: ...
async def find_all(self) -> list[TodoItem]: ...
async def delete(self, id: str) -> None: ...
Protocol provides structural subtyping — the infrastructure implementation does not need
to inherit from it, only satisfy the method signatures. Application use cases receive the
Protocol type in their signature; infrastructure passes the concrete class.
DOMAIN_TEST_GUIDE
Use pytest. Domain tests are synchronous — no pytest-asyncio needed for this layer.
One test file per aggregate / value object in tests/domain/.
import pytest
from domain.todo.entity import TodoItem
from domain.shared.errors import DomainError
def test_raises_validation_error_when_title_is_empty():
with pytest.raises(DomainError) as exc_info:
TodoItem(id='1', title='')
assert exc_info.value.code == 'VALIDATION_ERROR'
def test_raises_validation_error_when_id_is_missing():
with pytest.raises(DomainError) as exc_info:
TodoItem(id='', title='Buy milk')
assert exc_info.value.code == 'VALIDATION_ERROR'
def test_creates_with_default_is_completed_false():
item = TodoItem(id='1', title='Buy milk')
assert item.is_completed is False
def test_creates_valid_item():
item = TodoItem(id='1', title='Buy milk')
assert item.id == '1'
assert item.title == 'Buy milk'
Cover every invariant: one test that violates it, one that satisfies it.
APPLICATION_IMPLEMENTATION_GUIDE
One file per UC-XX in src/application/use_cases/. Each use case is an async def function
that receives repository instances as its first arguments (dependency injection by position).
# src/application/use_cases/create_todo_item.py
import uuid
from domain.todo.entity import TodoItem
from domain.todo.repository import TodoRepository
from domain.audit.repository import AuditRepository
from domain.shared.errors import DomainError
async def create_todo_item(
todo_repo: TodoRepository,
audit_repo: AuditRepository,
*,
title: str,
user_id: str,
) -> TodoItem:
item = TodoItem(id=str(uuid.uuid4()), title=title)
await todo_repo.save(item)
await audit_repo.save(action='TODO_CREATED', entity_id=item.id, user_id=user_id)
return item
# src/application/use_cases/get_todo_item.py
async def get_todo_item(
todo_repo: TodoRepository,
*,
id: str,
user_id: str,
) -> TodoItem:
item = await todo_repo.find_by_id(id)
if item is None:
raise DomainError(code='NOT_FOUND', message=f'Todo item {id} not found')
return item
Rules:
- Repositories are always injected — never imported from
infrastructure/ - Write an audit log entry on every mutation use case
- Raise domain error codes only (
NOT_FOUND,UNAUTHORIZED,CONFLICT,VALIDATION_ERROR) - Never import from
api/,admin/, ormcp/
APPLICATION_TEST_GUIDE
Use pytest-asyncio with AsyncMock from unittest.mock.
import pytest
from unittest.mock import AsyncMock
from application.use_cases.create_todo_item import create_todo_item
from domain.shared.errors import DomainError
@pytest.fixture
def todo_repo():
repo = AsyncMock()
repo.save = AsyncMock(return_value=None)
repo.find_by_id = AsyncMock(return_value=None)
return repo
@pytest.fixture
def audit_repo():
repo = AsyncMock()
repo.save = AsyncMock(return_value=None)
return repo
async def test_creates_item_and_writes_audit(todo_repo, audit_repo):
result = await create_todo_item(todo_repo, audit_repo, title='Buy milk', user_id='u1')
assert result.title == 'Buy milk'
todo_repo.save.assert_called_once()
audit_repo.save.assert_called_once()
async def test_raises_not_found_when_item_missing(todo_repo, audit_repo):
todo_repo.find_by_id = AsyncMock(return_value=None)
with pytest.raises(DomainError) as exc_info:
await get_todo_item(todo_repo, id='missing', user_id='u1')
assert exc_info.value.code == 'NOT_FOUND'
asyncio_mode = "auto" in pyproject.toml means no @pytest.mark.asyncio decorator
is needed — all async def test_* functions run automatically.
INFRASTRUCTURE_IMPLEMENTATION_GUIDE
Connection module (src/infrastructure/database/connection.py):
import os
import aiosqlite
async def connect_db(path: str | None = None) -> aiosqlite.Connection:
db_path = path or os.environ.get('DB_PATH', './data/dev.db')
conn = await aiosqlite.connect(db_path)
conn.row_factory = aiosqlite.Row
return conn
async def close_db(conn: aiosqlite.Connection) -> None:
await conn.close()
Migration runner (src/infrastructure/database/migrations.py):
import aiosqlite
async def run_migrations(db: aiosqlite.Connection) -> None:
await db.execute("""
CREATE TABLE IF NOT EXISTS todo_items (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
is_completed INTEGER NOT NULL DEFAULT 0,
user_id TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
""")
await db.execute("CREATE INDEX IF NOT EXISTS idx_todo_items_created_at ON todo_items(created_at)")
await db.commit()
Repository implementation (src/infrastructure/database/todo_repository.py):
import aiosqlite
from domain.todo.entity import TodoItem
from domain.shared.errors import DomainError
class SqliteTodoRepository:
def __init__(self, db: aiosqlite.Connection) -> None:
self._db = db
def _row_to_entity(self, row: aiosqlite.Row) -> TodoItem:
return TodoItem(
id=row['id'],
title=row['title'],
is_completed=bool(row['is_completed']),
)
async def save(self, item: TodoItem) -> None:
try:
await self._db.execute(
'INSERT OR REPLACE INTO todo_items (id, title, is_completed, user_id, created_at) VALUES (?, ?, ?, ?, datetime("now"))',
(item.id, item.title, 1 if item.is_completed else 0, item.user_id),
)
await self._db.commit()
except aiosqlite.IntegrityError as e:
raise DomainError(code='CONFLICT', message=str(e)) from e
async def find_by_id(self, id: str) -> TodoItem | None:
async with self._db.execute('SELECT * FROM todo_items WHERE id = ?', (id,)) as cursor:
row = await cursor.fetchone()
return self._row_to_entity(row) if row else None
async def find_all(self) -> list[TodoItem]:
async with self._db.execute('SELECT * FROM todo_items ORDER BY created_at DESC') as cursor:
rows = await cursor.fetchall()
return [self._row_to_entity(r) for r in rows]
async def delete(self, id: str) -> None:
await self._db.execute('DELETE FROM todo_items WHERE id = ?', (id,))
await self._db.commit()
Error mapping rules:
aiosqlite.IntegrityError(UNIQUE constraint) →DomainError(code='CONFLICT')- Row not found → return
Nonefromfind_by_id; the application use case raisesNOT_FOUND
INFRASTRUCTURE_TEST_GUIDE
Use the shared db fixture from tests/conftest.py — real in-memory SQLite, no mocks.
import pytest
from domain.todo.entity import TodoItem
from domain.shared.errors import DomainError
from infrastructure.database.todo_repository import SqliteTodoRepository
async def test_save_and_find_by_id(db):
repo = SqliteTodoRepository(db)
item = TodoItem(id='1', title='Buy milk', user_id='u1')
await repo.save(item)
found = await repo.find_by_id('1')
assert found is not None
assert found.title == 'Buy milk'
async def test_find_by_id_returns_none_for_unknown(db):
repo = SqliteTodoRepository(db)
result = await repo.find_by_id('nonexistent')
assert result is None
async def test_boolean_round_trips_correctly(db):
repo = SqliteTodoRepository(db)
item = TodoItem(id='1', title='Done', is_completed=True, user_id='u1')
await repo.save(item)
found = await repo.find_by_id('1')
assert found.is_completed is True
async def test_raises_conflict_on_duplicate_id(db):
repo = SqliteTodoRepository(db)
item = TodoItem(id='1', title='First', user_id='u1')
await repo.save(item)
duplicate = TodoItem(id='1', title='Second', user_id='u1')
with pytest.raises(DomainError) as exc_info:
await repo.save(duplicate)
assert exc_info.value.code == 'CONFLICT'
INTERFACE_IMPLEMENTATION_GUIDE
FastAPI router (src/api/routers/{aggregate}.py):
from fastapi import APIRouter, Depends
from api.schemas.todo import CreateTodoRequest, TodoResponse
from api.dependencies import get_todo_repo, get_audit_repo, require_auth
from application.use_cases.create_todo_item import create_todo_item
router = APIRouter(prefix='/api/todos', tags=['todos'])
@router.post('/', response_model=TodoResponse, status_code=201)
async def create_todo(
body: CreateTodoRequest,
auth: dict = Depends(require_auth),
todo_repo=Depends(get_todo_repo),
audit_repo=Depends(get_audit_repo),
):
item = await create_todo_item(todo_repo, audit_repo, title=body.title, user_id=auth['sub'])
return TodoResponse.model_validate(item.__dict__)
Pydantic schemas (src/api/schemas/{aggregate}.py):
from pydantic import BaseModel, Field
class CreateTodoRequest(BaseModel):
title: str = Field(..., min_length=1, max_length=500)
class TodoResponse(BaseModel):
model_config = {'from_attributes': True}
id: str
title: str
is_completed: bool
Dependencies (src/api/dependencies.py):
import os
import jwt
from fastapi import Depends, HTTPException, Request, Security
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from infrastructure.database.todo_repository import SqliteTodoRepository
security = HTTPBearer()
async def get_db(request: Request):
return request.app.state.db
async def get_todo_repo(db=Depends(get_db)) -> SqliteTodoRepository:
return SqliteTodoRepository(db)
async def require_auth(
credentials: HTTPAuthorizationCredentials = Security(security),
) -> dict:
try:
return jwt.decode(
credentials.credentials,
os.environ['JWT_SECRET'],
algorithms=['HS256'],
)
except jwt.InvalidTokenError:
raise HTTPException(
status_code=401,
detail={'error': 'UNAUTHORIZED', 'message': 'Invalid or missing token'},
)
Security headers middleware (src/api/middleware.py):
from fastapi import Request
from fastapi.responses import Response
async def add_security_headers(request: Request, call_next) -> Response:
response = await call_next(request)
response.headers['X-Content-Type-Options'] = 'nosniff'
response.headers['X-Frame-Options'] = 'DENY'
response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
response.headers['Permissions-Policy'] = 'camera=(), microphone=(), geolocation=()'
return response
Register in create_app():
from fastapi.middleware.cors import CORSMiddleware
app.middleware('http')(add_security_headers)
app.add_middleware(CORSMiddleware, allow_origins=[os.environ['CORS_ORIGIN']], allow_credentials=True, allow_methods=['*'], allow_headers=['*'])
Admin UI routes (src/admin/routes.py):
from fastapi import APIRouter, Request
from fastapi.templating import Jinja2Templates
router = APIRouter(prefix='/admin')
templates = Jinja2Templates(directory='src/admin/templates')
@router.get('/')
async def admin_home(request: Request):
return templates.TemplateResponse('base.html', {'request': request})
MCP server (src/mcp/server.py):
from fastmcp import FastMCP
mcp = FastMCP(name='{REPO_NAME}')
Mount in create_app():
from mcp.server import mcp
app.mount('/mcp', mcp.get_asgi_app())
PDF subprocess (invoke from any use case or route that needs PDF output):
import asyncio, json
async def render_pdf(data: dict) -> bytes:
proc = await asyncio.create_subprocess_exec(
'node', 'pdf_renderer/index.js',
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await proc.communicate(json.dumps(data).encode())
if proc.returncode != 0:
raise RuntimeError(f'PDF renderer failed: {stderr.decode()}')
return stdout
Non-negotiable interface rules:
GET /healthalways returns200 {"status": "ok"}with no auth requirement — wired increate_app()- Global
Exceptionhandler returns{"error": "INTERNAL_ERROR", "message": "Internal server error"}— never forwardstr(exc)to the client - Boolean request fields validated with
isinstance(value, bool)— Pydantic enforces this automatically when the field type isbool(strict mode not required for primitive booleans in Pydantic v2) CORS_ORIGINis always read fromos.environ— never hardcoded
BFF_PRESENT
false — FastAPI serves the REST API, MCP SSE, and Admin UI directly. There is no separate
BFF proxy service. The Jinja2 + HTMX admin templates make direct calls to the FastAPI
/api/ routes via HTMX attributes.
BFF_IMPLEMENTATION
Not applicable — BFF_PRESENT is false.
INTERFACE_TEST_GUIDE
Use httpx.AsyncClient with ASGITransport against the real FastAPI app backed by
the in-memory db fixture from tests/conftest.py.
async def test_create_todo_returns_201(client):
token = make_test_token()
response = await client.post(
'/api/todos',
json={'title': 'Buy milk'},
headers={'Authorization': f'Bearer {token}'},
)
assert response.status_code == 201
body = response.json()
assert body['title'] == 'Buy milk'
assert body['is_completed'] is False
assert 'id' in body
async def test_create_todo_returns_422_when_title_missing(client):
token = make_test_token()
response = await client.post(
'/api/todos',
json={},
headers={'Authorization': f'Bearer {token}'},
)
assert response.status_code == 422
async def test_create_todo_returns_401_without_token(client):
response = await client.post('/api/todos', json={'title': 'Buy milk'})
assert response.status_code == 401
async def test_health_requires_no_auth(client):
response = await client.get('/health')
assert response.status_code == 200
E2E_IMPLEMENTATION_GUIDE
One test file per UC-XX named test_UC{XX}_{name}.py in tests/e2e/. Use the shared
client fixture from tests/conftest.py. Full app stack, no DB seeding at seams — all
values obtained from prior API responses.
async def test_UC01_create_and_retrieve_todo(client):
# Step 1: register a user — obtain token from the API response, not from a DB insert
register_res = await client.post(
'/api/auth/register',
json={'username': 'alice', 'password': 'Password123!'},
)
assert register_res.status_code == 201
token = register_res.json()['token']
# Step 2: create a todo
create_res = await client.post(
'/api/todos',
json={'title': 'Buy milk'},
headers={'Authorization': f'Bearer {token}'},
)
assert create_res.status_code == 201
todo_id = create_res.json()['id']
# Step 3: retrieve — ID obtained from step 2, not seeded
get_res = await client.get(
f'/api/todos/{todo_id}',
headers={'Authorization': f'Bearer {token}'},
)
assert get_res.status_code == 200
assert get_res.json()['title'] == 'Buy milk'
No DB seeding at seams: if a use case produces a value (ID, token, slug) that a later use case consumes, extract it from the API response — never insert it directly into the database between steps.
RUN_LOCALLY_INSTRUCTIONS
# Install dependencies
pip install -r requirements.txt -r requirements-dev.txt
# (or: uv pip install -r requirements.txt -r requirements-dev.txt)
# Copy env file and fill in values
cp .env.example .env
# Install PDF renderer (only if pdf_renderer/ is present)
cd pdf_renderer && npm install && cd ..
# Start the server
python main.py
# → FastAPI on http://localhost:8000
# → REST API at /api/
# → MCP SSE at /mcp
# → Admin UI at /admin/
# Run tests
pytest # full suite
pytest tests/domain/ # domain only
pytest tests/application/ # application only
pytest tests/infrastructure/ # infrastructure only
pytest tests/api/ # interface only
pytest tests/e2e/ # e2e only
# Lint
ruff check src/ tests/
ruff format src/ tests/