Initial Commit

slip slop
This commit is contained in:
Ian Keane 2026-06-09 15:07:08 -04:00
commit 064e672ab7
61 changed files with 3504 additions and 0 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
__pycache__
.env

121
Makefile Normal file
View file

@ -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)

216
README.md Normal file
View file

@ -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/<id>` | Get a single tune with all sub-records |
| POST | `/tunes/` | Create a tune (optionally with nested banjo/fiddle/etc.) |
| PATCH | `/tunes/<id>` | Update tune core fields |
| DELETE | `/tunes/<id>` | 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/<id>/banjo` | Create or fully replace banjo entry |
| PUT | `/tunes/<id>/fiddle` | Create or fully replace fiddle entry|
| PATCH | `/tunes/<id>/banjo` | Partially update banjo entry |
| PATCH | `/tunes/<id>/fiddle` | Partially update fiddle entry |
| DELETE | `/tunes/<id>/banjo` | Remove banjo entry |
| DELETE | `/tunes/<id>/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/<id>/notes` | List notes |
| POST | `/tunes/<id>/notes` | Add a note |
| PATCH | `/tunes/<id>/notes/<note_id>` | Update a note |
| DELETE | `/tunes/<id>/notes/<note_id>` | Delete a note |
### References
| Method | Path | Description |
|--------|----------------------------------------------------|--------------------|
| GET | `/tunes/<id>/references` | List references |
| POST | `/tunes/<id>/references` | Add a reference |
| PATCH | `/tunes/<id>/references/<ref_id>` | Update a reference |
| DELETE | `/tunes/<id>/references/<ref_id>` | Delete a reference |
| POST | `/tunes/<id>/references/<ref_id>/musicians` | Add a musician tag |
| DELETE | `/tunes/<id>/references/<ref_id>/musicians/<name>` | 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`.

132
ain/README.md Normal file
View file

@ -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"`

15
ain/base.ain Normal file
View file

@ -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

8
ain/health.ain Normal file
View file

@ -0,0 +1,8 @@
# Health check
# ain ain/base.ain ain/health.ain
[Host]
/health
[Method]
GET

View file

@ -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

View file

@ -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

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

13
ain/notes/create.ain Normal file
View file

@ -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"
}

8
ain/notes/delete.ain Normal file
View file

@ -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

8
ain/notes/list.ain Normal file
View file

@ -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

13
ain/notes/update.ain Normal file
View file

@ -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"
}

View file

@ -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"
}

18
ain/references/create.ain Normal file
View file

@ -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" }
]
}

View file

@ -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

View file

@ -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

8
ain/references/list.ain Normal file
View file

@ -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

14
ain/references/update.ain Normal file
View file

@ -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"
}

46
ain/tunes/create.ain Normal file
View file

@ -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" }
]
}
]
}

8
ain/tunes/delete.ain Normal file
View file

@ -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

8
ain/tunes/get.ain Normal file
View file

@ -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

View file

@ -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

8
ain/tunes/list.ain Normal file
View file

@ -0,0 +1,8 @@
# List all tunes (no filters)
# ain ain/base.ain ain/tunes/list.ain
[Host]
/tunes/
[Method]
GET

15
ain/tunes/update.ain Normal file
View file

@ -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"
}

149
alembic.ini Normal file
View file

@ -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 <script_location>/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

53
app/__init__.py Normal file
View file

@ -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

17
app/auth.py Normal file
View file

@ -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

3
app/extensions.py Normal file
View file

@ -0,0 +1,3 @@
from flask_sqlalchemy import SQLAlchemy
db = SQLAlchemy()

175
app/models.py Normal file
View file

@ -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],
}

125
app/routes_instruments.py Normal file
View file

@ -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/<int:tune_id>/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/<int:tune_id>/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/<int:tune_id>/instruments/<int:entry_id>")
@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/<int:tune_id>/instruments/<int:entry_id>")
@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/<int:tune_id>/instruments/<int:entry_id>/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/<int:tune_id>/instruments/<int:entry_id>/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/<int:tune_id>/instruments/<int:entry_id>/notes/<int:note_id>")
@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/<int:tune_id>/instruments/<int:entry_id>/notes/<int:note_id>")
@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})

43
app/routes_musicians.py Normal file
View file

@ -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("/<int:musician_id>")
@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("/<int:musician_id>")
@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})

43
app/routes_sources.py Normal file
View file

@ -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("/<int:source_id>")
@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("/<int:source_id>")
@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})

124
app/routes_sub.py Normal file
View file

@ -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/<int:tune_id>/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/<int:tune_id>/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/<int:tune_id>/notes/<int:note_id>")
@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/<int:tune_id>/notes/<int:note_id>")
@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/<int:tune_id>/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/<int:tune_id>/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/<int:tune_id>/references/<int:ref_id>")
@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/<int:tune_id>/references/<int:ref_id>")
@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/<int:tune_id>/references/<int:ref_id>/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/<int:tune_id>/references/<int:ref_id>/musicians/<int:musician_id>")
@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())

155
app/routes_tunes.py Normal file
View file

@ -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=<substring match on name>
"""
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("/<int:tune_id>")
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("/<int:tune_id>")
@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("/<int:tune_id>")
@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})

43
app/routes_tunings.py Normal file
View file

@ -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("/<int:tuning_id>")
@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("/<int:tuning_id>")
@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})

20
docker/Dockerfile Normal file
View file

@ -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"]

29
docker/docker-compose.yml Normal file
View file

@ -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:

33
env.example Normal file
View file

@ -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

1
migrations/README Normal file
View file

@ -0,0 +1 @@
Generic single-database configuration.

58
migrations/env.py Normal file
View file

@ -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()

28
migrations/script.py.mako Normal file
View file

@ -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"}

View file

@ -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))

View file

@ -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")

View file

@ -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),
)

View file

@ -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")

View file

@ -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),
)

View file

@ -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")

View file

@ -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")

200
nixos/module.nix Normal file
View file

@ -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 <tarball url>`
# };
#
# ── 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/<owner>/repertory-api/archive/<rev>.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
'';
};
};
}

19
pyproject.toml Normal file
View file

@ -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",
]

53
tests/conftest.py Normal file
View file

@ -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"}

130
tests/test_instruments.py Normal file
View file

@ -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"

70
tests/test_sources.py Normal file
View file

@ -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

133
tests/test_sub.py Normal file
View file

@ -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"] == []

188
tests/test_tunes.py Normal file
View file

@ -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

456
uv.lock generated Normal file
View file

@ -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" },
]

10
wsgi.py Normal file
View file

@ -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()