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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ This file is for anyone changing this repository, human or agent. It states the
| `make install` | Installs every dependency group. |
| `make hooks` | Installs the pre-commit hooks so `make check` runs before each commit. |
| `make check` | Runs every lint gate and the unit tests. Green here means green in CI. |
| `make seed` | Loads `seed/fixtures.py`. A second run changes nothing. |
| `make test-integration` | Runs the Postgres tests in a disposable container. Needs Docker. |
| `make test-cov` | Runs all tests with the coverage floor from `pyproject.toml`. |
| `make openapi-snapshot` | Regenerates `tests/contract/openapi.json` after an intended API change. |
Expand Down
1 change: 1 addition & 0 deletions Dockerfile.migration
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ COPY --from=builder /app/.venv /app/.venv

COPY alembic.ini /app/
COPY alembic /app/alembic
COPY seed /app/seed

ENV PATH="/app/.venv/bin:$PATH"
ENV PYTHONUNBUFFERED=1
Expand Down
16 changes: 10 additions & 6 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: help install upgrade hooks lint lint-ruff lint-format lint-ty lint-imports lint-typos format check run migrate docker-up docker-down docker-down-destroy test test-unit test-integration test-cov openapi-snapshot
.PHONY: help install upgrade hooks lint lint-ruff lint-format lint-ty lint-imports lint-typos format check run migrate seed docker-up docker-down docker-down-destroy test test-unit test-integration test-cov openapi-snapshot

help:
@echo "Available targets:"
Expand All @@ -10,6 +10,7 @@ help:
@echo " make lint-imports - Run import-linter architecture checks"
@echo " make format - Auto-fix ruff findings and format the code"
@echo " make migrate - Apply Alembic migrations to the configured database"
@echo " make seed - Load fixture data; safe to run repeatedly"
@echo " make run - Apply migrations, then start FastAPI dev server with hot reload"
@echo " make docker-up - Start full project locally"
@echo " make docker-down - Stop full project locally"
Expand All @@ -34,13 +35,13 @@ check: lint test-unit
lint: lint-ruff lint-format lint-ty lint-imports lint-typos

lint-ruff:
uv run --group lint --all-packages ruff check app tests alembic scenario-tests
uv run --group lint --all-packages ruff check app tests alembic seed scenario-tests

lint-format:
uv run --group lint --all-packages ruff format --check app tests alembic scenario-tests
uv run --group lint --all-packages ruff format --check app tests alembic seed scenario-tests

lint-ty:
uv run --group lint --group migration --group test --all-packages ty check --error-on-warning app tests alembic scenario-tests
uv run --group lint --group migration --group test --all-packages ty check --error-on-warning app tests alembic seed scenario-tests

lint-imports:
uv run --group lint --all-packages lint-imports --config .importlinter
Expand All @@ -49,8 +50,8 @@ lint-typos:
uvx typos

format:
uv run --group lint --all-packages ruff check --fix app tests alembic scenario-tests
uv run --group lint --all-packages ruff format app tests alembic scenario-tests
uv run --group lint --all-packages ruff check --fix app tests alembic seed scenario-tests
uv run --group lint --all-packages ruff format app tests alembic seed scenario-tests

run:
@if [ ! -f .env ]; then \
Expand All @@ -64,6 +65,9 @@ run:
migrate:
uv run --group migration --env-file .env alembic -c alembic.ini upgrade head

seed:
uv run --group migration --env-file .env python -m seed

docker-up:
docker compose up --build

Expand Down
11 changes: 11 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,17 @@ services:
condition: service_healthy
restart: "no"

seed:
<<: *migration-image
profiles: ["seed"]
command: ["python", "-m", "seed"]
environment:
DATABASE_URL: postgresql+psycopg://postgres:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}@db:5432/fastapi_db
depends_on:
migrate:
condition: service_completed_successfully
restart: "no"

app:
<<: *app-image
ports:
Expand Down
4 changes: 4 additions & 0 deletions docs/local-development.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ PostgreSQL data survives a normal `docker compose down`. Removing volumes resets
docker compose down -v
```

## Seed data

Seeding is a separate command and never runs on application startup. `make seed` loads the rows in `seed/fixtures.py` into the database from `.env`. In the Compose stack, run `docker compose run --rm seed`. Every fixture row sets its primary key. A row whose primary key already exists is left as it is, so running the command again changes nothing. For an integer identity key, the command then moves the sequence past the highest stored ID if it is not already past it, so the next insert gets a new one. This assumes the ascending, step-1 sequence that SQLAlchemy's `Identity()` creates.

## Isolated stacks

`COMPOSE_PROJECT_NAME` separates container names, networks, volumes, and default image tags. Parallel stacks must also use distinct host ports and public URLs:
Expand Down
91 changes: 91 additions & 0 deletions seed/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
from collections.abc import Mapping, Sequence
from itertools import groupby

from sqlalchemy import (
Connection,
MetaData,
Table,
cast,
column,
func,
select,
table,
text,
)
from sqlalchemy.dialects.postgresql import REGCLASS, insert

type Row = Mapping[str, object]
type Fixtures = Mapping[str, Sequence[Row]]

_PG_CLASS = table("pg_class", column("oid"), column("relname"), column("relnamespace"))
_PG_NAMESPACE = table("pg_namespace", column("oid"), column("nspname"))


def seed(connection: Connection, fixtures: Fixtures) -> dict[str, int]:
metadata = MetaData()
metadata.reflect(bind=connection, only=list(fixtures))
inserted: dict[str, int] = {}
for seeded_table in metadata.sorted_tables:
rows = fixtures.get(seeded_table.name)
Comment thread
Subhransu-De marked this conversation as resolved.
if seeded_table.schema is not None or not rows:
continue
_require_primary_keys(seeded_table, rows)
_lock_against_writers(connection, seeded_table)
inserted[seeded_table.name] = _insert_rows(connection, seeded_table, rows)
_restart_identity_sequences(connection, seeded_table)
return inserted


def _require_primary_keys(seeded_table: Table, rows: Sequence[Row]) -> None:
key = [column.name for column in seeded_table.primary_key.columns]
if not key or any(row.get(name) is None for row in rows for name in key):
msg = f"{seeded_table.name} fixture rows must set the primary key {key}"
raise ValueError(msg)


def _lock_against_writers(connection: Connection, seeded_table: Table) -> None:
table_name = connection.dialect.identifier_preparer.format_table(seeded_table)
connection.execute(text(f"LOCK TABLE {table_name} IN SHARE ROW EXCLUSIVE MODE"))


def _insert_rows(
connection: Connection, seeded_table: Table, rows: Sequence[Row]
) -> int:
key = list(seeded_table.primary_key.columns)
statement = (
insert(seeded_table).on_conflict_do_nothing(index_elements=key).returning(*key)
)
return sum(
len(connection.execute(statement, list(batch)).all())
Comment thread
Subhransu-De marked this conversation as resolved.
for _, batch in groupby(rows, key=frozenset)
)


def _restart_identity_sequences(connection: Connection, seeded_table: Table) -> None:
table_name = connection.dialect.identifier_preparer.format_table(seeded_table)
for key_column in seeded_table.primary_key.columns:
sequence = connection.scalar(
select(func.pg_get_serial_sequence(table_name, key_column.name))
)
if sequence is None:
continue
highest = connection.execute(select(func.max(key_column))).scalar_one()
if highest >= _next_value(connection, sequence):
Comment thread
Subhransu-De marked this conversation as resolved.
connection.execute(
text(f"ALTER SEQUENCE {sequence} RESTART WITH {int(highest) + 1}")
)


def _next_value(connection: Connection, sequence: str) -> int:
schema, name = connection.execute(
select(_PG_NAMESPACE.c.nspname, _PG_CLASS.c.relname)
.join_from(
_PG_CLASS, _PG_NAMESPACE, _PG_CLASS.c.relnamespace == _PG_NAMESPACE.c.oid
)
.where(_PG_CLASS.c.oid == cast(sequence, REGCLASS))
).one()
state = table(name, column("last_value"), column("is_called"), schema=schema)
last_value, is_called = connection.execute(
select(state.c.last_value, state.c.is_called)
).one()
return last_value + 1 if is_called else last_value
29 changes: 29 additions & 0 deletions seed/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import logging
import os

from sqlalchemy import create_engine, pool

from seed import seed
from seed.fixtures import FIXTURES

logger = logging.getLogger("seed")


def main() -> None:
logging.basicConfig(level=logging.INFO, format="%(message)s")
database_url = os.environ.get("DATABASE_URL")
if not database_url:
msg = "DATABASE_URL must be set to seed the database"
raise RuntimeError(msg)
engine = create_engine(database_url, poolclass=pool.NullPool)
try:
with engine.begin() as connection:
inserted = seed(connection, FIXTURES)
finally:
engine.dispose()
for table, count in inserted.items():
logger.info("%s: %d new rows", table, count)


if __name__ == "__main__":
main()
18 changes: 18 additions & 0 deletions seed/fixtures.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
from uuid import UUID

from seed import Fixtures

FIXTURES: Fixtures = {
"entities": [
{
"id": UUID("6f1c2a52-8d3b-4c1e-9a4f-0b7e5d2c1a01"),
"name": "Sample entity",
"description": "Reference data loaded by make seed.",
},
{
"id": UUID("6f1c2a52-8d3b-4c1e-9a4f-0b7e5d2c1a02"),
"name": "Second sample entity",
"description": None,
},
],
}
Loading
Loading