diff --git a/.env.example b/.env.example index 85732df..e6f02dc 100644 --- a/.env.example +++ b/.env.example @@ -3,8 +3,14 @@ APP_NAME=Foresight DEBUG=false # Database -# Format: postgresql+asyncpg://:@:/ +# App traffic. Format: postgresql+asyncpg://:@:/ +# On Neon, `neon deploy` / `neon env pull` writes the pooled (-pooler) URL here in +# libpq form (postgresql://...?sslmode=require&channel_binding=require); config.py +# normalizes it for asyncpg, so paste it as-is. DATABASE_URL=postgresql+asyncpg://user:password@localhost:5432/dbname +# Direct (non-pooled) URL used only by Alembic migrations. Leave empty to reuse +# DATABASE_URL (fine for local Docker Postgres; required on Neon). +DATABASE_URL_UNPOOLED= # External event sources PREDICTHQ_TOKEN= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 51ac4a0..1bf2625 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -150,10 +150,19 @@ jobs: run: uv python install - name: Install dependencies - run: uv sync --dev --group ml + run: uv sync --dev + + - name: Single migration head + run: test "$(uv run alembic heads | wc -l)" -eq 1 - name: Run migrations run: uv run alembic upgrade head + - name: Models match migrations + run: uv run alembic check + + - name: Migrations roll back + run: uv run alembic downgrade base && uv run alembic upgrade head + - name: Pytest run: uv run pytest -q diff --git a/.gitignore b/.gitignore index 74b6a35..bd6445c 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,5 @@ backend/data/ # requests-cache store written by the Open-Meteo client .cache.sqlite +.neon +node_modules/ diff --git a/AGENTS.md b/AGENTS.md index 043c59d..a55d281 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,9 @@ # AGENTS.md Entry point for AI coding agents working in **Foresight**. Read this first, then -read the per-layer context doc in [`agents/`](agents/) for whatever you're touching. +[`docs/system-design.md`](docs/system-design.md) for how the backend fits +together, and the [decision records](docs/decisions/README.md) for the code +you're touching. The goal: keep everyone's software practices aligned when they use AI. ## What Foresight is @@ -21,8 +23,10 @@ GitHub Actions · ruff · Conventional Commits. routes → services → repository → database ``` -- **routes** — HTTP layer (FastAPI routers, `*Request`/`*Response` Pydantic schemas). -- **services** — business logic, orchestration, ML/parsers. +- **routes** — HTTP layer: FastAPI routers and the `*Request`/`*Response` + schemas each one uses. +- **services** — business logic: `domain/`, `ingest/`, `adapters/`, `ml/`, + `job.py`. - **repository** — data access; the only layer that talks to the ORM/session. - **database** — SQLAlchemy models, migrations, session/engine. @@ -31,19 +35,13 @@ ORM; services never build HTTP responses. Code lives in the installable package `backend/foresight/`. There's a working reference slice to copy — the `Widget` example threads all -four layers (`example_widget.py` in each). Each doc below points to its file. - -Per-layer detail: -- [`agents/routes.md`](agents/routes.md) -- [`agents/services.md`](agents/services.md) -- [`agents/repository.md`](agents/repository.md) -- [`agents/database.md`](agents/database.md) -- [`agents/schemas.md`](agents/schemas.md) — Pydantic standard (all layers) +four layers (`example_widget.py` in each). ## Commands (run from repo root) ```bash -just dev # db + backend with hot reload +just dev # db + backend with hot reload (local Docker Postgres) +just dev --neon # same, against Neon; `--neon` works on run/migrate*/test too just migrate # apply migrations just migrate-create "msg" # new Alembic migration after model changes just migrate-down # roll back last migration @@ -61,14 +59,18 @@ API: http://localhost:8000 · docs: http://localhost:8000/docs - **Migrations:** any model change needs an Alembic migration that applies *and* rolls back cleanly. - **Config:** add new settings to `config.py` and document them in `.env.example`. -- **Keep docs current:** if you change a layer's patterns, update that layer's - file in `agents/`. +- **Keep docs current:** if you change how the system fits together, update + [`docs/system-design.md`](docs/system-design.md). +- **Decisions:** design decisions live in [`docs/decisions/`](docs/decisions/), + one per file. Read the relevant record before changing that code; record new + decisions there. ## Do / don't for agents -- **Do** follow the existing pattern in the layer you're editing — read its - `agents/` doc before writing code. +- **Do** follow the existing pattern in the layer you're editing — see + `docs/system-design.md` and the `Widget` slice before writing code. - **Do** keep business logic in services and data access in the repository. - **Don't** skip layers, add dependencies without updating `pyproject.toml`, or change the schema without a migration. -- **Don't** invent new conventions; this file and `agents/` are the source of truth. +- **Don't** invent new conventions; this file, `docs/system-design.md` and + `docs/decisions/` are the source of truth. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md deleted file mode 100644 index 3b11f0a..0000000 --- a/ARCHITECTURE.md +++ /dev/null @@ -1,204 +0,0 @@ -# Architecture Overview - -Quick note before anything else: - -We're throwing a *lot* of information at you at once — new concepts, new tech, new patterns. -It is **not expected** that this all makes sense right away. If even ~10% clicks at first, that's totally fine. - -The goal of this doc is just to give you a **high-level mental model** of how things are structured. -You'll build real understanding by working on tickets and asking questions ‼️‼️‼️ (please do). - -> For AI coding agents: start with [`AGENTS.md`](AGENTS.md), then read the per-layer -> context docs in [`agents/`](agents/). - ---- - -## What Foresight Is - -Foresight is an **AI revenue-intelligence platform for boutique hotels**. The backend is a -pricing engine that ingests historical booking data plus external demand signals (events, -weather, flights, competitor pricing) and produces explainable room-rate recommendations. - -**Stack:** Python · FastAPI · async SQLAlchemy 2.0 · Alembic · Postgres · uv · Docker · -GitHub Actions · ruff · Conventional Commits. - -The project is **backend-only today** — `frontend/` is a placeholder (TBD). - ---- - -# Backend Architecture - -## High-Level Idea - -Foresight's backend follows a **layered (N-tier) architecture**. - -Most requests flow like this: - -``` -Client → Route → Service → Repository → Database -``` - -You don't need to memorize this — you'll see the pattern everywhere. - ---- - -## What Each Layer Does - -### Routes (API / HTTP layer) -- Define API endpoints (`GET /health`, `POST /example/widgets`, etc.) -- Handle HTTP stuff (requests, responses, status codes) -- Validate input and shape output via Pydantic **schemas** -- Call the service layer - -They should **not**: -- Contain business logic -- Talk directly to the ORM, session, or repository - -Each router is an `APIRouter` (one module per resource) and must be registered in -`foresight/main.py` via `app.include_router(...)`. - ---- - -### Services (business logic) -- Where most of the "thinking" happens -- Apply rules, validation, and transformations -- Orchestrate use cases, run the pricing/ML logic and data parsing -- Call repositories when data is needed - -If you're unsure where code should live, it usually belongs here. - -Two focused sub-areas live under `services/`: -- `services/ml/` — modeling / recommendation logic (regression, bayesian, timeseries, neural net). -- `services/parsers/` — ingesting external + hotel data. - ---- - -### Repository (data access) -- The **only** layer that talks to the ORM and the async SQLAlchemy session -- Turns domain requests from services into queries -- Returns ORM models / simple data back up - -Functions are `async` and receive an `AsyncSession` (they don't open their own). -Think of this as a clean interface between Python code and the DB. - ---- - -### Database -- SQLAlchemy models, the async engine/session, and Alembic migrations -- Defines *what* the data looks like (the repository decides *how* it's queried) -- Async SQLAlchemy 2.0 style (typed, `Mapped[...]`), all models on a shared declarative base -- Every schema change ships with an Alembic migration - ---- - -## Folder Mapping - -How this shows up in `backend/`: - -``` -backend/ -├── foresight/ # Installable package (foresight.main:app) -│ ├── main.py # FastAPI app entrypoint; routers registered here -│ ├── config.py # Settings (pydantic-settings) -│ ├── routes/ # API endpoints (one module per resource) -│ ├── schemas/ # Pydantic *Request/*Response schemas (agents/schemas.md) -│ ├── services/ # Business logic -│ │ ├── ml/ # ML models (regression, bayesian, timeseries, ...) -│ │ └── parsers/ # Client/external data ingestion -│ ├── repository/ # Data-access layer (ORM/session only here) -│ └── database/ # SQLAlchemy + Alembic -│ ├── base.py # Declarative base -│ ├── session.py # Async engine + session factory (get_session) -│ ├── models/ # SQLAlchemy models (hotel.py, external.py, ...) -│ └── migrations/ # Alembic env + versions/ -├── scripts/ # Dev tooling (mock data, model comparison) -├── tests/ # Pytest suite -├── alembic.ini -├── pyproject.toml -└── Dockerfile -``` - -There's a working **reference slice** to copy — the `Widget` example threads all four -layers (`example_widget.py` in `routes/`, `services/`, `repository/`, `schemas/`, and -`database/models/`). Start there. - ---- - -## Dependency Rule (Important) - -Dependencies should only flow **downward**, and a layer may only call the layer directly -below it: - -``` -route → service → repository → database -``` - -Avoid: -- Routes importing the ORM, session, or repository directly -- Services touching the ORM/session or building HTTP responses -- Repositories containing business logic or HTTP concerns - -If you're unsure whether something breaks this rule, just ask. - ---- - -## Example Request Flow - -Rough idea of what happens when a request comes in: - -1. Route receives the request and validates the body against a schema -2. Route calls a service function (passing the injected `AsyncSession`) -3. Service applies logic and calls a repository -4. Repository runs the query against the database -5. Result flows back up; the route maps it to a response schema and returns it - -You'll see this pattern everywhere. - ---- - -# Commands - -Run from the repo root (uses [`just`](https://github.com/casey/just)): - -```bash -just dev # db + backend with hot reload (docker compose up -d) -just build # build/rebuild the backend image -just logs # tail backend logs -just destroy # stop everything - -just migrate # apply migrations -just migrate-create "msg" # new Alembic migration after model changes -just migrate-down # roll back last migration - -just test # run the pytest suite -just lint # ruff checks -just format # ruff format -just fix # ruff --fix + format -just hooks # install pre-commit hooks -``` - -API: http://localhost:8000 · docs: http://localhost:8000/docs - ---- - -## Conventions - -- **Commits:** [Conventional Commits](https://www.conventionalcommits.org/) - (`feat:`, `fix:`, `docs:`, `refactor:`, `chore:`…). -- **Lint/format:** ruff must pass before commit; pre-commit hooks are enforced. -- **Migrations:** any model change needs an Alembic migration that applies *and* rolls back cleanly. -- **Config:** add new settings to `config.py` and document them in `.env.example`. -- **Keep docs current:** if you change a layer's patterns, update that layer's doc in `agents/`. - ---- - -## Final Notes - -If this feels overwhelming, that's normal. You're not expected to understand all of this immediately. - -Understanding comes from: -- Reading the `Widget` example slice -- Working on small pieces -- Asking questions ‼️‼️‼️‼️‼️‼️‼️‼️‼️‼️‼️‼️ - -This doc is just here to give you a map — not to test you. diff --git a/README.md b/README.md index cfcaac6..2e8934c 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,24 @@ just logs just destroy ``` +### Local vs Neon database + +Recipes that touch the database take an optional `--neon` flag. Without it +they use the local Docker Postgres; with it, the Neon `production` branch +(URLs from `.env`). + +```bash +just dev # local Docker Postgres +just dev --neon # same stack, pointed at Neon (local db container not started) +just migrate --neon # migrations against Neon (direct, non-pooled URL) +just run --neon # API on the host, outside Docker +``` + +`--neon` works on `dev`, `destroy`, `run`, `migrate`, `migrate-create`, +`migrate-down` and `test`. Re-run `just dev` / `just dev --neon` to switch; +Compose recreates the backend. Neon URLs come from `neon env pull`. See +`docs/decisions/0027-neon-postgres.md`. + The API will be available at [http://localhost:8000](http://localhost:8000) API docs at [http://localhost:8000/docs](http://localhost:8000/docs) @@ -75,29 +93,25 @@ Commit messages must follow [Conventional Commits](https://www.conventionalcommi ``` foresight/ ├── backend/ -│ ├── foresight/ # Installable package (see pyproject.toml) -│ │ ├── main.py # FastAPI app entrypoint (foresight.main:app) -│ │ ├── config.py # Settings (pydantic-settings) -│ │ ├── routes/ # API route handlers -│ │ ├── schemas/ # Pydantic request/response models -│ │ ├── services/ # Business logic -│ │ │ ├── ml/ # ML models (regression, bayesian, ...) -│ │ │ └── parsers/ # Client data ingestion -│ │ ├── ingest/ # External-source adapters + CLIs (see docs/) -│ │ ├── repository/ # Data-access layer -│ │ └── database/ # SQLAlchemy + Alembic -│ │ ├── base.py # Declarative base -│ │ ├── session.py # Async session factory -│ │ ├── models/ # SQLAlchemy models -│ │ └── migrations/ # Alembic migrations -│ ├── scripts/ # Dev tooling (mock data, model comparison) -│ ├── tests/ # Pytest suite -│ ├── alembic.ini +│ ├── foresight/ # Installable package (see pyproject.toml) +│ │ ├── main.py # FastAPI app (foresight.main:app) +│ │ ├── config.py # Settings (pydantic-settings) +│ │ ├── worker.py # Runs background jobs +│ │ ├── routes/ # HTTP: routers and their schemas +│ │ ├── services/ # Business logic: domain, ingest, adapters, ml +│ │ ├── repository/ # Data access +│ │ └── database/ # Models, enums, session, Alembic migrations +│ ├── tests/ │ ├── pyproject.toml │ └── Dockerfile -├── frontend/ # TBD +├── docs/ +│ ├── system-design.md # How the backend fits together +│ └── decisions/ # One design decision per file +├── frontend/ # TBD ├── docker-compose.yml ├── justfile -├── .env.example -└── .pre-commit-config.yaml +└── .env.example ``` + +See [`docs/system-design.md`](docs/system-design.md) for the layers, data +model, jobs and API. diff --git a/agents/database.md b/agents/database.md deleted file mode 100644 index 788da37..0000000 --- a/agents/database.md +++ /dev/null @@ -1,33 +0,0 @@ -# Database — Agent Context - -**Updated:** 2026-10-06 - -## Purpose -The persistence foundation: SQLAlchemy models, the async engine/session, and -Alembic migrations. Defines *what* the data looks like; the repository layer -decides *how* it's queried. - -## Key files -- `backend/foresight/database/models/` — ORM models (`hotel.py`, `external.py`). -- `backend/foresight/database/base.py` — declarative base. -- `backend/foresight/database/session.py` — async engine + session factory. -- `backend/foresight/database/migrations/` — Alembic env + `versions/`. - -## Patterns & conventions -- Async SQLAlchemy 2.0 style (typed, `Mapped[...]`), all models on the shared base. -- Every schema change ships with an Alembic migration generated via - `just migrate-create "msg"`. -- Engine/session config is driven by `config.py` settings. - -## Rules / invariants -- No business logic or queries here — models and wiring only (queries live in - `repository/`). -- Never edit the DB schema without a migration that applies and rolls back. -- New models must be imported where Alembic autogenerate can see them. - -## Reference example -Copy the pattern in `backend/foresight/database/models/example_widget.py` plus -its migration in `database/migrations/versions/*_example_widget.py`. - -## How to verify -`just migrate` (apply) then `just migrate-down` (roll back) run cleanly; `just lint`. diff --git a/agents/repository.md b/agents/repository.md deleted file mode 100644 index 2fddc12..0000000 --- a/agents/repository.md +++ /dev/null @@ -1,34 +0,0 @@ -# Repository — Agent Context - -**Updated:** 2026-10-06 - -## Purpose -The data-access layer. The **only** layer that talks to the ORM and the async -SQLAlchemy session. It turns domain requests from services into queries and -returns models/data back up. No business rules, no HTTP. - -## Key files -- `backend/foresight/repository/` — query/persistence functions per aggregate. -- `backend/foresight/database/session.py` — `get_session()` / session factory. -- `backend/foresight/database/models/` — the ORM models being queried. - -## Patterns & conventions -- Functions are `async` and receive an `AsyncSession` (don't open their own). -- **Never commit.** The caller owns the transaction: `get_session()` commits - once per request; CLIs and jobs commit once at the end. Use `flush()` when you - need generated ids. -- Encapsulate all query/CRUD logic here so services stay persistence-agnostic. -- Take plain values (never schemas); return ORM models or simple data. Routes map - results to `*Response` schemas. - -## Rules / invariants -- Only this layer imports `session` / ORM query APIs. -- No business logic and no HTTP concerns. -- No `commit()` or `rollback()` here — that's the caller's job. - -## Reference example -Copy the pattern in `backend/foresight/repository/example_widget.py` (the -canonical repository pattern). - -## How to verify -Exercise via a service + test, or through an endpoint with `just dev`. `just lint`. diff --git a/agents/routes.md b/agents/routes.md deleted file mode 100644 index 590504e..0000000 --- a/agents/routes.md +++ /dev/null @@ -1,37 +0,0 @@ -# Routes — Agent Context - -**Updated:** 2026-10-06 - -## Purpose -The HTTP layer. Defines FastAPI routers, validates input and shapes output via -Pydantic schemas, and delegates all real work to **services**. No business logic -and no database access here. - -## Key files -- `backend/foresight/routes/` — one module per resource (e.g. `health.py`). -- `backend/foresight/main.py` — app setup; routers registered with - `app.include_router(...)`. -- `backend/foresight/schemas/` — Pydantic `*Request` / `*Response` models used - here. See [`schemas.md`](schemas.md) for the standard. - -## Patterns & conventions -- One `APIRouter` per module, with `tags=[...]`; include it in `main.py`. -- Handlers are `async def`. Take bodies as `*Request` schemas; declare - `response_model=` with a matching `*Response` return type. -- Inject the session as `Depends(get_session, scope="function")` (see - `SessionDep` in the widget example). It commits once per request after the - handler returns; handlers and services never commit. -- Call into services; build the response with `XxxResponse.model_validate(result)`. - Keep handlers thin. - -## Rules / invariants -- Never import the ORM, session, or repository directly — go through a service. -- No SQL, no business rules in this layer. -- Every new router must be registered in `main.py`. - -## Reference example -Copy the pattern in `backend/foresight/routes/example_widget.py` (the `Widget` -vertical slice: `database -> repository -> service -> route`). - -## How to verify -`just dev`, then hit the endpoint via http://localhost:8000/docs. Run `just lint`. diff --git a/agents/schemas.md b/agents/schemas.md deleted file mode 100644 index 933b2c0..0000000 --- a/agents/schemas.md +++ /dev/null @@ -1,68 +0,0 @@ -# Schemas — Agent Context - -**Updated:** 2026-10-12 - -## Purpose -Pydantic models that validate and convert data at the edges of the app: HTTP -requests and responses, and payloads from external APIs. They are not the -database models (those are SQLAlchemy, in `database/models/`) and they are not -for passing data between our own functions. - -## Key files -- `backend/foresight/schemas/base.py` — `RequestSchema`, `ResponseSchema`, - `ExternalPayload` base classes. -- `backend/foresight/schemas/.py` — HTTP schemas, one module per resource. -- `backend/foresight/schemas/example_widget.py` — reference example. - -## When to use Pydantic -| Data | Use | -|---|---| -| HTTP request body | `Request(RequestSchema)` | -| HTTP response body | `Response(ResponseSchema)` | -| Payload from an external API | `Payload(ExternalPayload)` | -| Config | `pydantic-settings` (`config.py`) | -| Data passed between our own functions | ORM object or `@dataclass` — **not** Pydantic | - -Create one schema per distinct shape that crosses a boundary — not one per table. -A table never sent over HTTP needs no schema. - -## Patterns & conventions -- **Naming by direction:** `Request` for what a client sends, - `Response` for what an endpoint returns. Only create a `Request` if the - endpoint takes a body. -- **Base classes:** - - `RequestSchema` — `extra="forbid"`: unknown fields are rejected with a 422. - - `ResponseSchema` — `from_attributes=True`: build it from an ORM object with - `XxxResponse.model_validate(obj)`. - - `ExternalPayload` — `extra="ignore"`: external APIs add fields; we only - model what we use. -- **No inheritance between Request and Response.** Each lists its fields - explicitly. Duplication is fine; it keeps input fields from silently leaking - into output. -- **Nested items** follow the same naming (e.g. `ContributionResponse` inside - `RecommendationResponse`). -- **Fields:** `Decimal` for money, `date`/`datetime` for time. Use - `Field(description=..., ge=..., le=...)` for constraints and docs — they show - up in `/docs`. Use `@computed_field` for values derived from other fields. - -## How each layer uses them -- **Routes** take `Request` schemas as parameters, declare `response_model=`, - and build the `Response` with `model_validate(...)`. -- **Services** may take a `Request` schema as input. They return ORM objects or - dataclasses — never `Response` schemas (services don't build HTTP responses). -- **Repository** never imports schemas; it takes plain values. -- **External API clients** (services) parse raw responses into `ExternalPayload` - models, then map them to domain objects/ORM models. Payload models live in the - module that calls the API, not in `schemas/`. - -## Rules / invariants -- Every route declares `response_model=` and a matching return type. -- Never return an ORM object's sensitive/internal fields — only what the - `Response` declares is sent. -- No business logic in schemas beyond simple `@computed_field` derivations. - -## Reference example -Copy the pattern in `backend/foresight/schemas/example_widget.py`. - -## How to verify -`just dev`, check the schemas at http://localhost:8000/docs. Run `just lint`. diff --git a/agents/services.md b/agents/services.md deleted file mode 100644 index 1a96be1..0000000 --- a/agents/services.md +++ /dev/null @@ -1,42 +0,0 @@ -# Services — Agent Context - -**Updated:** 2026-10-06 - -## Purpose -The business-logic layer. Orchestrates use cases, runs the pricing/ML logic and -data parsing, and composes repository calls. Services are the only place where -domain rules live — they're called by routes and call the repository. - -## Key files -- `backend/foresight/services/` — business logic modules. -- `backend/foresight/services/ml/` — modeling / recommendation logic. -- `backend/foresight/services/parsers/` — ingesting external + hotel data. -- `backend/foresight/ingest/` — external-source adapters (events, weather, - flights). Part of the services layer: they fetch and normalize, then write - through `repository/`. -- `backend/foresight/services/backfill/` — fetch an external source over a date - range and save it. Long runs yield after each unit of work (a month of events, - a day of flights) so the CLIs in `backend/scripts/backfill/` can commit as - they go. - -## Patterns & conventions -- Pure-ish domain functions/classes; take plain inputs or `*Request` schemas, - return domain data (ORM objects or dataclasses) — never `*Response` schemas. - See [`schemas.md`](schemas.md). -- Parse external API responses into `ExternalPayload` models in the module that - calls the API. -- Get data through the repository layer; don't query the ORM here. -- Keep ML, parsing, and orchestration in focused modules; prefer small units. - -## Rules / invariants -- No FastAPI/HTTP concerns (no `Request`, no status codes, no routers). -- No direct ORM/session use — all persistence goes through `repository/`. -- External API access and heavy logic belong here, not in routes. -- Never open a session or commit. CLIs live in `backend/scripts/`; they open the - session, call the service, and commit once. - -## Reference example -Copy the pattern in `backend/foresight/services/example_widget.py`. - -## How to verify -Add/adjust unit tests under `backend/tests/` and run the suite; `just lint`. diff --git a/backend/Dockerfile b/backend/Dockerfile index 77def63..176b14d 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -42,8 +42,9 @@ CMD ["uv", "run", "uvicorn", "foresight.main:app", \ # ---- prod ------------------------------------------------------------------ # No dev deps, no --reload, and uvicorn is invoked directly from the venv so # no dependency resolution runs at container start (immutable, fast boot). -# Scale workers via WEB_CONCURRENCY env or by overriding CMD in your deploy. +# One worker: background jobs run inside the API process and a job's uploaded +# files live in its memory, so a second worker would split them. FROM base AS prod CMD [".venv/bin/uvicorn", "foresight.main:app", \ - "--host", "0.0.0.0", "--port", "8000"] + "--host", "0.0.0.0", "--port", "8000", "--workers", "1"] diff --git a/backend/foresight/config.py b/backend/foresight/config.py index c0fe4b5..1d87afb 100644 --- a/backend/foresight/config.py +++ b/backend/foresight/config.py @@ -1,6 +1,27 @@ +from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit + from pydantic_settings import BaseSettings, SettingsConfigDict +def to_asyncpg_url(url: str) -> str: + """Normalize a libpq-style Postgres URL (e.g. from `neon env pull`) for asyncpg. + + asyncpg rejects libpq-only query params: `sslmode` becomes asyncpg's `ssl`, + and `channel_binding` is dropped (asyncpg negotiates SCRAM itself). + """ + if not url: + return url + parts = urlsplit(url) + is_pg = parts.scheme.startswith("postgres") + scheme = "postgresql+asyncpg" if is_pg else parts.scheme + query = [] + for key, value in parse_qsl(parts.query): + if key == "channel_binding": + continue + query.append(("ssl" if key == "sslmode" else key, value)) + return urlunsplit(parts._replace(scheme=scheme, query=urlencode(query))) + + class Settings(BaseSettings): model_config = SettingsConfigDict( # Load root .env first (the one docker-compose reads), then backend/.env. @@ -15,9 +36,22 @@ class Settings(BaseSettings): debug: bool = False # Database + # App traffic. On Neon this is the pooled (-pooler) connection string. database_url: str = ( "postgresql+asyncpg://foresight:foresight@localhost:5432/foresight_db" ) + # Direct (non-pooled) connection for Alembic migrations. On Neon, PgBouncer + # runs in transaction mode, so migrations must bypass it. Falls back to + # database_url when unset (e.g. local Docker Postgres). + database_url_unpooled: str = "" + + @property + def async_database_url(self) -> str: + return to_asyncpg_url(self.database_url) + + @property + def async_migration_database_url(self) -> str: + return to_asyncpg_url(self.database_url_unpooled or self.database_url) # External event sources predicthq_token: str = "" diff --git a/backend/foresight/database/base.py b/backend/foresight/database/base.py index 0791e63..f232f46 100644 --- a/backend/foresight/database/base.py +++ b/backend/foresight/database/base.py @@ -1,5 +1,21 @@ +"""Declarative base shared by every model.""" + +from sqlalchemy import MetaData from sqlalchemy.orm import DeclarativeBase +# Every constraint and index gets a deterministic name derived from its table +# and columns, so migrations can drop or rename it and repositories can target +# it in ON CONFLICT without relying on names Postgres generated. +NAMING_CONVENTION = { + "pk": "pk_%(table_name)s", + "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s", + "uq": "uq_%(table_name)s_%(column_0_N_name)s", + "ix": "ix_%(table_name)s_%(column_0_N_name)s", + "ck": "ck_%(table_name)s_%(constraint_name)s", +} + class Base(DeclarativeBase): """Base class for all SQLAlchemy models.""" + + metadata = MetaData(naming_convention=NAMING_CONVENTION) diff --git a/backend/foresight/database/enums.py b/backend/foresight/database/enums.py new file mode 100644 index 0000000..e024ccc --- /dev/null +++ b/backend/foresight/database/enums.py @@ -0,0 +1,77 @@ +"""Enumerations stored in database columns. + +They live in the database layer because the models depend on them; every +other layer imports them from here. Columns store the member *name* +(`ACTIVE`) as a VARCHAR, so adding a member needs no migration. +""" + +from enum import StrEnum + + +class Source(StrEnum): + PREDICTHQ = "predicthq" + ASKNEWS = "asknews" + + +class EventStatus(StrEnum): + ACTIVE = "active" + PROVISIONAL = "provisional" # extracted from text, not yet corroborated + CANCELLED = "cancelled" + DELETED = "deleted" # withdrawn upstream + + +class DatePrecision(StrEnum): + """How precisely the source pinned the date. + + Anything coarser than DAY is unusable for per-night pricing but still worth + keeping as early signal, so it is recorded rather than rounded away. + """ + + DAY = "day" + MONTH = "month" + QUARTER = "quarter" + + +class EventCategory(StrEnum): + """PredictHQ's taxonomy, adopted project-wide. + + Text sources classify into these buckets instead of inventing a second + vocabulary. + """ + + ACADEMIC = "academic" + AIRPORT_DELAYS = "airport-delays" + COMMUNITY = "community" + CONCERTS = "concerts" + CONFERENCES = "conferences" + DAYLIGHT_SAVINGS = "daylight-savings" + DISASTERS = "disasters" + EXPOS = "expos" + FESTIVALS = "festivals" + HEALTH_WARNINGS = "health-warnings" + OBSERVANCES = "observances" + PERFORMING_ARTS = "performing-arts" + POLITICS = "politics" + PUBLIC_HOLIDAYS = "public-holidays" + SCHOOL_HOLIDAYS = "school-holidays" + SEVERE_WEATHER = "severe-weather" + SPORTS = "sports" + TERROR = "terror" + UNKNOWN = "unknown" + + +class JobKind(StrEnum): + """What a background job does. Each has one handler in `services/job.py`.""" + + WEATHER = "weather" # Open-Meteo forecast and recorded weather + EVENTS = "events" # PredictHQ events + PERFORMANCE = "performance" # the hotel's own nightly CSV exports + TRAIN = "train" # a new pricing model version + + +class JobStatus(StrEnum): + QUEUED = "queued" + RUNNING = "running" + SUCCEEDED = "succeeded" + STOPPED = "stopped" # a source's quota or rate limit; resume from `resume_from` + FAILED = "failed" # any other error; `resume_from` says where to pick up diff --git a/backend/foresight/database/migrations/env.py b/backend/foresight/database/migrations/env.py index f9cffaa..6b0b917 100644 --- a/backend/foresight/database/migrations/env.py +++ b/backend/foresight/database/migrations/env.py @@ -13,10 +13,14 @@ if config.config_file_name is not None: fileConfig(config.config_file_name) -# Override sqlalchemy.url with the app's database URL. Online mode uses an -# async engine, so the +asyncpg driver is kept as-is here; offline mode strips -# it below since it renders SQL without a live async connection. -config.set_main_option("sqlalchemy.url", settings.database_url) +# Override sqlalchemy.url with the direct (non-pooled) database URL: Neon's +# pooler runs PgBouncer in transaction mode, which migrations must bypass. +# Online mode uses an async engine, so the +asyncpg driver is kept as-is here; +# offline mode strips it below since it renders SQL without a live connection. +# `%` is escaped because Alembic's config is a ConfigParser. +config.set_main_option( + "sqlalchemy.url", settings.async_migration_database_url.replace("%", "%%") +) target_metadata = Base.metadata diff --git a/backend/foresight/database/migrations/versions/4c1e7a9d2b10_pricing_model_description.py b/backend/foresight/database/migrations/versions/4c1e7a9d2b10_pricing_model_description.py new file mode 100644 index 0000000..74789f0 --- /dev/null +++ b/backend/foresight/database/migrations/versions/4c1e7a9d2b10_pricing_model_description.py @@ -0,0 +1,47 @@ +"""pricing model description and training summary + +Training now runs as a job (the new TRAIN job kind), and every model version +records what it tried (`description`, written by whoever trained it) and what +it was trained on (`training_summary`: nights used and excluded, room types, +which signals had data). Existing rows get no description and an empty +summary. + +`jobs.kind` is a VARCHAR without a CHECK, so TRAIN needs no schema change. +A downgrade deletes TRAIN jobs, because the older code can't load them; jobs +are run history, not data. + +Revision ID: 4c1e7a9d2b10 +Revises: 80bab95951e7 +Create Date: 2026-10-15 17:00:00.000000 + +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = '4c1e7a9d2b10' +down_revision: Union[str, None] = '80bab95951e7' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column('pricing_models', sa.Column('description', sa.Text(), nullable=True)) + op.add_column( + 'pricing_models', + sa.Column( + 'training_summary', + postgresql.JSONB(astext_type=sa.Text()), + server_default=sa.text("'{}'::jsonb"), + nullable=False, + ), + ) + + +def downgrade() -> None: + op.execute("DELETE FROM jobs WHERE kind = 'TRAIN'") + op.drop_column('pricing_models', 'training_summary') + op.drop_column('pricing_models', 'description') diff --git a/backend/foresight/database/migrations/versions/72e850f4fa7b_hotel_and_external_schema.py b/backend/foresight/database/migrations/versions/72e850f4fa7b_hotel_and_external_schema.py index 9449a41..6d3b160 100644 --- a/backend/foresight/database/migrations/versions/72e850f4fa7b_hotel_and_external_schema.py +++ b/backend/foresight/database/migrations/versions/72e850f4fa7b_hotel_and_external_schema.py @@ -29,7 +29,7 @@ def upgrade() -> None: sa.Column('is_sold_out', sa.Boolean(), nullable=False), sa.Column('source', sa.String(length=40), nullable=False), sa.Column('captured_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), - sa.PrimaryKeyConstraint('id') + sa.PrimaryKeyConstraint('id', name='competitor_rates_pkey') ) op.create_table('events', sa.Column('id', sa.Integer(), nullable=False), @@ -44,7 +44,7 @@ def upgrade() -> None: sa.Column('longitude', sa.Float(), nullable=True), sa.Column('source', sa.String(length=40), nullable=False), sa.Column('captured_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), - sa.PrimaryKeyConstraint('id') + sa.PrimaryKeyConstraint('id', name='events_pkey') ) op.create_table('flight_arrivals', sa.Column('id', sa.Integer(), nullable=False), @@ -54,7 +54,7 @@ def upgrade() -> None: sa.Column('avg_fare_eur', sa.Numeric(precision=10, scale=2), nullable=True), sa.Column('source', sa.String(length=40), nullable=False), sa.Column('captured_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), - sa.PrimaryKeyConstraint('id') + sa.PrimaryKeyConstraint('id', name='flight_arrivals_pkey') ) op.create_table('hotels', sa.Column('id', sa.Integer(), nullable=False), @@ -63,7 +63,7 @@ def upgrade() -> None: sa.Column('total_rooms', sa.Integer(), nullable=False), sa.Column('latitude', sa.Float(), nullable=True), sa.Column('longitude', sa.Float(), nullable=True), - sa.PrimaryKeyConstraint('id') + sa.PrimaryKeyConstraint('id', name='hotels_pkey') ) op.create_table('weather', sa.Column('id', sa.Integer(), nullable=False), @@ -77,7 +77,7 @@ def upgrade() -> None: sa.Column('is_forecast', sa.Boolean(), nullable=False), sa.Column('source', sa.String(length=40), nullable=False), sa.Column('captured_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), - sa.PrimaryKeyConstraint('id') + sa.PrimaryKeyConstraint('id', name='weather_pkey') ) op.create_table('room_types', sa.Column('id', sa.Integer(), nullable=False), @@ -85,8 +85,8 @@ def upgrade() -> None: sa.Column('name', sa.String(length=120), nullable=False), sa.Column('room_count', sa.Integer(), nullable=False), sa.Column('base_rate', sa.Numeric(precision=10, scale=2), nullable=False), - sa.ForeignKeyConstraint(['hotel_id'], ['hotels.id'], ), - sa.PrimaryKeyConstraint('id') + sa.ForeignKeyConstraint(['hotel_id'], ['hotels.id'], name='room_types_hotel_id_fkey'), + sa.PrimaryKeyConstraint('id', name='room_types_pkey') ) op.create_table('daily_performance', sa.Column('id', sa.Integer(), nullable=False), @@ -96,9 +96,9 @@ def upgrade() -> None: sa.Column('rooms_available', sa.Integer(), nullable=False), sa.Column('rooms_sold', sa.Integer(), nullable=False), sa.Column('room_revenue', sa.Numeric(precision=10, scale=2), nullable=False), - sa.ForeignKeyConstraint(['room_type_id'], ['room_types.id'], ), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('room_type_id', 'stay_date') + sa.ForeignKeyConstraint(['room_type_id'], ['room_types.id'], name='daily_performance_room_type_id_fkey'), + sa.PrimaryKeyConstraint('id', name='daily_performance_pkey'), + sa.UniqueConstraint('room_type_id', 'stay_date', name='daily_performance_room_type_id_stay_date_key') ) # ### end Alembic commands ### diff --git a/backend/foresight/database/migrations/versions/80bab95951e7_job_kinds_per_signal.py b/backend/foresight/database/migrations/versions/80bab95951e7_job_kinds_per_signal.py new file mode 100644 index 0000000..eb50770 --- /dev/null +++ b/backend/foresight/database/migrations/versions/80bab95951e7_job_kinds_per_signal.py @@ -0,0 +1,31 @@ +"""job kinds per signal + +Ingest and backfill merged into one job kind per signal (WEATHER, EVENTS, +PERFORMANCE); a request's dates choose refresh or backfill. `jobs.kind` is a +VARCHAR without a CHECK, so no schema changes, but rows of the old kinds +would no longer load. Jobs are run history, not data, so those rows are +deleted; a downgrade deletes rows of the new kinds the same way. + +Revision ID: 80bab95951e7 +Revises: 916e453d1325 +Create Date: 2026-10-15 03:41:10.716151 + +""" +from typing import Sequence, Union + +from alembic import op + + +# revision identifiers, used by Alembic. +revision: str = '80bab95951e7' +down_revision: Union[str, None] = '916e453d1325' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute("DELETE FROM jobs WHERE kind NOT IN ('WEATHER', 'EVENTS', 'PERFORMANCE')") + + +def downgrade() -> None: + op.execute("DELETE FROM jobs WHERE kind IN ('WEATHER', 'EVENTS', 'PERFORMANCE')") diff --git a/backend/foresight/database/migrations/versions/916e453d1325_cities.py b/backend/foresight/database/migrations/versions/916e453d1325_cities.py new file mode 100644 index 0000000..32d8f38 --- /dev/null +++ b/backend/foresight/database/migrations/versions/916e453d1325_cities.py @@ -0,0 +1,145 @@ +"""cities + +A city is the market every outside signal belongs to. Signal tables keyed on +free-text city names matched inconsistently (one read lower-cased, another +compared exactly), and city-level facts (timezone, airport, the coordinates to +search around) sat on each hotel. Now `cities` holds them once; hotels and +every signal table point at it. + +Data: each distinct hotel city becomes a city, from its hotels' coordinates, +timezone and airport codes. Signal rows are copies of outside sources, so they +are cleared rather than re-keyed; ingest them again afterwards. A downgrade +restores the hotel columns and clears the signal tables the same way. + +Revision ID: 916e453d1325 +Revises: e1bcb1b39bcf +Create Date: 2026-10-15 03:17:24.368433 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +SIGNALS = 'event_observations, events, weather, flight_arrivals, competitor_rates' + + +# revision identifiers, used by Alembic. +revision: str = '916e453d1325' +down_revision: Union[str, None] = 'e1bcb1b39bcf' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('cities', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=120), nullable=False), + sa.Column('slug', sa.String(length=120), nullable=False), + sa.Column('country', sa.String(length=2), nullable=True), + sa.Column('timezone', sa.String(length=64), nullable=False), + sa.Column('latitude', sa.Float(), nullable=False), + sa.Column('longitude', sa.Float(), nullable=False), + sa.Column('airport_iata', sa.String(length=3), nullable=True), + sa.Column('airport_icao', sa.String(length=4), nullable=True), + sa.PrimaryKeyConstraint('id', name=op.f('pk_cities')), + sa.UniqueConstraint('slug', name=op.f('uq_cities_slug')) + ) + op.execute(f'TRUNCATE {SIGNALS}') + + conn = op.get_bind() + missing = conn.execute(sa.text( + 'SELECT id, name FROM hotels WHERE latitude IS NULL OR longitude IS NULL' + )).all() + if missing: + raise RuntimeError( + f'hotels {[tuple(r) for r in missing]} have no coordinates; set them ' + 'before upgrading, since a city needs a centre' + ) + op.add_column('hotels', sa.Column('city_id', sa.Integer(), nullable=True)) + # Same normalization as slugify_city: lower-case, non-alphanumerics to "-", + # region after a comma dropped. Accents aren't stripped here; none exist yet. + op.execute(''' + INSERT INTO cities (name, slug, timezone, latitude, longitude, airport_iata, airport_icao) + SELECT min(trim(split_part(city, ',', 1))), + trim(BOTH '-' FROM regexp_replace(lower(split_part(city, ',', 1)), '[^a-z0-9]+', '-', 'g')), + coalesce(max(timezone), 'UTC'), avg(latitude), avg(longitude), + max(airport_iata), max(airport_icao) + FROM hotels GROUP BY 2 + ''') + op.execute(''' + UPDATE hotels h SET city_id = c.id FROM cities c + WHERE c.slug = trim(BOTH '-' FROM regexp_replace(lower(split_part(h.city, ',', 1)), '[^a-z0-9]+', '-', 'g')) + ''') + op.alter_column('hotels', 'city_id', nullable=False) + op.add_column('competitor_rates', sa.Column('city_id', sa.Integer(), nullable=False)) + op.drop_index(op.f('ix_competitor_rates_city_stay_date'), table_name='competitor_rates') + op.drop_constraint(op.f('uq_competitor_rates_hotel_name_city_stay_date_source'), 'competitor_rates', type_='unique') + op.create_unique_constraint(op.f('uq_competitor_rates_city_id_hotel_name_stay_date_source'), 'competitor_rates', ['city_id', 'hotel_name', 'stay_date', 'source']) + op.create_foreign_key(op.f('fk_competitor_rates_city_id_cities'), 'competitor_rates', 'cities', ['city_id'], ['id'], ondelete='CASCADE') + op.drop_column('competitor_rates', 'city') + op.add_column('events', sa.Column('city_id', sa.Integer(), nullable=False)) + op.drop_index(op.f('ix_events_city_slug_start_local_date'), table_name='events') + op.create_index(op.f('ix_events_city_id_start_local_date'), 'events', ['city_id', 'start_local_date'], unique=False) + op.create_foreign_key(op.f('fk_events_city_id_cities'), 'events', 'cities', ['city_id'], ['id'], ondelete='CASCADE') + op.drop_column('events', 'city') + op.drop_column('events', 'city_slug') + op.add_column('flight_arrivals', sa.Column('city_id', sa.Integer(), nullable=False)) + op.drop_constraint(op.f('uq_flight_arrivals_airport_arrival_date_source'), 'flight_arrivals', type_='unique') + op.create_unique_constraint(op.f('uq_flight_arrivals_city_id_arrival_date_source'), 'flight_arrivals', ['city_id', 'arrival_date', 'source']) + op.create_foreign_key(op.f('fk_flight_arrivals_city_id_cities'), 'flight_arrivals', 'cities', ['city_id'], ['id'], ondelete='CASCADE') + op.drop_constraint(op.f('uq_hotels_name_city'), 'hotels', type_='unique') + op.create_unique_constraint(op.f('uq_hotels_city_id_name'), 'hotels', ['city_id', 'name']) + op.create_foreign_key(op.f('fk_hotels_city_id_cities'), 'hotels', 'cities', ['city_id'], ['id'], ondelete='RESTRICT') + op.drop_column('hotels', 'city') + op.drop_column('hotels', 'airport_iata') + op.drop_column('hotels', 'airport_icao') + op.drop_column('hotels', 'timezone') + op.add_column('weather', sa.Column('city_id', sa.Integer(), nullable=False)) + op.drop_constraint(op.f('uq_weather_city_weather_date_is_forecast_source'), 'weather', type_='unique') + op.create_unique_constraint(op.f('uq_weather_city_id_weather_date_is_forecast_source'), 'weather', ['city_id', 'weather_date', 'is_forecast', 'source']) + op.create_foreign_key(op.f('fk_weather_city_id_cities'), 'weather', 'cities', ['city_id'], ['id'], ondelete='CASCADE') + op.drop_column('weather', 'city') + # ### end Alembic commands ### + + +def downgrade() -> None: + op.execute(f'TRUNCATE {SIGNALS}') + op.add_column('hotels', sa.Column('city', sa.VARCHAR(length=120), nullable=True)) + op.add_column('hotels', sa.Column('timezone', sa.VARCHAR(length=64), nullable=True)) + op.add_column('hotels', sa.Column('airport_icao', sa.VARCHAR(length=4), nullable=True)) + op.add_column('hotels', sa.Column('airport_iata', sa.VARCHAR(length=3), nullable=True)) + op.execute(''' + UPDATE hotels h SET city = c.name, timezone = c.timezone, + airport_iata = c.airport_iata, airport_icao = c.airport_icao + FROM cities c WHERE c.id = h.city_id + ''') + op.alter_column('hotels', 'city', nullable=False) + op.add_column('weather', sa.Column('city', sa.VARCHAR(length=120), autoincrement=False, nullable=False)) + op.drop_constraint(op.f('fk_weather_city_id_cities'), 'weather', type_='foreignkey') + op.drop_constraint(op.f('uq_weather_city_id_weather_date_is_forecast_source'), 'weather', type_='unique') + op.create_unique_constraint(op.f('uq_weather_city_weather_date_is_forecast_source'), 'weather', ['city', 'weather_date', 'is_forecast', 'source'], postgresql_nulls_not_distinct=False) + op.drop_column('weather', 'city_id') + op.drop_constraint(op.f('fk_hotels_city_id_cities'), 'hotels', type_='foreignkey') + op.drop_constraint(op.f('uq_hotels_city_id_name'), 'hotels', type_='unique') + op.create_unique_constraint(op.f('uq_hotels_name_city'), 'hotels', ['name', 'city'], postgresql_nulls_not_distinct=False) + op.drop_column('hotels', 'city_id') + op.drop_constraint(op.f('fk_flight_arrivals_city_id_cities'), 'flight_arrivals', type_='foreignkey') + op.drop_constraint(op.f('uq_flight_arrivals_city_id_arrival_date_source'), 'flight_arrivals', type_='unique') + op.create_unique_constraint(op.f('uq_flight_arrivals_airport_arrival_date_source'), 'flight_arrivals', ['airport', 'arrival_date', 'source'], postgresql_nulls_not_distinct=False) + op.drop_column('flight_arrivals', 'city_id') + op.add_column('events', sa.Column('city_slug', sa.VARCHAR(length=120), autoincrement=False, nullable=False)) + op.add_column('events', sa.Column('city', sa.VARCHAR(length=120), autoincrement=False, nullable=False)) + op.drop_constraint(op.f('fk_events_city_id_cities'), 'events', type_='foreignkey') + op.drop_index(op.f('ix_events_city_id_start_local_date'), table_name='events') + op.create_index(op.f('ix_events_city_slug_start_local_date'), 'events', ['city_slug', 'start_local_date'], unique=False) + op.drop_column('events', 'city_id') + op.add_column('competitor_rates', sa.Column('city', sa.VARCHAR(length=120), autoincrement=False, nullable=False)) + op.drop_constraint(op.f('fk_competitor_rates_city_id_cities'), 'competitor_rates', type_='foreignkey') + op.drop_constraint(op.f('uq_competitor_rates_city_id_hotel_name_stay_date_source'), 'competitor_rates', type_='unique') + op.create_unique_constraint(op.f('uq_competitor_rates_hotel_name_city_stay_date_source'), 'competitor_rates', ['hotel_name', 'city', 'stay_date', 'source'], postgresql_nulls_not_distinct=False) + op.create_index(op.f('ix_competitor_rates_city_stay_date'), 'competitor_rates', ['city', 'stay_date'], unique=False) + op.drop_column('competitor_rates', 'city_id') + op.drop_table('cities') + # ### end Alembic commands ### diff --git a/backend/foresight/database/migrations/versions/a1b2c3d4e5f6_example_widget.py b/backend/foresight/database/migrations/versions/a1b2c3d4e5f6_example_widget.py index d58de04..e78beb0 100644 --- a/backend/foresight/database/migrations/versions/a1b2c3d4e5f6_example_widget.py +++ b/backend/foresight/database/migrations/versions/a1b2c3d4e5f6_example_widget.py @@ -25,7 +25,7 @@ def upgrade() -> None: sa.Column('name', sa.String(length=120), nullable=False), sa.Column('description', sa.String(length=500), nullable=True), sa.Column('quantity', sa.Integer(), nullable=False), - sa.PrimaryKeyConstraint('id'), + sa.PrimaryKeyConstraint('id', name='example_widgets_pkey'), ) diff --git a/backend/foresight/database/migrations/versions/a7c3e8d2f4b6_hotel_timezone.py b/backend/foresight/database/migrations/versions/a7c3e8d2f4b6_hotel_timezone.py new file mode 100644 index 0000000..4ec272b --- /dev/null +++ b/backend/foresight/database/migrations/versions/a7c3e8d2f4b6_hotel_timezone.py @@ -0,0 +1,30 @@ +"""hotel timezone + +Stay nights are local to the hotel. OpenSky reports arrivals as UTC instants, +so counting them per night needs the hotel's IANA zone. Nullable: existing +hotels have none until it is set, and sources that need it refuse to run. + +Revision ID: a7c3e8d2f4b6 +Revises: f2c8d5a1e9b3 +Create Date: 2026-10-14 00:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'a7c3e8d2f4b6' +down_revision: Union[str, None] = 'f2c8d5a1e9b3' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column('hotels', sa.Column('timezone', sa.String(length=64), nullable=True)) + + +def downgrade() -> None: + op.drop_column('hotels', 'timezone') diff --git a/backend/foresight/database/migrations/versions/b7d41e0c9a52_canonical_events_and_observations.py b/backend/foresight/database/migrations/versions/b7d41e0c9a52_canonical_events_and_observations.py index 9dbfd5e..e9b9552 100644 --- a/backend/foresight/database/migrations/versions/b7d41e0c9a52_canonical_events_and_observations.py +++ b/backend/foresight/database/migrations/versions/b7d41e0c9a52_canonical_events_and_observations.py @@ -52,8 +52,8 @@ def upgrade() -> None: sa.Column('primary_source', sa.String(length=40), nullable=False), sa.Column('first_seen_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), sa.Column('last_seen_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('dedupe_key') + sa.PrimaryKeyConstraint('id', name='events_pkey'), + sa.UniqueConstraint('dedupe_key', name='events_dedupe_key_key') ) op.create_index(op.f('ix_events_title_norm'), 'events', ['title_norm'], unique=False) op.create_index('ix_events_city_slug_start', 'events', ['city_slug', 'start_local_date'], unique=False) @@ -65,9 +65,9 @@ def upgrade() -> None: sa.Column('source_updated_at', sa.DateTime(timezone=True), nullable=True), sa.Column('payload', postgresql.JSONB(astext_type=sa.Text()), nullable=False), sa.Column('captured_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), - sa.ForeignKeyConstraint(['event_id'], ['events.id'], ), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('source', 'source_ref') + sa.ForeignKeyConstraint(['event_id'], ['events.id'], name='event_observations_event_id_fkey'), + sa.PrimaryKeyConstraint('id', name='event_observations_pkey'), + sa.UniqueConstraint('source', 'source_ref', name='event_observations_source_source_ref_key') ) @@ -89,5 +89,5 @@ def downgrade() -> None: sa.Column('longitude', sa.Float(), nullable=True), sa.Column('source', sa.String(length=40), nullable=False), sa.Column('captured_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), - sa.PrimaryKeyConstraint('id') + sa.PrimaryKeyConstraint('id', name='events_pkey') ) diff --git a/backend/foresight/database/migrations/versions/d4f8e2a7b1c9_pricing_models.py b/backend/foresight/database/migrations/versions/d4f8e2a7b1c9_pricing_models.py new file mode 100644 index 0000000..b589095 --- /dev/null +++ b/backend/foresight/database/migrations/versions/d4f8e2a7b1c9_pricing_models.py @@ -0,0 +1,71 @@ +"""pricing models + +Revision ID: d4f8e2a7b1c9 +Revises: c97a8a991e88 +Create Date: 2026-10-12 00:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + + +# revision identifiers, used by Alembic. +revision: str = 'd4f8e2a7b1c9' +down_revision: Union[str, None] = 'c97a8a991e88' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + 'pricing_models', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('hotel_id', sa.Integer(), nullable=False), + sa.Column('version', sa.Integer(), nullable=False), + sa.Column('algorithm', sa.String(length=60), nullable=False), + sa.Column('artifact', sa.LargeBinary(), nullable=False), + sa.Column('features', postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.Column('metrics', postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.Column( + 'lib_versions', postgresql.JSONB(astext_type=sa.Text()), nullable=False + ), + sa.Column('data_start', sa.Date(), nullable=False), + sa.Column('data_end', sa.Date(), nullable=False), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column( + 'trained_at', + sa.DateTime(timezone=True), + server_default=sa.text('now()'), + nullable=False, + ), + sa.ForeignKeyConstraint( + ['hotel_id'], ['hotels.id'], name='pricing_models_hotel_id_fkey' + ), + sa.PrimaryKeyConstraint('id', name='pricing_models_pkey'), + sa.UniqueConstraint( + 'hotel_id', 'version', name='pricing_models_hotel_id_version_key' + ), + ) + op.create_index( + op.f('ix_pricing_models_hotel_id'), 'pricing_models', ['hotel_id'] + ) + op.create_index( + 'uq_pricing_models_active_per_hotel', + 'pricing_models', + ['hotel_id'], + unique=True, + postgresql_where=sa.text('is_active'), + ) + + +def downgrade() -> None: + op.drop_index( + 'uq_pricing_models_active_per_hotel', + table_name='pricing_models', + postgresql_where=sa.text('is_active'), + ) + op.drop_index(op.f('ix_pricing_models_hotel_id'), table_name='pricing_models') + op.drop_table('pricing_models') diff --git a/backend/foresight/database/migrations/versions/e1bcb1b39bcf_jobs.py b/backend/foresight/database/migrations/versions/e1bcb1b39bcf_jobs.py new file mode 100644 index 0000000..b12ea4a --- /dev/null +++ b/backend/foresight/database/migrations/versions/e1bcb1b39bcf_jobs.py @@ -0,0 +1,51 @@ +"""jobs + +Long ingests and backfills now run through the API as background jobs. A +backfill can take hours and must keep finished work when a source's quota runs +out, so each job records its status, progress and resume point here, committed +after every chunk. Cascades with the hotel like the rest of its data. + +Revision ID: e1bcb1b39bcf +Revises: a7c3e8d2f4b6 +Create Date: 2026-10-14 13:43:02.825210 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = 'e1bcb1b39bcf' +down_revision: Union[str, None] = 'a7c3e8d2f4b6' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('jobs', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('hotel_id', sa.Integer(), nullable=False), + sa.Column('kind', sa.Enum('INGEST_EVENTS', 'INGEST_NEWS', 'SYNC_FORECAST', 'BACKFILL_EVENTS', 'BACKFILL_WEATHER', 'BACKFILL_FLIGHTS', name='jobkind', native_enum=False, length=40), nullable=False), + sa.Column('status', sa.Enum('QUEUED', 'RUNNING', 'SUCCEEDED', 'STOPPED', 'FAILED', name='jobstatus', native_enum=False, length=20), nullable=False), + sa.Column('params', postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.Column('progress', postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.Column('error', sa.Text(), nullable=True), + sa.Column('resume_from', sa.Date(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('started_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('finished_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['hotel_id'], ['hotels.id'], name=op.f('fk_jobs_hotel_id_hotels'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_jobs')) + ) + op.create_index(op.f('ix_jobs_hotel_id'), 'jobs', ['hotel_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_jobs_hotel_id'), table_name='jobs') + op.drop_table('jobs') + # ### end Alembic commands ### diff --git a/backend/foresight/database/migrations/versions/e5a9c3b2d7f1_hotel_airport_codes.py b/backend/foresight/database/migrations/versions/e5a9c3b2d7f1_hotel_airport_codes.py new file mode 100644 index 0000000..47930b0 --- /dev/null +++ b/backend/foresight/database/migrations/versions/e5a9c3b2d7f1_hotel_airport_codes.py @@ -0,0 +1,28 @@ +"""hotel airport codes + +Revision ID: e5a9c3b2d7f1 +Revises: d4f8e2a7b1c9 +Create Date: 2026-10-12 00:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'e5a9c3b2d7f1' +down_revision: Union[str, None] = 'd4f8e2a7b1c9' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column('hotels', sa.Column('airport_iata', sa.String(length=3), nullable=True)) + op.add_column('hotels', sa.Column('airport_icao', sa.String(length=4), nullable=True)) + + +def downgrade() -> None: + op.drop_column('hotels', 'airport_icao') + op.drop_column('hotels', 'airport_iata') diff --git a/backend/foresight/database/migrations/versions/f2c8d5a1e9b3_naming_convention_and_fk_policy.py b/backend/foresight/database/migrations/versions/f2c8d5a1e9b3_naming_convention_and_fk_policy.py new file mode 100644 index 0000000..97c97eb --- /dev/null +++ b/backend/foresight/database/migrations/versions/f2c8d5a1e9b3_naming_convention_and_fk_policy.py @@ -0,0 +1,156 @@ +"""naming convention and foreign key policy + +Brings every existing constraint and index in line with the naming convention +on Base.metadata, so names are derived from table and columns rather than +hand-picked or generated by Postgres. Renames happen in place: the table is +never without its key and no index is rebuilt. + +Foreign keys are recreated with an explicit ON DELETE: deleting a hotel +cascades to its room types, nights and models; deleting an event detaches +its observations rather than losing the raw sightings. The unindexed +event_observations.event_id gets an index, and pricing_models' standalone +hotel_id index is dropped because (hotel_id, version) already leads with it. + +Revision ID: f2c8d5a1e9b3 +Revises: e5a9c3b2d7f1 +Create Date: 2026-10-13 00:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op + + +# revision identifiers, used by Alembic. +revision: str = 'f2c8d5a1e9b3' +down_revision: Union[str, None] = 'e5a9c3b2d7f1' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +# (table, old name, new name) +_CONSTRAINTS = [ + ('competitor_rates', 'competitor_rates_pkey', 'pk_competitor_rates'), + ('daily_performance', 'daily_performance_pkey', 'pk_daily_performance'), + ('event_observations', 'event_observations_pkey', 'pk_event_observations'), + ('events', 'events_pkey', 'pk_events'), + ('example_widgets', 'example_widgets_pkey', 'pk_example_widgets'), + ('flight_arrivals', 'flight_arrivals_pkey', 'pk_flight_arrivals'), + ('hotels', 'hotels_pkey', 'pk_hotels'), + ('pricing_models', 'pricing_models_pkey', 'pk_pricing_models'), + ('room_types', 'room_types_pkey', 'pk_room_types'), + ('weather', 'weather_pkey', 'pk_weather'), + ( + 'competitor_rates', + 'uq_competitor_rates_hotel_date_source', + 'uq_competitor_rates_hotel_name_city_stay_date_source', + ), + ( + 'daily_performance', + 'uq_daily_performance_room_type_stay', + 'uq_daily_performance_room_type_id_stay_date', + ), + ( + 'event_observations', + 'event_observations_source_source_ref_key', + 'uq_event_observations_source_source_ref', + ), + ('events', 'events_dedupe_key_key', 'uq_events_dedupe_key'), + ( + 'flight_arrivals', + 'uq_flight_arrivals_airport_date_source', + 'uq_flight_arrivals_airport_arrival_date_source', + ), + ( + 'pricing_models', + 'pricing_models_hotel_id_version_key', + 'uq_pricing_models_hotel_id_version', + ), + ('room_types', 'uq_room_types_hotel_name', 'uq_room_types_hotel_id_name'), + ( + 'weather', + 'uq_weather_city_date_kind_source', + 'uq_weather_city_weather_date_is_forecast_source', + ), +] + +# (old name, new name) +_INDEXES = [ + ('ix_competitor_rates_city_stay', 'ix_competitor_rates_city_stay_date'), + ('ix_events_city_slug_start', 'ix_events_city_slug_start_local_date'), +] + +# (table, column, referred table, old name, new name, ondelete) +_FOREIGN_KEYS = [ + ( + 'room_types', + 'hotel_id', + 'hotels', + 'room_types_hotel_id_fkey', + 'fk_room_types_hotel_id_hotels', + 'CASCADE', + ), + ( + 'daily_performance', + 'room_type_id', + 'room_types', + 'daily_performance_room_type_id_fkey', + 'fk_daily_performance_room_type_id_room_types', + 'CASCADE', + ), + ( + 'pricing_models', + 'hotel_id', + 'hotels', + 'pricing_models_hotel_id_fkey', + 'fk_pricing_models_hotel_id_hotels', + 'CASCADE', + ), + ( + 'event_observations', + 'event_id', + 'events', + 'event_observations_event_id_fkey', + 'fk_event_observations_event_id_events', + 'SET NULL', + ), +] + + +def _rename_constraint(table: str, old: str, new: str) -> None: + op.execute(f'ALTER TABLE {table} RENAME CONSTRAINT {old} TO {new}') + + +def _rename_index(old: str, new: str) -> None: + op.execute(f'ALTER INDEX {old} RENAME TO {new}') + + +def upgrade() -> None: + for table, old, new in _CONSTRAINTS: + _rename_constraint(table, old, new) + for old, new in _INDEXES: + _rename_index(old, new) + + for table, column, referred, old, new, ondelete in _FOREIGN_KEYS: + op.drop_constraint(old, table, type_='foreignkey') + op.create_foreign_key( + new, table, referred, [column], ['id'], ondelete=ondelete + ) + + op.create_index( + 'ix_event_observations_event_id', 'event_observations', ['event_id'] + ) + op.drop_index('ix_pricing_models_hotel_id', table_name='pricing_models') + + +def downgrade() -> None: + op.create_index('ix_pricing_models_hotel_id', 'pricing_models', ['hotel_id']) + op.drop_index('ix_event_observations_event_id', table_name='event_observations') + + for table, column, referred, old, new, _ in reversed(_FOREIGN_KEYS): + op.drop_constraint(new, table, type_='foreignkey') + op.create_foreign_key(old, table, referred, [column], ['id']) + + for old, new in reversed(_INDEXES): + _rename_index(new, old) + for table, old, new in reversed(_CONSTRAINTS): + _rename_constraint(table, new, old) diff --git a/backend/foresight/database/models/__init__.py b/backend/foresight/database/models/__init__.py index 1c95002..b197b20 100644 --- a/backend/foresight/database/models/__init__.py +++ b/backend/foresight/database/models/__init__.py @@ -1,24 +1,27 @@ # Import all models here so Alembic can detect them from foresight.database.base import Base +from foresight.database.models.city import City from foresight.database.models.example_widget import Widget -from foresight.database.models.external import ( - CompetitorRate, - Event, - EventObservation, - FlightArrival, - Weather, -) from foresight.database.models.hotel import DailyPerformance, Hotel, RoomType +from foresight.database.models.job import Job +from foresight.database.models.pricing_model import PricingModel +from foresight.database.models.signals.competitor_rate import CompetitorRate +from foresight.database.models.signals.event import Event, EventObservation +from foresight.database.models.signals.flight import FlightArrival +from foresight.database.models.signals.weather import Weather __all__ = [ "Base", + "City", "CompetitorRate", "DailyPerformance", "Event", "EventObservation", "FlightArrival", "Hotel", + "Job", + "PricingModel", "RoomType", "Weather", "Widget", diff --git a/backend/foresight/database/models/city.py b/backend/foresight/database/models/city.py new file mode 100644 index 0000000..0cc2437 --- /dev/null +++ b/backend/foresight/database/models/city.py @@ -0,0 +1,34 @@ +"""A city we sell rooms in: the market every outside signal is keyed on.""" + +from sqlalchemy import String +from sqlalchemy.orm import Mapped, mapped_column + +from foresight.database.base import Base + + +class City(Base): + """One market. Hotels belong to it; weather, events, flights and competitor + rates are recorded against it and shared by every hotel in it. + """ + + __tablename__ = "cities" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(120)) # "Dublin" + # Normalized name (`services/adapters/normalize.slugify_city`). Unique, so + # "Dublin" and "dublin " can't become two markets. Also part of every + # event's dedupe key. + slug: Mapped[str] = mapped_column(String(120), unique=True) + country: Mapped[str | None] = mapped_column(String(2)) # ISO-3166 alpha-2 + + # IANA zone, e.g. "Europe/Dublin". Stay nights are local, so weather days, + # event dates and flight arrivals are all bucketed with it. + timezone: Mapped[str] = mapped_column(String(64)) + + # The centre of the market: where weather is read and events are searched. + latitude: Mapped[float] + longitude: Mapped[float] + + # The main airport. Both codes are stored because flight sources differ. + airport_iata: Mapped[str | None] = mapped_column(String(3)) # "DUB" + airport_icao: Mapped[str | None] = mapped_column(String(4)) # "EIDW" diff --git a/backend/foresight/database/models/hotel.py b/backend/foresight/database/models/hotel.py index d2e4d18..21ea0aa 100644 --- a/backend/foresight/database/models/hotel.py +++ b/backend/foresight/database/models/hotel.py @@ -1,29 +1,42 @@ -"""Hotel data.""" +"""A hotel, its room types, and how each room type sold per night.""" from datetime import date from decimal import Decimal +from typing import TYPE_CHECKING from sqlalchemy import Date, ForeignKey, Numeric, String, UniqueConstraint from sqlalchemy.orm import Mapped, mapped_column, relationship from foresight.database.base import Base +if TYPE_CHECKING: + from foresight.database.models.city import City + class Hotel(Base): + """A property we price for.""" + __tablename__ = "hotels" id: Mapped[int] = mapped_column(primary_key=True) name: Mapped[str] = mapped_column(String(200)) - city: Mapped[str] = mapped_column(String(120)) + # Its market. Timezone, airport and every outside signal come from here. + # RESTRICT: a city can't be deleted while hotels are in it. + city_id: Mapped[int] = mapped_column(ForeignKey("cities.id", ondelete="RESTRICT")) total_rooms: Mapped[int] - # Used to measure distance to event venues. + # The hotel's own location, for distance to event venues. The city's + # centre is used for searching. latitude: Mapped[float | None] longitude: Mapped[float | None] - room_types: Mapped[list["RoomType"]] = relationship(back_populates="hotel") + city: Mapped["City"] = relationship(lazy="raise") + room_types: Mapped[list["RoomType"]] = relationship( + back_populates="hotel", lazy="raise", passive_deletes=True + ) - __table_args__ = (UniqueConstraint("name", "city", name="uq_hotels_name_city"),) + # Leads on city_id, so it also serves as the foreign key index. + __table_args__ = (UniqueConstraint("city_id", "name"),) class RoomType(Base): @@ -32,18 +45,19 @@ class RoomType(Base): __tablename__ = "room_types" id: Mapped[int] = mapped_column(primary_key=True) - hotel_id: Mapped[int] = mapped_column(ForeignKey("hotels.id")) + hotel_id: Mapped[int] = mapped_column(ForeignKey("hotels.id", ondelete="CASCADE")) name: Mapped[str] = mapped_column(String(120)) room_count: Mapped[int] # how many physical rooms of this type base_rate: Mapped[Decimal] = mapped_column(Numeric(10, 2)) # normal price - hotel: Mapped["Hotel"] = relationship(back_populates="room_types") - nights: Mapped[list["DailyPerformance"]] = relationship(back_populates="room_type") - - __table_args__ = ( - UniqueConstraint("hotel_id", "name", name="uq_room_types_hotel_name"), + hotel: Mapped["Hotel"] = relationship(back_populates="room_types", lazy="raise") + nights: Mapped[list["DailyPerformance"]] = relationship( + back_populates="room_type", lazy="raise", passive_deletes=True ) + # Leads on hotel_id, so it also serves as the foreign key index. + __table_args__ = (UniqueConstraint("hotel_id", "name"),) + class DailyPerformance(Base): """One room type, one night: what we charged and how it sold.""" @@ -51,7 +65,9 @@ class DailyPerformance(Base): __tablename__ = "daily_performance" id: Mapped[int] = mapped_column(primary_key=True) - room_type_id: Mapped[int] = mapped_column(ForeignKey("room_types.id")) + room_type_id: Mapped[int] = mapped_column( + ForeignKey("room_types.id", ondelete="CASCADE") + ) stay_date: Mapped[date] = mapped_column(Date) # the night being sold rate: Mapped[Decimal | None] = mapped_column(Numeric(10, 2)) # price we listed @@ -59,11 +75,7 @@ class DailyPerformance(Base): rooms_sold: Mapped[int] = mapped_column(default=0) room_revenue: Mapped[Decimal] = mapped_column(Numeric(10, 2), default=0) - room_type: Mapped["RoomType"] = relationship(back_populates="nights") + room_type: Mapped["RoomType"] = relationship(back_populates="nights", lazy="raise") - # One row per room type per night. - __table_args__ = ( - UniqueConstraint( - "room_type_id", "stay_date", name="uq_daily_performance_room_type_stay" - ), - ) + # One row per room type per night. Also serves as the foreign key index. + __table_args__ = (UniqueConstraint("room_type_id", "stay_date"),) diff --git a/backend/foresight/database/models/job.py b/backend/foresight/database/models/job.py new file mode 100644 index 0000000..7ccdf1a --- /dev/null +++ b/backend/foresight/database/models/job.py @@ -0,0 +1,44 @@ +"""Background jobs: ingests started through the API.""" + +from datetime import date, datetime +from typing import Any + +from sqlalchemy import Date, DateTime, ForeignKey, Text, func +from sqlalchemy import Enum as SAEnum +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column + +from foresight.database.base import Base +from foresight.database.enums import JobKind, JobStatus + + +class Job(Base): + """One run of a job for a hotel, and how far it got. + + The runner commits after each chunk of work together with `progress`, so + the row always describes what is actually saved. + """ + + __tablename__ = "jobs" + + id: Mapped[int] = mapped_column(primary_key=True) + hotel_id: Mapped[int] = mapped_column( + ForeignKey("hotels.id", ondelete="CASCADE"), index=True + ) + kind: Mapped[JobKind] = mapped_column(SAEnum(JobKind, native_enum=False, length=40)) + status: Mapped[JobStatus] = mapped_column( + SAEnum(JobStatus, native_enum=False, length=20), default=JobStatus.QUEUED + ) + + params: Mapped[dict[str, Any]] = mapped_column(JSONB) # resolved inputs + progress: Mapped[dict[str, Any]] = mapped_column(JSONB, default=dict) + error: Mapped[str | None] = mapped_column(Text) + # Where to pick up if the run ends early: the `start` to send again, or the + # `end` for a run walking backwards. Null once it succeeds. + resume_from: Mapped[date | None] = mapped_column(Date) + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) diff --git a/backend/foresight/database/models/pricing_model.py b/backend/foresight/database/models/pricing_model.py new file mode 100644 index 0000000..c6fdcb9 --- /dev/null +++ b/backend/foresight/database/models/pricing_model.py @@ -0,0 +1,72 @@ +"""Trained pricing models, versioned per hotel.""" + +from datetime import date, datetime +from typing import Any + +from sqlalchemy import ( + Date, + DateTime, + ForeignKey, + Index, + LargeBinary, + String, + Text, + UniqueConstraint, + func, + text, +) +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column + +from foresight.database.base import Base + + +class PricingModel(Base): + """One trained model version for a hotel. + + The fitted model is serialized to bytes in `artifact`. Only one version per + hotel is active at a time. + """ + + __tablename__ = "pricing_models" + + id: Mapped[int] = mapped_column(primary_key=True) + hotel_id: Mapped[int] = mapped_column(ForeignKey("hotels.id", ondelete="CASCADE")) + version: Mapped[int] # increments per hotel + algorithm: Mapped[str] = mapped_column(String(60)) # e.g. "ebm" + # What this version tried, written by whoever trained it. + description: Mapped[str | None] = mapped_column(Text) + + # Serialized fitted model. Only load artifacts our own training wrote. + artifact: Mapped[bytes] = mapped_column(LargeBinary) + + features: Mapped[list[str]] = mapped_column(JSONB) # input columns, in order + metrics: Mapped[dict[str, Any]] = mapped_column(JSONB) # mae, rmse, r2, ... + lib_versions: Mapped[dict[str, str]] = mapped_column(JSONB) # pickle compat + # No longer written; new versions get {}. Older rows may still hold a + # summary. Kept until what a version was trained on gets its own columns. + training_summary: Mapped[dict[str, Any]] = mapped_column( + JSONB, server_default=text("'{}'::jsonb") + ) + + # Training window (stay dates). + data_start: Mapped[date] = mapped_column(Date) + data_end: Mapped[date] = mapped_column(Date) + + is_active: Mapped[bool] = mapped_column(default=False) + trained_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + + __table_args__ = ( + # Leads on hotel_id, so it also serves as the foreign key index. + UniqueConstraint("hotel_id", "version"), + # At most one active model per hotel. Named explicitly: a partial index + # means something the column-derived convention name can't express. + Index( + "uq_pricing_models_active_per_hotel", + "hotel_id", + unique=True, + postgresql_where=text("is_active"), + ), + ) diff --git a/backend/foresight/database/models/signals/__init__.py b/backend/foresight/database/models/signals/__init__.py new file mode 100644 index 0000000..dced1d9 --- /dev/null +++ b/backend/foresight/database/models/signals/__init__.py @@ -0,0 +1,5 @@ +"""Outside demand signals: weather, events, flights, competitor rates. + +Recorded per city (`cities`) and shared by every hotel in it, so these tables +have no foreign key to `hotels`. +""" diff --git a/backend/foresight/database/models/signals/competitor_rate.py b/backend/foresight/database/models/signals/competitor_rate.py new file mode 100644 index 0000000..9dea23d --- /dev/null +++ b/backend/foresight/database/models/signals/competitor_rate.py @@ -0,0 +1,38 @@ +"""What competing hotels charge per night.""" + +from datetime import date, datetime +from decimal import Decimal + +from sqlalchemy import ( + Date, + DateTime, + ForeignKey, + Numeric, + String, + UniqueConstraint, + func, +) +from sqlalchemy.orm import Mapped, mapped_column + +from foresight.database.base import Base + + +class CompetitorRate(Base): + """What another hotel is charging for a given night.""" + + __tablename__ = "competitor_rates" + + id: Mapped[int] = mapped_column(primary_key=True) + hotel_name: Mapped[str] = mapped_column(String(200)) # the competitor + city_id: Mapped[int] = mapped_column(ForeignKey("cities.id", ondelete="CASCADE")) + + stay_date: Mapped[date] = mapped_column(Date) # the night being priced + rate: Mapped[Decimal | None] = mapped_column(Numeric(10, 2)) # null if sold out + is_sold_out: Mapped[bool] = mapped_column(default=False) + + source: Mapped[str] = mapped_column(String(40)) + captured_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + + __table_args__ = (UniqueConstraint("city_id", "hotel_name", "stay_date", "source"),) diff --git a/backend/foresight/database/models/external.py b/backend/foresight/database/models/signals/event.py similarity index 51% rename from backend/foresight/database/models/external.py rename to backend/foresight/database/models/signals/event.py index 8c69e62..5aa4f75 100644 --- a/backend/foresight/database/models/external.py +++ b/backend/foresight/database/models/signals/event.py @@ -1,7 +1,6 @@ -"""External factors: weather, events, competitor pricing, flights.""" +"""Real-world events, merged across sources, and each source's raw sightings.""" from datetime import date, datetime -from decimal import Decimal from typing import Any from sqlalchemy import ( @@ -9,7 +8,6 @@ DateTime, ForeignKey, Index, - Numeric, String, UniqueConstraint, func, @@ -19,50 +17,7 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship from foresight.database.base import Base -from foresight.ingest.normalize import ( - DatePrecision, - EventCategory, - EventStatus, - Source, -) - - -class Weather(Base): - """Daily weather for a city. - - Rows are either a forecast (is_forecast=True, may be revised) or a recorded - actual (is_forecast=False, final). - """ - - __tablename__ = "weather" - - id: Mapped[int] = mapped_column(primary_key=True) - city: Mapped[str] = mapped_column(String(120)) - weather_date: Mapped[date] = mapped_column(Date) # the day being described - - temp_max_c: Mapped[float | None] - temp_min_c: Mapped[float | None] - precip_mm: Mapped[float | None] - wind_kph: Mapped[float | None] - condition: Mapped[str | None] = mapped_column(String(60)) # "rain", "cloudy", ... - - is_forecast: Mapped[bool] - source: Mapped[str] = mapped_column(String(40)) - captured_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), server_default=func.now() - ) - - # A forecast and the recorded actual for the same day are different rows, as - # are two sources' readings; re-fetching either one updates in place. - __table_args__ = ( - UniqueConstraint( - "city", - "weather_date", - "is_forecast", - "source", - name="uq_weather_city_date_kind_source", - ), - ) +from foresight.database.enums import DatePrecision, EventCategory, EventStatus, Source class Event(Base): @@ -85,8 +40,7 @@ class Event(Base): ) venue: Mapped[str | None] = mapped_column(String(200)) - city: Mapped[str] = mapped_column(String(120)) - city_slug: Mapped[str] = mapped_column(String(120)) + city_id: Mapped[int] = mapped_column(ForeignKey("cities.id", ondelete="CASCADE")) country: Mapped[str | None] = mapped_column(String(2)) # ISO-3166 alpha-2 latitude: Mapped[float | None] longitude: Mapped[float | None] @@ -126,13 +80,11 @@ class Event(Base): ) observations: Mapped[list["EventObservation"]] = relationship( - back_populates="event" + back_populates="event", lazy="raise", passive_deletes=True ) # Serves the core lookup: what is happening near this hotel on this night. - __table_args__ = ( - Index("ix_events_city_slug_start", "city_slug", "start_local_date"), - ) + __table_args__ = (Index(None, "city_id", "start_local_date"),) class EventObservation(Base): @@ -149,7 +101,9 @@ class EventObservation(Base): __tablename__ = "event_observations" id: Mapped[int] = mapped_column(primary_key=True) - event_id: Mapped[int | None] = mapped_column(ForeignKey("events.id")) + event_id: Mapped[int | None] = mapped_column( + ForeignKey("events.id", ondelete="SET NULL"), index=True + ) source: Mapped[Source] = mapped_column(SAEnum(Source, native_enum=False, length=40)) source_ref: Mapped[str] = mapped_column(String(600)) @@ -165,67 +119,8 @@ class EventObservation(Base): DateTime(timezone=True), server_default=func.now() ) - event: Mapped["Event | None"] = relationship(back_populates="observations") - - __table_args__ = (UniqueConstraint("source", "source_ref"),) - - -class CompetitorRate(Base): - """What another hotel is charging for a given night.""" - - __tablename__ = "competitor_rates" - - id: Mapped[int] = mapped_column(primary_key=True) - hotel_name: Mapped[str] = mapped_column(String(200)) # the competitor - city: Mapped[str] = mapped_column(String(120)) - - stay_date: Mapped[date] = mapped_column(Date) # the night being priced - rate: Mapped[Decimal | None] = mapped_column(Numeric(10, 2)) # null if sold out - is_sold_out: Mapped[bool] = mapped_column(default=False) - - source: Mapped[str] = mapped_column(String(40)) - captured_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), server_default=func.now() - ) - - __table_args__ = ( - UniqueConstraint( - "hotel_name", - "city", - "stay_date", - "source", - name="uq_competitor_rates_hotel_date_source", - ), - # The unique constraint leads on hotel_name, so it can't serve the - # market-wide read: every competitor's rate for a range of nights. - Index("ix_competitor_rates_city_stay", "city", "stay_date"), + event: Mapped["Event | None"] = relationship( + back_populates="observations", lazy="raise" ) - -class FlightArrival(Base): - """How many people are flying in on a given day.""" - - __tablename__ = "flight_arrivals" - - id: Mapped[int] = mapped_column(primary_key=True) - airport: Mapped[str] = mapped_column(String(10)) # "DUB" - arrival_date: Mapped[date] = mapped_column(Date) - - flight_count: Mapped[int | None] - avg_fare_eur: Mapped[Decimal | None] = mapped_column(Numeric(10, 2)) - - source: Mapped[str] = mapped_column(String(40)) - captured_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), server_default=func.now() - ) - - # CSO, OpenSky and AeroDataBox measure the same day at different grains, so - # each keeps its own row and the read layer picks which one wins. - __table_args__ = ( - UniqueConstraint( - "airport", - "arrival_date", - "source", - name="uq_flight_arrivals_airport_date_source", - ), - ) + __table_args__ = (UniqueConstraint("source", "source_ref"),) diff --git a/backend/foresight/database/models/signals/flight.py b/backend/foresight/database/models/signals/flight.py new file mode 100644 index 0000000..1d20e3e --- /dev/null +++ b/backend/foresight/database/models/signals/flight.py @@ -0,0 +1,41 @@ +"""Flight arrivals at a city's airport per day.""" + +from datetime import date, datetime +from decimal import Decimal + +from sqlalchemy import ( + Date, + DateTime, + ForeignKey, + Numeric, + String, + UniqueConstraint, + func, +) +from sqlalchemy.orm import Mapped, mapped_column + +from foresight.database.base import Base + + +class FlightArrival(Base): + """How many people are flying in on a given day.""" + + __tablename__ = "flight_arrivals" + + id: Mapped[int] = mapped_column(primary_key=True) + city_id: Mapped[int] = mapped_column(ForeignKey("cities.id", ondelete="CASCADE")) + # The code the source reported, IATA or ICAO; kept for tracing. + airport: Mapped[str] = mapped_column(String(10)) + arrival_date: Mapped[date] = mapped_column(Date) + + flight_count: Mapped[int | None] + avg_fare_eur: Mapped[Decimal | None] = mapped_column(Numeric(10, 2)) + + source: Mapped[str] = mapped_column(String(40)) + captured_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + + # CSO, OpenSky and AeroDataBox measure the same day at different grains, so + # each keeps its own row and the read layer picks which one wins. + __table_args__ = (UniqueConstraint("city_id", "arrival_date", "source"),) diff --git a/backend/foresight/database/models/signals/weather.py b/backend/foresight/database/models/signals/weather.py new file mode 100644 index 0000000..eb8269b --- /dev/null +++ b/backend/foresight/database/models/signals/weather.py @@ -0,0 +1,47 @@ +"""Daily weather per city: forecasts and recorded actuals.""" + +from datetime import date, datetime + +from sqlalchemy import ( + Date, + DateTime, + ForeignKey, + String, + UniqueConstraint, + func, +) +from sqlalchemy.orm import Mapped, mapped_column + +from foresight.database.base import Base + + +class Weather(Base): + """Daily weather for a city. + + Rows are either a forecast (is_forecast=True, may be revised) or a recorded + actual (is_forecast=False, final). + """ + + __tablename__ = "weather" + + id: Mapped[int] = mapped_column(primary_key=True) + city_id: Mapped[int] = mapped_column(ForeignKey("cities.id", ondelete="CASCADE")) + weather_date: Mapped[date] = mapped_column(Date) # the day being described + + temp_max_c: Mapped[float | None] + temp_min_c: Mapped[float | None] + precip_mm: Mapped[float | None] + wind_kph: Mapped[float | None] + condition: Mapped[str | None] = mapped_column(String(60)) # "rain", "cloudy", ... + + is_forecast: Mapped[bool] + source: Mapped[str] = mapped_column(String(40)) + captured_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + + # A forecast and the recorded actual for the same day are different rows, as + # are two sources' readings; re-fetching either one updates in place. + __table_args__ = ( + UniqueConstraint("city_id", "weather_date", "is_forecast", "source"), + ) diff --git a/backend/foresight/database/session.py b/backend/foresight/database/session.py index 2550053..70b4bfc 100644 --- a/backend/foresight/database/session.py +++ b/backend/foresight/database/session.py @@ -5,8 +5,10 @@ from foresight.config import settings engine = create_async_engine( - settings.database_url, + settings.async_database_url, echo=settings.debug, + # Neon computes scale to zero and drop idle connections; validate before use. + pool_pre_ping=True, ) async_session_factory = async_sessionmaker( diff --git a/backend/foresight/ingest/__init__.py b/backend/foresight/ingest/__init__.py deleted file mode 100644 index bcb8302..0000000 --- a/backend/foresight/ingest/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Ingestion of external data sources: events, weather, flights, competitor rates.""" diff --git a/backend/foresight/ingest/external.py b/backend/foresight/ingest/external.py deleted file mode 100644 index 2032dd9..0000000 --- a/backend/foresight/ingest/external.py +++ /dev/null @@ -1,120 +0,0 @@ -from __future__ import annotations - -import argparse -import asyncio -import sys -from datetime import date -from typing import TYPE_CHECKING - -from foresight.database.session import async_session_factory, engine -from foresight.ingest.flights import daily_arrivals -from foresight.ingest.weather import fetch_actuals, fetch_forecast -from foresight.repository.flights import upsert_flight_arrivals -from foresight.repository.weather import upsert_weather -from foresight.services.flight_data_service import fetch_aerodatabox, fetch_opensky - -if TYPE_CHECKING: - from collections.abc import Sequence - - from sqlalchemy.ext.asyncio import AsyncSession - - from foresight.repository import Outcome - -# shortcut: every flight provider is hardcoded to Dublin, so there is nothing -# for an --airport flag to vary yet; add one when a second airport appears. -AIRPORT = "DUB" -_FLIGHT_PROVIDERS = {"opensky": fetch_opensky, "aerodatabox": fetch_aerodatabox} - - -def _parse_date(value: str) -> date: - try: - return date.fromisoformat(value) - except ValueError: - raise argparse.ArgumentTypeError( - f"expected a date like 2026-10-05, got {value!r}" - ) from None - - -async def _weather( - session: AsyncSession, args: argparse.Namespace -) -> dict[Outcome, int]: - if args.start is None: - rows = fetch_forecast(args.city, args.lat, args.lon, "auto") - else: - # The archive refuses a future end date outright instead of returning - # empty days, so a range running past today has to be clamped here. - rows = fetch_actuals( - args.city, - args.lat, - args.lon, - "auto", - start=args.start, - end=min(args.end, date.today()), - ) - return await upsert_weather(session, rows) - - -async def _flights( - session: AsyncSession, args: argparse.Namespace -) -> dict[Outcome, int]: - frame = _FLIGHT_PROVIDERS[args.source](args.start, args.end) - rows = daily_arrivals(frame, airport=AIRPORT, source=args.source) - return await upsert_flight_arrivals(session, rows) - - -async def run(args: argparse.Namespace) -> dict[Outcome, int]: - handler = _weather if args.source == "weather" else _flights - async with async_session_factory() as session: - counts = await handler(session, args) - await session.commit() - return counts - - -def _parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser( - prog="python -m foresight.ingest.external", - description="Fetch one external source and upsert it into its table.", - ) - parser.add_argument( - "--source", choices=["weather", *_FLIGHT_PROVIDERS], required=True - ) - parser.add_argument("--start", type=_parse_date, help="first day, inclusive") - parser.add_argument("--end", type=_parse_date, help="last day, inclusive") - parser.add_argument("--city", help="weather only: the name stored on each row") - parser.add_argument("--lat", type=float, help="weather only") - parser.add_argument("--lon", type=float, help="weather only") - return parser - - -async def _main(args: argparse.Namespace) -> dict[Outcome, int]: - try: - return await run(args) - finally: - await engine.dispose() - - -def main(argv: Sequence[str] | None = None) -> int: - parser = _parser() - args = parser.parse_args(argv) - if args.source == "weather": - if None in (args.city, args.lat, args.lon): - parser.error("--source weather needs --city, --lat and --lon") - if (args.start is None) != (args.end is None): - parser.error("give both --start and --end, or neither for a forecast") - elif None in (args.start, args.end): - parser.error(f"--source {args.source} needs --start and --end") - if None not in (args.start, args.end) and args.start > args.end: - parser.error("--start is after --end") - - engine.echo = False - counts = asyncio.run(_main(args)) - total = sum(counts.values()) - print( - f"{args.source}: {total} rows " - f"({', '.join(f'{n} {outcome.value}' for outcome, n in counts.items())})" - ) - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/backend/foresight/ingest/flights.py b/backend/foresight/ingest/flights.py deleted file mode 100644 index d1205f5..0000000 --- a/backend/foresight/ingest/flights.py +++ /dev/null @@ -1,30 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING, Any - -if TYPE_CHECKING: - import pandas as pd - - -def daily_arrivals( - df: pd.DataFrame, *, airport: str, source: str -) -> list[dict[str, Any]]: - """One row per arrival date, as `flight_arrivals` rows. - - `arrival_time` is already local to the airport, so its date is the day the - hotel sees those arrivals. No source carries fares, so avg_fare_eur is null. - """ - if df.empty: - return [] - - counts = df["arrival_time"].dt.date.value_counts().sort_index() - return [ - { - "airport": airport, - "arrival_date": day, - "flight_count": int(n), - "avg_fare_eur": None, - "source": source, - } - for day, n in counts.items() - ] diff --git a/backend/foresight/ingest/predicthq.py b/backend/foresight/ingest/predicthq.py deleted file mode 100644 index 7522aef..0000000 --- a/backend/foresight/ingest/predicthq.py +++ /dev/null @@ -1,162 +0,0 @@ -"""PredictHQ adapter: events near a hotel as NormalizedEvents. No DB writes.""" - -from __future__ import annotations - -import logging -from dataclasses import dataclass, field -from datetime import UTC, datetime, timedelta -from typing import TYPE_CHECKING, Any - -import httpx - -from foresight.ingest.normalize import ( - DatePrecision, - EventCategory, - EventStatus, - NormalizedEvent, - Source, - coords_from_geojson, - to_local_date, -) - -if TYPE_CHECKING: - from collections.abc import Mapping - -log = logging.getLogger(__name__) - -API_URL = "https://api.predicthq.com/v1/events/" -PAGE_LIMIT = 500 -# The API returns only `active` unless asked; an unseen deletion would leave a -# cancelled event in the demand forecast indefinitely. -STATES = "active,predicted,deleted" - -_STATUS = { - "active": EventStatus.ACTIVE, - "predicted": EventStatus.PROVISIONAL, - "deleted": EventStatus.DELETED, -} - - -@dataclass(slots=True) -class Rejected: - source_ref: str - reason: str - - -@dataclass(slots=True) -class FetchResult: - events: list[NormalizedEvent] = field(default_factory=list) - rejected: list[Rejected] = field(default_factory=list) - - @property - def fetched(self) -> int: - return len(self.events) + len(self.rejected) - - -async def fetch_events( - *, - token: str, - latitude: float, - longitude: float, - city: str, - updated_since: datetime, - radius: str = "10km", - transport: httpx.AsyncBaseTransport | None = None, -) -> FetchResult: - """Events within `radius` of a point, revised upstream since `updated_since`. - - `city` is the hotel's market and becomes every event's city, so a suburb - venue still shares a dedupe key with a news article naming the city. - """ - params = { - "within": f"{radius}@{latitude},{longitude}", - "updated.gte": updated_since.astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%S"), - # A day of slack so an event running tonight in a zone behind UTC stays in. - "active.gte": (datetime.now(UTC).date() - timedelta(days=1)).isoformat(), - "state": STATES, - "limit": PAGE_LIMIT, - "sort": "updated", - } - headers = {"Authorization": f"Bearer {token}", "Accept": "application/json"} - result = FetchResult() - - async with httpx.AsyncClient( - headers=headers, timeout=30.0, transport=transport - ) as client: - url: str | None = API_URL - query: dict[str, Any] | None = params - while url: - response = await client.get(url, params=query) - response.raise_for_status() - body = response.json() - if body.get("overflow"): - log.warning("PredictHQ result overflow: plan limit truncated results") - - for raw in body.get("results", []): - try: - result.events.append(to_normalized(raw, city=city)) - except (KeyError, TypeError, ValueError) as exc: - ref = str(raw.get("id", "?")) if isinstance(raw, dict) else "?" - log.warning("rejected PredictHQ event %s: %s", ref, exc) - result.rejected.append(Rejected(ref, rejection_reason(exc))) - - # `next` already carries every query parameter. - url, query = body.get("next"), None - - return result - - -def to_normalized(raw: Mapping[str, Any], *, city: str) -> NormalizedEvent: - start = _parse_instant(raw["start"]) - end = _parse_instant(raw["end"]) if raw.get("end") else start - timezone = raw.get("timezone") - latitude, longitude = coords_from_geojson(raw.get("location")) - - return NormalizedEvent( - source=Source.PREDICTHQ, - source_ref=raw["id"], - title=raw["title"], - city=city, - start_local_date=to_local_date(start, timezone), - end_local_date=to_local_date(end, timezone), - timezone=timezone or "UTC", - category=_category(raw.get("category")), - date_precision=DatePrecision.DAY, - status=_STATUS[raw["state"]], - confidence=1.0, - venue=_venue(raw.get("entities") or []), - country=raw.get("country"), - latitude=latitude, - longitude=longitude, - start_at_utc=start, - end_at_utc=end, - expected_attendance=raw.get("phq_attendance"), - impact_rank=raw.get("rank"), - local_rank=raw.get("local_rank"), - source_updated_at=_parse_instant(raw["updated"]) - if raw.get("updated") - else None, - payload=dict(raw), - ) - - -def _parse_instant(value: str) -> datetime: - moment = datetime.fromisoformat(value) - return moment if moment.tzinfo else moment.replace(tzinfo=UTC) - - -def _category(value: str | None) -> EventCategory: - try: - return EventCategory(value) - except ValueError: - return EventCategory.UNKNOWN - - -def _venue(entities: list[Mapping[str, Any]]) -> str | None: - return next((e.get("name") for e in entities if e.get("type") == "venue"), None) - - -def rejection_reason(exc: Exception) -> str: - if isinstance(exc, KeyError): - return f"missing field {exc.args[0]!r}" - return str(exc) diff --git a/backend/foresight/ingest/run.py b/backend/foresight/ingest/run.py deleted file mode 100644 index 67012bd..0000000 --- a/backend/foresight/ingest/run.py +++ /dev/null @@ -1,269 +0,0 @@ -"""Fetch external events and upsert them into the canonical tables. - - python -m foresight.ingest.run --source predicthq --since 7d --hotel-id 1 --report - python -m foresight.ingest.run --source predicthq --since 90d \\ - --lat 53.3498 --lon -6.2603 --city Dublin --report -""" - -from __future__ import annotations - -import argparse -import asyncio -import logging -import re -import sys -from dataclasses import dataclass, field -from datetime import UTC, datetime, timedelta -from pathlib import Path -from typing import TYPE_CHECKING - -from alembic import command -from alembic.config import Config -from sqlalchemy import func, select - -from foresight.config import settings -from foresight.database.models.external import Event, EventObservation -from foresight.database.models.hotel import Hotel -from foresight.database.session import async_session_factory, engine -from foresight.ingest import predicthq -from foresight.ingest.normalize import Source, slugify_city -from foresight.repository import Outcome -from foresight.repository.events import upsert_event - -if TYPE_CHECKING: - from collections.abc import Sequence - - from sqlalchemy.ext.asyncio import AsyncSession - - from foresight.ingest.normalize import NormalizedEvent - from foresight.ingest.predicthq import Rejected - -log = logging.getLogger("foresight.ingest") - -_MIGRATIONS = Path(__file__).resolve().parents[1] / "database" / "migrations" -_SPAN = re.compile(r"^(\d+)([hdw])$") -_UNITS = {"h": "hours", "d": "days", "w": "weeks"} - - -@dataclass(slots=True) -class Market: - city: str - latitude: float - longitude: float - - -@dataclass(slots=True) -class RunReport: - source: Source - market: Market - radius: str - since: datetime - fetched: int = 0 - outcomes: dict[Outcome, int] = field(default_factory=dict) - rejected: list[Rejected] = field(default_factory=list) - events_by_status: dict[str, int] = field(default_factory=dict) - observations_by_source: dict[str, int] = field(default_factory=dict) - events: list[NormalizedEvent] = field(default_factory=list) - dry_run: bool = False - - def count(self, outcome: Outcome) -> int: - return self.outcomes.get(outcome, 0) - - def summary(self) -> str: - if self.dry_run: - return ( - f"fetched={self.fetched} rejected={len(self.rejected)} " - "(dry run, nothing written)" - ) - return ( - f"fetched={self.fetched} new={self.count(Outcome.NEW)} " - f"updated={self.count(Outcome.UPDATED)} " - f"unchanged={self.count(Outcome.UNCHANGED)} " - f"rejected={len(self.rejected)}" - ) - - def render(self) -> str: - m = self.market - rule = " " + "-" * 15 - lines = [ - f"{self.source.value} ingest | {m.city} | " - f"{self.radius} around {m.latitude:.4f}, {m.longitude:.4f}", - f"revised upstream since {self.since:%Y-%m-%d %H:%M} UTC", - "", - f" {'fetched':<11}{self.fetched:>4}", - rule, - *(f" {outcome.value:<11}{self.count(outcome):>4}" for outcome in Outcome), - f" {'rejected':<11}{len(self.rejected):>4}", - "", - ] - if self.dry_run: - lines.append(" nothing written (dry run); fetched events:") - lines += [ - f" {e.start_local_date} .. {e.end_local_date} " - f"{e.city_slug:<12} {e.title}" - for e in self.events - ] - else: - lines += [ - f" {slugify_city(m.city)} now holds " - f"{sum(self.events_by_status.values())} events " - f"({_breakdown(self.events_by_status)})", - f" backed by {sum(self.observations_by_source.values())} " - f"observations ({_breakdown(self.observations_by_source)})", - ] - if self.rejected: - lines += ["", " rejected records:"] - lines += [f" {r.source_ref:<22} {r.reason}" for r in self.rejected] - return "\n".join(lines) - - -def _breakdown(counts: dict[str, int]) -> str: - return ", ".join(f"{n} {k}" for k, n in sorted(counts.items())) or "none" - - -def parse_since(value: str, *, now: datetime | None = None) -> datetime: - """`7d`, `12h`, `2w`, or an ISO date/datetime (UTC if no offset given).""" - if match := _SPAN.match(value.strip().lower()): - amount, unit = int(match[1]), match[2] - return (now or datetime.now(UTC)) - timedelta(**{_UNITS[unit]: amount}) - try: - moment = datetime.fromisoformat(value) - except ValueError: - raise argparse.ArgumentTypeError( - f"expected a span like 7d, 12h, 2w or an ISO date, got {value!r}" - ) from None - return moment if moment.tzinfo else moment.replace(tzinfo=UTC) - - -def _parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser( - prog="python -m foresight.ingest.run", description=__doc__.splitlines()[0] - ) - parser.add_argument("--source", choices=[Source.PREDICTHQ.value], required=True) - parser.add_argument( - "--since", - type=parse_since, - default="7d", - help="only records revised upstream since then (default: 7d)", - ) - parser.add_argument("--hotel-id", type=int, help="use this hotel's stored lat/lon") - parser.add_argument("--lat", type=float, help="market latitude (no --hotel-id)") - parser.add_argument("--lon", type=float, help="market longitude (no --hotel-id)") - parser.add_argument("--city", help="market city (no --hotel-id)") - parser.add_argument("--radius", default="10km", help="e.g. 10km, 5mi") - parser.add_argument("--report", action="store_true", help="print a full run report") - parser.add_argument( - "--dry-run", - action="store_true", - help="fetch and print, without touching the database", - ) - return parser - - -async def resolve_market(session: AsyncSession, args: argparse.Namespace) -> Market: - if args.hotel_id is None: - return Market(city=args.city, latitude=args.lat, longitude=args.lon) - - hotel = await session.get(Hotel, args.hotel_id) - if hotel is None: - raise SystemExit(f"error: no hotel with id {args.hotel_id}") - if hotel.latitude is None or hotel.longitude is None: - raise SystemExit(f"error: hotel {hotel.id} ({hotel.name}) has no lat/lon") - return Market(city=hotel.city, latitude=hotel.latitude, longitude=hotel.longitude) - - -async def market_totals( - session: AsyncSession, city: str -) -> tuple[dict[str, int], dict[str, int]]: - slug = slugify_city(city) - by_status = await session.execute( - select(Event.status, func.count()) - .where(Event.city_slug == slug) - .group_by(Event.status) - ) - by_source = await session.execute( - select(EventObservation.source, func.count()) - .join(Event, EventObservation.event_id == Event.id) - .where(Event.city_slug == slug) - .group_by(EventObservation.source) - ) - return ( - {status.value: n for status, n in by_status.all()}, - {source.value: n for source, n in by_source.all()}, - ) - - -async def run(args: argparse.Namespace) -> RunReport: - async with async_session_factory() as session: - market = await resolve_market(session, args) - report = RunReport( - source=Source(args.source), - market=market, - radius=args.radius, - since=args.since, - dry_run=args.dry_run, - ) - - result = await predicthq.fetch_events( - token=settings.predicthq_token, - latitude=market.latitude, - longitude=market.longitude, - city=market.city, - updated_since=args.since, - radius=args.radius, - ) - report.fetched = result.fetched - report.rejected = result.rejected - report.events = result.events - if args.dry_run: - return report - - for event in result.events: - outcome = await upsert_event(session, event) - report.outcomes[outcome] = report.outcomes.get(outcome, 0) + 1 - await session.commit() - - report.events_by_status, report.observations_by_source = await market_totals( - session, market.city - ) - return report - - -def migrate() -> None: - """Bring the schema up to date, so a fresh pull needs no manual step. - - A no-op when already at head. No ini file is passed, so Alembic leaves this - process's logging alone. - """ - config = Config() - config.set_main_option("script_location", str(_MIGRATIONS)) - command.upgrade(config, "head") - - -async def _main(args: argparse.Namespace) -> None: - try: - report = await run(args) - finally: - await engine.dispose() - print(report.render() if args.report else report.summary()) - - -def main(argv: Sequence[str] | None = None) -> int: - parser = _parser() - args = parser.parse_args(argv) - if args.hotel_id is None and None in (args.lat, args.lon, args.city): - parser.error("give --hotel-id, or all of --lat, --lon and --city") - if args.source == Source.PREDICTHQ and not settings.predicthq_token: - parser.error("PREDICTHQ_TOKEN is not set; add it to .env") - - logging.basicConfig(level=logging.WARNING, format="%(levelname)s %(message)s") - if not (args.dry_run and args.hotel_id is None): - migrate() - # DEBUG=true turns on SQL echo, which would bury the report. - engine.echo = False - asyncio.run(_main(args)) - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/backend/foresight/ingest/weather.py b/backend/foresight/ingest/weather.py deleted file mode 100644 index 66d9003..0000000 --- a/backend/foresight/ingest/weather.py +++ /dev/null @@ -1,170 +0,0 @@ -from __future__ import annotations - -from datetime import timedelta -from typing import TYPE_CHECKING, Any - -import openmeteo_requests -import pandas as pd -import requests_cache -from retry_requests import retry - -if TYPE_CHECKING: - from collections.abc import Iterator - from datetime import date - -FORECAST_URL = "https://api.open-meteo.com/v1/forecast" -ARCHIVE_URL = "https://archive-api.open-meteo.com/v1/archive" -SOURCE = "open-meteo" -# A year per archive request keeps each call small; a failure only redoes that year. -CHUNK_DAYS = 365 - -# Order matters: the response exposes variables by index, not by name. -_DAILY = ( - "temperature_2m_max", - "temperature_2m_min", - "precipitation_sum", - "wind_speed_10m_max", - "weather_code", -) - - -_client: openmeteo_requests.Client | None = None - - -def _get_client() -> openmeteo_requests.Client: - """The shared Open-Meteo client, created on first use. - - Creating it opens a requests-cache file, so importing this module must not. - """ - global _client - if _client is None: - session = retry( - requests_cache.CachedSession(".cache", expire_after=3600), - retries=5, - backoff_factor=0.2, - ) - _client = openmeteo_requests.Client(session=session) # type: ignore[arg-type] - return _client - - -def _condition(code: float | None) -> str | None: - """A WMO 4677 code as the short label `condition` carries.""" - if code is None: - return None - code = int(code) - if code == 0: - return "clear" - if code <= 2: - return "partly cloudy" - if code == 3: - return "cloudy" - if code in (45, 48): - return "fog" - if code <= 57: - return "drizzle" - if code <= 67: - return "rain" - if code <= 77: - return "snow" - if code <= 82: - return "rain showers" - if code <= 86: - return "snow showers" - return "thunderstorm" - - -def _number(value: Any) -> float | None: - """Open-Meteo pads missing days with NaN, which Postgres cannot store.""" - return None if pd.isna(value) else float(value) - - -def _rows(response: Any, city: str, *, is_forecast: bool) -> list[dict[str, Any]]: - daily = response.Daily() - columns = [daily.Variables(i).ValuesAsNumpy() for i in range(len(_DAILY))] - # Time() is the UTC instant of local midnight, so east of Greenwich it - # falls on the previous calendar day; the offset recovers the local day. - offset = response.UtcOffsetSeconds() - dates = pd.date_range( - start=pd.to_datetime(daily.Time() + offset, unit="s", utc=True), - end=pd.to_datetime(daily.TimeEnd() + offset, unit="s", utc=True), - freq=pd.Timedelta(seconds=daily.Interval()), - inclusive="left", - ).date - - temp_max, temp_min, precip, wind, code = columns - return [ - { - "city": city, - "weather_date": day, - "temp_max_c": _number(temp_max[i]), - "temp_min_c": _number(temp_min[i]), - "precip_mm": _number(precip[i]), - "wind_kph": _number(wind[i]), - "condition": _condition(_number(code[i])), - "is_forecast": is_forecast, - "source": SOURCE, - } - for i, day in enumerate(dates) - ] - - -def fetch_forecast( - city: str, - latitude: float, - longitude: float, - timezone: str, - *, - days: int = 16, -) -> list[dict[str, Any]]: - """The next `days` of forecast for one city, as `weather` rows.""" - response = _get_client().weather_api( - FORECAST_URL, - params={ - "latitude": latitude, - "longitude": longitude, - "daily": list(_DAILY), - "timezone": timezone, - "forecast_days": days, - }, - )[0] - return _rows(response, city, is_forecast=True) - - -def fetch_actuals( - city: str, - latitude: float, - longitude: float, - timezone: str, - *, - start: date, - end: date, -) -> list[dict[str, Any]]: - """Recorded weather for `start`..`end` inclusive, as `weather` rows. - - The archive covers 1940-01-01 up to today and rejects anything beyond it - outright, so a caller backfilling a date range has to clamp `end` to - today rather than relying on empty rows coming back. - """ - rows: list[dict[str, Any]] = [] - for chunk_start, chunk_end in chunks(start, end): - response = _get_client().weather_api( - ARCHIVE_URL, - params={ - "latitude": latitude, - "longitude": longitude, - "daily": list(_DAILY), - "timezone": timezone, - "start_date": chunk_start.isoformat(), - "end_date": chunk_end.isoformat(), - }, - )[0] - rows += _rows(response, city, is_forecast=False) - return rows - - -def chunks(start: date, end: date) -> Iterator[tuple[date, date]]: - """Split `start`..`end` into back-to-back spans of at most CHUNK_DAYS days.""" - while start <= end: - chunk_end = min(start + timedelta(days=CHUNK_DAYS - 1), end) - yield start, chunk_end - start = chunk_end + timedelta(days=1) diff --git a/backend/foresight/main.py b/backend/foresight/main.py index 5db795a..45d5d2e 100644 --- a/backend/foresight/main.py +++ b/backend/foresight/main.py @@ -1,8 +1,34 @@ +"""App setup: routers, error handling, lifespan, and the API reference.""" + +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + from fastapi import FastAPI from scalar_fastapi import Theme, get_scalar_api_reference +from foresight import worker from foresight.config import settings -from foresight.routes import example_widget, health, hotels, signals +from foresight.routes import ( + city, + event, + example_widget, + flight, + health, + hotel, + ingest, + job, + pricing_model, + weather, +) +from foresight.routes.errors import register_error_handlers + + +@asynccontextmanager +async def lifespan(_: FastAPI) -> AsyncIterator[None]: + # Background jobs die with the process; anything left running is stale. + await worker.fail_interrupted_jobs() + yield + app = FastAPI( title=settings.app_name, @@ -10,11 +36,20 @@ version="0.1.0", # Use Scalar for the API reference instead of the built-in Swagger UI. docs_url=None, + lifespan=lifespan, ) +register_error_handlers(app) + app.include_router(health.router) -app.include_router(hotels.router) -app.include_router(signals.router) +app.include_router(city.router) +app.include_router(hotel.router) +app.include_router(weather.router) +app.include_router(event.router) +app.include_router(flight.router) +app.include_router(ingest.router) +app.include_router(job.router) +app.include_router(pricing_model.router) app.include_router(example_widget.router) diff --git a/backend/foresight/repository/__init__.py b/backend/foresight/repository/__init__.py index 4580b0d..39fa115 100644 --- a/backend/foresight/repository/__init__.py +++ b/backend/foresight/repository/__init__.py @@ -3,89 +3,7 @@ Encapsulates all database access. Repositories translate between ORM models (`database.models`) and the rest of the application, so services and routes never touch SQLAlchemy sessions directly. -""" - -from __future__ import annotations - -from enum import StrEnum -from typing import TYPE_CHECKING, Any - -from sqlalchemy import func, literal_column, or_ -from sqlalchemy.dialects.postgresql import insert - -if TYPE_CHECKING: - from collections.abc import Iterable, Mapping, Sequence - - from sqlalchemy import Table - from sqlalchemy.ext.asyncio import AsyncSession - -# Postgres allows at most 32,767 bind parameters in one statement. -_MAX_PARAMS = 32_767 - -class Outcome(StrEnum): - NEW = "new" - UPDATED = "updated" - UNCHANGED = "unchanged" - - -async def upsert_rows( - session: AsyncSession, - table: Table, - *, - constraint: str, - key: Sequence[str], - mutable: Sequence[str], - rows: Iterable[Mapping[str, Any]], -) -> dict[Outcome, int]: - """Upsert `rows` on `constraint`, touching a row only when a value changed. - - Does not commit; the caller owns the transaction. `key` must name the - columns `constraint` covers: Postgres refuses to update a row twice in one - statement, so repeats within a batch are collapsed to the last one first. - """ - counts = dict.fromkeys(Outcome, 0) - rows = list({tuple(r[c] for c in key): r for r in rows}.values()) - if not rows: - return counts - - # One statement per batch, sized to stay under the parameter limit. - batch = max(1, _MAX_PARAMS // len(rows[0])) - for i in range(0, len(rows), batch): - for outcome, n in ( - await _upsert_batch( - session, table, constraint, mutable, rows[i : i + batch] - ) - ).items(): - counts[outcome] += n - return counts - - -async def _upsert_batch( - session: AsyncSession, - table: Table, - constraint: str, - mutable: Sequence[str], - rows: list[Mapping[str, Any]], -) -> dict[Outcome, int]: - counts = dict.fromkeys(Outcome, 0) - stmt = insert(table).values(rows) - excluded = stmt.excluded - set_: dict[str, Any] = {c: excluded[c] for c in mutable} - # Only the external-signal tables record when a row was last fetched. - if "captured_at" in table.c: - set_["captured_at"] = func.clock_timestamp() - stmt = stmt.on_conflict_do_update( - constraint=constraint, - set_=set_, - # An unchanged row fails this, so it is never returned below. - where=or_(*(table.c[c].is_distinct_from(excluded[c]) for c in mutable)), - # xmax is 0 only on a freshly inserted tuple, never on a conflict update. - ).returning(literal_column("(xmax = 0)").label("inserted")) - - for inserted in (await session.execute(stmt)).scalars(): - counts[Outcome.NEW if inserted else Outcome.UPDATED] += 1 - counts[Outcome.UNCHANGED] = ( - len(rows) - counts[Outcome.NEW] - counts[Outcome.UPDATED] - ) - return counts +One module per model file (`database/models/X.py` <-> `repository/X.py`), plus +shared helpers: `upsert.py` and `pagination.py`. +""" diff --git a/backend/foresight/repository/city.py b/backend/foresight/repository/city.py new file mode 100644 index 0000000..8efeb93 --- /dev/null +++ b/backend/foresight/repository/city.py @@ -0,0 +1,55 @@ +"""Data access for `cities`. + +`create_city` and `update_city` return None when the slug would duplicate +another city's. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from sqlalchemy import select, update +from sqlalchemy.dialects.postgresql import insert +from sqlalchemy.exc import IntegrityError + +from foresight.database.models.city import City + +if TYPE_CHECKING: + from collections.abc import Mapping, Sequence + + from sqlalchemy.ext.asyncio import AsyncSession + + +async def create_city(session: AsyncSession, **values: Any) -> City | None: + return await session.scalar( + insert(City) + .values(**values) + .on_conflict_do_nothing(constraint="uq_cities_slug") + .returning(City) + ) + + +async def get_city(session: AsyncSession, city_id: int) -> City | None: + return await session.get(City, city_id) + + +async def list_cities(session: AsyncSession) -> Sequence[City]: + return (await session.scalars(select(City).order_by(City.name))).all() + + +async def update_city( + session: AsyncSession, city_id: int, changes: Mapping[str, Any] +) -> City | None: + """Apply `changes` in a savepoint, so a rejected rename leaves the caller's + transaction usable.""" + try: + async with session.begin_nested(): + return await session.scalar( + update(City) + .where(City.id == city_id) + .values(**changes) + .returning(City) + .execution_options(populate_existing=True) + ) + except IntegrityError: + return None diff --git a/backend/foresight/repository/competitor_rates.py b/backend/foresight/repository/competitor_rates.py deleted file mode 100644 index fc4b414..0000000 --- a/backend/foresight/repository/competitor_rates.py +++ /dev/null @@ -1,29 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING, Any, cast - -from foresight.database.models.external import CompetitorRate -from foresight.repository import Outcome, upsert_rows - -if TYPE_CHECKING: - from collections.abc import Iterable, Mapping - - from sqlalchemy import Table - from sqlalchemy.ext.asyncio import AsyncSession - -_KEY = ("hotel_name", "city", "stay_date", "source") -_MEASURES = ("rate", "is_sold_out") - - -async def upsert_competitor_rates( - session: AsyncSession, rows: Iterable[Mapping[str, Any]] -) -> dict[Outcome, int]: - """Write rows keyed on (hotel_name, city, stay_date, source).""" - return await upsert_rows( - session, - cast("Table", CompetitorRate.__table__), - constraint="uq_competitor_rates_hotel_date_source", - key=_KEY, - mutable=_MEASURES, - rows=rows, - ) diff --git a/backend/foresight/repository/events.py b/backend/foresight/repository/events.py deleted file mode 100644 index e0cc9ff..0000000 --- a/backend/foresight/repository/events.py +++ /dev/null @@ -1,198 +0,0 @@ -"""Persist NormalizedEvents onto the canonical `events` row and its observations.""" - -from __future__ import annotations - -from datetime import UTC, datetime -from typing import TYPE_CHECKING, Any - -from sqlalchemy import and_, delete, func, literal_column, not_, or_, select, update -from sqlalchemy.dialects.postgresql import insert - -from foresight.database.models.external import Event, EventObservation -from foresight.ingest.normalize import EventStatus, Source -from foresight.repository import Outcome - -if TYPE_CHECKING: - from collections.abc import Mapping - - from sqlalchemy.ext.asyncio import AsyncSession - - from foresight.ingest.normalize import NormalizedEvent - -_EVENTS = Event.__table__ -_OBSERVATIONS = EventObservation.__table__ - -# Structured listings outrank dates and venues extracted from prose. -_PRECEDENCE = {Source.PREDICTHQ: 2, Source.ASKNEWS: 1} -_WITHDRAWN = {EventStatus.DELETED, EventStatus.CANCELLED} -_NEVER = datetime.min.replace(tzinfo=UTC) - - -async def upsert_event(session: AsyncSession, event: NormalizedEvent) -> Outcome: - """Resolve `event` to its canonical row, write it, and record the sighting. - - Does not commit; the caller owns the transaction. - """ - row = event.as_row() - event_id = await session.scalar( - select(_OBSERVATIONS.c.event_id).where( - _OBSERVATIONS.c.source == event.source, - _OBSERVATIONS.c.source_ref == event.source_ref, - ) - ) - - # Source identity must be checked before dedupe_key: a revised start date - # changes the key, and resolving by key first would orphan the old row. - if event_id is not None: - event_id = await _absorb_key_collision(session, event_id, row["dedupe_key"]) - outcome = await _apply(session, event_id, event) - else: - event_id, inserted = await _insert_or_find(session, row) - outcome = Outcome.NEW if inserted else await _apply(session, event_id, event) - - await _record_observation(session, event, event_id) - return outcome - - -async def _absorb_key_collision( - session: AsyncSession, event_id: int, new_key: str -) -> int: - """If a revision moved onto a key another sighting already holds, merge.""" - other_id = await session.scalar( - select(_EVENTS.c.id).where( - _EVENTS.c.dedupe_key == new_key, _EVENTS.c.id != event_id - ) - ) - if other_id is None: - return event_id - - await session.execute( - update(_OBSERVATIONS) - .where(_OBSERVATIONS.c.event_id == event_id) - .values(event_id=other_id) - ) - await session.execute(delete(_EVENTS).where(_EVENTS.c.id == event_id)) - return other_id - - -async def _insert_or_find( - session: AsyncSession, row: Mapping[str, Any] -) -> tuple[int, bool]: - stmt = ( - insert(_EVENTS) - .values(**row) - .on_conflict_do_update( - index_elements=[_EVENTS.c.dedupe_key], - set_={"last_seen_at": func.clock_timestamp()}, - ) - # xmax is 0 only on a freshly inserted tuple, never on a conflict update. - .returning(_EVENTS.c.id, literal_column("(xmax = 0)").label("inserted")) - ) - result = (await session.execute(stmt)).one() - return result.id, result.inserted - - -async def _apply( - session: AsyncSession, event_id: int, event: NormalizedEvent -) -> Outcome: - row = event.as_row() - current = ( - ( - await session.execute( - select(_EVENTS).where(_EVENTS.c.id == event_id).with_for_update() - ) - ) - .mappings() - .one() - ) - - changes: dict[str, Any] = {} - if await _outranks_other_sightings(session, event_id, event): - changes = {k: v for k, v in row.items() if current[k] != v} - - await session.execute( - update(_EVENTS) - .where(_EVENTS.c.id == event_id) - .values(**changes, last_seen_at=func.clock_timestamp()) - ) - return Outcome.UPDATED if changes else Outcome.UNCHANGED - - -async def _outranks_other_sightings( - session: AsyncSession, event_id: int, event: NormalizedEvent -) -> bool: - """Whether `event` is the sighting whose values the row should carry. - - Every sighting of a row is ranked the same way, so reruns settle on one - winner instead of duplicate listings overwriting each other in turn. A - deleted duplicate thus never takes down the live listing it shares a row - with, on the first run or any later one. - """ - others = await session.execute( - select( - _OBSERVATIONS.c.source, - _OBSERVATIONS.c.status, - _OBSERVATIONS.c.source_updated_at, - _OBSERVATIONS.c.source_ref, - ).where( - _OBSERVATIONS.c.event_id == event_id, - not_( - and_( - _OBSERVATIONS.c.source == event.source, - _OBSERVATIONS.c.source_ref == event.source_ref, - ) - ), - ) - ) - mine = _rank(event.source, event.status, event.source_updated_at, event.source_ref) - return all(mine > _rank(*other) for other in others) - - -def _rank( - source: Source, - status: EventStatus | None, - updated_at: datetime | None, - source_ref: str, -) -> tuple[int, bool, datetime, str]: - """Structured sources first, then live listings, then the latest revision. - - source_ref only breaks exact ties, so the winner is stable across reruns. - """ - return ( - _PRECEDENCE[Source(source)], - status not in _WITHDRAWN, - updated_at or _NEVER, - source_ref, - ) - - -async def _record_observation( - session: AsyncSession, event: NormalizedEvent, event_id: int -) -> None: - stmt = insert(_OBSERVATIONS).values( - event_id=event_id, - source=event.source, - source_ref=event.source_ref, - source_updated_at=event.source_updated_at, - status=event.status, - payload=event.payload, - ) - excluded = stmt.excluded - await session.execute( - stmt.on_conflict_do_update( - index_elements=[_OBSERVATIONS.c.source, _OBSERVATIONS.c.source_ref], - set_={ - "event_id": excluded.event_id, - "source_updated_at": excluded.source_updated_at, - "status": excluded.status, - "payload": excluded.payload, - "captured_at": func.clock_timestamp(), - }, - # captured_at marks when this payload version arrived, not every rerun. - where=or_( - _OBSERVATIONS.c.payload.is_distinct_from(excluded.payload), - _OBSERVATIONS.c.event_id.is_distinct_from(excluded.event_id), - _OBSERVATIONS.c.status.is_distinct_from(excluded.status), - ), - ) - ) diff --git a/backend/foresight/repository/example_widget.py b/backend/foresight/repository/example_widget.py index 64cf406..9913cec 100644 --- a/backend/foresight/repository/example_widget.py +++ b/backend/foresight/repository/example_widget.py @@ -13,12 +13,19 @@ - No business logic lives here — just queries and persistence. """ -from collections.abc import Sequence +from __future__ import annotations + +from typing import TYPE_CHECKING from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession from foresight.database.models.example_widget import Widget +from foresight.repository.pagination import fetch_page + +if TYPE_CHECKING: + from collections.abc import Sequence + + from sqlalchemy.ext.asyncio import AsyncSession async def create_widget( @@ -39,6 +46,10 @@ async def get_widget(session: AsyncSession, widget_id: int) -> Widget | None: return await session.get(Widget, widget_id) -async def list_widgets(session: AsyncSession) -> Sequence[Widget]: - result = await session.execute(select(Widget).order_by(Widget.id)) - return result.scalars().all() +async def list_widgets( + session: AsyncSession, *, limit: int, offset: int +) -> tuple[Sequence[Widget], int]: + """One page, ordered so pages are stable, plus the unpaged total.""" + return await fetch_page( + session, select(Widget).order_by(Widget.id), limit=limit, offset=offset + ) diff --git a/backend/foresight/repository/flights.py b/backend/foresight/repository/flights.py deleted file mode 100644 index f2556ec..0000000 --- a/backend/foresight/repository/flights.py +++ /dev/null @@ -1,32 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING, Any, cast - -from foresight.database.models.external import FlightArrival -from foresight.repository import Outcome, upsert_rows - -if TYPE_CHECKING: - from collections.abc import Iterable, Mapping - - from sqlalchemy import Table - from sqlalchemy.ext.asyncio import AsyncSession - -_KEY = ("airport", "arrival_date", "source") -_MEASURES = ("flight_count", "avg_fare_eur") - - -async def upsert_flight_arrivals( - session: AsyncSession, rows: Iterable[Mapping[str, Any]] -) -> dict[Outcome, int]: - """Write rows keyed on (airport, arrival_date, source). - - Each source keeps its own row; which one wins is a read-side decision. - """ - return await upsert_rows( - session, - cast("Table", FlightArrival.__table__), - constraint="uq_flight_arrivals_airport_date_source", - key=_KEY, - mutable=_MEASURES, - rows=rows, - ) diff --git a/backend/foresight/repository/hotel.py b/backend/foresight/repository/hotel.py index 323701b..42f99d9 100644 --- a/backend/foresight/repository/hotel.py +++ b/backend/foresight/repository/hotel.py @@ -1,20 +1,27 @@ +"""Data access for `hotels`, `room_types` and `daily_performance`. + +`create_*` returns None on a duplicate (API writes); `upsert_*` is idempotent +(loaders). Hotels are always returned with their `city` loaded. +""" + from __future__ import annotations -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any -from sqlalchemy import func, select +from sqlalchemy import func, select, update from sqlalchemy.dialects.postgresql import insert +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import joinedload from foresight.database.models.hotel import DailyPerformance, Hotel, RoomType -from foresight.repository import Outcome, upsert_rows from foresight.repository.pagination import fetch_page +from foresight.repository.upsert import Outcome, upsert_rows if TYPE_CHECKING: from collections.abc import Iterable, Mapping, Sequence from datetime import date from decimal import Decimal - from sqlalchemy import Table from sqlalchemy.ext.asyncio import AsyncSession _PERF_KEY = ("room_type_id", "stay_date") @@ -22,37 +29,30 @@ async def get_hotel(session: AsyncSession, hotel_id: int) -> Hotel | None: - return await session.get(Hotel, hotel_id) + return await session.scalar( + select(Hotel) + .options(joinedload(Hotel.city)) + .where(Hotel.id == hotel_id) + .execution_options(populate_existing=True) + ) -async def upsert_hotel( - session: AsyncSession, - *, - name: str, - city: str, - total_rooms: int, - latitude: float | None = None, - longitude: float | None = None, -) -> int: - """Return the id of the hotel named `name` in `city`, creating it if new.""" - stmt = insert(Hotel).values( - name=name, - city=city, - total_rooms=total_rooms, - latitude=latitude, - longitude=longitude, - ) - stmt = stmt.on_conflict_do_update( - constraint="uq_hotels_name_city", - set_={ - "total_rooms": stmt.excluded.total_rooms, - # Coordinates are optional, so a caller that omits them must not - # erase what another caller already stored. - "latitude": func.coalesce(stmt.excluded.latitude, Hotel.latitude), - "longitude": func.coalesce(stmt.excluded.longitude, Hotel.longitude), - }, - ).returning(Hotel.id) - return (await session.execute(stmt)).scalar_one() +async def update_hotel( + session: AsyncSession, hotel_id: int, changes: Mapping[str, Any] +) -> Hotel | None: + """Apply `changes`; None if they'd duplicate another hotel's name and city. + + Runs in a savepoint so a rejected rename leaves the caller's transaction + usable. The caller checks a new `city_id` exists. + """ + try: + async with session.begin_nested(): + await session.execute( + update(Hotel).where(Hotel.id == hotel_id).values(**changes) + ) + except IntegrityError: + return None + return await get_hotel(session, hotel_id) async def upsert_room_type( @@ -67,11 +67,11 @@ async def upsert_room_type( hotel_id=hotel_id, name=name, room_count=room_count, base_rate=base_rate ) stmt = stmt.on_conflict_do_update( - constraint="uq_room_types_hotel_name", + constraint="uq_room_types_hotel_id_name", set_={ "room_count": stmt.excluded.room_count, - # The pricing model scales off base_rate, so a reload correcting it - # has to win rather than be silently dropped. + # base_rate is the hotel's stated normal price (the export has none), + # so a reload correcting it has to win rather than be dropped. "base_rate": stmt.excluded.base_rate, }, ).returning(RoomType.id) @@ -84,8 +84,8 @@ async def upsert_daily_performance( """Write rows keyed on (room_type_id, stay_date).""" return await upsert_rows( session, - cast("Table", DailyPerformance.__table__), - constraint="uq_daily_performance_room_type_stay", + DailyPerformance, + constraint="uq_daily_performance_room_type_id_stay_date", key=_PERF_KEY, mutable=_PERF_MEASURES, rows=rows, @@ -96,22 +96,28 @@ async def create_hotel( session: AsyncSession, *, name: str, - city: str, + city_id: int, total_rooms: int, latitude: float | None, longitude: float | None, -) -> Hotel: - hotel = Hotel( - name=name, - city=city, - total_rooms=total_rooms, - latitude=latitude, - longitude=longitude, +) -> Hotel | None: + """Insert a hotel; None if one with this name already exists in the city. + + The caller checks `city_id` exists. + """ + hotel_id = await session.scalar( + insert(Hotel) + .values( + name=name, + city_id=city_id, + total_rooms=total_rooms, + latitude=latitude, + longitude=longitude, + ) + .on_conflict_do_nothing(constraint="uq_hotels_city_id_name") + .returning(Hotel.id) ) - session.add(hotel) - await session.flush() - await session.refresh(hotel) - return hotel + return None if hotel_id is None else await get_hotel(session, hotel_id) async def create_room_type( @@ -121,33 +127,30 @@ async def create_room_type( name: str, room_count: int, base_rate: Decimal, -) -> RoomType: - room_type = RoomType( - hotel_id=hotel_id, name=name, room_count=room_count, base_rate=base_rate +) -> RoomType | None: + """Insert a room type; None if the hotel already has one with this name.""" + return await session.scalar( + insert(RoomType) + .values( + hotel_id=hotel_id, name=name, room_count=room_count, base_rate=base_rate + ) + .on_conflict_do_nothing(constraint="uq_room_types_hotel_id_name") + .returning(RoomType) ) - session.add(room_type) - await session.flush() - await session.refresh(room_type) - return room_type -async def list_hotels( - session: AsyncSession, *, limit: int, offset: int -) -> tuple[Sequence[Hotel], int]: - return await fetch_page( - session, select(Hotel).order_by(Hotel.id), limit=limit, offset=offset - ) +async def list_hotels(session: AsyncSession) -> Sequence[Hotel]: + stmt = select(Hotel).options(joinedload(Hotel.city)).order_by(Hotel.id) + return (await session.scalars(stmt)).all() async def get_room_type(session: AsyncSession, room_type_id: int) -> RoomType | None: return await session.get(RoomType, room_type_id) -async def list_room_types( - session: AsyncSession, hotel_id: int, *, limit: int, offset: int -) -> tuple[Sequence[RoomType], int]: +async def list_room_types(session: AsyncSession, hotel_id: int) -> Sequence[RoomType]: stmt = select(RoomType).where(RoomType.hotel_id == hotel_id).order_by(RoomType.id) - return await fetch_page(session, stmt, limit=limit, offset=offset) + return (await session.scalars(stmt)).all() async def list_performance( @@ -191,3 +194,31 @@ async def stay_date_range( ) ).one() return None if first is None else (first, last) + + +async def training_nights( + session: AsyncSession, hotel_id: int, *, start: date | None, end: date | None +) -> Sequence[Mapping[str, Any]]: + """Every night of every room type, oldest first, for training. + + Rows carry stay_date, room_type (its name), rate, rooms_sold and + rooms_available. Nights with no or a zero rate are included; the caller + decides what to drop. + """ + stmt = ( + select( + DailyPerformance.stay_date, + RoomType.name.label("room_type"), + DailyPerformance.rate, + DailyPerformance.rooms_sold, + DailyPerformance.rooms_available, + ) + .join(RoomType, DailyPerformance.room_type_id == RoomType.id) + .where(RoomType.hotel_id == hotel_id) + ) + if start is not None: + stmt = stmt.where(DailyPerformance.stay_date >= start) + if end is not None: + stmt = stmt.where(DailyPerformance.stay_date <= end) + stmt = stmt.order_by(DailyPerformance.stay_date, RoomType.id) + return (await session.execute(stmt)).mappings().all() diff --git a/backend/foresight/repository/job.py b/backend/foresight/repository/job.py new file mode 100644 index 0000000..b06ecfc --- /dev/null +++ b/backend/foresight/repository/job.py @@ -0,0 +1,85 @@ +"""Data access for `jobs`.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from sqlalchemy import func, select, update + +from foresight.database.enums import JobStatus +from foresight.database.models.job import Job +from foresight.repository.pagination import fetch_page + +if TYPE_CHECKING: + from collections.abc import Sequence + from datetime import datetime + + from sqlalchemy.ext.asyncio import AsyncSession + + from foresight.database.enums import JobKind + +_UNFINISHED = (JobStatus.QUEUED, JobStatus.RUNNING) + + +async def create_job( + session: AsyncSession, *, hotel_id: int, kind: JobKind, params: dict[str, Any] +) -> Job: + job = Job(hotel_id=hotel_id, kind=kind, params=params, progress={}) + session.add(job) + await session.flush() + await session.refresh(job) + return job + + +async def get_job(session: AsyncSession, job_id: int) -> Job | None: + return await session.get(Job, job_id) + + +async def list_jobs( + session: AsyncSession, hotel_id: int, *, limit: int, offset: int +) -> tuple[Sequence[Job], int]: + """Newest first.""" + stmt = select(Job).where(Job.hotel_id == hotel_id).order_by(Job.id.desc()) + return await fetch_page(session, stmt, limit=limit, offset=offset) + + +async def update_job(session: AsyncSession, job_id: int, **values: Any) -> None: + await session.execute(update(Job).where(Job.id == job_id).values(**values)) + + +async def fail_unfinished_jobs(session: AsyncSession, error: str) -> int: + """Mark every queued or running job failed; returns how many.""" + result = await session.execute( + update(Job) + .where(Job.status.in_(_UNFINISHED)) + .values(status=JobStatus.FAILED, error=error, finished_at=func.now()) + ) + return result.rowcount + + +async def find_unfinished_job( + session: AsyncSession, *, hotel_id: int, kind: JobKind +) -> Job | None: + """A queued or running job of this kind for the hotel, if any.""" + return await session.scalar( + select(Job).where( + Job.hotel_id == hotel_id, + Job.kind == kind, + Job.status.in_(_UNFINISHED), + ) + ) + + +async def last_succeeded_start( + session: AsyncSession, *, hotel_id: int, kind: JobKind, params: dict[str, Any] +) -> datetime | None: + """When the newest succeeded job of this kind whose params contain + `params` started, if any.""" + return await session.scalar( + select(func.max(Job.started_at)).where( + Job.hotel_id == hotel_id, + Job.kind == kind, + Job.status == JobStatus.SUCCEEDED, + Job.params.contains(params), + ) + ) diff --git a/backend/foresight/repository/pagination.py b/backend/foresight/repository/pagination.py index cd08a4a..b98b35d 100644 --- a/backend/foresight/repository/pagination.py +++ b/backend/foresight/repository/pagination.py @@ -1,8 +1,15 @@ -from collections.abc import Sequence -from typing import Any +"""Offset pagination shared by every `list_*` function.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any from sqlalchemy import Select, func, select -from sqlalchemy.ext.asyncio import AsyncSession + +if TYPE_CHECKING: + from collections.abc import Sequence + + from sqlalchemy.ext.asyncio import AsyncSession async def fetch_page[T]( diff --git a/backend/foresight/repository/pricing_model.py b/backend/foresight/repository/pricing_model.py new file mode 100644 index 0000000..148a18c --- /dev/null +++ b/backend/foresight/repository/pricing_model.py @@ -0,0 +1,126 @@ +"""Data access for `pricing_models`: trained model versions per hotel.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from sqlalchemy import func, select, update +from sqlalchemy.orm import defer + +from foresight.database.models.pricing_model import PricingModel +from foresight.repository.pagination import fetch_page + +if TYPE_CHECKING: + from collections.abc import Sequence + from datetime import date + + from sqlalchemy.ext.asyncio import AsyncSession + + +async def create_pricing_model( + session: AsyncSession, + *, + hotel_id: int, + algorithm: str, + description: str | None, + artifact: bytes, + features: list[str], + metrics: dict[str, Any], + lib_versions: dict[str, str], + data_start: date, + data_end: date, +) -> PricingModel: + """Save a new, inactive model as the hotel's next version. + + Call `activate_pricing_model` afterwards to start serving it. + """ + model = PricingModel( + hotel_id=hotel_id, + version=await _next_version(session, hotel_id), + algorithm=algorithm, + description=description, + artifact=artifact, + features=features, + metrics=metrics, + lib_versions=lib_versions, + data_start=data_start, + data_end=data_end, + is_active=False, + ) + session.add(model) + await session.flush() + await session.refresh(model) + return model + + +async def get_pricing_model( + session: AsyncSession, model_id: int, *, with_artifact: bool = False +) -> PricingModel | None: + """One model. The artifact (megabytes) is only loaded if asked for.""" + stmt = select(PricingModel).where(PricingModel.id == model_id) + if not with_artifact: + stmt = stmt.options(defer(PricingModel.artifact)) + return await session.scalar(stmt) + + +async def list_pricing_models( + session: AsyncSession, hotel_id: int, *, limit: int, offset: int +) -> tuple[Sequence[PricingModel], int]: + """The hotel's models, newest version first, without their artifacts.""" + stmt = ( + select(PricingModel) + .options(defer(PricingModel.artifact)) + .where(PricingModel.hotel_id == hotel_id) + .order_by(PricingModel.version.desc()) + ) + return await fetch_page(session, stmt, limit=limit, offset=offset) + + +async def get_active_pricing_model( + session: AsyncSession, hotel_id: int +) -> PricingModel | None: + """The model currently serving the hotel, if any, artifact included.""" + return await session.scalar( + select(PricingModel).where( + PricingModel.hotel_id == hotel_id, PricingModel.is_active + ) + ) + + +async def activate_pricing_model( + session: AsyncSession, model_id: int +) -> PricingModel | None: + """Make this model the hotel's active one, deactivating any other. + + Both writes land in the caller's transaction, so once it commits the hotel + is never left with zero or two active models. Returns None if the model + doesn't exist. + """ + model = await get_pricing_model(session, model_id) + if model is None: + return None + + # Deactivate first: the partial unique index allows one active row. + await session.execute( + update(PricingModel) + .where( + PricingModel.hotel_id == model.hotel_id, + PricingModel.is_active, + PricingModel.id != model.id, + ) + .values(is_active=False) + ) + await session.flush() + model.is_active = True + await session.flush() + await session.refresh(model) + return model + + +async def _next_version(session: AsyncSession, hotel_id: int) -> int: + current = await session.scalar( + select(func.coalesce(func.max(PricingModel.version), 0)).where( + PricingModel.hotel_id == hotel_id + ) + ) + return (current or 0) + 1 diff --git a/backend/foresight/repository/signals.py b/backend/foresight/repository/signals.py deleted file mode 100644 index d4cf2d4..0000000 --- a/backend/foresight/repository/signals.py +++ /dev/null @@ -1,68 +0,0 @@ -from collections.abc import Collection, Sequence -from datetime import date - -from sqlalchemy import func, select -from sqlalchemy.ext.asyncio import AsyncSession - -from foresight.database.models.external import Event, FlightArrival, Weather -from foresight.ingest.normalize import EventStatus -from foresight.repository.pagination import fetch_page - - -async def list_weather( - session: AsyncSession, - *, - city: str, - start: date | None, - end: date | None, - limit: int, - offset: int, -) -> tuple[Sequence[Weather], int]: - # `weather.city` is free text, so compare without regard to case. - stmt = select(Weather).where(func.lower(Weather.city) == city.lower()) - if start is not None: - stmt = stmt.where(Weather.weather_date >= start) - if end is not None: - stmt = stmt.where(Weather.weather_date <= end) - stmt = stmt.order_by(Weather.weather_date, Weather.id) - return await fetch_page(session, stmt, limit=limit, offset=offset) - - -async def list_events( - session: AsyncSession, - *, - city_slug: str, - start: date | None, - end: date | None, - exclude_statuses: Collection[EventStatus], - limit: int, - offset: int, -) -> tuple[Sequence[Event], int]: - """Events whose inclusive local-date range overlaps [start, end].""" - stmt = select(Event).where(Event.city_slug == city_slug) - if exclude_statuses: - stmt = stmt.where(Event.status.not_in(exclude_statuses)) - if start is not None: - stmt = stmt.where(Event.end_local_date >= start) - if end is not None: - stmt = stmt.where(Event.start_local_date <= end) - stmt = stmt.order_by(Event.start_local_date, Event.id) - return await fetch_page(session, stmt, limit=limit, offset=offset) - - -async def list_flight_arrivals( - session: AsyncSession, - *, - airport: str, - start: date | None, - end: date | None, - limit: int, - offset: int, -) -> tuple[Sequence[FlightArrival], int]: - stmt = select(FlightArrival).where(FlightArrival.airport == airport) - if start is not None: - stmt = stmt.where(FlightArrival.arrival_date >= start) - if end is not None: - stmt = stmt.where(FlightArrival.arrival_date <= end) - stmt = stmt.order_by(FlightArrival.arrival_date, FlightArrival.id) - return await fetch_page(session, stmt, limit=limit, offset=offset) diff --git a/backend/foresight/repository/signals/__init__.py b/backend/foresight/repository/signals/__init__.py new file mode 100644 index 0000000..044c6a2 --- /dev/null +++ b/backend/foresight/repository/signals/__init__.py @@ -0,0 +1 @@ +"""Data access for the outside-signal tables in `database/models/signals/`.""" diff --git a/backend/foresight/repository/signals/competitor_rate.py b/backend/foresight/repository/signals/competitor_rate.py new file mode 100644 index 0000000..eac6c58 --- /dev/null +++ b/backend/foresight/repository/signals/competitor_rate.py @@ -0,0 +1,30 @@ +"""Data access for `competitor_rates`: what competing hotels charge per night.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from foresight.database.models.signals.competitor_rate import CompetitorRate +from foresight.repository.upsert import Outcome, upsert_rows + +if TYPE_CHECKING: + from collections.abc import Iterable, Mapping + + from sqlalchemy.ext.asyncio import AsyncSession + +_KEY = ("city_id", "hotel_name", "stay_date", "source") +_MEASURES = ("rate", "is_sold_out") + + +async def upsert_competitor_rates( + session: AsyncSession, rows: Iterable[Mapping[str, Any]] +) -> dict[Outcome, int]: + """Write rows keyed on (city_id, hotel_name, stay_date, source).""" + return await upsert_rows( + session, + CompetitorRate, + constraint="uq_competitor_rates_city_id_hotel_name_stay_date_source", + key=_KEY, + mutable=_MEASURES, + rows=rows, + ) diff --git a/backend/foresight/repository/signals/event.py b/backend/foresight/repository/signals/event.py new file mode 100644 index 0000000..8955f88 --- /dev/null +++ b/backend/foresight/repository/signals/event.py @@ -0,0 +1,210 @@ +"""Data access for `events` and `event_observations`. + +Plain reads and writes only. Deciding which sighting's values an event row +carries is business logic and lives in `services/event.py`. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from sqlalchemy import and_, delete, func, literal_column, not_, or_, select, update +from sqlalchemy.dialects.postgresql import insert + +from foresight.database.models.signals.event import Event, EventObservation +from foresight.repository.pagination import fetch_page + +if TYPE_CHECKING: + from collections.abc import Collection, Mapping, Sequence + from datetime import date, datetime + + from sqlalchemy import Row + from sqlalchemy.ext.asyncio import AsyncSession + + from foresight.database.enums import EventStatus, Source + +_EVENTS = Event.__table__ +_OBSERVATIONS = EventObservation.__table__ + + +async def find_event_id_by_sighting( + session: AsyncSession, *, source: Source, source_ref: str +) -> int | None: + """The event a source's record was last resolved to, if it was seen before.""" + return await session.scalar( + select(_OBSERVATIONS.c.event_id).where( + _OBSERVATIONS.c.source == source, + _OBSERVATIONS.c.source_ref == source_ref, + ) + ) + + +async def find_other_event_id_by_key( + session: AsyncSession, *, dedupe_key: str, exclude_id: int +) -> int | None: + """Another event already holding `dedupe_key`, if any.""" + return await session.scalar( + select(_EVENTS.c.id).where( + _EVENTS.c.dedupe_key == dedupe_key, _EVENTS.c.id != exclude_id + ) + ) + + +async def merge_event_into( + session: AsyncSession, *, from_id: int, into_id: int +) -> None: + """Move every observation of `from_id` onto `into_id`, then delete `from_id`.""" + await session.execute( + update(_OBSERVATIONS) + .where(_OBSERVATIONS.c.event_id == from_id) + .values(event_id=into_id) + ) + await session.execute(delete(_EVENTS).where(_EVENTS.c.id == from_id)) + + +async def insert_or_touch_event( + session: AsyncSession, row: Mapping[str, Any] +) -> tuple[int, bool]: + """Insert `row`, or bump `last_seen_at` on the row with its dedupe_key. + + Returns the event id and whether it was newly inserted. + """ + stmt = ( + insert(_EVENTS) + .values(**row) + .on_conflict_do_update( + index_elements=[_EVENTS.c.dedupe_key], + set_={"last_seen_at": func.clock_timestamp()}, + ) + # xmax is 0 only on a freshly inserted tuple, never on a conflict update. + .returning(_EVENTS.c.id, literal_column("(xmax = 0)").label("inserted")) + ) + result = (await session.execute(stmt)).one() + return result.id, result.inserted + + +async def lock_event(session: AsyncSession, event_id: int) -> Mapping[str, Any]: + """The event's current column values, row-locked until the transaction ends.""" + result = await session.execute( + select(_EVENTS).where(_EVENTS.c.id == event_id).with_for_update() + ) + return result.mappings().one() + + +async def update_event( + session: AsyncSession, event_id: int, changes: Mapping[str, Any] +) -> None: + """Apply `changes` and mark the event as seen now.""" + await session.execute( + update(_EVENTS) + .where(_EVENTS.c.id == event_id) + .values(**changes, last_seen_at=func.clock_timestamp()) + ) + + +async def list_other_sightings( + session: AsyncSession, event_id: int, *, source: Source, source_ref: str +) -> Sequence[Row[tuple[Source, EventStatus | None, datetime | None, str]]]: + """(source, status, source_updated_at, source_ref) of every other sighting.""" + result = await session.execute( + select( + _OBSERVATIONS.c.source, + _OBSERVATIONS.c.status, + _OBSERVATIONS.c.source_updated_at, + _OBSERVATIONS.c.source_ref, + ).where( + _OBSERVATIONS.c.event_id == event_id, + not_( + and_( + _OBSERVATIONS.c.source == source, + _OBSERVATIONS.c.source_ref == source_ref, + ) + ), + ) + ) + return result.all() + + +async def upsert_observation( + session: AsyncSession, + *, + event_id: int, + source: Source, + source_ref: str, + source_updated_at: datetime | None, + status: EventStatus, + payload: dict[str, Any], +) -> None: + """Record a source's sighting, keyed on (source, source_ref).""" + stmt = insert(_OBSERVATIONS).values( + event_id=event_id, + source=source, + source_ref=source_ref, + source_updated_at=source_updated_at, + status=status, + payload=payload, + ) + excluded = stmt.excluded + await session.execute( + stmt.on_conflict_do_update( + index_elements=[_OBSERVATIONS.c.source, _OBSERVATIONS.c.source_ref], + set_={ + "event_id": excluded.event_id, + "source_updated_at": excluded.source_updated_at, + "status": excluded.status, + "payload": excluded.payload, + "captured_at": func.clock_timestamp(), + }, + # captured_at marks when this payload version arrived, not every rerun. + where=or_( + _OBSERVATIONS.c.payload.is_distinct_from(excluded.payload), + _OBSERVATIONS.c.event_id.is_distinct_from(excluded.event_id), + _OBSERVATIONS.c.status.is_distinct_from(excluded.status), + ), + ) + ) + + +async def count_events_by_status( + session: AsyncSession, city_id: int +) -> dict[EventStatus, int]: + result = await session.execute( + select(Event.status, func.count()) + .where(Event.city_id == city_id) + .group_by(Event.status) + ) + return dict(result.tuples().all()) + + +async def count_observations_by_source( + session: AsyncSession, city_id: int +) -> dict[Source, int]: + result = await session.execute( + select(EventObservation.source, func.count()) + .join(Event, EventObservation.event_id == Event.id) + .where(Event.city_id == city_id) + .group_by(EventObservation.source) + ) + return dict(result.tuples().all()) + + +async def list_events( + session: AsyncSession, + *, + city_id: int, + start: date | None, + end: date | None, + exclude_statuses: Collection[EventStatus], + limit: int, + offset: int, +) -> tuple[Sequence[Event], int]: + """Events whose inclusive local-date range overlaps [start, end].""" + stmt = select(Event).where(Event.city_id == city_id) + if exclude_statuses: + stmt = stmt.where(Event.status.not_in(exclude_statuses)) + if start is not None: + stmt = stmt.where(Event.end_local_date >= start) + if end is not None: + stmt = stmt.where(Event.start_local_date <= end) + stmt = stmt.order_by(Event.start_local_date, Event.id) + return await fetch_page(session, stmt, limit=limit, offset=offset) diff --git a/backend/foresight/repository/signals/flight.py b/backend/foresight/repository/signals/flight.py new file mode 100644 index 0000000..4e38373 --- /dev/null +++ b/backend/foresight/repository/signals/flight.py @@ -0,0 +1,57 @@ +"""Data access for `flight_arrivals`: arrivals per city per day, per source.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from sqlalchemy import select + +from foresight.database.models.signals.flight import FlightArrival +from foresight.repository.pagination import fetch_page +from foresight.repository.upsert import Outcome, upsert_rows + +if TYPE_CHECKING: + from collections.abc import Iterable, Mapping, Sequence + from datetime import date + + from sqlalchemy.ext.asyncio import AsyncSession + +_KEY = ("city_id", "arrival_date", "source") +_MEASURES = ("airport", "flight_count", "avg_fare_eur") + + +async def upsert_flight_arrivals( + session: AsyncSession, rows: Iterable[Mapping[str, Any]] +) -> dict[Outcome, int]: + """Write rows keyed on (city_id, arrival_date, source). + + Each source keeps its own row; which one wins is a read-side decision. + """ + return await upsert_rows( + session, + FlightArrival, + constraint="uq_flight_arrivals_city_id_arrival_date_source", + key=_KEY, + mutable=_MEASURES, + rows=rows, + ) + + +async def list_flight_arrivals( + session: AsyncSession, + *, + city_id: int, + start: date | None, + end: date | None, + limit: int, + offset: int, +) -> tuple[Sequence[FlightArrival], int]: + stmt = select(FlightArrival).where(FlightArrival.city_id == city_id) + if start is not None: + stmt = stmt.where(FlightArrival.arrival_date >= start) + if end is not None: + stmt = stmt.where(FlightArrival.arrival_date <= end) + stmt = stmt.order_by( + FlightArrival.arrival_date, FlightArrival.source, FlightArrival.id + ) + return await fetch_page(session, stmt, limit=limit, offset=offset) diff --git a/backend/foresight/repository/signals/weather.py b/backend/foresight/repository/signals/weather.py new file mode 100644 index 0000000..5f5e94f --- /dev/null +++ b/backend/foresight/repository/signals/weather.py @@ -0,0 +1,121 @@ +"""Data access for `weather`: daily forecasts and recorded actuals per city.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from sqlalchemy import Integer, cast, extract, func, select + +from foresight.database.models.signals.weather import Weather +from foresight.repository.pagination import fetch_page +from foresight.repository.upsert import Outcome, upsert_rows + +if TYPE_CHECKING: + from collections.abc import Iterable, Mapping, Sequence + from datetime import date + + from sqlalchemy.ext.asyncio import AsyncSession + +_KEY = ("city_id", "weather_date", "is_forecast", "source") +_MEASURES = ("temp_max_c", "temp_min_c", "precip_mm", "wind_kph", "condition") + + +async def upsert_weather( + session: AsyncSession, rows: Iterable[Mapping[str, Any]] +) -> dict[Outcome, int]: + """Write rows keyed on (city_id, weather_date, is_forecast, source).""" + return await upsert_rows( + session, + Weather, + constraint="uq_weather_city_id_weather_date_is_forecast_source", + key=_KEY, + mutable=_MEASURES, + rows=rows, + ) + + +async def recorded_days( + session: AsyncSession, *, city_id: int, start: date, end: date +) -> dict[date, bool]: + """Days with recorded weather saved, mapped to whether it has a temperature.""" + result = await session.execute( + select(Weather.weather_date, Weather.temp_max_c.is_not(None)).where( + Weather.city_id == city_id, + Weather.is_forecast.is_(False), + Weather.weather_date.between(start, end), + ) + ) + return {day: has_values for day, has_values in result.tuples().all()} + + +async def list_weather( + session: AsyncSession, + *, + city_id: int, + start: date | None, + end: date | None, + limit: int, + offset: int, +) -> tuple[Sequence[Weather], int]: + stmt = select(Weather).where(Weather.city_id == city_id) + if start is not None: + stmt = stmt.where(Weather.weather_date >= start) + if end is not None: + stmt = stmt.where(Weather.weather_date <= end) + stmt = stmt.order_by(Weather.weather_date, Weather.id) + return await fetch_page(session, stmt, limit=limit, offset=offset) + + +async def daily_weather( + session: AsyncSession, *, city_id: int, start: date, end: date +) -> Sequence[Mapping[str, Any]]: + """Recorded and forecast rows for the days in [start, end], every source. + + Rows carry weather_date, is_forecast, source, temp_max_c, precip_mm and + wind_kph. The caller picks one row per day. + """ + stmt = ( + select( + Weather.weather_date, + Weather.is_forecast, + Weather.source, + Weather.temp_max_c, + Weather.precip_mm, + Weather.wind_kph, + ) + .where(Weather.city_id == city_id, Weather.weather_date.between(start, end)) + .order_by(Weather.weather_date, Weather.is_forecast, Weather.source) + ) + return (await session.execute(stmt)).mappings().all() + + +async def typical_week( + session: AsyncSession, *, city_id: int, iso_week: int +) -> Mapping[str, Any] | None: + """Average recorded weather for one ISO week across every year saved. + + Returns years (how many distinct years contributed), temp_max_c, precip_mm + and wind_kph, or None when nothing is recorded for that week. + """ + week = cast(extract("week", Weather.weather_date), Integer) + row = ( + ( + await session.execute( + select( + func.count( + func.distinct(extract("isoyear", Weather.weather_date)) + ).label("years"), + func.avg(Weather.temp_max_c).label("temp_max_c"), + func.avg(Weather.precip_mm).label("precip_mm"), + func.avg(Weather.wind_kph).label("wind_kph"), + ).where( + Weather.city_id == city_id, + Weather.is_forecast.is_(False), + week == iso_week, + ) + ) + ) + .mappings() + .one() + ) + return row if row["years"] else None diff --git a/backend/foresight/repository/upsert.py b/backend/foresight/repository/upsert.py new file mode 100644 index 0000000..41fcc55 --- /dev/null +++ b/backend/foresight/repository/upsert.py @@ -0,0 +1,89 @@ +"""Bulk upsert shared by every table with a natural key.""" + +from __future__ import annotations + +from enum import StrEnum +from typing import TYPE_CHECKING, Any, cast + +from sqlalchemy import func, literal_column, or_ +from sqlalchemy.dialects.postgresql import insert + +if TYPE_CHECKING: + from collections.abc import Iterable, Mapping, Sequence + + from sqlalchemy import Table + from sqlalchemy.ext.asyncio import AsyncSession + + from foresight.database.base import Base + +# Postgres allows at most 32,767 bind parameters in one statement. +_MAX_PARAMS = 32_767 + + +class Outcome(StrEnum): + NEW = "new" + UPDATED = "updated" + UNCHANGED = "unchanged" + + +async def upsert_rows( + session: AsyncSession, + model: type[Base], + *, + constraint: str, + key: Sequence[str], + mutable: Sequence[str], + rows: Iterable[Mapping[str, Any]], +) -> dict[Outcome, int]: + """Upsert `rows` on `constraint`, touching a row only when a value changed. + + Does not commit; the caller owns the transaction. `key` must name the + columns `constraint` covers: Postgres refuses to update a row twice in one + statement, so repeats within a batch are collapsed to the last one first. + """ + table = cast("Table", model.__table__) + counts = dict.fromkeys(Outcome, 0) + rows = list({tuple(r[c] for c in key): r for r in rows}.values()) + if not rows: + return counts + + # One statement per batch, sized to stay under the parameter limit. + batch = max(1, _MAX_PARAMS // len(rows[0])) + for i in range(0, len(rows), batch): + for outcome, n in ( + await _upsert_batch( + session, table, constraint, mutable, rows[i : i + batch] + ) + ).items(): + counts[outcome] += n + return counts + + +async def _upsert_batch( + session: AsyncSession, + table: Table, + constraint: str, + mutable: Sequence[str], + rows: list[Mapping[str, Any]], +) -> dict[Outcome, int]: + counts = dict.fromkeys(Outcome, 0) + stmt = insert(table).values(rows) + excluded = stmt.excluded + set_: dict[str, Any] = {c: excluded[c] for c in mutable} + # Only the external-signal tables record when a row was last fetched. + if "captured_at" in table.c: + set_["captured_at"] = func.clock_timestamp() + stmt = stmt.on_conflict_do_update( + constraint=constraint, + set_=set_, + # An unchanged row fails this, so it is never returned below. + where=or_(*(table.c[c].is_distinct_from(excluded[c]) for c in mutable)), + # xmax is 0 only on a freshly inserted tuple, never on a conflict update. + ).returning(literal_column("(xmax = 0)").label("inserted")) + + for inserted in (await session.execute(stmt)).scalars(): + counts[Outcome.NEW if inserted else Outcome.UPDATED] += 1 + counts[Outcome.UNCHANGED] = ( + len(rows) - counts[Outcome.NEW] - counts[Outcome.UPDATED] + ) + return counts diff --git a/backend/foresight/repository/weather.py b/backend/foresight/repository/weather.py deleted file mode 100644 index 79f4290..0000000 --- a/backend/foresight/repository/weather.py +++ /dev/null @@ -1,46 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING, Any, cast - -from sqlalchemy import select - -from foresight.database.models.external import Weather -from foresight.repository import Outcome, upsert_rows - -if TYPE_CHECKING: - from collections.abc import Iterable, Mapping - from datetime import date - - from sqlalchemy import Table - from sqlalchemy.ext.asyncio import AsyncSession - -_KEY = ("city", "weather_date", "is_forecast", "source") -_MEASURES = ("temp_max_c", "temp_min_c", "precip_mm", "wind_kph", "condition") - - -async def upsert_weather( - session: AsyncSession, rows: Iterable[Mapping[str, Any]] -) -> dict[Outcome, int]: - """Write rows keyed on (city, weather_date, is_forecast, source).""" - return await upsert_rows( - session, - cast("Table", Weather.__table__), - constraint="uq_weather_city_date_kind_source", - key=_KEY, - mutable=_MEASURES, - rows=rows, - ) - - -async def recorded_days( - session: AsyncSession, *, city: str, start: date, end: date -) -> dict[date, bool]: - """Days with recorded weather saved, mapped to whether it has a temperature.""" - result = await session.execute( - select(Weather.weather_date, Weather.temp_max_c.is_not(None)).where( - Weather.city == city, - Weather.is_forecast.is_(False), - Weather.weather_date.between(start, end), - ) - ) - return {day: has_values for day, has_values in result.tuples().all()} diff --git a/backend/foresight/routes/city.py b/backend/foresight/routes/city.py new file mode 100644 index 0000000..a7dcead --- /dev/null +++ b/backend/foresight/routes/city.py @@ -0,0 +1,156 @@ +"""Cities: the markets hotels sit in. Weather, events and flights are kept per +city and shared by its hotels.""" + +from typing import Annotated, Any +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + +from fastapi import APIRouter, Path, status +from pydantic import Field, field_validator + +from foresight.routes.helpers import RequestSchema, ResponseSchema, SessionDep +from foresight.services.domain import city as city_service + +router = APIRouter(tags=["Cities"]) + + +# ╭──────────────────────────────────────────────────────────╮ +# │ Schemas │ +# ╰──────────────────────────────────────────────────────────╯ + +# Field settings shared by the create and update bodies. +_NAME: dict[str, Any] = { + "max_length": 120, + "description": "The city's name; unique once normalized", + "examples": ["Example City"], +} +_COUNTRY: dict[str, Any] = { + "min_length": 2, + "max_length": 2, + "description": "ISO-3166 alpha-2 country code", + "examples": ["XX"], +} +_TIMEZONE: dict[str, Any] = { + "max_length": 64, + "description": "IANA timezone; stay nights are local to it", + "examples": ["UTC"], +} +_LATITUDE: dict[str, Any] = { + "ge": -90, + "le": 90, + "description": "Centre of the market, in degrees; north is positive", + "examples": [12.34], +} +_LONGITUDE: dict[str, Any] = { + "ge": -180, + "le": 180, + "description": "Centre of the market, in degrees; east is positive, west negative", + "examples": [-56.78], +} +_IATA: dict[str, Any] = { + "min_length": 3, + "max_length": 3, + "description": "IATA code of the city's main airport", + "examples": ["XXX"], +} +_ICAO: dict[str, Any] = { + "min_length": 4, + "max_length": 4, + "description": "ICAO code of the city's main airport", + "examples": ["XXXX"], +} + + +def _check_zone(value: str | None) -> str | None: + if value is not None: + try: + ZoneInfo(value) + except (ZoneInfoNotFoundError, ValueError): + raise ValueError(f"unknown IANA timezone {value!r}") from None + return value + + +class CityRequest(RequestSchema): + name: str = Field(**_NAME) + country: str | None = Field(default=None, **_COUNTRY) + timezone: str = Field(**_TIMEZONE) + latitude: float = Field(**_LATITUDE) + longitude: float = Field(**_LONGITUDE) + airport_iata: str | None = Field(default=None, **_IATA) + airport_icao: str | None = Field(default=None, **_ICAO) + + _zone = field_validator("timezone")(_check_zone) + + +class CityUpdateRequest(RequestSchema): + """Only the fields sent are changed; send null to clear an optional one.""" + + name: str | None = Field(default=None, **_NAME) + country: str | None = Field(default=None, **_COUNTRY) + timezone: str | None = Field(default=None, **_TIMEZONE) + latitude: float | None = Field(default=None, **_LATITUDE) + longitude: float | None = Field(default=None, **_LONGITUDE) + airport_iata: str | None = Field(default=None, **_IATA) + airport_icao: str | None = Field(default=None, **_ICAO) + + _zone = field_validator("timezone")(_check_zone) + + @field_validator("name", "timezone", "latitude", "longitude") + @classmethod + def _not_null(cls, value: object) -> object: + # Required columns: omitting them leaves them alone, null can't clear them. + if value is None: + raise ValueError("cannot be null") + return value + + +class CityResponse(ResponseSchema): + id: int = Field(description="The city's id") + name: str = Field(description="The city's name") + slug: str = Field(description="Normalized name; unique") + country: str | None = Field(description="ISO-3166 alpha-2 code; null if unset") + timezone: str = Field(description="IANA timezone; stay nights are local to it") + latitude: float = Field(description="Centre of the market, in degrees") + longitude: float = Field(description="Centre of the market, in degrees") + airport_iata: str | None = Field(description="Main airport's IATA code") + airport_icao: str | None = Field(description="Main airport's ICAO code") + + +CityId = Annotated[int, Path(description="The city's id", examples=[1])] + + +# ╭──────────────────────────────────────────────────────────╮ +# │ Endpoints │ +# ╰──────────────────────────────────────────────────────────╯ + + +@router.post( + "/cities", response_model=CityResponse, status_code=status.HTTP_201_CREATED +) +async def create_city(data: CityRequest, session: SessionDep) -> CityResponse: + """Add a city. Names are unique once normalized ("Example" = "example").""" + city = await city_service.create_city(session, **data.model_dump()) + return CityResponse.model_validate(city) + + +@router.get("/cities", response_model=list[CityResponse]) +async def list_cities(session: SessionDep) -> list[CityResponse]: + """Every city, by name.""" + cities = await city_service.list_cities(session) + return [CityResponse.model_validate(city) for city in cities] + + +@router.get("/cities/{city_id}", response_model=CityResponse) +async def get_city(city_id: CityId, session: SessionDep) -> CityResponse: + """One city.""" + return CityResponse.model_validate(await city_service.get_city(session, city_id)) + + +@router.patch("/cities/{city_id}", response_model=CityResponse) +async def update_city( + city_id: CityId, data: CityUpdateRequest, session: SessionDep +) -> CityResponse: + """Change the fields sent; omitted fields are left alone.""" + city = await city_service.update_city( + session, city_id, data.model_dump(exclude_unset=True) + ) + return CityResponse.model_validate(city) diff --git a/backend/foresight/routes/common.py b/backend/foresight/routes/common.py deleted file mode 100644 index da0e6b9..0000000 --- a/backend/foresight/routes/common.py +++ /dev/null @@ -1,42 +0,0 @@ -from collections.abc import Sequence -from dataclasses import dataclass -from typing import Annotated, Any - -from fastapi import Depends, HTTPException, Query, status -from sqlalchemy.ext.asyncio import AsyncSession - -from foresight.database.session import get_session -from foresight.schemas.base import ResponseSchema -from foresight.schemas.common import PageResponse - -SessionDep = Annotated[AsyncSession, Depends(get_session, scope="function")] - - -@dataclass -class Pagination: - """`?limit=&offset=` query params shared by every list endpoint.""" - - limit: Annotated[int, Query(ge=1, le=200)] = 50 - offset: Annotated[int, Query(ge=0)] = 0 - - -PageDep = Annotated[Pagination, Depends()] - - -def paginate[T: ResponseSchema]( - schema: type[T], rows: Sequence[Any], total: int, page: Pagination -) -> PageResponse[T]: - """Map ORM rows to `schema` and wrap them in the shared page envelope.""" - return PageResponse( - items=[schema.model_validate(row) for row in rows], - total=total, - limit=page.limit, - offset=page.offset, - ) - - -def not_found(entity: str, entity_id: int) -> HTTPException: - return HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"{entity} {entity_id} not found", - ) diff --git a/backend/foresight/routes/errors.py b/backend/foresight/routes/errors.py new file mode 100644 index 0000000..740b5ea --- /dev/null +++ b/backend/foresight/routes/errors.py @@ -0,0 +1,33 @@ +"""Domain errors raised by services, mapped to HTTP status codes. + +The only place a service error becomes a status code. Every error body is +`{"detail": "..."}`, the same shape FastAPI uses for its own errors. +""" + +from fastapi import FastAPI, Request, status +from fastapi.responses import JSONResponse + +from foresight.services.errors import ( + ConflictError, + DomainError, + InvalidRequestError, + NotConfiguredError, + NotFoundError, +) + +_STATUS: dict[type[DomainError], int] = { + NotFoundError: status.HTTP_404_NOT_FOUND, + ConflictError: status.HTTP_409_CONFLICT, + InvalidRequestError: status.HTTP_422_UNPROCESSABLE_CONTENT, + NotConfiguredError: status.HTTP_503_SERVICE_UNAVAILABLE, +} + + +async def _handle(_: Request, exc: Exception) -> JSONResponse: + code = next(c for kind, c in _STATUS.items() if isinstance(exc, kind)) + return JSONResponse({"detail": str(exc)}, status_code=code) + + +def register_error_handlers(app: FastAPI) -> None: + for kind in _STATUS: + app.add_exception_handler(kind, _handle) diff --git a/backend/foresight/routes/event.py b/backend/foresight/routes/event.py new file mode 100644 index 0000000..de09758 --- /dev/null +++ b/backend/foresight/routes/event.py @@ -0,0 +1,98 @@ +"""Events in a hotel's city.""" + +from datetime import date, datetime +from typing import Annotated + +from fastapi import APIRouter, Path, Query +from pydantic import Field + +from foresight.database.enums import DatePrecision, EventCategory, EventStatus, Source +from foresight.routes.helpers import ( + PageDep, + PageResponse, + ResponseSchema, + SessionDep, + paginate, +) +from foresight.services.domain import event as event_service + +router = APIRouter(tags=["Events"]) + + +# ╭──────────────────────────────────────────────────────────╮ +# │ Schemas │ +# ╰──────────────────────────────────────────────────────────╯ + + +class EventResponse(ResponseSchema): + """A merged event. Raw per-source sightings (`event_observations`) stay internal.""" + + id: int = Field(description="The event's id") + title: str = Field(description="The event's name") + category: EventCategory = Field(description="What kind of event it is") + venue: str | None = Field(description="Where it's held; null if unknown") + city_id: int = Field(description="The city it's in") + country: str | None = Field(description="ISO-3166 alpha-2 code; null if unknown") + latitude: float | None = Field(description="The venue, in degrees; null if unknown") + longitude: float | None = Field( + description="The venue, in degrees; null if unknown" + ) + start_local_date: date = Field(description="First day, local to the event") + end_local_date: date = Field(description="Last day, inclusive, local to the event") + start_at_utc: datetime | None = Field( + description="Start time, UTC; null if unknown" + ) + end_at_utc: datetime | None = Field(description="End time, UTC; null if unknown") + timezone: str = Field(description="IANA timezone of the event") + date_precision: DatePrecision = Field( + description="How precisely the source pinned the date" + ) + expected_attendance: int | None = Field( + description="Predicted people; null if unknown" + ) + impact_rank: int | None = Field(description="PredictHQ rank, 0-100") + local_rank: int | None = Field(description="PredictHQ local rank, 0-100") + confidence: float = Field(description="1.0 for listings; lower when extracted") + status: EventStatus = Field(description="Whether it's still going ahead") + url: str | None = Field(description="Link to the source; null if none") + primary_source: Source = Field(description="Whose values the row carries") + first_seen_at: datetime = Field( + description="When any source first reported it, UTC" + ) + last_seen_at: datetime = Field(description="When a source last reported it, UTC") + + +HotelId = Annotated[int, Path(description="The hotel's id", examples=[1])] + + +# ╭──────────────────────────────────────────────────────────╮ +# │ Endpoints │ +# ╰──────────────────────────────────────────────────────────╯ + + +@router.get("/hotels/{hotel_id}/events", response_model=PageResponse[EventResponse]) +async def list_events( + hotel_id: HotelId, + session: SessionDep, + page: PageDep, + start: Annotated[ + date | None, + Query( + description="First day, inclusive. YYYY-MM-DD", + examples=["2025-01-01"], + ), + ] = None, + end: Annotated[ + date | None, + Query( + description="Last day, inclusive. YYYY-MM-DD", + examples=["2025-12-31"], + ), + ] = None, +) -> PageResponse[EventResponse]: + """Events in the hotel's city overlapping the date range; cancelled and + deleted events are hidden.""" + rows, total = await event_service.list_events( + session, hotel_id, start=start, end=end, limit=page.limit, offset=page.offset + ) + return paginate(EventResponse, rows, total, page) diff --git a/backend/foresight/routes/example_widget.py b/backend/foresight/routes/example_widget.py index 3cd9bf2..f21b62a 100644 --- a/backend/foresight/routes/example_widget.py +++ b/backend/foresight/routes/example_widget.py @@ -3,56 +3,98 @@ HTTP layer for the `Widget` reference slice. Layer rules shown here: -- Routers are thin: validate input via schemas, call a service, shape the - response. No business logic and no ORM/session access here. -- Inject the session with `Depends(get_session)` and pass it down. -- Translate domain results into HTTP (e.g. a missing record -> 404). -- Register the router in `foresight/main.py`. +- One router per domain module, registered in `foresight/main.py`. +- The `*Request`/`*Response` schemas live in the route module that uses them, + on the bases in `routes/helpers.py`. See decision 0015. +- Handlers are thin: take a `*Request`, pass plain arguments to one service + function, map the result with `*Response.model_validate`. No business + logic, no ORM/session; services never see a schema. +- Use the shared dependencies from `routes/helpers.py`: `SessionDep` (one + transaction per request, committed by `get_session`) and `PageDep` + + `paginate` for lists that can grow without bound. +- Services raise when something is missing (`NotFoundError`, `ConflictError`, + ...); `routes/errors.py` maps those to HTTP, so handlers don't check or + catch. +- Every handler declares `response_model=` and has a docstring: both appear in + `/docs`. +- Schemas follow the conventions in `routes/helpers.py` (decision 0030): every + field has a description, example values go in `examples=`, and the body + /docs shows is built from the fields' defaults and examples. """ from typing import Annotated -from fastapi import APIRouter, Depends, HTTPException, status -from sqlalchemy.ext.asyncio import AsyncSession +from fastapi import APIRouter, Path, status +from pydantic import Field -from foresight.database.session import get_session -from foresight.schemas.example_widget import WidgetRequest, WidgetResponse +from foresight.routes.helpers import ( + PageDep, + PageResponse, + RequestSchema, + ResponseSchema, + SessionDep, + paginate, +) from foresight.services import example_widget as widget_service -router = APIRouter(prefix="/example/widgets", tags=["example"]) +router = APIRouter(prefix="/example/widgets", tags=["Example"]) -# Reusable session dependency: one transaction per request, committed by -# get_session. scope="function" commits before the response is sent, so a failed -# commit returns a 500 instead of a 200. Using `Annotated` keeps it lint-clean. -SessionDep = Annotated[AsyncSession, Depends(get_session, scope="function")] + +# ╭──────────────────────────────────────────────────────────╮ +# │ Schemas │ +# ╰──────────────────────────────────────────────────────────╯ + + +# Request and Response never inherit from each other. Constraints and +# descriptions go in `Field(...)` so they are enforced and shown in /docs. +class WidgetRequest(RequestSchema): + name: str = Field( + max_length=120, description="The widget's name", examples=["Example Widget"] + ) + description: str | None = Field( + default=None, + max_length=500, + description="Free text; null for none", + examples=["A short description"], + ) + quantity: int = Field(default=0, ge=0, description="Units in stock") + + +class WidgetResponse(ResponseSchema): + id: int = Field(description="The widget's id") + name: str = Field(description="The widget's name") + description: str | None = Field(description="Free text; null if none") + quantity: int = Field(description="Units in stock") + + +WidgetId = Annotated[int, Path(description="The widget's id", examples=[1])] + + +# ╭──────────────────────────────────────────────────────────╮ +# │ Endpoints │ +# ╰──────────────────────────────────────────────────────────╯ @router.post("", response_model=WidgetResponse, status_code=status.HTTP_201_CREATED) -async def create_widget( - data: WidgetRequest, - session: SessionDep, -) -> WidgetResponse: - widget = await widget_service.create_widget(session, data) +async def create_widget(data: WidgetRequest, session: SessionDep) -> WidgetResponse: + """Create a widget.""" + widget = await widget_service.create_widget(session, **data.model_dump()) return WidgetResponse.model_validate(widget) -@router.get("", response_model=list[WidgetResponse]) +@router.get("", response_model=PageResponse[WidgetResponse]) async def list_widgets( - session: SessionDep, -) -> list[WidgetResponse]: - widgets = await widget_service.list_widgets(session) - return [WidgetResponse.model_validate(w) for w in widgets] + session: SessionDep, page: PageDep +) -> PageResponse[WidgetResponse]: + """Every widget, oldest first.""" + widgets, total = await widget_service.list_widgets( + session, limit=page.limit, offset=page.offset + ) + return paginate(WidgetResponse, widgets, total, page) @router.get("/{widget_id}", response_model=WidgetResponse) -async def get_widget( - widget_id: int, - session: SessionDep, -) -> WidgetResponse: +async def get_widget(widget_id: WidgetId, session: SessionDep) -> WidgetResponse: + """One widget.""" widget = await widget_service.get_widget(session, widget_id) - if widget is None: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Widget {widget_id} not found", - ) return WidgetResponse.model_validate(widget) diff --git a/backend/foresight/routes/flight.py b/backend/foresight/routes/flight.py new file mode 100644 index 0000000..b82f738 --- /dev/null +++ b/backend/foresight/routes/flight.py @@ -0,0 +1,76 @@ +"""Flight arrivals at the airport of a hotel's city.""" + +from datetime import date, datetime +from decimal import Decimal +from typing import Annotated + +from fastapi import APIRouter, Path, Query +from pydantic import Field + +from foresight.routes.helpers import ( + PageDep, + PageResponse, + ResponseSchema, + SessionDep, + paginate, +) +from foresight.services.domain import flight as flight_service + +router = APIRouter(tags=["Flights"]) + + +# ╭──────────────────────────────────────────────────────────╮ +# │ Schemas │ +# ╰──────────────────────────────────────────────────────────╯ + + +class FlightArrivalResponse(ResponseSchema): + id: int = Field(description="The row's id") + city_id: int = Field(description="The city the airport serves") + airport: str = Field(description="IATA or ICAO, as the source reports it") + arrival_date: date = Field(description="The day, local to the city") + flight_count: int | None = Field(description="Arriving flights; null if unknown") + avg_fare_eur: Decimal | None = Field( + description="Average fare, in EUR; null if unknown" + ) + source: str = Field(description="Each source keeps its own row per day") + captured_at: datetime = Field(description="When this row was last fetched, UTC") + + +HotelId = Annotated[int, Path(description="The hotel's id", examples=[1])] + + +# ╭──────────────────────────────────────────────────────────╮ +# │ Endpoints │ +# ╰──────────────────────────────────────────────────────────╯ + + +@router.get( + "/hotels/{hotel_id}/flight-arrivals", + response_model=PageResponse[FlightArrivalResponse], +) +async def list_flight_arrivals( + hotel_id: HotelId, + session: SessionDep, + page: PageDep, + start: Annotated[ + date | None, + Query( + description="First day, inclusive. YYYY-MM-DD", + examples=["2025-01-01"], + ), + ] = None, + end: Annotated[ + date | None, + Query( + description="Last day, inclusive. YYYY-MM-DD", + examples=["2025-12-31"], + ), + ] = None, +) -> PageResponse[FlightArrivalResponse]: + """Daily arrivals at the airport of the hotel's city. Each source keeps its + own row per day.""" + rows, total = await flight_service.list_flight_arrivals( + session, hotel_id, start=start, end=end, limit=page.limit, offset=page.offset + ) + return paginate(FlightArrivalResponse, rows, total, page) diff --git a/backend/foresight/routes/health.py b/backend/foresight/routes/health.py index 9a200d0..4048782 100644 --- a/backend/foresight/routes/health.py +++ b/backend/foresight/routes/health.py @@ -1,8 +1,30 @@ +"""Liveness check.""" + +from typing import Literal + from fastapi import APIRouter +from pydantic import Field + +from foresight.routes.helpers import ResponseSchema + +router = APIRouter(tags=["Health"]) + + +# ╭──────────────────────────────────────────────────────────╮ +# │ Schemas │ +# ╰──────────────────────────────────────────────────────────╯ + + +class HealthResponse(ResponseSchema): + status: Literal["ok"] = Field(description="Always ok while the API is serving") + -router = APIRouter(tags=["health"]) +# ╭──────────────────────────────────────────────────────────╮ +# │ Endpoints │ +# ╰──────────────────────────────────────────────────────────╯ -@router.get("/health") -async def health_check(): - return {"status": "ok"} +@router.get("/health", response_model=HealthResponse) +async def health_check() -> HealthResponse: + """Returns ok while the API is serving requests.""" + return HealthResponse(status="ok") diff --git a/backend/foresight/routes/helpers.py b/backend/foresight/routes/helpers.py new file mode 100644 index 0000000..7b6c773 --- /dev/null +++ b/backend/foresight/routes/helpers.py @@ -0,0 +1,102 @@ +"""Shared by every router: schema bases, the session, and pagination. + +Each route module defines the `*Request`/`*Response` schemas it uses, on the +two bases here. Services never see them: handlers pass plain arguments down +and map what comes back with `*Response.model_validate`. + +Schema conventions (see decision 0030): + +- Every field has a `description`; units, inclusive dates, what null means and + currency go there. Example values go in `examples=`, never in the text. +- Examples are neutral placeholders. A field with a default has no example of + its own: the default is what /docs shows. +- A request body's example is built from its fields (`example_body`): the + default where there is one, else the example. Don't write one by hand. +- A field whose value changes what the request does (an id to copy from, + model settings) gets no example, so the shown body never sets it. +- A PATCH body reuses its create body's field settings (a module-level dict + of `Field` kwargs), with `default=None`. +- Each module reads: docstring, then boxed `Schemas` and `Endpoints` headers. +""" + +from collections.abc import Sequence +from dataclasses import dataclass +from typing import Annotated, Any + +from fastapi import Depends, Query +from pydantic import BaseModel, ConfigDict, Field +from pydantic_core import PydanticUndefined +from sqlalchemy.ext.asyncio import AsyncSession + +from foresight.database.session import get_session + +SessionDep = Annotated[AsyncSession, Depends(get_session, scope="function")] + + +def example_body(model: type[BaseModel]) -> dict[str, Any]: + """A body made of each field's default, else its example; nested bodies + are built the same way. Fields with neither are left out, so a field + whose value changes behaviour (an id to copy from) has no example. + + A default always wins, so the body /docs shows does what sending nothing + would do for those fields. + """ + body: dict[str, Any] = {} + for name, field in model.model_fields.items(): + nested = field.annotation + if field.default not in (None, PydanticUndefined): + body[name] = field.default + elif isinstance(nested, type) and issubclass(nested, BaseModel): + body[name] = example_body(nested) + elif field.examples: + body[name] = field.examples[0] + return body + + +def _add_example_body(schema: dict[str, Any], model: type[BaseModel]) -> None: + schema["examples"] = [example_body(model)] + + +class RequestSchema(BaseModel): + """Base for request bodies. Unknown fields are rejected with a 422, and + /docs shows a body built from the fields' examples.""" + + model_config = ConfigDict(extra="forbid", json_schema_extra=_add_example_body) + + +class ResponseSchema(BaseModel): + """Base for response bodies. Can be built from an ORM object or dataclass.""" + + model_config = ConfigDict(from_attributes=True) + + +class PageResponse[T](ResponseSchema): + """One page of a list endpoint: the rows plus where they sit in the full set.""" + + items: list[T] = Field(description="This page's rows") + total: int = Field(description="Rows matching the filters, ignoring limit/offset") + limit: int = Field(description="Page size used") + offset: int = Field(description="Rows skipped before this page") + + +@dataclass +class Pagination: + """`?limit=&offset=` query params for lists that can grow without bound.""" + + limit: Annotated[int, Query(ge=1, le=200, description="Rows per page")] = 50 + offset: Annotated[int, Query(ge=0, description="Rows to skip")] = 0 + + +PageDep = Annotated[Pagination, Depends()] + + +def paginate[T: ResponseSchema]( + schema: type[T], rows: Sequence[Any], total: int, page: Pagination +) -> PageResponse[T]: + """Map ORM rows to `schema` and wrap them in the shared page envelope.""" + return PageResponse( + items=[schema.model_validate(row) for row in rows], + total=total, + limit=page.limit, + offset=page.offset, + ) diff --git a/backend/foresight/routes/hotel.py b/backend/foresight/routes/hotel.py new file mode 100644 index 0000000..d6f2d34 --- /dev/null +++ b/backend/foresight/routes/hotel.py @@ -0,0 +1,346 @@ +"""Hotels, their room types, nightly performance, and recommendations.""" + +from datetime import date +from decimal import Decimal +from typing import Annotated, Any + +from fastapi import APIRouter, Path, Query, status +from pydantic import Field, computed_field, field_validator + +from foresight.routes.city import CityResponse +from foresight.routes.helpers import ( + PageDep, + PageResponse, + RequestSchema, + ResponseSchema, + SessionDep, + paginate, +) +from foresight.services.domain import hotel as hotel_service +from foresight.services.domain import recommendation as recommendation_service + +router = APIRouter(tags=["Hotels"]) + + +# ╭──────────────────────────────────────────────────────────╮ +# │ Schemas │ +# ╰──────────────────────────────────────────────────────────╯ + +# Field settings shared by the create and update bodies. +_HOTEL_NAME: dict[str, Any] = { + "max_length": 200, + "description": "The hotel's name; unique within its city", + "examples": ["Example Hotel"], +} +_CITY_ID: dict[str, Any] = { + "description": "The city the hotel sits in; see /cities", + "examples": [1], +} +_TOTAL_ROOMS: dict[str, Any] = { + "gt": 0, + "description": "Rooms across every room type", + "examples": [100], +} +_LATITUDE: dict[str, Any] = { + "ge": -90, + "le": 90, + "description": "The hotel itself, not the city, in degrees", + "examples": [12.34], +} +_LONGITUDE: dict[str, Any] = { + "ge": -180, + "le": 180, + "description": "The hotel itself, not the city, in degrees", + "examples": [-56.78], +} + + +class HotelRequest(RequestSchema): + name: str = Field(**_HOTEL_NAME) + city_id: int = Field(**_CITY_ID) + total_rooms: int = Field(**_TOTAL_ROOMS) + latitude: float | None = Field(default=None, **_LATITUDE) + longitude: float | None = Field(default=None, **_LONGITUDE) + + +class HotelUpdateRequest(RequestSchema): + """Only the fields sent are changed; send null to clear an optional one.""" + + name: str | None = Field(default=None, **_HOTEL_NAME) + city_id: int | None = Field(default=None, **_CITY_ID) + total_rooms: int | None = Field(default=None, **_TOTAL_ROOMS) + latitude: float | None = Field(default=None, **_LATITUDE) + longitude: float | None = Field(default=None, **_LONGITUDE) + + @field_validator("name", "city_id", "total_rooms") + @classmethod + def _not_null(cls, value: object) -> object: + # Required columns: omitting them leaves them alone, null can't clear them. + if value is None: + raise ValueError("cannot be null") + return value + + +class HotelResponse(ResponseSchema): + id: int = Field(description="The hotel's id") + name: str = Field(description="The hotel's name") + city: CityResponse = Field(description="The city the hotel sits in") + total_rooms: int = Field(description="Rooms across every room type") + latitude: float | None = Field(description="The hotel itself, in degrees") + longitude: float | None = Field(description="The hotel itself, in degrees") + + +class RoomTypeRequest(RequestSchema): + name: str = Field( + max_length=120, + description="The room type's name; unique within the hotel", + examples=["Example Room"], + ) + room_count: int = Field( + gt=0, description="Physical rooms of this type", examples=[50] + ) + base_rate: Decimal = Field( + gt=0, + description="The standard nightly price (rack rate), in EUR", + examples=["100.00"], + ) + + +class RoomTypeResponse(ResponseSchema): + id: int = Field(description="The room type's id") + hotel_id: int = Field(description="The hotel it belongs to") + name: str = Field(description="The room type's name") + room_count: int = Field(description="Physical rooms of this type") + base_rate: Decimal = Field(description="The standard nightly price, in EUR") + + +class DailyPerformanceResponse(ResponseSchema): + """Adds the three standard hotel metrics, calculated rather than stored.""" + + id: int = Field(description="The night's row id") + room_type_id: int = Field(description="The room type sold") + stay_date: date = Field(description="The night, local to the hotel's city") + rate: Decimal | None = Field( + description="Average rate paid that night, in EUR; null if unsold" + ) + rooms_available: int = Field(description="Rooms of this type for sale") + rooms_sold: int = Field(description="Rooms of this type sold") + room_revenue: Decimal = Field(description="Revenue from these rooms, in EUR") + + @computed_field + @property + def occupancy(self) -> float | None: + """Share of rooms sold, 0-1; null if none were available.""" + if not self.rooms_available: + return None + return round(self.rooms_sold / self.rooms_available, 4) + + @computed_field + @property + def adr(self) -> Decimal | None: + """Average Daily Rate: revenue per room sold, in EUR; null if none sold.""" + if not self.rooms_sold: + return None + return round(self.room_revenue / self.rooms_sold, 2) + + @computed_field + @property + def revpar(self) -> Decimal | None: + """Revenue Per Available Room, in EUR; null if none were available.""" + if not self.rooms_available: + return None + return round(self.room_revenue / self.rooms_available, 2) + + +class FactorResponse(ResponseSchema): + """One term's pull on the recommended rate.""" + + detail: str = Field(description="The factor, in words", examples=["Saturday"]) + amount: Decimal = Field( + description="Contribution in EUR; baseline plus every amount is the " + "recommended rate" + ) + + +class ExplanationResponse(ResponseSchema): + summary: str = Field(description="The rate and its biggest factor, in words") + baseline: Decimal = Field(description="The model's rate before any factor, EUR") + factors: list[FactorResponse] = Field(description="Largest first") + + +class ConfidenceIntervalResponse(ResponseSchema): + lower: Decimal = Field(description="Low end of the range, in EUR") + upper: Decimal = Field(description="High end of the range, in EUR") + level: float = Field( + description="Share of nights expected to fall inside the range", + examples=[0.8], + ) + + +class RecommendationResponse(ResponseSchema): + hotel_id: int = Field(description="The hotel priced") + stay_date: date = Field(description="The night priced, local to the city") + room_type: str = Field(description="The room type priced") + recommended_rate: Decimal = Field(description="The rate to list, in `currency`") + currency: str = Field(description="ISO-4217 currency code", examples=["EUR"]) + explanation: ExplanationResponse = Field(description="Why the rate is what it is") + confidence_interval: ConfidenceIntervalResponse = Field( + description="The range the rate is likely to fall in" + ) + uncertainty: Decimal = Field( + description="How much the model's sub-models disagree, in EUR. High " + "means an unusual night: review the rate" + ) + model_id: int = Field(description="The model version that priced it") + model_version: str = Field(description="Algorithm and version", examples=["ebm-v1"]) + is_placeholder: bool = Field(description="Always false; kept for older clients") + + +HotelId = Annotated[int, Path(description="The hotel's id", examples=[1])] +RoomTypeId = Annotated[int, Path(description="The room type's id", examples=[1])] + + +# ╭──────────────────────────────────────────────────────────╮ +# │ Endpoints │ +# ╰──────────────────────────────────────────────────────────╯ + + +@router.post( + "/hotels", response_model=HotelResponse, status_code=status.HTTP_201_CREATED +) +async def create_hotel(data: HotelRequest, session: SessionDep) -> HotelResponse: + """Add a hotel to a city. Names are unique per city.""" + hotel = await hotel_service.create_hotel(session, **data.model_dump()) + return HotelResponse.model_validate(hotel) + + +@router.get("/hotels", response_model=list[HotelResponse]) +async def list_hotels(session: SessionDep) -> list[HotelResponse]: + """Every hotel, oldest first.""" + hotels = await hotel_service.list_hotels(session) + return [HotelResponse.model_validate(hotel) for hotel in hotels] + + +@router.get("/hotels/{hotel_id}", response_model=HotelResponse) +async def get_hotel(hotel_id: HotelId, session: SessionDep) -> HotelResponse: + """One hotel, with its city.""" + return HotelResponse.model_validate( + await hotel_service.get_hotel(session, hotel_id) + ) + + +@router.patch("/hotels/{hotel_id}", response_model=HotelResponse) +async def update_hotel( + hotel_id: HotelId, data: HotelUpdateRequest, session: SessionDep +) -> HotelResponse: + """Change the fields sent; omitted fields are left alone.""" + hotel = await hotel_service.update_hotel( + session, hotel_id, data.model_dump(exclude_unset=True) + ) + return HotelResponse.model_validate(hotel) + + +@router.post( + "/hotels/{hotel_id}/room-types", + response_model=RoomTypeResponse, + status_code=status.HTTP_201_CREATED, +) +async def create_room_type( + hotel_id: HotelId, data: RoomTypeRequest, session: SessionDep +) -> RoomTypeResponse: + """Add a room type to the hotel. Names are unique per hotel.""" + room_type = await hotel_service.create_room_type( + session, hotel_id, **data.model_dump() + ) + return RoomTypeResponse.model_validate(room_type) + + +@router.get("/hotels/{hotel_id}/room-types", response_model=list[RoomTypeResponse]) +async def list_room_types( + hotel_id: HotelId, session: SessionDep +) -> list[RoomTypeResponse]: + """The hotel's room types.""" + room_types = await hotel_service.list_room_types(session, hotel_id) + return [RoomTypeResponse.model_validate(r) for r in room_types] + + +@router.get("/room-types/{room_type_id}", response_model=RoomTypeResponse) +async def get_room_type( + room_type_id: RoomTypeId, session: SessionDep +) -> RoomTypeResponse: + """One room type.""" + return RoomTypeResponse.model_validate( + await hotel_service.get_room_type(session, room_type_id) + ) + + +@router.get( + "/hotels/{hotel_id}/performance", + response_model=PageResponse[DailyPerformanceResponse], +) +async def list_performance( + hotel_id: HotelId, + session: SessionDep, + page: PageDep, + start: Annotated[ + date | None, + Query( + description="First night, inclusive. YYYY-MM-DD", + examples=["2025-01-01"], + ), + ] = None, + end: Annotated[ + date | None, + Query( + description="Last night, inclusive. YYYY-MM-DD", + examples=["2025-12-31"], + ), + ] = None, + room_type_id: Annotated[ + int | None, + Query(description="Only this room type; omit for all", examples=[1]), + ] = None, +) -> PageResponse[DailyPerformanceResponse]: + """Nightly performance across the hotel's room types; `start`/`end` inclusive.""" + nights, total = await hotel_service.list_performance( + session, + hotel_id, + start=start, + end=end, + room_type_id=room_type_id, + limit=page.limit, + offset=page.offset, + ) + return paginate(DailyPerformanceResponse, nights, total, page) + + +@router.get("/hotels/{hotel_id}/recommendations", response_model=RecommendationResponse) +async def get_recommendation( + hotel_id: HotelId, + session: SessionDep, + # Named `stay_date` in code because `date` is the type; the query key stays `date`. + stay_date: Annotated[ + date, + Query( + alias="date", + description="The night to price. YYYY-MM-DD", + examples=["2025-01-01"], + ), + ], + room_type_id: Annotated[ + int | None, + Query( + description="The room type to price; omit for the hotel's first", + examples=[1], + ), + ] = None, +) -> RecommendationResponse: + """The active model's rate for one night, with its explanation. + + Uses only ingested data: recorded weather, else the forecast, else the + typical weather for that week. 404 if the hotel has no active model. + """ + recommendation = await recommendation_service.get_recommendation( + session, hotel_id, stay_date, room_type_id=room_type_id + ) + return RecommendationResponse.model_validate(recommendation) diff --git a/backend/foresight/routes/hotels.py b/backend/foresight/routes/hotels.py deleted file mode 100644 index 37c27d9..0000000 --- a/backend/foresight/routes/hotels.py +++ /dev/null @@ -1,124 +0,0 @@ -from datetime import date -from typing import Annotated - -from fastapi import APIRouter, Query, status - -from foresight.routes.common import PageDep, SessionDep, not_found, paginate -from foresight.schemas.common import PageResponse -from foresight.schemas.hotel import ( - DailyPerformanceResponse, - HotelRequest, - HotelResponse, - RoomTypeRequest, - RoomTypeResponse, -) -from foresight.schemas.recommendation import RecommendationResponse -from foresight.services import hotel as hotel_service -from foresight.services import recommendation as recommendation_service - -router = APIRouter(tags=["hotels"]) - - -@router.post( - "/hotels", response_model=HotelResponse, status_code=status.HTTP_201_CREATED -) -async def create_hotel(data: HotelRequest, session: SessionDep) -> HotelResponse: - hotel = await hotel_service.create_hotel(session, data) - return HotelResponse.model_validate(hotel) - - -@router.get("/hotels", response_model=PageResponse[HotelResponse]) -async def list_hotels( - session: SessionDep, page: PageDep -) -> PageResponse[HotelResponse]: - hotels, total = await hotel_service.list_hotels( - session, limit=page.limit, offset=page.offset - ) - return paginate(HotelResponse, hotels, total, page) - - -@router.get("/hotels/{hotel_id}", response_model=HotelResponse) -async def get_hotel(hotel_id: int, session: SessionDep) -> HotelResponse: - hotel = await hotel_service.get_hotel(session, hotel_id) - if hotel is None: - raise not_found("Hotel", hotel_id) - return HotelResponse.model_validate(hotel) - - -@router.post( - "/room-types", response_model=RoomTypeResponse, status_code=status.HTTP_201_CREATED -) -async def create_room_type( - data: RoomTypeRequest, session: SessionDep -) -> RoomTypeResponse: - room_type = await hotel_service.create_room_type(session, data) - if room_type is None: - raise not_found("Hotel", data.hotel_id) - return RoomTypeResponse.model_validate(room_type) - - -@router.get("/room-types/{room_type_id}", response_model=RoomTypeResponse) -async def get_room_type(room_type_id: int, session: SessionDep) -> RoomTypeResponse: - room_type = await hotel_service.get_room_type(session, room_type_id) - if room_type is None: - raise not_found("Room type", room_type_id) - return RoomTypeResponse.model_validate(room_type) - - -@router.get( - "/hotels/{hotel_id}/room-types", response_model=PageResponse[RoomTypeResponse] -) -async def list_room_types( - hotel_id: int, session: SessionDep, page: PageDep -) -> PageResponse[RoomTypeResponse]: - result = await hotel_service.list_room_types( - session, hotel_id, limit=page.limit, offset=page.offset - ) - if result is None: - raise not_found("Hotel", hotel_id) - room_types, total = result - return paginate(RoomTypeResponse, room_types, total, page) - - -@router.get( - "/hotels/{hotel_id}/performance", - response_model=PageResponse[DailyPerformanceResponse], -) -async def list_performance( - hotel_id: int, - session: SessionDep, - page: PageDep, - start: date | None = None, - end: date | None = None, - room_type_id: int | None = None, -) -> PageResponse[DailyPerformanceResponse]: - """Nightly performance across the hotel's room types; `start`/`end` inclusive.""" - result = await hotel_service.list_performance( - session, - hotel_id, - start=start, - end=end, - room_type_id=room_type_id, - limit=page.limit, - offset=page.offset, - ) - if result is None: - raise not_found("Hotel", hotel_id) - nights, total = result - return paginate(DailyPerformanceResponse, nights, total, page) - - -@router.get("/hotels/{hotel_id}/recommendations", response_model=RecommendationResponse) -async def get_recommendation( - hotel_id: int, - session: SessionDep, - # Named `stay_date` in code because `date` is the type; the query key stays `date`. - stay_date: Annotated[date, Query(alias="date")], -) -> RecommendationResponse: - """Placeholder rate for one night, in the final response shape.""" - recommendation = await recommendation_service.get_recommendation( - session, hotel_id, stay_date - ) - if recommendation is None: - raise not_found("Hotel", hotel_id) - return recommendation diff --git a/backend/foresight/routes/ingest.py b/backend/foresight/routes/ingest.py new file mode 100644 index 0000000..7d53a7c --- /dev/null +++ b/backend/foresight/routes/ingest.py @@ -0,0 +1,169 @@ +"""Ingest: pull a signal in for a hotel as a background job. + +Each POST validates and queues a job, returns 202 with it, and runs it after +the response. Poll `GET /jobs/{id}` (routes/job.py) for status and progress. + +Dates (weather and events): send neither for the routine refresh, or either to +backfill a window: + +- start and end: that window. +- start only: start to today. +- end only: back from end to the hotel's first loaded night, newest first. + +If a job stops or fails part-way, `resume_from` says where to pick up: send it +as `start` again (or as `end` for a run walking backwards). +""" + +from datetime import date +from decimal import Decimal +from typing import Annotated, Any + +from fastapi import ( + APIRouter, + BackgroundTasks, + Body, + File, + Form, + Path, + UploadFile, + status, +) +from pydantic import Field + +from foresight import worker +from foresight.routes.helpers import RequestSchema, SessionDep, example_body +from foresight.routes.job import JobResponse +from foresight.services import job as job_service + +router = APIRouter(tags=["Ingest"]) + + +# ╭──────────────────────────────────────────────────────────╮ +# │ Schemas │ +# ╰──────────────────────────────────────────────────────────╯ + +_DATE_HELP = "YYYY-MM-DD, local to the hotel's city" + + +class IngestRequest(RequestSchema): + """Both dates are optional. Leave both out for the routine refresh.""" + + start: date | None = Field( + default=None, + description=f"First day, inclusive. {_DATE_HELP}. Alone: from here to today", + examples=["2025-01-01"], + ) + end: date | None = Field( + default=None, + description=f"Last day, inclusive. {_DATE_HELP}. Alone: back from here " + "to the hotel's first loaded night", + examples=["2025-12-31"], + ) + + +# One per mode, shown as a picker in /docs. The first is the body built from +# the fields (`example_body`), so it matches the schema; the others are cut +# down from it. The empty refresh body goes last. +_FULL = example_body(IngestRequest) +_DATE_EXAMPLES: dict[str, Any] = { + "window": {"summary": "Backfill a window", "value": _FULL}, + "start_only": { + "summary": "Backfill from a date to today", + "value": {"start": _FULL["start"]}, + }, + "end_only": { + "summary": "Backfill back to the first loaded night", + "description": "Needs performance data loaded first; walks newest first.", + "value": {"end": _FULL["end"]}, + }, + "refresh": { + "summary": "Routine refresh (no dates)", + "description": "Send no body, or {}. Weather: the 16-day forecast. " + "Events: what PredictHQ revised since the last refresh.", + "value": {}, + }, +} + +IngestBody = Annotated[IngestRequest | None, Body(openapi_examples=_DATE_EXAMPLES)] + +_ACCEPTED = {"status_code": status.HTTP_202_ACCEPTED, "response_model": JobResponse} + +HotelId = Annotated[int, Path(description="The hotel's id", examples=[1])] + + +# ╭──────────────────────────────────────────────────────────╮ +# │ Endpoints │ +# ╰──────────────────────────────────────────────────────────╯ + + +@router.post("/hotels/{hotel_id}/ingest/weather", **_ACCEPTED) +async def ingest_weather( + hotel_id: HotelId, + session: SessionDep, + tasks: BackgroundTasks, + data: IngestBody = None, +) -> JobResponse: + """Open-Meteo weather for the hotel's city. + + No dates: the next 16 days of forecast. Dates: recorded weather for the + past days in the window, forecast for the next 16; days beyond that have + no source and are listed in `progress.beyond_forecast`. + """ + data = data or IngestRequest() + job = await job_service.start_weather_job( + session, hotel_id, start=data.start, end=data.end + ) + # Runs after the response, by which time get_session has committed the row. + tasks.add_task(worker.run_job, job.id) + return JobResponse.model_validate(job) + + +@router.post("/hotels/{hotel_id}/ingest/events", **_ACCEPTED) +async def ingest_events( + hotel_id: HotelId, + session: SessionDep, + tasks: BackgroundTasks, + data: IngestBody = None, +) -> JobResponse: + """PredictHQ events within 10km of the city centre. + + No dates: upcoming events revised since the last such run (the last week, + the first time). Dates: every event in the window, a month at a time. + """ + data = data or IngestRequest() + job = await job_service.start_events_job( + session, hotel_id, start=data.start, end=data.end + ) + tasks.add_task(worker.run_job, job.id) + return JobResponse.model_validate(job) + + +@router.post("/hotels/{hotel_id}/ingest/performance", **_ACCEPTED) +async def ingest_performance( + hotel_id: HotelId, + session: SessionDep, + tasks: BackgroundTasks, + files: Annotated[ + list[UploadFile], File(description="Client nightly exports (CSV)") + ], + base_rate: Annotated[ + Decimal, + Form( + gt=0, + description="The standard nightly price (rack rate), in EUR. Stored " + "on the room type; re-importing with a new value replaces it", + examples=["100.00"], + ), + ], +) -> JobResponse: + """Load client exports onto the hotel's standard room type, one file per + step. Every file is parsed before the job is queued, so a bad file is a + 422 and nothing is saved. Re-importing a file changes nothing.""" + contents = [ + (f.filename or f"file {i}", await f.read()) for i, f in enumerate(files) + ] + job, parsed = await job_service.start_performance_job( + session, hotel_id, files=contents, base_rate=base_rate + ) + tasks.add_task(worker.run_job, job.id, parsed) + return JobResponse.model_validate(job) diff --git a/backend/foresight/routes/job.py b/backend/foresight/routes/job.py new file mode 100644 index 0000000..602d3e5 --- /dev/null +++ b/backend/foresight/routes/job.py @@ -0,0 +1,78 @@ +"""Jobs: read the background jobs that ingest queues (routes/ingest.py). + +Poll `GET /jobs/{id}` for status and progress. If a job stops or fails +part-way, `resume_from` says where to pick up: send it to the same ingest +endpoint as `start` again (or as `end` for a run walking backwards). +""" + +from datetime import date, datetime +from typing import Annotated, Any + +from fastapi import APIRouter, Path +from pydantic import Field + +from foresight.database.enums import JobKind, JobStatus +from foresight.routes.helpers import ( + PageDep, + PageResponse, + ResponseSchema, + SessionDep, + paginate, +) +from foresight.services import job as job_service + +router = APIRouter(tags=["Jobs"]) + + +# ╭──────────────────────────────────────────────────────────╮ +# │ Schemas │ +# ╰──────────────────────────────────────────────────────────╯ + + +class JobResponse(ResponseSchema): + id: int = Field(description="The job's id; poll GET /jobs/{id}") + hotel_id: int = Field(description="The hotel it runs for") + kind: JobKind = Field(description="What the job does") + status: JobStatus = Field( + description="queued → running → succeeded, or stopped (a quota) or failed" + ) + params: dict[str, Any] = Field(description="Inputs, as resolved when queued") + progress: dict[str, Any] = Field(description="Counts so far; matches what's saved") + error: str | None = Field(description="Why it stopped or failed; null otherwise") + resume_from: date | None = Field( + description="Set when the run ended early: send it as `start` again, or " + "as `end` if the run was walking backwards" + ) + created_at: datetime = Field(description="When it was queued, UTC") + started_at: datetime | None = Field( + description="When it began, UTC; null if queued" + ) + finished_at: datetime | None = Field( + description="When it ended, UTC; null while unfinished" + ) + + +JobId = Annotated[int, Path(description="The job's id", examples=[1])] +HotelId = Annotated[int, Path(description="The hotel's id", examples=[1])] + + +# ╭──────────────────────────────────────────────────────────╮ +# │ Endpoints │ +# ╰──────────────────────────────────────────────────────────╯ + + +@router.get("/jobs/{job_id}", response_model=JobResponse) +async def get_job(job_id: JobId, session: SessionDep) -> JobResponse: + """One job's status, progress and resume point.""" + return JobResponse.model_validate(await job_service.get_job(session, job_id)) + + +@router.get("/hotels/{hotel_id}/jobs", response_model=PageResponse[JobResponse]) +async def list_jobs( + hotel_id: HotelId, session: SessionDep, page: PageDep +) -> PageResponse[JobResponse]: + """The hotel's jobs, newest first.""" + jobs, total = await job_service.list_jobs( + session, hotel_id, limit=page.limit, offset=page.offset + ) + return paginate(JobResponse, jobs, total, page) diff --git a/backend/foresight/routes/pricing_model.py b/backend/foresight/routes/pricing_model.py new file mode 100644 index 0000000..de17574 --- /dev/null +++ b/backend/foresight/routes/pricing_model.py @@ -0,0 +1,219 @@ +"""Pricing models: train new versions, compare them, choose which one serves. + +Training runs as a job on data that's already ingested; it never fetches. +Every run makes a new version, so earlier ones stay for comparison. Poll +`GET /jobs/{id}` for the result; `progress.model_id` is the new version. +""" + +from datetime import date, datetime +from typing import Annotated, Any + +from fastapi import APIRouter, BackgroundTasks, Body, Path, status +from pydantic import Field + +from foresight import worker +from foresight.routes.helpers import ( + PageDep, + PageResponse, + RequestSchema, + ResponseSchema, + SessionDep, + example_body, + paginate, +) +from foresight.routes.job import JobResponse +from foresight.services import job as job_service +from foresight.services.domain import pricing_model as model_service + +router = APIRouter(tags=["Models"]) + + +# ╭──────────────────────────────────────────────────────────╮ +# │ Schemas │ +# ╰──────────────────────────────────────────────────────────╯ + +_DATE_HELP = "YYYY-MM-DD" + + +class EBMSettings(RequestSchema): + """EBM settings to try. Leave any out for the library default.""" + + interactions: int | None = Field( + default=None, + ge=0, + le=50, + description="Pairwise terms such as day of week with month; 0 for " + "none. Library default: 5 x the feature count", + ) + learning_rate: float | None = Field( + default=None, + gt=0, + le=1, + description="Step size per boosting round; smaller learns slower and " + "smoother. Library default: 0.04", + ) + max_bins: int | None = Field( + default=None, + ge=8, + le=4096, + description="Most bins a numeric feature is cut into. Library default: 1024", + ) + outer_bags: int | None = Field( + default=None, + ge=2, + le=64, + description="Sub-models averaged; also drives the uncertainty signal. " + "Library default: 14", + ) + min_samples_leaf: int | None = Field( + default=None, + ge=1, + le=500, + description="Fewest nights a split may leave on a side. Library default: 4", + ) + smoothing_rounds: int | None = Field( + default=None, + ge=0, + le=5000, + description="Early rounds that smooth the learned curves. Library default: 500", + ) + + +class TrainRequest(RequestSchema): + description: str | None = Field( + default=None, + max_length=2000, + description="What this version tries, so you can tell versions apart", + examples=["What this version tries"], + ) + start: date | None = Field( + default=None, + description=f"First night to train on, inclusive. {_DATE_HELP}. " + "Omit for the first loaded night", + examples=["2025-01-01"], + ) + end: date | None = Field( + default=None, + description=f"Last night to train on, inclusive. {_DATE_HELP}. " + "Omit for the last loaded night", + examples=["2025-12-31"], + ) + activate: bool = Field( + default=True, + description="Serve this version once trained. False keeps the current " + "one serving, to compare first", + ) + settings: EBMSettings = Field( + default_factory=EBMSettings, + description="EBM settings to try; omitted ones use the library default", + ) + + +# One per use, shown as a picker in /docs. The first is the body built from +# the fields (`example_body`), so it matches the schema and its defaults; the +# others change one thing from it. Only the experiment sets settings: they +# change what training does, so the fields carry no example. +_FULL = example_body(TrainRequest) +_TRAIN_EXAMPLES: dict[str, Any] = { + "window": {"summary": "New model on a window", "value": _FULL}, + "all_nights": { + "summary": "New model on every loaded night", + "value": {"description": _FULL["description"], "activate": _FULL["activate"]}, + }, + "experiment": { + "summary": "Try settings without serving it", + "value": {**_FULL, "activate": False, "settings": {"interactions": 0}}, + }, +} + + +class PricingModelResponse(ResponseSchema): + id: int = Field(description="The model's id") + hotel_id: int = Field(description="The hotel it prices") + version: int = Field(description="Counts up per hotel, from 1") + algorithm: str = Field(description="The model type", examples=["ebm"]) + description: str | None = Field( + description="What this version tries, as given; null if none" + ) + is_active: bool = Field(description="True for the one version serving the hotel") + features: list[str] = Field(description="Model inputs, in order") + metrics: dict[str, Any] = Field( + description="Scored on the most recent nights, held out from training; " + "mae and rmse in EUR" + ) + lib_versions: dict[str, str] = Field( + description="Library versions it was trained with" + ) + data_start: date = Field(description="First night trained on, inclusive") + data_end: date = Field(description="Last night trained on, inclusive") + trained_at: datetime = Field(description="When it was saved, UTC") + + +HotelId = Annotated[int, Path(description="The hotel's id", examples=[1])] +ModelId = Annotated[int, Path(description="The model's id", examples=[1])] + + +# ╭──────────────────────────────────────────────────────────╮ +# │ Endpoints │ +# ╰──────────────────────────────────────────────────────────╯ + + +@router.post( + "/hotels/{hotel_id}/train", + status_code=status.HTTP_202_ACCEPTED, + response_model=JobResponse, +) +async def train_model( + hotel_id: HotelId, + session: SessionDep, + tasks: BackgroundTasks, + data: Annotated[TrainRequest | None, Body(openapi_examples=_TRAIN_EXAMPLES)] = None, +) -> JobResponse: + """Train a new version on the hotel's ingested performance and weather. + + Ingest first; this never fetches. Fails fast (422) if no nights are + loaded in the window. + """ + data = data or TrainRequest() + job = await job_service.start_train_job( + session, + hotel_id, + description=data.description, + start=data.start, + end=data.end, + activate=data.activate, + ebm_params=data.settings.model_dump(exclude_none=True), + ) + tasks.add_task(worker.run_job, job.id) + return JobResponse.model_validate(job) + + +@router.get( + "/hotels/{hotel_id}/models", response_model=PageResponse[PricingModelResponse] +) +async def list_models( + hotel_id: HotelId, session: SessionDep, page: PageDep +) -> PageResponse[PricingModelResponse]: + """Every version for the hotel, newest first, to compare metrics.""" + models, total = await model_service.list_models( + session, hotel_id, limit=page.limit, offset=page.offset + ) + return paginate(PricingModelResponse, models, total, page) + + +@router.get("/models/{model_id}", response_model=PricingModelResponse) +async def get_model(model_id: ModelId, session: SessionDep) -> PricingModelResponse: + """One version: what it was trained on and how it scored.""" + return PricingModelResponse.model_validate( + await model_service.get_model(session, model_id) + ) + + +@router.post("/models/{model_id}/activate", response_model=PricingModelResponse) +async def activate_model( + model_id: ModelId, session: SessionDep +) -> PricingModelResponse: + """Serve this version for its hotel; the previous one stops serving.""" + return PricingModelResponse.model_validate( + await model_service.activate_model(session, model_id) + ) diff --git a/backend/foresight/routes/signals.py b/backend/foresight/routes/signals.py deleted file mode 100644 index 7178a8e..0000000 --- a/backend/foresight/routes/signals.py +++ /dev/null @@ -1,64 +0,0 @@ -from datetime import date - -from fastapi import APIRouter - -from foresight.routes.common import PageDep, SessionDep, paginate -from foresight.schemas.common import PageResponse -from foresight.schemas.signals import ( - EventResponse, - FlightArrivalResponse, - WeatherResponse, -) -from foresight.services import signals as signals_service - -router = APIRouter(prefix="/signals", tags=["signals"]) - - -@router.get("/weather", response_model=PageResponse[WeatherResponse]) -async def list_weather( - session: SessionDep, - page: PageDep, - city: str, - start: date | None = None, - end: date | None = None, -) -> PageResponse[WeatherResponse]: - """Daily weather for a city. Forecast and recorded rows are both returned.""" - rows, total = await signals_service.list_weather( - session, city=city, start=start, end=end, limit=page.limit, offset=page.offset - ) - return paginate(WeatherResponse, rows, total, page) - - -@router.get("/events", response_model=PageResponse[EventResponse]) -async def list_events( - session: SessionDep, - page: PageDep, - city: str, - start: date | None = None, - end: date | None = None, -) -> PageResponse[EventResponse]: - """Events in a city overlapping the date range; cancelled/deleted are hidden.""" - rows, total = await signals_service.list_events( - session, city=city, start=start, end=end, limit=page.limit, offset=page.offset - ) - return paginate(EventResponse, rows, total, page) - - -@router.get("/flight-arrivals", response_model=PageResponse[FlightArrivalResponse]) -async def list_flight_arrivals( - session: SessionDep, - page: PageDep, - airport: str, - start: date | None = None, - end: date | None = None, -) -> PageResponse[FlightArrivalResponse]: - """Arrivals for an airport code exactly as stored (sources differ: DUB, EIDW).""" - rows, total = await signals_service.list_flight_arrivals( - session, - airport=airport, - start=start, - end=end, - limit=page.limit, - offset=page.offset, - ) - return paginate(FlightArrivalResponse, rows, total, page) diff --git a/backend/foresight/routes/weather.py b/backend/foresight/routes/weather.py new file mode 100644 index 0000000..f272775 --- /dev/null +++ b/backend/foresight/routes/weather.py @@ -0,0 +1,128 @@ +"""Weather for a hotel's city, and how completely it's been backfilled.""" + +from datetime import date, datetime +from typing import Annotated + +from fastapi import APIRouter, Path, Query +from pydantic import Field + +from foresight.routes.helpers import ( + PageDep, + PageResponse, + ResponseSchema, + SessionDep, + paginate, +) +from foresight.services.domain import weather as weather_service +from foresight.services.domain import weather_coverage as coverage_service + +router = APIRouter(tags=["Weather"]) + + +# ╭──────────────────────────────────────────────────────────╮ +# │ Schemas │ +# ╰──────────────────────────────────────────────────────────╯ + + +class WeatherResponse(ResponseSchema): + id: int = Field(description="The row's id") + city_id: int = Field(description="The city described") + weather_date: date = Field(description="The day described, local to the city") + temp_max_c: float | None = Field(description="Daily high, °C; null if unknown") + temp_min_c: float | None = Field(description="Daily low, °C; null if unknown") + precip_mm: float | None = Field(description="Rain and snow, mm; null if unknown") + wind_kph: float | None = Field(description="Peak wind, km/h; null if unknown") + condition: str | None = Field( + description="Summary of the day; null if unknown", + examples=["rain", "cloudy"], + ) + is_forecast: bool = Field( + description="True for a forecast (may be revised), false for recorded" + ) + source: str = Field(description="Where the row came from", examples=["open-meteo"]) + captured_at: datetime = Field(description="When this row was last fetched, UTC") + + +class DateSpanResponse(ResponseSchema): + first: date = Field(description="First day, inclusive") + last: date = Field(description="Last day, inclusive") + + +class WeatherCoverageResponse(ResponseSchema): + city_id: int = Field(description="The city checked") + start: date = Field(description="First day checked, inclusive") + end: date = Field(description="Last day checked, inclusive") + days: int = Field(description="Days in the window") + rows: int = Field(description="Days with a recorded row saved") + earliest: date | None = Field(description="First day with values") + missing: list[DateSpanResponse] = Field(description="Days with no row at all") + empty: list[DateSpanResponse] = Field( + description="Rows saved without values, usually the archive's lag" + ) + + +HotelId = Annotated[int, Path(description="The hotel's id", examples=[1])] + + +# ╭──────────────────────────────────────────────────────────╮ +# │ Endpoints │ +# ╰──────────────────────────────────────────────────────────╯ + + +@router.get("/hotels/{hotel_id}/weather", response_model=PageResponse[WeatherResponse]) +async def list_weather( + hotel_id: HotelId, + session: SessionDep, + page: PageDep, + start: Annotated[ + date | None, + Query( + description="First day, inclusive. YYYY-MM-DD", + examples=["2025-01-01"], + ), + ] = None, + end: Annotated[ + date | None, + Query( + description="Last day, inclusive. YYYY-MM-DD", + examples=["2025-12-31"], + ), + ] = None, +) -> PageResponse[WeatherResponse]: + """Daily weather for the hotel's city. Forecast and recorded rows are both + returned; `is_forecast` tells them apart.""" + rows, total = await weather_service.list_weather( + session, hotel_id, start=start, end=end, limit=page.limit, offset=page.offset + ) + return paginate(WeatherResponse, rows, total, page) + + +@router.get( + "/hotels/{hotel_id}/coverage/weather", response_model=WeatherCoverageResponse +) +async def weather_coverage( + hotel_id: HotelId, + session: SessionDep, + start: Annotated[ + date | None, + Query( + description="First day, inclusive. YYYY-MM-DD. Omit for the hotel's " + "first loaded night", + examples=["2025-01-01"], + ), + ] = None, + end: Annotated[ + date | None, + Query( + description="Last day, inclusive. YYYY-MM-DD. Omit for the hotel's " + "last loaded night", + examples=["2025-12-31"], + ), + ] = None, +) -> WeatherCoverageResponse: + """Days with recorded weather in the window, and the gaps. Dates default to + the hotel's loaded stay dates.""" + report = await coverage_service.hotel_weather_coverage( + session, hotel_id, start=start, end=end + ) + return WeatherCoverageResponse.model_validate(report) diff --git a/backend/foresight/schemas/__init__.py b/backend/foresight/schemas/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/foresight/schemas/base.py b/backend/foresight/schemas/base.py deleted file mode 100644 index a806777..0000000 --- a/backend/foresight/schemas/base.py +++ /dev/null @@ -1,19 +0,0 @@ -from pydantic import BaseModel, ConfigDict - - -class RequestSchema(BaseModel): - """Base for request bodies. Unknown fields are rejected with a 422.""" - - model_config = ConfigDict(extra="forbid") - - -class ResponseSchema(BaseModel): - """Base for response bodies. Can be built from an ORM object.""" - - model_config = ConfigDict(from_attributes=True) - - -class ExternalPayload(BaseModel): - """Base for data from external APIs. Fields we don't model are ignored.""" - - model_config = ConfigDict(extra="ignore") diff --git a/backend/foresight/schemas/common.py b/backend/foresight/schemas/common.py deleted file mode 100644 index 9160195..0000000 --- a/backend/foresight/schemas/common.py +++ /dev/null @@ -1,10 +0,0 @@ -from foresight.schemas.base import ResponseSchema - - -class PageResponse[T](ResponseSchema): - """One page of a list endpoint: the rows plus where they sit in the full set.""" - - items: list[T] - total: int # rows matching the filters, ignoring limit/offset - limit: int - offset: int diff --git a/backend/foresight/schemas/example_widget.py b/backend/foresight/schemas/example_widget.py deleted file mode 100644 index c606520..0000000 --- a/backend/foresight/schemas/example_widget.py +++ /dev/null @@ -1,26 +0,0 @@ -"""REFERENCE EXAMPLE — copy this pattern; do not use in production. - -Pydantic shapes for the `Widget` reference slice. - -Layer rules shown here: -- `*Request` schemas describe a request body: only the fields a client may set. -- `*Response` schemas describe what an endpoint returns, built from an ORM - object. Each lists its fields explicitly; Request and Response never inherit - from each other. -- See `agents/schemas.md` for the full standard. -""" - -from foresight.schemas.base import RequestSchema, ResponseSchema - - -class WidgetRequest(RequestSchema): - name: str - description: str | None = None - quantity: int = 0 - - -class WidgetResponse(ResponseSchema): - id: int - name: str - description: str | None - quantity: int diff --git a/backend/foresight/schemas/hotel.py b/backend/foresight/schemas/hotel.py deleted file mode 100644 index 27e8e40..0000000 --- a/backend/foresight/schemas/hotel.py +++ /dev/null @@ -1,85 +0,0 @@ -"""API shapes for hotel data.""" - -from datetime import date -from decimal import Decimal - -from pydantic import computed_field - -from foresight.schemas.base import RequestSchema, ResponseSchema - - -class HotelRequest(RequestSchema): - name: str - city: str - total_rooms: int - latitude: float | None = None - longitude: float | None = None - - -class HotelResponse(ResponseSchema): - id: int - name: str - city: str - total_rooms: int - latitude: float | None - longitude: float | None - - -class RoomTypeRequest(RequestSchema): - hotel_id: int - name: str - room_count: int - base_rate: Decimal - - -class RoomTypeResponse(ResponseSchema): - id: int - hotel_id: int - name: str - room_count: int - base_rate: Decimal - - -class DailyPerformanceRequest(RequestSchema): - room_type_id: int - stay_date: date - rate: Decimal | None = None - rooms_available: int - rooms_sold: int = 0 - room_revenue: Decimal = Decimal("0") - - -class DailyPerformanceResponse(ResponseSchema): - """Adds the three standard hotel metrics, calculated rather than stored.""" - - id: int - room_type_id: int - stay_date: date - rate: Decimal | None - rooms_available: int - rooms_sold: int - room_revenue: Decimal - - @computed_field - @property - def occupancy(self) -> float | None: - """Share of rooms sold, 0-1.""" - if not self.rooms_available: - return None - return round(self.rooms_sold / self.rooms_available, 4) - - @computed_field - @property - def adr(self) -> Decimal | None: - """Average Daily Rate: revenue per room sold.""" - if not self.rooms_sold: - return None - return round(self.room_revenue / self.rooms_sold, 2) - - @computed_field - @property - def revpar(self) -> Decimal | None: - """Revenue Per Available Room: revenue per room we had to sell.""" - if not self.rooms_available: - return None - return round(self.room_revenue / self.rooms_available, 2) diff --git a/backend/foresight/schemas/recommendation.py b/backend/foresight/schemas/recommendation.py deleted file mode 100644 index b15027b..0000000 --- a/backend/foresight/schemas/recommendation.py +++ /dev/null @@ -1,35 +0,0 @@ -from datetime import date -from decimal import Decimal -from typing import Literal - -from foresight.schemas.base import ResponseSchema - - -class FactorResponse(ResponseSchema): - """One signal's pull on the recommended rate.""" - - signal: str # e.g. "events", "weather", "flights" - direction: Literal["up", "down", "neutral"] - detail: str - - -class ExplanationResponse(ResponseSchema): - summary: str - factors: list[FactorResponse] - - -class ConfidenceIntervalResponse(ResponseSchema): - lower: Decimal - upper: Decimal - level: float # coverage, e.g. 0.95 - - -class RecommendationResponse(ResponseSchema): - hotel_id: int - stay_date: date - recommended_rate: Decimal - currency: str - explanation: ExplanationResponse - confidence_interval: ConfidenceIntervalResponse - model_version: str - is_placeholder: bool # true until the pricing model is wired in diff --git a/backend/foresight/schemas/signals.py b/backend/foresight/schemas/signals.py deleted file mode 100644 index 16f57d2..0000000 --- a/backend/foresight/schemas/signals.py +++ /dev/null @@ -1,62 +0,0 @@ -from datetime import date, datetime -from decimal import Decimal - -from foresight.ingest.normalize import ( - DatePrecision, - EventCategory, - EventStatus, - Source, -) -from foresight.schemas.base import ResponseSchema - - -class WeatherResponse(ResponseSchema): - id: int - city: str - weather_date: date - temp_max_c: float | None - temp_min_c: float | None - precip_mm: float | None - wind_kph: float | None - condition: str | None - is_forecast: bool - source: str - captured_at: datetime - - -class EventResponse(ResponseSchema): - """A merged event. Raw per-source sightings (`event_observations`) stay internal.""" - - id: int - title: str - category: EventCategory - venue: str | None - city: str - country: str | None - latitude: float | None - longitude: float | None - start_local_date: date - end_local_date: date # inclusive - start_at_utc: datetime | None - end_at_utc: datetime | None - timezone: str - date_precision: DatePrecision - expected_attendance: int | None - impact_rank: int | None - local_rank: int | None - confidence: float - status: EventStatus - url: str | None - primary_source: Source - first_seen_at: datetime - last_seen_at: datetime - - -class FlightArrivalResponse(ResponseSchema): - id: int - airport: str - arrival_date: date - flight_count: int | None - avg_fare_eur: Decimal | None - source: str - captured_at: datetime diff --git a/backend/foresight/services/adapters/__init__.py b/backend/foresight/services/adapters/__init__.py new file mode 100644 index 0000000..f70acc2 --- /dev/null +++ b/backend/foresight/services/adapters/__init__.py @@ -0,0 +1,4 @@ +"""External data adapters: call a third-party API or parse a file, and return +normalized rows. Adapters never touch the database; `services/ingest/` saves +what they return. +""" diff --git a/backend/foresight/services/adapters/aerodatabox.py b/backend/foresight/services/adapters/aerodatabox.py new file mode 100644 index 0000000..f22f4fe --- /dev/null +++ b/backend/foresight/services/adapters/aerodatabox.py @@ -0,0 +1,94 @@ +"""AeroDataBox adapter (via RapidAPI): scheduled arrivals per airport per day. + +Each day costs about 4 API units; the free plan has 400 a month. No DB writes. +""" + +from __future__ import annotations + +import asyncio +from datetime import date, datetime, time, timedelta +from typing import TYPE_CHECKING, Any + +import httpx + +if TYPE_CHECKING: + from collections.abc import Awaitable, Callable + + type Sleep = Callable[[float], Awaitable[None]] + +HOST = "aerodatabox.p.rapidapi.com" +SOURCE = "aerodatabox" +# RapidAPI's basic plan allows about one request a second. +PACE_SECONDS = 1.1 + +_PARAMS = { + "direction": "Arrival", + "withCancelled": "false", + "withCodeshared": "false", + "withCargo": "false", + "withPrivate": "false", + "withLocation": "false", +} + + +class RateLimitedError(Exception): + """AeroDataBox answered 429: the monthly quota, or requests too fast.""" + + +def client( + api_key: str, *, transport: httpx.AsyncBaseTransport | None = None +) -> httpx.AsyncClient: + """An authenticated client; close it with `async with`.""" + return httpx.AsyncClient( + base_url=f"https://{HOST}", + headers={"X-RapidAPI-Key": api_key, "X-RapidAPI-Host": HOST}, + timeout=30.0, + transport=transport, + ) + + +async def count_arrivals( + http: httpx.AsyncClient, + day: date, + *, + airport_iata: str, + sleep: Sleep = asyncio.sleep, +) -> int: + """Flights scheduled to arrive at `airport_iata` on `day` (airport local time). + + The API allows at most 12 hours per request, so a day is two requests. + Delayed flights scheduled for the day before are skipped, and a flight + returned by both windows counts once. + + Raises: + RateLimitedError: If AeroDataBox answers 429. + httpx.HTTPStatusError: If a request fails otherwise. + """ + seen: set[tuple[Any, ...]] = set() + midnight = datetime.combine(day, time.min) + for window_start in (midnight, midnight + timedelta(hours=12)): + window_end = window_start + timedelta(hours=11, minutes=59) + await sleep(PACE_SECONDS) + response = await _get( + http, + f"/flights/airports/iata/{airport_iata}" + f"/{window_start:%Y-%m-%dT%H:%M}/{window_end:%Y-%m-%dT%H:%M}", + ) + if response.status_code == 204: # no flights in this window + continue + for item in response.json().get("arrivals", []): + move = item.get("movement") or {} + scheduled = move.get("scheduledTime") or {} + if not scheduled.get("local", "").startswith(day.isoformat()): + continue + origin = (move.get("airport") or {}).get("iata") + seen.add((item.get("number"), scheduled.get("utc"), origin)) + return len(seen) + + +async def _get(http: httpx.AsyncClient, path: str) -> httpx.Response: + response = await http.get(path, params=_PARAMS) + if response.status_code == 429: + raise RateLimitedError(response.text[:300]) + response.raise_for_status() + return response diff --git a/backend/foresight/ingest/asknews.py b/backend/foresight/services/adapters/asknews.py similarity index 94% rename from backend/foresight/ingest/asknews.py rename to backend/foresight/services/adapters/asknews.py index 7b507e7..c05bc73 100644 --- a/backend/foresight/ingest/asknews.py +++ b/backend/foresight/services/adapters/asknews.py @@ -18,7 +18,6 @@ from __future__ import annotations -import asyncio import re from collections import Counter from dataclasses import dataclass, field @@ -26,16 +25,13 @@ from enum import StrEnum from typing import TYPE_CHECKING, Any -from foresight.ingest.normalize import ( - DatePrecision, - EventCategory, - EventStatus, +from foresight.database.enums import DatePrecision, EventCategory, EventStatus, Source +from foresight.services.adapters.normalize import ( NormalizedEvent, - Source, normalize_title, slugify_city, ) -from foresight.ingest.textdates import ( +from foresight.services.adapters.textdates import ( MAX_LOOKAHEAD_DAYS, looks_retrospective, resolve_date, @@ -58,8 +54,6 @@ "announces dates lineup tickets venue" ) -_MAX_ATTEMPTS = 3 -_RETRY_STATUSES = frozenset({429, 500, 502, 503, 504}) _TOKEN_EXPIRY_SLACK_SECONDS = 60 _CATEGORY_KEYWORDS: tuple[tuple[EventCategory, tuple[str, ...]], ...] = ( @@ -209,16 +203,10 @@ def __init__( self._token_expires_at: datetime | None = None async def _request(self, method: str, url: str, **kwargs: Any) -> httpx.Response: - """Issue a request, backing off once per retryable status.""" - for attempt in range(1, _MAX_ATTEMPTS + 1): - response = await self._http.request(method, url, **kwargs) - if response.status_code not in _RETRY_STATUSES or attempt == _MAX_ATTEMPTS: - response.raise_for_status() - return response - retry_after = response.headers.get("retry-after") - delay = float(retry_after) if retry_after and retry_after.isdigit() else 2.0 - await asyncio.sleep(delay * attempt) - raise AssertionError("unreachable") + """Issue a request, raising for any failure.""" + response = await self._http.request(method, url, **kwargs) + response.raise_for_status() + return response async def _access_token(self) -> str: now = datetime.now(UTC) diff --git a/backend/foresight/services/adapters/cso.py b/backend/foresight/services/adapters/cso.py new file mode 100644 index 0000000..e9f1623 --- /dev/null +++ b/backend/foresight/services/adapters/cso.py @@ -0,0 +1,66 @@ +"""CSO adapter: monthly passenger numbers for Irish airports (table TAM07). + +Not persisted yet: `flight_arrivals` is one row per airport per day and CSO +reports per month. Kept as an adapter until a monthly table or a daily +spreading rule is decided. No DB writes. +""" + +from __future__ import annotations + +import csv +import io +from dataclasses import dataclass +from datetime import date, datetime +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + import httpx + + +URL = ( + "https://ws.cso.ie/public/api.restful/PxStat.Data.Cube_API.ReadDataset" + "/TAM07/CSV/1.0/en" +) + + +@dataclass(frozen=True, slots=True) +class MonthlyPassengers: + month: date # first of the month + airport: str # CSO name, e.g. "Dublin" + country: str + direction: str + flight_type: str + passengers_thousands: float | None + + +async def fetch_monthly_passengers( + http: httpx.AsyncClient, + start: date, + end: date, + *, + airport: str = "Dublin", +) -> list[MonthlyPassengers]: + """Passenger rows for every month touching `start`..`end`.""" + response = await http.get(URL, follow_redirects=True) + response.raise_for_status() + first_month = start.replace(day=1) + out = [] + for row in csv.DictReader(io.StringIO(response.content.decode("utf-8-sig"))): + if row["Statistic Label"] != "Passengers": + continue + if row["Airports in Ireland"] != airport: + continue + month = datetime.strptime(row["Month"], "%Y %B").date() + if not first_month <= month <= end: + continue + out.append( + MonthlyPassengers( + month=month, + airport=airport, + country=row["Country"], + direction=row["Direction"], + flight_type=row["Flight Type"], + passengers_thousands=float(row["VALUE"]) if row["VALUE"] else None, + ) + ) + return out diff --git a/backend/foresight/services/parsers/hotel_data_parser.py b/backend/foresight/services/adapters/hotel_csv.py similarity index 81% rename from backend/foresight/services/parsers/hotel_data_parser.py rename to backend/foresight/services/adapters/hotel_csv.py index 1f5117f..d5a65da 100644 --- a/backend/foresight/services/parsers/hotel_data_parser.py +++ b/backend/foresight/services/adapters/hotel_csv.py @@ -1,27 +1,28 @@ +"""Client hotel exports: parse a nightly CSV into `daily_performance` rows. + +Never touches the database; `services/ingest/performance.py` saves the rows. +""" + +from __future__ import annotations + import logging from decimal import Decimal import pandas as pd -from foresight.repository.hotel import ( - upsert_daily_performance, - upsert_hotel, - upsert_room_type, -) - # The client draws no distinction between rooms. They price one standard room # and apply their own multipliers downstream, so every night hangs off a single # room type rather than the schema's per-type breakdown. ROOM_TYPE = "Standard" +# What a malformed file raises while parsing. +PARSE_ERRORS = (ValueError, KeyError, pd.errors.ParserError) + class HotelDataParser: """ Parser for client hotel data used in the Foresight pipeline. - Client data files should be placed in backend/data (not committed to - git). Point --input at the file's path within that folder. - Purpose: - Load raw hotel data from a client file - Clean and standardize the data @@ -34,20 +35,15 @@ class HotelDataParser: 3. transform_data() 4. validate_data() - Persisting is a separate step, so the parser itself never touches a - session. `persist()` writes the result through the repository layer. + The parser never touches the database; `services/ingest/performance.py` + saves what it returns. Main usage: - parser = HotelDataParser(input_path, hotel_name, city, base_rate) + parser = HotelDataParser(file) parser.run() Attributes: input_path: Path to the client input file - hotel_name: Name of the hotel this file belongs to - city: City the hotel is in, which the client export does not carry - base_rate: Standard room rate the client prices against - latitude: Hotel latitude, optional, left alone on reload when omitted - longitude: Hotel longitude, optional, left alone on reload when omitted schema: Expected Foresight schema (required output columns) raw_data: Original loaded client data cleaned_data: Cleaned version of the client data @@ -69,38 +65,18 @@ class HotelDataParser: DEFAULT_SCHEMA = ["stay_date", "rooms_available", "rooms_sold", "room_revenue"] - def __init__( - self, - input_path, - hotel_name, - city, - base_rate, - latitude=None, - longitude=None, - schema=None, - column_mapping=None, - ): + def __init__(self, input_path, schema=None, column_mapping=None): """ Initialize the hotel data parser. Args: - input_path: Path to the client hotel data file. - hotel_name: Name of the hotel this file belongs to. - city: City the hotel is in. - base_rate: Standard room rate the client prices against. - latitude: Hotel latitude, which the events ingester needs. - longitude: Hotel longitude, which the events ingester needs. + input_path: Path to, or file-like object of, a client export. + schema: Optional list of required output columns. column_mapping: Optional mapping of client column names to Foresight column names. """ - # Stores anything needed throughout the pipeline self.input_path = input_path - self.hotel_name = hotel_name - self.city = city - self.base_rate = Decimal(str(base_rate)) - self.latitude = latitude - self.longitude = longitude self.schema = schema or self.DEFAULT_SCHEMA self.column_mapping = column_mapping or self.DEFAULT_COLUMN_MAPPING @@ -109,11 +85,6 @@ def __init__( self.cleaned_data: pd.DataFrame | None = None self.transformed_data: pd.DataFrame | None = None - # Set up logging for parser runs - logging.basicConfig( - level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s" - ) - self.logger = logging.getLogger(__name__) def load_data(self): @@ -251,10 +222,8 @@ def transform_data(self): columns=self.column_mapping ) - # Keep only the columns the schema cares about, plus the hotel - # identifier needed to attribute rows once they land in the DB. + # Keep only the columns the schema cares about. self.transformed_data = self.transformed_data[self.schema].copy() - self.transformed_data.insert(0, "hotel_name", self.hotel_name) self.logger.info("Data transformation completed.") @@ -377,23 +346,3 @@ def average_rate(room_revenue, rooms_sold): if not rooms_sold: return None return (Decimal(str(room_revenue)) / int(rooms_sold)).quantize(Decimal("0.01")) - - -async def persist(parser, session): - """Write a parsed hotel's nights through the repository, without committing.""" - hotel_id = await upsert_hotel( - session, - name=parser.hotel_name, - city=parser.city, - total_rooms=parser.total_rooms(), - latitude=parser.latitude, - longitude=parser.longitude, - ) - room_type_id = await upsert_room_type( - session, - hotel_id=hotel_id, - name=ROOM_TYPE, - room_count=parser.total_rooms(), - base_rate=parser.base_rate, - ) - return await upsert_daily_performance(session, parser.rows(room_type_id)) diff --git a/backend/foresight/ingest/normalize.py b/backend/foresight/services/adapters/normalize.py similarity index 83% rename from backend/foresight/ingest/normalize.py rename to backend/foresight/services/adapters/normalize.py index 6c1d56b..71c743b 100644 --- a/backend/foresight/ingest/normalize.py +++ b/backend/foresight/services/adapters/normalize.py @@ -12,66 +12,15 @@ import unicodedata from dataclasses import dataclass, field from datetime import UTC, date, datetime, tzinfo -from enum import StrEnum from typing import TYPE_CHECKING, Any from zoneinfo import ZoneInfo, ZoneInfoNotFoundError +from foresight.database.enums import DatePrecision, EventCategory, EventStatus, Source + if TYPE_CHECKING: from collections.abc import Sequence -class Source(StrEnum): - PREDICTHQ = "predicthq" - ASKNEWS = "asknews" - - -class EventStatus(StrEnum): - ACTIVE = "active" - PROVISIONAL = "provisional" # extracted from text, not yet corroborated - CANCELLED = "cancelled" - DELETED = "deleted" # withdrawn upstream - - -class DatePrecision(StrEnum): - """How precisely the source pinned the date. - - Anything coarser than DAY is unusable for per-night pricing but still worth - keeping as early signal, so it is recorded rather than rounded away. - """ - - DAY = "day" - MONTH = "month" - QUARTER = "quarter" - - -class EventCategory(StrEnum): - """PredictHQ's taxonomy, adopted project-wide. - - Text sources classify into these buckets instead of inventing a second - vocabulary. - """ - - ACADEMIC = "academic" - AIRPORT_DELAYS = "airport-delays" - COMMUNITY = "community" - CONCERTS = "concerts" - CONFERENCES = "conferences" - DAYLIGHT_SAVINGS = "daylight-savings" - DISASTERS = "disasters" - EXPOS = "expos" - FESTIVALS = "festivals" - HEALTH_WARNINGS = "health-warnings" - OBSERVANCES = "observances" - PERFORMING_ARTS = "performing-arts" - POLITICS = "politics" - PUBLIC_HOLIDAYS = "public-holidays" - SCHOOL_HOLIDAYS = "school-holidays" - SEVERE_WEATHER = "severe-weather" - SPORTS = "sports" - TERROR = "terror" - UNKNOWN = "unknown" - - _BRACKETED = re.compile(r"[(\[{][^)\]}]*[)\]}]") _PRESENTER_TAIL = re.compile( r"\s*[-–—:|]?\s*(?:presented|sponsored|brought)\s+by\b.*$", re.I @@ -233,8 +182,6 @@ def as_row(self) -> dict[str, Any]: "title_norm": self.title_norm, "category": self.category, "venue": self.venue, - "city": self.city, - "city_slug": self.city_slug, "country": self.country, "latitude": self.latitude, "longitude": self.longitude, diff --git a/backend/foresight/services/adapters/open_meteo.py b/backend/foresight/services/adapters/open_meteo.py new file mode 100644 index 0000000..a5e5f6b --- /dev/null +++ b/backend/foresight/services/adapters/open_meteo.py @@ -0,0 +1,159 @@ +"""Open-Meteo adapter: daily forecasts and recorded weather as `weather` rows. + +Uses the plain JSON API. With `timezone` set, Open-Meteo returns each day as +the local calendar date, so `weather_date` joins to a hotel's stay_date as-is. +No DB writes. +""" + +from __future__ import annotations + +from datetime import date, timedelta +from typing import TYPE_CHECKING, Any + +import httpx + +if TYPE_CHECKING: + from collections.abc import Iterator + + +FORECAST_URL = "https://api.open-meteo.com/v1/forecast" +ARCHIVE_URL = "https://archive-api.open-meteo.com/v1/archive" +SOURCE = "open-meteo" +# The forecast reaches 16 days; further-out nights have no forecast row. +FORECAST_DAYS = 16 +# A year per archive request keeps each call small; a failure only redoes that year. +CHUNK_DAYS = 365 + +_DAILY = ( + "temperature_2m_max", + "temperature_2m_min", + "precipitation_sum", + "wind_speed_10m_max", + "weather_code", +) + + +def client(*, transport: httpx.AsyncBaseTransport | None = None) -> httpx.AsyncClient: + """A client for either Open-Meteo endpoint; close it with `async with`.""" + return httpx.AsyncClient(timeout=60.0, transport=transport) + + +async def fetch_forecast( + http: httpx.AsyncClient, + *, + latitude: float, + longitude: float, + timezone: str = "auto", + days: int = FORECAST_DAYS, +) -> list[dict[str, Any]]: + """The next `days` of forecast at a point, as `weather` rows.""" + body = await _get( + http, + FORECAST_URL, + { + "latitude": latitude, + "longitude": longitude, + "daily": ",".join(_DAILY), + "timezone": timezone, + "forecast_days": days, + }, + ) + return rows(body, is_forecast=True) + + +async def fetch_actuals( + http: httpx.AsyncClient, + *, + latitude: float, + longitude: float, + start: date, + end: date, + timezone: str = "auto", + today: date | None = None, +) -> list[dict[str, Any]]: + """Recorded weather for `start`..`end` inclusive, as `weather` rows. + + The archive rejects any date after today outright rather than returning + empty days, so `end` is clamped to today here. + """ + end = min(end, today or date.today()) + out: list[dict[str, Any]] = [] + for chunk_start, chunk_end in chunks(start, end): + body = await _get( + http, + ARCHIVE_URL, + { + "latitude": latitude, + "longitude": longitude, + "daily": ",".join(_DAILY), + "timezone": timezone, + "start_date": chunk_start.isoformat(), + "end_date": chunk_end.isoformat(), + }, + ) + out += rows(body, is_forecast=False) + return out + + +def rows(body: dict[str, Any], *, is_forecast: bool) -> list[dict[str, Any]]: + """An Open-Meteo `daily` block as `weather` rows, without `city_id`. + + Missing values are null. + """ + daily = body["daily"] + temp_max, temp_min, precip, wind, code = (daily[name] for name in _DAILY) + return [ + { + "weather_date": date.fromisoformat(day), + "temp_max_c": temp_max[i], + "temp_min_c": temp_min[i], + "precip_mm": precip[i], + "wind_kph": wind[i], + "condition": condition(code[i]), + "is_forecast": is_forecast, + "source": SOURCE, + } + for i, day in enumerate(daily["time"]) + ] + + +def condition(code: float | None) -> str | None: + """A WMO 4677 code as the short label `condition` carries.""" + if code is None: + return None + code = int(code) + if code == 0: + return "clear" + if code <= 2: + return "partly cloudy" + if code == 3: + return "cloudy" + if code in (45, 48): + return "fog" + if code <= 57: + return "drizzle" + if code <= 67: + return "rain" + if code <= 77: + return "snow" + if code <= 82: + return "rain showers" + if code <= 86: + return "snow showers" + return "thunderstorm" + + +def chunks(start: date, end: date) -> Iterator[tuple[date, date]]: + """Split `start`..`end` into back-to-back spans of at most CHUNK_DAYS days.""" + while start <= end: + chunk_end = min(start + timedelta(days=CHUNK_DAYS - 1), end) + yield start, chunk_end + start = chunk_end + timedelta(days=1) + + +async def _get( + http: httpx.AsyncClient, url: str, params: dict[str, Any] +) -> dict[str, Any]: + response = await http.get(url, params=params) + response.raise_for_status() + return response.json() diff --git a/backend/foresight/services/adapters/opensky.py b/backend/foresight/services/adapters/opensky.py new file mode 100644 index 0000000..c303cf0 --- /dev/null +++ b/backend/foresight/services/adapters/opensky.py @@ -0,0 +1,125 @@ +"""OpenSky adapter: arrivals per airport per day, from ADS-B tracking. + +Historical only: OpenSky has flights up to yesterday (UTC). A request may span +at most 2 UTC days and costs credits from a daily allowance, so a long range is +fetched in 2-day chunks and stops cleanly when the allowance runs out. No DB +writes. +""" + +from __future__ import annotations + +from collections import Counter +from datetime import UTC, date, datetime, time, timedelta +from typing import TYPE_CHECKING +from zoneinfo import ZoneInfo + +import httpx + +if TYPE_CHECKING: + from collections.abc import AsyncIterator + +ARRIVAL_URL = "https://opensky-network.org/api/flights/arrival" +TOKEN_URL = ( + "https://auth.opensky-network.org/auth/realms/opensky-network" + "/protocol/openid-connect/token" +) +SOURCE = "opensky" +CHUNK_DAYS = 2 + + +class CreditsExhaustedError(Exception): + """OpenSky's daily credit allowance is used up; `retry_after` is in seconds.""" + + def __init__(self, retry_after: str | None) -> None: + super().__init__(f"OpenSky credit limit reached, retry in {retry_after}s") + self.retry_after = retry_after + + +class OpenSkyClient: + """Authenticated OpenSky access; refreshes its token when it expires.""" + + def __init__( + self, + client_id: str, + client_secret: str, + *, + transport: httpx.AsyncBaseTransport | None = None, + ) -> None: + self._client_id = client_id + self._client_secret = client_secret + self._http = httpx.AsyncClient(timeout=60.0, transport=transport) + self._token: str | None = None + + async def __aenter__(self) -> OpenSkyClient: + return self + + async def __aexit__(self, *_: object) -> None: + await self._http.aclose() + + async def daily_arrivals( + self, start: date, end: date, *, airport_icao: str, timezone: str + ) -> AsyncIterator[tuple[date, int]]: + """(local day, arrival count) for each day from `start` to `end`. + + `end` is clamped to yesterday (UTC). Yields per chunk, oldest first, + so a caller can save as it goes; days with no flights yield 0. + + Raises: + CreditsExhaustedError: When the daily allowance runs out; every + day yielded before it is complete. + """ + zone = ZoneInfo(timezone) + end = min(end, datetime.now(UTC).date() - timedelta(days=1)) + first = start + while first <= end: + last = min(first + timedelta(days=CHUNK_DAYS - 1), end) + counts: Counter[date] = Counter() + for flight in await self._arrivals(first, last, airport_icao): + landed = datetime.fromtimestamp(flight["lastSeen"], UTC) + counts[landed.astimezone(zone).date()] += 1 + day = first + while day <= last: + yield day, counts[day] + day += timedelta(days=1) + first = last + timedelta(days=1) + + async def _arrivals(self, first: date, last: date, airport_icao: str) -> list: + # Midnight UTC to the last second of `last`: never more than 2 UTC days. + begin = datetime.combine(first, time.min, UTC) + until = datetime.combine(last + timedelta(days=1), time.min, UTC) + params = { + "airport": airport_icao, + "begin": int(begin.timestamp()), + "end": int(until.timestamp()) - 1, + } + for _ in range(2): + response = await self._http.get( + ARRIVAL_URL, + params=params, + headers={"Authorization": f"Bearer {await self._access_token()}"}, + ) + if response.status_code != 401: + break + self._token = None # expired: fetch a new one and retry once + if response.status_code == 429: + raise CreditsExhaustedError( + response.headers.get("X-Rate-Limit-Retry-After-Seconds") + ) + if response.status_code == 404: # no flights in this chunk + return [] + response.raise_for_status() + return response.json() + + async def _access_token(self) -> str: + if self._token is None: + response = await self._http.post( + TOKEN_URL, + data={ + "grant_type": "client_credentials", + "client_id": self._client_id, + "client_secret": self._client_secret, + }, + ) + response.raise_for_status() + self._token = response.json()["access_token"] + return self._token diff --git a/backend/foresight/services/adapters/predicthq.py b/backend/foresight/services/adapters/predicthq.py new file mode 100644 index 0000000..3002346 --- /dev/null +++ b/backend/foresight/services/adapters/predicthq.py @@ -0,0 +1,228 @@ +"""PredictHQ adapter: events near a hotel as NormalizedEvents. No DB writes. + +One client for both the recent-revisions sync and the date-range backfill; +they differ only in query parameters. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from datetime import UTC, date, datetime, timedelta +from typing import TYPE_CHECKING, Any + +import httpx + +from foresight.database.enums import DatePrecision, EventCategory, EventStatus, Source +from foresight.services.adapters.normalize import ( + NormalizedEvent, + coords_from_geojson, + to_local_date, +) + +if TYPE_CHECKING: + from collections.abc import Mapping + +log = logging.getLogger(__name__) + +API_URL = "https://api.predicthq.com/v1/events/" +PAGE_LIMIT = 500 +# PredictHQ's docs don't name the status for an exhausted plan; treat both. +_OUT_OF_CREDITS = frozenset({402, 403}) +# The API returns only `active` unless asked; an unseen deletion would leave a +# cancelled event in the demand forecast indefinitely. +STATES = "active,predicted,deleted" + +_STATUS = { + "active": EventStatus.ACTIVE, + "predicted": EventStatus.PROVISIONAL, + "deleted": EventStatus.DELETED, +} + + +@dataclass(slots=True) +class Market: + """Where to search: the hotel's city and the centre of the radius.""" + + city: str + latitude: float + longitude: float + + +@dataclass(slots=True) +class Rejected: + source_ref: str + reason: str + + +@dataclass(slots=True) +class FetchResult: + events: list[NormalizedEvent] = field(default_factory=list) + rejected: list[Rejected] = field(default_factory=list) + overflowed: bool = False # the plan truncated results + + @property + def fetched(self) -> int: + return len(self.events) + len(self.rejected) + + +class PredictHQLimitError(Exception): + """PredictHQ refused further requests; the run should stop.""" + + +class OutOfCreditsError(PredictHQLimitError): + """The plan's quota or credits are used up.""" + + +class RateLimitedError(PredictHQLimitError): + """PredictHQ answered 429.""" + + +def client( + token: str, *, transport: httpx.AsyncBaseTransport | None = None +) -> httpx.AsyncClient: + """An authenticated client; close it with `async with`.""" + return httpx.AsyncClient( + headers={"Authorization": f"Bearer {token}", "Accept": "application/json"}, + timeout=30.0, + transport=transport, + ) + + +def recent_params( + market: Market, *, updated_since: datetime, radius: str = "10km" +) -> dict[str, Any]: + """Upcoming events near `market` revised upstream since `updated_since`.""" + return { + "within": f"{radius}@{market.latitude},{market.longitude}", + "updated.gte": updated_since.astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%S"), + # A day of slack so an event running tonight in a zone behind UTC stays in. + "active.gte": (datetime.now(UTC).date() - timedelta(days=1)).isoformat(), + "state": STATES, + "limit": PAGE_LIMIT, + "sort": "updated", + } + + +def window_params( + market: Market, start: date, end: date, *, radius: str = "10km" +) -> dict[str, Any]: + """Every event active from `start` to `end`, with a day of slack each side.""" + return { + "within": f"{radius}@{market.latitude},{market.longitude}", + "active.gte": (start - timedelta(days=1)).isoformat(), + "active.lte": (end + timedelta(days=1)).isoformat(), + "state": STATES, + "limit": PAGE_LIMIT, + "sort": "start", + } + + +async def fetch( + http: httpx.AsyncClient, + params: dict[str, Any], + *, + city: str, + into: FetchResult | None = None, +) -> FetchResult: + """Every page of a query, normalized. Bad records are rejected, not raised. + + `city` is the hotel's market and becomes every event's city, so a suburb + venue still shares a dedupe key with a news article naming the city. Pass + `into` to accumulate several queries in one result: on a limit error it + keeps every page fetched before the error. + + Raises: + OutOfCreditsError: On a 402 or 403. + RateLimitedError: On a 429. + httpx.HTTPError: On any other failure. + """ + result = into if into is not None else FetchResult() + url: str | None = API_URL + query: dict[str, Any] | None = params + while url: + body = await get_page(http, url, query) + if body.get("overflow"): + log.warning("PredictHQ overflow: plan limit truncated results") + result.overflowed = True + for raw in body.get("results", []): + try: + result.events.append(to_normalized(raw, city=city)) + except (KeyError, TypeError, ValueError) as exc: + ref = str(raw.get("id", "?")) if isinstance(raw, dict) else "?" + log.warning("rejected PredictHQ event %s: %s", ref, exc) + result.rejected.append(Rejected(ref, rejection_reason(exc))) + # `next` already carries every query parameter. + url, query = body.get("next"), None + return result + + +async def get_page( + http: httpx.AsyncClient, + url: str, + params: dict[str, Any] | None, +) -> dict[str, Any]: + """GET one page. No retries: a failed job records where to resume.""" + response = await http.get(url, params=params) + if response.status_code == 429: + raise RateLimitedError(f"HTTP 429: {response.text[:300]}") + if response.status_code in _OUT_OF_CREDITS: + raise OutOfCreditsError(f"HTTP {response.status_code}: {response.text[:300]}") + response.raise_for_status() + return response.json() + + +def to_normalized(raw: Mapping[str, Any], *, city: str) -> NormalizedEvent: + start = _parse_instant(raw["start"]) + end = _parse_instant(raw["end"]) if raw.get("end") else start + timezone = raw.get("timezone") + latitude, longitude = coords_from_geojson(raw.get("location")) + + return NormalizedEvent( + source=Source.PREDICTHQ, + source_ref=raw["id"], + title=raw["title"], + city=city, + start_local_date=to_local_date(start, timezone), + end_local_date=to_local_date(end, timezone), + timezone=timezone or "UTC", + category=_category(raw.get("category")), + date_precision=DatePrecision.DAY, + status=_STATUS[raw["state"]], + confidence=1.0, + venue=_venue(raw.get("entities") or []), + country=raw.get("country"), + latitude=latitude, + longitude=longitude, + start_at_utc=start, + end_at_utc=end, + expected_attendance=raw.get("phq_attendance"), + impact_rank=raw.get("rank"), + local_rank=raw.get("local_rank"), + source_updated_at=_parse_instant(raw["updated"]) + if raw.get("updated") + else None, + payload=dict(raw), + ) + + +def _parse_instant(value: str) -> datetime: + moment = datetime.fromisoformat(value) + return moment if moment.tzinfo else moment.replace(tzinfo=UTC) + + +def _category(value: str | None) -> EventCategory: + try: + return EventCategory(value) + except ValueError: + return EventCategory.UNKNOWN + + +def _venue(entities: list[Mapping[str, Any]]) -> str | None: + return next((e.get("name") for e in entities if e.get("type") == "venue"), None) + + +def rejection_reason(exc: Exception) -> str: + if isinstance(exc, KeyError): + return f"missing field {exc.args[0]!r}" + return str(exc) diff --git a/backend/foresight/ingest/textdates.py b/backend/foresight/services/adapters/textdates.py similarity index 99% rename from backend/foresight/ingest/textdates.py rename to backend/foresight/services/adapters/textdates.py index c891522..e9e2937 100644 --- a/backend/foresight/ingest/textdates.py +++ b/backend/foresight/services/adapters/textdates.py @@ -17,7 +17,7 @@ from dataclasses import dataclass from datetime import date -from foresight.ingest.normalize import DatePrecision +from foresight.database.enums import DatePrecision MAX_LOOKAHEAD_DAYS = 540 _PAST_TOLERANCE_DAYS = 2 diff --git a/backend/foresight/services/backfill/__init__.py b/backend/foresight/services/backfill/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/foresight/services/backfill/events.py b/backend/foresight/services/backfill/events.py deleted file mode 100644 index 8b69464..0000000 --- a/backend/foresight/services/backfill/events.py +++ /dev/null @@ -1,265 +0,0 @@ -from __future__ import annotations - -import asyncio -import logging -from collections import Counter -from dataclasses import dataclass, field -from datetime import date, timedelta -from typing import TYPE_CHECKING, Any - -import httpx - -from foresight.ingest.predicthq import ( - API_URL, - PAGE_LIMIT, - STATES, - Rejected, - rejection_reason, - to_normalized, -) -from foresight.repository import Outcome -from foresight.repository.events import upsert_event - -if TYPE_CHECKING: - from collections.abc import AsyncIterator, Awaitable, Callable - - from sqlalchemy.ext.asyncio import AsyncSession - - from foresight.ingest.run import Market - -log = logging.getLogger("foresight.ingest") - -MAX_RETRIES = 5 -BACKOFF_SECONDS = 2.0 -# PredictHQ's docs don't name the status for an exhausted plan; treat both. -_OUT_OF_CREDITS = {402, 403} - - -class PredictHQLimitError(Exception): - """PredictHQ refused further requests; the run should stop.""" - - -class OutOfCreditsError(PredictHQLimitError): - """The plan's quota or credits are used up.""" - - -class RetriesExhaustedError(PredictHQLimitError): - """Still rate limited or failing after every retry.""" - - -@dataclass(slots=True) -class BackfillReport: - market: Market - start: date - end: date - chunks_total: int - chunks_done: int = 0 - fetched: int = 0 - outcomes: Counter[Outcome] = field(default_factory=Counter) - rejected: list[Rejected] = field(default_factory=list) - overflow_chunks: list[tuple[date, date]] = field(default_factory=list) - stopped: str | None = None - resume_from: date | None = None - dry_run: bool = False - - def summary(self) -> str: - """One line of counts for the run.""" - counts = ( - "(dry run, nothing written)" - if self.dry_run - else " ".join(f"{o.value}={self.outcomes[o]}" for o in Outcome) - ) - return ( - f"{self.market.city} {self.start}..{self.end} " - f"chunks={self.chunks_done}/{self.chunks_total} " - f"fetched={self.fetched} {counts} rejected={len(self.rejected)}" - ) - - -def month_chunks(start: date, end: date) -> list[tuple[date, date]]: - """Split a date range into calendar-month pieces, oldest first. - - Args: - start (date): First day, inclusive. - end (date): Last day, inclusive. - - Returns: - list[tuple[date, date]]: Inclusive (first, last) day of each piece. - """ - chunks = [] - first = start - while first <= end: - next_month = (first.replace(day=1) + timedelta(days=32)).replace(day=1) - last = min(next_month - timedelta(days=1), end) - chunks.append((first, last)) - first = last + timedelta(days=1) - return chunks - - -def chunk_params(market: Market, start: date, end: date, radius: str) -> dict[str, Any]: - """Query for every event active within a chunk, with a day of slack each side. - - Args: - market (Market): Centre of the search. - start (date): First day of the chunk. - end (date): Last day of the chunk. - radius (str): Search radius, e.g. "10km". - - Returns: - dict[str, Any]: PredictHQ query parameters. - """ - return { - "within": f"{radius}@{market.latitude},{market.longitude}", - "active.gte": (start - timedelta(days=1)).isoformat(), - "active.lte": (end + timedelta(days=1)).isoformat(), - "state": STATES, - "limit": PAGE_LIMIT, - "sort": "start", - } - - -async def _get( - client: httpx.AsyncClient, - url: str, - params: dict[str, Any] | None, - *, - sleep: Callable[[float], Awaitable[None]] = asyncio.sleep, -) -> dict[str, Any]: - """GET one page, waiting and retrying only on 429, 5xx or network errors. - - Args: - client (httpx.AsyncClient): Authenticated client. - url (str): Page URL. - params (dict[str, Any] | None): Query, or None for a `next` link. - sleep (Callable[[float], Awaitable[None]]): Waits between retries. - - Returns: - dict[str, Any]: The page's JSON body. - - Raises: - OutOfCreditsError: On a 402 or 403. - RetriesExhaustedError: If every retry still fails. - httpx.HTTPStatusError: On any other 4xx. - """ - problem, delay = "", 0.0 - for attempt in range(MAX_RETRIES + 1): - if attempt: - await sleep(delay) - try: - response = await client.get(url, params=params) - except httpx.TransportError as exc: - problem = f"network error: {exc}" - delay = BACKOFF_SECONDS * 2**attempt - continue - - status = response.status_code - if status in _OUT_OF_CREDITS: - raise OutOfCreditsError(f"HTTP {status}: {response.text[:300]}") - if status == 429 or status >= 500: - problem = f"HTTP {status}" - delay = _retry_after(response) or BACKOFF_SECONDS * 2**attempt - log.warning("PredictHQ %s, retrying in %.0fs", problem, delay) - continue - response.raise_for_status() - return response.json() - - raise RetriesExhaustedError(f"{problem} after {MAX_RETRIES} retries") - - -def _retry_after(response: httpx.Response) -> float | None: - try: - return float(response.headers["Retry-After"]) - except (KeyError, ValueError): - return None - - -async def backfill( - session: AsyncSession | None, - market: Market, - start: date, - end: date, - *, - token: str, - radius: str = "10km", - transport: httpx.AsyncBaseTransport | None = None, - sleep: Callable[[float], Awaitable[None]] = asyncio.sleep, -) -> AsyncIterator[BackfillReport]: - """Fetch and upsert every event active from `start` to `end`, a month at a time. - - Yields the report after each month so the caller can commit as it goes; - never commits itself. A PredictHQ limit ends the run: the report is yielded - once more with `stopped` and `resume_from` set. - - Args: - session (AsyncSession | None): Where to write; None for a dry run. - market (Market): City and centre of the search. - start (date): First day, inclusive. - end (date): Last day, inclusive. - token (str): PredictHQ access token. - radius (str): Search radius, e.g. "10km". - transport (httpx.AsyncBaseTransport | None): Test transport. - sleep (Callable[[float], Awaitable[None]]): Waits between retries. - - Yields: - BackfillReport: Running counts after each month, or the stopped run. - """ - chunks = month_chunks(start, end) - report = BackfillReport( - market=market, - start=start, - end=end, - chunks_total=len(chunks), - dry_run=session is None, - ) - headers = {"Authorization": f"Bearer {token}", "Accept": "application/json"} - - async with httpx.AsyncClient( - headers=headers, timeout=30.0, transport=transport - ) as client: - for first, last in chunks: - try: - await _backfill_chunk( - client, session, report, first, last, radius, sleep - ) - except PredictHQLimitError as exc: - report.stopped = str(exc) - report.resume_from = first - yield report - return - report.chunks_done += 1 - yield report - - -async def _backfill_chunk( - client: httpx.AsyncClient, - session: AsyncSession | None, - report: BackfillReport, - first: date, - last: date, - radius: str, - sleep: Callable[[float], Awaitable[None]], -) -> None: - url: str | None = API_URL - query: dict[str, Any] | None = chunk_params(report.market, first, last, radius) - while url: - body = await _get(client, url, query, sleep=sleep) - if body.get("overflow") and (first, last) not in report.overflow_chunks: - log.warning( - "PredictHQ overflow for %s..%s: plan truncated results", first, last - ) - report.overflow_chunks.append((first, last)) - - for raw in body.get("results", []): - report.fetched += 1 - try: - event = to_normalized(raw, city=report.market.city) - except (KeyError, TypeError, ValueError) as exc: - ref = str(raw.get("id", "?")) if isinstance(raw, dict) else "?" - log.warning("rejected PredictHQ event %s: %s", ref, exc) - report.rejected.append(Rejected(ref, rejection_reason(exc))) - continue - if session is not None: - report.outcomes[await upsert_event(session, event)] += 1 - - # `next` already carries every query parameter. - url, query = body.get("next"), None diff --git a/backend/foresight/services/backfill/flights.py b/backend/foresight/services/backfill/flights.py deleted file mode 100644 index a220190..0000000 --- a/backend/foresight/services/backfill/flights.py +++ /dev/null @@ -1,62 +0,0 @@ -from __future__ import annotations - -from datetime import timedelta -from typing import TYPE_CHECKING - -from foresight.repository.flights import upsert_flight_arrivals -from foresight.services.flight_data_service import RateLimitedError, fetch_aerodatabox - -if TYPE_CHECKING: - from collections.abc import AsyncIterator, Callable - from datetime import date - - import pandas as pd - from sqlalchemy.ext.asyncio import AsyncSession - -SOURCE = "aerodatabox" - - -class FlightBackfillStoppedError(Exception): - """AeroDataBox rate limited us; `day` is the first day not saved.""" - - def __init__(self, day: date, reason: str) -> None: - super().__init__(f"rate limited on {day}: {reason}") - self.day = day - - -async def backfill_flights( - session: AsyncSession, - start: date, - end: date, - airport: str = "DUB", - fetch: Callable[[date, date, str], pd.DataFrame] = fetch_aerodatabox, -) -> AsyncIterator[tuple[date, int]]: - """Fetch and save the arrival count for each day from `start` to `end`. - - Yields (day, count) after each day is written, so the caller can commit - day by day. Does not commit itself. - - Raises: - FlightBackfillStoppedError: If AeroDataBox rate limits us; every day yielded - before it is complete. - """ - day = start - while day <= end: - try: - flights = fetch(day, day, airport) - except RateLimitedError as exc: - raise FlightBackfillStoppedError(day, str(exc)) from exc - await upsert_flight_arrivals( - session, - [ - { - "airport": airport, - "arrival_date": day, - "flight_count": len(flights), - "avg_fare_eur": None, - "source": SOURCE, - } - ], - ) - yield day, len(flights) - day += timedelta(days=1) diff --git a/backend/foresight/services/backfill/weather.py b/backend/foresight/services/backfill/weather.py deleted file mode 100644 index 9c74baf..0000000 --- a/backend/foresight/services/backfill/weather.py +++ /dev/null @@ -1,32 +0,0 @@ -from __future__ import annotations - -from datetime import date -from typing import TYPE_CHECKING - -from foresight.ingest.weather import fetch_actuals -from foresight.repository import weather as weather_repo - -if TYPE_CHECKING: - from sqlalchemy.ext.asyncio import AsyncSession - - from foresight.repository import Outcome - - -async def backfill_weather( - session: AsyncSession, - *, - city: str, - latitude: float, - longitude: float, - start: date, - end: date, -) -> dict[Outcome, int]: - """Fetch recorded weather for every day from `start` to `end` and save it. - - The archive rejects future dates, so `end` is clamped to today. Does not - commit; the caller owns the transaction. - """ - rows = fetch_actuals( - city, latitude, longitude, "auto", start=start, end=min(end, date.today()) - ) - return await weather_repo.upsert_weather(session, rows) diff --git a/backend/foresight/services/backfill/weather_coverage.py b/backend/foresight/services/backfill/weather_coverage.py deleted file mode 100644 index e12755b..0000000 --- a/backend/foresight/services/backfill/weather_coverage.py +++ /dev/null @@ -1,54 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from datetime import date, timedelta -from typing import TYPE_CHECKING - -from foresight.repository import weather as weather_repo - -if TYPE_CHECKING: - from collections.abc import Iterable - - from sqlalchemy.ext.asyncio import AsyncSession - - -@dataclass(frozen=True, slots=True) -class WeatherCoverage: - city: str - start: date - end: date - days: int # days in the window - rows: int # days with a row saved - earliest: date | None # first day with values - missing: list[tuple[date, date]] # no row at all - empty: list[tuple[date, date]] # row saved, values missing (archive lag) - - -async def weather_coverage( - session: AsyncSession, *, city: str, start: date, end: date -) -> WeatherCoverage: - """Count saved days and group the gaps into runs of consecutive days.""" - saved = await weather_repo.recorded_days(session, city=city, start=start, end=end) - window = [start + timedelta(days=n) for n in range((end - start).days + 1)] - usable = [d for d, has_values in saved.items() if has_values] - return WeatherCoverage( - city=city, - start=start, - end=end, - days=len(window), - rows=len(saved), - earliest=min(usable, default=None), - missing=_spans(d for d in window if d not in saved), - empty=_spans(sorted(d for d, has_values in saved.items() if not has_values)), - ) - - -def _spans(days: Iterable[date]) -> list[tuple[date, date]]: - """Group sorted days into (first, last) runs of consecutive days.""" - spans: list[tuple[date, date]] = [] - for day in days: - if spans and day == spans[-1][1] + timedelta(days=1): - spans[-1] = (spans[-1][0], day) - else: - spans.append((day, day)) - return spans diff --git a/backend/foresight/services/backfill/window.py b/backend/foresight/services/backfill/window.py deleted file mode 100644 index e1a01cc..0000000 --- a/backend/foresight/services/backfill/window.py +++ /dev/null @@ -1,43 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING - -from foresight.repository import hotel as hotel_repo - -if TYPE_CHECKING: - from datetime import date - - from sqlalchemy.ext.asyncio import AsyncSession - - -async def hotel_window( - session: AsyncSession, - hotel_id: int | None, - start: date | None, - end: date | None, -) -> tuple[date, date]: - """Fill a missing `start` or `end` from the hotel's stay_date range. - - Every source aims at the same window; one whose API can't reach back that far - just returns nothing for the early days, and the coverage report shows the gap. - - Raises: - ValueError: If a date is missing and the hotel has no nights loaded (or - no hotel was given), or if `start` is after `end`. - """ - if start is None or end is None: - loaded = ( - None - if hotel_id is None - else await hotel_repo.stay_date_range(session, hotel_id) - ) - if loaded is None: - raise ValueError( - "no hotel nights loaded; give --hotel-id of a loaded hotel, " - "or both --start and --end" - ) - start = start or loaded[0] - end = end or loaded[1] - if start > end: - raise ValueError(f"start {start} is after end {end}") - return start, end diff --git a/backend/foresight/services/domain/__init__.py b/backend/foresight/services/domain/__init__.py new file mode 100644 index 0000000..6c6b7f0 --- /dev/null +++ b/backend/foresight/services/domain/__init__.py @@ -0,0 +1 @@ +"""Business logic for each domain: hotels, cities and the signals read for them.""" diff --git a/backend/foresight/services/domain/city.py b/backend/foresight/services/domain/city.py new file mode 100644 index 0000000..867d7d5 --- /dev/null +++ b/backend/foresight/services/domain/city.py @@ -0,0 +1,66 @@ +"""Cities: the markets hotels sit in and every outside signal is keyed on.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from foresight.repository import city as city_repo +from foresight.services.adapters.normalize import slugify_city +from foresight.services.errors import ConflictError, InvalidRequestError, NotFoundError + +if TYPE_CHECKING: + from collections.abc import Sequence + + from sqlalchemy.ext.asyncio import AsyncSession + + from foresight.database.models.city import City + + +async def create_city(session: AsyncSession, *, name: str, **values: Any) -> City: + """Raises ConflictError if a city with the same normalized name exists.""" + city = await city_repo.create_city(session, name=name, slug=_slug(name), **values) + if city is None: + raise ConflictError(f"City {name!r} already exists") + return city + + +async def get_city(session: AsyncSession, city_id: int) -> City: + """Raises NotFoundError for an unknown city.""" + city = await city_repo.get_city(session, city_id) + if city is None: + raise NotFoundError("City", city_id) + return city + + +async def list_cities(session: AsyncSession) -> Sequence[City]: + return await city_repo.list_cities(session) + + +async def update_city( + session: AsyncSession, city_id: int, changes: dict[str, Any] +) -> City: + """Change only the fields given. A rename re-derives the slug. + + Events already saved keep the dedupe key built from the old name, so a + rename is for fixing a typo before ingesting, not for routine use. + + Raises: + NotFoundError: Unknown city. + ConflictError: The new name normalizes to another city's. + """ + city = await get_city(session, city_id) + if not changes: + return city + if "name" in changes: + changes = {**changes, "slug": _slug(changes["name"])} + updated = await city_repo.update_city(session, city_id, changes) + if updated is None: + raise ConflictError(f"City {changes['name']!r} already exists") + return updated + + +def _slug(name: str) -> str: + slug = slugify_city(name) + if not slug: + raise InvalidRequestError(f"city name {name!r} has no letters or digits") + return slug diff --git a/backend/foresight/services/domain/event.py b/backend/foresight/services/domain/event.py new file mode 100644 index 0000000..08f1804 --- /dev/null +++ b/backend/foresight/services/domain/event.py @@ -0,0 +1,163 @@ +"""Events: merging sightings from several sources onto one canonical row. + +PredictHQ and AskNews can describe the same real-world event. Each source's +record is kept as an observation, and the `events` row carries the values of +whichever sighting ranks highest (see `_rank`). +""" + +from __future__ import annotations + +from datetime import UTC, datetime +from typing import TYPE_CHECKING + +from foresight.database.enums import EventStatus, Source +from foresight.repository.signals import event as event_repo +from foresight.repository.upsert import Outcome +from foresight.services.domain.hotel import get_hotel + +if TYPE_CHECKING: + from collections.abc import Sequence + from datetime import date + + from sqlalchemy.ext.asyncio import AsyncSession + + from foresight.database.models.signals.event import Event + from foresight.services.adapters.normalize import NormalizedEvent + +# Structured listings outrank dates and venues extracted from prose. +_PRECEDENCE = {Source.PREDICTHQ: 2, Source.ASKNEWS: 1} +# Withdrawn or called-off events don't count as demand. +_WITHDRAWN = (EventStatus.DELETED, EventStatus.CANCELLED) +_NEVER = datetime.min.replace(tzinfo=UTC) + + +async def save_event( + session: AsyncSession, city_id: int, event: NormalizedEvent +) -> Outcome: + """Resolve `event` to its canonical row in `city_id`, write it, and record + the sighting. + + Does not commit; the caller owns the transaction. + """ + row = {**event.as_row(), "city_id": city_id} + event_id = await event_repo.find_event_id_by_sighting( + session, source=event.source, source_ref=event.source_ref + ) + + # Source identity must be checked before dedupe_key: a revised start date + # changes the key, and resolving by key first would orphan the old row. + if event_id is not None: + event_id = await _absorb_key_collision(session, event_id, row["dedupe_key"]) + outcome = await _apply(session, event_id, event, row) + else: + event_id, inserted = await event_repo.insert_or_touch_event(session, row) + outcome = ( + Outcome.NEW if inserted else await _apply(session, event_id, event, row) + ) + + await event_repo.upsert_observation( + session, + event_id=event_id, + source=event.source, + source_ref=event.source_ref, + source_updated_at=event.source_updated_at, + status=event.status, + payload=event.payload, + ) + return outcome + + +async def list_events( + session: AsyncSession, + hotel_id: int, + *, + start: date | None, + end: date | None, + limit: int, + offset: int, +) -> tuple[Sequence[Event], int]: + """Live events in the hotel's city overlapping [start, end]; withdrawn ones + are hidden.""" + hotel = await get_hotel(session, hotel_id) + return await event_repo.list_events( + session, + city_id=hotel.city_id, + start=start, + end=end, + exclude_statuses=_WITHDRAWN, + limit=limit, + offset=offset, + ) + + +async def market_totals( + session: AsyncSession, city_id: int +) -> tuple[dict[str, int], dict[str, int]]: + """Events in the city by status, and their observations by source.""" + by_status = await event_repo.count_events_by_status(session, city_id) + by_source = await event_repo.count_observations_by_source(session, city_id) + return ( + {status.value: n for status, n in by_status.items()}, + {source.value: n for source, n in by_source.items()}, + ) + + +async def _absorb_key_collision( + session: AsyncSession, event_id: int, new_key: str +) -> int: + """If a revision moved onto a key another sighting already holds, merge.""" + other_id = await event_repo.find_other_event_id_by_key( + session, dedupe_key=new_key, exclude_id=event_id + ) + if other_id is None: + return event_id + await event_repo.merge_event_into(session, from_id=event_id, into_id=other_id) + return other_id + + +async def _apply( + session: AsyncSession, event_id: int, event: NormalizedEvent, row: dict +) -> Outcome: + current = await event_repo.lock_event(session, event_id) + + changes = {} + if await _outranks_other_sightings(session, event_id, event): + changes = {k: v for k, v in row.items() if current[k] != v} + + await event_repo.update_event(session, event_id, changes) + return Outcome.UPDATED if changes else Outcome.UNCHANGED + + +async def _outranks_other_sightings( + session: AsyncSession, event_id: int, event: NormalizedEvent +) -> bool: + """Whether `event` is the sighting whose values the row should carry. + + Every sighting of a row is ranked the same way, so reruns settle on one + winner instead of duplicate listings overwriting each other in turn. A + deleted duplicate thus never takes down the live listing it shares a row + with, on the first run or any later one. + """ + others = await event_repo.list_other_sightings( + session, event_id, source=event.source, source_ref=event.source_ref + ) + mine = _rank(event.source, event.status, event.source_updated_at, event.source_ref) + return all(mine > _rank(*other) for other in others) + + +def _rank( + source: Source, + status: EventStatus | None, + updated_at: datetime | None, + source_ref: str, +) -> tuple[int, bool, datetime, str]: + """Structured sources first, then live listings, then the latest revision. + + source_ref only breaks exact ties, so the winner is stable across reruns. + """ + return ( + _PRECEDENCE[Source(source)], + status not in _WITHDRAWN, + updated_at or _NEVER, + source_ref, + ) diff --git a/backend/foresight/services/domain/flight.py b/backend/foresight/services/domain/flight.py new file mode 100644 index 0000000..f3da1b8 --- /dev/null +++ b/backend/foresight/services/domain/flight.py @@ -0,0 +1,32 @@ +"""Flights: daily arrival counts at a city's airport, per source.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from foresight.repository.signals import flight as flight_repo +from foresight.services.domain.hotel import get_hotel + +if TYPE_CHECKING: + from collections.abc import Sequence + from datetime import date + + from sqlalchemy.ext.asyncio import AsyncSession + + from foresight.database.models.signals.flight import FlightArrival + + +async def list_flight_arrivals( + session: AsyncSession, + hotel_id: int, + *, + start: date | None, + end: date | None, + limit: int, + offset: int, +) -> tuple[Sequence[FlightArrival], int]: + """Arrivals at the hotel's city airport. Each source keeps its own row per day.""" + hotel = await get_hotel(session, hotel_id) + return await flight_repo.list_flight_arrivals( + session, city_id=hotel.city_id, start=start, end=end, limit=limit, offset=offset + ) diff --git a/backend/foresight/services/domain/hotel.py b/backend/foresight/services/domain/hotel.py new file mode 100644 index 0000000..664d5c0 --- /dev/null +++ b/backend/foresight/services/domain/hotel.py @@ -0,0 +1,133 @@ +"""Hotels, their room types, and nightly performance.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from foresight.repository import hotel as hotel_repo +from foresight.services.domain.city import get_city +from foresight.services.errors import ConflictError, NotFoundError + +if TYPE_CHECKING: + from collections.abc import Sequence + from datetime import date + from decimal import Decimal + + from sqlalchemy.ext.asyncio import AsyncSession + + from foresight.database.models.hotel import DailyPerformance, Hotel, RoomType + + +async def create_hotel( + session: AsyncSession, + *, + name: str, + city_id: int, + total_rooms: int, + latitude: float | None, + longitude: float | None, +) -> Hotel: + """Raises NotFoundError for an unknown city, ConflictError if the city + already has a hotel with this name.""" + city = await get_city(session, city_id) + hotel = await hotel_repo.create_hotel( + session, + name=name, + city_id=city_id, + total_rooms=total_rooms, + latitude=latitude, + longitude=longitude, + ) + if hotel is None: + raise ConflictError(f"Hotel {name!r} already exists in {city.name}") + return hotel + + +async def get_hotel(session: AsyncSession, hotel_id: int) -> Hotel: + """The hotel with its city loaded. Raises NotFoundError for an unknown hotel.""" + hotel = await hotel_repo.get_hotel(session, hotel_id) + if hotel is None: + raise NotFoundError("Hotel", hotel_id) + return hotel + + +async def update_hotel( + session: AsyncSession, hotel_id: int, changes: dict[str, Any] +) -> Hotel: + """Change only the fields given. + + Raises: + NotFoundError: Unknown hotel, or unknown new city. + ConflictError: The new name and city match another hotel. + """ + hotel = await get_hotel(session, hotel_id) + if not changes: + return hotel + if "city_id" in changes: + await get_city(session, changes["city_id"]) + updated = await hotel_repo.update_hotel(session, hotel_id, changes) + if updated is None: + name = changes.get("name", hotel.name) + raise ConflictError(f"Hotel {name!r} already exists in that city") + return updated + + +async def list_hotels(session: AsyncSession) -> Sequence[Hotel]: + return await hotel_repo.list_hotels(session) + + +async def create_room_type( + session: AsyncSession, + hotel_id: int, + *, + name: str, + room_count: int, + base_rate: Decimal, +) -> RoomType: + """Raises NotFoundError for an unknown hotel, ConflictError for a duplicate.""" + await get_hotel(session, hotel_id) + room_type = await hotel_repo.create_room_type( + session, + hotel_id=hotel_id, + name=name, + room_count=room_count, + base_rate=base_rate, + ) + if room_type is None: + raise ConflictError(f"Hotel {hotel_id} already has a room type named {name!r}") + return room_type + + +async def get_room_type(session: AsyncSession, room_type_id: int) -> RoomType: + """Raises NotFoundError for an unknown room type.""" + room_type = await hotel_repo.get_room_type(session, room_type_id) + if room_type is None: + raise NotFoundError("Room type", room_type_id) + return room_type + + +async def list_room_types(session: AsyncSession, hotel_id: int) -> Sequence[RoomType]: + await get_hotel(session, hotel_id) + return await hotel_repo.list_room_types(session, hotel_id) + + +async def list_performance( + session: AsyncSession, + hotel_id: int, + *, + start: date | None, + end: date | None, + room_type_id: int | None, + limit: int, + offset: int, +) -> tuple[Sequence[DailyPerformance], int]: + await get_hotel(session, hotel_id) + return await hotel_repo.list_performance( + session, + hotel_id, + start=start, + end=end, + room_type_id=room_type_id, + limit=limit, + offset=offset, + ) diff --git a/backend/foresight/services/domain/model_inputs.py b/backend/foresight/services/domain/model_inputs.py new file mode 100644 index 0000000..86a3171 --- /dev/null +++ b/backend/foresight/services/domain/model_inputs.py @@ -0,0 +1,122 @@ +"""The signals the pricing model reads, loaded the same way for training and +serving. + +Only reads what's already ingested; nothing here fetches from a source. Weather +follows decision 0024: + +- A recorded day wins over a forecast for the same day. +- Training uses recorded weather only; a night without it stays empty. +- Serving falls back to the forecast, then to the typical weather for that + ISO week across every recorded year (week 53 borrows week 52 when no + recorded year has one), and says which it used. + +Events and flights aren't used yet (no history to train on), so both are +passed as empty frames. The feature columns stay fixed (`ml/features.FEATURES`) +so adding them later doesn't change the model's inputs. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +import pandas as pd + +from foresight.repository.signals import weather as weather_repo + +if TYPE_CHECKING: + from collections.abc import Sequence + from datetime import date + + from sqlalchemy.ext.asyncio import AsyncSession + + from foresight.database.models.city import City + +WEATHER_COLUMNS = ["temp_max_c", "precip_mm", "wind_kph"] + +# Signals the model has columns for but no data yet. +NO_EVENTS = pd.DataFrame( + columns=[ + "start_local_date", + "end_local_date", + "category", + "expected_attendance", + "local_rank", + ] +) +NO_FLIGHTS = pd.DataFrame(columns=["arrival_date", "flight_count", "avg_fare_eur"]) + + +@dataclass(frozen=True, slots=True) +class DailyWeather: + """One row per day for `build_features`, and where each day's came from.""" + + frame: pd.DataFrame # weather_date + WEATHER_COLUMNS + sources: dict[date, str] # "recorded", "forecast", "typical week 43 (4 years)" + + +async def weather_for( + session: AsyncSession, + city: City, + days: Sequence[date], + *, + fallback: bool, +) -> DailyWeather: + """Weather for each day: recorded, else (with `fallback`) forecast, else + the typical weather for its ISO week. Days with none are left out of + `frame` and marked "missing" in `sources`.""" + if not days: + return DailyWeather( + pd.DataFrame(columns=["weather_date", *WEATHER_COLUMNS]), {} + ) + + rows = await weather_repo.daily_weather( + session, city_id=city.id, start=min(days), end=max(days) + ) + recorded: dict[date, dict] = {} + forecast: dict[date, dict] = {} + for row in rows: # ordered by source, so the first per day is kept + target = forecast if row["is_forecast"] else recorded + target.setdefault(row["weather_date"], dict(row)) + + picked: dict[date, dict] = {} + sources: dict[date, str] = {} + typical_cache: dict[int, dict | None] = {} + for day in sorted(set(days)): + if day in recorded: + picked[day], sources[day] = recorded[day], "recorded" + elif fallback and day in forecast: + picked[day], sources[day] = forecast[day], "forecast" + elif fallback: + week = day.isocalendar().week + if week not in typical_cache: + found = await weather_repo.typical_week( + session, city_id=city.id, iso_week=week + ) + if found is None and week == 53: + # Only some years have an ISO week 53; its neighbour is close. + found = await weather_repo.typical_week( + session, city_id=city.id, iso_week=52 + ) + typical_cache[week] = dict(found) if found else None + typical = typical_cache[week] + if typical is None: + sources[day] = "missing" + continue + picked[day] = typical + sources[day] = f"typical week {week} ({typical['years']} years)" + else: + sources[day] = "missing" + + frame = pd.DataFrame( + [ + {"weather_date": day, **{c: _float(row[c]) for c in WEATHER_COLUMNS}} + for day, row in picked.items() + ], + columns=["weather_date", *WEATHER_COLUMNS], + ) + return DailyWeather(frame, sources) + + +def _float(value: object) -> float | None: + return None if value is None else float(value) # avg() returns Decimal diff --git a/backend/foresight/services/domain/pricing_model.py b/backend/foresight/services/domain/pricing_model.py new file mode 100644 index 0000000..2be92b9 --- /dev/null +++ b/backend/foresight/services/domain/pricing_model.py @@ -0,0 +1,205 @@ +"""Pricing models: train a new version for a hotel, and read the versions. + +Training only reads what's already ingested (performance and weather); it +never fetches. Every run makes a new version, so earlier ones stay to compare +against. Activation follows decision 0009: a new version is saved inactive and +becomes the hotel's active one only once training succeeded, unless the caller +asks to keep the current one serving. +""" + +from __future__ import annotations + +import asyncio +import time +from dataclasses import dataclass +from importlib.metadata import version +from typing import TYPE_CHECKING, Any + +import numpy as np +import pandas as pd + +from foresight.repository import hotel as hotel_repo +from foresight.repository import pricing_model as model_repo +from foresight.services.domain.hotel import get_hotel +from foresight.services.domain.model_inputs import ( + NO_EVENTS, + NO_FLIGHTS, + weather_for, +) +from foresight.services.errors import InvalidRequestError, NotFoundError +from foresight.services.ml.ebm import EBMModel +from foresight.services.ml.features import FEATURES, build_features +from foresight.services.ml.weighting import sample_weights, training_mask + +if TYPE_CHECKING: + from collections.abc import Sequence + from datetime import date + + from sqlalchemy.ext.asyncio import AsyncSession + + from foresight.database.models.hotel import Hotel + from foresight.database.models.pricing_model import PricingModel + +ALGORITHM = "ebm" +# EBM needs enough rows for its own validation split plus our calibration set. +MIN_TRAINING_NIGHTS = 120 +# Share of the most recent nights scored after training; also the conformal +# calibration share (decision 0005), so metrics describe unseen nights. +HOLDOUT_FRAC = 0.15 +_NIGHT_COLUMNS = ["stay_date", "room_type", "rate", "rooms_sold", "rooms_available"] +_LIBS = ("interpret-core", "scikit-learn", "numpy", "pandas", "joblib") + + +@dataclass(frozen=True, slots=True) +class TrainingResult: + model: PricingModel + activated: bool + seconds: float + nights_used: int + + +async def check_trainable( + session: AsyncSession, hotel_id: int, *, start: date | None, end: date | None +) -> Hotel: + """Fail fast, before a job is queued, if training can't run. + + Raises: + NotFoundError: Unknown hotel. + InvalidRequestError: start is after end, or no nights are loaded in + the window. + """ + hotel = await get_hotel(session, hotel_id) + if start is not None and end is not None and start > end: + raise InvalidRequestError(f"start {start} is after end {end}") + loaded = await hotel_repo.stay_date_range(session, hotel.id) + if loaded is None: + raise InvalidRequestError( + "no nights are loaded for this hotel; ingest performance first" + ) + first, last = loaded + if (start is not None and start > last) or (end is not None and end < first): + raise InvalidRequestError( + f"no nights in the window; loaded nights run {first} to {last}" + ) + return hotel + + +async def train( + session: AsyncSession, + hotel: Hotel, + *, + description: str | None, + start: date | None, + end: date | None, + activate: bool, + params: dict[str, Any], +) -> TrainingResult: + """Train, score and save a new version. Does not commit. + + `params` go to the EBM as-is (e.g. {"interactions": 0}); the job validated + them. Raises InvalidRequestError if too few nights are left to train on. + """ + began = time.monotonic() + rows = await hotel_repo.training_nights(session, hotel.id, start=start, end=end) + nights = pd.DataFrame(rows, columns=pd.Index(_NIGHT_COLUMNS)) + # Rates arrive as Decimal or None; None becomes NaN. + rate = pd.Series(nights["rate"], dtype=float) + nights["rate"] = rate + + # Decision 0004: free/staff/unpriced nights go before features and weights, + # so all three line up row for row. + keep = training_mask(rate) + nights = nights.loc[keep].reset_index(drop=True) + if len(nights) < MIN_TRAINING_NIGHTS: + raise InvalidRequestError( + f"{len(nights)} priced nights to train on; need at least " + f"{MIN_TRAINING_NIGHTS}" + ) + + days = sorted(set(nights["stay_date"])) + weather = await weather_for(session, hotel.city, days, fallback=False) + x = build_features(nights, NO_EVENTS, weather.frame, NO_FLIGHTS) + y = nights["rate"] + occupancy = nights["rooms_sold"] / nights["rooms_available"] + weights = sample_weights(occupancy.clip(0, 1)) + + # CPU-bound for seconds; a thread keeps the API answering meanwhile. + metrics, artifact = await asyncio.to_thread(_fit, x, y, weights, nights, params) + + saved = await model_repo.create_pricing_model( + session, + hotel_id=hotel.id, + algorithm=ALGORITHM, + description=description, + artifact=artifact, + features=FEATURES, + metrics=metrics, + lib_versions={lib: version(lib) for lib in _LIBS}, + data_start=nights["stay_date"].min(), + data_end=nights["stay_date"].max(), + ) + if activate: + activated = await model_repo.activate_pricing_model(session, saved.id) + assert activated is not None # saved just above, in this transaction + saved = activated + return TrainingResult( + saved, activate, round(time.monotonic() - began, 1), len(nights) + ) + + +async def list_models( + session: AsyncSession, hotel_id: int, *, limit: int, offset: int +) -> tuple[Sequence[PricingModel], int]: + await get_hotel(session, hotel_id) + return await model_repo.list_pricing_models( + session, hotel_id, limit=limit, offset=offset + ) + + +async def get_model(session: AsyncSession, model_id: int) -> PricingModel: + """Raises NotFoundError for an unknown model.""" + model = await model_repo.get_pricing_model(session, model_id) + if model is None: + raise NotFoundError("Pricing model", model_id) + return model + + +async def activate_model(session: AsyncSession, model_id: int) -> PricingModel: + """Make this version the one its hotel serves. Raises NotFoundError.""" + await get_model(session, model_id) + model = await model_repo.activate_pricing_model(session, model_id) + assert model is not None + return model + + +def _fit( + x: pd.DataFrame, + y: pd.Series, + weights: np.ndarray, + nights: pd.DataFrame, + params: dict[str, Any], +) -> tuple[dict[str, Any], bytes]: + """Fit, score on the held-out nights, and serialize. Returns the metrics + and the artifact to store.""" + model = EBMModel(alpha=0.2, calibration_frac=HOLDOUT_FRAC, **params).fit( + x, y, sample_weight=weights + ) + cut = int(len(x) * (1 - HOLDOUT_FRAC)) + metrics = _metrics(model, x.iloc[cut:], y.iloc[cut:], nights.iloc[cut:]) + return metrics, model.to_bytes() + + +def _metrics( + model: EBMModel, x: pd.DataFrame, y: pd.Series, nights: pd.DataFrame +) -> dict[str, Any]: + """Scores on the most recent nights; these also calibrated the interval.""" + scores = model.evaluate(x, y) + return { + **{k: (None if v is None else round(float(v), 4)) for k, v in scores.items()}, + "interval_level": 1 - model.alpha, + "interval_margin": round(model.interval_margin(model.alpha), 2), + "holdout_nights": len(x), + "holdout_start": nights["stay_date"].min().isoformat(), + "holdout_end": nights["stay_date"].max().isoformat(), + "mean_rate": round(float(np.mean(y)), 2), + } diff --git a/backend/foresight/services/domain/recommendation.py b/backend/foresight/services/domain/recommendation.py new file mode 100644 index 0000000..df98b86 --- /dev/null +++ b/backend/foresight/services/domain/recommendation.py @@ -0,0 +1,204 @@ +"""Nightly rate recommendations from the hotel's active pricing model. + +Reads only what's already ingested; it never fetches. If data is missing for +the night, the model gets an empty value (it learned an effect for that) +rather than this call pulling the data in. + +Returns domain dataclasses; the route maps them to `RecommendationResponse`. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from decimal import ROUND_HALF_UP, Decimal +from typing import TYPE_CHECKING, cast + +import pandas as pd + +from foresight.repository import hotel as hotel_repo +from foresight.repository import pricing_model as model_repo +from foresight.services.domain.hotel import get_hotel +from foresight.services.domain.model_inputs import ( + NO_EVENTS, + NO_FLIGHTS, + weather_for, +) +from foresight.services.errors import InvalidRequestError, NotFoundError +from foresight.services.ml.ebm import EBMModel +from foresight.services.ml.features import build_features + +if TYPE_CHECKING: + from datetime import date + + from sqlalchemy.ext.asyncio import AsyncSession + +CURRENCY = "EUR" # decision 0002 +# Terms worth naming in the explanation; smaller ones are summed as "other". +_MIN_FACTOR_EUR = 1.0 +_DAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] +_MONTHS = [ + "January", "February", "March", "April", "May", "June", "July", + "August", "September", "October", "November", "December", +] # fmt: skip + + +@dataclass(frozen=True, slots=True) +class Factor: + """One term's pull on the recommended rate.""" + + detail: str # the factor, e.g. "Saturday" or "18.3°C max" + amount: Decimal # contribution in currency; terms + baseline = the rate + + +@dataclass(frozen=True, slots=True) +class Explanation: + summary: str + baseline: Decimal # the model's starting point before any term + factors: list[Factor] + + +@dataclass(frozen=True, slots=True) +class ConfidenceInterval: + lower: Decimal + upper: Decimal + level: float # coverage, e.g. 0.8 + + +@dataclass(frozen=True, slots=True) +class Recommendation: + hotel_id: int + stay_date: date + room_type: str + recommended_rate: Decimal + currency: str + explanation: Explanation + confidence_interval: ConfidenceInterval + uncertainty: Decimal # sub-model disagreement; high = review (decision 0006) + model_id: int + model_version: str + is_placeholder: bool # kept for clients written against the placeholder + + +async def get_recommendation( + session: AsyncSession, + hotel_id: int, + stay_date: date, + *, + room_type_id: int | None = None, +) -> Recommendation: + """The recommended rate for one night of one room type. + + Raises: + NotFoundError: Unknown hotel or room type, or no active model. + InvalidRequestError: The room type belongs to another hotel or the + model wasn't trained on it. + """ + hotel = await get_hotel(session, hotel_id) + stored = await model_repo.get_active_pricing_model(session, hotel.id) + if stored is None: + raise NotFoundError("Active pricing model for hotel", hotel.id) + + room_types = await hotel_repo.list_room_types(session, hotel.id) + if room_type_id is None: + room_type = room_types[0] if room_types else None + else: + room_type = next((r for r in room_types if r.id == room_type_id), None) + if room_type is None: + raise NotFoundError("Room type", room_type_id or "for hotel") + trained_on = stored.training_summary.get("room_types", []) + if trained_on and room_type.name not in trained_on: + raise InvalidRequestError( + f"model v{stored.version} wasn't trained on room type " + f"{room_type.name!r}; train a new version" + ) + + model = EBMModel.from_bytes(stored.artifact) + weather = await weather_for(session, hotel.city, [stay_date], fallback=True) + nights = pd.DataFrame({"stay_date": [stay_date], "room_type": [room_type.name]}) + x = build_features(nights, NO_EVENTS, weather.frame, NO_FLIGHTS) + # A single row can't infer the trained category set; give it the model's. + x["room_type"] = pd.Categorical(x["room_type"], categories=trained_on or None) + + pred, lower, upper = (float(v[0]) for v in model.predict_interval(x)) + contributions = model.explain(x).iloc[0] + row = x.iloc[0] + + return Recommendation( + hotel_id=hotel.id, + stay_date=stay_date, + room_type=room_type.name, + recommended_rate=_money(pred), + currency=CURRENCY, + explanation=_explanation(model.intercept, contributions, row, pred), + confidence_interval=ConfidenceInterval( + _money(max(lower, 0.0)), _money(upper), round(1 - model.alpha, 2) + ), + uncertainty=_money(float(model.predict_uncertainty(x)[0])), + model_id=stored.id, + model_version=f"{stored.algorithm}-v{stored.version}", + is_placeholder=False, + ) + + +def _explanation( + intercept: float, contributions: pd.Series, row: pd.Series, pred: float +) -> Explanation: + """Name every term worth at least €1; the rest is summed as "other". + + Amounts are rounded to cents, and "other" absorbs the rounding, so the + baseline plus every amount always equals the recommended rate exactly. + """ + baseline = _money(intercept) + factors: list[Factor] = [] + for term, amount in contributions.sort_values(key=abs, ascending=False).items(): + if abs(amount) < _MIN_FACTOR_EUR: + continue + factors.append(Factor(detail=_describe(str(term), row), amount=_money(amount))) + other = _money(pred) - baseline - sum((f.amount for f in factors), Decimal(0)) + if other: + factors.append(Factor(detail="Smaller effects combined", amount=other)) + top = ( + f"{factors[0].detail}: {_signed(float(factors[0].amount))}" + if factors + else "no strong factors" + ) + summary = f"€{pred:.0f} from a typical €{intercept:.0f}; biggest factor: {top}." + return Explanation(summary, baseline, factors) + + +def _describe(term: str, row: pd.Series) -> str: + """Name a term by the night's value, e.g. "Saturday" or "18.3°C max".""" + # `row` is one night, so each column holds a single scalar; pandas' stubs + # type indexing as possibly a Series, hence the cast. + return " with ".join( + _value(part, cast("float | int | str | None", row[part])) + for part in term.split(" & ") + ) + + +def _value(feature: str, value: float | int | str | None) -> str: + """One feature's value for this night, in words. Values are scalars: + numbers for calendar and weather, a name for the room type.""" + if value is None or pd.isna(value): + return f"{feature.replace('_', ' ')} unknown" + if feature == "day_of_week": + return _DAYS[int(value)] + if feature == "month": + return _MONTHS[int(value) - 1] + if feature == "temp_max_c": + return f"{float(value):.1f}°C max" + if feature == "precip_mm": + return f"{float(value):.1f} mm rain" + if feature == "wind_kph": + return f"{float(value):.0f} km/h wind" + if feature == "room_type": + return f"{value} room" + return f"{feature.replace('_', ' ')} {value}" + + +def _signed(amount: float) -> str: + return f"{'+' if amount >= 0 else '-'}€{abs(amount):.2f}" + + +def _money(value: float) -> Decimal: + return Decimal(str(value)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) diff --git a/backend/foresight/services/domain/weather.py b/backend/foresight/services/domain/weather.py new file mode 100644 index 0000000..87f0459 --- /dev/null +++ b/backend/foresight/services/domain/weather.py @@ -0,0 +1,32 @@ +"""Weather: daily forecasts and recorded actuals for a hotel's city.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from foresight.repository.signals import weather as weather_repo +from foresight.services.domain.hotel import get_hotel + +if TYPE_CHECKING: + from collections.abc import Sequence + from datetime import date + + from sqlalchemy.ext.asyncio import AsyncSession + + from foresight.database.models.signals.weather import Weather + + +async def list_weather( + session: AsyncSession, + hotel_id: int, + *, + start: date | None, + end: date | None, + limit: int, + offset: int, +) -> tuple[Sequence[Weather], int]: + """Forecast and recorded rows for the hotel's city, both returned.""" + hotel = await get_hotel(session, hotel_id) + return await weather_repo.list_weather( + session, city_id=hotel.city_id, start=start, end=end, limit=limit, offset=offset + ) diff --git a/backend/foresight/services/domain/weather_coverage.py b/backend/foresight/services/domain/weather_coverage.py new file mode 100644 index 0000000..59d16be --- /dev/null +++ b/backend/foresight/services/domain/weather_coverage.py @@ -0,0 +1,87 @@ +"""How completely recorded weather covers a date window.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date, timedelta +from typing import TYPE_CHECKING + +from foresight.repository import hotel as hotel_repo +from foresight.repository.signals import weather as weather_repo +from foresight.services.domain.hotel import get_hotel +from foresight.services.ingest.window import InvalidWindowError + +if TYPE_CHECKING: + from collections.abc import Iterable + + from sqlalchemy.ext.asyncio import AsyncSession + + +@dataclass(frozen=True, slots=True) +class DateSpan: + """Consecutive days, both ends inclusive.""" + + first: date + last: date + + +@dataclass(frozen=True, slots=True) +class WeatherCoverage: + city_id: int + start: date + end: date + days: int # days in the window + rows: int # days with a row saved + earliest: date | None # first day with values + missing: list[DateSpan] # no row at all + empty: list[DateSpan] # row saved, values missing (archive lag) + + +async def weather_coverage( + session: AsyncSession, *, city_id: int, start: date, end: date +) -> WeatherCoverage: + """Count saved days and group the gaps into runs of consecutive days.""" + saved = await weather_repo.recorded_days( + session, city_id=city_id, start=start, end=end + ) + window = [start + timedelta(days=n) for n in range((end - start).days + 1)] + usable = [d for d, has_values in saved.items() if has_values] + return WeatherCoverage( + city_id=city_id, + start=start, + end=end, + days=len(window), + rows=len(saved), + earliest=min(usable, default=None), + missing=_spans(d for d in window if d not in saved), + empty=_spans(sorted(d for d, has_values in saved.items() if not has_values)), + ) + + +async def hotel_weather_coverage( + session: AsyncSession, hotel_id: int, *, start: date | None, end: date | None +) -> WeatherCoverage: + """Coverage of the hotel's city; omitted dates default to its loaded nights.""" + hotel = await get_hotel(session, hotel_id) + if start is None or end is None: + loaded = await hotel_repo.stay_date_range(session, hotel.id) + if loaded is None: + raise InvalidWindowError( + "no hotel nights loaded; load performance data or give both " + "start and end" + ) + start, end = start or loaded[0], end or loaded[1] + if start > end: + raise InvalidWindowError(f"start {start} is after end {end}") + return await weather_coverage(session, city_id=hotel.city_id, start=start, end=end) + + +def _spans(days: Iterable[date]) -> list[DateSpan]: + """Group sorted days into runs of consecutive days.""" + spans: list[DateSpan] = [] + for day in days: + if spans and day == spans[-1].last + timedelta(days=1): + spans[-1] = DateSpan(spans[-1].first, day) + else: + spans.append(DateSpan(day, day)) + return spans diff --git a/backend/foresight/services/errors.py b/backend/foresight/services/errors.py new file mode 100644 index 0000000..a53c14d --- /dev/null +++ b/backend/foresight/services/errors.py @@ -0,0 +1,31 @@ +"""Domain errors raised by services and translated to HTTP by the routes layer. + +Services describe *what* went wrong; `routes/errors.py` maps each error to a +status code. A `get_x(id)` lookup that finds nothing raises NotFoundError. +""" + + +class DomainError(Exception): + """Base for every error a service raises on purpose.""" + + +class NotFoundError(DomainError): + """Something the operation depends on doesn't exist.""" + + def __init__(self, entity: str, entity_id: int | str) -> None: + super().__init__(f"{entity} {entity_id} not found") + self.entity = entity + self.entity_id = entity_id + + +class ConflictError(DomainError): + """The write would duplicate something that already exists.""" + + +class InvalidRequestError(DomainError): + """The request can't be carried out as given, e.g. a hotel lacks the data + a source needs, or a date window is empty.""" + + +class NotConfiguredError(DomainError): + """A server-side setting the operation needs (an API key) is missing.""" diff --git a/backend/foresight/services/example_widget.py b/backend/foresight/services/example_widget.py index abec566..c5591ee 100644 --- a/backend/foresight/services/example_widget.py +++ b/backend/foresight/services/example_widget.py @@ -5,33 +5,44 @@ Layer rules shown here: - Services hold domain logic and orchestration; they call the repository for persistence and never touch the ORM/session directly. -- Services take plain inputs or schemas and return domain data (ORM models or - simple values) — never HTTP responses or status codes. -- Map request schemas to repository calls here so the repository stays - schema-agnostic. +- Services take plain arguments (never a schema) and return domain data (ORM + models or dataclasses) — never `*Response` schemas or status codes. +- When an operation can't go ahead, raise an error from `services/errors.py` + (`NotFoundError`, `ConflictError`, ...); `routes/errors.py` maps it to HTTP. + `get_x(id)` raises NotFoundError rather than returning None. """ -from collections.abc import Sequence +from __future__ import annotations -from sqlalchemy.ext.asyncio import AsyncSession +from typing import TYPE_CHECKING -from foresight.database.models.example_widget import Widget from foresight.repository import example_widget as widget_repo -from foresight.schemas.example_widget import WidgetRequest +from foresight.services.errors import NotFoundError +if TYPE_CHECKING: + from collections.abc import Sequence -async def create_widget(session: AsyncSession, data: WidgetRequest) -> Widget: + from sqlalchemy.ext.asyncio import AsyncSession + + from foresight.database.models.example_widget import Widget + + +async def create_widget( + session: AsyncSession, *, name: str, description: str | None, quantity: int +) -> Widget: return await widget_repo.create_widget( - session, - name=data.name, - description=data.description, - quantity=data.quantity, + session, name=name, description=description, quantity=quantity ) -async def get_widget(session: AsyncSession, widget_id: int) -> Widget | None: - return await widget_repo.get_widget(session, widget_id) +async def get_widget(session: AsyncSession, widget_id: int) -> Widget: + widget = await widget_repo.get_widget(session, widget_id) + if widget is None: + raise NotFoundError("Widget", widget_id) + return widget -async def list_widgets(session: AsyncSession) -> Sequence[Widget]: - return await widget_repo.list_widgets(session) +async def list_widgets( + session: AsyncSession, *, limit: int, offset: int +) -> tuple[Sequence[Widget], int]: + return await widget_repo.list_widgets(session, limit=limit, offset=offset) diff --git a/backend/foresight/services/flight_data_service.py b/backend/foresight/services/flight_data_service.py deleted file mode 100644 index 4bd52c9..0000000 --- a/backend/foresight/services/flight_data_service.py +++ /dev/null @@ -1,292 +0,0 @@ -r"""Fetch flight data from the CSO, OpenSky and AeroDataBox and save it as CSV. - -Dates and times are in Dublin local time (Europe/Dublin). - -Example: - Run from ``backend/``:: - - uv run python foresight/services/flight_data_service.py \ - --start 2026-06-01 --end 2026-06-07 - uv run python foresight/services/flight_data_service.py \ - --api cso --start 2026-01-01 --end 2026-06-30 -""" - -import argparse -import io -from datetime import date, datetime, time, timedelta -from pathlib import Path -from time import sleep -from typing import Any -from zoneinfo import ZoneInfo - -import httpx -import pandas as pd - -from foresight.config import settings - -OUTPUT_DIR = Path(__file__).resolve().parents[2] / "data" / "flights" - -CSO_URL = "https://ws.cso.ie/public/api.restful/PxStat.Data.Cube_API.ReadDataset/TAM07/CSV/1.0/en" -OPENSKY_URL = "https://opensky-network.org/api/flights/arrival" -OPENSKY_TOKEN_URL = "https://auth.opensky-network.org/auth/realms/opensky-network/protocol/openid-connect/token" -AERODATABOX_HOST = "aerodatabox.p.rapidapi.com" -DUBLIN = ZoneInfo("Europe/Dublin") -# RapidAPI's basic plan allows about one request a second. -AERODATABOX_PACE_SECONDS = 1.1 -# A 429 is often just "too fast": wait 2s, 4s, 8s before treating it as the quota. -AERODATABOX_MAX_RETRIES = 3 -AERODATABOX_BACKOFF_SECONDS = 2.0 -_RETRY_STATUS = frozenset({429, 500, 502, 503, 504}) - - -class RateLimitedError(Exception): - """AeroDataBox said 429: too many requests, or the monthly quota is used up.""" - - -def _days(start: date, end: date) -> list[date]: - """List every date in a range. - - Args: - start (date): First date. - end (date): Last date, inclusive. - - Returns: - list[date]: Dates from ``start`` to ``end``. - """ - return [start + timedelta(days=i) for i in range((end - start).days + 1)] - - -def fetch_cso(start: date, end: date, airport: str = "Dublin") -> pd.DataFrame: - """Fetch monthly passenger numbers for an Irish airport from CSO table TAM07. - - Args: - start (date): First day of the range. Its whole month is included. - end (date): Last day of the range, inclusive. Its whole month is included. - airport (str): CSO airport name, e.g. "Dublin" or "Cork". Defaults to "Dublin". - - Returns: - pandas.DataFrame: One row per month, country, direction and flight type, with - columns ``year``, ``month``, ``airport``, ``country``, ``direction``, - ``flight_type`` and ``passengers_thousands``. - - Raises: - httpx.HTTPStatusError: If the CSO request fails. - """ - response = httpx.get(CSO_URL, timeout=60, follow_redirects=True) - response.raise_for_status() - df = pd.read_csv(io.StringIO(response.content.decode("utf-8-sig"))) - - df = df[ - (df["Statistic Label"] == "Passengers") & (df["Airports in Ireland"] == airport) - ] - dates = pd.to_datetime(df["Month"], format="%Y %B") - df = pd.DataFrame( - { - "year": dates.dt.year, - "month": dates.dt.month, - "airport": df["Airports in Ireland"], - "country": df["Country"], - "direction": df["Direction"], - "flight_type": df["Flight Type"], - "passengers_thousands": df["VALUE"], - } - ) - first_month = pd.Timestamp(start).replace(day=1) - return df[(dates >= first_month) & (dates <= pd.Timestamp(end))] - - -def fetch_opensky(start: date, end: date, airport: str = "EIDW") -> pd.DataFrame: - """Fetch individual flights arriving at an airport from OpenSky. - - Args: - start (date): First day of the range (Dublin time). - end (date): Last day of the range, inclusive (Dublin time). - airport (str): ICAO airport code. Defaults to "EIDW" (Dublin). - - Returns: - pandas.DataFrame: One row per flight with OpenSky's fields; ``firstSeen``, - ``lastSeen`` and ``arrival_time`` are in Dublin time. Empty if there were no - flights. - - Raises: - httpx.HTTPStatusError: If authentication or a request fails. - """ - token = httpx.post( - OPENSKY_TOKEN_URL, - data={ - "grant_type": "client_credentials", - "client_id": settings.opensky_client_id, - "client_secret": settings.opensky_client_secret, - }, - timeout=30, - ) - token.raise_for_status() - headers = {"Authorization": f"Bearer {token.json()['access_token']}"} - - rows = [] - for day in _days(start, end): - # Midnight to midnight in Dublin; 23 or 25 hours on clock-change days. - begin = datetime.combine(day, time.min, DUBLIN) - end_of_day = datetime.combine(day + timedelta(days=1), time.min, DUBLIN) - response = httpx.get( - OPENSKY_URL, - params={ - "airport": airport, - "begin": int(begin.timestamp()), - "end": int(end_of_day.timestamp()), - }, - headers=headers, - timeout=60, - ) - if response.status_code == 404: # no flights that day - continue - response.raise_for_status() - rows.extend(response.json()) - - df = pd.DataFrame(rows) - if df.empty: - return df - for col in ("firstSeen", "lastSeen"): - df[col] = pd.to_datetime(df[col], unit="s", utc=True).dt.tz_convert(DUBLIN) - df["arrival_time"] = df["lastSeen"] - return df - - -def _get_aerodatabox(url: str, **kwargs: Any) -> httpx.Response: - """GET from AeroDataBox, retrying rate limits, 5xx and network errors. - - Raises: - RateLimitedError: If it still answers 429 after every retry. - httpx.HTTPStatusError: On any other failed status. - """ - for attempt in range(AERODATABOX_MAX_RETRIES): - try: - response = httpx.get(url, **kwargs) - except httpx.TransportError: - pass # network blip or timeout: wait and try again - else: - if response.status_code not in _RETRY_STATUS: - response.raise_for_status() - return response - sleep(AERODATABOX_BACKOFF_SECONDS * 2**attempt) - # Last try: a 429 now most likely means the monthly quota is used up. - response = httpx.get(url, **kwargs) - if response.status_code == 429: - raise RateLimitedError(response.text[:300]) - response.raise_for_status() - return response - - -def fetch_aerodatabox(start: date, end: date, airport: str = "DUB") -> pd.DataFrame: - """Fetch scheduled flights arriving at an airport from AeroDataBox (via RapidAPI). - - Each day costs about 4 API units; the free plan has 400 a month. - - Args: - start (date): First day of the range (airport local time). - end (date): Last day of the range, inclusive (airport local time). - airport (str): IATA airport code. Defaults to "DUB" (Dublin). - - Returns: - pandas.DataFrame: One row per flight scheduled in the range, with columns - ``flight_number``, ``airline``, ``aircraft_model``, ``origin_iata``, - ``origin_name``, ``origin_country``, ``status`` and ``arrival_time`` - (scheduled, in Dublin time). Empty if there were no flights. - - Raises: - RateLimitedError: If AeroDataBox still answers 429 after retrying - (usually the monthly quota). - httpx.HTTPStatusError: If a request fails otherwise. - """ - headers = { - "X-RapidAPI-Key": settings.xrapidapi_key, - "X-RapidAPI-Host": AERODATABOX_HOST, - } - params = { - "direction": "Arrival", - "withCancelled": "false", - "withCodeshared": "false", - "withCargo": "false", - "withPrivate": "false", - "withLocation": "false", - } - - rows = [] - for day in _days(start, end): - # The API allows at most 12 hours per request. - midnight = datetime.combine(day, time.min) - for window_start in (midnight, midnight + timedelta(hours=12)): - window_end = window_start + timedelta(hours=11, minutes=59) - sleep(AERODATABOX_PACE_SECONDS) - response = _get_aerodatabox( - f"https://{AERODATABOX_HOST}/flights/airports/iata/{airport}" - f"/{window_start:%Y-%m-%dT%H:%M}/{window_end:%Y-%m-%dT%H:%M}", - params=params, - headers=headers, - timeout=30, - ) - if response.status_code == 204: # no flights in this window - continue - for item in response.json().get("arrivals", []): - move = item.get("movement") or {} - origin = move.get("airport") or {} - scheduled = move.get("scheduledTime") or {} - # Skip delayed flights that were scheduled for the day before. - if not scheduled.get("local", "").startswith(day.isoformat()): - continue - rows.append( - { - "flight_number": item.get("number"), - "airline": (item.get("airline") or {}).get("name"), - "aircraft_model": (item.get("aircraft") or {}).get("model"), - "origin_iata": origin.get("iata"), - "origin_name": origin.get("name"), - "origin_country": origin.get("countryCode"), - "status": item.get("status"), - "arrival_time": scheduled.get("utc"), - } - ) - - df = pd.DataFrame(rows) - if df.empty: - return df - # A delayed flight near a window edge can be returned twice. - df = df.drop_duplicates(["flight_number", "arrival_time", "origin_iata"]) - df["arrival_time"] = pd.to_datetime(df["arrival_time"], utc=True).dt.tz_convert( - DUBLIN - ) - return df - - -FETCHERS = { - "cso": fetch_cso, - "opensky": fetch_opensky, - "aerodatabox": fetch_aerodatabox, -} - - -def main() -> None: - """Fetch the chosen APIs for a date range and save a CSV for each.""" - parser = argparse.ArgumentParser( - description="Fetch Dublin flight data and save CSVs." - ) - parser.add_argument("--api", nargs="+", choices=FETCHERS, default=list(FETCHERS)) - parser.add_argument( - "--start", type=date.fromisoformat, required=True, help="YYYY-MM-DD" - ) - parser.add_argument( - "--end", type=date.fromisoformat, help="YYYY-MM-DD, defaults to --start" - ) - args = parser.parse_args() - end = args.end or args.start - - OUTPUT_DIR.mkdir(parents=True, exist_ok=True) - for api in args.api: - df = FETCHERS[api](args.start, end) - path = OUTPUT_DIR / f"{api}_{args.start}_{end}.csv" - df.to_csv(path, index=False) - print(f"[{api}] {len(df)} rows saved to {path}") - - -if __name__ == "__main__": - main() diff --git a/backend/foresight/services/hotel.py b/backend/foresight/services/hotel.py deleted file mode 100644 index c640495..0000000 --- a/backend/foresight/services/hotel.py +++ /dev/null @@ -1,80 +0,0 @@ -from collections.abc import Sequence -from datetime import date - -from sqlalchemy.ext.asyncio import AsyncSession - -from foresight.database.models.hotel import DailyPerformance, Hotel, RoomType -from foresight.repository import hotel as hotel_repo -from foresight.schemas.hotel import HotelRequest, RoomTypeRequest - - -async def create_hotel(session: AsyncSession, data: HotelRequest) -> Hotel: - return await hotel_repo.create_hotel( - session, - name=data.name, - city=data.city, - total_rooms=data.total_rooms, - latitude=data.latitude, - longitude=data.longitude, - ) - - -async def get_hotel(session: AsyncSession, hotel_id: int) -> Hotel | None: - return await hotel_repo.get_hotel(session, hotel_id) - - -async def list_hotels( - session: AsyncSession, *, limit: int, offset: int -) -> tuple[Sequence[Hotel], int]: - return await hotel_repo.list_hotels(session, limit=limit, offset=offset) - - -async def create_room_type( - session: AsyncSession, data: RoomTypeRequest -) -> RoomType | None: - if await hotel_repo.get_hotel(session, data.hotel_id) is None: - return None - return await hotel_repo.create_room_type( - session, - hotel_id=data.hotel_id, - name=data.name, - room_count=data.room_count, - base_rate=data.base_rate, - ) - - -async def get_room_type(session: AsyncSession, room_type_id: int) -> RoomType | None: - return await hotel_repo.get_room_type(session, room_type_id) - - -async def list_room_types( - session: AsyncSession, hotel_id: int, *, limit: int, offset: int -) -> tuple[Sequence[RoomType], int] | None: - if await hotel_repo.get_hotel(session, hotel_id) is None: - return None - return await hotel_repo.list_room_types( - session, hotel_id, limit=limit, offset=offset - ) - - -async def list_performance( - session: AsyncSession, - hotel_id: int, - *, - start: date | None, - end: date | None, - room_type_id: int | None, - limit: int, - offset: int, -) -> tuple[Sequence[DailyPerformance], int] | None: - if await hotel_repo.get_hotel(session, hotel_id) is None: - return None - return await hotel_repo.list_performance( - session, - hotel_id, - start=start, - end=end, - room_type_id=room_type_id, - limit=limit, - offset=offset, - ) diff --git a/backend/foresight/services/ingest/__init__.py b/backend/foresight/services/ingest/__init__.py new file mode 100644 index 0000000..311398b --- /dev/null +++ b/backend/foresight/services/ingest/__init__.py @@ -0,0 +1,3 @@ +"""Ingest: fetch a signal for a city through an adapter and save it, in +chunks a job commits as it goes. +""" diff --git a/backend/foresight/services/ingest/event.py b/backend/foresight/services/ingest/event.py new file mode 100644 index 0000000..c1f5d7f --- /dev/null +++ b/backend/foresight/services/ingest/event.py @@ -0,0 +1,113 @@ +"""PredictHQ events for a city, fetched and saved. + +Two modes, chosen by whether the caller gave dates: + +- `ingest_recent`: the routine refresh. Only events PredictHQ revised since a + moment, so a frequent run costs little quota. +- `ingest_window`: a backfill. Every event active in the window, a month at a + time, so a run cut short keeps every finished month. +""" + +from __future__ import annotations + +import logging +from collections import Counter +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +from foresight.services.adapters import predicthq +from foresight.services.adapters.predicthq import FetchResult, Market +from foresight.services.domain.event import save_event + +if TYPE_CHECKING: + from collections.abc import AsyncIterator + from datetime import date, datetime + + import httpx + from sqlalchemy.ext.asyncio import AsyncSession + + from foresight.database.models.city import City + from foresight.repository.upsert import Outcome + from foresight.services.ingest.window import Window + +log = logging.getLogger(__name__) + +RADIUS = "10km" + + +@dataclass(slots=True) +class EventReport: + """Running totals; yielded after every saved chunk.""" + + fetched: int = 0 + rejected: int = 0 + outcomes: Counter[Outcome] = field(default_factory=Counter) + # Months PredictHQ truncated at the plan's result limit. + overflowed: list[tuple[date, date]] = field(default_factory=list) + + +async def ingest_recent( + session: AsyncSession, + city: City, + *, + token: str, + updated_since: datetime, + transport: httpx.AsyncBaseTransport | None = None, +) -> EventReport: + """Save upcoming events PredictHQ revised since `updated_since`. + + Does not commit. Raises the adapter's errors. + """ + async with predicthq.client(token, transport=transport) as http: + result = await predicthq.fetch( + http, + predicthq.recent_params( + _market(city), updated_since=updated_since, radius=RADIUS + ), + city=city.name, + ) + report = EventReport() + await _save(session, city, result, report) + return report + + +async def ingest_window( + session: AsyncSession, + city: City, + window: Window, + *, + token: str, + transport: httpx.AsyncBaseTransport | None = None, +) -> AsyncIterator[tuple[EventReport, date, date]]: + """Save every event active in `window`, a month at a time. + + Yields the running report and the month just saved, so the caller can + commit as it goes; never commits itself. A failure raises out of the + month it happened in, with that month unsaved. + """ + report = EventReport() + market = _market(city) + async with predicthq.client(token, transport=transport) as http: + for first, last in window.months(): + result = await predicthq.fetch( + http, + predicthq.window_params(market, first, last, radius=RADIUS), + city=city.name, + ) + if result.overflowed: + report.overflowed.append((first, last)) + await _save(session, city, result, report) + yield report, first, last + + +async def _save( + session: AsyncSession, city: City, result: FetchResult, report: EventReport +) -> None: + report.fetched += result.fetched + report.rejected += len(result.rejected) + for event in result.events: + report.outcomes[await save_event(session, city.id, event)] += 1 + + +def _market(city: City) -> Market: + return Market(city=city.name, latitude=city.latitude, longitude=city.longitude) diff --git a/backend/foresight/services/ingest/performance.py b/backend/foresight/services/ingest/performance.py new file mode 100644 index 0000000..b5fec6c --- /dev/null +++ b/backend/foresight/services/ingest/performance.py @@ -0,0 +1,69 @@ +"""A hotel's nightly performance, parsed from client CSV exports and saved.""" + +from __future__ import annotations + +import io +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +from foresight.repository import hotel as hotel_repo +from foresight.services.adapters.hotel_csv import ( + PARSE_ERRORS, + ROOM_TYPE, + HotelDataParser, +) +from foresight.services.errors import InvalidRequestError + +if TYPE_CHECKING: + from decimal import Decimal + + from sqlalchemy.ext.asyncio import AsyncSession + + from foresight.database.models.hotel import Hotel + from foresight.repository.upsert import Outcome + + +@dataclass(frozen=True, slots=True) +class ParsedFile: + name: str + nights: list[dict[str, Any]] # `daily_performance` rows minus room_type_id + total_rooms: int + + +def parse_files(files: list[tuple[str, bytes]]) -> list[ParsedFile]: + """Parse every file before anything is saved. + + Raises: + InvalidRequestError: Naming the first file that doesn't parse. + """ + parsed = [] + for name, content in files: + parser = HotelDataParser(io.BytesIO(content)) + try: + parser.run() + except PARSE_ERRORS as exc: + raise InvalidRequestError(f"could not import {name}: {exc}") from exc + parsed.append( + ParsedFile(name, parser.rows(room_type_id=None), parser.total_rooms()) + ) + return parsed + + +async def save_file( + session: AsyncSession, hotel: Hotel, parsed: ParsedFile, *, base_rate: Decimal +) -> dict[Outcome, int]: + """Upsert one file's nights onto the hotel's standard room type. + + The room type is created on first import; its room count follows the + file. Re-importing the same file changes nothing. Does not commit. + """ + room_type_id = await hotel_repo.upsert_room_type( + session, + hotel_id=hotel.id, + name=ROOM_TYPE, + room_count=parsed.total_rooms, + base_rate=base_rate, + ) + return await hotel_repo.upsert_daily_performance( + session, [{**night, "room_type_id": room_type_id} for night in parsed.nights] + ) diff --git a/backend/foresight/services/ingest/weather.py b/backend/foresight/services/ingest/weather.py new file mode 100644 index 0000000..4a3a1ef --- /dev/null +++ b/backend/foresight/services/ingest/weather.py @@ -0,0 +1,71 @@ +"""Recorded and forecast weather for a city, fetched and saved.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from foresight.repository.signals import weather as weather_repo +from foresight.services.adapters import open_meteo + +if TYPE_CHECKING: + from datetime import date + + import httpx + from sqlalchemy.ext.asyncio import AsyncSession + + from foresight.database.models.city import City + from foresight.repository.upsert import Outcome + + +async def ingest_actuals( + session: AsyncSession, + city: City, + *, + start: date, + end: date, + transport: httpx.AsyncBaseTransport | None = None, +) -> dict[Outcome, int]: + """Fetch recorded weather for every day from `start` to `end` and save it. + + The archive stops at today. Does not commit; the caller owns the transaction. + """ + async with open_meteo.client(transport=transport) as http: + rows = await open_meteo.fetch_actuals( + http, + latitude=city.latitude, + longitude=city.longitude, + start=start, + end=end, + timezone=city.timezone, + ) + return await weather_repo.upsert_weather(session, _for(city, rows)) + + +async def ingest_forecast( + session: AsyncSession, + city: City, + *, + start: date | None = None, + end: date | None = None, + transport: httpx.AsyncBaseTransport | None = None, +) -> dict[Outcome, int]: + """Fetch the next 16 days of forecast and save the days in `start`..`end` + (all of them when omitted).""" + async with open_meteo.client(transport=transport) as http: + rows = await open_meteo.fetch_forecast( + http, + latitude=city.latitude, + longitude=city.longitude, + timezone=city.timezone, + ) + rows = [ + row + for row in rows + if (start is None or row["weather_date"] >= start) + and (end is None or row["weather_date"] <= end) + ] + return await weather_repo.upsert_weather(session, _for(city, rows)) + + +def _for(city: City, rows: list[dict]) -> list[dict]: + return [{**row, "city_id": city.id} for row in rows] diff --git a/backend/foresight/services/ingest/window.py b/backend/foresight/services/ingest/window.py new file mode 100644 index 0000000..b020d18 --- /dev/null +++ b/backend/foresight/services/ingest/window.py @@ -0,0 +1,114 @@ +"""The date window an ingest covers, from the optional `start` and `end` given. + +Giving any date makes the run a backfill over a window: + +- start and end: that window. +- start only: start to today. +- end only: backwards from end to the hotel's first loaded night, newest + first, so a source that runs out of credits has saved the most recent part. + +Giving neither isn't a window; each signal has its own routine refresh. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date, timedelta +from typing import TYPE_CHECKING, Any + +from foresight.repository import hotel as hotel_repo +from foresight.services.errors import InvalidRequestError + +if TYPE_CHECKING: + from collections.abc import Callable, Iterator, Mapping + + from sqlalchemy.ext.asyncio import AsyncSession + + +class InvalidWindowError(InvalidRequestError): + """The requested date window can't be resolved.""" + + +@dataclass(frozen=True, slots=True) +class Window: + start: date + end: date + backwards: bool = False # walk newest first + + def as_params(self) -> dict[str, str | bool]: + return { + "start": self.start.isoformat(), + "end": self.end.isoformat(), + "backwards": self.backwards, + } + + @classmethod + def from_params(cls, params: Mapping[str, Any]) -> Window: + return cls( + date.fromisoformat(params["start"]), + date.fromisoformat(params["end"]), + params.get("backwards", False), + ) + + def months(self) -> Iterator[tuple[date, date]]: + """Calendar-month pieces, inclusive, in walking order.""" + return self._pieces( + lambda d: (d.replace(day=1) + timedelta(days=32)).replace(day=1) + ) + + def years(self) -> Iterator[tuple[date, date]]: + """Calendar-year pieces, inclusive, in walking order.""" + return self._pieces(lambda d: date(d.year + 1, 1, 1)) + + def clip(self, start: date, end: date) -> Window | None: + """The part of this window inside `start`..`end`, if any.""" + first, last = max(self.start, start), min(self.end, end) + return Window(first, last, self.backwards) if first <= last else None + + def _pieces( + self, next_boundary: Callable[[date], date] + ) -> Iterator[tuple[date, date]]: + pieces = [] + first = self.start + while first <= self.end: + last = min(next_boundary(first) - timedelta(days=1), self.end) + pieces.append((first, last)) + first = last + timedelta(days=1) + return reversed(pieces) if self.backwards else iter(pieces) + + def resume_point(self, first: date, last: date) -> date: + """The `start` (forwards) or `end` (backwards) to pass to pick up at + the piece `first`..`last`.""" + return last if self.backwards else first + + +async def resolve_window( + session: AsyncSession, + hotel_id: int, + start: date | None, + end: date | None, + *, + today: date, +) -> Window: + """Raises InvalidWindowError if the window is empty or can't be resolved. + + Only call with at least one date given. + """ + if start is not None and end is None: + end = today + elif start is None and end is not None: + loaded = await hotel_repo.stay_date_range(session, hotel_id) + if loaded is None: + raise InvalidWindowError( + "end alone walks back to the hotel's first loaded night, and " + "none are loaded; give a start too" + ) + if loaded[0] > end: + raise InvalidWindowError( + f"end {end} is before the hotel's first loaded night {loaded[0]}" + ) + return Window(loaded[0], end, backwards=True) + assert start is not None and end is not None + if start > end: + raise InvalidWindowError(f"start {start} is after end {end}") + return Window(start, end) diff --git a/backend/foresight/services/job.py b/backend/foresight/services/job.py new file mode 100644 index 0000000..806e113 --- /dev/null +++ b/backend/foresight/services/job.py @@ -0,0 +1,456 @@ +"""Background jobs: one kind per signal, and the job lifecycle. + +`start_*` validates up front (the API key is set, the window resolves, the +files parse) so a bad request fails with 4xx instead of a queued job that +fails later. Handlers are async generators: they save through +`services/ingest/` and yield a `Step` after each chunk. The worker +(`foresight/worker.py`) commits at every step, so a job's progress always +matches what is saved, and records the last step's `resume_from` if the run +ends early. + +Dates: with neither `start` nor `end` a job does its signal's routine refresh; +with either it backfills the window `services/ingest/window.py` resolves. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from datetime import date as date_cls +from decimal import Decimal +from typing import TYPE_CHECKING, Any +from zoneinfo import ZoneInfo + +from foresight.config import settings +from foresight.database.enums import JobKind, JobStatus +from foresight.repository import job as job_repo +from foresight.services.adapters.open_meteo import FORECAST_DAYS +from foresight.services.adapters.predicthq import PredictHQLimitError +from foresight.services.domain import pricing_model as pricing_model_service +from foresight.services.domain.hotel import get_hotel +from foresight.services.errors import ( + ConflictError, + NotConfiguredError, + NotFoundError, +) +from foresight.services.ingest import event as event_ingest +from foresight.services.ingest import performance as performance_ingest +from foresight.services.ingest import weather as weather_ingest +from foresight.services.ingest.window import InvalidWindowError, Window, resolve_window + +if TYPE_CHECKING: + from collections.abc import AsyncIterator, Callable, Mapping, Sequence + from datetime import date + + from sqlalchemy.ext.asyncio import AsyncSession + + from foresight.database.models.city import City + from foresight.database.models.hotel import Hotel + from foresight.database.models.job import Job + from foresight.repository.upsert import Outcome + + type Handler = Callable[ + [AsyncSession, Hotel, Mapping[str, Any], Any], AsyncIterator[Step] + ] + +# How far back the first routine events refresh looks for revisions. +FIRST_REFRESH_DAYS = 7 +# A source's quota or rate limit: the job stops rather than fails. +_QUOTA_ERRORS = (PredictHQLimitError,) + + +@dataclass(frozen=True, slots=True) +class Step: + """One saved chunk: counts so far, and where to pick up if the run ends + now (None once nothing is left).""" + + progress: dict[str, Any] + resume_from: date | None + + +# --- starting and reading jobs ---------------------------------------------- + + +async def start_weather_job( + session: AsyncSession, hotel_id: int, *, start: date | None, end: date | None +) -> Job: + """No dates: the next 16 days of forecast. Dates: recorded weather for the + past part of the window and forecast for the next 16 days of it. + + Raises: + NotFoundError: Unknown hotel. + InvalidWindowError: The window doesn't resolve, or lies wholly beyond + the forecast. + ConflictError: A weather job is already queued or running. + """ + hotel = await get_hotel(session, hotel_id) + params: dict[str, Any] = {"mode": "forecast"} + if start is not None or end is not None: + today = _today(hotel.city) + window = await resolve_window(session, hotel.id, start, end, today=today) + if window.start > _forecast_end(today): + raise InvalidWindowError( + f"the forecast reaches {_forecast_end(today)}; {window.start} is " + "beyond it" + ) + params = {"mode": "window", **window.as_params()} + return await _queue(session, hotel, JobKind.WEATHER, params) + + +async def start_events_job( + session: AsyncSession, hotel_id: int, *, start: date | None, end: date | None +) -> Job: + """No dates: upcoming events PredictHQ revised since the last such run + (or the last week, the first time). Dates: every event in the window. + + Raises: + NotFoundError: Unknown hotel. + NotConfiguredError: PREDICTHQ_TOKEN isn't set. + InvalidWindowError: The window doesn't resolve. + ConflictError: An events job is already queued or running. + """ + hotel = await get_hotel(session, hotel_id) + if not settings.predicthq_token: + raise NotConfiguredError("PREDICTHQ_TOKEN not set on the server") + if start is None and end is None: + since = await job_repo.last_succeeded_start( + session, hotel_id=hotel.id, kind=JobKind.EVENTS, params={"mode": "recent"} + ) or _now() - timedelta(days=FIRST_REFRESH_DAYS) + params = {"mode": "recent", "updated_since": since.isoformat()} + else: + window = await resolve_window( + session, hotel.id, start, end, today=_today(hotel.city) + ) + params = {"mode": "window", **window.as_params()} + return await _queue(session, hotel, JobKind.EVENTS, params) + + +async def start_performance_job( + session: AsyncSession, + hotel_id: int, + *, + files: Sequence[tuple[str, bytes]], + base_rate: Decimal, +) -> tuple[Job, list[performance_ingest.ParsedFile]]: + """Parse every file now; the job saves them. Returns the job and the + parsed files, which the caller hands to the worker. + + Raises: + NotFoundError: Unknown hotel. + InvalidRequestError: A file doesn't parse. + ConflictError: A performance job is already queued or running. + """ + hotel = await get_hotel(session, hotel_id) + parsed = performance_ingest.parse_files(list(files)) + params = {"files": [f.name for f in parsed], "base_rate": str(base_rate)} + return await _queue(session, hotel, JobKind.PERFORMANCE, params), parsed + + +async def start_train_job( + session: AsyncSession, + hotel_id: int, + *, + description: str | None, + start: date | None, + end: date | None, + activate: bool, + ebm_params: dict[str, Any], +) -> Job: + """Train a new model version on what's already ingested. + + Raises: + NotFoundError: Unknown hotel. + InvalidRequestError: Nothing to train on, or start after end. + ConflictError: A training job is already queued or running. + """ + hotel = await pricing_model_service.check_trainable( + session, hotel_id, start=start, end=end + ) + params = { + "description": description, + "start": start.isoformat() if start else None, + "end": end.isoformat() if end else None, + "activate": activate, + "ebm_params": ebm_params, + } + return await _queue(session, hotel, JobKind.TRAIN, params) + + +async def get_job(session: AsyncSession, job_id: int) -> Job: + """Raises NotFoundError for an unknown job.""" + job = await job_repo.get_job(session, job_id) + if job is None: + raise NotFoundError("Job", job_id) + return job + + +async def list_jobs( + session: AsyncSession, hotel_id: int, *, limit: int, offset: int +) -> tuple[Sequence[Job], int]: + await get_hotel(session, hotel_id) + return await job_repo.list_jobs(session, hotel_id, limit=limit, offset=offset) + + +async def _queue( + session: AsyncSession, hotel: Hotel, kind: JobKind, params: dict[str, Any] +) -> Job: + running = await job_repo.find_unfinished_job(session, hotel_id=hotel.id, kind=kind) + if running is not None: + raise ConflictError( + f"job {running.id} ({kind.value}) is already {running.status.value}" + ) + return await job_repo.create_job( + session, hotel_id=hotel.id, kind=kind, params=params + ) + + +# --- lifecycle (called by the worker) --------------------------------------- + + +async def run( + session: AsyncSession, job: Job, payload: Any = None +) -> AsyncIterator[Step]: + """Run the job's handler, yielding after each saved chunk. + + `payload` is whatever the request handed over in memory (parsed files). + """ + hotel = await get_hotel(session, job.hotel_id) + async for step in _HANDLERS[job.kind](session, hotel, job.params, payload): + yield step + + +def initial_resume_from(job: Job) -> date | None: + """Where to pick up if the job ends before its first chunk is saved.""" + if job.params.get("mode") != "window": + return None + window = Window.from_params(job.params) + return window.end if window.backwards else window.start + + +async def mark_running(session: AsyncSession, job_id: int) -> None: + await job_repo.update_job( + session, job_id, status=JobStatus.RUNNING, started_at=_now() + ) + + +async def record_progress( + session: AsyncSession, job_id: int, progress: dict[str, Any] +) -> None: + await job_repo.update_job(session, job_id, progress=progress) + + +async def mark_succeeded(session: AsyncSession, job_id: int) -> None: + await job_repo.update_job( + session, + job_id, + status=JobStatus.SUCCEEDED, + resume_from=None, + finished_at=_now(), + ) + + +async def mark_ended_early( + session: AsyncSession, job_id: int, exc: Exception, resume_from: date | None +) -> None: + """Stopped on a quota or rate limit, failed on anything else. Either way + the saved chunks stay and `resume_from` says where to pick up.""" + stopped = isinstance(exc, _QUOTA_ERRORS) + await job_repo.update_job( + session, + job_id, + status=JobStatus.STOPPED if stopped else JobStatus.FAILED, + error=str(exc) if stopped else f"{type(exc).__name__}: {exc}", + resume_from=resume_from, + finished_at=_now(), + ) + + +async def fail_interrupted_jobs(session: AsyncSession) -> int: + """Jobs left queued or running by a restart can't continue; fail them.""" + return await job_repo.fail_unfinished_jobs( + session, "interrupted by a server restart; start it again" + ) + + +# --- handlers --------------------------------------------------------------- + + +async def _weather( + session: AsyncSession, hotel: Hotel, params: Mapping[str, Any], _: Any +) -> AsyncIterator[Step]: + city = hotel.city + if params["mode"] == "forecast": + outcomes = await weather_ingest.ingest_forecast(session, city) + yield Step({"outcomes": _counts(outcomes)}, None) + return + + window = Window.from_params(params) + today = _today(city) + past = window.clip(window.start, today - timedelta(days=1)) + future = window.clip(today, _forecast_end(today)) + pieces = [ + ("recorded", first, last) for first, last in (past.years() if past else ()) + ] + if future is not None: + forecast = ("forecast", future.start, future.end) + pieces = [forecast, *pieces] if window.backwards else [*pieces, forecast] + + progress: dict[str, Any] = {"saved": [], "outcomes": {}} + if window.end > _forecast_end(today): + progress["beyond_forecast"] = [ + max(window.start, _forecast_end(today) + timedelta(days=1)).isoformat(), + window.end.isoformat(), + ] + total: dict[str, int] = {} + for i, (source, first, last) in enumerate(pieces): + if source == "recorded": + outcomes = await weather_ingest.ingest_actuals( + session, city, start=first, end=last + ) + else: + outcomes = await weather_ingest.ingest_forecast( + session, city, start=first, end=last + ) + for name, n in _counts(outcomes).items(): + total[name] = total.get(name, 0) + n + progress = { + **progress, + "saved": [ + *progress["saved"], + [source, first.isoformat(), last.isoformat()], + ], + "outcomes": dict(total), + } + yield Step(progress, _next_resume(window, pieces, i)) + + +async def _events( + session: AsyncSession, hotel: Hotel, params: Mapping[str, Any], _: Any +) -> AsyncIterator[Step]: + if params["mode"] == "recent": + report = await event_ingest.ingest_recent( + session, + hotel.city, + token=settings.predicthq_token, + updated_since=datetime.fromisoformat(params["updated_since"]), + ) + yield Step(_event_progress(report, None), None) + return + + window = Window.from_params(params) + months = list(window.months()) + async for report, first, last in event_ingest.ingest_window( + session, hotel.city, window, token=settings.predicthq_token + ): + done = months.index((first, last)) + yield Step( + _event_progress(report, (done + 1, len(months))), + _next_resume(window, [(None, a, b) for a, b in months], done), + ) + + +async def _performance( + session: AsyncSession, + hotel: Hotel, + params: Mapping[str, Any], + parsed: list[performance_ingest.ParsedFile] | None, +) -> AsyncIterator[Step]: + if parsed is None: + raise RuntimeError("the uploaded files didn't reach the worker") + + total: dict[str, int] = {} + for i, parsed_file in enumerate(parsed, start=1): + outcomes = await performance_ingest.save_file( + session, hotel, parsed_file, base_rate=Decimal(params["base_rate"]) + ) + for name, n in _counts(outcomes).items(): + total[name] = total.get(name, 0) + n + yield Step( + {"files_done": i, "files_total": len(parsed), "outcomes": dict(total)}, + None, + ) + + +async def _train( + session: AsyncSession, hotel: Hotel, params: Mapping[str, Any], _: Any +) -> AsyncIterator[Step]: + result = await pricing_model_service.train( + session, + hotel, + description=params["description"], + start=_date_or_none(params["start"]), + end=_date_or_none(params["end"]), + activate=params["activate"], + params=params["ebm_params"], + ) + model = result.model + yield Step( + { + "model_id": model.id, + "version": model.version, + "activated": result.activated, + "seconds": result.seconds, + "nights_used": result.nights_used, + "metrics": {k: model.metrics[k] for k in ("mae", "mape", "r2", "coverage")}, + }, + None, + ) + + +_HANDLERS: dict[JobKind, Handler] = { + JobKind.WEATHER: _weather, + JobKind.EVENTS: _events, + JobKind.PERFORMANCE: _performance, + JobKind.TRAIN: _train, +} + + +# --- helpers ---------------------------------------------------------------- + + +def _event_progress( + report: event_ingest.EventReport, months: tuple[int, int] | None +) -> dict[str, Any]: + progress: dict[str, Any] = { + "fetched": report.fetched, + "rejected": report.rejected, + "outcomes": _counts(report.outcomes), + "overflowed_months": [ + [a.isoformat(), b.isoformat()] for a, b in report.overflowed + ], + } + if months is not None: + progress["months_done"], progress["months_total"] = months + return progress + + +def _next_resume( + window: Window, pieces: Sequence[tuple[Any, date, date]], done: int +) -> date | None: + """Where to pick up once piece `done` is saved: the next piece's start + (or end, walking backwards), or None when it was the last.""" + if done + 1 >= len(pieces): + return None + _, first, last = pieces[done + 1] + return window.resume_point(first, last) + + +def _date_or_none(value: str | None) -> date | None: + return None if value is None else date_cls.fromisoformat(value) + + +def _forecast_end(today: date) -> date: + return today + timedelta(days=FORECAST_DAYS - 1) + + +def _today(city: City) -> date: + """Today where the city is: stay nights are local.""" + return datetime.now(ZoneInfo(city.timezone)).date() + + +def _counts(outcomes: Mapping[Outcome, int]) -> dict[str, int]: + return {outcome.value: n for outcome, n in outcomes.items()} + + +def _now() -> datetime: + return datetime.now(UTC) diff --git a/backend/foresight/services/ml/base.py b/backend/foresight/services/ml/base.py index 1be551b..24f8348 100644 --- a/backend/foresight/services/ml/base.py +++ b/backend/foresight/services/ml/base.py @@ -1,5 +1,5 @@ from abc import ABC, abstractmethod -from typing import Self +from typing import Any, Self import numpy as np import pandas as pd @@ -20,9 +20,9 @@ def __init__(self, seed: int = 9, **params): """ self.seed = seed self.params = params - self._model = None # set by fit + self._model: Any = None # set by fit - def _fitted(self): + def _fitted(self) -> Any: """Returns the underlying trained model. Returns: diff --git a/backend/foresight/services/ml/bayesian.py b/backend/foresight/services/ml/bayesian.py deleted file mode 100644 index af04f80..0000000 --- a/backend/foresight/services/ml/bayesian.py +++ /dev/null @@ -1,58 +0,0 @@ -from scipy.stats import norm -from sklearn.linear_model import BayesianRidge -from sklearn.pipeline import make_pipeline -from sklearn.preprocessing import StandardScaler - -from foresight.services.ml.base import Model - - -class BayesianModel(Model): - """Bayesian linear regression with standardized features.""" - - name = "bayesian_ridge" - - def fit(self, x, y): - """Trains the model on the given data. - - Args: - x (pd.DataFrame): Training features. - y (pd.Series): Training target. - - Returns: - BayesianModel: The trained model. - """ - self._model = make_pipeline(StandardScaler(), BayesianRidge(**self.params)) - self._model.fit(x, y) - return self - - def predict(self, x): - """Predicts the target for each row. - - Args: - x (pd.DataFrame): Features to predict on. - - Returns: - np.ndarray: One prediction per row of x. - - Raises: - RuntimeError: If fit has not been called yet. - """ - return self._fitted().predict(x) - - def predict_interval(self, x, alpha=0.05): - """Predicts a value and a (1 - alpha) interval for each row. - - Args: - x (pd.DataFrame): Features to predict on. - alpha (float): Significance level; 0.05 gives a 95% interval. - - Returns: - tuple[np.ndarray, np.ndarray, np.ndarray]: Predictions, lower bounds, - and upper bounds, one per row of x. - - Raises: - RuntimeError: If fit has not been called yet. - """ - mean, std = self._fitted().predict(x, return_std=True) - z = norm.ppf(1 - alpha / 2) - return mean, mean - z * std, mean + z * std diff --git a/backend/foresight/services/ml/ebm.py b/backend/foresight/services/ml/ebm.py new file mode 100644 index 0000000..cad8a97 --- /dev/null +++ b/backend/foresight/services/ml/ebm.py @@ -0,0 +1,218 @@ +import io + +import joblib +import numpy as np +import pandas as pd +from interpret.glassbox import ExplainableBoostingRegressor + +from foresight.services.ml.base import Model + + +class EBMModel(Model): + """Explainable Boosting Machine with split-conformal prediction intervals.""" + + name = "ebm" + + def __init__(self, alpha=0.2, calibration_frac=0.15, **params): + """Stores the interval and calibration settings. + + Args: + alpha (float): Default significance level; 0.2 gives an 80% interval. + calibration_frac (float): Share of the most recent rows held out to + calibrate the interval. + **params: Seed and settings passed through to the EBM. + """ + super().__init__(**params) + self.alpha = alpha + self.calibration_frac = calibration_frac + self._residuals: np.ndarray | None = None # set by fit + + def fit(self, x, y, sample_weight=None): + """Trains the model and calibrates its prediction interval. + + The last `calibration_frac` of rows are held out: the model trains on + the earlier rows, and its errors on the held-out rows set the interval + width. Rows must be sorted by time so the held-out rows are the latest. + + Args: + x (pd.DataFrame): Training features, sorted by time. + y (pd.Series): Training target (rate). + sample_weight (np.ndarray | None): How much each row counts when + training. None weights every row equally. + + Returns: + EBMModel: The trained model. + + Raises: + ValueError: If there are too few rows to hold out a calibration set, + or any sample weight is not positive. + """ + cut = int(len(x) * (1 - self.calibration_frac)) + if cut < 1 or cut >= len(x): + raise ValueError( + f"{self.name} needs more rows to hold out a calibration set " + f"(got {len(x)})" + ) + train_weight = None + if sample_weight is not None: + train_weight = np.asarray(sample_weight, dtype=float)[:cut] + if not (train_weight > 0).all(): + raise ValueError(f"{self.name} needs every sample weight > 0") + + self._model = ExplainableBoostingRegressor( + random_state=self.seed, **self.params + ).fit(x.iloc[:cut], y.iloc[:cut], sample_weight=train_weight) + + calibration_pred = self._model.predict(x.iloc[cut:]) + self._residuals = np.abs( + np.asarray(y.iloc[cut:], dtype=float) - calibration_pred + ) + return self + + def predict(self, x): + """Predicts the rate for each row. + + Args: + x (pd.DataFrame): Features to predict on. + + Returns: + np.ndarray: One prediction per row of x. + + Raises: + RuntimeError: If fit has not been called yet. + """ + return self._fitted().predict(x) + + def predict_interval(self, x, alpha=None): + """Predicts a rate and a (1 - alpha) interval for each row. + + Args: + x (pd.DataFrame): Features to predict on. + alpha (float | None): Significance level. None uses the model's + default. + + Returns: + tuple[np.ndarray, np.ndarray, np.ndarray]: Predictions, lower bounds, + and upper bounds, one per row of x. + + Raises: + RuntimeError: If fit has not been called yet. + """ + pred = self.predict(x) + margin = self.interval_margin(self.alpha if alpha is None else alpha) + return pred, pred - margin, pred + margin + + def interval_margin(self, alpha): + """Returns how far the interval extends either side of a prediction. + + Args: + alpha (float): Significance level; 0.2 gives an 80% interval. + + Returns: + float: The interval half-width, in rate units. + + Raises: + RuntimeError: If fit has not been called yet. + """ + self._fitted() + residuals = self._residuals + if residuals is None: + raise RuntimeError(f"{self.name} must be fit before predicting") + n = len(residuals) + level = min(1.0, np.ceil((n + 1) * (1 - alpha)) / n) + return float(np.quantile(residuals, level, method="higher")) + + def predict_uncertainty(self, x): + """Measures how much the model's internal sub-models disagree per row. + + High values flag nights unlike the training data. This is not a range + for the rate; use predict_interval for that. + + Args: + x (pd.DataFrame): Features to predict on. + + Returns: + np.ndarray: One standard deviation per row of x, in rate units. + + Raises: + RuntimeError: If fit has not been called yet. + """ + return self._fitted().predict_with_uncertainty(x)[:, 1] + + def explain(self, x): + """Splits each prediction into the contribution of every term. + + Each row's contributions plus `intercept` add up to its prediction. + + Args: + x (pd.DataFrame): Features to explain. + + Returns: + pd.DataFrame: One row per row of x, one column per term (features + and pairwise interactions such as "day_of_week & month"). + + Raises: + RuntimeError: If fit has not been called yet. + """ + model = self._fitted() + return pd.DataFrame( + model.eval_terms(x), columns=model.term_names_, index=x.index + ) + + @property + def intercept(self): + """The prediction before any term contributes. + + Returns: + float: The model's intercept. + + Raises: + RuntimeError: If fit has not been called yet. + """ + return float(self._fitted().intercept_) + + @property + def features(self): + """The input columns the model was trained on, in order. + + Returns: + list[str]: Feature names. + + Raises: + RuntimeError: If fit has not been called yet. + """ + return list(self._fitted().feature_names_in_) + + def to_bytes(self): + """Serializes the trained model for storage. + + Returns: + bytes: The model, including its calibration. + + Raises: + RuntimeError: If fit has not been called yet. + """ + self._fitted() + buffer = io.BytesIO() + joblib.dump(self, buffer) + return buffer.getvalue() + + @classmethod + def from_bytes(cls, data): + """Loads a model serialized by to_bytes. + + Only load bytes our own training wrote: unpickling can run code. + + Args: + data (bytes): Output of to_bytes. + + Returns: + EBMModel: The trained model. + + Raises: + TypeError: If the bytes don't hold an EBMModel. + """ + model = joblib.load(io.BytesIO(data)) + if not isinstance(model, cls): + raise TypeError(f"Expected {cls.__name__}, got {type(model).__name__}") + return model diff --git a/backend/foresight/services/ml/features.py b/backend/foresight/services/ml/features.py new file mode 100644 index 0000000..ff03ad2 --- /dev/null +++ b/backend/foresight/services/ml/features.py @@ -0,0 +1,159 @@ +import pandas as pd + +from foresight.database.enums import EventCategory + +# Model input columns, in order. Training and serving both use this list. +FEATURES = [ + "room_type", + "day_of_week", + "month", + "is_public_holiday", + "is_school_holiday", + "event_count", + "event_attendance", + "event_rank_max", + "temp_max_c", + "precip_mm", + "wind_kph", + "flight_count", + "avg_fare_eur", +] + +# Events that bring people to the city. Holidays become their own flags, and +# categories like disasters or airport delays aren't counted as demand. +ATTENDED_CATEGORIES = [ + EventCategory.ACADEMIC.value, + EventCategory.COMMUNITY.value, + EventCategory.CONCERTS.value, + EventCategory.CONFERENCES.value, + EventCategory.EXPOS.value, + EventCategory.FESTIVALS.value, + EventCategory.PERFORMING_ARTS.value, + EventCategory.SPORTS.value, +] + + +def build_features( + nights: pd.DataFrame, + events: pd.DataFrame, + weather: pd.DataFrame, + flights: pd.DataFrame, +) -> pd.DataFrame: + """Builds the model's input columns, one row per room type per night. + + Used for training (past nights) and serving (the night being priced), so + both see identical features. Missing weather or flight data stays empty; + the model learns a separate effect for unknown values. + + Args: + nights (pd.DataFrame): One row per room type per night, with columns + stay_date and room_type. + events (pd.DataFrame): Active events in the hotel's city, with columns + start_local_date, end_local_date, category, expected_attendance, + and local_rank. + weather (pd.DataFrame): At most one row per day, with columns + weather_date, temp_max_c, precip_mm, and wind_kph. + flights (pd.DataFrame): At most one row per day for the hotel's + airport, with columns arrival_date, flight_count, and avg_fare_eur. + + Returns: + pd.DataFrame: The FEATURES columns, in order, indexed like nights. + + Raises: + ValueError: If weather or flights has more than one row for a day. + """ + stay_date = pd.to_datetime(nights["stay_date"]) + dates = pd.DatetimeIndex(stay_date.unique()) + + daily = ( + _event_features(events, dates) + .join(_daily(weather, "weather_date", ["temp_max_c", "precip_mm", "wind_kph"])) + .join(_daily(flights, "arrival_date", ["flight_count", "avg_fare_eur"])) + ) + + features = daily.reindex(stay_date).set_index(nights.index) + features["room_type"] = nights["room_type"].astype("category") + features["day_of_week"] = stay_date.dt.dayofweek.to_numpy() + features["month"] = stay_date.dt.month.to_numpy() + return features.reindex(columns=FEATURES) + + +def _event_features(events: pd.DataFrame, dates: pd.DatetimeIndex) -> pd.DataFrame: + """Summarizes the events happening on each date. + + A multi-day event counts on every night from its start to its end date. + + Args: + events (pd.DataFrame): See build_features. + dates (pd.DatetimeIndex): The nights to summarize. + + Returns: + pd.DataFrame: Holiday flags and event totals, indexed by date. + """ + result = pd.DataFrame( + { + "is_public_holiday": 0, + "is_school_holiday": 0, + "event_count": 0, + "event_attendance": 0.0, + "event_rank_max": 0.0, + }, + index=dates, + ) + if events.empty: + return result + + # str() turns EventCategory members into their values, e.g. "concerts". + expanded = events.assign( + category=events["category"].astype(str), + date=[ + pd.date_range(start, end) + for start, end in zip( + events["start_local_date"], events["end_local_date"], strict=True + ) + ], + ).explode("date") + expanded = expanded.loc[expanded["date"].isin(dates.to_list())] + by_date = expanded.groupby("date") + + for column, category in [ + ("is_public_holiday", EventCategory.PUBLIC_HOLIDAYS.value), + ("is_school_holiday", EventCategory.SCHOOL_HOLIDAYS.value), + ]: + flagged = by_date["category"].agg(lambda c, cat=category: int((c == cat).any())) + result.loc[flagged.index, column] = flagged + + attended = expanded.loc[expanded["category"].isin(ATTENDED_CATEGORIES)] + if not attended.empty: + totals = attended.groupby("date").agg( + event_count=("category", "size"), + event_attendance=("expected_attendance", "sum"), + event_rank_max=("local_rank", "max"), + ) + totals = totals.fillna(0) + result.loc[totals.index, totals.columns] = totals + return result + + +def _daily(df: pd.DataFrame, date_column: str, columns: list[str]) -> pd.DataFrame: + """Indexes one-row-per-day data by date. + + Args: + df (pd.DataFrame): The daily data. + date_column (str): Which column holds the date. + columns (list[str]): Which columns to keep. + + Returns: + pd.DataFrame: The columns, indexed by date. + + Raises: + ValueError: If any date appears more than once. + """ + daily = ( + df.assign(**{date_column: pd.to_datetime(df[date_column])}) + .set_index(date_column) + .reindex(columns=columns) + ) + if daily.index.has_duplicates: + raise ValueError(f"expected at most one row per {date_column}") + return daily.astype(float) diff --git a/backend/foresight/services/ml/neural_net.py b/backend/foresight/services/ml/neural_net.py deleted file mode 100644 index c46bc0a..0000000 --- a/backend/foresight/services/ml/neural_net.py +++ /dev/null @@ -1,48 +0,0 @@ -from sklearn.neural_network import MLPRegressor -from sklearn.pipeline import make_pipeline -from sklearn.preprocessing import StandardScaler - -from foresight.services.ml.base import Model - -DEFAULTS = { - "hidden_layer_sizes": (32, 16), - "max_iter": 2000, - "early_stopping": True, -} - - -class NNModel(Model): - """Small feed-forward neural network with standardized features.""" - - name = "mlp" - - def fit(self, x, y): - """Trains the model on the given data. - - Args: - x (pd.DataFrame): Training features. - y (pd.Series): Training target. - - Returns: - NNModel: The trained model. - """ - params = {**DEFAULTS, **self.params} - self._model = make_pipeline( - StandardScaler(), MLPRegressor(random_state=self.seed, **params) - ) - self._model.fit(x, y) - return self - - def predict(self, x): - """Predicts the target for each row. - - Args: - x (pd.DataFrame): Features to predict on. - - Returns: - np.ndarray: One prediction per row of x. - - Raises: - RuntimeError: If fit has not been called yet. - """ - return self._fitted().predict(x) diff --git a/backend/foresight/services/ml/regression.py b/backend/foresight/services/ml/regression.py deleted file mode 100644 index b5abec6..0000000 --- a/backend/foresight/services/ml/regression.py +++ /dev/null @@ -1,91 +0,0 @@ -import numpy as np -from sklearn.ensemble import HistGradientBoostingRegressor - -from foresight.services.ml.base import Model - - -class GBMModel(Model): - "Gradient-boosted trees for per-booking dynamic pricing." - - name = "hist_gradient_boosting" - - def __init__(self, alpha=0.05, **params): - super().__init__(**params) - self.alpha = alpha - - def fit(self, x, y): - """Trains point and quantile models on the given data. - - Args: - x (pd.DataFrame): Training features (room_type should be a - pandas categorical dtype). - y (pd.Series): Training target (price). - - Returns: - GBMModel: The trained model. - """ - cat_cols = x.select_dtypes(include="category").columns.tolist() - base_kwargs = {k: v for k, v in self.params.items() if k != "random_state"} - random_state = self.params.get("random_state", 0) - - def make(loss, **extra): - return HistGradientBoostingRegressor( - loss=loss, - categorical_features=cat_cols or None, - random_state=random_state, - **base_kwargs, - **extra, - ) - - self._model = { - "median": make("squared_error").fit(x, y), - "lower": make("quantile", quantile=self.alpha / 2).fit(x, y), - "upper": make("quantile", quantile=1 - self.alpha / 2).fit(x, y), - } - return self - - def predict(self, x): - """Predicts the point price for each row. - - Args: - x (pd.DataFrame): Features to predict on. - - Returns: - np.ndarray: One prediction per row of x. - """ - return self._fitted()["median"].predict(x) - - def predict_interval(self, x, alpha=None): - """Predicts a value and interval for each row. - - Args: - x (pd.DataFrame): Features to predict on. - alpha (float | None): Must match the alpha the model was - fit with (its quantile heads are trained for one fixed - alpha); pass None to use it implicitly. - - Returns: - tuple[np.ndarray, np.ndarray, np.ndarray]: Predictions, lower - bounds, and upper bounds, one per row of x. - - Raises: - RuntimeError: If fit has not been called yet. - ValueError: If a different alpha than the one fit is requested. - """ - if alpha is not None and not np.isclose(alpha, self.alpha): - raise ValueError( - f"{self.name} was fit with alpha={self.alpha}; its quantile " - f"heads can't serve alpha={alpha}. Re-instantiate with " - f"GBMModel(alpha={alpha}) and refit." - ) - model = self._fitted() - pred = model["median"].predict(x) - lower = model["lower"].predict(x) - upper = model["upper"].predict(x) - # Lower/upper/median are three independently-fit models, so they can - # occasionally cross or leave the median outside the band. Repair - # that rather than let it silently violate lower <= pred <= upper. - lower, upper = np.minimum(lower, upper), np.maximum(lower, upper) - lower = np.minimum(lower, pred) - upper = np.maximum(upper, pred) - return pred, lower, upper diff --git a/backend/foresight/services/ml/timeseries.py b/backend/foresight/services/ml/timeseries.py deleted file mode 100644 index 8eb9004..0000000 --- a/backend/foresight/services/ml/timeseries.py +++ /dev/null @@ -1,74 +0,0 @@ -from statsmodels.tsa.statespace.sarimax import SARIMAX - -from foresight.services.ml.base import Model - - -class TimeSeriesModel(Model): - """SARIMAX forecaster for the daily average price series. - - Captures trend/weekly seasonality in price itself (order + seasonal_order) - while also using same-day demand drivers (occupancy, competitor price, - weekend/holiday/event flags, average lead time) as exogenous regressors, - since price isn't just autocorrelated noise -- it responds to demand. - """ - - name = "sarimax" - - def __init__(self, order=(1, 1, 1), seasonal_order=(1, 1, 1, 7), **params): - super().__init__(**params) - self.order = order - self.seasonal_order = seasonal_order - - def fit(self, x, y): - """Fits the SARIMAX model on a contiguous daily series. - - Args: - x (pd.DataFrame): Exogenous regressors, daily frequency, - same index as y. - y (pd.Series): Daily average price, daily frequency. - - Returns: - TimeSeriesModel: The trained model. - """ - self._model = SARIMAX( - y, - exog=x, - order=self.order, - seasonal_order=self.seasonal_order, - enforce_stationarity=False, - enforce_invertibility=False, - ).fit(disp=False, **self.params) - return self - - def predict(self, x): - """Forecasts the point price for each step in x. - - Args: - x (pd.DataFrame): Exogenous regressors for the forecast - horizon; must immediately follow the training period. - - Returns: - np.ndarray: One forecast per row of x. - """ - forecast = self._fitted().get_forecast(steps=len(x), exog=x) - return forecast.predicted_mean.to_numpy() - - def predict_interval(self, x, alpha=0.05): - """Forecasts a value and a (1 - alpha) interval for each step in x. - - Args: - x (pd.DataFrame): Exogenous regressors for the forecast horizon. - alpha (float): Significance level; 0.05 gives a 95% interval. - - Returns: - tuple[np.ndarray, np.ndarray, np.ndarray]: Forecasts, lower - bounds, and upper bounds, one per row of x. - - Raises: - RuntimeError: If fit has not been called yet. - """ - forecast = self._fitted().get_forecast(steps=len(x), exog=x) - pred = forecast.predicted_mean.to_numpy() - ci = forecast.conf_int(alpha=alpha) - lower, upper = ci.iloc[:, 0].to_numpy(), ci.iloc[:, 1].to_numpy() - return pred, lower, upper diff --git a/backend/foresight/services/ml/weighting.py b/backend/foresight/services/ml/weighting.py new file mode 100644 index 0000000..5400e53 --- /dev/null +++ b/backend/foresight/services/ml/weighting.py @@ -0,0 +1,59 @@ +import numpy as np +import pandas as pd + +# Smallest weight a night can get. EBM rejects zero weights, and nights with +# many vacancies should still count a little. +MIN_WEIGHT = 0.05 + + +def training_mask(rate: pd.Series) -> np.ndarray: + """Marks which nights can be used for training. + + Free or staff rooms (rate of 0 or less) and nights with no listed rate say + nothing about pricing, so they're left out entirely rather than weighted. + + Args: + rate (pd.Series): Price listed each night. + + Returns: + np.ndarray: True for each night to train on. + """ + rate = rate.to_numpy(float) + return ~np.isnan(rate) & (rate > 0) + + +def vacancy_factor(occupancy: pd.Series) -> np.ndarray: + """Scores each night by how full it was. + + Vacancies on the day mean guests wouldn't pay the price, so emptier nights + count less. + + Args: + occupancy (pd.Series): Share of rooms sold each night, 0-1. + + Returns: + np.ndarray: One factor per night, 0-1. + """ + return occupancy.to_numpy(float) + + +def sample_weights(occupancy: pd.Series, min_weight: float = MIN_WEIGHT) -> np.ndarray: + """Weights each night by how much its price should be trusted. + + New signals are added as their own factor function and multiplied in here. + + Args: + occupancy (pd.Series): Share of rooms sold each night, 0-1. + min_weight (float): Smallest weight any night gets; must be > 0. + + Returns: + np.ndarray: One weight per night, all >= min_weight. + + Raises: + ValueError: If min_weight is not positive, or any occupancy is missing. + """ + if min_weight <= 0: + raise ValueError("min_weight must be > 0") + if occupancy.isna().any(): + raise ValueError("occupancy has missing values; can't weight those nights") + return np.maximum(vacancy_factor(occupancy), min_weight) diff --git a/backend/foresight/services/parsers/__init__.py b/backend/foresight/services/parsers/__init__.py deleted file mode 100644 index b023ea9..0000000 --- a/backend/foresight/services/parsers/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# nothing needed here diff --git a/backend/foresight/services/recommendation.py b/backend/foresight/services/recommendation.py deleted file mode 100644 index 6964901..0000000 --- a/backend/foresight/services/recommendation.py +++ /dev/null @@ -1,55 +0,0 @@ -from datetime import date -from decimal import Decimal - -from sqlalchemy.ext.asyncio import AsyncSession - -from foresight.repository import hotel as hotel_repo -from foresight.schemas.recommendation import ( - ConfidenceIntervalResponse, - ExplanationResponse, - FactorResponse, - RecommendationResponse, -) - - -def _placeholder(hotel_id: int, stay_date: date) -> RecommendationResponse: - return RecommendationResponse( - hotel_id=hotel_id, - stay_date=stay_date, - recommended_rate=Decimal("150.00"), - currency="EUR", - explanation=ExplanationResponse( - summary="Placeholder recommendation; the pricing model isn't wired in yet.", - factors=[ - FactorResponse( - signal="events", - direction="neutral", - detail="Placeholder: nearby events are not yet considered.", - ), - FactorResponse( - signal="weather", - direction="neutral", - detail="Placeholder: the forecast is not yet considered.", - ), - FactorResponse( - signal="flights", - direction="neutral", - detail="Placeholder: flight arrivals are not yet considered.", - ), - ], - ), - confidence_interval=ConfidenceIntervalResponse( - lower=Decimal("135.00"), upper=Decimal("165.00"), level=0.95 - ), - model_version="stub-0", - is_placeholder=True, - ) - - -async def get_recommendation( - session: AsyncSession, hotel_id: int, stay_date: date -) -> RecommendationResponse | None: - """The recommendation for one night, or `None` if the hotel doesn't exist.""" - if await hotel_repo.get_hotel(session, hotel_id) is None: - return None - return _placeholder(hotel_id, stay_date) diff --git a/backend/foresight/services/signals.py b/backend/foresight/services/signals.py deleted file mode 100644 index 1e7f1b9..0000000 --- a/backend/foresight/services/signals.py +++ /dev/null @@ -1,59 +0,0 @@ -from collections.abc import Sequence -from datetime import date - -from sqlalchemy.ext.asyncio import AsyncSession - -from foresight.database.models.external import Event, FlightArrival, Weather -from foresight.ingest.normalize import EventStatus, slugify_city -from foresight.repository import signals as signals_repo - -# Withdrawn or called-off events shouldn't count as demand. -_INACTIVE_EVENT_STATUSES = (EventStatus.DELETED, EventStatus.CANCELLED) - - -async def list_weather( - session: AsyncSession, - *, - city: str, - start: date | None, - end: date | None, - limit: int, - offset: int, -) -> tuple[Sequence[Weather], int]: - return await signals_repo.list_weather( - session, city=city, start=start, end=end, limit=limit, offset=offset - ) - - -async def list_events( - session: AsyncSession, - *, - city: str, - start: date | None, - end: date | None, - limit: int, - offset: int, -) -> tuple[Sequence[Event], int]: - return await signals_repo.list_events( - session, - city_slug=slugify_city(city), - start=start, - end=end, - exclude_statuses=_INACTIVE_EVENT_STATUSES, - limit=limit, - offset=offset, - ) - - -async def list_flight_arrivals( - session: AsyncSession, - *, - airport: str, - start: date | None, - end: date | None, - limit: int, - offset: int, -) -> tuple[Sequence[FlightArrival], int]: - return await signals_repo.list_flight_arrivals( - session, airport=airport, start=start, end=end, limit=limit, offset=offset - ) diff --git a/backend/foresight/worker.py b/backend/foresight/worker.py new file mode 100644 index 0000000..eaffa99 --- /dev/null +++ b/backend/foresight/worker.py @@ -0,0 +1,51 @@ +"""Runs background jobs started through the API. + +The one place besides `database.session.get_session` that opens a session and +commits. A job outlives the request that queued it, and it commits after every +chunk so a run that ends early keeps its finished work and records where to +resume. Everything it does goes through `services/job.py`. + +Jobs run in the API process (FastAPI background tasks), so production runs a +single uvicorn worker. A restart kills running jobs; on startup +`fail_interrupted_jobs` marks those failed so they can be started again. +""" + +from __future__ import annotations + +import logging +from typing import Any + +from foresight.database.session import async_session_factory +from foresight.services import job as job_service + +log = logging.getLogger(__name__) + + +async def run_job(job_id: int, payload: Any = None) -> None: + """`payload` is in-memory input the request hands over (uploaded files).""" + async with async_session_factory() as session: + job = await job_service.get_job(session, job_id) + await job_service.mark_running(session, job_id) + await session.commit() + + resume_from = job_service.initial_resume_from(job) + try: + async for step in job_service.run(session, job, payload): + await job_service.record_progress(session, job_id, step.progress) + await session.commit() + resume_from = step.resume_from + except Exception as exc: + log.exception("job %s ended early", job_id) + await session.rollback() + await job_service.mark_ended_early(session, job_id, exc, resume_from) + else: + await job_service.mark_succeeded(session, job_id) + await session.commit() + + +async def fail_interrupted_jobs() -> None: + async with async_session_factory() as session: + count = await job_service.fail_interrupted_jobs(session) + await session.commit() + if count: + log.warning("marked %d interrupted job(s) failed", count) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 6b34fa2..60e4959 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -20,9 +20,10 @@ dependencies = [ "psycopg2-binary>=2.9", "scikit-learn>=1.9.1", "httpx>=0.28", - "openmeteo-requests>=1.7.5", - "requests-cache>=1.3.3", - "retry-requests>=2.0.0", + "python-multipart>=0.0.20", + "interpret-core>=0.7.8", + "numpy>=2.0", + "joblib>=1.4", ] [dependency-groups] @@ -35,16 +36,6 @@ dev = [ "pytest>=8.0", "pytest-asyncio>=0.25", ] -ml = [ - "matplotlib>=3.11.2", - "numpy>=2.5.3", - "pandas>=3.0.6", - "scikit-learn>=1.9.1", - "scipy>=1.18.1", - "statsmodels>=0.15.0", - "numpy>=2.5.3", - "pandas>=3.0.6", -] [tool.hatch.build.targets.wheel] packages = ["foresight"] @@ -74,4 +65,4 @@ known-first-party = ["foresight"] [tool.pytest.ini_options] asyncio_mode = "auto" testpaths = ["tests"] -pythonpath = [".", "scripts"] +pythonpath = ["."] diff --git a/backend/scripts/backfill/backfill_events.py b/backend/scripts/backfill/backfill_events.py deleted file mode 100644 index ef931e7..0000000 --- a/backend/scripts/backfill/backfill_events.py +++ /dev/null @@ -1,106 +0,0 @@ -import argparse -import asyncio -import logging -import sys -from collections.abc import Sequence -from datetime import date - -from foresight.config import settings -from foresight.database.session import async_session_factory, engine -from foresight.ingest.run import market_totals, migrate, resolve_market -from foresight.services.backfill.events import BackfillReport, backfill -from foresight.services.backfill.window import hotel_window - - -def _parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser( - prog="scripts/backfill/backfill_events.py", - description="Backfill PredictHQ events for a date range.", - ) - parser.add_argument( - "--start", type=date.fromisoformat, help="YYYY-MM-DD; defaults to the hotel's" - ) - parser.add_argument( - "--end", type=date.fromisoformat, help="YYYY-MM-DD; defaults to the hotel's" - ) - parser.add_argument( - "--hotel-id", type=int, help="use this hotel's city and lat/lon" - ) - parser.add_argument("--lat", type=float, help="market latitude (no --hotel-id)") - parser.add_argument("--lon", type=float, help="market longitude (no --hotel-id)") - parser.add_argument("--city", help="market city (no --hotel-id)") - parser.add_argument("--radius", default="10km", help="e.g. 10km, 5mi") - parser.add_argument( - "--dry-run", - action="store_true", - help="fetch and normalize, without writing to the database", - ) - return parser - - -async def _run(args: argparse.Namespace) -> BackfillReport: - try: - async with async_session_factory() as session: - market = await resolve_market(session, args) - try: - start, end = await hotel_window( - session, args.hotel_id, args.start, args.end - ) - except ValueError as exc: - raise SystemExit(f"error: {exc}") from exc - report = None - async for progress in backfill( - None if args.dry_run else session, - market, - start, - end, - token=settings.predicthq_token, - radius=args.radius, - ): - report = progress - if not args.dry_run: - await session.commit() # keep each finished month - if report is None: - raise SystemExit("error: empty date range") - if not args.dry_run: - by_status, by_source = await market_totals(session, market.city) - print( - f"{market.city}: events by status {by_status}, " - f"observations by source {by_source}" - ) - finally: - await engine.dispose() - return report - - -def main(argv: Sequence[str] | None = None) -> int: - parser = _parser() - args = parser.parse_args(argv) - if args.hotel_id is None and None in (args.lat, args.lon, args.city): - parser.error("give --hotel-id, or all of --lat, --lon and --city") - if not settings.predicthq_token: - parser.error("PREDICTHQ_TOKEN is not set; add it to .env") - - logging.basicConfig(level=logging.WARNING, format="%(levelname)s %(message)s") - if not args.dry_run: - migrate() - engine.echo = False # DEBUG=true would bury the report in SQL - report = asyncio.run(_run(args)) - - print(report.summary()) - for r in report.rejected: - print(f" rejected {r.source_ref:<22} {r.reason}") - if report.overflow_chunks: - print(f" overflow (plan truncated): {report.overflow_chunks}") - if report.stopped: - print(f"stopped: {report.stopped}", file=sys.stderr) - print( - f"saved everything before it; resume with --start {report.resume_from}", - file=sys.stderr, - ) - return 1 - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/backend/scripts/backfill/backfill_flights.py b/backend/scripts/backfill/backfill_flights.py deleted file mode 100644 index b4eed65..0000000 --- a/backend/scripts/backfill/backfill_flights.py +++ /dev/null @@ -1,71 +0,0 @@ -import argparse -import asyncio -import logging -import sys -from datetime import date - -from foresight.config import settings -from foresight.database.session import async_session_factory, engine -from foresight.ingest.run import migrate -from foresight.services.backfill.flights import ( - FlightBackfillStoppedError, - backfill_flights, -) -from foresight.services.backfill.window import hotel_window - -log = logging.getLogger("foresight.backfill") - - -async def _run(args: argparse.Namespace) -> date | None: - """Backfill day by day, committing each. Returns the day a limit stopped on.""" - try: - async with async_session_factory() as session: - try: - start, end = await hotel_window( - session, args.hotel_id, args.start, args.end - ) - except ValueError as exc: - raise SystemExit(f"error: {exc}") from exc - try: - async for day, count in backfill_flights( - session, start, end, args.airport - ): - await session.commit() # keep each finished day - log.info("%s: %d arrivals saved", day, count) - except FlightBackfillStoppedError as exc: - log.error("%s", exc) - return exc.day - finally: - await engine.dispose() - return None - - -def main() -> int: - parser = argparse.ArgumentParser( - description="Save the number of flights arriving each day, from AeroDataBox." - ) - parser.add_argument( - "--start", type=date.fromisoformat, help="YYYY-MM-DD; defaults to the hotel's" - ) - parser.add_argument( - "--end", type=date.fromisoformat, help="YYYY-MM-DD; defaults to the hotel's" - ) - parser.add_argument("--hotel-id", type=int, help="default the dates to this hotel") - parser.add_argument("--airport", default="DUB", help="IATA code (default: DUB)") - args = parser.parse_args() - args.airport = args.airport.upper() - if not settings.xrapidapi_key: - parser.error("XRAPIDAPI_KEY is not set; add it to .env") - - logging.basicConfig(level=logging.INFO, format="%(message)s") - migrate() - engine.echo = False - stopped_on = asyncio.run(_run(args)) - if stopped_on: - print(f"resume with --start {stopped_on}", file=sys.stderr) - return 1 - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/backend/scripts/backfill/backfill_weather.py b/backend/scripts/backfill/backfill_weather.py deleted file mode 100644 index 45a4275..0000000 --- a/backend/scripts/backfill/backfill_weather.py +++ /dev/null @@ -1,64 +0,0 @@ -import argparse -import asyncio -from datetime import date - -from foresight.database.session import async_session_factory, engine -from foresight.ingest.run import resolve_market -from foresight.services.backfill.weather import backfill_weather -from foresight.services.backfill.window import hotel_window - - -def _parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser( - description="Backfill recorded weather for a city over a date range." - ) - parser.add_argument( - "--start", type=date.fromisoformat, help="YYYY-MM-DD; defaults to the hotel's" - ) - parser.add_argument( - "--end", type=date.fromisoformat, help="YYYY-MM-DD; defaults to the hotel's" - ) - parser.add_argument( - "--hotel-id", type=int, help="use this hotel's city and lat/lon" - ) - parser.add_argument("--city", help="market city (no --hotel-id)") - parser.add_argument("--lat", type=float, help="market latitude (no --hotel-id)") - parser.add_argument("--lon", type=float, help="market longitude (no --hotel-id)") - return parser - - -async def main() -> int: - parser = _parser() - args = parser.parse_args() - if args.hotel_id is None and None in (args.city, args.lat, args.lon): - parser.error("give --hotel-id, or all of --city, --lat and --lon") - - engine.echo = False - try: - async with async_session_factory() as session: - market = await resolve_market(session, args) - try: - start, end = await hotel_window( - session, args.hotel_id, args.start, args.end - ) - except ValueError as exc: - parser.error(str(exc)) - counts = await backfill_weather( - session, - city=market.city, - latitude=market.latitude, - longitude=market.longitude, - start=start, - end=end, - ) - await session.commit() - finally: - await engine.dispose() - - summary = ", ".join(f"{n} {outcome.value}" for outcome, n in counts.items()) - print(f"weather | {market.city} | {start} → {end} | {summary}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(asyncio.run(main())) diff --git a/backend/scripts/backfill/weather_coverage.py b/backend/scripts/backfill/weather_coverage.py deleted file mode 100644 index e1d04d6..0000000 --- a/backend/scripts/backfill/weather_coverage.py +++ /dev/null @@ -1,54 +0,0 @@ -import argparse -import asyncio -from datetime import date - -from foresight.database.session import async_session_factory, engine -from foresight.services.backfill.weather_coverage import weather_coverage -from foresight.services.backfill.window import hotel_window - - -def _parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser( - description="Report recorded-weather coverage for a city over a date range." - ) - parser.add_argument("--city", required=True, help="market city, e.g. Dublin") - parser.add_argument("--hotel-id", type=int, help="default the dates to this hotel") - parser.add_argument( - "--start", type=date.fromisoformat, help="YYYY-MM-DD; defaults to the hotel's" - ) - parser.add_argument( - "--end", type=date.fromisoformat, help="YYYY-MM-DD; defaults to the hotel's" - ) - return parser - - -async def main() -> int: - parser = _parser() - args = parser.parse_args() - - engine.echo = False - try: - async with async_session_factory() as session: - try: - start, end = await hotel_window( - session, args.hotel_id, args.start, args.end - ) - except ValueError as exc: - parser.error(str(exc)) - report = await weather_coverage( - session, city=args.city, start=start, end=end - ) - finally: - await engine.dispose() - - print(f"weather | {report.city} | {report.start} → {report.end}") - print(f" rows: {report.rows} of {report.days} days") - print(f" earliest usable: {report.earliest or 'none'}") - for label, spans in (("missing:", report.missing), ("no values:", report.empty)): - gaps = ", ".join(str(a) if a == b else f"{a} → {b}" for a, b in spans) - print(f" {label:<16} {gaps or 'none'}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(asyncio.run(main())) diff --git a/backend/scripts/compare_models.py b/backend/scripts/compare_models.py deleted file mode 100644 index ace9671..0000000 --- a/backend/scripts/compare_models.py +++ /dev/null @@ -1,82 +0,0 @@ -"""Compares model performance on predicting room rate. - -Run from backend/: uv run python scripts/compare_models.py -""" - -import numpy as np -import pandas as pd -from generate_mock_data import write_csv - -from foresight.services.ml.bayesian import BayesianModel -from foresight.services.ml.neural_net import NNModel -from foresight.services.ml.regression import GBMModel -from foresight.services.ml.timeseries import TimeSeriesModel - -# Known only after the night, so never used to predict price. -POST_STAY_COLUMNS = ["rooms_sold", "occupancy", "room_revenue"] -MODELS = [BayesianModel, NNModel, GBMModel, TimeSeriesModel] -METRICS = ["mae", "rmse", "mape", "r2", "coverage"] -TEST_FRAC = 0.2 -SEEDS = range(5) - - -def load_data() -> tuple[pd.DataFrame, pd.Series]: - """Generates the mock data and splits it into features and target. - - Returns: - tuple[pd.DataFrame, pd.Series]: Features and rate, sorted by stay_date. - """ - df = pd.read_csv(write_csv(), parse_dates=["stay_date"]) - df = df.sort_values("stay_date", kind="stable").reset_index(drop=True) - x = df.drop(columns=["rate", "stay_date", *POST_STAY_COLUMNS]) - x = pd.get_dummies(x, columns=["room_type", "condition"], dtype=float) - return x, df["rate"] - - -def seed_runs(x_train, y_train, x_test, y_test) -> pd.DataFrame: - """Scores each model on the holdout once per seed. - - Args: - x_train (pd.DataFrame): Training features. - y_train (pd.Series): Training target. - x_test (pd.DataFrame): Holdout features. - y_test (pd.Series): Holdout target. - - Returns: - pd.DataFrame: One row per model per seed, with evaluate metrics. - """ - rows = [] - for model_cls in MODELS: - for seed in SEEDS: - model = model_cls(seed=seed).fit(x_train, y_train) - rows.append({"model": model.name, **model.evaluate(x_test, y_test)}) - return pd.DataFrame(rows) - - -def summarize(results: pd.DataFrame) -> pd.DataFrame: - """Averages metrics per model. - - Args: - results (pd.DataFrame): Output of seed_runs. - - Returns: - pd.DataFrame: Mean and std of each metric, one row per model. - """ - return results.groupby("model", sort=False)[METRICS].agg(["mean", "std"]).round(3) - - -def main(): - """Prints holdout results for each model.""" - x, y = load_data() - cut = int(len(x) * (1 - TEST_FRAC)) - x_train, x_test, y_train, y_test = x[:cut], x[cut:], y[:cut], y[cut:] - - naive_mae = np.mean(np.abs(y_test - y_train.mean())) - print(f"Baseline (always predict the average rate): MAE {naive_mae:.2f}\n") - - print(f"Holdout (last {TEST_FRAC:.0%} of nights, {len(SEEDS)} seeds)") - print(summarize(seed_runs(x_train, y_train, x_test, y_test)).to_string()) - - -if __name__ == "__main__": - main() diff --git a/backend/scripts/fetch_climate_projections.py b/backend/scripts/fetch_climate_projections.py deleted file mode 100644 index bd67131..0000000 --- a/backend/scripts/fetch_climate_projections.py +++ /dev/null @@ -1,88 +0,0 @@ -import argparse -from pathlib import Path - -import pandas as pd - -from foresight.ingest.weather import _get_client - -CLIMATE_URL = "https://climate-api.open-meteo.com/v1/climate" -OUTPUT_DIR = Path(__file__).resolve().parents[1] / "data" / "weather" - -_CLIMATE_MODELS = ( - "CMCC_CM2_VHR4", - "FGOALS_f3_H", - "HiRAM_SIT_HR", - "MRI_AGCM3_2_S", - "EC_Earth3P_HR", - "MPI_ESM1_2_XR", - "NICAM16_8S", -) - - -def fetch_climate_projections( - city: str, - latitude: float, - longitude: float, - *, - start: str = "2026-01-01", - end: str = "2030-12-31", - models: tuple[str, ...] = _CLIMATE_MODELS, -) -> pd.DataFrame: - """Multi-model climate projections, written to CSV. - - Not persisted: these are per-model projections of a future decade, and - `weather` holds one row per city per day. Kept for exploration until we - decide whether the models belong in the schema at all. - """ - responses = _get_client().weather_api( - CLIMATE_URL, - params={ - "latitude": latitude, - "longitude": longitude, - "start_date": start, - "end_date": end, - "models": list(models), - "daily": ["temperature_2m_mean", "precipitation_sum"], - }, - ) - - frames = [] - for response in responses: - daily = response.Daily() - frames.append( - pd.DataFrame( - { - "date": pd.date_range( - start=pd.to_datetime(daily.Time(), unit="s", utc=True), - end=pd.to_datetime(daily.TimeEnd(), unit="s", utc=True), - freq=pd.Timedelta(seconds=daily.Interval()), - inclusive="left", - ).date, - "city": city, - "model": response.Model(), - "temp_mean_c": daily.Variables(0).ValuesAsNumpy(), - "precip_mm": daily.Variables(1).ValuesAsNumpy(), - } - ) - ) - - combined = pd.concat(frames, ignore_index=True) - OUTPUT_DIR.mkdir(parents=True, exist_ok=True) - combined.to_csv(OUTPUT_DIR / f"climate_{city}.csv", index=False) - return combined - - -def main(): - parser = argparse.ArgumentParser( - description="Write multi-model climate projections for a city to CSV." - ) - parser.add_argument("--city", required=True) - parser.add_argument("--lat", type=float, required=True) - parser.add_argument("--lon", type=float, required=True) - args = parser.parse_args() - frame = fetch_climate_projections(args.city, args.lat, args.lon) - print(f"{len(frame)} rows -> {OUTPUT_DIR / f'climate_{args.city}.csv'}") - - -if __name__ == "__main__": - main() diff --git a/backend/scripts/fetch_opensky.py b/backend/scripts/fetch_opensky.py deleted file mode 100644 index d03c60f..0000000 --- a/backend/scripts/fetch_opensky.py +++ /dev/null @@ -1,117 +0,0 @@ -r"""Download historical airport arrivals from OpenSky in 2-day chunks. - -Each chunk is saved as its own CSV in ``data/flights/opensky/`` and chunks that already -exist are skipped, so re-running the same command resumes where it stopped. The script -stops when OpenSky's daily credit limit is reached. - -OpenSky allows at most 2 days per arrivals request. A request covering up to 2 UTC days -costs 30 credits and a standard account gets 4,000 credits a day, so a day's credits -cover about 133 chunks (about 266 days of flights). Dates are UTC days so that every -request stays within 2 UTC days. Only flights up to yesterday (UTC) are available. - -Example: - Run from ``backend/``:: - - uv run python scripts/fetch_opensky.py --start 2022-01-01 --end 2025-12-31 -""" - -import argparse -from datetime import UTC, date, datetime, time, timedelta -from pathlib import Path - -import httpx -import pandas as pd - -from foresight.config import settings - -OUTPUT_DIR = Path(__file__).resolve().parents[1] / "data" / "flights" / "opensky" -ARRIVAL_URL = "https://opensky-network.org/api/flights/arrival" -TOKEN_URL = "https://auth.opensky-network.org/auth/realms/opensky-network/protocol/openid-connect/token" - - -def get_token() -> str: - """Get an OpenSky access token, which expires after 30 minutes. - - Returns: - str: The bearer token. - - Raises: - httpx.HTTPStatusError: If authentication fails. - """ - response = httpx.post( - TOKEN_URL, - data={ - "grant_type": "client_credentials", - "client_id": settings.opensky_client_id, - "client_secret": settings.opensky_client_secret, - }, - timeout=30, - ) - response.raise_for_status() - return response.json()["access_token"] - - -def main() -> None: - """Download arrivals for a date range, one CSV per 2-day chunk.""" - parser = argparse.ArgumentParser( - description="Download OpenSky airport arrivals in 2-day chunks." - ) - parser.add_argument( - "--start", type=date.fromisoformat, required=True, help="YYYY-MM-DD (UTC)" - ) - parser.add_argument( - "--end", - type=date.fromisoformat, - help="YYYY-MM-DD (UTC), defaults to yesterday", - ) - parser.add_argument( - "--airport", default="EIDW", help="ICAO code, defaults to Dublin (EIDW)" - ) - args = parser.parse_args() - - yesterday = datetime.now(UTC).date() - timedelta(days=1) - end = min(args.end or yesterday, yesterday) - OUTPUT_DIR.mkdir(parents=True, exist_ok=True) - token = get_token() - - for offset in range(0, (end - args.start).days + 1, 2): - first = args.start + timedelta(days=offset) - last = min(first + timedelta(days=1), end) - path = OUTPUT_DIR / f"{args.airport}_{first}_{last}.csv" - if path.exists(): - continue - - # Midnight UTC to the last second of ``last``: never more than 2 UTC days. - begin = datetime.combine(first, time.min, UTC) - until = datetime.combine(last + timedelta(days=1), time.min, UTC) - params = { - "airport": args.airport, - "begin": int(begin.timestamp()), - "end": int(until.timestamp()) - 1, - } - for _ in range(2): - response = httpx.get( - ARRIVAL_URL, - params=params, - headers={"Authorization": f"Bearer {token}"}, - timeout=60, - ) - if response.status_code != 401: - break - token = get_token() # the token expired, so get a new one and retry - - if response.status_code == 429: - wait = response.headers.get("X-Rate-Limit-Retry-After-Seconds") - print(f"Credit limit reached, retry in {wait}s. Re-run to resume.") - return - if response.status_code == 404: # no flights in this chunk - rows = [] - else: - response.raise_for_status() - rows = response.json() - pd.DataFrame(rows).to_csv(path, index=False) - print(f"{first} to {last}: {len(rows)} flights") - - -if __name__ == "__main__": - main() diff --git a/backend/scripts/generate_mock_data.py b/backend/scripts/generate_mock_data.py deleted file mode 100644 index 40adb17..0000000 --- a/backend/scripts/generate_mock_data.py +++ /dev/null @@ -1,176 +0,0 @@ -"""Generates mock hotel data with a known pricing rule. - -Run from backend/: uv run python scripts/generate_mock_data.py -""" - -from pathlib import Path - -import numpy as np -import pandas as pd - -CSV_PATH = Path(__file__).resolve().parents[1] / "data" / "mock_hotel_data.csv" - -# RoomType.name: (base_rate, room_count) -ROOM_TYPES = { - "standard": (120.0, 30), - "deluxe": (180.0, 15), - "suite": (300.0, 5), -} - -# CompetitorRate.hotel_name: typical nightly rate -COMPETITORS = { - "harbour_view": 140.0, - "the_grand": 230.0, - "city_lodge": 160.0, - "riverside": 190.0, -} -COMPETITOR_REF = np.mean(list(COMPETITORS.values())) - - -def generate( - seed: int = 9, - start: str = "2024-01-01", - days: int = 730, - expected_attendance: np.ndarray | None = None, -) -> pd.DataFrame: - """Generates one row per room type per night. - - Args: - seed (int): Random seed for reproducible data. - start (str): First stay date, as YYYY-MM-DD. - days (int): Number of nights to generate. - expected_attendance (np.ndarray | None): Per-night event attendance to - use instead of random events; the rest of the data responds to it. - Random draws are unchanged, so two calls differ only by its effect. - - Returns: - pd.DataFrame: Mock nightly data, sorted by stay_date then room_type. - """ - rng = np.random.default_rng(seed) - dates = pd.date_range(start, periods=days, freq="D") - doy = dates.dayofyear.to_numpy() - is_weekend = dates.dayofweek.isin([4, 5]).astype(int) # Fri, Sat nights - season = 0.15 * np.sin(2 * np.pi * (doy - 100) / 365) - - # Weather: warm summers, rain on ~35% of days. - temp_max_c = 12 + 7 * np.sin(2 * np.pi * (doy - 110) / 365) + rng.normal(0, 2, days) - temp_min_c = temp_max_c - rng.normal(6, 1.5, days) - rainy = rng.random(days) < 0.35 - precip_mm = np.where(rainy, rng.exponential(6, days), 0.0) - wind_kph = rng.gamma(2, 8, days) + 8 * rainy - condition = np.where( - rainy, "rain", np.where(rng.random(days) < 0.5, "cloudy", "sunny") - ) - - # Events: total expected attendance of the day's events, ~10% of days. - has_event = rng.random(days) < 0.10 - random_attendance = np.where(has_event, rng.lognormal(9.5, 0.7, days), 0) - if expected_attendance is None: - expected_attendance = random_attendance - - demand = np.clip( - 0.55 - + season - + 0.15 * is_weekend - + 0.25 * np.minimum(expected_attendance / 40_000, 1) - - 0.05 * rainy - - 0.03 * (wind_kph > 30) - + rng.normal(0, 0.03, days), - 0.05, - 1.0, - ) - - # Flights into the city. - flight_count = ( - 400 + 300 * season + 60 * is_weekend + expected_attendance / 200 - ) + rng.normal(0, 25, days) - avg_fare_eur = 110 + 150 * (demand - 0.55) + rng.normal(0, 10, days) - - # Competitors: each lists a rate or sells out; summarized per night. - listed = np.column_stack( - [ - typical * (1 + 0.8 * (demand - 0.55)) * rng.normal(1, 0.05, days) - for typical in COMPETITORS.values() - ] - ) - sold_out = rng.random(listed.shape) < np.clip(2 * (demand - 0.8), 0, 0.5)[:, None] - all_sold_out = sold_out.all(axis=1) - competitor_rate = np.where( - all_sold_out, # no rate to observe; fall back to what they'd have listed - listed.mean(axis=1), - np.nanmean(np.where(sold_out, np.nan, listed), axis=1), - ) - competitors_sold_out = sold_out.sum(axis=1) - - frames = [] - for room_type, (base_rate, room_count) in ROOM_TYPES.items(): - # Pricing rule the models should learn. - fair_rate = base_rate * (0.75 + 0.5 * demand) - rate = ( - fair_rate - + 0.3 * base_rate * (competitor_rate / COMPETITOR_REF - 1) - + 0.05 * base_rate * is_weekend - + rng.normal(0, 0.03 * base_rate, days) - ) - occupancy_prob = np.clip( - demand + rng.normal(0, 0.05, days) - 1.5 * (rate / fair_rate - 1), 0, 1 - ) - rooms_sold = rng.binomial(room_count, occupancy_prob) - - frames.append( - pd.DataFrame( - { - # DailyPerformance / RoomType - "stay_date": dates.date, - "room_type": room_type, - "base_rate": base_rate, - "rooms_available": room_count, - "rate": rate.round(2), - "rooms_sold": rooms_sold, - "room_revenue": (rate.round(2) * rooms_sold).round(2), - "occupancy": (rooms_sold / room_count).round(4), - # From stay_date - "day_of_week": dates.dayofweek, - "month": dates.month, - "is_weekend": is_weekend, - # Weather - "temp_max_c": temp_max_c.round(1), - "temp_min_c": temp_min_c.round(1), - "precip_mm": precip_mm.round(1), - "wind_kph": wind_kph.round(1), - "condition": condition, - # Event - "expected_attendance": expected_attendance.round().astype(int), - # CompetitorRate - "competitor_rate": competitor_rate.round(2), - "competitors_sold_out": competitors_sold_out, - # FlightArrival - "flight_count": flight_count.round().astype(int), - "avg_fare_eur": avg_fare_eur.round(2), - } - ) - ) - - return ( - pd.concat(frames) - .sort_values(["stay_date", "room_type"], kind="stable") - .reset_index(drop=True) - ) - - -def write_csv(path: Path = CSV_PATH) -> Path: - """Generates the mock data and writes it to a CSV. - - Args: - path (Path): Where to write the CSV. - - Returns: - Path: The path written to. - """ - path.parent.mkdir(parents=True, exist_ok=True) - generate().to_csv(path, index=False) - return path - - -if __name__ == "__main__": - print(f"Wrote {write_csv()}") diff --git a/backend/scripts/load_hotel_data.py b/backend/scripts/load_hotel_data.py deleted file mode 100644 index 4525374..0000000 --- a/backend/scripts/load_hotel_data.py +++ /dev/null @@ -1,59 +0,0 @@ -import argparse -import asyncio -from decimal import Decimal - -from foresight.database.session import async_session_factory, engine -from foresight.services.parsers.hotel_data_parser import HotelDataParser, persist - - -async def _load(parser): - try: - async with async_session_factory() as session: - counts = await persist(parser, session) - await session.commit() - finally: - await engine.dispose() - return counts - - -def main(): - parser = argparse.ArgumentParser( - description="Parse a client hotel export and load it into Postgres." - ) - parser.add_argument("--input", required=True, help="Path to the client CSV file.") - parser.add_argument( - "--hotel-name", required=True, help="Hotel this file belongs to." - ) - parser.add_argument("--city", required=True, help="City the hotel is in.") - parser.add_argument( - "--base-rate", - required=True, - type=Decimal, - help="Standard room rate the client prices against.", - ) - parser.add_argument( - "--lat", type=float, help="Hotel latitude, needed by the events ingester." - ) - parser.add_argument( - "--lon", type=float, help="Hotel longitude, needed by the events ingester." - ) - args = parser.parse_args() - - hotel_parser = HotelDataParser( - input_path=args.input, - hotel_name=args.hotel_name, - city=args.city, - base_rate=args.base_rate, - latitude=args.lat, - longitude=args.lon, - ) - hotel_parser.run() - counts = asyncio.run(_load(hotel_parser)) - print( - f"{args.hotel_name}: {sum(counts.values())} nights " - f"({', '.join(f'{n} {outcome.value}' for outcome, n in counts.items())})" - ) - - -if __name__ == "__main__": - main() diff --git a/backend/scripts/run_asknews.py b/backend/scripts/run_asknews.py deleted file mode 100644 index 8883d9d..0000000 --- a/backend/scripts/run_asknews.py +++ /dev/null @@ -1,195 +0,0 @@ -"""Run the AskNews event ingest and print a cleaning report per run. - -Two modes: - - uv run python scripts/run_asknews.py # replay recorded batches - uv run python scripts/run_asknews.py --live # call the real API - -Replay is the default so the pipeline can be demonstrated and regression-checked -without credentials or network. The extraction path is identical in both modes; -only where the articles come from differs. - -Events are written through the shared upsert, so re-polling an overlapping -window visibly produces no duplicate rows. -""" - -from __future__ import annotations - -import argparse -import asyncio -import json -from collections import Counter -from datetime import date, datetime -from pathlib import Path -from typing import TYPE_CHECKING, Any - -from sqlalchemy import func, select - -from foresight.config import settings -from foresight.database.models.external import Event, EventObservation -from foresight.database.session import async_session_factory, engine -from foresight.ingest.asknews import ( - AskNewsClient, - CityTarget, - IngestReport, - collect_events, - extract_batch, -) -from foresight.repository import Outcome -from foresight.repository.events import upsert_event - -if TYPE_CHECKING: - from collections.abc import Sequence - - from foresight.ingest.normalize import NormalizedEvent - -FIXTURE_DIR = Path(__file__).resolve().parents[1] / "tests" / "fixtures" / "asknews" - -# The cities we sell rooms in. Everything else AskNews returns is noise. -CITIES = ( - CityTarget("Dublin", "Europe/Dublin", "IE", aliases=("Baile Atha Cliath",)), - CityTarget("Lisbon", "Europe/Lisbon", "PT", aliases=("Lisboa",)), - CityTarget("Edinburgh", "Europe/London", "GB"), -) - - -async def persist(events: Sequence[NormalizedEvent]) -> Counter[Outcome]: - """Write a run through the shared upsert, one transaction per run.""" - counts: Counter[Outcome] = Counter() - async with async_session_factory() as session: - for event in events: - counts[await upsert_event(session, event)] += 1 - await session.commit() - return counts - - -async def stored_events() -> tuple[list[Event], int]: - async with async_session_factory() as session: - rows = await session.scalars( - select(Event).order_by(Event.start_local_date, Event.title) - ) - observations = await session.scalar( - select(func.count()).select_from(EventObservation) - ) - return list(rows), observations or 0 - - -def load_fixture(path: Path) -> list[dict[str, Any]]: - return json.loads(path.read_text()) - - -async def run_live( - today: date, hours_back: int, per_query: int -) -> tuple[list[NormalizedEvent], IngestReport]: - """Exactly the call a scheduled worker makes. The CLI adds nothing to it.""" - import httpx - - if not (settings.asknews_client_id and settings.asknews_client_secret): - raise SystemExit( - "ASKNEWS_CLIENT_ID and ASKNEWS_CLIENT_SECRET must be set for --live" - ) - async with httpx.AsyncClient(timeout=30.0) as http: - client = AskNewsClient( - settings.asknews_client_id, settings.asknews_client_secret, http=http - ) - return await collect_events( - client, - CITIES, - today=today, - articles_per_query=per_query, - hours_back=hours_back, - ) - - -def describe(event: NormalizedEvent | Event) -> str: - """Same fields on the extracted object and the stored row.""" - return ( - f" {event.start_local_date} .. {event.end_local_date} " - f"{event.city_slug:<10} {event.date_precision:<8} " - f"conf {event.confidence:.2f} {event.title}" - ) - - -def print_run( - label: str, - source: str, - report: IngestReport, - events: Sequence[NormalizedEvent], - counts: Counter[Outcome] | None, -) -> None: - print(f"\n{'=' * 68}\n{label} -- {source}\n{'=' * 68}") - print(report.render()) - if counts is None: - print("extracted (nothing written)") - for event in events: - print(describe(event)) - return - print("written to the events table") - for outcome in Outcome: - print(f" {outcome.value:<22} {counts[outcome]}") - - -async def main() -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument( - "--live", action="store_true", help="call AskNews instead of replaying" - ) - parser.add_argument("--runs", type=int, default=3) - parser.add_argument( - "--dry-run", - action="store_true", - help="print what was extracted instead of writing to the database", - ) - parser.add_argument("--hours-back", type=int, default=24) - parser.add_argument("--articles-per-query", type=int, default=50) - parser.add_argument( - "--today", - type=date.fromisoformat, - default=datetime.now().date(), - help="reference date for 'is this event still ahead of us'", - ) - args = parser.parse_args() - - totals = IngestReport() - - for index in range(1, args.runs + 1): - if args.live: - events, report = await run_live( - args.today, args.hours_back, args.articles_per_query - ) - source = f"live, last {args.hours_back}h" - else: - path = FIXTURE_DIR / f"run_{index}.json" - if not path.exists(): - print(f"no fixture for run {index} at {path}") - break - events, report = extract_batch(load_fixture(path), CITIES, today=args.today) - report.queries_run = 1 - source = f"replay {path.name}" - - counts = None if args.dry_run else await persist(events) - print_run(f"RUN {index}", source, report, events, counts) - - totals.articles_seen += report.articles_seen - totals.events_kept += report.events_kept - totals.rejected.update(report.rejected) - - print(f"\n{'=' * 68}\nACROSS ALL RUNS\n{'=' * 68}") - print(f"articles processed {totals.articles_seen}") - print(f"articles rejected {totals.articles_rejected}") - for reason, count in totals.rejected.most_common(): - print(f" {reason:<22} {count}") - if args.dry_run: - return 0 - - rows, observations = await stored_events() - print(f"rows in events {len(rows)}") - print(f"rows in observations {observations}") - for event in rows: - print(describe(event)) - await engine.dispose() - return 0 - - -if __name__ == "__main__": - raise SystemExit(asyncio.run(main())) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py deleted file mode 100644 index 582ca30..0000000 --- a/backend/tests/conftest.py +++ /dev/null @@ -1,84 +0,0 @@ -"""Shared fixtures: a rolled-back Postgres session, mock hotel data, ML splits.""" - -from collections.abc import AsyncIterator - -import pandas as pd -import pytest -from generate_mock_data import write_csv -from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine - -from foresight.config import settings - - -@pytest.fixture -async def session() -> AsyncIterator[AsyncSession]: - """A session whose transaction is always rolled back; skips with no Postgres.""" - engine = create_async_engine(settings.database_url) - try: - conn = await engine.connect() - except (OSError, ConnectionError) as exc: - await engine.dispose() - pytest.skip(f"Postgres unavailable: {exc}") - - trans = await conn.begin() - async with AsyncSession( - bind=conn, join_transaction_mode="create_savepoint" - ) as session: - yield session - await trans.rollback() - await conn.close() - await engine.dispose() - - -# Known only after the night, so never used to predict price. -POST_STAY_COLUMNS = ["rooms_sold", "occupancy", "room_revenue"] -TEST_FRAC = 0.2 - - -@pytest.fixture(scope="session") -def df(): - """Mock nightly data, freshly generated to data/, sorted by stay_date.""" - df = pd.read_csv(write_csv(), parse_dates=["stay_date"]) - return df.sort_values("stay_date", kind="stable").reset_index(drop=True) - - -@pytest.fixture(scope="session") -def price_split(df): - """x_train, x_test, y_train, y_test for predicting rate. - - The test set is the most recent TEST_FRAC of nights. - """ - x = df.drop(columns=["rate", "stay_date", *POST_STAY_COLUMNS]) - x = pd.get_dummies(x, columns=["room_type", "condition"], dtype=float) - y = df["rate"] - - cut = int(len(df) * (1 - TEST_FRAC)) - return x.iloc[:cut], x.iloc[cut:], y.iloc[:cut], y.iloc[cut:] - - -# SARIMAX inputs besides past prices; same for every room type on a night. -TS_EXOG_COLUMNS = [ - "is_weekend", - "temp_max_c", - "precip_mm", - "expected_attendance", - "competitor_rate", - "competitors_sold_out", - "flight_count", - "avg_fare_eur", -] - - -@pytest.fixture(scope="session") -def ts_split(df): - """x_train, x_test, y_train, y_test for forecasting the nightly mean rate. - - One row per night at daily frequency; the test set is the most recent - TEST_FRAC of nights. - """ - # SARIMAX needs one row per day, so average the room types for each night. - daily = df.groupby("stay_date")[["rate", *TS_EXOG_COLUMNS]].mean().asfreq("D") - x, y = daily[TS_EXOG_COLUMNS], daily["rate"] - - cut = int(len(daily) * (1 - TEST_FRAC)) - return x.iloc[:cut], x.iloc[cut:], y.iloc[:cut], y.iloc[cut:] diff --git a/backend/tests/fixtures/asknews/run_1.json b/backend/tests/fixtures/asknews/run_1.json deleted file mode 100644 index 60f6d9d..0000000 --- a/backend/tests/fixtures/asknews/run_1.json +++ /dev/null @@ -1,122 +0,0 @@ -[ - { - "article_url": "https://rte.example/forbidden-fruit-2027", - "eng_title": "Forbidden Fruit announces 2027 return to Kilmainham", - "summary": "Organisers have confirmed the festival will return to the Royal Hospital Kilmainham in Dublin on June 5-7, 2027. Tickets go on sale Friday at 9am.", - "key_points": ["Three-day festival", "Tickets on sale Friday"], - "pub_date": "2026-10-05T08:15:00+00:00", - "entities": {"Event": ["Forbidden Fruit"], "Location": ["Dublin", "Ireland"], "Organization": ["Royal Hospital Kilmainham"]}, - "country": "IE", - "page_rank": 2, - "language": "en", - "classification": ["Entertainment"] - }, - { - "article_url": "https://publico.example/web-summit-2026", - "eng_title": "Web Summit returns to Lisbon in November", - "summary": "Web Summit will be held at the Altice Arena in Lisbon from November 10-13, 2026, with organisers expecting another sell-out edition.", - "key_points": ["Altice Arena", "Four-day conference"], - "pub_date": "2026-10-04T11:00:00+00:00", - "entities": {"Event": ["Web Summit"], "Location": ["Lisbon", "Portugal"]}, - "country": "PT", - "page_rank": 3, - "language": "en", - "classification": ["Technology"] - }, - { - "article_url": "https://scotsman.example/hogmanay-2026", - "eng_title": "Edinburgh Hogmanay street party lineup revealed", - "summary": "Edinburgh's Hogmanay celebrations will run December 30, 2026 - January 1, 2027, with the street party returning to Princes Street.", - "key_points": ["Princes Street party", "Three nights"], - "pub_date": "2026-10-03T16:40:00+00:00", - "entities": {"Event": ["Edinburgh Hogmanay"], "Location": ["Edinburgh", "Scotland"]}, - "country": "GB", - "page_rank": 2, - "language": "en", - "classification": ["Culture"] - }, - { - "article_url": "https://guardian.example/glastonbury-2027", - "eng_title": "Glastonbury confirms 2027 dates", - "summary": "Glastonbury Festival will take over Worthy Farm in Somerset on June 23-27, 2027, organisers announced.", - "key_points": ["Worthy Farm"], - "pub_date": "2026-10-05T07:00:00+00:00", - "entities": {"Event": ["Glastonbury Festival"], "Location": ["Somerset", "England"]}, - "country": "GB", - "page_rank": 1, - "language": "en", - "classification": ["Entertainment"] - }, - { - "article_url": "https://irishtimes.example/theatre-festival-plans", - "eng_title": "Dublin Theatre Festival plans expanded programme", - "summary": "The Dublin Theatre Festival will expand its programme next edition, though organisers say dates have not yet been confirmed.", - "key_points": ["Dates to be confirmed"], - "pub_date": "2026-10-02T09:20:00+00:00", - "entities": {"Event": ["Dublin Theatre Festival"], "Location": ["Dublin"]}, - "country": "IE", - "page_rank": 3, - "language": "en", - "classification": ["Culture"] - }, - { - "article_url": "https://observador.example/lisbon-marathon-recap", - "eng_title": "Lisbon half marathon draws record field", - "summary": "The Lisbon half marathon took place on Sunday and drew a record crowd of runners across the Vasco da Gama bridge.", - "key_points": ["Record field"], - "pub_date": "2026-10-05T18:30:00+00:00", - "entities": {"Event": ["Lisbon Half Marathon"], "Location": ["Lisbon"]}, - "country": "PT", - "page_rank": 4, - "language": "en", - "classification": ["Sports"] - }, - { - "article_url": "https://siliconrepublic.example/dublin-tech-summit", - "eng_title": "Dublin Tech Summit eyes spring slot", - "summary": "Dublin Tech Summit is scheduled for spring 2027 at the Convention Centre Dublin, organisers confirmed, with exact dates to follow.", - "key_points": ["Convention Centre Dublin"], - "pub_date": "2026-10-01T13:05:00+00:00", - "entities": {"Event": ["Dublin Tech Summit"], "Location": ["Dublin"]}, - "country": "IE", - "page_rank": 5, - "language": "en", - "classification": ["Technology"] - }, - { - "article_url": "", - "eng_title": "Untitled wire item", - "summary": "Dublin concert announced for November 20, 2026.", - "key_points": [], - "pub_date": "2026-10-05T12:00:00+00:00", - "entities": {"Location": ["Dublin"]}, - "country": "IE", - "page_rank": 9, - "language": "en", - "classification": ["Entertainment"] - }, - { - "article_url": "https://hotpress.example/stadium-2031", - "eng_title": "Dublin stadium pencils in 2031 showcase", - "summary": "A showcase concert at the Aviva Stadium in Dublin is scheduled for August 28, 2031 under the venue's long-range plan.", - "key_points": ["Long-range plan"], - "pub_date": "2026-10-04T10:00:00+00:00", - "entities": {"Event": ["Aviva Showcase"], "Location": ["Dublin"]}, - "country": "IE", - "page_rank": 7, - "language": "en", - "classification": ["Entertainment"] - }, - { - "article_url": "https://heraldscotland.example/edinburgh-fringe-2027", - "eng_title": "Edinburgh Festival Fringe sets August window", - "summary": "The Edinburgh Festival Fringe will run through August 2027, with the full programme due in the new year.", - "key_points": ["Programme due in the new year"], - "pub_date": "2026-10-02T15:00:00+00:00", - "entities": {"Event": ["Edinburgh Festival Fringe"], "Location": ["Edinburgh"]}, - "country": "GB", - "page_rank": 2, - "language": "en", - "classification": ["Culture"] - } -] diff --git a/backend/tests/fixtures/asknews/run_2.json b/backend/tests/fixtures/asknews/run_2.json deleted file mode 100644 index 8f15a99..0000000 --- a/backend/tests/fixtures/asknews/run_2.json +++ /dev/null @@ -1,110 +0,0 @@ -[ - { - "article_url": "https://rte.example/forbidden-fruit-2027", - "eng_title": "Forbidden Fruit announces 2027 return to Kilmainham", - "summary": "Organisers have confirmed the festival will return to the Royal Hospital Kilmainham in Dublin on June 5-7, 2027. Tickets go on sale Friday at 9am.", - "key_points": ["Three-day festival", "Tickets on sale Friday"], - "pub_date": "2026-10-05T08:15:00+00:00", - "entities": {"Event": ["Forbidden Fruit"], "Location": ["Dublin", "Ireland"], "Organization": ["Royal Hospital Kilmainham"]}, - "country": "IE", - "page_rank": 2, - "language": "en", - "classification": ["Entertainment"] - }, - { - "article_url": "https://publico.example/web-summit-2026", - "eng_title": "Web Summit returns to Lisbon in November", - "summary": "Web Summit will be held at the Altice Arena in Lisbon from November 10-13, 2026, with organisers expecting another sell-out edition.", - "key_points": ["Altice Arena", "Four-day conference"], - "pub_date": "2026-10-04T11:00:00+00:00", - "entities": {"Event": ["Web Summit"], "Location": ["Lisbon", "Portugal"]}, - "country": "PT", - "page_rank": 3, - "language": "en", - "classification": ["Technology"] - }, - { - "article_url": "https://techcrunch.example/web-summit-lisbon-preview", - "eng_title": "What to expect from this year's Lisbon tech gathering", - "summary": "Running November 10-13, 2026 in Lisbon, the conference will host more than 70,000 attendees at the Altice Arena.", - "key_points": ["70,000 attendees expected"], - "pub_date": "2026-10-06T06:45:00+00:00", - "entities": {"Event": ["Web Summit"], "Location": ["Lisbon"]}, - "country": "PT", - "page_rank": 2, - "language": "en", - "classification": ["Technology"] - }, - { - "article_url": "https://scotsman.example/hogmanay-2026", - "eng_title": "Edinburgh Hogmanay street party lineup revealed", - "summary": "Edinburgh's Hogmanay celebrations will run December 30, 2026 - January 1, 2027, with the street party returning to Princes Street.", - "key_points": ["Princes Street party", "Three nights"], - "pub_date": "2026-10-03T16:40:00+00:00", - "entities": {"Event": ["Edinburgh Hogmanay"], "Location": ["Edinburgh", "Scotland"]}, - "country": "GB", - "page_rank": 2, - "language": "en", - "classification": ["Culture"] - }, - { - "article_url": "https://independent.example/dublin-marathon-2026", - "eng_title": "Dublin Marathon route confirmed for October", - "summary": "The Dublin Marathon is scheduled for October 25, 2026, with organisers confirming a revised route through the city centre.", - "key_points": ["Revised city centre route"], - "pub_date": "2026-10-06T07:30:00+00:00", - "entities": {"Event": ["Dublin Marathon"], "Location": ["Dublin"]}, - "country": "IE", - "page_rank": 3, - "language": "en", - "classification": ["Sports"] - }, - { - "article_url": "https://timeout.example/lisbon-christmas-market", - "eng_title": "Lisbon Christmas market dates set", - "summary": "The Lisbon Christmas market will open December 1, 2026 and run through January 4, 2027 in Praca do Comercio.", - "key_points": ["Praca do Comercio"], - "pub_date": "2026-10-06T09:10:00+00:00", - "entities": {"Event": ["Lisbon Christmas Market"], "Location": ["Lisbon"]}, - "country": "PT", - "page_rank": 5, - "language": "en", - "classification": ["Culture"] - }, - { - "article_url": "https://bbc.example/manchester-gig", - "eng_title": "Arena tour adds Manchester night", - "summary": "The tour will add a Manchester date on March 14, 2027 at the Co-op Live arena.", - "key_points": [], - "pub_date": "2026-10-06T08:00:00+00:00", - "entities": {"Event": ["Arena Tour"], "Location": ["Manchester"]}, - "country": "GB", - "page_rank": 2, - "language": "en", - "classification": ["Entertainment"] - }, - { - "article_url": "https://thejournal.example/dublin-protest", - "eng_title": "Dublin city centre march planned", - "summary": "A march through Dublin city centre is being organised, with a date expected to be announced in the coming weeks.", - "key_points": ["Date expected soon"], - "pub_date": "2026-10-06T10:00:00+00:00", - "entities": {"Location": ["Dublin"]}, - "country": "IE", - "page_rank": 6, - "language": "en", - "classification": ["Politics"] - }, - { - "article_url": "https://scotsman.example/fringe-recap-2026", - "eng_title": "Edinburgh Fringe 2026 in numbers", - "summary": "This summer's Edinburgh Fringe concluded with record attendance. The festival was held across 26 days in August.", - "key_points": ["Record attendance"], - "pub_date": "2026-09-30T12:00:00+00:00", - "entities": {"Event": ["Edinburgh Festival Fringe"], "Location": ["Edinburgh"]}, - "country": "GB", - "page_rank": 3, - "language": "en", - "classification": ["Culture"] - } -] diff --git a/backend/tests/fixtures/asknews/run_3.json b/backend/tests/fixtures/asknews/run_3.json deleted file mode 100644 index f7c2bd7..0000000 --- a/backend/tests/fixtures/asknews/run_3.json +++ /dev/null @@ -1,110 +0,0 @@ -[ - { - "article_url": "https://rte.example/forbidden-fruit-2027", - "eng_title": "Forbidden Fruit moves 2027 edition back a week", - "summary": "Organisers have revised the dates: the festival will now return to the Royal Hospital Kilmainham in Dublin on June 11-13, 2027. Existing tickets remain valid.", - "key_points": ["Dates moved back a week", "Tickets remain valid"], - "pub_date": "2026-10-06T14:20:00+00:00", - "entities": {"Event": ["Forbidden Fruit"], "Location": ["Dublin", "Ireland"], "Organization": ["Royal Hospital Kilmainham"]}, - "country": "IE", - "page_rank": 2, - "language": "en", - "classification": ["Entertainment"] - }, - { - "article_url": "https://publico.example/web-summit-2026", - "eng_title": "Web Summit returns to Lisbon in November", - "summary": "Web Summit will be held at the Altice Arena in Lisbon from November 10-13, 2026, with organisers expecting another sell-out edition.", - "key_points": ["Altice Arena", "Four-day conference"], - "pub_date": "2026-10-04T11:00:00+00:00", - "entities": {"Event": ["Web Summit"], "Location": ["Lisbon", "Portugal"]}, - "country": "PT", - "page_rank": 3, - "language": "en", - "classification": ["Technology"] - }, - { - "article_url": "https://independent.example/dublin-marathon-2026", - "eng_title": "Dublin Marathon route confirmed for October", - "summary": "The Dublin Marathon is scheduled for October 25, 2026, with organisers confirming a revised route through the city centre.", - "key_points": ["Revised city centre route"], - "pub_date": "2026-10-06T07:30:00+00:00", - "entities": {"Event": ["Dublin Marathon"], "Location": ["Dublin"]}, - "country": "IE", - "page_rank": 3, - "language": "en", - "classification": ["Sports"] - }, - { - "article_url": "https://rte.example/tradfest-2027", - "eng_title": "TradFest announces January dates for Temple Bar", - "summary": "TradFest will run January 20-24, 2027 across venues in Temple Bar, Dublin, organisers confirmed.", - "key_points": ["Temple Bar venues"], - "pub_date": "2026-10-06T11:00:00+00:00", - "entities": {"Event": ["TradFest"], "Location": ["Dublin"]}, - "country": "IE", - "page_rank": 4, - "language": "en", - "classification": ["Culture"] - }, - { - "article_url": "https://irishtimes.example/tradfest-temple-bar", - "eng_title": "Temple Bar winter music festival confirms programme", - "summary": "The festival is scheduled for January 20-24, 2027 in Dublin, with more than 100 performances announced.", - "key_points": ["100 performances"], - "pub_date": "2026-10-06T12:30:00+00:00", - "entities": {"Event": ["TradFest"], "Location": ["Dublin"]}, - "country": "IE", - "page_rank": 3, - "language": "en", - "classification": ["Culture"] - }, - { - "article_url": "https://publico.example/porto-sao-joao", - "eng_title": "Porto sets Sao Joao festival date", - "summary": "Porto's Sao Joao festival will take over the city on June 23, 2027, with fireworks over the Douro.", - "key_points": ["Fireworks over the Douro"], - "pub_date": "2026-10-06T13:00:00+00:00", - "entities": {"Event": ["Sao Joao"], "Location": ["Porto", "Portugal"]}, - "country": "PT", - "page_rank": 4, - "language": "en", - "classification": ["Culture"] - }, - { - "article_url": "https://heraldscotland.example/edinburgh-summit-q2", - "eng_title": "Edinburgh to host energy summit", - "summary": "An international energy summit is scheduled for Q2 2027 in Edinburgh, the organisers said, with the venue still to be confirmed.", - "key_points": ["Venue to be confirmed"], - "pub_date": "2026-10-06T15:45:00+00:00", - "entities": {"Event": ["International Energy Summit"], "Location": ["Edinburgh"]}, - "country": "GB", - "page_rank": 5, - "language": "en", - "classification": ["Business"] - }, - { - "article_url": "https://thejournal.example/weekend-listings", - "eng_title": "What's on in Dublin this weekend", - "summary": "A roundup of gigs and markets happening in Dublin this weekend, including a craft fair and two late-night sessions.", - "key_points": ["Craft fair", "Late-night sessions"], - "pub_date": "2026-10-06T16:00:00+00:00", - "entities": {"Location": ["Dublin"]}, - "country": "IE", - "page_rank": 8, - "language": "en", - "classification": ["Culture"] - }, - { - "article_url": "https://observador.example/lisbon-council-budget", - "eng_title": "Lisbon council approves 2027 budget", - "summary": "Lisbon's council approved its 2027 budget on Tuesday, allocating funds across transport and housing.", - "key_points": ["Transport and housing"], - "pub_date": "2026-10-06T17:10:00+00:00", - "entities": {"Location": ["Lisbon"], "Organization": ["Lisbon City Council"]}, - "country": "PT", - "page_rank": 4, - "language": "en", - "classification": ["Politics"] - } -] diff --git a/backend/tests/test_asknews.py b/backend/tests/test_asknews.py deleted file mode 100644 index a3e53f3..0000000 --- a/backend/tests/test_asknews.py +++ /dev/null @@ -1,449 +0,0 @@ -"""AskNews adapter: what survives extraction, what gets dropped, and the client.""" - -from __future__ import annotations - -from datetime import date -from typing import Any - -import httpx -import pytest - -from foresight.ingest import asknews -from foresight.ingest.asknews import ( - AskNewsClient, - CityTarget, - RejectReason, - collect_events, - extract_batch, - extract_event, - plan_searches, -) -from foresight.ingest.normalize import ( - DatePrecision, - EventCategory, - EventStatus, - NormalizedEvent, - Source, -) - -TODAY = date(2026, 6, 1) -DUBLIN = CityTarget( - name="Dublin", - timezone="Europe/Dublin", - country="IE", - aliases=("Baile Atha Cliath",), -) -LISBON = CityTarget(name="Lisbon", timezone="Europe/Lisbon", country="PT") -CITIES = (DUBLIN, LISBON) - - -def article(**overrides: Any) -> dict[str, Any]: - base: dict[str, Any] = { - "article_url": "https://news.example/forbidden-fruit", - "eng_title": "Forbidden Fruit announces August dates for Dublin", - "summary": ( - "Organisers confirmed the festival will return to the Royal Hospital " - "Kilmainham in Dublin on August 28-30, 2026, with tickets on sale Friday." - ), - "key_points": ["Tickets go on sale Friday"], - "pub_date": "2026-05-20T09:30:00+00:00", - "entities": {"Event": ["Forbidden Fruit"], "Location": ["Dublin", "Ireland"]}, - "country": "IE", - "page_rank": 3, - "language": "en", - } - return base | overrides - - -def test_a_clean_announcement_becomes_a_provisional_event() -> None: - event = extract_event(article(), CITIES, today=TODAY) - - assert isinstance(event, NormalizedEvent) - assert event.source is Source.ASKNEWS - assert event.source_ref == "https://news.example/forbidden-fruit" - assert event.title == "Forbidden Fruit" - assert event.city == "Dublin" - assert event.start_local_date == date(2026, 8, 28) - assert event.end_local_date == date(2026, 8, 30) - assert event.category is EventCategory.FESTIVALS - assert event.date_precision is DatePrecision.DAY - assert event.status is EventStatus.PROVISIONAL - - -def test_timezone_comes_from_the_city_not_the_article() -> None: - event = extract_event(article(), CITIES, today=TODAY) - - assert isinstance(event, NormalizedEvent) - assert event.timezone == "Europe/Dublin" - assert event.country == "IE" - - -def test_extraction_is_never_fully_confident() -> None: - event = extract_event(article(), CITIES, today=TODAY) - - assert isinstance(event, NormalizedEvent) - assert 0.0 < event.confidence <= 0.95 - - -def test_an_event_entity_names_the_event_rather_than_the_headline() -> None: - headline_only = article(entities={"Location": ["Dublin"]}) - - titled = extract_event(article(), CITIES, today=TODAY) - untitled = extract_event(headline_only, CITIES, today=TODAY) - - assert isinstance(titled, NormalizedEvent) - assert isinstance(untitled, NormalizedEvent) - assert titled.title == "Forbidden Fruit" - assert untitled.title.startswith("Forbidden Fruit announces") - # The entity hit is the stronger signal, so it must score higher. - assert titled.confidence > untitled.confidence - - -def test_a_location_entity_outscores_a_bare_mention_in_the_body() -> None: - weak = article(entities={"Event": ["Forbidden Fruit"]}) - - strong_event = extract_event(article(), CITIES, today=TODAY) - weak_event = extract_event(weak, CITIES, today=TODAY) - - assert isinstance(strong_event, NormalizedEvent) - assert isinstance(weak_event, NormalizedEvent) - assert strong_event.confidence > weak_event.confidence - - -def test_an_alias_resolves_to_the_same_city() -> None: - aliased = article( - entities={"Event": ["Forbidden Fruit"], "Location": ["Baile Atha Cliath"]}, - summary="Returns on August 28-30, 2026.", - eng_title="Festival returns", - ) - - event = extract_event(aliased, CITIES, today=TODAY) - - assert isinstance(event, NormalizedEvent) - assert event.city_slug == "dublin" - - -@pytest.mark.parametrize( - ("overrides", "reason"), - [ - ( - { - "eng_title": "Glastonbury announces dates", - "summary": "The festival runs August 28-30, 2026 in Somerset.", - "entities": {"Event": ["Glastonbury"], "Location": ["Somerset"]}, - }, - RejectReason.NO_CITY_MATCH, - ), - ( - { - "summary": "A Dublin festival will return, dates to be confirmed.", - "eng_title": "Dublin festival to return", - "entities": {"Location": ["Dublin"]}, - }, - RejectReason.NO_RESOLVABLE_DATE, - ), - ( - { - "summary": "The Dublin festival was held on August 28, 2025.", - "eng_title": "Dublin festival review", - "key_points": [], - "entities": {"Location": ["Dublin"]}, - }, - RejectReason.RETROSPECTIVE, - ), - ( - { - "summary": "The Dublin festival is scheduled for August 28, 2035.", - "eng_title": "Dublin festival long-range plan", - "entities": {"Location": ["Dublin"]}, - }, - RejectReason.BEYOND_HORIZON, - ), - ({"article_url": ""}, RejectReason.MALFORMED_RECORD), - ( - {"eng_title": "", "title": "", "summary": "", "key_points": []}, - RejectReason.MALFORMED_RECORD, - ), - ], -) -def test_unusable_articles_are_rejected_with_a_reason( - overrides: dict[str, Any], reason: RejectReason -) -> None: - assert extract_event(article(**overrides), CITIES, today=TODAY) is reason - - -def test_an_event_that_already_finished_is_rejected_as_past() -> None: - stale = article( - pub_date="2026-01-10T09:00:00+00:00", - eng_title="Dublin parade set for March 17", - summary="The Dublin parade is scheduled for March 17, 2026.", - entities={"Location": ["Dublin"]}, - ) - - assert extract_event(stale, CITIES, today=TODAY) is RejectReason.EVENT_ALREADY_PAST - - -def test_an_event_running_across_today_is_still_kept() -> None: - ongoing = article( - pub_date="2026-05-25T09:00:00+00:00", - eng_title="Dublin festival under way", - summary="The Dublin festival is scheduled for May 30 - June 3, 2026.", - entities={"Location": ["Dublin"]}, - ) - - event = extract_event(ongoing, CITIES, today=TODAY) - - assert isinstance(event, NormalizedEvent) - assert event.start_local_date < TODAY <= event.end_local_date - - -def test_the_same_event_from_two_outlets_collapses_to_one_row() -> None: - wire_copy = article( - article_url="https://other.example/ff", - eng_title="Dublin festival confirms summer return", - entities={"Event": ["Forbidden Fruit"], "Location": ["Dublin"]}, - ) - - events, report = extract_batch([article(), wire_copy], CITIES, today=TODAY) - - assert len(events) == 1 - assert report.articles_seen == 2 - assert report.events_kept == 1 - assert report.rejected[RejectReason.DUPLICATE_IN_BATCH.value] == 1 - - -def test_the_higher_confidence_sighting_wins_a_collapse() -> None: - weak = article( - article_url="https://other.example/ff", - entities={"Event": ["Forbidden Fruit"]}, - ) - - events, _ = extract_batch([weak, article()], CITIES, today=TODAY) - - assert len(events) == 1 - assert events[0].source_ref == "https://news.example/forbidden-fruit" - - -def test_two_outlets_do_not_collapse_without_an_event_entity() -> None: - """A known limit: headlines differ, so the dedupe key differs. - - AskNews supplies an Event entity for most announcements. Where it does - not, the same event arrives twice and only PredictHQ corroboration or a - later title match will merge it. - """ - wire_copy = article( - article_url="https://other.example/ff", - eng_title="Forbidden Fruit returns to Dublin", - entities={"Location": ["Dublin"]}, - ) - - events, _ = extract_batch([article(), wire_copy], CITIES, today=TODAY) - - assert len(events) == 2 - - -def test_the_report_tallies_everything_that_came_in() -> None: - batch = [ - article(), - article( - article_url="https://x.example/1", summary="No date here.", key_points=[] - ), - article( - article_url="https://x.example/2", - entities={"Location": ["Somerset"]}, - eng_title="Somerset show", - summary="Runs August 28-30, 2026 in Somerset.", - ), - ] - - events, report = extract_batch(batch, CITIES, today=TODAY) - - assert report.articles_seen == 3 - assert report.events_kept == len(events) == 1 - assert report.articles_rejected == 2 - assert report.by_city["dublin"] == 1 - assert report.by_precision["day"] == 1 - assert "articles seen" in report.render() - - -def test_events_are_returned_in_date_order() -> None: - later = article( - article_url="https://x.example/later", - eng_title="Lisbon summit", - summary="The Lisbon summit is scheduled for October 5, 2026.", - entities={"Event": ["Lisbon Summit"], "Location": ["Lisbon"]}, - ) - - events, _ = extract_batch([later, article()], CITIES, today=TODAY) - - assert [e.start_local_date for e in events] == sorted( - e.start_local_date for e in events - ) - - -# --- client --------------------------------------------------------------- - - -def build_client(handler: Any) -> tuple[AskNewsClient, dict[str, int]]: - calls: dict[str, int] = {"token": 0, "search": 0} - - def route(request: httpx.Request) -> httpx.Response: - if request.url.path.endswith("/token"): - calls["token"] += 1 - else: - calls["search"] += 1 - return handler(request, calls) - - http = httpx.AsyncClient(transport=httpx.MockTransport(route)) - return AskNewsClient("id", "secret", http=http), calls - - -def ok(request: httpx.Request, _calls: dict[str, int]) -> httpx.Response: - if request.url.path.endswith("/token"): - return httpx.Response(200, json={"access_token": "tok", "expires_in": 3600}) - return httpx.Response(200, json={"as_dicts": [article()], "offset": 0}) - - -async def test_search_authenticates_then_returns_articles() -> None: - client, calls = build_client(ok) - - articles = await client.search_news(query="festival", n_articles=5) - - assert calls == {"token": 1, "search": 1} - assert articles[0]["article_url"] == "https://news.example/forbidden-fruit" - - -async def test_the_token_is_reused_across_searches() -> None: - client, calls = build_client(ok) - - await client.search_news(query="a") - await client.search_news(query="b") - - assert calls["token"] == 1 - assert calls["search"] == 2 - - -async def test_the_bearer_token_is_sent_on_the_search() -> None: - seen: list[str] = [] - - def capture(request: httpx.Request, _calls: dict[str, int]) -> httpx.Response: - if request.url.path.endswith("/token"): - return httpx.Response(200, json={"access_token": "tok", "expires_in": 3600}) - seen.append(request.headers.get("authorization", "")) - return httpx.Response(200, json={"as_dicts": []}) - - client, _ = build_client(capture) - await client.search_news(query="a") - - assert seen == ["Bearer tok"] - - -async def test_a_rate_limited_search_is_retried( - monkeypatch: pytest.MonkeyPatch, -) -> None: - async def no_sleep(_seconds: float) -> None: - return None - - monkeypatch.setattr(asknews.asyncio, "sleep", no_sleep) - - def throttle(request: httpx.Request, calls: dict[str, int]) -> httpx.Response: - if request.url.path.endswith("/token"): - return httpx.Response(200, json={"access_token": "tok", "expires_in": 3600}) - if calls["search"] == 1: - return httpx.Response(429, headers={"retry-after": "1"}, json={}) - return httpx.Response(200, json={"as_dicts": [article()]}) - - client, calls = build_client(throttle) - articles = await client.search_news(query="festival") - - assert calls["search"] == 2 - assert len(articles) == 1 - - -async def test_a_persistent_server_error_is_raised() -> None: - async def no_sleep(_seconds: float) -> None: - return None - - def broken(request: httpx.Request, _calls: dict[str, int]) -> httpx.Response: - if request.url.path.endswith("/token"): - return httpx.Response(200, json={"access_token": "tok", "expires_in": 3600}) - return httpx.Response(503, json={}) - - client, _ = build_client(broken) - asknews.asyncio.sleep = no_sleep # type: ignore[assignment] - - with pytest.raises(httpx.HTTPStatusError): - await client.search_news(query="festival") - - -async def test_a_token_response_without_a_token_fails_loudly() -> None: - def tokenless(request: httpx.Request, _calls: dict[str, int]) -> httpx.Response: - return httpx.Response(200, json={"detail": "nope"}) - - client, _ = build_client(tokenless) - - with pytest.raises(ValueError, match="access_token"): - await client.search_news(query="festival") - - -async def test_collect_events_is_one_call_a_scheduled_worker_can_make() -> None: - """The cron path end to end: fetch every query, extract one clean batch.""" - queries_seen: list[str] = [] - hours_seen: list[str] = [] - - def handler(request: httpx.Request, _calls: dict[str, int]) -> httpx.Response: - if request.url.path.endswith("/token"): - return httpx.Response(200, json={"access_token": "tok", "expires_in": 3600}) - queries_seen.append(request.url.params.get("query", "")) - hours_seen.append(request.url.params.get("hours_back", "")) - return httpx.Response(200, json={"as_dicts": [article()]}) - - client, calls = build_client(handler) - events, report = await collect_events(client, CITIES, today=TODAY, hours_back=6) - - assert calls["search"] == len(CITIES) - assert [q.split()[0] for q in queries_seen] == ["Dublin", "Lisbon"] - assert hours_seen == ["6", "6"] - assert report.queries_run == len(CITIES) - assert report.articles_seen == 2 - # The same article from both queries is one event, not two. - assert len(events) == 1 - assert report.rejected[RejectReason.DUPLICATE_IN_BATCH.value] == 1 - - -def test_each_city_gets_its_own_scoped_search() -> None: - plans = plan_searches(CITIES) - - assert [p.countries for p in plans] == [("IE",), ("PT",)] - assert all(p.query.startswith(c.name) for p, c in zip(plans, CITIES, strict=True)) - assert all("festival" in p.query for p in plans) - - -def test_a_city_without_a_country_is_not_country_filtered() -> None: - plans = plan_searches((CityTarget("Dublin", "Europe/Dublin"),)) - - assert plans[0].countries == () - - -async def test_collect_events_sends_one_country_scoped_search_per_city() -> None: - sent: list[tuple[str, list[str]]] = [] - - def handler(request: httpx.Request, _calls: dict[str, int]) -> httpx.Response: - if request.url.path.endswith("/token"): - return httpx.Response(200, json={"access_token": "tok", "expires_in": 3600}) - sent.append( - ( - request.url.params.get("query", ""), - request.url.params.get_list("countries"), - ) - ) - return httpx.Response(200, json={"as_dicts": []}) - - client, calls = build_client(handler) - _, report = await collect_events(client, CITIES, today=TODAY) - - assert calls["search"] == len(CITIES) - assert report.queries_run == len(CITIES) - assert [countries for _, countries in sent] == [["IE"], ["PT"]] - assert sent[0][0].startswith("Dublin") - assert sent[1][0].startswith("Lisbon") diff --git a/backend/tests/test_backfill_events.py b/backend/tests/test_backfill_events.py deleted file mode 100644 index 1b2541c..0000000 --- a/backend/tests/test_backfill_events.py +++ /dev/null @@ -1,227 +0,0 @@ -from __future__ import annotations - -from datetime import date -from typing import TYPE_CHECKING, Any - -import httpx -import pytest -from sqlalchemy import delete, func, select -from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine - -from foresight.config import settings -from foresight.database.models.external import Event, EventObservation -from foresight.ingest.predicthq import API_URL -from foresight.ingest.run import Market -from foresight.repository import Outcome -from foresight.services.backfill.events import ( - MAX_RETRIES, - OutOfCreditsError, - RetriesExhaustedError, - _get, - backfill, - chunk_params, - month_chunks, -) - -if TYPE_CHECKING: - from collections.abc import AsyncIterator, Callable - -DUBLIN = Market(city="Dublin", latitude=53.3498, longitude=-6.2603) - - -class FakeSleep: - def __init__(self) -> None: - self.waits: list[float] = [] - - async def __call__(self, seconds: float) -> None: - self.waits.append(seconds) - - -def _raw(event_id: str, day: str) -> dict[str, Any]: - return { - "id": event_id, - "title": f"Gig {event_id}", - "category": "concerts", - "state": "active", - "start": f"{day}T19:00:00Z", - "end": f"{day}T22:00:00Z", - "timezone": "Europe/Dublin", - "location": [-6.2286, 53.3478], - "country": "IE", - "updated": "2025-01-01T00:00:00Z", - } - - -def _client(handler: Callable[[httpx.Request], httpx.Response]) -> httpx.AsyncClient: - return httpx.AsyncClient(transport=httpx.MockTransport(handler)) - - -async def _drain(*args: Any, **kwargs: Any) -> Any: - """Run a backfill to the end and return its last report.""" - report = None - async for report in backfill(*args, **kwargs): # noqa: B007 - pass - return report - - -def _replies(*responses: httpx.Response) -> Callable[[httpx.Request], httpx.Response]: - queue = list(responses) - return lambda request: queue.pop(0) - - -def test_month_chunks_split_partial_months() -> None: - assert month_chunks(date(2024, 1, 15), date(2024, 3, 10)) == [ - (date(2024, 1, 15), date(2024, 1, 31)), - (date(2024, 2, 1), date(2024, 2, 29)), - (date(2024, 3, 1), date(2024, 3, 10)), - ] - - -def test_month_chunks_single_day() -> None: - day = date(2025, 6, 30) - assert month_chunks(day, day) == [(day, day)] - - -def test_chunk_params_cover_the_chunk_with_slack_and_no_revision_filter() -> None: - params = chunk_params(DUBLIN, date(2025, 3, 1), date(2025, 3, 31), "10km") - - assert params["active.gte"] == "2025-02-28" - assert params["active.lte"] == "2025-04-01" - assert params["within"] == "10km@53.3498,-6.2603" - assert "updated.gte" not in params - - -async def test_success_never_waits() -> None: - sleep = FakeSleep() - async with _client(_replies(httpx.Response(200, json={"results": []}))) as client: - assert await _get(client, API_URL, None, sleep=sleep) == {"results": []} - assert sleep.waits == [] - - -async def test_rate_limit_backs_off_then_succeeds() -> None: - sleep = FakeSleep() - handler = _replies( - httpx.Response(429), httpx.Response(429), httpx.Response(200, json={}) - ) - async with _client(handler) as client: - await _get(client, API_URL, None, sleep=sleep) - assert sleep.waits == [2.0, 4.0] - - -async def test_rate_limit_honours_retry_after() -> None: - sleep = FakeSleep() - handler = _replies( - httpx.Response(429, headers={"Retry-After": "7"}), httpx.Response(200, json={}) - ) - async with _client(handler) as client: - await _get(client, API_URL, None, sleep=sleep) - assert sleep.waits == [7.0] - - -async def test_gives_up_after_every_retry() -> None: - sleep = FakeSleep() - async with _client(lambda request: httpx.Response(429)) as client: - with pytest.raises(RetriesExhaustedError): - await _get(client, API_URL, None, sleep=sleep) - assert len(sleep.waits) == MAX_RETRIES - - -async def test_out_of_credits_stops_without_waiting() -> None: - sleep = FakeSleep() - async with _client(_replies(httpx.Response(402, text="quota exceeded"))) as client: - with pytest.raises(OutOfCreditsError, match="quota exceeded"): - await _get(client, API_URL, None, sleep=sleep) - assert sleep.waits == [] - - -async def test_dry_run_counts_without_a_database() -> None: - page = {"results": [_raw("phq-a", "2025-03-05"), {"id": "phq-bad"}]} - report = await _drain( - None, - DUBLIN, - date(2025, 3, 1), - date(2025, 3, 31), - token="t", - transport=httpx.MockTransport(_replies(httpx.Response(200, json=page))), - sleep=FakeSleep(), - ) - - assert (report.fetched, len(report.rejected), report.chunks_done) == (2, 1, 1) - assert report.stopped is None - - -@pytest.fixture -async def session() -> AsyncIterator[AsyncSession]: - engine = create_async_engine(settings.database_url) - try: - conn = await engine.connect() - except (OSError, ConnectionError) as exc: - await engine.dispose() - pytest.skip(f"Postgres unavailable: {exc}") - - trans = await conn.begin() - async with AsyncSession( - bind=conn, join_transaction_mode="create_savepoint" - ) as session: - await session.execute(delete(EventObservation)) - await session.execute(delete(Event)) - yield session - await trans.rollback() - await conn.close() - await engine.dispose() - - -async def test_out_of_credits_keeps_earlier_pages_and_rerun_adds_no_duplicates( - session: AsyncSession, -) -> None: - page_1 = { - "results": [_raw("phq-a", "2025-03-05"), _raw("phq-b", "2025-03-12")], - "next": f"{API_URL}?page=2", - } - page_2 = {"results": [_raw("phq-c", "2025-03-20")]} - march = (date(2025, 3, 1), date(2025, 3, 31)) - - stopped = await _drain( - session, - DUBLIN, - *march, - token="t", - transport=httpx.MockTransport( - _replies(httpx.Response(200, json=page_1), httpx.Response(402)) - ), - sleep=FakeSleep(), - ) - assert stopped.stopped is not None - assert stopped.resume_from == date(2025, 3, 1) - assert await session.scalar(select(func.count()).select_from(Event)) == 2 - - resumed = await _drain( - session, - DUBLIN, - *march, - token="t", - transport=httpx.MockTransport( - _replies(httpx.Response(200, json=page_1), httpx.Response(200, json=page_2)) - ), - sleep=FakeSleep(), - ) - assert resumed.stopped is None - assert resumed.outcomes[Outcome.NEW] == 1 - assert await session.scalar(select(func.count()).select_from(Event)) == 3 - - -async def test_yields_after_each_month_without_committing() -> None: - empty = httpx.Response(200, json={"results": []}) - reports = [ - report.chunks_done - async for report in backfill( - None, - DUBLIN, - date(2025, 1, 15), - date(2025, 3, 10), - token="t", - transport=httpx.MockTransport(lambda request: empty), - sleep=FakeSleep(), - ) - ] - assert reports == [1, 2, 3] diff --git a/backend/tests/test_backfill_flights.py b/backend/tests/test_backfill_flights.py deleted file mode 100644 index f102130..0000000 --- a/backend/tests/test_backfill_flights.py +++ /dev/null @@ -1,63 +0,0 @@ -from __future__ import annotations - -from datetime import date -from typing import TYPE_CHECKING - -import pandas as pd -import pytest -from sqlalchemy import select - -from foresight.database.models.external import FlightArrival -from foresight.services.backfill.flights import ( - FlightBackfillStoppedError, - backfill_flights, -) -from foresight.services.flight_data_service import RateLimitedError - -if TYPE_CHECKING: - from sqlalchemy.ext.asyncio import AsyncSession - -AIRPORT = "TST" - - -async def _saved(session: AsyncSession) -> dict[date, int | None]: - rows = await session.scalars( - select(FlightArrival).where(FlightArrival.airport == AIRPORT) - ) - return {row.arrival_date: row.flight_count for row in rows} - - -async def test_rate_limit_keeps_earlier_days_and_rerun_overwrites( - session: AsyncSession, -) -> None: - def rate_limited_on_june_2(start: date, end: date, airport: str) -> pd.DataFrame: - if start == date(2026, 6, 2): - raise RateLimitedError("Too many requests") - return pd.DataFrame({"flight_number": ["EI 1", "EI 2"]}) - - saved_days = [] - with pytest.raises(FlightBackfillStoppedError) as stopped: - async for day, _ in backfill_flights( - session, - date(2026, 6, 1), - date(2026, 6, 3), - AIRPORT, - fetch=rate_limited_on_june_2, - ): - saved_days.append(day) - assert stopped.value.day == date(2026, 6, 2) - assert saved_days == [date(2026, 6, 1)] - assert await _saved(session) == {date(2026, 6, 1): 2} - - def one_flight(start: date, end: date, airport: str) -> pd.DataFrame: - return pd.DataFrame({"flight_number": ["EI 1"]}) - - async for _ in backfill_flights( - session, date(2026, 6, 1), date(2026, 6, 3), AIRPORT, fetch=one_flight - ): - pass - assert await _saved(session) == { - date(2026, 6, 1): 1, - date(2026, 6, 2): 1, - date(2026, 6, 3): 1, - } diff --git a/backend/tests/test_backfill_window.py b/backend/tests/test_backfill_window.py deleted file mode 100644 index 098fda1..0000000 --- a/backend/tests/test_backfill_window.py +++ /dev/null @@ -1,55 +0,0 @@ -from __future__ import annotations - -from datetime import date - -import pytest - -from foresight.repository import hotel as hotel_repo -from foresight.services.backfill.window import hotel_window - -HOTEL_RANGE = (date(2022, 3, 1), date(2025, 9, 30)) - - -@pytest.fixture -def hotel_range(monkeypatch: pytest.MonkeyPatch): - """Stand in for the DB: stay_date_range returns whatever the test sets.""" - answer = {"range": HOTEL_RANGE} - - async def fake_stay_date_range(session, hotel_id): - return answer["range"] - - monkeypatch.setattr(hotel_repo, "stay_date_range", fake_stay_date_range) - return answer - - -async def test_defaults_to_the_hotel_range(hotel_range) -> None: - assert await hotel_window(None, 1, None, None) == HOTEL_RANGE - - -async def test_given_dates_win_over_the_hotel_range(hotel_range) -> None: - start, end = date(2024, 1, 1), date(2024, 1, 31) - - assert await hotel_window(None, 1, start, None) == (start, HOTEL_RANGE[1]) - assert await hotel_window(None, 1, start, end) == (start, end) - - -async def test_both_dates_need_no_hotel(hotel_range) -> None: - start, end = date(2024, 1, 1), date(2024, 1, 31) - assert await hotel_window(None, None, start, end) == (start, end) - - -async def test_no_hotel_nights_needs_both_dates(hotel_range) -> None: - hotel_range["range"] = None - - with pytest.raises(ValueError, match="no hotel nights loaded"): - await hotel_window(None, 1, None, None) - - -async def test_missing_date_without_a_hotel_is_rejected(hotel_range) -> None: - with pytest.raises(ValueError, match="no hotel nights loaded"): - await hotel_window(None, None, date(2024, 1, 1), None) - - -async def test_start_after_end_is_rejected(hotel_range) -> None: - with pytest.raises(ValueError, match="is after"): - await hotel_window(None, None, date(2025, 1, 1), date(2024, 1, 1)) diff --git a/backend/tests/test_competitor_rates.py b/backend/tests/test_competitor_rates.py deleted file mode 100644 index e4350b6..0000000 --- a/backend/tests/test_competitor_rates.py +++ /dev/null @@ -1,110 +0,0 @@ -from __future__ import annotations - -from datetime import date -from decimal import Decimal -from typing import TYPE_CHECKING, Any - -from sqlalchemy import func, select - -from foresight.database.models.external import CompetitorRate -from foresight.repository import Outcome -from foresight.repository.competitor_rates import upsert_competitor_rates - -if TYPE_CHECKING: - from sqlalchemy.ext.asyncio import AsyncSession - -CITY = "Testville" - - -def _row(**overrides: Any) -> dict[str, Any]: - return { - "hotel_name": "The Rival", - "city": CITY, - "stay_date": date(2026, 6, 1), - "rate": Decimal("189.00"), - "is_sold_out": False, - "source": "test-scraper", - } | overrides - - -async def _rows(session: AsyncSession) -> int: - return await session.scalar( - select(func.count()) - .select_from(CompetitorRate) - .where(CompetitorRate.city == CITY) - ) - - -async def test_first_write_is_new(session: AsyncSession) -> None: - counts = await upsert_competitor_rates(session, [_row()]) - assert counts[Outcome.NEW] == 1 - assert await _rows(session) == 1 - - -async def test_identical_rerun_adds_no_rows(session: AsyncSession) -> None: - await upsert_competitor_rates(session, [_row()]) - counts = await upsert_competitor_rates(session, [_row()]) - assert counts == {Outcome.NEW: 0, Outcome.UPDATED: 0, Outcome.UNCHANGED: 1} - assert await _rows(session) == 1 - - -async def test_revised_rate_updates_in_place(session: AsyncSession) -> None: - await upsert_competitor_rates(session, [_row()]) - counts = await upsert_competitor_rates(session, [_row(rate=Decimal("215.50"))]) - assert counts[Outcome.UPDATED] == 1 - assert await _rows(session) == 1 - assert await session.scalar( - select(CompetitorRate.rate).where(CompetitorRate.city == CITY) - ) == Decimal("215.50") - - -async def test_selling_out_is_an_update(session: AsyncSession) -> None: - # rate goes to NULL: IS DISTINCT FROM must still see it as a change. - await upsert_competitor_rates(session, [_row()]) - counts = await upsert_competitor_rates(session, [_row(rate=None, is_sold_out=True)]) - assert counts[Outcome.UPDATED] == 1 - assert await _rows(session) == 1 - - -async def test_sold_out_rerun_is_unchanged(session: AsyncSession) -> None: - # NULL == NULL must not count as a change, or every re-run would "update". - sold_out = _row(rate=None, is_sold_out=True) - await upsert_competitor_rates(session, [sold_out]) - counts = await upsert_competitor_rates(session, [sold_out]) - assert counts[Outcome.UNCHANGED] == 1 - - -async def test_each_competitor_and_night_is_its_own_row( - session: AsyncSession, -) -> None: - await upsert_competitor_rates( - session, - [ - _row(), - _row(hotel_name="The Other Place"), - _row(stay_date=date(2026, 6, 2)), - ], - ) - assert await _rows(session) == 3 - - -async def test_second_source_is_its_own_row(session: AsyncSession) -> None: - await upsert_competitor_rates(session, [_row(), _row(source="other-scraper")]) - assert await _rows(session) == 2 - - -async def test_duplicate_key_in_one_batch_keeps_the_last( - session: AsyncSession, -) -> None: - counts = await upsert_competitor_rates( - session, [_row(), _row(rate=Decimal("199.00"))] - ) - assert counts[Outcome.NEW] == 1 - assert await session.scalar( - select(CompetitorRate.rate).where(CompetitorRate.city == CITY) - ) == Decimal("199.00") - - -async def test_empty_batch_writes_nothing(session: AsyncSession) -> None: - assert await upsert_competitor_rates(session, []) == dict.fromkeys(Outcome, 0) - assert await _rows(session) == 0 diff --git a/backend/tests/test_events_repository.py b/backend/tests/test_events_repository.py deleted file mode 100644 index 1fc2e1f..0000000 --- a/backend/tests/test_events_repository.py +++ /dev/null @@ -1,256 +0,0 @@ -"""Upsert resolution order against a real Postgres; skipped when none is reachable.""" - -from __future__ import annotations - -from datetime import UTC, date, datetime -from typing import TYPE_CHECKING - -import pytest -from sqlalchemy import delete, func, select - -from foresight.database.models.external import Event, EventObservation -from foresight.ingest.normalize import EventStatus, NormalizedEvent, Source -from foresight.repository import Outcome -from foresight.repository.events import upsert_event - -if TYPE_CHECKING: - from sqlalchemy.ext.asyncio import AsyncSession - - -@pytest.fixture(autouse=True) -async def _empty_events(session: AsyncSession) -> None: - """Counts below assume empty tables; real rows come back on rollback.""" - await session.execute(delete(EventObservation)) - await session.execute(delete(Event)) - - -def _phq(**overrides: object) -> NormalizedEvent: - base: dict[str, object] = { - "source": Source.PREDICTHQ, - "source_ref": "phq-test-1", - "title": "Hozier", - "city": "Dublin", - "start_local_date": date(2026, 11, 14), - "end_local_date": date(2026, 11, 14), - "timezone": "Europe/Dublin", - "local_rank": 71, - "payload": {"id": "phq-test-1"}, - } - return NormalizedEvent(**(base | overrides)) # type: ignore[arg-type] - - -def _news(**overrides: object) -> NormalizedEvent: - base: dict[str, object] = { - "source": Source.ASKNEWS, - "source_ref": "https://example.test/hozier-dublin", - "title": "Hozier (Live)", - "city": "Dublin, Ireland", - "start_local_date": date(2026, 11, 14), - "end_local_date": date(2026, 11, 14), - "venue": "Somewhere Vague", - "confidence": 0.6, - "status": EventStatus.PROVISIONAL, - "payload": {"url": "https://example.test/hozier-dublin"}, - } - return NormalizedEvent(**(base | overrides)) # type: ignore[arg-type] - - -async def _count(session: AsyncSession, model: type) -> int: - return await session.scalar(select(func.count()).select_from(model)) - - -async def _only_event(session: AsyncSession) -> Event: - return ( - await session.scalars(select(Event).execution_options(populate_existing=True)) - ).one() - - -async def test_start_date_revision_updates_the_row_instead_of_orphaning_it( - session: AsyncSession, -) -> None: - assert await upsert_event(session, _phq()) is Outcome.NEW - first = await _only_event(session) - first_id, first_seen, first_last = first.id, first.first_seen_at, first.last_seen_at - - revised = _phq( - start_local_date=date(2026, 11, 15), - end_local_date=date(2026, 11, 15), - payload={"id": "phq-test-1", "rev": 2}, - ) - assert await upsert_event(session, revised) is Outcome.UPDATED - - assert await _count(session, Event) == 1 - assert await _count(session, EventObservation) == 1 - event = await _only_event(session) - assert event.id == first_id - assert event.start_local_date == date(2026, 11, 15) - assert event.dedupe_key == revised.dedupe_key - assert event.first_seen_at == first_seen - assert event.last_seen_at > first_last - - -async def test_identical_rerun_is_unchanged_but_still_bumps_last_seen( - session: AsyncSession, -) -> None: - await upsert_event(session, _phq()) - before = (await _only_event(session)).last_seen_at - - assert await upsert_event(session, _phq()) is Outcome.UNCHANGED - assert await _count(session, Event) == 1 - assert await _count(session, EventObservation) == 1 - assert (await _only_event(session)).last_seen_at > before - - -async def test_second_source_attaches_to_the_existing_event( - session: AsyncSession, -) -> None: - await upsert_event(session, _phq()) - - # AskNews is outranked, so the PredictHQ values stand. - assert await upsert_event(session, _news()) is Outcome.UNCHANGED - - assert await _count(session, Event) == 1 - assert await _count(session, EventObservation) == 2 - event = await _only_event(session) - assert event.primary_source is Source.PREDICTHQ - assert event.venue is None - assert event.status is EventStatus.ACTIVE - - -async def test_higher_precedence_source_takes_over_a_text_extracted_event( - session: AsyncSession, -) -> None: - await upsert_event(session, _news()) - - assert await upsert_event(session, _phq()) is Outcome.UPDATED - event = await _only_event(session) - assert event.primary_source is Source.PREDICTHQ - assert event.confidence == 1.0 - assert event.status is EventStatus.ACTIVE - - -async def test_revision_onto_an_existing_key_merges_rather_than_duplicates( - session: AsyncSession, -) -> None: - await upsert_event(session, _phq()) - # An article already reported the date PredictHQ is about to move to. - await upsert_event( - session, - _news(start_local_date=date(2026, 11, 15), end_local_date=date(2026, 11, 15)), - ) - assert await _count(session, Event) == 2 - - await upsert_event( - session, - _phq(start_local_date=date(2026, 11, 15), end_local_date=date(2026, 11, 15)), - ) - - assert await _count(session, Event) == 1 - event = await _only_event(session) - assert event.primary_source is Source.PREDICTHQ - linked = await session.scalars(select(EventObservation.event_id)) - assert set(linked) == {event.id} - - -async def test_deletion_of_a_known_record_marks_the_event_deleted( - session: AsyncSession, -) -> None: - await upsert_event(session, _phq()) - - deleted = _phq(status=EventStatus.DELETED, payload={"state": "deleted"}) - assert await upsert_event(session, deleted) is Outcome.UPDATED - assert (await _only_event(session)).status is EventStatus.DELETED - - -async def test_another_records_deletion_does_not_kill_a_live_event( - session: AsyncSession, -) -> None: - await upsert_event(session, _phq()) - - duplicate = _phq( - source_ref="phq-test-dupe", - status=EventStatus.DELETED, - payload={"deleted_reason": "duplicate"}, - ) - assert await upsert_event(session, duplicate) is Outcome.UNCHANGED - assert (await _only_event(session)).status is EventStatus.ACTIVE - assert await _count(session, EventObservation) == 2 - - -async def test_observation_payload_tracks_the_latest_revision( - session: AsyncSession, -) -> None: - await upsert_event(session, _phq()) - updated_at = datetime(2026, 10, 6, 9, 30, tzinfo=UTC) - await upsert_event( - session, - _phq(payload={"id": "phq-test-1", "rev": 2}, source_updated_at=updated_at), - ) - - obs = ( - await session.scalars( - select(EventObservation).execution_options(populate_existing=True) - ) - ).one() - assert obs.payload == {"id": "phq-test-1", "rev": 2} - assert obs.source_updated_at == updated_at - - -async def test_deleted_duplicate_stays_harmless_on_every_rerun( - session: AsyncSession, -) -> None: - live = _phq() - duplicate = _phq(source_ref="phq-test-dupe", status=EventStatus.DELETED) - - for _ in range(3): - assert await upsert_event(session, live) in {Outcome.NEW, Outcome.UNCHANGED} - assert await upsert_event(session, duplicate) is Outcome.UNCHANGED - assert (await _only_event(session)).status is EventStatus.ACTIVE - - -async def test_live_duplicates_settle_on_the_latest_revision( - session: AsyncSession, -) -> None: - older = _phq( - expected_attendance=210, - source_updated_at=datetime(2026, 10, 1, tzinfo=UTC), - ) - newer = _phq( - source_ref="phq-test-dupe", - expected_attendance=211, - source_updated_at=datetime(2026, 10, 5, tzinfo=UTC), - ) - await upsert_event(session, older) - assert await upsert_event(session, newer) is Outcome.UPDATED - - # A rerun must not flip the row back and forth between the two listings. - assert await upsert_event(session, older) is Outcome.UNCHANGED - assert await upsert_event(session, newer) is Outcome.UNCHANGED - assert (await _only_event(session)).expected_attendance == 211 - - -async def test_live_listing_restores_an_event_its_duplicate_deleted( - session: AsyncSession, -) -> None: - # As left behind before sightings recorded their status. - await upsert_event(session, _phq(source_ref="phq-test-dupe")) - await upsert_event(session, _phq()) - await session.execute(EventObservation.__table__.update().values(status=None)) - await session.execute(Event.__table__.update().values(status=EventStatus.DELETED)) - - await upsert_event( - session, _phq(source_ref="phq-test-dupe", status=EventStatus.DELETED) - ) - assert await upsert_event(session, _phq()) is Outcome.UPDATED - assert (await _only_event(session)).status is EventStatus.ACTIVE - - -async def test_structured_deletion_outranks_a_live_article( - session: AsyncSession, -) -> None: - await upsert_event(session, _phq()) - await upsert_event(session, _news()) - - deleted = _phq(status=EventStatus.DELETED) - assert await upsert_event(session, deleted) is Outcome.UPDATED - assert (await _only_event(session)).status is EventStatus.DELETED diff --git a/backend/tests/test_external_runner.py b/backend/tests/test_external_runner.py deleted file mode 100644 index 8c402f4..0000000 --- a/backend/tests/test_external_runner.py +++ /dev/null @@ -1,112 +0,0 @@ -from __future__ import annotations - -from datetime import date, timedelta -from types import SimpleNamespace -from typing import TYPE_CHECKING, Any - -import pandas as pd -import pytest - -from foresight.ingest import external -from foresight.repository import Outcome - -if TYPE_CHECKING: - from sqlalchemy.ext.asyncio import AsyncSession - -_TODAY = date.today() - - -def _args(**overrides: Any) -> SimpleNamespace: - return SimpleNamespace( - **{ - "source": "weather", - "start": None, - "end": None, - "city": "Dublin", - "lat": 53.3, - "lon": -6.2, - } - | overrides - ) - - -@pytest.mark.parametrize( - ("end", "requested"), - [ - (date(2025, 6, 7), date(2025, 6, 7)), - # The archive rejects a future end date outright instead of returning - # empty days, so an over-long range has to be trimmed before the call. - (_TODAY + timedelta(days=30), _TODAY), - ], -) -async def test_the_archive_is_never_asked_past_today( - session: AsyncSession, - monkeypatch: pytest.MonkeyPatch, - end: date, - requested: date, -) -> None: - seen: dict[str, date] = {} - - def _fake(*_: Any, start: date, end: date) -> list[dict[str, Any]]: - seen.update(start=start, end=end) - return [] - - monkeypatch.setattr(external, "fetch_actuals", _fake) - await external._weather(session, _args(start=date(2025, 6, 1), end=end)) - assert seen == {"start": date(2025, 6, 1), "end": requested} - - -async def test_no_dates_fetches_the_forecast( - session: AsyncSession, monkeypatch: pytest.MonkeyPatch -) -> None: - monkeypatch.setattr(external, "fetch_forecast", lambda *_: []) - monkeypatch.setattr( - external, "fetch_actuals", lambda *_, **__: pytest.fail("called the archive") - ) - assert await external._weather(session, _args()) == dict.fromkeys(Outcome, 0) - - -async def test_flight_provider_rows_reach_the_table( - session: AsyncSession, monkeypatch: pytest.MonkeyPatch -) -> None: - frame = pd.DataFrame( - { - "arrival_time": pd.to_datetime( - ["2026-06-01T08:00Z", "2026-06-01T12:00Z"], utc=True - ).tz_convert("Europe/Dublin") - } - ) - monkeypatch.setitem(external._FLIGHT_PROVIDERS, "opensky", lambda *_: frame) - counts = await external._flights( - session, _args(source="opensky", start=date(2026, 6, 1), end=date(2026, 6, 1)) - ) - assert counts[Outcome.NEW] == 1 - - -@pytest.mark.parametrize( - "argv", - [ - ["--source", "weather"], - ["--source", "weather", "--city", "Dublin", "--lat", "53.3"], - # fmt: off - [ - "--source", - "weather", - "--city", - "D", - "--lat", - "1", - "--lon", - "2", - "--start", - "2026-06-01", - ], - # fmt: on - ["--source", "opensky"], - ["--source", "opensky", "--start", "2026-06-07", "--end", "2026-06-01"], - ], -) -def test_incomplete_arguments_are_refused(argv: list[str]) -> None: - with pytest.raises(SystemExit) as exit_info: - external.main(argv) - assert exit_info.value.code == 2 diff --git a/backend/tests/test_flight_data_service.py b/backend/tests/test_flight_data_service.py deleted file mode 100644 index b5793dc..0000000 --- a/backend/tests/test_flight_data_service.py +++ /dev/null @@ -1,78 +0,0 @@ -from datetime import date -from typing import Any - -import httpx -import pytest - -from foresight.services import flight_data_service -from foresight.services.flight_data_service import ( - RateLimitedError, - fetch_aerodatabox, -) - -JUNE_1 = date(2026, 6, 1) - - -def _arrival(number: str, local: str) -> dict[str, Any]: - return { - "number": number, - "movement": { - "airport": {"iata": "LHR"}, - "scheduledTime": {"local": local, "utc": f"{local[:16]}Z"}, - }, - } - - -def _fake_api(monkeypatch: pytest.MonkeyPatch, *responses: httpx.Response) -> list[str]: - """Answer each request with the next response; returns the URLs asked for.""" - urls, queue = [], list(responses) - - def get(url: str, **kwargs: Any) -> httpx.Response: - urls.append(url) - response = queue.pop(0) - response.request = httpx.Request("GET", url) - return response - - monkeypatch.setattr(flight_data_service.httpx, "get", get) - monkeypatch.setattr(flight_data_service, "sleep", lambda seconds: None) - return urls - - -def test_fetches_both_halves_of_the_day(monkeypatch: pytest.MonkeyPatch) -> None: - urls = _fake_api(monkeypatch, httpx.Response(204), httpx.Response(204)) - - assert fetch_aerodatabox(JUNE_1, JUNE_1).empty - assert [url.split("/DUB/")[1] for url in urls] == [ - "2026-06-01T00:00/2026-06-01T11:59", - "2026-06-01T12:00/2026-06-01T23:59", - ] - - -def test_skips_duplicates_and_the_day_before(monkeypatch: pytest.MonkeyPatch) -> None: - ei1 = _arrival("EI 1", "2026-06-01 10:05+01:00") - late = _arrival("FR 2", "2026-05-31 23:50+01:00") - _fake_api( - monkeypatch, - httpx.Response(200, json={"arrivals": [ei1, late]}), - httpx.Response(200, json={"arrivals": [ei1]}), - ) - - assert list(fetch_aerodatabox(JUNE_1, JUNE_1)["flight_number"]) == ["EI 1"] - - -def test_rate_limit_raises(monkeypatch: pytest.MonkeyPatch) -> None: - quota = [httpx.Response(429, text="exceeded the MONTHLY quota") for _ in range(4)] - urls = _fake_api(monkeypatch, *quota) - - with pytest.raises(RateLimitedError, match="MONTHLY quota"): - fetch_aerodatabox(JUNE_1, JUNE_1) - assert len(urls) == 4 # 1 try + 3 retries - - -def test_retries_a_rate_limit_then_carries_on(monkeypatch: pytest.MonkeyPatch) -> None: - urls = _fake_api( - monkeypatch, httpx.Response(429), httpx.Response(204), httpx.Response(204) - ) - - assert fetch_aerodatabox(JUNE_1, JUNE_1).empty - assert len(urls) == 3 # the morning window was asked twice diff --git a/backend/tests/test_flights_ingest.py b/backend/tests/test_flights_ingest.py deleted file mode 100644 index 077392d..0000000 --- a/backend/tests/test_flights_ingest.py +++ /dev/null @@ -1,48 +0,0 @@ -from __future__ import annotations - -from datetime import date - -import pandas as pd - -from foresight.ingest.flights import daily_arrivals - -DUBLIN = "Europe/Dublin" - - -def _df(*timestamps: str) -> pd.DataFrame: - return pd.DataFrame( - {"arrival_time": pd.to_datetime(list(timestamps), utc=True).tz_convert(DUBLIN)} - ) - - -def test_counts_flights_per_local_day() -> None: - rows = daily_arrivals( - _df("2026-06-01T08:00Z", "2026-06-01T19:00Z", "2026-06-02T08:00Z"), - airport="DUB", - source="aerodatabox", - ) - assert [(r["arrival_date"], r["flight_count"]) for r in rows] == [ - (date(2026, 6, 1), 2), - (date(2026, 6, 2), 1), - ] - - -def test_late_utc_arrival_counts_on_the_local_day() -> None: - # 23:30Z on 1 June is 00:30 on 2 June in Dublin summer time. - rows = daily_arrivals(_df("2026-06-01T23:30Z"), airport="DUB", source="opensky") - assert rows[0]["arrival_date"] == date(2026, 6, 2) - - -def test_row_matches_the_table_columns() -> None: - (row,) = daily_arrivals(_df("2026-06-01T08:00Z"), airport="DUB", source="opensky") - assert row == { - "airport": "DUB", - "arrival_date": date(2026, 6, 1), - "flight_count": 1, - "avg_fare_eur": None, - "source": "opensky", - } - - -def test_no_flights_is_no_rows() -> None: - assert daily_arrivals(pd.DataFrame(), airport="DUB", source="opensky") == [] diff --git a/backend/tests/test_flights_repository.py b/backend/tests/test_flights_repository.py deleted file mode 100644 index b811c36..0000000 --- a/backend/tests/test_flights_repository.py +++ /dev/null @@ -1,65 +0,0 @@ -from __future__ import annotations - -from datetime import date -from typing import TYPE_CHECKING, Any - -from sqlalchemy import func, select - -from foresight.database.models.external import FlightArrival -from foresight.repository import Outcome -from foresight.repository.flights import upsert_flight_arrivals - -if TYPE_CHECKING: - from sqlalchemy.ext.asyncio import AsyncSession - -AIRPORT = "TST" - - -def _row(**overrides: Any) -> dict[str, Any]: - return { - "airport": AIRPORT, - "arrival_date": date(2026, 6, 1), - "flight_count": 131, - "avg_fare_eur": None, - "source": "aerodatabox", - } | overrides - - -async def _rows(session: AsyncSession) -> int: - return await session.scalar( - select(func.count()) - .select_from(FlightArrival) - .where(FlightArrival.airport == AIRPORT) - ) - - -async def test_first_write_is_new(session: AsyncSession) -> None: - counts = await upsert_flight_arrivals(session, [_row()]) - assert counts[Outcome.NEW] == 1 - assert await _rows(session) == 1 - - -async def test_identical_rerun_adds_no_rows(session: AsyncSession) -> None: - await upsert_flight_arrivals(session, [_row()]) - counts = await upsert_flight_arrivals(session, [_row()]) - assert counts == {Outcome.NEW: 0, Outcome.UPDATED: 0, Outcome.UNCHANGED: 1} - assert await _rows(session) == 1 - - -async def test_revised_count_updates_in_place(session: AsyncSession) -> None: - await upsert_flight_arrivals(session, [_row()]) - counts = await upsert_flight_arrivals(session, [_row(flight_count=140)]) - assert counts[Outcome.UPDATED] == 1 - assert await _rows(session) == 1 - - -async def test_each_source_keeps_its_own_row(session: AsyncSession) -> None: - await upsert_flight_arrivals( - session, - [ - _row(source="cso", flight_count=100), - _row(source="opensky", flight_count=120), - _row(), - ], - ) - assert await _rows(session) == 3 diff --git a/backend/tests/test_health.py b/backend/tests/test_health.py index 1e38f2f..b8ba295 100644 --- a/backend/tests/test_health.py +++ b/backend/tests/test_health.py @@ -1,4 +1,8 @@ -"""Smoke test for the /health endpoint.""" +"""Smoke test for the /health endpoint. + +Runs the app in-process through httpx's ASGI transport. The transport doesn't +run the app's lifespan, so no database connection is opened. +""" from __future__ import annotations diff --git a/backend/tests/test_hotel_parser.py b/backend/tests/test_hotel_parser.py deleted file mode 100644 index a845d4f..0000000 --- a/backend/tests/test_hotel_parser.py +++ /dev/null @@ -1,136 +0,0 @@ -from __future__ import annotations - -from datetime import date -from decimal import Decimal -from typing import TYPE_CHECKING, Any - -import pandas as pd -import pytest -from sqlalchemy import func, select - -from foresight.database.models.hotel import DailyPerformance, Hotel, RoomType -from foresight.repository import Outcome -from foresight.services.parsers.hotel_data_parser import ( - ROOM_TYPE, - HotelDataParser, - persist, -) - -if TYPE_CHECKING: - from sqlalchemy.ext.asyncio import AsyncSession - -CITY = "Testville" -HOTEL = "The Testel" - -_NIGHTS = [ - (date(2026, 6, 1), 40, 31, 5890.0), - (date(2026, 6, 2), 40, 28, 5320.0), -] - - -def _parser(nights: list[tuple[Any, ...]] | None = None) -> HotelDataParser: - parser = HotelDataParser( - input_path="unused.csv", - hotel_name=HOTEL, - city=CITY, - base_rate=Decimal("180.00"), - ) - # transform_data's own output shape, so no file or network is needed. - parser.transformed_data = pd.DataFrame( - [ - { - "hotel_name": HOTEL, - "stay_date": stay_date, - "rooms_available": available, - "rooms_sold": sold, - "room_revenue": revenue, - } - for stay_date, available, sold, revenue in (nights or _NIGHTS) - ] - ) - return parser - - -def test_rows_match_the_daily_performance_columns() -> None: - rows = _parser().rows(room_type_id=7) - assert rows[0] == { - "room_type_id": 7, - "stay_date": date(2026, 6, 1), - "rate": Decimal("190.00"), - "rooms_available": 40, - "rooms_sold": 31, - "room_revenue": Decimal("5890.0"), - } - assert len(rows) == 2 - - -def test_every_night_hangs_off_the_one_room_type() -> None: - # The client reports per hotel per night, the table keys per room type per - # night, and this is where that conversion happens. - rows = _parser().rows(room_type_id=7) - assert {row["room_type_id"] for row in rows} == {7} - assert all("hotel_name" not in row for row in rows) - - -def test_inventory_comes_from_the_busiest_night() -> None: - parser = _parser( - [(date(2026, 6, 1), 38, 30, 100.0), (date(2026, 6, 2), 40, 31, 100.0)] - ) - assert parser.total_rooms() == 40 - - -def test_rows_before_transform_is_an_error() -> None: - parser = HotelDataParser( - input_path="unused.csv", - hotel_name=HOTEL, - city=CITY, - base_rate=Decimal("180.00"), - ) - with pytest.raises(ValueError): - parser.rows(room_type_id=1) - - -async def test_persist_writes_through_the_repository(session: AsyncSession) -> None: - counts = await persist(_parser(), session) - assert counts[Outcome.NEW] == 2 - - hotel_id = await session.scalar(select(Hotel.id).where(Hotel.city == CITY)) - assert hotel_id is not None - room_type = ( - await session.execute(select(RoomType).where(RoomType.hotel_id == hotel_id)) - ).scalar_one() - assert room_type.name == ROOM_TYPE - assert room_type.room_count == 40 - assert room_type.base_rate == Decimal("180.00") - assert ( - await session.scalar( - select(func.count()) - .select_from(DailyPerformance) - .where(DailyPerformance.room_type_id == room_type.id) - ) - == 2 - ) - - -async def test_reloading_the_same_file_adds_no_rows(session: AsyncSession) -> None: - await persist(_parser(), session) - counts = await persist(_parser(), session) - assert counts == {Outcome.NEW: 0, Outcome.UPDATED: 0, Outcome.UNCHANGED: 2} - - -async def test_a_revised_night_updates_in_place(session: AsyncSession) -> None: - await persist(_parser(), session) - revised = [(date(2026, 6, 1), 40, 35, 6600.0), _NIGHTS[1]] - counts = await persist(_parser(revised), session) - assert counts[Outcome.UPDATED] == 1 - assert counts[Outcome.UNCHANGED] == 1 - - -async def test_an_unsold_night_still_persists(session: AsyncSession) -> None: - counts = await persist(_parser([(date(2026, 6, 1), 40, 0, 0.0)]), session) - assert counts[Outcome.NEW] == 1 - - -def test_an_unsold_night_has_no_rate() -> None: - rows = _parser([(date(2026, 6, 1), 40, 0, 0.0)]).rows(room_type_id=7) - assert rows[0]["rate"] is None diff --git a/backend/tests/test_hotel_repository.py b/backend/tests/test_hotel_repository.py deleted file mode 100644 index a630ec5..0000000 --- a/backend/tests/test_hotel_repository.py +++ /dev/null @@ -1,186 +0,0 @@ -from __future__ import annotations - -from datetime import date -from decimal import Decimal -from typing import TYPE_CHECKING, Any - -from sqlalchemy import func, select - -from foresight.database.models.hotel import DailyPerformance, RoomType -from foresight.repository import Outcome -from foresight.repository.hotel import ( - get_hotel, - stay_date_range, - upsert_daily_performance, - upsert_hotel, - upsert_room_type, -) - -if TYPE_CHECKING: - from sqlalchemy.ext.asyncio import AsyncSession - -CITY = "Testville" - - -async def _hotel(session: AsyncSession, **overrides: Any) -> int: - return await upsert_hotel( - session, **{"name": "The Testel", "city": CITY, "total_rooms": 40} | overrides - ) - - -async def _room_type(session: AsyncSession, hotel_id: int, **overrides: Any) -> int: - return await upsert_room_type( - session, - **{ - "hotel_id": hotel_id, - "name": "Deluxe King", - "room_count": 10, - "base_rate": Decimal("180.00"), - } - | overrides, - ) - - -def _night(room_type_id: int, **overrides: Any) -> dict[str, Any]: - return { - "room_type_id": room_type_id, - "stay_date": date(2026, 6, 1), - "rate": Decimal("199.00"), - "rooms_available": 10, - "rooms_sold": 7, - "room_revenue": Decimal("1393.00"), - } | overrides - - -async def _nights(session: AsyncSession, room_type_id: int) -> int: - return await session.scalar( - select(func.count()) - .select_from(DailyPerformance) - .where(DailyPerformance.room_type_id == room_type_id) - ) - - -async def test_the_same_hotel_resolves_to_one_row(session: AsyncSession) -> None: - assert await _hotel(session) == await _hotel(session) - - -async def test_room_count_updates_in_place(session: AsyncSession) -> None: - hotel_id = await _hotel(session) - await _hotel(session, total_rooms=45) - hotel = await get_hotel(session, hotel_id) - assert hotel is not None - assert hotel.total_rooms == 45 - - -async def test_the_same_name_in_another_city_is_a_different_hotel( - session: AsyncSession, -) -> None: - assert await _hotel(session) != await _hotel(session, city="Otherville") - - -async def test_a_room_type_resolves_to_one_row_per_hotel( - session: AsyncSession, -) -> None: - hotel_id = await _hotel(session) - assert await _room_type(session, hotel_id) == await _room_type(session, hotel_id) - assert await _room_type(session, hotel_id, name="Twin") != await _room_type( - session, hotel_id - ) - - -async def test_first_night_is_new(session: AsyncSession) -> None: - room_type_id = await _room_type(session, await _hotel(session)) - counts = await upsert_daily_performance(session, [_night(room_type_id)]) - assert counts[Outcome.NEW] == 1 - assert await _nights(session, room_type_id) == 1 - - -async def test_identical_rerun_adds_no_rows(session: AsyncSession) -> None: - room_type_id = await _room_type(session, await _hotel(session)) - await upsert_daily_performance(session, [_night(room_type_id)]) - counts = await upsert_daily_performance(session, [_night(room_type_id)]) - assert counts == {Outcome.NEW: 0, Outcome.UPDATED: 0, Outcome.UNCHANGED: 1} - assert await _nights(session, room_type_id) == 1 - - -async def test_a_resold_night_updates_in_place(session: AsyncSession) -> None: - room_type_id = await _room_type(session, await _hotel(session)) - await upsert_daily_performance(session, [_night(room_type_id)]) - counts = await upsert_daily_performance( - session, [_night(room_type_id, rooms_sold=9)] - ) - assert counts[Outcome.UPDATED] == 1 - assert await _nights(session, room_type_id) == 1 - - -async def test_an_unpriced_night_is_still_a_change(session: AsyncSession) -> None: - # rate is nullable, so IS DISTINCT FROM has to see the drop to NULL. - room_type_id = await _room_type(session, await _hotel(session)) - await upsert_daily_performance(session, [_night(room_type_id)]) - counts = await upsert_daily_performance(session, [_night(room_type_id, rate=None)]) - assert counts[Outcome.UPDATED] == 1 - - -async def test_each_night_is_its_own_row(session: AsyncSession) -> None: - room_type_id = await _room_type(session, await _hotel(session)) - await upsert_daily_performance( - session, - [_night(room_type_id), _night(room_type_id, stay_date=date(2026, 6, 2))], - ) - assert await _nights(session, room_type_id) == 2 - - -async def test_empty_batch_writes_nothing(session: AsyncSession) -> None: - assert await upsert_daily_performance(session, []) == dict.fromkeys(Outcome, 0) - - -async def test_a_corrected_base_rate_wins_on_reload(session: AsyncSession) -> None: - # The pricing model scales off base_rate, so a first-load typo must be - # fixable rather than frozen. - hotel_id = await _hotel(session) - room_type_id = await _room_type(session, hotel_id) - await _room_type(session, hotel_id, base_rate=Decimal("205.00")) - assert await session.scalar( - select(RoomType.base_rate).where(RoomType.id == room_type_id) - ) == Decimal("205.00") - - -async def test_coordinates_are_stored_when_given(session: AsyncSession) -> None: - hotel_id = await _hotel(session, latitude=53.3498, longitude=-6.2603) - hotel = await get_hotel(session, hotel_id) - assert hotel is not None - assert (hotel.latitude, hotel.longitude) == (53.3498, -6.2603) - - -async def test_a_reload_without_coordinates_keeps_them( - session: AsyncSession, -) -> None: - # The nightly loader does not carry lat/lon, so it must not wipe what a - # caller that does know them already stored. - hotel_id = await _hotel(session, latitude=53.3498, longitude=-6.2603) - await _hotel(session, total_rooms=45) - hotel = await get_hotel(session, hotel_id) - assert hotel is not None - assert (hotel.latitude, hotel.longitude) == (53.3498, -6.2603) - assert hotel.total_rooms == 45 - - -async def test_stay_date_range_spans_every_room_type(session: AsyncSession) -> None: - hotel_id = await _hotel(session) - king = await _room_type(session, hotel_id) - twin = await _room_type(session, hotel_id, name="Twin") - await upsert_daily_performance( - session, - [ - _night(king, stay_date=date(2026, 6, 3)), - _night(twin, stay_date=date(2026, 5, 30)), - ], - ) - assert await stay_date_range(session, hotel_id) == ( - date(2026, 5, 30), - date(2026, 6, 3), - ) - - -async def test_stay_date_range_is_none_without_nights(session: AsyncSession) -> None: - assert await stay_date_range(session, await _hotel(session)) is None diff --git a/backend/tests/test_ingest_run.py b/backend/tests/test_ingest_run.py deleted file mode 100644 index c36be69..0000000 --- a/backend/tests/test_ingest_run.py +++ /dev/null @@ -1,81 +0,0 @@ -"""CLI argument parsing and the report shown at the end of a run.""" - -from __future__ import annotations - -import argparse -from datetime import UTC, date, datetime - -import pytest - -from foresight.ingest.normalize import NormalizedEvent, Source -from foresight.ingest.predicthq import Rejected -from foresight.ingest.run import Market, RunReport, parse_since -from foresight.repository import Outcome - -NOW = datetime(2026, 10, 6, 12, 0, tzinfo=UTC) - - -@pytest.mark.parametrize( - ("value", "expected"), - [ - ("7d", datetime(2026, 9, 29, 12, 0, tzinfo=UTC)), - ("12h", datetime(2026, 10, 6, 0, 0, tzinfo=UTC)), - ("2w", datetime(2026, 9, 22, 12, 0, tzinfo=UTC)), - ("2026-10-01", datetime(2026, 10, 1, tzinfo=UTC)), - ], -) -def test_parse_since(value: str, expected: datetime) -> None: - assert parse_since(value, now=NOW) == expected - - -def test_parse_since_rejects_garbage() -> None: - with pytest.raises(argparse.ArgumentTypeError): - parse_since("last tuesday", now=NOW) - - -def test_report_lists_every_count_and_rejection() -> None: - report = RunReport( - source=Source.PREDICTHQ, - market=Market(city="Dublin", latitude=53.3498, longitude=-6.2603), - radius="10km", - since=NOW, - fetched=6, - outcomes={Outcome.NEW: 1, Outcome.UPDATED: 2, Outcome.UNCHANGED: 2}, - rejected=[Rejected("phq-broken", "end precedes start")], - events_by_status={"active": 4, "deleted": 1}, - observations_by_source={"predicthq": 5}, - ) - - assert report.summary() == "fetched=6 new=1 updated=2 unchanged=2 rejected=1" - rendered = report.render() - for line in ("fetched 6", "updated 2", "rejected 1"): - assert line in rendered - assert "dublin now holds 5 events (4 active, 1 deleted)" in rendered - assert "phq-broken" in rendered - - -def test_a_dry_run_reports_what_it_fetched_and_wrote_nothing() -> None: - event = NormalizedEvent( - source=Source.PREDICTHQ, - source_ref="phq-1", - title="Forbidden Fruit", - city="Dublin", - start_local_date=date(2027, 6, 5), - end_local_date=date(2027, 6, 7), - ) - report = RunReport( - source=Source.PREDICTHQ, - market=Market(city="Dublin", latitude=53.3498, longitude=-6.2603), - radius="10km", - since=NOW, - fetched=1, - events=[event], - dry_run=True, - ) - - assert report.summary() == "fetched=1 rejected=0 (dry run, nothing written)" - rendered = report.render() - assert "nothing written (dry run)" in rendered - assert "Forbidden Fruit" in rendered - # The stored-totals lines would be zeroes and read as data loss. - assert "now holds" not in rendered diff --git a/backend/tests/test_ml_models.py b/backend/tests/test_ml_models.py deleted file mode 100644 index d604fcc..0000000 --- a/backend/tests/test_ml_models.py +++ /dev/null @@ -1,118 +0,0 @@ -"""Each model predicting room rate on held-out, later nights.""" - -import numpy as np -import pandas as pd -import pytest - -from foresight.services.ml.base import Model -from foresight.services.ml.bayesian import BayesianModel -from foresight.services.ml.neural_net import NNModel -from foresight.services.ml.regression import GBMModel -from foresight.services.ml.timeseries import TimeSeriesModel - -MODELS = [BayesianModel, NNModel, GBMModel, TimeSeriesModel] - - -@pytest.fixture(scope="module", params=MODELS, ids=lambda m: m.name) -def fitted(request, price_split): - """Each model, fit on the price training split.""" - x_train, _, y_train, _ = price_split - return request.param().fit(x_train, y_train) - - -def test_predicts_one_price_per_row(fitted, price_split): - """predict returns one positive price per test row.""" - _, x_test, _, _ = price_split - pred = fitted.predict(x_test) - assert pred.shape == (len(x_test),) - assert (pred > 0).all() - - -@pytest.mark.parametrize("model_cls", MODELS, ids=lambda m: m.name) -def test_same_seed_gives_same_predictions(model_cls, price_split): - """Two fits with the same seed give identical predictions.""" - x_train, x_test, y_train, _ = price_split - first = model_cls(seed=3).fit(x_train, y_train).predict(x_test) - second = model_cls(seed=3).fit(x_train, y_train).predict(x_test) - np.testing.assert_array_equal(first, second) - - -@pytest.mark.parametrize("model_cls", MODELS, ids=lambda m: m.name) -def test_predict_before_fit_raises(model_cls, price_split): - """predict raises RuntimeError on an unfit model.""" - _, x_test, _, _ = price_split - with pytest.raises(RuntimeError): - model_cls().predict(x_test) - - -def test_bayesian_interval(price_split): - """Bayesian intervals run, match predict, and contain the prediction.""" - x_train, x_test, y_train, y_test = price_split - model = BayesianModel().fit(x_train, y_train) - - pred, lower, upper = model.predict_interval(x_test) - np.testing.assert_array_equal(pred, model.predict(x_test)) - assert (lower < pred).all() and (pred < upper).all() - assert 0.0 <= model.evaluate(x_test, y_test)["coverage"] <= 1.0 - - -def test_nn_has_no_interval(price_split): - """NN raises NotImplementedError for intervals; coverage is None.""" - x_train, x_test, y_train, y_test = price_split - model = NNModel().fit(x_train, y_train) - - with pytest.raises(NotImplementedError): - model.predict_interval(x_test) - assert model.evaluate(x_test, y_test)["coverage"] is None - - -class FixedModel(Model): - """Returns preset predictions and a +/-5 band, recording the alpha asked for.""" - - name = "fixed" - - def __init__(self, pred): - super().__init__() - self.pred = np.asarray(pred, dtype=float) - self.alpha_seen = "not called" - - def fit(self, x, y): - """Does nothing; predictions are preset.""" - return self - - def predict(self, x): - """Returns the preset predictions.""" - return self.pred - - def predict_interval(self, x, alpha="default"): - """Returns the preset predictions with a +/-5 band; records alpha.""" - self.alpha_seen = alpha - return self.pred, self.pred - 5, self.pred + 5 - - -def test_evaluate_metrics(): - """evaluate computes each metric correctly on known values.""" - # Errors are -10, 10, -10, 0; the 0 true value is left out of MAPE. - y = pd.Series([100.0, 200.0, 0.0, 400.0]) - model = FixedModel([110.0, 190.0, 10.0, 400.0]) - - scores = model.evaluate(pd.DataFrame(index=y.index), y) - - assert scores["mae"] == pytest.approx(7.5) - assert scores["rmse"] == pytest.approx(np.sqrt(75)) - assert scores["mape"] == pytest.approx((0.1 + 0.05 + 0.0) / 3) - assert scores["r2"] == pytest.approx(1 - 300 / 87500) - assert scores["coverage"] == pytest.approx(0.25) # only the last row is within +/-5 - - -def test_evaluate_passes_alpha_only_when_given(): - """evaluate passes alpha to predict_interval only when one is given.""" - y = pd.Series([1.0, 2.0]) - x = pd.DataFrame(index=y.index) - model = FixedModel([1.0, 2.0]) - - model.evaluate(x, y) - assert model.alpha_seen == "default" # model keeps its own alpha - - model.evaluate(x, y, alpha=0.2) - assert model.alpha_seen == 0.2 diff --git a/backend/tests/test_normalize.py b/backend/tests/test_normalize.py deleted file mode 100644 index 85b07af..0000000 --- a/backend/tests/test_normalize.py +++ /dev/null @@ -1,140 +0,0 @@ -"""Normalization contract: identity, idempotency, and the known source gotchas.""" - -from __future__ import annotations - -from datetime import UTC, date, datetime -from zoneinfo import ZoneInfo - -import pytest - -from foresight.ingest.normalize import ( - DatePrecision, - EventCategory, - NormalizedEvent, - Source, - coords_from_geojson, - dedupe_key, - normalize_title, - slugify_city, - to_local_date, -) - - -def test_dedupe_key_is_stable_across_runs() -> None: - args = { - "title": "Electric Picnic", - "city": "Stradbally", - "start_local_date": date(2026, 8, 28), - } - - assert dedupe_key(**args) == dedupe_key(**args) - - -@pytest.mark.parametrize( - ("left", "right"), - [ - ("Fontaines D.C.", "Fontaines DC"), - ("The National", "National"), - ("Hozier - Presented by Aiken Promotions", "Hozier"), - ("Kneecap feat. special guests", "Kneecap"), - ("Dua Lipa (Rescheduled)", "Dua Lipa"), - ("Sigur Rós", "Sigur Ros"), - ], -) -def test_titles_that_mean_the_same_event_collapse(left: str, right: str) -> None: - assert normalize_title(left) == normalize_title(right) - - -def test_distinct_titles_do_not_collapse() -> None: - assert normalize_title("Longitude Festival") != normalize_title("Latitude Festival") - - -def test_city_slug_drops_trailing_region() -> None: - assert slugify_city("Dublin, Ireland") == slugify_city("Dublin") == "dublin" - - -def test_geojson_coords_are_lon_lat_ordered() -> None: - # PredictHQ's Dublin: [-6.26, 53.35]. Latitude must come back as 53.35. - assert coords_from_geojson([-6.26, 53.35]) == (53.35, -6.26) - - -def test_geojson_coords_tolerate_missing_location() -> None: - assert coords_from_geojson(None) == (None, None) - - -def test_local_date_uses_event_timezone_not_utc() -> None: - # 20:00 on 14 March in Los Angeles is already 15 March in UTC. - moment = datetime(2026, 3, 15, 3, 0, tzinfo=UTC) - - assert to_local_date(moment, "America/Los_Angeles") == date(2026, 3, 14) - assert to_local_date(moment, None) == date(2026, 3, 15) - - -def test_unknown_timezone_falls_back_to_utc() -> None: - moment = datetime(2026, 3, 15, 3, 0, tzinfo=UTC) - - assert to_local_date(moment, "Mars/Olympus_Mons") == date(2026, 3, 15) - - -def _event(**overrides: object) -> NormalizedEvent: - base: dict[str, object] = { - "source": Source.PREDICTHQ, - "source_ref": "z13B8ehGzCCZjPM3Ff", - "title": "Electric Picnic 2026", - "city": "Stradbally", - "start_local_date": date(2026, 8, 28), - "end_local_date": date(2026, 8, 30), - } - return NormalizedEvent(**(base | overrides)) # type: ignore[arg-type] - - -def test_two_sources_seeing_one_event_share_a_dedupe_key() -> None: - from_phq = _event(title="Electric Picnic 2026", category=EventCategory.FESTIVALS) - from_news = _event( - source=Source.ASKNEWS, - source_ref="https://example.test/electric-picnic", - title="Electric Picnic 2026 (Festival)", - city="Stradbally, Ireland", - category=EventCategory.CONCERTS, - confidence=0.6, - ) - - assert from_phq.dedupe_key == from_news.dedupe_key - - -def test_as_row_carries_derived_identity_columns() -> None: - row = _event().as_row() - - assert row["dedupe_key"] == _event().dedupe_key - assert row["city_slug"] == "stradbally" - assert row["primary_source"] is Source.PREDICTHQ - assert row["date_precision"] is DatePrecision.DAY - - -@pytest.mark.parametrize( - ("overrides", "message"), - [ - ({"end_local_date": date(2026, 8, 27)}, "precedes start"), - ({"confidence": 1.4}, "outside 0..1"), - ({"latitude": 53.35}, "set together"), - # Singapore with lon/lat transposed: 103.5 is not a latitude. - ({"latitude": 103.85, "longitude": 1.29}, "swapped"), - ({"title": "(Live)"}, "normalizes to empty"), - ({"source_ref": ""}, "source_ref is required"), - ], -) -def test_malformed_records_are_rejected( - overrides: dict[str, object], message: str -) -> None: - with pytest.raises(ValueError, match=message): - _event(**overrides) - - -def test_swapped_coordinates_are_caught_only_when_out_of_range() -> None: - # A real swap inside both valid ranges cannot be detected here; the - # coords_from_geojson helper is the defence, not validation. - assert _event(latitude=53.35, longitude=-6.26).latitude == 53.35 - - -def test_zoneinfo_dependency_is_available() -> None: - assert ZoneInfo("Europe/Dublin").key == "Europe/Dublin" diff --git a/backend/tests/test_predicthq.py b/backend/tests/test_predicthq.py deleted file mode 100644 index 80ab283..0000000 --- a/backend/tests/test_predicthq.py +++ /dev/null @@ -1,142 +0,0 @@ -"""PredictHQ adapter against a mocked API: request shape, paging, field mapping.""" - -from __future__ import annotations - -from datetime import UTC, date, datetime -from typing import Any - -import httpx - -from foresight.ingest.normalize import EventCategory, EventStatus, Source -from foresight.ingest.predicthq import API_URL, fetch_events, to_normalized - - -def _raw(**overrides: Any) -> dict[str, Any]: - base: dict[str, Any] = { - "id": "z13B8ehGzCCZjPM3Ff", - "title": "Fontaines D.C.", - "category": "concerts", - "state": "active", - # 20:00 in Dublin, which is already 19:00Z: the local and UTC dates agree. - "start": "2026-11-14T19:00:00Z", - "end": "2026-11-14T22:30:00Z", - "timezone": "Europe/Dublin", - "location": [-6.2286, 53.3478], - "country": "IE", - "rank": 68, - "local_rank": 81, - "phq_attendance": 13000, - "entities": [ - {"type": "event-group", "name": "Fontaines D.C. Tour"}, - {"type": "venue", "name": "3Arena"}, - ], - "updated": "2026-10-05T08:12:44Z", - } - return base | overrides - - -def test_maps_geojson_ranks_and_venue() -> None: - event = to_normalized(_raw(), city="Dublin") - - assert event.source is Source.PREDICTHQ - assert (event.latitude, event.longitude) == (53.3478, -6.2286) - assert (event.impact_rank, event.local_rank) == (68, 81) - assert event.venue == "3Arena" - assert event.category is EventCategory.CONCERTS - assert event.expected_attendance == 13000 - assert event.confidence == 1.0 - assert event.source_updated_at == datetime(2026, 10, 5, 8, 12, 44, tzinfo=UTC) - assert event.payload["id"] == "z13B8ehGzCCZjPM3Ff" - - -def test_late_evening_event_keeps_its_local_night() -> None: - # 21:30 in Los Angeles on 14 Nov is 05:30Z on 15 Nov. - event = to_normalized( - _raw( - start="2026-11-15T05:30:00Z", - end="2026-11-15T07:00:00Z", - timezone="America/Los_Angeles", - location=[-118.2673, 34.0430], - ), - city="Los Angeles", - ) - - assert event.start_local_date == date(2026, 11, 14) - assert event.end_local_date == date(2026, 11, 14) - - -def test_deleted_state_becomes_deleted_status() -> None: - event = to_normalized( - _raw(state="deleted", deleted_reason="cancelled"), city="Dublin" - ) - assert event.status is EventStatus.DELETED - - -def test_unknown_category_falls_back() -> None: - event = to_normalized(_raw(category="something-new"), city="Dublin") - assert event.category is EventCategory.UNKNOWN - - -async def test_fetch_filters_by_radius_and_revision_clock_and_follows_next() -> None: - requests: list[httpx.Request] = [] - page_two = f"{API_URL}?offset=1&limit=500" - - def handler(request: httpx.Request) -> httpx.Response: - requests.append(request) - if request.url.params.get("offset") == "1": - return httpx.Response( - 200, - json={ - "count": 3, - "next": None, - "results": [ - _raw(id="b", state="deleted"), - _raw(id="bad", title="(Live)"), - ], - }, - ) - return httpx.Response( - 200, json={"count": 3, "next": page_two, "results": [_raw(id="a")]} - ) - - result = await fetch_events( - token="test-token", - latitude=53.3498, - longitude=-6.2603, - city="Dublin", - updated_since=datetime(2026, 9, 29, 14, 0, tzinfo=UTC), - transport=httpx.MockTransport(handler), - ) - - first = requests[0] - assert first.headers["Authorization"] == "Bearer test-token" - assert first.url.params["within"] == "10km@53.3498,-6.2603" - assert first.url.params["updated.gte"] == "2026-09-29T14:00:00" - assert first.url.params["limit"] == "500" - assert "deleted" in first.url.params["state"].split(",") - assert len(requests) == 2 - - assert result.fetched == 3 - assert [e.source_ref for e in result.events] == ["a", "b"] - assert result.events[1].status is EventStatus.DELETED - assert [r.source_ref for r in result.rejected] == ["bad"] - assert "normalizes to empty" in result.rejected[0].reason - - -async def test_record_missing_required_field_is_rejected_not_raised() -> None: - def handler(request: httpx.Request) -> httpx.Response: - broken = _raw(id="nostart") - del broken["start"] - return httpx.Response(200, json={"count": 1, "next": None, "results": [broken]}) - - result = await fetch_events( - token="t", - latitude=0.0, - longitude=0.0, - city="Dublin", - updated_since=datetime(2026, 9, 29, tzinfo=UTC), - transport=httpx.MockTransport(handler), - ) - - assert result.events == [] - assert result.rejected[0].reason == "missing field 'start'" diff --git a/backend/tests/test_textdates.py b/backend/tests/test_textdates.py deleted file mode 100644 index f216ead..0000000 --- a/backend/tests/test_textdates.py +++ /dev/null @@ -1,133 +0,0 @@ -"""Date resolution: the formats announcements actually use, and what we refuse.""" - -from __future__ import annotations - -from datetime import date - -import pytest - -from foresight.ingest.normalize import DatePrecision -from foresight.ingest.textdates import looks_retrospective, resolve_date - -REFERENCE = date(2026, 6, 1) - - -@pytest.mark.parametrize( - ("text", "start", "end"), - [ - ("runs on August 28, 2026", date(2026, 8, 28), date(2026, 8, 28)), - ("runs 28 August 2026", date(2026, 8, 28), date(2026, 8, 28)), - ("August 28-30, 2026", date(2026, 8, 28), date(2026, 8, 30)), - ("28-30 August 2026", date(2026, 8, 28), date(2026, 8, 30)), - ("Aug. 28th to 30th, 2026", date(2026, 8, 28), date(2026, 8, 30)), - ("on 2026-08-28", date(2026, 8, 28), date(2026, 8, 28)), - ("2026-08-28 to 2026-08-30", date(2026, 8, 28), date(2026, 8, 30)), - # Ranges that roll into the next month, and the next year. - ("August 30 - September 2, 2026", date(2026, 8, 30), date(2026, 9, 2)), - ("December 30 - January 2, 2026", date(2026, 12, 30), date(2027, 1, 2)), - # A year stated on both sides of the dash. - ( - "December 30, 2026 - January 1, 2027", - date(2026, 12, 30), - date(2027, 1, 1), - ), - # A range written as prose rather than punctuation. - ( - "opens December 1, 2026 and runs through January 4, 2027", - date(2026, 12, 1), - date(2027, 1, 4), - ), - ("runs July 1 through July 9, 2027", date(2027, 7, 1), date(2027, 7, 9)), - ], -) -def test_day_precision_formats(text: str, start: date, end: date) -> None: - resolved = resolve_date(text, REFERENCE) - - assert resolved is not None - assert (resolved.start, resolved.end) == (start, end) - assert resolved.precision is DatePrecision.DAY - - -def test_missing_year_is_inferred_forward() -> None: - resolved = resolve_date("the festival returns August 28", REFERENCE) - - assert resolved is not None - assert resolved.start == date(2026, 8, 28) - assert not resolved.had_explicit_year - - -def test_missing_year_rolls_over_when_the_month_has_passed() -> None: - """In June, an undated "March 14" means next March.""" - resolved = resolve_date("tickets for March 14", REFERENCE) - - assert resolved is not None - assert resolved.start == date(2027, 3, 14) - - -def test_month_year_keeps_month_precision() -> None: - resolved = resolve_date("sometime in August 2026", REFERENCE) - - assert resolved is not None - assert resolved.precision is DatePrecision.MONTH - assert (resolved.start, resolved.end) == (date(2026, 8, 1), date(2026, 8, 31)) - - -@pytest.mark.parametrize( - ("text", "start", "end"), - [ - ("summer 2026", date(2026, 6, 1), date(2026, 8, 31)), - ("autumn 2026", date(2026, 9, 1), date(2026, 11, 30)), - ("winter 2026", date(2026, 12, 1), date(2027, 2, 28)), - ("Q3 2026", date(2026, 7, 1), date(2026, 9, 30)), - ], -) -def test_coarse_spans_keep_quarter_precision(text: str, start: date, end: date) -> None: - resolved = resolve_date(text, REFERENCE) - - assert resolved is not None - assert resolved.precision is DatePrecision.QUARTER - assert (resolved.start, resolved.end) == (start, end) - - -def test_finest_precision_wins_when_a_text_carries_several() -> None: - text = "announced for summer 2026, with the main stage on August 28, 2026" - - resolved = resolve_date(text, REFERENCE) - - assert resolved is not None - assert resolved.precision is DatePrecision.DAY - assert resolved.start == date(2026, 8, 28) - - -def test_impossible_calendar_date_is_skipped_for_the_next_candidate() -> None: - resolved = resolve_date("February 30, 2026 -- sorry, July 3, 2026", REFERENCE) - - assert resolved is not None - assert resolved.start == date(2026, 7, 3) - - -@pytest.mark.parametrize( - "text", - [ - "happening this weekend", - "next Friday at the venue", - "coming soon to the city", - "no date has been announced", - ], -) -def test_relative_and_absent_dates_are_refused(text: str) -> None: - assert resolve_date(text, REFERENCE) is None - - -def test_date_already_past_relative_to_the_reference_is_refused() -> None: - assert resolve_date("was on January 5, 2020", REFERENCE) is None - - -def test_retrospective_coverage_is_flagged() -> None: - assert looks_retrospective("the festival took place over the weekend") - - -def test_a_forward_looking_marker_rescues_a_retrospective_recap() -> None: - text = "last year's festival drew crowds; this year's runs August 28-30, 2026" - - assert not looks_retrospective(text) diff --git a/backend/tests/test_weather_coverage.py b/backend/tests/test_weather_coverage.py deleted file mode 100644 index 2ef17c8..0000000 --- a/backend/tests/test_weather_coverage.py +++ /dev/null @@ -1,56 +0,0 @@ -from __future__ import annotations - -from datetime import date -from typing import TYPE_CHECKING - -from foresight.repository import weather as weather_repo -from foresight.services.backfill.weather_coverage import weather_coverage - -if TYPE_CHECKING: - import pytest - - -def _saved(monkeypatch: pytest.MonkeyPatch, days: dict[date, bool]) -> None: - async def fake_recorded_days(session, *, city, start, end): - return days - - monkeypatch.setattr(weather_repo, "recorded_days", fake_recorded_days) - - -async def test_counts_rows_and_groups_gaps(monkeypatch: pytest.MonkeyPatch) -> None: - _saved( - monkeypatch, - { - date(2024, 3, 1): False, - date(2024, 3, 2): True, - date(2024, 3, 5): True, - date(2024, 3, 6): False, - }, - ) - - report = await weather_coverage( - None, city="Dublin", start=date(2024, 3, 1), end=date(2024, 3, 7) - ) - - assert (report.days, report.rows) == (7, 4) - assert report.earliest == date(2024, 3, 2) - assert report.missing == [ - (date(2024, 3, 3), date(2024, 3, 4)), - (date(2024, 3, 7), date(2024, 3, 7)), - ] - assert report.empty == [ - (date(2024, 3, 1), date(2024, 3, 1)), - (date(2024, 3, 6), date(2024, 3, 6)), - ] - - -async def test_nothing_saved_is_one_big_gap(monkeypatch: pytest.MonkeyPatch) -> None: - _saved(monkeypatch, {}) - - report = await weather_coverage( - None, city="Dublin", start=date(2024, 3, 1), end=date(2024, 3, 31) - ) - - assert report.rows == 0 - assert report.earliest is None - assert report.missing == [(date(2024, 3, 1), date(2024, 3, 31))] diff --git a/backend/tests/test_weather_ingest.py b/backend/tests/test_weather_ingest.py deleted file mode 100644 index 1febaf7..0000000 --- a/backend/tests/test_weather_ingest.py +++ /dev/null @@ -1,127 +0,0 @@ -from __future__ import annotations - -from datetime import date -from types import SimpleNamespace - -import numpy as np -import pandas as pd -import pytest - -from foresight.ingest.weather import _rows, chunks - -_DAY = 86400 -# temperature_2m_max, temperature_2m_min, precipitation_sum, wind_speed_10m_max, -# weather_code -- the order `_DAILY` requests them in. -_ONE_DAY = ([18.4], [9.1], [0.0], [14.0], [3.0]) - - -def _response( - columns: tuple[list[float], ...], - *, - offset_hours: int = 1, - first_day: str = "2026-06-01", -) -> SimpleNamespace: - # Open-Meteo reports local midnight as a UTC instant, so the fixture has to - # subtract the offset the same way the real response does. - start = int(pd.Timestamp(first_day, tz="UTC").timestamp()) - offset_hours * 3600 - days = len(columns[0]) - daily = SimpleNamespace( - Time=lambda: start, - TimeEnd=lambda: start + days * _DAY, - Interval=lambda: _DAY, - Variables=lambda i: SimpleNamespace( - ValuesAsNumpy=lambda: np.array(columns[i], dtype=float) - ), - ) - return SimpleNamespace( - Daily=lambda: daily, UtcOffsetSeconds=lambda: offset_hours * 3600 - ) - - -@pytest.mark.parametrize("offset_hours", [-11, -5, 0, 1, 13]) -def test_local_calendar_day_survives_the_utc_offset(offset_hours: int) -> None: - # The day the hotel sees, not the UTC instant it started: weather_date - # joins to stay_date, so a one-day shift mis-attributes every night. - rows = _rows( - _response(_ONE_DAY, offset_hours=offset_hours), "Dublin", is_forecast=False - ) - assert [r["weather_date"] for r in rows] == [date(2026, 6, 1)] - - -def test_a_range_comes_back_one_row_per_day() -> None: - three_days = ( - [18.4, 19.0, 17.2], - [9.1, 10.0, 8.8], - [0.0, 1.2, 4.0], - [14.0, 12.0, 20.0], - [3.0, 61.0, 95.0], - ) - rows = _rows(_response(three_days), "Dublin", is_forecast=False) - assert [r["weather_date"] for r in rows] == [ - date(2026, 6, 1), - date(2026, 6, 2), - date(2026, 6, 3), - ] - assert [r["condition"] for r in rows] == ["cloudy", "rain", "thunderstorm"] - - -@pytest.mark.parametrize( - ("code", "condition"), - [ - (0, "clear"), - (2, "partly cloudy"), - (3, "cloudy"), - (45, "fog"), - (48, "fog"), - (57, "drizzle"), - (67, "rain"), - (77, "snow"), - (82, "rain showers"), - (86, "snow showers"), - (95, "thunderstorm"), - ], -) -def test_wmo_code_becomes_a_condition(code: int, condition: str) -> None: - columns = (*_ONE_DAY[:4], [float(code)]) - (row,) = _rows(_response(columns), "Dublin", is_forecast=True) - assert row["condition"] == condition - - -def test_missing_measures_become_null() -> None: - # Open-Meteo pads days it has no data for with NaN, which Postgres rejects. - nans = tuple([float("nan")] for _ in range(5)) - (row,) = _rows(_response(nans), "Dublin", is_forecast=True) - assert row["temp_max_c"] is None - assert row["temp_min_c"] is None - assert row["precip_mm"] is None - assert row["wind_kph"] is None - assert row["condition"] is None - - -def test_row_matches_the_table_columns() -> None: - (row,) = _rows(_response(_ONE_DAY), "Dublin", is_forecast=True) - assert row == { - "city": "Dublin", - "weather_date": date(2026, 6, 1), - "temp_max_c": 18.4, - "temp_min_c": 9.1, - "precip_mm": 0.0, - "wind_kph": 14.0, - "condition": "cloudy", - "is_forecast": True, - "source": "open-meteo", - } - - -def test_long_ranges_are_split_a_year_at_a_time() -> None: - spans = list(chunks(date(2024, 1, 1), date(2025, 6, 30))) - assert spans == [ - (date(2024, 1, 1), date(2024, 12, 30)), - (date(2024, 12, 31), date(2025, 6, 30)), - ] - - -def test_a_short_range_is_one_request() -> None: - assert list(chunks(date(2025, 6, 1), date(2025, 6, 1))) == [ - (date(2025, 6, 1), date(2025, 6, 1)) - ] diff --git a/backend/tests/test_weather_repository.py b/backend/tests/test_weather_repository.py deleted file mode 100644 index 4effec3..0000000 --- a/backend/tests/test_weather_repository.py +++ /dev/null @@ -1,102 +0,0 @@ -from __future__ import annotations - -from datetime import date -from typing import TYPE_CHECKING, Any - -from sqlalchemy import func, select - -from foresight.database.models.external import Weather -from foresight.repository import Outcome -from foresight.repository.weather import recorded_days, upsert_weather - -if TYPE_CHECKING: - from sqlalchemy.ext.asyncio import AsyncSession - -CITY = "Testville" - - -def _row(**overrides: Any) -> dict[str, Any]: - return { - "city": CITY, - "weather_date": date(2026, 6, 1), - "is_forecast": False, - "source": "open-meteo", - "temp_max_c": 18.0, - "temp_min_c": 9.0, - "precip_mm": 0.0, - "wind_kph": 12.0, - "condition": "cloudy", - } | overrides - - -async def _rows(session: AsyncSession) -> int: - return await session.scalar( - select(func.count()).select_from(Weather).where(Weather.city == CITY) - ) - - -async def test_first_write_is_new(session: AsyncSession) -> None: - counts = await upsert_weather(session, [_row()]) - assert counts[Outcome.NEW] == 1 - assert await _rows(session) == 1 - - -async def test_identical_rerun_adds_no_rows(session: AsyncSession) -> None: - await upsert_weather(session, [_row()]) - counts = await upsert_weather(session, [_row()]) - assert counts == {Outcome.NEW: 0, Outcome.UPDATED: 0, Outcome.UNCHANGED: 1} - assert await _rows(session) == 1 - - -async def test_revised_measure_updates_in_place(session: AsyncSession) -> None: - await upsert_weather(session, [_row()]) - counts = await upsert_weather(session, [_row(temp_max_c=21.5)]) - assert counts[Outcome.UPDATED] == 1 - assert await _rows(session) == 1 - assert ( - await session.scalar(select(Weather.temp_max_c).where(Weather.city == CITY)) - == 21.5 - ) - - -async def test_forecast_and_actual_are_separate_rows(session: AsyncSession) -> None: - await upsert_weather(session, [_row(), _row(is_forecast=True)]) - assert await _rows(session) == 2 - - -async def test_second_source_is_its_own_row(session: AsyncSession) -> None: - await upsert_weather(session, [_row(), _row(source="other-provider")]) - assert await _rows(session) == 2 - - -async def test_empty_batch_writes_nothing(session: AsyncSession) -> None: - assert await upsert_weather(session, []) == dict.fromkeys(Outcome, 0) - assert await _rows(session) == 0 - - -async def test_an_update_restamps_captured_at(session: AsyncSession) -> None: - # upsert_rows only stamps captured_at on tables that have the column, so - # the tables that do must keep getting it. - await upsert_weather(session, [_row()]) - first = await session.scalar( - select(Weather.captured_at).where(Weather.city == CITY) - ) - await upsert_weather(session, [_row(temp_max_c=21.0)]) - assert ( - await session.scalar(select(Weather.captured_at).where(Weather.city == CITY)) - ) > first - - -async def test_recorded_days_flags_days_without_values(session: AsyncSession) -> None: - await upsert_weather( - session, - [ - _row(weather_date=date(2026, 6, 1)), - _row(weather_date=date(2026, 6, 2), temp_max_c=None), - _row(weather_date=date(2026, 6, 3), is_forecast=True), - ], - ) - days = await recorded_days( - session, city=CITY, start=date(2026, 6, 1), end=date(2026, 6, 30) - ) - assert days == {date(2026, 6, 1): True, date(2026, 6, 2): False} diff --git a/backend/uv.lock b/backend/uv.lock index 1ec2b8c..0375417 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -98,28 +98,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" }, ] -[[package]] -name = "attrs" -version = "26.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, -] - -[[package]] -name = "cattrs" -version = "26.2.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "attrs" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/23/75/e72b839c3dc869c990b4842f3dba730bdcdf5215f68fc7955edf849a1792/cattrs-26.2.1.tar.gz", hash = "sha256:679132bfdc225c5ee40c024fc42519954767c387f950dc6751946c586bccdc6d", size = 525617, upload-time = "2026-09-26T20:53:21.114Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e8/cf/22794a399d99480486120e26e879ef008e21f5e85274c2ed591d568bb326/cattrs-26.2.1-py3-none-any.whl", hash = "sha256:a12aaa3453dc8f633a815293179f08b7421ed18d2575c459c3c736f840beac24", size = 74843, upload-time = "2026-09-26T20:53:19.767Z" }, -] - [[package]] name = "certifi" version = "2026.7.22" @@ -138,137 +116,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, ] -[[package]] -name = "charset-normalizer" -version = "3.5.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/33/1c/f41d4e74c28ab327ff3acd36053f7ea506c55872d7a90b0fa71aa3ab0c89/charset_normalizer-3.5.2.tar.gz", hash = "sha256:39de2a259fc954455c57274dc94c79d5842774e1247a016aff30bc0efed0f4ef", size = 172659, upload-time = "2026-09-30T04:39:23.398Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/c8/693809898870237d82785a03f3b2b58fe4c9f14669f84a7d4e623c92a59e/charset_normalizer-3.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ed2a239c0ea213acc1908150a3037257083c7c083128f1a4cec2ec4b97dca491", size = 367780, upload-time = "2026-09-30T04:35:30.888Z" }, - { url = "https://files.pythonhosted.org/packages/c9/87/2fea8c13dc24b3ca9c6f803a5b2dfdeae73eb4f9e12c7885ed908ff0433c/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b91363207bd9dc966a691e959bb47f64b30f7ac4b072be9968b366982f7db77c", size = 246730, upload-time = "2026-09-30T04:35:32.286Z" }, - { url = "https://files.pythonhosted.org/packages/a8/9e/09efac30b937722f46d3110ba30b875b24b2e3a266ed746cc4e376a94d80/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:38a873987f3be698494da8b2e3085e29da02da7b633dce73e79c699a113d7bf0", size = 237707, upload-time = "2026-09-30T04:35:33.709Z" }, - { url = "https://files.pythonhosted.org/packages/9e/18/70d76670b13686237863a379928d60bd10e021f17d243ab3d7014c4a5f4e/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:355ad8011081dec5412240c087a9a0c9d4d5039f3ed11a3f13e18c2b29b56c51", size = 273050, upload-time = "2026-09-30T04:35:35.138Z" }, - { url = "https://files.pythonhosted.org/packages/54/e2/77a8b09d5adc013ed07b95b01b8b8fa5441c4e810e83ee7e4aae2fa4d91a/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ee21e28f0430bd6dc9086c6e525d5e818a44a5ad19720c8a0ef766792f3eb5e5", size = 270345, upload-time = "2026-09-30T04:35:36.502Z" }, - { url = "https://files.pythonhosted.org/packages/7f/c5/38806a25ab5e65fc178f39affeda20858efafede2fce1ffc2556cfc9fe73/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d31298449090ab8d47b7b1b2a555ff73cac7ed438a08b7ac160980c7ebed649", size = 257601, upload-time = "2026-09-30T04:35:37.919Z" }, - { url = "https://files.pythonhosted.org/packages/ae/8d/213565184708fdb263ae55e2c04ee1ff748129dd65d48ed0e3502da9c85a/charset_normalizer-3.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5cde776b7cc66e4f6c99612cea4aa7269aa65863f7a15841b2c264f103822f4e", size = 252222, upload-time = "2026-09-30T04:35:39.544Z" }, - { url = "https://files.pythonhosted.org/packages/7e/24/76d2cefc25472531e4c5c7dfff68865eb1c39b78482f0fdc15b46f047830/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ae4f5fea5b8b8ccff88238cc8569303e5ee95efae67fa62922a311397a71f346", size = 248482, upload-time = "2026-09-30T04:35:41.088Z" }, - { url = "https://files.pythonhosted.org/packages/7d/dc/65a801b66ab4c197e22c433ab25e7ac24324ac6f45a2269aca42cce309bf/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:f7d486c83842422badd511868fd8a9a20e9407ace71564b6af47ce7e60a336c1", size = 241206, upload-time = "2026-09-30T04:35:42.59Z" }, - { url = "https://files.pythonhosted.org/packages/a7/95/ca9b5eabde673002c6f1e7ada1b223916fe18f6d661da7aabd4d643718f1/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:11a4d68a6ecda3292cb1e50239e111543ba5d709bb62a6b4ea1afcfa729d8875", size = 273190, upload-time = "2026-09-30T04:35:44.347Z" }, - { url = "https://files.pythonhosted.org/packages/2d/8b/803b4d2a3f6e1740f63f1e87b04d14b42f3d4fdfe6ed7d4db2d34102b14f/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d6734d2ef8a50fbf8445c139477da401f50d62a0606bf00e20ec6d87773fefb1", size = 253527, upload-time = "2026-09-30T04:35:45.915Z" }, - { url = "https://files.pythonhosted.org/packages/a9/55/93c0e5dbd085ae0471346026abbe7e0db9ea2d6fea74e51f0b5a46f233a7/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a815775b6c38d4e0ff7bcffbeba67feded90202bb6a226b8dd35f1c855217413", size = 271285, upload-time = "2026-09-30T04:35:47.49Z" }, - { url = "https://files.pythonhosted.org/packages/95/69/0dbd0e0b9b16cfa816cdfcb3e2e3854a1f680dc07fb1245ea125e7448060/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:23851fb4e1b85ed3f6c2a27b777cdfe2e19fb5b38429a8faf38c7542b7665869", size = 260010, upload-time = "2026-09-30T04:35:48.996Z" }, - { url = "https://files.pythonhosted.org/packages/58/9d/e7b88e7b1bf403590c3b573277b5e1e488c68c7a6fbacca310a2c324e90c/charset_normalizer-3.5.2-cp312-cp312-win32.whl", hash = "sha256:db19d07e2e0129e974a0e65d0064fc222a446cd5122c2fd4184d2af9fc734a9e", size = 184125, upload-time = "2026-09-30T04:35:50.777Z" }, - { url = "https://files.pythonhosted.org/packages/eb/e6/e6e083884cbcfd49c64865af05027fe7011be7b2d9179524f099a1b611f3/charset_normalizer-3.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:780fbe7cab297b81dad9fb8dc5eb003c0468ffb0d9e5f65068c53a34661a96bc", size = 207486, upload-time = "2026-09-30T04:35:52.194Z" }, - { url = "https://files.pythonhosted.org/packages/c4/e3/017aea0911ada7405a825c7d937eb3a13009664e2f5b38e8c4bbf2abf894/charset_normalizer-3.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:e2af3aad578aa6bd1384bcf4750fc285e5a9de53f40b7d41e5a0bf748edeb2b3", size = 196734, upload-time = "2026-09-30T04:35:53.636Z" }, - { url = "https://files.pythonhosted.org/packages/c5/34/68292d68512768591aaff07c59bb53ee31341c87759433a859c4641a50c2/charset_normalizer-3.5.2-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:ed905975ab14056a2e5eb1c376cb2e1ebc5396baf84163939c518556fccde9f5", size = 225946, upload-time = "2026-09-30T04:35:55.313Z" }, - { url = "https://files.pythonhosted.org/packages/e3/80/bee0b01b90ccd5322ae1d0abb33fab1bd95b7c2eadaf02aeccf22e04ee83/charset_normalizer-3.5.2-cp313-cp313-android_24_x86_64.whl", hash = "sha256:a66c3bc5ab1f0ff2164fc9965ddd611ff0802173f4b9d24554c563f6ab7e1d6e", size = 238619, upload-time = "2026-09-30T04:35:56.863Z" }, - { url = "https://files.pythonhosted.org/packages/78/6e/60ce52a85a7fd631ae8482ae6d74521014ca2f255892679484dc04d7ef56/charset_normalizer-3.5.2-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2374b62878abb00cd8309b32af6c0b715cd02dec0ca74ef12e5069bdc64144a", size = 206701, upload-time = "2026-09-30T04:35:58.639Z" }, - { url = "https://files.pythonhosted.org/packages/36/8c/71aafad23f971afc84c2b295bc0c560739ce1dac558aad9fec22e39f3639/charset_normalizer-3.5.2-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d376bbd28b3a8999db1a103b3b388aee6f1ddeb3e51bc2172993efdcd86e064d", size = 210506, upload-time = "2026-09-30T04:36:00.147Z" }, - { url = "https://files.pythonhosted.org/packages/91/da/3c5a7798c046df7d2d68ad653cf5b6c5a8bfee225055a843c6f2f42aac1a/charset_normalizer-3.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6045373d5a89a5ec71afde535db987ca28e76dfa276c2d4c818265b375d4b055", size = 366626, upload-time = "2026-09-30T04:36:01.77Z" }, - { url = "https://files.pythonhosted.org/packages/e1/16/710ac3de2ee354e2bd1a9c94efe45a2d27b5c6ad39b2d6a905be2c094b6c/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:849df64e889b2e17230d58410a03dba311a65b163508fd33679b2b737d4b7858", size = 244624, upload-time = "2026-09-30T04:36:03.389Z" }, - { url = "https://files.pythonhosted.org/packages/d6/39/45c7439f5b63d24f7d5b2a1d760f34af7628782d7144b4cc8ded45c2d4bc/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:15c44f7edfd477b06f517a5cc317fc1707edb9de2c865f43d4b6513907473234", size = 235731, upload-time = "2026-09-30T04:36:04.987Z" }, - { url = "https://files.pythonhosted.org/packages/4d/34/38f3154785ce92e9f56eb226f4d35bdfae6b008480dd055f58837a89c810/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a89012d6d5476ee112d20d998570ed58df2260a852afb1758809cd6900411d21", size = 270352, upload-time = "2026-09-30T04:36:06.412Z" }, - { url = "https://files.pythonhosted.org/packages/04/f3/859f74e7babc977705026b30593b3be04049632a522fb7000f83c033d747/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0c951d5e6dd9c2ff60609476752bee49da4206adde960ebc247766937f72e718", size = 267393, upload-time = "2026-09-30T04:36:07.865Z" }, - { url = "https://files.pythonhosted.org/packages/4b/85/41d27f234b82e47c167a5f6c0f62501dc0c640585ff4aba79e08a390336a/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7218e8f32b0956cfcd048fd42d9d5779809745ca1d86113ca56f66e7ae1549c4", size = 254660, upload-time = "2026-09-30T04:36:09.248Z" }, - { url = "https://files.pythonhosted.org/packages/58/ca/5d1a997587febe5b26d8daffe363b5c1a091cece19828eec6502fd09c5ef/charset_normalizer-3.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19a731138fc27d5682277d3b9df22855cea1239bce7fcec5f78f42ef2d1f3c3", size = 249996, upload-time = "2026-09-30T04:36:10.73Z" }, - { url = "https://files.pythonhosted.org/packages/b3/1f/d1e78246f7ed60c8c8d606b4ac27f66ce49cc3e95f24893ccbeba9f77302/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:62603db9a7caa0802eaa28c1c46fecd7b3a263a774069c24c3c28c302448721c", size = 247028, upload-time = "2026-09-30T04:36:12.294Z" }, - { url = "https://files.pythonhosted.org/packages/8e/37/eba316edd4f0c4d3a5d945924c4eeeae59abac4056aa815d8a4268f863a2/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:b6856554c4f44d79fc2307d5768854310a8f0096e501c75637542c82292b0429", size = 240562, upload-time = "2026-09-30T04:36:13.887Z" }, - { url = "https://files.pythonhosted.org/packages/c8/8e/aaa037d40ca9ef045977f1a661048b1aa33f223adfce3452fe9be9f79d14/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1bc0baf5ef96b6ede57d47f4b8fe4d9d84019c3bfcbeb20a41edc6a6ee341f1f", size = 271699, upload-time = "2026-09-30T04:36:15.41Z" }, - { url = "https://files.pythonhosted.org/packages/26/19/1c1c9f75974adf523b87f34b8a2adc5a435cd65916812bcbd0dfa45f9a29/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:56bc200a365efb37383b7852e4cc5898d3b2da5987289b543956cf8cad71018a", size = 252030, upload-time = "2026-09-30T04:36:16.839Z" }, - { url = "https://files.pythonhosted.org/packages/bc/90/0660ef18e18df0a4d2a1a0edff7dfbba42d4e50ef2425557a5bb7051f77b/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2c9ad19a6cfcd5ea5c0d41161d22f9df1dcc277e9bef2751391334546a314c00", size = 268565, upload-time = "2026-09-30T04:36:18.468Z" }, - { url = "https://files.pythonhosted.org/packages/79/ba/57adc269824e8658f1a0f97a9e514c247445a9632b3419b97e0ba37f16dc/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e243bd13217235fc7290c621941c3f5cc8b66e4872495be821d7436ba2fb838d", size = 257627, upload-time = "2026-09-30T04:36:19.938Z" }, - { url = "https://files.pythonhosted.org/packages/9a/85/33abd4315c052d3d4f54c92b1ee49bfbc0dc7115a981e462a793b6d2ab87/charset_normalizer-3.5.2-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:a090bb2c68df85450502e3e20d665e3a5af9c65a84d6508ed477badd49166fd3", size = 143407, upload-time = "2026-09-30T04:36:21.376Z" }, - { url = "https://files.pythonhosted.org/packages/4f/de/6435e18d1aaa5d910b896d551411c96af1f42a0c56c29afc2016c61ccc2e/charset_normalizer-3.5.2-cp313-cp313-win32.whl", hash = "sha256:2b7b3bbfb4fe8ef40600792d762fbaa9057559f9d3fad209525b7a22b99e91fd", size = 183207, upload-time = "2026-09-30T04:36:22.776Z" }, - { url = "https://files.pythonhosted.org/packages/9c/76/b8ec57f4e9ee3253541abf95e4a462c0175fe8032dcd070f1f2421240942/charset_normalizer-3.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:78456a747de8dc58360ffa581f30a002baf5aa28cb262536545e91f113ed7639", size = 206489, upload-time = "2026-09-30T04:36:24.306Z" }, - { url = "https://files.pythonhosted.org/packages/3e/60/c647c6ae47480221e875ea5d743ff94946f7416e3c69415ab772928e8d32/charset_normalizer-3.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:11912e4bb14baae7c5d8791aa55ba0a3a03ec6729073307b0f57270abaa713d3", size = 195501, upload-time = "2026-09-30T04:36:25.846Z" }, - { url = "https://files.pythonhosted.org/packages/58/ca/7aa91362a2f77ac8e9e28a9b902a74f7d0e11a851ef0d27a74308da8cd90/charset_normalizer-3.5.2-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:1afb975bd5d68d5ce9f6b6d44fdf2f7e34b895a35e95708a7a91b20a3b51d187", size = 226010, upload-time = "2026-09-30T04:36:27.669Z" }, - { url = "https://files.pythonhosted.org/packages/a8/cf/ac8878d0322cf88a1aad4c7b147db32ca0bd806eb0060957b2e31486dbe6/charset_normalizer-3.5.2-cp314-cp314-android_24_x86_64.whl", hash = "sha256:bbbfc8e28816f19d7c0f1816664980c0a9875d01b27cdf8eedddb639d9e108ad", size = 239266, upload-time = "2026-09-30T04:36:29.434Z" }, - { url = "https://files.pythonhosted.org/packages/c9/6d/9a08d7e0b29b7208e2c6c01dc56c8e0520e7c7beadbbfb024b58fd69c8a5/charset_normalizer-3.5.2-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7967d08cf06dee78443b874f98c98036f624f3a4e73e11f9f64f5be4d25393cf", size = 206783, upload-time = "2026-09-30T04:36:30.872Z" }, - { url = "https://files.pythonhosted.org/packages/82/44/b0aa350280e6ff5a5492d17cf10460dd39d5ee848f872f7ba2df10607f60/charset_normalizer-3.5.2-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c2b5031f63e331e3839b40aed2dd6f191e9c07edbde303e7876846ea1946995", size = 210449, upload-time = "2026-09-30T04:36:32.625Z" }, - { url = "https://files.pythonhosted.org/packages/7c/8a/40db9aa9f5907bb0e6f8b6d64064bf8852fb33d4b813ff9414911df7647c/charset_normalizer-3.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fcff63213e8e6e47770541a4607175404f47cbb3ebea7b6058cc82d524a0e424", size = 367815, upload-time = "2026-09-30T04:36:34.197Z" }, - { url = "https://files.pythonhosted.org/packages/7f/72/9c5e7707b57c8ddfa9ddf7b0b1d009d7fbab9e9e887d5b721060f37e307d/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8d86d6fc60743dc916eb79e2eb1ec4818e21e427731543af40a3021851174a13", size = 247095, upload-time = "2026-09-30T04:36:35.803Z" }, - { url = "https://files.pythonhosted.org/packages/83/09/71e453691e927de4ddf792770cfaab3f49d494e222f66ea5e404bbd5e39c/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7a881931aa470808df94a8c380eed2bbbc76cd9dc622310f99665658c821eb6d", size = 233942, upload-time = "2026-09-30T04:36:37.407Z" }, - { url = "https://files.pythonhosted.org/packages/9f/86/85c84e4da8b27dd409577d9437926ff581c5f9d3c66038dc68c1a526de51/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8024d00c3faf3fc0c16e07a69f4405e8eac7cc0ab15f65fe6cf43827c4cf72b4", size = 273762, upload-time = "2026-09-30T04:36:38.904Z" }, - { url = "https://files.pythonhosted.org/packages/92/08/564955a4b5f2ccb410ab480bbe8c6a18063ff27f2d35458731c4a5335df9/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4d48f2d08b9de5864e2c8744d4461b862fb149a18274abc8b698c45975573438", size = 269514, upload-time = "2026-09-30T04:36:40.469Z" }, - { url = "https://files.pythonhosted.org/packages/18/24/bad3ac4271589df29cf5ce2f5ae490518a5739358052bd0d61209e6fea54/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34276fd796040bf0993ab33a369aa572e6979c7aab225a88893667ad8eac8f7a", size = 255056, upload-time = "2026-09-30T04:36:42.02Z" }, - { url = "https://files.pythonhosted.org/packages/d6/3e/350d89ad49916b86554d6f5f2d03ec1152148f87e5ff735106c6a03b1a36/charset_normalizer-3.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0521c5665880b33d603717defa76c094048900010897909952397feb3039da56", size = 251311, upload-time = "2026-09-30T04:36:43.577Z" }, - { url = "https://files.pythonhosted.org/packages/56/5b/4970a2d154df502e133402906dd04e3ae7cada7b3011283c88d0479a2585/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:eff0ac9dbe711a4aee69bf04a83896aa9b85f19641264053a9f6d48573abb7dd", size = 250190, upload-time = "2026-09-30T04:36:45.185Z" }, - { url = "https://files.pythonhosted.org/packages/88/8c/f1a91bddc8fb47c2889e29ea7ea49a194eb0d9868675d786806519c00d76/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1503bccbeb36d5527790c3930327704c39af22de3112f1b1666a9f3ce15ee204", size = 237098, upload-time = "2026-09-30T04:36:46.689Z" }, - { url = "https://files.pythonhosted.org/packages/24/0e/bb5dace3cc7e79068425386a6589c19b5a2ab5fefc2a46abea6919683332/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:52aa6992700996af31f375de0c6bacd402b0097fe40b53c426b9f51a90ebabc7", size = 274228, upload-time = "2026-09-30T04:36:48.31Z" }, - { url = "https://files.pythonhosted.org/packages/9d/79/b849ad523017ea9f5a45581bbebed91439e0cf42fd2860a6f64e358eb5a6/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e09a3942ecbdee5cce73ea9d42da82b81b72ac1bf031ce069b93b5adf4eac8cd", size = 252401, upload-time = "2026-09-30T04:36:50.091Z" }, - { url = "https://files.pythonhosted.org/packages/89/8c/75469d690cf47200bce8f6cad7655724fc23148e147abfc5ce78b5f65863/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c7c9ab723cde841fefb34efbad91e87f00a674b1fe1cd0784fde742bf2c154dc", size = 271846, upload-time = "2026-09-30T04:36:51.719Z" }, - { url = "https://files.pythonhosted.org/packages/26/cd/6d52d3c7437cdcf2e310ce9f28f282e733d4ef60ed19105d1819c356255f/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ddc7dacc8ece3a182e7f15cb862d1fd616b46d076cb1ae9dd232b2c38b655874", size = 258151, upload-time = "2026-09-30T04:36:53.234Z" }, - { url = "https://files.pythonhosted.org/packages/f7/4c/070b38bdb5f49a70199fce923ec0726a49536a63ab262abbfcaaf351110b/charset_normalizer-3.5.2-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:ee43c17b173d46a3212baa6ead3ae258eeabdae48c263a01ccf0218c366dd655", size = 143570, upload-time = "2026-09-30T04:36:54.816Z" }, - { url = "https://files.pythonhosted.org/packages/81/84/9ebfc8ed6c8c4fcd8e726ff6bf220cc8deb3966e31dce9be8dd8aa017e64/charset_normalizer-3.5.2-cp314-cp314-win32.whl", hash = "sha256:4f87960d57feabfb618e4e0af6e7371645fa26a277860739d6e5d6e0012c92f0", size = 186412, upload-time = "2026-09-30T04:36:56.643Z" }, - { url = "https://files.pythonhosted.org/packages/d1/78/5ed86f743d4bc350db307e7636419a0a5ee1d91806d30c7f667bd5c80dae/charset_normalizer-3.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:e4e81e09c1578b8df602e3db08b0b3ea0a6947ad612f52bf8dc5ea8d47691f0c", size = 210965, upload-time = "2026-09-30T04:36:58.205Z" }, - { url = "https://files.pythonhosted.org/packages/53/94/a3a7698e9b1a395e1eb99ccd9a324be9347973bff4e72db2a06496d7cd27/charset_normalizer-3.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:80d02b6f04e92601a081dd97b23d3128033098bff5d35d392ddcc0476ea11253", size = 200406, upload-time = "2026-09-30T04:36:59.764Z" }, - { url = "https://files.pythonhosted.org/packages/c1/48/c5dd00d5ef7791f02666de250a5bb6071e29b7e133cf4b835800b6d3bc27/charset_normalizer-3.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:dca9ab98072a5a54ebacebdc45f53e645336b320c667410b061be1ca588ae709", size = 396871, upload-time = "2026-09-30T04:37:01.543Z" }, - { url = "https://files.pythonhosted.org/packages/12/c8/8379554b42e8368161d898476686947a0fdbd3e8865170d7909dcabfdee8/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f0aa869112ef88429ae17820d99c3dd9504c9e9c671d3c246f3d7442cb051084", size = 249711, upload-time = "2026-09-30T04:37:03.111Z" }, - { url = "https://files.pythonhosted.org/packages/4a/eb/2ddb1035d17320caa9f41682935123a9a250277b261c3efc86b2d2a21343/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c0afc6800ba57ccc350374c5bd6150419915d95ce93cdbab2d783d75eaf30ecb", size = 231146, upload-time = "2026-09-30T04:37:04.721Z" }, - { url = "https://files.pythonhosted.org/packages/4a/24/2ecb4bde104322cd7859d6594fcfa74649f8d90b3221c9feecbef149875b/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7dcd882da75ef9adf94903b1e3b9419e8aa8fb4c7396822b834b9ef7fb96954f", size = 271552, upload-time = "2026-09-30T04:37:06.295Z" }, - { url = "https://files.pythonhosted.org/packages/3f/98/9d5f6ebc3aee9fef5d30b4aff11fb2ab7a1222b4064f8ef2c7c87cde217a/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2e06a3a98f916dd41d27f3105e02e7a40181c98c94b9158733d03a6f80506c09", size = 269810, upload-time = "2026-09-30T04:37:07.905Z" }, - { url = "https://files.pythonhosted.org/packages/09/e1/a3b06a10461b1b7628853c934c644e03bc28e42767116afb52f19a56519b/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6bd128f206a7752ae1f2ab6c61bf8a24ba28913a10df8b14c2637b973ff97a80", size = 259845, upload-time = "2026-09-30T04:37:09.554Z" }, - { url = "https://files.pythonhosted.org/packages/fd/d3/6f561f74a296cf27d61775a1dc665ad13f3bff6a798810ca05907f37a7c4/charset_normalizer-3.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c8f3d67aeaf55f017982b73683f0e7342ba2f6635a78f69ce89ebb26aa411e5c", size = 252825, upload-time = "2026-09-30T04:37:11.274Z" }, - { url = "https://files.pythonhosted.org/packages/26/9f/69e13ca3b18f43e0eafcd34c04a45b732ae22a43b54a5fc9e119103356eb/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fe9753dfee015c570d73df76f899f18444d41388bffcde097deba51c4fadbb9f", size = 252659, upload-time = "2026-09-30T04:37:12.941Z" }, - { url = "https://files.pythonhosted.org/packages/73/a9/ace29806a0dae18939919c76ba526472d83214afa101105fabff2cf30625/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:92888bb3187c5ba50500b00b3b310c9f2c651709d28036077680cb5255450a03", size = 238819, upload-time = "2026-09-30T04:37:14.659Z" }, - { url = "https://files.pythonhosted.org/packages/f8/c1/6116d52a2e3311ec80f21f5fb5e17b27405f10b9608af8f6e69516841a1b/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d008d90a7f2471519aef0c90dfbe73b3e6e4d5e66ac48e19154c17e89e98b604", size = 272973, upload-time = "2026-09-30T04:37:16.346Z" }, - { url = "https://files.pythonhosted.org/packages/19/aa/9955c7e93bba10a9c7e8f7a5031b7ced66f3a1883a55c00712b8d5850ff3/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31f3930700408d211f13378ccbe1c40845d8da54bd0681fac3a9b5aae81c7aa8", size = 253692, upload-time = "2026-09-30T04:37:18.212Z" }, - { url = "https://files.pythonhosted.org/packages/bb/33/2a6ae7fdc1b10cb581cef91addd8cdfc5f40d50abb5702309369d5834579/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:2a925889534b3748302dae5dead07cc13480de1dac3aea80a941b729b471ef93", size = 268757, upload-time = "2026-09-30T04:37:19.877Z" }, - { url = "https://files.pythonhosted.org/packages/a2/22/80992720a0282cd39bba1db35868e6b9c22f41281160143a836544bc1d8a/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f5ec61164adcec446f8969a3358ec3f9b26bbda3b9213e5586d219afa8df2915", size = 262220, upload-time = "2026-09-30T04:37:21.583Z" }, - { url = "https://files.pythonhosted.org/packages/92/9f/181fd07e1bffea1d95cd80c84ac537354f50699c22cfc4d3c02b6fc16208/charset_normalizer-3.5.2-cp314-cp314t-win32.whl", hash = "sha256:598a11a2c7ebaa5334bf698bf29568c9c390abac6a154d8170fedecd1cea38c5", size = 197797, upload-time = "2026-09-30T04:37:23.235Z" }, - { url = "https://files.pythonhosted.org/packages/49/1c/25d8415ec1c4f2f41f1680435e4c87cfb378ff2f677d950946f2a45d0632/charset_normalizer-3.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:7fdde2c9fd9e3eca40631e024664cf2584272cc8f96308cbe5fdfc930f51d8bc", size = 222765, upload-time = "2026-09-30T04:37:24.891Z" }, - { url = "https://files.pythonhosted.org/packages/3e/b4/46b48f013dadfc0d0d33b375438e31bdf5a989dc68389c6bf627054d4df9/charset_normalizer-3.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d1befeed746d247c81127bb14de9dc3d30edb6e5976d34f83f86ed262b1d9105", size = 210985, upload-time = "2026-09-30T04:37:26.634Z" }, - { url = "https://files.pythonhosted.org/packages/ca/e9/34e597dee616d0b8ee4b34d29399e85c2204ade174157a48505d42baa4ff/charset_normalizer-3.5.2-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:87475fabc8d9996fd9c27debb395e642e8c838d78a00b6e932227a0e06b81e26", size = 367676, upload-time = "2026-09-30T04:37:28.329Z" }, - { url = "https://files.pythonhosted.org/packages/60/9f/a5d1c91c0263745e2cd344c5a4415d787c575501ab1d449f1148ac6b495d/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9409a8bf35cf78353942504b24a57de3d75b708997a1e4bd8db71ac8633ce364", size = 248414, upload-time = "2026-09-30T04:37:30.167Z" }, - { url = "https://files.pythonhosted.org/packages/26/79/e697f77464748a3ee3cf490c83d592459400d4898380d66c38366b03080c/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:498dc3188ca05a68231ac3fdbfc7f57eb67e1343c30e0fea17f8218c1599b253", size = 244775, upload-time = "2026-09-30T04:37:31.964Z" }, - { url = "https://files.pythonhosted.org/packages/ca/87/3d42a42e18ea066e2513936fd678a00696e77878b5ae04528976abdbcb83/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e242bb1c5e76e97dfa9e7f209a71e93a01d7f19ffdd5cfbb2e2d55b4f08f8ab0", size = 273877, upload-time = "2026-09-30T04:37:33.661Z" }, - { url = "https://files.pythonhosted.org/packages/c3/76/8a28136f3938ba9836f84280ce0c4d61ed1cf15a036b2034900c62634162/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:def79fa35ef0cef8d2accec024f4fdc7ead3012ff02f5215c783f39f03ef8cfc", size = 269455, upload-time = "2026-09-30T04:37:35.573Z" }, - { url = "https://files.pythonhosted.org/packages/a0/a1/4fbf5d0f0f1b2a080474c1cf9a2f12c4c6531bb0e8ba591055e846d2b4e9/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3df041de8887954562c9b261cba85ca0e9ded74048daf125f45edcfaa4832229", size = 257336, upload-time = "2026-09-30T04:37:37.397Z" }, - { url = "https://files.pythonhosted.org/packages/ba/a2/8b50aa320adb880ad579518e6f718f24944804b42a88b83d267d5d444125/charset_normalizer-3.5.2-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04851f73ae72b8413dddadb16a49dfee95263553741fd42d546f7d66907e6be5", size = 257584, upload-time = "2026-09-30T04:37:39.522Z" }, - { url = "https://files.pythonhosted.org/packages/a5/57/50e3fed84e175f40349bd0da7a4fce94c87f0378f52d74f511d89e0bdc20/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:183b88127acdb4fabe59d951ab424faf1af7b63cdbb5f776186c1ea2ffcaed98", size = 251854, upload-time = "2026-09-30T04:37:41.23Z" }, - { url = "https://files.pythonhosted.org/packages/d6/54/f7fbb3493c9f49091213b9c2d6dd65800696f1ce1a3f196a4205f50417b1/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:16fa0eccf81304b79c5cd87f9271c3b85dd9dd99245e4422ae9c0dd45e0f99d3", size = 248767, upload-time = "2026-09-30T04:37:42.883Z" }, - { url = "https://files.pythonhosted.org/packages/d9/37/b3a6385acc5a1e45b39ae9c90bfb9cf838a09b9dd37ef2740ab4c6b4a2eb/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:7441d755b7ab94f8d4eb3e43ec05482d760842fd263d003a99102d742cd835e2", size = 274479, upload-time = "2026-09-30T04:37:44.658Z" }, - { url = "https://files.pythonhosted.org/packages/89/44/809913e2cfd279e635a9294fdbbfb1b1dc62a8189d473d561f649fce98d8/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:ca403d7e4798f525fdfc78e258820419cbbd0f0ecbab9de7840e3c017cf6b8cf", size = 258310, upload-time = "2026-09-30T04:37:46.529Z" }, - { url = "https://files.pythonhosted.org/packages/af/a2/f28400ab13359d91bd39179df8e149376b9bf36588e739a3a4f9de2b84b2/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:df29a0a7107f7011e77f4eebdddec4c7331e24d787a0b21a46d63bdf7445da95", size = 271100, upload-time = "2026-09-30T04:37:48.399Z" }, - { url = "https://files.pythonhosted.org/packages/e9/89/9bab37955edf0adb3b66f8a3a6617d9f2f487e0d56f295a6a286cb640aa6/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f3c96f633825733f735c5a9cf21d21a257d8e1edf0b1cee0a064b9c424ca0f7d", size = 259936, upload-time = "2026-09-30T04:37:50.023Z" }, - { url = "https://files.pythonhosted.org/packages/23/b5/4459e08d45a679f903d50fea08bc52cfa728cca4d7bd02c757b5e5abda2e/charset_normalizer-3.5.2-cp315-cp315-win32.whl", hash = "sha256:281cb91036248400f4cc957495cccd44c275c2e0c5854f7e45ac5cf7dc193847", size = 186301, upload-time = "2026-09-30T04:37:51.722Z" }, - { url = "https://files.pythonhosted.org/packages/98/e8/55d5fd3935b4bce6da4fe0df61898e8c82653e317e677bd58aceb9c60f13/charset_normalizer-3.5.2-cp315-cp315-win_amd64.whl", hash = "sha256:89b53f3cda69831909888e0494f4fa0bcd3537e3e138dabeb620bd6ad946bae8", size = 210858, upload-time = "2026-09-30T04:37:53.427Z" }, - { url = "https://files.pythonhosted.org/packages/a9/5b/974423c2fd8e524c7a7f64318c1e02240ef954912fa2b4d70344107b9c68/charset_normalizer-3.5.2-cp315-cp315-win_arm64.whl", hash = "sha256:6be488a102b8cf28d0391d8c4ba7748938ae28b78ad901f8585520fca33ead1a", size = 200309, upload-time = "2026-09-30T04:37:55.015Z" }, - { url = "https://files.pythonhosted.org/packages/ee/f9/00ee0195db1013d8f7c416fd770fbeb560bb46eb2e36b054d05cb56f6cfa/charset_normalizer-3.5.2-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:915563965d418f986e7e145accc592eae9e1a1be3566ff98a05d7a9ec42a76e1", size = 395343, upload-time = "2026-09-30T04:37:56.743Z" }, - { url = "https://files.pythonhosted.org/packages/04/3a/c00b50e94c964cf934c7899cd47c97952fc11dad71cc5884b3c61795b09b/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65cd72beeeca9d3aaea1201e5923859f308f952f9c71de93f06063c79f0f7a3b", size = 250114, upload-time = "2026-09-30T04:37:58.607Z" }, - { url = "https://files.pythonhosted.org/packages/50/27/d102dc880bbcffd0479ab64dfc1fb96777a854355a55e2bda72a71efadcb/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b7fd005a73d9e657273b7a10dc71a9e03c8fb9ee6999798d6918ce095b81ac7f", size = 239997, upload-time = "2026-09-30T04:38:00.511Z" }, - { url = "https://files.pythonhosted.org/packages/a5/4a/bf7ef45794dd293fab5f98a9309817977fbb845b9998f171b8cc5d8437a3/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e54da4baf05720032d527874d40b65fa4d7e5c6c6a43d0c3adbeffcaf275a2b3", size = 272313, upload-time = "2026-09-30T04:38:02.509Z" }, - { url = "https://files.pythonhosted.org/packages/e8/ee/008a2837737991474c5754bb3191010007663860979701990982a502cbaf/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:124fbf1a8ff966d87ae05bb8bd45a71f966055ed8bba320d0c7cf450bc5f4d0e", size = 269986, upload-time = "2026-09-30T04:38:04.435Z" }, - { url = "https://files.pythonhosted.org/packages/93/ad/bd74a283940dc910c5b14f8e4f80a248082bc9c0fcbe1f54530cb6d9cc5e/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28b4f0d66fb834ff90f28209ac7bce77868c45d8c93e26f906709d9b7c2e1af9", size = 260202, upload-time = "2026-09-30T04:38:06.549Z" }, - { url = "https://files.pythonhosted.org/packages/8a/7b/ed341c66f69f688723501fac752be3d63c7159ca0d0d4174fc611e5710bb/charset_normalizer-3.5.2-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:58ca3755ee7ff7f59b57789ec9833c9de9ea275405cdd240eda1f193112e398a", size = 259083, upload-time = "2026-09-30T04:38:08.311Z" }, - { url = "https://files.pythonhosted.org/packages/cc/9d/e41588b777965e5031a43128a1e96173ebb35ac75fc53ec3b517e7c21cd4/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:443eae2bf318abeaf6f15d785138f71fd6de770e99a92158b8b814265e079115", size = 253081, upload-time = "2026-09-30T04:38:10.402Z" }, - { url = "https://files.pythonhosted.org/packages/81/35/b761eb6d8c1eb218b9b42b9b4d5ac902afdc399fb6dac6f9a9aac7bda589/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:58f361dcbab699cf8f42db3f47c8e7fd1036f138c23a5d08de9fde5f425a730c", size = 248712, upload-time = "2026-09-30T04:38:12.317Z" }, - { url = "https://files.pythonhosted.org/packages/4d/2c/147169a041b747759f37405c0a97157e8e92de967968373101ff14915cba/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1b4cbc7c3491ccb4aa17fcd8165649d01cf39f76de1696da8631b5f71b85401d", size = 274176, upload-time = "2026-09-30T04:38:14.138Z" }, - { url = "https://files.pythonhosted.org/packages/f0/2d/0ff8db0d373ba8538db686db11cd7e8912031490b9e4f383b41912e8d594/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:ba0b1d2620edf869789c3879223f52bf2afc5d31b3cb47cc57b3a12c05e2aa9d", size = 259413, upload-time = "2026-09-30T04:38:15.841Z" }, - { url = "https://files.pythonhosted.org/packages/8a/8e/b4a085fb47c9d3a7e43576a4784fdd8fe23f907514a972de8086edaf7a48/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:5e2b6b57e9733d39f0c9fd3185efa6b8e29652c4cd8fe94180272cf6ed9a78c4", size = 269434, upload-time = "2026-09-30T04:38:17.626Z" }, - { url = "https://files.pythonhosted.org/packages/83/1c/d8d8d7322a7c3eecdf3237a4a419cf41d2eaad8e006ce7dfdd9d4c8fa2eb/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:51cf45226a9b588d0d2b4880c62d686934b63ab0bd79ca23ab0e9762eb27441b", size = 262716, upload-time = "2026-09-30T04:38:19.214Z" }, - { url = "https://files.pythonhosted.org/packages/a0/16/0e4c6ba9b44e97a2da150e52d331e8f9c968b21b358fbffa6c856cebcd89/charset_normalizer-3.5.2-cp315-cp315t-win32.whl", hash = "sha256:5fb29fb8cd1a46c27a1bf9613ad5ec2599310d46b4025d9556404a6b6a292800", size = 197159, upload-time = "2026-09-30T04:38:21.037Z" }, - { url = "https://files.pythonhosted.org/packages/be/33/e90bc2b1374f7f36ef106f56620de5a783907e19ca857efe2277e31cac3e/charset_normalizer-3.5.2-cp315-cp315t-win_amd64.whl", hash = "sha256:a192e2c40070d92c3ccf777e3a5c4ff515573cd2bb7ed0c537fdadbbec5bbf21", size = 221322, upload-time = "2026-09-30T04:38:22.886Z" }, - { url = "https://files.pythonhosted.org/packages/66/89/dfa6dcb08c200b7830ab56439e8c1890f2971d51aafbb3937894a2e7fcfc/charset_normalizer-3.5.2-cp315-cp315t-win_arm64.whl", hash = "sha256:749e97e1b32313717a565abbe321bc2190bc8b35f1a67e4cdbc7c56c8d8ffe58", size = 209192, upload-time = "2026-09-30T04:38:24.648Z" }, - { url = "https://files.pythonhosted.org/packages/8c/ab/176fbfd5b64939c55d652366aa5b9ef1d767af207a3aa6ebeb0d226c484d/charset_normalizer-3.5.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:4275811936e2f06feff5e598fb42a1b7ae852da8e39605211892b56b81a34efd", size = 331815, upload-time = "2026-09-30T04:38:26.216Z" }, - { url = "https://files.pythonhosted.org/packages/7e/84/371eac6b30bdbcbf2d632a1a01809103459216fcaae61b8b8d922c1bfb8a/charset_normalizer-3.5.2-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1c50fe28bbc2ced33386f298650d91218076c05420e6cbd790b913adc41659e7", size = 253276, upload-time = "2026-09-30T04:38:28.032Z" }, - { url = "https://files.pythonhosted.org/packages/43/6f/c4fbae58febff71709c51bc7e18fdfa55341dc382704740f9f0cbf03817b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d19fbd981a488e22cd04883659ca6b08f50b5974f9fd7c95655ef6a043e5893f", size = 241239, upload-time = "2026-09-30T04:38:29.732Z" }, - { url = "https://files.pythonhosted.org/packages/61/71/458c3f42164a07d0c5210798e9e704b39e540a6793b05aba67f3a35243a9/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0fed1d06615f022ee3b13caf5e8b180cfea32bb2c5aded8a9d44277afc040f93", size = 231121, upload-time = "2026-09-30T04:38:31.462Z" }, - { url = "https://files.pythonhosted.org/packages/09/54/ab9e89367076f6331bb6c65c4bf14a5361fa5191cb6561bf534f18504e1b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:838dcc90063569a0448120554591a1d6c4a4ffe11babf048908793154ab86ade", size = 260350, upload-time = "2026-09-30T04:38:33.239Z" }, - { url = "https://files.pythonhosted.org/packages/7c/c1/061431ecc688d9d76602502cb57cc01e691e682c18f1beb45f9673b5bbd2/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2ce45c6627b22c47e390bc91a41c3d13032192e699fa0bea96e9671b373d69b0", size = 255430, upload-time = "2026-09-30T04:38:34.865Z" }, - { url = "https://files.pythonhosted.org/packages/8d/1f/20c8949f0676f7ab811abdeb7f4d7f1cbc6e61ff20bef08b44edeb092bc8/charset_normalizer-3.5.2-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0774bf9bf620249fee3e0b8b9fd3065de213be30f3aa94ce2494b3b638949e26", size = 250612, upload-time = "2026-09-30T04:38:36.649Z" }, - { url = "https://files.pythonhosted.org/packages/2b/9e/46f2fa4c431fc98c4ae76a8cb5bdca54e0341e3cfc3fcfd8e82740250818/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1db38f4c5496827c1a501846d64d14c3b80c7e6714e406cd7dc36a9899fa1011", size = 242083, upload-time = "2026-09-30T04:38:38.26Z" }, - { url = "https://files.pythonhosted.org/packages/bd/39/559be29a0c0f086e0bba6922babd38916cc5e0b58ced4de13ee01ea05508/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:304d8e4d493af723536393eee0c689eb7813f4a474c8b479dee63f1fdd98f621", size = 232738, upload-time = "2026-09-30T04:38:39.81Z" }, - { url = "https://files.pythonhosted.org/packages/ff/6c/387b0e4f756a282831c1d9fc6aeb6c51ca4507ca202767c8de15ce9b12e2/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:9b7f416ff0978e2f2249330527f0ad6fa02f4932e6199692d3b52da2048c19e4", size = 260703, upload-time = "2026-09-30T04:38:41.346Z" }, - { url = "https://files.pythonhosted.org/packages/96/92/1fdf015f09ef449f50d3ac4b67c90887c9c318b727daa95cc4f866e6521d/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:01077390b03f7988f11d700a2194e69b119741a86b1a638b1db88891e3eced8e", size = 247622, upload-time = "2026-09-30T04:38:42.937Z" }, - { url = "https://files.pythonhosted.org/packages/dc/3c/8e7b8a5671ad5d433669fb2a76f1a0164df2d9b1718b0206bc2a16d840cc/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:7e841fb9010836c992c9f12fcbd43a831de93a5f726fc1ccd8ca1d0268c5014c", size = 257500, upload-time = "2026-09-30T04:38:44.604Z" }, - { url = "https://files.pythonhosted.org/packages/b4/f0/45b579df5cabc1d5d53ea1cc35e8437d3ca768c0acccc7041517cb6fbb32/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9cae88599c7219005d879f98e5ed53341e9a122af585e1091200358a3003d2a0", size = 255100, upload-time = "2026-09-30T04:38:46.289Z" }, - { url = "https://files.pythonhosted.org/packages/31/68/fdec18a343f5fb3f310588dd478b09ac4799e0b187dbade3a8cd776f03ef/charset_normalizer-3.5.2-cp37-abi3-win32.whl", hash = "sha256:01b0c0d2262a9e28e8484a278c7e1b5d650e3ac8cf2683d2967e25899f208bdf", size = 174499, upload-time = "2026-09-30T04:38:47.999Z" }, - { url = "https://files.pythonhosted.org/packages/9d/8a/b618149cc5207943a0242068d7a27897f56a62947b5a039085f2a22029f8/charset_normalizer-3.5.2-cp37-abi3-win_amd64.whl", hash = "sha256:9f56f72050826f63dcee7a7f55b0a77168cb3bfc553fd405e7f8f9ece75a4036", size = 200092, upload-time = "2026-09-30T04:38:49.707Z" }, - { url = "https://files.pythonhosted.org/packages/03/cf/4c66866fa9e2b1c78e3c911516d1de497a677b7ac60f1eceda74ce777ca3/charset_normalizer-3.5.2-cp37-abi3-win_arm64.whl", hash = "sha256:40ab6bffa02ae10a0581e6c198be7d2d8ca5c2a0c64e4ed3465d766df457573e", size = 294363, upload-time = "2026-09-30T04:38:51.312Z" }, - { url = "https://files.pythonhosted.org/packages/fc/ad/d07d7862a62ffa6d79d68074d14823243dd235a77c45262acbf6adeb28bf/charset_normalizer-3.5.2-py3-none-any.whl", hash = "sha256:b6b751274acb69d77b3323d6b7dbaa3c7fdfc1eb829b7eb61d262f32e1af9685", size = 68872, upload-time = "2026-09-30T04:39:21.828Z" }, -] - [[package]] name = "click" version = "8.5.0" @@ -296,95 +143,6 @@ 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 = "contourpy" -version = "1.4.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/83/5a/a55177dd22553a277388e8a1b3220e92de91bacb28356cdc73caa240121d/contourpy-1.4.0.tar.gz", hash = "sha256:20156f5a1ac4f8ce02656e39a61e82164a3d359796dc8026f75b062783d500e1", size = 13323726, upload-time = "2026-09-11T19:05:05.808Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/97/ba/01bde9753bdbba04a6da9d2bff3881f021bc708f654655e95fae26d3b3a3/contourpy-1.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:186ba929df36d61b6127da2e89cd1357e4cb79aca647381ab1a6feb0b152877b", size = 297302, upload-time = "2026-09-11T19:02:45.103Z" }, - { url = "https://files.pythonhosted.org/packages/dd/f5/c5bf67522d49a2222f3154fe46879451d26f0e35ece41770758a64ea78cb/contourpy-1.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c76f3a5318164db7d9401132fc1b5364b784c613c93fa506e3b5e6d1bf353ec8", size = 284921, upload-time = "2026-09-11T19:02:48.049Z" }, - { url = "https://files.pythonhosted.org/packages/0f/cc/cb989599eec12fda312e127eb8e04a8b21a7a6c94bf7ff1bc68273334a42/contourpy-1.4.0-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:43c3ccbb32c6294b183dcc8e8c46dacf5ecef809497e3d48a5be298eef185ad0", size = 356332, upload-time = "2026-09-11T19:02:51.408Z" }, - { url = "https://files.pythonhosted.org/packages/47/fb/6f620d7602507817b0b4c21dd790ed68688f1f4ae9c29aacf21f08db5cf1/contourpy-1.4.0-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:86d05cec773c9507a3950122e0e40ce77c23c75ceeb2fc189514e71893cbb34b", size = 405878, upload-time = "2026-09-11T19:02:54.093Z" }, - { url = "https://files.pythonhosted.org/packages/32/4e/693c6d6bdece679f0953775eef3a1b66d56be55478d936e7deb1f3004422/contourpy-1.4.0-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2deb580178ca19437a84bd77e4bc2cd91a8ccad212413a83c273900868b5978c", size = 408296, upload-time = "2026-09-11T19:02:56.528Z" }, - { url = "https://files.pythonhosted.org/packages/78/f9/b6831508960d559581c448532ba4df312217072975486dbf2b24fcc5b76f/contourpy-1.4.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:875f42444c9cf48d56f724f2637e60d0f73b3b12c9041e1484580a233edf9591", size = 383243, upload-time = "2026-09-11T19:02:58.638Z" }, - { url = "https://files.pythonhosted.org/packages/f2/21/52903825a0ae7bb625e8bd30a09816d4c3c5d6174a469c10a616166cf780/contourpy-1.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f863c6100bf926cf47d13f3cd75f9bb8ebd98aaab230eeb4468b91f98f39a6b3", size = 1357306, upload-time = "2026-09-11T19:03:01.036Z" }, - { url = "https://files.pythonhosted.org/packages/d8/79/d68b1ce8539e4071518fed9523f558398f34dcd078b8927b109c72dad2ef/contourpy-1.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3863ef2e2b13fe93f8c0ebb08ee400cb07153b8b0e91c5acb26e4537f283634f", size = 1427243, upload-time = "2026-09-11T19:03:03.866Z" }, - { url = "https://files.pythonhosted.org/packages/ac/f0/b75a10e9d0616b97a30de277b6dfe83f1879880854c7330337309530eb48/contourpy-1.4.0-cp312-cp312-win32.whl", hash = "sha256:5450f091ac1be0be3ad3a2a3b3f23b5e443e78c670ced4fd347d626f92a28fd2", size = 347345, upload-time = "2026-09-11T19:03:06.192Z" }, - { url = "https://files.pythonhosted.org/packages/50/a9/dab08786bb4d77ef9a046b3c1be923be4e73c5d07d91f5a54e8ea9b09e41/contourpy-1.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:6e697d94e69f499ff6bebb899cae97a58d5d14f0e1fe9568b43a0248d2f9af8c", size = 234083, upload-time = "2026-09-11T19:03:08.122Z" }, - { url = "https://files.pythonhosted.org/packages/47/b9/3ba509755a970dbde5948e7142ac21f266be72f38017cc4087a05fdceee1/contourpy-1.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:0c7a4c2716a4e98342221954416a836cca77c996c14ddbf22b5f01d5d93ca09c", size = 559521, upload-time = "2026-09-11T19:03:10.047Z" }, - { url = "https://files.pythonhosted.org/packages/b7/8b/62a6eacea08ea0b6ab0159a7783df7ed421ff9c1ff58fef7c31f58fa5e52/contourpy-1.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e21d1d4a9db5b3a793649c7ab0ddb4697f2818929eec871a7b9c45fce7b2a1ea", size = 297330, upload-time = "2026-09-11T19:03:11.809Z" }, - { url = "https://files.pythonhosted.org/packages/3f/2f/103cdc5b7077cd569989d3ddb99ee3d4e96ee31bde8df2a6e426b2dc6195/contourpy-1.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:196759a3e4db60e1546167e361090ebb6d199aa3aafc9e34ea17a5ac3b23e814", size = 285037, upload-time = "2026-09-11T19:03:13.781Z" }, - { url = "https://files.pythonhosted.org/packages/bf/25/97001d1fdf940e6f5adfc592d4f5899fff95daf7dceead3988fe09987d2d/contourpy-1.4.0-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c0e07c691f3b3321913ed9b8161c50ea5f77fadd006f52f5e755359b0dcbfc5", size = 356675, upload-time = "2026-09-11T19:03:15.374Z" }, - { url = "https://files.pythonhosted.org/packages/a6/5e/9a7241a89e2f74402aefa2569e1349ad2750ff77823c04c788eaaedb7fdd/contourpy-1.4.0-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7f861af508fca2384eef392bc1d8377cfa349b2b854cb11b45d89c75780e2561", size = 406990, upload-time = "2026-09-11T19:03:17.44Z" }, - { url = "https://files.pythonhosted.org/packages/36/81/3a4fbbf6a806eada5453a6d16f6c7d96b9f4b3e57a3127368fb545986b8a/contourpy-1.4.0-cp313-cp313-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a03b32c2e7eda8b17757c022162c13c86f5c3d6f937dddf9ba3c3ff7b513953b", size = 407271, upload-time = "2026-09-11T19:03:19.523Z" }, - { url = "https://files.pythonhosted.org/packages/68/77/8f93ecbde1a4d11dfd460cd19a0c159f5487352e751fa87f5d78cc9db08e/contourpy-1.4.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f1219a8898523cba821085f2da8a1b962cc696325763f09d7f93538ba43b2d70", size = 384707, upload-time = "2026-09-11T19:03:21.208Z" }, - { url = "https://files.pythonhosted.org/packages/05/07/e5cdf94d68961ee464de6b4fedd3d2f666584f59ccd124bb1290597122ab/contourpy-1.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:20da2f86bfaed60dbba729fe738c8241b1dd22b6cf7e8cbbf30df290bd04ba28", size = 1356741, upload-time = "2026-09-11T19:03:23.621Z" }, - { url = "https://files.pythonhosted.org/packages/fd/8a/2301d07accb257ac08daeb631875fada06fedd019c20ff7b00ee07e9d211/contourpy-1.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6ced1670fafee703b8277ab1645072a226e74f167166eae146fb743916119b67", size = 1426793, upload-time = "2026-09-11T19:03:25.955Z" }, - { url = "https://files.pythonhosted.org/packages/7d/5d/a62648f06259d15c139b14eb8cf3c58fe53cb970a55f2bb77d1070e80a41/contourpy-1.4.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:abd0f7e51ecc52bf8c95713bd80b1c17c516a71fa391c54475308a4377903c2b", size = 117961, upload-time = "2026-09-11T19:03:27.713Z" }, - { url = "https://files.pythonhosted.org/packages/71/31/c5b7771ba6982f319ec90c0cf5c425b36891adfeee2d9f667f90b2d0c166/contourpy-1.4.0-cp313-cp313-win32.whl", hash = "sha256:510f7d93d94cf6ebf4e2cd0640bea16ab8affac7547230175b6745b596ce0599", size = 347438, upload-time = "2026-09-11T19:03:29.829Z" }, - { url = "https://files.pythonhosted.org/packages/68/c1/a59df0754bf83a6f3e153cc19d550a5b01f607e389b1c4d57f436f9139e0/contourpy-1.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:6507a016976a75a15aee47809a34605de6244ccc056e6164bf9fb14b27eecad4", size = 234084, upload-time = "2026-09-11T19:03:31.657Z" }, - { url = "https://files.pythonhosted.org/packages/41/48/97224f5decb1efa5a610f912195deb45321f73ec9b9dac485cd6fe2d5a6a/contourpy-1.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:423bd5f4b3f11d54a8c597234e513382e3454bb106b8ec7ae32ba4ed63f8d99b", size = 559559, upload-time = "2026-09-11T19:03:33.528Z" }, - { url = "https://files.pythonhosted.org/packages/75/b0/b548628c8dddf6e5e0a981b7fa8e4c008024df09c7d17c6a9e271edfa0b3/contourpy-1.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a3e67bc1a6a4618a1dac7c3053a9ffece5ddfa2046b2670b342cf430bb0b87d1", size = 297354, upload-time = "2026-09-11T19:03:35.017Z" }, - { url = "https://files.pythonhosted.org/packages/04/5a/513484208742f65af2648c519f0b7ab034616e183c4403538024c136a1ab/contourpy-1.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:072a702e2e178f4fcf96f04775d0c059e4c917925abf0d3208ebb6865df3c23d", size = 283729, upload-time = "2026-09-11T19:03:36.818Z" }, - { url = "https://files.pythonhosted.org/packages/35/da/5a6562febc994b2c4bf9d01e57a50458ef8a2051bb3e7b35a00333e0fc26/contourpy-1.4.0-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:404dbcd9233513dfd1323b66ea593fef90e899f658f6b8a9ed8e93bd0ca669db", size = 357819, upload-time = "2026-09-11T19:03:39.058Z" }, - { url = "https://files.pythonhosted.org/packages/6d/99/7b358ce888aaa426f755daa096c99c59d17c352037344e836f20e5d3b957/contourpy-1.4.0-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:61624f6722480aa7e168fd746164e7bfdf48f8ccb6542f1613741200dc37e2b1", size = 407795, upload-time = "2026-09-11T19:03:41.293Z" }, - { url = "https://files.pythonhosted.org/packages/be/02/34d7548adf08c60967435d79d7a90882eacc84f5093435287974c7e99943/contourpy-1.4.0-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2e741a39dfc96babe1722561e81351aeec104526c57dbe91c167c1db606c2d55", size = 408552, upload-time = "2026-09-11T19:03:42.728Z" }, - { url = "https://files.pythonhosted.org/packages/cf/8c/9677dad226e5aaac5b823d5b09d881ebc32ff1648af417597649841e516c/contourpy-1.4.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e439ab450c93455feb0218532ded3e946ec7a9b5f459069f122cfa44b89229f6", size = 384850, upload-time = "2026-09-11T19:03:44.402Z" }, - { url = "https://files.pythonhosted.org/packages/08/f5/e3fbeaa489c223629fbb434737cb3de4ecaca9cae4825e77b92490f640f8/contourpy-1.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:417be048f7e122cefbe2a34c51a1f2b0410f8eb35796f794d45475ddb7872d9d", size = 1357601, upload-time = "2026-09-11T19:03:46.746Z" }, - { url = "https://files.pythonhosted.org/packages/88/be/55fec293d342c222ed58a5d4056df4362e192af2e59527a3d5d822de8828/contourpy-1.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:43d80072a32299bf945de0a6dd4e034ea0162e832261de2e07a274a5adbba9c9", size = 1427340, upload-time = "2026-09-11T19:03:48.745Z" }, - { url = "https://files.pythonhosted.org/packages/86/40/0faabf453edf59b0e3633381d7ca72c3414fb9b72b5cbb08b06ed792d3e5/contourpy-1.4.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:fa1b787362a3856e63dd2b89449f6c384d55ee1beb8f94f4bcc871b40f02462c", size = 118355, upload-time = "2026-09-11T19:03:50.047Z" }, - { url = "https://files.pythonhosted.org/packages/38/0c/7ea75aa3559ae8a6805015e15a19c92b6480752ecc92526361e5776e88c5/contourpy-1.4.0-cp314-cp314-win32.whl", hash = "sha256:738c44fa71735a617f36da58e32810512407d283d5d1bb5b1cecb00d7eeba7cf", size = 356977, upload-time = "2026-09-11T19:04:11.401Z" }, - { url = "https://files.pythonhosted.org/packages/15/41/3df8cc14bb572c8b447f9dfbc876703f0f2f2896058285b5c87a1d4a638d/contourpy-1.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:181bea01bc742734ae672fa00c717d855dd35e1f029536406538c52e7b0cd73d", size = 240101, upload-time = "2026-09-11T19:04:12.772Z" }, - { url = "https://files.pythonhosted.org/packages/66/01/ee6830a5aa4565662a345172f6f35eeeed625d20f1c9d4ecd35318fe4ade/contourpy-1.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:64039341e2d8804f1a13bda8c69083eab935167bbd7e856cf096241f17d5bd45", size = 576359, upload-time = "2026-09-11T19:04:14.983Z" }, - { url = "https://files.pythonhosted.org/packages/51/af/49ee9c9cf012699e505c1b63c531cc99f19df50f205173092c4d541e3ec7/contourpy-1.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ef47fef9a912c77e3d84b014f701e5a9340f25703e9ed3bcebe37f91fb69dc49", size = 313601, upload-time = "2026-09-11T19:03:51.452Z" }, - { url = "https://files.pythonhosted.org/packages/ad/59/ce411ab2be0f805038626d8f4ca9ce563487ff51ff6c363e7512271564ed/contourpy-1.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:96019f059bcb3774acc0104be3360a57b36d33eaaa2e1d7f77c800ca8e07618b", size = 302812, upload-time = "2026-09-11T19:03:53.022Z" }, - { url = "https://files.pythonhosted.org/packages/95/b0/390915f9af14c1e2d3b2a9ad99d85b98df4352346a51e368cbb825f813c3/contourpy-1.4.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ddf5a2596d716fd3793434844caf31abc7a40b1e8718431420c89858268fb909", size = 348996, upload-time = "2026-09-11T19:03:54.594Z" }, - { url = "https://files.pythonhosted.org/packages/c7/85/e0952576d54322f3e9983b1e049a7a31f42ce82c5c29d01821e6dd3780d7/contourpy-1.4.0-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dd59df9e2fb0aff8bd7fd1adb9f349b7c835245a9d0f18c2f2deeb537190f2b1", size = 400272, upload-time = "2026-09-11T19:03:56.822Z" }, - { url = "https://files.pythonhosted.org/packages/d6/7d/f13543a5e1598e4a4ad01d9d5e2d65f9c09c37660616429913d606c5977d/contourpy-1.4.0-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b7794ea07cab575633daad8d6e963b662053391022294d3aacc1d9ba9ef54114", size = 412895, upload-time = "2026-09-11T19:03:58.487Z" }, - { url = "https://files.pythonhosted.org/packages/c5/a8/6e5eaff53507dfd2dfc428240eecda32453067277d4b3e7ca71210b2d611/contourpy-1.4.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a1c8a74744fa746eeaa12f0f9ed7f8b4add9d8f1b14d95e3b5e133675eac888f", size = 377060, upload-time = "2026-09-11T19:04:00.122Z" }, - { url = "https://files.pythonhosted.org/packages/16/a3/d00e44d35511b3cead5bd794ee672d4eecf30d9de4ceac57a49d6204064f/contourpy-1.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:107ec46f7aa1664266d69b1181aadc953d58d39f7d8d648fa5b795d4a060b0de", size = 1345688, upload-time = "2026-09-11T19:04:02.705Z" }, - { url = "https://files.pythonhosted.org/packages/1e/01/1e533b0cae32c8edd63fe40435ec3b29345dab87321afcd28722d7f957d1/contourpy-1.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5a4c89c7f38a0d7a94356d74e3391073c4325c4d47ad2fd065c3fe7dbae16c0c", size = 1416140, upload-time = "2026-09-11T19:04:04.77Z" }, - { url = "https://files.pythonhosted.org/packages/6d/0c/4ee66759f00d6a5d6909782f14f441d639fa18a4a43142e334351efc47f8/contourpy-1.4.0-cp314-cp314t-win32.whl", hash = "sha256:912c6afaa106e2f74ba22b30416b77ef7eb94e3bdc5a4df51ab147845dba9b6d", size = 371982, upload-time = "2026-09-11T19:04:06.233Z" }, - { url = "https://files.pythonhosted.org/packages/e6/ac/b3e5324138c3e3741f526663f4f265f4f69dfdf26415a39428940864a5f6/contourpy-1.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:ef9440f6f8506246269a82734f5ff9e2e4c5e775b3996fc883cc491c5257eca6", size = 262907, upload-time = "2026-09-11T19:04:07.882Z" }, - { url = "https://files.pythonhosted.org/packages/19/37/c9aa45e47819dc15a38fc5c81a2fb987fde55e9d3b991fbde514e3b6b5f5/contourpy-1.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:fc9feef8f1f001c5b87decadc67c4a5d1eebb62ca39c4763d1237ff62cf2b707", size = 587071, upload-time = "2026-09-11T19:04:09.898Z" }, - { url = "https://files.pythonhosted.org/packages/cc/74/66d4e6fbea3bcb3fd4e5ae21c039ba31b8fc408b35884b4bc09b88016dce/contourpy-1.4.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:79b06c60d5e569ce12c5da8f0c51217cdc484386e2349fa717c30fafb56d2871", size = 297358, upload-time = "2026-09-11T19:04:16.718Z" }, - { url = "https://files.pythonhosted.org/packages/2e/8d/8f7f25e71d73f15d7859943189900d2d348e740388c9ef52a0180d284762/contourpy-1.4.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6f963ea9f0d68d2b6a02d861e3c0aae09ae25ba21f243fcf281e9fed468b078d", size = 283732, upload-time = "2026-09-11T19:04:18.087Z" }, - { url = "https://files.pythonhosted.org/packages/f0/90/957549e3b03c407c670e3e191db63f4aef60dab6605bc967af3a806b543e/contourpy-1.4.0-cp315-cp315-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5545ff38229c15d11d822fc6f7415b932da9f4975f53e305b7b96ebfb01f889c", size = 357820, upload-time = "2026-09-11T19:04:20.077Z" }, - { url = "https://files.pythonhosted.org/packages/56/4c/fa8de87d907b41b05d03f4945a51675cd67319650d01b4c927d209ffa6ee/contourpy-1.4.0-cp315-cp315-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:630d32f06cedf37b7f1dd2b6a12e4492826bd477a4c9c7b2e2fee6ce3a2cea76", size = 407804, upload-time = "2026-09-11T19:04:21.781Z" }, - { url = "https://files.pythonhosted.org/packages/04/e7/96bd4f4a4ae285727befbdb4b332d53a2654cc5da4585d2525b47a063e1f/contourpy-1.4.0-cp315-cp315-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:be85d160e7c795113c2a93254dde2fc7e86b375322570eee78b420969426973c", size = 408559, upload-time = "2026-09-11T19:04:23.432Z" }, - { url = "https://files.pythonhosted.org/packages/ae/94/39660e8dc309f96bf7971eb4ec5659f272f84e42b34c9990a96ecce8442c/contourpy-1.4.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:018227e134b73090b06f911e773a526c77c9af426be29065500ce096a5accd4c", size = 384861, upload-time = "2026-09-11T19:04:25.198Z" }, - { url = "https://files.pythonhosted.org/packages/ab/7b/411dccbf73d5d19da49bf7735786b3baf4ddb1456412d48ae4d4ab1fc761/contourpy-1.4.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:4bffcb2e8cc5c0e837631acab59dd04265fdac376550e7c3e310523877c68cb7", size = 1357612, upload-time = "2026-09-11T19:04:27.614Z" }, - { url = "https://files.pythonhosted.org/packages/a0/39/bf2442130e7d5aae75a4de4504f0c8aca3ef3da3feac3940ee27ca985d83/contourpy-1.4.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc87f2ffa84a49a77d76cb792f28a48ff3b05a222aa0b2bddbd839ba78e1d5ca", size = 1427345, upload-time = "2026-09-11T19:04:29.646Z" }, - { url = "https://files.pythonhosted.org/packages/96/60/e5d9196dd83ccd7dd103762982b10ef19a4f72e8ef67ae7157699b9be0bb/contourpy-1.4.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:c8be3392b84cf43373a488874dc1ebf46f60784a70977f92586e18019d71c7ed", size = 118157, upload-time = "2026-09-11T19:04:31.091Z" }, - { url = "https://files.pythonhosted.org/packages/1a/b2/ae3d90f08ba2ed82189d93b55ffbd41e559760e89af4f9ef54d51869f41d/contourpy-1.4.0-cp315-cp315-win32.whl", hash = "sha256:618137ec5778fb76d494c9ec854b104a377fddba0d128bef7136cce2c3bf0df8", size = 356982, upload-time = "2026-09-11T19:04:52.913Z" }, - { url = "https://files.pythonhosted.org/packages/02/a0/36648fff407dcf8b8482985d33367ffc0fe90be72383869312cd0bd00a1b/contourpy-1.4.0-cp315-cp315-win_amd64.whl", hash = "sha256:ea09dc704029930cbd097f75cb0cc1db9852adbcf1a151fac7e9559f7bcbfd19", size = 240101, upload-time = "2026-09-11T19:04:54.587Z" }, - { url = "https://files.pythonhosted.org/packages/07/09/49e987a96706b8f877596cee00d558d7880793a833addc6b33f80805e5dc/contourpy-1.4.0-cp315-cp315-win_arm64.whl", hash = "sha256:a5e2bb871b90cd7a52bdee63bf80ef7acb46398d27efc66d7679240016efc5aa", size = 576375, upload-time = "2026-09-11T19:04:56.515Z" }, - { url = "https://files.pythonhosted.org/packages/e3/18/820ba24a070fe2a834af73e52245c501960746f5c1777ebc64ec0c0dec12/contourpy-1.4.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:432e89f835cf01f8d2123a130a3126b17ee6e0348e4d62c0973872b30aa873bf", size = 313543, upload-time = "2026-09-11T19:04:32.582Z" }, - { url = "https://files.pythonhosted.org/packages/d3/4c/f61a63d8107e0c1c3fa47d30ce45abad90b2ad48a1a9a4510332db7e0176/contourpy-1.4.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:3e0a2392533e5e2abdf277c951047516acc8d2f3c42b2dd9dc34c907c9ebabf2", size = 302702, upload-time = "2026-09-11T19:04:34.257Z" }, - { url = "https://files.pythonhosted.org/packages/9d/93/5af95cb5113ecc6e780ca0c6c79756a2cea8833f971bbae112dd4ad73ded/contourpy-1.4.0-cp315-cp315t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:758e7496cec3195fc28a28945eab3ceef348c47fb95c7afc2eca2f3901209c8c", size = 354370, upload-time = "2026-09-11T19:04:36.158Z" }, - { url = "https://files.pythonhosted.org/packages/47/75/5a9942446647202beb7b4d150891bfcb78fb112c73862484bbe4cc031092/contourpy-1.4.0-cp315-cp315t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bee6ff96653e0807cd0f8eaef00174a031e3a2effa5c20f49a5a8d574b05af23", size = 402340, upload-time = "2026-09-11T19:04:37.914Z" }, - { url = "https://files.pythonhosted.org/packages/c3/4f/bfdb0c67775a584f957211936661289789c4d0ec3e988bca2c8c02f8090e/contourpy-1.4.0-cp315-cp315t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a58b5f130afec61a093d743cdda435cc4047748b6791627e34a68f41ec3a9b07", size = 410721, upload-time = "2026-09-11T19:04:39.925Z" }, - { url = "https://files.pythonhosted.org/packages/65/d3/dddf13d6bb26e145c99debd1f2695018b5f3e24452dd9bfc9904c9d2ec89/contourpy-1.4.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9dc493e7924e5aa32d89d0b8e50986280f3d4b284968a792787bd7bcffd3cad", size = 382604, upload-time = "2026-09-11T19:04:41.793Z" }, - { url = "https://files.pythonhosted.org/packages/37/b4/89f0a5f6c6ccd322eae2704c7e933c82a4e594a61773933b4b0a18646866/contourpy-1.4.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:c927ec747633e68c9920bcaad48b0bcefe648812b822f18c34e2d0e2270a3d2a", size = 1354996, upload-time = "2026-09-11T19:04:43.856Z" }, - { url = "https://files.pythonhosted.org/packages/5d/29/fc91cc1ac6f3b591a269f4357b656d6ed0c39eb17c148f41f6da2f9729b9/contourpy-1.4.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:80c7fc8e7ac217777ce7ba2db3cf8478652b5a4b335283111012ff9eaac9d842", size = 1425030, upload-time = "2026-09-11T19:04:45.981Z" }, - { url = "https://files.pythonhosted.org/packages/d8/b8/bb129f524210c5d3eb8616876e02af280fba8b5554e0ac1afad80b51e06b/contourpy-1.4.0-cp315-cp315t-win32.whl", hash = "sha256:90feb8da006803a95ba573fddc9536357d0c3faddc3a4c9e22816ca49af52878", size = 371854, upload-time = "2026-09-11T19:04:48.106Z" }, - { url = "https://files.pythonhosted.org/packages/d8/b1/bc774d9d200cfc4f073bba78b1bb1cb3b55bf1c8c345eae41fc2db510459/contourpy-1.4.0-cp315-cp315t-win_amd64.whl", hash = "sha256:de503609fdb71f597634ca64fb4bd2f13d27c97f140e912b1e8ed93544840df0", size = 262450, upload-time = "2026-09-11T19:04:49.502Z" }, - { url = "https://files.pythonhosted.org/packages/54/bf/eb4ff492e1c3d027617b5a20e4e6d63a035b41b8cf77a95950159556a3a5/contourpy-1.4.0-cp315-cp315t-win_arm64.whl", hash = "sha256:78ed5c5f962b3e156109f0531b174a65ba80d13cc681a70227b12524c162e184", size = 587064, upload-time = "2026-09-11T19:04:51.148Z" }, -] - -[[package]] -name = "cycler" -version = "0.12.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615, upload-time = "2023-10-07T05:32:18.335Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, -] - [[package]] name = "distlib" version = "0.4.3" @@ -419,72 +177,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/36/d2/b70a31e13d04456d28493f31d2aa087e99eeb2767ef0293b2625727ccb8c/filelock-3.32.5-py3-none-any.whl", hash = "sha256:142cd9fa77a872c5e78c62329a0d15278fadc686eb89e760017968961a4fd6b2", size = 100003, upload-time = "2026-08-31T18:56:33.078Z" }, ] -[[package]] -name = "flatbuffers" -version = "25.9.23" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9d/1f/3ee70b0a55137442038f2a33469cc5fddd7e0ad2abf83d7497c18a2b6923/flatbuffers-25.9.23.tar.gz", hash = "sha256:676f9fa62750bb50cf531b42a0a2a118ad8f7f797a511eda12881c016f093b12", size = 22067, upload-time = "2025-09-24T05:25:30.106Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ee/1b/00a78aa2e8fbd63f9af08c9c19e6deb3d5d66b4dda677a0f61654680ee89/flatbuffers-25.9.23-py2.py3-none-any.whl", hash = "sha256:255538574d6cb6d0a79a17ec8bc0d30985913b87513a01cce8bcdb6b4c44d0e2", size = 30869, upload-time = "2025-09-24T05:25:28.912Z" }, -] - -[[package]] -name = "fonttools" -version = "4.66.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/24/86f9930b930b97fc82266083320f3c34643ef67261c32651e893db525aca/fonttools-4.66.0.tar.gz", hash = "sha256:ef0610dfe7bb5bf574d9bdad6f597403ebc9807d124ac6f148d7604b2609be98", size = 3692007, upload-time = "2026-09-23T17:59:14.345Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/c1/25821e3bddd0644df229cfdc2e8b4101d28e4da15ffbe6e6b563ea6f3acd/fonttools-4.66.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:67ea5af3ca60e1e5c9b2841b1f6dba5ef16aab581bf463abb68515447154ab61", size = 3096788, upload-time = "2026-09-23T17:57:08.302Z" }, - { url = "https://files.pythonhosted.org/packages/0c/3d/d16b4cb63710abd91f1aaa482f2848d8b0c402502de5b8a72ba9d2613cb0/fonttools-4.66.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:dbee639a23c4e067aedfbcf84d2e466a71a1a1156ad04d6d09023791ed167495", size = 2586926, upload-time = "2026-09-23T17:57:10.678Z" }, - { url = "https://files.pythonhosted.org/packages/e2/f0/dffa4fa83ec6780464e8484fa2f30ccc252d480137c8e4d3fc6ac510b3eb/fonttools-4.66.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:35684b562df7154d7a0ebfa512db9199c7fb0a93b5abfb59dac9d022dbee7aaa", size = 5415821, upload-time = "2026-09-23T17:57:13.174Z" }, - { url = "https://files.pythonhosted.org/packages/6d/56/3c862bdd297a272a37e2ba1149da01446fd3f387ee80855699bb939bf5b2/fonttools-4.66.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aea0cc5609a5f2a2500f91bd6e1989fdd22da0f225be00dc1c4cec775838a7d0", size = 5396689, upload-time = "2026-09-23T17:57:15.987Z" }, - { url = "https://files.pythonhosted.org/packages/93/d7/f4b7c81a7a6b316b7226d11f8e8659f023babf1e0957f8e7ddcc4e155a82/fonttools-4.66.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8af85dfc2ad564f95b3e650024578bb2268bc4d827d418aab9650bbde4b6926a", size = 5353218, upload-time = "2026-09-23T17:57:18.803Z" }, - { url = "https://files.pythonhosted.org/packages/70/1e/f12bc535f3da359a4d29d5e7442ab2d7d67579d1516b9c7e1a38890168bd/fonttools-4.66.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2b278e2abe596872b3c9fe611f956ded85588537974e48e2adbe2d6ee159e099", size = 5516965, upload-time = "2026-09-23T17:57:21.077Z" }, - { url = "https://files.pythonhosted.org/packages/14/25/a1df66888c8b5f63c14e089400f2353da0c1ff9d09683cc0bf85afea348e/fonttools-4.66.0-cp312-cp312-win32.whl", hash = "sha256:ee7b9f6c835ea1a9beb5a35c8eb0c62b6a3290f8105d3d0c29a0a18c90f9b8ba", size = 2434425, upload-time = "2026-09-23T17:57:23.463Z" }, - { url = "https://files.pythonhosted.org/packages/b5/f2/fd490f1ddf2e430289c13059982771e6b02a2bf37504401d2a5c53307892/fonttools-4.66.0-cp312-cp312-win_amd64.whl", hash = "sha256:1d120ea0f5260b04e9b5ac0d9239efde4c23d990347d5d5889cdd37221726fec", size = 2486335, upload-time = "2026-09-23T17:57:25.561Z" }, - { url = "https://files.pythonhosted.org/packages/a0/c8/1cf0a4f54c8cf351a4d9f8fcad5201dc0c5fb8676efdb1ba7cc3136ca523/fonttools-4.66.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c144e68572af1dfcb05fe065827d0920d4118b265504928e1d2262e3701a25a1", size = 3094615, upload-time = "2026-09-23T17:57:27.942Z" }, - { url = "https://files.pythonhosted.org/packages/c5/ed/89baf1795841eaad6fcf0ee6f319c236f4379dd98653b4bff294b83d4172/fonttools-4.66.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ada87643b20a8fa5763e6dd4fa0f5e2901cbdbe0833deb6d5c5a9c3186782b94", size = 2586645, upload-time = "2026-09-23T17:57:30.248Z" }, - { url = "https://files.pythonhosted.org/packages/56/f9/982609322f92532bdd769a225c9b3e2bbac0a077c37f7780710db9a8e018/fonttools-4.66.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700c448f190cc7bbd9f059bd96131f4770e90938c9692550b142153bea73b14c", size = 5389194, upload-time = "2026-09-23T17:57:33.07Z" }, - { url = "https://files.pythonhosted.org/packages/7d/22/12177031dd8be493d5afda574323da19f6ea5488c85202f3a5708c94757c/fonttools-4.66.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c519433e8632284dbe64ee4c81b609a17e205bc1afff285553723964880d1c89", size = 5371422, upload-time = "2026-09-23T17:57:35.915Z" }, - { url = "https://files.pythonhosted.org/packages/19/e1/e8ed0950eb846f4a404ff84aea89e4c93393c2475cdfdf1c03a1b3f4e2ef/fonttools-4.66.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8739d77810aeb6d2ecd55cc2e4864d98a00c317fb6131a6d22ca58b3f50cf22a", size = 5332183, upload-time = "2026-09-23T17:57:39.041Z" }, - { url = "https://files.pythonhosted.org/packages/3e/fb/c0310cf9838859d74130530ef351a903735c6ecf47418d7d0dc4cf459a9b/fonttools-4.66.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cffa168522a0e057d3a5628c8ebd43ddf66a34613f42e116bffb85b0b32ce5a0", size = 5490019, upload-time = "2026-09-23T17:57:41.945Z" }, - { url = "https://files.pythonhosted.org/packages/a9/7b/928f334aea18c77f6892895bb1e28388c9e2466e04bc924f93043024c780/fonttools-4.66.0-cp313-cp313-win32.whl", hash = "sha256:379b94fe10651d26caf398eb646b3ecde886a9c504d3cce5e46164d58a78e32d", size = 2432785, upload-time = "2026-09-23T17:57:44.484Z" }, - { url = "https://files.pythonhosted.org/packages/fa/5b/576072a97256f06fe6d0112c522c118fe72390482baf52ad9bfa3b0d1db6/fonttools-4.66.0-cp313-cp313-win_amd64.whl", hash = "sha256:ee4f85d1341af630d787514eef1a0e1ad883f9d6c11a4c1465faeb9cd0bb5ced", size = 2484475, upload-time = "2026-09-23T17:57:46.625Z" }, - { url = "https://files.pythonhosted.org/packages/38/e6/2ea45d294ce967a8f76b5dec041abdd71c7abb652c9a81b24592edc00579/fonttools-4.66.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:ce818581070527883e3678b92b1ac7ed3569e48c80f51b89b264f97d84be2608", size = 3098619, upload-time = "2026-09-23T17:57:48.843Z" }, - { url = "https://files.pythonhosted.org/packages/1a/d5/225d95df6b88df277d86c3d86fdaa73833cd628dc2918fcf7b73133d6f67/fonttools-4.66.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:cf0033f343cb1592fb24ae3eaf8b58156f208884ac16991094293ee94c585654", size = 2588128, upload-time = "2026-09-23T17:57:52.041Z" }, - { url = "https://files.pythonhosted.org/packages/15/e6/5892522733dc50b0577693bb164032b47d3504f33ee44d92a1a740d2e89b/fonttools-4.66.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3889fdbe63e85dbf2a0d9d7c9d90746827ce60af2cdd24f0bb4738380a9540eb", size = 5373639, upload-time = "2026-09-23T17:57:54.555Z" }, - { url = "https://files.pythonhosted.org/packages/9a/72/72ec66154babfd53a1c705a9f973c49d12fa81084aa72d939e062242ada1/fonttools-4.66.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:850d50b347f31667d3a277a6e66a4219236db9d98afadb2993b8addf9bd4f593", size = 5317190, upload-time = "2026-09-23T17:57:57.31Z" }, - { url = "https://files.pythonhosted.org/packages/cd/c6/a8ca0334c73d1fa4fae1d423ff92e0b0be0885ed98e866e3478a00ad1a47/fonttools-4.66.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:221defad3b1949c1fdde2558d1fe780d42f926b683a71a46a4495a0e6a6614bd", size = 5315603, upload-time = "2026-09-23T17:57:59.842Z" }, - { url = "https://files.pythonhosted.org/packages/d2/34/d3d4cd925d1a934e841d39a0540539b21e952c40dfac27874bac1392f3d2/fonttools-4.66.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b02686b2e47115b2378ab6156abd984c776c6faa4f56898fbcc1c30ee387937d", size = 5448784, upload-time = "2026-09-23T17:58:02.669Z" }, - { url = "https://files.pythonhosted.org/packages/61/b9/269bebce24f590d28f2e59fd8808f3282b5f5511a4a95c5f6413f0507993/fonttools-4.66.0-cp314-cp314-win32.whl", hash = "sha256:b46500e4d6f5708a9127ea12902ba54cb0ec594fc6bec649afc53154a1125b2b", size = 2437006, upload-time = "2026-09-23T17:58:05.39Z" }, - { url = "https://files.pythonhosted.org/packages/ca/bd/9c10c9a62a274451f10295c17a01286c7777f44da87cd9f77d08c5a21d4e/fonttools-4.66.0-cp314-cp314-win_amd64.whl", hash = "sha256:0cf33d1041364ebb7a9958db757e213c6d2a238eb41755fbf935db028c2db20f", size = 2489086, upload-time = "2026-09-23T17:58:07.516Z" }, - { url = "https://files.pythonhosted.org/packages/1f/45/c9bc900dca5ed7760c1692a6b1161c380515071da9d855eddedd4bb21fb5/fonttools-4.66.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d2d10f89c4bf7cd42b84695da617f9130fa594647ee74673bf8c8d001fa04ef9", size = 3172290, upload-time = "2026-09-23T17:58:09.975Z" }, - { url = "https://files.pythonhosted.org/packages/bb/82/1017a312eeb373f30d99d032d04bf55a8ea80858f1670b2e12ed90732574/fonttools-4.66.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:135ce8738a6341fbefeedc061e20c2889350e84405abea7e61b1c9d87e1a1f2f", size = 2621293, upload-time = "2026-09-23T17:58:12.309Z" }, - { url = "https://files.pythonhosted.org/packages/af/c3/b54678b08a6637d5a222b68e1f133c58096c664403b9ead1cce4eab53fd1/fonttools-4.66.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:107aae7c38f05561fef5103baa98a44400b5f4c69d87839c9a5f8125405e7d9a", size = 5546296, upload-time = "2026-09-23T17:58:15.041Z" }, - { url = "https://files.pythonhosted.org/packages/98/2c/42e1c39e4025323bf65e042ea7ed709912aebfa22d422a2937da12268331/fonttools-4.66.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e4677025ee40b3b1420387ca85f55545b8095f7596c3d5d59552dbee19c91f07", size = 5352908, upload-time = "2026-09-23T17:58:17.77Z" }, - { url = "https://files.pythonhosted.org/packages/f1/66/07a6b9e634d7e0edb3e8f23ebb483479599842093624f13bde44e3c7a4bc/fonttools-4.66.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:03922992966a1830b94a750961d1ccfe1a7375d792ca61077a5afb719811788a", size = 5413677, upload-time = "2026-09-23T17:58:20.511Z" }, - { url = "https://files.pythonhosted.org/packages/a4/c6/d649d4d09ebbfdb0eb13cdeea731bf9685d184fef716e0c18f7779eb44fb/fonttools-4.66.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5dda4087a7f8f0b985995489b3c0115bd117f18c0fc236013a0e78e837bb9ac2", size = 5449024, upload-time = "2026-09-23T17:58:23.302Z" }, - { url = "https://files.pythonhosted.org/packages/04/4c/60c6c9a2bc4cc0ae1012df9084bfe4ba69a1a60df06f30830787a4e6c061/fonttools-4.66.0-cp314-cp314t-win32.whl", hash = "sha256:85e2960a29e6882567b981ba408b5a5ca2d8865b8f67240e1b420348997a13be", size = 2470760, upload-time = "2026-09-23T17:58:25.739Z" }, - { url = "https://files.pythonhosted.org/packages/d1/5e/fd8593bbe3da06d2cbf9c096e17becbb48ace6e7a33e0e5e21f82a55c32c/fonttools-4.66.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8ea6265535b25f8254868f5a212a1c9dbd88c2c4050f3df18d9174949df0d6d9", size = 2520614, upload-time = "2026-09-23T17:58:28.078Z" }, - { url = "https://files.pythonhosted.org/packages/a0/23/f9f265526c6a4096bbe67f45d6ed853519d49ad7e07667bd0c8edb33f7fd/fonttools-4.66.0-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:9b0198962bbba64a88bf15dc5d95e4bab8c5208dede22a10fb06280406ed9874", size = 3092996, upload-time = "2026-09-23T17:58:30.828Z" }, - { url = "https://files.pythonhosted.org/packages/a9/25/44d594d7fd703b2c4914b0df728012b8f2e69578de2d9238742c1754243a/fonttools-4.66.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:15315302c620e6ce8582934e07932dcf442fc3f59ab208d30564200b9a2f7ea4", size = 2586696, upload-time = "2026-09-23T17:58:33.159Z" }, - { url = "https://files.pythonhosted.org/packages/b5/73/b33888692f06471222a66a79bd7f3e7b9aa9e3d87be6adb794a2ba3dc467/fonttools-4.66.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:056641fcedeaad24b92e343e35e9fd1337200c102450678894b17928482d010d", size = 5370868, upload-time = "2026-09-23T17:58:35.93Z" }, - { url = "https://files.pythonhosted.org/packages/34/22/b7fa275e29642ab706bdffc5d02564bf6ade4168bd4c8b98b6ac2a78a653/fonttools-4.66.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fa995d96ced196388d8ed7a67ee001b181459d230e24bb68b193ae859f5609da", size = 5334214, upload-time = "2026-09-23T17:58:38.966Z" }, - { url = "https://files.pythonhosted.org/packages/b2/69/417f550986631f9b443666b6152c9396830c0bb7a241b2aa235d1faa2500/fonttools-4.66.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:f014bbf05f30731bf8b9f5c7d2b4c0e4f28f375926f95b187f85e0bbceb16029", size = 5311067, upload-time = "2026-09-23T17:58:41.686Z" }, - { url = "https://files.pythonhosted.org/packages/8f/f5/80b8b3b55306975a69995b188d640664b53891509a47e6e19e5496adc529/fonttools-4.66.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:886f3029ea10362c592d2e1a5326a44645b85a2a0be6fdb7c9edbdb60cedb0ab", size = 5458838, upload-time = "2026-09-23T17:58:44.145Z" }, - { url = "https://files.pythonhosted.org/packages/f4/1a/7f5e8b4e0e876a94424faf039e99e09833c3ffb105978e3617a6cba58e92/fonttools-4.66.0-cp315-cp315-win32.whl", hash = "sha256:0640e69b00e089d6b82e0a54e3a03af84b775f1f0b2cbecc1b0d7fe3980a194a", size = 2435840, upload-time = "2026-09-23T17:58:46.482Z" }, - { url = "https://files.pythonhosted.org/packages/e0/8a/91ecd6ed7140e0589538298f941934eaccaf7ab067a60585de3a60ddcc32/fonttools-4.66.0-cp315-cp315-win_amd64.whl", hash = "sha256:a5a0624aaefb0a29e4ed7500a98732a1b0ec2d3040fe86cc8837f82e3fe4f446", size = 2488078, upload-time = "2026-09-23T17:58:49.027Z" }, - { url = "https://files.pythonhosted.org/packages/a5/16/3a82202c98d55aeb53dd1fe5cd295c53b1057a45e6be943a347dba5bc92b/fonttools-4.66.0-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:4bdaf80590fcccb1b9d8e3b8ca72225defb14d03b79788f59781ce1bb66049b9", size = 3163820, upload-time = "2026-09-23T17:58:51.661Z" }, - { url = "https://files.pythonhosted.org/packages/74/06/597c587ec929f18622d0f6ad4aac15a698e5d4e42d291cf051e63fe0d1b0/fonttools-4.66.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:398bfc5ce9055e0b91a6189f8cf462d4230b38f522b2ceaecb4f34cbf4fb5480", size = 2617537, upload-time = "2026-09-23T17:58:54.421Z" }, - { url = "https://files.pythonhosted.org/packages/cc/08/6477bf19e923deb834c4c5866c66f47a679cc0a31028e2240ea58f9c1244/fonttools-4.66.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fd9297a5f670f3e59ae697289ddad5902d3c4a86188e83e7823bde06334a560c", size = 5524223, upload-time = "2026-09-23T17:58:57.078Z" }, - { url = "https://files.pythonhosted.org/packages/69/2a/3e893827e4d6f8858784f71f0484046626a43e21492d3683b7ea5c7262bb/fonttools-4.66.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f2032ac005e14c56e774fd19f2bc1c22c796c0c3b39d91741c0e3f9575944d38", size = 5345920, upload-time = "2026-09-23T17:58:59.848Z" }, - { url = "https://files.pythonhosted.org/packages/22/7a/574cfd9f567231006152694a8904e1cfa2e7ddd2b7476e8e9d1b079278b4/fonttools-4.66.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:02117d05dddb51e39b5c5a6eed0702ed8e0e1c341cb138101b2446eb479858f8", size = 5392188, upload-time = "2026-09-23T17:59:02.461Z" }, - { url = "https://files.pythonhosted.org/packages/17/15/656c907ef5063eeb9a0f6e36a253c14ffe5d5605009d6c86140234cb0968/fonttools-4.66.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:6b11180c4166a4fc353bb02b147322b7c454927bba4f5040ccdef109a73f9051", size = 5438594, upload-time = "2026-09-23T17:59:04.967Z" }, - { url = "https://files.pythonhosted.org/packages/5d/82/5df8cfb6c16035a9f0baef4c7b37ae1691e5d9280195459f1eb6f0633014/fonttools-4.66.0-cp315-cp315t-win32.whl", hash = "sha256:6627f48130cdbe04c7e1189bff4192886ecda757f575626ee8468f6580eaecf3", size = 2467910, upload-time = "2026-09-23T17:59:07.332Z" }, - { url = "https://files.pythonhosted.org/packages/fd/cd/3359c485efaed245663bf6f840e1bb45fffa1230b1e9af1880d7d9003e56/fonttools-4.66.0-cp315-cp315t-win_amd64.whl", hash = "sha256:a9a45a23b020e667499fb2dbce0f1373b1b1b8cf88e47f3031e69ef3ea21f3ee", size = 2516298, upload-time = "2026-09-23T17:59:09.934Z" }, - { url = "https://files.pythonhosted.org/packages/83/65/826290863c9df6041f2e36a5ae5d604cd8247fffc8ec7b45581fd2473e4d/fonttools-4.66.0-py3-none-any.whl", hash = "sha256:bc7b7ddc1a1f46c363354304e9a8dd93722e4a6a24f785015650898dacf40df9", size = 1201583, upload-time = "2026-09-23T17:59:12.104Z" }, -] - [[package]] name = "foresight" version = "0.1.0" @@ -494,13 +186,14 @@ dependencies = [ { name = "asyncpg" }, { name = "fastapi" }, { name = "httpx" }, - { name = "openmeteo-requests" }, + { name = "interpret-core" }, + { name = "joblib" }, + { name = "numpy" }, { name = "pandas" }, { name = "psycopg2-binary" }, { name = "pydantic-settings" }, { name = "python-dotenv" }, - { name = "requests-cache" }, - { name = "retry-requests" }, + { name = "python-multipart" }, { name = "scalar-fastapi" }, { name = "scikit-learn" }, { name = "sqlalchemy", extra = ["asyncio"] }, @@ -514,14 +207,6 @@ dev = [ { name = "pytest-asyncio" }, { name = "ruff" }, ] -ml = [ - { name = "matplotlib" }, - { name = "numpy" }, - { name = "pandas" }, - { name = "scikit-learn" }, - { name = "scipy" }, - { name = "statsmodels" }, -] [package.metadata] requires-dist = [ @@ -529,13 +214,14 @@ requires-dist = [ { name = "asyncpg", specifier = ">=0.30" }, { name = "fastapi", specifier = ">=0.115" }, { name = "httpx", specifier = ">=0.28" }, - { name = "openmeteo-requests", specifier = ">=1.7.5" }, + { name = "interpret-core", specifier = ">=0.7.8" }, + { name = "joblib", specifier = ">=1.4" }, + { name = "numpy", specifier = ">=2.0" }, { name = "pandas", specifier = ">=2.2" }, { name = "psycopg2-binary", specifier = ">=2.9" }, { name = "pydantic-settings", specifier = ">=2.9" }, { name = "python-dotenv", specifier = ">=1.1" }, - { name = "requests-cache", specifier = ">=1.3.3" }, - { name = "retry-requests", specifier = ">=2.0.0" }, + { name = "python-multipart", specifier = ">=0.0.20" }, { name = "scalar-fastapi", specifier = ">=1.4" }, { name = "scikit-learn", specifier = ">=1.9.1" }, { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0" }, @@ -549,32 +235,6 @@ dev = [ { name = "pytest-asyncio", specifier = ">=0.25" }, { name = "ruff", specifier = "==0.16.9" }, ] -ml = [ - { name = "matplotlib", specifier = ">=3.11.2" }, - { name = "numpy", specifier = ">=2.5.3" }, - { name = "pandas", specifier = ">=3.0.6" }, - { name = "scikit-learn", specifier = ">=1.9.1" }, - { name = "scipy", specifier = ">=1.18.1" }, - { name = "statsmodels", specifier = ">=0.15.0" }, -] - -[[package]] -name = "formulaic" -version = "1.2.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "interface-meta" }, - { name = "narwhals" }, - { name = "numpy" }, - { name = "pandas" }, - { name = "scipy" }, - { name = "typing-extensions" }, - { name = "wrapt" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f1/87/e7b9b39d218793f159d7425e264c0ac196da07f1decc333001502fac02fb/formulaic-1.2.2.tar.gz", hash = "sha256:c99e8f11ff7d327eaecaf63855ca69b7fa0da100ad6c0041ef80912fbac667e6", size = 657522, upload-time = "2026-06-02T18:24:42.632Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fc/75/0576b03f7889ad25b5385b4f6c69e0543713425a6f193b056c9b0a2e65ce/formulaic-1.2.2-py3-none-any.whl", hash = "sha256:0f84ff49e3fc9dc0e68ab08a0a9427874021aa6c558e66b44dc634a35739b09b", size = 118939, upload-time = "2026-06-02T18:24:41.344Z" }, -] [[package]] name = "greenlet" @@ -744,81 +404,18 @@ wheels = [ ] [[package]] -name = "interface-meta" -version = "2.0.1" +name = "interpret-core" +version = "0.7.8" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/28/5b/202a48a1ccdef721a95357fbe263c54ef87c18c4972df1ab5c22fe0f33d5/interface_meta-2.0.1.tar.gz", hash = "sha256:902bd9a95a12f195f15753a1080075d4eca7a2cb934fac7ac03c9e362b50796a", size = 15281, upload-time = "2026-04-30T22:35:36.882Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a1/d3/20a61a248feb1249cd81f83ce731f0bdf5170ed96a08a298f7081cda9e90/interface_meta-2.0.1-py3-none-any.whl", hash = "sha256:f38016bef9a4429b6d0792d809be7b65e9781820c674bf7f463999086b6e6323", size = 15330, upload-time = "2026-04-30T22:35:35.201Z" }, +dependencies = [ + { name = "joblib" }, + { name = "numpy" }, + { name = "pandas" }, + { name = "scikit-learn" }, ] - -[[package]] -name = "jh2" -version = "5.0.15" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/57/fa/a3ba1417800b142d6777c48657f6b01fc46bd1a4777aba979463e36d78c5/jh2-5.0.15.tar.gz", hash = "sha256:53fb377caca50441fd1892f60745c44d3ba8c43844c3792e73c34bf9f841adaf", size = 7336589, upload-time = "2026-09-21T09:26:30.487Z" } +sdist = { url = "https://files.pythonhosted.org/packages/72/85/a89ccbd5c6a318ba2a0de49febc956a49959ff668e68a407f0943e52ab0c/interpret_core-0.7.8.tar.gz", hash = "sha256:39512a4a3971d47c92f50c97f434aa603ab1449aad6d94f09a1d1c072a78dda5", size = 1347723, upload-time = "2026-03-17T21:54:29.282Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/cd/accbdb5274bc4bee11fc7d8a1b94fd8ffcfd8efbee02765afb6023373f84/jh2-5.0.15-cp311-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:855dea668a30eab826ecdbc07b69b19e8daedeb1f0519e02759a6b37426354c5", size = 631133, upload-time = "2026-09-21T09:23:08.375Z" }, - { url = "https://files.pythonhosted.org/packages/b8/a7/2b63b9ad01c4d9e7742a528501ab9d57503dbc968b1e40cc0084f9c8dba3/jh2-5.0.15-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cd129c07f30387463e634d7aec629da31b041219e17c6d42fc7a7641e1e7ef8b", size = 401835, upload-time = "2026-09-21T09:23:10.674Z" }, - { url = "https://files.pythonhosted.org/packages/c0/2a/31f3046c4ca641aa462deb35477e3eb88ad74c52fa0a8003b4c2514d5369/jh2-5.0.15-cp311-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f702abcd2e3666d54f721629ec921a1e698357e28f6348af7b4d9d95b89fe37", size = 404159, upload-time = "2026-09-21T09:23:12.533Z" }, - { url = "https://files.pythonhosted.org/packages/a9/e3/b13b1fcb66c8668cd642849aa7858b9a33a1df99d96a5dd68115a7b96d58/jh2-5.0.15-cp311-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:9698673b2cccac7a27dd4ae48d6f4936c998a7962043049783dab4fd03e65084", size = 529332, upload-time = "2026-09-21T09:23:14.35Z" }, - { url = "https://files.pythonhosted.org/packages/e8/ad/5a903ea3cef3d75751878ce46f2e49f1b32de8fba7a26bebfcaed1d6f8fc/jh2-5.0.15-cp311-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9695b0f3c696cb87b851b3f28d5dc81d8932c81a1547713913266b88ee99e37a", size = 521994, upload-time = "2026-09-21T09:23:16.823Z" }, - { url = "https://files.pythonhosted.org/packages/0b/a3/44195ec39fa7e38b182c3db025b8dd99799cad96bdb50f999f509b16cc10/jh2-5.0.15-cp311-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d71f6aa2c97f8042669102200a1a2dc1241e0fb5044cc14032d0321936f85cd0", size = 421130, upload-time = "2026-09-21T09:23:18.567Z" }, - { url = "https://files.pythonhosted.org/packages/55/fa/d80e721e77714ccda44437da86b8424dd03ce07977c8f7f0dc3d6efd805c/jh2-5.0.15-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ea4ac921ddd2d519646df9b94f96e2cd55529f92f6d7ff4c65508ca2f65399ef", size = 407364, upload-time = "2026-09-21T09:23:20.197Z" }, - { url = "https://files.pythonhosted.org/packages/6d/06/f3ec546c42ce68476b33113d799bce960cc1cd05cfc05e7e352d10a16ec3/jh2-5.0.15-cp311-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f0257484827212950bb4624dbfdf70d713dbaed5b1c489151b41f525fea3c1", size = 424773, upload-time = "2026-09-21T09:23:22.387Z" }, - { url = "https://files.pythonhosted.org/packages/19/f8/0939b4cfc3ea9b5c7e372b8c9c8302f2d30ec20d8f92525874135f5395ff/jh2-5.0.15-cp311-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:7f1f45e3a7f4b13ec10e9a55d1188fb64f090a24fd847ad124fde4da3682e8ab", size = 578499, upload-time = "2026-09-21T09:23:24.344Z" }, - { url = "https://files.pythonhosted.org/packages/da/82/ac555673b970e536621d27b6864c097f34a186fef24056dae4c4df9ba1c6/jh2-5.0.15-cp311-abi3-musllinux_1_1_armv7l.whl", hash = "sha256:1a7f6ee93c57672f7bd7c3e2c2f841a35c1a0c3571c39abc674e125f344242fa", size = 680124, upload-time = "2026-09-21T09:23:26.15Z" }, - { url = "https://files.pythonhosted.org/packages/21/21/2761e9fad9a436ffdde17ff29c7816c511caca22f1749f342b21ab270f0d/jh2-5.0.15-cp311-abi3-musllinux_1_1_i686.whl", hash = "sha256:7bddf19067d411e900cc19fdfee566cc3bc0aa1b76d07666202a8fcbf2c980e5", size = 641709, upload-time = "2026-09-21T09:23:27.909Z" }, - { url = "https://files.pythonhosted.org/packages/fd/fc/2a601d517907fc65ca6958874fed2a6ea0b6300d628ec6afdfed7f636bde/jh2-5.0.15-cp311-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:bfd24d34ed13b5943d5d548a86fd0efaa5029aaa6615932e677ea14d1a47324b", size = 610414, upload-time = "2026-09-21T09:23:29.63Z" }, - { url = "https://files.pythonhosted.org/packages/4e/bb/85fb291a6c971417c6793c57f7cd76e2f70bef4d9930b5fcf9efbc618002/jh2-5.0.15-cp311-abi3-win32.whl", hash = "sha256:03ef4cea5c924a89d3747fa5eca768763e727326ddf8d2ee20b66f4cd2942161", size = 254216, upload-time = "2026-09-21T09:23:31.173Z" }, - { url = "https://files.pythonhosted.org/packages/78/e2/55d6701b78285b8b6d595700b5c7179752941e4212ef873960e5e700a566/jh2-5.0.15-cp311-abi3-win_amd64.whl", hash = "sha256:5a32cd4bb2fc7f03315b3415b0d1f37b204f4334b352e68c25e3e31f0750b7cc", size = 261552, upload-time = "2026-09-21T09:23:32.637Z" }, - { url = "https://files.pythonhosted.org/packages/44/bd/f2fc94e23a2eabd613c83b8fddcfd7e1be20c91545cc90762a4deb088fa9/jh2-5.0.15-cp311-abi3-win_arm64.whl", hash = "sha256:4d0b98c5c2f27d7dfcec835960f9e4c76c7936f4bad1c94ab323f8f0831295c6", size = 258453, upload-time = "2026-09-21T09:23:34.105Z" }, - { url = "https://files.pythonhosted.org/packages/ad/b7/08fc98d041e3054aa412e8a596994450e2df21c7bf8630d58e44773caf30/jh2-5.0.15-cp313-cp313t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:3d4a44a6e28b1fc653795b5e34bd910ae90022f8bd25320a0e9e70851864a154", size = 634869, upload-time = "2026-09-21T09:23:35.61Z" }, - { url = "https://files.pythonhosted.org/packages/70/d8/01da7a605dff616785e941f8b1486efc6134e81b93dedafdeb1bd6a650ff/jh2-5.0.15-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:43ae82fbeada5b1a8c9460d83ed2a527e3284b14538f0f9336ebdee137d59ef1", size = 401920, upload-time = "2026-09-21T09:23:37.393Z" }, - { url = "https://files.pythonhosted.org/packages/e2/69/7554341fd992b3e1ae64a20a8f3cc07ad6c1371c25656b6650a2c3862f65/jh2-5.0.15-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a8c934fc618a663796f104f6f10519e27a45020f4163e4ff9ff3ae2bb9254cf2", size = 406607, upload-time = "2026-09-21T09:23:38.896Z" }, - { url = "https://files.pythonhosted.org/packages/4d/98/66bc2b44fe444180fc847633882d9f1d7f69fe48929c2e8f53dbb295830d/jh2-5.0.15-cp313-cp313t-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:249b38e59fa0e5e4c1f9dfb935e30fa2f3a20bbeb547f9725494fc2c62f59d58", size = 528423, upload-time = "2026-09-21T09:23:40.736Z" }, - { url = "https://files.pythonhosted.org/packages/6f/ef/dcfb83b72a15731e92f1b06ecea85e71052f362433adabecfde158931022/jh2-5.0.15-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c69436326bb70f759695b58f9630628c5e54d3c2c3efda819ae402df30644f28", size = 519933, upload-time = "2026-09-21T09:23:42.324Z" }, - { url = "https://files.pythonhosted.org/packages/79/be/bf04f4bf6f5314760aaf3536d0e7137bad96822a8428e42ec19a9432f90e/jh2-5.0.15-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:56746b5542a0181e2717af4ae151f4c51761de9999e227d7022d98593b0d3e8d", size = 423079, upload-time = "2026-09-21T09:23:43.737Z" }, - { url = "https://files.pythonhosted.org/packages/57/8b/0404495d2e201a0a1461be9ed9e33d586a53975e6e383b3144f70bf95de7/jh2-5.0.15-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:512d9e7387e7d123f42afa3ce6867c71d071a73342c359263bf29b3328e48b7c", size = 407070, upload-time = "2026-09-21T09:23:45.515Z" }, - { url = "https://files.pythonhosted.org/packages/24/41/6d8a16b983aad43d471022ab7ccb98e953e8a8f64c4f20bf54da1fe01fd7/jh2-5.0.15-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:727859bf2579af904b70294a5f8e096776396be8410459c635e87742ba3ac23a", size = 425687, upload-time = "2026-09-21T09:23:46.97Z" }, - { url = "https://files.pythonhosted.org/packages/03/ae/c643cd0cc0260557dc6b1b3c59389b5edb1166e30ed170b757424029a424/jh2-5.0.15-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:778e72cc6a7ea3273ce290c0a71ad907cb3482d70df33e625c76f180754ff2d3", size = 578424, upload-time = "2026-09-21T09:23:48.43Z" }, - { url = "https://files.pythonhosted.org/packages/a1/5b/cf84e2563f25faf777beeec1bc4b7b44e353b252a87354951d91d98ceeca/jh2-5.0.15-cp313-cp313t-musllinux_1_1_armv7l.whl", hash = "sha256:835c5aab444a7bd3694c1941b27e0da85ff7476629812b7d6b91efafcf1cdd28", size = 683048, upload-time = "2026-09-21T09:23:49.918Z" }, - { url = "https://files.pythonhosted.org/packages/5a/7a/5edc713b7b86587f33c209cf911f8b9139967310a55f32ab92840fcf3c1e/jh2-5.0.15-cp313-cp313t-musllinux_1_1_i686.whl", hash = "sha256:e33d7d2213b74c860f0ec5731484059e00e729debb3a385f8120f0b92af7a277", size = 642329, upload-time = "2026-09-21T09:23:51.393Z" }, - { url = "https://files.pythonhosted.org/packages/2b/72/f1e910a74ddb000f20353f5ba59e3dd5ee3d133f4d45ad4378a9ae55eb7e/jh2-5.0.15-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:ba3ad560f3f0b9efbc4f3d8ee0b28fe010ae39918ae46203ff1f9446b33491c0", size = 610518, upload-time = "2026-09-21T09:23:53.375Z" }, - { url = "https://files.pythonhosted.org/packages/96/c4/92a57264dc432556c5fb90b75d7d3202bc2128ad6d5f14aebf18fdb72c96/jh2-5.0.15-cp313-cp313t-win32.whl", hash = "sha256:faa0733a4c2dd0b38200d665bc2f33f9dc33d0fcb353e00744da555570816527", size = 255378, upload-time = "2026-09-21T09:23:54.928Z" }, - { url = "https://files.pythonhosted.org/packages/28/79/1cd633016cbdeb12c4743e8d1d54bef2f669f5c5514ab6b483e3351901aa/jh2-5.0.15-cp313-cp313t-win_amd64.whl", hash = "sha256:4fbc6e3784e778a834a6201735c3b71505393a471c336e9f518c07ecbf65f07d", size = 263494, upload-time = "2026-09-21T09:23:56.386Z" }, - { url = "https://files.pythonhosted.org/packages/5f/fa/fd28891b1a2eaf3291738381683aa1971479e366a0ecbcadd41a21c3bce4/jh2-5.0.15-cp313-cp313t-win_arm64.whl", hash = "sha256:5364883ba707e689167e37e795f2e5b04cb69379cd716cdef00c6137437679b8", size = 260108, upload-time = "2026-09-21T09:23:57.756Z" }, - { url = "https://files.pythonhosted.org/packages/e6/ac/594165f15b860cca56264eda0a174e01d1ae857b954ea748cd378f0a4db4/jh2-5.0.15-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:136f1f06d644e105ace8e830a55c66bb6be15355af427c013cefabbb4360889c", size = 634614, upload-time = "2026-09-21T09:23:59.368Z" }, - { url = "https://files.pythonhosted.org/packages/e8/ea/519be71bc4188ee72becdf4c0dbd1c30db5d0d4d9121d3bdcba9ebd9f948/jh2-5.0.15-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7aae38ee0dea5f3af2a918597df28f7659d1e82d1795605ee46c5d291d7e8ff", size = 401257, upload-time = "2026-09-21T09:24:01.129Z" }, - { url = "https://files.pythonhosted.org/packages/1d/f4/c27efd5a931f172fbdf69af1a75abd0433798cef646eddd5f47673f04774/jh2-5.0.15-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:34f864522d4fafd8a61bf40dc01e2a93840e6c1a17dba37db008529be22ef40a", size = 405697, upload-time = "2026-09-21T09:24:02.605Z" }, - { url = "https://files.pythonhosted.org/packages/35/1a/ae7cf874b48babe926fc1b3b861892e291bee7b59387a921dfcacc88f146/jh2-5.0.15-cp314-cp314t-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:783463574dcac3a6d8ae948e2110bb1b46099c79b919912c88360d21b7c91b5c", size = 527107, upload-time = "2026-09-21T09:24:04.702Z" }, - { url = "https://files.pythonhosted.org/packages/c7/50/e2dafa5435a02265eabe747b58c4addcc917b0f8b76fbabf2a0a1627f697/jh2-5.0.15-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7d6069850eb0febdcdd649a4f5dbe74560ea6219ddce467808b4b4bf532c14e3", size = 519280, upload-time = "2026-09-21T09:24:06.195Z" }, - { url = "https://files.pythonhosted.org/packages/11/95/ef4cc29497ddd194c681af013dc47875c807bb20dbf66d8235c4261b1adb/jh2-5.0.15-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6af18cc6b061b5830b121fff1cc876e864e881909c446fd606903e64a9796567", size = 422291, upload-time = "2026-09-21T09:24:07.918Z" }, - { url = "https://files.pythonhosted.org/packages/8d/c5/67eb11e17093e2ac5c5efe118a48ce0ab94eac8a66213721406c67a1517c/jh2-5.0.15-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4aaf2dd05d85289cf83641f3c4f54947a0168fa81c46fc9038ea9d241caad50e", size = 406956, upload-time = "2026-09-21T09:24:09.35Z" }, - { url = "https://files.pythonhosted.org/packages/9f/de/6456d56f796304fb3ead01125b9eff8301e03c8277f505b5e34cc8a50b08/jh2-5.0.15-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bad01872ec803e67d91228fe5f040175c86bbdb0b345be4ce0c5ccd620a220a0", size = 425105, upload-time = "2026-09-21T09:24:11.058Z" }, - { url = "https://files.pythonhosted.org/packages/06/e2/9cb3bc8a54a646d837fef8427728417d252aff3c470725acdc41a89394a9/jh2-5.0.15-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:c201b89f9d15f563fe4d6dad0738ce02641636c13b141096ad7497aa7bd95b3c", size = 577255, upload-time = "2026-09-21T09:24:12.963Z" }, - { url = "https://files.pythonhosted.org/packages/4b/c7/8a71b2f0bd38b94208c34396108310a42d71b214a8c6a960771a79804cc0/jh2-5.0.15-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:b43d69d715f77d840d5600842a179d8175625bcb5f5d8dbe9bc7ed71dcb43696", size = 681961, upload-time = "2026-09-21T09:24:14.732Z" }, - { url = "https://files.pythonhosted.org/packages/87/87/727968778eebb3e328c37c24435c7cca91443ace85a83da964ad35787017/jh2-5.0.15-cp314-cp314t-musllinux_1_1_i686.whl", hash = "sha256:4a511057fb3d8bc6cb76eed0645a8a446e870640d9250144f0d403007383e2c3", size = 641875, upload-time = "2026-09-21T09:24:16.134Z" }, - { url = "https://files.pythonhosted.org/packages/8e/d2/422129114ce5a243a33d27b93a9c67893400ef648c1e5b0b7472ca1de516/jh2-5.0.15-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:c4a2c16fae658a2f7e67d1af3f1cfaf03003cb2f6d9a8c1d3f237b337614d568", size = 609990, upload-time = "2026-09-21T09:24:17.512Z" }, - { url = "https://files.pythonhosted.org/packages/3a/13/ad4e1ea235fa5f79ffafb9ff7992758c4400cadf8931df63069ddb7162d5/jh2-5.0.15-cp314-cp314t-win32.whl", hash = "sha256:36aeaa24c9af2a2ac0830523721d0fadad5602842d662aa0c5391b89ea3e7d1e", size = 255313, upload-time = "2026-09-21T09:24:19.188Z" }, - { url = "https://files.pythonhosted.org/packages/22/f9/d5642a254e2f6526c4ed09354d495451f4db2840e69a16afbecc2654620c/jh2-5.0.15-cp314-cp314t-win_amd64.whl", hash = "sha256:a88320003a21783b5ab5c605ba4b687f836b46dadedc55cfc3e64ca88c41b81d", size = 262881, upload-time = "2026-09-21T09:24:20.843Z" }, - { url = "https://files.pythonhosted.org/packages/b6/a3/663a4a10b42e2603b14fe7655ceaab716afec6cbf45584dbac4b409c0188/jh2-5.0.15-cp314-cp314t-win_arm64.whl", hash = "sha256:7eb050ada76d078cf27b7660451c0ee7b38e3f770222d852cc34d2f4cf0bc782", size = 259576, upload-time = "2026-09-21T09:24:22.449Z" }, - { url = "https://files.pythonhosted.org/packages/36/9e/a9a50a5176a720d9ea6752bfa36b4ee9655b26ff9f4870bd71cabb1ec63d/jh2-5.0.15-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:06cd5acc4ff28ac27cca5c2f0985d259e81bc29b845c521aae04440a369a00ae", size = 647460, upload-time = "2026-09-21T09:24:24.258Z" }, - { url = "https://files.pythonhosted.org/packages/ec/3e/df15495c118ae1749c74df00c4355dab7c44cd5859f3bd648573e1df2581/jh2-5.0.15-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b8912f22c97cdcb00edce434b61fa1d05af41ad07deb6bb1ed9e06348df11eea", size = 409994, upload-time = "2026-09-21T09:24:25.702Z" }, - { url = "https://files.pythonhosted.org/packages/8e/4f/3e1ce6fb6728de964ff87ced5574988bea1ae7306f087cad5cef17466bda/jh2-5.0.15-cp37-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d28771bccaa6944c4e606a44b1035ea11b2d684bd2c7c195ee3c3a9afd84443f", size = 414812, upload-time = "2026-09-21T09:24:27.574Z" }, - { url = "https://files.pythonhosted.org/packages/e0/21/988a70b062dfc1a78f210ebc17a98fce8592b4245b6c3d81cd9b2d865f02/jh2-5.0.15-cp37-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:1e0fda6e8a7b7364077a03749321f3bb9f01aa369ab659f63d241860f4b66bbb", size = 536476, upload-time = "2026-09-21T09:24:29.428Z" }, - { url = "https://files.pythonhosted.org/packages/8f/83/81cd4dcd7b24d010e525f25c6be71ffa019138585da62a8edc0753d2d0c8/jh2-5.0.15-cp37-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6596a9d05f049f783326e927b5363ebe0b1da6716835237ae32b642529b405ac", size = 527785, upload-time = "2026-09-21T09:24:31.392Z" }, - { url = "https://files.pythonhosted.org/packages/79/f2/e6bbd8667dd3516764026fef86367c9c6f79a8b4611b53324d7088dd99e1/jh2-5.0.15-cp37-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:29a0014ee53ffc8bf0581dd77270e25329397c55f4724bb18fddef9a105177e5", size = 429758, upload-time = "2026-09-21T09:24:33.175Z" }, - { url = "https://files.pythonhosted.org/packages/6a/0b/3118b778d2cbc2eccac1ee6007d47fb0030398f4a6df3425656b1a624347/jh2-5.0.15-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6cd7e4d259eab50e076e4b30ecbd937e55b319a58ad75642bdffb401455520b7", size = 416188, upload-time = "2026-09-21T09:24:35.151Z" }, - { url = "https://files.pythonhosted.org/packages/ae/cf/67c69906bae910700fc02acd0865a4ac3d247a50adc5e6ebc070cbfcc223/jh2-5.0.15-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:97299e27743ebf52d9544e596165700407b8b46394870dc374d6fd49c3251084", size = 434214, upload-time = "2026-09-21T09:24:36.709Z" }, - { url = "https://files.pythonhosted.org/packages/c9/15/ba3848b2ef78203f6d1fc570e41067fbd2494950dc1a94c580d10c3e6380/jh2-5.0.15-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:95443c602d6e7fa8aa5e49d19381cb5a39d53b79a1678e90a4c39f4f0f0542e5", size = 587932, upload-time = "2026-09-21T09:24:38.289Z" }, - { url = "https://files.pythonhosted.org/packages/35/b7/dd90d99d86381f4df44bf69ddc9a8b5e68c27ad795587b563fdcb9306d9f/jh2-5.0.15-cp37-abi3-musllinux_1_1_armv7l.whl", hash = "sha256:13fa1a5e98a8c8f55405e2e7c6f6a1e005d01a48f7510826ff34939f624b1ad6", size = 691155, upload-time = "2026-09-21T09:24:39.862Z" }, - { url = "https://files.pythonhosted.org/packages/39/cf/8d771ac927d49cc279841a592f4ae4bb2d962dcf9ca6952973e0dfc4bef2/jh2-5.0.15-cp37-abi3-musllinux_1_1_i686.whl", hash = "sha256:aa99840f9222928b0c9c0dd2b0c0308f5d1be8f6815bbf6f820746c51de54dd7", size = 651868, upload-time = "2026-09-21T09:24:41.316Z" }, - { url = "https://files.pythonhosted.org/packages/90/ed/0c7328a574efe79203af639e37072f25cdd70386a3d1ddd31dc44d92259e/jh2-5.0.15-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:723a0a4c5ac0e61c94cb275118c7a6a6aaf5fd56084b02632f4b080cc83c6691", size = 618661, upload-time = "2026-09-21T09:24:42.915Z" }, - { url = "https://files.pythonhosted.org/packages/7c/2d/10348c52fc0660729ad4fb5285c2546e2c879b326a6ff332764bd837b468/jh2-5.0.15-cp37-abi3-win32.whl", hash = "sha256:fa1c3885ed090c65054e4bbd983a407df0436121b1a841ba7fe1f678a7982c65", size = 261264, upload-time = "2026-09-21T09:24:44.774Z" }, - { url = "https://files.pythonhosted.org/packages/64/94/d702bcbf81730301bbac04c93e82e533bc59dcdd608b9ffd0e99717518be/jh2-5.0.15-cp37-abi3-win_amd64.whl", hash = "sha256:8c5850335fe11078b42fab606e5ac4a68c6ee8c6799864badc464f7839f06ec2", size = 269034, upload-time = "2026-09-21T09:24:46.445Z" }, - { url = "https://files.pythonhosted.org/packages/08/1d/c7cb221314fbe3c119a8c8e463a38bd70c3d9bfb2fa09f107399843dd353/jh2-5.0.15-cp37-abi3-win_arm64.whl", hash = "sha256:9493d5a20f6ba04dc285e3580bcffe64c0d61660c85aee7fc19e9adbaea55ef7", size = 266111, upload-time = "2026-09-21T09:24:48.202Z" }, - { url = "https://files.pythonhosted.org/packages/60/9f/f94e7a18e880824bef49a1f80b4ce081570a3c19e8a3937ad2fea0544651/jh2-5.0.15-py3-none-any.whl", hash = "sha256:0ed7246846e68109fef261fdb00534ca5af3363d48dbf881d06a2d0e3a814e99", size = 99140, upload-time = "2026-09-21T09:26:28.641Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ba/3075daaf19b8873f207a94e524f52f9a236836729828a64ee0281c723e00/interpret_core-0.7.8-py3-none-any.whl", hash = "sha256:6644f1498b8d247c5004fe691a9815ff0efc74af243f249f295a5e9f3202b483", size = 15596047, upload-time = "2026-03-17T21:54:26.42Z" }, ] [[package]] @@ -833,116 +430,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/53/84099323c2ec4be98d935f63c033ac4151ee83836ca1050ede3b3aadf155/joblib-1.6.0-py3-none-any.whl", hash = "sha256:3dbbf9f6e4b592a2357b854608e980fe6390d131d7a82f011a377ef2ebef7aba", size = 306115, upload-time = "2026-08-31T09:39:02.298Z" }, ] -[[package]] -name = "kiwisolver" -version = "1.5.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ba/07/bd78e6a8fae171ea041ef5bba3ed21a003522fa088834b069b1909981f30/kiwisolver-1.5.1.tar.gz", hash = "sha256:f1303ef2eec81262a4b708c3e858afe58d7c75ad91c1c05266eda7673369859a", size = 104395, upload-time = "2026-08-28T10:28:27.153Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6b/9b/65b302742389c6f96f2956bef5decf26011309feb2fc5d79613af18adea4/kiwisolver-1.5.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:63fb7294b768f444eb4b068965f2662f28c2fd4161e23bd60fcf3ff27b74c046", size = 123876, upload-time = "2026-08-28T10:25:27.44Z" }, - { url = "https://files.pythonhosted.org/packages/71/74/c21f339956f6f691b2ed7e31d5f3ae767304df6c460192739fc830853051/kiwisolver-1.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0ebdef3eae5336568147c39a55be6a2036ffde53faa9ca2d978989ae7c2da12c", size = 66487, upload-time = "2026-08-28T10:25:28.728Z" }, - { url = "https://files.pythonhosted.org/packages/84/e5/bdb34e21523e01dceda064d63713f3bdec91388af24fba1eca7ea5e85864/kiwisolver-1.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1798e83840c3f627246104c4d8a9639c60fa068adf9ce92b61791781fa8a68c1", size = 64660, upload-time = "2026-08-28T10:25:30.071Z" }, - { url = "https://files.pythonhosted.org/packages/fc/f4/dadfec469313c7f428efa7e84b4aba9732f813c13ea7131a24b7b008ef57/kiwisolver-1.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34633ecf50d16187ab8e5528b7a2530f2feb4e23f300db4672538b51cfc5cd38", size = 1477929, upload-time = "2026-08-28T10:25:31.495Z" }, - { url = "https://files.pythonhosted.org/packages/6f/35/09c58daac34e6f6ea5c6dee0094b422118e5a7c265586008a95fd135ac5f/kiwisolver-1.5.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d27c2123977cb9269c30a49ba45f03a4323017ef693e19db4ec9dbe1299a3002", size = 1278499, upload-time = "2026-08-28T10:25:33.375Z" }, - { url = "https://files.pythonhosted.org/packages/19/32/739765e24fbad29d13f83e546ea4abc215a78cea9d677ca09025b027724d/kiwisolver-1.5.1-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6a797a1cefc8b9c93170db580337e1fe3d011ad18b1299943231279406342048", size = 1296677, upload-time = "2026-08-28T10:25:35.059Z" }, - { url = "https://files.pythonhosted.org/packages/df/32/03304d1010e2cc45e5b3b52cef7e43fed3a2a5cd6c87a89b4a88e1d85b5d/kiwisolver-1.5.1-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2551cf9917af48ee7c4b29cc82320489508cf96fd26a51f6fc124de661cd44c7", size = 1346037, upload-time = "2026-08-28T10:25:36.705Z" }, - { url = "https://files.pythonhosted.org/packages/3e/57/4c49377bfd274450dd72ecaa13eaac32ea804a03363e4d1db0c5aa999ceb/kiwisolver-1.5.1-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:38f6e0deb4d0a4615efe0c4efc5990b06ae450ab50a0b321c0b078b6d238c083", size = 988248, upload-time = "2026-08-28T10:25:38.299Z" }, - { url = "https://files.pythonhosted.org/packages/cb/c3/38df144a08b6c5d75ca4504e5cc3141bb3bfef64c04f4ef48204f42711b6/kiwisolver-1.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bfd1de989b3330420e29de39352f5c049905c9e3ee67233a50d550e3d652c148", size = 2228722, upload-time = "2026-08-28T10:25:40.038Z" }, - { url = "https://files.pythonhosted.org/packages/e7/11/3221838a89cd64d9b386353e000cd8a296069a20fbe3584507fdfd5bebae/kiwisolver-1.5.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1209042a623ddfda5497e4066c7b77651dde8e1d3a9dd97599dc7e97f3b9b78c", size = 2325216, upload-time = "2026-08-28T10:25:41.699Z" }, - { url = "https://files.pythonhosted.org/packages/83/d4/075c219230697bb5db910d37262b9bacf880f92b4811a02ab81ed073a253/kiwisolver-1.5.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:26e8268480be5061d509e29669d59103c067a26377a56491630ece11762e3858", size = 1977689, upload-time = "2026-08-28T10:25:43.559Z" }, - { url = "https://files.pythonhosted.org/packages/bb/08/1d219c3c2dd960983d0d4da623d916e9de6385df2b0bab3d1af0e9b8fccc/kiwisolver-1.5.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:d79308fa689fac89cbcfbd4dbfc80b5f95c54c5a7fd4d194be221f9d33d026e6", size = 2491443, upload-time = "2026-08-28T10:25:45.242Z" }, - { url = "https://files.pythonhosted.org/packages/ba/d3/024208ec1079d273f1047468d1bdffbf38bb75b7b268090fd3a0301b9d9a/kiwisolver-1.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b03af77d77e50edba2030fd5f7c352ff209314b09030a3cba7c14edf9a09a444", size = 2295200, upload-time = "2026-08-28T10:25:46.984Z" }, - { url = "https://files.pythonhosted.org/packages/6e/7c/7b210498f9f92e1cd7855f260fa69ef056881087b199ee20c208f0e4189a/kiwisolver-1.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:06a6917674de9e0fe3f66f5430787f59a9f2ddb64af9b714eaec547e29ef5c19", size = 70748, upload-time = "2026-08-28T10:25:48.444Z" }, - { url = "https://files.pythonhosted.org/packages/94/61/ef0daa157c8bb23672f7423e0d14c39db1dc6ef8ed47e6bc54c9c1bef3bf/kiwisolver-1.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:ad8b9671348d7c8716715652ae11f85ed0eb99e265a2df2ca490577d69860b2c", size = 68324, upload-time = "2026-08-28T10:25:49.81Z" }, - { url = "https://files.pythonhosted.org/packages/08/c1/88018321d976f53c421e379c43bc6993e70ce0c8a3ec5edc4bfe102257f6/kiwisolver-1.5.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b6ae6a0328f0bc035741820fdeecdcd67bf4694eee03972e843663107122f450", size = 62272, upload-time = "2026-08-28T10:25:51.02Z" }, - { url = "https://files.pythonhosted.org/packages/85/d2/712bc17ea4f1d216034928069d612defbc6c95a471c55a7203a39faecb1a/kiwisolver-1.5.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:886fc26012f0e8b5f69d1cfe6d711f6b11f194621539bf8e6bb1c25c5dc82724", size = 64481, upload-time = "2026-08-28T10:25:52.22Z" }, - { url = "https://files.pythonhosted.org/packages/1b/e6/6c5380d676f43b6d918033962ea5e72360ca69e5a404154bc496b598ffdb/kiwisolver-1.5.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:aefe930d113798330e9462f7874542977869c0613cba3262e2de3a8d5dee8f3a", size = 66260, upload-time = "2026-08-28T10:25:53.387Z" }, - { url = "https://files.pythonhosted.org/packages/92/6a/7087f5822cc8bb272679641404b1966a42504dac9ee74e2b33840475a0aa/kiwisolver-1.5.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a5ca5aebae78a0bc13c1943af4af615d4966c5b650b05d5aa83b50e427196fee", size = 123876, upload-time = "2026-08-28T10:25:54.644Z" }, - { url = "https://files.pythonhosted.org/packages/5c/4b/9f385087ca09ee5ab9c09c6832561a7d2f7c78d3e5661d511e669f70e439/kiwisolver-1.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1ed0f5e49d0ceff8b72190824d9e59c062fbbc02c231b853112c78474b3f5ec2", size = 66487, upload-time = "2026-08-28T10:25:55.899Z" }, - { url = "https://files.pythonhosted.org/packages/47/8b/40d33ffd2f378094ed462e9a9a0907e59d4de9845e65a59561272da350d4/kiwisolver-1.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:77a4c8187a5948d7f8795adb765a3c7b553d07d86d88e43038fc32fc1fb9a3f3", size = 64673, upload-time = "2026-08-28T10:25:57.048Z" }, - { url = "https://files.pythonhosted.org/packages/ab/43/86aacc027959108b4c66eeae8b73cedb057dfa6eb3a335d05ad65197081c/kiwisolver-1.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:74ad5c3dad54a4641b4c28cd15ded70899d04459c6c7aeacafea716be97cce6d", size = 1477992, upload-time = "2026-08-28T10:25:58.479Z" }, - { url = "https://files.pythonhosted.org/packages/ed/40/b1d0369048c79733a32c8abb0f2718532e6630641368e33a81384246e844/kiwisolver-1.5.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e46b23a2da695c364124817bc01d970effd5483147f8d66a6a7167e3f6b851", size = 1278821, upload-time = "2026-08-28T10:26:00.121Z" }, - { url = "https://files.pythonhosted.org/packages/61/a1/fa71c1792272ff9461432715ae60ffb7e11a4d7ac3bf68961b9cab6c60cf/kiwisolver-1.5.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75d9b1cf8258462dbdc1eeda718c96ea7f079324c09067f6daabfcf37712b7fe", size = 1296805, upload-time = "2026-08-28T10:26:01.868Z" }, - { url = "https://files.pythonhosted.org/packages/a3/49/3f0bd94af8e06ecc47eb834b195a06f05d48711ceb2352c56d6835160f0e/kiwisolver-1.5.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fca690b00c4c48f6c2a547b0160ed511357093a4e4c9b47e0fadf3128066d89", size = 1346109, upload-time = "2026-08-28T10:26:03.59Z" }, - { url = "https://files.pythonhosted.org/packages/a4/44/3afe6ef9cf06d61220953a8963e94eca978491be1d9547cb01d82a1efa08/kiwisolver-1.5.1-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:876bbfd276473d3daffe30e8c975df4ed9429967b41a6cb362dbb5155b6f13ad", size = 988252, upload-time = "2026-08-28T10:26:05.306Z" }, - { url = "https://files.pythonhosted.org/packages/e4/30/a12bd7a7285a211e1747c3eec77b8c614dbfcc1dad942f7611a1a6921ae5/kiwisolver-1.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f942903fde7363d1d879057ec5de01310efda2597161784d752fa9953a01a71a", size = 2228846, upload-time = "2026-08-28T10:26:07.312Z" }, - { url = "https://files.pythonhosted.org/packages/a1/6b/233e2958abf0dab7b18d07e52f286e02e519d7651bfbbe97af9347564109/kiwisolver-1.5.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c90d3022d8a94778939cda8638c6c8da8fa757b8958dad7ec868ce29c87681b8", size = 2325583, upload-time = "2026-08-28T10:26:09.093Z" }, - { url = "https://files.pythonhosted.org/packages/42/8e/7673060a27b01405b580058510adef34687069d229800239f5e44682d4d0/kiwisolver-1.5.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8a34616dc2521cc8dc1d7d081734da63539f021ac0450ce950908340c6e7aa2f", size = 1978221, upload-time = "2026-08-28T10:26:11.127Z" }, - { url = "https://files.pythonhosted.org/packages/3f/7b/1b882fc1a8b4a0bb8084e7d1d85004116d08c92c0705d31f2928dec607f2/kiwisolver-1.5.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:8bf4df63592c2a66b4f8edc5df2544998c288aa02f96ce0acd880cd1de8c8127", size = 2491819, upload-time = "2026-08-28T10:26:13.348Z" }, - { url = "https://files.pythonhosted.org/packages/39/9c/426deb49e62c5f69464b64bbeca064d3b758a7506b8913d986ef34f4619c/kiwisolver-1.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d09037ca068d784ebc4aec290ef952ca27ac15dd9c0b5801a88c6e1096b83e6b", size = 2295520, upload-time = "2026-08-28T10:26:15.042Z" }, - { url = "https://files.pythonhosted.org/packages/f5/22/deabbb3ad6d918d74b7831b2d8ae7151b09d21c87974e5ee8a456f58c94c/kiwisolver-1.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:dc23390afe9f4ef9ac3bcc72a03a56eebbde03f4c571a32cb38f859cff9a6524", size = 70758, upload-time = "2026-08-28T10:26:16.504Z" }, - { url = "https://files.pythonhosted.org/packages/b9/71/02fa5c2fd92068bb8952847e70ee6c5cb280e7febe11653d17812acc53dd/kiwisolver-1.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:186884a58486651e3c217b6acea0a53eaa9498fdd472057c46f2f0fb5c25aad5", size = 68329, upload-time = "2026-08-28T10:26:17.658Z" }, - { url = "https://files.pythonhosted.org/packages/dd/87/2d5dfad0daf17dcc18d98c48ed2332fc3f051cf599e60be6182a30dd4cf1/kiwisolver-1.5.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0324cd2567259b7a095f6cf18a52b0ffc6f3de9e69528ff1bc0e7a37bd43ff1a", size = 62337, upload-time = "2026-08-28T10:26:18.778Z" }, - { url = "https://files.pythonhosted.org/packages/08/c8/83e1624f15d6262b470dbcc80b09979fd4d5b2ea3ddfc6b6e3327e235726/kiwisolver-1.5.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:74ea337e0ec3f6f342a36a4f1b5cd94dd9affddcd28ba9aae2905af932ee8c6b", size = 64513, upload-time = "2026-08-28T10:26:19.909Z" }, - { url = "https://files.pythonhosted.org/packages/c9/2d/827ec30eb07f528c08d8459ffb318ae91a56d793ee8acbea8b491f0ff906/kiwisolver-1.5.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ee9df1f0d77b9c6e94f4ac0fec533fbddd5ea3a327807f18d7b069ae019ded80", size = 66287, upload-time = "2026-08-28T10:26:21.078Z" }, - { url = "https://files.pythonhosted.org/packages/53/11/5c43a562529dad8def4b81e5e1877c612a7e0298105a5939b3b409d2079c/kiwisolver-1.5.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fc271a6f0a2126958f4090e5507b9da5848927dae331f8f763bd4aa642b3d2cd", size = 123940, upload-time = "2026-08-28T10:26:22.475Z" }, - { url = "https://files.pythonhosted.org/packages/64/db/9bd6c505c95128c258a55236bfbb3a7a3fb6023f863316b6d7d9f3c69052/kiwisolver-1.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9b3092d8992a1d69b7a59c3e39f35e1b9be327a17f68a7c35fc17329e337d6f2", size = 66493, upload-time = "2026-08-28T10:26:23.743Z" }, - { url = "https://files.pythonhosted.org/packages/e1/f4/b3007a3ed5c9be73f81161140684cf7d9bdb9c4b632f5f484d2a1c713fb9/kiwisolver-1.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c2306e8bb53601979fcb3fa09cc65e031876d9ae01eff2fcbcd7a84ef94d5bc1", size = 64720, upload-time = "2026-08-28T10:26:24.95Z" }, - { url = "https://files.pythonhosted.org/packages/a1/13/08188f0cafa3a800403e4ff62b9aad4e7a17f9c4c7e080dc8f18c64794cf/kiwisolver-1.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:18a0cfb124546a4c2e6087c5f3029c7f44b37c85b142e0ced71f73a7599ac208", size = 1475867, upload-time = "2026-08-28T10:26:26.393Z" }, - { url = "https://files.pythonhosted.org/packages/8a/3e/053bdc3c9abdb8f2606225eda398adca25c0c91ab90add8222a69db65ee0/kiwisolver-1.5.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34ec467940442c9943016fb2d4c81d1ba84351eeca2f1a78f8bc87f1ba0d414c", size = 1282865, upload-time = "2026-08-28T10:26:28.118Z" }, - { url = "https://files.pythonhosted.org/packages/5e/64/a44c341b36b610588cc2f1e89b3cae072a3119aa8be578e90987cd640751/kiwisolver-1.5.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a83ee7107df13abe42a54a6654670eef9bb39425cf2e27f65e0007465e1286ab", size = 1300865, upload-time = "2026-08-28T10:26:30.125Z" }, - { url = "https://files.pythonhosted.org/packages/60/5e/7e7d716dca38c714478b741257a5b4a321d9932b8d851551a136dcaf3984/kiwisolver-1.5.1-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bebb89489b279b2f5661bbbb2abcc87bcd4a46607bb4a5c966f04f1db6b8df9a", size = 1348071, upload-time = "2026-08-28T10:26:31.829Z" }, - { url = "https://files.pythonhosted.org/packages/10/1a/2b98fdda8bf45b7be317e48ed12393d44334394d315c74b81f4a14c0e31b/kiwisolver-1.5.1-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:509735237ae0d849e8a843551d423d2500d2e0a9ac1611a145658b29c0fb9f85", size = 992191, upload-time = "2026-08-28T10:26:33.544Z" }, - { url = "https://files.pythonhosted.org/packages/b6/55/d893f5ede0e50f9e3fcf01f6015f42ec7d9cd221e26772701fe4a98745f9/kiwisolver-1.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:824c3d763a05ea9e9003610145186b0e9848c7584a5575c79bac5a8e7cd80bad", size = 2233854, upload-time = "2026-08-28T10:26:35.282Z" }, - { url = "https://files.pythonhosted.org/packages/b1/82/f85f6279555a6ee1639fef7bfe83adb037a03e11a6fc9eaa54b8d0380339/kiwisolver-1.5.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1fff05e239575b1481b6ed1a782f6fad616efbf1f0b1f44e6e85c4dfe426e483", size = 2330621, upload-time = "2026-08-28T10:26:36.9Z" }, - { url = "https://files.pythonhosted.org/packages/56/31/e11aea078f66fc2fffcc179d38ca90d9da97652a241b64519169742ba46a/kiwisolver-1.5.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0627b9bceb9c3cdcf12b8a18655eedfed2692b038df27423383c120d0b7dc2d6", size = 1982848, upload-time = "2026-08-28T10:26:39.01Z" }, - { url = "https://files.pythonhosted.org/packages/af/ea/2956b63bf5140ca46aa2c2818e6aa03e2d5754dd2fa41db1c6b28922940c/kiwisolver-1.5.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:8a708a47ade1fe19e8371d5da076bac0dd4b0a5a7985ad6c637f7f7e361b6baa", size = 2494850, upload-time = "2026-08-28T10:26:40.837Z" }, - { url = "https://files.pythonhosted.org/packages/11/d1/3829542258d8b3fc0898d221e7ef0e2c83eca0d348709bb8dbe54f3d4005/kiwisolver-1.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:007a5553dfc4f4e8d184f588a0200e2cd4b63a59cc8796df3c39909e679dc7a0", size = 2298067, upload-time = "2026-08-28T10:26:42.803Z" }, - { url = "https://files.pythonhosted.org/packages/4e/0e/49522e1ab5788cbaf63a26fbd3b851f9028616828c961b8a31b35cb96df8/kiwisolver-1.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:f4167e87b397f273dc2356fcf1eaf50a6bac51e6105f45103ef7129c8efb0255", size = 72282, upload-time = "2026-08-28T10:26:44.268Z" }, - { url = "https://files.pythonhosted.org/packages/f5/b6/22e7ca5315d363e6f81c9f37c9472e12e7b298731e77c0428e6a911a2c39/kiwisolver-1.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:5c490db2168a508088f59140dd392556a54b8bd1048fc6383c8baff13c359673", size = 69855, upload-time = "2026-08-28T10:26:45.725Z" }, - { url = "https://files.pythonhosted.org/packages/30/8c/03a9cfbe871964c8758a816eb03ac96c806da2795a9a7cd9bf9648bfb594/kiwisolver-1.5.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4d4ca09bf13cff792b1884f64b98ee6c2467930d632233be25c56b442d99f10e", size = 126289, upload-time = "2026-08-28T10:26:47.023Z" }, - { url = "https://files.pythonhosted.org/packages/f2/e3/14ce3041ca79dff9c9d884ca00c7bf32374e76028a865a9ecd99b4f5a517/kiwisolver-1.5.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:44b8faef94f1857e77fa0238f3390ff1ac51d2ea20a487e2e452a59fd2b5f5ca", size = 67709, upload-time = "2026-08-28T10:26:48.268Z" }, - { url = "https://files.pythonhosted.org/packages/af/c4/45030471a66ec8ef042e9f96ffe1d522c9ab12da180186a0898966fc1385/kiwisolver-1.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2ae70bc59790d2af72a3f76f24b272403e135070340281108b447cb77ea70819", size = 65909, upload-time = "2026-08-28T10:26:49.523Z" }, - { url = "https://files.pythonhosted.org/packages/42/73/17dce073a6ae259bb32cf9d686c4079d2e538868bc45967462bf33df914a/kiwisolver-1.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:43844c1a7ad6d723d5b5b4c4fc7f5bd399c40e288120d16257c7c9e8765c6e85", size = 1584907, upload-time = "2026-08-28T10:26:50.933Z" }, - { url = "https://files.pythonhosted.org/packages/8c/84/ae3c75909f507283cbfcc7e916c7e822579ef962020b97e6882b27b4478f/kiwisolver-1.5.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22d5e5aaad6be121f2515765e3b1c444352cb8eb4c86510801db8f2e50757316", size = 1392474, upload-time = "2026-08-28T10:26:52.638Z" }, - { url = "https://files.pythonhosted.org/packages/34/31/8bcc83caad5bce8fa4577152389848bf6bc110e51e573a2b4e7c2aa34c89/kiwisolver-1.5.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3fa5855898f6d3d01b72ccd48a2d65cbdee301251603fefe34e2025bddba219c", size = 1405246, upload-time = "2026-08-28T10:26:54.248Z" }, - { url = "https://files.pythonhosted.org/packages/55/72/220345537d790cf4ae54f8acfff4b5cc2468e0702a384d651cf7a771c63e/kiwisolver-1.5.1-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d66a64dd5dec136040ec2ae94aa026a912ee60fdd45bc28d3db30037fd809e88", size = 1456099, upload-time = "2026-08-28T10:26:56.042Z" }, - { url = "https://files.pythonhosted.org/packages/cd/10/3725fd2398f66d18c34b4e0f81a8d03764cd4f4f089f58a527f0b4428086/kiwisolver-1.5.1-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:9e51c119992ea8820706871c30a4642ec76de20ae82f9b50b9a45517d8e9f810", size = 1073695, upload-time = "2026-08-28T10:26:57.658Z" }, - { url = "https://files.pythonhosted.org/packages/86/cb/28d6e09e66b93e4588b2e6b7d84d020ccefea09e2f4de788510a07efeab7/kiwisolver-1.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:70ed9a45c7484d2b30cdacf60d220f494a1763b9fec1ad03285c6553fa0889f2", size = 2335355, upload-time = "2026-08-28T10:26:59.202Z" }, - { url = "https://files.pythonhosted.org/packages/6a/b3/a0f31d5e4e40af7dc97c36b8a74fdd3a36cf3c8bbd098da9a23466ff6a94/kiwisolver-1.5.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:98b208a7cc42c803445ef551d6753cc42a5ea13e9cab1ee66cd8b9cb70195330", size = 2426524, upload-time = "2026-08-28T10:27:01.181Z" }, - { url = "https://files.pythonhosted.org/packages/2f/c9/728f63bd58c72cafdc79fc306abeeac7391bec03b757a48dadeb30906521/kiwisolver-1.5.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c6834b92dd2428e2dd85ef3d85f723d3c12f20aaf43a2ddd4f944ca25d833408", size = 2063430, upload-time = "2026-08-28T10:27:03.06Z" }, - { url = "https://files.pythonhosted.org/packages/84/df/ce188b96f92f9a2c958231da140768918cba53c9713dc887b82f85462118/kiwisolver-1.5.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:5d142e352eb13facc7dd047489aebdff6ba78576c239f1ea04931979caaf0567", size = 2597513, upload-time = "2026-08-28T10:27:05.072Z" }, - { url = "https://files.pythonhosted.org/packages/69/d6/76947c8203768968382e5bd74d9cc95654746703a61ea53015f2c74a2e06/kiwisolver-1.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f9b1c4900736e489a812c529100de4b8fb617d4db075e931e213c57424b83d9b", size = 2394488, upload-time = "2026-08-28T10:27:07.423Z" }, - { url = "https://files.pythonhosted.org/packages/e4/d4/14b21e4eb203c4d15425e8b6a2c625a320b4a1f2f7557eead63ffc30ffb7/kiwisolver-1.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5978c3340f16a35c30f8ab2fa7bcf559973c55f1a5ef6970e1f621acf3c4db13", size = 75404, upload-time = "2026-08-28T10:27:08.892Z" }, - { url = "https://files.pythonhosted.org/packages/cb/f5/53157899fc7f45f76421b77b99eb1639dd0f83f26ff9d76300c96bb4a3b0/kiwisolver-1.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ca307d6c259e5c98d3cb9ade55342b47a6839762caf2536f3d7b46ee660cc82e", size = 72946, upload-time = "2026-08-28T10:27:10.944Z" }, - { url = "https://files.pythonhosted.org/packages/75/62/f786c3a27f181fa339d851a77e266d208e776b9883cabc40a5b041a31b5a/kiwisolver-1.5.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:bb7c99f0673c03017a3ee01e54a5c2617a05468b11eabe513b0080e063ed95b1", size = 62420, upload-time = "2026-08-28T10:27:12.308Z" }, - { url = "https://files.pythonhosted.org/packages/02/cd/58a91ed25fbad0facdf503297b03768efab04bdf3141e5e3b49a34be7443/kiwisolver-1.5.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:0d8924877ce22e17326a99a418c3c82037da078df3c6a260b13eca677444e6e7", size = 64577, upload-time = "2026-08-28T10:27:13.502Z" }, - { url = "https://files.pythonhosted.org/packages/f6/5c/d501ef5a0958b226eac28306d24d5e5f114be0ace50e19cabae7b6b3b197/kiwisolver-1.5.1-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:534f02c1abb31ed6dbd3515545285c330b2f12d00fdb1fdb71658b9ca5a13a6a", size = 66284, upload-time = "2026-08-28T10:27:14.813Z" }, - { url = "https://files.pythonhosted.org/packages/a3/a6/8fbecaf4fc18c02f31f05e47a84c010a80e3ec391ed2f0bdade1d62b5954/kiwisolver-1.5.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:cea20da04494e662b83c872683bf4ff2345206043d036315ed0e924b652e7294", size = 124031, upload-time = "2026-08-28T10:27:16.193Z" }, - { url = "https://files.pythonhosted.org/packages/7c/c6/bf3090d2983b4204347cbdbe952116e7c3b2abf62b4e33e50167a13e75ee/kiwisolver-1.5.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:7fd82debf43c6acd0a94359d232f6bb516ee13f269a7993736a9ac9f988bb5d9", size = 66489, upload-time = "2026-08-28T10:27:17.506Z" }, - { url = "https://files.pythonhosted.org/packages/85/de/562dddef55fdd7c291da8626d6619e72b5fc0870e6ccca0e149a5731e7f3/kiwisolver-1.5.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:18170a77ddfecf40ec60d0928268dc95880c881864e015a8f34094ed18b9b9ad", size = 64806, upload-time = "2026-08-28T10:27:18.673Z" }, - { url = "https://files.pythonhosted.org/packages/89/b1/ba7b9c0164ce1cf62bf2872db63b8483289cf0f3110d6f9390eb09e409ed/kiwisolver-1.5.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ca7f6fe0f37ca978a1e5eb7a3a68e6413f417e78e838324947ffd420202b198b", size = 1482211, upload-time = "2026-08-28T10:27:20.039Z" }, - { url = "https://files.pythonhosted.org/packages/13/dc/34da54efb4976616d45c20aae32d70e89d6e7395ed908029154d1609ef22/kiwisolver-1.5.1-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5b973887ff782cfd6b67c9904ad8ca542e0bc5e4961503408b423b5a688b4d38", size = 1283739, upload-time = "2026-08-28T10:27:21.82Z" }, - { url = "https://files.pythonhosted.org/packages/5a/3a/30ffb62bee646e266e98a1b5cd276d9c75b6116fbfcb87c1190838c1b6df/kiwisolver-1.5.1-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f76fc85bd054c806960f917ec0f329e24e436f1712267d90588e4c39890caa63", size = 1301681, upload-time = "2026-08-28T10:27:23.876Z" }, - { url = "https://files.pythonhosted.org/packages/a9/8d/13c70be22a8506880b35fdc38dca36629613bc493405c79f4037f2cd2bb9/kiwisolver-1.5.1-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:828f75af2b0080c8a972e75f649ab46af008e92c6104a57a759157200b835b75", size = 1349159, upload-time = "2026-08-28T10:27:25.899Z" }, - { url = "https://files.pythonhosted.org/packages/82/0e/993972b8ec6767f47cd69818fb3a5ff14510557d29f7d1a839be7574fa1b/kiwisolver-1.5.1-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:431dc224a1a92a5c8f582d96e505196a3b5997a7271076678da2dfde67b77e9a", size = 997613, upload-time = "2026-08-28T10:27:27.507Z" }, - { url = "https://files.pythonhosted.org/packages/36/82/ca26eddd2eda2420dfc56693449c1f821f78b485da9cbde9904c03af3f93/kiwisolver-1.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:61e9a64c7635095a6bfe483e2ff055d437c59bd45f3617a228b37277f0185d62", size = 2235109, upload-time = "2026-08-28T10:27:30.113Z" }, - { url = "https://files.pythonhosted.org/packages/c7/84/97d920881e10840b8d7c7185620298e3e4c88820b05514e3a15a258b08a6/kiwisolver-1.5.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:3c24cd69455e1b00ddf770c13b6e2c33e07d6dc3f2d34add0bf9277c5c6bbd46", size = 2331207, upload-time = "2026-08-28T10:27:32.795Z" }, - { url = "https://files.pythonhosted.org/packages/e2/ce/34d74b8f25acc58800f4c09268371e8d6159cf0f1206f1e4dc7835629b48/kiwisolver-1.5.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:27add358abe374ebaa3b8763ef380bc99051b5a4b18d94878366a9e4f59efef0", size = 1986696, upload-time = "2026-08-28T10:27:34.628Z" }, - { url = "https://files.pythonhosted.org/packages/0e/01/f892644014612527aef7031d3306a2ffc60b3cb044f802c1561f8e5e14f3/kiwisolver-1.5.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:255605693a483db7bd5c79f60437f7bf658f7f520d61aa42722e32257c941951", size = 2496400, upload-time = "2026-08-28T10:27:36.818Z" }, - { url = "https://files.pythonhosted.org/packages/32/6d/d8284e66e697026536e5f418b9cfe56567bffd3c775e3ecfbae373605854/kiwisolver-1.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:7d38b0c279c3032e8c9cc013b405c6df8e1668dbf15465779aa7f15f61201812", size = 2302967, upload-time = "2026-08-28T10:27:38.803Z" }, - { url = "https://files.pythonhosted.org/packages/46/0a/69a355e27f32ba50d5b6369949b6a1702e122f5277c89bc76d452b81c1c4/kiwisolver-1.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:958254518717542d02d0688d0d20cbf771da5e415e6f49543f92481c850a4540", size = 72283, upload-time = "2026-08-28T10:27:40.491Z" }, - { url = "https://files.pythonhosted.org/packages/ef/d8/7a95be90c33dcdd52204d4aa6384d731443225b887283bbd8b61e7931f6c/kiwisolver-1.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:da3275833be0edbaf4830fae08bae3dc7219f40ce0c37eaa6c25825957e06612", size = 69860, upload-time = "2026-08-28T10:27:41.835Z" }, - { url = "https://files.pythonhosted.org/packages/31/f8/9bc493e7f5707788ba7f621902c68f82dc3a7ba03c78fbd337b026cef1ed/kiwisolver-1.5.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:470d420f98d368d6f010633a20659b544c5fdfa5329e6b70219f2ef08fd4a7ef", size = 126336, upload-time = "2026-08-28T10:27:43.379Z" }, - { url = "https://files.pythonhosted.org/packages/d1/82/3aea86b3f99712db825e9ac5631bf99571e818a8b8961ff98cebd798413e/kiwisolver-1.5.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:83f78128fa28705fa85d01c59771c72fe81c11bd0e6155edbb9f818983a7d761", size = 67698, upload-time = "2026-08-28T10:27:44.613Z" }, - { url = "https://files.pythonhosted.org/packages/84/7d/8daafc5d2e7f9c47a4f78f8865d86d2a9cf399c2a85f86c44a993594410c/kiwisolver-1.5.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:9506e892bcc3b409831d363c6f53e5985e1c8d1f6f6b0256d00358684ff85378", size = 65945, upload-time = "2026-08-28T10:27:45.932Z" }, - { url = "https://files.pythonhosted.org/packages/3d/c9/51dc974d9130da70a8c47a96160123443d387ffe1b6b833d6f91d9429339/kiwisolver-1.5.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cea90547bfd93807e0013a004dc76552be44fad3bc1cc2b38610a9e889ed098f", size = 1588030, upload-time = "2026-08-28T10:27:47.678Z" }, - { url = "https://files.pythonhosted.org/packages/e6/86/f3e1a730e7a995149d8d3ff9e313b6d8a17b2cf1d98a8eff139dc30463fb/kiwisolver-1.5.1-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e8e4d953faaded9ec7ede36824e9814082d22d4c7b1eafbfa079ecba8cd0d076", size = 1390760, upload-time = "2026-08-28T10:27:49.676Z" }, - { url = "https://files.pythonhosted.org/packages/4f/3b/8ba25a2b5a0d2375e046f1b72de5179513f0be95aba6e7b094c89303929f/kiwisolver-1.5.1-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7e9c01d3dd7ceba4d1d436cc021d40d592466e40b9bc7f5d83dc4e98a5c9cd8c", size = 1403279, upload-time = "2026-08-28T10:27:51.242Z" }, - { url = "https://files.pythonhosted.org/packages/18/d0/278d5cb8be812740027d5ca0a7eda0c375488a88d6dce0fa60fcc2591ad2/kiwisolver-1.5.1-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37f801b5d7cc0e5a548921308e059fd2b057bb42972b591cfa3049f95423c4ed", size = 1454429, upload-time = "2026-08-28T10:27:53.213Z" }, - { url = "https://files.pythonhosted.org/packages/94/37/bcbab41063ec284c1d200efe5087cf087798c2f8916960aa8a20dd303290/kiwisolver-1.5.1-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:e68e151428b5384f766cd25739bf77c7e4a3dc93b5ded7a12118d9fbfdf78ab6", size = 1073225, upload-time = "2026-08-28T10:27:55.089Z" }, - { url = "https://files.pythonhosted.org/packages/cb/c5/ab79dcdf5ae28909a51210ae0a1c579e97ff997b3466414f0d04c0994583/kiwisolver-1.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:8f8fddb8e323bd6eee4e54e69a39243beab22689070f4c66b472c4cc88bb89d8", size = 2334335, upload-time = "2026-08-28T10:27:56.78Z" }, - { url = "https://files.pythonhosted.org/packages/8d/8e/71d047468a189041d9c93f3b76844b924f9793b188c44bd149fa258912da/kiwisolver-1.5.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:3cc210010fd2f438a3ed430b45f1b501fd13a8618bf984dc2c5ce5b69b78752e", size = 2424982, upload-time = "2026-08-28T10:27:58.639Z" }, - { url = "https://files.pythonhosted.org/packages/7d/37/1347461bbea6d0e1f0580b94ef603b18e72c2be5f667fa1653867361a00b/kiwisolver-1.5.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:b5664603a253efd3a75716d793d1d3a6a82723b61dc6db767b2460bbbeec4c0f", size = 2062857, upload-time = "2026-08-28T10:28:00.407Z" }, - { url = "https://files.pythonhosted.org/packages/1f/87/c7f0976c9cd0d127643351bc0c9929e0b8899d7f49d4ec238cd909e39c42/kiwisolver-1.5.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:a7b85b2cc6ea45e5f7e8c9a30bc9fabd47cda09106cbb4b967335c3e6c43b69d", size = 2596022, upload-time = "2026-08-28T10:28:02.183Z" }, - { url = "https://files.pythonhosted.org/packages/e3/9a/59a6f6ae6f938c15076be2c21b6cedea973d71bb1349ec84fa485fab82cf/kiwisolver-1.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:ab620eb663952455271ac37f9aaad86b73c969c02f11f53cea405b38e96a4300", size = 2395634, upload-time = "2026-08-28T10:28:03.977Z" }, - { url = "https://files.pythonhosted.org/packages/e9/2d/8982c1fb7926da5bb7ed60318c3665b5c3f941447271ac982960a11b8637/kiwisolver-1.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:cb6fae641357ed2f6e533c0d3c6504a4a5703621a50c89459e46051d56b61140", size = 75379, upload-time = "2026-08-28T10:28:06.307Z" }, - { url = "https://files.pythonhosted.org/packages/07/78/ba7b6dfa1708b82b373ac056928a30c545d5c1a627df9839dcec3c6c1881/kiwisolver-1.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:b390aec180a7c054919c04898835e1c77bced23ea8383eb2c570213bf25d1a86", size = 73011, upload-time = "2026-08-28T10:28:07.578Z" }, - { url = "https://files.pythonhosted.org/packages/a9/c4/1407df7512a5b36cc79840e01710dc575733c461b13ab866cae77eaf87f3/kiwisolver-1.5.1-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:482676e5bd48d70ac99d9fc78863469845421e01184fa83f1f9366dc49f7e974", size = 134002, upload-time = "2026-08-28T10:28:08.881Z" }, - { url = "https://files.pythonhosted.org/packages/16/45/c37a21ad5c0ab581a93c55ad544721aaa1f0ae94edb29c6a678a23d013e6/kiwisolver-1.5.1-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:072bdb15a3c19a5b5dbc8f8fb1f4e1884bf4f3507eeb4cc6334401274d37a5c0", size = 194292, upload-time = "2026-08-28T10:28:11.06Z" }, - { url = "https://files.pythonhosted.org/packages/a6/c1/69f00d627949580e43d57af0aa465df46868d7c29801c137a55374101294/kiwisolver-1.5.1-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:a5a00665d1a0e26763a7338d7e911d4598fbc1d50dd0d6b7919b7dc6c5d6569f", size = 73362, upload-time = "2026-08-28T10:28:12.449Z" }, -] - [[package]] name = "mako" version = "1.4.1" @@ -1018,67 +505,6 @@ wheels = [ { 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 = "matplotlib" -version = "3.11.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "contourpy" }, - { name = "cycler" }, - { name = "fonttools" }, - { name = "kiwisolver" }, - { name = "numpy" }, - { name = "packaging" }, - { name = "pillow" }, - { name = "pyparsing" }, - { name = "python-dateutil" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e7/c8/9aa712a0afb882649424dd8de8ad9aa6235e796e84c6052e8f6dc1598d0d/matplotlib-3.11.2.tar.gz", hash = "sha256:cec596316640f2b394b8f0daa0ea61a8eae82d017b620b9f202befb972a59ea4", size = 32660610, upload-time = "2026-09-11T19:05:31.214Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/ce/1bfcc4873b121597791ad74032943b123218c0613af0b97e8dd05e916fb1/matplotlib-3.11.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ef752769cd962f39ea0b6ffc82d1ea43a0012c5a6157c7a075212fa509cfcff2", size = 9476466, upload-time = "2026-09-11T19:03:19.45Z" }, - { url = "https://files.pythonhosted.org/packages/a6/c4/7f5f3601ee69baf072c0c7d3ce60c03e0618621c5a56c34a62a460e29d11/matplotlib-3.11.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ef31985c4dedb5f1424e1aec6849a47dd37689cb7fa3c20b1b82187f26806261", size = 9305583, upload-time = "2026-09-11T19:03:22.39Z" }, - { url = "https://files.pythonhosted.org/packages/b8/90/2b3fd67ee273163faeda6d514be70b5596eaa0fd77b60ffc294ad0b34f5f/matplotlib-3.11.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:df4f7784aca81a94f254c0a2767d592ee25f407e488f5fa7203e51093fb6ca27", size = 9860353, upload-time = "2026-09-11T19:03:25.049Z" }, - { url = "https://files.pythonhosted.org/packages/f4/84/32549e7a462dc311aed2ab62e5d2538028840b5c53a8be0195c789937c3d/matplotlib-3.11.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b9a7ad579856284135e401ecc918c5f8a017ee30539298862a109f51b971710", size = 10670348, upload-time = "2026-09-11T19:03:28.108Z" }, - { url = "https://files.pythonhosted.org/packages/84/39/02e21b74f7439bd643d717ea846006d46e309e6d29b632b57751265311d6/matplotlib-3.11.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3aa4b8516fd26659e4363abbf317c703d9116496c5db2e9d0609a2866dd39dd2", size = 10810580, upload-time = "2026-09-11T19:03:31.402Z" }, - { url = "https://files.pythonhosted.org/packages/04/93/0d239bde12b308262265c2d98909b2f0ecad2b5deeae96241642e822e9c7/matplotlib-3.11.2-cp312-cp312-win_amd64.whl", hash = "sha256:c5c1c68ee401fc98271263410f0e5ce88285abacf7627132914e8adf3d70ff43", size = 9349409, upload-time = "2026-09-11T19:03:34.195Z" }, - { url = "https://files.pythonhosted.org/packages/49/a8/06baf901c02246c8a222b655cc4540ef9c15e1549a04e07927f9cde716c3/matplotlib-3.11.2-cp312-cp312-win_arm64.whl", hash = "sha256:643ff850d8e0f5b8319337f87ed3cb59506afb3df3cc48de777d85871233be7b", size = 9028084, upload-time = "2026-09-11T19:03:37.032Z" }, - { url = "https://files.pythonhosted.org/packages/bf/ce/fc58855a3d17f6cb8a87b6f9ccd100f65432a3a7ffed57309cbf14c78987/matplotlib-3.11.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7d43ff8cebb50840648cb6429b2228621dbd709010f3117b3740abb20abf21c0", size = 9476987, upload-time = "2026-09-11T19:03:40.17Z" }, - { url = "https://files.pythonhosted.org/packages/a9/29/2a47b700545aca050e0ed73c89ba21e08407e11f5f42a567421b220de6c1/matplotlib-3.11.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2a8285cea8ef4d92aa041d1c33788bcae82248503300f93f1ca2136b9049452f", size = 9306050, upload-time = "2026-09-11T19:03:43.103Z" }, - { url = "https://files.pythonhosted.org/packages/a9/df/e7c10292f348cf55178a5b3c0092a38ea68891a3945f199ae8b7b8ac9dc1/matplotlib-3.11.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1944895967f87c84c9b4bad29a707b31f5b36df4a3a2339ea1f8ea3ce5105539", size = 9861060, upload-time = "2026-09-11T19:03:46.044Z" }, - { url = "https://files.pythonhosted.org/packages/e4/b9/b978de6d43d47f1d4d8e653fac32c16d8d2e5f46bbbb822e26d7cb1adece/matplotlib-3.11.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:af2661f6ac6bbd1d081996f54fdd9385715c625ace8cec0869055c0cfbf38981", size = 10670885, upload-time = "2026-09-11T19:03:48.942Z" }, - { url = "https://files.pythonhosted.org/packages/a3/80/d72f61bb2631ac15d17fada1f17e0f99fecba69d8376d34f17cf93b7eddd/matplotlib-3.11.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7e5a90f8a707ebb6004a713b1cff091a40cc5df4c7c1cb165e5a505ebc11c292", size = 10811222, upload-time = "2026-09-11T19:03:51.665Z" }, - { url = "https://files.pythonhosted.org/packages/94/2e/6d1d33f242c705b5c6c6fb3f0fc141add51cb1abb62912e59de0835d28c4/matplotlib-3.11.2-cp313-cp313-win_amd64.whl", hash = "sha256:bc067c462a86f0e57bf52fc6e058d90171a5420007dac00c46e90153050c69a7", size = 9349539, upload-time = "2026-09-11T19:03:54.534Z" }, - { url = "https://files.pythonhosted.org/packages/bb/bb/c0aa86a7f4c0e62f1c3c0711e361f6f23f87eb9018732a5e11994bd1b7ec/matplotlib-3.11.2-cp313-cp313-win_arm64.whl", hash = "sha256:3e8576f7c47e02fd4f21f44171302d2d1d58d4471d46da3d71fe8899d19539d9", size = 9028295, upload-time = "2026-09-11T19:03:57.261Z" }, - { url = "https://files.pythonhosted.org/packages/54/b4/4facdb700bc236e4c676495ca3798d8134ab8668cbfe0f6b2e9ebf962a38/matplotlib-3.11.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c9721f81275499da1feeb36a2cf8192ea086283b3bd16b7dc4c9d7aedb7396d6", size = 9478976, upload-time = "2026-09-11T19:04:00.12Z" }, - { url = "https://files.pythonhosted.org/packages/4c/a0/bba05f0a25bfeb0475c2f2a87958b31103aa6b7a5386017afb1a96212825/matplotlib-3.11.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a3040b209f3968b4e84161df7b174f07a9fad33b0f2d7e48ea3bbd3075e2863", size = 9307932, upload-time = "2026-09-11T19:04:02.976Z" }, - { url = "https://files.pythonhosted.org/packages/d4/42/81d5cba4bb39b41b0998efc880e129f2d7cdb94256ca49678fdcd9c21be5/matplotlib-3.11.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf3fe71fbfb8ec0e310e0bc8537c3405a01f38f25f9394ed2135e6202fed542b", size = 10674370, upload-time = "2026-09-11T19:04:06.912Z" }, - { url = "https://files.pythonhosted.org/packages/29/5e/52f56f93d5b20815ee0ae352f7a38eff25580edb180b4fa7df87cfeba68f/matplotlib-3.11.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8c8255de28f986d935a64c9ca71c0ec2d2f41d355691f5ea684725dc91413f71", size = 10954597, upload-time = "2026-09-11T19:04:09.948Z" }, - { url = "https://files.pythonhosted.org/packages/6e/ae/f8918a1b564db49c53cd603eb3c8748c5767e4ce0fb2cf1d7c38653e4c5d/matplotlib-3.11.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a8756cc73d9af9a7fe0deb54ea2e75ef73b01d9e575877e72acad5458e660943", size = 10812583, upload-time = "2026-09-11T19:04:12.887Z" }, - { url = "https://files.pythonhosted.org/packages/ea/61/4315ef0d63937fe56f451887b4a0be3b8e8b22cf1d92a8d157caac54570d/matplotlib-3.11.2-cp314-cp314-win_amd64.whl", hash = "sha256:ecea603dd2fbf8242fd31a305a8b12a4ece2de28096870c65fdd0d1e35b8d9a6", size = 9505972, upload-time = "2026-09-11T19:04:15.788Z" }, - { url = "https://files.pythonhosted.org/packages/d5/00/02398b0a1a62ef73d9af0a957fe1773363eec9a6ca7e433c72ae2790fbce/matplotlib-3.11.2-cp314-cp314-win_arm64.whl", hash = "sha256:01dc8eaaab5a9fce9ff615eca82345728f289e4715b186ee10c6d85272fc26bb", size = 9184534, upload-time = "2026-09-11T19:04:18.691Z" }, - { url = "https://files.pythonhosted.org/packages/ec/c5/1e6ca10baa7e04f113c37856aadb4adc3dbb1c55fc7da6dc830bba1292d7/matplotlib-3.11.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:79a258f58253dfa025af80a9e9bb228d75fced007f0e93ae7423fefbde81a74d", size = 9526415, upload-time = "2026-09-11T19:04:21.39Z" }, - { url = "https://files.pythonhosted.org/packages/e0/93/7561727af07ccc84747953c6a33eee3981cc436d04f465032e707ca0e429/matplotlib-3.11.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c0b83f044ce10a98027b105b3931548719a6e8c7ef986b4362651e0b5367c8dc", size = 9361064, upload-time = "2026-09-11T19:04:24.107Z" }, - { url = "https://files.pythonhosted.org/packages/c1/40/ce270ef2a6794d94a409fe28b8dabedffc07c07f9e5dbb89e0f605bd157d/matplotlib-3.11.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e43b188f0a5b75447bcc197728258166aa64365770ed1caa36595a5e1ca4bbba", size = 10684626, upload-time = "2026-09-11T19:04:27.201Z" }, - { url = "https://files.pythonhosted.org/packages/45/46/396e0307dbf1c1f2d0a3654caa6dbc780dd5379f5f11257369cf8d21d9fe/matplotlib-3.11.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3304eb5a59442a8867f6920d484591c0fa09ffc29e9260be2feec3351e25869", size = 10960602, upload-time = "2026-09-11T19:04:30.254Z" }, - { url = "https://files.pythonhosted.org/packages/1c/97/4defb84695477340aa88b27c9482cc1ca4de11f36f66b6c8c0ddd685def1/matplotlib-3.11.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:02329432ae5c6af87cf208ee575b701d698bdf0b1a3bb28cc6d53c36e967e575", size = 10822603, upload-time = "2026-09-11T19:04:32.964Z" }, - { url = "https://files.pythonhosted.org/packages/7d/e7/eab54551c1ec6b72dcd678a078b6c6da8d4c85ec1ff0016e81fe52866304/matplotlib-3.11.2-cp314-cp314t-win_amd64.whl", hash = "sha256:f2ac30cf5eb5dff1b584627ae0b0e1186551a4f69ae3c75073911da497a29170", size = 9551206, upload-time = "2026-09-11T19:04:35.811Z" }, - { url = "https://files.pythonhosted.org/packages/4e/0e/04698032cb4d8fb1e30d8ffec5ded20f6f582b03c8c58b2385c95245f158/matplotlib-3.11.2-cp314-cp314t-win_arm64.whl", hash = "sha256:cf41ecd1b0c0b6f7177ed965a54c2afbe888715c7cf6054dc12d53bc1494002c", size = 9234977, upload-time = "2026-09-11T19:04:38.972Z" }, - { url = "https://files.pythonhosted.org/packages/44/a9/ca827c184b87364378964e36e7c0ae23651d146fdbc4000635205490cf82/matplotlib-3.11.2-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:894a9cbbecbe30ae6787d464df2e8fc7a8d475cfc68f87c3029f7c11152899b3", size = 9478997, upload-time = "2026-09-11T19:04:41.914Z" }, - { url = "https://files.pythonhosted.org/packages/48/60/01aaafc9c3159f3fae9a65bbcddaa4a6d4d72b0aeffdd636d7f6d00a5c8d/matplotlib-3.11.2-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:bbf1062991d826ed27e2144f3afa4461afbb8ff56e8f703043e191a8163b1ee9", size = 9307947, upload-time = "2026-09-11T19:04:44.834Z" }, - { url = "https://files.pythonhosted.org/packages/1e/a0/6aa72c7cd7cf4dfac652744d7a3310007cec3ce8e5ab9be607bec7096471/matplotlib-3.11.2-cp315-cp315-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:399fef672f7046ef7d6a57572b2a6f9845f3f5afcff04e7b2df7a363a9f42190", size = 10674370, upload-time = "2026-09-11T19:04:47.745Z" }, - { url = "https://files.pythonhosted.org/packages/a1/97/d7e90225ac145046787a2ee0fa4cdc7e4d5cb6772ea232130b10f8a4095a/matplotlib-3.11.2-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc82dde2a0d3e3ad472edce04897ad7146b8d8bfd1df8a32992eebb81af18fdc", size = 10954581, upload-time = "2026-09-11T19:04:50.712Z" }, - { url = "https://files.pythonhosted.org/packages/58/18/89cf056a9d2cae01f4b8baf01c7e809d613244fcdfa2a2acf28c1e0d79fa/matplotlib-3.11.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:930efb28f59fda124e39265d177bab302625297bb147d2910de725a2fc2aef54", size = 10812605, upload-time = "2026-09-11T19:04:53.552Z" }, - { url = "https://files.pythonhosted.org/packages/9d/4d/c61b9f4c34af802d4d84e58803e009013d626b5f468bafe1416131863b5b/matplotlib-3.11.2-cp315-cp315-win_amd64.whl", hash = "sha256:3da3bc0cbf7245e7db72cc6d29d12c5abef72cb73059c73b945f14e3545f3eb2", size = 9506068, upload-time = "2026-09-11T19:04:56.519Z" }, - { url = "https://files.pythonhosted.org/packages/03/7b/047f2fa8708d47430d8e7fa45e9fffb0aee5d4e300f978e087be2ee11fbc/matplotlib-3.11.2-cp315-cp315-win_arm64.whl", hash = "sha256:f25446b2981717dca9786bac841cb3fd7efb568e3e3c755dd980481c5cb9228d", size = 9184576, upload-time = "2026-09-11T19:04:59.318Z" }, - { url = "https://files.pythonhosted.org/packages/03/6e/ad9c5989950785a98642667df265a47c6637e924cb69b5dcf68c3299ae7c/matplotlib-3.11.2-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:fea03cf56568cc1cba08b470be6a0559e71c3a5b688d54b7179bb35ba23d0821", size = 9525988, upload-time = "2026-09-11T19:05:02.304Z" }, - { url = "https://files.pythonhosted.org/packages/a6/c3/25c61701fb655dd412428b15eee2591ce554cdd593e39a045a153373f428/matplotlib-3.11.2-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:254d4ddb2fa8df3b4c689c0c306063aee10521df82cfb438185e499c75fe37c1", size = 9360774, upload-time = "2026-09-11T19:05:05.092Z" }, - { url = "https://files.pythonhosted.org/packages/58/5a/4552878fce8f9d249a5e264c16f5c267c27eac08fb039fa3e207a06feff5/matplotlib-3.11.2-cp315-cp315t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e60cf3047a51edecdc4535a9196bbd6a732936b8e9f8184aabf4d16165884aaf", size = 10685892, upload-time = "2026-09-11T19:05:07.686Z" }, - { url = "https://files.pythonhosted.org/packages/af/dd/b3b19ec0124fc4aafd8a4b3bae859c562a7ff0fd8ae3b2099947f78bde19/matplotlib-3.11.2-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eb3712dc9b464793de0a4e42a7313d50293c751f94bddaf7332a1bc71bccdda9", size = 10964946, upload-time = "2026-09-11T19:05:10.943Z" }, - { url = "https://files.pythonhosted.org/packages/7b/30/687ccce22f66a57fed85942450e88062773bcbf1a42914e70c7148ec71dc/matplotlib-3.11.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:75b6d88402770e181b5d06a67dda4129c31da7d05004d63a21c310ec78d1b83c", size = 10824960, upload-time = "2026-09-11T19:05:13.9Z" }, - { url = "https://files.pythonhosted.org/packages/d3/34/117fe35a8d05054b4b34d2d29287099927c24c3841d36e06d9fa2f20c1cf/matplotlib-3.11.2-cp315-cp315t-win_amd64.whl", hash = "sha256:a6939df7567114b6bac7f4c5e06c84a67f197c1b2f2e4b234d4eecb3bfec9482", size = 9551204, upload-time = "2026-09-11T19:05:16.48Z" }, - { url = "https://files.pythonhosted.org/packages/63/5a/9f440e7bec8b80d0af2a65d1165b2f4e718982dad785531f62255d9ff19b/matplotlib-3.11.2-cp315-cp315t-win_arm64.whl", hash = "sha256:d480038c83691532ed52ff3147db51fa902fc78cb2d8349993a1cdb684435bff", size = 9233946, upload-time = "2026-09-11T19:05:19.063Z" }, -] - [[package]] name = "narwhals" version = "2.26.0" @@ -1088,20 +514,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/40/b5/1b84b2c784db76d69442334bc8b8748c840f13ca53be086f4f250ad4a0bc/narwhals-2.26.0-py3-none-any.whl", hash = "sha256:29326d74f107c347fd1009bd58e38d9f7c7c5b51e6de97bc93dbc325d9038b54", size = 474034, upload-time = "2026-09-08T13:32:07.159Z" }, ] -[[package]] -name = "niquests" -version = "3.21.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "charset-normalizer" }, - { name = "urllib3-future" }, - { name = "wassima", marker = "sys_platform != 'emscripten'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/1c/2a/e368ec88a02c6bfc56c031ba6da9dd8099ad998fb818c28b687bee74c91a/niquests-3.21.2.tar.gz", hash = "sha256:586d6b9475c9018a42f19f1e4688077b8158101cbf40673c63d2c1a096cc7665", size = 1094494, upload-time = "2026-09-23T06:23:46.681Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/65/51/eb07449eb9e7ecae4350b5a40bfcb002d6d26d58cf2cfd2f928ae7c2f559/niquests-3.21.2-py3-none-any.whl", hash = "sha256:73e52614a712ee2f4d410bdd29789ca6a4ed031f8961410156e395d42b091208", size = 229060, upload-time = "2026-09-23T06:23:44.056Z" }, -] - [[package]] name = "nodeenv" version = "1.10.0" @@ -1184,31 +596,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fb/0b/b12a2df5d1b774bd9007a6fdff9381145b6223d37f11afc9c37ab0efd9a1/numpy-2.5.3-cp315-cp315t-win_arm64.whl", hash = "sha256:befa1ae5bd6030b3f512b43ff3fa5290bbed6b84411a44244b14adf835f5b89d", size = 10850807, upload-time = "2026-09-06T16:27:43.868Z" }, ] -[[package]] -name = "openmeteo-requests" -version = "1.7.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "niquests" }, - { name = "openmeteo-sdk" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f0/dd/b645df0f975c0bca1415fc7d2920615f35d4f8f092e9f6012c234ce212f5/openmeteo_requests-1.7.5.tar.gz", hash = "sha256:5557b5df957aa00f5e673d85ddc47f8b5bcadde6c72a98c0ec1999b538db39f9", size = 5752353, upload-time = "2026-01-19T16:42:20.006Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f9/ac/5fcc90394486247405ea5f12623de4bd93b4ab31a34e6b782ad1a9834d8c/openmeteo_requests-1.7.5-py3-none-any.whl", hash = "sha256:790cfd7942b030901696c9b0e84cebf21b6593b95fe640d0ab223ea1b6328190", size = 7149, upload-time = "2026-01-19T16:42:18.241Z" }, -] - -[[package]] -name = "openmeteo-sdk" -version = "1.28.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "flatbuffers" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/2a/3d/bce9523afe53743a0e47b5fd9ecf5500070eed816494798189b13d87858a/openmeteo_sdk-1.28.0.tar.gz", hash = "sha256:e77011b34f8696eb8cd6756d22b6868f9b3420671b93285171f2ab0ea3be1ba4", size = 12559, upload-time = "2026-07-29T09:43:44.232Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/48/0e/31785d1ceec24a5d8b7f8425430fbc9099769940ee970de2f5a8aa187b6f/openmeteo_sdk-1.28.0-py3-none-any.whl", hash = "sha256:e975faffd9f3095ec2e10409b1233913b5110a2cf47dd7e64fc650c661619fe2", size = 19629, upload-time = "2026-07-29T09:43:43.183Z" }, -] - [[package]] name = "packaging" version = "26.3" @@ -1280,90 +667,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/a3/6419c14da2adc1f09a6a183b8f91d7494d325b287f4ca984ac04f663638a/pandas-3.0.6-cp315-cp315t-win_arm64.whl", hash = "sha256:963ca21199097a84c7827c4678b04e30833084fbf8ef44fde3fa7180a29f8fa0", size = 9364529, upload-time = "2026-09-17T23:23:15.274Z" }, ] -[[package]] -name = "patsy" -version = "1.0.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, - { name = "packaging" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/08/e3/950e513affdfe1c1864397b4f40d6ed06ec1b54f608e462c27f151dd72df/patsy-1.0.3.tar.gz", hash = "sha256:79ebf4c93ff4d296e58a9d5be2b2ee31bd49d737cf11d70ffbd8a44b2de42e65", size = 399992, upload-time = "2026-08-29T21:35:32.258Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/20/70/cd3cf5cff538323076a879edef02cce6f13f7d75e03d12f211b0a0dbd178/patsy-1.0.3-py2.py3-none-any.whl", hash = "sha256:d3dbebe8fd5f46e29912d030b63c6268647b59bf788a99e2af28a30234cf357c", size = 233318, upload-time = "2026-08-29T21:35:30.913Z" }, -] - -[[package]] -name = "pillow" -version = "12.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" }, - { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" }, - { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" }, - { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" }, - { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" }, - { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" }, - { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" }, - { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" }, - { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" }, - { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" }, - { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" }, - { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" }, - { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" }, - { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" }, - { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" }, - { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" }, - { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" }, - { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" }, - { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" }, - { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" }, - { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" }, - { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" }, - { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" }, - { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" }, - { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" }, - { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" }, - { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" }, - { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" }, - { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" }, - { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" }, - { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" }, - { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" }, - { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" }, - { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" }, - { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" }, - { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" }, - { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" }, - { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" }, - { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" }, - { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" }, - { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" }, - { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, - { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" }, - { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" }, - { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" }, - { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" }, - { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" }, - { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" }, - { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" }, - { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" }, - { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" }, - { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" }, - { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" }, - { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" }, - { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" }, - { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" }, - { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" }, - { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" }, - { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" }, - { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" }, - { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" }, - { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" }, - { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" }, -] - [[package]] name = "platformdirs" version = "4.11.7" @@ -1563,15 +866,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, ] -[[package]] -name = "pyparsing" -version = "3.3.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e4/11/b213bebff182584360cb8d17c72c1677fec5c5c228de439e63bcf8ab1c8f/pyparsing-3.3.3.tar.gz", hash = "sha256:928ae7e20211f3b6f3915a72f06a0cfd29ab9d24279dd6346b6b1a7146397d36", size = 1050487, upload-time = "2026-09-20T20:59:05.609Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/38/bb/d215ee7c73b61497b28a5503f9f53523f294fcc936762b7caf90e0c1c2b5/pyparsing-3.3.3-py3-none-any.whl", hash = "sha256:ece8c00a69cf01b45d0b1dedabb469c90d8caf996d4fda40f147627a122849a4", size = 126420, upload-time = "2026-09-20T20:59:04.025Z" }, -] - [[package]] name = "pytest" version = "9.1.1" @@ -1634,6 +928,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" }, ] +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -1680,110 +983,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] -[[package]] -name = "qh3" -version = "2.0.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/76/d3/692f1410aad9cfda0001e1e74533cb2cbdf0c525a8e7c2e4c587a51929ae/qh3-2.0.4.tar.gz", hash = "sha256:72b174c9dabfccdddf050dabc3c1b49972ed7a7564896d3db558e57376187f75", size = 380633, upload-time = "2026-10-01T08:40:22.204Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/0d/8b2f9939533132a5eae6fab2f562d842248cc96dc2694330f6a58fc0c506/qh3-2.0.4-cp313-cp313t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:450914c25105e6ce2b4037b3e09bf4ac2516084c4308a2b17117a251eb0ede70", size = 4669169, upload-time = "2026-10-01T08:36:10.777Z" }, - { url = "https://files.pythonhosted.org/packages/a1/a5/2fa3a4f8a3fd94566830722be66c70b71814ef167bf5fb47274ffd27a943/qh3-2.0.4-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf65072302d50cd24a543d053cd4e465c353d78afd209bc39d43ff86fefa7870", size = 2288032, upload-time = "2026-10-01T08:36:12.609Z" }, - { url = "https://files.pythonhosted.org/packages/08/80/78e53b93a523116996117eff8e05f9e3cac435c8c9a28ee732fa8d22e61c/qh3-2.0.4-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4104481a7d026ca6fc8f42c741474f09f4c5caf3118e10ba97edb5a4684a6159", size = 2014560, upload-time = "2026-10-01T08:36:14.382Z" }, - { url = "https://files.pythonhosted.org/packages/e0/8a/24bd1ce85f1e12cb43b45adea70e12a4f35b97d1e34676357d6d61c315b4/qh3-2.0.4-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:77687d315857f3077ccee21300638fc5ede09b6452ca07dd2c5c722640e53520", size = 2200379, upload-time = "2026-10-01T08:36:16.195Z" }, - { url = "https://files.pythonhosted.org/packages/a6/d5/665dcb2b00dea31cc957484c28a6554e02ebad2a7f82e1512afd4f6d3157/qh3-2.0.4-cp313-cp313t-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:83babe9d129ad4bae3f6d8d98d48187d849312412f18e2d1858e1e67cd9ba165", size = 2188529, upload-time = "2026-10-01T08:36:17.967Z" }, - { url = "https://files.pythonhosted.org/packages/95/76/9a9f17c58ce5bf651a912fcefe5e355a22aad6a2eaa38e5d45924488117c/qh3-2.0.4-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8896bd885127ea94cda2421bfa554eeb02dc8cfde236195ec7c5faab8a019f65", size = 2209283, upload-time = "2026-10-01T08:36:19.798Z" }, - { url = "https://files.pythonhosted.org/packages/ec/db/36c584437b782fe630103a7a8fc71167a403b3475992b7dec7ce77cb1779/qh3-2.0.4-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99aabab5245ed9be3056212a72695bcf192e20379bfaad6d0c68f3a7ea2b6b99", size = 2243832, upload-time = "2026-10-01T08:36:21.782Z" }, - { url = "https://files.pythonhosted.org/packages/c7/fc/a6349cbc5580e92b763265e4027a166b83661b801f1165a02c09972f7305/qh3-2.0.4-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:194765884027e1a6055facc4763bc6881de71b0bc4d573fe8fdec51a9ee94767", size = 2513297, upload-time = "2026-10-01T08:36:24.014Z" }, - { url = "https://files.pythonhosted.org/packages/6e/15/89f088d6bc1110d0908191622e04e9da404b5fd9b9fd5242cfa90c65c489/qh3-2.0.4-cp313-cp313t-manylinux_2_39_riscv64.whl", hash = "sha256:5e5f42683b5b8a9435babf735db0d44595bec7cb62938d7f3da143909a990824", size = 2136618, upload-time = "2026-10-01T08:36:26.252Z" }, - { url = "https://files.pythonhosted.org/packages/2b/d0/49c9866fd3e0cc35c699a6b9a14e6ddd8ba56071ae1ab6ac16228cd38c9c/qh3-2.0.4-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d6980e0db66971aed7cf45e84aaca59c4cfc3cde4e838af9c37b1f76fb2f7ea2", size = 2481361, upload-time = "2026-10-01T08:36:28.207Z" }, - { url = "https://files.pythonhosted.org/packages/a9/54/178f4ffbb7ca7f73a70362974e74b48c46ae56e51ec26a35343ed54786d1/qh3-2.0.4-cp313-cp313t-musllinux_1_1_armv7l.whl", hash = "sha256:34802c8e0a5e9dc3139f292153771517aa22599534201136cde86c46764e243d", size = 2271701, upload-time = "2026-10-01T08:36:30.673Z" }, - { url = "https://files.pythonhosted.org/packages/3c/77/6a79a4e94ee50f623d9e1e37039a85b4d03cf35242b7acaad4f6b72e59b4/qh3-2.0.4-cp313-cp313t-musllinux_1_1_i686.whl", hash = "sha256:26ea067c0601345ac9c0a8b19204e7976a78f05644ca3ba53d6272787eb81c62", size = 2389324, upload-time = "2026-10-01T08:36:32.717Z" }, - { url = "https://files.pythonhosted.org/packages/7b/7f/f5347c28bb5173f74a008dc3d1a230560acadbf56e70bea28fe027ba4d09/qh3-2.0.4-cp313-cp313t-musllinux_1_1_riscv64.whl", hash = "sha256:0852cc816943ef3a9109feb2b4cd2e2a3233bced3e9653812d8f6ce8f287624f", size = 2245847, upload-time = "2026-10-01T08:36:34.548Z" }, - { url = "https://files.pythonhosted.org/packages/21/e5/0638a1c5de22ecaade29441eaae4c20f332100184e0fd096b74ae17074af/qh3-2.0.4-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:72114f1b8ef09e8c2b79212e05b35a6434f3c226ae5307fb09c7b5c8ca518d02", size = 2737552, upload-time = "2026-10-01T08:36:37.114Z" }, - { url = "https://files.pythonhosted.org/packages/2a/bb/3fca39f94e915b7625083bdfc2420057ffc99a0e313d4c0ec486806b3931/qh3-2.0.4-cp313-cp313t-win32.whl", hash = "sha256:18e9a092dcdefac66c57959e9c1254f990ff8e33f2afedcbe71ede27d91acde7", size = 2016042, upload-time = "2026-10-01T08:36:39.059Z" }, - { url = "https://files.pythonhosted.org/packages/2b/07/f5d1b84a9edeb8e5762910c2b5a7a2bff9dcba6b8e57978d1ffc34173fae/qh3-2.0.4-cp313-cp313t-win_amd64.whl", hash = "sha256:4678fc59d36c693bea489c9e3be5946a312d6f671a8fe226ac0ddee778a275de", size = 2272863, upload-time = "2026-10-01T08:36:40.864Z" }, - { url = "https://files.pythonhosted.org/packages/ab/4c/4c1d6e49c08913131159f3b384e004bb39bb5d596028dec44b8d0d3d175c/qh3-2.0.4-cp313-cp313t-win_arm64.whl", hash = "sha256:d609dd7815c63ba1117cb5d612b26d69d1dda12e5e1f7c7ec005f387e34c111d", size = 2094890, upload-time = "2026-10-01T08:36:42.833Z" }, - { url = "https://files.pythonhosted.org/packages/b3/9b/23facf63fac09a724b733e1a3967d0a5ff86d9a911ff6f831e50ca360709/qh3-2.0.4-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:a64bf88e94862d54a0406db92b4c51e86775e2fa5223618f92431f7ea0e668a1", size = 4670152, upload-time = "2026-10-01T08:36:44.901Z" }, - { url = "https://files.pythonhosted.org/packages/4a/62/b2af91aed5ddd040ed6d1fa6bab0183b55e30cf9b0a0a412e6df6a890b30/qh3-2.0.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d9d22cdf5a643f3f3236b3e25462d33083f88d200425afd09958cc0d2c5ca54", size = 2288241, upload-time = "2026-10-01T08:36:47.042Z" }, - { url = "https://files.pythonhosted.org/packages/9d/dd/eaa827770d96b422d9b88d3789a327bd22c1d735add8ccaaf1336c7e4145/qh3-2.0.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6f9d726d83942a2cb20ee127e995a0249dbea3ffe2b3772eb113acfdfd3874a1", size = 2014730, upload-time = "2026-10-01T08:36:49.637Z" }, - { url = "https://files.pythonhosted.org/packages/8c/77/d83d0a386c73ca75dc1ae16c090db77671c0c56c927904f1918b0a4602cc/qh3-2.0.4-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:968a94dcdc85219794d44fdce7b15ce6a90ca295f2574881b623ebe06850311c", size = 2200409, upload-time = "2026-10-01T08:36:51.817Z" }, - { url = "https://files.pythonhosted.org/packages/e6/43/ebe3c0268d26089431977c087c2b917a2d0d08f61032e2728fe4edc0ba0f/qh3-2.0.4-cp314-cp314t-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:bcae368dc6b5449397f81208493361a5197df1f955d4883b164294915f83638b", size = 2189134, upload-time = "2026-10-01T08:36:54.03Z" }, - { url = "https://files.pythonhosted.org/packages/5e/78/dfd4ccb1b743b178617f453afda4413de1670ea07b95e035db5b9bccdf08/qh3-2.0.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:36f201b834d96759a241171a4cdc6ab7442a6c52260d1dcfb1879707b430c6d7", size = 2209549, upload-time = "2026-10-01T08:36:56.359Z" }, - { url = "https://files.pythonhosted.org/packages/f5/03/863f5a5fd376712752e29e77f627a326930679e617340bb03e5757f22c77/qh3-2.0.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2f47b1f2c5fe8d70472a6134f374b6022ad6f709d6be56282ad4024d28905829", size = 2243982, upload-time = "2026-10-01T08:36:58.176Z" }, - { url = "https://files.pythonhosted.org/packages/eb/9b/ec159ecf23d089b981cb13e072f889a6c366377283bb45d4ebbc7b5ca952/qh3-2.0.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:edc81973c3823533d75da5570e89214d87aded6205ac76cb9ae3bb9f25b5ffe6", size = 2513632, upload-time = "2026-10-01T08:37:00.055Z" }, - { url = "https://files.pythonhosted.org/packages/4c/5b/ea1d431681c20fa62fd6d4f029e8ef49e40adf634425c9742e1e343933ae/qh3-2.0.4-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:1964c6471d5e4c074a002bf6a95c1797576165c000d74448260426e10209bed0", size = 2137457, upload-time = "2026-10-01T08:37:02.249Z" }, - { url = "https://files.pythonhosted.org/packages/e1/40/5a2637158ef3ed7a2b8872163726888b22cf6f348911c86d4e59bc631064/qh3-2.0.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:690cf4bcd8c7baf075187d6197ab31cc933d2aa951311dc65729cb01dccc92dc", size = 2482032, upload-time = "2026-10-01T08:37:04.728Z" }, - { url = "https://files.pythonhosted.org/packages/49/19/975cba853ef505246dee37415bb3c59f49b4107b552010dc420d258df882/qh3-2.0.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:8449e99c275b04c866f13f24e6d656d609fe8875fedf5896201a93a60bbe1412", size = 2272089, upload-time = "2026-10-01T08:37:06.729Z" }, - { url = "https://files.pythonhosted.org/packages/48/f2/e995bbfca89bdcb5262c831e8d1007c5427217faa762134a42144511a085/qh3-2.0.4-cp314-cp314t-musllinux_1_1_i686.whl", hash = "sha256:db25d8132c966ccebc294b8aeb197d2f48d14f772e675ff8282ab958009b801b", size = 2389523, upload-time = "2026-10-01T08:37:08.681Z" }, - { url = "https://files.pythonhosted.org/packages/aa/83/1701570200d36d72265d0cc57753f0a297148b5f7e4d2ac8a342c967e22a/qh3-2.0.4-cp314-cp314t-musllinux_1_1_riscv64.whl", hash = "sha256:c03566def5374925a6270bbc1dafc8c8889b3ff3ecd14396979ea07fec9def26", size = 2246808, upload-time = "2026-10-01T08:37:10.786Z" }, - { url = "https://files.pythonhosted.org/packages/9b/85/72343d1ac93240e4719e3e665edf0d0db31ba429234fefde635e7beaf94e/qh3-2.0.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:19cd7751da3f4d581ea6f5f44510a2b30a19495daba4e632c4e96d8375856694", size = 2737900, upload-time = "2026-10-01T08:37:12.851Z" }, - { url = "https://files.pythonhosted.org/packages/01/04/3dbdcd11d46b376e982715e7bbce6a3fe4d3acee50ffe3c3d19fbd817bac/qh3-2.0.4-cp314-cp314t-win32.whl", hash = "sha256:c6414837e375fd06f4664b276fcc3e76b17fa2431f650f52f0e41c48489d7e81", size = 2016207, upload-time = "2026-10-01T08:37:14.834Z" }, - { url = "https://files.pythonhosted.org/packages/9e/c1/bbeb18b3f934546454f77849b03b1df8296295306bc2a4201d17f6a017df/qh3-2.0.4-cp314-cp314t-win_amd64.whl", hash = "sha256:1923830f3e5e236bcf024ea317625346e5a3e1b08e7852e83293c1e4b3b8d7db", size = 2273740, upload-time = "2026-10-01T08:37:16.777Z" }, - { url = "https://files.pythonhosted.org/packages/96/47/dddc83f5af33072ccd851c61f7b70a94b6daf49cc6ddbbc05948633ac5a0/qh3-2.0.4-cp314-cp314t-win_arm64.whl", hash = "sha256:9d505d790ab10e378cf4e5c2d6447cb8b602d9cc36a4113160f9ece7bba9abc6", size = 2095281, upload-time = "2026-10-01T08:37:18.798Z" }, - { url = "https://files.pythonhosted.org/packages/3f/f5/a56a4707bd64fbd0e8edf91f08f798e66596555a0440a61ca6245140e330/qh3-2.0.4-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:ec087930c9b43d1e32c5c4cdc8d550fcb72cbfcc957c1d922859ce11b1e55663", size = 4687298, upload-time = "2026-10-01T08:37:20.783Z" }, - { url = "https://files.pythonhosted.org/packages/cc/d8/9de98b875a4fd0d080b720add984ed3a2e6fc60c52362b4c9f2b1b7306f3/qh3-2.0.4-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:406577d5fc72bed8d12b6397099b4e164948171dd0a16be8e768b31f1ef155ae", size = 2293535, upload-time = "2026-10-01T08:37:22.979Z" }, - { url = "https://files.pythonhosted.org/packages/54/fc/39c7971eb10fbee02f9cf1d4c8df185f36d7e9063d57e87717db352c96cb/qh3-2.0.4-cp37-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2e94ba4e929e0e54f615c4e28493980af5d82a6d928c644a2da26983df73e110", size = 2018713, upload-time = "2026-10-01T08:37:25.11Z" }, - { url = "https://files.pythonhosted.org/packages/81/d2/aa469211fb8d3cc78779ef245f2c26f2c1124797f3005f5ac00a4e08bfbf/qh3-2.0.4-cp37-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:03015a711f8b1ab24825d6ac6d2af1874d4157ac886d236bc19f7a21e69377b6", size = 2204383, upload-time = "2026-10-01T08:37:26.931Z" }, - { url = "https://files.pythonhosted.org/packages/b9/9c/3fae765e1e3613569c062dfe0c6c72d63d1d8fb1611ae313fe359e5f47a5/qh3-2.0.4-cp37-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:fda466d648cebffccca1a68d9716b31d41eb09b284ecf17705b4a04991dbb881", size = 2199386, upload-time = "2026-10-01T08:37:29.283Z" }, - { url = "https://files.pythonhosted.org/packages/f5/c1/d73e1c6259c3b4883df041ceeb53778f509625b28df9af3840539d179547/qh3-2.0.4-cp37-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:06e8c7ad92fcb482de360af5ae00d8e4559e969d1559a5a88b68989c6bac22c4", size = 2215951, upload-time = "2026-10-01T08:37:31.679Z" }, - { url = "https://files.pythonhosted.org/packages/97/91/a37e5c5ee6beea12b6743af42893ea1f2d3b94b95cbadf57abc10db6e69f/qh3-2.0.4-cp37-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6c0907ab53bd1fb1cca0a309cf26df42c9862e43e40c79d0e5ac3d2b6b747689", size = 2250916, upload-time = "2026-10-01T08:37:33.8Z" }, - { url = "https://files.pythonhosted.org/packages/77/e0/25e888ca43626083989396eaa97394c87694f7032489586b33b455ffd363/qh3-2.0.4-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:af34fad8554560fa38304f61708176379d5efb192da2ece5fb25dec23c304b36", size = 2518986, upload-time = "2026-10-01T08:37:36.103Z" }, - { url = "https://files.pythonhosted.org/packages/f4/2a/4b929c53dfc99e685ff1489209618a4d90ec8b1ec534b212d875be58d90a/qh3-2.0.4-cp37-abi3-manylinux_2_39_riscv64.whl", hash = "sha256:b6760be9bc0401bf7e6c1e889181587619529f34ffcf3f8a8de5f074815dc2ab", size = 2143334, upload-time = "2026-10-01T08:37:38.022Z" }, - { url = "https://files.pythonhosted.org/packages/cd/86/56e64b90aa4c621f3051f296b56783ede536ccd4bbe86b8afe3b20c95555/qh3-2.0.4-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:a29a0c05b7986ae0400a4229ca7176805cd44774675663b4ab0d4b6ebef7655c", size = 2486716, upload-time = "2026-10-01T08:37:39.984Z" }, - { url = "https://files.pythonhosted.org/packages/9b/7b/76ee46893aab07fbac826f370f0dd1917e40a565dcf2cca9d6eab52a12e6/qh3-2.0.4-cp37-abi3-musllinux_1_1_armv7l.whl", hash = "sha256:3dcedaeaf791114a050762b097b18839f6cc491dbe83873137b080f7b6dd1411", size = 2276102, upload-time = "2026-10-01T08:37:42.274Z" }, - { url = "https://files.pythonhosted.org/packages/22/77/5ddf3e3c0c22767af0a044aaea4fca4a20edd311bd961643eebc66f3c4cc/qh3-2.0.4-cp37-abi3-musllinux_1_1_i686.whl", hash = "sha256:8a8fbc4f30683a47dfb2b9eebc95e5f8b76c3a1eb818b0dd3354aad54c2d99f6", size = 2393700, upload-time = "2026-10-01T08:37:45.343Z" }, - { url = "https://files.pythonhosted.org/packages/ad/fe/1bc12e8c21203e96b659bfe60306b6c86098257bcc5ab90635d981df16ad/qh3-2.0.4-cp37-abi3-musllinux_1_1_riscv64.whl", hash = "sha256:d777f66879bda1e2b73caa2ca624045362d3aab8b474d2db868d0f284822e24c", size = 2251101, upload-time = "2026-10-01T08:37:47.749Z" }, - { url = "https://files.pythonhosted.org/packages/97/b7/8d9c5e2cd35da5337619a144b4cec2502f5f74f47a9faaa80b0e104821ae/qh3-2.0.4-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:1dad9bae44479f682cad2e0c0ea8cbd384ce42cd2921f1f1b4c03e1299f6acbc", size = 2743308, upload-time = "2026-10-01T08:37:50.067Z" }, - { url = "https://files.pythonhosted.org/packages/d4/c6/41b844253571a2fd07606af62a255a6a8e1226853efd9668996627de31b1/qh3-2.0.4-cp37-abi3-win32.whl", hash = "sha256:45d57837ef9990c58c1776a12d551e7c88f2592a9885b6e781d30e18062b57c6", size = 2023298, upload-time = "2026-10-01T08:37:52.664Z" }, - { url = "https://files.pythonhosted.org/packages/63/3d/fd077e2b0a8108a63c8139545e2e220d7ba3e1fd2746fbc31b69ec31be39/qh3-2.0.4-cp37-abi3-win_amd64.whl", hash = "sha256:53f7180a74db23f5469a610a6bcdf2b29a4ed45e067a40276c83b1cead3816df", size = 2283134, upload-time = "2026-10-01T08:37:54.583Z" }, - { url = "https://files.pythonhosted.org/packages/12/6f/31e604f3cd5ff8ab0ec5658ecde85683eba382dd8adb4d4e8aae2f7c5531/qh3-2.0.4-cp37-abi3-win_arm64.whl", hash = "sha256:79e93801a8f33e651a7751bde3e0e120b565a93d18d146bf1fc8127d1669494f", size = 2099099, upload-time = "2026-10-01T08:37:56.972Z" }, -] - -[[package]] -name = "requests" -version = "2.34.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "charset-normalizer" }, - { name = "idna" }, - { name = "urllib3" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, -] - -[[package]] -name = "requests-cache" -version = "1.3.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "attrs" }, - { name = "cattrs" }, - { name = "platformdirs" }, - { name = "requests" }, - { name = "url-normalize" }, - { name = "urllib3" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/32/ab/a340c7f529646f16e5656a8ba1424ed0de406203e4554868491786628730/requests_cache-1.3.3.tar.gz", hash = "sha256:79b72d5ac5143992d1836ad78f4d8e65666061dd44e220548caab3723089826b", size = 101179, upload-time = "2026-07-03T19:48:57.963Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a5/bf/c1775e49b350225bd851576ba75263bc728d8f05c0e31439a45f3429cc7b/requests_cache-1.3.3-py3-none-any.whl", hash = "sha256:c8df20ff874ebfc026959e3874e6c12bd6724934cdb10925915908453d4b17e4", size = 70788, upload-time = "2026-07-03T19:48:56.693Z" }, -] - -[[package]] -name = "retry-requests" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "requests" }, - { name = "urllib3" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/1e/da/6e961557733660bef8d095a1d81423a3707486e2b2ecd2c5ad5ad8d2f59d/retry-requests-2.0.0.tar.gz", hash = "sha256:3d02135e5aafedf09240414182fc7389c5d2b4de0252daba0054c9d6a27e7639", size = 16084, upload-time = "2023-05-28T18:33:08.196Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b1/f3/8ce908497bebbc2790ef06240a2c0fb28c096abb59062d88f85090464a5f/retry_requests-2.0.0-py3-none-any.whl", hash = "sha256:38e8e3f55051e7b7915c1768884269097865a5da2ea87d5dcafd6ba9498c363f", size = 15772, upload-time = "2023-05-28T18:33:05.868Z" }, -] - [[package]] name = "ruff" version = "0.16.9" @@ -2007,64 +1206,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969, upload-time = "2026-08-08T18:27:56.196Z" }, ] -[[package]] -name = "statsmodels" -version = "0.15.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "formulaic" }, - { name = "numpy" }, - { name = "packaging" }, - { name = "pandas" }, - { name = "patsy" }, - { name = "scipy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a8/1f/a3cbf6ed7cf6286afec694bfef561f3e51483197716717fb8476f1f0c50e/statsmodels-0.15.0.tar.gz", hash = "sha256:5d257fe58d0772bc46a557880ca78e2a8e07fec7bfd9d11074aef8e33e1aecbc", size = 15736853, upload-time = "2026-08-27T10:45:21.988Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1f/17/14121b56377f2881ce94c0d0a01466585468923340d94841337d324f6be6/statsmodels-0.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8e8deb1e3d4ab89fec2a52815f1ec924920c4e0cdb307421a4e47cfed87c03ff", size = 11654329, upload-time = "2026-08-27T15:09:42.207Z" }, - { url = "https://files.pythonhosted.org/packages/ef/f5/6b550b5c936dd9ec5c859477698b4fcbccc826e9a4a4a52847fa51673357/statsmodels-0.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c6e20788141df134b83ffb43b8cccf135a876188f84124835c86f011f138adee", size = 11527231, upload-time = "2026-08-27T10:35:42.669Z" }, - { url = "https://files.pythonhosted.org/packages/f0/a5/0e7e87e0dedc68bfeecc24bea92876023db09bac1c0fe50584a7d0819fb5/statsmodels-0.15.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4f90dc4989bd82837c80059fdce132976d4e5a80100119d16e1b00537eb55867", size = 11747428, upload-time = "2026-08-27T14:32:28.987Z" }, - { url = "https://files.pythonhosted.org/packages/41/c7/aeadca282baf51d7b9c135674bac79ded91f4db79c69c188575d22234342/statsmodels-0.15.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6f2bd48915cc9b3b95c3c7f6ac363b6f8aaa38172962ba5f7f6885f0dfe57f2", size = 12042643, upload-time = "2026-08-27T14:33:06.022Z" }, - { url = "https://files.pythonhosted.org/packages/9c/44/7d3eb3836b3c054280baca7ec91bd12858825de437c6319c1212c2531d94/statsmodels-0.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:733887a09ff878a2e29504eda684488b26bd134ba3539ac58d2136a58914c65a", size = 12079497, upload-time = "2026-08-27T14:33:47.147Z" }, - { url = "https://files.pythonhosted.org/packages/03/e3/d863228849da191d37c54bdd72634fbbcfadd12e910e29e4bdca1b952bb6/statsmodels-0.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:ba89eb3ccb1c9c1e400d5081157305495b09350b3a65f2d66d3424ad1d3c4209", size = 11322106, upload-time = "2026-08-27T10:35:58.135Z" }, - { url = "https://files.pythonhosted.org/packages/87/17/fee8cfc100c37b1c553515dbad59607a55085a5b9beac2b008e4c03b6388/statsmodels-0.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:09fe34ac80e147cd76cb702ba672559ee3c5dd3ba15c25a232922b46e5ffe70e", size = 11696848, upload-time = "2026-08-27T10:36:13.782Z" }, - { url = "https://files.pythonhosted.org/packages/9e/60/a885751cd3aa796ef94b60f6d3de0c9ace4190feb3e55414ee728d2a0abc/statsmodels-0.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0ffe08752f420f12a0b03e8f7d8f3a45417615309b1f37f10561f9a53f044737", size = 11556170, upload-time = "2026-08-27T10:36:29.848Z" }, - { url = "https://files.pythonhosted.org/packages/0f/f4/482701d2dc7540897b13ead827cf8499b11c7325753b8cc4ff1a82c87a18/statsmodels-0.15.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a7ab98634d47048c6b7c168b3f8f71369e256d7314bc386168526f34c7699d7", size = 11752604, upload-time = "2026-08-27T10:47:50.809Z" }, - { url = "https://files.pythonhosted.org/packages/cc/88/5c8819f232448d52514b76a64cf285bc608cff9179f3eeacf58750612323/statsmodels-0.15.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bcaa1e4499697c4e023e5fa6fb04459a34e4f4a449c45ae441992e933bde24db", size = 12055386, upload-time = "2026-08-27T10:49:13.7Z" }, - { url = "https://files.pythonhosted.org/packages/19/a8/298e9b5c993cad3b58913d61b7e7cb0e2fba12e39d3114cd8484f922a5d1/statsmodels-0.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:88dbe345ecf317862481b3c86305f42133082e5b366a3a8cf240a6caac404daf", size = 12088356, upload-time = "2026-08-27T10:48:10.645Z" }, - { url = "https://files.pythonhosted.org/packages/28/f5/386c6cfda27c47df81643dc9466b3e28dc2a82152e524131977275a88e0c/statsmodels-0.15.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:ac9bce461d0c8f529f8ed331a469eaa5dd7c3213c06b62623a2e5438a6383093", size = 9809418, upload-time = "2026-08-27T10:36:50.646Z" }, - { url = "https://files.pythonhosted.org/packages/15/b4/a8a4d89918599309bebfc09a2f793a910279f3900671bc959ac8c810edd1/statsmodels-0.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:081adf7e5f2da63f63cbd90491447de5d4c4c2921f19814f235bcf3e736edd3c", size = 11312877, upload-time = "2026-08-27T10:37:08.325Z" }, - { url = "https://files.pythonhosted.org/packages/7a/79/66b57afdcbf9a8b21b1a8e4414ddb006e819b2a81243cd74c12f3301eca3/statsmodels-0.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:3e5f870037ca154a4d177aeac68704a784b24cf2c45d86b046fc28e37a2a5584", size = 10812282, upload-time = "2026-08-27T10:37:26.708Z" }, - { url = "https://files.pythonhosted.org/packages/86/51/daf737efb6002905bd006d296f9ad031ed3861b640075bde3b734706b49d/statsmodels-0.15.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:04e65ee05475aeceb9cb954cf96c0f9361b9cdfd497b8aae47afbab0c7c786dd", size = 11698848, upload-time = "2026-08-27T10:37:46.312Z" }, - { url = "https://files.pythonhosted.org/packages/22/b1/ca42c91e427db43917d4ab5cc6991e7e50b574bcfdf6c4c8eb9590af71d3/statsmodels-0.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1f1443cc1ff7683f3d7a9054e6dccd92040fbd4324616a0b6dbb8af1ce7889fb", size = 11571229, upload-time = "2026-08-27T10:38:04.723Z" }, - { url = "https://files.pythonhosted.org/packages/5b/04/d803e8d014d4643d9c0a5eec78343073fbfff1c1018f776dd416c07fc076/statsmodels-0.15.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a45026596c71afa18543ec2af40b895609c181fc3649433c1cded5ce23ec5cd4", size = 11801588, upload-time = "2026-08-27T15:00:31.603Z" }, - { url = "https://files.pythonhosted.org/packages/fd/44/40d7678858065e9ec8efd9f37f04227b22a66632ba194e4aa43fda023f6a/statsmodels-0.15.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:edd803f4606061af8c7a83e2326ea294cbfa802a908dfbda4a88347301fb6820", size = 12062254, upload-time = "2026-08-27T15:00:49.803Z" }, - { url = "https://files.pythonhosted.org/packages/51/69/16c73c3c09f4b8d85f125d7d3ae0f7092d4736a83d24c0159e4fb21632ec/statsmodels-0.15.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:eb7fc1147cd8d17132190db7a8e63cd76a06145dec502816d8f3c20619923f2b", size = 12101259, upload-time = "2026-08-27T15:01:08.755Z" }, - { url = "https://files.pythonhosted.org/packages/23/4c/1138aad8e3c27ebf941d732788488cce7c28d76404a7b7fc1186faad2890/statsmodels-0.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:c3c1138b4d0e5b0c2387b17dafd2a0fd137c67bd869f70d4ee56feecc918cb0d", size = 11348219, upload-time = "2026-08-27T15:11:12.924Z" }, - { url = "https://files.pythonhosted.org/packages/3a/bd/53546d8c9ab013d9a0513706d2a09a03aff12034e6f898987e8ce0f38257/statsmodels-0.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:74c0b63448f03020cc27a8ee698fbcf2ba91e61a9f9eee7978c0c4ed33a9b17f", size = 10866645, upload-time = "2026-08-27T10:38:20.886Z" }, - { url = "https://files.pythonhosted.org/packages/38/a1/0d38328643d4704c8de7867518c3f52ec145ff62537b2ca1e14cdeecf51b/statsmodels-0.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:6197b4cca69fd7bc23ab9430abf1c479764cfe74767467f51964a633dfade7a5", size = 11870287, upload-time = "2026-08-27T10:38:38.271Z" }, - { url = "https://files.pythonhosted.org/packages/28/55/fac7e03895f24a0b4883ff9fc69f9421a5df1b60e7edd7e68d49f4327593/statsmodels-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5538a734dd28efd450bb38a5d5fc94d13ee709bc497e3e4c0a24b3803e3eac39", size = 11755467, upload-time = "2026-08-27T10:38:54.683Z" }, - { url = "https://files.pythonhosted.org/packages/4c/38/5321b9f6065d1c5861e87853720603d56ae81795a83d3040410ca2684305/statsmodels-0.15.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dde0657d18e322eedb2540a84c0582e97afd0f78c370b45eebb0d4ac228a7a83", size = 11636274, upload-time = "2026-08-27T15:03:41.86Z" }, - { url = "https://files.pythonhosted.org/packages/28/56/c75e984c492509d9ab2ba5f2c0e4eab7177cd2cec424cef9dafb42f0cc70/statsmodels-0.15.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90e75e726be2c9fc1d1d4c43337503a35864bdf5b4693773a08c6ee92d04a153", size = 11865818, upload-time = "2026-08-27T15:04:01.503Z" }, - { url = "https://files.pythonhosted.org/packages/61/72/9e03975701c808767c19f394f670941a962204a4f6a00eeaf56b560348e8/statsmodels-0.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65e64afc2655a486ea44a5ffe4e2b05ee3abbcb10ea61d1c02d3e9e519338d9b", size = 11907800, upload-time = "2026-08-27T15:05:49.786Z" }, - { url = "https://files.pythonhosted.org/packages/4f/a7/c467019a8bf1ab6a07ca88004f0a1d4ea2dae89642aa804e49437b256243/statsmodels-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:edee066ac9b171d95c3de925225331a21de1deb5b6452f252bc853e44566e72b", size = 11559083, upload-time = "2026-08-27T15:06:05.872Z" }, - { url = "https://files.pythonhosted.org/packages/54/10/dc78352367cd5bb298340b57c390c944b66236f6d026cc238bc8264c7633/statsmodels-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:40b2737456e75d96866943e33017a1dffc9166025ee33b7eb373fbe8c7a85e09", size = 11029613, upload-time = "2026-08-27T10:39:10.043Z" }, - { url = "https://files.pythonhosted.org/packages/de/cb/a1f5ad730cb66c7d911ebb45413880333d837f1cffff990a4c6d2c541b95/statsmodels-0.15.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:d48ba61db0d13d3033330e6bd1f864dfa9b48c0b952b3b0f7570206b35d552e5", size = 11702125, upload-time = "2026-08-30T16:04:07.635Z" }, - { url = "https://files.pythonhosted.org/packages/ce/db/fb95f242adce112c1507e2062ad6b7b00aea338925f2a03fbe47973c4d72/statsmodels-0.15.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:24b9917a09bbe0227047e512a40652c4b9117c2cfbf19c4a5b9971a8944a8606", size = 11571622, upload-time = "2026-08-30T16:04:29.558Z" }, - { url = "https://files.pythonhosted.org/packages/b5/3f/f4cc7e5f4d6c8f46551aee63e59d9b964881070f552400ebb76667b39ad9/statsmodels-0.15.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:333d0c70f269ff98b647f157d2ae7fbcf0617e6f9a5b1817fc6609766502292c", size = 11780713, upload-time = "2026-08-30T07:40:06.332Z" }, - { url = "https://files.pythonhosted.org/packages/e9/20/fa315a013f70efa6674adfaca59365ea7562371914c408551fc3c399792f/statsmodels-0.15.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:80b946d7c922f46f045f25e50daf13439f79c05e403ba50081cad509892c8b26", size = 12070117, upload-time = "2026-08-30T07:40:27.04Z" }, - { url = "https://files.pythonhosted.org/packages/91/43/a63f7880050c2c24056fa8d663b7c8a1816b25864660abbc73afff3f3558/statsmodels-0.15.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cf897abce5616e2763ab7d7a7fa914677df87a649971f877b83a358c98f6e291", size = 12105153, upload-time = "2026-08-30T07:40:45.814Z" }, - { url = "https://files.pythonhosted.org/packages/d4/d4/523ddb13862c228407297754486b6c9e8777e5114f4532b667b47728c544/statsmodels-0.15.0-cp315-cp315-win_amd64.whl", hash = "sha256:511a4f9cadbf91a690bfa863bb3e25dce583d8140046886fdf35fbfabfbce401", size = 11352844, upload-time = "2026-08-30T16:04:48.14Z" }, - { url = "https://files.pythonhosted.org/packages/53/59/f3058214e61b15582ef17d761c9333def46960ee521313409b2705791029/statsmodels-0.15.0-cp315-cp315-win_arm64.whl", hash = "sha256:0cb6228713fe47046b606daee63ed4473a7c246fa97cf0a6d6f20d3b0c96c051", size = 10866685, upload-time = "2026-08-30T16:05:03.971Z" }, - { url = "https://files.pythonhosted.org/packages/4a/2d/43089b37f4bbbf7373929cd198de6cbdb0b5a4580a6e5e32bf9fe74fccab/statsmodels-0.15.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:27e9467fae83ff327c9d16ae5ea7e771bcdd66a682f49aa324eeefea5584f2d5", size = 11858149, upload-time = "2026-08-30T16:05:20.585Z" }, - { url = "https://files.pythonhosted.org/packages/7d/38/ca88421b81666cdcfc3d0eb12c714f80f2be327669ececbb5930b68a71c0/statsmodels-0.15.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:ecf84918dcd410ab34d3d85d34e0b16ebb10319c9fc77dbf9a1f1a49324c59f8", size = 11739931, upload-time = "2026-08-30T16:05:37.286Z" }, - { url = "https://files.pythonhosted.org/packages/ab/64/98b9e2b1fbfdc4e2392bff349718d8dc796403006a3106ec375c59a5d3f8/statsmodels-0.15.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5891ad077b36d6401e44967739df3d2cdd380a0e6e9e46dadec7e3d3a828af55", size = 11657996, upload-time = "2026-08-30T07:41:05.608Z" }, - { url = "https://files.pythonhosted.org/packages/34/6d/d8c9e73013ee854031218e8a044ba679535268bc61cf59466f19ac8d980c/statsmodels-0.15.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:af80f9658c48792947fc754a026ffbebca61a8b483253781973f604a67cf1f8c", size = 11893402, upload-time = "2026-08-30T07:41:27.553Z" }, - { url = "https://files.pythonhosted.org/packages/cc/f5/b9b47f3c688a8af1b199af96e098b8f20897a60b45187c57ce6fcde77a5f/statsmodels-0.15.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:583eb2a46f7c7a5c34a26715c8b6d92199bfb4f04623db5fff9902a3c7c832d3", size = 11934786, upload-time = "2026-08-30T07:41:46.809Z" }, - { url = "https://files.pythonhosted.org/packages/c4/08/608871f18bc18c6cba0b484e81fd25a023ef3ca6541f6793ec839fa49e1f/statsmodels-0.15.0-cp315-cp315t-win_amd64.whl", hash = "sha256:38038823fbe86f11e09433b4a0f4af5f102327c3137a1604b002ce3cf722a31f", size = 11549124, upload-time = "2026-08-30T16:05:53.179Z" }, - { url = "https://files.pythonhosted.org/packages/e3/eb/90a506dbee5cde669e2ef28565ff6b7a0791ab415eab2da82f92d033cccc/statsmodels-0.15.0-cp315-cp315t-win_arm64.whl", hash = "sha256:90df413d7e09474f7e7cbf8807440c1bf76298cdd9daabf9141f76d7627c9151", size = 11021454, upload-time = "2026-08-30T16:06:08.124Z" }, -] - [[package]] name = "threadpoolctl" version = "3.7.0" @@ -2104,41 +1245,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f9/bc/8737e8d54cf51106118039b83f485a4783112fab49ea9d044b234978a46e/tzdata-2026.4-py2.py3-none-any.whl", hash = "sha256:c2169a8b0a7a5e9674da5a135ccdfb2b3e671b333ed9fed17b41f73c34476e81", size = 347494, upload-time = "2026-09-12T12:56:01.67Z" }, ] -[[package]] -name = "url-normalize" -version = "3.0.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "idna" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/33/26/b60cce0211e94bb130e88dbcba87583f61c6ddf386fa6adc10a167461f6a/url_normalize-3.0.1.tar.gz", hash = "sha256:1655cd214159d9d47dc37aa6ce993c2149da44fa35cac6bafd90036a4eda3ac3", size = 28198, upload-time = "2026-09-22T22:20:54.513Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9d/bf/98209a164859c81d9eec311ee2b35cd1e5b33c7be8d3665c08850557abe1/url_normalize-3.0.1-py3-none-any.whl", hash = "sha256:97ea68fc543b1fc9f270f34c90cf164453e7d490da2ec653dcd8ebd4e3ac1faf", size = 18296, upload-time = "2026-09-22T22:20:53.342Z" }, -] - -[[package]] -name = "urllib3" -version = "2.8.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" }, -] - -[[package]] -name = "urllib3-future" -version = "2.25.902" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "h11" }, - { name = "jh2" }, - { name = "qh3", marker = "(platform_machine == 'AMD64' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'ARM64' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'arm64' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'armv7l' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'i686' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'ppc64' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'ppc64le' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'riscv64' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'riscv64gc' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 's390x' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'x86' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'AMD64' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'ARM64' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'aarch64' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'arm64' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'armv7l' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'i686' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'ppc64' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'ppc64le' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'riscv64' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'riscv64gc' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 's390x' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'x86' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'x86_64' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'AMD64' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 'ARM64' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 'aarch64' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 'arm64' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 'armv7l' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 'i686' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 'ppc64' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 'ppc64le' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 'riscv64' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 'riscv64gc' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 's390x' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 'x86' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 'x86_64' and platform_python_implementation == 'CPython' and sys_platform == 'win32')" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a7/71/b8238b130ed77fafb067d87d26045fe67963494150a5977d4658712a826a/urllib3_future-2.25.902.tar.gz", hash = "sha256:5c443668b9f87bce38bad1fdec7d7950c47e9c5b7328b1fd029a429741f3be70", size = 1530236, upload-time = "2026-10-04T08:50:58.392Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/bc/c2a384f5bc7b98a48fcb58ded963bd0862f753f8442572ce76037a753e7b/urllib3_future-2.25.902-py3-none-any.whl", hash = "sha256:88e6f3c19f553931e78f84f011a34220f827c971e6cf06b3a075eaa788c2bf7d", size = 862229, upload-time = "2026-10-04T08:50:56.575Z" }, -] - [[package]] name = "uvicorn" version = "0.52.4" @@ -2209,15 +1315,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9f/34/88d507d4a4030fa559788de9c690a214f9a4053aa1d91cfb60e9b36127c2/virtualenv-21.7.8-py3-none-any.whl", hash = "sha256:3040eb3cbf5d32b10ffd57d167e6a162237ad82ba7d8cf1400a1efed593d85ac", size = 5324617, upload-time = "2026-09-01T13:36:11.248Z" }, ] -[[package]] -name = "wassima" -version = "2.1.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5f/92/fe256733440f7c36084ce924f373fbbf709277ddf92262b2295e8caf26d5/wassima-2.1.4.tar.gz", hash = "sha256:21bb77253bb8032c05393172c4d2df395f423b7e704538f3b2407f50c032034d", size = 143472, upload-time = "2026-08-27T06:15:48.889Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/56/1d/032b95952208fa3e623d54efb650bf0a134e2a02e7400b36c63363c27b74/wassima-2.1.4-py3-none-any.whl", hash = "sha256:26022272d4618aada0e26df2e79dbc0e034be8be9d6d5396512690ffc6a94e7b", size = 134348, upload-time = "2026-08-27T06:15:47.274Z" }, -] - [[package]] name = "watchfiles" version = "1.2.0" @@ -2432,78 +1529,3 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ce/3c/8b9a225b523f06a9389be81f1b0ab07c49bec6014742e6aa359c1f920f1f/websockets-17.1-cp315-cp315t-win_arm64.whl", hash = "sha256:e552e0037230ac16e5f568de7012041344d1b18c9feed30ec2891b8eba55af81", size = 217372, upload-time = "2026-08-26T14:58:22.807Z" }, { url = "https://files.pythonhosted.org/packages/41/63/23572870e01836a98346075b9e17a8bc24a6ddd9800a3204ceee58677f3c/websockets-17.1-py3-none-any.whl", hash = "sha256:f221081107b8c48184d99f7019604486376e7ef826037e70aad6b02540732c23", size = 211134, upload-time = "2026-08-26T17:25:31.397Z" }, ] - -[[package]] -name = "wrapt" -version = "2.5.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3e/d2/a254a26d8ceaea87e0eee2e89fcfe53ddc1858418647493bb2937549ab6f/wrapt-2.5.0.tar.gz", hash = "sha256:c48cdb6c904dca76d9915a579e4a5fab6b0c25f650c1019ce78a78effaf7a345", size = 183288, upload-time = "2026-09-27T01:41:56.874Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/a6/44589f9b34160280a1fbccbcf206b18df034a1b3e9584336d3a7f039a33c/wrapt-2.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b898caea081303006decc562c7fca5126f7c96507e78dd8f1ae3285dfa50ddc7", size = 106192, upload-time = "2026-09-27T01:40:00.941Z" }, - { url = "https://files.pythonhosted.org/packages/ce/2b/94db3ba2e9528400e4173cbb67a56822a4644d4c2b319feb0cc638094b36/wrapt-2.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8837fbe708cb9d8a2d32a37dee836d24a531f02560db26418e2b181986fa21cb", size = 105828, upload-time = "2026-09-27T01:40:02.494Z" }, - { url = "https://files.pythonhosted.org/packages/11/0e/3ce67af67525c0068680637c0eba823ce2f0fc6eff2d1779cab656fb8c3d/wrapt-2.5.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0cabb9c17ab79b2549d1f23b36f436473ad9253ef53995a817feba26fae69d5b", size = 250177, upload-time = "2026-09-27T01:40:03.819Z" }, - { url = "https://files.pythonhosted.org/packages/d3/3b/262b2c3c38aca6fa32cfea6407dbb346ce26ff788179b98137b3b3f3d8ca/wrapt-2.5.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6761765cc520ff9616fb035c02a85d1d744f7f70edd4649718fd0d09c589eacf", size = 250332, upload-time = "2026-09-27T01:40:05.15Z" }, - { url = "https://files.pythonhosted.org/packages/2e/ec/d54d273a2223d1964ecd5b5954aca18e8fbf2c5d8471e60ef3cbaaed9325/wrapt-2.5.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a145a7826eddea3eb5814903f98f93756042b919bb5305544cb1331daa2705b1", size = 229600, upload-time = "2026-09-27T01:40:06.47Z" }, - { url = "https://files.pythonhosted.org/packages/8e/88/33c75ac47b13b0bf77b44d96d02b1d0a2e6db66180909bacce86ef8fcd8e/wrapt-2.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6a9ee62a970075738909909bdbef3a7da9f7ae03dfca584db283547a29503b56", size = 246890, upload-time = "2026-09-27T01:40:07.838Z" }, - { url = "https://files.pythonhosted.org/packages/d2/0e/c3a3a158801e5bee7a0d9aa3561a127bd80f166b02b7b520a39e45d8f9b9/wrapt-2.5.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:691671ea05684f921ffc2e935fd3f9311c1795a10fbbfa006b46269733668f66", size = 226463, upload-time = "2026-09-27T01:40:09.206Z" }, - { url = "https://files.pythonhosted.org/packages/7c/2c/4c48ba51698a87e2e8299fc04856bbef391cb725f195bc93f0bc6196e1f2/wrapt-2.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e716f47c7f61e11709d3c0904213c94fc22999abf0c41461276cef886c1e8b4d", size = 238583, upload-time = "2026-09-27T01:40:10.631Z" }, - { url = "https://files.pythonhosted.org/packages/33/d2/077835618ed96b131730f74301a7236efd841ec2f341afe36303389b56ef/wrapt-2.5.0-cp312-cp312-win32.whl", hash = "sha256:5421acb5c363a9bc959122a8645e3f1f42010c932dc53885b11a5ff5b5a6d730", size = 100221, upload-time = "2026-09-27T01:40:11.91Z" }, - { url = "https://files.pythonhosted.org/packages/96/9f/9e56db2a3492275be809082c6f63a7a063f52b3e1812aae1eb03ec33f64c/wrapt-2.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:ab45839c912777e2738fed369636589c8b2a6d9c44ca56de0fd0814581d467c2", size = 105786, upload-time = "2026-09-27T01:40:13.208Z" }, - { url = "https://files.pythonhosted.org/packages/0c/e4/e37ff75e5254564aa50e13bc4313383d02d5ec78f5bd3b9b1994dd7052d5/wrapt-2.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:ce4cab32c37ef71e69cf88f909b7febd0dd79543e5ae3650e2b874e0f3d3b975", size = 102937, upload-time = "2026-09-27T01:40:14.534Z" }, - { url = "https://files.pythonhosted.org/packages/d6/4b/cc7bb5668f7ddc0e73e236e96a0c06cab8fddfca9c53538c9dffac62db6f/wrapt-2.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b312b3cc87951faaed3cfef984d768ee8bee7f935d9cc929aaa9946b0b96a98c", size = 105808, upload-time = "2026-09-27T01:40:15.826Z" }, - { url = "https://files.pythonhosted.org/packages/4a/13/5d15ef0e2f42d5f084930dc4863e6e52c160c28301c8780aae170c58421c/wrapt-2.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c57ddae24cf72eb6bd18112638a987cafe6109d90f2df111e6934362cc03ac1a", size = 105931, upload-time = "2026-09-27T01:40:17.136Z" }, - { url = "https://files.pythonhosted.org/packages/1e/02/c7174e78b0c38bb279b2d3c25a6bd7fb9d3b0200c3e5a8fad084e7cc3e85/wrapt-2.5.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b95a6eca3b927853529eea958310563c83140ae8451dd5dc4399c7da385dc4f3", size = 248259, upload-time = "2026-09-27T01:40:18.446Z" }, - { url = "https://files.pythonhosted.org/packages/a4/f9/47ae1d7ef325c3f6c81ae3c1fb4a3fef9d98c8025ed676c0bfc1550903ce/wrapt-2.5.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6058e12e9caa33468f9a36fb88c15a4bb30a479f997b37834b83abdbf062f264", size = 248224, upload-time = "2026-09-27T01:40:19.713Z" }, - { url = "https://files.pythonhosted.org/packages/38/7b/a394448bcbbaf8e5a3f856520edbbb1b92fc42061def56284c9083f3ac87/wrapt-2.5.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0d245ac03f5ae77f1eea6eb19edd9e778c2f772490c20496c2f1cd3a102ee1b6", size = 227137, upload-time = "2026-09-27T01:40:21.359Z" }, - { url = "https://files.pythonhosted.org/packages/41/45/fc252bda5aa1ca01bc838d3b108778e786a2a13d0c52fd17c5f6179aa246/wrapt-2.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f4ef4935962f7029b2058a99f1a47ccbffc3be919dddb3becb6c2c48eac3d9f0", size = 244710, upload-time = "2026-09-27T01:40:22.693Z" }, - { url = "https://files.pythonhosted.org/packages/0b/1c/527d1bde7371dcc2c378d88c97de03b121b486fb4cd3dbb399bc332c0676/wrapt-2.5.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f12e80c3089ebc03727d368f8205b811b5af2cd4a72b5e4cac75e901dd316e39", size = 225053, upload-time = "2026-09-27T01:40:24.345Z" }, - { url = "https://files.pythonhosted.org/packages/81/8e/2b823fded8c3b815408c58633929812eacd29d824fe57548b7868c4ee422/wrapt-2.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a346408f19b6d589bf029f25f65c0b4cdeed6302ef8f40da4e5d1552d22dc037", size = 236884, upload-time = "2026-09-27T01:40:25.654Z" }, - { url = "https://files.pythonhosted.org/packages/00/d7/5d185c1193b073a0bf4cbe862b5d31f81067eddc39eff30ae632f346563d/wrapt-2.5.0-cp313-cp313-win32.whl", hash = "sha256:79e68f0fd7d381b9bbd71776f602a2d5440d4d2077459128e02fd6607465422c", size = 100198, upload-time = "2026-09-27T01:40:27.111Z" }, - { url = "https://files.pythonhosted.org/packages/ce/9a/51d95640e01d0ebdd04a7223755f076e4936b0c124ce99bb01a12b53e66c/wrapt-2.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:77f0a74ff6f6cf89f5b673a732d5afe1911a6e6b1c017260836fdfdf85518dc1", size = 105610, upload-time = "2026-09-27T01:40:28.465Z" }, - { url = "https://files.pythonhosted.org/packages/67/52/183d5ce7c2a9391774e6a623be6ae564351545713ec9a3693528f89c8e85/wrapt-2.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:b620d7559b6b2197c5730332fab0867ecf1c8cb74d45533ebbcbcad1eacf4616", size = 102641, upload-time = "2026-09-27T01:40:30Z" }, - { url = "https://files.pythonhosted.org/packages/f3/4b/0009086ab8f2d5fb32405ef49fdd11104ce40f69ae9f4cdfba8326462816/wrapt-2.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:65f2ee406dc592a5b22a7dc6abac13e8a3e8de4b2ecf5dc3c22937865496e4b6", size = 106182, upload-time = "2026-09-27T01:40:31.503Z" }, - { url = "https://files.pythonhosted.org/packages/5d/35/8f38339a4c55a42df00296dcf6ad50598d2280049f7a6ffa525e9a1f66d1/wrapt-2.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d75d6366203c8d025c1a74bae0b565952187ddae79bd5c7bf10687652a56f020", size = 106123, upload-time = "2026-09-27T01:40:32.927Z" }, - { url = "https://files.pythonhosted.org/packages/23/38/285b433121d73c7a447b5b82d93c91dc3330f33ab5853975f34551e0c773/wrapt-2.5.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b640460f0ffb346b192686bd6fac5589e35a6c6640501c59a9fb6e82b0dd6bd8", size = 251245, upload-time = "2026-09-27T01:40:34.309Z" }, - { url = "https://files.pythonhosted.org/packages/7b/a6/3f63f4637e89484c1839a9ba3aedda5b2912e7ce12617034c6bd49752cfa/wrapt-2.5.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f17c5a3836397bf59fd57b0e5b0dc42969b1daa70d31aa361c6e13cbf138b5a", size = 250483, upload-time = "2026-09-27T01:40:35.841Z" }, - { url = "https://files.pythonhosted.org/packages/e9/cd/f24ee96016da222dbb921cfb22e2beb5ca189a7b730ef49e8bb106b49449/wrapt-2.5.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4343880acd72e74233baf092285aaaf4306244e31d7601828bd2600316027df0", size = 231341, upload-time = "2026-09-27T01:40:37.295Z" }, - { url = "https://files.pythonhosted.org/packages/23/eb/c9b180124271e494f615a130f966be56143e3e26e87706bc28582f94bc09/wrapt-2.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ea4fdc79c0045d6bb1603c109127145245cafe888588888444e1e37fbeadbac3", size = 247736, upload-time = "2026-09-27T01:40:38.653Z" }, - { url = "https://files.pythonhosted.org/packages/fd/60/345b8c213389809435d1950136b09991a1af2a66b988d0cd930ecd1b9f19/wrapt-2.5.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f42239c89430eee2d8a6dec39e34677abdbb67fff63caf2467dd6124ea4d4d58", size = 228533, upload-time = "2026-09-27T01:40:40.12Z" }, - { url = "https://files.pythonhosted.org/packages/ea/15/c79f0f5827a9062c6be4fc25dc73e92fe1c014c7bfde2e61c8c0b56a91af/wrapt-2.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:bad63bb4dea3c58e8078a3a173259ac2df5442a437e49c632b4099c0250e803b", size = 238830, upload-time = "2026-09-27T01:40:41.505Z" }, - { url = "https://files.pythonhosted.org/packages/5f/de/79a95ac238c9cae7ae7eb3a18501afc646e3ed61d8d108c725b17bbee301/wrapt-2.5.0-cp314-cp314-win32.whl", hash = "sha256:b58138d19f34e32833e62de5e910bc2a8baae43310b921d783bd39b15227c2dd", size = 100664, upload-time = "2026-09-27T01:40:42.884Z" }, - { url = "https://files.pythonhosted.org/packages/f0/15/32de0f1e6a46a82c773430672562d53203406df14a6d73c93abb59b679e9/wrapt-2.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:1a3c4035d2026b87ef23dd8d165f1f8d3853ee2bd02791cfd22bd8c6226c41ce", size = 105986, upload-time = "2026-09-27T01:40:44.652Z" }, - { url = "https://files.pythonhosted.org/packages/d9/2a/10a7ff69097385de15b3db7d91587a54c26f8025fcbf36a1d9e83a1e0ad1/wrapt-2.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:def66258d97ebf1e4e97def12c5daa542d1cc728a3da83ed3a43933f56df6dab", size = 103718, upload-time = "2026-09-27T01:40:45.956Z" }, - { url = "https://files.pythonhosted.org/packages/f7/01/963f893b1906ac6c2aecb777c36e9ab2156a4cf89fabf6125e953ec4ad52/wrapt-2.5.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:23a9d6cb6413359b76f030d6bbb75340b7669c69da245dde2919a4c93708993b", size = 109290, upload-time = "2026-09-27T01:40:47.22Z" }, - { url = "https://files.pythonhosted.org/packages/cd/6c/30e04d2b1284de2eea5411850008e0411d1876bf4552dc0990c904a0a783/wrapt-2.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:21cfe343ef9c2deb865ad0d5c57822266447c88dcf6d8805dd8c363fe367f30c", size = 109597, upload-time = "2026-09-27T01:40:49Z" }, - { url = "https://files.pythonhosted.org/packages/6a/34/3980fe5a899b69454f66db2991c144ecc828dbbd355ce6cd7b881056ebbc/wrapt-2.5.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0dfc38cb672af51fc29696ba9c6f05d2315f5e62c4af2564e50f07f81198a163", size = 283840, upload-time = "2026-09-27T01:40:50.406Z" }, - { url = "https://files.pythonhosted.org/packages/b6/b4/b37001235fd5871b3f31941229f8fef608279353b772dab3ccb248fd8726/wrapt-2.5.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:181a45000506a6382eb337354ca7e8525690702f1ccf2eae4f23a210ff339543", size = 293622, upload-time = "2026-09-27T01:40:51.868Z" }, - { url = "https://files.pythonhosted.org/packages/09/b3/9b751c6268fa2111efc7e43895105bc0f60a83896b08581009e77563f8c7/wrapt-2.5.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:58b2a87c65cbfb20917ec48ace47f71b1962c1f81dbf18a4052e3037abf72028", size = 262840, upload-time = "2026-09-27T01:40:53.297Z" }, - { url = "https://files.pythonhosted.org/packages/47/7d/b7b51d601981ccc1f7b9e6023991548dec43dc9d40317597fbe0085bc876/wrapt-2.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0ea62bc142f4fa8b2e0ab058f50699ccd679ef6199c8fa3cc1c2396c7a659000", size = 288781, upload-time = "2026-09-27T01:40:54.756Z" }, - { url = "https://files.pythonhosted.org/packages/d1/82/1a84f288246905d0a71d44aa1f470ff8c75df2b96ef791d2938124449cb0/wrapt-2.5.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:425349a99b8c9540399d36620c376dc26e6aca93071cd6fafa239c2f1b5d53a4", size = 260116, upload-time = "2026-09-27T01:40:56.667Z" }, - { url = "https://files.pythonhosted.org/packages/5a/0e/0572224d1c4a3f0846f82614702ec3110d45c843dcc7781c5f33779e1fdc/wrapt-2.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fe09aac4837ec720af606a493e814dcc3f65631984e1b7231b589efcf9917024", size = 278485, upload-time = "2026-09-27T01:40:58.443Z" }, - { url = "https://files.pythonhosted.org/packages/76/44/5a5c111f8ac6dd15f54437c2161588431d3924718a7e4de59c471cd794e9/wrapt-2.5.0-cp314-cp314t-win32.whl", hash = "sha256:bc5607c1911c92530cb402ea90d818933cffb28bd8de9b453a2542279816d8c7", size = 103157, upload-time = "2026-09-27T01:40:59.995Z" }, - { url = "https://files.pythonhosted.org/packages/e6/80/96cc2da58cbc0893f5165f6a0f4f9cb75d7574f409022ad792aa80a0ff3f/wrapt-2.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:7138b0e7990e5555a905c519e8dad17c1da1f20e08b283e202c414229065740f", size = 109683, upload-time = "2026-09-27T01:41:01.43Z" }, - { url = "https://files.pythonhosted.org/packages/c7/70/10dab499970e66c926ba6d404ff456318b68092b8ad0c4608a52160e43a2/wrapt-2.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:a5bb346a34499e091e4fa23df58251ad192173c088e5413d893ca1c730c133c7", size = 105953, upload-time = "2026-09-27T01:41:03.041Z" }, - { url = "https://files.pythonhosted.org/packages/ab/18/5154954f69afdbf5bdeddc07ed60f30bf6e83ed1e9fb6f96c66cc20e4223/wrapt-2.5.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:a45a5249a6965d91aac9f991fda7c17e6b8b41fe91592a6099f182bf53c82724", size = 106186, upload-time = "2026-09-27T01:41:04.472Z" }, - { url = "https://files.pythonhosted.org/packages/5d/43/7db9952d26b1a89afcf22da8ec7948e6ca55c48721488d53f84754eed89d/wrapt-2.5.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:cbd45dfba6b5c1bfbabe1feb3c0f117fbd62416e98268d9a7cd9ad8802875356", size = 106155, upload-time = "2026-09-27T01:41:05.795Z" }, - { url = "https://files.pythonhosted.org/packages/57/b6/41a0d7f9cf1f8e6aaecbf4b5b4eaacf4036fa3396c7d814364e07728a041/wrapt-2.5.0-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bc6491d3008ecabf685b0746f03ad8241a0950336939b14addb03af39b51a316", size = 251754, upload-time = "2026-09-27T01:41:07.26Z" }, - { url = "https://files.pythonhosted.org/packages/55/d4/dd2de1260a490cd55d083b3c1bc47a36aff0e8363249d108d3b34c091c0e/wrapt-2.5.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:47abb2bb7f15b416e72fbe5e68e49a6f09331dae6af1ca6055f5aa2251d2bd2f", size = 250768, upload-time = "2026-09-27T01:41:08.681Z" }, - { url = "https://files.pythonhosted.org/packages/4c/40/d08297feb5728cd6d3c1133633cad0249c2b7a82eb0213062ebab9cc1266/wrapt-2.5.0-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7e25e9697f60af41fb86b08697e470b1e7eb6cd6ac0eb25e4b1f519839adc271", size = 232618, upload-time = "2026-09-27T01:41:10.293Z" }, - { url = "https://files.pythonhosted.org/packages/54/52/d8ca61b26c2a34927cc999fc250f1018f415741691581280e6a76cced736/wrapt-2.5.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:bcd42e7b69c8c1e33a29b79b28de03bdc08876a49745830f9162a3af860e06d0", size = 248043, upload-time = "2026-09-27T01:41:12.132Z" }, - { url = "https://files.pythonhosted.org/packages/8c/5e/ba02904736e2d3b05afd7447b4ddff677ce61762265939555541adb8c668/wrapt-2.5.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:36703cafc2ec059e118c2175e6cb7ad7299c2924aecdcb1b7a7ebbf7a3e20c19", size = 229577, upload-time = "2026-09-27T01:41:13.746Z" }, - { url = "https://files.pythonhosted.org/packages/a0/94/23968c18a6e37a8a130706dc52ccf4344a71f1fe53c99965eb0a7715a459/wrapt-2.5.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:1ebc0d09906057ada57a32158a657364ca40b8f86e604da3e7d979069b601502", size = 239372, upload-time = "2026-09-27T01:41:15.459Z" }, - { url = "https://files.pythonhosted.org/packages/ee/ee/8437e73fffa57c96a5f0f6721f942ccf7b1461b64cd965f83e2181e25252/wrapt-2.5.0-cp315-cp315-win32.whl", hash = "sha256:76fb341d5a707a4f211631b8c77259b2df149147e9d9c245ae6ba3dd936bfdfb", size = 100673, upload-time = "2026-09-27T01:41:16.925Z" }, - { url = "https://files.pythonhosted.org/packages/37/6d/6d640f98197d68e61fbeaded20478e4aa840f9c88d11e7a49c8d6d14ae15/wrapt-2.5.0-cp315-cp315-win_amd64.whl", hash = "sha256:6269637d9a54990430b4a769df15833935a46c4d004d9fe8a153bbadf0b9a097", size = 105999, upload-time = "2026-09-27T01:41:18.372Z" }, - { url = "https://files.pythonhosted.org/packages/0d/3e/8b8a0c94f2698c99afb499510824b107c81e9c4a34b3db7e877233a634b7/wrapt-2.5.0-cp315-cp315-win_arm64.whl", hash = "sha256:1122f4f9e363da804ccba05a9f0f39baa3716eb82452c929258bc3f1420c899b", size = 103728, upload-time = "2026-09-27T01:41:19.729Z" }, - { url = "https://files.pythonhosted.org/packages/37/96/88f08f58759ee3739544cc51941853e946df1f400ba60c6efbeccfb589d7/wrapt-2.5.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e3c6fb1c1a516881353186bed9cfcb8899f968c03b3509720c79db0d967acf3b", size = 109275, upload-time = "2026-09-27T01:41:21.213Z" }, - { url = "https://files.pythonhosted.org/packages/29/cc/68846aa92814d0704d4b128a30d7707368be6951e8a8f42f1254ce4ab31c/wrapt-2.5.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:0a7a369e7fca9fc8c2c50df634382009b09af416853da3d4515e4bb048a5b9ee", size = 109610, upload-time = "2026-09-27T01:41:22.647Z" }, - { url = "https://files.pythonhosted.org/packages/fe/87/bbaa188dace348b6a403bbf3cc483f3f419ac97700274340e17f2dbc700e/wrapt-2.5.0-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:88bb24b9fdccb1d805258d5648533206eb58c58b6554985c47db53a89c11be85", size = 284602, upload-time = "2026-09-27T01:41:24.094Z" }, - { url = "https://files.pythonhosted.org/packages/be/2e/8a3309b0cbd3ab809ee6b76812c3be211f5a08732f321f131d26bb4f078a/wrapt-2.5.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e3d110d7f99644946f249927c346d9dba507a78815d1bcebdbf0d94c14c5649", size = 294893, upload-time = "2026-09-27T01:41:25.654Z" }, - { url = "https://files.pythonhosted.org/packages/15/b7/eda8bbdb6a3b7343d2c71e23fb0ebfc15c12fd470e3cce7ea42f7a57aaac/wrapt-2.5.0-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1ffb2823c95dbeb8a47fedfba9636b2afeb0a8ef94b66df97bd081bdfe5a263f", size = 264658, upload-time = "2026-09-27T01:41:27.375Z" }, - { url = "https://files.pythonhosted.org/packages/41/f0/589bad71ca3ce5444a626ee10d657fd6aa5080ada76b3bcd4550468aa16e/wrapt-2.5.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:557ebf4ce5568588368675014a2540405db687c2e4c7ad1eb83aa7e857be1864", size = 289974, upload-time = "2026-09-27T01:41:29.032Z" }, - { url = "https://files.pythonhosted.org/packages/dc/97/c48f3c820ae6687e87b537041cd49ad4caa41e05a8f0d8ec08e449ca303c/wrapt-2.5.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:05246a100da68259af521b88788f131ba005465f1c95d358cc3c03ec5e351b52", size = 261945, upload-time = "2026-09-27T01:41:30.941Z" }, - { url = "https://files.pythonhosted.org/packages/fd/ad/d96898f500cb1e4185474bac6cb14bb7ea670a32f37e8c354a0c647e3a92/wrapt-2.5.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:c932273bc43b068538f3874fa5e6c2a60f33fa0b11c1ebc7768652f6a0608943", size = 279744, upload-time = "2026-09-27T01:41:32.617Z" }, - { url = "https://files.pythonhosted.org/packages/5d/8b/7981d2ac838d0dc07e81145cb1c9911812e060b98e8c5a47c84fb92f8f81/wrapt-2.5.0-cp315-cp315t-win32.whl", hash = "sha256:2fd8a61c31220840c7f52621cf51c961af5058bd009a55bcdf4a6732bdb13b35", size = 103124, upload-time = "2026-09-27T01:41:34.117Z" }, - { url = "https://files.pythonhosted.org/packages/2a/1d/374cec175b6087e1067a780374d81d74ca966e1e5e39e666402eaee19a65/wrapt-2.5.0-cp315-cp315t-win_amd64.whl", hash = "sha256:1d4da5f0e9a719471502b0db80d5c97503aeca796b7aeb9ab8f47403b2be76e6", size = 109671, upload-time = "2026-09-27T01:41:35.553Z" }, - { url = "https://files.pythonhosted.org/packages/c7/93/fc9e477a1771bec52d7677eee5e8404afe662a47efe1859405a18fff206c/wrapt-2.5.0-cp315-cp315t-win_arm64.whl", hash = "sha256:78b7bdaa8b27b7f7607c66bdb6ab15c1dcbd9e9a1556a253a347dad511f615d1", size = 105969, upload-time = "2026-09-27T01:41:36.973Z" }, - { url = "https://files.pythonhosted.org/packages/87/7d/5ed859fad4b5eddd598a846150aaab2703730ed4886c5c5e03b0df0cfdd5/wrapt-2.5.0-py3-none-any.whl", hash = "sha256:107eea1a511e98a3a5033b0c2cb403fbb37f05dee6ac1fb85c0460d311ec278c", size = 81586, upload-time = "2026-09-27T01:41:55.479Z" }, -] diff --git a/docker-compose.neon.yml b/docker-compose.neon.yml new file mode 100644 index 0000000..db4e5b3 --- /dev/null +++ b/docker-compose.neon.yml @@ -0,0 +1,28 @@ +# Overlay that points the stack at Neon instead of the local `db` container. +# Used by `just --neon`; don't run it by hand unless you mean to: +# docker compose -f docker-compose.yml -f docker-compose.neon.yml up -d +# +# The URLs come from the root .env, written by `neon deploy` / `neon env pull` +# (Compose reads .env for ${...} interpolation). The app's config.py converts +# them for asyncpg. See docs/decisions/0027-neon-postgres.md. + +x-neon-env: &neon-env + # Pooled (-pooler) URL for app traffic. + DATABASE_URL: ${DATABASE_URL:?DATABASE_URL missing from .env; run `neon env pull`} + # Direct URL; Alembic must bypass Neon's transaction-mode pooler. + DATABASE_URL_UNPOOLED: ${DATABASE_URL_UNPOOLED:?DATABASE_URL_UNPOOLED missing from .env; run `neon env pull`} + +services: + # The local Postgres isn't needed; only start it if the `local` profile is on. + db: + profiles: ["local"] + + migrate: + environment: *neon-env + depends_on: !reset {} + + backend: + environment: *neon-env + depends_on: !override + migrate: + condition: service_completed_successfully diff --git a/docker-compose.yml b/docker-compose.yml index a36d196..825ea36 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,7 @@ # Single source of truth for the in-cluster database URL, shared by the # `migrate` and `backend` services so they can never drift apart. +# This file always targets the local `db` container, even though .env holds +# Neon URLs; `just --neon` layers docker-compose.neon.yml on top to switch. x-database-url: &database-url "postgresql+asyncpg://foresight:foresight@db:5432/foresight_db" services: @@ -51,6 +53,8 @@ services: - .env environment: DATABASE_URL: *database-url + # Blank out the Neon direct URL that env_file loads from .env. + DATABASE_URL_UNPOOLED: "" volumes: # Mount source code for hot reload. The `foresight` package is installed # editable in the image, so edits under ./backend/foresight are diff --git a/docs/backfill-coverage.md b/docs/backfill-coverage.md deleted file mode 100644 index 23b5573..0000000 --- a/docs/backfill-coverage.md +++ /dev/null @@ -1,60 +0,0 @@ -# Backfill coverage - -What the historical backfill (ticket 37) actually got, per source. Numbers are -from a local Docker Postgres run on 2026-10-12; re-run on Neon once it's set up. - -Hotel data isn't loaded yet, so these runs used explicit `--start`/`--end` -instead of the hotel window. Once a hotel's nights are in `daily_performance`, -pass `--hotel-id` and leave the dates out: every script defaults to that -hotel's first → last `stay_date`. - -## Summary - -| Source | Market | Requested | Earliest usable | Rows | Known gaps | -|---|---|---|---|---|---| -| Weather (Open-Meteo) | Dublin | 2020-01-01 → 2026-10-11 | 2020-01-01 | 2,476 | none | -| Events (PredictHQ) | Dublin, 10 km | 2026-01-01 → 2026-10-11 | 2026-07-01 | 2,666 | everything before 2026-07-01 | -| Flights (AeroDataBox) | DUB | not run | — | — | no API key yet | - -## Weather — Open-Meteo Historical API - -- Recorded actuals (`is_forecast = false`), one row per city and day. -- The archive goes back to 1940, so it covers any hotel window. -- 2,476 of 2,476 days saved; no missing days and no days without values. -- Re-running the full range kept the count at 2,476 (zero duplicates). -- Requests are one year at a time, retrying 429/5xx/network errors (2s, 4s, 8s). -- Free tier is for non-commercial use; a commercial deployment needs a paid plan. - -## Events — PredictHQ - -- 2,666 events: 2,488 active, 178 that PredictHQ marked deleted (cancelled). -- **Trial floor: 2026-07-01.** Requesting from 2026-01-01 returned nothing - before July, so the trial gives about 3½ months of history. Covering an - older hotel window needs a paid plan. -- Re-running September: `new=0 unchanged=987` (zero duplicates). -- Monthly chunks, retries on 429/5xx with `Retry-After`, stops cleanly and - prints where to resume if credits run out. - -## Flights — AeroDataBox (via RapidAPI) - -- **Not run: no `XRAPIDAPI_KEY` yet.** -- Each day costs about 4 API units; the free plan has 400 a month, so roughly - 100 days per month on the free tier. -- History floor not confirmed yet — check the RapidAPI plan and record it here. -- Retries 429/5xx (2s, 4s, 8s); a 429 after that is treated as the monthly - quota, and the run stops with `resume with --start `. - -## Re-running - -From `backend/`, with `DATABASE_URL` pointing at the target database: - - uv run python scripts/backfill/backfill_weather.py --hotel-id 1 - uv run python scripts/backfill/weather_coverage.py --city Dublin --hotel-id 1 - uv run python scripts/backfill/backfill_events.py --hotel-id 1 - uv run python scripts/backfill/backfill_flights.py --hotel-id 1 --airport DUB - -Leave out `--start`/`--end` to use the hotel's window. Without a hotel, give -`--city --lat --lon` (weather, events) and both dates. - -Events commit after each month and flights after each day, so a run stopped by -a quota keeps everything before it; re-run with the printed `--start`. diff --git a/docs/decisions/0001-ebm-pricing-model.md b/docs/decisions/0001-ebm-pricing-model.md new file mode 100644 index 0000000..9e06ef6 --- /dev/null +++ b/docs/decisions/0001-ebm-pricing-model.md @@ -0,0 +1,24 @@ +# 0001. Explainable Boosting Machine is the pricing model + +**Status:** Accepted · **Date:** 2026-10-13 + +## Context +We need a model that recommends room rates hotel managers can trust. It has to +explain each recommendation, and it has to let us tell training which past +nights represent good pricing. + +## Decision +Use the Explainable Boosting Machine (`ExplainableBoostingRegressor`) from +InterpretML, installed as `interpret-core` (the model without the dashboard +dependencies). It lives in `services/ml/ebm.py` as `EBMModel`, a subclass of +the shared `Model` base. + +## Consequences +- Every prediction splits exactly into per-feature contributions plus an + intercept, including pairwise interactions (e.g. `day_of_week & month`). + These become the recommendation's explanation. +- `fit` accepts `sample_weight`, which the weighting strategy depends on + (see [0003](0003-sample-weighting.md)). +- We don't benchmark against the earlier comparison models; the choice is made. +- Version is pinned by `uv.lock`; each stored model records the library + versions it was trained with (see [0008](0008-models-stored-in-postgres.md)). diff --git a/docs/decisions/0002-predict-rate-in-currency.md b/docs/decisions/0002-predict-rate-in-currency.md new file mode 100644 index 0000000..6cea0e7 --- /dev/null +++ b/docs/decisions/0002-predict-rate-in-currency.md @@ -0,0 +1,18 @@ +# 0002. Predict the nightly rate in currency + +**Status:** Accepted · **Date:** 2026-10-13 + +## Context +The model could predict the rate directly, demand (occupancy) with a search +over candidate rates, or the rate as a multiple of the room type's base rate. + +## Decision +Predict the nightly rate per room type, in currency (EUR). + +## Consequences +- Simple to train, serve and explain: contributions are in euros + ("Concert: +€12"). +- Not a multiple of base rate: hotels have no standard base rate to divide by. +- `room_type` is a model feature so the model learns price gaps between types. +- Learning from past prices risks copying past pricing mistakes; sample + weighting counters that (see [0003](0003-sample-weighting.md)). diff --git a/docs/decisions/0003-sample-weighting.md b/docs/decisions/0003-sample-weighting.md new file mode 100644 index 0000000..c6aab15 --- /dev/null +++ b/docs/decisions/0003-sample-weighting.md @@ -0,0 +1,40 @@ +# 0003. Weight training nights by occupancy + +**Status:** Accepted · **Date:** 2026-10-13 + +## Context +Past prices weren't all good prices. A night's price was close to right when +the hotel filled up, but only just in time. Two signs it was wrong: +- **Vacancies on the night** — guests wouldn't pay it (overpriced). +- **Booked too early / sold out early** — guests grabbed it because it was + cheap (underpriced). + +A sample weight sets how much each night's error counts in training. It does +not change the rate; when similar nights had different prices, the model +sides with the higher-weighted ones. Only the ratios between weights matter. + +## Decision +`services/ml/weighting.py`: +- `sample_weights = vacancy_factor` (occupancy, 0–1), floored at + `MIN_WEIGHT = 0.05`. EBM rejects zero weights, and unsold nights should still + count a little. +- Each signal is its own `*_factor()` function returning 0–1; factors are + multiplied together. New signals are added the same way. +- Used by training only. Serving never weights (it predicts future nights, + whose occupancy isn't known). + +Rejected: +- **`occupancy × rate / base_rate`** — rewards high prices whether or not they + sold, biasing predictions upward. +- **Time decay** (older nights count less) — not wanted for now. + +## Consequences +- **Booked too early** isn't implemented: `daily_performance` has no booking + dates. Needs booking-level data or booking-pace snapshots (the client + export's "cumulative columns" may be this) — a schema and parser change. +- **Competitor signals** (we sold out while they had rooms, and vice versa) + aren't implemented: there's no competitor data source yet. +- **Waitlist** and **group blocks** exist in the client export but aren't + mapped yet. Whether group blocks get a low weight or are excluded is open. +- On mock data, weighting barely moves predictions because mock prices are + formulaic; expect a larger effect on real data. diff --git a/docs/decisions/0004-exclude-free-nights.md b/docs/decisions/0004-exclude-free-nights.md new file mode 100644 index 0000000..d928afd --- /dev/null +++ b/docs/decisions/0004-exclude-free-nights.md @@ -0,0 +1,17 @@ +# 0004. Exclude free and staff nights from training + +**Status:** Accepted · **Date:** 2026-10-13 + +## Context +Nights sold at a rate of 0 (free or staff rooms) or with no listed rate aren't +sales; they say nothing about pricing. + +## Decision +Drop them from training with `training_mask(rate)` in +`services/ml/weighting.py`, rather than giving them a low weight. + +## Consequences +- Even a minimum weight would still pull predictions toward €0, so excluding + them is safer. +- Training must apply the mask **before** building features and weights, so + features, targets and weights line up row for row. diff --git a/docs/decisions/0005-conformal-confidence-interval.md b/docs/decisions/0005-conformal-confidence-interval.md new file mode 100644 index 0000000..1c9fcaa --- /dev/null +++ b/docs/decisions/0005-conformal-confidence-interval.md @@ -0,0 +1,23 @@ +# 0005. Conformal confidence interval, 80% by default + +**Status:** Accepted · **Date:** 2026-10-13 + +## Context +Each recommendation needs a range the rate is likely to fall in. EBM doesn't +produce prediction intervals itself. + +## Decision +Use split-conformal intervals in `EBMModel`: +- `fit` holds out the most recent 15% of rows (`calibration_frac=0.15`) — + rows must be sorted by time. +- Errors on those held-out rows set the interval width: for an 80% interval, + the margin is the error size 80% of misses stayed under. +- The level is chosen, not learned. Default 80% (`alpha=0.2`). + +## Consequences +- The interval has a real coverage guarantee: on mock data, 78.5% of actual + rates fell inside the 80% interval (48.6% at 50%, 90.9% at 90%, 96.6% at 95%). +- Higher confidence means a wider, less useful range; 80% balances the two. +- The width is the same for every night; it doesn't flag unusual nights (see + [0006](0006-uncertainty-alongside-interval.md)). +- The level is hard-coded today; planned to move to a `config.py` setting. diff --git a/docs/decisions/0006-uncertainty-alongside-interval.md b/docs/decisions/0006-uncertainty-alongside-interval.md new file mode 100644 index 0000000..eb1cf11 --- /dev/null +++ b/docs/decisions/0006-uncertainty-alongside-interval.md @@ -0,0 +1,26 @@ +# 0006. Report model uncertainty alongside the interval + +**Status:** Accepted · **Date:** 2026-10-13 + +## Context +EBM trains 14 sub-models (`outer_bags`) and averages them. How much they +disagree measures how familiar a night is to the model. This answers a +different question from the confidence interval: + +| | Uncertainty | Confidence interval | +|---|---|---| +| Question | How much should we trust the model on this night? | What range will the rate fall in? | +| Varies per night | Yes — grows on unusual nights | No — same width every night | +| Is a rate range | No | Yes, with a coverage guarantee | + +## Decision +Return both. `EBMModel.predict_uncertainty(x)` gives the sub-models' standard +deviation per row; the recommendation response will carry it next to the +interval as a "review this rate" signal. + +## Consequences +- The per-bag scores (`bagged_scores_`) must be kept, so a stored model is + about 17 MB instead of about 3 MB. Lowering `max_bins` can shrink it if + storage or load time becomes a problem. +- The signal is weak for values beyond the training range: EBM treats them as + the largest value it saw, so uncertainty rises less than the novelty does. diff --git a/docs/decisions/0007-shared-feature-builder.md b/docs/decisions/0007-shared-feature-builder.md new file mode 100644 index 0000000..c3751c5 --- /dev/null +++ b/docs/decisions/0007-shared-feature-builder.md @@ -0,0 +1,38 @@ +# 0007. One feature builder for training and serving + +**Status:** Accepted · **Date:** 2026-10-13 + +## Context +If training and serving compute features differently, the model sees inputs at +serving time that don't match what it learned (train/serve skew). + +## Decision +`build_features(nights, events, weather, flights)` in +`services/ml/features.py` is the only place features are computed. It's pure: +DataFrames in, DataFrame out; no database access. `FEATURES` fixes the column +order. + +Features: `room_type`, `day_of_week`, `month`, `is_public_holiday`, +`is_school_holiday`, `event_count`, `event_attendance`, `event_rank_max`, +`temp_max_c`, `precip_mm`, `wind_kph`, `flight_count`, `avg_fare_eur`. + +Rules: +- A multi-day event counts on every night it runs. +- Only categories that bring visitors (`ATTENDED_CATEGORIES`) count toward the + event features; holidays are separate flags; disasters, delays and the like + aren't demand. +- Missing weather or flight data stays empty; EBM learns a separate effect for + unknown values. +- Weather and flights must have at most one row per day, otherwise it raises. + +Excluded: `rate` (the target), anything known only after the night (rooms +sold, occupancy, revenue), competitor rates (no data), lead time (no booking +dates), `temp_min_c` and `is_weekend` (redundant). + +## Consequences +- Works the same on mock or real data. +- Weather forecasts reach about 14 days ahead and future flight data may not + exist; those features stay empty for far-out dates until data is backfilled. +- On mock data, MAE rose from €5.59 to €7.53 versus the mock CSV columns, + because those included competitor and base rates the mock pricing formula + uses directly. diff --git a/docs/decisions/0008-models-stored-in-postgres.md b/docs/decisions/0008-models-stored-in-postgres.md new file mode 100644 index 0000000..f848b5f --- /dev/null +++ b/docs/decisions/0008-models-stored-in-postgres.md @@ -0,0 +1,23 @@ +# 0008. Store trained models in Postgres + +**Status:** Accepted · **Date:** 2026-10-13 + +## Context +Trained models must be versioned, retrievable by the API, and traceable to +the data and code that produced them. + +## Decision +Store each trained model as a row in `pricing_models` +(`database/models/pricing_model.py`): the model serialized with joblib in a +`bytea` column (`artifact`), plus `features`, `metrics`, `lib_versions`, +training window (`data_start`, `data_end`), `version` and `trained_at`. +`EBMModel.to_bytes()` / `from_bytes()` handle serialization; the repository +treats the artifact as opaque bytes. + +## Consequences +- No separate file store or model registry to run. +- Pickles can execute code when loaded: only load artifacts our own training + wrote; never accept a model from outside. +- Pickles can break across library upgrades, hence `lib_versions`. +- Each row is about 17 MB (see [0006](0006-uncertainty-alongside-interval.md)); + every save and load moves that much. diff --git a/docs/decisions/0009-one-model-per-hotel.md b/docs/decisions/0009-one-model-per-hotel.md new file mode 100644 index 0000000..e9816a8 --- /dev/null +++ b/docs/decisions/0009-one-model-per-hotel.md @@ -0,0 +1,21 @@ +# 0009. One model per hotel, one active at a time + +**Status:** Accepted · **Date:** 2026-10-13 + +## Context +Hotels differ in market, room mix and pricing history. Retraining produces new +versions, and serving must know exactly which one to use. + +## Decision +- Train one model per hotel. For now we build and test against a single hotel. +- Versions increment per hotel. A new model is saved inactive and becomes + active only if training succeeds (`repository/pricing_model.activate`). +- At most one active model per hotel, enforced by a partial unique index + (`uq_pricing_models_active_per_hotel ... WHERE is_active`). `activate` + deactivates the old model and activates the new one in one transaction. + +## Consequences +- A bug can't leave a hotel with two active models; the database rejects it. +- A failed training run leaves the previous model serving. +- Versions are computed as max + 1, so two simultaneous training runs for the + same hotel would conflict on `(hotel_id, version)`. Run one at a time. diff --git a/docs/decisions/0010-hotel-airport-codes.md b/docs/decisions/0010-hotel-airport-codes.md new file mode 100644 index 0000000..8f66dc2 --- /dev/null +++ b/docs/decisions/0010-hotel-airport-codes.md @@ -0,0 +1,17 @@ +# 0010. Hotels store both IATA and ICAO airport codes + +**Status:** Superseded by [0017](0017-cities-are-the-market.md) · **Date:** 2026-10-13 + +## Context +Flight arrivals are stored per airport, but nothing linked a hotel to its +airport. The flight sources disagree on codes: AeroDataBox uses IATA (`"DUB"`), +OpenSky uses ICAO (`"EIDW"`). + +## Decision +Add `airport_iata` (`String(3)`) and `airport_icao` (`String(4)`) to `hotels`, +both nullable. One airport per hotel. + +## Consequences +- Flight lookups match `flight_arrivals.airport` against either code. +- A hotel served by several airports would need a separate `hotel_airports` + table; not needed for boutique hotels today. diff --git a/docs/decisions/0011-pydantic-at-boundaries.md b/docs/decisions/0011-pydantic-at-boundaries.md new file mode 100644 index 0000000..9487a53 --- /dev/null +++ b/docs/decisions/0011-pydantic-at-boundaries.md @@ -0,0 +1,29 @@ +# 0011. Pydantic only at boundaries, named Request/Response + +**Status:** Superseded by [0015](0015-schemas-in-route-modules.md) · **Date:** 2026-10-13 + +## Context +Schemas followed a `*Create` / `*Read` pattern, one pair per table, with `Read` +inheriting from `Create`. That creates boilerplate for tables never sent over +HTTP, and inheritance silently copies input fields into output. + +## Decision +- Use Pydantic only where data enters or leaves the app: HTTP requests and + responses, external API payloads, config. Internal data uses ORM objects or + dataclasses. +- One schema per distinct shape that crosses a boundary, not one per table. +- Name by direction: `Request`, `Response`, + `Payload`. No inheritance between Request and Response. +- Shared bases in `schemas/base.py`: `RequestSchema` (`extra="forbid"`), + `ResponseSchema` (`from_attributes=True`), `ExternalPayload` + (`extra="ignore"`). +- Services may take `Request` schemas but never return `Response` schemas; the + repository never imports schemas. + + +## Consequences +- Widget and hotel schemas were converted. +- Branch 38 (API ticket) still uses `*Read`/`*Create`; its schemas and imports + need converting when it merges. +- `ingest/` still parses PredictHQ and AskNews into dataclasses; converting + them to `ExternalPayload` is a separate refactor. diff --git a/docs/decisions/0012-layered-architecture.md b/docs/decisions/0012-layered-architecture.md new file mode 100644 index 0000000..249ece6 --- /dev/null +++ b/docs/decisions/0012-layered-architecture.md @@ -0,0 +1,28 @@ +# 0012. Four layers, each calling only the one below + +**Status:** Accepted · **Date:** 2026-10-15 + +## Context +Without a fixed structure, HTTP handling, business rules and queries end up +mixed together, and every change touches everything. + +## Decision +The backend is four layers, in this order: + +``` +routes → services → repository → database +``` + +- **routes**: HTTP only. Parse the request, call one service, map the result. +- **services**: business rules, orchestration, outside APIs, the model. +- **repository**: the only code that queries or writes the database. +- **database**: tables, enums, constraints, migrations, the session. + +A layer calls only the layer directly below it. Enums live in the database +layer, because columns use them, and every layer may import them. + +## Consequences +- Routes never touch the ORM. Services never build HTTP responses or know + about status codes. The repository never holds business rules. +- Each layer can be tested on its own. Adapters to outside APIs are tested + without a database (see [0016](0016-services-split-by-role.md)). diff --git a/docs/decisions/0013-one-transaction-per-unit-of-work.md b/docs/decisions/0013-one-transaction-per-unit-of-work.md new file mode 100644 index 0000000..f76bb25 --- /dev/null +++ b/docs/decisions/0013-one-transaction-per-unit-of-work.md @@ -0,0 +1,22 @@ +# 0013. Only the request and the job runner commit + +**Status:** Accepted · **Date:** 2026-10-15 + +## Context +When repositories or services commit on their own, a multi-step operation can +be left half-done, and tests can't roll their changes back. + +## Decision +Exactly two places commit: +- **An HTTP request**: `database/session.get_session` commits once after the + handler returns and rolls back on any error. +- **A background job**: `worker.run_job` commits after every chunk of work. + +Repositories and services never commit. They flush when they need a generated +id, and use a savepoint when one statement may fail on its own (a rename that +hits a unique constraint). + +## Consequences +- Every request is all-or-nothing. +- The session dependency runs with `scope="function"`, so the commit happens + before the response is sent. A failed commit is a 500, never a false 200. diff --git a/docs/decisions/0014-services-raise-domain-errors.md b/docs/decisions/0014-services-raise-domain-errors.md new file mode 100644 index 0000000..d933f3b --- /dev/null +++ b/docs/decisions/0014-services-raise-domain-errors.md @@ -0,0 +1,23 @@ +# 0014. Services raise domain errors; one place maps them to HTTP + +**Status:** Accepted · **Date:** 2026-10-15 + +## Context +"Not found" was handled two ways: some lookups returned None and the route +raised a 404, others raised inside the service. Status codes were scattered. + +## Decision +- Services raise errors from `services/errors.py`: + - `NotFoundError` → 404 + - `ConflictError` → 409 + - `InvalidRequestError` → 422 + - `NotConfiguredError` (an API key isn't set) → 503 +- Every `get_x(id)` raises `NotFoundError` rather than returning None. +- `routes/errors.py` is the only place that turns an error into a status code. + Routes never check for None and never catch domain errors. +- Every error body is `{"detail": "..."}`, the same shape as FastAPI's own. + +## Consequences +- A route handler is three lines: parse, call, map. +- The repository still returns None for a missing row or a duplicate insert. + Deciding that this is an error is the service's job. diff --git a/docs/decisions/0015-schemas-in-route-modules.md b/docs/decisions/0015-schemas-in-route-modules.md new file mode 100644 index 0000000..de037c8 --- /dev/null +++ b/docs/decisions/0015-schemas-in-route-modules.md @@ -0,0 +1,27 @@ +# 0015. HTTP schemas live in the route that uses them + +**Status:** Accepted · **Date:** 2026-10-15 + +Supersedes [0011](0011-pydantic-at-boundaries.md). + +## Context +A separate `schemas/` layer let services accept `*Request` objects, which tied +business logic to the HTTP shape. The `ExternalPayload` base for outside APIs +was never used. + +## Decision +- Pydantic is used only for HTTP bodies and for config. +- Each route module defines the `*Request`/`*Response` schemas it uses, on two + bases in `routes/helpers.py`: + - `RequestSchema` rejects unknown fields. + - `ResponseSchema` builds from an ORM object or a dataclass. +- Request and Response never inherit from each other. +- Services take plain arguments and return ORM objects or dataclasses. They + never see a schema. +- Data from outside APIs is parsed into dataclasses (`NormalizedEvent`) or + plain row dicts. + +## Consequences +- The HTTP shape can change without touching a service. +- A response that is reused across routes is imported from the route that + owns it (`HotelResponse` nests `CityResponse` from `routes/city.py`). diff --git a/docs/decisions/0016-services-split-by-role.md b/docs/decisions/0016-services-split-by-role.md new file mode 100644 index 0000000..9efc824 --- /dev/null +++ b/docs/decisions/0016-services-split-by-role.md @@ -0,0 +1,26 @@ +# 0016. Services are split into domain, adapters, ingest and ml + +**Status:** Accepted · **Date:** 2026-10-15 + +## Context +The services layer mixed four kinds of code: domain rules, outside API +clients, fetch-and-save runs, and the model. The folders didn't show which +could call which. + +## Decision +``` +services/ + domain/ rules and reads for one domain (hotel, city, event, ...) + adapters/ one outside source each: call it, return plain data. No database. + ingest/ fetch through an adapter and save through the repository, in chunks + ml/ the pricing model; pure functions and classes + job.py job kinds and lifecycle + errors.py domain errors +``` + +- Adapters never touch the database. +- Ingest never commits; it yields after each chunk so the job runner can. + +## Consequences +- An adapter can be tested with a mock HTTP transport and no database. +- Adding a data source means adding one adapter and one ingest module. diff --git a/docs/decisions/0017-cities-are-the-market.md b/docs/decisions/0017-cities-are-the-market.md new file mode 100644 index 0000000..1287a67 --- /dev/null +++ b/docs/decisions/0017-cities-are-the-market.md @@ -0,0 +1,25 @@ +# 0017. Cities are the market every signal belongs to + +**Status:** Accepted · **Date:** 2026-10-15 + +Supersedes [0010](0010-hotel-airport-codes.md). + +## Context +Signals were matched to hotels by free-text city names, and different reads +compared them differently (one lower-cased, another compared exactly). Facts +about a place (timezone, airport, where to search) were copied onto each +hotel. + +## Decision +- A `cities` table holds each market once: name, unique normalized slug, + country, timezone, centre coordinates, and the IATA and ICAO airport codes. +- Hotels belong to a city. Weather, events, flight arrivals and competitor + rates are stored against the city and shared by every hotel in it. +- Weather is read and events are searched at the city's centre. A hotel keeps + its own coordinates for measuring distance to venues. + +## Consequences +- Reads match on `city_id`. Spelling and case can no longer split a market. +- Two hotels in one city share one copy of each signal, so it is fetched once. +- A city with more than one airport, or a hotel outside its city's radius, + would need this revisited. diff --git a/docs/decisions/0018-idempotent-upserts.md b/docs/decisions/0018-idempotent-upserts.md new file mode 100644 index 0000000..b12f1a1 --- /dev/null +++ b/docs/decisions/0018-idempotent-upserts.md @@ -0,0 +1,21 @@ +# 0018. Every outside write is an upsert on a natural key + +**Status:** Accepted · **Date:** 2026-10-15 + +## Context +Jobs stop part-way and get re-run, and windows overlap. Re-running must +never duplicate data. + +## Decision +- Every write from an outside source or file is an upsert keyed on what the + row describes. Examples: city + day + source for weather, room type + night + for performance. +- `repository/upsert.py` is the shared bulk upsert. It rewrites a row only + when a value changed, and reports new, updated and unchanged counts. +- Each source keeps its own row. Deciding which source wins is done when the + data is read for the model, not when it is written. + +## Consequences +- Any job or import can be run again safely. Overlap costs API calls, not + correctness. +- Reads for the model must pick one value per night from several sources. diff --git a/docs/decisions/0019-events-merged-across-sources.md b/docs/decisions/0019-events-merged-across-sources.md new file mode 100644 index 0000000..77a56d0 --- /dev/null +++ b/docs/decisions/0019-events-merged-across-sources.md @@ -0,0 +1,24 @@ +# 0019. Events are merged across sources; raw sightings are kept + +**Status:** Accepted · **Date:** 2026-10-15 + +## Context +The same real-world event can be listed by PredictHQ and reported in the +news. Pricing needs it counted once, and sources revise or withdraw events +over time. + +## Decision +- `events` holds one canonical row per real-world event, identified by a + dedupe key: normalized title + city + local start date. +- `event_observations` keeps every source's raw record, verbatim and keyed on + the source's own id. +- When sightings disagree, the row carries the values of the highest-ranked + sighting. Ranking order: + 1. structured listings over text extraction + 2. live over withdrawn + 3. newest revision +- The rule lives in `services/domain/event.py`. + +## Consequences +- An event can be re-derived from its observations if the merge rule changes. +- A revision that moves an event onto another event's key merges the two rows. diff --git a/docs/decisions/0020-jobs-in-the-api-process.md b/docs/decisions/0020-jobs-in-the-api-process.md new file mode 100644 index 0000000..c582b6f --- /dev/null +++ b/docs/decisions/0020-jobs-in-the-api-process.md @@ -0,0 +1,25 @@ +# 0020. Long work runs as jobs inside the API, through the API only + +**Status:** Accepted · **Date:** 2026-10-15 + +## Context +Ingests ran from scripts, outside the API, with no record of what ran or how +far it got. A full backfill can take hours. + +## Decision +- There are no scripts. Everything is started through the API. +- Long work is a job: + - The request validates its input and stores a `jobs` row. + - It returns 202, and the job runs as a background task in the same + process. + - `GET /jobs/{id}` reports its status, progress and resume point. +- Production runs a single uvicorn worker, because jobs live in that process. +- On startup, jobs left queued or running are marked failed. +- One unfinished job per kind per hotel. A second request gets a 409. + +## Consequences +- No queue or worker service to deploy. +- A restart kills running jobs. Their saved chunks stay, and they are started + again by hand. +- Jobs start immediately and run side by side. Running them in order would + need a worker loop and stored uploads. diff --git a/docs/decisions/0021-one-ingest-job-per-signal.md b/docs/decisions/0021-one-ingest-job-per-signal.md new file mode 100644 index 0000000..84e14d8 --- /dev/null +++ b/docs/decisions/0021-one-ingest-job-per-signal.md @@ -0,0 +1,26 @@ +# 0021. One ingest job per signal; the dates decide refresh or backfill + +**Status:** Accepted · **Date:** 2026-10-15 + +## Context +There were separate "ingest" and "backfill" endpoints and job kinds per +source, each with its own date rules. + +## Decision +Each signal has one endpoint and one job kind, +`POST /hotels/{id}/ingest/{weather,events,performance}`. + +- **No dates**: the signal's routine refresh. + - Weather: the 16-day forecast. + - Events: what PredictHQ revised since the last refresh. +- **Any date**: a backfill over a window. + - start and end: that window. + - start only: from start to today. + - end only: back from end to the hotel's first loaded night, newest first. +- Performance takes one or more client CSV files and parses them all before + the job is queued. + +## Consequences +- The word "backfill" describes a mode, not an endpoint. +- Walking backwards from `end` saves the most recent data first if a quota + runs out. diff --git a/docs/decisions/0022-no-retries-resume-instead.md b/docs/decisions/0022-no-retries-resume-instead.md new file mode 100644 index 0000000..0b32bde --- /dev/null +++ b/docs/decisions/0022-no-retries-resume-instead.md @@ -0,0 +1,20 @@ +# 0022. No automatic retries; jobs commit per chunk and record where to resume + +**Status:** Accepted · **Date:** 2026-10-15 + +## Context +A shared retry helper hid failures, slowed runs down, and spent quota on +sources whose 429 means "come back tomorrow". + +## Decision +- Adapters make each request once and raise on failure. +- Jobs commit after every chunk (a month of events, a year of weather, one + file). Each chunk records `resume_from`: the next `start` to send, or the + next `end` when walking backwards. +- A quota or rate limit ends the job as `stopped`. Anything else ends it as + `failed`. Both keep their saved chunks and their `resume_from`. + +## Consequences +- Resuming means starting the same job again from `resume_from`. Because + writes are upserts, overlapping is safe. +- A brief network error fails a job that a retry would have saved. diff --git a/docs/decisions/0023-stay-nights-are-local-dates.md b/docs/decisions/0023-stay-nights-are-local-dates.md new file mode 100644 index 0000000..33a0cf6 --- /dev/null +++ b/docs/decisions/0023-stay-nights-are-local-dates.md @@ -0,0 +1,18 @@ +# 0023. A stay night is a local date in the city's timezone + +**Status:** Accepted · **Date:** 2026-10-15 + +## Context +Sources report times in UTC, in the venue's zone, or as plain dates. A +21:00 concert must count toward that night, not the next UTC day. + +## Decision +- Every per-night value is stored as a `Date` local to the city: stay + nights, weather days, event start and end dates, flight arrival days. +- "Today" is today in the city's timezone. +- Instants (when something was fetched or happened) are stored as + timezone-aware timestamps. + +## Consequences +- Signals join to stay nights by date, with no conversion when read. +- Every city must have a valid IANA timezone. diff --git a/docs/decisions/0024-weather-for-future-nights.md b/docs/decisions/0024-weather-for-future-nights.md new file mode 100644 index 0000000..4cfa804 --- /dev/null +++ b/docs/decisions/0024-weather-for-future-nights.md @@ -0,0 +1,19 @@ +# 0024. Weather for future nights: forecast, then typical weather + +**Status:** Accepted · **Date:** 2026-10-15 + +## Context +The model needs weather for every night it prices, but forecasts only reach +16 days ahead. + +## Decision +- Nights up to 16 days ahead use the forecast. +- Nights further out use the typical weather for that calendar week, taken + from recorded history. +- Training uses recorded weather. When a night has both a recorded and a + forecast value, the recorded one wins. + +## Consequences +- Recorded weather must be ingested for past years, not just for the nights + being trained on. +- Weather has little effect on far-out nights by design. diff --git a/docs/decisions/0025-migrations.md b/docs/decisions/0025-migrations.md new file mode 100644 index 0000000..6427f62 --- /dev/null +++ b/docs/decisions/0025-migrations.md @@ -0,0 +1,20 @@ +# 0025. Linear migrations that always roll back + +**Status:** Accepted · **Date:** 2026-10-15 + +## Context +The migration history had branched, and constraint names had been generated +by Postgres, so migrations couldn't refer to them reliably. + +## Decision +- Alembic history is linear: one head. +- Every model change has a migration that applies and rolls back. Its + docstring says why the change exists and what happens to existing data. +- Constraints and indexes are named by a naming convention on + `Base.metadata`, and upserts target them by name. +- Every foreign key states its `ON DELETE` rule. +- CI checks for one head, that the models match the migrations, and a full + downgrade and upgrade. + +## Consequences +- Any migration can be undone in development and production alike. diff --git a/docs/decisions/0026-no-authentication.md b/docs/decisions/0026-no-authentication.md new file mode 100644 index 0000000..d7cf4dd --- /dev/null +++ b/docs/decisions/0026-no-authentication.md @@ -0,0 +1,13 @@ +# 0026. The API has no authentication + +**Status:** Accepted · **Date:** 2026-10-15 + +## Context +Foresight is in early development with one client and no frontend. + +## Decision +No endpoint requires authentication. + +## Consequences +- The API must not be exposed publicly as it stands. +- Adding authentication later is a routes-layer change (a FastAPI dependency). diff --git a/docs/decisions/0027-neon-postgres.md b/docs/decisions/0027-neon-postgres.md new file mode 100644 index 0000000..ddcd3e4 --- /dev/null +++ b/docs/decisions/0027-neon-postgres.md @@ -0,0 +1,31 @@ +# 0027. Postgres is hosted on Neon + +**Status:** Accepted · **Date:** 2026-10-15 + +## Context +Foresight needs a managed Postgres outside the local Docker setup. Neon +(project `aged-truth-96098127`, branch `production`) provides it, configured +by `neon.ts` and linked by `.neon`. + +## Decision +- The app keeps its driver: async SQLAlchemy over asyncpg, in a long-running + FastAPI process. +- App traffic uses the pooled (`-pooler`) URL in `DATABASE_URL`, with + `pool_pre_ping` so connections dropped by scale-to-zero are replaced. +- Alembic uses the direct URL in `DATABASE_URL_UNPOOLED`, because Neon's + PgBouncer runs in transaction mode and migrations need a session. +- `neon env pull` writes libpq-style URLs; `config.py` converts them for + asyncpg (`sslmode` → `ssl`, `channel_binding` dropped) so the env vars + stay exactly as Neon writes them. + +- One flag picks the database: `just --neon` uses Neon, and no + flag uses the local Docker Postgres. Docker uses `docker-compose.neon.yml` + as an overlay for `--neon`, because the base compose file pins the local + URL over `.env`. Host-side recipes override `.env`'s Neon URLs with the + local one when `--neon` is absent. + +## Consequences +- `neon env pull` refreshes credentials with no hand edits. +- Local Docker still works: with `DATABASE_URL_UNPOOLED` unset, migrations + use `DATABASE_URL`. +- The first query after the compute idles pays a cold-start delay. diff --git a/docs/decisions/0028-train-and-serve-on-ingested-data.md b/docs/decisions/0028-train-and-serve-on-ingested-data.md new file mode 100644 index 0000000..86823eb --- /dev/null +++ b/docs/decisions/0028-train-and-serve-on-ingested-data.md @@ -0,0 +1,29 @@ +# 0028. Training and recommendations use only ingested data + +**Status:** Accepted · **Date:** 2026-10-15 + +## Context +A recommendation needs weather (and later events and flights) for the night +being priced. The endpoint could fetch what's missing, or rely on ingest +having run. + +## Decision +- Training (`POST /hotels/{id}/train`) and recommendations + (`GET /hotels/{id}/recommendations`) read only what's already saved. Neither + calls an adapter. +- Missing data isn't fetched: the feature stays empty, which the model learned + an effect for. The response doesn't yet say which data was used for the + night; that waits until it can be reported from stored data rather than + fixed labels. +- Weather follows [0024](0024-weather-for-future-nights.md): training uses + recorded weather only; serving uses recorded, then forecast, then the + typical weather for that ISO week. Week 53 borrows week 52 when no recorded + year has one. +- Events and flights are passed empty for now. Their columns stay in + `FEATURES` so adding them later doesn't change the model's inputs. + +## Consequences +- A recommendation is fast and predictable: no outside calls, no quotas. +- Keeping recommendations fresh means running the weather forecast refresh + (`POST /hotels/{id}/ingest/weather` with no dates) regularly. +- Until events have history, the model can't learn their effect. diff --git a/docs/decisions/0029-every-training-run-is-a-new-version.md b/docs/decisions/0029-every-training-run-is-a-new-version.md new file mode 100644 index 0000000..32cecc6 --- /dev/null +++ b/docs/decisions/0029-every-training-run-is-a-new-version.md @@ -0,0 +1,32 @@ +# 0029. Every training run is a new, described version + +**Status:** Accepted · **Date:** 2026-10-15 + +## Context +We want to improve the model and try new approaches, which means comparing +versions and remembering what each one tried. + +## Decision +- Training is a job (`JobKind.TRAIN`). Every run saves a new version + (decision [0009](0009-one-model-per-hotel.md)); none is overwritten. +- Each version stores a free-text `description` (what it tried) next to its + `metrics`. What it was trained on (window, settings, room types) isn't + stored yet; when it is, it goes in its own columns, measured in the run, + never as fixed text describing the code. +- `metrics` are scored on the newest 15% of nights, held out from fitting; + they also calibrate the interval + ([0005](0005-conformal-confidence-interval.md)). +- `activate: false` trains without serving, to compare first. + `POST /models/{id}/activate` chooses which version serves. +- There is no "retrain this version" option yet: it needs the settings and + window stored per version first. Until then, send them again. +- The EBM settings accepted are a fixed list (`interactions`, + `learning_rate`, `max_bins`, `outer_bags`, `min_samples_leaf`, + `smoothing_rounds`), validated in the request. + +## Consequences +- Old versions keep their artifact, about 3 MB each on this hotel; prune + them if storage matters. +- The fit runs in a thread, so the API keeps answering during training. +- One training job per hotel at a time (409 otherwise), which also avoids the + version race noted in 0009. diff --git a/docs/decisions/0030-schema-descriptions-and-examples.md b/docs/decisions/0030-schema-descriptions-and-examples.md new file mode 100644 index 0000000..0a498d3 --- /dev/null +++ b/docs/decisions/0030-schema-descriptions-and-examples.md @@ -0,0 +1,35 @@ +# 0030. Every field is described; examples are built from the fields + +**Status:** Accepted · **Date:** 2026-10-15 + +## Context +`/docs` was inconsistent. Most response fields and every path id had no +description, units were missing (°C, mm, km/h, EUR), and examples were written +three different ways. Only two request bodies showed a filled-in example, and +those were written by hand, so they could disagree with the fields' defaults. + +## Decision +- Every request field, response field, path id and query parameter has a + `description`. Units, inclusive dates, currency and what `null` means go + there; example values never do. +- Example values go in `examples=` and are neutral placeholders ("Example + City", "XX", 12.34, 2025-01-01), not real hotel data. +- A field with a default has no separate example: the default is what + `/docs` shows. `limit`/`offset` show 50 and 0, `activate` shows true. +- `RequestSchema` builds each body's example from its fields + (`example_body` in `routes/helpers.py`): the default where there is one, + else the example. Nobody writes a body example by hand, so it can't drift. +- Fields whose value changes what a request does (the EBM `settings`) carry + no example, so the default body never sets them. + They're shown in the endpoint's picker instead. +- Endpoints with several modes (ingest, train) keep a picker. Its first entry + is the generated body; the others are derived from it. +- A PATCH body reuses its create body's field settings (module-level dicts of + `Field` kwargs). The "cannot be null" check and the `start`/`end` query + parameters stay in each module rather than in shared helpers. +- Route modules use boxed `Schemas` and `Endpoints` section headers. + +## Consequences +- Changing a field's default or example changes the body `/docs` shows, with + nothing else to update. +- Real values for local testing come from the API, not from `/docs`. diff --git a/docs/decisions/README.md b/docs/decisions/README.md new file mode 100644 index 0000000..ecfec06 --- /dev/null +++ b/docs/decisions/README.md @@ -0,0 +1,53 @@ +# Decision records + +One decision per file: what we chose, why, and what it rules out. Read the +relevant record before changing the code it describes. To change a decision, +add a new record that supersedes the old one and update the old record's +status; don't rewrite history. + +## Format + +``` +# NNNN. Title +**Status:** Accepted | Superseded by NNNN · **Date:** YYYY-MM-DD +## Context — the problem and constraints +## Decision — what we chose +## Consequences — what follows, including trade-offs +``` + +Number files sequentially (`0027-short-title.md`) and add them to the index. + +## Index + +| # | Decision | Area | +|---|---|---| +| [0001](0001-ebm-pricing-model.md) | Explainable Boosting Machine is the pricing model | ML | +| [0002](0002-predict-rate-in-currency.md) | Predict the nightly rate in currency | ML | +| [0003](0003-sample-weighting.md) | Weight training nights by occupancy | ML | +| [0004](0004-exclude-free-nights.md) | Exclude free and staff nights from training | ML | +| [0005](0005-conformal-confidence-interval.md) | Conformal confidence interval, 80% by default | ML | +| [0006](0006-uncertainty-alongside-interval.md) | Report model uncertainty alongside the interval | ML | +| [0007](0007-shared-feature-builder.md) | One feature builder for training and serving | ML | +| [0008](0008-models-stored-in-postgres.md) | Store trained models in Postgres | ML / database | +| [0009](0009-one-model-per-hotel.md) | One model per hotel, one active at a time | ML / database | +| [0010](0010-hotel-airport-codes.md) | ~~Hotels store both IATA and ICAO airport codes~~ (superseded by 0017) | database | +| [0011](0011-pydantic-at-boundaries.md) | ~~Pydantic only at boundaries, named Request/Response~~ (superseded by 0015) | schemas | +| [0012](0012-layered-architecture.md) | Four layers, each calling only the one below | architecture | +| [0013](0013-one-transaction-per-unit-of-work.md) | Only the request and the job runner commit | architecture | +| [0014](0014-services-raise-domain-errors.md) | Services raise domain errors; one place maps them to HTTP | architecture | +| [0015](0015-schemas-in-route-modules.md) | HTTP schemas live in the route that uses them | routes | +| [0016](0016-services-split-by-role.md) | Services are split into domain, adapters, ingest and ml | services | +| [0017](0017-cities-are-the-market.md) | Cities are the market every signal belongs to | data model | +| [0018](0018-idempotent-upserts.md) | Every outside write is an upsert on a natural key | data model | +| [0019](0019-events-merged-across-sources.md) | Events are merged across sources; raw sightings are kept | data model | +| [0020](0020-jobs-in-the-api-process.md) | Long work runs as jobs inside the API, through the API only | jobs | +| [0021](0021-one-ingest-job-per-signal.md) | One ingest job per signal; the dates decide refresh or backfill | jobs | +| [0022](0022-no-retries-resume-instead.md) | No automatic retries; jobs commit per chunk and record where to resume | jobs | +| [0023](0023-stay-nights-are-local-dates.md) | A stay night is a local date in the city's timezone | data model | +| [0024](0024-weather-for-future-nights.md) | Weather for future nights: forecast, then typical weather | ML | +| [0025](0025-migrations.md) | Linear migrations that always roll back | database | +| [0026](0026-no-authentication.md) | The API has no authentication | API | +| [0027](0027-neon-postgres.md) | Postgres is hosted on Neon; pooled for the app, direct for migrations | database | +| [0028](0028-train-and-serve-on-ingested-data.md) | Training and recommendations use only ingested data | ML | +| [0029](0029-every-training-run-is-a-new-version.md) | Every training run is a new, described version | ML | +| [0030](0030-schema-descriptions-and-examples.md) | Every field is described; examples are built from the fields | API | diff --git a/docs/event-extraction.md b/docs/event-extraction.md deleted file mode 100644 index 8f62254..0000000 --- a/docs/event-extraction.md +++ /dev/null @@ -1,257 +0,0 @@ -# Event extraction - -Two sources feed the `events` table. They disagree about almost everything — -what a record is, how trustworthy it is, how often it changes — so they meet at -a single contract rather than talking to the database in their own shapes. - -```mermaid -flowchart LR - PHQ[PredictHQ API] --> PA[ingest/predicthq.py] - AN[AskNews API] --> AA[ingest/asknews.py] - PA --> NE[NormalizedEvent] - AA --> TD[ingest/textdates.py] --> NE - NE --> R[ingest/repository.py
upsert_event] - R --> EV[(events)] - R --> OB[(event_observations)] -``` - -## The contract - -`ingest/normalize.py` — every adapter returns `NormalizedEvent`, which -validates on construction (empty title, end before start, a latitude that is -really a longitude). An adapter cannot emit a malformed event, only fail to -build one. - -Identity is `dedupe_key` = normalized title + city slug + local start date. -Category is excluded on purpose: the sources routinely disagree on it, and that -must not split one real event into two rows. - -Two fields carry trust. `status` is `ACTIVE` for a confirmed listing, -`PROVISIONAL` for a PredictHQ *predicted* event or anything from prose, and -`DELETED`/`CANCELLED` once withdrawn. `date_precision` is `DAY`, `MONTH` or -`QUARTER` — coarse dates are kept as early signal rather than rounded into a -stay-night they cannot support. - -## The two adapters - -**PredictHQ** (`ingest/predicthq.py`) pages structured listings by -`updated.gte`. Records arrive dated and geocoded, so the adapter is mostly -field mapping: GeoJSON `[lon, lat]` unpacked the right way round, UTC converted -to the venue's local night. It requests `deleted` state explicitly — without -that, an event cancelled upstream would inflate demand forever. Everything it -emits is `confidence = 1.0`, `DatePrecision.DAY`. - -**AskNews** (`ingest/asknews.py` + `textdates.py`) indexes *articles, not -events*, so every record is an inference and most articles yield nothing. -Dropping them is the bulk of the work. Three stages: - -1. **Search** — one query per city, filtered to that city's country. -2. **Date resolution** — "August 28-30, 2026", "opens December 1 and runs - through January 4", "Q3 2027" become a span plus the precision achieved. - Relative forms ("this weekend") are refused outright: a wrong date is worse - than a missing one. -3. **Extraction** — city match, category, confidence, and a named - `RejectReason` for anything that does not survive. - -One event per article, since `event_observations` is unique on -`(source, source_ref)` and `source_ref` is the article URL. Everything is -`PROVISIONAL`, confidence capped at 0.95, and it never sets -`expected_attendance`, `venue` or coordinates. - -## Where they meet - -`ingest/repository.py` — `upsert_event(session, event)` is source-agnostic. It -resolves by `(source, source_ref)` **before** `dedupe_key`. Backwards would -orphan rows: a revised start date changes the key, so resolving by key first -strands the original. `_absorb_key_collision` merges when a revision lands on a -key another sighting already holds. - -```python -_PRECEDENCE = {Source.PREDICTHQ: 2, Source.ASKNEWS: 1} -``` - -A structured listing outranks prose. AskNews exists to see an event *before* -PredictHQ does, not to outvote it once it arrives. - -`events` holds one merged row per occurrence; `event_observations` keeps each -source's raw sighting verbatim, which makes re-runs idempotent and lets a -parser bug be fixed and replayed. - -## Three runs - -Three recorded article batches replayed through the real upsert against a local -Postgres. Each batch is a later poll of an overlapping window. - -| | Run 1 | Run 2 | Run 3 | -| --- | ---: | ---: | ---: | -| articles seen | 10 | 9 | 9 | -| events kept | 5 (50%) | 5 (56%) | 5 (56%) | -| articles rejected | 5 | 4 | 4 | -| → `no_city_match` | 1 | 1 | 1 | -| → `no_resolvable_date` | 1 | 1 | 2 | -| → `retrospective` | 1 | 1 | — | -| → `duplicate_in_batch` | — | 1 | 1 | -| → `malformed_record` | 1 | — | — | -| → `beyond_horizon` | 1 | — | — | -| **rows written** | 5 new | 2 new, 3 unchanged | 2 new, 1 updated, 2 unchanged | - -28 articles in, **13 rejected**, 9 distinct events out. - -``` -2026-10-25 dublin DAY 0.95 PROVISIONAL Dublin Marathon -2026-11-10 lisbon DAY 0.95 PROVISIONAL Web Summit -2026-12-01 lisbon DAY 0.95 PROVISIONAL Lisbon Christmas Market -2026-12-30 edinburgh DAY 0.95 PROVISIONAL Edinburgh Hogmanay -2027-01-20 dublin DAY 0.95 PROVISIONAL TradFest -2027-03-01 dublin QUARTER 0.75 PROVISIONAL Dublin Tech Summit -2027-04-01 edinburgh QUARTER 0.75 PROVISIONAL International Energy Summit -2027-06-11 dublin DAY 0.95 PROVISIONAL Forbidden Fruit -2027-08-01 edinburgh MONTH 0.80 PROVISIONAL Edinburgh Festival Fringe -``` - -### Against the live API - -Two real runs over the same 24-hour window, before and after scoping the search -per city: - -| | unscoped | per-city + country | -| --- | ---: | ---: | -| search calls | 4 | 3 | -| articles | 198 | 125 | -| `no_city_match` | 188 | 60 | -| events kept | 4 (2%) | 47 (38%) | - -Keep rate is not quality. Those 47 include historical entities ("Irish Civil -War", "midterms"), wrong-city assignments (Reading Festival → Dublin) and one -festival split across four rows. See "Known gaps". - -### What a run looks like - -AskNews, `scripts/run_asknews.py --dry-run` (run 1 of the three above): - -``` -RUN 1 -- replay run_1.json -==================================================================== -queries run 1 -articles seen 10 -events kept 5 (50%) -articles rejected 5 - no_city_match 1 - no_resolvable_date 1 - retrospective 1 - malformed_record 1 - beyond_horizon 1 -events by city - edinburgh 2 - dublin 2 - lisbon 1 -events by date precision - day 3 - quarter 1 - month 1 -extracted (nothing written) - 2026-11-10 .. 2026-11-13 lisbon day conf 0.95 Web Summit - 2026-12-30 .. 2027-01-01 edinburgh day conf 0.95 Edinburgh Hogmanay - 2027-03-01 .. 2027-05-31 dublin quarter conf 0.75 Dublin Tech Summit - 2027-06-05 .. 2027-06-07 dublin day conf 0.95 Forbidden Fruit - 2027-08-01 .. 2027-08-31 edinburgh month conf 0.80 Edinburgh Festival Fringe -``` - -PredictHQ, `python -m foresight.ingest.run --source predicthq --since 7d ---city Dublin --lat 53.3498 --lon -6.2603 --report` — a real run: - -``` -predicthq ingest | Dublin | 10km around 53.3498, -6.2603 -revised upstream since 2026-09-30 22:54 UTC - - fetched 662 - --------------- - new 636 - updated 19 - unchanged 7 - rejected 0 - - dublin now holds 640 events (619 active, 14 deleted, 7 provisional) - backed by 666 observations (4 asknews, 662 predicthq) -``` - -## New files - -Under `backend/`. - -| File | Purpose | -| --- | --- | -| `foresight/ingest/normalize.py` | The shared contract: `NormalizedEvent`, `dedupe_key`, enums. | -| `foresight/ingest/predicthq.py` | Structured listings near a point. No DB writes. | -| `foresight/ingest/asknews.py` | Per-city news search and event inference; owns the reject reasons. | -| `foresight/ingest/textdates.py` | Prose dates to a span plus the precision achieved. | -| `foresight/ingest/repository.py` | `upsert_event` — merge onto `events`, append to `event_observations`. | -| `foresight/ingest/run.py` | Ingest CLI (`python -m foresight.ingest.run`). PredictHQ today. | -| `scripts/run_asknews.py` | AskNews runner with the cleaning report. | -| `foresight/database/migrations/…b7d41e0c9a52…` | Creates `events` + `event_observations`. | -| `foresight/database/migrations/…c3e9a1f4b6d8…` | Adds `status` to observations. | -| `tests/` | `test_normalize`, `test_predicthq`, `test_asknews`, `test_textdates`, `test_repository`, `test_ingest_run`. | -| `tests/fixtures/asknews/run_{1,2,3}.json` | The three batches above. Hand-built to the documented AskNews schema, not recorded responses. | - -## How to test it - -**Fastest check — no credentials, no database, no API credits:** - -```bash -cd backend && uv run python scripts/run_asknews.py --dry-run -``` - -Prints the cleaning report and every extracted event, writes nothing. Use this -while changing extraction. Drop `--dry-run` to persist (needs Postgres). - -**Tests:** `just test` — 122 passing. `test_repository.py` needs Postgres via -`just db-up`; without it those tests error rather than skip, which looks -alarming and is not. - -**Live AskNews** needs `ASKNEWS_CLIENT_ID` / `ASKNEWS_CLIENT_SECRET` in the -repo-root `.env` (OAuth2 client credentials, not an API key): - -```bash -cd backend && uv run python scripts/run_asknews.py --live --runs 1 -``` - -Use `--runs 1`. AskNews bills per search; only `--live` spends. - -**PredictHQ** needs `PREDICTHQ_TOKEN`: - -```bash -cd backend && uv run python -m foresight.ingest.run \ - --source predicthq --since 7d --city Dublin \ - --lat 53.3498 --lon -6.2603 --report --dry-run -``` - -With `--lat/--lon/--city`, `--dry-run` touches no database and skips the -automatic migration. `--hotel-id` still needs one. - -**What landed:** - -```bash -psql -U foresight -h localhost -d foresight_db -c \ - "SELECT start_local_date, city_slug, date_precision, status, title - FROM events ORDER BY start_local_date;" -``` - -Re-running an ingest must not grow `event_observations`. - -> If you already run Postgres locally it binds `127.0.0.1:5432` and wins over -> Docker, so `just db-up` succeeds while the app talks to a different database. -> Check with `lsof -nP -iTCP:5432 -sTCP:LISTEN`. - -## Known gaps - -- **AskNews precision.** `entities.Event` is not a scheduled-event list — it - includes historical events and film titles. City matching accepts a bare - mention in the body, which misfiles touring events. -- **`confidence` measures extraction certainty, not event-ness.** A live run - scored "midterms" at 0.95. Not safe as a quality filter. The removed feature - layer weighted attendance by it, so fix this before anything reads it again. -- Two entry points: `run.py` (PredictHQ) and `scripts/run_asknews.py`. Both - support `--dry-run`; folding AskNews in as `--source asknews` would collapse - them. -- Rejected articles are counted, not persisted. No quarantine table. -- Nothing consumes `events` since `69d4ec9`. diff --git a/docs/external-signals.md b/docs/external-signals.md deleted file mode 100644 index 9881fcf..0000000 --- a/docs/external-signals.md +++ /dev/null @@ -1,71 +0,0 @@ -# External signals and hotel data - -How each source reaches Postgres. Events have their own doc, -[event-extraction.md](event-extraction.md). - -## The contract - -An adapter produces normalized records. A repository upserts them. The caller -owns the transaction and commits once. - -Repositories never commit. A multi-source run has to be all-or-nothing, and the -rolled-back-transaction fixture in `tests/conftest.py` is defeated by a -repository that commits internally. - -| Source | Adapter | Repository | Table | -| --- | --- | --- | --- | -| Open-Meteo | `ingest/weather.py` | `repository/weather.py` | `weather` | -| OpenSky, AeroDataBox | `ingest/flights.py` | `repository/flights.py` | `flight_arrivals` | -| Client CSV | `services/parsers/hotel_data_parser.py` (CLI: `scripts/load_hotel_data.py`) | `repository/hotel.py` | `hotels`, `room_types`, `daily_performance` | -| PredictHQ, AskNews | `ingest/predicthq.py`, `ingest/asknews.py` | `repository/events.py` | `events`, `event_observations` | - -Weather, flights, competitor rates and the hotel tables share `upsert_rows` in -`repository/__init__.py`, which upserts on a named constraint, touches a row -only when a value changed, and returns `{new, updated, unchanged}` counts. -Events keep their own upsert, because two sources describe the same event and -one has to win on precedence rather than last-write. - -## Running them - -External signals, one source per run: - -```bash -cd backend -# Next 16 days of forecast -uv run python -m foresight.ingest.external --source weather \ - --city Dublin --lat 53.3498 --lon -6.2603 -# Recorded actuals for a range, clamped to today -uv run python -m foresight.ingest.external --source weather \ - --city Dublin --lat 53.3498 --lon -6.2603 --start 2025-06-01 --end 2025-06-07 -# Flights, needs credentials -uv run python -m foresight.ingest.external --source aerodatabox \ - --start 2026-10-05 --end 2026-10-11 -``` - -Hotel booking history: - -```bash -uv run python scripts/load_hotel_data.py \ - --input backend/data/.csv --hotel-name "The Grand" \ - --city Dublin --base-rate 180.00 [--lat 53.3 --lon -6.2] -``` - -`--city` and `--base-rate` are required because the client export carries -neither. `total_rooms` is read from the export's `InStock` column. Coordinates -are optional and a run that omits them leaves any stored value alone, so the -events ingester's `--hotel-id` keeps working. - -Re-running anything is safe. A second identical run reports every row -unchanged and writes nothing. - -## Known gaps - -- `flight_arrivals` is empty without an OpenSky client id and secret and a - RapidAPI key. The code path is wired and tested. -- `daily_performance.rate` is the average rate paid (room revenue / rooms - sold, the client's `ARR`), not the listed price, and is null on unsold nights. - It is a stand-in until the client's listed-rate (BAR) history arrives. -- `competitor_rates` has a repository and tests but no adapter, so nothing - populates it. -- Only one room type per hotel, named `Standard`. The client prices one - standard room and applies its own multipliers downstream. diff --git a/docs/system-design.md b/docs/system-design.md new file mode 100644 index 0000000..881e010 --- /dev/null +++ b/docs/system-design.md @@ -0,0 +1,506 @@ +# Foresight backend: system design + +How the backend fits together: the layers, the patterns each one uses, and how +a request or a job moves through them. Written from the code as of 2026-10-15 +(`43-implement-ebm`). The reasons behind each choice are in +[`decisions/`](decisions/README.md); this document links to them rather than +repeating them. + +--- + +## 1. The system in one page + +Foresight stores a hotel's nightly sales history and the outside signals that +move demand (weather, events, flights), so a pricing model can recommend a +nightly rate. + +``` + ┌──────────────────────────────────────────────┐ + HTTP client ────▶ │ routes HTTP only: parse, call, map │ + └──────────────┬───────────────────────────────┘ + │ plain arguments in, domain objects out + ┌──────────────▼───────────────────────────────┐ + worker.py ──────▶ │ services │ + (background jobs) │ domain/ rules and reads per domain │ + │ ingest/ fetch + save, in chunks │ + │ adapters/ outside APIs and files, no DB │──▶ outside APIs + │ job.py job kinds + lifecycle │ + │ ml/ pricing model (EBM) │ + └──────────────┬───────────────────────────────┘ + │ plain values in, ORM objects out + ┌──────────────▼───────────────────────────────┐ + │ repository the only code that queries │ + └──────────────┬───────────────────────────────┘ + ┌──────────────▼───────────────────────────────┐ + │ database models, enums, session, Alembic │──▶ Postgres + └──────────────────────────────────────────────┘ +``` + +Two kinds of work enter the system: + +- **Requests** (reads, creates, updates). One request is one database + transaction, committed before the response is sent. +- **Jobs** (ingesting weather, events and performance data). The request + validates the input, stores a job and returns `202` at once. The job runs in + the background in the same process and commits after every chunk. + +Everything is async: FastAPI handlers, SQLAlchemy sessions and every outside +HTTP call. The API has no authentication ([0026](decisions/0026-no-authentication.md)). + +--- + +## 2. Layers and the rules between them + +A layer calls only the layer directly below it +([0012](decisions/0012-layered-architecture.md)). + +| Layer | Folder | Owns | Must not | +|---|---|---|---| +| routes | `routes/`, `main.py` | URLs, status codes, request/response schemas | query, hold business rules, catch domain errors | +| services | `services/` | business rules, orchestration, outside APIs, the model | know about HTTP, see schemas, commit | +| repository | `repository/` | every query and write | commit, hold business rules | +| database | `database/` | tables, enums, constraints, migrations, session | query | + +Actual imports: + +``` +routes → services, database.enums, worker (to schedule a job) +services → repository, database.models/enums, config (job.py only) +repository → database +worker → services.job, database.session +``` + +Enums live in `database/enums.py` because columns use them; every layer may +import them. + +### 2.1 File layout + +One file per domain, singular, with the same name in each layer. Outside +signals are grouped under `signals/` in both the database and the repository. + +``` +backend/foresight/ + main.py app, routers, error handlers, lifespan + config.py settings (pydantic-settings) + worker.py runs jobs; commits per chunk + routes/ + helpers.py SessionDep, schema bases, PageResponse, paginate + errors.py domain error → status code + city.py hotel.py weather.py event.py flight.py ingest.py job.py + pricing_model.py health.py + services/ + errors.py NotFound / Conflict / InvalidRequest / NotConfigured + job.py job kinds, validation, handlers, lifecycle + domain/ city, hotel, weather, weather_coverage, event, flight, + model_inputs, pricing_model, recommendation + ingest/ weather, event, performance, window + adapters/ open_meteo, predicthq, asknews, aerodatabox, opensky, cso, + hotel_csv, normalize, textdates + ml/ base, ebm, features, weighting + repository/ + upsert.py pagination.py + city.py hotel.py job.py pricing_model.py + signals/ weather, event, flight, competitor_rate + database/ + base.py enums.py session.py + models/ city, hotel, job, pricing_model + signals/ weather, event, flight, competitor_rate + migrations/ +``` + +`example_widget.py` in each layer is a small reference slice that shows every +pattern in this document. + +--- + +## 3. Patterns that span every layer + +### 3.1 Transactions + +Only two places commit ([0013](decisions/0013-one-transaction-per-unit-of-work.md)): + +| Caller | Session from | Commits | +|---|---|---| +| HTTP request | `database/session.get_session` | once, after the handler; rolls back on any error | +| job | `worker.run_job` | after every chunk, then once for the final status | + +Repositories flush when they need a generated id. `update_hotel` and +`update_city` run in a savepoint, so a rename that hits a unique constraint +fails on its own and the request can still answer 409. + +### 3.2 Errors + +Services raise; `routes/errors.py` maps +([0014](decisions/0014-services-raise-domain-errors.md)). + +``` +services/errors.py routes/errors.py +DomainError + ├─ NotFoundError ─────────▶ 404 + ├─ ConflictError ─────────▶ 409 + ├─ InvalidRequestError ─────────▶ 422 + │ └─ InvalidWindowError (services/ingest/window.py) + └─ NotConfiguredError ─────────▶ 503 (an API key isn't set) +``` + +- Every `get_x(id)` in a service raises `NotFoundError`; nothing returns None + to a route. +- The repository returns None for a missing row or a duplicate insert. The + service decides that it is an error. +- Every error body is `{"detail": "..."}`. +- Adapter errors (`OutOfCreditsError`, `RateLimitedError`, ...) never reach + HTTP. They end a job as `stopped` or `failed` (section 5). + +### 3.3 Naming + +| Verb | Returns | +|---|---| +| `get_x(id)` | the row; the service raises `NotFoundError` if missing | +| `list_x(...)` | rows, or `(rows, total)` when paginated | +| `create_x` | the new row; a duplicate becomes `ConflictError` | +| `update_x` | partial update; only the fields given | +| `upsert_x` | idempotent write; returns new/updated/unchanged counts | + +Constraints and indexes are named by the convention on `Base.metadata`: +`pk_`, `uq_
_`, `fk_
__`, +`ix_
_`. Upserts target constraints by these names. + +### 3.4 Data types + +| Kind | Type | +|---|---| +| money | `Numeric(10, 2)` ↔ `Decimal` | +| a night (stay, weather day, event date, arrival day) | `Date`, local to the city ([0023](decisions/0023-stay-nights-are-local-dates.md)) | +| an instant | `DateTime(timezone=True)` | +| enums | VARCHAR holding the member name; adding a member needs no migration | +| raw source payloads | `JSONB`, kept verbatim | + +### 3.5 Idempotent writes + +Every write from an outside source or a file is an upsert on a natural key +([0018](decisions/0018-idempotent-upserts.md)): + +| Table | Natural key | +|---|---| +| `weather` | city_id, weather_date, is_forecast, source | +| `flight_arrivals` | city_id, arrival_date, source | +| `competitor_rates` | city_id, hotel_name, stay_date, source | +| `daily_performance` | room_type_id, stay_date | +| `room_types` (CSV import) | hotel_id, name | +| `events` | dedupe_key (normalized title + city + local start date) | +| `event_observations` | source, source_ref | + +`repository/upsert.py:upsert_rows` is the shared bulk upsert. It rewrites a +row only when a value changed, reports `NEW / UPDATED / UNCHANGED` counts, +batches under Postgres's parameter limit and collapses duplicate keys in a +batch. Each source keeps its own row; choosing between sources happens when +the model reads the data. + +### 3.6 Schemas and pagination + +HTTP schemas live in the route module that uses them +([0015](decisions/0015-schemas-in-route-modules.md)): + +- `RequestSchema` rejects unknown fields; `ResponseSchema` builds from an ORM + object or a dataclass. Both are in `routes/helpers.py`. +- Constraints and descriptions go in `Field(...)`, so they are enforced and + shown in `/docs`. Every field, path id and query parameter has a + description; units, inclusive dates, currency and what `null` means go there + ([0030](decisions/0030-schema-descriptions-and-examples.md)). +- Example values go in `examples=`, as neutral placeholders. A field with a + default shows its default instead. The body `/docs` shows is built from the + fields (`example_body` in `routes/helpers.py`), never written by hand. +- A PATCH body reuses its create body's field settings; each module keeps its + own "cannot be null" check and its own `start`/`end` parameters. +- PATCH bodies use `model_dump(exclude_unset=True)`: omitted fields are left + alone, `null` clears an optional field and is a 422 on a required one. +- Derived values (occupancy, ADR, RevPAR) are `@computed_field`s, never stored. + +Lists that grow without bound take `?limit=&offset=` (default 50, max 200) and +return `{"items", "total", "limit", "offset"}`: performance, weather, events, +flight arrivals and jobs. Cities, hotels and room types return a plain list. + +### 3.7 Outside HTTP + +Adapters make each request once and raise on failure; there are no automatic +retries ([0022](decisions/0022-no-retries-resume-instead.md)). Each adapter +turns its source's quota responses into a named error: + +| Source | Quota error | +|---|---| +| PredictHQ | 402/403 → `OutOfCreditsError`; 429 → `RateLimitedError` | +| AeroDataBox | 429 → `RateLimitedError`; paces requests 1.1s apart | +| OpenSky | 429 → `CreditsExhaustedError`; refreshes its token on 401 | +| Open-Meteo, AskNews, CSO | none; any failure raises | + +--- + +## 4. Data model + +``` +cities ─┬─< hotels ─┬─< room_types ─< daily_performance (the hotel's own history) + │ ├─< pricing_models (trained models; one active) + │ └─< jobs (ingest runs) + │ + ├─< weather ┐ + ├─< events ─< event_observations │ outside signals, + ├─< flight_arrivals │ shared by every hotel + └─< competitor_rates ┘ in the city +``` + +**Cities are the market** ([0017](decisions/0017-cities-are-the-market.md)). +A city holds its name, a unique normalized slug, country, IANA timezone, +centre coordinates and both airport codes. Weather is read and events are +searched at the centre; "today" for a job is today in the city's timezone. A +hotel keeps its own coordinates for distance to venues. + +**Foreign keys** always state `ON DELETE`: + +- `CASCADE` from a city to its signals, and from a hotel to its room types, + nights, pricing models and jobs. +- `RESTRICT` from a hotel to its city: a city with hotels can't be deleted. +- `SET NULL` from an observation to its event, so raw sightings survive. + +**Relationships are `lazy="raise"`.** An unloaded relationship fails at once +rather than at some later point in async code. The hotel repository always +loads `Hotel.city` with a join, because almost every service needs it. + +**Events** ([0019](decisions/0019-events-merged-across-sources.md)): `events` +holds one merged row per real-world event; `event_observations` keeps each +source's raw record. `services/domain/event.save_event`: + +1. Looks the sighting up by the source's own id first, then by dedupe key. A + revised start date changes the key, so looking up by key first would orphan + the old row. +2. Merges two rows if a revision moves onto a key another event holds. +3. Ranks every sighting of the event (structured over extracted, live over + withdrawn, newest revision, then source_ref) and writes the winner's values. +4. Records the sighting. + +**Migrations** ([0025](decisions/0025-migrations.md)) are linear, explain why +they exist, and always roll back. CI checks for one head, runs `alembic check` +and does a full downgrade and upgrade. + +--- + +## 5. Jobs + +Long work runs as jobs inside the API process +([0020](decisions/0020-jobs-in-the-api-process.md)), one kind per signal +([0021](decisions/0021-one-ingest-job-per-signal.md)). + +### 5.1 Kinds + +| `POST /hotels/{id}/ingest/…` | Kind | No dates | With dates | Chunk | +|---|---|---|---|---| +| `weather` | `WEATHER` | next 16 days of forecast | recorded weather for past days, forecast for the next 16 | one year of recorded; the forecast | +| `events` | `EVENTS` | events PredictHQ revised since the last refresh (7 days the first time) | every event active in the window | one month | +| `performance` (multipart) | `PERFORMANCE` | — (takes CSV files and `base_rate`) | — | one file | + +**Window rules** (`services/ingest/window.py`): + +| Given | Window | +|---|---| +| start and end | that window | +| start only | start to today | +| end only | the hotel's first loaded night to end, walked newest first | + +Weather days beyond the forecast have no source; they're listed in the job's +`progress.beyond_forecast` instead of being fetched. + +Events use a 10km radius around the city centre and need `PREDICTHQ_TOKEN` +(a 503 if it isn't set). Performance files are all parsed before the job is +queued, so one bad file is a 422 and nothing is saved. + +### 5.2 Lifecycle + +``` +POST /hotels/7/ingest/events {"start": "2025-01-01"} + routes/ingest.ingest_events + → job_service.start_events_job(session, 7, start=…, end=None) + get_hotel → 404 + API key set? → 503 + resolve_window → 422 + same kind already queued or running? → 409 + job_repo.create_job(params = resolved inputs) + → tasks.add_task(worker.run_job, job.id) + ← 202 {job} get_session commits the row first + +worker.run_job(id) after the response, its own session + status = running; commit + for each Step the handler yields: + save progress; commit; remember step.resume_from + on a quota error → rollback; status = stopped, error, resume_from + on any other error → rollback; status = failed, error, resume_from + otherwise → status = succeeded + +GET /jobs/{id} → status, params, progress, error, resume_from +``` + +A job's `progress` is committed together with the chunk it describes, so it +always matches what is saved. + +**Resuming** is by hand: start the same job again with `start = resume_from` +(or `end = resume_from` for a run walking backwards). Upserts make overlap +safe. + +**Limits:** + +- Jobs start immediately and run side by side; there's no ordering. +- One unfinished job per kind per hotel; a second request gets a 409. +- A restart kills running jobs. On startup they're marked failed. +- Production runs a single uvicorn worker, because jobs and a performance + job's parsed files live in that process. +- Nothing runs on a schedule; the routine refreshes run when called. + +--- + +## 6. End-to-end flows + +### 6.1 A read: `GET /hotels/7/weather?start=…` + +``` +routes/weather.list_weather + → services/domain/weather.list_weather(session, 7, …) + → get_hotel(7) 404 if missing + → repository/signals/weather.list_weather(city_id=hotel.city_id, …) + → fetch_page(SELECT … WHERE city_id = … ORDER BY weather_date, id) + ← (rows, total) + → paginate(WeatherResponse, rows, total, page) +``` + +### 6.2 A performance import + +``` +POST /hotels/7/ingest/performance files=a.csv,b.csv base_rate=150 + → start_performance_job + → ingest/performance.parse_files: HotelDataParser per file + load → clean → transform → validate (pandas); any failure → 422 + → queue the job; parsed rows go to the worker in memory + worker, per file: + upsert_room_type("Standard", room_count from the file, base_rate) + upsert_daily_performance(rows) → commit +``` + +The client's export has one room type, so every night is stored under +"Standard". The rate is revenue divided by rooms sold, because the export has +no listed price. + +### 6.3 An events backfill + +``` +job EVENTS, mode=window, 2025-01-01 … 2025-12-31 + for each month: + predicthq.fetch(window_params(city centre, month)) + pages through `next`; each record → NormalizedEvent, or rejected with a reason + for each event: domain/event.save_event(session, city_id, event) + yield Step(progress, resume_from = next month's start) → commit +``` + +### 6.4 Training and recommendations + +Both read only what's already ingested; neither fetches +([0028](decisions/0028-train-and-serve-on-ingested-data.md)). + +``` +POST /hotels/7/train {"description": "...", "settings": {...}} → job, 202 + job_service.start_train_job hotel exists, nights loaded in window → 422 + → domain/pricing_model.train + hotel_repo.training_nights every night, oldest first + ml/weighting.training_mask drop unpriced nights (0004) + domain/model_inputs.weather_for recorded weather only + ml/features.build_features events and flights empty (0007) + ml/weighting.sample_weights occupancy (0003) + EBMModel.fit in a thread calibrates on the newest 15% (0005) + model_repo.create_pricing_model new version, inactive (0008, 0009) + model_repo.activate_pricing_model unless activate=false + progress = {model_id, version, metrics, ...} + +GET /hotels/7/recommendations?date=2026-10-24[&room_type_id=] + → domain/recommendation.get_recommendation + model_repo.get_active_pricing_model 404 if none + domain/model_inputs.weather_for recorded → forecast → typical week (0024) + build_features → predict_interval, explain, predict_uncertainty + ← rate, 80% interval, factors in € that sum to the rate +``` + +Every training run is a new version +([0029](decisions/0029-every-training-run-is-a-new-version.md)): +`description` says what it tried, and `POST /models/{id}/activate` chooses +which version serves. + +--- + +## 7. API + +| Method | Path | Notes | +|---|---|---| +| GET | `/health` | | +| POST, GET | `/cities` | | +| GET, PATCH | `/cities/{id}` | | +| POST, GET | `/hotels` | a hotel includes its city | +| GET, PATCH | `/hotels/{id}` | | +| POST, GET | `/hotels/{id}/room-types` | | +| GET | `/room-types/{id}` | | +| GET | `/hotels/{id}/performance` | paginated | +| GET | `/hotels/{id}/weather` | paginated; forecast and recorded rows | +| GET | `/hotels/{id}/coverage/weather` | recorded-weather gaps in a window | +| GET | `/hotels/{id}/events` | paginated; withdrawn events hidden | +| GET | `/hotels/{id}/flight-arrivals` | paginated; one row per source per day | +| GET | `/hotels/{id}/recommendations?date=` | active model; optional `room_type_id` | +| POST | `/hotels/{id}/train` | job, 202; optional description, window, settings | +| GET | `/hotels/{id}/models` | paginated, newest version first | +| GET | `/models/{id}` | one version, no artifact | +| POST | `/models/{id}/activate` | serve this version | +| POST | `/hotels/{id}/ingest/{weather,events}` | job, 202; optional `{start, end}` | +| POST | `/hotels/{id}/ingest/performance` | job, 202; multipart files + `base_rate` | +| GET | `/jobs/{id}` | | +| GET | `/hotels/{id}/jobs` | paginated, newest first | +| POST, GET | `/example/widgets`, `/example/widgets/{id}` | reference slice | + +Status codes: `201` create, `202` job queued, `200` otherwise, and the error +codes in 3.2. + +--- + +## 8. Configuration + +Settings live in `config.py`, read from `../.env` then `backend/.env`. Every +setting is listed in `.env.example`. + +| Setting | Used by | +|---|---| +| `DATABASE_URL`, `DEBUG` | `database/session.py` (`DEBUG` echoes SQL) | +| `PREDICTHQ_TOKEN` | events job | +| `ASKNEWS_CLIENT_ID`, `_SECRET` | AskNews adapter (no job yet) | +| `XRAPIDAPI_KEY` | AeroDataBox adapter (no job yet) | +| `OPENSKY_CLIENT_ID`, `_SECRET` | OpenSky adapter (no job yet) | + +Only `services/job.py` reads settings. A missing key is a 503 when the job is +requested. + +--- + +## 9. Built but not connected + +| Piece | State | +|---|---| +| `adapters/aerodatabox.py`, `opensky.py` | work; no flights job yet, so `flight_arrivals` stays empty | +| `adapters/cso.py` | monthly airport passengers; needs a monthly table before it can be stored | +| `adapters/asknews.py` | news-extracted events; no job yet | +| `competitor_rates` | table and upsert; no source | + +--- + +## 10. Open questions + +1. **Events and flights in the model.** Both have feature columns but no + history to train on (PredictHQ shows only recent months; no flights job). +2. **Choosing between sources.** Weather is decided (recorded beats forecast). + Which flight source wins, and whether CSO's monthly totals are spread over + days, isn't. +3. **Flights ingest.** Waiting on the CSO table and the choice in question 2. +4. **Scheduling.** The routine refreshes (forecast, recent events) only run + when called. +5. **Tests.** The suite predates this design and hasn't been updated. Nothing + has been run against real PredictHQ, AskNews, AeroDataBox or OpenSky keys. diff --git a/justfile b/justfile index a027aef..8a2a19d 100644 --- a/justfile +++ b/justfile @@ -1,5 +1,20 @@ set windows-shell := ["C:\\Program Files\\Git\\bin\\sh.exe", "-c"] +# Database selection: recipes that touch the database take an optional +# `--neon` flag, e.g. `just dev --neon`, `just migrate --neon`. Without it they +# use the local Docker Postgres. +_compose_local := "docker compose" +_compose_neon := "docker compose -f docker-compose.yml -f docker-compose.neon.yml" + +# Host-side commands (uv run ...) read .env, which holds the Neon URLs, so +# local mode overrides them with the Docker Postgres on :5432. +_env_local := "DATABASE_URL=postgresql+asyncpg://foresight:foresight@localhost:5432/foresight_db DATABASE_URL_UNPOOLED=" + +# Rejects anything other than nothing or `--neon`. +[private] +_flags flags: + @{{ if flags =~ '^(--neon)?$' { "true" } else { error("unknown flag '" + flags + "' (only --neon is supported)") } }} + # Default recipe default: @just --list @@ -20,13 +35,14 @@ build: build-prod: docker build --target prod -t foresight-backend:prod backend/ -# Start all services (db + backend with hot reload) -dev: - docker compose up -d +# Start all services with hot reload (`just dev --neon` to use Neon) +dev *flags: (_flags flags) + @echo "Database: {{ if flags == "--neon" { "neon" } else { "local" } }}" + {{ if flags == "--neon" { _compose_neon } else { _compose_local } }} up -d # Stop all services -destroy: - docker compose down +destroy *flags: (_flags flags) + {{ if flags == "--neon" { _compose_neon } else { _compose_local } }} --profile local down # Start only the database, with migrations applied db-up: @@ -47,25 +63,33 @@ db-wipe: logs: docker compose logs -f backend +# Run the API on the host, outside Docker (`just run --neon` to use Neon) +run *flags: (_flags flags) + @echo "Database: {{ if flags == "--neon" { "neon" } else { "local" } }}" + cd backend && {{ if flags == "--neon" { "" } else { _env_local } }} uv run uvicorn foresight.main:app --reload --port 8000 + # --- Database Migrations --- +# All take `--neon`; the default is the local Docker Postgres. # Run all pending migrations -migrate: - cd backend && uv run alembic upgrade head +migrate *flags: (_flags flags) + @echo "Database: {{ if flags == "--neon" { "neon" } else { "local" } }}" + cd backend && {{ if flags == "--neon" { "" } else { _env_local } }} uv run alembic upgrade head -# Create a new migration -migrate-create name: - cd backend && uv run alembic revision --autogenerate -m "{{name}}" +# Create a new migration (`just migrate-create "msg" --neon` to diff against Neon) +migrate-create name *flags: (_flags flags) + cd backend && {{ if flags == "--neon" { "" } else { _env_local } }} uv run alembic revision --autogenerate -m "{{name}}" # Rollback last migration -migrate-down: - cd backend && uv run alembic downgrade -1 +migrate-down *flags: (_flags flags) + @echo "Database: {{ if flags == "--neon" { "neon" } else { "local" } }}" + cd backend && {{ if flags == "--neon" { "" } else { _env_local } }} uv run alembic downgrade -1 # --- Testing --- # Run the test suite -test: - cd backend && uv run pytest -q +test *flags: (_flags flags) + cd backend && {{ if flags == "--neon" { "" } else { _env_local } }} uv run pytest -q # --- Quality --- diff --git a/neon.ts b/neon.ts new file mode 100644 index 0000000..22b8e20 --- /dev/null +++ b/neon.ts @@ -0,0 +1,20 @@ +import { defineConfig } from "@neon/config/v1"; + +export default defineConfig({ + // Declare your Neon services here + auth: false, + // Branch policy: per-branch tuning + branch: (branch) => { + if (branch.isDefault) { + // Default branch: no overrides, uses project defaults + return {}; + } + if (!branch.exists) { + // New non-default branches: auto-expire + // Run `neon checkout ` to create a new branch with these settings + return { ttl: "7d" }; + } + // Existing branch: no changes + return {}; + }, +}); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..08bdc4d --- /dev/null +++ b/package-lock.json @@ -0,0 +1,500 @@ +{ + "name": "foresight", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "@neon/config": "^1.9.1", + "@neon/env": "^1.6.2" + } + }, + "node_modules/@napi-rs/keyring": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring/-/keyring-1.3.0.tgz", + "integrity": "sha512-WrOw/bcXm0f9qHkumlT1QlArXSTWqaY9sunsDpOk+yCCorCKMxvWT/a3xko4EYHVdeZoh00yI2TydXn6eyICDA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/keyring-darwin-arm64": "1.3.0", + "@napi-rs/keyring-darwin-x64": "1.3.0", + "@napi-rs/keyring-freebsd-x64": "1.3.0", + "@napi-rs/keyring-linux-arm-gnueabihf": "1.3.0", + "@napi-rs/keyring-linux-arm64-gnu": "1.3.0", + "@napi-rs/keyring-linux-arm64-musl": "1.3.0", + "@napi-rs/keyring-linux-riscv64-gnu": "1.3.0", + "@napi-rs/keyring-linux-x64-gnu": "1.3.0", + "@napi-rs/keyring-linux-x64-musl": "1.3.0", + "@napi-rs/keyring-win32-arm64-msvc": "1.3.0", + "@napi-rs/keyring-win32-ia32-msvc": "1.3.0", + "@napi-rs/keyring-win32-x64-msvc": "1.3.0" + } + }, + "node_modules/@napi-rs/keyring-darwin-arm64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-arm64/-/keyring-darwin-arm64-1.3.0.tgz", + "integrity": "sha512-pl76hJvdYUBn6I24bXiOBMA9nbDapo3I5B+f3OorjDU4dUMSypXeKbOVehJe8fhgTiH24flMyTS3aAIy43xegQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-darwin-x64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-x64/-/keyring-darwin-x64-1.3.0.tgz", + "integrity": "sha512-YcJtEV5LA3cvA4z3BurgxH5IhTsW1JfIvcAAcqcecwk06Si9F9NqkxbZVIfDwQ8oRHgaBmT3zZJnLAotCrVahw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-freebsd-x64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-freebsd-x64/-/keyring-freebsd-x64-1.3.0.tgz", + "integrity": "sha512-vlLf31TGhfRAaxLDBhg8b89ss0HHD/lyNmL5F3UjSaz5CUXElsJmKYq9fqA/B+cZKUEUcLHHGhF0I/CqcFdaVw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-arm-gnueabihf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm-gnueabihf/-/keyring-linux-arm-gnueabihf-1.3.0.tgz", + "integrity": "sha512-KiWdMMu/Inz/bHHIAGrnF7r54FZDYXuHO6UFF/rhIrshUsxbMG1Rl9lEymNtqqsVo927G0VYcb02FzWQ3iBQRQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-arm64-gnu": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-gnu/-/keyring-linux-arm64-gnu-1.3.0.tgz", + "integrity": "sha512-eyKGpY40lm9Jvs1aD294XRH4y7+TlJM0YVAryZeXA6TX0mb4gMkxVXwSQv7MCwgah7raeUd0dKUb4BPAYIgcMg==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-arm64-musl": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-musl/-/keyring-linux-arm64-musl-1.3.0.tgz", + "integrity": "sha512-iIK6JWHXAJqDrEyLY3TmswwloVyt2vj+04TZnew+uSJ9gnDO8EwRbp3/iw3LpWaXiDO7VomGO6y8I0Id8uBZSw==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-riscv64-gnu": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-riscv64-gnu/-/keyring-linux-riscv64-gnu-1.3.0.tgz", + "integrity": "sha512-/PGqrwn6EwgtK6vccASSXJRfOSP4vN1F4ASsIQ+7MdrK6hNvAJ1FZPrIuD5gGGdxezo3F++To2Wq7DbuGIeuNQ==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-x64-gnu": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-gnu/-/keyring-linux-x64-gnu-1.3.0.tgz", + "integrity": "sha512-2PDK1WKWTu9lBGq9VvNEkSlQD3O7YwVpmnyN2M3cy4v7NJ/8gDMd9GXv3G+FVXN13uhp4gnnPBS+ScefmEeD2A==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-x64-musl": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-musl/-/keyring-linux-x64-musl-1.3.0.tgz", + "integrity": "sha512-oJ2HkX8YUo46QBkn0pG+HuIKQNqr523q6vBobCn+P95s4C4K6/kLBqHY/1bg5J4ap31DzsznhnFKcfBNBsjCnw==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-arm64-msvc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-arm64-msvc/-/keyring-win32-arm64-msvc-1.3.0.tgz", + "integrity": "sha512-tOd3c/uAaeoE4ycVlmAdSvygz0Zt3zdca6Y7gokBeIbaRDWpjDIUOpU3MvML59XAaqyuKGsVVu0F/DZb1lHPmw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-ia32-msvc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-ia32-msvc/-/keyring-win32-ia32-msvc-1.3.0.tgz", + "integrity": "sha512-sPSqeAFZMGqP1R++M2JTza7GQJJ/TpCo6JU6Vcd4jnebvOaEDs9b7eipakU1PJdSvhpC2yXMCNRk9gXfrhuwHQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-x64-msvc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-x64-msvc/-/keyring-win32-x64-msvc-1.3.0.tgz", + "integrity": "sha512-4DnCWXwDc0HRKwyRlG5y0VhKZW2tNRQfKKfyj6IX/KWfDNyq9hn4n+GL1auyDcOO/v8PwnhmYo2+rOOqCkvvOg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@neon/config": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@neon/config/-/config-1.9.1.tgz", + "integrity": "sha512-vywTl84GIxxCqKzi5Q/V5cCvVUdsMZ1iN2cdFER3AzLve+oLy0oaykuBBQfMjfDpNZ0eeCNEDYEqbo4lr39mdA==", + "license": "Apache-2.0", + "dependencies": { + "@neon/sdk": "7.2.0", + "jiti": "^2.7.0", + "zod": "^4.4.3" + }, + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@neon/env": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/@neon/env/-/env-1.6.2.tgz", + "integrity": "sha512-ks8Uf00xOYYe+yaIHUhlRQO3ygWYpO2bPzmTcwNtsn3Ax/CwD/r9EpA+f7nRTTI2zWJgCQlawAxBa591CUu+BA==", + "license": "Apache-2.0", + "dependencies": { + "@neon/config": "1.9.1", + "yargs": "^18.0.0", + "zod": "^4.4.3" + }, + "bin": { + "neon-env": "dist/cli.js" + }, + "engines": { + "node": ">=20.19.0" + }, + "optionalDependencies": { + "@napi-rs/keyring": "1.3.0" + } + }, + "node_modules/@neon/sdk": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@neon/sdk/-/sdk-7.2.0.tgz", + "integrity": "sha512-s/vphaf8eR6Otj5+zCczOCoC6nXTsTSgRtP8bZU1mDWBypjNbfMpCBs08splUfCF4mmoHsMRuPWy5fcQdIDyqQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/ansi-regex": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz", + "integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cliui/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.7.0.tgz", + "integrity": "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/string-width": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.3.0.tgz", + "integrity": "sha512-ZbmZM0JCihQN91dWnxoipT2KOEyHqEyfRXUyjuRhW8b/xnqPDoq4gWEVApTVa9db2wN8mmoikgFBbjh71+cGeQ==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.2.0.tgz", + "integrity": "sha512-9OpKOLeaoNFecEp7P6iYbzze/5CqWoH7N3SMs/6y1XF4nMvFMspEGZzJ6uFi9MmQwXhyzqYoSEKO3tvA3Z/o2w==", + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^8.2.1", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/zod": { + "version": "4.6.7", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", + "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..37ab729 --- /dev/null +++ b/package.json @@ -0,0 +1,6 @@ +{ + "dependencies": { + "@neon/config": "^1.9.1", + "@neon/env": "^1.6.2" + } +}