Install
$ agentstack add skill-robhowley-py-pit-skills-fastapi-init ✓ scanned · ✓ verified — works with Claude Code, Cursor, and more.
Security review
✓ PassedNo issues found. Passed automated security review. · v0.1.0 How review works →
- ✓ Prompt-injection patterns
- ✓ Secret / credential exfiltration
- ✓ Dangerous shell & filesystem operations
- ✓ Untrusted network calls
- ✓ Known-malicious package signatures
What it can access
- ● Network access Used
- ✓ Filesystem access No
- ✓ Shell / process execution No
- ● Environment & secrets Used
- ✓ Dynamic code execution No
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
About
fastapi-init
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.
Prerequisite skills
- uv skill: use for all
uv add,uv run, and environment commands — never fall back to pip - click-cli skill: consult if the user wants an extended CLI beyond the basic server entry point
- fastapi-errors skill: the authority on the full error architecture — domain subclasses, error codes, auth error patterns, and existing-repo strategy
Step 1 — Confirm the service name
Ask: "What's the name of this service?"
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.
Step 2 — Initialize with uv
uv init {pkg_name} --app --no-workspace
cd {pkg_name}
Remove the stub file uv generates (hello.py), then add dependencies:
uv add fastapi "uvicorn[standard]" "sqlalchemy[asyncio]" aiosqlite "pydantic-settings" click
uv add --dev pytest pytest-asyncio httpx
After uv init, add the hatchling build backend to pyproject.toml so [project.scripts] works:
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["{pkg_name}"]
Step 3 — Directory structure
Build this layout under the project root:
{pkg_name}/
├── pyproject.toml
├── uv.lock
├── {pkg_name}/ ← Python package, same name as project root
│ ├── __init__.py
│ ├── main.py # FastAPI app + lifespan
│ ├── cli.py # click entry point
│ ├── core/
│ │ ├── __init__.py
│ │ ├── config.py # pydantic-settings Settings
│ │ └── exceptions.py # {PkgName}Error base + usage note
│ ├── api/
│ │ ├── __init__.py
│ │ ├── deps.py # shared Annotated Depends providers
│ │ └── v1/
│ │ ├── __init__.py
│ │ └── routes/
│ │ ├── __init__.py
│ │ └── health.py
│ ├── db/
│ │ ├── __init__.py
│ │ ├── base.py # Base, TimestampMixin, naming convention
│ │ └── session.py # engine, AsyncSessionLocal, get_db
│ ├── models/
│ │ └── __init__.py # SQLAlchemy declarative models
│ └── schemas/
│ ├── __init__.py
│ └── base.py # APIModel + ReadModel base schemas
└── tests/
├── __init__.py
├── conftest.py
└── api/
└── v1/
├── __init__.py
└── test_health.py
Step 4 — File templates
pyproject.toml
After uv init, add these sections (the build-system block from Step 2 plus):
[project.scripts]
{pkg_name} = "{pkg_name}.cli:cli"
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
{pkg_name}/core/config.py
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_prefix="{PKG_NAME}_", env_file=".env", extra="ignore",
)
app_name: str = "{pkg_name}"
debug: bool = False
database_url: str = "sqlite+aiosqlite:///./app.db"
settings = Settings()
{pkg_name}/db/base.py
Schema infrastructure - Base class, naming convention, and shared mixins. Models import Base from here.
from datetime import datetime, timezone
from sqlalchemy import DateTime, MetaData
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
convention = {
"ix": "ix_%(column_0_label)s",
"uq": "uq_%(table_name)s_%(column_0_name)s",
"ck": "ck_%(table_name)s_%(constraint_name)s",
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
"pk": "pk_%(table_name)s",
}
def utcnow() -> datetime:
return datetime.now(timezone.utc)
class Base(DeclarativeBase):
metadata = MetaData(naming_convention=convention)
class TimestampMixin:
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=utcnow, onupdate=utcnow,
)
{pkg_name}/db/session.py
Connection infrastructure. get_db is the single canonical source of truth for database sessions in routes and tests - this is what makes DI overrides in tests work cleanly. Background tasks run outside the FastAPI DI lifecycle and must open sessions via AsyncSessionLocal directly.
from collections.abc import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from {pkg_name}.core.config import settings
engine = create_async_engine(settings.database_url)
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)
async def get_db() -> AsyncGenerator[AsyncSession, None]:
async with AsyncSessionLocal() as session:
yield session
expire_on_commit=False prevents lazy-load errors when accessing attributes after a commit on async sessions - SQLAlchemy cannot implicitly issue blocking I/O in an async context.
{pkg_name}/api/deps.py
from typing import Annotated
from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession
from {pkg_name}.db.session import get_db
DbSession = Annotated[AsyncSession, Depends(get_db)]
Use DbSession as a type annotation on route parameters — it's self-documenting and avoids repeating Depends(get_db) everywhere.
{pkg_name}/core/exceptions.py
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.
class {PkgName}Error(Exception):
status_code: int = 500
detail: str = "An unexpected error occurred."
def __init__(self, detail: str | None = None, status_code: int | None = None, **context):
self.detail = detail if detail is not None else self.__class__.detail
self.status_code = status_code if status_code is not None else self.__class__.status_code
self.context = context
Example subclass:
class NotFoundError({PkgName}Error):
status_code = 404
detail = "Resource not found."
See the fastapi-errors skill for the full error architecture: domain subclasses, error codes, auth error patterns, and existing-repo strategy.
{pkg_name}/schemas/base.py
from pydantic import BaseModel, ConfigDict
class APIModel(BaseModel):
model_config = ConfigDict(
extra="forbid",
str_strip_whitespace=True,
validate_assignment=True,
use_enum_values=True,
populate_by_name=True,
)
class ReadModel(APIModel):
model_config = APIModel.model_config.copy()
model_config["from_attributes"] = True
class CreateModel(APIModel):
"""Request body for resource creation."""
class UpdateModel(APIModel):
"""Partial update payload. Use model_dump(exclude_unset=True) in the service layer."""
class QueryModel(APIModel):
"""Search, list, and filtering inputs."""
model_config = APIModel.model_config.copy()
model_config["extra"] = "ignore"
class CommandModel(APIModel):
"""Action-oriented request body for non-CRUD endpoints."""
{pkg_name}/main.py
Use the lifespan pattern — on_event is deprecated.
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from {pkg_name}.api.v1.routes import health
from {pkg_name}.core.config import settings
from {pkg_name}.core.exceptions import {PkgName}Error
@asynccontextmanager
async def lifespan(app: FastAPI):
# startup: run migrations, warm caches, etc.
yield
# shutdown: close connections, flush buffers, etc.
app = FastAPI(title=settings.app_name, lifespan=lifespan)
app.include_router(health.router, prefix="/api/v1")
# see fastapi-errors skill for extended patterns (context logging, error codes, auth errors)
@app.exception_handler({PkgName}Error)
async def {pkg_name}_error_handler(request: Request, exc: {PkgName}Error) -> JSONResponse:
return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
@app.exception_handler(RequestValidationError)
async def validation_error_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
return JSONResponse(status_code=422, content={"detail": "Invalid request data", "details": exc.errors()})
@app.exception_handler(Exception)
async def unexpected_error_handler(request: Request, exc: Exception) -> JSONResponse:
return JSONResponse(status_code=500, content={"detail": "An unexpected error occurred."})
{pkg_name}/api/v1/routes/health.py
from fastapi import APIRouter
router = APIRouter(tags=["health"])
@router.get("/health")
async def health_check():
return {"status": "ok"}
{pkg_name}/cli.py
import click
import uvicorn
from {pkg_name}.core.config import settings
@click.group()
def cli():
"""{pkg_name} service CLI."""
@cli.command()
@click.option("--host", default="0.0.0.0", show_default=True, help="Bind host.")
@click.option("--port", default=8000, show_default=True, type=int, help="Bind port.")
@click.option("--reload", is_flag=True, default=settings.debug, help="Enable hot reload.")
def serve(host: str, port: int, reload: bool):
"""Start the uvicorn server."""
uvicorn.run("{pkg_name}.main:app", host=host, port=port, reload=reload)
Step 5 — Test setup
tests/conftest.py
The test suite is built around three layered async fixtures. The design principle: never hit a real database, never reach outside the process, override DI at the boundary.
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from {pkg_name}.db.base import Base
from {pkg_name}.db.session import get_db
from {pkg_name}.main import app
TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
@pytest_asyncio.fixture(scope="session")
async def engine():
eng = create_async_engine(TEST_DATABASE_URL)
async with eng.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield eng
async with eng.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await eng.dispose()
@pytest_asyncio.fixture
async def db_session(engine) -> AsyncSession:
async with engine.connect() as conn:
async with conn.begin() as trans:
session = async_sessionmaker(bind=conn, expire_on_commit=False)()
yield session
await session.close()
await trans.rollback()
@pytest_asyncio.fixture
async def client(db_session: AsyncSession) -> AsyncClient:
async def override_get_db():
yield db_session
app.dependency_overrides[get_db] = override_get_db
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as c:
yield c
app.dependency_overrides.clear()
tests/api/v1/test_health.py
from httpx import AsyncClient
async def test_health_returns_ok(client: AsyncClient):
response = await client.get("/api/v1/health")
assert response.status_code == 200
assert response.json() == {"status": "ok"}
Test invariants
Hold these across every test file in the project:
- Async tests — all test functions are
async def;asyncio_mode = "auto"means no per-test markers needed - No test classes — top-level
test_*functions only;pytest_asynciofixtures handle all setup and teardown - No real databases — all DB-touching tests use the
db_sessionfixture; async SQLite in-memory only - DI overrides, not patches — swap behavior via
app.dependency_overrides; don't mock internals - Shared fixtures in conftest.py — test files stay clean; fixtures go in conftest
- Transaction-per-test isolation — the rollback in
db_sessionensures tests never affect each other even if they write data - AsyncClient, not TestClient — use
httpx.AsyncClientwithASGITransportfor endpoint tests
Step 6 — Verify the scaffold
uv run pytest
uv run {pkg_name} serve --help
Both should succeed with no errors before handing the project to the user.
What's next
The scaffold is ready to extend. Common next steps:
- Add models - use the
sqlalchemy-modelsskill to define ORM entities undermodels/ - Initialize migrations - use the
alembic-migrationsskill to set up Alembic after adding models - Add schemas - use the
pydantic-schemasskill for request/response schemas beyond the base classes
Completion checklist
- [ ]
[project.scripts]entry in pyproject.toml points to{pkg_name}.cli:cli - [ ]
asyncio_mode = "auto"set in[tool.pytest.ini_options] - [ ] hatchling build backend present with
packages = ["{pkg_name}"] - [ ]
get_dbis the single source of truth for DB sessions — routes and tests both go through it - [ ]
app.dependency_overridesis cleared after every test fixture that sets it - [ ] No test touches a real database, network socket, or Docker container
- [ ] All test functions are top-level — no test classes
- [ ]
{PkgName}Errorbase class present incore/exceptions.pyand handler registered inmain.py - [ ]
uv run pytestpasses from the project root
Source & license
This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.
- Author: robhowley
- Source: robhowley/py-pit-skills
- License: MIT
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet — be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.