Skip to content

Commit 52902c8

Browse files
authored
Merge pull request #563 from nanotaboada/feat/2-implement-alembic-database-migrations
feat(migrations): implement Alembic for database migrations (#2)
2 parents 9151eee + c9a01d1 commit 52902c8

26 files changed

+962
-149
lines changed

.github/copilot-instructions.md

Lines changed: 30 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ and in-memory caching.
1111
- **Language**: Python 3.13
1212
- **Framework**: FastAPI + Uvicorn
1313
- **ORM**: SQLAlchemy 2.0 (async) + aiosqlite
14-
- **Database**: SQLite
14+
- **Database**: SQLite (local/test), PostgreSQL-compatible
15+
- **Migrations**: Alembic (async, `render_as_batch=True`)
1516
- **Validation**: Pydantic
1617
- **Caching**: aiocache (in-memory, 10-minute TTL)
1718
- **Testing**: pytest + pytest-cov + httpx
@@ -22,14 +23,15 @@ and in-memory caching.
2223

2324
```text
2425
main.py — application entry point: FastAPI setup, router registration
26+
alembic.ini — Alembic configuration (sqlalchemy.url set dynamically)
27+
alembic/ — Alembic migration environment and version scripts
2528
routes/ — HTTP route definitions + dependency injection [HTTP layer]
2629
services/ — async business logic + cache management [business layer]
2730
schemas/ — SQLAlchemy ORM models (database schema) [data layer]
28-
databases/ — async SQLAlchemy session setup
31+
databases/ — async SQLAlchemy session setup + get_database_url()
2932
models/ — Pydantic models for request/response validation
30-
storage/ — SQLite database file (players-sqlite3.db, pre-seeded)
3133
scripts/ — shell scripts for Docker (entrypoint.sh, healthcheck.sh)
32-
tools/ — standalone seed scripts (run manually, not via Alembic)
34+
tools/ — legacy standalone seed scripts (superseded by Alembic migrations)
3335
tests/ — pytest integration tests
3436
```
3537

@@ -65,12 +67,13 @@ concerns only; business logic belongs in services. Never skip a layer.
6567
- **Logging**: `logging` module only; never `print()`
6668
- **Line length**: 88; complexity ≤ 10
6769
- **Import order**: stdlib → third-party → local
68-
- **Tests**: integration tests against the real pre-seeded SQLite DB via
69-
`TestClient` — no mocking. Naming pattern
70+
- **Tests**: integration tests against the real SQLite DB (seeded via
71+
Alembic migrations) via `TestClient` — no mocking. Naming pattern
7072
`test_request_{method}_{resource}_{context}_response_{outcome}`;
71-
docstrings single-line, concise; `tests/player_stub.py` for test data;
73+
docstrings single-line, concise; `tests/player_fake.py` for test data;
7274
`tests/conftest.py` provides a `function`-scoped `client` fixture for
73-
isolation; `tests/test_main.py` excluded from Black
75+
isolation; `tests/test_main.py` excluded from Black;
76+
`tests/test_migrations.py` covers Alembic downgrade paths
7477
- **Decisions**: justify every decision on its own technical merits; never use
7578
"another project does it this way" as a reason — that explains nothing and
7679
may mean replicating a mistake
@@ -87,6 +90,9 @@ uv venv
8790
source .venv/bin/activate # Linux/macOS; use .venv\Scripts\activate on Windows
8891
uv pip install --group dev
8992

93+
# Apply migrations (required once before first run, and after down -v)
94+
uv run alembic upgrade head
95+
9096
# Run application
9197
uv run uvicorn main:app --reload --port 9000 # http://localhost:9000/docs
9298

@@ -98,6 +104,11 @@ uv run pytest --cov=./ --cov-report=term # with coverage (target >=80%
98104
uv run flake8 .
99105
uv run black --check .
100106

107+
# Migration workflow
108+
uv run alembic upgrade head # apply all pending migrations
109+
uv run alembic downgrade -1 # roll back last migration
110+
uv run alembic revision --autogenerate -m "desc" # generate migration from schema
111+
101112
# Docker
102113
docker compose up
103114
docker compose down -v
@@ -149,9 +160,10 @@ Never suggest a release tag with a coach name not on this list.
149160

150161
### Ask before changing
151162

152-
- Database schema (`schemas/player_schema.py` — no Alembic; changes require
153-
manually updating `storage/players-sqlite3.db` and the seed scripts in
154-
`tools/`)
163+
- Database schema (`schemas/player_schema.py`) and Alembic migrations
164+
(`alembic/versions/`) — schema changes require a new migration file;
165+
seed data changes require updating the relevant migration and any test
166+
fixtures that reference specific UUIDs
155167
- `models/player_model.py` design decisions — especially splitting or merging
156168
request/response models; discuss the rationale before restructuring
157169
- Dependencies (`pyproject.toml` with PEP 735 dependency groups)
@@ -164,8 +176,8 @@ Never suggest a release tag with a coach name not on this list.
164176
### Never modify
165177

166178
- `.env` files (secrets)
167-
- `storage/players-sqlite3.db` directly — schema changes go through
168-
`schemas/player_schema.py` and `tools/` seed scripts
179+
- `alembic/versions/` migration files once merged to `master` — migrations
180+
are append-only; fix forward with a new migration, never edit history
169181
- Production configurations
170182

171183
### Creating Issues
@@ -194,10 +206,11 @@ shape is new → add async service method in `services/` with error handling and
194206
rollback → add route in `routes/` with `Depends(generate_async_session)`
195207
add tests following the naming pattern → run pre-commit checks.
196208

197-
**Modify schema**: Update `schemas/player_schema.py` → manually update
198-
`storage/players-sqlite3.db` (preserve all 26 seeded players) → update
199-
`models/player_model.py` if the API shape changes → update services and tests
200-
→ run `pytest`.
209+
**Modify schema**: Update `schemas/player_schema.py` → run
210+
`uv run alembic revision --autogenerate -m "description"` to generate a
211+
migration → review and adjust the generated file in `alembic/versions/`
212+
run `uv run alembic upgrade head` → update `models/player_model.py` if the
213+
API shape changes → update services and tests → run `pytest`.
201214

202215
**After completing work**: Propose a branch name and commit message for user
203216
approval. Do not create a branch, commit, or push until the user explicitly

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ cover/
6161
local_settings.py
6262
db.sqlite3
6363
db.sqlite3-journal
64+
*.db
6465
*.db-shm
6566
*.db-wal
6667
*.db.bak.*

CHANGELOG.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,27 @@ This project uses famous football coaches as release codenames, following an A-Z
4444

4545
### Added
4646

47+
- `alembic/`: Alembic migration support for async SQLAlchemy — `env.py`
48+
configured for async execution with `render_as_batch=True` (SQLite/PostgreSQL
49+
compatible); three migrations: `001` creates the `players` table, `002` seeds
50+
11 Starting XI players, `003` seeds 15 Substitute players (all with
51+
deterministic UUID v5 values); `alembic upgrade head` applied by
52+
`entrypoint.sh` (Docker) or manually for local development (#2)
53+
- `alembic==1.18.4`, `asyncpg==0.31.0`, `gunicorn==25.3.0` added to dependencies (#2)
54+
- `gunicorn.conf.py`: Gunicorn configuration — binds to `0.0.0.0:9000`, uses
55+
`UvicornWorker`, derives worker count from `WEB_CONCURRENCY` env var; the
56+
`on_starting` hook runs `alembic upgrade head` once in the master process
57+
before any workers are forked, replacing the entrypoint-driven migration
58+
pattern (#2)
59+
- `tests/test_migrations.py`: integration tests for migration downgrade paths —
60+
verifies each step removes only its seeded rows and restores correctly; guarded
61+
with `pytestmark` skip for non-SQLite databases; assertions moved before
62+
`upgrade head` restore step for clarity (#2)
63+
- `tests/conftest.py`: session-scoped `apply_migrations` fixture runs
64+
`alembic upgrade head` once before the test session, ensuring the database
65+
exists and is at head in CI and local environments (#2)
66+
- `codecov.yaml`: excludes `alembic/env.py` from coverage (offline mode is
67+
tooling infrastructure, not application logic) (#2)
4768
- `.sonarcloud.properties`: SonarCloud Automatic Analysis configuration —
4869
sources, tests, coverage exclusions aligned with `codecov.yml` (#554)
4970
- `.dockerignore`: added `.claude/`, `CLAUDE.md`, `.coderabbit.yaml`,
@@ -53,6 +74,25 @@ This project uses famous football coaches as release codenames, following an A-Z
5374

5475
### Changed
5576

77+
- `databases/player_database.py`: extracted `get_database_url()` helper
78+
(reads `DATABASE_URL`, falls back to `STORAGE_PATH`, SQLite default);
79+
`connect_args` made conditional on SQLite dialect (#2)
80+
- `alembic/env.py`: removed duplicated DATABASE_URL construction; now calls
81+
`get_database_url()` from `databases.player_database` (#2)
82+
- `main.py`: removed `_apply_migrations` from lifespan — migrations are a
83+
one-shot step, not a per-process startup concern; lifespan now logs startup
84+
only (#2)
85+
- `Dockerfile`: removed `COPY storage/ ./hold/` and its associated comment;
86+
added `COPY alembic.ini` and `COPY alembic/` (#2)
87+
- `scripts/entrypoint.sh`: checks for an existing database file in the Docker
88+
volume (informational logging only); adds structured `log()` helper with
89+
timestamps and API/Swagger UI addresses; migrations delegated to Gunicorn
90+
`on_starting` hook (#2)
91+
- `Dockerfile`: replaced `CMD uvicorn` with `CMD gunicorn -c gunicorn.conf.py` (#2)
92+
- `compose.yaml`: replaced `STORAGE_PATH` with `DATABASE_URL` pointing to the
93+
SQLite volume path (#2)
94+
- `.gitignore`: added `*.db`; `storage/players-sqlite3.db` removed from git
95+
tracking; `storage/` directory deleted (#2)
5696
- `tests/player_stub.py` renamed to `tests/player_fake.py`; class docstring
5797
updated to reflect fake (not stub) role; module-level docstring added
5898
documenting the three-term data-state vocabulary (`existing`, `nonexistent`,

Dockerfile

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,9 @@ RUN pip install --no-cache-dir --no-index --find-links /app/wheelhouse /app/whee
5151

5252
# Copy application source code
5353
COPY main.py ./
54+
COPY gunicorn.conf.py ./
55+
COPY alembic.ini ./
56+
COPY alembic/ ./alembic/
5457
COPY databases/ ./databases/
5558
COPY models/ ./models/
5659
COPY routes/ ./routes/
@@ -61,10 +64,6 @@ COPY tools/ ./tools/
6164
# Copy entrypoint and healthcheck scripts
6265
COPY --chmod=755 scripts/entrypoint.sh ./entrypoint.sh
6366
COPY --chmod=755 scripts/healthcheck.sh ./healthcheck.sh
64-
# The 'hold' is our storage compartment within the image. Here, we copy a
65-
# pre-seeded SQLite database file, which Compose will mount as a persistent
66-
# 'storage' volume when the container starts up.
67-
COPY --chmod=755 storage/ ./hold/
6867

6968
# Add non-root user and make volume mount point writable
7069
# Avoids running the container as root (see: https://rules.sonarsource.com/docker/RSPEC-6504/)
@@ -82,4 +81,4 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
8281
CMD ["./healthcheck.sh"]
8382

8483
ENTRYPOINT ["./entrypoint.sh"]
85-
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "9000"]
84+
CMD ["gunicorn", "-c", "gunicorn.conf.py", "main:app"]

README.md

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ Proof of Concept for a RESTful Web Service built with **FastAPI** and **Python 3
2121
- 📚 **Interactive Documentation** - Auto-generated Swagger UI with VS Code and JetBrains REST Client support
2222
-**Performance Caching** - In-memory caching with aiocache and async SQLite operations
2323
-**Input Validation** - Pydantic models enforce request/response schemas with automatic error responses
24-
- 🐳 **Containerized Deployment** - Production-ready Docker setup with pre-seeded database
24+
- 🐳 **Containerized Deployment** - Production-ready Docker setup with migration-based database initialization
2525
- 🔄 **Automated Pipeline** - Continuous integration with Black, Flake8, and automated testing
2626

2727
## Tech Stack
@@ -171,6 +171,10 @@ uv pip install --group dev
171171
### Run
172172

173173
```bash
174+
# Apply database migrations (required once before the first run, and after
175+
# docker compose down -v)
176+
uv run alembic upgrade head
177+
174178
uv run uvicorn main:app --reload --port 9000
175179
```
176180

@@ -190,7 +194,7 @@ Once the application is running, you can access:
190194
docker compose up
191195
```
192196

193-
> 💡 **Note:** On first run, the container copies a pre-seeded SQLite database into a persistent volume. On subsequent runs, that volume is reused and the data is preserved.
197+
> 💡 **Note:** On first run, the entrypoint applies Alembic migrations (`alembic upgrade head`), which creates the database and seeds all 26 players. On subsequent runs, migrations are a no-op and the volume data is preserved.
194198
195199
### Stop
196200

@@ -200,7 +204,7 @@ docker compose down
200204

201205
### Reset Database
202206

203-
To remove the volume and reinitialize the database from the built-in seed file:
207+
To remove the volume and re-apply migrations from scratch on next start:
204208

205209
```bash
206210
docker compose down -v
@@ -224,8 +228,14 @@ docker pull ghcr.io/nanotaboada/python-samples-fastapi-restful:latest
224228
## Environment Variables
225229

226230
```bash
227-
# Database storage path (default: ./storage/players-sqlite3.db)
228-
STORAGE_PATH=./storage/players-sqlite3.db
231+
# Full async database URL (SQLite default, PostgreSQL compatible)
232+
# SQLite (local/test):
233+
DATABASE_URL=sqlite+aiosqlite:///./players-sqlite3.db
234+
# PostgreSQL (Docker/production):
235+
DATABASE_URL=postgresql+asyncpg://postgres:password@localhost:5432/playersdb
236+
237+
# Legacy: SQLite file path — used only when DATABASE_URL is not set
238+
STORAGE_PATH=./players-sqlite3.db
229239

230240
# Python output buffering: set to 1 for real-time logs in Docker
231241
PYTHONUNBUFFERED=1

alembic.ini

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
# A generic, single database configuration.
2+
3+
[alembic]
4+
# path to migration scripts
5+
script_location = alembic
6+
7+
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
8+
# Uncomment the line below if you want the files to be prepended with date and time
9+
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
10+
11+
# sys.path path, will be prepended to sys.path if present.
12+
# defaults to the current working directory.
13+
prepend_sys_path = .
14+
15+
# timezone to use when rendering the date within the migration file
16+
# as well as the filename.
17+
# If specified, requires the python>=3.9 or backports.zoneinfo library.
18+
# Any required deps can installed via pip install.[tz]
19+
# timezone =
20+
21+
# max length of characters to apply to the "slug" field
22+
# truncate_slug_length = 40
23+
24+
# set to 'true' to run the environment during
25+
# the 'revision' command, regardless of autogenerate
26+
# revision_environment = false
27+
28+
# set to 'true' to allow .pyc and .pyo files without
29+
# a source .py file to be detected as revisions in the
30+
# versions/ directory
31+
# sourceless = false
32+
33+
# version location specification; This defaults
34+
# to alembic/versions. When using multiple version
35+
# directories, initial revisions must be specified with --version-path.
36+
# The path separator used here should be the separator specified by "version_path_separator" below.
37+
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
38+
39+
# version path separator; As mentioned above, this is the character used to split
40+
# version_locations. The default within new alembic.ini files is "os", which uses
41+
# os.pathsep. Note that this character also holds a place in the string formatting,
42+
# so if you use a character that is a % placeholder, you need to escape it (%%):
43+
# version_path_separator = :
44+
# version_path_separator = ;
45+
version_path_separator = os
46+
path_separator = os
47+
48+
# set to 'true' to search source files recursively
49+
# in each "version_locations" directory
50+
# new in Alembic version 1.10
51+
# recursive_version_locations = false
52+
53+
# the output encoding used when revision files
54+
# are written from script.py.mako
55+
# output_encoding = utf-8
56+
57+
# sqlalchemy.url is set dynamically in env.py from the DATABASE_URL environment
58+
# variable, so this placeholder is intentionally left empty.
59+
sqlalchemy.url =
60+
61+
62+
[post_write_hooks]
63+
# post_write_hooks defines scripts or Python functions that are run
64+
# on newly generated revision scripts. See the documentation for further
65+
# detail and examples
66+
67+
# format using "black" - use the console_scripts runner, against the "black" entrypoint
68+
# hooks = black
69+
# black.type = console_scripts
70+
# black.entrypoint = black
71+
# black.options = -l 79 REVISION_SCRIPT_FILENAME
72+
73+
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
74+
# hooks = ruff
75+
# ruff.type = exec
76+
# ruff.executable = %(here)s/.venv/bin/ruff
77+
# ruff.options = --fix REVISION_SCRIPT_FILENAME
78+
79+
# Logging configuration
80+
[loggers]
81+
keys = root,sqlalchemy,alembic
82+
83+
[handlers]
84+
keys = console
85+
86+
[formatters]
87+
keys = generic
88+
89+
[logger_root]
90+
level = WARN
91+
handlers = console
92+
qualname =
93+
94+
[logger_sqlalchemy]
95+
level = WARN
96+
handlers =
97+
qualname = sqlalchemy.engine
98+
99+
[logger_alembic]
100+
level = INFO
101+
handlers =
102+
qualname = alembic
103+
104+
[handler_console]
105+
class = StreamHandler
106+
args = (sys.stderr,)
107+
level = NOTSET
108+
formatter = generic
109+
110+
[formatter_generic]
111+
format = %(levelname)-5.5s [%(name)s] %(message)s
112+
datefmt = %H:%M:%S

alembic/README

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Generic single-database configuration with an async SQLAlchemy backend.

0 commit comments

Comments
 (0)