diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index d548408..f68ebbc 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -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 @@ -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 ``` @@ -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 @@ -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 @@ -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 @@ -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) @@ -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 @@ -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 diff --git a/.gitignore b/.gitignore index aa7b685..8785b06 100644 --- a/.gitignore +++ b/.gitignore @@ -61,6 +61,7 @@ cover/ local_settings.py db.sqlite3 db.sqlite3-journal +*.db *.db-shm *.db-wal *.db.bak.* diff --git a/CHANGELOG.md b/CHANGELOG.md index 6570699..f6db25c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,27 @@ 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; guarded + with `pytestmark` skip for non-SQLite databases; assertions moved before + `upgrade head` restore step for clarity (#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`, @@ -53,6 +74,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`, diff --git a/Dockerfile b/Dockerfile index 13c2e61..e845e89 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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/ @@ -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/) @@ -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"] diff --git a/README.md b/README.md index e5d1ebb..0b19c7f 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 ``` @@ -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 @@ -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 @@ -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 diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..e3c5f1c --- /dev/null +++ b/alembic.ini @@ -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 diff --git a/alembic/README b/alembic/README new file mode 100644 index 0000000..b3746c6 --- /dev/null +++ b/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration with an async SQLAlchemy backend. diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..40232c0 --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,78 @@ +""" +Alembic environment configuration for async SQLAlchemy. + +Reads DATABASE_URL from the environment to support both SQLite (local dev/test) +and PostgreSQL (Docker/production). Uses render_as_batch=True for SQLite ALTER +TABLE compatibility (harmless on PostgreSQL). +""" + +import asyncio +from logging.config import fileConfig + +from sqlalchemy import pool +from sqlalchemy.engine import Connection +from sqlalchemy.ext.asyncio import async_engine_from_config + +from alembic import context + +from databases.player_database import Base, get_database_url +from schemas.player_schema import Player # noqa: F401 — registers ORM model with Base + +# Supports both SQLite (local) and PostgreSQL (Docker, see #542): +# sqlite+aiosqlite:///./players-sqlite3.db +# postgresql+asyncpg://postgres:password@postgres:5432/playersdb +database_url = get_database_url() + +config = context.config +config.set_main_option("sqlalchemy.url", database_url) + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode (no DB connection, generates SQL).""" + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + render_as_batch=True, + ) + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection: Connection) -> None: + context.configure( + connection=connection, + target_metadata=target_metadata, + render_as_batch=True, + ) + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations() -> None: + connectable = async_engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + await connectable.dispose() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode (against a live DB connection).""" + asyncio.run(run_async_migrations()) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..5866ecf --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/alembic/versions/001_create_players_table.py b/alembic/versions/001_create_players_table.py new file mode 100644 index 0000000..afe5c83 --- /dev/null +++ b/alembic/versions/001_create_players_table.py @@ -0,0 +1,40 @@ +"""Create players table + +Revision ID: 001 +Revises: +Create Date: 2026-04-09 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "001" +down_revision: Union[str, Sequence[str], None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "players", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("firstName", sa.String(), nullable=False), + sa.Column("middleName", sa.String(), nullable=True), + sa.Column("lastName", sa.String(), nullable=False), + sa.Column("dateOfBirth", sa.String(), nullable=True), + sa.Column("squadNumber", sa.Integer(), nullable=False), + sa.Column("position", sa.String(), nullable=False), + sa.Column("abbrPosition", sa.String(), nullable=True), + sa.Column("team", sa.String(), nullable=True), + sa.Column("league", sa.String(), nullable=True), + sa.Column("starting11", sa.Boolean(), nullable=True), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("squadNumber"), + ) + + +def downgrade() -> None: + op.drop_table("players") diff --git a/alembic/versions/002_seed_starting11.py b/alembic/versions/002_seed_starting11.py new file mode 100644 index 0000000..eaca96f --- /dev/null +++ b/alembic/versions/002_seed_starting11.py @@ -0,0 +1,85 @@ +"""Seed Starting XI players + +Seeds the 11 Starting XI players from the 2022 FIFA World Cup squad of the +Argentina national football team. Uses deterministic UUID v5 values so that +IDs are stable across environments. + +Revision ID: 002 +Revises: 001 +Create Date: 2026-04-09 + +""" + +from typing import Sequence, Union + +from alembic import op + +revision: str = "002" +down_revision: Union[str, Sequence[str], None] = "001" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +# UUID v5 (deterministic) values — stable across environments. +# See schemas/player_schema.py for the rationale. +_STARTING_ELEVEN_SQL = """ +INSERT INTO players + (id, firstName, middleName, lastName, dateOfBirth, + squadNumber, position, abbrPosition, team, league, starting11) +VALUES + ('01772c59-43f0-5d85-b913-c78e4e281452', 'Damián', 'Emiliano', 'Martínez', + '1992-09-02T00:00:00.000Z', 23, 'Goalkeeper', 'GK', 'Aston Villa FC', + 'Premier League', 1), + ('da31293b-4c7e-5e0f-a168-469ee29ecbc4', 'Nahuel', NULL, 'Molina', + '1998-04-06T00:00:00.000Z', 26, 'Right-Back', 'RB', 'Atlético Madrid', + 'La Liga', 1), + ('c096c69e-762b-5281-9290-bb9c167a24a0', 'Cristian', 'Gabriel', 'Romero', + '1998-04-27T00:00:00.000Z', 13, 'Centre-Back', 'CB', 'Tottenham Hotspur', + 'Premier League', 1), + ('d5f7dd7a-1dcb-5960-ba27-e34865b63358', 'Nicolás', 'Hernán Gonzalo', 'Otamendi', + '1988-02-12T00:00:00.000Z', 19, 'Centre-Back', 'CB', 'SL Benfica', + 'Liga Portugal', 1), + ('2f6f90a0-9b9d-5023-96d2-a2aaf03143a6', 'Nicolás', 'Alejandro', 'Tagliafico', + '1992-08-31T00:00:00.000Z', 3, 'Left-Back', 'LB', 'Olympique Lyon', + 'Ligue 1', 1), + ('b5b46e79-929e-5ed2-949d-0d167109c022', 'Ángel', 'Fabián', 'Di María', + '1988-02-14T00:00:00.000Z', 11, 'Right Winger', 'RW', 'SL Benfica', + 'Liga Portugal', 1), + ('0293b282-1da8-562e-998e-83849b417a42', 'Rodrigo', 'Javier', 'de Paul', + '1994-05-24T00:00:00.000Z', 7, 'Central Midfield', 'CM', 'Atlético Madrid', + 'La Liga', 1), + ('d3ba552a-dac3-588a-b961-1ea7224017fd', 'Enzo', 'Jeremías', 'Fernández', + '2001-01-17T00:00:00.000Z', 24, 'Central Midfield', 'CM', 'SL Benfica', + 'Liga Portugal', 1), + ('9613cae9-16ab-5b54-937e-3135123b9e0d', 'Alexis', NULL, 'Mac Allister', + '1998-12-24T00:00:00.000Z', 20, 'Central Midfield', 'CM', + 'Brighton & Hove Albion', 'Premier League', 1), + ('acc433bf-d505-51fe-831e-45eb44c4d43c', 'Lionel', 'Andrés', 'Messi', + '1987-06-24T00:00:00.000Z', 10, 'Right Winger', 'RW', 'Paris Saint-Germain', + 'Ligue 1', 1), + ('38bae91d-8519-55a2-b30a-b9fe38849bfb', 'Julián', NULL, 'Álvarez', + '2000-01-31T00:00:00.000Z', 9, 'Centre-Forward', 'CF', 'Manchester City', + 'Premier League', 1) +""" + +_SEEDED_IDS = ( + "'01772c59-43f0-5d85-b913-c78e4e281452'", + "'da31293b-4c7e-5e0f-a168-469ee29ecbc4'", + "'c096c69e-762b-5281-9290-bb9c167a24a0'", + "'d5f7dd7a-1dcb-5960-ba27-e34865b63358'", + "'2f6f90a0-9b9d-5023-96d2-a2aaf03143a6'", + "'b5b46e79-929e-5ed2-949d-0d167109c022'", + "'0293b282-1da8-562e-998e-83849b417a42'", + "'d3ba552a-dac3-588a-b961-1ea7224017fd'", + "'9613cae9-16ab-5b54-937e-3135123b9e0d'", + "'acc433bf-d505-51fe-831e-45eb44c4d43c'", + "'38bae91d-8519-55a2-b30a-b9fe38849bfb'", +) + + +def upgrade() -> None: + op.execute(_STARTING_ELEVEN_SQL) + + +def downgrade() -> None: + ids = ", ".join(_SEEDED_IDS) + op.execute(f"DELETE FROM players WHERE id IN ({ids})") diff --git a/alembic/versions/003_seed_substitutes.py b/alembic/versions/003_seed_substitutes.py new file mode 100644 index 0000000..6486fec --- /dev/null +++ b/alembic/versions/003_seed_substitutes.py @@ -0,0 +1,104 @@ +"""Seed Substitute players + +Seeds the 15 Substitute players from the 2022 FIFA World Cup squad of the +Argentina national football team. Uses deterministic UUID v5 values so that +IDs are stable across environments. + +Rolling back this migration removes only substitutes, leaving Starting XI +(migration 002) intact. + +Revision ID: 003 +Revises: 002 +Create Date: 2026-04-09 + +""" + +from typing import Sequence, Union + +from alembic import op + +revision: str = "003" +down_revision: Union[str, Sequence[str], None] = "002" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +# UUID v5 (deterministic) values — stable across environments. +# See schemas/player_schema.py for the rationale. +_SUBSTITUTES_SQL = """ +INSERT INTO players + (id, firstName, middleName, lastName, dateOfBirth, + squadNumber, position, abbrPosition, team, league, starting11) +VALUES + ('5a9cd988-95e6-54c1-bc34-9aa08acca8d0', 'Franco', 'Daniel', 'Armani', + '1986-10-16T00:00:00.000Z', 1, 'Goalkeeper', 'GK', 'River Plate', + 'Copa de la Liga', 0), + ('c62f2ac1-41e8-5d34-b073-2ba0913d0e31', 'Gerónimo', NULL, 'Rulli', + '1992-05-20T00:00:00.000Z', 12, 'Goalkeeper', 'GK', 'Ajax Amsterdam', + 'Eredivisie', 0), + ('5fdb10e8-38c0-5084-9a3f-b369a960b9c2', 'Juan', 'Marcos', 'Foyth', + '1998-01-12T00:00:00.000Z', 2, 'Right-Back', 'RB', 'Villarreal', + 'La Liga', 0), + ('bbd441f7-fcfb-5834-8468-2a9004b64c8c', 'Gonzalo', 'Ariel', 'Montiel', + '1997-01-01T00:00:00.000Z', 4, 'Right-Back', 'RB', 'Nottingham Forest', + 'Premier League', 0), + ('d8bfea25-f189-5d5e-b3a5-ed89329b9f7c', 'Germán', 'Alejo', 'Pezzella', + '1991-06-27T00:00:00.000Z', 6, 'Centre-Back', 'CB', 'Real Betis Balompié', + 'La Liga', 0), + ('dca343a8-12e5-53d6-89a8-916b120a5ee4', 'Marcos', 'Javier', 'Acuña', + '1991-10-28T00:00:00.000Z', 8, 'Left-Back', 'LB', 'Sevilla FC', + 'La Liga', 0), + ('98306555-a466-5d18-804e-dc82175e697b', 'Lisandro', NULL, 'Martínez', + '1998-01-18T00:00:00.000Z', 25, 'Centre-Back', 'CB', 'Manchester United', + 'Premier League', 0), + ('9d140400-196f-55d8-86e1-e0b96a375c83', 'Leandro', 'Daniel', 'Paredes', + '1994-06-29T00:00:00.000Z', 5, 'Defensive Midfield', 'DM', 'AS Roma', + 'Serie A', 0), + ('d3b0e8e8-2c34-531a-b608-b24fed0ef986', 'Exequiel', 'Alejandro', 'Palacios', + '1998-10-05T00:00:00.000Z', 14, 'Central Midfield', 'CM', + 'Bayer 04 Leverkusen', 'Bundesliga', 0), + ('7cc8d527-56a2-58bd-9528-2618fc139d30', 'Alejandro', 'Darío', 'Gómez', + '1988-02-15T00:00:00.000Z', 17, 'Left Winger', 'LW', 'AC Monza', + 'Serie A', 0), + ('191c82af-0c51-526a-b903-c3600b61b506', 'Guido', NULL, 'Rodríguez', + '1994-04-12T00:00:00.000Z', 18, 'Defensive Midfield', 'DM', + 'Real Betis Balompié', 'La Liga', 0), + ('b1306b7b-a3a4-5f7c-90fd-dd5bdbed57ba', 'Ángel', 'Martín', 'Correa', + '1995-03-09T00:00:00.000Z', 15, 'Right Winger', 'RW', 'Atlético Madrid', + 'La Liga', 0), + ('ecec27e8-487b-5622-b116-0855020477ed', 'Thiago', 'Ezequiel', 'Almada', + '2001-04-26T00:00:00.000Z', 16, 'Attacking Midfield', 'AM', + 'Atlanta United FC', 'Major League Soccer', 0), + ('7941cd7c-4df1-5952-97e8-1e7f5d08e8aa', 'Paulo', 'Exequiel', 'Dybala', + '1993-11-15T00:00:00.000Z', 21, 'Second Striker', 'SS', 'AS Roma', + 'Serie A', 0), + ('79c96f29-c59f-5f98-96b8-3a5946246624', 'Lautaro', 'Javier', 'Martínez', + '1997-08-22T00:00:00.000Z', 22, 'Centre-Forward', 'CF', 'Inter Milan', + 'Serie A', 0) +""" + +_SEEDED_IDS = ( + "'5a9cd988-95e6-54c1-bc34-9aa08acca8d0'", + "'c62f2ac1-41e8-5d34-b073-2ba0913d0e31'", + "'5fdb10e8-38c0-5084-9a3f-b369a960b9c2'", + "'bbd441f7-fcfb-5834-8468-2a9004b64c8c'", + "'d8bfea25-f189-5d5e-b3a5-ed89329b9f7c'", + "'dca343a8-12e5-53d6-89a8-916b120a5ee4'", + "'98306555-a466-5d18-804e-dc82175e697b'", + "'9d140400-196f-55d8-86e1-e0b96a375c83'", + "'d3b0e8e8-2c34-531a-b608-b24fed0ef986'", + "'7cc8d527-56a2-58bd-9528-2618fc139d30'", + "'191c82af-0c51-526a-b903-c3600b61b506'", + "'b1306b7b-a3a4-5f7c-90fd-dd5bdbed57ba'", + "'ecec27e8-487b-5622-b116-0855020477ed'", + "'7941cd7c-4df1-5952-97e8-1e7f5d08e8aa'", + "'79c96f29-c59f-5f98-96b8-3a5946246624'", +) + + +def upgrade() -> None: + op.execute(_SUBSTITUTES_SQL) + + +def downgrade() -> None: + ids = ", ".join(_SEEDED_IDS) + op.execute(f"DELETE FROM players WHERE id IN ({ids})") diff --git a/codecov.yaml b/codecov.yaml new file mode 100644 index 0000000..722eb96 --- /dev/null +++ b/codecov.yaml @@ -0,0 +1,11 @@ +coverage: + status: + patch: + default: + target: 80% + project: + default: + target: 80% + +ignore: + - "alembic/env.py" diff --git a/compose.yaml b/compose.yaml index 19dde9b..5655539 100644 --- a/compose.yaml +++ b/compose.yaml @@ -12,6 +12,7 @@ services: environment: - PYTHONUNBUFFERED=1 - STORAGE_PATH=/storage/players-sqlite3.db + - DATABASE_URL=sqlite+aiosqlite:////storage/players-sqlite3.db restart: unless-stopped volumes: diff --git a/databases/player_database.py b/databases/player_database.py index 9a5fd5d..9a976c9 100644 --- a/databases/player_database.py +++ b/databases/player_database.py @@ -1,12 +1,16 @@ """ -Database setup and session management for async SQLAlchemy with SQLite. +Database setup and session management for async SQLAlchemy. -- Configures the async database engine using `aiosqlite` driver. +- Configures the async database engine from the DATABASE_URL environment + variable (SQLite default, PostgreSQL compatible). - Creates an async sessionmaker for ORM operations. - Defines the declarative base class for model definitions. - Provides an async generator dependency to yield database sessions. -The `STORAGE_PATH` environment variable controls the SQLite file location. +Environment variables: + DATABASE_URL: Full async database URL. Defaults to SQLite: + sqlite+aiosqlite:///./players-sqlite3.db + STORAGE_PATH: (legacy) SQLite file path. Ignored when DATABASE_URL is set. """ import logging @@ -15,15 +19,30 @@ from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession from sqlalchemy.orm import sessionmaker, declarative_base -storage_path = os.getenv("STORAGE_PATH", "./storage/players-sqlite3.db") -DATABASE_URL = f"sqlite+aiosqlite:///{storage_path}" + +def get_database_url() -> str: + """Return the async database URL from environment variables. + + Reads DATABASE_URL first; if unset, constructs a SQLite URL from + STORAGE_PATH (defaulting to ./players-sqlite3.db). + """ + database_url = os.getenv("DATABASE_URL") + if not database_url: + storage_path = os.getenv("STORAGE_PATH", "./players-sqlite3.db") + database_url = f"sqlite+aiosqlite:///{storage_path}" + return database_url + + +DATABASE_URL: str = get_database_url() + +_connect_args = ( + {"check_same_thread": False} if DATABASE_URL.startswith("sqlite") else {} +) logger = logging.getLogger("uvicorn") logging.getLogger("sqlalchemy.engine.Engine").handlers = logger.handlers -async_engine = create_async_engine( - DATABASE_URL, connect_args={"check_same_thread": False}, echo=True -) +async_engine = create_async_engine(DATABASE_URL, connect_args=_connect_args, echo=True) async_sessionmaker = sessionmaker( bind=async_engine, class_=AsyncSession, autocommit=False, autoflush=False diff --git a/docs/adr/0002-no-alembic-manual-seed-scripts.md b/docs/adr/0002-no-alembic-manual-seed-scripts.md index 464eca3..f34a934 100644 --- a/docs/adr/0002-no-alembic-manual-seed-scripts.md +++ b/docs/adr/0002-no-alembic-manual-seed-scripts.md @@ -4,8 +4,8 @@ Date: 2026-03-21 ## Status -Accepted. Migration to Alembic is under consideration — tracked in -issue #2. +Superseded by [ADR-0010](0010-alembic-migration-based-schema-management.md). +Alembic was adopted in issue #2. ## Context diff --git a/docs/adr/0009-docker-and-compose-strategy.md b/docs/adr/0009-docker-and-compose-strategy.md index e9a78e7..018d93e 100644 --- a/docs/adr/0009-docker-and-compose-strategy.md +++ b/docs/adr/0009-docker-and-compose-strategy.md @@ -49,14 +49,13 @@ locally. (for the health check); copies the pre-built wheels from the builder; installs them with `--no-index --find-links` (no network access, no build tools required); removes the wheelhouse after installation. -- **Entrypoint script**: on first start, copies the pre-seeded database - from the image's read-only `hold/` directory to the writable named - volume at `/storage/`, then runs both seed scripts to ensure the schema - and data are up to date. On subsequent starts, the volume file is - preserved and seed scripts run again (they are idempotent). +- **Entrypoint script**: runs `alembic upgrade head` before launching + Uvicorn. Alembic creates the database and seeds all 26 players on first + start; on subsequent starts it is a no-op (already at head). No + pre-seeded file is bundled in the image. - **Compose (`compose.yaml`)**: defines a single service with port mapping (`9000`), a named volume (`storage`), and environment variables - (`STORAGE_PATH`, `PYTHONUNBUFFERED=1`). Health checks are declared in + (`DATABASE_URL`, `PYTHONUNBUFFERED=1`). Health checks are declared in the Dockerfile (`GET /health`); Compose relies on that declaration. - A non-root `fastapi` user is created in the runtime stage following the principle of least privilege. @@ -81,10 +80,11 @@ locally. - The wheelhouse is an intermediate artifact: if a wheel cannot be pre-built (e.g. binary-only distributions without a source distribution), the builder stage will fail. -- The seed scripts run on every container start. They are idempotent but - add latency to startup and must remain so as the project evolves. -- The SQLite database file is versioned and bundled, meaning schema changes - require a Docker image rebuild. +- `alembic upgrade head` runs on every container start. It is a no-op + when no migrations are pending but adds a small DB round-trip to startup + time. +- Schema changes now require an Alembic migration rather than a Docker + image rebuild; see ADR-0010. **When to revisit:** diff --git a/docs/adr/0010-alembic-migration-based-schema-management.md b/docs/adr/0010-alembic-migration-based-schema-management.md new file mode 100644 index 0000000..a5159c6 --- /dev/null +++ b/docs/adr/0010-alembic-migration-based-schema-management.md @@ -0,0 +1,83 @@ +# ADR-0010: Alembic — Migration-Based Schema Management + +Date: 2026-04-09 + +## Status + +Accepted. Supersedes [ADR-0002](0002-no-alembic-manual-seed-scripts.md). + +## Context + +ADR-0002 decided against Alembic in favour of standalone seed scripts in +`tools/`, citing low schema churn and the simplicity of a single committed +SQLite file. Issue #2 was opened to revisit this decision when the project +matured. + +Several factors made the status quo unsustainable: + +- Committing a binary `.db` file to the repository means every schema + change produces an opaque, unreviewable diff. Reviewers cannot tell what + data changed or why. +- The Docker entrypoint copied a pre-seeded file from the image into the + container volume — coupling the data to the image build and requiring a + full rebuild whenever the dataset changed. +- The project targets future PostgreSQL support (issue #542), which a + SQLite-only committed file cannot accommodate. +- As a cross-language educational reference, the project should demonstrate + production-grade database lifecycle practices, and Alembic is the standard + tool for SQLAlchemy projects. + +## Alternatives Considered + +- **Keep the committed `.db` file + seed scripts**: Already rejected in + issue #2; the coupling to the binary file blocks PostgreSQL support and + produces unreadable diffs. +- **Prisma Client Python**: Requires Node.js alongside Python, uses its own + schema DSL instead of SQLAlchemy models, and has less mature Python + support. Rejected because it would introduce a second source of truth for + the schema. + +## Decision + +We will use Alembic as the schema and seed data migration tool. + +Three migration files replace the committed database and the standalone +seed scripts: + +- `001_create_players_table.py` — autogenerated from the SQLAlchemy `Player` + schema; creates the `players` table. +- `002_seed_starting11.py` — inserts the 11 Starting XI players using + deterministic UUID v5 values. +- `003_seed_substitutes.py` — inserts the 15 Substitute players using + deterministic UUID v5 values. + +`alembic/env.py` is configured for async SQLAlchemy (`asyncio.run` via +thread executor), reads `DATABASE_URL` from the environment (SQLite default, +PostgreSQL compatible), and sets `render_as_batch=True` for SQLite `ALTER +TABLE` compatibility. Each migration's `downgrade()` function deletes only +the rows it inserted (by UUID), so migrations are independently reversible. + +`alembic upgrade head` is run by `entrypoint.sh` before Uvicorn starts +(Docker). For local development, it is run once manually: +`uv run alembic upgrade head`. + +## Consequences + +**Positive:** +- Schema and seed data changes are versioned, reviewable, and reversible. +- The committed `.db` file is removed from the repository; `*.db` is + added to `.gitignore`. +- `DATABASE_URL` environment variable enables the same codebase to target + both SQLite (local/test) and PostgreSQL (Docker/production) without code + changes — a prerequisite for issue #542. +- UUID v5 seed values are deterministic across environments, so test + fixtures that reference player IDs remain stable. + +**Negative:** +- Local development requires `uv run alembic upgrade head` before the first + server start (or after a `docker compose down -v`). +- Alembic's offline mode (`alembic --sql`) is not exercised by integration + tests; `alembic/env.py` offline path is excluded from coverage. +- `render_as_batch=True` means SQLite schema changes use a copy-transform- + replace strategy rather than `ALTER TABLE` — this is invisible to callers + but must be understood when debugging migration failures. diff --git a/gunicorn.conf.py b/gunicorn.conf.py new file mode 100644 index 0000000..13f6e04 --- /dev/null +++ b/gunicorn.conf.py @@ -0,0 +1,25 @@ +""" +Gunicorn configuration for production deployment. + +Uses UvicornWorker to run the FastAPI ASGI app. The on_starting hook runs +Alembic migrations once in the master process before any workers are forked, +ensuring a single, race-free initialization step. +""" + +import multiprocessing +import os +from pathlib import Path +from typing import Any + +from alembic import command +from alembic.config import Config + +bind: str = "0.0.0.0:9000" +workers: int = int(os.getenv("WEB_CONCURRENCY", multiprocessing.cpu_count() * 2 + 1)) +worker_class: str = "uvicorn.workers.UvicornWorker" + + +def on_starting(_server: Any) -> None: + """Apply Alembic migrations once before workers are spawned.""" + alembic_config = Config(str(Path(__file__).resolve().parent / "alembic.ini")) + command.upgrade(alembic_config, "head") diff --git a/main.py b/main.py index a1167f0..52b41d7 100644 --- a/main.py +++ b/main.py @@ -5,6 +5,10 @@ - Defines the lifespan event handler for app startup/shutdown logging. - Includes API routers for player and health endpoints. +Database migrations are applied by entrypoint.sh before the process starts +(Docker). For local development, run `alembic upgrade head` once before +starting the server. + This serves as the entry point for running the API server. """ @@ -24,7 +28,7 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]: """ Lifespan event handler for FastAPI. """ - logger.info("Lifespan event handler execution complete.") + logger.info("Application startup complete.") yield diff --git a/pyproject.toml b/pyproject.toml index c22499c..3138da6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,9 @@ dependencies = [ "SQLAlchemy==2.0.49", "aiosqlite==0.22.1", "aiocache==0.12.3", + "alembic==1.18.4", + "asyncpg==0.31.0", + "gunicorn>=25.3.0", ] [dependency-groups] diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh index 521f50b..f452cfe 100644 --- a/scripts/entrypoint.sh +++ b/scripts/entrypoint.sh @@ -1,29 +1,27 @@ -#!/bin/bash +#!/bin/sh set -e -IMAGE_STORAGE_PATH="/app/hold/players-sqlite3.db" -VOLUME_STORAGE_PATH="/storage/players-sqlite3.db" +# Helper function for formatted logging +log() { + echo "[ENTRYPOINT] $(date '+%Y/%m/%d - %H:%M:%S') | $1" + return 0 +} -echo "✔ Starting container..." +STORAGE_PATH="${STORAGE_PATH:-/storage/players-sqlite3.db}" -if [[ ! -f "$VOLUME_STORAGE_PATH" ]]; then - echo "⚠️ No existing database file found in volume." - if [[ -f "$IMAGE_STORAGE_PATH" ]]; then - echo "Copying database file to writable volume..." - cp "$IMAGE_STORAGE_PATH" "$VOLUME_STORAGE_PATH" - echo "✔ Database initialized at $VOLUME_STORAGE_PATH" - else - echo "⚠️ Database file missing at $IMAGE_STORAGE_PATH" - exit 1 - fi +log "✔ Starting container..." + +mkdir -p "$(dirname "$STORAGE_PATH")" + +if [ ! -f "$STORAGE_PATH" ]; then + log "⚠️ No existing database file found in volume." + log "🗄️ Gunicorn will apply Alembic migrations on first start." else - echo "✔ Existing database file found. Skipping seed copy." + log "✔ Existing database file found at $STORAGE_PATH." fi -echo "🔄 Running seed scripts to ensure schema is up to date..." -python /app/tools/seed_001_starting_eleven.py --db-path "$VOLUME_STORAGE_PATH" -python /app/tools/seed_002_substitutes.py --db-path "$VOLUME_STORAGE_PATH" - -echo "✔ Ready!" -echo "🚀 Launching app..." +log "✔ Ready!" +log "🚀 Launching app..." +log "🔌 API endpoints | http://localhost:9000" +log "📚 Swagger UI | http://localhost:9000/docs" exec "$@" diff --git a/storage/players-sqlite3.db b/storage/players-sqlite3.db deleted file mode 100644 index 82dd18b..0000000 Binary files a/storage/players-sqlite3.db and /dev/null differ diff --git a/tests/conftest.py b/tests/conftest.py index b327b5c..cd09f88 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,7 +1,10 @@ import warnings +from pathlib import Path from typing import Any, Generator import pytest +from alembic import command +from alembic.config import Config from fastapi.testclient import TestClient from main import app from tests.player_fake import Player, nonexistent_player @@ -9,6 +12,14 @@ # Suppress the DeprecationWarning from httpx warnings.filterwarnings("ignore", category=DeprecationWarning) +ALEMBIC_CONFIG = Config(str(Path(__file__).resolve().parent.parent / "alembic.ini")) + + +@pytest.fixture(scope="session", autouse=True) +def apply_migrations(): + """Apply Alembic migrations once before the test session starts.""" + command.upgrade(ALEMBIC_CONFIG, "head") + @pytest.fixture(scope="function") def client(): diff --git a/tests/test_migrations.py b/tests/test_migrations.py new file mode 100644 index 0000000..2133b85 --- /dev/null +++ b/tests/test_migrations.py @@ -0,0 +1,73 @@ +""" +Integration tests for Alembic migration downgrade paths. + +These tests exercise the downgrade() functions in each migration version, +verifying that each step removes exactly the rows it seeded and leaves the +rest of the database intact. + +Tests run after test_main.py (alphabetical order). Each test downgrades one +or more steps, asserts the expected state, then restores to head before the +next test, ensuring the shared SQLite database remains consistent for any +subsequent test runs. + +These tests are SQLite-only: they open the database file directly via +sqlite3.connect() to inspect raw state, which is not possible with PostgreSQL. +""" + +import sqlite3 + +import pytest +from alembic import command + +from databases.player_database import DATABASE_URL +from tests.conftest import ALEMBIC_CONFIG + +pytestmark = pytest.mark.skipif( + not DATABASE_URL.startswith("sqlite"), + reason="Migration downgrade tests require SQLite", +) + +DB_PATH = DATABASE_URL.replace("sqlite+aiosqlite:///", "") + + +def test_migration_downgrade_003_removes_substitutes_only(): + """Downgrade 003→002 removes the 15 seeded substitutes, leaves Starting XI.""" + command.downgrade(ALEMBIC_CONFIG, "-1") + + conn = sqlite3.connect(DB_PATH) + total = conn.execute("SELECT COUNT(*) FROM players").fetchone()[0] + subs = conn.execute("SELECT COUNT(*) FROM players WHERE starting11=0").fetchone()[0] + conn.close() + + assert total == 11 + assert subs == 0 + + command.upgrade(ALEMBIC_CONFIG, "head") + + +def test_migration_downgrade_002_removes_starting11_only(): + """Downgrade 002→001 removes the 11 seeded Starting XI, leaves table empty.""" + command.downgrade(ALEMBIC_CONFIG, "-2") + + conn = sqlite3.connect(DB_PATH) + total = conn.execute("SELECT COUNT(*) FROM players").fetchone()[0] + conn.close() + + assert total == 0 + + command.upgrade(ALEMBIC_CONFIG, "head") + + +def test_migration_downgrade_001_drops_players_table(): + """Downgrade 001→base drops the players table entirely.""" + command.downgrade(ALEMBIC_CONFIG, "base") + + conn = sqlite3.connect(DB_PATH) + table = conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='players'" + ).fetchone() + conn.close() + + assert table is None + + command.upgrade(ALEMBIC_CONFIG, "head") diff --git a/uv.lock b/uv.lock index 284394d..e2419e8 100644 --- a/uv.lock +++ b/uv.lock @@ -24,6 +24,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" }, ] +[[package]] +name = "alembic" +version = "1.18.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mako" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/94/13/8b084e0f2efb0275a1d534838844926f798bd766566b1375174e2448cd31/alembic-1.18.4.tar.gz", hash = "sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc", size = 2056725, upload-time = "2026-02-10T16:00:47.195Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/29/6533c317b74f707ea28f8d633734dbda2119bbadfc61b2f3640ba835d0f7/alembic-1.18.4-py3-none-any.whl", hash = "sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a", size = 263893, upload-time = "2026-02-10T16:00:49.997Z" }, +] + [[package]] name = "annotated-doc" version = "0.0.4" @@ -54,9 +68,41 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, ] +[[package]] +name = "asyncpg" +version = "0.31.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/cc/d18065ce2380d80b1bcce927c24a2642efd38918e33fd724bc4bca904877/asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735", size = 993667, upload-time = "2025-11-24T23:27:00.812Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/11/97b5c2af72a5d0b9bc3fa30cd4b9ce22284a9a943a150fdc768763caf035/asyncpg-0.31.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c204fab1b91e08b0f47e90a75d1b3c62174dab21f670ad6c5d0f243a228f015b", size = 661111, upload-time = "2025-11-24T23:26:04.467Z" }, + { url = "https://files.pythonhosted.org/packages/1b/71/157d611c791a5e2d0423f09f027bd499935f0906e0c2a416ce712ba51ef3/asyncpg-0.31.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:54a64f91839ba59008eccf7aad2e93d6e3de688d796f35803235ea1c4898ae1e", size = 636928, upload-time = "2025-11-24T23:26:05.944Z" }, + { url = "https://files.pythonhosted.org/packages/2e/fc/9e3486fb2bbe69d4a867c0b76d68542650a7ff1574ca40e84c3111bb0c6e/asyncpg-0.31.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0e0822b1038dc7253b337b0f3f676cadc4ac31b126c5d42691c39691962e403", size = 3424067, upload-time = "2025-11-24T23:26:07.957Z" }, + { url = "https://files.pythonhosted.org/packages/12/c6/8c9d076f73f07f995013c791e018a1cd5f31823c2a3187fc8581706aa00f/asyncpg-0.31.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bef056aa502ee34204c161c72ca1f3c274917596877f825968368b2c33f585f4", size = 3518156, upload-time = "2025-11-24T23:26:09.591Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3b/60683a0baf50fbc546499cfb53132cb6835b92b529a05f6a81471ab60d0c/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0bfbcc5b7ffcd9b75ab1558f00db2ae07db9c80637ad1b2469c43df79d7a5ae2", size = 3319636, upload-time = "2025-11-24T23:26:11.168Z" }, + { url = "https://files.pythonhosted.org/packages/50/dc/8487df0f69bd398a61e1792b3cba0e47477f214eff085ba0efa7eac9ce87/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22bc525ebbdc24d1261ecbf6f504998244d4e3be1721784b5f64664d61fbe602", size = 3472079, upload-time = "2025-11-24T23:26:13.164Z" }, + { url = "https://files.pythonhosted.org/packages/13/a1/c5bbeeb8531c05c89135cb8b28575ac2fac618bcb60119ee9696c3faf71c/asyncpg-0.31.0-cp313-cp313-win32.whl", hash = "sha256:f890de5e1e4f7e14023619399a471ce4b71f5418cd67a51853b9910fdfa73696", size = 527606, upload-time = "2025-11-24T23:26:14.78Z" }, + { url = "https://files.pythonhosted.org/packages/91/66/b25ccb84a246b470eb943b0107c07edcae51804912b824054b3413995a10/asyncpg-0.31.0-cp313-cp313-win_amd64.whl", hash = "sha256:dc5f2fa9916f292e5c5c8b2ac2813763bcd7f58e130055b4ad8a0531314201ab", size = 596569, upload-time = "2025-11-24T23:26:16.189Z" }, + { url = "https://files.pythonhosted.org/packages/3c/36/e9450d62e84a13aea6580c83a47a437f26c7ca6fa0f0fd40b6670793ea30/asyncpg-0.31.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f6b56b91bb0ffc328c4e3ed113136cddd9deefdf5f79ab448598b9772831df44", size = 660867, upload-time = "2025-11-24T23:26:17.631Z" }, + { url = "https://files.pythonhosted.org/packages/82/4b/1d0a2b33b3102d210439338e1beea616a6122267c0df459ff0265cd5807a/asyncpg-0.31.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:334dec28cf20d7f5bb9e45b39546ddf247f8042a690bff9b9573d00086e69cb5", size = 638349, upload-time = "2025-11-24T23:26:19.689Z" }, + { url = "https://files.pythonhosted.org/packages/41/aa/e7f7ac9a7974f08eff9183e392b2d62516f90412686532d27e196c0f0eeb/asyncpg-0.31.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98cc158c53f46de7bb677fd20c417e264fc02b36d901cc2a43bd6cb0dc6dbfd2", size = 3410428, upload-time = "2025-11-24T23:26:21.275Z" }, + { url = "https://files.pythonhosted.org/packages/6f/de/bf1b60de3dede5c2731e6788617a512bc0ebd9693eac297ee74086f101d7/asyncpg-0.31.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9322b563e2661a52e3cdbc93eed3be7748b289f792e0011cb2720d278b366ce2", size = 3471678, upload-time = "2025-11-24T23:26:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/46/78/fc3ade003e22d8bd53aaf8f75f4be48f0b460fa73738f0391b9c856a9147/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19857a358fc811d82227449b7ca40afb46e75b33eb8897240c3839dd8b744218", size = 3313505, upload-time = "2025-11-24T23:26:25.235Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e9/73eb8a6789e927816f4705291be21f2225687bfa97321e40cd23055e903a/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ba5f8886e850882ff2c2ace5732300e99193823e8107e2c53ef01c1ebfa1e85d", size = 3434744, upload-time = "2025-11-24T23:26:26.944Z" }, + { url = "https://files.pythonhosted.org/packages/08/4b/f10b880534413c65c5b5862f79b8e81553a8f364e5238832ad4c0af71b7f/asyncpg-0.31.0-cp314-cp314-win32.whl", hash = "sha256:cea3a0b2a14f95834cee29432e4ddc399b95700eb1d51bbc5bfee8f31fa07b2b", size = 532251, upload-time = "2025-11-24T23:26:28.404Z" }, + { url = "https://files.pythonhosted.org/packages/d3/2d/7aa40750b7a19efa5d66e67fc06008ca0f27ba1bd082e457ad82f59aba49/asyncpg-0.31.0-cp314-cp314-win_amd64.whl", hash = "sha256:04d19392716af6b029411a0264d92093b6e5e8285ae97a39957b9a9c14ea72be", size = 604901, upload-time = "2025-11-24T23:26:30.34Z" }, + { url = "https://files.pythonhosted.org/packages/ce/fe/b9dfe349b83b9dee28cc42360d2c86b2cdce4cb551a2c2d27e156bcac84d/asyncpg-0.31.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bdb957706da132e982cc6856bb2f7b740603472b54c3ebc77fe60ea3e57e1bd2", size = 702280, upload-time = "2025-11-24T23:26:32Z" }, + { url = "https://files.pythonhosted.org/packages/6a/81/e6be6e37e560bd91e6c23ea8a6138a04fd057b08cf63d3c5055c98e81c1d/asyncpg-0.31.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6d11b198111a72f47154fa03b85799f9be63701e068b43f84ac25da0bda9cb31", size = 682931, upload-time = "2025-11-24T23:26:33.572Z" }, + { url = "https://files.pythonhosted.org/packages/a6/45/6009040da85a1648dd5bc75b3b0a062081c483e75a1a29041ae63a0bf0dc/asyncpg-0.31.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18c83b03bc0d1b23e6230f5bf8d4f217dc9bc08644ce0502a9d91dc9e634a9c7", size = 3581608, upload-time = "2025-11-24T23:26:35.638Z" }, + { url = "https://files.pythonhosted.org/packages/7e/06/2e3d4d7608b0b2b3adbee0d0bd6a2d29ca0fc4d8a78f8277df04e2d1fd7b/asyncpg-0.31.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e009abc333464ff18b8f6fd146addffd9aaf63e79aa3bb40ab7a4c332d0c5e9e", size = 3498738, upload-time = "2025-11-24T23:26:37.275Z" }, + { url = "https://files.pythonhosted.org/packages/7d/aa/7d75ede780033141c51d83577ea23236ba7d3a23593929b32b49db8ed36e/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3b1fbcb0e396a5ca435a8826a87e5c2c2cc0c8c68eb6fadf82168056b0e53a8c", size = 3401026, upload-time = "2025-11-24T23:26:39.423Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7a/15e37d45e7f7c94facc1e9148c0e455e8f33c08f0b8a0b1deb2c5171771b/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8df714dba348efcc162d2adf02d213e5fab1bd9f557e1305633e851a61814a7a", size = 3429426, upload-time = "2025-11-24T23:26:41.032Z" }, + { url = "https://files.pythonhosted.org/packages/13/d5/71437c5f6ae5f307828710efbe62163974e71237d5d46ebd2869ea052d10/asyncpg-0.31.0-cp314-cp314t-win32.whl", hash = "sha256:1b41f1afb1033f2b44f3234993b15096ddc9cd71b21a42dbd87fc6a57b43d65d", size = 614495, upload-time = "2025-11-24T23:26:42.659Z" }, + { url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" }, +] + [[package]] name = "black" -version = "26.1.0" +version = "26.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, @@ -66,19 +112,19 @@ dependencies = [ { name = "platformdirs" }, { name = "pytokens" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/13/88/560b11e521c522440af991d46848a2bde64b5f7202ec14e1f46f9509d328/black-26.1.0.tar.gz", hash = "sha256:d294ac3340eef9c9eb5d29288e96dc719ff269a88e27b396340459dd85da4c58", size = 658785, upload-time = "2026-01-18T04:50:11.993Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/c5/61175d618685d42b005847464b8fb4743a67b1b8fdb75e50e5a96c31a27a/black-26.3.1.tar.gz", hash = "sha256:2c50f5063a9641c7eed7795014ba37b0f5fa227f3d408b968936e24bc0566b07", size = 666155, upload-time = "2026-03-12T03:36:03.593Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/79/04/fa2f4784f7237279332aa735cdfd5ae2e7730db0072fb2041dadda9ae551/black-26.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ba1d768fbfb6930fc93b0ecc32a43d8861ded16f47a40f14afa9bb04ab93d304", size = 1877781, upload-time = "2026-01-18T04:59:39.054Z" }, - { url = "https://files.pythonhosted.org/packages/cf/ad/5a131b01acc0e5336740a039628c0ab69d60cf09a2c87a4ec49f5826acda/black-26.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2b807c240b64609cb0e80d2200a35b23c7df82259f80bef1b2c96eb422b4aac9", size = 1699670, upload-time = "2026-01-18T04:59:41.005Z" }, - { url = "https://files.pythonhosted.org/packages/da/7c/b05f22964316a52ab6b4265bcd52c0ad2c30d7ca6bd3d0637e438fc32d6e/black-26.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1de0f7d01cc894066a1153b738145b194414cc6eeaad8ef4397ac9abacf40f6b", size = 1775212, upload-time = "2026-01-18T04:59:42.545Z" }, - { url = "https://files.pythonhosted.org/packages/a6/a3/e8d1526bea0446e040193185353920a9506eab60a7d8beb062029129c7d2/black-26.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:91a68ae46bf07868963671e4d05611b179c2313301bd756a89ad4e3b3db2325b", size = 1409953, upload-time = "2026-01-18T04:59:44.357Z" }, - { url = "https://files.pythonhosted.org/packages/c7/5a/d62ebf4d8f5e3a1daa54adaab94c107b57be1b1a2f115a0249b41931e188/black-26.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:be5e2fe860b9bd9edbf676d5b60a9282994c03fbbd40fe8f5e75d194f96064ca", size = 1217707, upload-time = "2026-01-18T04:59:45.719Z" }, - { url = "https://files.pythonhosted.org/packages/6a/83/be35a175aacfce4b05584ac415fd317dd6c24e93a0af2dcedce0f686f5d8/black-26.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9dc8c71656a79ca49b8d3e2ce8103210c9481c57798b48deeb3a8bb02db5f115", size = 1871864, upload-time = "2026-01-18T04:59:47.586Z" }, - { url = "https://files.pythonhosted.org/packages/a5/f5/d33696c099450b1274d925a42b7a030cd3ea1f56d72e5ca8bbed5f52759c/black-26.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b22b3810451abe359a964cc88121d57f7bce482b53a066de0f1584988ca36e79", size = 1701009, upload-time = "2026-01-18T04:59:49.443Z" }, - { url = "https://files.pythonhosted.org/packages/1b/87/670dd888c537acb53a863bc15abbd85b22b429237d9de1b77c0ed6b79c42/black-26.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:53c62883b3f999f14e5d30b5a79bd437236658ad45b2f853906c7cbe79de00af", size = 1767806, upload-time = "2026-01-18T04:59:50.769Z" }, - { url = "https://files.pythonhosted.org/packages/fe/9c/cd3deb79bfec5bcf30f9d2100ffeec63eecce826eb63e3961708b9431ff1/black-26.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:f016baaadc423dc960cdddf9acae679e71ee02c4c341f78f3179d7e4819c095f", size = 1433217, upload-time = "2026-01-18T04:59:52.218Z" }, - { url = "https://files.pythonhosted.org/packages/4e/29/f3be41a1cf502a283506f40f5d27203249d181f7a1a2abce1c6ce188035a/black-26.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:66912475200b67ef5a0ab665011964bf924745103f51977a78b4fb92a9fc1bf0", size = 1245773, upload-time = "2026-01-18T04:59:54.457Z" }, - { url = "https://files.pythonhosted.org/packages/e4/3d/51bdb3ecbfadfaf825ec0c75e1de6077422b4afa2091c6c9ba34fbfc0c2d/black-26.1.0-py3-none-any.whl", hash = "sha256:1054e8e47ebd686e078c0bb0eaf31e6ce69c966058d122f2c0c950311f9f3ede", size = 204010, upload-time = "2026-01-18T04:50:09.978Z" }, + { url = "https://files.pythonhosted.org/packages/f5/77/5728052a3c0450c53d9bb3945c4c46b91baa62b2cafab6801411b6271e45/black-26.3.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:855822d90f884905362f602880ed8b5df1b7e3ee7d0db2502d4388a954cc8c54", size = 1895034, upload-time = "2026-03-12T03:40:21.813Z" }, + { url = "https://files.pythonhosted.org/packages/52/73/7cae55fdfdfbe9d19e9a8d25d145018965fe2079fa908101c3733b0c55a0/black-26.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8a33d657f3276328ce00e4d37fe70361e1ec7614da5d7b6e78de5426cb56332f", size = 1718503, upload-time = "2026-03-12T03:40:23.666Z" }, + { url = "https://files.pythonhosted.org/packages/e1/87/af89ad449e8254fdbc74654e6467e3c9381b61472cc532ee350d28cfdafb/black-26.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f1cd08e99d2f9317292a311dfe578fd2a24b15dbce97792f9c4d752275c1fa56", size = 1793557, upload-time = "2026-03-12T03:40:25.497Z" }, + { url = "https://files.pythonhosted.org/packages/43/10/d6c06a791d8124b843bf325ab4ac7d2f5b98731dff84d6064eafd687ded1/black-26.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:c7e72339f841b5a237ff14f7d3880ddd0fc7f98a1199e8c4327f9a4f478c1839", size = 1422766, upload-time = "2026-03-12T03:40:27.14Z" }, + { url = "https://files.pythonhosted.org/packages/59/4f/40a582c015f2d841ac24fed6390bd68f0fc896069ff3a886317959c9daf8/black-26.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:afc622538b430aa4c8c853f7f63bc582b3b8030fd8c80b70fb5fa5b834e575c2", size = 1232140, upload-time = "2026-03-12T03:40:28.882Z" }, + { url = "https://files.pythonhosted.org/packages/d5/da/e36e27c9cebc1311b7579210df6f1c86e50f2d7143ae4fcf8a5017dc8809/black-26.3.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2d6bfaf7fd0993b420bed691f20f9492d53ce9a2bcccea4b797d34e947318a78", size = 1889234, upload-time = "2026-03-12T03:40:30.964Z" }, + { url = "https://files.pythonhosted.org/packages/0e/7b/9871acf393f64a5fa33668c19350ca87177b181f44bb3d0c33b2d534f22c/black-26.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f89f2ab047c76a9c03f78d0d66ca519e389519902fa27e7a91117ef7611c0568", size = 1720522, upload-time = "2026-03-12T03:40:32.346Z" }, + { url = "https://files.pythonhosted.org/packages/03/87/e766c7f2e90c07fb7586cc787c9ae6462b1eedab390191f2b7fc7f6170a9/black-26.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b07fc0dab849d24a80a29cfab8d8a19187d1c4685d8a5e6385a5ce323c1f015f", size = 1787824, upload-time = "2026-03-12T03:40:33.636Z" }, + { url = "https://files.pythonhosted.org/packages/ac/94/2424338fb2d1875e9e83eed4c8e9c67f6905ec25afd826a911aea2b02535/black-26.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:0126ae5b7c09957da2bdbd91a9ba1207453feada9e9fe51992848658c6c8e01c", size = 1445855, upload-time = "2026-03-12T03:40:35.442Z" }, + { url = "https://files.pythonhosted.org/packages/86/43/0c3338bd928afb8ee7471f1a4eec3bdbe2245ccb4a646092a222e8669840/black-26.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:92c0ec1f2cc149551a2b7b47efc32c866406b6891b0ee4625e95967c8f4acfb1", size = 1258109, upload-time = "2026-03-12T03:40:36.832Z" }, + { url = "https://files.pythonhosted.org/packages/8e/0d/52d98722666d6fc6c3dd4c76df339501d6efd40e0ff95e6186a7b7f0befd/black-26.3.1-py3-none-any.whl", hash = "sha256:2bd5aa94fc267d38bb21a70d7410a89f1a1d318841855f698746f8e7f51acd1b", size = 207542, upload-time = "2026-03-12T03:36:01.668Z" }, ] [[package]] @@ -224,7 +270,7 @@ wheels = [ [[package]] name = "fastapi" -version = "0.135.1" +version = "0.135.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -233,9 +279,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e7/7b/f8e0211e9380f7195ba3f3d40c292594fd81ba8ec4629e3854c353aaca45/fastapi-0.135.1.tar.gz", hash = "sha256:d04115b508d936d254cea545b7312ecaa58a7b3a0f84952535b4c9afae7668cd", size = 394962, upload-time = "2026-03-01T18:18:29.369Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/e6/7adb4c5fa231e82c35b8f5741a9f2d055f520c29af5546fd70d3e8e1cd2e/fastapi-0.135.3.tar.gz", hash = "sha256:bd6d7caf1a2bdd8d676843cdcd2287729572a1ef524fc4d65c17ae002a1be654", size = 396524, upload-time = "2026-04-01T16:23:58.188Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e4/72/42e900510195b23a56bde950d26a51f8b723846bfcaa0286e90287f0422b/fastapi-0.135.1-py3-none-any.whl", hash = "sha256:46e2fc5745924b7c840f71ddd277382af29ce1cdb7d5eab5bf697e3fb9999c9e", size = 116999, upload-time = "2026-03-01T18:18:30.831Z" }, + { url = "https://files.pythonhosted.org/packages/84/a4/5caa2de7f917a04ada20018eccf60d6cc6145b0199d55ca3711b0fc08312/fastapi-0.135.3-py3-none-any.whl", hash = "sha256:9b0f590c813acd13d0ab43dd8494138eb58e484bfac405db1f3187cfc5810d98", size = 117734, upload-time = "2026-04-01T16:23:59.328Z" }, ] [package.optional-dependencies] @@ -358,7 +404,7 @@ wheels = [ [[package]] name = "gevent" -version = "25.9.1" +version = "26.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation == 'CPython' and sys_platform == 'win32'" }, @@ -366,24 +412,24 @@ dependencies = [ { name = "zope-event" }, { name = "zope-interface" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9e/48/b3ef2673ffb940f980966694e40d6d32560f3ffa284ecaeb5ea3a90a6d3f/gevent-25.9.1.tar.gz", hash = "sha256:adf9cd552de44a4e6754c51ff2e78d9193b7fa6eab123db9578a210e657235dd", size = 5059025, upload-time = "2025-09-17T16:15:34.528Z" } +sdist = { url = "https://files.pythonhosted.org/packages/20/27/1062fa31333dc3428a1f5f33cd6598b0552165ba679ca3ba116de42c9e8e/gevent-26.4.0.tar.gz", hash = "sha256:288d03addfccf0d1c67268358b6759b04392bf3bc35d26f3d9a45c82899c292d", size = 6242440, upload-time = "2026-04-09T12:08:19.482Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/77/b97f086388f87f8ad3e01364f845004aef0123d4430241c7c9b1f9bde742/gevent-25.9.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:4f84591d13845ee31c13f44bdf6bd6c3dbf385b5af98b2f25ec328213775f2ed", size = 2973739, upload-time = "2025-09-17T14:53:30.279Z" }, - { url = "https://files.pythonhosted.org/packages/3c/2e/9d5f204ead343e5b27bbb2fedaec7cd0009d50696b2266f590ae845d0331/gevent-25.9.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9cdbb24c276a2d0110ad5c978e49daf620b153719ac8a548ce1250a7eb1b9245", size = 1809165, upload-time = "2025-09-17T15:41:27.193Z" }, - { url = "https://files.pythonhosted.org/packages/10/3e/791d1bf1eb47748606d5f2c2aa66571f474d63e0176228b1f1fd7b77ab37/gevent-25.9.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:88b6c07169468af631dcf0fdd3658f9246d6822cc51461d43f7c44f28b0abb82", size = 1890638, upload-time = "2025-09-17T15:49:02.45Z" }, - { url = "https://files.pythonhosted.org/packages/f2/5c/9ad0229b2b4d81249ca41e4f91dd8057deaa0da6d4fbe40bf13cdc5f7a47/gevent-25.9.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b7bb0e29a7b3e6ca9bed2394aa820244069982c36dc30b70eb1004dd67851a48", size = 1857118, upload-time = "2025-09-17T15:49:22.125Z" }, - { url = "https://files.pythonhosted.org/packages/49/2a/3010ed6c44179a3a5c5c152e6de43a30ff8bc2c8de3115ad8733533a018f/gevent-25.9.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2951bb070c0ee37b632ac9134e4fdaad70d2e660c931bb792983a0837fe5b7d7", size = 2111598, upload-time = "2025-09-17T15:15:15.226Z" }, - { url = "https://files.pythonhosted.org/packages/08/75/6bbe57c19a7aa4527cc0f9afcdf5a5f2aed2603b08aadbccb5bf7f607ff4/gevent-25.9.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e4e17c2d57e9a42e25f2a73d297b22b60b2470a74be5a515b36c984e1a246d47", size = 1829059, upload-time = "2025-09-17T15:52:42.596Z" }, - { url = "https://files.pythonhosted.org/packages/06/6e/19a9bee9092be45679cb69e4dd2e0bf5f897b7140b4b39c57cc123d24829/gevent-25.9.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8d94936f8f8b23d9de2251798fcb603b84f083fdf0d7f427183c1828fb64f117", size = 2173529, upload-time = "2025-09-17T15:24:13.897Z" }, - { url = "https://files.pythonhosted.org/packages/ca/4f/50de9afd879440e25737e63f5ba6ee764b75a3abe17376496ab57f432546/gevent-25.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:eb51c5f9537b07da673258b4832f6635014fee31690c3f0944d34741b69f92fa", size = 1681518, upload-time = "2025-09-17T19:39:47.488Z" }, - { url = "https://files.pythonhosted.org/packages/15/1a/948f8167b2cdce573cf01cec07afc64d0456dc134b07900b26ac7018b37e/gevent-25.9.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:1a3fe4ea1c312dbf6b375b416925036fe79a40054e6bf6248ee46526ea628be1", size = 2982934, upload-time = "2025-09-17T14:54:11.302Z" }, - { url = "https://files.pythonhosted.org/packages/9b/ec/726b146d1d3aad82e03d2e1e1507048ab6072f906e83f97f40667866e582/gevent-25.9.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0adb937f13e5fb90cca2edf66d8d7e99d62a299687400ce2edee3f3504009356", size = 1813982, upload-time = "2025-09-17T15:41:28.506Z" }, - { url = "https://files.pythonhosted.org/packages/35/5d/5f83f17162301662bd1ce702f8a736a8a8cac7b7a35e1d8b9866938d1f9d/gevent-25.9.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:427f869a2050a4202d93cf7fd6ab5cffb06d3e9113c10c967b6e2a0d45237cb8", size = 1894902, upload-time = "2025-09-17T15:49:03.702Z" }, - { url = "https://files.pythonhosted.org/packages/83/cd/cf5e74e353f60dab357829069ffc300a7bb414c761f52cf8c0c6e9728b8d/gevent-25.9.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c049880175e8c93124188f9d926af0a62826a3b81aa6d3074928345f8238279e", size = 1861792, upload-time = "2025-09-17T15:49:23.279Z" }, - { url = "https://files.pythonhosted.org/packages/dd/65/b9a4526d4a4edce26fe4b3b993914ec9dc64baabad625a3101e51adb17f3/gevent-25.9.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b5a67a0974ad9f24721034d1e008856111e0535f1541499f72a733a73d658d1c", size = 2113215, upload-time = "2025-09-17T15:15:16.34Z" }, - { url = "https://files.pythonhosted.org/packages/e5/be/7d35731dfaf8370795b606e515d964a0967e129db76ea7873f552045dd39/gevent-25.9.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1d0f5d8d73f97e24ea8d24d8be0f51e0cf7c54b8021c1fddb580bf239474690f", size = 1833449, upload-time = "2025-09-17T15:52:43.75Z" }, - { url = "https://files.pythonhosted.org/packages/65/58/7bc52544ea5e63af88c4a26c90776feb42551b7555a1c89c20069c168a3f/gevent-25.9.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ddd3ff26e5c4240d3fbf5516c2d9d5f2a998ef87cfb73e1429cfaeaaec860fa6", size = 2176034, upload-time = "2025-09-17T15:24:15.676Z" }, - { url = "https://files.pythonhosted.org/packages/c2/69/a7c4ba2ffbc7c7dbf6d8b4f5d0f0a421f7815d229f4909854266c445a3d4/gevent-25.9.1-cp314-cp314-win_amd64.whl", hash = "sha256:bb63c0d6cb9950cc94036a4995b9cc4667b8915366613449236970f4394f94d7", size = 1703019, upload-time = "2025-09-17T19:30:55.272Z" }, + { url = "https://files.pythonhosted.org/packages/3d/be/51809d98bb00846d7756a0b82625024f9302145f3d024846b43f05efeddb/gevent-26.4.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1fe581d41c63cd1d8b12c69561ce53a48ad0d8763b254740d7bfea997335a38c", size = 2951507, upload-time = "2026-04-08T21:25:25.809Z" }, + { url = "https://files.pythonhosted.org/packages/d6/86/89325a62a4e8cc1934e155b383b66491ed21d1e774b13d5054d51fa0ac81/gevent-26.4.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:c503b0c0a681e795255a13e5bb4e41615c3b020c1db93b8dfa04cfeb8f19d5a9", size = 1786029, upload-time = "2026-04-08T22:23:20.395Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ea/04d112844aa992da583cbd280f17a4ba097da338dab347efd0aa5e235645/gevent-26.4.0-cp313-cp313-manylinux_2_28_ppc64le.whl", hash = "sha256:684256c29e3e5d4d0c4d06b772d00574d0dc859dfbb2fd13d318c512b16e1f89", size = 1881326, upload-time = "2026-04-08T22:27:11.822Z" }, + { url = "https://files.pythonhosted.org/packages/a1/33/71900c5ba442f5df89456b6d9fdaa43da2ae7cdd937d8c5667b49323ceb4/gevent-26.4.0-cp313-cp313-manylinux_2_28_s390x.whl", hash = "sha256:73eafd06b158d511f1ec6e5902a45e0ae3b48e745f35e9df97d25f809f537d88", size = 1833123, upload-time = "2026-04-08T22:34:39.001Z" }, + { url = "https://files.pythonhosted.org/packages/d0/af/7df19c92e56842921f34787e1168c7afc52a23b0d1253bba99344809a935/gevent-26.4.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:1a18e543c830a1c07a2efeb33786a57ccac360af70cb42bbaf5a6f5f7ca49300", size = 2114330, upload-time = "2026-04-08T21:54:16.547Z" }, + { url = "https://files.pythonhosted.org/packages/ca/0e/202694960f8d4dda68fd2a73bbcb8251e2d5308339924310ff1fff31bf7c/gevent-26.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:74f1e3a460c43aefcb4ff9ef91aac15abc0b42e5233771e1956574d14ba9cac6", size = 1798427, upload-time = "2026-04-08T22:26:54.462Z" }, + { url = "https://files.pythonhosted.org/packages/75/69/2d056b2a4e3ef1f65f94002725572d1e99163ff79231dbb68ad529e7cb9d/gevent-26.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:954258873ae0bcc97fb41e48db25284fb73454bfefe27db8ceb89225da5502fb", size = 2140100, upload-time = "2026-04-08T22:00:03.966Z" }, + { url = "https://files.pythonhosted.org/packages/fe/a0/1a7f64aa2476c2b44abaecca919a6561bda85234f99fc7ac3c66bcb93050/gevent-26.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:4a9a64c064457c1afaf93ee2815fe0f38be6ecbb92806a6a712f12afc3e26cf5", size = 1680206, upload-time = "2026-04-08T23:01:56.636Z" }, + { url = "https://files.pythonhosted.org/packages/7e/f3/64638a941988f09aa1816e2674eb1efb215b6fa64a97edef6e25177b0845/gevent-26.4.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:7ab0f183a6fd2369eef619832eef14f1f2f69c605163c3f2dc41deb799af4a71", size = 2967206, upload-time = "2026-04-08T21:25:44.73Z" }, + { url = "https://files.pythonhosted.org/packages/f6/70/a86be65a51d3ebb92c82a70adc9c5c32b1a9d9579120d0be1db7cf534ce0/gevent-26.4.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:7e5906860e632bf965e1966c57e6bfc19dcb79dc262f04fdb0a9d7c12147bf69", size = 1792916, upload-time = "2026-04-08T22:23:22.006Z" }, + { url = "https://files.pythonhosted.org/packages/40/92/18fdb4b28f20129395f1c041773adee99e7fc2bcfff216df93bfb80787d5/gevent-26.4.0-cp314-cp314-manylinux_2_28_ppc64le.whl", hash = "sha256:297a361071dc6708115d4544859321e93b02a6cd5823ba02c0a909530a519d45", size = 1886617, upload-time = "2026-04-08T22:27:13.716Z" }, + { url = "https://files.pythonhosted.org/packages/af/c9/d02222ecf79d10c8a0c2755661485395b58c4bfffaafd88bcc230ce392de/gevent-26.4.0-cp314-cp314-manylinux_2_28_s390x.whl", hash = "sha256:7e74f59e5c9011afa2a9cb7106bb9a59f2a1f74c3d7b272c1b852eb0bc0b8f90", size = 1837660, upload-time = "2026-04-08T22:34:40.823Z" }, + { url = "https://files.pythonhosted.org/packages/46/85/9376d125fa4f7b0f269925d0d622eda0ff8f8dfc8d0c097a096c511fc738/gevent-26.4.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:45d6010a6a981f5a2b3411c4e38fbe305a1b46e4b12db3b4914775927dea7ba4", size = 2119342, upload-time = "2026-04-08T21:54:17.747Z" }, + { url = "https://files.pythonhosted.org/packages/b1/c4/1fe2817daca8e97c365fd739dd4057f71cce26ef600fb8465deb8060c83c/gevent-26.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:dc38137ba2f43794c488615aafa2eefd0cc142f484a8274d4c827ed7a031a1e2", size = 1805672, upload-time = "2026-04-08T22:26:55.792Z" }, + { url = "https://files.pythonhosted.org/packages/e7/cc/ccbcbd56e7e85482291fbb90a317f5febf630ec4174a91506f4167ba0912/gevent-26.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:29a225d2d4da37e20c7a246754a64442d0e43e4534b8cc764f89530bb22a4237", size = 2145594, upload-time = "2026-04-08T22:00:05.275Z" }, + { url = "https://files.pythonhosted.org/packages/8e/b9/7dd37b6001d16f692b1bfb6e68cad642beb38b34a753c29bbff312f46e4b/gevent-26.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:d1c08bc9bb6bd79732a26710a99588b5e9b67b668e165dd609704b876f41baab", size = 1703189, upload-time = "2026-04-08T22:48:31.713Z" }, ] [[package]] @@ -420,6 +466,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/29/4b/45d90626aef8e65336bed690106d1382f7a43665e2249017e9527df8823b/greenlet-3.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c04c5e06ec3e022cbfe2cd4a846e1d4e50087444f875ff6d2c2ad8445495cf1a", size = 237086, upload-time = "2026-02-20T20:20:45.786Z" }, ] +[[package]] +name = "gunicorn" +version = "25.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/f4/e78fa054248fab913e2eab0332c6c2cb07421fca1ce56d8fe43b6aef57a4/gunicorn-25.3.0.tar.gz", hash = "sha256:f74e1b2f9f76f6cd1ca01198968bd2dd65830edc24b6e8e4d78de8320e2fe889", size = 634883, upload-time = "2026-03-27T00:00:26.092Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/c8/8aaf447698c4d59aa853fd318eed300b5c9e44459f242ab8ead6c9c09792/gunicorn-25.3.0-py3-none-any.whl", hash = "sha256:cacea387dab08cd6776501621c295a904fe8e3b7aae9a1a3cbb26f4e7ed54660", size = 208403, upload-time = "2026-03-27T00:00:27.386Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -509,6 +567,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] +[[package]] +name = "mako" +version = "1.3.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/38/bd5b78a920a64d708fe6bc8e0a2c075e1389d53bef8413725c63ba041535/mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28", size = 392474, upload-time = "2025-04-10T12:44:31.16Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509, upload-time = "2025-04-10T12:50:53.297Z" }, +] + [[package]] name = "markdown-it-py" version = "4.0.0" @@ -774,7 +844,7 @@ wheels = [ [[package]] name = "pytest" -version = "9.0.2" +version = "9.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -783,23 +853,23 @@ dependencies = [ { name = "pluggy" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] [[package]] name = "pytest-cov" -version = "7.0.0" +version = "7.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "coverage" }, { name = "pluggy" }, { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, ] [[package]] @@ -840,7 +910,10 @@ source = { virtual = "." } dependencies = [ { name = "aiocache" }, { name = "aiosqlite" }, + { name = "alembic" }, + { name = "asyncpg" }, { name = "fastapi", extra = ["standard"] }, + { name = "gunicorn" }, { name = "sqlalchemy" }, ] @@ -868,27 +941,30 @@ test = [ requires-dist = [ { name = "aiocache", specifier = "==0.12.3" }, { name = "aiosqlite", specifier = "==0.22.1" }, - { name = "fastapi", extras = ["standard"], specifier = "==0.135.1" }, - { name = "sqlalchemy", specifier = "==2.0.47" }, + { name = "alembic", specifier = "==1.18.4" }, + { name = "asyncpg", specifier = "==0.31.0" }, + { name = "fastapi", extras = ["standard"], specifier = "==0.135.3" }, + { name = "gunicorn", specifier = ">=25.3.0" }, + { name = "sqlalchemy", specifier = "==2.0.49" }, ] [package.metadata.requires-dev] dev = [ - { name = "black", specifier = "==26.1.0" }, + { name = "black", specifier = "==26.3.1" }, { name = "flake8", specifier = "==7.3.0" }, - { name = "gevent", specifier = "==25.9.1" }, - { name = "pytest", specifier = "==9.0.2" }, - { name = "pytest-cov", specifier = "==7.0.0" }, + { name = "gevent", specifier = "==26.4.0" }, + { name = "pytest", specifier = "==9.0.3" }, + { name = "pytest-cov", specifier = "==7.1.0" }, { name = "pytest-sugar", specifier = "==1.1.1" }, ] lint = [ - { name = "black", specifier = "==26.1.0" }, + { name = "black", specifier = "==26.3.1" }, { name = "flake8", specifier = "==7.3.0" }, ] test = [ - { name = "gevent", specifier = "==25.9.1" }, - { name = "pytest", specifier = "==9.0.2" }, - { name = "pytest-cov", specifier = "==7.0.0" }, + { name = "gevent", specifier = "==26.4.0" }, + { name = "pytest", specifier = "==9.0.3" }, + { name = "pytest-cov", specifier = "==7.1.0" }, { name = "pytest-sugar", specifier = "==1.1.1" }, ] @@ -1056,41 +1132,41 @@ wheels = [ [[package]] name = "sqlalchemy" -version = "2.0.47" +version = "2.0.49" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cd/4b/1e00561093fe2cd8eef09d406da003c8a118ff02d6548498c1ae677d68d9/sqlalchemy-2.0.47.tar.gz", hash = "sha256:e3e7feb57b267fe897e492b9721ae46d5c7de6f9e8dee58aacf105dc4e154f3d", size = 9886323, upload-time = "2026-02-24T16:34:27.947Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4a/e5/0af64ce7d8f60ec5328c10084e2f449e7912a9b8bdbefdcfb44454a25f49/sqlalchemy-2.0.47-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:456a135b790da5d3c6b53d0ef71ac7b7d280b7f41eb0c438986352bf03ca7143", size = 2152551, upload-time = "2026-02-24T17:05:47.675Z" }, - { url = "https://files.pythonhosted.org/packages/63/79/746b8d15f6940e2ac469ce22d7aa5b1124b1ab820bad9b046eb3000c88a6/sqlalchemy-2.0.47-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09a2f7698e44b3135433387da5d8846cf7cc7c10e5425af7c05fee609df978b6", size = 3278782, upload-time = "2026-02-24T17:18:10.012Z" }, - { url = "https://files.pythonhosted.org/packages/91/b1/bd793ddb34345d1ed43b13ab2d88c95d7d4eb2e28f5b5a99128b9cc2bca2/sqlalchemy-2.0.47-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0bbc72e6a177c78d724f9106aaddc0d26a2ada89c6332b5935414eccf04cbd5", size = 3295155, upload-time = "2026-02-24T17:27:22.827Z" }, - { url = "https://files.pythonhosted.org/packages/97/84/7213def33f94e5ca6f5718d259bc9f29de0363134648425aa218d4356b23/sqlalchemy-2.0.47-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:75460456b043b78b6006e41bdf5b86747ee42eafaf7fffa3b24a6e9a456a2092", size = 3226834, upload-time = "2026-02-24T17:18:11.465Z" }, - { url = "https://files.pythonhosted.org/packages/ef/06/456810204f4dc29b5f025b1b0a03b4bd6b600ebf3c1040aebd90a257fa33/sqlalchemy-2.0.47-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5d9adaa616c3bc7d80f9ded57cd84b51d6617cad6a5456621d858c9f23aaee01", size = 3265001, upload-time = "2026-02-24T17:27:24.813Z" }, - { url = "https://files.pythonhosted.org/packages/fb/20/df3920a4b2217dbd7390a5bd277c1902e0393f42baaf49f49b3c935e7328/sqlalchemy-2.0.47-cp313-cp313-win32.whl", hash = "sha256:76e09f974382a496a5ed985db9343628b1cb1ac911f27342e4cc46a8bac10476", size = 2113647, upload-time = "2026-02-24T17:22:55.747Z" }, - { url = "https://files.pythonhosted.org/packages/46/06/7873ddf69918efbfabd7211829f4bd8019739d0a719253112d305d3ba51d/sqlalchemy-2.0.47-cp313-cp313-win_amd64.whl", hash = "sha256:0664089b0bf6724a0bfb49a0cf4d4da24868a0a5c8e937cd7db356d5dcdf2c66", size = 2139425, upload-time = "2026-02-24T17:22:57.033Z" }, - { url = "https://files.pythonhosted.org/packages/54/fa/61ad9731370c90ac7ea5bf8f5eaa12c48bb4beec41c0fa0360becf4ac10d/sqlalchemy-2.0.47-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ed0c967c701ae13da98eb220f9ddab3044ab63504c1ba24ad6a59b26826ad003", size = 3558809, upload-time = "2026-02-24T17:12:15.232Z" }, - { url = "https://files.pythonhosted.org/packages/33/d5/221fac96f0529391fe374875633804c866f2b21a9c6d3a6ca57d9c12cfd7/sqlalchemy-2.0.47-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3537943a61fd25b241e976426a0c6814434b93cf9b09d39e8e78f3c9eb9a487", size = 3525480, upload-time = "2026-02-24T17:27:59.602Z" }, - { url = "https://files.pythonhosted.org/packages/ec/55/8247d53998c3673e4a8d1958eba75c6f5cc3b39082029d400bb1f2a911ae/sqlalchemy-2.0.47-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:57f7e336a64a0dba686c66392d46b9bc7af2c57d55ce6dc1697b4ef32b043ceb", size = 3466569, upload-time = "2026-02-24T17:12:16.94Z" }, - { url = "https://files.pythonhosted.org/packages/6b/b5/c1f0eea1bac6790845f71420a7fe2f2a0566203aa57543117d4af3b77d1c/sqlalchemy-2.0.47-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dff735a621858680217cb5142b779bad40ef7322ddbb7c12062190db6879772e", size = 3475770, upload-time = "2026-02-24T17:28:02.034Z" }, - { url = "https://files.pythonhosted.org/packages/c5/ed/2f43f92474ea0c43c204657dc47d9d002cd738b96ca2af8e6d29a9b5e42d/sqlalchemy-2.0.47-cp313-cp313t-win32.whl", hash = "sha256:3893dc096bb3cca9608ea3487372ffcea3ae9b162f40e4d3c51dd49db1d1b2dc", size = 2141300, upload-time = "2026-02-24T17:14:37.024Z" }, - { url = "https://files.pythonhosted.org/packages/cc/a9/8b73f9f1695b6e92f7aaf1711135a1e3bbeb78bca9eded35cb79180d3c6d/sqlalchemy-2.0.47-cp313-cp313t-win_amd64.whl", hash = "sha256:b5103427466f4b3e61f04833ae01f9a914b1280a2a8bcde3a9d7ab11f3755b42", size = 2173053, upload-time = "2026-02-24T17:14:38.688Z" }, - { url = "https://files.pythonhosted.org/packages/c1/30/98243209aae58ed80e090ea988d5182244ca7ab3ff59e6d850c3dfc7651e/sqlalchemy-2.0.47-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b03010a5a5dfe71676bc83f2473ebe082478e32d77e6f082c8fe15a31c3b42a6", size = 2154355, upload-time = "2026-02-24T17:05:48.959Z" }, - { url = "https://files.pythonhosted.org/packages/ab/62/12ca6ea92055fe486d6558a2a4efe93e194ff597463849c01f88e5adb99d/sqlalchemy-2.0.47-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f8e3371aa9024520883a415a09cc20c33cfd3eeccf9e0f4f4c367f940b9cbd44", size = 3274486, upload-time = "2026-02-24T17:18:13.659Z" }, - { url = "https://files.pythonhosted.org/packages/97/88/7dfbdeaa8d42b1584e65d6cc713e9d33b6fa563e0d546d5cb87e545bb0e5/sqlalchemy-2.0.47-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9449f747e50d518c6e1b40cc379e48bfc796453c47b15e627ea901c201e48a6", size = 3279481, upload-time = "2026-02-24T17:27:26.491Z" }, - { url = "https://files.pythonhosted.org/packages/d0/b7/75e1c1970616a9dd64a8a6fd788248da2ddaf81c95f4875f2a1e8aee4128/sqlalchemy-2.0.47-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:21410f60d5cac1d6bfe360e05bd91b179be4fa0aa6eea6be46054971d277608f", size = 3224269, upload-time = "2026-02-24T17:18:15.078Z" }, - { url = "https://files.pythonhosted.org/packages/31/ac/eec1a13b891df9a8bc203334caf6e6aac60b02f61b018ef3b4124b8c4120/sqlalchemy-2.0.47-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:819841dd5bb4324c284c09e2874cf96fe6338bfb57a64548d9b81a4e39c9871f", size = 3246262, upload-time = "2026-02-24T17:27:27.986Z" }, - { url = "https://files.pythonhosted.org/packages/c9/b0/661b0245b06421058610da39f8ceb34abcc90b49f90f256380968d761dbe/sqlalchemy-2.0.47-cp314-cp314-win32.whl", hash = "sha256:e255ee44821a7ef45649c43064cf94e74f81f61b4df70547304b97a351e9b7db", size = 2116528, upload-time = "2026-02-24T17:22:59.363Z" }, - { url = "https://files.pythonhosted.org/packages/aa/ef/1035a90d899e61810791c052004958be622a2cf3eb3df71c3fe20778c5d0/sqlalchemy-2.0.47-cp314-cp314-win_amd64.whl", hash = "sha256:209467ff73ea1518fe1a5aaed9ba75bb9e33b2666e2553af9ccd13387bf192cb", size = 2142181, upload-time = "2026-02-24T17:23:01.001Z" }, - { url = "https://files.pythonhosted.org/packages/76/bb/17a1dd09cbba91258218ceb582225f14b5364d2683f9f5a274f72f2d764f/sqlalchemy-2.0.47-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e78fd9186946afaa287f8a1fe147ead06e5d566b08c0afcb601226e9c7322a64", size = 3563477, upload-time = "2026-02-24T17:12:18.46Z" }, - { url = "https://files.pythonhosted.org/packages/66/8f/1a03d24c40cc321ef2f2231f05420d140bb06a84f7047eaa7eaa21d230ba/sqlalchemy-2.0.47-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5740e2f31b5987ed9619d6912ae5b750c03637f2078850da3002934c9532f172", size = 3528568, upload-time = "2026-02-24T17:28:03.732Z" }, - { url = "https://files.pythonhosted.org/packages/fd/53/d56a213055d6b038a5384f0db5ece7343334aca230ff3f0fa1561106f22c/sqlalchemy-2.0.47-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb9ac00d03de93acb210e8ec7243fefe3e012515bf5fd2f0898c8dff38bc77a4", size = 3472284, upload-time = "2026-02-24T17:12:20.319Z" }, - { url = "https://files.pythonhosted.org/packages/ff/19/c235d81b9cfdd6130bf63143b7bade0dc4afa46c4b634d5d6b2a96bea233/sqlalchemy-2.0.47-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c72a0b9eb2672d70d112cb149fbaf172d466bc691014c496aaac594f1988e706", size = 3478410, upload-time = "2026-02-24T17:28:05.892Z" }, - { url = "https://files.pythonhosted.org/packages/0e/db/cafdeca5ecdaa3bb0811ba5449501da677ce0d83be8d05c5822da72d2e86/sqlalchemy-2.0.47-cp314-cp314t-win32.whl", hash = "sha256:c200db1128d72a71dc3c31c24b42eb9fd85b2b3e5a3c9ba1e751c11ac31250ff", size = 2147164, upload-time = "2026-02-24T17:14:40.783Z" }, - { url = "https://files.pythonhosted.org/packages/fc/5e/ff41a010e9e0f76418b02ad352060a4341bb15f0af66cedc924ab376c7c6/sqlalchemy-2.0.47-cp314-cp314t-win_amd64.whl", hash = "sha256:669837759b84e575407355dcff912835892058aea9b80bd1cb76d6a151cf37f7", size = 2182154, upload-time = "2026-02-24T17:14:43.205Z" }, - { url = "https://files.pythonhosted.org/packages/15/9f/7c378406b592fcf1fc157248607b495a40e3202ba4a6f1372a2ba6447717/sqlalchemy-2.0.47-py3-none-any.whl", hash = "sha256:e2647043599297a1ef10e720cf310846b7f31b6c841fee093d2b09d81215eb93", size = 1940159, upload-time = "2026-02-24T17:15:07.158Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/09/45/461788f35e0364a8da7bda51a1fe1b09762d0c32f12f63727998d85a873b/sqlalchemy-2.0.49.tar.gz", hash = "sha256:d15950a57a210e36dd4cec1aac22787e2a4d57ba9318233e2ef8b2daf9ff2d5f", size = 9898221, upload-time = "2026-04-03T16:38:11.704Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/81/81755f50eb2478eaf2049728491d4ea4f416c1eb013338682173259efa09/sqlalchemy-2.0.49-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:df2d441bacf97022e81ad047e1597552eb3f83ca8a8f1a1fdd43cd7fe3898120", size = 2154547, upload-time = "2026-04-03T16:53:08.64Z" }, + { url = "https://files.pythonhosted.org/packages/a2/bc/3494270da80811d08bcfa247404292428c4fe16294932bce5593f215cad9/sqlalchemy-2.0.49-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8e20e511dc15265fb433571391ba313e10dd8ea7e509d51686a51313b4ac01a2", size = 3280782, upload-time = "2026-04-03T17:07:43.508Z" }, + { url = "https://files.pythonhosted.org/packages/cd/f5/038741f5e747a5f6ea3e72487211579d8cbea5eb9827a9cbd61d0108c4bd/sqlalchemy-2.0.49-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47604cb2159f8bbd5a1ab48a714557156320f20871ee64d550d8bf2683d980d3", size = 3297156, upload-time = "2026-04-03T17:12:27.697Z" }, + { url = "https://files.pythonhosted.org/packages/88/50/a6af0ff9dc954b43a65ca9b5367334e45d99684c90a3d3413fc19a02d43c/sqlalchemy-2.0.49-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:22d8798819f86720bc646ab015baff5ea4c971d68121cb36e2ebc2ee43ead2b7", size = 3228832, upload-time = "2026-04-03T17:07:45.38Z" }, + { url = "https://files.pythonhosted.org/packages/bc/d1/5f6bdad8de0bf546fc74370939621396515e0cdb9067402d6ba1b8afbe9a/sqlalchemy-2.0.49-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9b1c058c171b739e7c330760044803099c7fff11511e3ab3573e5327116a9c33", size = 3267000, upload-time = "2026-04-03T17:12:29.657Z" }, + { url = "https://files.pythonhosted.org/packages/f7/30/ad62227b4a9819a5e1c6abff77c0f614fa7c9326e5a3bdbee90f7139382b/sqlalchemy-2.0.49-cp313-cp313-win32.whl", hash = "sha256:a143af2ea6672f2af3f44ed8f9cd020e9cc34c56f0e8db12019d5d9ecf41cb3b", size = 2115641, upload-time = "2026-04-03T17:05:43.989Z" }, + { url = "https://files.pythonhosted.org/packages/17/3a/7215b1b7d6d49dc9a87211be44562077f5f04f9bb5a59552c1c8e2d98173/sqlalchemy-2.0.49-cp313-cp313-win_amd64.whl", hash = "sha256:12b04d1db2663b421fe072d638a138460a51d5a862403295671c4f3987fb9148", size = 2141498, upload-time = "2026-04-03T17:05:45.7Z" }, + { url = "https://files.pythonhosted.org/packages/28/4b/52a0cb2687a9cd1648252bb257be5a1ba2c2ded20ba695c65756a55a15a4/sqlalchemy-2.0.49-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24bd94bb301ec672d8f0623eba9226cc90d775d25a0c92b5f8e4965d7f3a1518", size = 3560807, upload-time = "2026-04-03T16:58:31.666Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d8/fda95459204877eed0458550d6c7c64c98cc50c2d8d618026737de9ed41a/sqlalchemy-2.0.49-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a51d3db74ba489266ef55c7a4534eb0b8db9a326553df481c11e5d7660c8364d", size = 3527481, upload-time = "2026-04-03T17:06:00.155Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0a/2aac8b78ac6487240cf7afef8f203ca783e8796002dc0cf65c4ee99ff8bb/sqlalchemy-2.0.49-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:55250fe61d6ebfd6934a272ee16ef1244e0f16b7af6cd18ab5b1fc9f08631db0", size = 3468565, upload-time = "2026-04-03T16:58:33.414Z" }, + { url = "https://files.pythonhosted.org/packages/a5/3d/ce71cfa82c50a373fd2148b3c870be05027155ce791dc9a5dcf439790b8b/sqlalchemy-2.0.49-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:46796877b47034b559a593d7e4b549aba151dae73f9e78212a3478161c12ab08", size = 3477769, upload-time = "2026-04-03T17:06:02.787Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e8/0a9f5c1f7c6f9ca480319bf57c2d7423f08d31445974167a27d14483c948/sqlalchemy-2.0.49-cp313-cp313t-win32.whl", hash = "sha256:9c4969a86e41454f2858256c39bdfb966a20961e9b58bf8749b65abf447e9a8d", size = 2143319, upload-time = "2026-04-03T17:02:04.328Z" }, + { url = "https://files.pythonhosted.org/packages/0e/51/fb5240729fbec73006e137c4f7a7918ffd583ab08921e6ff81a999d6517a/sqlalchemy-2.0.49-cp313-cp313t-win_amd64.whl", hash = "sha256:b9870d15ef00e4d0559ae10ee5bc71b654d1f20076dbe8bc7ed19b4c0625ceba", size = 2175104, upload-time = "2026-04-03T17:02:05.989Z" }, + { url = "https://files.pythonhosted.org/packages/55/33/bf28f618c0a9597d14e0b9ee7d1e0622faff738d44fe986ee287cdf1b8d0/sqlalchemy-2.0.49-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:233088b4b99ebcbc5258c755a097aa52fbf90727a03a5a80781c4b9c54347a2e", size = 2156356, upload-time = "2026-04-03T16:53:09.914Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a7/5f476227576cb8644650eff68cc35fa837d3802b997465c96b8340ced1e2/sqlalchemy-2.0.49-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57ca426a48eb2c682dae8204cd89ea8ab7031e2675120a47924fabc7caacbc2a", size = 3276486, upload-time = "2026-04-03T17:07:46.9Z" }, + { url = "https://files.pythonhosted.org/packages/2e/84/efc7c0bf3a1c5eef81d397f6fddac855becdbb11cb38ff957888603014a7/sqlalchemy-2.0.49-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:685e93e9c8f399b0c96a624799820176312f5ceef958c0f88215af4013d29066", size = 3281479, upload-time = "2026-04-03T17:12:32.226Z" }, + { url = "https://files.pythonhosted.org/packages/91/68/bb406fa4257099c67bd75f3f2261b129c63204b9155de0d450b37f004698/sqlalchemy-2.0.49-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e0400fa22f79acc334d9a6b185dc00a44a8e6578aa7e12d0ddcd8434152b187", size = 3226269, upload-time = "2026-04-03T17:07:48.678Z" }, + { url = "https://files.pythonhosted.org/packages/67/84/acb56c00cca9f251f437cb49e718e14f7687505749ea9255d7bd8158a6df/sqlalchemy-2.0.49-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a05977bffe9bffd2229f477fa75eabe3192b1b05f408961d1bebff8d1cd4d401", size = 3248260, upload-time = "2026-04-03T17:12:34.381Z" }, + { url = "https://files.pythonhosted.org/packages/56/19/6a20ea25606d1efd7bd1862149bb2a22d1451c3f851d23d887969201633f/sqlalchemy-2.0.49-cp314-cp314-win32.whl", hash = "sha256:0f2fa354ba106eafff2c14b0cc51f22801d1e8b2e4149342023bd6f0955de5f5", size = 2118463, upload-time = "2026-04-03T17:05:47.093Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4f/8297e4ed88e80baa1f5aa3c484a0ee29ef3c69c7582f206c916973b75057/sqlalchemy-2.0.49-cp314-cp314-win_amd64.whl", hash = "sha256:77641d299179c37b89cf2343ca9972c88bb6eef0d5fc504a2f86afd15cd5adf5", size = 2144204, upload-time = "2026-04-03T17:05:48.694Z" }, + { url = "https://files.pythonhosted.org/packages/1f/33/95e7216df810c706e0cd3655a778604bbd319ed4f43333127d465a46862d/sqlalchemy-2.0.49-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c1dc3368794d522f43914e03312202523cc89692f5389c32bea0233924f8d977", size = 3565474, upload-time = "2026-04-03T16:58:35.128Z" }, + { url = "https://files.pythonhosted.org/packages/0c/a4/ed7b18d8ccf7f954a83af6bb73866f5bc6f5636f44c7731fbb741f72cc4f/sqlalchemy-2.0.49-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c821c47ecfe05cc32140dcf8dc6fd5d21971c86dbd56eabfe5ba07a64910c01", size = 3530567, upload-time = "2026-04-03T17:06:04.587Z" }, + { url = "https://files.pythonhosted.org/packages/73/a3/20faa869c7e21a827c4a2a42b41353a54b0f9f5e96df5087629c306df71e/sqlalchemy-2.0.49-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9c04bff9a5335eb95c6ecf1c117576a0aa560def274876fd156cfe5510fccc61", size = 3474282, upload-time = "2026-04-03T16:58:37.131Z" }, + { url = "https://files.pythonhosted.org/packages/b7/50/276b9a007aa0764304ad467eceb70b04822dc32092492ee5f322d559a4dc/sqlalchemy-2.0.49-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7f605a456948c35260e7b2a39f8952a26f077fd25653c37740ed186b90aaa68a", size = 3480406, upload-time = "2026-04-03T17:06:07.176Z" }, + { url = "https://files.pythonhosted.org/packages/e5/c3/c80fcdb41905a2df650c2a3e0337198b6848876e63d66fe9188ef9003d24/sqlalchemy-2.0.49-cp314-cp314t-win32.whl", hash = "sha256:6270d717b11c5476b0cbb21eedc8d4dbb7d1a956fd6c15a23e96f197a6193158", size = 2149151, upload-time = "2026-04-03T17:02:07.281Z" }, + { url = "https://files.pythonhosted.org/packages/05/52/9f1a62feab6ed368aff068524ff414f26a6daebc7361861035ae00b05530/sqlalchemy-2.0.49-cp314-cp314t-win_amd64.whl", hash = "sha256:275424295f4256fd301744b8f335cff367825d270f155d522b30c7bf49903ee7", size = 2184178, upload-time = "2026-04-03T17:02:08.623Z" }, + { url = "https://files.pythonhosted.org/packages/e5/30/8519fdde58a7bdf155b714359791ad1dc018b47d60269d5d160d311fdc36/sqlalchemy-2.0.49-py3-none-any.whl", hash = "sha256:ec44cfa7ef1a728e88ad41674de50f6db8cfdb3e2af84af86e0041aaf02d43d0", size = 1942158, upload-time = "2026-04-03T16:53:44.135Z" }, ] [[package]]