Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
47 changes: 30 additions & 17 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ and in-memory caching.
- **Language**: Python 3.13
- **Framework**: FastAPI + Uvicorn
- **ORM**: SQLAlchemy 2.0 (async) + aiosqlite
- **Database**: SQLite
- **Database**: SQLite (local/test), PostgreSQL-compatible
- **Migrations**: Alembic (async, `render_as_batch=True`)
- **Validation**: Pydantic
- **Caching**: aiocache (in-memory, 10-minute TTL)
- **Testing**: pytest + pytest-cov + httpx
Expand All @@ -22,14 +23,15 @@ and in-memory caching.

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

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

# Apply migrations (required once before first run, and after down -v)
uv run alembic upgrade head

# Run application
uv run uvicorn main:app --reload --port 9000 # http://localhost:9000/docs

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

# Migration workflow
uv run alembic upgrade head # apply all pending migrations
uv run alembic downgrade -1 # roll back last migration
uv run alembic revision --autogenerate -m "desc" # generate migration from schema

# Docker
docker compose up
docker compose down -v
Expand Down Expand Up @@ -149,9 +160,10 @@ Never suggest a release tag with a coach name not on this list.

### Ask before changing

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

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

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

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

**After completing work**: Propose a branch name and commit message for user
approval. Do not create a branch, commit, or push until the user explicitly
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ cover/
local_settings.py
db.sqlite3
db.sqlite3-journal
*.db
*.db-shm
*.db-wal
*.db.bak.*
Expand Down
38 changes: 38 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,25 @@ This project uses famous football coaches as release codenames, following an A-Z

### Added

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

### Changed

- `databases/player_database.py`: extracted `get_database_url()` helper
(reads `DATABASE_URL`, falls back to `STORAGE_PATH`, SQLite default);
`connect_args` made conditional on SQLite dialect (#2)
- `alembic/env.py`: removed duplicated DATABASE_URL construction; now calls
`get_database_url()` from `databases.player_database` (#2)
- `main.py`: removed `_apply_migrations` from lifespan — migrations are a
one-shot step, not a per-process startup concern; lifespan now logs startup
only (#2)
- `Dockerfile`: removed `COPY storage/ ./hold/` and its associated comment;
added `COPY alembic.ini` and `COPY alembic/` (#2)
- `scripts/entrypoint.sh`: checks for an existing database file in the Docker
volume (informational logging only); adds structured `log()` helper with
timestamps and API/Swagger UI addresses; migrations delegated to Gunicorn
`on_starting` hook (#2)
- `Dockerfile`: replaced `CMD uvicorn` with `CMD gunicorn -c gunicorn.conf.py` (#2)
- `compose.yaml`: replaced `STORAGE_PATH` with `DATABASE_URL` pointing to the
SQLite volume path (#2)
- `.gitignore`: added `*.db`; `storage/players-sqlite3.db` removed from git
tracking; `storage/` directory deleted (#2)
- `tests/player_stub.py` renamed to `tests/player_fake.py`; class docstring
updated to reflect fake (not stub) role; module-level docstring added
documenting the three-term data-state vocabulary (`existing`, `nonexistent`,
Expand Down
9 changes: 4 additions & 5 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ RUN pip install --no-cache-dir --no-index --find-links /app/wheelhouse /app/whee

# Copy application source code
COPY main.py ./
COPY gunicorn.conf.py ./
COPY alembic.ini ./
COPY alembic/ ./alembic/
COPY databases/ ./databases/
COPY models/ ./models/
COPY routes/ ./routes/
Expand All @@ -61,10 +64,6 @@ COPY tools/ ./tools/
# Copy entrypoint and healthcheck scripts
COPY --chmod=755 scripts/entrypoint.sh ./entrypoint.sh
COPY --chmod=755 scripts/healthcheck.sh ./healthcheck.sh
# The 'hold' is our storage compartment within the image. Here, we copy a
# pre-seeded SQLite database file, which Compose will mount as a persistent
# 'storage' volume when the container starts up.
COPY --chmod=755 storage/ ./hold/

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

ENTRYPOINT ["./entrypoint.sh"]
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "9000"]
CMD ["gunicorn", "-c", "gunicorn.conf.py", "main:app"]
20 changes: 15 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ Proof of Concept for a RESTful Web Service built with **FastAPI** and **Python 3
- 📚 **Interactive Documentation** - Auto-generated Swagger UI with VS Code and JetBrains REST Client support
- ⚡ **Performance Caching** - In-memory caching with aiocache and async SQLite operations
- ✅ **Input Validation** - Pydantic models enforce request/response schemas with automatic error responses
- 🐳 **Containerized Deployment** - Production-ready Docker setup with pre-seeded database
- 🐳 **Containerized Deployment** - Production-ready Docker setup with migration-based database initialization
- 🔄 **Automated Pipeline** - Continuous integration with Black, Flake8, and automated testing

## Tech Stack
Expand Down Expand Up @@ -171,6 +171,10 @@ uv pip install --group dev
### Run

```bash
# Apply database migrations (required once before the first run, and after
# docker compose down -v)
uv run alembic upgrade head

uv run uvicorn main:app --reload --port 9000
```

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

> 💡 **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.
> 💡 **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.

### Stop

Expand All @@ -200,7 +204,7 @@ docker compose down

### Reset Database

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

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

```bash
# Database storage path (default: ./storage/players-sqlite3.db)
STORAGE_PATH=./storage/players-sqlite3.db
# Full async database URL (SQLite default, PostgreSQL compatible)
# SQLite (local/test):
DATABASE_URL=sqlite+aiosqlite:///./players-sqlite3.db
# PostgreSQL (Docker/production):
DATABASE_URL=postgresql+asyncpg://postgres:password@localhost:5432/playersdb

# Legacy: SQLite file path — used only when DATABASE_URL is not set
STORAGE_PATH=./players-sqlite3.db

# Python output buffering: set to 1 for real-time logs in Docker
PYTHONUNBUFFERED=1
Expand Down
112 changes: 112 additions & 0 deletions alembic.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
# A generic, single database configuration.

[alembic]
# path to migration scripts
script_location = alembic

# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s

# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory.
prepend_sys_path = .

# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python>=3.9 or backports.zoneinfo library.
# Any required deps can installed via pip install.[tz]
# timezone =

# max length of characters to apply to the "slug" field
# truncate_slug_length = 40

# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false

# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false

# version location specification; This defaults
# to alembic/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "version_path_separator" below.
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions

# version path separator; As mentioned above, this is the character used to split
# version_locations. The default within new alembic.ini files is "os", which uses
# os.pathsep. Note that this character also holds a place in the string formatting,
# so if you use a character that is a % placeholder, you need to escape it (%%):
# version_path_separator = :
# version_path_separator = ;
version_path_separator = os
path_separator = os

# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false

# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8

# sqlalchemy.url is set dynamically in env.py from the DATABASE_URL environment
# variable, so this placeholder is intentionally left empty.
sqlalchemy.url =


[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples

# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME

# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
# hooks = ruff
# ruff.type = exec
# ruff.executable = %(here)s/.venv/bin/ruff
# ruff.options = --fix REVISION_SCRIPT_FILENAME

# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic

[handlers]
keys = console

[formatters]
keys = generic

[logger_root]
level = WARN
handlers = console
qualname =

[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine

[logger_alembic]
level = INFO
handlers =
qualname = alembic

[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic

[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
1 change: 1 addition & 0 deletions alembic/README
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Generic single-database configuration with an async SQLAlchemy backend.
Loading
Loading