From 064e672ab73ea98f098fb9ce203506fb1145751c Mon Sep 17 00:00:00 2001 From: Ian Keane Date: Tue, 9 Jun 2026 15:07:08 -0400 Subject: [PATCH] Initial Commit slip slop --- .gitignore | 2 + Makefile | 121 +++++ README.md | 216 +++++++++ ain/README.md | 132 +++++ ain/base.ain | 15 + ain/health.ain | 8 + ain/instruments/delete-banjo.ain | 8 + ain/instruments/delete-fiddle.ain | 8 + ain/instruments/patch-banjo.ain | 14 + ain/instruments/patch-fiddle.ain | 14 + ain/instruments/upsert-banjo.ain | 19 + ain/instruments/upsert-fiddle.ain | 19 + ain/notes/create.ain | 13 + ain/notes/delete.ain | 8 + ain/notes/list.ain | 8 + ain/notes/update.ain | 13 + ain/references/add-musician.ain | 13 + ain/references/create.ain | 18 + ain/references/delete-musician.ain | 8 + ain/references/delete.ain | 8 + ain/references/list.ain | 8 + ain/references/update.ain | 14 + ain/tunes/create.ain | 46 ++ ain/tunes/delete.ain | 8 + ain/tunes/get.ain | 8 + ain/tunes/list-filtered.ain | 21 + ain/tunes/list.ain | 8 + ain/tunes/update.ain | 15 + alembic.ini | 149 ++++++ app/__init__.py | 53 ++ app/auth.py | 17 + app/extensions.py | 3 + app/models.py | 175 +++++++ app/routes_instruments.py | 125 +++++ app/routes_musicians.py | 43 ++ app/routes_sources.py | 43 ++ app/routes_sub.py | 124 +++++ app/routes_tunes.py | 155 ++++++ app/routes_tunings.py | 43 ++ docker/Dockerfile | 20 + docker/docker-compose.yml | 29 ++ env.example | 33 ++ migrations/README | 1 + migrations/env.py | 58 +++ migrations/script.py.mako | 28 ++ .../024f72e7bea5_drop_tune_id_from_tunes.py | 21 + .../versions/2bb981f6ee16_initial_schema.py | 82 ++++ .../2bd4f3ff0ccc_decouple_musicians.py | 65 +++ .../57326830536b_instrument_tunings.py | 29 ++ .../84db90f5499e_tune_by_instrument.py | 90 ++++ .../versions/abdeb80d003e_add_tunings.py | 32 ++ .../versions/ea4bb85f9908_add_sources.py | 34 ++ nixos/module.nix | 200 ++++++++ pyproject.toml | 19 + tests/conftest.py | 53 ++ tests/test_instruments.py | 130 +++++ tests/test_sources.py | 70 +++ tests/test_sub.py | 133 +++++ tests/test_tunes.py | 188 ++++++++ uv.lock | 456 ++++++++++++++++++ wsgi.py | 10 + 61 files changed, 3504 insertions(+) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 README.md create mode 100644 ain/README.md create mode 100644 ain/base.ain create mode 100644 ain/health.ain create mode 100644 ain/instruments/delete-banjo.ain create mode 100644 ain/instruments/delete-fiddle.ain create mode 100644 ain/instruments/patch-banjo.ain create mode 100644 ain/instruments/patch-fiddle.ain create mode 100644 ain/instruments/upsert-banjo.ain create mode 100644 ain/instruments/upsert-fiddle.ain create mode 100644 ain/notes/create.ain create mode 100644 ain/notes/delete.ain create mode 100644 ain/notes/list.ain create mode 100644 ain/notes/update.ain create mode 100644 ain/references/add-musician.ain create mode 100644 ain/references/create.ain create mode 100644 ain/references/delete-musician.ain create mode 100644 ain/references/delete.ain create mode 100644 ain/references/list.ain create mode 100644 ain/references/update.ain create mode 100644 ain/tunes/create.ain create mode 100644 ain/tunes/delete.ain create mode 100644 ain/tunes/get.ain create mode 100644 ain/tunes/list-filtered.ain create mode 100644 ain/tunes/list.ain create mode 100644 ain/tunes/update.ain create mode 100644 alembic.ini create mode 100644 app/__init__.py create mode 100644 app/auth.py create mode 100644 app/extensions.py create mode 100644 app/models.py create mode 100644 app/routes_instruments.py create mode 100644 app/routes_musicians.py create mode 100644 app/routes_sources.py create mode 100644 app/routes_sub.py create mode 100644 app/routes_tunes.py create mode 100644 app/routes_tunings.py create mode 100644 docker/Dockerfile create mode 100644 docker/docker-compose.yml create mode 100644 env.example create mode 100644 migrations/README create mode 100644 migrations/env.py create mode 100644 migrations/script.py.mako create mode 100644 migrations/versions/024f72e7bea5_drop_tune_id_from_tunes.py create mode 100644 migrations/versions/2bb981f6ee16_initial_schema.py create mode 100644 migrations/versions/2bd4f3ff0ccc_decouple_musicians.py create mode 100644 migrations/versions/57326830536b_instrument_tunings.py create mode 100644 migrations/versions/84db90f5499e_tune_by_instrument.py create mode 100644 migrations/versions/abdeb80d003e_add_tunings.py create mode 100644 migrations/versions/ea4bb85f9908_add_sources.py create mode 100644 nixos/module.nix create mode 100644 pyproject.toml create mode 100644 tests/conftest.py create mode 100644 tests/test_instruments.py create mode 100644 tests/test_sources.py create mode 100644 tests/test_sub.py create mode 100644 tests/test_tunes.py create mode 100644 uv.lock create mode 100644 wsgi.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..112f331 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__ +.env diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..deaf316 --- /dev/null +++ b/Makefile @@ -0,0 +1,121 @@ +# ── repertory-api Makefile ──────────────────────────────────────────────────── +# +# make local — first-time (or repeated) local dev setup: +# copies env.example → .env if missing, +# starts Postgres via Docker Compose, +# waits for it to be ready, +# runs Alembic migrations, +# starts the API container. +# +# make stop — stop all Docker Compose services (data is preserved). +# make reset — stop services AND wipe the Postgres volume (fresh slate). +# make migrate — run pending Alembic migrations against the local DB. +# make test — run the pytest suite (SQLite in-memory, no Docker needed). +# make logs — tail Docker Compose logs. +# make shell — open a psql shell in the running Postgres container. +# ───────────────────────────────────────────────────────────────────────────── + +COMPOSE = docker compose -f docker/docker-compose.yml --env-file .env +ALEMBIC = PYTHONPATH=. uv run alembic +PYTEST = PYTHONPATH=. uv run pytest +PY = uv run python + +DB_HOST = localhost +DB_USER = repertory +DB_NAME = tunes + +.PHONY: help local stop reset migrate test logs shell sync + +# Default target +help: + @echo "" + @echo " make local — set up and start local dev environment" + @echo " make stop — stop Docker services" + @echo " make reset — stop services and wipe database volume" + @echo " make migrate — run pending Alembic migrations" + @echo " make test — run test suite (no Docker required)" + @echo " make logs — tail Docker Compose logs" + @echo " make shell — open a psql shell in the Postgres container" + @echo "" + +# ── Sync dependencies ───────────────────────────────────────────────────────── + +sync: + uv sync + +# ── Local dev ───────────────────────────────────────────────────────────────── + +local: sync _copy_env _db_up _wait_for_db migrate _api_up + @echo "" + @echo " ✅ Local environment is ready." + @echo "" + @set -a; . ./.env; set +a; \ + echo " API: http://localhost:$${API_PORT:-5000}" + @echo " API key: $$(grep '^API_KEY=' .env | cut -d= -f2)" + @echo "" + @echo " Run 'make logs' to tail the logs." + @echo " Run 'make stop' to stop services." + @echo "" + +_copy_env: + @if [ ! -f .env ]; then \ + cp env.example .env; \ + echo " 📋 Copied env.example → .env"; \ + echo " API key is set to: $$(grep '^API_KEY=' .env | cut -d= -f2)"; \ + echo " Edit .env to change it before deploying to production."; \ + else \ + echo " ✔ .env already exists — skipping copy."; \ + fi + +_db_up: + @echo " 🐳 Starting Postgres…" + $(COMPOSE) up -d db + +_wait_for_db: + @echo " ⏳ Waiting for Postgres to be ready…" + @set -a; . ./.env; set +a; \ + for i in $$(seq 1 30); do \ + if pg_isready -U $(DB_USER) -d $(DB_NAME) -h $(DB_HOST) -p $${DB_PORT:-5432} \ + > /dev/null 2>&1; then \ + echo " ✔ Postgres is ready."; \ + break; \ + fi; \ + if [ $$i -eq 30 ]; then \ + echo " ❌ Timed out waiting for Postgres."; exit 1; \ + fi; \ + sleep 1; \ + done + +_api_up: + @echo " 🐳 Starting API container…" + $(COMPOSE) up -d --build api + +# ── Migrations ──────────────────────────────────────────────────────────────── + +migrate: sync + @echo " 🗄 Running Alembic migrations…" + @set -a; . ./.env; set +a; $(ALEMBIC) upgrade head + @echo " ✔ Migrations complete." + +# ── Tests ───────────────────────────────────────────────────────────────────── +# Uses SQLite in-memory — no Docker or network required. + +test: sync + @echo " 🧪 Running tests…" + $(PYTEST) tests/ -v + +# ── Utilities ───────────────────────────────────────────────────────────────── + +stop: + $(COMPOSE) stop + +reset: + @echo " ⚠️ Stopping services and removing database volume…" + $(COMPOSE) down -v + @echo " ✔ Done. Run 'make local' to start fresh." + +logs: + $(COMPOSE) logs -f + +shell: + $(COMPOSE) exec db psql -U $(DB_USER) -d $(DB_NAME) diff --git a/README.md b/README.md new file mode 100644 index 0000000..aed1e00 --- /dev/null +++ b/README.md @@ -0,0 +1,216 @@ +# repertory-api + +Flask + SQLAlchemy REST API for the Repertory tune-tracking database. + +## Stack + +- **Flask 3** – HTTP API +- **SQLAlchemy 2 / Flask-SQLAlchemy** – ORM +- **PostgreSQL** – database (`tunes`) +- **Alembic** – migrations +- **uv** – dependency management +- **Docker Compose** – local development +- **NixOS systemd module** – production deployment + +--- + +## Local development + +### First time setup + +```bash +make local +``` + +This will: +1. Copy `env.example` → `.env` (skipped if `.env` already exists) +2. Start Postgres via Docker Compose +3. Wait for Postgres to be ready +4. Run Alembic migrations +5. Start the API container + +At the end it prints the API URL and your local API key: + +``` + ✅ Local environment is ready. + + API: http://localhost:5000 + API key: dev-secret-key-change-me +``` + +The API key is set in `.env` as `API_KEY`. Change it there if you want — it only affects your local dev environment. Production keys are managed via SOPS (see below). + +### Subsequent runs + +```bash +make local # idempotent — safe to re-run, skips what's already done +make stop # stop containers (data preserved) +make reset # stop containers AND wipe the database volume (fresh slate) +``` + +### Other useful commands + +```bash +make test # run the test suite (SQLite in-memory, no Docker required) +make migrate # run pending Alembic migrations against the local DB +make logs # tail Docker Compose logs +make shell # open a psql shell in the running Postgres container +``` + +Run `make` or `make help` to see all available targets. + +### Adding a schema migration + +After changing a model in `app/models.py`: + +```bash +# With the local DB running: +uv run alembic revision --autogenerate -m "describe your change" +make migrate +``` + +Commit both the model change and the new file in `migrations/versions/`. + +--- + +## API Endpoints + +### Health + +| Method | Path | Description | +|--------|-----------|--------------| +| GET | `/health` | Health check | + +### Tunes + +| Method | Path | Description | +|--------|---------------|----------------------------------------------------------| +| GET | `/tunes/` | List all tunes (supports query filters — see below) | +| GET | `/tunes/` | Get a single tune with all sub-records | +| POST | `/tunes/` | Create a tune (optionally with nested banjo/fiddle/etc.) | +| PATCH | `/tunes/` | Update tune core fields | +| DELETE | `/tunes/` | Delete a tune (cascades to all sub-records) | + +**GET `/tunes/` query params** (all optional, combinable): + +| Param | Example | Notes | +|--------------|----------------------|------------------------------------| +| `search` | `?search=cluck` | Substring match on name | +| `key` | `?key=D` | Exact key match (case-insensitive) | +| `instrument` | `?instrument=fiddle` | Filter by instrument | +| `tuning` | `?tuning=cross` | Substring match on tuning | +| `callable` | `?callable=true` | Boolean | +| `review` | `?review=false` | Boolean | +| `to_learn` | `?to_learn=true` | Boolean | +| `difficulty` | `?difficulty=easy` | "easy" or "hard" | + +`instrument` must be set for `tuning`, `callable`, `review`, `to_learn`, and `difficulty` filters to apply. + +### Instrument sub-records (banjo / fiddle) + +| Method | Path | Description | +|--------|-----------------------|-------------------------------------| +| PUT | `/tunes//banjo` | Create or fully replace banjo entry | +| PUT | `/tunes//fiddle` | Create or fully replace fiddle entry| +| PATCH | `/tunes//banjo` | Partially update banjo entry | +| PATCH | `/tunes//fiddle` | Partially update fiddle entry | +| DELETE | `/tunes//banjo` | Remove banjo entry | +| DELETE | `/tunes//fiddle` | Remove fiddle entry | + +Instrument fields: `learned_from`, `date_learned` (ISO date string), `tuning`, `callable` (bool), `review` (bool), `to_learn` (bool), `difficulty` ("easy"/"hard"). + +### Notes + +| Method | Path | Description | +|--------|-----------------------------------|---------------| +| GET | `/tunes//notes` | List notes | +| POST | `/tunes//notes` | Add a note | +| PATCH | `/tunes//notes/` | Update a note | +| DELETE | `/tunes//notes/` | Delete a note | + +### References + +| Method | Path | Description | +|--------|----------------------------------------------------|--------------------| +| GET | `/tunes//references` | List references | +| POST | `/tunes//references` | Add a reference | +| PATCH | `/tunes//references/` | Update a reference | +| DELETE | `/tunes//references/` | Delete a reference | +| POST | `/tunes//references//musicians` | Add a musician tag | +| DELETE | `/tunes//references//musicians/` | Remove a musician | + +--- + +## Production deployment (NixOS + SOPS + age) + +The NixOS module in [`nixos/module.nix`](nixos/module.nix) fetches the application source directly from your Forgejo instance at a pinned tag, builds the Python environment, and manages the service under systemd — no manual file copying required. + +### 1. Add secrets to your SOPS file + +```yaml +repertory-api-key: "your-api-key-here" +repertory-db-url: "postgresql://user:pass@host:5432/tunes" +``` + +### 2. Wire up the module in `configuration.nix` + +```nix +imports = [ (fetchTarball { + url = "https://git.yourdomain.com/yourusername/repertory-api/archive/v1.0.0.tar.gz"; + sha256 = "0000000000000000000000000000000000000000000000000000"; +} + "/nixos/module.nix") ]; + +sops.secrets."repertory-api-key" = {}; +sops.secrets."repertory-db-url" = {}; + +services.repertory-api = { + enable = true; + domain = "git.yourdomain.com"; + owner = "yourusername"; + version = "v1.0.0"; + rev = "abc123..."; # git commit SHA for the tag + sha256 = "sha256-..."; # see below +}; +``` + +### 3. Deploy + +```bash +nixos-rebuild switch +``` + +On start, the service automatically runs `alembic upgrade head` before Flask comes up — so migrations are applied on every deploy with no manual step. + +### Updating to a new version + +1. Push a new tag to Forgejo (e.g. `v1.1.0`) +2. Get the new sha256: + ```bash + nix-prefetch-url --unpack \ + https://git.yourdomain.com/yourusername/repertory-api/archive/v1.1.0.tar.gz + ``` +3. Update `version`, `rev`, and `sha256` in `configuration.nix` +4. `nixos-rebuild switch` — Nix fetches the new source, rebuilds the Python env, restarts the service, and runs any new migrations + +### How secrets are handled + +The `preStart` script reads the SOPS-decrypted secret files (written to `/run/secrets/` by sops-nix) and assembles them into a mode-600 env file at `/run/repertory-api/env` on tmpfs. The Flask process receives `API_KEY` and `DATABASE_URL` from that file. Nothing is written to disk in plaintext. + +--- + +## Database schema + +``` +musicians (id, name) +sources (id, name) +tunings (id, name) +instruments (id, name) +tunes (id, name, key, modal, source_id → sources.id) + ├── tune_by_instrument (id, tune_id, instrument_id, learned_from_id, tuning_id, date_learned, callable, review, to_learn, difficulty) + │ └── tune_by_instrument_notes (id, tune_by_instrument_id, note) + ├── tune_notes (id, tune_id, note) + └── references (id, tune_id, link, site) + └── reference_musicians (reference_id, musician_id) ← join table +``` + +To add a new instrument (e.g. harmonica): add a model in `app/models.py` mirroring `Banjo`/`Fiddle`, register the name in `INSTRUMENT_MODELS` in `app/routes_instruments.py`, then `uv run alembic revision --autogenerate -m "add harmonica"` and `make migrate`. diff --git a/ain/README.md b/ain/README.md new file mode 100644 index 0000000..ad3e5c3 --- /dev/null +++ b/ain/README.md @@ -0,0 +1,132 @@ +# ain templates + +[ain](https://github.com/jonaslu/ain) is a terminal HTTP client. These templates cover every API endpoint. + +## Setup + +### 1. Install ain + +```bash +brew install ain # macOS +# or: go install github.com/jonaslu/ain/cmd/ain@latest +``` + +ain requires `curl`, `wget`, or `httpie` to be available — `curl` is already on macOS. + +### 2. Configure connection details + +ain picks up variables from the project root `.env` automatically when run from the project root. `make local` will have already created that file for you. The relevant variables are: + +```bash +API_KEY=dev-secret-key-change-me +REPERTORY_HOST=http://localhost:5000 +``` + +### 3. Run a template + +Every template is run by combining `base.ain` with a specific template file. `base.ain` provides the host and auth header; the specific file adds the path, method, and body. + +```bash +ain ain/base.ain ain/health.ain +``` + +--- + +## Templates + +### Health + +```bash +ain ain/base.ain ain/health.ain +``` + +### Tunes + +```bash +# List all tunes +ain ain/base.ain ain/tunes/list.ain + +# List with filters (edit the [Query] section in the file first) +ain ain/base.ain ain/tunes/list-filtered.ain + +# Get a single tune +ain ain/base.ain ain/tunes/get.ain --vars ID=1 + +# Create a tune (edit the [Body] in the file first) +ain ain/base.ain ain/tunes/create.ain + +# Update core fields +ain ain/base.ain ain/tunes/update.ain --vars ID=1 + +# Delete a tune +ain ain/base.ain ain/tunes/delete.ain --vars ID=1 +``` + +### Instruments (banjo / fiddle) + +```bash +# Create or replace a banjo entry +ain ain/base.ain ain/instruments/upsert-banjo.ain --vars ID=1 + +# Create or replace a fiddle entry +ain ain/base.ain ain/instruments/upsert-fiddle.ain --vars ID=1 + +# Patch individual banjo fields +ain ain/base.ain ain/instruments/patch-banjo.ain --vars ID=1 + +# Patch individual fiddle fields +ain ain/base.ain ain/instruments/patch-fiddle.ain --vars ID=1 + +# Delete banjo entry +ain ain/base.ain ain/instruments/delete-banjo.ain --vars ID=1 + +# Delete fiddle entry +ain ain/base.ain ain/instruments/delete-fiddle.ain --vars ID=1 +``` + +### Notes + +```bash +# List notes for a tune +ain ain/base.ain ain/notes/list.ain --vars ID=1 + +# Add a note +ain ain/base.ain ain/notes/create.ain --vars ID=1 + +# Update a note +ain ain/base.ain ain/notes/update.ain --vars ID=1 NOTE_ID=2 + +# Delete a note +ain ain/base.ain ain/notes/delete.ain --vars ID=1 NOTE_ID=2 +``` + +### References + +```bash +# List references for a tune +ain ain/base.ain ain/references/list.ain --vars ID=1 + +# Add a reference (with optional musicians in the body) +ain ain/base.ain ain/references/create.ain --vars ID=1 + +# Update a reference +ain ain/base.ain ain/references/update.ain --vars ID=1 REF_ID=2 + +# Delete a reference +ain ain/base.ain ain/references/delete.ain --vars ID=1 REF_ID=2 + +# Add a musician to a reference +ain ain/base.ain ain/references/add-musician.ain --vars ID=1 REF_ID=2 + +# Remove a musician from a reference +ain ain/base.ain ain/references/delete-musician.ain --vars ID=1 REF_ID=2 NAME="Tony Rice" +``` + +--- + +## Tips + +- **Print without sending**: `ain -p ain/base.ain ain/tunes/list.ain` — shows the curl command ain would run, useful for debugging +- **Pipe output**: `ain ain/base.ain ain/tunes/list.ain | jq .` — pipe straight into jq or anything else +- **Multiple filters**: edit `ain/tunes/list-filtered.ain` and remove the query params you don't need before running +- **Musician names with spaces**: quote the whole `--vars` value: `--vars NAME="Doc Watson"` diff --git a/ain/base.ain b/ain/base.ain new file mode 100644 index 0000000..3cb1a93 --- /dev/null +++ b/ain/base.ain @@ -0,0 +1,15 @@ +# ain/base.ain +# Shared host and auth header — included by every other template. +# Variables are loaded from the project root .env automatically when +# ain is run from the project root. +# See env.example for the full list of variables. + +[Host] +${REPERTORY_HOST} + +[Headers] +X-API-Key: ${API_KEY} +Content-Type: application/json + +[Backend] +curl diff --git a/ain/health.ain b/ain/health.ain new file mode 100644 index 0000000..6c275da --- /dev/null +++ b/ain/health.ain @@ -0,0 +1,8 @@ +# Health check +# ain ain/base.ain ain/health.ain + +[Host] +/health + +[Method] +GET diff --git a/ain/instruments/delete-banjo.ain b/ain/instruments/delete-banjo.ain new file mode 100644 index 0000000..390d596 --- /dev/null +++ b/ain/instruments/delete-banjo.ain @@ -0,0 +1,8 @@ +# Delete a banjo entry for a tune +# ain ain/base.ain ain/instruments/delete-banjo.ain --vars ID=1 + +[Host] +/tunes/${ID}/banjo + +[Method] +DELETE diff --git a/ain/instruments/delete-fiddle.ain b/ain/instruments/delete-fiddle.ain new file mode 100644 index 0000000..7305cbb --- /dev/null +++ b/ain/instruments/delete-fiddle.ain @@ -0,0 +1,8 @@ +# Delete a fiddle entry for a tune +# ain ain/base.ain ain/instruments/delete-fiddle.ain --vars ID=1 + +[Host] +/tunes/${ID}/fiddle + +[Method] +DELETE diff --git a/ain/instruments/patch-banjo.ain b/ain/instruments/patch-banjo.ain new file mode 100644 index 0000000..455b5f8 --- /dev/null +++ b/ain/instruments/patch-banjo.ain @@ -0,0 +1,14 @@ +# Partially update a banjo entry — only include fields you want to change +# ain ain/base.ain ain/instruments/patch-banjo.ain --vars ID=1 + +[Host] +/tunes/${ID}/banjo + +[Method] +PATCH + +[Body] +{ + "callable": true, + "review": false +} diff --git a/ain/instruments/patch-fiddle.ain b/ain/instruments/patch-fiddle.ain new file mode 100644 index 0000000..575d352 --- /dev/null +++ b/ain/instruments/patch-fiddle.ain @@ -0,0 +1,14 @@ +# Partially update a fiddle entry — only include fields you want to change +# ain ain/base.ain ain/instruments/patch-fiddle.ain --vars ID=1 + +[Host] +/tunes/${ID}/fiddle + +[Method] +PATCH + +[Body] +{ + "tuning": "GDAD", + "to_learn": false +} diff --git a/ain/instruments/upsert-banjo.ain b/ain/instruments/upsert-banjo.ain new file mode 100644 index 0000000..76353a4 --- /dev/null +++ b/ain/instruments/upsert-banjo.ain @@ -0,0 +1,19 @@ +# Create or fully replace a banjo entry for a tune +# ain ain/base.ain ain/instruments/upsert-banjo.ain --vars ID=1 + +[Host] +/tunes/${ID}/banjo + +[Method] +PUT + +[Body] +{ + "tuning": "double C", + "learned_from": "Earl Scruggs", + "date_learned": "2024-03-01", + "callable": true, + "review": false, + "to_learn": false, + "difficulty": null +} diff --git a/ain/instruments/upsert-fiddle.ain b/ain/instruments/upsert-fiddle.ain new file mode 100644 index 0000000..032d688 --- /dev/null +++ b/ain/instruments/upsert-fiddle.ain @@ -0,0 +1,19 @@ +# Create or fully replace a fiddle entry for a tune +# ain ain/base.ain ain/instruments/upsert-fiddle.ain --vars ID=1 + +[Host] +/tunes/${ID}/fiddle + +[Method] +PUT + +[Body] +{ + "tuning": "cross", + "learned_from": "Alison Krauss", + "date_learned": "2024-06-15", + "callable": false, + "review": true, + "to_learn": false, + "difficulty": null +} diff --git a/ain/notes/create.ain b/ain/notes/create.ain new file mode 100644 index 0000000..bbbca24 --- /dev/null +++ b/ain/notes/create.ain @@ -0,0 +1,13 @@ +# Add a note to a tune +# ain ain/base.ain ain/notes/create.ain --vars ID=1 + +[Host] +/tunes/${ID}/notes + +[Method] +POST + +[Body] +{ + "note": "tricky B part — watch the timing" +} diff --git a/ain/notes/delete.ain b/ain/notes/delete.ain new file mode 100644 index 0000000..abdb658 --- /dev/null +++ b/ain/notes/delete.ain @@ -0,0 +1,8 @@ +# Delete a note +# ain ain/base.ain ain/notes/delete.ain --vars ID=1 NOTE_ID=2 + +[Host] +/tunes/${ID}/notes/${NOTE_ID} + +[Method] +DELETE diff --git a/ain/notes/list.ain b/ain/notes/list.ain new file mode 100644 index 0000000..a6f1517 --- /dev/null +++ b/ain/notes/list.ain @@ -0,0 +1,8 @@ +# List all notes for a tune +# ain ain/base.ain ain/notes/list.ain --vars ID=1 + +[Host] +/tunes/${ID}/notes + +[Method] +GET diff --git a/ain/notes/update.ain b/ain/notes/update.ain new file mode 100644 index 0000000..476aecf --- /dev/null +++ b/ain/notes/update.ain @@ -0,0 +1,13 @@ +# Update a note +# ain ain/base.ain ain/notes/update.ain --vars ID=1 NOTE_ID=2 + +[Host] +/tunes/${ID}/notes/${NOTE_ID} + +[Method] +PATCH + +[Body] +{ + "note": "updated note text" +} diff --git a/ain/references/add-musician.ain b/ain/references/add-musician.ain new file mode 100644 index 0000000..5726c95 --- /dev/null +++ b/ain/references/add-musician.ain @@ -0,0 +1,13 @@ +# Add a musician tag to a reference +# ain ain/base.ain ain/references/add-musician.ain --vars ID=1 REF_ID=2 + +[Host] +/tunes/${ID}/references/${REF_ID}/musicians + +[Method] +POST + +[Body] +{ + "name": "Tony Rice" +} diff --git a/ain/references/create.ain b/ain/references/create.ain new file mode 100644 index 0000000..929e88c --- /dev/null +++ b/ain/references/create.ain @@ -0,0 +1,18 @@ +# Add a reference to a tune (with optional musicians) +# ain ain/base.ain ain/references/create.ain --vars ID=1 + +[Host] +/tunes/${ID}/references + +[Method] +POST + +[Body] +{ + "link": "https://www.youtube.com/watch?v=example", + "site": "YouTube", + "musicians": [ + { "name": "Doc Watson" }, + { "name": "Merle Watson" } + ] +} diff --git a/ain/references/delete-musician.ain b/ain/references/delete-musician.ain new file mode 100644 index 0000000..52ddb1a --- /dev/null +++ b/ain/references/delete-musician.ain @@ -0,0 +1,8 @@ +# Remove a musician tag from a reference +# ain ain/base.ain ain/references/delete-musician.ain --vars ID=1 REF_ID=2 NAME="Tony Rice" + +[Host] +/tunes/${ID}/references/${REF_ID}/musicians/${NAME} + +[Method] +DELETE diff --git a/ain/references/delete.ain b/ain/references/delete.ain new file mode 100644 index 0000000..10575db --- /dev/null +++ b/ain/references/delete.ain @@ -0,0 +1,8 @@ +# Delete a reference (cascades to its musicians) +# ain ain/base.ain ain/references/delete.ain --vars ID=1 REF_ID=2 + +[Host] +/tunes/${ID}/references/${REF_ID} + +[Method] +DELETE diff --git a/ain/references/list.ain b/ain/references/list.ain new file mode 100644 index 0000000..b883339 --- /dev/null +++ b/ain/references/list.ain @@ -0,0 +1,8 @@ +# List all references for a tune +# ain ain/base.ain ain/references/list.ain --vars ID=1 + +[Host] +/tunes/${ID}/references + +[Method] +GET diff --git a/ain/references/update.ain b/ain/references/update.ain new file mode 100644 index 0000000..ed9f88c --- /dev/null +++ b/ain/references/update.ain @@ -0,0 +1,14 @@ +# Update a reference (link and/or site) +# ain ain/base.ain ain/references/update.ain --vars ID=1 REF_ID=2 + +[Host] +/tunes/${ID}/references/${REF_ID} + +[Method] +PATCH + +[Body] +{ + "site": "TuneArch", + "link": "https://tunearchy.com/example" +} diff --git a/ain/tunes/create.ain b/ain/tunes/create.ain new file mode 100644 index 0000000..aed039d --- /dev/null +++ b/ain/tunes/create.ain @@ -0,0 +1,46 @@ +# Create a tune with nested banjo, fiddle, notes, and references in one shot. +# All fields except the top-level ones are optional — remove any block you don't need. +# ain ain/base.ain ain/tunes/create.ain + +[Host] +/tunes/ + +[Method] +POST + +[Body] +{ + "name": "Cluck Old Hen", + "key": "A", + "version": "full", + "banjo": { + "tuning": "double C", + "learned_from": "Earl Scruggs", + "date_learned": "2024-03-01", + "callable": true, + "review": false, + "to_learn": false, + "difficulty": null + }, + "fiddle": { + "tuning": "cross", + "learned_from": "Alison Krauss", + "date_learned": "2024-06-15", + "callable": false, + "review": true, + "to_learn": false, + "difficulty": null + }, + "notes": [ + { "note": "tricky B part — watch the timing" } + ], + "references": [ + { + "link": "https://www.youtube.com/watch?v=example", + "site": "YouTube", + "musicians": [ + { "name": "Alison Krauss" } + ] + } + ] +} diff --git a/ain/tunes/delete.ain b/ain/tunes/delete.ain new file mode 100644 index 0000000..528a39a --- /dev/null +++ b/ain/tunes/delete.ain @@ -0,0 +1,8 @@ +# Delete a tune (cascades to all sub-records) +# ain ain/base.ain ain/tunes/delete.ain --vars ID=1 + +[Host] +/tunes/${ID} + +[Method] +DELETE diff --git a/ain/tunes/get.ain b/ain/tunes/get.ain new file mode 100644 index 0000000..d28e285 --- /dev/null +++ b/ain/tunes/get.ain @@ -0,0 +1,8 @@ +# Get a single tune by ID +# ain ain/base.ain ain/tunes/get.ain --vars ID=1 + +[Host] +/tunes/${ID} + +[Method] +GET diff --git a/ain/tunes/list-filtered.ain b/ain/tunes/list-filtered.ain new file mode 100644 index 0000000..9094d73 --- /dev/null +++ b/ain/tunes/list-filtered.ain @@ -0,0 +1,21 @@ +# Filter tunes — all params are optional, combine as needed +# ain ain/base.ain ain/tunes/list-filtered.ain +# +# Tweak the [Query] section before running. +# instrument must be set for tuning/callable/review/to_learn/difficulty to apply. + +[Host] +/tunes/ + +[Query] +instrument=fiddle +key=A +tuning=cross +callable=true +review=false +to_learn=false +difficulty=easy +search=cluck + +[Method] +GET diff --git a/ain/tunes/list.ain b/ain/tunes/list.ain new file mode 100644 index 0000000..19af77d --- /dev/null +++ b/ain/tunes/list.ain @@ -0,0 +1,8 @@ +# List all tunes (no filters) +# ain ain/base.ain ain/tunes/list.ain + +[Host] +/tunes/ + +[Method] +GET diff --git a/ain/tunes/update.ain b/ain/tunes/update.ain new file mode 100644 index 0000000..0b5f8a4 --- /dev/null +++ b/ain/tunes/update.ain @@ -0,0 +1,15 @@ +# Update core tune fields (name, key, version) +# ain ain/base.ain ain/tunes/update.ain --vars ID=1 + +[Host] +/tunes/${ID} + +[Method] +PATCH + +[Body] +{ + "name": "Cluck Old Hen", + "key": "D", + "version": "A part only" +} diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..08f1d9f --- /dev/null +++ b/alembic.ini @@ -0,0 +1,149 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +# this is typically a path given in POSIX (e.g. forward slashes) +# format, relative to the token %(here)s which refers to the location of this +# ini file +script_location = %(here)s/migrations + +# 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 +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s +# Or organize into date-based subdirectories (requires recursive_version_locations = true) +# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. for multiple paths, the path separator +# is defined by "path_separator" below. +prepend_sys_path = . + + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the tzdata library which can be installed by adding +# `alembic[tz]` to the pip requirements. +# string value is passed to ZoneInfo() +# leave blank for localtime +# 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 /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 "path_separator" +# below. +# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions + +# path_separator; This indicates what character is used to split lists of file +# paths, including version_locations and prepend_sys_path within configparser +# files such as alembic.ini. +# The default rendered in new alembic.ini files is "os", which uses os.pathsep +# to provide os-dependent path splitting. +# +# Note that in order to support legacy alembic.ini files, this default does NOT +# take place if path_separator is not present in alembic.ini. If this +# option is omitted entirely, fallback logic is as follows: +# +# 1. Parsing of the version_locations option falls back to using the legacy +# "version_path_separator" key, which if absent then falls back to the legacy +# behavior of splitting on spaces and/or commas. +# 2. Parsing of the prepend_sys_path option falls back to the legacy +# behavior of splitting on spaces, commas, or colons. +# +# Valid values for path_separator are: +# +# path_separator = : +# path_separator = ; +# path_separator = space +# path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +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 + +# database URL. This is consumed by the user-maintained env.py script only. +# other means of configuring database URLs may be customized within the env.py +# file. +sqlalchemy.url = driver://user:pass@localhost/dbname + + +[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 module runner, against the "ruff" module +# hooks = ruff +# ruff.type = module +# ruff.module = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Alternatively, use the exec runner to execute a binary found on your PATH +# hooks = ruff +# ruff.type = exec +# ruff.executable = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Logging configuration. This is also consumed by the user-maintained +# env.py script only. +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +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/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e648f13 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,53 @@ +"""Flask application factory.""" +import os +from flask import Flask +from flask_cors import CORS +from app.extensions import db +from app.routes_tunes import tunes_bp +from app.routes_instruments import instruments_bp +from app.routes_sub import sub_bp +from app.routes_sources import sources_bp +from app.routes_musicians import musicians_bp +from app.routes_tunings import tunings_bp + + +def create_app(): + app = Flask(__name__) + + # --- Config --- + app.config["SQLALCHEMY_DATABASE_URI"] = os.environ["DATABASE_URL"] + app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + + # --- CORS --- + # CORS_ORIGINS in .env is a comma-separated list of allowed origins. + # Defaults to * for local dev. Lock this down in production to your + # S3 bucket / CloudFront domain, e.g.: + # CORS_ORIGINS=https://yourdomain.com,https://www.yourdomain.com + raw = os.environ.get("CORS_ORIGINS", "*") + origins = [o.strip() for o in raw.split(",")] if raw != "*" else "*" + CORS(app, origins=origins, supports_credentials=False) + + # --- Extensions --- + db.init_app(app) + + # --- Blueprints --- + app.register_blueprint(tunes_bp) + app.register_blueprint(instruments_bp) + app.register_blueprint(sub_bp) + app.register_blueprint(sources_bp) + app.register_blueprint(musicians_bp) + app.register_blueprint(tunings_bp) + + # --- Error handlers --- + from sqlalchemy.exc import IntegrityError + + @app.errorhandler(IntegrityError) + def handle_integrity_error(e): + db.session.rollback() + return {"error": "Conflict — duplicate or constraint violation"}, 409 + + @app.get("/health") + def health(): + return {"status": "ok"} + + return app diff --git a/app/auth.py b/app/auth.py new file mode 100644 index 0000000..a4d17e0 --- /dev/null +++ b/app/auth.py @@ -0,0 +1,17 @@ +"""Simple API-key authentication decorator.""" +import os +from functools import wraps +from flask import request, jsonify + + +def require_api_key(f): + @wraps(f) + def decorated(*args, **kwargs): + key = request.headers.get("X-API-Key") or request.args.get("api_key") + expected = os.environ.get("API_KEY", "") + if not expected: + return jsonify({"error": "API key not configured on server"}), 500 + if key != expected: + return jsonify({"error": "Unauthorized"}), 401 + return f(*args, **kwargs) + return decorated diff --git a/app/extensions.py b/app/extensions.py new file mode 100644 index 0000000..f0b13d6 --- /dev/null +++ b/app/extensions.py @@ -0,0 +1,3 @@ +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..aa67a3c --- /dev/null +++ b/app/models.py @@ -0,0 +1,175 @@ +from sqlalchemy import Boolean, Column, Date, ForeignKey, Integer, String, Table, UniqueConstraint +from app.extensions import db + + +# ── Many-to-many: references ↔ musicians ───────────────────── +reference_musicians = Table( + "reference_musicians", + db.metadata, + Column("reference_id", Integer, ForeignKey("references.id", ondelete="CASCADE"), primary_key=True), + Column("musician_id", Integer, ForeignKey("musicians.id", ondelete="CASCADE"), primary_key=True), +) + + +class Musician(db.Model): + __tablename__ = "musicians" + + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String, nullable=False) + + references = db.relationship("Reference", secondary=reference_musicians, back_populates="musicians") + instrument_entries = db.relationship("TuneByInstrument", back_populates="learned_from_musician") + + def to_dict(self): + return {"id": self.id, "name": self.name} + + +class Source(db.Model): + __tablename__ = "sources" + + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String, nullable=False) + + tunes = db.relationship("Tune", back_populates="source") + + def to_dict(self): + return {"id": self.id, "name": self.name} + + +class Instrument(db.Model): + __tablename__ = "instruments" + + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String, nullable=False, unique=True) + + entries = db.relationship("TuneByInstrument", back_populates="instrument") + + def to_dict(self): + return {"id": self.id, "name": self.name} + + +class Tuning(db.Model): + __tablename__ = "tunings" + + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String, nullable=False, unique=True) + + entries = db.relationship("TuneByInstrument", back_populates="tuning") + + def to_dict(self): + return {"id": self.id, "name": self.name} + + +class Tune(db.Model): + __tablename__ = "tunes" + + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String, nullable=True) + key = Column(String, nullable=True) + modal = Column(Boolean, nullable=True) + source_id = Column(Integer, ForeignKey("sources.id", ondelete="SET NULL"), nullable=True) + + source = db.relationship("Source", back_populates="tunes") + instruments = db.relationship("TuneByInstrument", back_populates="tune", cascade="all, delete-orphan") + notes = db.relationship("TuneNote", back_populates="tune", cascade="all, delete-orphan") + references = db.relationship("Reference", back_populates="tune", cascade="all, delete-orphan") + + def to_dict(self): + return { + "id": self.id, + "name": self.name, + "key": self.key, + "modal": self.modal, + "source_id": self.source_id, + "source": self.source.to_dict() if self.source else None, + "instruments": [i.to_dict() for i in self.instruments], + "notes": [n.to_dict() for n in self.notes], + "references": [r.to_dict() for r in self.references], + } + + +class TuneByInstrument(db.Model): + __tablename__ = "tune_by_instrument" + __table_args__ = (UniqueConstraint("tune_id", "instrument_id", name="uq_tune_instrument"),) + + id = Column(Integer, primary_key=True, autoincrement=True) + tune_id = Column(Integer, ForeignKey("tunes.id", ondelete="CASCADE"), nullable=False) + instrument_id = Column(Integer, ForeignKey("instruments.id", ondelete="CASCADE"), nullable=False) + learned_from_id = Column(Integer, ForeignKey("musicians.id", ondelete="SET NULL"), nullable=True) + tuning_id = Column(Integer, ForeignKey("tunings.id", ondelete="SET NULL"), nullable=True) + date_learned = Column(Date, nullable=True) + callable = Column(Boolean, nullable=True) + review = Column(Boolean, nullable=True) + to_learn = Column(Boolean, nullable=True, default=False) + difficulty = Column(String, nullable=True) + + tune = db.relationship("Tune", back_populates="instruments") + instrument = db.relationship("Instrument", back_populates="entries") + tuning = db.relationship("Tuning", back_populates="entries") + learned_from_musician = db.relationship("Musician", back_populates="instrument_entries") + notes = db.relationship("TuneByInstrumentNote", back_populates="entry", cascade="all, delete-orphan") + + def to_dict(self): + return { + "id": self.id, + "tune_id": self.tune_id, + "instrument_id": self.instrument_id, + "instrument_name": self.instrument.name if self.instrument else None, + "learned_from_id": self.learned_from_id, + "learned_from": self.learned_from_musician.name if self.learned_from_musician else None, + "date_learned": self.date_learned.isoformat() if self.date_learned else None, + "tuning_id": self.tuning_id, + "tuning": self.tuning.name if self.tuning else None, + "callable": self.callable, + "review": self.review, + "to_learn": self.to_learn, + "difficulty": self.difficulty, + "notes": [n.to_dict() for n in self.notes], + } + + +class TuneNote(db.Model): + __tablename__ = "tune_notes" + + id = Column(Integer, primary_key=True, autoincrement=True) + tune_id = Column(Integer, ForeignKey("tunes.id", ondelete="CASCADE"), nullable=False) + note = Column(String, nullable=True) + + tune = db.relationship("Tune", back_populates="notes") + + def to_dict(self): + return {"id": self.id, "tune_id": self.tune_id, "note": self.note} + + +class TuneByInstrumentNote(db.Model): + __tablename__ = "tune_by_instrument_notes" + + id = Column(Integer, primary_key=True, autoincrement=True) + tune_by_instrument_id = Column(Integer, ForeignKey("tune_by_instrument.id", ondelete="CASCADE"), nullable=False) + note = Column(String, nullable=True) + + entry = db.relationship("TuneByInstrument", back_populates="notes") + + def to_dict(self): + return {"id": self.id, "tune_by_instrument_id": self.tune_by_instrument_id, "note": self.note} + + +class Reference(db.Model): + __tablename__ = "references" + + id = Column(Integer, primary_key=True, autoincrement=True) + tune_id = Column(Integer, ForeignKey("tunes.id", ondelete="CASCADE"), nullable=True) + link = Column(String, nullable=True) + site = Column(String, nullable=True) + + tune = db.relationship("Tune", back_populates="references") + musicians = db.relationship("Musician", secondary=reference_musicians, back_populates="references") + + def to_dict(self): + return { + "id": self.id, + "tune_id": self.tune_id, + "link": self.link, + "site": self.site, + "musicians": [m.to_dict() for m in self.musicians], + } diff --git a/app/routes_instruments.py b/app/routes_instruments.py new file mode 100644 index 0000000..9df26a1 --- /dev/null +++ b/app/routes_instruments.py @@ -0,0 +1,125 @@ +"""Routes for tune_by_instrument entries and their notes.""" +from flask import Blueprint, jsonify, request +from app.extensions import db +from app.models import Tune, Instrument, TuneByInstrument, TuneByInstrumentNote +from app.auth import require_api_key + +instruments_bp = Blueprint("instruments", __name__) + + +# --------------------------------------------------------------------------- +# Instruments lookup (public) +# --------------------------------------------------------------------------- +@instruments_bp.get("/instruments") +def list_instruments(): + return jsonify([i.to_dict() for i in + db.session.query(Instrument).order_by(Instrument.name).all()]) + + +# --------------------------------------------------------------------------- +# List all tune_by_instrument entries for a tune +# --------------------------------------------------------------------------- +@instruments_bp.get("/tunes//instruments") +def list_tune_instruments(tune_id): + db.get_or_404(Tune, tune_id) + entries = db.session.query(TuneByInstrument).filter_by(tune_id=tune_id).all() + return jsonify([e.to_dict() for e in entries]) + + +# --------------------------------------------------------------------------- +# Create a tune_by_instrument entry +# --------------------------------------------------------------------------- +@instruments_bp.post("/tunes//instruments") +@require_api_key +def create_tune_instrument(tune_id): + db.get_or_404(Tune, tune_id) + data = request.get_json(force=True) + + # Validate instrument exists + instrument_id = data.get("instrument_id") + db.get_or_404(Instrument, instrument_id) + + entry = TuneByInstrument( + tune_id=tune_id, + instrument_id=instrument_id, + learned_from_id=data.get("learned_from_id"), + tuning_id=data.get("tuning_id"), + date_learned=data.get("date_learned"), + callable=data.get("callable"), + review=data.get("review"), + to_learn=data.get("to_learn", False), + difficulty=data.get("difficulty"), + ) + db.session.add(entry) + db.session.commit() + return jsonify(entry.to_dict()), 201 + + +# --------------------------------------------------------------------------- +# Update a tune_by_instrument entry +# --------------------------------------------------------------------------- +@instruments_bp.patch("/tunes//instruments/") +@require_api_key +def update_tune_instrument(tune_id, entry_id): + entry = db.get_or_404(TuneByInstrument, entry_id) + data = request.get_json(force=True) + fields = ("instrument_id", "learned_from_id", "tuning_id", "date_learned", + "callable", "review", "to_learn", "difficulty") + for field in fields: + if field in data: + setattr(entry, field, data[field]) + db.session.commit() + return jsonify(entry.to_dict()) + + +# --------------------------------------------------------------------------- +# Delete a tune_by_instrument entry +# --------------------------------------------------------------------------- +@instruments_bp.delete("/tunes//instruments/") +@require_api_key +def delete_tune_instrument(tune_id, entry_id): + entry = db.get_or_404(TuneByInstrument, entry_id) + db.session.delete(entry) + db.session.commit() + return jsonify({"deleted": entry_id}) + + +# --------------------------------------------------------------------------- +# Notes on a tune_by_instrument entry +# --------------------------------------------------------------------------- +@instruments_bp.get("/tunes//instruments//notes") +def list_instrument_notes(tune_id, entry_id): + db.get_or_404(TuneByInstrument, entry_id) + notes = db.session.query(TuneByInstrumentNote).filter_by(tune_by_instrument_id=entry_id).all() + return jsonify([n.to_dict() for n in notes]) + + +@instruments_bp.post("/tunes//instruments//notes") +@require_api_key +def add_instrument_note(tune_id, entry_id): + db.get_or_404(TuneByInstrument, entry_id) + data = request.get_json(force=True) + note = TuneByInstrumentNote(tune_by_instrument_id=entry_id, note=data.get("note")) + db.session.add(note) + db.session.commit() + return jsonify(note.to_dict()), 201 + + +@instruments_bp.patch("/tunes//instruments//notes/") +@require_api_key +def update_instrument_note(tune_id, entry_id, note_id): + note = db.get_or_404(TuneByInstrumentNote, note_id) + data = request.get_json(force=True) + if "note" in data: + note.note = data["note"] + db.session.commit() + return jsonify(note.to_dict()) + + +@instruments_bp.delete("/tunes//instruments//notes/") +@require_api_key +def delete_instrument_note(tune_id, entry_id, note_id): + note = db.get_or_404(TuneByInstrumentNote, note_id) + db.session.delete(note) + db.session.commit() + return jsonify({"deleted": note_id}) diff --git a/app/routes_musicians.py b/app/routes_musicians.py new file mode 100644 index 0000000..a51da17 --- /dev/null +++ b/app/routes_musicians.py @@ -0,0 +1,43 @@ +"""Routes for the standalone musicians table.""" +from flask import Blueprint, jsonify, request +from app.extensions import db +from app.models import Musician +from app.auth import require_api_key + +musicians_bp = Blueprint("musicians", __name__, url_prefix="/musicians") + + +@musicians_bp.get("/") +def list_musicians(): + musicians = db.session.query(Musician).order_by(Musician.name).all() + return jsonify([m.to_dict() for m in musicians]) + + +@musicians_bp.post("/") +@require_api_key +def create_musician(): + data = request.get_json(force=True) + musician = Musician(name=data["name"]) + db.session.add(musician) + db.session.commit() + return jsonify(musician.to_dict()), 201 + + +@musicians_bp.patch("/") +@require_api_key +def update_musician(musician_id): + musician = db.get_or_404(Musician, musician_id) + data = request.get_json(force=True) + if "name" in data: + musician.name = data["name"] + db.session.commit() + return jsonify(musician.to_dict()) + + +@musicians_bp.delete("/") +@require_api_key +def delete_musician(musician_id): + musician = db.get_or_404(Musician, musician_id) + db.session.delete(musician) + db.session.commit() + return jsonify({"deleted": musician_id}) diff --git a/app/routes_sources.py b/app/routes_sources.py new file mode 100644 index 0000000..feef943 --- /dev/null +++ b/app/routes_sources.py @@ -0,0 +1,43 @@ +"""Routes for sources (musicians/artists whose version a tune is based on).""" +from flask import Blueprint, jsonify, request +from app.extensions import db +from app.models import Source +from app.auth import require_api_key + +sources_bp = Blueprint("sources", __name__, url_prefix="/sources") + + +@sources_bp.get("/") +def list_sources(): + sources = db.session.query(Source).order_by(Source.name).all() + return jsonify([s.to_dict() for s in sources]) + + +@sources_bp.post("/") +@require_api_key +def create_source(): + data = request.get_json(force=True) + source = Source(name=data["name"]) + db.session.add(source) + db.session.commit() + return jsonify(source.to_dict()), 201 + + +@sources_bp.patch("/") +@require_api_key +def update_source(source_id): + source = db.get_or_404(Source, source_id) + data = request.get_json(force=True) + if "name" in data: + source.name = data["name"] + db.session.commit() + return jsonify(source.to_dict()) + + +@sources_bp.delete("/") +@require_api_key +def delete_source(source_id): + source = db.get_or_404(Source, source_id) + db.session.delete(source) + db.session.commit() + return jsonify({"deleted": source_id}) diff --git a/app/routes_sub.py b/app/routes_sub.py new file mode 100644 index 0000000..7f94be0 --- /dev/null +++ b/app/routes_sub.py @@ -0,0 +1,124 @@ +"""Routes for notes and references sub-resources.""" +from flask import Blueprint, jsonify, request +from app.extensions import db +from app.models import Tune, TuneNote, Reference, Musician +from app.auth import require_api_key + +sub_bp = Blueprint("sub", __name__) + + +# =========================================================================== +# Notes +# =========================================================================== + +@sub_bp.get("/tunes//notes") +def list_notes(tune_id): + db.get_or_404(Tune, tune_id) + notes = db.session.query(TuneNote).filter_by(tune_id=tune_id).all() + return jsonify([n.to_dict() for n in notes]) + + +@sub_bp.post("/tunes//notes") +@require_api_key +def add_note(tune_id): + db.get_or_404(Tune, tune_id) + data = request.get_json(force=True) + note = TuneNote(tune_id=tune_id, note=data.get("note")) + db.session.add(note) + db.session.commit() + return jsonify(note.to_dict()), 201 + + +@sub_bp.patch("/tunes//notes/") +@require_api_key +def update_note(tune_id, note_id): + note = db.get_or_404(TuneNote, note_id) + data = request.get_json(force=True) + if "note" in data: + note.note = data["note"] + db.session.commit() + return jsonify(note.to_dict()) + + +@sub_bp.delete("/tunes//notes/") +@require_api_key +def delete_note(tune_id, note_id): + note = db.get_or_404(TuneNote, note_id) + db.session.delete(note) + db.session.commit() + return jsonify({"deleted": note_id}) + + +# =========================================================================== +# References +# =========================================================================== + +@sub_bp.get("/tunes//references") +def list_references(tune_id): + db.get_or_404(Tune, tune_id) + refs = db.session.query(Reference).filter_by(tune_id=tune_id).all() + return jsonify([r.to_dict() for r in refs]) + + +@sub_bp.post("/tunes//references") +@require_api_key +def add_reference(tune_id): + db.get_or_404(Tune, tune_id) + data = request.get_json(force=True) + ref = Reference(tune_id=tune_id, link=data.get("link"), site=data.get("site")) + db.session.add(ref) + db.session.flush() + for m in data.get("musicians", []): + musician = db.session.get(Musician, m["id"]) + if musician: + ref.musicians.append(musician) + db.session.commit() + return jsonify(ref.to_dict()), 201 + + +@sub_bp.patch("/tunes//references/") +@require_api_key +def update_reference(tune_id, ref_id): + ref = db.get_or_404(Reference, ref_id) + data = request.get_json(force=True) + for field in ("link", "site"): + if field in data: + setattr(ref, field, data[field]) + db.session.commit() + return jsonify(ref.to_dict()) + + +@sub_bp.delete("/tunes//references/") +@require_api_key +def delete_reference(tune_id, ref_id): + ref = db.get_or_404(Reference, ref_id) + db.session.delete(ref) + db.session.commit() + return jsonify({"deleted": ref_id}) + + +# =========================================================================== +# Musicians on a reference (add/remove via join table) +# =========================================================================== + +@sub_bp.post("/tunes//references//musicians") +@require_api_key +def add_musician_to_reference(tune_id, ref_id): + ref = db.get_or_404(Reference, ref_id) + data = request.get_json(force=True) + musician = db.get_or_404(Musician, data["musician_id"]) + if musician not in ref.musicians: + ref.musicians.append(musician) + db.session.commit() + return jsonify(ref.to_dict()) + + +@sub_bp.delete("/tunes//references//musicians/") +@require_api_key +def remove_musician_from_reference(tune_id, ref_id, musician_id): + ref = db.get_or_404(Reference, ref_id) + musician = db.get_or_404(Musician, musician_id) + if musician in ref.musicians: + ref.musicians.remove(musician) + db.session.commit() + return jsonify(ref.to_dict()) diff --git a/app/routes_tunes.py b/app/routes_tunes.py new file mode 100644 index 0000000..6cf6cf6 --- /dev/null +++ b/app/routes_tunes.py @@ -0,0 +1,155 @@ +"""Routes for the core tunes table.""" +from flask import Blueprint, jsonify, request +from app.extensions import db +from app.models import Tune, TuneByInstrument, TuneNote, Reference, Musician +from app.auth import require_api_key + +tunes_bp = Blueprint("tunes", __name__, url_prefix="/tunes") + + +# --------------------------------------------------------------------------- +# List / filter +# --------------------------------------------------------------------------- +@tunes_bp.get("/") +def list_tunes(): + """ + Query params (all optional, combinable): + instrument_id=1 + key=D + tuning_id=1 + callable=true|false + review=true|false + to_learn=true|false + difficulty=easy|hard + modal=true|false + source_id=1 + search= + """ + q = db.session.query(Tune) + + search = request.args.get("search") + if search: + q = q.filter(Tune.name.ilike(f"%{search}%")) + + key = request.args.get("key") + if key: + q = q.filter(Tune.key.ilike(key)) + + modal = request.args.get("modal") + if modal is not None: + q = q.filter(Tune.modal == (modal.lower() in ("true", "1", "yes"))) + + source_id = request.args.get("source_id") + if source_id: + q = q.filter(Tune.source_id == int(source_id)) + + instrument_id = request.args.get("instrument_id") + tuning = request.args.get("tuning") + callable_ = request.args.get("callable") + review = request.args.get("review") + to_learn = request.args.get("to_learn") + difficulty = request.args.get("difficulty") + + def _bool(val): + return val.lower() in ("true", "1", "yes") + + if any([instrument_id, tuning, callable_, review, to_learn, difficulty]): + q = q.join(Tune.instruments) + if instrument_id: + q = q.filter(TuneByInstrument.instrument_id == int(instrument_id)) + if tuning: + q = q.filter(TuneByInstrument.tuning_id == int(tuning)) + if callable_ is not None: + q = q.filter(TuneByInstrument.callable == _bool(callable_)) + if review is not None: + q = q.filter(TuneByInstrument.review == _bool(review)) + if to_learn is not None: + q = q.filter(TuneByInstrument.to_learn == _bool(to_learn)) + if difficulty: + q = q.filter(TuneByInstrument.difficulty.ilike(difficulty)) + + tunes = q.order_by(Tune.name).all() + return jsonify([t.to_dict() for t in tunes]) + + +# --------------------------------------------------------------------------- +# Get single tune +# --------------------------------------------------------------------------- +@tunes_bp.get("/") +def get_tune(tune_id): + tune = db.get_or_404(Tune, tune_id) + return jsonify(tune.to_dict()) + + +# --------------------------------------------------------------------------- +# Create tune (with optional nested instruments / notes / references) +# --------------------------------------------------------------------------- +@tunes_bp.post("/") +@require_api_key +def create_tune(): + data = request.get_json(force=True) + + tune = Tune( + name=data.get("name"), + key=data.get("key"), + modal=data.get("modal"), + source_id=data.get("source_id"), + ) + db.session.add(tune) + db.session.flush() + + for inst_data in data.get("instruments", []): + entry = TuneByInstrument( + tune_id=tune.id, + instrument_id=inst_data["instrument_id"], + learned_from_id=inst_data.get("learned_from_id"), + tuning_id=inst_data.get("tuning_id"), + date_learned=inst_data.get("date_learned"), + callable=inst_data.get("callable"), + review=inst_data.get("review"), + to_learn=inst_data.get("to_learn", False), + difficulty=inst_data.get("difficulty"), + ) + db.session.add(entry) + + for note_data in data.get("notes", []): + db.session.add(TuneNote(tune_id=tune.id, note=note_data.get("note"))) + + for ref_data in data.get("references", []): + ref = Reference(tune_id=tune.id, link=ref_data.get("link"), site=ref_data.get("site")) + db.session.add(ref) + db.session.flush() + for m in ref_data.get("musicians", []): + musician = db.session.get(Musician, m["id"]) + if musician: + ref.musicians.append(musician) + + db.session.commit() + return jsonify(tune.to_dict()), 201 + + +# --------------------------------------------------------------------------- +# Update tune core fields +# --------------------------------------------------------------------------- +@tunes_bp.patch("/") +@require_api_key +def update_tune(tune_id): + tune = db.get_or_404(Tune, tune_id) + data = request.get_json(force=True) + for field in ("name", "key", "modal", "source_id"): + if field in data: + setattr(tune, field, data[field]) + db.session.commit() + return jsonify(tune.to_dict()) + + +# --------------------------------------------------------------------------- +# Delete tune +# --------------------------------------------------------------------------- +@tunes_bp.delete("/") +@require_api_key +def delete_tune(tune_id): + tune = db.get_or_404(Tune, tune_id) + db.session.delete(tune) + db.session.commit() + return jsonify({"deleted": tune_id}) diff --git a/app/routes_tunings.py b/app/routes_tunings.py new file mode 100644 index 0000000..ed96a38 --- /dev/null +++ b/app/routes_tunings.py @@ -0,0 +1,43 @@ +"""Routes for the tunings lookup table.""" +from flask import Blueprint, jsonify, request +from app.extensions import db +from app.models import Tuning +from app.auth import require_api_key + +tunings_bp = Blueprint("tunings", __name__, url_prefix="/tunings") + + +@tunings_bp.get("/") +def list_tunings(): + return jsonify([t.to_dict() for t in + db.session.query(Tuning).order_by(Tuning.name).all()]) + + +@tunings_bp.post("/") +@require_api_key +def create_tuning(): + data = request.get_json(force=True) + tuning = Tuning(name=data["name"]) + db.session.add(tuning) + db.session.commit() + return jsonify(tuning.to_dict()), 201 + + +@tunings_bp.patch("/") +@require_api_key +def update_tuning(tuning_id): + tuning = db.get_or_404(Tuning, tuning_id) + data = request.get_json(force=True) + if "name" in data: + tuning.name = data["name"] + db.session.commit() + return jsonify(tuning.to_dict()) + + +@tunings_bp.delete("/") +@require_api_key +def delete_tuning(tuning_id): + tuning = db.get_or_404(Tuning, tuning_id) + db.session.delete(tuning) + db.session.commit() + return jsonify({"deleted": tuning_id}) diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..9e8966e --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,20 @@ +FROM python:3.12-slim + +WORKDIR /app + +# Install uv +COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv + +RUN apt-get update && apt-get install -y --no-install-recommends \ + libpq-dev gcc \ + && rm -rf /var/lib/apt/lists/* + +# Copy dependency files and install — leverages Docker layer cache +COPY pyproject.toml uv.lock ./ +RUN uv sync --no-dev --frozen + +COPY . . + +EXPOSE 5000 + +CMD ["uv", "run", "flask", "--app", "wsgi:app", "run", "--host=0.0.0.0", "--port=5000"] diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..fb48ca5 --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,29 @@ +services: + db: + image: postgres:16 + restart: unless-stopped + environment: + POSTGRES_USER: repertory + POSTGRES_PASSWORD: repertory + POSTGRES_DB: tunes + ports: + - "${DB_PORT:-5432}:5432" + volumes: + - pgdata:/var/lib/postgresql/data + + api: + build: + context: .. + dockerfile: docker/Dockerfile + restart: unless-stopped + ports: + - "${API_PORT:-5000}:5000" + env_file: + - ../.env + environment: + DATABASE_URL: postgresql://repertory:repertory@db:5432/tunes + depends_on: + - db + +volumes: + pgdata: diff --git a/env.example b/env.example new file mode 100644 index 0000000..d1e0808 --- /dev/null +++ b/env.example @@ -0,0 +1,33 @@ +# Copy this file to .env and fill in your values. + +# --- Database --- +# Local dev (matches docker/docker-compose.yml defaults): +DATABASE_URL=postgresql://repertory:repertory@localhost:5432/tunes + +# Production example: +# DATABASE_URL=postgresql://user:password@your-db-host:5432/tunes + +# --- Ports --- +# If you change DB_PORT, update the port in DATABASE_URL to match. +# If you change API_PORT, update the port in REPERTORY_HOST to match. +API_PORT=5000 +DB_PORT=5432 + +# --- Flask --- +FLASK_ENV=development +FLASK_DEBUG=1 + +# --- CORS --- +# Comma-separated list of allowed origins, or * for local dev. +# In production set this to your S3/CloudFront domain: +# CORS_ORIGINS=https://yourdomain.com +CORS_ORIGINS=* + +# --- API Key --- +# For local dev set this directly. +# In production this is retrieved at startup via SOPS + age (see README). +API_KEY=dev-secret-key-change-me + +# --- ain (terminal HTTP client) --- +# Used by the ain templates in ain/ — set to wherever the API is running. +REPERTORY_HOST=http://localhost:5000 diff --git a/migrations/README b/migrations/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/migrations/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..183a253 --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,58 @@ +"""Alembic environment — wired to our SQLAlchemy models and DATABASE_URL.""" +import os +from logging.config import fileConfig + +from dotenv import load_dotenv +from sqlalchemy import engine_from_config, pool +from alembic import context + +# Load .env so DATABASE_URL is available when running alembic locally. +load_dotenv() + +# Alembic Config object +config = context.config + +# Set up loggers from alembic.ini +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# Override sqlalchemy.url from environment (takes precedence over alembic.ini). +db_url = os.environ.get("DATABASE_URL") +if db_url: + config.set_main_option("sqlalchemy.url", db_url) + +# Import models so Alembic autogenerate can see the metadata. +from app.extensions import db # noqa: E402 +import app.models # noqa: E402, F401 — registers all models on db.metadata + +target_metadata = db.metadata + + +def run_migrations_offline() -> None: + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..1101630 --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,28 @@ +"""${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: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/migrations/versions/024f72e7bea5_drop_tune_id_from_tunes.py b/migrations/versions/024f72e7bea5_drop_tune_id_from_tunes.py new file mode 100644 index 0000000..ebd750c --- /dev/null +++ b/migrations/versions/024f72e7bea5_drop_tune_id_from_tunes.py @@ -0,0 +1,21 @@ +"""drop tune_id from tunes + +Revision ID: 024f72e7bea5 +Revises: 2bb981f6ee16 +Create Date: 2026-06-09 +""" +from alembic import op + +revision = "024f72e7bea5" +down_revision = "2bb981f6ee16" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.drop_column("tunes", "tune_id") + + +def downgrade() -> None: + import sqlalchemy as sa + op.add_column("tunes", sa.Column("tune_id", sa.Integer(), nullable=True)) diff --git a/migrations/versions/2bb981f6ee16_initial_schema.py b/migrations/versions/2bb981f6ee16_initial_schema.py new file mode 100644 index 0000000..1eb0049 --- /dev/null +++ b/migrations/versions/2bb981f6ee16_initial_schema.py @@ -0,0 +1,82 @@ +"""initial schema + +Revision ID: 2bb981f6ee16 +Revises: +Create Date: 2026-06-09 +""" +from alembic import op +import sqlalchemy as sa + +revision = "2bb981f6ee16" +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "tunes", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True, nullable=False), + sa.Column("name", sa.String(), nullable=True), + sa.Column("version", sa.String(), nullable=True), + sa.Column("key", sa.String(), nullable=True), + sa.Column("tune_id", sa.Integer(), nullable=True), + ) + + op.create_table( + "banjo", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True, nullable=False), + sa.Column("tune_id", sa.Integer(), sa.ForeignKey("tunes.id", ondelete="CASCADE"), nullable=False), + sa.Column("learned_from", sa.String(), nullable=True), + sa.Column("date_learned", sa.Date(), nullable=True), + sa.Column("tuning", sa.String(), nullable=True), + sa.Column("callable", sa.Boolean(), nullable=True), + sa.Column("review", sa.Boolean(), nullable=True), + sa.Column("to_learn", sa.Boolean(), nullable=True), + sa.Column("difficulty", sa.String(), nullable=True), + sa.UniqueConstraint("tune_id", name="banjo_tune_id_unique"), + ) + + op.create_table( + "fiddle", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True, nullable=False), + sa.Column("tune_id", sa.Integer(), sa.ForeignKey("tunes.id", ondelete="CASCADE"), nullable=False), + sa.Column("learned_from", sa.String(), nullable=True), + sa.Column("date_learned", sa.Date(), nullable=True), + sa.Column("tuning", sa.String(), nullable=True), + sa.Column("callable", sa.Boolean(), nullable=True), + sa.Column("review", sa.Boolean(), nullable=True), + sa.Column("to_learn", sa.Boolean(), nullable=True), + sa.Column("difficulty", sa.String(), nullable=True), + sa.UniqueConstraint("tune_id", name="fiddle_tune_id_unique"), + ) + + op.create_table( + "notes", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True, nullable=False), + sa.Column("tune_id", sa.Integer(), sa.ForeignKey("tunes.id", ondelete="CASCADE"), nullable=True), + sa.Column("note", sa.String(), nullable=True), + ) + + op.create_table( + "references", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True, nullable=False), + sa.Column("tune_id", sa.Integer(), sa.ForeignKey("tunes.id", ondelete="CASCADE"), nullable=True), + sa.Column("link", sa.String(), nullable=True), + sa.Column("site", sa.String(), nullable=True), + ) + + op.create_table( + "musicians", + sa.Column("name", sa.String(), primary_key=True, nullable=False), + sa.Column("reference_id", sa.Integer(), sa.ForeignKey("references.id", ondelete="CASCADE"), nullable=True), + ) + + +def downgrade() -> None: + op.drop_table("musicians") + op.drop_table("references") + op.drop_table("notes") + op.drop_table("fiddle") + op.drop_table("banjo") + op.drop_table("tunes") diff --git a/migrations/versions/2bd4f3ff0ccc_decouple_musicians.py b/migrations/versions/2bd4f3ff0ccc_decouple_musicians.py new file mode 100644 index 0000000..90e9bd1 --- /dev/null +++ b/migrations/versions/2bd4f3ff0ccc_decouple_musicians.py @@ -0,0 +1,65 @@ +"""decouple musicians: standalone table, join table for references, fk for learned_from + +Revision ID: 2bd4f3ff0ccc +Revises: ea4bb85f9908 +Create Date: 2026-06-09 +""" +from alembic import op +import sqlalchemy as sa + +revision = "2bd4f3ff0ccc" +down_revision = "ea4bb85f9908" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # 1. Drop the old musicians table (string PK, tied to references) + op.drop_table("musicians") + + # 2. Create standalone musicians table with integer PK + op.create_table( + "musicians", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True, nullable=False), + sa.Column("name", sa.String(), nullable=False), + ) + + # 3. Create reference_musicians join table (many-to-many) + op.create_table( + "reference_musicians", + sa.Column("reference_id", sa.Integer(), + sa.ForeignKey("references.id", ondelete="CASCADE"), + nullable=False), + sa.Column("musician_id", sa.Integer(), + sa.ForeignKey("musicians.id", ondelete="CASCADE"), + nullable=False), + sa.PrimaryKeyConstraint("reference_id", "musician_id"), + ) + + # 4. Add learned_from_id FK to banjo and fiddle (keep old learned_from text + # column for the downgrade path, then drop it) + op.add_column("banjo", sa.Column("learned_from_id", sa.Integer(), + sa.ForeignKey("musicians.id", ondelete="SET NULL"), + nullable=True)) + op.add_column("fiddle", sa.Column("learned_from_id", sa.Integer(), + sa.ForeignKey("musicians.id", ondelete="SET NULL"), + nullable=True)) + op.drop_column("banjo", "learned_from") + op.drop_column("fiddle", "learned_from") + + +def downgrade() -> None: + op.add_column("banjo", sa.Column("learned_from", sa.String(), nullable=True)) + op.add_column("fiddle", sa.Column("learned_from", sa.String(), nullable=True)) + op.drop_column("banjo", "learned_from_id") + op.drop_column("fiddle", "learned_from_id") + op.drop_table("reference_musicians") + op.drop_table("musicians") + # Recreate original musicians table + op.create_table( + "musicians", + sa.Column("name", sa.String(), primary_key=True, nullable=False), + sa.Column("reference_id", sa.Integer(), + sa.ForeignKey("references.id", ondelete="CASCADE"), + nullable=True), + ) diff --git a/migrations/versions/57326830536b_instrument_tunings.py b/migrations/versions/57326830536b_instrument_tunings.py new file mode 100644 index 0000000..250d690 --- /dev/null +++ b/migrations/versions/57326830536b_instrument_tunings.py @@ -0,0 +1,29 @@ +"""add instrument_tunings join table + +Revision ID: 57326830536b +Revises: abdeb80d003e +Create Date: 2026-06-09 +""" +from alembic import op +import sqlalchemy as sa + +revision = "57326830536b" +down_revision = "abdeb80d003e" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "instrument_tunings", + sa.Column("instrument_id", sa.Integer(), + sa.ForeignKey("instruments.id", ondelete="CASCADE"), + primary_key=True, nullable=False), + sa.Column("tuning_id", sa.Integer(), + sa.ForeignKey("tunings.id", ondelete="CASCADE"), + primary_key=True, nullable=False), + ) + + +def downgrade() -> None: + op.drop_table("instrument_tunings") diff --git a/migrations/versions/84db90f5499e_tune_by_instrument.py b/migrations/versions/84db90f5499e_tune_by_instrument.py new file mode 100644 index 0000000..3c31b57 --- /dev/null +++ b/migrations/versions/84db90f5499e_tune_by_instrument.py @@ -0,0 +1,90 @@ +"""replace banjo/fiddle with instruments + tune_by_instrument tables + +Revision ID: 84db90f5499e +Revises: 2bd4f3ff0ccc +Create Date: 2026-06-09 +""" +from alembic import op +import sqlalchemy as sa + +revision = "84db90f5499e" +down_revision = "2bd4f3ff0ccc" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # 1. Drop old per-instrument tables (and their notes) + op.drop_table("banjo") + op.drop_table("fiddle") + + # Rename notes → tune_notes + op.rename_table("notes", "tune_notes") + + # 2. Instruments lookup table + op.create_table( + "instruments", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True, nullable=False), + sa.Column("name", sa.String(), nullable=False, unique=True), + ) + # Seed banjo and fiddle + op.execute("INSERT INTO instruments (name) VALUES ('banjo'), ('fiddle')") + + # 3. Generic tune_by_instrument table + op.create_table( + "tune_by_instrument", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True, nullable=False), + sa.Column("tune_id", sa.Integer(), + sa.ForeignKey("tunes.id", ondelete="CASCADE"), nullable=False), + sa.Column("instrument_id", sa.Integer(), + sa.ForeignKey("instruments.id", ondelete="CASCADE"), nullable=False), + sa.Column("learned_from_id", sa.Integer(), + sa.ForeignKey("musicians.id", ondelete="SET NULL"), nullable=True), + sa.Column("date_learned", sa.Date(), nullable=True), + sa.Column("tuning", sa.String(), nullable=True), + sa.Column("callable", sa.Boolean(), nullable=True), + sa.Column("review", sa.Boolean(), nullable=True), + sa.Column("to_learn", sa.Boolean(), nullable=True), + sa.Column("difficulty", sa.String(), nullable=True), + sa.UniqueConstraint("tune_id", "instrument_id", name="uq_tune_instrument"), + ) + + # 4. Instrument-specific notes + op.create_table( + "tune_by_instrument_notes", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True, nullable=False), + sa.Column("tune_by_instrument_id", sa.Integer(), + sa.ForeignKey("tune_by_instrument.id", ondelete="CASCADE"), nullable=False), + sa.Column("note", sa.String(), nullable=True), + ) + + +def downgrade() -> None: + op.drop_table("tune_by_instrument_notes") + op.drop_table("tune_by_instrument") + op.drop_table("instruments") + op.rename_table("tune_notes", "notes") + op.create_table( + "banjo", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True, nullable=False), + sa.Column("tune_id", sa.Integer(), sa.ForeignKey("tunes.id", ondelete="CASCADE"), nullable=False, unique=True), + sa.Column("learned_from_id", sa.Integer(), sa.ForeignKey("musicians.id", ondelete="SET NULL"), nullable=True), + sa.Column("date_learned", sa.Date(), nullable=True), + sa.Column("tuning", sa.String(), nullable=True), + sa.Column("callable", sa.Boolean(), nullable=True), + sa.Column("review", sa.Boolean(), nullable=True), + sa.Column("to_learn", sa.Boolean(), nullable=True), + sa.Column("difficulty", sa.String(), nullable=True), + ) + op.create_table( + "fiddle", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True, nullable=False), + sa.Column("tune_id", sa.Integer(), sa.ForeignKey("tunes.id", ondelete="CASCADE"), nullable=False, unique=True), + sa.Column("learned_from_id", sa.Integer(), sa.ForeignKey("musicians.id", ondelete="SET NULL"), nullable=True), + sa.Column("date_learned", sa.Date(), nullable=True), + sa.Column("tuning", sa.String(), nullable=True), + sa.Column("callable", sa.Boolean(), nullable=True), + sa.Column("review", sa.Boolean(), nullable=True), + sa.Column("to_learn", sa.Boolean(), nullable=True), + sa.Column("difficulty", sa.String(), nullable=True), + ) diff --git a/migrations/versions/abdeb80d003e_add_tunings.py b/migrations/versions/abdeb80d003e_add_tunings.py new file mode 100644 index 0000000..97694da --- /dev/null +++ b/migrations/versions/abdeb80d003e_add_tunings.py @@ -0,0 +1,32 @@ +"""add tunings table and tuning_id fk on tune_by_instrument + +Revision ID: abdeb80d003e +Revises: 84db90f5499e +Create Date: 2026-06-09 +""" +from alembic import op +import sqlalchemy as sa + +revision = "abdeb80d003e" +down_revision = "84db90f5499e" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "tunings", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True, nullable=False), + sa.Column("name", sa.String(), nullable=False, unique=True), + ) + op.drop_column("tune_by_instrument", "tuning") + op.add_column("tune_by_instrument", + sa.Column("tuning_id", sa.Integer(), + sa.ForeignKey("tunings.id", ondelete="SET NULL"), + nullable=True)) + + +def downgrade() -> None: + op.drop_column("tune_by_instrument", "tuning_id") + op.add_column("tune_by_instrument", sa.Column("tuning", sa.String(), nullable=True)) + op.drop_table("tunings") diff --git a/migrations/versions/ea4bb85f9908_add_sources.py b/migrations/versions/ea4bb85f9908_add_sources.py new file mode 100644 index 0000000..deef8ff --- /dev/null +++ b/migrations/versions/ea4bb85f9908_add_sources.py @@ -0,0 +1,34 @@ +"""add sources table, source_id and modal to tunes, drop version + +Revision ID: ea4bb85f9908 +Revises: 024f72e7bea5 +Create Date: 2026-06-09 +""" +from alembic import op +import sqlalchemy as sa + +revision = "ea4bb85f9908" +down_revision = "024f72e7bea5" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "sources", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True, nullable=False), + sa.Column("name", sa.String(), nullable=False), + ) + + op.drop_column("tunes", "version") + op.add_column("tunes", sa.Column("source_id", sa.Integer(), + sa.ForeignKey("sources.id", ondelete="SET NULL"), + nullable=True)) + op.add_column("tunes", sa.Column("modal", sa.Boolean(), nullable=True)) + + +def downgrade() -> None: + op.drop_column("tunes", "modal") + op.drop_column("tunes", "source_id") + op.add_column("tunes", sa.Column("version", sa.String(), nullable=True)) + op.drop_table("sources") diff --git a/nixos/module.nix b/nixos/module.nix new file mode 100644 index 0000000..ee87c68 --- /dev/null +++ b/nixos/module.nix @@ -0,0 +1,200 @@ +# NixOS module for repertory-api +# +# This module fetches the application source directly from a self-hosted Forgejo instance at a pinned +# tag/commit, builds a Python environment, runs Alembic migrations on start, +# and manages the Flask service under systemd. +# +# ── Typical usage in configuration.nix ─────────────────────────────────────── +# +# imports = [ (fetchTarball { +# url = "https://git.yourdomain.com/yourusername/repertory-api/archive/v1.0.0.tar.gz"; +# sha256 = "0000000000000000000000000000000000000000000000000000"; +# } + "/nixos/module.nix") ]; +# +# ── Secrets ────────────────────────────────────────────────────────────────── +# +# The module expects two sops-nix secrets to exist. Add to configuration.nix: +# +# sops.secrets."repertory-api-key" = {}; +# sops.secrets."repertory-db-url" = {}; +# +# ── Minimal configuration.nix example ──────────────────────────────────────── +# +# services.repertory-api = { +# enable = true; +# domain = "git.yourdomain.com"; +# owner = "yourusername"; +# version = "v1.0.0"; +# rev = "abc123..."; # git commit SHA for the tag +# sha256 = "sha256-..."; # `nix-prefetch-url --unpack ` +# }; +# +# ── Updating ───────────────────────────────────────────────────────────────── +# +# 1. Push a new tag to your Forgejo instance (e.g. v1.1.0). +# 2. Get the new sha256: +# nix-prefetch-url --unpack \ +# https://git.yourdomain.com/yourusername/repertory-api/archive/v1.1.0.tar.gz +# 3. Update `version`, `rev`, and `sha256` in your configuration.nix. +# 4. nixos-rebuild switch +# → Nix fetches the new source, rebuilds the Python env, restarts the +# service, and runs migrations automatically in preStart. +# +# ───────────────────────────────────────────────────────────────────────────── + +{ config, lib, pkgs, ... }: + +with lib; + +let + cfg = config.services.repertory-api; + + # Fetch the application source from the self-hosted Forgejo instance at the + # pinned rev. Nix verifies the sha256 hash, so this is fully reproducible. + appSrc = pkgs.fetchFromGitea { + domain = cfg.domain; + owner = cfg.owner; + repo = "repertory-api"; + rev = cfg.rev; + sha256 = cfg.sha256; + }; + + # Build the Python environment with all runtime dependencies. + # This mirrors pyproject.toml [project.dependencies]. + # uv is a local dev tool — on NixOS, Nix itself plays the role of + # dependency resolver and the Python env is built at evaluation time. + pythonEnv = pkgs.python3.withPackages (ps: with ps; [ + flask + flask-sqlalchemy + sqlalchemy + psycopg2 + python-dotenv + alembic + ]); + +in { + + options.services.repertory-api = { + enable = mkEnableOption "Repertory API"; + + domain = mkOption { + type = types.str; + description = "Hostname of your self-hosted Forgejo instance."; + example = "git.yourdomain.com"; + }; + + owner = mkOption { + type = types.str; + description = "Forgejo username / org that owns the repertory-api repo."; + example = "yourusername"; + }; + + version = mkOption { + type = types.str; + description = "Human-readable version label (used only for documentation/logging)."; + example = "v1.0.0"; + }; + + rev = mkOption { + type = types.str; + description = "Git commit SHA for the tag you want to deploy."; + example = "abc1234def5678"; + }; + + sha256 = mkOption { + type = types.str; + description = '' + sha256 hash of the fetched source tarball. + Obtain with: + nix-prefetch-url --unpack \ + https://git.yourdomain.com//repertory-api/archive/.tar.gz + ''; + example = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; + }; + + port = mkOption { + type = types.port; + default = 5000; + description = "Port the Flask app listens on."; + }; + + user = mkOption { + type = types.str; + default = "repertory"; + description = "Unix user to run the service as."; + }; + + group = mkOption { + type = types.str; + default = "repertory"; + description = "Unix group to run the service as."; + }; + + apiKeySecret = mkOption { + type = types.str; + default = "/run/secrets/repertory-api-key"; + description = "Path to the SOPS-decrypted file containing the raw API key."; + }; + + dbUrlSecret = mkOption { + type = types.str; + default = "/run/secrets/repertory-db-url"; + description = "Path to the SOPS-decrypted file containing the DATABASE_URL string."; + }; + }; + + config = mkIf cfg.enable { + + users.users.${cfg.user} = { + isSystemUser = true; + group = cfg.group; + home = "/var/lib/repertory-api"; + createHome = true; + }; + + users.groups.${cfg.group} = {}; + + systemd.services.repertory-api = { + description = "Repertory API (Flask) — ${cfg.version}"; + wantedBy = [ "multi-user.target" ]; + after = [ "network.target" "postgresql.service" ]; + + serviceConfig = { + User = cfg.user; + Group = cfg.group; + WorkingDirectory = appSrc; + Restart = "on-failure"; + RestartSec = "5s"; + + ExecStart = "${pythonEnv}/bin/python -m flask --app wsgi:app run --host=0.0.0.0 --port=${toString cfg.port}"; + + # Secrets are injected via the env file written in preStart. + EnvironmentFiles = [ "/run/repertory-api/env" ]; + + # Security hardening + ProtectSystem = "strict"; + ProtectHome = true; + PrivateTmp = true; + NoNewPrivileges = true; + # /run/repertory-api is on tmpfs and holds only the mode-600 env file. + ReadWritePaths = [ "/run/repertory-api" ]; + }; + + preStart = '' + # Write secrets into a tmpfs env file so nothing is on-disk in plaintext. + mkdir -p /run/repertory-api + chmod 700 /run/repertory-api + printf 'API_KEY=%s\n' "$(cat ${cfg.apiKeySecret})" > /run/repertory-api/env + printf 'DATABASE_URL=%s\n' "$(cat ${cfg.dbUrlSecret})" >> /run/repertory-api/env + chmod 600 /run/repertory-api/env + + # Run any pending Alembic migrations before the server starts. + # On a fresh database this creates all tables; on subsequent deploys + # it applies only new migration files. + export DATABASE_URL="$(cat ${cfg.dbUrlSecret})" + cd ${appSrc} + ${pythonEnv}/bin/python -m alembic upgrade head + ''; + }; + }; +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..01a13ae --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,19 @@ +[project] +name = "repertory-api" +version = "0.1.0" +requires-python = ">=3.12" +dependencies = [ + "flask>=3.0", + "flask-sqlalchemy>=3.1", + "flask-cors>=4.0", + "sqlalchemy>=2.0", + "psycopg2-binary>=2.9", + "python-dotenv>=1.0", + "alembic>=1.13", +] + +[dependency-groups] +dev = [ + "pytest>=8.0", + "pytest-flask>=1.3", +] diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..aecb4a0 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,53 @@ +""" +Shared pytest fixtures. +Tests run against SQLite in-memory — no Docker required. +""" +import os +import pytest + +os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:") +os.environ.setdefault("API_KEY", "test-key") + +from app import create_app +from app.extensions import db as _db + + +@pytest.fixture(scope="session") +def app(): + application = create_app() + application.config.update( + TESTING=True, + SQLALCHEMY_DATABASE_URI="sqlite:///:memory:", + ) + with application.app_context(): + _db.create_all() + # Seed instruments + from app.models import Instrument + for name in ("banjo", "fiddle"): + if not _db.session.query(Instrument).filter_by(name=name).first(): + _db.session.add(Instrument(name=name)) + _db.session.commit() + yield application + _db.drop_all() + + +@pytest.fixture() +def client(app): + return app.test_client() + + +@pytest.fixture(autouse=True) +def clean_db(app): + with app.app_context(): + yield + _db.session.rollback() + from app.models import (TuneByInstrumentNote, TuneByInstrument, + Reference, TuneNote, Tune, Source, Musician, Tuning) + for model in (TuneByInstrumentNote, Reference, TuneNote, + TuneByInstrument, Tune, Source, Musician, Tuning): + _db.session.query(model).delete() + _db.session.commit() + + +AUTH = {"X-API-Key": "test-key"} +BAD_AUTH = {"X-API-Key": "wrong-key"} diff --git a/tests/test_instruments.py b/tests/test_instruments.py new file mode 100644 index 0000000..cdf1dbd --- /dev/null +++ b/tests/test_instruments.py @@ -0,0 +1,130 @@ +"""Tests for tune_by_instrument endpoints.""" +from tests.conftest import AUTH + + +def make_tune(client): + r = client.post("/tunes/", json={"name": "Test Tune", "key": "D"}, headers=AUTH) + assert r.status_code == 201 + return r.get_json() + + +def make_musician(client, name="Earl Scruggs"): + r = client.post("/musicians/", json={"name": name}, headers=AUTH) + assert r.status_code == 201 + return r.get_json() + + +def get_instrument_id(client, name): + return next(i["id"] for i in client.get("/instruments").get_json() if i["name"] == name) + + +def make_tuning(client, name="double C"): + r = client.post("/tunings/", json={"name": name}, headers=AUTH) + assert r.status_code == 201 + return r.get_json() + + +class TestTuneByInstrument: + def test_create_entry(self, client): + tune = make_tune(client) + banjo_id = get_instrument_id(client, "banjo") + tuning = make_tuning(client, "double C") + r = client.post(f'/tunes/{tune["id"]}/instruments', + json={"instrument_id": banjo_id, "tuning_id": tuning["id"], "callable": True}, + headers=AUTH) + assert r.status_code == 201 + body = r.get_json() + assert body["instrument_name"] == "banjo" + assert body["tuning"] == "double C" + assert body["callable"] is True + + def test_create_with_musician(self, client): + tune = make_tune(client) + banjo_id = get_instrument_id(client, "banjo") + m = make_musician(client) + r = client.post(f'/tunes/{tune["id"]}/instruments', + json={"instrument_id": banjo_id, "learned_from_id": m["id"]}, + headers=AUTH) + assert r.status_code == 201 + assert r.get_json()["learned_from"] == "Earl Scruggs" + + def test_update_entry(self, client): + tune = make_tune(client) + banjo_id = get_instrument_id(client, "banjo") + tuning = make_tuning(client, "sawmill") + entry = client.post(f'/tunes/{tune["id"]}/instruments', + json={"instrument_id": banjo_id, "callable": False}, + headers=AUTH).get_json() + r = client.patch(f'/tunes/{tune["id"]}/instruments/{entry["id"]}', + json={"callable": True, "tuning_id": tuning["id"]}, headers=AUTH) + assert r.status_code == 200 + assert r.get_json()["callable"] is True + assert r.get_json()["tuning"] == "sawmill" + + def test_delete_entry(self, client): + tune = make_tune(client) + banjo_id = get_instrument_id(client, "banjo") + entry = client.post(f'/tunes/{tune["id"]}/instruments', + json={"instrument_id": banjo_id}, headers=AUTH).get_json() + r = client.delete(f'/tunes/{tune["id"]}/instruments/{entry["id"]}', headers=AUTH) + assert r.status_code == 200 + remaining = client.get(f'/tunes/{tune["id"]}/instruments').get_json() + assert all(e["id"] != entry["id"] for e in remaining) + + def test_unique_constraint(self, client): + tune = make_tune(client) + banjo_id = get_instrument_id(client, "banjo") + client.post(f'/tunes/{tune["id"]}/instruments', + json={"instrument_id": banjo_id}, headers=AUTH) + r = client.post(f'/tunes/{tune["id"]}/instruments', + json={"instrument_id": banjo_id}, headers=AUTH) + assert r.status_code == 409 + + +class TestInstrumentNotes: + def test_add_and_list_notes(self, client): + tune = make_tune(client) + banjo_id = get_instrument_id(client, "banjo") + entry = client.post(f'/tunes/{tune["id"]}/instruments', + json={"instrument_id": banjo_id}, headers=AUTH).get_json() + client.post(f'/tunes/{tune["id"]}/instruments/{entry["id"]}/notes', + json={"note": "tricky B part"}, headers=AUTH) + client.post(f'/tunes/{tune["id"]}/instruments/{entry["id"]}/notes', + json={"note": "watch the timing"}, headers=AUTH) + notes = client.get(f'/tunes/{tune["id"]}/instruments/{entry["id"]}/notes').get_json() + assert len(notes) == 2 + + def test_update_note(self, client): + tune = make_tune(client) + banjo_id = get_instrument_id(client, "banjo") + entry = client.post(f'/tunes/{tune["id"]}/instruments', + json={"instrument_id": banjo_id}, headers=AUTH).get_json() + note = client.post(f'/tunes/{tune["id"]}/instruments/{entry["id"]}/notes', + json={"note": "old"}, headers=AUTH).get_json() + r = client.patch(f'/tunes/{tune["id"]}/instruments/{entry["id"]}/notes/{note["id"]}', + json={"note": "new"}, headers=AUTH) + assert r.get_json()["note"] == "new" + + def test_delete_note(self, client): + tune = make_tune(client) + banjo_id = get_instrument_id(client, "banjo") + entry = client.post(f'/tunes/{tune["id"]}/instruments', + json={"instrument_id": banjo_id}, headers=AUTH).get_json() + note = client.post(f'/tunes/{tune["id"]}/instruments/{entry["id"]}/notes', + json={"note": "to delete"}, headers=AUTH).get_json() + r = client.delete(f'/tunes/{tune["id"]}/instruments/{entry["id"]}/notes/{note["id"]}', + headers=AUTH) + assert r.status_code == 200 + + def test_notes_included_in_entry_to_dict(self, client): + tune = make_tune(client) + banjo_id = get_instrument_id(client, "banjo") + entry = client.post(f'/tunes/{tune["id"]}/instruments', + json={"instrument_id": banjo_id}, headers=AUTH).get_json() + client.post(f'/tunes/{tune["id"]}/instruments/{entry["id"]}/notes', + json={"note": "my note"}, headers=AUTH) + # Notes appear in the instrument entry and in the parent tune + tune_data = client.get(f'/tunes/{tune["id"]}').get_json() + inst_entry = tune_data["instruments"][0] + assert len(inst_entry["notes"]) == 1 + assert inst_entry["notes"][0]["note"] == "my note" diff --git a/tests/test_sources.py b/tests/test_sources.py new file mode 100644 index 0000000..44f1fda --- /dev/null +++ b/tests/test_sources.py @@ -0,0 +1,70 @@ +"""Tests for /sources endpoints.""" +from tests.conftest import AUTH, BAD_AUTH + + +def create_source(client, name="Doc Watson"): + r = client.post("/sources/", json={"name": name}, headers=AUTH) + assert r.status_code == 201 + return r.get_json() + + +class TestSources: + def test_create(self, client): + s = create_source(client) + assert s["name"] == "Doc Watson" + assert "id" in s + + def test_list(self, client): + create_source(client, "Doc Watson") + create_source(client, "Tony Rice") + r = client.get("/sources/") + assert r.status_code == 200 + names = [s["name"] for s in r.get_json()] + assert "Doc Watson" in names + assert "Tony Rice" in names + + def test_list_is_public(self, client): + r = client.get("/sources/") + assert r.status_code == 200 + + def test_create_requires_auth(self, client): + r = client.post("/sources/", json={"name": "test"}) + assert r.status_code == 401 + + def test_update(self, client): + s = create_source(client) + r = client.patch(f'/sources/{s["id"]}', json={"name": "Doc Watson Jr."}, headers=AUTH) + assert r.status_code == 200 + assert r.get_json()["name"] == "Doc Watson Jr." + + def test_delete(self, client): + s = create_source(client) + r = client.delete(f'/sources/{s["id"]}', headers=AUTH) + assert r.status_code == 200 + remaining = [x["id"] for x in client.get("/sources/").get_json()] + assert s["id"] not in remaining + + def test_tune_with_source(self, client): + s = create_source(client, "Clarence Ashley") + r = client.post("/tunes/", json={"name": "Coo Coo Bird", "key": "D", "source_id": s["id"]}, headers=AUTH) + assert r.status_code == 201 + tune = r.get_json() + assert tune["source"]["name"] == "Clarence Ashley" + assert tune["source_id"] == s["id"] + + def test_filter_by_source(self, client): + s = create_source(client, "Clarence Ashley") + client.post("/tunes/", json={"name": "Coo Coo Bird", "source_id": s["id"]}, headers=AUTH) + client.post("/tunes/", json={"name": "Salt Creek"}, headers=AUTH) + r = client.get(f'/tunes/?source_id={s["id"]}') + results = r.get_json() + assert len(results) == 1 + assert results[0]["name"] == "Coo Coo Bird" + + def test_filter_by_modal(self, client): + client.post("/tunes/", json={"name": "Modal Tune", "modal": True}, headers=AUTH) + client.post("/tunes/", json={"name": "Regular Tune", "modal": False}, headers=AUTH) + r = client.get("/tunes/?modal=true") + names = [t["name"] for t in r.get_json()] + assert "Modal Tune" in names + assert "Regular Tune" not in names diff --git a/tests/test_sub.py b/tests/test_sub.py new file mode 100644 index 0000000..029758d --- /dev/null +++ b/tests/test_sub.py @@ -0,0 +1,133 @@ +"""Tests for notes, references, and musicians sub-resource endpoints.""" +from tests.conftest import AUTH + + +def make_tune(client): + r = client.post("/tunes/", json={"name": "Sub Test Tune", "key": "G"}, headers=AUTH) + assert r.status_code == 201 + return r.get_json() + + +def make_musician(client, name="Doc Watson"): + r = client.post("/musicians/", json={"name": name}, headers=AUTH) + assert r.status_code == 201 + return r.get_json() + + +# ── Notes ────────────────────────────────────────────────────── + +class TestNotes: + def test_add_note(self, client): + tune = make_tune(client) + r = client.post(f'/tunes/{tune["id"]}/notes', + json={"note": "remember the B part"}, headers=AUTH) + assert r.status_code == 201 + assert r.get_json()["note"] == "remember the B part" + + def test_list_notes(self, client): + tune = make_tune(client) + client.post(f'/tunes/{tune["id"]}/notes', json={"note": "note 1"}, headers=AUTH) + client.post(f'/tunes/{tune["id"]}/notes', json={"note": "note 2"}, headers=AUTH) + r = client.get(f'/tunes/{tune["id"]}/notes', headers=AUTH) + assert len(r.get_json()) == 2 + + def test_update_note(self, client): + tune = make_tune(client) + note = client.post(f'/tunes/{tune["id"]}/notes', + json={"note": "old"}, headers=AUTH).get_json() + r = client.patch(f'/tunes/{tune["id"]}/notes/{note["id"]}', + json={"note": "new"}, headers=AUTH) + assert r.get_json()["note"] == "new" + + def test_delete_note(self, client): + tune = make_tune(client) + note = client.post(f'/tunes/{tune["id"]}/notes', + json={"note": "to delete"}, headers=AUTH).get_json() + r = client.delete(f'/tunes/{tune["id"]}/notes/{note["id"]}', headers=AUTH) + assert r.status_code == 200 + + def test_note_on_missing_tune_returns_404(self, client): + r = client.post("/tunes/99999/notes", json={"note": "x"}, headers=AUTH) + assert r.status_code == 404 + + +# ── References ──────────────────────────────────────────────── + +class TestReferences: + def test_add_reference(self, client): + tune = make_tune(client) + r = client.post(f'/tunes/{tune["id"]}/references', + json={"link": "https://youtube.com/abc", "site": "YouTube"}, + headers=AUTH) + assert r.status_code == 201 + assert r.get_json()["musicians"] == [] + + def test_add_reference_with_musicians(self, client): + tune = make_tune(client) + m1 = make_musician(client, "Doc Watson") + m2 = make_musician(client, "Merle Watson") + r = client.post(f'/tunes/{tune["id"]}/references', + json={"link": "https://example.com", "site": "YouTube", + "musicians": [{"id": m1["id"]}, {"id": m2["id"]}]}, + headers=AUTH) + assert r.status_code == 201 + names = [m["name"] for m in r.get_json()["musicians"]] + assert "Doc Watson" in names + assert "Merle Watson" in names + + def test_list_references(self, client): + tune = make_tune(client) + client.post(f'/tunes/{tune["id"]}/references', + json={"link": "https://a.com"}, headers=AUTH) + client.post(f'/tunes/{tune["id"]}/references', + json={"link": "https://b.com"}, headers=AUTH) + r = client.get(f'/tunes/{tune["id"]}/references', headers=AUTH) + assert len(r.get_json()) == 2 + + def test_update_reference(self, client): + tune = make_tune(client) + ref = client.post(f'/tunes/{tune["id"]}/references', + json={"link": "https://old.com", "site": "Old"}, + headers=AUTH).get_json() + r = client.patch(f'/tunes/{tune["id"]}/references/{ref["id"]}', + json={"site": "New"}, headers=AUTH) + assert r.get_json()["site"] == "New" + + def test_delete_reference(self, client): + tune = make_tune(client) + ref = client.post(f'/tunes/{tune["id"]}/references', + json={"link": "https://del.com"}, headers=AUTH).get_json() + r = client.delete(f'/tunes/{tune["id"]}/references/{ref["id"]}', headers=AUTH) + assert r.status_code == 200 + + +# ── Musicians on references ─────────────────────────────────── + +class TestReferenceMusicians: + def _make_ref(self, client, tune_id): + r = client.post(f'/tunes/{tune_id}/references', + json={"link": "https://example.com"}, headers=AUTH) + return r.get_json() + + def test_add_musician_to_reference(self, client): + tune = make_tune(client) + ref = self._make_ref(client, tune["id"]) + m = make_musician(client, "Tony Rice") + r = client.post(f'/tunes/{tune["id"]}/references/{ref["id"]}/musicians', + json={"musician_id": m["id"]}, headers=AUTH) + assert r.status_code == 200 + names = [x["name"] for x in r.get_json()["musicians"]] + assert "Tony Rice" in names + + def test_remove_musician_from_reference(self, client): + tune = make_tune(client) + m = make_musician(client, "Tony Rice") + ref = self._make_ref(client, tune["id"]) + # add musician via the join endpoint + client.post(f'/tunes/{tune["id"]}/references/{ref["id"]}/musicians', + json={"musician_id": m["id"]}, headers=AUTH) + r = client.delete( + f'/tunes/{tune["id"]}/references/{ref["id"]}/musicians/{m["id"]}', + headers=AUTH) + assert r.status_code == 200 + assert r.get_json()["musicians"] == [] diff --git a/tests/test_tunes.py b/tests/test_tunes.py new file mode 100644 index 0000000..52f1de9 --- /dev/null +++ b/tests/test_tunes.py @@ -0,0 +1,188 @@ +"""Tests for /tunes endpoints.""" +from tests.conftest import AUTH, BAD_AUTH + + +def create_tune(client, **kwargs): + data = {"name": "Cluck Old Hen", "key": "A", **kwargs} + r = client.post("/tunes/", json=data, headers=AUTH) + assert r.status_code == 201, r.get_json() + return r.get_json() + + +def get_instrument_id(client, name): + instruments = client.get("/instruments").get_json() + return next(i["id"] for i in instruments if i["name"] == name) + + +# ── Auth ────────────────────────────────────────────────────── + +class TestAuth: + def test_missing_key_rejected_on_write(self, client): + r = client.post("/tunes/", json={"name": "Test"}) + assert r.status_code == 401 + + def test_wrong_key_rejected_on_write(self, client): + r = client.post("/tunes/", json={"name": "Test"}, headers=BAD_AUTH) + assert r.status_code == 401 + + def test_reads_are_public(self, client): + assert client.get("/tunes/").status_code == 200 + + def test_correct_key_accepted_on_write(self, client): + r = client.post("/tunes/", json={"name": "Test"}, headers=AUTH) + assert r.status_code == 201 + + +# ── Health ──────────────────────────────────────────────────── + +class TestHealth: + def test_health(self, client): + assert client.get("/health").get_json()["status"] == "ok" + + +# ── Instruments lookup ──────────────────────────────────────── + +class TestInstrumentsLookup: + def test_list_instruments(self, client): + names = [i["name"] for i in client.get("/instruments").get_json()] + assert "banjo" in names + assert "fiddle" in names + + +# ── Tune CRUD ───────────────────────────────────────────────── + +class TestTuneCRUD: + def test_create_minimal(self, client): + tune = create_tune(client) + assert tune["name"] == "Cluck Old Hen" + assert tune["key"] == "A" + assert tune["modal"] is None + assert tune["source"] is None + assert tune["instruments"] == [] + assert tune["notes"] == [] + assert tune["references"] == [] + + def test_create_with_instrument(self, client): + banjo_id = get_instrument_id(client, "banjo") + tuning = client.post("/tunings/", json={"name": "double C"}, headers=AUTH).get_json() + tune = create_tune(client, instruments=[{ + "instrument_id": banjo_id, + "tuning_id": tuning["id"], + "callable": True, + }]) + assert len(tune["instruments"]) == 1 + assert tune["instruments"][0]["instrument_name"] == "banjo" + assert tune["instruments"][0]["tuning"] == "double C" + + def test_create_with_two_instruments(self, client): + banjo_id = get_instrument_id(client, "banjo") + fiddle_id = get_instrument_id(client, "fiddle") + tune = create_tune(client, instruments=[ + {"instrument_id": banjo_id, "tuning": "double C"}, + {"instrument_id": fiddle_id, "tuning": "cross"}, + ]) + assert len(tune["instruments"]) == 2 + + def test_create_with_notes_and_references(self, client): + tune = create_tune(client, + notes=[{"note": "tricky B part"}], + references=[{"link": "https://youtube.com/x", "site": "YouTube"}], + ) + assert tune["notes"][0]["note"] == "tricky B part" + assert tune["references"][0]["site"] == "YouTube" + + def test_get_single(self, client): + created = create_tune(client) + r = client.get(f'/tunes/{created["id"]}', headers=AUTH) + assert r.status_code == 200 + assert r.get_json()["id"] == created["id"] + + def test_get_nonexistent_returns_404(self, client): + assert client.get("/tunes/99999", headers=AUTH).status_code == 404 + + def test_list_returns_all(self, client): + create_tune(client, name="Tune A") + create_tune(client, name="Tune B") + names = [t["name"] for t in client.get("/tunes/", headers=AUTH).get_json()] + assert "Tune A" in names and "Tune B" in names + + def test_patch_core_fields(self, client): + tune = create_tune(client) + r = client.patch(f'/tunes/{tune["id"]}', + json={"key": "G", "name": "New Name", "modal": True}, + headers=AUTH) + assert r.status_code == 200 + u = r.get_json() + assert u["key"] == "G" and u["name"] == "New Name" and u["modal"] is True + + def test_delete(self, client): + tune = create_tune(client) + r = client.delete(f'/tunes/{tune["id"]}', headers=AUTH) + assert r.status_code == 200 + assert client.get(f'/tunes/{tune["id"]}', headers=AUTH).status_code == 404 + + def test_delete_cascades_to_instrument_entry(self, client): + from app.models import TuneByInstrument + from app.extensions import db + banjo_id = get_instrument_id(client, "banjo") + tune = create_tune(client, instruments=[{"instrument_id": banjo_id}]) + client.delete(f'/tunes/{tune["id"]}', headers=AUTH) + assert db.session.query(TuneByInstrument).filter_by(tune_id=tune["id"]).count() == 0 + + +# ── Filtering ───────────────────────────────────────────────── + +class TestFiltering: + def test_filter_by_key(self, client): + create_tune(client, name="D tune", key="D") + create_tune(client, name="G tune", key="G") + results = client.get("/tunes/?key=D", headers=AUTH).get_json() + assert all(t["key"] == "D" for t in results) + + def test_search_by_name(self, client): + create_tune(client, name="Whiskey Before Breakfast") + create_tune(client, name="Salt Creek") + results = client.get("/tunes/?search=whiskey", headers=AUTH).get_json() + assert len(results) == 1 + assert results[0]["name"] == "Whiskey Before Breakfast" + + def test_filter_by_instrument(self, client): + banjo_id = get_instrument_id(client, "banjo") + create_tune(client, name="Has Banjo", + instruments=[{"instrument_id": banjo_id, "tuning": "standard"}]) + create_tune(client, name="No Banjo") + results = client.get(f"/tunes/?instrument_id={banjo_id}", headers=AUTH).get_json() + names = [t["name"] for t in results] + assert "Has Banjo" in names and "No Banjo" not in names + + def test_filter_by_tuning(self, client): + fiddle_id = get_instrument_id(client, "fiddle") + cross_tuning = client.post("/tunings/", json={"name": "cross"}, headers=AUTH).get_json() + std_tuning = client.post("/tunings/", json={"name": "standard"}, headers=AUTH).get_json() + create_tune(client, name="Cross", instruments=[{"instrument_id": fiddle_id, "tuning_id": cross_tuning["id"]}]) + create_tune(client, name="Std", instruments=[{"instrument_id": fiddle_id, "tuning_id": std_tuning["id"]}]) + results = client.get(f'/tunes/?instrument_id={fiddle_id}&tuning={cross_tuning["id"]}', headers=AUTH).get_json() + assert len(results) == 1 and results[0]["name"] == "Cross" + + def test_filter_callable(self, client): + banjo_id = get_instrument_id(client, "banjo") + create_tune(client, name="Can call", instruments=[{"instrument_id": banjo_id, "callable": True}]) + create_tune(client, name="Cannot call", instruments=[{"instrument_id": banjo_id, "callable": False}]) + results = client.get(f"/tunes/?instrument_id={banjo_id}&callable=true", headers=AUTH).get_json() + names = [t["name"] for t in results] + assert "Can call" in names and "Cannot call" not in names + + def test_filter_to_learn(self, client): + fiddle_id = get_instrument_id(client, "fiddle") + create_tune(client, name="To learn", instruments=[{"instrument_id": fiddle_id, "to_learn": True}]) + create_tune(client, name="Learned", instruments=[{"instrument_id": fiddle_id, "to_learn": False}]) + results = client.get(f"/tunes/?instrument_id={fiddle_id}&to_learn=true", headers=AUTH).get_json() + names = [t["name"] for t in results] + assert "To learn" in names and "Learned" not in names + + def test_filter_modal(self, client): + create_tune(client, name="Modal", modal=True) + create_tune(client, name="Regular", modal=False) + results = client.get("/tunes/?modal=true", headers=AUTH).get_json() + names = [t["name"] for t in results] + assert "Modal" in names and "Regular" not in names diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..7dce652 --- /dev/null +++ b/uv.lock @@ -0,0 +1,456 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[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 = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, +] + +[[package]] +name = "click" +version = "8.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "flask" +version = "3.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "blinker" }, + { name = "click" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" }, +] + +[[package]] +name = "flask-cors" +version = "6.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flask" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/47/03/4e464a50860f9adf08b5c1d3479cb8ea1f12af2aa69535c7042c6e628135/flask_cors-6.0.5.tar.gz", hash = "sha256:30c5031552cd59f620ac0c8211dac45b345d3b2df310e7721879e4f46ef9c601", size = 101386, upload-time = "2026-06-08T20:20:17.765Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/55/5bb1a2d918e9f02f131e47a59032bae70e48050e986e941511fd737a935c/flask_cors-6.0.5-py3-none-any.whl", hash = "sha256:68fcf75693e961f3af26683b23c4b9a8fb6b64de17d20d0c37b95e8de7ab2ed8", size = 16692, upload-time = "2026-06-08T20:20:16.247Z" }, +] + +[[package]] +name = "flask-sqlalchemy" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flask" }, + { name = "sqlalchemy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/91/53/b0a9fcc1b1297f51e68b69ed3b7c3c40d8c45be1391d77ae198712914392/flask_sqlalchemy-3.1.1.tar.gz", hash = "sha256:e4b68bb881802dda1a7d878b2fc84c06d1ee57fb40b874d3dc97dabfa36b8312", size = 81899, upload-time = "2023-09-11T21:42:36.147Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/6a/89963a5c6ecf166e8be29e0d1bf6806051ee8fe6c82e232842e3aeac9204/flask_sqlalchemy-3.1.1-py3-none-any.whl", hash = "sha256:4ba4be7f419dc72f4efd8802d69974803c37259dd42f3913b0dcf75c9447e0a0", size = 25125, upload-time = "2023-09-11T21:42:34.514Z" }, +] + +[[package]] +name = "greenlet" +version = "3.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/6e/802acd792aebb2256fbbee8cacf2727faaeb6f240ac11008f09eae4414bc/greenlet-3.5.1.tar.gz", hash = "sha256:5a56aeb7d5d9cc4b3a735efb5095bd4b4f6f0e4f93e5ca876d0e2315137b7829", size = 197356, upload-time = "2026-05-20T15:05:03.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/37/4549f149c9797c21b32c2683c33522af22522099de128b2406672526d005/greenlet-3.5.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:fa4f98af3a528f0c3fd592a26df7f376f93329c8f4d987f6bb979057af8bf5e2", size = 286220, upload-time = "2026-05-20T13:07:28.463Z" }, + { url = "https://files.pythonhosted.org/packages/38/ff/a4f436709716965eaab9f36ea7b906c8a927fbe32fb1372a2071d964f6b1/greenlet-3.5.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffea73584b216150eab159b6d12348fb253e68757974de1e2c40d8a318ac89ed", size = 601585, upload-time = "2026-05-20T14:00:06.141Z" }, + { url = "https://files.pythonhosted.org/packages/65/ad/54bc3fcee3ad368a61b19b67d88117f7a8c29727bf71fffdeda81fbd946e/greenlet-3.5.1-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1072b4f9edcc1e192d9283a66a3e68d6b84c561de33a83d7858beb9ba1effe10", size = 614215, upload-time = "2026-05-20T14:05:42.675Z" }, + { url = "https://files.pythonhosted.org/packages/40/69/b91cda0647df839483201545913514c2827ebea5e5ccdf931842763bc127/greenlet-3.5.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:add5217d68b31130f0beca584d7fef4878327d2e31642b66618a14eef312b63b", size = 611358, upload-time = "2026-05-20T13:14:26.37Z" }, + { url = "https://files.pythonhosted.org/packages/59/90/3cf77e080350cd02fa307bb2abf05df48f4482c240275bbd2c203ba8bb1c/greenlet-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a5ea42a752d47a145eae922b605cd1634665ac3d5ec1e72402d5048e8d60d207", size = 1570475, upload-time = "2026-05-20T14:02:25.29Z" }, + { url = "https://files.pythonhosted.org/packages/65/2c/18cece62045e74598c3c393f70dce4a63f56222015ba29a5d4eeb04f764c/greenlet-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c5551170cf4f5ff5623e9af81323751979fee2c731e2287b61f73cd27257b823", size = 1635625, upload-time = "2026-05-20T13:14:34.027Z" }, + { url = "https://files.pythonhosted.org/packages/30/f5/310d104ddf41eb5a70f4c268d22508dfb0c3c8e86fec152be34d0d2ed819/greenlet-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3c8bb982ad117d29478ef8f5533e97df21f1e2befd17a299257b0c96d1371c0b", size = 238791, upload-time = "2026-05-20T13:10:39.018Z" }, + { url = "https://files.pythonhosted.org/packages/62/90/ceca11f504cd23a8047a3dea31919adc48df9b626dd0c13f0d858734fdfd/greenlet-3.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:80eb4b04dadc4e67df3fae179a32c4706a3f495bc7f22fc8a81115d5f5512188", size = 235580, upload-time = "2026-05-20T13:08:45.056Z" }, + { url = "https://files.pythonhosted.org/packages/27/69/7f7e5372d998b81001899b1c0823c957aa413ba0f2662e65821611cc31e4/greenlet-3.5.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:51518ff74664078fc51bffcc6fc529b0df5ae58da192691cee765d45ce944a2b", size = 285060, upload-time = "2026-05-20T13:08:51.899Z" }, + { url = "https://files.pythonhosted.org/packages/b1/bf/387f9b6b865fd2ae0d0be09e0004827295a01b71be76ed350dd1e28a91a4/greenlet-3.5.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ffdb3c0bb002c99cd8f298957e046c3dbf6006b5b7cdf11a4e19194624a0a0a", size = 604370, upload-time = "2026-05-20T14:00:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/32/f5/169ce3d4e4c67291bd18f8cbe0299c9f3e45102c7f1fb3c14780c93e4532/greenlet-3.5.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7715a5a2c3378ba602c3a440558261e13a820bb53a82693aacd7b7f6d964e283", size = 616987, upload-time = "2026-05-20T14:05:44.237Z" }, + { url = "https://files.pythonhosted.org/packages/ee/e5/7f2e41d5273be07e77560d61ea4e56485b4d6c316d2a84518c62d1364061/greenlet-3.5.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc71ff466927a201b08305acac451ebe1aedfcea002f62f1f2f2ac2ac1e6a135", size = 613911, upload-time = "2026-05-20T13:14:27.539Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a4/fbdc67579b73615a1f91615e814303cc71e06128f7baaba87be79b8fb90c/greenlet-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cd443683db272ebaaca03af98c0b063ab30db70ea8a31a1559f35e3f7b744ccd", size = 1570689, upload-time = "2026-05-20T14:02:27.225Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b4/77abbe35078be39718a46cd49caf16bceb35662f97a34101dca28aa98e47/greenlet-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:089fff7a6ce8d9316d1f65ebc00273a56be258c1725b32b94de90a3a979557e1", size = 1635602, upload-time = "2026-05-20T13:14:36.344Z" }, + { url = "https://files.pythonhosted.org/packages/37/f7/129f27ca700845b8ee8ca88ce7f43435a1239c2eddb7677fc938822762cf/greenlet-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:110a1ca7b49b014b097f6078272c3f4ed31af45b254de5228b79adba879f6af9", size = 238683, upload-time = "2026-05-20T13:11:50.57Z" }, + { url = "https://files.pythonhosted.org/packages/6d/5c/a485a36e87df8d8fd0632ee01511244f5156a20ed3746cc6599340326395/greenlet-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:f16ba1efc0715b680a18b8123d90dad887c6112ae3555b4b5c32c149540c6b4e", size = 235499, upload-time = "2026-05-20T13:12:42.028Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cb/c62454606daf5640369c94d8a9dd540599b1bfc090e2d2180cb77f4038d2/greenlet-3.5.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8ab31c9de8651a2facdd5c5bb0011f2380dd1a7af78ce2adf4b56095294fc07", size = 285579, upload-time = "2026-05-20T13:08:56.396Z" }, + { url = "https://files.pythonhosted.org/packages/ec/71/c4270398c2eba968a6071af1dfbdcaeee6ec1c24bc8b435b8cc452700da6/greenlet-3.5.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e300185139abc337ade480c327183adf42a875ac7181bfe66d7d4efea31fbea", size = 651106, upload-time = "2026-05-20T14:00:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/1a/ab/71e34b78a44ec271fb5f550c17bc46d301ddc5953890d935f270b0dcdb5a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7ffdb990dcaa0234cf9845aead5df2e3c3a8b6507d409274dd87e0d5ab05ffc2", size = 663478, upload-time = "2026-05-20T14:05:45.88Z" }, + { url = "https://files.pythonhosted.org/packages/77/96/4efd6fa5c62c85426a0c19077a586258ebc3a2a146ff2493e4312a697a22/greenlet-3.5.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f82b3597e9d83b63408affed0b48fd0f54935edac4302237b9a837be0dae33c", size = 660800, upload-time = "2026-05-20T13:14:29.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/e0/6c71401a25cac7000261304e866a2f2cc04dc74810d40e2f118aa4799495/greenlet-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c0141e37414c10164e702b8fb1473304221ad98f71600850c6ef7ff4880feba0", size = 1617518, upload-time = "2026-05-20T14:02:28.662Z" }, + { url = "https://files.pythonhosted.org/packages/41/26/c5c06643e8c0af9e7bf18e16cb51d0ab7625155f0392e1c9015d66d556cd/greenlet-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:50ae25a67bea74ea41fb14b960bc532df73eb713417b2d61892dced82fe8d3bc", size = 1681593, upload-time = "2026-05-20T13:14:39.417Z" }, + { url = "https://files.pythonhosted.org/packages/8a/bd/e11a108317485075e68af9d23039619b86b28130c3b50d227d42edece64b/greenlet-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:8a17c42330e261299766b75ac1ea32caa437a9453c8f65d16a13140db378ecd3", size = 239800, upload-time = "2026-05-20T13:09:30.128Z" }, + { url = "https://files.pythonhosted.org/packages/47/f8/8e8e8417b7bf28639a5a56356ef934d0375e1d0c70a57e04d7701e870ffe/greenlet-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:7b5f5fae05b8ac6d176a61b60c394a8cbdc2b5b91b81793066e68745cf165e54", size = 236862, upload-time = "2026-05-20T13:09:10.498Z" }, + { url = "https://files.pythonhosted.org/packages/90/12/41bf27fde4d3605d3773ae57751eda182b8be2f5398011c041173b1d9534/greenlet-3.5.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:ea8da1e900d758d078810d4255d8c6aa572181896a31ec79d779eb79c3adc9ad", size = 293637, upload-time = "2026-05-20T13:12:35.529Z" }, + { url = "https://files.pythonhosted.org/packages/44/44/ba14b23e9757707050c2f397d305bbcae62e5d7cad122f8b6baec5ae4a1f/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a19570c52a21420dcbc94e661994bc325c0b5b11304540fed514586da5dc8f2e", size = 650840, upload-time = "2026-05-20T14:00:11.079Z" }, + { url = "https://files.pythonhosted.org/packages/a8/37/5ddc2b686a6844f91abecef43411842426da2e1573f60b49ecf2547f4ae1/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3d955c89b75eeca4723d7cc14135f393cd47c32e2a6cb4a8e4c6e760a26b0986", size = 656416, upload-time = "2026-05-20T14:05:47.118Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f0/d17510297c35a2992712f0bf84de3779749999f7d3d63aa1f09db7c62dbe/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2daaaebd1a5aa88c49045b6baf9310b3263796bd88db713edf37cf53e7bb4e", size = 654397, upload-time = "2026-05-20T13:14:30.696Z" }, + { url = "https://files.pythonhosted.org/packages/37/eb/147387705bb89092645b012586e7273cb5ed3c90ef7eaf3a69173eaf0209/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bfbd69cc349e43bf3a8ae1c85548ff0718efc887615c2db16c3833d7b0b072d", size = 1614469, upload-time = "2026-05-20T14:02:30.192Z" }, + { url = "https://files.pythonhosted.org/packages/a6/4e/37ee0da7732b7aa9896f17e15579a9df34b9fcb9dd494f0adfa749af6623/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4378720dd888136c27215a0214d32a4d37c3852765d45bc37aad0623423cfd78", size = 1675115, upload-time = "2026-05-20T13:14:40.972Z" }, + { url = "https://files.pythonhosted.org/packages/57/f3/97dfcf4a6eb5077f8a672234216fb5923eb89f2cab7081cb10b2cf75b605/greenlet-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:45718441607f9325d948db98cbc691276059316d0358c188c246da4e1d4d23d2", size = 245246, upload-time = "2026-05-20T13:12:22.646Z" }, + { url = "https://files.pythonhosted.org/packages/5d/73/d7f72e34b582f694f4a9b248162db7b09cc458a259ba8f0c0bfa1a34ea7d/greenlet-3.5.1-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:2baee5ca02031757ffe8cc3d69f0cc0aec7065ce362622da74f32d3bcab1c541", size = 285575, upload-time = "2026-05-20T13:12:07.043Z" }, + { url = "https://files.pythonhosted.org/packages/df/59/fa9c6e87dc8ad27a95dabe2f29f372b733d05a8a67470f6c901ed9975655/greenlet-3.5.1-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b1ec3274918a81d3ea778b9e75b56b72b33f300edb6cf7f3a7fe1dae56683de", size = 656428, upload-time = "2026-05-20T14:00:12.556Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f9/e753408871eaa61dfe35e619cfc67512b036fde99893685d50eea9e07146/greenlet-3.5.1-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:111e2390ffffc47d5840b01711dd7fac07d4c09283d0283e7f3264b14e284c64", size = 667064, upload-time = "2026-05-20T14:05:48.662Z" }, + { url = "https://files.pythonhosted.org/packages/96/27/5565b5b40389f1c7753003a07e21892fda8660926787036d5bc0308b8113/greenlet-3.5.1-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e630136e905fe5ff43e86945ae41220b6d1470956a39220e708110ac48d01ea5", size = 665697, upload-time = "2026-05-20T13:14:32.943Z" }, + { url = "https://files.pythonhosted.org/packages/cf/82/e7de4178c0c2d1c9a5a3be3cc0b33e46a85b3ee4a77c071bf7ad8600e079/greenlet-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:975eac34b44a7077ca4d421348455b94f0f518246a7f14bc6d2fdcfe5b584368", size = 1621256, upload-time = "2026-05-20T14:02:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/00/10/f2dddcf7dacac17dfc68691809589adad06135eb28930429cf58a6467a2f/greenlet-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:9ab3c3a0b2ae6198e67c898dad5215a49f9ae0d0081b3c3ec59f333e39eeca26", size = 1685956, upload-time = "2026-05-20T13:14:42.55Z" }, + { url = "https://files.pythonhosted.org/packages/22/17/4a232b32133230ada52f70e9d7f5b65b0caef8772f01849bd8d149e7e4ca/greenlet-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:cbfc69be86e10dcfef5b1e6269d1d6926552aa89ee39e1de3353360c1b6989ab", size = 239802, upload-time = "2026-05-20T13:13:15.481Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ae/4e623a7e6d4d2a5f4cb8e4c82de4169fc637942caae68d6e676b8a128ac5/greenlet-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:92fd6d44ac5e5a887c8a5dc4a8ba0ba908527c31c12f78c6bc7dcfe8aab279f6", size = 236853, upload-time = "2026-05-20T13:15:37.301Z" }, + { url = "https://files.pythonhosted.org/packages/7a/57/816d9cff29119da3505b3d6a5e14a8af89006ac36f47f891ff293ee05af1/greenlet-3.5.1-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:a6fdf2433a5441ef9a95464f7c3e674775da1c8c1177fff311cee1acad4626ed", size = 293877, upload-time = "2026-05-20T13:10:19.078Z" }, + { url = "https://files.pythonhosted.org/packages/23/a1/59b0a7c7d140ff1a75626680b9a9899b79a9176cab298b394968fb023295/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7546556f0d649f99f6a361098a55f761181bb2ea12ff150bb16d26092ad88244", size = 655333, upload-time = "2026-05-20T14:00:14.758Z" }, + { url = "https://files.pythonhosted.org/packages/72/1b/5efe127597625042218939d01855109f352779050768b670b52edcc16a6c/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5ee3ea898009fa898f85f9982255d35278c477bebe185beca249cab42d4526c", size = 659443, upload-time = "2026-05-20T14:05:50.159Z" }, + { url = "https://files.pythonhosted.org/packages/6c/6d/c404246ea4d22d097a7426d0efb5b781bd7eb67715f09e79001bd552ab18/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5c81f74d204d3edd136ebfd50dce53acbb776995d721a0fe801626cfc93b8cd", size = 658356, upload-time = "2026-05-20T13:14:35.091Z" }, + { url = "https://files.pythonhosted.org/packages/51/02/f8ee37fb6d2219329f350af241c27fcf12df57e723d11f6fc6d3bacdadaa/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:2c18ef16bf6d4dd410e4dd52996888ea1497be26892fe5bbc73580aba4287b8e", size = 1619216, upload-time = "2026-05-20T14:02:33.403Z" }, + { url = "https://files.pythonhosted.org/packages/93/c5/3dc9475ace2c7a3680da12372cddd7f1ac874eb410a1ac48d3e9dab83782/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:17d86354f0ae6b61bf9be5148d0dd34e06c3cb7c602c671f79f29ac3b150e659", size = 1678427, upload-time = "2026-05-20T13:14:43.71Z" }, + { url = "https://files.pythonhosted.org/packages/df/4e/750c15c317a41ffb36f0bf40b933e3d744a7dede61889f74443ea69690cf/greenlet-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:e7516cf6ae6b8a582c2770a0caed47b8a48373ed732c33d69a72913ae6ac923e", size = 245225, upload-time = "2026-05-20T13:13:59.366Z" }, + { url = "https://files.pythonhosted.org/packages/4f/fd/d3baea2eeb7b617efd47e87ca06e2ec2c6118d303aa9e918e0ce16eadc10/greenlet-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:5028648bf2253ec4745add746129d3904121fa7fe871a76bed23c5720573ce0a", size = 239590, upload-time = "2026-05-20T13:13:37.382Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +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.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/62/791b31e69ae182791ec67f04850f2f062716bbd205483d63a215f3e062d3/mako-1.3.12.tar.gz", hash = "sha256:9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a", size = 400219, upload-time = "2026-04-28T19:01:08.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/b1/a0ec7a5a9db730a08daef1fdfb8090435b82465abbf758a596f0ea88727e/mako-1.3.12-py3-none-any.whl", hash = "sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9", size = 78521, upload-time = "2026-04-28T19:01:10.393Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "psycopg2-binary" +version = "2.9.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2a/60/a3624f79acea344c16fbef3a94d28b89a8042ddfb8f3e4ca83f538671409/psycopg2_binary-2.9.12.tar.gz", hash = "sha256:5ac9444edc768c02a6b6a591f070b8aae28ff3a99be57560ac996001580f294c", size = 379686, upload-time = "2026-04-21T09:40:34.304Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/9f/ef4ef3c8e15083df90ca35265cfd1a081a2f0cc07bb229c6314c6af817f4/psycopg2_binary-2.9.12-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5cdc05117180c5fa9c40eea8ea559ce64d73824c39d928b7da9fb5f6a9392433", size = 3712459, upload-time = "2026-04-20T23:34:30.549Z" }, + { url = "https://files.pythonhosted.org/packages/b5/01/3dd14e46ba48c1e1a6ec58ee599fa1b5efa00c246d5046cd903d0eeb1af1/psycopg2_binary-2.9.12-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d3227a3bc228c10d21011a99245edca923e4e8bf461857e869a507d9a41fe9f6", size = 3822936, upload-time = "2026-04-20T23:34:32.77Z" }, + { url = "https://files.pythonhosted.org/packages/a6/f7/0640e4901119d8a9f7a1784b927f494e2198e213ceb593753d1f2c8b1b30/psycopg2_binary-2.9.12-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:995ce929eede89db6254b50827e2b7fd61e50d11f0b116b29fffe4a2e53c4580", size = 4578676, upload-time = "2026-04-20T23:34:35.18Z" }, + { url = "https://files.pythonhosted.org/packages/b0/55/44df3965b5f297c50cc0b1b594a31c67d6127a9d133045b8a66611b14dfb/psycopg2_binary-2.9.12-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9fe06d93e72f1c048e731a2e3e7854a5bfaa58fc736068df90b352cefe66f03f", size = 4274917, upload-time = "2026-04-20T23:34:37.982Z" }, + { url = "https://files.pythonhosted.org/packages/b0/4b/74535248b1eac0c9336862e8617c765ac94dac76f9e25d7c4a79588c8907/psycopg2_binary-2.9.12-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40e7b28b63aaf737cb3a1edc3a9bbc9a9f4ad3dcb7152e8c1130e4050eddcb7d", size = 5894843, upload-time = "2026-04-20T23:34:40.856Z" }, + { url = "https://files.pythonhosted.org/packages/f2/ba/f1bf8d2ae71868ad800b661099086ee52bc0f8d9f05be1acd8ebb06757cc/psycopg2_binary-2.9.12-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:89d19a9f7899e8eb0656a2b3a08e0da04c720a06db6e0033eab5928aabe60fa9", size = 4110556, upload-time = "2026-04-20T23:34:44.016Z" }, + { url = "https://files.pythonhosted.org/packages/45/46/c15706c338403b7c420bcc0c2905aad116cc064545686d8bf85f1999ea00/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:612b965daee295ae2da8f8218ce1d274645dc76ef3f1abf6a0a94fd57eff876d", size = 3655714, upload-time = "2026-04-20T23:34:46.233Z" }, + { url = "https://files.pythonhosted.org/packages/b3/7c/a2d5dc09b64a4564db242a0fe418fde7d33f6f8259dd2c5b9d7def00fb5a/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b9a339b79d37c1b45f3235265f07cdeb0cb5ad7acd2ac7720a5920989c17c24e", size = 3301154, upload-time = "2026-04-20T23:34:49.528Z" }, + { url = "https://files.pythonhosted.org/packages/c0/e8/cc8c9a4ce71461f9ec548d38cadc41dc184b34c73e6455450775a9334ccd/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:3471336e1acfd9c7fe507b8bad5af9317b6a89294f9eb37bd9a030bb7bebcdc6", size = 3048882, upload-time = "2026-04-20T23:34:51.86Z" }, + { url = "https://files.pythonhosted.org/packages/19/6a/31e2296bc0787c5ab75d3d118e40b239db8151b5192b90b77c72bc9256e9/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7af18183109e23502c8b2ae7f6926c0882766f35b5175a4cd737ad825e4d7a1b", size = 3351298, upload-time = "2026-04-20T23:34:54.124Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a8/75f4e3e11203b590150abed2cf7794b9c9c9f7eceddae955191138b44dde/psycopg2_binary-2.9.12-cp312-cp312-win_amd64.whl", hash = "sha256:398fcd4db988c7d7d3713e2b8e18939776fd3fb447052daae4f24fa39daede4c", size = 2757230, upload-time = "2026-04-20T23:34:56.242Z" }, + { url = "https://files.pythonhosted.org/packages/91/bb/4608c96f970f6e0c56572e87027ef4404f709382a3503e9934526d7ba051/psycopg2_binary-2.9.12-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7c729a73c7b1b84de3582f73cdd27d905121dc2c531f3d9a3c32a3011033b965", size = 3712419, upload-time = "2026-04-20T23:34:58.754Z" }, + { url = "https://files.pythonhosted.org/packages/5e/af/48f76af9d50d61cf390f8cd657b503168b089e2e9298e48465d029fcc713/psycopg2_binary-2.9.12-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4413d0caef93c5cf50b96863df4c2efe8c269bf2267df353225595e7e15e8df7", size = 3822990, upload-time = "2026-04-20T23:35:00.821Z" }, + { url = "https://files.pythonhosted.org/packages/7a/df/aba0f99397cd811d32e06fc0cc781f1f3ce98bc0e729cb423925085d781a/psycopg2_binary-2.9.12-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4dfcf8e45ebb0c663be34a3442f65e17311f3367089cd4e5e3a3e8e62c978777", size = 4578696, upload-time = "2026-04-20T23:35:03.409Z" }, + { url = "https://files.pythonhosted.org/packages/95/9c/eaa74021ac4e4d5c2f83d82fc6615a63f4fe6c94dc4e94c3990427053f67/psycopg2_binary-2.9.12-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c41321a14dd74aceb6a9a643b9253a334521babfa763fa873e33d89cfa122fb5", size = 4274982, upload-time = "2026-04-20T23:35:05.583Z" }, + { url = "https://files.pythonhosted.org/packages/35/ed/c25deff98bd26187ba48b3b250a3ffc3037c46c5b89362534a15d200e0db/psycopg2_binary-2.9.12-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83946ba43979ebfdc99a3cd0ee775c89f221df026984ba19d46133d8d75d3cd9", size = 5894867, upload-time = "2026-04-20T23:35:07.902Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/8d0e21ca77373c6c9589e5c4528f6e8f0c08c62cafc76fb0bddb7a2cee22/psycopg2_binary-2.9.12-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:411e85815652d13560fbe731878daa5d92378c4995a22302071890ec3397d019", size = 4110578, upload-time = "2026-04-20T23:35:10.149Z" }, + { url = "https://files.pythonhosted.org/packages/00/fc/f481e2435bd8f742d0123309174aae4165160ad3ef17c1b99c3622c241d2/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c8ad4c08e00f7679559eaed7aff1edfffc60c086b976f93972f686384a95e2c", size = 3655816, upload-time = "2026-04-20T23:35:12.56Z" }, + { url = "https://files.pythonhosted.org/packages/53/79/b9f46466bdbe9f239c96cde8be33c1aace4842f06013b47b730dc9759187/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:00814e40fa23c2b37ef0a1e3c749d89982c73a9cb5046137f0752a22d432e82f", size = 3301307, upload-time = "2026-04-20T23:35:15.029Z" }, + { url = "https://files.pythonhosted.org/packages/3f/19/7dc003b32fe35024df89b658104f7c8538a8b2dcbde7a4e746ce929742e7/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:98062447aebc20ed20add1f547a364fd0ef8933640d5372ff1873f8deb9b61be", size = 3048968, upload-time = "2026-04-20T23:35:16.757Z" }, + { url = "https://files.pythonhosted.org/packages/91/58/2dbd7db5c604d45f4950d988506aae672a14126ec22998ced5021cbb76bb/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:66a7685d7e548f10fb4ce32fb01a7b7f4aa702134de92a292c7bd9e0d3dbd290", size = 3351369, upload-time = "2026-04-20T23:35:18.933Z" }, + { url = "https://files.pythonhosted.org/packages/42/ee/dee8dcaad07f735824de3d6563bc67119fa6c28257b17977a8d624f02fab/psycopg2_binary-2.9.12-cp313-cp313-win_amd64.whl", hash = "sha256:b6937f5fe4e180aeee87de907a2fa982ded6f7f15d7218f78a083e4e1d68f2a0", size = 2757347, upload-time = "2026-04-20T23:35:21.283Z" }, + { url = "https://files.pythonhosted.org/packages/13/1b/708c0dca874acfad6d65314271859899a79007686f3a1f74e82a2ed4b645/psycopg2_binary-2.9.12-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6f3b3de8a74ef8db215f22edffb19e32dc6fa41340456de7ec99efdc8a7b3ec2", size = 3712428, upload-time = "2026-04-20T23:35:23.453Z" }, + { url = "https://files.pythonhosted.org/packages/d6/39/ddbea9d4b4de6aca9431b6ed253f530f8a02d3b8f9bcfd0dbfe2b3de6fe4/psycopg2_binary-2.9.12-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1006fb62f0f0bc5ce256a832356c6262e91be43f5e4eb15b5eaf38079464caf2", size = 3823184, upload-time = "2026-04-20T23:35:25.92Z" }, + { url = "https://files.pythonhosted.org/packages/bf/a0/bc2fef74b106fa345567122a0659e6d94512ed7dc0131ec44c9e5aba3725/psycopg2_binary-2.9.12-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:840066105706cd2eb29b9a1c2329620056582a4bf3e8169dec5c447042d0869f", size = 4579157, upload-time = "2026-04-20T23:35:28.542Z" }, + { url = "https://files.pythonhosted.org/packages/57/d7/d4e3b2005d3de607ca4fbb0e8742e248056e52184a6b94ebda3c1c2c329b/psycopg2_binary-2.9.12-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:863f5d12241ebe1c76a72a04c2113b6dc905f90b9cef0e9be0efd994affd9354", size = 4274970, upload-time = "2026-04-20T23:35:30.418Z" }, + { url = "https://files.pythonhosted.org/packages/2e/42/c9853f8db3967fe08bcde11f53d53b85d351750cae726ce001cb68afa9c1/psycopg2_binary-2.9.12-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a99eaab34a9010f1a086b126de467466620a750634d114d20455f3a824aae033", size = 5895175, upload-time = "2026-04-20T23:35:33.584Z" }, + { url = "https://files.pythonhosted.org/packages/eb/fd/b82b5601a97630308bef079f545ffec481bbbc795c2ba5ec416a01d03f60/psycopg2_binary-2.9.12-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ffdd7dc5463ccd61845ac37b7012d0f35a1548df9febe14f8dd549be4a0bc81e", size = 4110658, upload-time = "2026-04-20T23:35:35.638Z" }, + { url = "https://files.pythonhosted.org/packages/62/8c/32ca69b0389ef25dd22937bf9e8fbe2ce27aea20b05ded48c4ce4cb42475/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:54a0dfecab1b48731f934e06139dfe11e24219fb6d0ceb32177cf0375f14c7b5", size = 3656251, upload-time = "2026-04-20T23:35:37.854Z" }, + { url = "https://files.pythonhosted.org/packages/c4/29/96992a2b59e3b9d730fcf9612d0a387305025dc867a9fc490a9e496e074e/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:96937c9c5d891f772430f418a7a8b4691a90c3e6b93cf72b5bd7cad8cbca32a5", size = 3301810, upload-time = "2026-04-20T23:35:39.927Z" }, + { url = "https://files.pythonhosted.org/packages/56/ad/44b06659949b243ae10112cd3b20a197f9bf3e81d5651379b9eb889bfaad/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:77b348775efd4cdab410ec6609d81ccecd1139c90265fa583a7255c8064bc03d", size = 3048977, upload-time = "2026-04-20T23:35:41.806Z" }, + { url = "https://files.pythonhosted.org/packages/1d/f2/10a1bcebadb6aa55e280e1f58975c36a7b560ea525184c7aa4064c466633/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:527e6342b3e44c2f0544f6b8e927d60de7f163f5723b8f1dfa7d2a84298738cd", size = 3351466, upload-time = "2026-04-20T23:35:43.993Z" }, + { url = "https://files.pythonhosted.org/packages/20/be/b732c8418ffa5bcfda002890f5dc4c869fc17db66ff11f53b17cfe44afc0/psycopg2_binary-2.9.12-cp314-cp314-win_amd64.whl", hash = "sha256:f12ae41fcafadb39b2785e64a40f9db05d6de2ac114077457e0e7c597f3af980", size = 2848762, upload-time = "2026-04-20T23:35:46.421Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +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/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-flask" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flask" }, + { name = "pytest" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fb/23/32b36d2f769805c0f3069ca8d9eeee77b27fcf86d41d40c6061ddce51c7d/pytest-flask-1.3.0.tar.gz", hash = "sha256:58be1c97b21ba3c4d47e0a7691eb41007748506c36bf51004f78df10691fa95e", size = 35816, upload-time = "2023-10-23T14:53:20.696Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/03/7a917fda3d0e96b4e80ab1f83a6628ec4ee4a882523b49417d3891bacc9e/pytest_flask-1.3.0-py3-none-any.whl", hash = "sha256:c0e36e6b0fddc3b91c4362661db83fa694d1feb91fa505475be6732b5bc8c253", size = 13105, upload-time = "2023-10-23T14:53:18.959Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "repertory-api" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "alembic" }, + { name = "flask" }, + { name = "flask-cors" }, + { name = "flask-sqlalchemy" }, + { name = "psycopg2-binary" }, + { name = "python-dotenv" }, + { name = "sqlalchemy" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-flask" }, +] + +[package.metadata] +requires-dist = [ + { name = "alembic", specifier = ">=1.13" }, + { name = "flask", specifier = ">=3.0" }, + { name = "flask-cors", specifier = ">=4.0" }, + { name = "flask-sqlalchemy", specifier = ">=3.1" }, + { name = "psycopg2-binary", specifier = ">=2.9" }, + { name = "python-dotenv", specifier = ">=1.0" }, + { name = "sqlalchemy", specifier = ">=2.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8.0" }, + { name = "pytest-flask", specifier = ">=1.3" }, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.50" +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/57/da/6fbf010c8ebb347679d0d100b22fe9ba5e13fd04046c5df7280d2f0bf706/sqlalchemy-2.0.50.tar.gz", hash = "sha256:af5607d11ef90fd6a5c0549fe0045dce1663d427426bcfb506dcb5346a85a3b9", size = 9907424, upload-time = "2026-05-24T19:20:04.018Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/b0/a9d19b43f38f878b1278bca5b00b909f7540d41494396dd2561f9ad0956d/sqlalchemy-2.0.50-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23ae23d8b9d344d30d0a92f06d45825024a5790f1c1dd4cf452636a50d3e58cb", size = 2159807, upload-time = "2026-05-24T19:27:53.086Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2c/191dd58a248fd2cfd4780fa82c375c505e4ad98c8b522fa69ec492130d77/sqlalchemy-2.0.50-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:47b71b933e7b4ebad407c8fdfd70d2c4f08b78b3238bb30eebdd6eb32ca51b89", size = 3343358, upload-time = "2026-05-24T20:09:29.279Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2b/514fce8a7df81cf5bad7ff7865de7ac0c5776a38cc043475c4703eb7fe8b/sqlalchemy-2.0.50-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:110fdac56ace278949f00de805edacbd6141e382d992f9ba28238b3a0827a600", size = 3357994, upload-time = "2026-05-24T20:17:13.495Z" }, + { url = "https://files.pythonhosted.org/packages/35/a6/a0e283f5494f92b0d77e319ff77e437b1ffe4a051ba67c81d53234825475/sqlalchemy-2.0.50-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0f5e4ac70e9e757f6b3e87c0491ff034442ecd8dfd36d041a50564c322dafc0e", size = 3289399, upload-time = "2026-05-24T20:09:32.239Z" }, + { url = "https://files.pythonhosted.org/packages/b7/96/1b07325ba71752d6a028b77d07bed1483ad545f794e8b1dc89b3ba3b3c68/sqlalchemy-2.0.50-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:724f3dcbe53dd0151e3cb5e7ec4ba4c620bede579caacd16275dc35ce06e8615", size = 3321216, upload-time = "2026-05-24T20:17:15.581Z" }, + { url = "https://files.pythonhosted.org/packages/ed/8e/bad6ed253e8a99edfc99af02f7173ec48a1d3ed1b9b35a1b8bc1700900cc/sqlalchemy-2.0.50-cp312-cp312-win32.whl", hash = "sha256:1208050441471d003b7c8cb4054fb084f185cf35ac3f0ea270803865bca9939a", size = 2119194, upload-time = "2026-05-24T19:50:04.943Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2d/314a6690dda4b9cfc571eab1a63cf6fe6e1470aa3759ccda6aa016ee0f5a/sqlalchemy-2.0.50-cp312-cp312-win_amd64.whl", hash = "sha256:9d1af51558029a156a70986b7df88f042b3d158d7c8d8fb5072912d4b32d89c7", size = 2146186, upload-time = "2026-05-24T19:50:06.74Z" }, + { url = "https://files.pythonhosted.org/packages/0b/c4/c42356b527296e9862f67990efce31ef78b4cf69cd3f80873a528a060320/sqlalchemy-2.0.50-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:06a9210bdc5f4298cff0781087e2ff45683922252dacc452846373a58761f093", size = 2156697, upload-time = "2026-05-24T19:27:54.764Z" }, + { url = "https://files.pythonhosted.org/packages/60/a1/b1a70e3c4365ac7fe9e347f3710f19b562c866fb96d45e3c891588789a7b/sqlalchemy-2.0.50-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b53784972ade4f8174b9aa661f31a06f8a936d2cfdd602913ff3c6dd40ae873", size = 3284260, upload-time = "2026-05-24T20:09:34.195Z" }, + { url = "https://files.pythonhosted.org/packages/3f/4a/f3ac3caa19f263d57b0a47f8c91bbf56583dc2d3fc63acfbf644abb24fe0/sqlalchemy-2.0.50-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:31648fa14460537e768a7303b078e4344d208e0d23e06867c1f376a227ed82db", size = 3302280, upload-time = "2026-05-24T20:17:17.825Z" }, + { url = "https://files.pythonhosted.org/packages/66/55/ccada3e3d62254587819749a0bc69f41173eb48a6e385d10e66d32a9c88e/sqlalchemy-2.0.50-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:03f4323c980ad0e918cc9e5369b015f759f4e534db5bbaf4dc36832c10d05064", size = 3231580, upload-time = "2026-05-24T20:09:36.406Z" }, + { url = "https://files.pythonhosted.org/packages/05/f6/6809349130a2de0e109e7f00fd7d431da9565b9b2868b32ee684754f672b/sqlalchemy-2.0.50-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2b9dcc43afef8ac157cd92fce96985d6b8b0cfbd3df4d666f66b4d55a75d202f", size = 3269375, upload-time = "2026-05-24T20:17:20.34Z" }, + { url = "https://files.pythonhosted.org/packages/48/84/278a811ef4e07be9c89dc5cdd7be833268509a66a68c4897cf585e67428f/sqlalchemy-2.0.50-cp313-cp313-win32.whl", hash = "sha256:60922d6599065ddca2c6f376b9aa2f41a6b85a271725e0909490bbc50b1998a5", size = 2117229, upload-time = "2026-05-24T19:50:08.215Z" }, + { url = "https://files.pythonhosted.org/packages/f6/1c/067cc6187ed32d2ec222fe6d2643acc1659a6d0659f8a7cbc5ad3ae83280/sqlalchemy-2.0.50-cp313-cp313-win_amd64.whl", hash = "sha256:287086e67275a212c4582d166a6fb03a65ccc5551d80866270ce0dd9f34eccd3", size = 2143126, upload-time = "2026-05-24T19:50:09.691Z" }, + { url = "https://files.pythonhosted.org/packages/df/32/10ac51b4be7cdecd7e93d069251c86dfbf70b7adbd7c67b48ccea6c49e1c/sqlalchemy-2.0.50-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c966932507a4d7d0a37314927dbfcd89720e3f37d2a1e3352e7ae7939fa8e8a0", size = 2158519, upload-time = "2026-05-24T19:27:56.472Z" }, + { url = "https://files.pythonhosted.org/packages/5a/76/e703d2f7681d7d66c4c891af3f07c7ccf4c76ad7f18351de035b5eda007a/sqlalchemy-2.0.50-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:faffef4bcc20a1892e65e155293d99d60855bbbc79250ab712819cfd56a8e6bb", size = 3282063, upload-time = "2026-05-24T20:09:38.57Z" }, + { url = "https://files.pythonhosted.org/packages/31/26/ef168b184a25701f9995e8fb7e503fafd7a99c1c77cda1bc1a26ea2ed486/sqlalchemy-2.0.50-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c206aec519a2e7bd08abbfb33436e325fd22c632d9c21a9047e376ce241646e", size = 3287069, upload-time = "2026-05-24T20:17:21.942Z" }, + { url = "https://files.pythonhosted.org/packages/c2/15/765acc2bc693bccc43ca4a95d5b69750da8aaf6db1b5c616536e087f8920/sqlalchemy-2.0.50-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bef4ac756363227ef6402a75fee025a4bc690f92328e825868939b3b3a446a6d", size = 3230453, upload-time = "2026-05-24T20:09:40.398Z" }, + { url = "https://files.pythonhosted.org/packages/63/61/08e03c3adbf5db0087a0b6816746fec8f3032fb2f7fc899a9bb9b2a48ce4/sqlalchemy-2.0.50-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:96fbee6b19c19cd1556c8bf9419447cf2ec149ffcab7ab64348c23e54ef8547f", size = 3252413, upload-time = "2026-05-24T20:17:24.067Z" }, + { url = "https://files.pythonhosted.org/packages/03/0c/370a1f2db38436c615e10134c8a37de3688e74084792380695f3f5083860/sqlalchemy-2.0.50-cp314-cp314-win32.whl", hash = "sha256:8f00e3eb43ba30eb1b238ee03a8a62309486d1321eda3328bb611e0340033ad8", size = 2120063, upload-time = "2026-05-24T19:50:11.08Z" }, + { url = "https://files.pythonhosted.org/packages/7f/a0/fe92bb9817863bc13ba093bda931979a26cc2ca69f8e8f26d07add3d7c6f/sqlalchemy-2.0.50-cp314-cp314-win_amd64.whl", hash = "sha256:15708c613cd5005b7dffe1f66ee6a63ee8f5e46799f71c70ebad74178c676a39", size = 2145830, upload-time = "2026-05-24T19:50:12.452Z" }, + { url = "https://files.pythonhosted.org/packages/cc/ff/e5640a98a0b2f491eb8fde10fb6c773621a2e44340de231fafcc9370f4a9/sqlalchemy-2.0.50-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3699dac4be410e97049a1658e9480da9cde956594aa0f3aebc60b88f21c5ba70", size = 2178435, upload-time = "2026-05-24T19:42:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/b7/85/337116e186f1236375b5fb70c21cfac98e8e8ab0d3a47be838dc47a59e08/sqlalchemy-2.0.50-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f96233858e3df43932ac11589e22520da6e8aeb624b03fedfeebb0e8ea213086", size = 3566059, upload-time = "2026-05-24T20:01:20.848Z" }, + { url = "https://files.pythonhosted.org/packages/96/34/bb0e190e161c3c2c24314a65add57218be14a4a9486886b7f5047c1ff7c8/sqlalchemy-2.0.50-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c4e70c46fad30c3bcc6a4708bc0130a3173e11a5b25f0ea4a9d8911b450f1f52", size = 3535366, upload-time = "2026-05-24T20:03:56.768Z" }, + { url = "https://files.pythonhosted.org/packages/df/5a/a7f759f97e4fd499c5d4e4488c760d5a7fbecf3028b465a04274fcd52384/sqlalchemy-2.0.50-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1918a3cf564d16d95bca7301005f41ab2ad50b07cd3b9da50d3ed986db148d6a", size = 3474879, upload-time = "2026-05-24T20:01:23.058Z" }, + { url = "https://files.pythonhosted.org/packages/9d/d9/2907ea38eb60687d297bf9c39e5ee58053c87b57fe8a9cae97090cecbf10/sqlalchemy-2.0.50-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b00098cdbdbd38c7be3d568b0c9c3122b8c0ec62b911b57cd5e6e0254d60a76d", size = 3486117, upload-time = "2026-05-24T20:03:59.052Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e3/5aa06f167559f8c0bdae487e297d23ba548150ab016a3418265d617a4985/sqlalchemy-2.0.50-cp314-cp314t-win32.whl", hash = "sha256:1fbd55a969d7ac44a98e3dec75016074f809fa08f871585ace58dde110d1bf3e", size = 2150823, upload-time = "2026-05-24T20:08:58.644Z" }, + { url = "https://files.pythonhosted.org/packages/65/9b/112fb8f977582d7489d036e409e3723948bcf5320b3ac465f3c481bbe8f9/sqlalchemy-2.0.50-cp314-cp314t-win_amd64.whl", hash = "sha256:c5c3cdb753a9004183e1ccb634b41611654c989e61bc68617ce878e46d6f1e51", size = 2185794, upload-time = "2026-05-24T20:09:00.319Z" }, + { url = "https://files.pythonhosted.org/packages/d0/10/f7220e9b784d295d241c86ed99aeb537f92afcd469a64861f2717e9bb077/sqlalchemy-2.0.50-py3-none-any.whl", hash = "sha256:92064363517a3ff8212b5a93b8c62876579d8dfd1ca5b561335f30152d884fa9", size = 1943861, upload-time = "2026-05-24T19:59:01.119Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "werkzeug" +version = "3.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" }, +] diff --git a/wsgi.py b/wsgi.py new file mode 100644 index 0000000..d518add --- /dev/null +++ b/wsgi.py @@ -0,0 +1,10 @@ +"""Entry point — loads .env then starts Flask.""" +from dotenv import load_dotenv +load_dotenv() + +from app import create_app + +app = create_app() + +if __name__ == "__main__": + app.run()