From 9fe8e41765d721069ef660306f2b5e245e8e634b Mon Sep 17 00:00:00 2001 From: Alexcchip Date: Tue, 13 Oct 2026 13:41:57 -0400 Subject: [PATCH 1/6] feat: add upsert keys to external and hotel tables Named unique constraints on weather, flight_arrivals, competitor_rates, hotels, room_types and daily_performance so re-running an ingest updates rows instead of duplicating them. Co-authored-by: Vichu Selvaraju --- ...9719be9df40_external_signal_upsert_keys.py | 57 +++++++++++++++++++ .../c97a8a991e88_hotel_upsert_keys.py | 46 +++++++++++++++ backend/foresight/database/models/external.py | 36 ++++++++++++ backend/foresight/database/models/hotel.py | 12 +++- 4 files changed, 150 insertions(+), 1 deletion(-) create mode 100644 backend/foresight/database/migrations/versions/b9719be9df40_external_signal_upsert_keys.py create mode 100644 backend/foresight/database/migrations/versions/c97a8a991e88_hotel_upsert_keys.py diff --git a/backend/foresight/database/migrations/versions/b9719be9df40_external_signal_upsert_keys.py b/backend/foresight/database/migrations/versions/b9719be9df40_external_signal_upsert_keys.py new file mode 100644 index 0000000..6471276 --- /dev/null +++ b/backend/foresight/database/migrations/versions/b9719be9df40_external_signal_upsert_keys.py @@ -0,0 +1,57 @@ +"""external signal upsert keys + +Gives weather, flight_arrivals and competitor_rates the natural keys their +ingestion needs: without a conflict target every re-run appends duplicate +rows. Source stays in each key, so two providers describing the same day are +distinct rows rather than silently overwriting one another, and a provider +re-fetching a day updates its own row in place. + +Revision ID: b9719be9df40 +Revises: a1b2c3d4e5f6 +Create Date: 2026-10-12 02:26:03.496929 + +""" +from typing import Sequence, Union + +from alembic import op + + +# revision identifiers, used by Alembic. +revision: str = 'b9719be9df40' +down_revision: Union[str, None] = 'a1b2c3d4e5f6' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_unique_constraint( + 'uq_weather_city_date_kind_source', + 'weather', + ['city', 'weather_date', 'is_forecast', 'source'], + ) + op.create_unique_constraint( + 'uq_flight_arrivals_airport_date_source', + 'flight_arrivals', + ['airport', 'arrival_date', 'source'], + ) + op.create_unique_constraint( + 'uq_competitor_rates_hotel_date_source', + 'competitor_rates', + ['hotel_name', 'city', 'stay_date', 'source'], + ) + # The unique constraint above leads on hotel_name, so it cannot serve the + # market-wide read: every competitor's rate across a range of nights. + op.create_index( + 'ix_competitor_rates_city_stay', 'competitor_rates', ['city', 'stay_date'] + ) + + +def downgrade() -> None: + op.drop_index('ix_competitor_rates_city_stay', table_name='competitor_rates') + op.drop_constraint( + 'uq_competitor_rates_hotel_date_source', 'competitor_rates', type_='unique' + ) + op.drop_constraint( + 'uq_flight_arrivals_airport_date_source', 'flight_arrivals', type_='unique' + ) + op.drop_constraint('uq_weather_city_date_kind_source', 'weather', type_='unique') diff --git a/backend/foresight/database/migrations/versions/c97a8a991e88_hotel_upsert_keys.py b/backend/foresight/database/migrations/versions/c97a8a991e88_hotel_upsert_keys.py new file mode 100644 index 0000000..81169ab --- /dev/null +++ b/backend/foresight/database/migrations/versions/c97a8a991e88_hotel_upsert_keys.py @@ -0,0 +1,46 @@ +"""hotel upsert keys + +Gives hotels, room_types and daily_performance the named conflict targets +repository/hotel.py already upserts against; without them every one of its +three functions raises. daily_performance only needs the rename, since its +unique constraint was created unnamed and Postgres generated the name. + +Revision ID: c97a8a991e88 +Revises: b9719be9df40 +Create Date: 2026-10-12 21:42:11.000000 + +""" +from typing import Sequence, Union + +from alembic import op + + +# revision identifiers, used by Alembic. +revision: str = 'c97a8a991e88' +down_revision: Union[str, None] = 'b9719be9df40' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +_GENERATED_PERF_KEY = 'daily_performance_room_type_id_stay_date_key' + + +def upgrade() -> None: + op.create_unique_constraint('uq_hotels_name_city', 'hotels', ['name', 'city']) + op.create_unique_constraint( + 'uq_room_types_hotel_name', 'room_types', ['hotel_id', 'name'] + ) + # A rename, not a drop and recreate, so the table is never without its + # unique and the existing index is reused. + op.execute( + f'ALTER TABLE daily_performance RENAME CONSTRAINT ' + f'{_GENERATED_PERF_KEY} TO uq_daily_performance_room_type_stay' + ) + + +def downgrade() -> None: + op.execute( + f'ALTER TABLE daily_performance RENAME CONSTRAINT ' + f'uq_daily_performance_room_type_stay TO {_GENERATED_PERF_KEY}' + ) + op.drop_constraint('uq_room_types_hotel_name', 'room_types', type_='unique') + op.drop_constraint('uq_hotels_name_city', 'hotels', type_='unique') diff --git a/backend/foresight/database/models/external.py b/backend/foresight/database/models/external.py index a803927..8c69e62 100644 --- a/backend/foresight/database/models/external.py +++ b/backend/foresight/database/models/external.py @@ -52,6 +52,18 @@ class Weather(Base): 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", + ), + ) + class Event(Base): """One real-world event occurrence, merged across sources. @@ -176,6 +188,19 @@ class CompetitorRate(Base): 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"), + ) + class FlightArrival(Base): """How many people are flying in on a given day.""" @@ -193,3 +218,14 @@ class FlightArrival(Base): 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", + ), + ) diff --git a/backend/foresight/database/models/hotel.py b/backend/foresight/database/models/hotel.py index 760fa58..d2e4d18 100644 --- a/backend/foresight/database/models/hotel.py +++ b/backend/foresight/database/models/hotel.py @@ -23,6 +23,8 @@ class Hotel(Base): room_types: Mapped[list["RoomType"]] = relationship(back_populates="hotel") + __table_args__ = (UniqueConstraint("name", "city", name="uq_hotels_name_city"),) + class RoomType(Base): """A category of room, e.g. "Deluxe King". Price is set per room type.""" @@ -38,6 +40,10 @@ class RoomType(Base): 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"), + ) + class DailyPerformance(Base): """One room type, one night: what we charged and how it sold.""" @@ -56,4 +62,8 @@ class DailyPerformance(Base): room_type: Mapped["RoomType"] = relationship(back_populates="nights") # One row per room type per night. - __table_args__ = (UniqueConstraint("room_type_id", "stay_date"),) + __table_args__ = ( + UniqueConstraint( + "room_type_id", "stay_date", name="uq_daily_performance_room_type_stay" + ), + ) From eb16f0765bfc424ac1c53b60470ca00298ec4367 Mon Sep 17 00:00:00 2001 From: Vichu Selvaraju Date: Tue, 13 Oct 2026 13:41:58 -0400 Subject: [PATCH 2/6] refactor: callers own the transaction, repositories never commit get_session commits once per request on success and rolls back on error. Routes depend on it with scope="function" so the commit runs before the response is sent. The widget repository flushes instead of committing. --- agents/repository.md | 5 ++++- agents/routes.md | 3 +++ backend/foresight/database/session.py | 13 ++++++++++++- backend/foresight/repository/example_widget.py | 5 +++-- backend/foresight/routes/example_widget.py | 7 ++++--- 5 files changed, 26 insertions(+), 7 deletions(-) diff --git a/agents/repository.md b/agents/repository.md index 1613e13..fa0c9aa 100644 --- a/agents/repository.md +++ b/agents/repository.md @@ -14,13 +14,16 @@ returns models/data back up. No business rules, no HTTP. ## 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. - Return ORM models or simple data; let callers map to schemas. ## Rules / invariants - Only this layer imports `session` / ORM query APIs. - No business logic and no HTTP concerns. -- Don't manage transactions ad hoc — use the session provided via `get_session()`. +- No `commit()` or `rollback()` here — that's the caller's job. ## Reference example Copy the pattern in `backend/foresight/repository/example_widget.py` (the diff --git a/agents/routes.md b/agents/routes.md index ad9d342..3e03922 100644 --- a/agents/routes.md +++ b/agents/routes.md @@ -16,6 +16,9 @@ and no database access here. ## Patterns & conventions - One `APIRouter` per module, with `tags=[...]`; include it in `main.py`. - Handlers are `async def`. Type request bodies and responses with schemas. +- 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; translate service results into responses. Keep handlers thin. ## Rules / invariants diff --git a/backend/foresight/database/session.py b/backend/foresight/database/session.py index 5b06eaa..2550053 100644 --- a/backend/foresight/database/session.py +++ b/backend/foresight/database/session.py @@ -17,5 +17,16 @@ async def get_session() -> AsyncGenerator[AsyncSession, None]: + """One transaction per request: commits on success, rolls back on error. + + Repositories never commit. Depend on this with `scope="function"` so the + commit runs before the response is sent and a failed commit returns a 500. + """ async with async_session_factory() as session: - yield session + try: + yield session + except Exception: + await session.rollback() + raise + else: + await session.commit() diff --git a/backend/foresight/repository/example_widget.py b/backend/foresight/repository/example_widget.py index cb8261c..64cf406 100644 --- a/backend/foresight/repository/example_widget.py +++ b/backend/foresight/repository/example_widget.py @@ -6,7 +6,8 @@ Layer rules shown here: - This is the ONLY layer that imports ORM models and uses the session. - Functions are `async` and receive an `AsyncSession` — they never open their - own session or manage the engine. + own session, manage the engine, or commit. The caller owns the transaction; + `flush()` when you need generated ids. - Construct/return ORM models here; callers pass plain values and map to schemas themselves, so services stay persistence-agnostic. - No business logic lives here — just queries and persistence. @@ -29,7 +30,7 @@ async def create_widget( ) -> Widget: widget = Widget(name=name, description=description, quantity=quantity) session.add(widget) - await session.commit() + await session.flush() await session.refresh(widget) return widget diff --git a/backend/foresight/routes/example_widget.py b/backend/foresight/routes/example_widget.py index dcf06ab..1bafa20 100644 --- a/backend/foresight/routes/example_widget.py +++ b/backend/foresight/routes/example_widget.py @@ -21,9 +21,10 @@ router = APIRouter(prefix="/example/widgets", tags=["example"]) -# Reusable session dependency. Using `Annotated` (instead of a `Depends(...)` -# default value) keeps the FastAPI DI pattern lint-clean. -SessionDep = Annotated[AsyncSession, Depends(get_session)] +# 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")] @router.post("", response_model=WidgetRead, status_code=status.HTTP_201_CREATED) From 00c712e08db3c6a14eead9d57ebc6b1d23ba3583 Mon Sep 17 00:00:00 2001 From: Alexcchip Date: Tue, 13 Oct 2026 13:41:59 -0400 Subject: [PATCH 3/6] feat: add repositories for events, weather, flights, competitors and hotels upsert_rows is the shared primitive: it collapses duplicate keys in a batch, touches a row only when a value changed, and returns new/updated/ unchanged counts. The events upsert moves from ingest/ into repository/. Co-authored-by: heatherlarange Co-authored-by: Vichu Selvaraju --- backend/foresight/ingest/run.py | 3 +- backend/foresight/repository/__init__.py | 62 +++++++ .../foresight/repository/competitor_rates.py | 29 ++++ .../repository.py => repository/events.py} | 8 +- backend/foresight/repository/flights.py | 32 ++++ backend/foresight/repository/hotel.py | 90 ++++++++++ backend/foresight/repository/weather.py | 29 ++++ backend/scripts/run_asknews.py | 3 +- backend/tests/conftest.py | 28 ++- backend/tests/test_competitor_rates.py | 110 ++++++++++++ ...epository.py => test_events_repository.py} | 37 ++-- backend/tests/test_flights_repository.py | 65 +++++++ backend/tests/test_hotel_repository.py | 164 ++++++++++++++++++ backend/tests/test_ingest_run.py | 2 +- backend/tests/test_weather_repository.py | 87 ++++++++++ 15 files changed, 711 insertions(+), 38 deletions(-) create mode 100644 backend/foresight/repository/competitor_rates.py rename backend/foresight/{ingest/repository.py => repository/events.py} (98%) create mode 100644 backend/foresight/repository/flights.py create mode 100644 backend/foresight/repository/hotel.py create mode 100644 backend/foresight/repository/weather.py create mode 100644 backend/tests/test_competitor_rates.py rename backend/tests/{test_repository.py => test_events_repository.py} (90%) create mode 100644 backend/tests/test_flights_repository.py create mode 100644 backend/tests/test_hotel_repository.py create mode 100644 backend/tests/test_weather_repository.py diff --git a/backend/foresight/ingest/run.py b/backend/foresight/ingest/run.py index 2fc1186..a407955 100644 --- a/backend/foresight/ingest/run.py +++ b/backend/foresight/ingest/run.py @@ -27,7 +27,8 @@ from foresight.database.session import async_session_factory, engine from foresight.ingest import predicthq from foresight.ingest.normalize import Source, slugify_city -from foresight.ingest.repository import Outcome, upsert_event +from foresight.repository import Outcome +from foresight.repository.events import upsert_event if TYPE_CHECKING: from collections.abc import Sequence diff --git a/backend/foresight/repository/__init__.py b/backend/foresight/repository/__init__.py index b439031..594e78c 100644 --- a/backend/foresight/repository/__init__.py +++ b/backend/foresight/repository/__init__.py @@ -4,3 +4,65 @@ (`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 + + +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 + + 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/competitor_rates.py b/backend/foresight/repository/competitor_rates.py new file mode 100644 index 0000000..fc4b414 --- /dev/null +++ b/backend/foresight/repository/competitor_rates.py @@ -0,0 +1,29 @@ +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/ingest/repository.py b/backend/foresight/repository/events.py similarity index 98% rename from backend/foresight/ingest/repository.py rename to backend/foresight/repository/events.py index a3551a7..e0cc9ff 100644 --- a/backend/foresight/ingest/repository.py +++ b/backend/foresight/repository/events.py @@ -3,7 +3,6 @@ from __future__ import annotations from datetime import UTC, datetime -from enum import StrEnum from typing import TYPE_CHECKING, Any from sqlalchemy import and_, delete, func, literal_column, not_, or_, select, update @@ -11,6 +10,7 @@ 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 @@ -28,12 +28,6 @@ _NEVER = datetime.min.replace(tzinfo=UTC) -class Outcome(StrEnum): - NEW = "new" - UPDATED = "updated" - UNCHANGED = "unchanged" - - async def upsert_event(session: AsyncSession, event: NormalizedEvent) -> Outcome: """Resolve `event` to its canonical row, write it, and record the sighting. diff --git a/backend/foresight/repository/flights.py b/backend/foresight/repository/flights.py new file mode 100644 index 0000000..f2556ec --- /dev/null +++ b/backend/foresight/repository/flights.py @@ -0,0 +1,32 @@ +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 new file mode 100644 index 0000000..2df1aa1 --- /dev/null +++ b/backend/foresight/repository/hotel.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, cast + +from sqlalchemy import func +from sqlalchemy.dialects.postgresql import insert + +from foresight.database.models.hotel import DailyPerformance, Hotel, RoomType +from foresight.repository import Outcome, upsert_rows + +if TYPE_CHECKING: + from collections.abc import Iterable, Mapping + from decimal import Decimal + + from sqlalchemy import Table + from sqlalchemy.ext.asyncio import AsyncSession + +_PERF_KEY = ("room_type_id", "stay_date") +_PERF_MEASURES = ("rate", "rooms_available", "rooms_sold", "room_revenue") + + +async def get_hotel(session: AsyncSession, hotel_id: int) -> Hotel | None: + return await session.get(Hotel, hotel_id) + + +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 upsert_room_type( + session: AsyncSession, + *, + hotel_id: int, + name: str, + room_count: int, + base_rate: Decimal, +) -> int: + stmt = insert(RoomType).values( + 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", + 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": stmt.excluded.base_rate, + }, + ).returning(RoomType.id) + return (await session.execute(stmt)).scalar_one() + + +async def upsert_daily_performance( + session: AsyncSession, rows: Iterable[Mapping[str, Any]] +) -> dict[Outcome, int]: + """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", + key=_PERF_KEY, + mutable=_PERF_MEASURES, + rows=rows, + ) diff --git a/backend/foresight/repository/weather.py b/backend/foresight/repository/weather.py new file mode 100644 index 0000000..bc5ee20 --- /dev/null +++ b/backend/foresight/repository/weather.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, cast + +from foresight.database.models.external import Weather +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 = ("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, + ) diff --git a/backend/scripts/run_asknews.py b/backend/scripts/run_asknews.py index 479db40..8883d9d 100644 --- a/backend/scripts/run_asknews.py +++ b/backend/scripts/run_asknews.py @@ -35,7 +35,8 @@ collect_events, extract_batch, ) -from foresight.ingest.repository import Outcome, upsert_event +from foresight.repository import Outcome +from foresight.repository.events import upsert_event if TYPE_CHECKING: from collections.abc import Sequence diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 38c6ca4..582ca30 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -1,8 +1,34 @@ -"""Shared fixtures: mock hotel data and train/test splits for the ML models.""" +"""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"] diff --git a/backend/tests/test_competitor_rates.py b/backend/tests/test_competitor_rates.py new file mode 100644 index 0000000..e4350b6 --- /dev/null +++ b/backend/tests/test_competitor_rates.py @@ -0,0 +1,110 @@ +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_repository.py b/backend/tests/test_events_repository.py similarity index 90% rename from backend/tests/test_repository.py rename to backend/tests/test_events_repository.py index b3f58e9..1fc2e1f 100644 --- a/backend/tests/test_repository.py +++ b/backend/tests/test_events_repository.py @@ -7,38 +7,21 @@ 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.normalize import EventStatus, NormalizedEvent, Source -from foresight.ingest.repository import Outcome, upsert_event +from foresight.repository import Outcome +from foresight.repository.events import upsert_event if TYPE_CHECKING: - from collections.abc import AsyncIterator - - -@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: - # Counts below assume an empty table; real ingested rows come back on - # rollback. - await session.execute(delete(EventObservation)) - await session.execute(delete(Event)) - yield session - await trans.rollback() - await conn.close() - await engine.dispose() + 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: diff --git a/backend/tests/test_flights_repository.py b/backend/tests/test_flights_repository.py new file mode 100644 index 0000000..b811c36 --- /dev/null +++ b/backend/tests/test_flights_repository.py @@ -0,0 +1,65 @@ +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_hotel_repository.py b/backend/tests/test_hotel_repository.py new file mode 100644 index 0000000..5f04036 --- /dev/null +++ b/backend/tests/test_hotel_repository.py @@ -0,0 +1,164 @@ +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, + 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 diff --git a/backend/tests/test_ingest_run.py b/backend/tests/test_ingest_run.py index 055e898..c36be69 100644 --- a/backend/tests/test_ingest_run.py +++ b/backend/tests/test_ingest_run.py @@ -9,8 +9,8 @@ from foresight.ingest.normalize import NormalizedEvent, Source from foresight.ingest.predicthq import Rejected -from foresight.ingest.repository import Outcome from foresight.ingest.run import Market, RunReport, parse_since +from foresight.repository import Outcome NOW = datetime(2026, 10, 6, 12, 0, tzinfo=UTC) diff --git a/backend/tests/test_weather_repository.py b/backend/tests/test_weather_repository.py new file mode 100644 index 0000000..ff5280e --- /dev/null +++ b/backend/tests/test_weather_repository.py @@ -0,0 +1,87 @@ +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 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 From 66db4df787b6b6c1105cba6ec9b3e5b40a429be4 Mon Sep 17 00:00:00 2001 From: Alexcchip Date: Tue, 13 Oct 2026 13:42:00 -0400 Subject: [PATCH 4/6] feat: ingest weather and flight arrivals Open-Meteo weather moves into ingest/ and fixes dates landing a day early east of Greenwich. Per-flight rows collapse into daily counts. A runner fetches one source per run. The Open-Meteo client is created on first use, and climate projections live in scripts/. Co-authored-by: Vichu Selvaraju --- .gitignore | 3 + backend/foresight/ingest/external.py | 120 ++++++ backend/foresight/ingest/flights.py | 30 ++ backend/foresight/ingest/weather.py | 155 +++++++ backend/pyproject.toml | 3 + backend/scripts/fetch_climate_projections.py | 88 ++++ backend/src/data/weather.py | 210 --------- backend/tests/test_external_runner.py | 112 +++++ backend/tests/test_flights_ingest.py | 48 +++ backend/tests/test_weather_ingest.py | 113 +++++ backend/uv.lock | 426 ++++++++++++++++++- 11 files changed, 1097 insertions(+), 211 deletions(-) create mode 100644 backend/foresight/ingest/external.py create mode 100644 backend/foresight/ingest/flights.py create mode 100644 backend/foresight/ingest/weather.py create mode 100644 backend/scripts/fetch_climate_projections.py delete mode 100644 backend/src/data/weather.py create mode 100644 backend/tests/test_external_runner.py create mode 100644 backend/tests/test_flights_ingest.py create mode 100644 backend/tests/test_weather_ingest.py diff --git a/.gitignore b/.gitignore index 63a6cb2..74b6a35 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,6 @@ backend/data/ # Personal developer journal (private, not for team repo) .journal-entries/ + +# requests-cache store written by the Open-Meteo client +.cache.sqlite diff --git a/backend/foresight/ingest/external.py b/backend/foresight/ingest/external.py new file mode 100644 index 0000000..2032dd9 --- /dev/null +++ b/backend/foresight/ingest/external.py @@ -0,0 +1,120 @@ +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 new file mode 100644 index 0000000..d1205f5 --- /dev/null +++ b/backend/foresight/ingest/flights.py @@ -0,0 +1,30 @@ +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/weather.py b/backend/foresight/ingest/weather.py new file mode 100644 index 0000000..4af6485 --- /dev/null +++ b/backend/foresight/ingest/weather.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +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 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" + +# 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. + """ + response = _get_client().weather_api( + ARCHIVE_URL, + params={ + "latitude": latitude, + "longitude": longitude, + "daily": list(_DAILY), + "timezone": timezone, + "start_date": start.isoformat(), + "end_date": end.isoformat(), + }, + )[0] + return _rows(response, city, is_forecast=False) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 7f9e6cc..6b34fa2 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -20,6 +20,9 @@ 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", ] [dependency-groups] diff --git a/backend/scripts/fetch_climate_projections.py b/backend/scripts/fetch_climate_projections.py new file mode 100644 index 0000000..bd67131 --- /dev/null +++ b/backend/scripts/fetch_climate_projections.py @@ -0,0 +1,88 @@ +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/src/data/weather.py b/backend/src/data/weather.py deleted file mode 100644 index 1fbea51..0000000 --- a/backend/src/data/weather.py +++ /dev/null @@ -1,210 +0,0 @@ -# imports -from pathlib import Path - -import openmeteo_requests -import pandas as pd -import requests_cache -from retry_requests import retry - -DATA_DIR = Path(__file__).resolve().parent - - -# Shared Open-Meteo client setup -cache_session = requests_cache.CachedSession(".cache", expire_after=3600) - -retry_session = retry(cache_session, retries=5, backoff_factor=0.2) - -openmeteo = openmeteo_requests.Client(session=retry_session) - - -def fetch_longterm_weather(location_name, latitude, longitude): - url = "https://climate-api.open-meteo.com/v1/climate" - - params = { - "latitude": latitude, - "longitude": longitude, - "start_date": "2026-01-01", - "end_date": "2030-12-31", - "models": [ - "CMCC_CM2_VHR4", - "FGOALS_f3_H", - "HiRAM_SIT_HR", - "MRI_AGCM3_2_S", - "EC_Earth3P_HR", - "MPI_ESM1_2_XR", - "NICAM16_8S", - ], - "timeformat": "unixtime", - "wind_speed_unit": "mph", - "temperature_unit": "fahrenheit", - "precipitation_unit": "inch", - "daily": [ - "temperature_2m_mean", - "wind_speed_10m_mean", - "cloud_cover_mean", - "precipitation_sum", - ], - } - - print("\n" + "=" * 60) - print(f"LONG-TERM WEATHER: {location_name}") - print(f"Latitude: {latitude}") - print(f"Longitude: {longitude}") - print("=" * 60) - - responses = openmeteo.weather_api(url, params=params) - - all_longterm_data = [] - - for response in responses: - daily = response.Daily() - - daily_temperature_2m_mean = daily.Variables(0).ValuesAsNumpy() - - daily_wind_speed_10m_mean = daily.Variables(1).ValuesAsNumpy() - - daily_cloud_cover_mean = daily.Variables(2).ValuesAsNumpy() - - daily_precipitation_sum = daily.Variables(3).ValuesAsNumpy() - - daily_data = { - "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, - "location": location_name, - "model_number": response.Model(), - "temperature_mean_f": daily_temperature_2m_mean, - "wind_speed_mean_mph": daily_wind_speed_10m_mean, - "cloud_cover_mean_pct": daily_cloud_cover_mean, - "precipitation_sum_in": daily_precipitation_sum, - } - - daily_dataframe = pd.DataFrame(daily_data) - - daily_dataframe = daily_dataframe.round( - { - "temperature_mean_f": 2, - "wind_speed_mean_mph": 2, - "cloud_cover_mean_pct": 2, - "precipitation_sum_in": 3, - } - ) - - print(f"\nModel {response.Model()} sample:") - - print(daily_dataframe.head()) - - print("\nMissing values:") - print(daily_dataframe.isnull().sum()) - - all_longterm_data.append(daily_dataframe) - - combined_longterm = pd.concat(all_longterm_data, ignore_index=True) - - filename = DATA_DIR / f"longterm_weather_{location_name}.csv" - - combined_longterm.to_csv(filename, index=False) - - print(f"\nSaved long-term weather to: {filename}") - - return combined_longterm - - -def fetch_shortterm_weather(location_name, latitude, longitude, timezone): - url = "https://api.open-meteo.com/v1/forecast" - - params = { - "latitude": latitude, - "longitude": longitude, - "daily": [ - "apparent_temperature_max", - "precipitation_sum", - "uv_index_clear_sky_max", - "daylight_duration", - "wind_gusts_10m_max", - ], - "timezone": timezone, - "forecast_days": 16, - } - - print("\n" + "=" * 60) - print(f"SHORT-TERM WEATHER: {location_name}") - print(f"Latitude: {latitude}") - print(f"Longitude: {longitude}") - print("=" * 60) - - responses = openmeteo.weather_api(url, params=params) - - response = responses[0] - - daily = response.Daily() - - daily_apparent_temperature_max = daily.Variables(0).ValuesAsNumpy() - - daily_precipitation_sum = daily.Variables(1).ValuesAsNumpy() - - daily_uv_index_clear_sky_max = daily.Variables(2).ValuesAsNumpy() - - daily_daylight_duration = daily.Variables(3).ValuesAsNumpy() - - daily_wind_gusts_10m_max = daily.Variables(4).ValuesAsNumpy() - - daily_data = { - "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", - ).tz_convert(response.Timezone().decode()), - "location": location_name, - "apparent_temperature_max": daily_apparent_temperature_max, - "precipitation_sum": daily_precipitation_sum, - "uv_index_clear_sky_max": daily_uv_index_clear_sky_max, - "daylight_duration": daily_daylight_duration, - "wind_gusts_10m_max": daily_wind_gusts_10m_max, - } - - daily_dataframe = pd.DataFrame(daily_data) - - daily_dataframe = daily_dataframe.round( - { - "apparent_temperature_max": 2, - "precipitation_sum": 3, - "uv_index_clear_sky_max": 2, - "daylight_duration": 2, - "wind_gusts_10m_max": 2, - } - ) - - # Remove incomplete forecast rows - daily_dataframe = daily_dataframe.dropna().reset_index(drop=True) - - print("\nShort-term weather sample:") - print(daily_dataframe.head()) - - print("\nMissing values:") - print(daily_dataframe.isnull().sum()) - - filename = DATA_DIR / f"shortterm_weather_{location_name}.csv" - - daily_dataframe.to_csv(filename, index=False) - - print(f"\nSaved short-term weather to: {filename}") - - return daily_dataframe - - -if __name__ == "__main__": - test_locations = [ - ("Dublin", 53.331, -6.2489, "Europe/Dublin"), - ("Boston", 42.3601, -71.0589, "America/New_York"), - ("Madrid", 40.4168, -3.7038, "Europe/Madrid"), - ] - - for name, latitude, longitude, timezone in test_locations: - fetch_longterm_weather(name, latitude, longitude) - - fetch_shortterm_weather(name, latitude, longitude, timezone) diff --git a/backend/tests/test_external_runner.py b/backend/tests/test_external_runner.py new file mode 100644 index 0000000..8c402f4 --- /dev/null +++ b/backend/tests/test_external_runner.py @@ -0,0 +1,112 @@ +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_flights_ingest.py b/backend/tests/test_flights_ingest.py new file mode 100644 index 0000000..077392d --- /dev/null +++ b/backend/tests/test_flights_ingest.py @@ -0,0 +1,48 @@ +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_weather_ingest.py b/backend/tests/test_weather_ingest.py new file mode 100644 index 0000000..b46db29 --- /dev/null +++ b/backend/tests/test_weather_ingest.py @@ -0,0 +1,113 @@ +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 + +_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", + } diff --git a/backend/uv.lock b/backend/uv.lock index 0d9d9d7..1ec2b8c 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 5 +revision = 3 requires-python = ">=3.12" resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", @@ -98,6 +98,28 @@ 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" @@ -116,6 +138,137 @@ 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" @@ -266,6 +419,15 @@ 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" @@ -332,10 +494,13 @@ dependencies = [ { name = "asyncpg" }, { name = "fastapi" }, { name = "httpx" }, + { name = "openmeteo-requests" }, { name = "pandas" }, { name = "psycopg2-binary" }, { name = "pydantic-settings" }, { name = "python-dotenv" }, + { name = "requests-cache" }, + { name = "retry-requests" }, { name = "scalar-fastapi" }, { name = "scikit-learn" }, { name = "sqlalchemy", extra = ["asyncio"] }, @@ -364,10 +529,13 @@ 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 = "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 = "scalar-fastapi", specifier = ">=1.4" }, { name = "scikit-learn", specifier = ">=1.9.1" }, { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0" }, @@ -584,6 +752,75 @@ 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" }, ] +[[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" } +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" }, +] + [[package]] name = "joblib" version = "1.6.0" @@ -851,6 +1088,20 @@ 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" @@ -933,6 +1184,31 @@ 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" @@ -1404,6 +1680,110 @@ 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" @@ -1724,6 +2104,41 @@ 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" @@ -1794,6 +2209,15 @@ 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" From 11f28005e11ad524819380de4547eff754e6e921 Mon Sep 17 00:00:00 2001 From: Alexcchip Date: Tue, 13 Oct 2026 13:42:00 -0400 Subject: [PATCH 5/6] feat: load client hotel data through the repository The parser no longer writes SQL. rate is room revenue / rooms sold (the client's ARR) until listed-rate history arrives; unsold nights have no rate. The CLI that opens a session lives in scripts/load_hotel_data.py. Co-authored-by: Vichu Selvaraju --- .../services/parsers/hotel_data_parser.py | 179 ++++++++---------- backend/scripts/load_hotel_data.py | 59 ++++++ backend/tests/test_hotel_parser.py | 136 +++++++++++++ 3 files changed, 276 insertions(+), 98 deletions(-) create mode 100644 backend/scripts/load_hotel_data.py create mode 100644 backend/tests/test_hotel_parser.py diff --git a/backend/foresight/services/parsers/hotel_data_parser.py b/backend/foresight/services/parsers/hotel_data_parser.py index 5790099..1f5117f 100644 --- a/backend/foresight/services/parsers/hotel_data_parser.py +++ b/backend/foresight/services/parsers/hotel_data_parser.py @@ -1,10 +1,18 @@ -import argparse import logging +from decimal import Decimal import pandas as pd -from sqlalchemy import create_engine, text -STAGING_TABLE = "hotel_data_staging" +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" class HotelDataParser: @@ -19,24 +27,28 @@ class HotelDataParser: - Clean and standardize the data - Transform it into Foresight's expected schema - Validate the final output - - Save the processed data to the local database Expected flow: 1. load_data() 2. clean_data() 3. transform_data() 4. validate_data() - 5. save_to_db() + + Persisting is a separate step, so the parser itself never touches a + session. `persist()` writes the result through the repository layer. Main usage: - parser = HotelDataParser(input_path, hotel_name, schema, db_url) + parser = HotelDataParser(input_path, hotel_name, city, base_rate) 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) - db_url: Database connection information (sync driver, e.g. psycopg2) raw_data: Original loaded client data cleaned_data: Cleaned version of the client data transformed_data: Data transformed into Foresight's expected schema @@ -61,8 +73,11 @@ def __init__( self, input_path, hotel_name, + city, + base_rate, + latitude=None, + longitude=None, schema=None, - db_url=None, column_mapping=None, ): """ @@ -71,8 +86,10 @@ def __init__( Args: input_path: Path to the client hotel data file. hotel_name: Name of the hotel this file belongs to. - schema: Expected Foresight schema (required output columns). - db_url: Database connection information (sync driver). + 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. column_mapping: Optional mapping of client column names to Foresight column names. """ @@ -80,14 +97,17 @@ def __init__( # 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.db_url = db_url self.column_mapping = column_mapping or self.DEFAULT_COLUMN_MAPPING # Stores data at different stages of processing - self.raw_data = None - self.cleaned_data = None - self.transformed_data = None + self.raw_data: pd.DataFrame | None = None + self.cleaned_data: pd.DataFrame | None = None + self.transformed_data: pd.DataFrame | None = None # Set up logging for parser runs logging.basicConfig( @@ -287,70 +307,35 @@ def validate_data(self): return True - def save_to_db(self, table_name=STAGING_TABLE): - """ - Save the transformed hotel data to the local database. + def total_rooms(self): + """The hotel's inventory, which the client reports per night as InStock.""" + if self.transformed_data is None: + raise ValueError("Data must be transformed before reading inventory.") - Data is only saved after validation has passed. Existing rows for - this hotel covering the same date range are deleted first, so - re-running the parser on the same file (e.g. from a scheduler retry) - doesn't duplicate rows. + return int(self.transformed_data["rooms_available"].astype(int).max()) - Args: - table_name: Name of the database table where hotel data will be stored. + def rows(self, room_type_id): + """Transformed nights as `daily_performance` rows. - Raises: - ValueError: If transformed data does not exist or no database URL - is provided. - Exception: If the database insert fails. + One client row is one night for the hotel, which is one night for its + single room type. The export has no listed price, so rate is the + average rate paid (room revenue / rooms sold, the client's ARR) until + listed-rate history arrives. Unsold nights have no rate. """ - if self.transformed_data is None: - raise ValueError("Data must be transformed before saving.") - - if self.db_url is None: - raise ValueError("Database URL is required to save data.") - - self.logger.info("Saving hotel data to database...") - - try: - engine = create_engine(self.db_url) - - start_date = self.transformed_data["stay_date"].min() - end_date = self.transformed_data["stay_date"].max() - - with engine.begin() as connection: - table_exists = connection.execute( - text("SELECT to_regclass(:table_name)"), - {"table_name": table_name}, - ).scalar() - - if table_exists: - connection.execute( - text( - f"DELETE FROM {table_name} " - "WHERE hotel_name = :hotel_name " - "AND stay_date BETWEEN :start_date AND :end_date" - ), - { - "hotel_name": self.hotel_name, - "start_date": start_date, - "end_date": end_date, - }, - ) - - self.transformed_data.to_sql( - name=table_name, con=engine, if_exists="append", index=False - ) - - self.logger.info( - f"{len(self.transformed_data)} rows inserted into {table_name}." - ) - - except Exception as e: - # Log the error and allow the pipeline to fail - self.logger.error(f"Failed to save data to database: {e}") - raise + raise ValueError("Data must be transformed before persisting.") + + return [ + { + "room_type_id": room_type_id, + "stay_date": night["stay_date"], + "rate": average_rate(night["room_revenue"], night["rooms_sold"]), + "rooms_available": int(night["rooms_available"]), + "rooms_sold": int(night["rooms_sold"]), + "room_revenue": Decimal(str(night["room_revenue"])), + } + for night in self.transformed_data.to_dict(orient="records") + ] def run(self): """ @@ -361,7 +346,6 @@ def run(self): 2. Clean the data 3. Transform it into Foresight's schema 4. Validate the transformed data - 5. Save the final data to the database Returns: pandas.DataFrame: The final transformed hotel data. @@ -377,7 +361,6 @@ def run(self): self.clean_data() self.transform_data() self.validate_data() - self.save_to_db() self.logger.info("Hotel data parser completed successfully.") @@ -389,28 +372,28 @@ def run(self): raise -def main(): - parser = argparse.ArgumentParser( - description="Parse client hotel data into Foresight's schema." - ) - 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( - "--db-url", - required=True, - help="Sync SQLAlchemy database URL (e.g. postgresql+psycopg2://...).", - ) - args = parser.parse_args() - - hotel_parser = HotelDataParser( - input_path=args.input, - hotel_name=args.hotel_name, - db_url=args.db_url, - ) - hotel_parser.run() +def average_rate(room_revenue, rooms_sold): + """Revenue per room sold, to the cent; None when nothing sold.""" + if not rooms_sold: + return None + return (Decimal(str(room_revenue)) / int(rooms_sold)).quantize(Decimal("0.01")) -if __name__ == "__main__": - main() +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/scripts/load_hotel_data.py b/backend/scripts/load_hotel_data.py new file mode 100644 index 0000000..4525374 --- /dev/null +++ b/backend/scripts/load_hotel_data.py @@ -0,0 +1,59 @@ +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/tests/test_hotel_parser.py b/backend/tests/test_hotel_parser.py new file mode 100644 index 0000000..a845d4f --- /dev/null +++ b/backend/tests/test_hotel_parser.py @@ -0,0 +1,136 @@ +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 From aaa547bf92276c3734b1e0e2aa8e00db132956cf Mon Sep 17 00:00:00 2001 From: Alexcchip Date: Tue, 13 Oct 2026 13:42:01 -0400 Subject: [PATCH 6/6] docs: document ingestion commands and the services layer ingest/ is part of the services layer; only scripts/ open sessions. Co-authored-by: Vichu Selvaraju --- README.md | 1 + agents/services.md | 5 +++ docs/external-signals.md | 71 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 77 insertions(+) create mode 100644 docs/external-signals.md diff --git a/README.md b/README.md index 87308a1..cfcaac6 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,7 @@ foresight/ │ │ ├── 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 diff --git a/agents/services.md b/agents/services.md index 4372351..b76a3c4 100644 --- a/agents/services.md +++ b/agents/services.md @@ -11,6 +11,9 @@ domain rules live — they're called by routes and call the repository. - `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/`. ## Patterns & conventions - Pure-ish domain functions/classes; take plain inputs or schemas, return domain @@ -22,6 +25,8 @@ domain rules live — they're called by routes and call the repository. - 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`. diff --git a/docs/external-signals.md b/docs/external-signals.md new file mode 100644 index 0000000..9881fcf --- /dev/null +++ b/docs/external-signals.md @@ -0,0 +1,71 @@ +# 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.