diff --git a/.github/workflows/pytest_unit.yml b/.github/workflows/pytest_unit.yml index b3f963a1..d0cebdba 100644 --- a/.github/workflows/pytest_unit.yml +++ b/.github/workflows/pytest_unit.yml @@ -13,20 +13,12 @@ jobs: with: python-version: '3.13' - - uses: actions/cache@v5 - id: cache + - uses: astral-sh/setup-uv@v6 with: - path: ~/.cache/pip - key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.*') }} - restore-keys: | - ${{ runner.os }}-pip- + enable-cache: true - name: Install dependencies - run: | - python -m pip install --upgrade pip - python -m venv venv - source ./venv/bin/activate - pip install ".[test]" + run: uv sync --extra test --locked - name: Run unit tests - run: PYTHONPATH=src ./venv/bin/python -m pytest ./tests/unit -v + run: uv run pytest ./tests/unit -v diff --git a/.gitignore b/.gitignore index 8bbedfde..2446bb90 100755 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ wheels .venv .DS_Store +.env diff --git a/pyproject.toml b/pyproject.toml index 21c328be..88fcc07f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,22 +12,23 @@ dependencies = [ "asyncpg==0.31.0", "alembic==1.18.4", "google-api-python-client==2.194.0", - # minor "xmltodict==0.13.0", "httpx==0.28.1", + "pydantic-settings==2.14.1", + "gtfs-realtime-bindings==2.0.0", + "pandas==3.0.3", ] [project.optional-dependencies] dev = [ "ruff==0.15.12", # linting and formatter - "pre-commit" + "pre-commit", ] test = [ "pytest", # test framework "pytest-asyncio", - "httpx", ] [project.urls] diff --git a/src/alembic/env.py b/src/alembic/env.py index 3693ae0e..4d00951c 100644 --- a/src/alembic/env.py +++ b/src/alembic/env.py @@ -13,6 +13,7 @@ import officers.tables import candidates.tables import event.tables +import translink.tables from alembic import context # this is the Alembic Config object, which provides diff --git a/src/alembic/versions/401b254a02a5_merge_events_and_translink_branches.py b/src/alembic/versions/401b254a02a5_merge_events_and_translink_branches.py new file mode 100644 index 00000000..706bce9e --- /dev/null +++ b/src/alembic/versions/401b254a02a5_merge_events_and_translink_branches.py @@ -0,0 +1,26 @@ +"""Merge events and translink branches + +Revision ID: 401b254a02a5 +Revises: 42f855bec532, c1a70c8cfd64 +Create Date: 2026-06-07 12:25:14.294686 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '401b254a02a5' +down_revision: Union[str, None] = ('42f855bec532', 'c1a70c8cfd64') +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass diff --git a/src/alembic/versions/c1a70c8cfd64_create_translink_static_schedule.py b/src/alembic/versions/c1a70c8cfd64_create_translink_static_schedule.py new file mode 100644 index 00000000..1f5ba4b1 --- /dev/null +++ b/src/alembic/versions/c1a70c8cfd64_create_translink_static_schedule.py @@ -0,0 +1,35 @@ +"""Create TransLink static schedule + +Revision ID: c1a70c8cfd64 +Revises: 0a2c458d1ddd +Create Date: 2026-05-17 16:17:26.176355 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = 'c1a70c8cfd64' +down_revision: Union[str, None] = '0a2c458d1ddd' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('translink_static_schedule', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('date_fetched', sa.Date(), nullable=False), + sa.Column('schedule', postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.PrimaryKeyConstraint('id', name=op.f('pk_translink_static_schedule')) + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('translink_static_schedule') + # ### end Alembic commands ### diff --git a/src/alembic/versions/f0c99d0db277_create_translink_realtime_cache.py b/src/alembic/versions/f0c99d0db277_create_translink_realtime_cache.py new file mode 100644 index 00000000..8b2d9af7 --- /dev/null +++ b/src/alembic/versions/f0c99d0db277_create_translink_realtime_cache.py @@ -0,0 +1,36 @@ +"""Create TransLink realtime cache + +Revision ID: f0c99d0db277 +Revises: 401b254a02a5 +Create Date: 2026-06-26 16:54:33.26523 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "f0c99d0db277" +down_revision: str | None = "401b254a02a5" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "translink_realtime_cache", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("fetched_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("response_bytes", sa.LargeBinary(), nullable=False), + sa.PrimaryKeyConstraint("id", name=op.f("pk_translink_realtime_cache")), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("translink_realtime_cache") + # ### end Alembic commands ### diff --git a/src/auth/urls.py b/src/auth/urls.py index 88c82363..cb7b622d 100644 --- a/src/auth/urls.py +++ b/src/auth/urls.py @@ -3,7 +3,6 @@ import os import urllib.parse -import httpx import xmltodict from fastapi import APIRouter, BackgroundTasks, HTTPException, Request, Response from fastapi.responses import JSONResponse, RedirectResponse diff --git a/src/config.py b/src/config.py new file mode 100644 index 00000000..903d81fe --- /dev/null +++ b/src/config.py @@ -0,0 +1,10 @@ +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=".env") + + translink_api_key: str | None = None + + +settings = Settings() diff --git a/src/database.py b/src/database.py index bbf1815f..54ddc356 100644 --- a/src/database.py +++ b/src/database.py @@ -5,8 +5,7 @@ from typing import Annotated, Any import asyncpg -import httpx -from fastapi import Depends, FastAPI +from fastapi import Depends from sqlalchemy import MetaData from sqlalchemy.ext.asyncio import AsyncConnection, AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.orm import DeclarativeBase @@ -100,26 +99,14 @@ async def session(self) -> AsyncGenerator[AsyncSession]: def setup_database(): global sessionmanager + db_url = SQLALCHEMY_TEST_DATABASE_URL if os.environ.get("ENV") == "test" else SQLALCHEMY_DATABASE_URL # TODO: where is sys.stdout piped to? I want all these to go to a specific logs folder sessionmanager = DatabaseSessionManager( - SQLALCHEMY_TEST_DATABASE_URL if os.environ.get("LOCAL") else SQLALCHEMY_DATABASE_URL, + db_url, {"echo": True}, ) -@contextlib.asynccontextmanager -async def lifespan(app: FastAPI): - """ - Handles startup and shutdown events, see https://fastapi.tiangolo.com/advanced/events/ - """ - app.state.http_client = httpx.AsyncClient() - yield - await app.state.http_client.aclose() - if sessionmanager._engine is not None: - # Close the DB connection - await sessionmanager.close() - - async def get_db_session(): async with sessionmanager.session() as session: yield session diff --git a/src/load_test_db.py b/src/load_test_db.py index 09ff0eea..024d1830 100644 --- a/src/load_test_db.py +++ b/src/load_test_db.py @@ -26,6 +26,7 @@ update_officer_term, ) from officers.tables import OfficerInfoDB, OfficerTermDB +from translink.tables import TransLinkStaticScheduleDB async def reset_db(engine): diff --git a/src/main.py b/src/main.py index 60bb5a74..d7b30145 100755 --- a/src/main.py +++ b/src/main.py @@ -1,5 +1,8 @@ +# pyright: reportUnusedImport=false +import contextlib import logging +import httpx from fastapi import FastAPI, Request, status from fastapi.encoders import jsonable_encoder from fastapi.exceptions import RequestValidationError @@ -14,11 +17,26 @@ import nominees.urls import officers.urls import permission.urls +import translink.urls from constants import IS_PROD logging.basicConfig(level=logging.DEBUG) database.setup_database() + +@contextlib.asynccontextmanager +async def lifespan(app: FastAPI): + """ + Handles startup and shutdown events, see https://fastapi.tiangolo.com/advanced/events/ + """ + app.state.http_client = httpx.AsyncClient() + yield + await app.state.http_client.aclose() + if database.sessionmanager._engine is not None: + # Close the DB connection + await database.sessionmanager.close() + + # Enable OpenAPI docs only for local development if not IS_PROD: print("Running local environment") @@ -27,7 +45,7 @@ "http://localhost:8080", # for existing applications/sites ] app = FastAPI( - lifespan=database.lifespan, + lifespan=lifespan, title="CSSS Site Backend", root_path="/api", ) @@ -41,7 +59,7 @@ "https://madness.sfucsss.org", ] app = FastAPI( - lifespan=database.lifespan, + lifespan=lifespan, title="CSSS Site Backend", root_path="/api", docs_url=None, # disables Swagger UI @@ -60,6 +78,7 @@ app.include_router(officers.urls.router) app.include_router(permission.urls.router) app.include_router(event.urls.router) +app.include_router(translink.urls.router) @app.get("/") diff --git a/src/nominees/urls.py b/src/nominees/urls.py index da3bd2db..913a9877 100644 --- a/src/nominees/urls.py +++ b/src/nominees/urls.py @@ -84,7 +84,7 @@ async def get_nominee_info(db_session: database.DBSession, computing_id: str): operation_id="delete_nominee", dependencies=[Depends(perm_election)], ) -async def delete_nominee_info(db_session: database.tDBSession, computing_id: str): +async def delete_nominee_info(db_session: database.DBSession, computing_id: str): try: await nominees.crud.delete_nominee_info(db_session, computing_id) await db_session.commit() diff --git a/src/officers/__init__.py b/src/officers/__init__.py deleted file mode 100755 index e69de29b..00000000 diff --git a/src/translink/README.md b/src/translink/README.md new file mode 100644 index 00000000..43d76eb7 --- /dev/null +++ b/src/translink/README.md @@ -0,0 +1,16 @@ +# TransLink API Documentation +This is supporting documentation for the TransLink API our server uses. +This server only filters for the buses that begin/end at the upper bus loop at SFU Burnaby campus which are: 143, 144, 145, and R5. +All dates are adjusted for the America/Vancouver timezone. + +## Quickstart +1. You need an API key to do anything with realtime data. You can sign up for one [here](https://www.translink.ca/about-us/doing-business-with-translink/app-developer-resources/register +2. Put the API key in `src/.env` with the key `TRANSLINK_API_KEY=` or create an environment variable `export TRANSLINK_API_KEY=`. +3. Make sure your database has the correct migrations `alembic upgrade head`. Reload your test database as well `python src/load_test_db.py` +4. Start (or restart) the web server to test the endpoints + +## Endpoints +You can see the exact schemas in the `/docs` page. At the time this was written there are three endpoints: +1. `translink/realtime`: returns realtime data for buses that are at or are approaching SFU +2. `translink/static`: returns the schedule for the current day +3. `translink/schedule`: combines the realtime and static data to show if a bus is at the loop, is running late, or was cancelled diff --git a/src/translink/crud.py b/src/translink/crud.py new file mode 100644 index 00000000..ee9cce60 --- /dev/null +++ b/src/translink/crud.py @@ -0,0 +1,361 @@ +import io +import logging +import zipfile +from datetime import date, datetime, timedelta +from typing import Any, cast + +import httpx +import pandas as pd +import sqlalchemy +import sqlalchemy.exc +from google.protobuf.message import DecodeError +from google.transit import gtfs_realtime_pb2 +from httpx import AsyncClient + +from config import settings +from constants import TZ_INFO +from database import DBSession +from translink.models import BusStatus, TransLinkRealtimeResponse, TransLinkScheduleResponse +from translink.tables import TransLinkRealtimeCacheDB, TransLinkStaticScheduleDB +from translink.types import FeedMessage + +REALTIME_URL = "https://gtfsapi.translink.ca/v3/gtfsrealtime" +POSITION_URL = "https://gtfsapi.translink.ca/v3/gtfsposition" +STATIC_URL = "https://gtfs-static.translink.ca/gtfs/google_transit.zip" +REALTIME_CACHE_ID = 1 +REALTIME_CACHE_TTL_SECONDS = 90 +REALTIME_CACHE_LOCK_ID = 2026062601 + + +# Taken from the static data. +# Key: Route ID +# 0: Direction ID (always starts from SFU) +# 1: SFU Stop ID +# 2: Route number +BUS_DATA = { + "6656": (0, "2836", "143"), # Burquitlam + "6657": (1, "12972", "144"), # Metrotown + "6658": (1, "1875", "145"), # Production + "37807": (1, "3129", "R5"), # Hastings +} + + +def _gtfs_time_to_seconds(time_str: str) -> int: + """ + Stop times are in HH:MM:SS format as a 24-hour clock, but they sometimes display times beyond 24:00:00, + so everything is converted to be an offset of midnight of the day the ride was scheduled. + """ + h, m, s = map(int, time_str.split(":")) + return h * 3600 + m * 60 + s + + +def _get_active_service_ids(z: zipfile.ZipFile) -> set[str]: + today = datetime.now(tz=TZ_INFO) + # Dates in the calendar.txt are in YYYYMMDD + date_str = today.strftime("%Y%m%d") + day_name = today.strftime("%A").lower() + + calendar = pd.read_csv(z.open("calendar.txt"), dtype=str) + active = set( + calendar[ + (calendar[day_name] == "1") & (calendar["start_date"] <= date_str) & (calendar["end_date"] >= date_str) + ]["service_id"] + ) + + # These are exceptions to services in the calendar + # exception_type=1 means service was added + # exception_type=2 means service was removed + exceptions = pd.read_csv(z.open("calendar_dates.txt"), dtype=str) + added = exceptions[(exceptions["date"] == date_str) & (exceptions["exception_type"] == "1")]["service_id"] + removed = exceptions[(exceptions["date"] == date_str) & (exceptions["exception_type"] == "2")]["service_id"] + active |= set(added) + active -= set(removed) + return active + + +async def fetch_static_schedule(client: AsyncClient) -> pd.DataFrame: + """ + Gets the static bus schedule from the static TransLink GTFS API + """ + # Retrieve the static TransLink bus schedule data + try: + static_response = await client.get(STATIC_URL) + except httpx.HTTPError as e: + raise RuntimeError(f"Failed to fetch static schedule: {e}") from e + + try: + z = zipfile.ZipFile(io.BytesIO(static_response.content)) + except zipfile.BadZipFile as e: + raise RuntimeError(f"Failed to read static schedule zip file: {e}") from e + + # A trip is from one stop to the next one + active_services = _get_active_service_ids(z) + trips = pd.read_csv(z.open("trips.txt"), dtype=str) + # Stop times contain when the bus should depart a bus stop + stop_times = pd.read_csv(z.open("stop_times.txt"), dtype=str) + + # From all the active trips, only get the ones that go to the bus loop + route_ids = set(BUS_DATA.keys()) + filtered_trips = trips[trips["route_id"].isin(list(route_ids)) & trips["service_id"].isin(list(active_services))] + filtered_trips = filtered_trips[ + filtered_trips.apply(lambda row: int(row["direction_id"]) == BUS_DATA[row["route_id"]][0], axis=1) + ] + + # Get the stop times entries for the stops at the bus loop + stop_ids = {s[1] for s in BUS_DATA.values()} + stop_times = stop_times[ + stop_times["trip_id"].isin(list(filtered_trips["trip_id"])) & stop_times["stop_id"].isin(list(stop_ids)) + ] + + # Join the data from the trips and the stops + # Casts are done to avoid some typing issues, but they might be unnecessary + merged = stop_times.merge(cast(pd.DataFrame, filtered_trips[["trip_id", "route_id"]]), on="trip_id") + merged = cast( + pd.DataFrame, merged[merged.apply(lambda row: row["stop_id"] == BUS_DATA[row["route_id"]][1], axis=1)] + ) # filter for the stops we care about + + merged = merged.copy() # stops pandas from complaining about modifying original data + merged["bus_number"] = merged["route_id"].map(lambda r: BUS_DATA[r][2]) + merged["departure_seconds"] = merged["departure_time"].map(_gtfs_time_to_seconds) + return ( + cast(pd.DataFrame, merged[["trip_id", "route_id", "bus_number", "departure_time", "departure_seconds"]]) + .reset_index(drop=True) + .sort_values(by=["route_id", "departure_seconds"]) + ) + + +async def get_or_fetch_static_schedule(db_session: DBSession, client: AsyncClient) -> tuple[date, pd.DataFrame]: + today = datetime.now(tz=TZ_INFO).date() + + try: + result = await db_session.scalar( + sqlalchemy.select(TransLinkStaticScheduleDB).where(TransLinkStaticScheduleDB.date_fetched == today) + ) + except sqlalchemy.exc.SQLAlchemyError as e: + logging.error(f"Failed to query static schedule from database: {e}") + result = None + + if result is not None: + return (result.date_fetched, pd.DataFrame(result.schedule)) + + result = await fetch_static_schedule(client) + if result.empty: + raise ValueError("No active schedule found for today") + + try: + await db_session.merge( + TransLinkStaticScheduleDB(id=1, date_fetched=today, schedule=result.to_dict(orient="records")) + ) + await db_session.commit() + except sqlalchemy.exc.SQLAlchemyError as e: + logging.warning(f"Failed to cache static schedule to database: {e}") + await db_session.rollback() + + return (today, result) + + +def get_next_departures(schedule: pd.DataFrame, n: int = 3) -> pd.DataFrame: + """ + Get the next few departures for today. + + Args: + schedule: static schedule filtered out for the relevant routes + n: the number of departures to get for each route + + Returns: + A dataframe with the next n departures for each route, sorted by route ID and departure time (in seconds). + """ + now = datetime.now(tz=TZ_INFO) + current_seconds = int((now - now.replace(hour=0, minute=0, second=0, microsecond=0)).total_seconds()) + upcoming = cast(pd.DataFrame, schedule[schedule["departure_seconds"] > current_seconds]) + return ( + upcoming.sort_values("departure_seconds") + .groupby("route_id") + .head(n) + .sort_values(["route_id", "departure_seconds"]) + ) + + +def _parse_feed(content: bytes) -> FeedMessage: + feed = cast(FeedMessage, gtfs_realtime_pb2.FeedMessage()) # pyright: ignore[reportAttributeAccessIssue] + feed.ParseFromString(content) + return feed + + +def _parse_cached_feed(cached_feed: TransLinkRealtimeCacheDB) -> FeedMessage | None: + try: + return _parse_feed(cached_feed.response_bytes) + except DecodeError as e: + logging.error(f"Failed to parse cached TransLink realtime feed: {e}") + return None + + +def _scheduled_timestamp(departure_seconds: int) -> int: + now = datetime.now(tz=TZ_INFO) + midnight = now.replace(hour=0, minute=0, second=0, microsecond=0) + return int((midnight + timedelta(seconds=departure_seconds)).timestamp()) + + +def _is_realtime_cache_fresh(cached_feed: TransLinkRealtimeCacheDB) -> bool: + fetched_at = cached_feed.fetched_at + if fetched_at.tzinfo is None: + fetched_at = fetched_at.replace(tzinfo=TZ_INFO) + + return datetime.now(tz=TZ_INFO) - fetched_at < timedelta(seconds=REALTIME_CACHE_TTL_SECONDS) + + +async def fetch_feed(client: AsyncClient, url: str, params: dict[str, Any]) -> FeedMessage | None: + try: + response = await client.get(url, params=params) + response.raise_for_status() + return _parse_feed(response.content) + except (httpx.HTTPError, DecodeError) as e: + logging.error(f"Failed to fetch feed from {url}: {e}") + return None + + +async def get_or_fetch_realtime_feed(db_session: DBSession, client: AsyncClient) -> FeedMessage | None: + cached_feed: TransLinkRealtimeCacheDB | None = None + + try: + cached_feed = await db_session.scalar( + sqlalchemy.select(TransLinkRealtimeCacheDB).where(TransLinkRealtimeCacheDB.id == REALTIME_CACHE_ID) + ) + if cached_feed is not None and _is_realtime_cache_fresh(cached_feed): + return _parse_cached_feed(cached_feed) + + # Transaction lock, released on commit or rollback. + # This prevents multiple requests from fetching the feed at the same time. + await db_session.execute( + sqlalchemy.text("SELECT pg_advisory_xact_lock(:lock_id)"), {"lock_id": REALTIME_CACHE_LOCK_ID} + ) + cached_feed = await db_session.scalar( + sqlalchemy.select(TransLinkRealtimeCacheDB).where(TransLinkRealtimeCacheDB.id == REALTIME_CACHE_ID) + ) + + if cached_feed is not None and _is_realtime_cache_fresh(cached_feed): + await db_session.commit() + return _parse_cached_feed(cached_feed) + + response = await client.get(REALTIME_URL, params={"apikey": settings.translink_api_key}) + response.raise_for_status() + feed = _parse_feed(response.content) + await db_session.merge( + TransLinkRealtimeCacheDB( + id=REALTIME_CACHE_ID, + fetched_at=datetime.now(tz=TZ_INFO), + response_bytes=response.content, + ) + ) + await db_session.commit() + return feed + except (httpx.HTTPError, DecodeError) as e: + logging.error(f"Failed to fetch realtime feed from {REALTIME_URL}: {e}") + await db_session.rollback() + if cached_feed is not None: + return _parse_cached_feed(cached_feed) + return None + except sqlalchemy.exc.SQLAlchemyError as e: + logging.error(f"Failed to use TransLink realtime cache: {e}") + await db_session.rollback() + return None + + +async def fetch_realtime_schedule(db_session: DBSession, client: AsyncClient) -> list[TransLinkRealtimeResponse]: + # FeedMessage is generated at runtime, so the type checker can't find this function + trip_feed = await get_or_fetch_realtime_feed(db_session, client) + + if trip_feed is None: + return [] + + result: list[TransLinkRealtimeResponse] = [] + for entity in trip_feed.entity: + if not entity.HasField("trip_update"): + continue + + tu = entity.trip_update + trip = tu.trip + bus_data = BUS_DATA.get(trip.route_id) + + if bus_data is None or trip.direction_id != bus_data[0]: + continue + + _, stop_id, bus_number = bus_data + stop = next((s for s in tu.stop_time_update if s.stop_id == stop_id), None) + if stop is None: + continue + + result.append( + TransLinkRealtimeResponse( + route_number=bus_number, + scheduled_departure_time=stop.departure.time - stop.departure.delay, + realtime_time=stop.departure.time, + delay_seconds=stop.departure.delay, + ) + ) + + result.sort(key=lambda e: e.realtime_time) + return result + + +async def get_departure_statuses(db_session: DBSession, client: AsyncClient) -> list[TransLinkScheduleResponse]: + """ + Gets the real-time bus schedule from the TransLink GTFS Realtime API and merge it with the static data. + """ + + def _response_from_static_row(row: Any, delay: int = 0, status: BusStatus = BusStatus.OnTime): + scheduled_time = _scheduled_timestamp(cast(int, row["departure_seconds"])) + return TransLinkScheduleResponse( + route_number=cast(str, row["bus_number"]), + scheduled_departure_time=scheduled_time, + realtime_time=scheduled_time + delay, + delay_seconds=delay, + status=status, + ) + + _, schedule = await get_or_fetch_static_schedule(db_session, client) + next_departures = get_next_departures(schedule) + trip_feed = await get_or_fetch_realtime_feed(db_session, client) + # If the trip feed fails to fetch then just return information from the static schedule. + if trip_feed is None: + return [_response_from_static_row(row) for _, row in next_departures.iterrows()] + # FeedMessage is generated at runtime, so the type checker can't find this function + + # Map all the realtime data to each bus's status + realtime_map: dict[str, tuple[int, BusStatus]] = {} + for entity in trip_feed.entity: + if not entity.HasField("trip_update"): + continue + + trip_update = entity.trip_update + trip = trip_update.trip + bus_data = BUS_DATA.get(trip.route_id) + if bus_data is None or trip.direction_id != bus_data[0]: + continue + + if trip.schedule_relationship == gtfs_realtime_pb2.TripDescriptor.CANCELED: # pyright: ignore[reportAttributeAccessIssue] + realtime_map[trip.trip_id] = (0, BusStatus.Cancelled) + continue + + _, stop_id, _ = bus_data + stop = next((s for s in trip_update.stop_time_update if s.stop_id == stop_id), None) + if stop is None: + continue + + first_stop = min(trip_update.stop_time_update, key=lambda s: s.stop_sequence) + if first_stop.stop_id == stop_id: + status = BusStatus.Arrived + elif stop.departure.delay > 0: + status = BusStatus.Delayed + else: + status = BusStatus.OnTime + + realtime_map[trip.trip_id] = (stop.departure.delay, status) + + return [ + _response_from_static_row( + row, + *realtime_map.get(cast(str, row["trip_id"]), (0, BusStatus.OnTime)), + ) + for _, row in next_departures.iterrows() + ] diff --git a/src/translink/models.py b/src/translink/models.py new file mode 100644 index 00000000..227468e6 --- /dev/null +++ b/src/translink/models.py @@ -0,0 +1,48 @@ +from datetime import date +from enum import Enum + +from pydantic import BaseModel, Field + + +class BusStatus(Enum): + Arrived = 1 + Delayed = 2 + OnTime = 3 + Cancelled = 4 + + +class TransLinkStaticScheduleEntry(BaseModel): + trip_id: str = Field(..., description="The GTFS Trip ID for this bus.") + route_id: str = Field(..., description="The GTFS Route ID for this bus.") + bus_number: str = Field(..., description="The bus route number.") + departure_seconds: int = Field(..., description="The number of seconds after midnight for this departure.") + departure_time: str = Field(..., description="Time that the bus is departing as a HH:MM:SS string.") + + +class TransLinkStaticResponse(BaseModel): + date_fetched: date = Field( + ..., + description="The date derived from the app's TZ_INFO (most likely America/Vancouver)", + ) + schedule: list[TransLinkStaticScheduleEntry] = Field( + ..., description="The static departure schedule for the buses at the upper bus loop." + ) + + +class TransLinkRealtimeResponse(BaseModel): + route_number: str = Field(..., description="The bus route number.") + scheduled_departure_time: int = Field( + ..., description="Unix timestamp for the scheduled departure time, in seconds." + ) + realtime_time: int = Field(..., description="Unix timestamp for the buses actual arrival time, in seconds.") + delay_seconds: int = Field( + ..., + description="How delayed the bus is, in seconds. Positive numbers indicate the bus is late, negative is early.", + ) + + +class TransLinkScheduleResponse(TransLinkRealtimeResponse): + status: BusStatus = Field( + ..., + description="Enum that indicates if the bus has arrived (1), is delayed (2), is on time (3), or cancelled (4).", + ) diff --git a/src/translink/tables.py b/src/translink/tables.py new file mode 100644 index 00000000..b00b777b --- /dev/null +++ b/src/translink/tables.py @@ -0,0 +1,25 @@ +from datetime import date, datetime + +from sqlalchemy import DateTime, LargeBinary +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column + +from database import Base + + +class TransLinkStaticScheduleDB(Base): + __tablename__ = "translink_static_schedule" + + id: Mapped[int] = mapped_column(primary_key=True) + + date_fetched: Mapped[date] = mapped_column() + schedule: Mapped[list[dict]] = mapped_column(JSONB) + + +class TransLinkRealtimeCacheDB(Base): + __tablename__ = "translink_realtime_cache" + + id: Mapped[int] = mapped_column(primary_key=True) + + fetched_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) + response_bytes: Mapped[bytes] = mapped_column(LargeBinary) diff --git a/src/translink/types.py b/src/translink/types.py new file mode 100644 index 00000000..4e54e87f --- /dev/null +++ b/src/translink/types.py @@ -0,0 +1,47 @@ +# ruff: noqa: N802 +from collections.abc import Iterator +from typing import Protocol + +from google.transit import gtfs_realtime_pb2 + + +class Trip(Protocol): + trip_id: str + route_id: str + direction_id: int + schedule_relationship: gtfs_realtime_pb2.TripDescriptor # pyright: ignore[reportAttributeAccessIssue] + + +class StopTimeUpdate(Protocol): + stop_sequence: int + stop_id: str + + class _Time(Protocol): + time: int + delay: int + + arrival: _Time + departure: _Time + + +class TripUpdate(Protocol): + class _Vehicle(Protocol): + id: str + + trip: Trip + vehicle: _Vehicle + stop_time_update: list[StopTimeUpdate] + + def HasField(self, name: str) -> bool: ... + + +class FeedEntity(Protocol): + trip_update: TripUpdate + + def HasField(self, name: str) -> bool: ... + + +class FeedMessage(Protocol): + entity: Iterator[FeedEntity] + + def ParseFromString(self, data: bytes) -> int: ... diff --git a/src/translink/urls.py b/src/translink/urls.py new file mode 100644 index 00000000..2fa2d566 --- /dev/null +++ b/src/translink/urls.py @@ -0,0 +1,55 @@ +from fastapi import APIRouter, Request + +from database import DBSession +from translink.crud import ( + fetch_realtime_schedule, + get_departure_statuses, + get_or_fetch_static_schedule, +) +from translink.models import ( + TransLinkRealtimeResponse, + TransLinkScheduleResponse, + TransLinkStaticResponse, + TransLinkStaticScheduleEntry, +) + +router = APIRouter( + prefix="/translink", + tags=["translink"], +) + + +@router.get( + "/realtime", + description="Get the realtime TransLink bus status.", + response_description="Realtime information for bus status", + response_model=list[TransLinkRealtimeResponse], + operation_id="get_realtime_schedule", +) +async def get_realtime_schedule(db_session: DBSession, request: Request): + return await fetch_realtime_schedule(db_session, request.app.state.http_client) + + +@router.get( + "/static", + description="Get the static TransLink departure schedule.", + response_description="The static departure schedule for the buses at the upper bus loop.", + response_model=TransLinkStaticResponse, + operation_id="get_static_schedule", +) +async def get_static_schedule(db_session: DBSession, request: Request): + date_fetched, df = await get_or_fetch_static_schedule(db_session, request.app.state.http_client) + schedule = [TransLinkStaticScheduleEntry(**row) for row in df.to_dict(orient="records")] + + return TransLinkStaticResponse(date_fetched=date_fetched, schedule=schedule) + + +@router.get( + "/schedule", + description="Get the departure schedule with bus status. Attempts to use the cached static schedule first.", + response_description="The next three depature times with bus status information.", + response_model=list[TransLinkScheduleResponse], + operation_id="get_departure_schedule", +) +async def get_departure_schedule(db_session: DBSession, request: Request): + return await get_departure_statuses(db_session, request.app.state.http_client) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..a6aa0f16 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,10 @@ +import logging +import os + +os.environ["ENV"] = "test" + + +def pytest_configure(config): + loggers = ["sqlalchemy.engine.Engine", "httpx"] + for logger in loggers: + logging.getLogger(logger).setLevel(logging.WARNING) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 0d6d7555..aa648dbd 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -1,28 +1,18 @@ # Configuration of Pytest -import logging from collections.abc import AsyncGenerator from typing import Any -import pytest import pytest_asyncio from httpx import ASGITransport, AsyncClient from auth.crud import create_user_session, remove_user_session -from database import SQLALCHEMY_TEST_DATABASE_URL, DatabaseSessionManager +from database import SQLALCHEMY_TEST_DATABASE_URL, DatabaseSessionManager, get_db_session from load_test_db import SYSADMIN_COMPUTING_ID, async_main from main import app -# This might be able to be moved to `package` scope as long as I inject it to every test function -@pytest.fixture(scope="session") -def suppress_sqlalchemy_logs(): - logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING) - yield - logging.getLogger("sqlalchemy.engine").setLevel(logging.INFO) - - @pytest_asyncio.fixture(scope="module", loop_scope="session") -async def database_setup(): +async def test_database(): # reset the database again, just in case print("Resetting DB...") sessionmanager = DatabaseSessionManager(SQLALCHEMY_TEST_DATABASE_URL, {"echo": False}, check_db=False) @@ -34,25 +24,32 @@ async def database_setup(): @pytest_asyncio.fixture(scope="function", loop_scope="session") -async def db_session(database_setup: DatabaseSessionManager): - async with database_setup.session() as session: +async def db_session(test_database: DatabaseSessionManager): + async with test_database.session() as session: yield session @pytest_asyncio.fixture(scope="module", loop_scope="session") -async def client() -> AsyncGenerator[Any]: +async def client(test_database: DatabaseSessionManager) -> AsyncGenerator[Any]: + async def override_get_db_session(): + async with test_database.session() as session: + yield session + + app.dependency_overrides[get_db_session] = override_get_db_session # base_url is just a random placeholder url # ASGITransport is just telling the async client to pass all requests to app # `async with` syntax used so that the connecton will automatically be closed once done async with AsyncClient(transport=ASGITransport(app), base_url="http://test") as client: yield client + app.dependency_overrides.clear() + @pytest_asyncio.fixture(scope="module", loop_scope="session") -async def admin_client(database_setup: DatabaseSessionManager, client: AsyncClient): +async def admin_client(test_database: DatabaseSessionManager, client: AsyncClient): session_id = "temp_id_" + SYSADMIN_COMPUTING_ID client.cookies = {"session_id": session_id} - async with database_setup.session() as session: + async with test_database.session() as session: await create_user_session(session, session_id, SYSADMIN_COMPUTING_ID) yield client await remove_user_session(session, session_id) diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py new file mode 100644 index 00000000..b73b7770 --- /dev/null +++ b/tests/unit/conftest.py @@ -0,0 +1,24 @@ +from collections.abc import AsyncGenerator +from typing import Any +from unittest.mock import AsyncMock + +import pytest_asyncio +from fastapi import FastAPI +from httpx import ASGITransport, AsyncClient + +from database import get_db_session +from translink.urls import router as translink_router + + +@pytest_asyncio.fixture(scope="module", loop_scope="session") +async def client() -> AsyncGenerator[Any]: + app = FastAPI() + app.include_router(translink_router) + app.state.http_client = AsyncMock(spec=AsyncClient) + + async def override_get_db_session(): + yield AsyncMock() + + app.dependency_overrides[get_db_session] = override_get_db_session + async with AsyncClient(transport=ASGITransport(app), base_url="http://test") as client: + yield client diff --git a/tests/unit/test_translink.py b/tests/unit/test_translink.py new file mode 100644 index 00000000..1cf9b13d --- /dev/null +++ b/tests/unit/test_translink.py @@ -0,0 +1,652 @@ +import io +import zipfile +from datetime import date, datetime, timedelta +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import pandas as pd +import pytest +from fastapi import status +from google.transit import gtfs_realtime_pb2 +from httpx import AsyncClient, Request, Response + +from constants import TZ_INFO +from translink.crud import ( + BUS_DATA, + _gtfs_time_to_seconds, + fetch_realtime_schedule, + fetch_static_schedule, + get_departure_statuses, + get_next_departures, + get_or_fetch_realtime_feed, + get_or_fetch_static_schedule, +) +from translink.models import BusStatus, TransLinkRealtimeResponse, TransLinkScheduleResponse +from translink.tables import TransLinkRealtimeCacheDB, TransLinkStaticScheduleDB + +pytestmark = pytest.mark.asyncio(loop_scope="session") + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _current_day_name() -> str: + return datetime.now(tz=TZ_INFO).strftime("%A").lower() + + +def make_gtfs_zip(departure_time: str = "23:00:00") -> bytes: + """ + Return a minimal but valid GTFS zip whose single service is active today, + with one trip per route in BUS_DATA. + + `departure_time` is used for all stop_times rows — set it in the future + (the default "23:00:00" works for most of the day) so get_next_departures + includes them. + """ + buf = io.BytesIO() + day = _current_day_name() + all_days = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"] + + with zipfile.ZipFile(buf, "w") as z: + # calendar.txt - one service active only on today's weekday + cal_row = {d: ("1" if d == day else "0") for d in all_days} + cal_row.update({"service_id": "SVC1", "start_date": "20240101", "end_date": "20991231"}) + z.writestr("calendar.txt", pd.DataFrame([cal_row]).to_csv(index=False)) + + # calendar_dates.txt - no exceptions + z.writestr("calendar_dates.txt", "date,service_id,exception_type\n") + + # trips.txt - one trip per (route_id, direction_id) pair in BUS_DATA + trips_rows = [ + {"trip_id": f"trip_{num}", "route_id": rid, "service_id": "SVC1", "direction_id": str(did)} + for rid, (did, _sid, num) in BUS_DATA.items() + ] + z.writestr("trips.txt", pd.DataFrame(trips_rows).to_csv(index=False)) + + # stop_times.txt - one stop per trip at the correct SFU bus loop stop + stop_rows = [ + {"trip_id": f"trip_{num}", "stop_id": sid, "departure_time": departure_time} + for _rid, (_, sid, num) in BUS_DATA.items() + ] + z.writestr("stop_times.txt", pd.DataFrame(stop_rows).to_csv(index=False)) + + return buf.getvalue() + + +def make_feed_bytes( + trip_id: str, + route_id: str, + direction_id: int, + stop_id: str, + departure_unix: int, + delay: int = 0, + cancelled: bool = False, +) -> bytes: + """Return a serialised GTFS-RT FeedMessage with a single trip-update entity.""" + feed = gtfs_realtime_pb2.FeedMessage() # pyright: ignore[reportAttributeAccessIssue] + feed.header.gtfs_realtime_version = "2.0" + feed.header.timestamp = departure_unix + + entity = feed.entity.add() + entity.id = "e1" + tu = entity.trip_update + tu.trip.trip_id = trip_id + tu.trip.route_id = route_id + tu.trip.direction_id = direction_id + + if cancelled: + tu.trip.schedule_relationship = gtfs_realtime_pb2.TripDescriptor.CANCELED # pyright: ignore[reportAttributeAccessIssue] + else: + stu = tu.stop_time_update.add() + stu.stop_sequence = 1 + stu.stop_id = stop_id + stu.departure.time = departure_unix + stu.departure.delay = delay + + return feed.SerializeToString() + + +def make_empty_feed_bytes() -> bytes: + feed = gtfs_realtime_pb2.FeedMessage() # pyright: ignore[reportAttributeAccessIssue] + feed.header.gtfs_realtime_version = "2.0" + return feed.SerializeToString() + + +def mock_http_client(content: bytes) -> AsyncMock: + """Return an AsyncMock httpx client whose .get() always returns `content`.""" + resp = MagicMock(spec=Response) + resp.content = content + client = AsyncMock(spec=AsyncClient) + client.get = AsyncMock(return_value=resp) + return client + + +def mock_db_session(cached_row=None) -> AsyncMock: + """ + Return an AsyncMock DB session. + `cached_row` is what scalar() will return — pass None to simulate a cache miss. + """ + session = AsyncMock() + session.scalar = AsyncMock(return_value=cached_row) + session.merge = AsyncMock() + session.commit = AsyncMock() + session.rollback = AsyncMock() + return session + + +# --------------------------------------------------------------------------- +# Unit tests — pure functions +# --------------------------------------------------------------------------- + + +async def test__gtfs_time_to_seconds_normal_times(): + assert _gtfs_time_to_seconds("00:00:00") == 0 + assert _gtfs_time_to_seconds("01:00:00") == 3600 + assert _gtfs_time_to_seconds("00:01:00") == 60 + assert _gtfs_time_to_seconds("00:00:01") == 1 + assert _gtfs_time_to_seconds("12:34:56") == 12 * 3600 + 34 * 60 + 56 + + +async def test__gtfs_time_to_seconds_past_midnight(): + # GTFS allows times > 24:00 for trips that started the previous service day + assert _gtfs_time_to_seconds("25:00:00") == 25 * 3600 + assert _gtfs_time_to_seconds("26:30:45") == 26 * 3600 + 30 * 60 + 45 + + +async def test__get_next_departures_filters_past(): + now = datetime.now(tz=TZ_INFO) + midnight = now.replace(hour=0, minute=0, second=0, microsecond=0) + now_secs = int((now - midnight).total_seconds()) + + schedule = pd.DataFrame( + [ + # Already departed - must be excluded + { + "trip_id": "past_trip", + "route_id": "6656", + "bus_number": "143", + "departure_time": "00:01:00", + "departure_seconds": 60, + }, + # Future - must be included + { + "trip_id": "future_trip", + "route_id": "6656", + "bus_number": "143", + "departure_time": "23:00:00", + "departure_seconds": now_secs + 3600, + }, + ] + ) + + result = get_next_departures(schedule, n=3) + assert len(result) == 1 + assert result.iloc[0]["trip_id"] == "future_trip" + + +async def test__get_next_departures_respects_n(): + now = datetime.now(tz=TZ_INFO) + midnight = now.replace(hour=0, minute=0, second=0, microsecond=0) + now_secs = int((now - midnight).total_seconds()) + + # Five future trips on the same route - n=2 should limit to 2 + schedule = pd.DataFrame( + [ + { + "trip_id": f"trip_{i}", + "route_id": "6656", + "bus_number": "143", + "departure_time": "23:00:00", + "departure_seconds": now_secs + i * 600, + } + for i in range(1, 6) + ] + ) + + assert len(get_next_departures(schedule, n=2)) == 2 + assert len(get_next_departures(schedule, n=1)) == 1 + + +async def test__get_next_departures_multiple_routes(): + now = datetime.now(tz=TZ_INFO) + midnight = now.replace(hour=0, minute=0, second=0, microsecond=0) + now_secs = int((now - midnight).total_seconds()) + + schedule = pd.DataFrame( + [ + { + "trip_id": f"trip_{rid}_{i}", + "route_id": rid, + "bus_number": num, + "departure_time": "23:00:00", + "departure_seconds": now_secs + i * 600, + } + for rid, (_, _, num) in BUS_DATA.items() + for i in range(1, 4) + ] + ) + + result = get_next_departures(schedule, n=2) + assert len(result) == 8 + assert set(result["route_id"]) == set(BUS_DATA.keys()) + + +# --------------------------------------------------------------------------- +# Tests for fetch_static_schedule +# --------------------------------------------------------------------------- + + +async def test__fetch_static_schedule_returns_all_routes(): + client = mock_http_client(make_gtfs_zip()) + df = await fetch_static_schedule(client) + + assert not df.empty + expected_cols = {"trip_id", "route_id", "bus_number", "departure_time", "departure_seconds"} + assert expected_cols.issubset(df.columns) + assert set(df["bus_number"]) == {num for _, (_, _, num) in BUS_DATA.items()} + + +async def test__fetch_static_schedule_excludes_wrong_direction(): + """Trips are direction-filtered; a wrong-direction trip should not appear.""" + buf = io.BytesIO() + day = _current_day_name() + all_days = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"] + + with zipfile.ZipFile(buf, "w") as z: + cal_row = {d: ("1" if d == day else "0") for d in all_days} + cal_row.update({"service_id": "SVC1", "start_date": "20240101", "end_date": "20991231"}) + z.writestr("calendar.txt", pd.DataFrame([cal_row]).to_csv(index=False)) + z.writestr("calendar_dates.txt", "date,service_id,exception_type\n") + + # Route 6656 expects direction_id=0; give it direction_id=1 + trips_df = pd.DataFrame( + [ + {"trip_id": "wrong_dir", "route_id": "6656", "service_id": "SVC1", "direction_id": "1"}, + ] + ) + z.writestr("trips.txt", trips_df.to_csv(index=False)) + z.writestr( + "stop_times.txt", + pd.DataFrame([{"trip_id": "wrong_dir", "stop_id": "2836", "departure_time": "23:00:00"}]).to_csv( + index=False + ), + ) + + client = mock_http_client(buf.getvalue()) + df = await fetch_static_schedule(client) + assert df.empty + + +async def test__fetch_static_schedule_raises_on_http_error(): + import httpx + + client = AsyncMock(spec=AsyncClient) + client.get = AsyncMock(side_effect=httpx.HTTPError("connection refused")) + + with pytest.raises(RuntimeError, match="Failed to fetch static schedule"): + await fetch_static_schedule(client) + + +async def test__fetch_static_schedule_raises_on_bad_zip(): + client = mock_http_client(b"this is not a zip") + + with pytest.raises(RuntimeError, match="Failed to read static schedule zip file"): + await fetch_static_schedule(client) + + +# --------------------------------------------------------------------------- +# Tests for fetch_realtime_schedule +# --------------------------------------------------------------------------- + + +async def test__fetch_realtime_schedule_parses_single_entity(): + departure_unix = 1_700_000_000 + # Route 6656: direction=0, stop="2836", bus="143" + feed_bytes = make_feed_bytes( + trip_id="trip_143", + route_id="6656", + direction_id=0, + stop_id="2836", + departure_unix=departure_unix, + delay=120, + ) + results = await fetch_realtime_schedule(mock_db_session(), mock_http_client(feed_bytes)) + + assert len(results) == 1 + r = results[0] + assert r.route_number == "143" + assert r.delay_seconds == 120 + assert r.realtime_time == departure_unix + assert r.scheduled_departure_time == departure_unix - 120 + + +async def test__fetch_realtime_schedule_ignores_wrong_direction(): + # Route 6656 expects direction_id=0; providing 1 should be dropped + feed_bytes = make_feed_bytes( + trip_id="trip_143", + route_id="6656", + direction_id=1, + stop_id="2836", + departure_unix=1_700_000_000, + ) + results = await fetch_realtime_schedule(mock_db_session(), mock_http_client(feed_bytes)) + assert results == [] + + +async def test__fetch_realtime_schedule_ignores_unknown_route(): + feed_bytes = make_feed_bytes( + trip_id="trip_999", + route_id="9999", + direction_id=0, + stop_id="9999", + departure_unix=1_700_000_000, + ) + results = await fetch_realtime_schedule(mock_db_session(), mock_http_client(feed_bytes)) + assert results == [] + + +async def test__fetch_realtime_schedule_ignores_missing_stop(): + """An entity where the SFU stop doesn't appear in stop_time_update is skipped.""" + feed = gtfs_realtime_pb2.FeedMessage() # pyright: ignore[reportAttributeAccessIssue] + feed.header.gtfs_realtime_version = "2.0" + entity = feed.entity.add() + entity.id = "e1" + tu = entity.trip_update + tu.trip.trip_id = "trip_143" + tu.trip.route_id = "6656" + tu.trip.direction_id = 0 + # Add a stop_time_update for a different stop (not "2836") + stu = tu.stop_time_update.add() + stu.stop_id = "0000" + stu.departure.time = 1_700_000_000 + + results = await fetch_realtime_schedule(mock_db_session(), mock_http_client(feed.SerializeToString())) + assert results == [] + + +async def test__fetch_realtime_schedule_empty_feed(): + results = await fetch_realtime_schedule(mock_db_session(), mock_http_client(make_empty_feed_bytes())) + assert results == [] + + +async def test__fetch_realtime_schedule_sorted_by_time(): + """Results should be sorted by realtime departure time ascending.""" + feed = gtfs_realtime_pb2.FeedMessage() # pyright: ignore[reportAttributeAccessIssue] + feed.header.gtfs_realtime_version = "2.0" + + # Two routes with out-of-order times + entries = [ + ("trip_144", "6657", 1, "12972", 1_700_000_200), + ("trip_143", "6656", 0, "2836", 1_700_000_100), + ] + for i, (tid, rid, did, sid, t) in enumerate(entries): + e = feed.entity.add() + e.id = str(i) + tu = e.trip_update + tu.trip.trip_id = tid + tu.trip.route_id = rid + tu.trip.direction_id = did + stu = tu.stop_time_update.add() + stu.stop_id = sid + stu.departure.time = t + + results = await fetch_realtime_schedule(mock_db_session(), mock_http_client(feed.SerializeToString())) + assert len(results) == 2 + assert results[0].realtime_time < results[1].realtime_time + + +async def test__get_or_fetch_realtime_feed_uses_fresh_cache(): + feed_bytes = make_empty_feed_bytes() + cached_row = TransLinkRealtimeCacheDB( + id=1, + fetched_at=datetime.now(tz=TZ_INFO), + response_bytes=feed_bytes, + ) + session = mock_db_session(cached_row=cached_row) + client = AsyncMock(spec=AsyncClient) + + result = await get_or_fetch_realtime_feed(session, client) + + assert result is not None + assert len(result.entity) == 0 + client.get.assert_not_called() + session.execute.assert_not_called() + session.commit.assert_not_called() + + +async def test__get_or_fetch_realtime_feed_refreshes_stale_cache(): + stale_row = TransLinkRealtimeCacheDB( + id=1, + fetched_at=datetime.now(tz=TZ_INFO) - timedelta(seconds=120), + response_bytes=make_empty_feed_bytes(), + ) + new_feed_bytes = make_feed_bytes( + trip_id="trip_143", + route_id="6656", + direction_id=0, + stop_id="2836", + departure_unix=1_700_000_000, + ) + session = mock_db_session(cached_row=stale_row) + client = mock_http_client(new_feed_bytes) + + result = await get_or_fetch_realtime_feed(session, client) + + assert result is not None + assert len(result.entity) == 1 + client.get.assert_awaited_once() + session.merge.assert_awaited_once() + session.commit.assert_awaited_once() + + +async def test__get_or_fetch_realtime_feed_returns_none_on_http_error_status(): + session = mock_db_session(cached_row=None) + client = AsyncMock(spec=AsyncClient) + client.get = AsyncMock( + return_value=Response( + status_code=401, + request=Request("GET", "https://gtfsapi.translink.ca/v3/gtfsrealtime"), + content=b"Unauthorized", + ) + ) + + result = await get_or_fetch_realtime_feed(session, client) + + assert result is None + session.merge.assert_not_called() + session.rollback.assert_awaited_once() + + +# --------------------------------------------------------------------------- +# Tests for get_or_fetch_static_schedule +# --------------------------------------------------------------------------- + + +async def test__get_or_fetch_static_schedule_cache_hit(): + """When the DB has today's row, no HTTP call should be made.""" + cached_records = [ + { + "trip_id": "trip_143", + "route_id": "6656", + "bus_number": "143", + "departure_time": "23:00:00", + "departure_seconds": 82800, + } + ] + cached_row = TransLinkStaticScheduleDB(id=1, date_fetched=date.today(), schedule=cached_records) + session = mock_db_session(cached_row=cached_row) + client = AsyncMock(spec=AsyncClient) + + result_date, result_df = await get_or_fetch_static_schedule(session, client) + + assert result_date == date.today() + assert not result_df.empty + assert result_df.iloc[0]["bus_number"] == "143" + client.get.assert_not_called() + + +async def test__get_or_fetch_static_schedule_cache_miss_fetches(): + """On a cache miss the function should call the API and persist the result.""" + session = mock_db_session(cached_row=None) + client = mock_http_client(make_gtfs_zip()) + + result_date, result_df = await get_or_fetch_static_schedule(session, client) + + assert result_date == date.today() + assert not result_df.empty + session.merge.assert_awaited_once() + session.commit.assert_awaited_once() + + +async def test__get_or_fetch_static_schedule_db_write_failure_still_returns(): + """If the DB write fails, the function should still return the fetched data.""" + import sqlalchemy.exc + + session = mock_db_session(cached_row=None) + session.merge = AsyncMock(side_effect=sqlalchemy.exc.SQLAlchemyError("disk full")) + client = mock_http_client(make_gtfs_zip()) + + result_date, result_df = await get_or_fetch_static_schedule(session, client) + + assert result_date == date.today() + assert not result_df.empty + session.rollback.assert_awaited_once() + + +async def test__get_departure_statuses_uses_timestamps_when_realtime_unavailable(): + now = datetime.now(tz=TZ_INFO) + midnight = now.replace(hour=0, minute=0, second=0, microsecond=0) + departure_seconds = int((now - midnight).total_seconds()) + 600 + cached_row = TransLinkStaticScheduleDB( + id=1, + date_fetched=now.date(), + schedule=[ + { + "trip_id": "trip_143", + "route_id": "6656", + "bus_number": "143", + "departure_time": "23:00:00", + "departure_seconds": departure_seconds, + } + ], + ) + session = mock_db_session() + session.scalar = AsyncMock(side_effect=[cached_row, None, None]) + client = AsyncMock(spec=AsyncClient) + client.get = AsyncMock(side_effect=httpx.ConnectError("realtime unavailable")) + + result = await get_departure_statuses(session, client) + + expected_timestamp = int((midnight + timedelta(seconds=departure_seconds)).timestamp()) + assert result == [ + TransLinkScheduleResponse( + route_number="143", + scheduled_departure_time=expected_timestamp, + realtime_time=expected_timestamp, + delay_seconds=0, + status=BusStatus.OnTime, + ) + ] + + +# --------------------------------------------------------------------------- +# REST API endpoint tests +# --------------------------------------------------------------------------- + + +async def test__endpoint_realtime_returns_200(client): + mock_response = [ + TransLinkRealtimeResponse( + route_number="143", + scheduled_departure_time=1_700_000_000, + realtime_time=1_700_000_060, + delay_seconds=60, + ) + ] + with patch("translink.urls.fetch_realtime_schedule", return_value=mock_response) as mock_fn: + response = await client.get("/translink/realtime") + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert isinstance(data, list) + assert len(data) == 1 + assert data[0]["route_number"] == "143" + assert data[0]["delay_seconds"] == 60 + mock_fn.assert_awaited_once() + + +async def test__endpoint_static_returns_schedule(client): + today = date.today() + mock_df = pd.DataFrame( + [ + { + "trip_id": f"trip_{num}", + "route_id": rid, + "bus_number": num, + "departure_time": "23:00:00", + "departure_seconds": 82800, + } + for rid, (_, _, num) in BUS_DATA.items() + ] + ) + with patch( + "translink.urls.get_or_fetch_static_schedule", + return_value=(today, mock_df), + ) as mock_fn: + response = await client.get("/translink/static") + + assert response.status_code == status.HTTP_200_OK + body = response.json() + assert body["date_fetched"] == today.isoformat() + assert len(body["schedule"]) == len(BUS_DATA) + mock_fn.assert_awaited_once() + + +async def test__endpoint_schedule_returns_departure_list(client): + mock_results = [ + TransLinkScheduleResponse( + route_number="143", + scheduled_departure_time=1_700_000_000, + realtime_time=1_700_000_000, + delay_seconds=0, + status=BusStatus.OnTime, + ), + TransLinkScheduleResponse( + route_number="144", + scheduled_departure_time=1_700_000_600, + realtime_time=1_700_000_720, + delay_seconds=120, + status=BusStatus.Delayed, + ), + ] + with patch("translink.urls.get_departure_statuses", return_value=mock_results) as mock_fn: + response = await client.get("/translink/schedule") + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert isinstance(data, list) + assert len(data) == 2 + assert data[1]["delay_seconds"] == 120 + mock_fn.assert_awaited_once() + + +async def test__endpoint_schedule_on_time_when_no_realtime(client): + """An empty realtime feed (all buses on time) should still return static rows.""" + mock_results = [ + TransLinkScheduleResponse( + route_number=num, + scheduled_departure_time=1_700_000_000 + i * 600, + realtime_time=1_700_000_000 + i * 600, + delay_seconds=0, + status=BusStatus.OnTime, + ) + for i, (_, (_, _, num)) in enumerate(BUS_DATA.items()) + ] + with patch("translink.urls.get_departure_statuses", return_value=mock_results): + response = await client.get("/translink/schedule") + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert all(d["delay_seconds"] == 0 for d in data) diff --git a/uv.lock b/uv.lock index ab25eb3c..74b08ab0 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,11 @@ version = 1 revision = 3 requires-python = "==3.13.*" +resolution-markers = [ + "sys_platform == 'win32'", + "sys_platform == 'emscripten'", + "sys_platform != 'emscripten' and sys_platform != 'win32'", +] [[package]] name = "alembic" @@ -197,8 +202,11 @@ dependencies = [ { name = "asyncpg" }, { name = "fastapi" }, { name = "google-api-python-client" }, + { name = "gtfs-realtime-bindings" }, { name = "gunicorn" }, { name = "httpx" }, + { name = "pandas" }, + { name = "pydantic-settings" }, { name = "sqlalchemy", extra = ["asyncio"] }, { name = "uvicorn", extra = ["standard"] }, { name = "xmltodict" }, @@ -210,7 +218,6 @@ dev = [ { name = "ruff" }, ] test = [ - { name = "httpx" }, { name = "pytest" }, { name = "pytest-asyncio" }, ] @@ -221,10 +228,12 @@ requires-dist = [ { name = "asyncpg", specifier = "==0.31.0" }, { name = "fastapi", specifier = "==0.135.4" }, { name = "google-api-python-client", specifier = "==2.194.0" }, + { name = "gtfs-realtime-bindings", specifier = "==2.0.0" }, { name = "gunicorn", specifier = "==25.3.0" }, { name = "httpx", specifier = "==0.28.1" }, - { name = "httpx", marker = "extra == 'test'" }, + { name = "pandas", specifier = "==3.0.3" }, { name = "pre-commit", marker = "extra == 'dev'" }, + { name = "pydantic-settings", specifier = "==2.14.1" }, { name = "pytest", marker = "extra == 'test'" }, { name = "pytest-asyncio", marker = "extra == 'test'" }, { name = "ruff", marker = "extra == 'dev'", specifier = "==0.15.12" }, @@ -356,6 +365,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9b/96/795619651d39c7fbd809a522f881aa6f0ead504cc8201c3a5b789dfaef99/greenlet-3.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:9390ad88b652b1903814eaabd629ca184db15e0eeb6fe8a390bbf8b9106ae15a", size = 235498, upload-time = "2026-04-08T17:05:00.584Z" }, ] +[[package]] +name = "gtfs-realtime-bindings" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6b/9b/c33d7889ff8d24cc9e9ae82a83fb9defba3f6595312f056cb62937dd1b33/gtfs_realtime_bindings-2.0.0.tar.gz", hash = "sha256:861a9dcf4c40f9a59520044d870e336b00894ad5638bcf2c4a9b998923543b42", size = 6231, upload-time = "2025-12-03T19:19:40.273Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/d7/a5a71c655fd10c98fadb00d4db080195ced839445f410684fa6dfb901c28/gtfs_realtime_bindings-2.0.0-py3-none-any.whl", hash = "sha256:e66e581dfccad20e3b9eaed36aae106f945c2f5e506b03a63d37070096b8de39", size = 5303, upload-time = "2025-12-03T19:19:38.845Z" }, +] + [[package]] name = "gunicorn" version = "25.3.0" @@ -510,6 +531,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, ] +[[package]] +name = "numpy" +version = "2.4.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/50/8e/b8041bc719f056afd864478029d52214789341ac6583437b0ee5031e9530/numpy-2.4.5.tar.gz", hash = "sha256:ca670567a5683b7c1670ec03e0ddd5862e10934e92a70751d68d7b7b74ca7f9f", size = 20735669, upload-time = "2026-05-15T20:25:19.492Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/a4/fb50657c7cab297bf34edcd60a074cb0647f61771430d6363575274160fe/numpy-2.4.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1ef248460b645c102026b82337cc4e88231909c66dd77b59ec6d6cac7e44f277", size = 16684760, upload-time = "2026-05-15T20:23:19.436Z" }, + { url = "https://files.pythonhosted.org/packages/3e/43/87e731299b9408eda705b3b9cb31c7bceb9347d2af9cbb16b2b1e4b5bc0f/numpy-2.4.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4603622bdcdbf8dccb1d9d5b21d16a7aa4e473ae6c8e14048d846fd4ca2907a0", size = 14694117, upload-time = "2026-05-15T20:23:21.832Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c7/0b2bb8acea222e9dd6e582afc2bc553b89b8833cbdccc68e68f050fb31f8/numpy-2.4.5-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:6c18d49c67689c562854b53fdc433b93e47c12952aa6fa6d59f185e1a5992419", size = 5199141, upload-time = "2026-05-15T20:23:24.066Z" }, + { url = "https://files.pythonhosted.org/packages/39/60/b6972b5d47033d90000f0097c81a98b9486589a2d7003bf725bff275cb0d/numpy-2.4.5-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:b1c663ddc641f4192e90511bec61a09bc231e3bbdb996cdc6edbcaa0e528d685", size = 6546954, upload-time = "2026-05-15T20:23:26.099Z" }, + { url = "https://files.pythonhosted.org/packages/c1/e9/ed667cb12c11ca0adde431f685d3a5dd78e6f78b27228c581c8415198e9e/numpy-2.4.5-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93793222b524f692f12b2f8752ce8b1d9d9125b2bfd5dbf0fb69c92c5e1ce86c", size = 15669430, upload-time = "2026-05-15T20:23:28.147Z" }, + { url = "https://files.pythonhosted.org/packages/44/e5/679f6ffeb01294b0008e5ada4a113cb47617bc0e1819a529fd7973c6d7f4/numpy-2.4.5-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1616bde34b2bcba2fa9bde06217ce00da4f3d1bdfb264d54525a99e8fe170d83", size = 16633390, upload-time = "2026-05-15T20:23:31.622Z" }, + { url = "https://files.pythonhosted.org/packages/36/46/42bfffc9a780ec902ccd7470d3219192ee82b7b442710307dd85b4d121b0/numpy-2.4.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:09d7d97da1c2c62f4818b3e150a57572ff8dcf1cf5ac501aac832ffd4ebd9566", size = 17020709, upload-time = "2026-05-15T20:23:34.08Z" }, + { url = "https://files.pythonhosted.org/packages/44/00/3e840bfee0cc6cec22209f2c97057f26eeb30de031e4933b4dfc0395416c/numpy-2.4.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2d68d0b355ab2e39fe0de59001d7151dfdbbb880ef67baeed806661e03df5097", size = 18357818, upload-time = "2026-05-15T20:23:36.965Z" }, + { url = "https://files.pythonhosted.org/packages/72/cb/3447b400b9da84134575486f0f656541559b00d4b262477bce9b678bbca8/numpy-2.4.5-cp313-cp313-win32.whl", hash = "sha256:fe28b64777ddfa0eca9b5f51474034ebe3dcb8324f48f27b28f479085673ae33", size = 5961114, upload-time = "2026-05-15T20:23:39.586Z" }, + { url = "https://files.pythonhosted.org/packages/28/f9/a90d2220ffcdc0798f5d55bb5d5463cd6254ec9ef43f384dae80217d7a2f/numpy-2.4.5-cp313-cp313-win_amd64.whl", hash = "sha256:fb4a6c9c537d6ccec9cc4aeae4261bd3cc79b070c67ddc0646f5b1c07fddde42", size = 12318553, upload-time = "2026-05-15T20:23:41.436Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c9/96f531fb3234545315152d34efdf3de7daee81254448447eb619e8d16967/numpy-2.4.5-cp313-cp313-win_arm64.whl", hash = "sha256:6d7df2da2e7ea0624a43aa368104b3a3ce14aae98ad4bb2c9a93fecef76f1c97", size = 10222200, upload-time = "2026-05-15T20:23:43.681Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f4/a291caab5a3c520babf93ff77c54fd5fdb1ebbc3296cee2eb2146ce773b1/numpy-2.4.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:2a235607a18df941760a695927051af4b1cd5d3ee85840d0e2af816785771feb", size = 14821438, upload-time = "2026-05-15T20:23:45.911Z" }, + { url = "https://files.pythonhosted.org/packages/85/26/13dbb1159b864370568e7309063fd72667984df89db74e9caeb175d067c7/numpy-2.4.5-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:58dcf64969d870f36bc7fbd557d2617e997db7dc06261b6e3327148ea460d0a4", size = 5326663, upload-time = "2026-05-15T20:23:48.18Z" }, + { url = "https://files.pythonhosted.org/packages/7c/99/d233408072a0e019e2288e27edd23f7d572ccd4a73d1539baa3270ede85d/numpy-2.4.5-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:235f54b0156274d8fa3155db3ed6d2f401c7e8f3367c90db0a12f02a58fde6ed", size = 6646874, upload-time = "2026-05-15T20:23:49.856Z" }, + { url = "https://files.pythonhosted.org/packages/c5/00/eeb6f193dfe767725e952e0464f3e51f44145c5dd261cd7389aa36ac0713/numpy-2.4.5-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef3b5bb65437a3555c648e706475db01c645559ca80dc8b03e4f202ea757e0d6", size = 15728147, upload-time = "2026-05-15T20:23:51.655Z" }, + { url = "https://files.pythonhosted.org/packages/e5/c9/b8ed039f1fde1b13a8807c893e7e2f9432a379f4d6401edecf0028da5b2c/numpy-2.4.5-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7f09a7e5f017d7098c66522097c96257411c9620c0926212200d66bc8cee3976", size = 16681770, upload-time = "2026-05-15T20:23:53.933Z" }, + { url = "https://files.pythonhosted.org/packages/11/5b/0198ef6cb7016eca6d895d392106012138127fab23f46637e76d5e25c9f5/numpy-2.4.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:993a88d8fdd8554466a8765cd8bacd97ba56b70ca6b0a04bcdca77f5afed4222", size = 17086218, upload-time = "2026-05-15T20:23:56.646Z" }, + { url = "https://files.pythonhosted.org/packages/f0/fe/8821f3cfc660ae84c92ee158505941874b62c56a42e035a41425228cd8cf/numpy-2.4.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:84f58bed609b5669f5ad3d597901a4f1f86ee5b3c3708aaa55f05b4fe6e0f656", size = 18403542, upload-time = "2026-05-15T20:23:59.173Z" }, + { url = "https://files.pythonhosted.org/packages/0e/00/e64ecaf498865e7b091f57658b2c522503e5d1b70e43b807f5f8247e1d88/numpy-2.4.5-cp313-cp313t-win32.whl", hash = "sha256:7200c58f3f933ca61e66346667dcc8510bb111995e9ce15398a731e6a4afa4bb", size = 6084903, upload-time = "2026-05-15T20:24:01.506Z" }, + { url = "https://files.pythonhosted.org/packages/20/c0/354997dedaf74e8311c2cf9a6027b476fd8d424cb92189cc0ae2b25f501c/numpy-2.4.5-cp313-cp313t-win_amd64.whl", hash = "sha256:c26c71080d35db5002102f5d9ff614d45de02aa1f7802943e691e063e5ee93bc", size = 12458420, upload-time = "2026-05-15T20:24:03.735Z" }, + { url = "https://files.pythonhosted.org/packages/66/dc/917ee5ea4a31ca1a6e4c9a85386477efa318dcc60db257c5ef4adda096c1/numpy-2.4.5-cp313-cp313t-win_arm64.whl", hash = "sha256:2caa576d1707b275cba1aeb60a5c50daa6fa2a3f28ecb08123bc05fd439005db", size = 10291826, upload-time = "2026-05-15T20:24:06.535Z" }, +] + [[package]] name = "packaging" version = "26.2" @@ -519,6 +569,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] +[[package]] +name = "pandas" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "python-dateutil" }, + { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/87/4341c6252d1c47b08768c3d25ac487362bf403f0313ddae4a2a26c9b1b4c/pandas-3.0.3.tar.gz", hash = "sha256:696a4a00a2a2a35d4e5deb3fc946641b96c944f02230e4f76137fe35d806c4fc", size = 4651414, upload-time = "2026-05-11T18:54:29.21Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/90/62d8302883c44308c477e222c3daf7c813a34c8e96985882fbd53d964352/pandas-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:67b3b64c11910cfa29f4e94a14d3bff9ee693b6fc76055e7cad549cee0aec5fa", size = 10331071, upload-time = "2026-05-11T18:52:58.838Z" }, + { url = "https://files.pythonhosted.org/packages/7f/ae/6a6493c783a101f165e4356953ba3c74d6f77f0042fa7d753da9dfbb640c/pandas-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:39436b377d56d2a2e52d0395bdbee171f01068e99af5250509aceeb929f765c7", size = 9875690, upload-time = "2026-05-11T18:53:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/62/7c/5df8e9f56c69a2769fbe9382a5ef8f2658c007e376434e1e2cbb57ad895f/pandas-3.0.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4be06d68f9ddcfc645b87534911da79a8fbffc7573c80e0edcf42a5020624d8", size = 10381634, upload-time = "2026-05-11T18:53:04.393Z" }, + { url = "https://files.pythonhosted.org/packages/99/68/1237369725aa617bb358263d535803e3053fdbc593513ec5ed9c9896b5b6/pandas-3.0.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a4eeb6830daf35a71cc09649bd823e2b542dac246cdee9614c6e4bd65028cd6a", size = 10891243, upload-time = "2026-05-11T18:53:07.643Z" }, + { url = "https://files.pythonhosted.org/packages/25/93/77d108e8af7222b4a503ebde0e30215b1c2e4f8e53a526431890f22d5586/pandas-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1928e07221f82db493cd4af1e23c1bfca524a19a4699887975bff68f49a72bfb", size = 11388659, upload-time = "2026-05-11T18:53:10.634Z" }, + { url = "https://files.pythonhosted.org/packages/d0/bd/eff5b4399f332ac386c853f6cd2bd3fa2ca0061b9f36ecd9c4d7c4265649/pandas-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51b1fe551acb77dac643c6fda86084d8d446c10fe64b06a9cc29c4cc8540e7f2", size = 11942880, upload-time = "2026-05-11T18:53:13.536Z" }, + { url = "https://files.pythonhosted.org/packages/2c/20/559ace4200982c3887d0b86bfd0d856a2143ef8ddab63cc07934951a964c/pandas-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:a82d532a3351d435432cd913edbccaf8b8e01d4dd0e5ced5a8d2e8ecd94c7e44", size = 9757091, upload-time = "2026-05-11T18:53:16.306Z" }, + { url = "https://files.pythonhosted.org/packages/3a/66/69055a09fe200f29f922a3eeec4804611900b95f52d932ece3393c3c0c19/pandas-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:275c14e0fce14a2ec20eee474aecd305478ea3c1e6f6a9d8fe219a165542717e", size = 9057282, upload-time = "2026-05-11T18:53:18.768Z" }, + { url = "https://files.pythonhosted.org/packages/57/0e/efe801b0e6811e8e650cd21b7f2608e30f08a7067e2bf6e8752b0d56ee3c/pandas-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:46997386d528eb40376ecd6b033cf4a8a1e5282580f68f43de875b78cba2199d", size = 10767016, upload-time = "2026-05-11T18:53:21.227Z" }, + { url = "https://files.pythonhosted.org/packages/ea/dc/eb55135a1d5f0f0519f28da1f609a206d2cad1f9c35c32d51e38dd7261ae/pandas-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:261e308dfb22448384b7580cf719d2f998fe2966c92893c3e77d14008af1f066", size = 10420210, upload-time = "2026-05-11T18:53:23.982Z" }, + { url = "https://files.pythonhosted.org/packages/c6/3e/b1d5d955ce33ffecb407465a60bc32769d74fcf68224b7ae67ae11d4dea4/pandas-3.0.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dd1a5d1def6a46002e964510bdc67c368aa0951df5d1d9f8365336f5a1f490cd", size = 10336126, upload-time = "2026-05-11T18:53:26.731Z" }, + { url = "https://files.pythonhosted.org/packages/f5/76/a01261711ab60a22d71b862f0de20e4c504bf80457270ad8cb42110f6abc/pandas-3.0.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d72828c20c6d6e83e1e22a6a3b47b326b71664112fa9705dcbccfd7a39b62085", size = 10728051, upload-time = "2026-05-11T18:53:29.125Z" }, + { url = "https://files.pythonhosted.org/packages/e9/21/ea191195e587b18cf682e97f433f81b2d0fbe341380e80a3e0d6e4403c8e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d26cbe1fcfc12e8fd900e2454163e466b2d3af84f7c75481df7683ffc073d870", size = 11350796, upload-time = "2026-05-11T18:53:32.056Z" }, + { url = "https://files.pythonhosted.org/packages/64/69/f0eaaf54939f0e8c6768fd06be9af2cef9b36048b96dfb9e1b2c685a807e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3e91cec1879ada0624fc3dc9953c5cbd60208e59c0db28f540c5d6d47502422f", size = 11799741, upload-time = "2026-05-11T18:53:34.985Z" }, + { url = "https://files.pythonhosted.org/packages/45/a4/865e0e510cae5fc2194de4db28be638952de942571ba9125934fd9c01d47/pandas-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:08d789b41f87e0905880e293cedf6197ce71fe67cc081358b1e148a491b9bd13", size = 10499958, upload-time = "2026-05-11T18:53:37.857Z" }, +] + [[package]] name = "platformdirs" version = "4.9.6" @@ -651,6 +729,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/52/eb/66faefabebfe68bd7788339c9c9127231e680b11906368c67ce112fdb47f/pydantic_core-2.46.3-cp313-cp313-win_arm64.whl", hash = "sha256:f64b5537ac62b231572879cd08ec05600308636a5d63bcbdb15063a466977bec", size = 2035802, upload-time = "2026-04-20T14:43:38.507Z" }, ] +[[package]] +name = "pydantic-settings" +version = "2.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/60/1d1e59c9c90d54591469ada7d268251f71c24bdb765f1a8a832cee8c6653/pydantic_settings-2.14.1.tar.gz", hash = "sha256:e874d3bec7e787b0c9958277956ed9b4dd5de6a80e162188fdaff7c5e26fd5fa", size = 235551, upload-time = "2026-05-08T13:40:06.542Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/8d/f1af3832f5e6eb13ba94ee809e72b8ecb5eef226d27ee0bef7d963d943c7/pydantic_settings-2.14.1-py3-none-any.whl", hash = "sha256:6e3c7edfd8277687cdc598f56e5cff0e9bfff0910a3749deaa8d4401c3a2b9de", size = 60964, upload-time = "2026-05-08T13:40:04.958Z" }, +] + [[package]] name = "pygments" version = "2.20.0" @@ -697,6 +789,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, ] +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + [[package]] name = "python-discovery" version = "1.3.1" @@ -777,6 +881,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" }, ] +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + [[package]] name = "sqlalchemy" version = "2.0.49" @@ -841,6 +954,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] +[[package]] +name = "tzdata" +version = "2026.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" }, +] + [[package]] name = "uritemplate" version = "4.2.0"