From e16a798a5f0ae96f2cdff61a9483c3d64870dc5d Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Fri, 15 May 2026 13:23:51 -0700 Subject: [PATCH 01/28] feat(env): added python-env package for devs --- pyproject.toml | 4 ++-- uv.lock | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 21c328be..eb7990c5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,13 +21,13 @@ dependencies = [ [project.optional-dependencies] dev = [ "ruff==0.15.12", # linting and formatter - "pre-commit" + "pre-commit", + "python-dotenv==1.2.2", ] test = [ "pytest", # test framework "pytest-asyncio", - "httpx", ] [project.urls] diff --git a/uv.lock b/uv.lock index ab25eb3c..ef29df7b 100644 --- a/uv.lock +++ b/uv.lock @@ -207,10 +207,10 @@ dependencies = [ [package.optional-dependencies] dev = [ { name = "pre-commit" }, + { name = "python-dotenv" }, { name = "ruff" }, ] test = [ - { name = "httpx" }, { name = "pytest" }, { name = "pytest-asyncio" }, ] @@ -223,10 +223,10 @@ requires-dist = [ { name = "google-api-python-client", specifier = "==2.194.0" }, { name = "gunicorn", specifier = "==25.3.0" }, { name = "httpx", specifier = "==0.28.1" }, - { name = "httpx", marker = "extra == 'test'" }, { name = "pre-commit", marker = "extra == 'dev'" }, { name = "pytest", marker = "extra == 'test'" }, { name = "pytest-asyncio", marker = "extra == 'test'" }, + { name = "python-dotenv", marker = "extra == 'dev'", specifier = "==1.2.2" }, { name = "ruff", marker = "extra == 'dev'", specifier = "==0.15.12" }, { name = "sqlalchemy", extras = ["asyncio"], specifier = "==2.0.49" }, { name = "uvicorn", extras = ["standard"], specifier = "==0.46.0" }, From 9317dee1b033250413baf66c28e55695236206f2 Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Fri, 15 May 2026 13:25:30 -0700 Subject: [PATCH 02/28] fix: add .env to .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 8bbedfde..2446bb90 100755 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ wheels .venv .DS_Store +.env From 9025d0a1e0912cfc0a7ef37983fbfe81985aaea9 Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Fri, 15 May 2026 13:53:29 -0700 Subject: [PATCH 03/28] feat: add support for TransLink API key --- pyproject.toml | 3 +-- src/config.py | 10 ++++++++++ src/main.py | 1 + uv.lock | 18 ++++++++++++++++-- 4 files changed, 28 insertions(+), 4 deletions(-) create mode 100644 src/config.py diff --git a/pyproject.toml b/pyproject.toml index eb7990c5..de0009cb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,17 +12,16 @@ 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", ] [project.optional-dependencies] dev = [ "ruff==0.15.12", # linting and formatter "pre-commit", - "python-dotenv==1.2.2", ] test = [ 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/main.py b/src/main.py index 60bb5a74..daf3655b 100755 --- a/src/main.py +++ b/src/main.py @@ -14,6 +14,7 @@ import nominees.urls import officers.urls import permission.urls +from config import settings from constants import IS_PROD logging.basicConfig(level=logging.DEBUG) diff --git a/uv.lock b/uv.lock index ef29df7b..f872a618 100644 --- a/uv.lock +++ b/uv.lock @@ -199,6 +199,7 @@ dependencies = [ { name = "google-api-python-client" }, { name = "gunicorn" }, { name = "httpx" }, + { name = "pydantic-settings" }, { name = "sqlalchemy", extra = ["asyncio"] }, { name = "uvicorn", extra = ["standard"] }, { name = "xmltodict" }, @@ -207,7 +208,6 @@ dependencies = [ [package.optional-dependencies] dev = [ { name = "pre-commit" }, - { name = "python-dotenv" }, { name = "ruff" }, ] test = [ @@ -224,9 +224,9 @@ requires-dist = [ { name = "gunicorn", specifier = "==25.3.0" }, { name = "httpx", specifier = "==0.28.1" }, { 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 = "python-dotenv", marker = "extra == 'dev'", specifier = "==1.2.2" }, { name = "ruff", marker = "extra == 'dev'", specifier = "==0.15.12" }, { name = "sqlalchemy", extras = ["asyncio"], specifier = "==2.0.49" }, { name = "uvicorn", extras = ["standard"], specifier = "==0.46.0" }, @@ -651,6 +651,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" From 499d22e0cf63750105fcb071b762b4c6c532b112 Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Sun, 7 Jun 2026 12:16:48 -0700 Subject: [PATCH 04/28] feat: add realtime data and parse for next bus --- pyproject.toml | 1 + src/auth/urls.py | 1 - src/main.py | 3 +- src/translink/models.py | 17 ++++++ src/translink/types.py | 51 ++++++++++++++++++ src/translink/urls.py | 117 ++++++++++++++++++++++++++++++++++++++++ uv.lock | 14 +++++ 7 files changed, 202 insertions(+), 2 deletions(-) create mode 100644 src/translink/models.py create mode 100644 src/translink/types.py create mode 100644 src/translink/urls.py diff --git a/pyproject.toml b/pyproject.toml index de0009cb..e88ab08b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,7 @@ dependencies = [ "xmltodict==0.13.0", "httpx==0.28.1", "pydantic-settings==2.14.1", + "gtfs-realtime-bindings==2.0.0", ] [project.optional-dependencies] 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/main.py b/src/main.py index daf3655b..b8ac33c9 100755 --- a/src/main.py +++ b/src/main.py @@ -14,7 +14,7 @@ import nominees.urls import officers.urls import permission.urls -from config import settings +import translink.urls from constants import IS_PROD logging.basicConfig(level=logging.DEBUG) @@ -61,6 +61,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/translink/models.py b/src/translink/models.py new file mode 100644 index 00000000..efac5010 --- /dev/null +++ b/src/translink/models.py @@ -0,0 +1,17 @@ +from enum import StrEnum + +from pydantic import BaseModel + + +class BusStatus(StrEnum): + INCOMING_AT = "INCOMING_AT" + STOPPED_AT = "STOPPED_AT" + IN_TRANSIT_TO = "IN_TRANSIT_TO" + + +class BusScheduleEntry(BaseModel): + bus_number: str + scheduled_departure_time: int + realtime_time: int + delay_seconds: int + status: BusStatus diff --git a/src/translink/types.py b/src/translink/types.py new file mode 100644 index 00000000..bb6fa20b --- /dev/null +++ b/src/translink/types.py @@ -0,0 +1,51 @@ +# ruff: noqa: N802 +from collections.abc import Iterator +from typing import Protocol + +from translink.models import BusStatus + + +class Trip(Protocol): + trip_id: str + route_id: str + direction_id: int + + +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): + class _Vehicle(Protocol): + trip: Trip + current_status: BusStatus + + trip_update: TripUpdate + vehicle: _Vehicle + + 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..78037841 --- /dev/null +++ b/src/translink/urls.py @@ -0,0 +1,117 @@ +import asyncio +from typing import Any, cast + +import httpx +from fastapi import APIRouter, Request +from fastapi.responses import JSONResponse +from google.protobuf.json_format import MessageToDict +from google.protobuf.message import Message +from google.transit import gtfs_realtime_pb2 + +from config import settings +from translink.models import BusScheduleEntry, BusStatus +from translink.types import FeedMessage + +REALTIME_URL = "https://gtfsapi.translink.ca/v3/gtfsrealtime" +POSITION_URL = "https://gtfsapi.translink.ca/v3/gtfsposition" + + +# Taken from the static data. +# 0: Direction ID (always starts from SFU) +# 1: SFU origin bus stop number +# 2: Bus number +BUS_DATA = { + "6656": (0, "2836", "143"), # Burquitlam + "6657": (1, "12972", "144"), # Metrotown + "6658": (1, "1875", "145"), # Production + "37807": (1, "3129", "R5"), # Hastings +} + + +def get_bus_status_string(status: int) -> BusStatus: + match status: + case 0: + return BusStatus.INCOMING_AT + case 1: + return BusStatus.STOPPED_AT + case 2: + return BusStatus.IN_TRANSIT_TO + case _: + raise ValueError(f"Unknown bus status: {status}") + + +async def fetch_feed(client: httpx.AsyncClient, url: str, params: dict[str, Any]): + response = await client.get(url, params=params) + feed = cast(FeedMessage, gtfs_realtime_pb2.FeedMessage()) # pyright: ignore[reportAttributeAccessIssue] + feed.ParseFromString(response.content) + return feed + + +router = APIRouter( + prefix="/translink", + tags=["translink"], +) + + +@router.get( + "/schedule", + description="Get TransLink bus schedule", + response_description="Real-time information for bus schedules", + response_model=list[BusScheduleEntry], + operation_id="get_bus_schedules", +) +async def get_bus_schedules(request: Request): + # FeedMessage is generated at runtime, so the LSP can't find this function + params = {"apikey": settings.translink_api_key} + + trip_feed, position_feed = await asyncio.gather( + fetch_feed(request.app.state.http_client, REALTIME_URL, params=params), + fetch_feed(request.app.state.http_client, POSITION_URL, params=params), + ) + + vehicle_positions = {} + for entity in position_feed.entity: + if not entity.HasField("vehicle"): + continue + + trip = entity.vehicle.trip + if trip.route_id not in BUS_DATA or trip.direction_id != BUS_DATA[trip.route_id][0]: + continue + vehicle_positions[trip.trip_id] = entity.vehicle + + result: list[BusScheduleEntry] = [] + 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 + + _, stop_id, bus_number = 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 + + scheduled_time = stop.departure.time - stop.departure.delay + + trip_id = trip.trip_id + status = BusStatus.INCOMING_AT + vehicle = vehicle_positions.get(trip_id) + if vehicle: + status = get_bus_status_string(vehicle.current_status) + + result.append( + BusScheduleEntry( + bus_number=bus_number, + scheduled_departure_time=scheduled_time, + realtime_time=stop.departure.time, + delay_seconds=stop.departure.delay, + status=status, + ) + ) + + return result diff --git a/uv.lock b/uv.lock index f872a618..08de7eac 100644 --- a/uv.lock +++ b/uv.lock @@ -197,6 +197,7 @@ dependencies = [ { name = "asyncpg" }, { name = "fastapi" }, { name = "google-api-python-client" }, + { name = "gtfs-realtime-bindings" }, { name = "gunicorn" }, { name = "httpx" }, { name = "pydantic-settings" }, @@ -221,6 +222,7 @@ 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 = "pre-commit", marker = "extra == 'dev'" }, @@ -356,6 +358,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" From d170599d37941d95fef2b4254efebbdc052e1f4e Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Sat, 16 May 2026 14:35:27 -0700 Subject: [PATCH 05/28] feat: add static data for bus timetable * Pulls out all the stops for all bus information in the SFU bus loop --- pyproject.toml | 1 + src/translink/crud.py | 99 +++++++++++++++++++++++++++++++++++++++++++ src/translink/urls.py | 23 +++++----- uv.lock | 94 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 205 insertions(+), 12 deletions(-) create mode 100644 src/translink/crud.py diff --git a/pyproject.toml b/pyproject.toml index e88ab08b..88fcc07f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ dependencies = [ "httpx==0.28.1", "pydantic-settings==2.14.1", "gtfs-realtime-bindings==2.0.0", + "pandas==3.0.3", ] [project.optional-dependencies] diff --git a/src/translink/crud.py b/src/translink/crud.py new file mode 100644 index 00000000..28d8caa5 --- /dev/null +++ b/src/translink/crud.py @@ -0,0 +1,99 @@ +import io +import zipfile +from datetime import datetime +from typing import cast + +import pandas as pd +from httpx import AsyncClient + +from constants import TZ_INFO + +# Taken from the static data. +# Key: Route ID +# 0: Direction ID (always starts from SFU) +# 1: SFU Stop ID +# 2: Bus 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. + This will handle those cases as well. + """ + 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 load_static_schedule(client: AsyncClient) -> pd.DataFrame: + """ + Gets the static bus schedule from the static TransLink GTFS API + """ + # Retrieve the static TransLink bus schedule data + static_response = await client.get("https://gtfs-static.translink.ca/gtfs/google_transit.zip") + z = zipfile.ZipFile(io.BytesIO(static_response.content)) + + # 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") # cross join + 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) + print(merged) + + return cast( + pd.DataFrame, + merged[["trip_id", "route_id", "bus_number", "departure_seconds"]].reset_index(drop=True), + ) diff --git a/src/translink/urls.py b/src/translink/urls.py index 78037841..1698a533 100644 --- a/src/translink/urls.py +++ b/src/translink/urls.py @@ -9,6 +9,7 @@ from google.transit import gtfs_realtime_pb2 from config import settings +from translink.crud import BUS_DATA, load_static_schedule from translink.models import BusScheduleEntry, BusStatus from translink.types import FeedMessage @@ -16,18 +17,6 @@ POSITION_URL = "https://gtfsapi.translink.ca/v3/gtfsposition" -# Taken from the static data. -# 0: Direction ID (always starts from SFU) -# 1: SFU origin bus stop number -# 2: Bus number -BUS_DATA = { - "6656": (0, "2836", "143"), # Burquitlam - "6657": (1, "12972", "144"), # Metrotown - "6658": (1, "1875", "145"), # Production - "37807": (1, "3129", "R5"), # Hastings -} - - def get_bus_status_string(status: int) -> BusStatus: match status: case 0: @@ -115,3 +104,13 @@ async def get_bus_schedules(request: Request): ) return result + + +@router.get( + "/static", + description="Get the static TransLink schedule", + response_description="Static TransLink schedule", + operation_id="get_static_schedule", +) +async def get_static_schedule(request: Request): + return (await load_static_schedule(request.app.state.http_client)).to_dict(orient="records") diff --git a/uv.lock b/uv.lock index 08de7eac..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" @@ -200,6 +205,7 @@ dependencies = [ { name = "gtfs-realtime-bindings" }, { name = "gunicorn" }, { name = "httpx" }, + { name = "pandas" }, { name = "pydantic-settings" }, { name = "sqlalchemy", extra = ["asyncio"] }, { name = "uvicorn", extra = ["standard"] }, @@ -225,6 +231,7 @@ requires-dist = [ { name = "gtfs-realtime-bindings", specifier = "==2.0.0" }, { name = "gunicorn", specifier = "==25.3.0" }, { name = "httpx", specifier = "==0.28.1" }, + { name = "pandas", specifier = "==3.0.3" }, { name = "pre-commit", marker = "extra == 'dev'" }, { name = "pydantic-settings", specifier = "==2.14.1" }, { name = "pytest", marker = "extra == 'test'" }, @@ -524,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" @@ -533,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" @@ -725,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" @@ -805,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" @@ -869,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" From 6df767c9173e544173e593899d3978949aa24aae Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Sat, 16 May 2026 14:44:18 -0700 Subject: [PATCH 06/28] fix: move lifespan declaration to main --- src/database.py | 16 +--------------- src/main.py | 27 +++++++++++++++++++++++++-- 2 files changed, 26 insertions(+), 17 deletions(-) diff --git a/src/database.py b/src/database.py index bbf1815f..bba7b98b 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 @@ -107,19 +106,6 @@ def 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 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/main.py b/src/main.py index b8ac33c9..bf0fb1e1 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 @@ -20,6 +23,26 @@ 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() + # app.state.shared_state = shared_state + + # with shared_lock: + # if "static_translink_schedule" not in shared_state: + # schedule = await load_static_schedule(app.state.http_client) + # shared_state["static_schedule"] = schedule.to_dict(orient="records") + 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") @@ -28,7 +51,7 @@ "http://localhost:8080", # for existing applications/sites ] app = FastAPI( - lifespan=database.lifespan, + lifespan=lifespan, title="CSSS Site Backend", root_path="/api", ) @@ -42,7 +65,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 From e967f6723d6ccd67f18e2bfd96e114ea99acf100 Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Sat, 16 May 2026 15:41:50 -0700 Subject: [PATCH 07/28] feat: static schedule updates * returns the schedule grouped by route_id and sorted based on departure time * by default returns the next 3 departures --- src/database.py | 2 ++ src/translink/crud.py | 18 ++++++++++++++++-- src/translink/urls.py | 8 +++----- 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/src/database.py b/src/database.py index bba7b98b..d859db39 100644 --- a/src/database.py +++ b/src/database.py @@ -93,6 +93,8 @@ async def session(self) -> AsyncGenerator[AsyncSession]: SQLALCHEMY_DATABASE_URL = "postgresql+asyncpg:///main" SQLALCHEMY_TEST_DATABASE_URL = "postgresql+asyncpg:///test" +print(f"Using database: {SQLALCHEMY_DATABASE_URL}") + # also TODO: make this nicer, using a class to hold state... # and use this in load_test_db for the test db as well? diff --git a/src/translink/crud.py b/src/translink/crud.py index 28d8caa5..2f56d86b 100644 --- a/src/translink/crud.py +++ b/src/translink/crud.py @@ -54,7 +54,7 @@ def _get_active_service_ids(z: zipfile.ZipFile) -> set[str]: return active -async def load_static_schedule(client: AsyncClient) -> pd.DataFrame: +async def fetch_static_schedule(client: AsyncClient) -> pd.DataFrame: """ Gets the static bus schedule from the static TransLink GTFS API """ @@ -91,9 +91,23 @@ async def load_static_schedule(client: AsyncClient) -> pd.DataFrame: 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) - print(merged) return cast( pd.DataFrame, merged[["trip_id", "route_id", "bus_number", "departure_seconds"]].reset_index(drop=True), ) + + +def get_next_departures(schedule: pd.DataFrame, n: int = 3) -> pd.DataFrame: + """ + Gets the next departure time for each bus based on the current time in seconds since midnight + """ + 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"]) + ) diff --git a/src/translink/urls.py b/src/translink/urls.py index 1698a533..b3cff2ba 100644 --- a/src/translink/urls.py +++ b/src/translink/urls.py @@ -3,13 +3,10 @@ import httpx from fastapi import APIRouter, Request -from fastapi.responses import JSONResponse -from google.protobuf.json_format import MessageToDict -from google.protobuf.message import Message from google.transit import gtfs_realtime_pb2 from config import settings -from translink.crud import BUS_DATA, load_static_schedule +from translink.crud import BUS_DATA, fetch_static_schedule, get_next_departures from translink.models import BusScheduleEntry, BusStatus from translink.types import FeedMessage @@ -113,4 +110,5 @@ async def get_bus_schedules(request: Request): operation_id="get_static_schedule", ) async def get_static_schedule(request: Request): - return (await load_static_schedule(request.app.state.http_client)).to_dict(orient="records") + schedule = await fetch_static_schedule(request.app.state.http_client) + return get_next_departures(schedule).to_dict(orient="records") From a3942e25c420beb04850c519b911caaa6ec8f9f5 Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Sun, 17 May 2026 10:14:25 -0700 Subject: [PATCH 08/28] fix: change bus_number to route_number and add docs --- src/translink/crud.py | 15 +++++++++++---- src/translink/models.py | 21 ++++++++++++--------- src/translink/urls.py | 17 ++++++++++------- 3 files changed, 33 insertions(+), 20 deletions(-) diff --git a/src/translink/crud.py b/src/translink/crud.py index 2f56d86b..6ba18483 100644 --- a/src/translink/crud.py +++ b/src/translink/crud.py @@ -12,7 +12,7 @@ # Key: Route ID # 0: Direction ID (always starts from SFU) # 1: SFU Stop ID -# 2: Bus number +# 2: Route number BUS_DATA = { "6656": (0, "2836", "143"), # Burquitlam "6657": (1, "12972", "144"), # Metrotown @@ -23,8 +23,8 @@ 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. - This will handle those cases as well. + 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 @@ -100,7 +100,14 @@ async def fetch_static_schedule(client: AsyncClient) -> pd.DataFrame: def get_next_departures(schedule: pd.DataFrame, n: int = 3) -> pd.DataFrame: """ - Gets the next departure time for each bus based on the current time in seconds since midnight + 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()) diff --git a/src/translink/models.py b/src/translink/models.py index efac5010..164ee062 100644 --- a/src/translink/models.py +++ b/src/translink/models.py @@ -1,17 +1,20 @@ from enum import StrEnum -from pydantic import BaseModel +from pydantic import BaseModel, Field class BusStatus(StrEnum): - INCOMING_AT = "INCOMING_AT" - STOPPED_AT = "STOPPED_AT" - IN_TRANSIT_TO = "IN_TRANSIT_TO" + INCOMING_AT = "INCOMING_AT" # The vehicle is going to stop at the next bus stop + STOPPED_AT = "STOPPED_AT" # The vehicle is currently waiting at a bus stop + IN_TRANSIT_TO = "IN_TRANSIT_TO" # The vehicle has departed its previous stop and in transit class BusScheduleEntry(BaseModel): - bus_number: str - scheduled_departure_time: int - realtime_time: int - delay_seconds: int - status: BusStatus + route_number: str = Field(..., description="The bus route number.") + scheduled_departure_time: int = Field(..., description="Unix timestamp for the scheduled arrival 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.", + ) + status: BusStatus = Field(..., description="The bus status: incoming, stopped, or in transit to") diff --git a/src/translink/urls.py b/src/translink/urls.py index b3cff2ba..307e84b9 100644 --- a/src/translink/urls.py +++ b/src/translink/urls.py @@ -40,14 +40,14 @@ async def fetch_feed(client: httpx.AsyncClient, url: str, params: dict[str, Any] @router.get( - "/schedule", + "/realtime", description="Get TransLink bus schedule", - response_description="Real-time information for bus schedules", + response_description="Realtime information for bus schedules", response_model=list[BusScheduleEntry], - operation_id="get_bus_schedules", + operation_id="get_realtime_schedule", ) -async def get_bus_schedules(request: Request): - # FeedMessage is generated at runtime, so the LSP can't find this function +async def get_realtime_schedule(request: Request): + # FeedMessage is generated at runtime, so the type checker can't find this function params = {"apikey": settings.translink_api_key} trip_feed, position_feed = await asyncio.gather( @@ -55,6 +55,7 @@ async def get_bus_schedules(request: Request): fetch_feed(request.app.state.http_client, POSITION_URL, params=params), ) + # Filter for the relevant vehicles vehicle_positions = {} for entity in position_feed.entity: if not entity.HasField("vehicle"): @@ -65,6 +66,8 @@ async def get_bus_schedules(request: Request): continue vehicle_positions[trip.trip_id] = entity.vehicle + # Go through all of the trips occurring and filter for the relevant ones, + # then determine which ones are delayed. result: list[BusScheduleEntry] = [] for entity in trip_feed.entity: if not entity.HasField("trip_update"): @@ -77,7 +80,7 @@ async def get_bus_schedules(request: Request): if bus_data is None or trip.direction_id != bus_data[0]: continue - _, stop_id, bus_number = bus_data + _, stop_id, route_number = 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 @@ -92,7 +95,7 @@ async def get_bus_schedules(request: Request): result.append( BusScheduleEntry( - bus_number=bus_number, + route_number=route_number, scheduled_departure_time=scheduled_time, realtime_time=stop.departure.time, delay_seconds=stop.departure.delay, From 52263916542a6929ac4bd922e1019d3703cb4a83 Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Sun, 17 May 2026 11:16:44 -0700 Subject: [PATCH 09/28] fix: remove __init__.py from officers directory --- src/officers/__init__.py | 0 src/translink/crud.py | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) delete mode 100755 src/officers/__init__.py diff --git a/src/officers/__init__.py b/src/officers/__init__.py deleted file mode 100755 index e69de29b..00000000 diff --git a/src/translink/crud.py b/src/translink/crud.py index 6ba18483..cac23efa 100644 --- a/src/translink/crud.py +++ b/src/translink/crud.py @@ -83,7 +83,7 @@ async def fetch_static_schedule(client: AsyncClient) -> pd.DataFrame: # 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") # cross join + 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 From 2c1a89f6ba8162452b15c2a0451a6a7dc18889fd Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Sun, 17 May 2026 12:08:36 -0700 Subject: [PATCH 10/28] chore: move fetching realtime schedule to crud file --- src/translink/crud.py | 94 +++++++++++++++++++++++++++++++++++++- src/translink/urls.py | 103 ++++++------------------------------------ 2 files changed, 107 insertions(+), 90 deletions(-) diff --git a/src/translink/crud.py b/src/translink/crud.py index cac23efa..2006659e 100644 --- a/src/translink/crud.py +++ b/src/translink/crud.py @@ -1,12 +1,22 @@ +import asyncio import io import zipfile from datetime import datetime -from typing import cast +from typing import Any, cast +import httpx import pandas as pd +from google.transit import gtfs_realtime_pb2 from httpx import AsyncClient +from config import settings from constants import TZ_INFO +from translink.models import BusScheduleEntry, BusStatus +from translink.types import FeedMessage + +REALTIME_URL = "https://gtfsapi.translink.ca/v3/gtfsrealtime" +POSITION_URL = "https://gtfsapi.translink.ca/v3/gtfsposition" + # Taken from the static data. # Key: Route ID @@ -21,6 +31,18 @@ } +def get_bus_status_string(status: int) -> BusStatus: + match status: + case 0: + return BusStatus.INCOMING_AT + case 1: + return BusStatus.STOPPED_AT + case 2: + return BusStatus.IN_TRANSIT_TO + case _: + raise ValueError(f"Unknown bus status: {status}") + + 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, @@ -118,3 +140,73 @@ def get_next_departures(schedule: pd.DataFrame, n: int = 3) -> pd.DataFrame: .head(n) .sort_values(["route_id", "departure_seconds"]) ) + + +async def fetch_feed(client: httpx.AsyncClient, url: str, params: dict[str, Any]): + response = await client.get(url, params=params) + feed = cast(FeedMessage, gtfs_realtime_pb2.FeedMessage()) # pyright: ignore[reportAttributeAccessIssue] + feed.ParseFromString(response.content) + return feed + + +async def fetch_realtime_schedule(client: AsyncClient) -> list[BusScheduleEntry]: + """ + Gets the real-time bus schedule from the TransLink GTFS Realtime API. + """ + # FeedMessage is generated at runtime, so the type checker can't find this function + params = {"apikey": settings.translink_api_key} + + trip_feed, position_feed = await asyncio.gather( + fetch_feed(client, REALTIME_URL, params=params), + fetch_feed(client, POSITION_URL, params=params), + ) + + # Filter for the relevant vehicles + vehicle_positions = {} + for entity in position_feed.entity: + if not entity.HasField("vehicle"): + continue + + trip = entity.vehicle.trip + if trip.route_id not in BUS_DATA or trip.direction_id != BUS_DATA[trip.route_id][0]: + continue + vehicle_positions[trip.trip_id] = entity.vehicle + + # Go through all of the trips occurring and filter for the relevant ones, + # then determine which ones are delayed. + result: list[BusScheduleEntry] = [] + 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 + + _, stop_id, route_number = 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 + + scheduled_time = stop.departure.time - stop.departure.delay + + trip_id = trip.trip_id + status = BusStatus.INCOMING_AT + vehicle = vehicle_positions.get(trip_id) + if vehicle: + status = get_bus_status_string(vehicle.current_status) + + result.append( + BusScheduleEntry( + route_number=route_number, + scheduled_departure_time=scheduled_time, + realtime_time=stop.departure.time, + delay_seconds=stop.departure.delay, + status=status, + ) + ) + + return result diff --git a/src/translink/urls.py b/src/translink/urls.py index 307e84b9..4e86e584 100644 --- a/src/translink/urls.py +++ b/src/translink/urls.py @@ -1,37 +1,7 @@ -import asyncio -from typing import Any, cast - -import httpx from fastapi import APIRouter, Request -from google.transit import gtfs_realtime_pb2 - -from config import settings -from translink.crud import BUS_DATA, fetch_static_schedule, get_next_departures -from translink.models import BusScheduleEntry, BusStatus -from translink.types import FeedMessage - -REALTIME_URL = "https://gtfsapi.translink.ca/v3/gtfsrealtime" -POSITION_URL = "https://gtfsapi.translink.ca/v3/gtfsposition" - - -def get_bus_status_string(status: int) -> BusStatus: - match status: - case 0: - return BusStatus.INCOMING_AT - case 1: - return BusStatus.STOPPED_AT - case 2: - return BusStatus.IN_TRANSIT_TO - case _: - raise ValueError(f"Unknown bus status: {status}") - - -async def fetch_feed(client: httpx.AsyncClient, url: str, params: dict[str, Any]): - response = await client.get(url, params=params) - feed = cast(FeedMessage, gtfs_realtime_pb2.FeedMessage()) # pyright: ignore[reportAttributeAccessIssue] - feed.ParseFromString(response.content) - return feed +from translink.crud import fetch_realtime_schedule, fetch_static_schedule, get_next_departures +from translink.models import BusScheduleEntry router = APIRouter( prefix="/translink", @@ -47,63 +17,7 @@ async def fetch_feed(client: httpx.AsyncClient, url: str, params: dict[str, Any] operation_id="get_realtime_schedule", ) async def get_realtime_schedule(request: Request): - # FeedMessage is generated at runtime, so the type checker can't find this function - params = {"apikey": settings.translink_api_key} - - trip_feed, position_feed = await asyncio.gather( - fetch_feed(request.app.state.http_client, REALTIME_URL, params=params), - fetch_feed(request.app.state.http_client, POSITION_URL, params=params), - ) - - # Filter for the relevant vehicles - vehicle_positions = {} - for entity in position_feed.entity: - if not entity.HasField("vehicle"): - continue - - trip = entity.vehicle.trip - if trip.route_id not in BUS_DATA or trip.direction_id != BUS_DATA[trip.route_id][0]: - continue - vehicle_positions[trip.trip_id] = entity.vehicle - - # Go through all of the trips occurring and filter for the relevant ones, - # then determine which ones are delayed. - result: list[BusScheduleEntry] = [] - 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 - - _, stop_id, route_number = 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 - - scheduled_time = stop.departure.time - stop.departure.delay - - trip_id = trip.trip_id - status = BusStatus.INCOMING_AT - vehicle = vehicle_positions.get(trip_id) - if vehicle: - status = get_bus_status_string(vehicle.current_status) - - result.append( - BusScheduleEntry( - route_number=route_number, - scheduled_departure_time=scheduled_time, - realtime_time=stop.departure.time, - delay_seconds=stop.departure.delay, - status=status, - ) - ) - - return result + return await fetch_realtime_schedule(request.app.state.http_client) @router.get( @@ -115,3 +29,14 @@ async def get_realtime_schedule(request: Request): async def get_static_schedule(request: Request): schedule = await fetch_static_schedule(request.app.state.http_client) return get_next_departures(schedule).to_dict(orient="records") + + +@router.get( + "/schedule", + description="Get a combination of the ", + response_description="Static TransLink schedule", + operation_id="get_bus_schedule", +) +async def get_bus_schedule(request: Request): + schedule = await fetch_static_schedule(request.app.state.http_client) + return get_next_departures(schedule).to_dict(orient="records") From 6f0f74e31ef97eaceb2de62a46594164d4a982e9 Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Sun, 17 May 2026 15:38:49 -0700 Subject: [PATCH 11/28] feat: add schedule endpoint Combines static schedule data with the realtime data to indicate if something is going on with the next 3 departures. --- src/translink/crud.py | 112 +++++++++++++++++++++++----------------- src/translink/models.py | 19 ++++--- src/translink/types.py | 7 --- src/translink/urls.py | 29 +++++------ 4 files changed, 90 insertions(+), 77 deletions(-) diff --git a/src/translink/crud.py b/src/translink/crud.py index 2006659e..d3a10e98 100644 --- a/src/translink/crud.py +++ b/src/translink/crud.py @@ -1,17 +1,15 @@ -import asyncio import io import zipfile -from datetime import datetime +from datetime import datetime, timedelta from typing import Any, cast -import httpx import pandas as pd from google.transit import gtfs_realtime_pb2 from httpx import AsyncClient from config import settings from constants import TZ_INFO -from translink.models import BusScheduleEntry, BusStatus +from translink.models import BusRealtimeResponse, BusScheduleResponse, BusStatus from translink.types import FeedMessage REALTIME_URL = "https://gtfsapi.translink.ca/v3/gtfsrealtime" @@ -31,18 +29,6 @@ } -def get_bus_status_string(status: int) -> BusStatus: - match status: - case 0: - return BusStatus.INCOMING_AT - case 1: - return BusStatus.STOPPED_AT - case 2: - return BusStatus.IN_TRANSIT_TO - case _: - raise ValueError(f"Unknown bus status: {status}") - - 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, @@ -142,39 +128,58 @@ def get_next_departures(schedule: pd.DataFrame, n: int = 3) -> pd.DataFrame: ) -async def fetch_feed(client: httpx.AsyncClient, url: str, params: dict[str, Any]): +async def fetch_feed(client: AsyncClient, url: str, params: dict[str, Any]): response = await client.get(url, params=params) feed = cast(FeedMessage, gtfs_realtime_pb2.FeedMessage()) # pyright: ignore[reportAttributeAccessIssue] feed.ParseFromString(response.content) return feed -async def fetch_realtime_schedule(client: AsyncClient) -> list[BusScheduleEntry]: - """ - Gets the real-time bus schedule from the TransLink GTFS Realtime API. - """ +async def fetch_realtime_schedule(client: AsyncClient) -> list[BusRealtimeResponse]: # FeedMessage is generated at runtime, so the type checker can't find this function - params = {"apikey": settings.translink_api_key} + trip_feed = await fetch_feed(client, REALTIME_URL, params={"apikey": settings.translink_api_key}) - trip_feed, position_feed = await asyncio.gather( - fetch_feed(client, REALTIME_URL, params=params), - fetch_feed(client, POSITION_URL, params=params), - ) + result: list[BusRealtimeResponse] = [] + for entity in trip_feed.entity: + if not entity.HasField("trip_update"): + continue - # Filter for the relevant vehicles - vehicle_positions = {} - for entity in position_feed.entity: - if not entity.HasField("vehicle"): + 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 - trip = entity.vehicle.trip - if trip.route_id not in BUS_DATA or trip.direction_id != BUS_DATA[trip.route_id][0]: + _, 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 - vehicle_positions[trip.trip_id] = entity.vehicle - # Go through all of the trips occurring and filter for the relevant ones, - # then determine which ones are delayed. - result: list[BusScheduleEntry] = [] + result.append( + BusRealtimeResponse( + 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_route_statuses(client: AsyncClient) -> list[BusScheduleResponse]: + """ + Gets the real-time bus schedule from the TransLink GTFS Realtime API and merge it with the static data. + """ + schedule = await fetch_static_schedule(client) + next_departures = get_next_departures(schedule) + # FeedMessage is generated at runtime, so the type checker can't find this function + trip_feed = await fetch_feed(client, REALTIME_URL, params={"apikey": settings.translink_api_key}) + + # 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 @@ -182,29 +187,40 @@ async def fetch_realtime_schedule(client: AsyncClient) -> list[BusScheduleEntry] 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 - _, stop_id, route_number = bus_data + _, 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 - scheduled_time = stop.departure.time - stop.departure.delay + 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 - trip_id = trip.trip_id - status = BusStatus.INCOMING_AT - vehicle = vehicle_positions.get(trip_id) - if vehicle: - status = get_bus_status_string(vehicle.current_status) + realtime_map[trip.trip_id] = (stop.departure.delay, status) + + result: list[BusScheduleResponse] = [] + now = datetime.now(tz=TZ_INFO) + midnight = now.replace(hour=0, minute=0, second=0, microsecond=0) + for _, row in next_departures.iterrows(): + scheduled_time = int((midnight + timedelta(seconds=cast(int, row["departure_seconds"]))).timestamp()) + + delay, status = realtime_map.get( + cast(str, row["trip_id"]), (0, BusStatus.OnTime) + ) # default to on time if no realtime info result.append( - BusScheduleEntry( - route_number=route_number, + BusScheduleResponse( + route_number=cast(str, row["bus_number"]), scheduled_departure_time=scheduled_time, - realtime_time=stop.departure.time, - delay_seconds=stop.departure.delay, + realtime_time=scheduled_time + delay, + delay_seconds=delay, status=status, ) ) diff --git a/src/translink/models.py b/src/translink/models.py index 164ee062..413f34d5 100644 --- a/src/translink/models.py +++ b/src/translink/models.py @@ -1,15 +1,15 @@ -from enum import StrEnum +from enum import Enum from pydantic import BaseModel, Field -class BusStatus(StrEnum): - INCOMING_AT = "INCOMING_AT" # The vehicle is going to stop at the next bus stop - STOPPED_AT = "STOPPED_AT" # The vehicle is currently waiting at a bus stop - IN_TRANSIT_TO = "IN_TRANSIT_TO" # The vehicle has departed its previous stop and in transit +class BusStatus(Enum): + Arrived = 1 + Delayed = 2 + OnTime = 3 -class BusScheduleEntry(BaseModel): +class BusRealtimeResponse(BaseModel): route_number: str = Field(..., description="The bus route number.") scheduled_departure_time: int = Field(..., description="Unix timestamp for the scheduled arrival time, in seconds.") realtime_time: int = Field(..., description="Unix timestamp for the buses actual arrival time, in seconds.") @@ -17,4 +17,9 @@ class BusScheduleEntry(BaseModel): ..., description="How delayed the bus is, in seconds. Positive numbers indicate the bus is late, negative is early.", ) - status: BusStatus = Field(..., description="The bus status: incoming, stopped, or in transit to") + + +class BusScheduleResponse(BusRealtimeResponse): + status: BusStatus = Field( + ..., description="Enum that indicates if the bus has arrived (1), is delayed (2), or is on time (3)" + ) diff --git a/src/translink/types.py b/src/translink/types.py index bb6fa20b..b27ab73c 100644 --- a/src/translink/types.py +++ b/src/translink/types.py @@ -2,8 +2,6 @@ from collections.abc import Iterator from typing import Protocol -from translink.models import BusStatus - class Trip(Protocol): trip_id: str @@ -35,12 +33,7 @@ def HasField(self, name: str) -> bool: ... class FeedEntity(Protocol): - class _Vehicle(Protocol): - trip: Trip - current_status: BusStatus - trip_update: TripUpdate - vehicle: _Vehicle def HasField(self, name: str) -> bool: ... diff --git a/src/translink/urls.py b/src/translink/urls.py index 4e86e584..5f5ee0b4 100644 --- a/src/translink/urls.py +++ b/src/translink/urls.py @@ -1,7 +1,7 @@ from fastapi import APIRouter, Request -from translink.crud import fetch_realtime_schedule, fetch_static_schedule, get_next_departures -from translink.models import BusScheduleEntry +from translink.crud import fetch_realtime_schedule, fetch_static_schedule, get_route_statuses +from translink.models import BusRealtimeResponse router = APIRouter( prefix="/translink", @@ -11,9 +11,9 @@ @router.get( "/realtime", - description="Get TransLink bus schedule", - response_description="Realtime information for bus schedules", - response_model=list[BusScheduleEntry], + description="Get the realtime TransLink bus status", + response_description="Realtime information for bus status", + response_model=list[BusRealtimeResponse], operation_id="get_realtime_schedule", ) async def get_realtime_schedule(request: Request): @@ -22,21 +22,20 @@ async def get_realtime_schedule(request: Request): @router.get( "/static", - description="Get the static TransLink schedule", - response_description="Static TransLink schedule", + description="Get the static TransLink departure schedule", + response_description="The static departure schedule for the buses at the upper bus loop.", operation_id="get_static_schedule", ) async def get_static_schedule(request: Request): - schedule = await fetch_static_schedule(request.app.state.http_client) - return get_next_departures(schedule).to_dict(orient="records") + return (await fetch_static_schedule(request.app.state.http_client)).to_dict(orient="records") @router.get( "/schedule", - description="Get a combination of the ", - response_description="Static TransLink schedule", - operation_id="get_bus_schedule", + description="Get the departure schedule with bus status", + response_description="The next three depature times with bus status information.", + response_model=list[BusRealtimeResponse], + operation_id="get_departure_schedule", ) -async def get_bus_schedule(request: Request): - schedule = await fetch_static_schedule(request.app.state.http_client) - return get_next_departures(schedule).to_dict(orient="records") +async def get_departure_schedule(request: Request): + return await get_route_statuses(request.app.state.http_client) From f8d19559629dd70c602cc6182abd43a677b9aa00 Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Sun, 17 May 2026 15:53:38 -0700 Subject: [PATCH 12/28] fix: add cancelled status --- src/translink/crud.py | 4 ++++ src/translink/models.py | 4 +++- src/translink/types.py | 3 +++ 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/translink/crud.py b/src/translink/crud.py index d3a10e98..f1175702 100644 --- a/src/translink/crud.py +++ b/src/translink/crud.py @@ -190,6 +190,10 @@ async def get_route_statuses(client: AsyncClient) -> list[BusScheduleResponse]: 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: diff --git a/src/translink/models.py b/src/translink/models.py index 413f34d5..1d2f27cb 100644 --- a/src/translink/models.py +++ b/src/translink/models.py @@ -7,6 +7,7 @@ class BusStatus(Enum): Arrived = 1 Delayed = 2 OnTime = 3 + Cancelled = 4 class BusRealtimeResponse(BaseModel): @@ -21,5 +22,6 @@ class BusRealtimeResponse(BaseModel): class BusScheduleResponse(BusRealtimeResponse): status: BusStatus = Field( - ..., description="Enum that indicates if the bus has arrived (1), is delayed (2), or is on time (3)" + ..., + 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/types.py b/src/translink/types.py index b27ab73c..4e54e87f 100644 --- a/src/translink/types.py +++ b/src/translink/types.py @@ -2,11 +2,14 @@ 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): From 52ab2956a26bb05e641bae9a29434ea187b39bbc Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Sun, 17 May 2026 17:18:25 -0700 Subject: [PATCH 13/28] feat: static schedule now cached when fetched --- src/alembic/env.py | 1 + ...8cfd64_create_translink_static_schedule.py | 35 +++++++++++++++ src/load_test_db.py | 1 + src/main.py | 6 --- src/translink/crud.py | 45 ++++++++++++++----- src/translink/models.py | 28 ++++++++++-- src/translink/tables.py | 15 +++++++ src/translink/urls.py | 31 ++++++++----- 8 files changed, 129 insertions(+), 33 deletions(-) create mode 100644 src/alembic/versions/c1a70c8cfd64_create_translink_static_schedule.py create mode 100644 src/translink/tables.py 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/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/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 bf0fb1e1..d7b30145 100755 --- a/src/main.py +++ b/src/main.py @@ -30,12 +30,6 @@ async def lifespan(app: FastAPI): Handles startup and shutdown events, see https://fastapi.tiangolo.com/advanced/events/ """ app.state.http_client = httpx.AsyncClient() - # app.state.shared_state = shared_state - - # with shared_lock: - # if "static_translink_schedule" not in shared_state: - # schedule = await load_static_schedule(app.state.http_client) - # shared_state["static_schedule"] = schedule.to_dict(orient="records") yield await app.state.http_client.aclose() if database.sessionmanager._engine is not None: diff --git a/src/translink/crud.py b/src/translink/crud.py index f1175702..a8f4dd91 100644 --- a/src/translink/crud.py +++ b/src/translink/crud.py @@ -1,15 +1,18 @@ import io import zipfile -from datetime import datetime, timedelta +from datetime import date, datetime, timedelta from typing import Any, cast import pandas as pd +import sqlalchemy from google.transit import gtfs_realtime_pb2 from httpx import AsyncClient from config import settings from constants import TZ_INFO -from translink.models import BusRealtimeResponse, BusScheduleResponse, BusStatus +from database import DBSession +from translink.models import BusStatus, TransLinkRealtimeResponse, TransLinkScheduleResponse +from translink.tables import TransLinkStaticScheduleDB from translink.types import FeedMessage REALTIME_URL = "https://gtfsapi.translink.ca/v3/gtfsrealtime" @@ -99,12 +102,30 @@ async def fetch_static_schedule(client: AsyncClient) -> pd.DataFrame: 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_seconds"]]).reset_index(drop=True) - return cast( - pd.DataFrame, - merged[["trip_id", "route_id", "bus_number", "departure_seconds"]].reset_index(drop=True), + +async def get_or_fetch_static_schedule(db_session: DBSession, client: AsyncClient) -> tuple[date, pd.DataFrame]: + today = datetime.now(tz=TZ_INFO).date() + + result = await db_session.scalar( + sqlalchemy.select(TransLinkStaticScheduleDB).where(TransLinkStaticScheduleDB.id == 1) ) + 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") + + await db_session.merge( + TransLinkStaticScheduleDB(id=1, date_fetched=today, schedule=result.to_dict(orient="records")) + ) + await db_session.commit() + + return (today, result) + def get_next_departures(schedule: pd.DataFrame, n: int = 3) -> pd.DataFrame: """ @@ -135,11 +156,11 @@ async def fetch_feed(client: AsyncClient, url: str, params: dict[str, Any]): return feed -async def fetch_realtime_schedule(client: AsyncClient) -> list[BusRealtimeResponse]: +async def fetch_realtime_schedule(client: AsyncClient) -> list[TransLinkRealtimeResponse]: # FeedMessage is generated at runtime, so the type checker can't find this function trip_feed = await fetch_feed(client, REALTIME_URL, params={"apikey": settings.translink_api_key}) - result: list[BusRealtimeResponse] = [] + result: list[TransLinkRealtimeResponse] = [] for entity in trip_feed.entity: if not entity.HasField("trip_update"): continue @@ -157,7 +178,7 @@ async def fetch_realtime_schedule(client: AsyncClient) -> list[BusRealtimeRespon continue result.append( - BusRealtimeResponse( + TransLinkRealtimeResponse( route_number=bus_number, scheduled_departure_time=stop.departure.time - stop.departure.delay, realtime_time=stop.departure.time, @@ -169,11 +190,11 @@ async def fetch_realtime_schedule(client: AsyncClient) -> list[BusRealtimeRespon return result -async def get_route_statuses(client: AsyncClient) -> list[BusScheduleResponse]: +async def get_route_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. """ - schedule = await fetch_static_schedule(client) + _, schedule = await get_or_fetch_static_schedule(db_session, client) next_departures = get_next_departures(schedule) # FeedMessage is generated at runtime, so the type checker can't find this function trip_feed = await fetch_feed(client, REALTIME_URL, params={"apikey": settings.translink_api_key}) @@ -209,7 +230,7 @@ async def get_route_statuses(client: AsyncClient) -> list[BusScheduleResponse]: realtime_map[trip.trip_id] = (stop.departure.delay, status) - result: list[BusScheduleResponse] = [] + result: list[TransLinkScheduleResponse] = [] now = datetime.now(tz=TZ_INFO) midnight = now.replace(hour=0, minute=0, second=0, microsecond=0) for _, row in next_departures.iterrows(): @@ -220,7 +241,7 @@ async def get_route_statuses(client: AsyncClient) -> list[BusScheduleResponse]: ) # default to on time if no realtime info result.append( - BusScheduleResponse( + TransLinkScheduleResponse( route_number=cast(str, row["bus_number"]), scheduled_departure_time=scheduled_time, realtime_time=scheduled_time + delay, diff --git a/src/translink/models.py b/src/translink/models.py index 1d2f27cb..3358c275 100644 --- a/src/translink/models.py +++ b/src/translink/models.py @@ -1,3 +1,4 @@ +from datetime import date from enum import Enum from pydantic import BaseModel, Field @@ -10,9 +11,28 @@ class BusStatus(Enum): Cancelled = 4 -class BusRealtimeResponse(BaseModel): +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="Unix timestamp for the scheduled departure time, in seconds.") + + +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 arrival time, in seconds.") + 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( ..., @@ -20,8 +40,8 @@ class BusRealtimeResponse(BaseModel): ) -class BusScheduleResponse(BusRealtimeResponse): +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)", + 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..4384e064 --- /dev/null +++ b/src/translink/tables.py @@ -0,0 +1,15 @@ +from datetime import date + +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) diff --git a/src/translink/urls.py b/src/translink/urls.py index 5f5ee0b4..ebd61a6b 100644 --- a/src/translink/urls.py +++ b/src/translink/urls.py @@ -1,7 +1,12 @@ from fastapi import APIRouter, Request -from translink.crud import fetch_realtime_schedule, fetch_static_schedule, get_route_statuses -from translink.models import BusRealtimeResponse +from database import DBSession +from translink.crud import ( + fetch_realtime_schedule, + get_or_fetch_static_schedule, + get_route_statuses, +) +from translink.models import TransLinkRealtimeResponse, TransLinkStaticResponse, TransLinkStaticScheduleEntry router = APIRouter( prefix="/translink", @@ -11,9 +16,9 @@ @router.get( "/realtime", - description="Get the realtime TransLink bus status", + description="Get the realtime TransLink bus status.", response_description="Realtime information for bus status", - response_model=list[BusRealtimeResponse], + response_model=list[TransLinkRealtimeResponse], operation_id="get_realtime_schedule", ) async def get_realtime_schedule(request: Request): @@ -22,20 +27,24 @@ async def get_realtime_schedule(request: Request): @router.get( "/static", - description="Get the static TransLink departure schedule", + 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(request: Request): - return (await fetch_static_schedule(request.app.state.http_client)).to_dict(orient="records") +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", + description="Get the departure schedule with bus status. Uses the cached version of the static schedule if it exists, otherwise it fetches the newest schedule.", response_description="The next three depature times with bus status information.", - response_model=list[BusRealtimeResponse], + response_model=list[TransLinkRealtimeResponse], operation_id="get_departure_schedule", ) -async def get_departure_schedule(request: Request): - return await get_route_statuses(request.app.state.http_client) +async def get_departure_schedule(db_session: DBSession, request: Request): + return await get_route_statuses(db_session, request.app.state.http_client) From a505f57640d7dfbf5a5ef9089abf203f409b7fae Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Sun, 17 May 2026 17:45:59 -0700 Subject: [PATCH 14/28] fix: add error handling --- src/translink/crud.py | 72 +++++++++++++++++++++++++++++++---------- src/translink/models.py | 3 +- src/translink/urls.py | 6 ++-- 3 files changed, 60 insertions(+), 21 deletions(-) diff --git a/src/translink/crud.py b/src/translink/crud.py index a8f4dd91..ec0d7805 100644 --- a/src/translink/crud.py +++ b/src/translink/crud.py @@ -1,10 +1,13 @@ 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.transit import gtfs_realtime_pb2 from httpx import AsyncClient @@ -70,8 +73,15 @@ 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 - static_response = await client.get("https://gtfs-static.translink.ca/gtfs/google_transit.zip") - z = zipfile.ZipFile(io.BytesIO(static_response.content)) + try: + static_response = await client.get("https://gtfs-static.translink.ca/gtfs/google_transit.zip") + 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) @@ -102,15 +112,21 @@ async def fetch_static_schedule(client: AsyncClient) -> pd.DataFrame: 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_seconds"]]).reset_index(drop=True) + return cast( + pd.DataFrame, merged[["trip_id", "route_id", "bus_number", "departure_time", "departure_seconds"]] + ).reset_index(drop=True) async def get_or_fetch_static_schedule(db_session: DBSession, client: AsyncClient) -> tuple[date, pd.DataFrame]: today = datetime.now(tz=TZ_INFO).date() - result = await db_session.scalar( - sqlalchemy.select(TransLinkStaticScheduleDB).where(TransLinkStaticScheduleDB.id == 1) - ) + 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)) @@ -119,10 +135,14 @@ async def get_or_fetch_static_schedule(db_session: DBSession, client: AsyncClien if result.empty: raise ValueError("No active schedule found for today") - await db_session.merge( - TransLinkStaticScheduleDB(id=1, date_fetched=today, schedule=result.to_dict(orient="records")) - ) - await db_session.commit() + 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) @@ -149,17 +169,24 @@ def get_next_departures(schedule: pd.DataFrame, n: int = 3) -> pd.DataFrame: ) -async def fetch_feed(client: AsyncClient, url: str, params: dict[str, Any]): - response = await client.get(url, params=params) - feed = cast(FeedMessage, gtfs_realtime_pb2.FeedMessage()) # pyright: ignore[reportAttributeAccessIssue] - feed.ParseFromString(response.content) - return feed +async def fetch_feed(client: AsyncClient, url: str, params: dict[str, Any]) -> FeedMessage | None: + try: + response = await client.get(url, params=params) + feed = cast(FeedMessage, gtfs_realtime_pb2.FeedMessage()) # pyright: ignore[reportAttributeAccessIssue] + feed.ParseFromString(response.content) + return feed + except httpx.HTTPError as e: + logging.error(f"Failed to fetch feed from {url}: {e}") + return None async def fetch_realtime_schedule(client: AsyncClient) -> list[TransLinkRealtimeResponse]: # FeedMessage is generated at runtime, so the type checker can't find this function trip_feed = await fetch_feed(client, REALTIME_URL, params={"apikey": settings.translink_api_key}) + if trip_feed is None: + return [] + result: list[TransLinkRealtimeResponse] = [] for entity in trip_feed.entity: if not entity.HasField("trip_update"): @@ -190,14 +217,25 @@ async def fetch_realtime_schedule(client: AsyncClient) -> list[TransLinkRealtime return result -async def get_route_statuses(db_session: DBSession, client: AsyncClient) -> list[TransLinkScheduleResponse]: +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. """ _, schedule = await get_or_fetch_static_schedule(db_session, client) next_departures = get_next_departures(schedule) - # FeedMessage is generated at runtime, so the type checker can't find this function trip_feed = await fetch_feed(client, REALTIME_URL, params={"apikey": settings.translink_api_key}) + if trip_feed is None: + return [ + TransLinkScheduleResponse( + route_number=cast(str, row["bus_number"]), + scheduled_departure_time=cast(int, row["departure_time"]), + realtime_time=cast(int, row["departure_time"]), + delay_seconds=0, + status=BusStatus.OnTime, + ) + 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]] = {} diff --git a/src/translink/models.py b/src/translink/models.py index 3358c275..227468e6 100644 --- a/src/translink/models.py +++ b/src/translink/models.py @@ -15,7 +15,8 @@ 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="Unix timestamp for the scheduled departure time, in seconds.") + 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): diff --git a/src/translink/urls.py b/src/translink/urls.py index ebd61a6b..e527e842 100644 --- a/src/translink/urls.py +++ b/src/translink/urls.py @@ -3,8 +3,8 @@ from database import DBSession from translink.crud import ( fetch_realtime_schedule, + get_departure_statuses, get_or_fetch_static_schedule, - get_route_statuses, ) from translink.models import TransLinkRealtimeResponse, TransLinkStaticResponse, TransLinkStaticScheduleEntry @@ -41,10 +41,10 @@ async def get_static_schedule(db_session: DBSession, request: Request): @router.get( "/schedule", - description="Get the departure schedule with bus status. Uses the cached version of the static schedule if it exists, otherwise it fetches the newest 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[TransLinkRealtimeResponse], operation_id="get_departure_schedule", ) async def get_departure_schedule(db_session: DBSession, request: Request): - return await get_route_statuses(db_session, request.app.state.http_client) + return await get_departure_statuses(db_session, request.app.state.http_client) From cb93e2e132658ea0b4052d1d9a620825fffb2dda Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Sun, 17 May 2026 20:36:45 -0700 Subject: [PATCH 15/28] tests: initial tests added --- tests/unit/test_translink.py | 551 +++++++++++++++++++++++++++++++++++ 1 file changed, 551 insertions(+) create mode 100644 tests/unit/test_translink.py diff --git a/tests/unit/test_translink.py b/tests/unit/test_translink.py new file mode 100644 index 00000000..8ef1c97e --- /dev/null +++ b/tests/unit/test_translink.py @@ -0,0 +1,551 @@ +import io +import zipfile +from datetime import date, datetime +from unittest.mock import AsyncMock, MagicMock, patch + +import pandas as pd +import pytest +from fastapi import status +from google.transit import gtfs_realtime_pb2 +from httpx import AsyncClient, Response + +from constants import TZ_INFO +from translink.crud import ( + BUS_DATA, + _gtfs_time_to_seconds, + fetch_realtime_schedule, + fetch_static_schedule, + get_next_departures, + get_or_fetch_static_schedule, +) +from translink.models import BusStatus, TransLinkRealtimeResponse, TransLinkScheduleResponse +from translink.tables import 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_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_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_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_http_client(feed.SerializeToString())) + assert results == [] + + +async def test__fetch_realtime_schedule_empty_feed(): + results = await fetch_realtime_schedule(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_http_client(feed.SerializeToString())) + assert len(results) == 2 + assert results[0].realtime_time < results[1].realtime_time + + +# --------------------------------------------------------------------------- +# 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() + + +# --------------------------------------------------------------------------- +# 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) From 3929a31b1159acf70c42f19a51d8a788c6d9797f Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Sun, 17 May 2026 20:48:25 -0700 Subject: [PATCH 16/28] docs: add documentation to use the TransLink API --- src/translink/README.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 src/translink/README.md diff --git a/src/translink/README.md b/src/translink/README.md new file mode 100644 index 00000000..0866435b --- /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=` +3. Make sure your database has the correct migrations `alembic upgrade head`. Reload the 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 From 3baf643d7d7438171938e096f6f33960d703cd5f Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Sun, 17 May 2026 20:50:51 -0700 Subject: [PATCH 17/28] fix: wrong response model on schedule endpoint --- src/translink/urls.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/translink/urls.py b/src/translink/urls.py index e527e842..2ced12a1 100644 --- a/src/translink/urls.py +++ b/src/translink/urls.py @@ -6,7 +6,12 @@ get_departure_statuses, get_or_fetch_static_schedule, ) -from translink.models import TransLinkRealtimeResponse, TransLinkStaticResponse, TransLinkStaticScheduleEntry +from translink.models import ( + TransLinkRealtimeResponse, + TransLinkScheduleResponse, + TransLinkStaticResponse, + TransLinkStaticScheduleEntry, +) router = APIRouter( prefix="/translink", @@ -43,7 +48,7 @@ async def get_static_schedule(db_session: DBSession, request: Request): "/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[TransLinkRealtimeResponse], + response_model=list[TransLinkScheduleResponse], operation_id="get_departure_schedule", ) async def get_departure_schedule(db_session: DBSession, request: Request): From d814ff43792ddc05aa7c3939c88d274ccb4b3a64 Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Sun, 17 May 2026 20:54:53 -0700 Subject: [PATCH 18/28] docs: fix a typo and add environment variable option --- src/translink/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/translink/README.md b/src/translink/README.md index 0866435b..43d76eb7 100644 --- a/src/translink/README.md +++ b/src/translink/README.md @@ -5,8 +5,8 @@ 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=` -3. Make sure your database has the correct migrations `alembic upgrade head`. Reload the your test database as well `python src/load_test_db.py` +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 From 02962c0a988cded5c0d60bba817c8501f06ca79d Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Sun, 17 May 2026 22:30:23 -0700 Subject: [PATCH 19/28] tests: add client fixture for endpoint tests --- tests/unit/conftest.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 tests/unit/conftest.py diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py new file mode 100644 index 00000000..b19213d0 --- /dev/null +++ b/tests/unit/conftest.py @@ -0,0 +1,15 @@ +from collections.abc import AsyncGenerator +from typing import Any +from unittest.mock import AsyncMock + +import pytest_asyncio +from httpx import ASGITransport, AsyncClient + +from main import app + + +@pytest_asyncio.fixture(scope="module", loop_scope="session") +async def client() -> AsyncGenerator[Any]: + app.state.http_client = AsyncMock(spec=AsyncClient) + async with AsyncClient(transport=ASGITransport(app), base_url="http://test") as client: + yield client From d853256dc840cb746f1c4100aada87100885001f Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Tue, 19 May 2026 19:10:38 -0700 Subject: [PATCH 20/28] test: test database is used exclusively for Pytest * When interacting with the application for anything other than Pytest the `main` database will be used * Logging when running tests has been suppressed --- src/database.py | 2 +- tests/conftest.py | 10 ++++++++++ tests/integration/conftest.py | 31 ++++++++++++++----------------- 3 files changed, 25 insertions(+), 18 deletions(-) create mode 100644 tests/conftest.py diff --git a/src/database.py b/src/database.py index d859db39..93bd571f 100644 --- a/src/database.py +++ b/src/database.py @@ -103,7 +103,7 @@ def setup_database(): # 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, + SQLALCHEMY_DATABASE_URL, {"echo": True}, ) 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) From dabd5b69f63ad5e32346a14cbaed3103877b6df1 Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Thu, 4 Jun 2026 16:19:19 -0700 Subject: [PATCH 21/28] fix: typo from another PR --- src/nominees/urls.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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() From cfbe05064bd94b6344f8f54a4f4a1d8e4f254802 Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Sun, 7 Jun 2026 12:27:17 -0700 Subject: [PATCH 22/28] fix: added a merge migration between events and translink --- ...2a5_merge_events_and_translink_branches.py | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 src/alembic/versions/401b254a02a5_merge_events_and_translink_branches.py 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 From f55e4e2d713d2131827ab0b9c5f43584b02d3c90 Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Thu, 25 Jun 2026 08:39:08 -0700 Subject: [PATCH 23/28] fix: sort static schedule data by departure_seconds in each route --- src/translink/crud.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/translink/crud.py b/src/translink/crud.py index ec0d7805..e3d148e7 100644 --- a/src/translink/crud.py +++ b/src/translink/crud.py @@ -112,9 +112,11 @@ async def fetch_static_schedule(client: AsyncClient) -> pd.DataFrame: 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) + 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]: From 344cf49da70ee07007a615758ee3ce3f10dc5ff0 Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Mon, 29 Jun 2026 23:36:34 -0700 Subject: [PATCH 24/28] feat(translink): add 90s realtime cache --- ...d0db277_create_translink_realtime_cache.py | 36 +++++++++ src/translink/crud.py | 78 +++++++++++++++++-- src/translink/tables.py | 12 ++- src/translink/urls.py | 4 +- tests/unit/test_translink.py | 61 +++++++++++++-- 5 files changed, 172 insertions(+), 19 deletions(-) create mode 100644 src/alembic/versions/f0c99d0db277_create_translink_realtime_cache.py 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/translink/crud.py b/src/translink/crud.py index e3d148e7..a6a681bf 100644 --- a/src/translink/crud.py +++ b/src/translink/crud.py @@ -15,11 +15,15 @@ from constants import TZ_INFO from database import DBSession from translink.models import BusStatus, TransLinkRealtimeResponse, TransLinkScheduleResponse -from translink.tables import TransLinkStaticScheduleDB +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. @@ -74,7 +78,7 @@ async def fetch_static_schedule(client: AsyncClient) -> pd.DataFrame: """ # Retrieve the static TransLink bus schedule data try: - static_response = await client.get("https://gtfs-static.translink.ca/gtfs/google_transit.zip") + static_response = await client.get(STATIC_URL) except httpx.HTTPError as e: raise RuntimeError(f"Failed to fetch static schedule: {e}") from e @@ -171,20 +175,77 @@ def get_next_departures(schedule: pd.DataFrame, n: int = 3) -> pd.DataFrame: ) +def _parse_feed(content: bytes) -> FeedMessage: + feed = cast(FeedMessage, gtfs_realtime_pb2.FeedMessage()) # pyright: ignore[reportAttributeAccessIssue] + feed.ParseFromString(content) + return feed + + +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) - feed = cast(FeedMessage, gtfs_realtime_pb2.FeedMessage()) # pyright: ignore[reportAttributeAccessIssue] - feed.ParseFromString(response.content) - return feed + return _parse_feed(response.content) except httpx.HTTPError as e: logging.error(f"Failed to fetch feed from {url}: {e}") return None -async def fetch_realtime_schedule(client: AsyncClient) -> list[TransLinkRealtimeResponse]: +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_feed(cached_feed.response_bytes) + + # 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_feed(cached_feed.response_bytes) + + response = await client.get(REALTIME_URL, params={"apikey": settings.translink_api_key}) + 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 _parse_feed(response.content) + except httpx.HTTPError 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_feed(cached_feed.response_bytes) + 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 fetch_feed(client, REALTIME_URL, params={"apikey": settings.translink_api_key}) + trip_feed = await get_or_fetch_realtime_feed(db_session, client) if trip_feed is None: return [] @@ -225,7 +286,8 @@ async def get_departure_statuses(db_session: DBSession, client: AsyncClient) -> """ _, schedule = await get_or_fetch_static_schedule(db_session, client) next_departures = get_next_departures(schedule) - trip_feed = await fetch_feed(client, REALTIME_URL, params={"apikey": settings.translink_api_key}) + 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 [ TransLinkScheduleResponse( diff --git a/src/translink/tables.py b/src/translink/tables.py index 4384e064..b00b777b 100644 --- a/src/translink/tables.py +++ b/src/translink/tables.py @@ -1,5 +1,6 @@ -from datetime import date +from datetime import date, datetime +from sqlalchemy import DateTime, LargeBinary from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import Mapped, mapped_column @@ -13,3 +14,12 @@ class TransLinkStaticScheduleDB(Base): 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/urls.py b/src/translink/urls.py index 2ced12a1..2fa2d566 100644 --- a/src/translink/urls.py +++ b/src/translink/urls.py @@ -26,8 +26,8 @@ response_model=list[TransLinkRealtimeResponse], operation_id="get_realtime_schedule", ) -async def get_realtime_schedule(request: Request): - return await fetch_realtime_schedule(request.app.state.http_client) +async def get_realtime_schedule(db_session: DBSession, request: Request): + return await fetch_realtime_schedule(db_session, request.app.state.http_client) @router.get( diff --git a/tests/unit/test_translink.py b/tests/unit/test_translink.py index 8ef1c97e..5dee3fb0 100644 --- a/tests/unit/test_translink.py +++ b/tests/unit/test_translink.py @@ -1,6 +1,6 @@ import io import zipfile -from datetime import date, datetime +from datetime import date, datetime, timedelta from unittest.mock import AsyncMock, MagicMock, patch import pandas as pd @@ -16,10 +16,11 @@ fetch_realtime_schedule, fetch_static_schedule, get_next_departures, + get_or_fetch_realtime_feed, get_or_fetch_static_schedule, ) from translink.models import BusStatus, TransLinkRealtimeResponse, TransLinkScheduleResponse -from translink.tables import TransLinkStaticScheduleDB +from translink.tables import TransLinkRealtimeCacheDB, TransLinkStaticScheduleDB pytestmark = pytest.mark.asyncio(loop_scope="session") @@ -309,7 +310,7 @@ async def test__fetch_realtime_schedule_parses_single_entity(): departure_unix=departure_unix, delay=120, ) - results = await fetch_realtime_schedule(mock_http_client(feed_bytes)) + results = await fetch_realtime_schedule(mock_db_session(), mock_http_client(feed_bytes)) assert len(results) == 1 r = results[0] @@ -328,7 +329,7 @@ async def test__fetch_realtime_schedule_ignores_wrong_direction(): stop_id="2836", departure_unix=1_700_000_000, ) - results = await fetch_realtime_schedule(mock_http_client(feed_bytes)) + results = await fetch_realtime_schedule(mock_db_session(), mock_http_client(feed_bytes)) assert results == [] @@ -340,7 +341,7 @@ async def test__fetch_realtime_schedule_ignores_unknown_route(): stop_id="9999", departure_unix=1_700_000_000, ) - results = await fetch_realtime_schedule(mock_http_client(feed_bytes)) + results = await fetch_realtime_schedule(mock_db_session(), mock_http_client(feed_bytes)) assert results == [] @@ -359,12 +360,12 @@ async def test__fetch_realtime_schedule_ignores_missing_stop(): stu.stop_id = "0000" stu.departure.time = 1_700_000_000 - results = await fetch_realtime_schedule(mock_http_client(feed.SerializeToString())) + 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_http_client(make_empty_feed_bytes())) + results = await fetch_realtime_schedule(mock_db_session(), mock_http_client(make_empty_feed_bytes())) assert results == [] @@ -389,11 +390,55 @@ async def test__fetch_realtime_schedule_sorted_by_time(): stu.stop_id = sid stu.departure.time = t - results = await fetch_realtime_schedule(mock_http_client(feed.SerializeToString())) + 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() + + # --------------------------------------------------------------------------- # Tests for get_or_fetch_static_schedule # --------------------------------------------------------------------------- From 2b8af791093bd2697d1fb49bf966d03cdd61921a Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Tue, 30 Jun 2026 01:12:33 -0700 Subject: [PATCH 25/28] fix: add error handling for empty GTFS responses --- src/translink/crud.py | 78 +++++++++++++++++++----------------- tests/unit/test_translink.py | 58 ++++++++++++++++++++++++++- 2 files changed, 99 insertions(+), 37 deletions(-) diff --git a/src/translink/crud.py b/src/translink/crud.py index a6a681bf..ee9cce60 100644 --- a/src/translink/crud.py +++ b/src/translink/crud.py @@ -8,6 +8,7 @@ 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 @@ -181,6 +182,20 @@ def _parse_feed(content: bytes) -> FeedMessage: 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: @@ -192,8 +207,9 @@ def _is_realtime_cache_fresh(cached_feed: TransLinkRealtimeCacheDB) -> bool: 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 as e: + except (httpx.HTTPError, DecodeError) as e: logging.error(f"Failed to fetch feed from {url}: {e}") return None @@ -206,7 +222,7 @@ async def get_or_fetch_realtime_feed(db_session: DBSession, client: AsyncClient) sqlalchemy.select(TransLinkRealtimeCacheDB).where(TransLinkRealtimeCacheDB.id == REALTIME_CACHE_ID) ) if cached_feed is not None and _is_realtime_cache_fresh(cached_feed): - return _parse_feed(cached_feed.response_bytes) + 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. @@ -219,9 +235,11 @@ async def get_or_fetch_realtime_feed(db_session: DBSession, client: AsyncClient) if cached_feed is not None and _is_realtime_cache_fresh(cached_feed): await db_session.commit() - return _parse_feed(cached_feed.response_bytes) + 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, @@ -230,12 +248,12 @@ async def get_or_fetch_realtime_feed(db_session: DBSession, client: AsyncClient) ) ) await db_session.commit() - return _parse_feed(response.content) - except httpx.HTTPError as e: + 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_feed(cached_feed.response_bytes) + return _parse_cached_feed(cached_feed) return None except sqlalchemy.exc.SQLAlchemyError as e: logging.error(f"Failed to use TransLink realtime cache: {e}") @@ -284,21 +302,23 @@ async def get_departure_statuses(db_session: DBSession, client: AsyncClient) -> """ 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 [ - TransLinkScheduleResponse( - route_number=cast(str, row["bus_number"]), - scheduled_departure_time=cast(int, row["departure_time"]), - realtime_time=cast(int, row["departure_time"]), - delay_seconds=0, - status=BusStatus.OnTime, - ) - for _, row in next_departures.iterrows() - ] + 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 @@ -332,24 +352,10 @@ async def get_departure_statuses(db_session: DBSession, client: AsyncClient) -> realtime_map[trip.trip_id] = (stop.departure.delay, status) - result: list[TransLinkScheduleResponse] = [] - now = datetime.now(tz=TZ_INFO) - midnight = now.replace(hour=0, minute=0, second=0, microsecond=0) - for _, row in next_departures.iterrows(): - scheduled_time = int((midnight + timedelta(seconds=cast(int, row["departure_seconds"]))).timestamp()) - - delay, status = realtime_map.get( - cast(str, row["trip_id"]), (0, BusStatus.OnTime) - ) # default to on time if no realtime info - - result.append( - TransLinkScheduleResponse( - route_number=cast(str, row["bus_number"]), - scheduled_departure_time=scheduled_time, - realtime_time=scheduled_time + delay, - delay_seconds=delay, - status=status, - ) + return [ + _response_from_static_row( + row, + *realtime_map.get(cast(str, row["trip_id"]), (0, BusStatus.OnTime)), ) - - return result + for _, row in next_departures.iterrows() + ] diff --git a/tests/unit/test_translink.py b/tests/unit/test_translink.py index 5dee3fb0..1cf9b13d 100644 --- a/tests/unit/test_translink.py +++ b/tests/unit/test_translink.py @@ -3,11 +3,12 @@ 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, Response +from httpx import AsyncClient, Request, Response from constants import TZ_INFO from translink.crud import ( @@ -15,6 +16,7 @@ _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, @@ -439,6 +441,24 @@ async def test__get_or_fetch_realtime_feed_refreshes_stale_cache(): 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 # --------------------------------------------------------------------------- @@ -495,6 +515,42 @@ async def test__get_or_fetch_static_schedule_db_write_failure_still_returns(): 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 # --------------------------------------------------------------------------- From f5cf94b9411dda4977b8340db66cb19ff7ce1e7d Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Tue, 30 Jun 2026 01:27:55 -0700 Subject: [PATCH 26/28] fix: use the test database when running tests --- src/database.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/database.py b/src/database.py index 93bd571f..54ddc356 100644 --- a/src/database.py +++ b/src/database.py @@ -93,17 +93,16 @@ async def session(self) -> AsyncGenerator[AsyncSession]: SQLALCHEMY_DATABASE_URL = "postgresql+asyncpg:///main" SQLALCHEMY_TEST_DATABASE_URL = "postgresql+asyncpg:///test" -print(f"Using database: {SQLALCHEMY_DATABASE_URL}") - # also TODO: make this nicer, using a class to hold state... # and use this in load_test_db for the test db as well? 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_DATABASE_URL, + db_url, {"echo": True}, ) From bd39213b9e3d265c703191e21b2d315e476e2f54 Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Tue, 30 Jun 2026 01:39:56 -0700 Subject: [PATCH 27/28] fix: use minimal FastAPI app so unit tests run --- tests/unit/conftest.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index b19213d0..b73b7770 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -3,13 +3,22 @@ from unittest.mock import AsyncMock import pytest_asyncio +from fastapi import FastAPI from httpx import ASGITransport, AsyncClient -from main import app +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 From eedc7937e72108f1b7b97c9a86823c113b92d73b Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Tue, 30 Jun 2026 01:46:52 -0700 Subject: [PATCH 28/28] feat: pytest GitHub actions now use uv --- .github/workflows/pytest_unit.yml | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) 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