From 13c4341f3ac34dd1d3f1c3bc406755763cfba607 Mon Sep 17 00:00:00 2001 From: Edward Betts Date: Mon, 25 May 2026 21:48:53 +0100 Subject: [PATCH] Add full type annotations and black formatting across all modules Annotated all functions with mypy --strict-compatible types (-> None, dict[str, Any], Generator types, etc.), added # type: ignore for untyped third-party libs (lxml), and reformatted with black. All 18 source files now pass mypy --strict with zero errors. Co-Authored-By: Claude Sonnet 4.6 --- app.py | 603 +++++++++++----- cache.py | 9 +- circle_line.py | 136 ++-- config/default.py | 6 +- scraper/eurostar.py | 184 ++--- scraper/gwr_fares.py | 32 +- scraper/realtime_trains.py | 61 +- tests/test_app.py | 959 +++++++++++++++++--------- tests/test_cache.py | 46 +- tests/test_eurostar_scraper.py | 242 ++++--- tests/test_playwright_return_fares.py | 107 ++- tests/test_rtt_scraper.py | 63 +- tests/test_trip_planner.py | 255 ++++--- trip_planner.py | 73 +- 14 files changed, 1802 insertions(+), 974 deletions(-) diff --git a/app.py b/app.py index a73b979..ed09bf5 100644 --- a/app.py +++ b/app.py @@ -2,9 +2,21 @@ Combine GWR Bristol→Paddington trains with Eurostar St Pancras→destination trains. """ -from flask import Flask, render_template, redirect, url_for, request, abort, jsonify, Response, stream_with_context +from flask import ( + Flask, + render_template, + redirect, + url_for, + request, + abort, + jsonify, + Response, + stream_with_context, +) +from flask.typing import ResponseReturnValue from datetime import date, timedelta from pathlib import Path +from typing import Any, Callable, Generator import json import os @@ -42,11 +54,11 @@ if os.path.exists(_local): import cache import circle_line -cache.CACHE_DIR = app.config["CACHE_DIR"] -circle_line._TXC_XML = app.config["CIRCLE_LINE_XML"] +cache.CACHE_DIR = app.config["CACHE_DIR"] # type: ignore[attr-defined] +circle_line._TXC_XML = app.config["CIRCLE_LINE_XML"] # type: ignore[attr-defined] -def _load_stations(): +def _load_stations() -> list[tuple[str, str]]: tsv = Path(__file__).parent / "data" / "direct_to_paddington.tsv" stations = [] for line in tsv.read_text().splitlines(): @@ -71,7 +83,7 @@ DESTINATIONS = { @app.route("/") -def index(): +def index() -> ResponseReturnValue: today = date.today().isoformat() default_min, default_max = _get_defaults() return render_template( @@ -91,12 +103,26 @@ VALID_MIN_CONNECTIONS = {45, 50, 60, 70, 80, 90, 100, 110, 120} VALID_MAX_CONNECTIONS = {60, 70, 80, 90, 100, 110, 120, 130, 140, 150, 160, 170, 180} VALID_INBOUND_MIN_CONNECTIONS = {20, 30, 40, 45, 50, 60, 70, 80, 90, 100, 110, 120} VALID_INBOUND_RETURN_MIN_CONNECTIONS = {30, 40, 50, 60} -VALID_INBOUND_MAX_CONNECTIONS = {60, 70, 80, 90, 100, 110, 120, 130, 140, 150, 160, 170, 180} +VALID_INBOUND_MAX_CONNECTIONS = { + 60, + 70, + 80, + 90, + 100, + 110, + 120, + 130, + 140, + 150, + 160, + 170, + 180, +} VALID_JOURNEY_TYPES = {"outbound", "inbound", "return"} -VALID_NR_CLASSES = {'walkon', 'advance_std', 'advance_1st'} -VALID_ES_CLASSES = {'standard', 'plus'} -DEFAULT_NR_CLASS = 'walkon' -DEFAULT_ES_CLASS = 'standard' +VALID_NR_CLASSES = {"walkon", "advance_std", "advance_1st"} +VALID_ES_CLASSES = {"standard", "plus"} +DEFAULT_NR_CLASS = "walkon" +DEFAULT_ES_CLASS = "standard" NR_TIMETABLE_PERIODS = [ (date(2026, 5, 17), date(2026, 12, 12), "2026-05-17_2026-12-12"), ] @@ -129,29 +155,39 @@ def _nr_weekday_cache_key(direction: str, station_crs: str, section_date: str) - ) -def _walkon_weekday_cache_key(direction: str, station_crs: str, section_date: str) -> str: +def _walkon_weekday_cache_key( + direction: str, station_crs: str, section_date: str +) -> str: return ( f"weekday_gwr_fares_{direction}_{station_crs}_" f"{_nr_timetable_period_key(section_date)}_{_weekday_for(section_date)}" ) -def _eurostar_exact_cache_key(direction: str, section_date: str, destination: str) -> str: +def _eurostar_exact_cache_key( + direction: str, section_date: str, destination: str +) -> str: return f"eurostar_{direction}_{section_date}_{destination}" -def _eurostar_weekday_cache_key(direction: str, section_date: str, destination: str) -> str: +def _eurostar_weekday_cache_key( + direction: str, section_date: str, destination: str +) -> str: return ( f"weekday_eurostar_{direction}_{destination}_" f"{_month_for(section_date)}_{_weekday_for(section_date)}" ) -def _eurostar_return_exact_cache_key(travel_date: str, return_date: str, destination: str) -> str: +def _eurostar_return_exact_cache_key( + travel_date: str, return_date: str, destination: str +) -> str: return f"eurostar_return_{travel_date}_{return_date}_{destination}" -def _eurostar_return_weekday_cache_key(travel_date: str, return_date: str, destination: str) -> str: +def _eurostar_return_weekday_cache_key( + travel_date: str, return_date: str, destination: str +) -> str: return ( f"weekday_eurostar_return_{destination}_" f"{_month_for(travel_date)}_{_weekday_for(travel_date)}_" @@ -159,7 +195,7 @@ def _eurostar_return_weekday_cache_key(travel_date: str, return_date: str, desti ) -def _strip_nr_timetable(trains): +def _strip_nr_timetable(trains: list[dict[str, Any]]) -> list[dict[str, Any]]: keys = { "depart_bristol", "arrive_paddington", @@ -171,7 +207,7 @@ def _strip_nr_timetable(trains): return [{k: train[k] for k in keys if k in train} for train in trains] -def _strip_eurostar_timetable(services): +def _strip_eurostar_timetable(services: list[dict[str, Any]]) -> list[dict[str, Any]]: keys = { "depart_st_pancras", "arrive_destination", @@ -183,7 +219,7 @@ def _strip_eurostar_timetable(services): return [{k: service[k] for k in keys if k in service} for service in services] -def _strip_eurostar_return_timetable(es_return): +def _strip_eurostar_return_timetable(es_return: Any) -> dict[str, list[dict[str, Any]]]: if not isinstance(es_return, dict): return {"outbound": [], "inbound": []} return { @@ -192,14 +228,20 @@ def _strip_eurostar_return_timetable(es_return): } -def _timetable_signature(data) -> str: +def _timetable_signature(data: Any) -> str: return json.dumps(data, sort_keys=True, separators=(",", ":")) -def _eurostar_prices_by_row(section_id: str, direction: str, services): +def _eurostar_prices_by_row( + section_id: str, direction: str, services: list[dict[str, Any]] +) -> dict[str, dict[str, Any]]: prices = {} for service in services: - key = service.get("depart_st_pancras") if direction == "outbound" else service.get("depart_destination") + key = ( + service.get("depart_st_pancras") + if direction == "outbound" + else service.get("depart_destination") + ) if not key: continue prices[f"{section_id}:{key}"] = { @@ -217,22 +259,22 @@ def _eurostar_prices_by_row(section_id: str, direction: str, services): return prices -def _get_defaults(): +def _get_defaults() -> tuple[int, int]: return ( app.config["DEFAULT_MIN_CONNECTION"], app.config["DEFAULT_MAX_CONNECTION"], ) -def _parse_connection(raw, default, valid_set): +def _parse_connection(raw: str | None, default: int, valid_set: set[int]) -> int: try: - val = int(raw) + val = int(raw or "") except (TypeError, ValueError): return default return val if val in valid_set else default -def _section_trip_fares(section): +def _section_trip_fares(section: dict[str, Any]) -> dict[str, Any]: trip_fares = {} for row in section["rows"]: circle_svcs = row.get("circle_services") or [] @@ -248,7 +290,10 @@ def _section_trip_fares(section): else None ) es_plus = ( - {"price": row["eurostar_plus_price"], "seats": row.get("eurostar_plus_seats")} + { + "price": row["eurostar_plus_price"], + "seats": row.get("eurostar_plus_seats"), + } if row.get("eurostar_plus_price") is not None else None ) @@ -264,15 +309,22 @@ def _section_trip_fares(section): return trip_fares -def _build_summary_html(sections, journey_type, from_cache_parts, provisional_timetable): - def pl(n, word): +def _build_summary_html( + sections: list[dict[str, Any]], + journey_type: str, + from_cache_parts: list[str], + provisional_timetable: bool, +) -> str: + def pl(n: int, word: str) -> str: return f"{n} {word}{'s' if n != 1 else ''}" if journey_type == "return": parts = [] for s in sections: label = "Outbound" if s["direction"] == "outbound" else "Return" - parts.append(f"{label}: {pl(s['gwr_count'], 'National Rail service')}, {pl(s['eurostar_count'], 'Eurostar service')}") + parts.append( + f"{label}: {pl(s['gwr_count'], 'National Rail service')}, {pl(s['eurostar_count'], 'Eurostar service')}" + ) html = "  ·  ".join(parts) else: s = sections[0] @@ -286,13 +338,13 @@ def _build_summary_html(sections, journey_type, from_cache_parts, provisional_ti def _results_url( - station_crs, - slug, - travel_date, - journey_type="outbound", - return_date=None, - **params, -): + station_crs: str, + slug: str, + travel_date: str, + journey_type: str = "outbound", + return_date: str | None = None, + **params: Any, +) -> str: params = {k: v for k, v in params.items() if v is not None} if journey_type == "return": return url_for( @@ -315,7 +367,7 @@ def _results_url( @app.route("/search") -def search(): +def search() -> ResponseReturnValue: slug = request.args.get("destination", "") travel_date = request.args.get("travel_date", "") return_date = request.args.get("return_date", "") @@ -326,8 +378,14 @@ def search(): if station_crs not in STATION_BY_CRS: station_crs = "BRI" if journey_type == "inbound": - default_min, default_max = INBOUND_MIN_CONNECTION_MINUTES, INBOUND_MAX_CONNECTION_MINUTES - valid_min, valid_max = VALID_INBOUND_MIN_CONNECTIONS, VALID_INBOUND_MAX_CONNECTIONS + default_min, default_max = ( + INBOUND_MIN_CONNECTION_MINUTES, + INBOUND_MAX_CONNECTION_MINUTES, + ) + valid_min, valid_max = ( + VALID_INBOUND_MIN_CONNECTIONS, + VALID_INBOUND_MAX_CONNECTIONS, + ) else: default_min, default_max = _get_defaults() valid_min, valid_max = VALID_MIN_CONNECTIONS, VALID_MAX_CONNECTIONS @@ -345,11 +403,17 @@ def search(): es_class = DEFAULT_ES_CLASS if journey_type == "return": try: - if return_date and date.fromisoformat(return_date) < date.fromisoformat(travel_date): + if return_date and date.fromisoformat(return_date) < date.fromisoformat( + travel_date + ): return_date = "" except ValueError: return_date = "" - if slug in DESTINATIONS and travel_date and (journey_type != "return" or return_date): + if ( + slug in DESTINATIONS + and travel_date + and (journey_type != "return" or return_date) + ): return redirect( _results_url( station_crs=station_crs, @@ -367,7 +431,7 @@ def search(): @app.route("/results///") -def results(station_crs, slug, travel_date): +def results(station_crs: str, slug: str, travel_date: str) -> ResponseReturnValue: return _results( station_crs, slug, @@ -378,11 +442,19 @@ def results(station_crs, slug, travel_date): @app.route("/results////return/") -def return_results(station_crs, slug, travel_date, return_date): +def return_results( + station_crs: str, slug: str, travel_date: str, return_date: str +) -> ResponseReturnValue: return _results(station_crs, slug, travel_date, "return", return_date) -def _results(station_crs, slug, travel_date, journey_type, return_date): +def _results( + station_crs: str, + slug: str, + travel_date: str, + journey_type: str, + return_date: str | None, +) -> ResponseReturnValue: departure_station_name = STATION_BY_CRS.get(station_crs) if departure_station_name is None: abort(404) @@ -394,14 +466,22 @@ def _results(station_crs, slug, travel_date, journey_type, return_date): journey_type = "outbound" if journey_type == "return": try: - if not return_date or date.fromisoformat(return_date) < date.fromisoformat(travel_date): + if not return_date or date.fromisoformat(return_date) < date.fromisoformat( + travel_date + ): return redirect(url_for("index")) except ValueError: return redirect(url_for("index")) if journey_type == "inbound": - default_min, default_max = INBOUND_MIN_CONNECTION_MINUTES, INBOUND_MAX_CONNECTION_MINUTES - valid_min, valid_max = VALID_INBOUND_MIN_CONNECTIONS, VALID_INBOUND_MAX_CONNECTIONS + default_min, default_max = ( + INBOUND_MIN_CONNECTION_MINUTES, + INBOUND_MAX_CONNECTION_MINUTES, + ) + valid_min, valid_max = ( + VALID_INBOUND_MIN_CONNECTIONS, + VALID_INBOUND_MAX_CONNECTIONS, + ) else: default_min, default_max = _get_defaults() valid_min, valid_max = VALID_MIN_CONNECTIONS, VALID_MAX_CONNECTIONS @@ -420,12 +500,22 @@ def _results(station_crs, slug, travel_date, journey_type, return_date): inbound_min_connection = INBOUND_MIN_CONNECTION_MINUTES if journey_type == "return": - def _p(raw, default, valid): + + def _p(raw: str | None, default: str, valid: set[str]) -> str: return raw if raw in valid else default - nr_class_out = _p(request.args.get("nr_class_out"), DEFAULT_NR_CLASS, VALID_NR_CLASSES) - nr_class_in = _p(request.args.get("nr_class_in"), DEFAULT_NR_CLASS, VALID_NR_CLASSES) - es_class_out = _p(request.args.get("es_class_out"), DEFAULT_ES_CLASS, VALID_ES_CLASSES) - es_class_in = _p(request.args.get("es_class_in"), DEFAULT_ES_CLASS, VALID_ES_CLASSES) + + nr_class_out = _p( + request.args.get("nr_class_out"), DEFAULT_NR_CLASS, VALID_NR_CLASSES + ) + nr_class_in = _p( + request.args.get("nr_class_in"), DEFAULT_NR_CLASS, VALID_NR_CLASSES + ) + es_class_out = _p( + request.args.get("es_class_out"), DEFAULT_ES_CLASS, VALID_ES_CLASSES + ) + es_class_in = _p( + request.args.get("es_class_in"), DEFAULT_ES_CLASS, VALID_ES_CLASSES + ) inbound_min_connection = _parse_connection( request.args.get("min_connection_in"), INBOUND_MIN_CONNECTION_MINUTES, @@ -437,12 +527,8 @@ def _results(station_crs, slug, travel_date, journey_type, return_date): render = request.args.get("render") - if ( - render not in ("full", "stream") - and not ( - app.config.get("TESTING") - and request.args.get("progressive") != "1" - ) + if render not in ("full", "stream") and not ( + app.config.get("TESTING") and request.args.get("progressive") != "1" ): dt = date.fromisoformat(travel_date) travel_date_display = dt.strftime("%A %-d %B %Y") @@ -489,7 +575,7 @@ def _results(station_crs, slug, travel_date, journey_type, return_date): from_cache_parts = [] provisional_timetable = False - def cached_fetch(key, ttl, fetcher, label): + def cached_fetch(key: str, ttl: int, fetcher: Callable[[], Any], label: str) -> Any: cached = get_cached(key, ttl=ttl) if cached is not None: from_cache_parts.append(key) @@ -502,7 +588,14 @@ def _results(station_crs, slug, travel_date, journey_type, return_date): error_messages.append(f"Could not fetch {label}: {e}") return [] if label != "GWR fares" else {} - def cached_timetable_fetch(exact_key, weekday_key, fetcher, label, stripper, ttl=None): + def cached_timetable_fetch( + exact_key: str, + weekday_key: str, + fetcher: Callable[[], Any], + label: str, + stripper: Callable[[Any], Any], + ttl: int | None = None, + ) -> tuple[Any, bool]: nonlocal provisional_timetable cached = get_cached(exact_key, ttl=ttl) if cached is not None: @@ -524,15 +617,18 @@ def _results(station_crs, slug, travel_date, journey_type, return_date): return {"outbound": [], "inbound": []}, False return [], False - es_return = None + es_return: dict[str, Any] = {"outbound": [], "inbound": []} es_return_provisional = False - def _fetch_es_return(): + def _fetch_es_return() -> None: nonlocal es_return, es_return_provisional + assert return_date is not None es_return, es_return_provisional = cached_timetable_fetch( _eurostar_return_exact_cache_key(travel_date, return_date, destination), _eurostar_return_weekday_cache_key(travel_date, return_date, destination), - lambda: eurostar_scraper.fetch_return(destination, travel_date, return_date), + lambda: eurostar_scraper.fetch_return( + destination, travel_date, return_date + ), "Eurostar return times", _strip_eurostar_return_timetable, 24 * 3600, @@ -540,15 +636,24 @@ def _results(station_crs, slug, travel_date, journey_type, return_date): if not isinstance(es_return, dict): es_return = {"outbound": [], "inbound": []} - def build_section(section_id, direction, section_date, eurostar_services=None): + def build_section( + section_id: str, + direction: str, + section_date: str, + eurostar_services: list[dict[str, Any]] | None = None, + ) -> dict[str, Any]: section_min_connection = min_connection section_max_connection = max_connection if journey_type == "return" and direction == "inbound": section_min_connection = inbound_min_connection section_max_connection = INBOUND_MAX_CONNECTION_MINUTES - rtt_direction = "to_paddington" if direction == "outbound" else "from_paddington" + rtt_direction = ( + "to_paddington" if direction == "outbound" else "from_paddington" + ) rtt_cache_key = _nr_exact_cache_key(rtt_direction, station_crs, section_date) - rtt_weekday_cache_key = _nr_weekday_cache_key(rtt_direction, station_crs, section_date) + rtt_weekday_cache_key = _nr_weekday_cache_key( + rtt_direction, station_crs, section_date + ) gwr_cache_key = f"gwr_fares_{rtt_direction}_{station_crs}_{section_date}" advance_cache_key = f"gwr_advance_{rtt_direction}_{station_crs}_{section_date}" @@ -564,19 +669,29 @@ def _results(station_crs, slug, travel_date, journey_type, return_date): trains, nr_provisional = cached_timetable_fetch( rtt_cache_key, rtt_weekday_cache_key, - lambda: rtt_scraper.fetch_from_paddington(section_date, user_agent, station_crs), + lambda: rtt_scraper.fetch_from_paddington( + section_date, user_agent, station_crs + ), "GWR trains", _strip_nr_timetable, ) es_provisional = es_return_provisional if journey_type == "return" else False if eurostar_services is None: - es_cache_key = _eurostar_exact_cache_key(direction, section_date, destination) - es_weekday_cache_key = _eurostar_weekday_cache_key(direction, section_date, destination) + es_cache_key = _eurostar_exact_cache_key( + direction, section_date, destination + ) + es_weekday_cache_key = _eurostar_weekday_cache_key( + direction, section_date, destination + ) es_fetcher = ( (lambda: eurostar_scraper.fetch(destination, section_date)) if direction == "outbound" - else (lambda: eurostar_scraper.fetch(destination, section_date, direction=direction)) + else ( + lambda: eurostar_scraper.fetch( + destination, section_date, direction=direction + ) + ) ) eurostar_services, es_provisional = cached_timetable_fetch( es_cache_key, @@ -587,12 +702,18 @@ def _results(station_crs, slug, travel_date, journey_type, return_date): 24 * 3600, ) - fare_direction = "to_paddington" if direction == "outbound" else "from_paddington" - gwr_fares = {} + fare_direction = ( + "to_paddington" if direction == "outbound" else "from_paddington" + ) + gwr_fares: dict[str, Any] = {} cached_advance = get_cached(advance_cache_key, ttl=24 * 3600) - walkon_weekday_key = _walkon_weekday_cache_key(rtt_direction, station_crs, section_date) + walkon_weekday_key = _walkon_weekday_cache_key( + rtt_direction, station_crs, section_date + ) exact_walkon = get_cached(gwr_cache_key, ttl=30 * 24 * 3600) - cached_walkon = exact_walkon if exact_walkon is not None else get_cached(walkon_weekday_key) + cached_walkon = ( + exact_walkon if exact_walkon is not None else get_cached(walkon_weekday_key) + ) if direction == "outbound": trips = combine_trips( @@ -617,7 +738,10 @@ def _results(station_crs, slug, travel_date, journey_type, return_date): ] rows = sorted( [{"row_type": "trip", "direction": direction, **trip} for trip in trips] - + [{"row_type": "unreachable", "direction": direction, **svc} for svc in unreachable], + + [ + {"row_type": "unreachable", "direction": direction, **svc} + for svc in unreachable + ], key=lambda row: row["depart_st_pancras"], ) else: @@ -643,16 +767,27 @@ def _results(station_crs, slug, travel_date, journey_type, return_date): ] rows = sorted( [{"row_type": "trip", "direction": direction, **trip} for trip in trips] - + [{"row_type": "unreachable", "direction": direction, **svc} for svc in unreachable], + + [ + {"row_type": "unreachable", "direction": direction, **svc} + for svc in unreachable + ], key=lambda row: row["depart_destination"], ) es_by_key = { - (svc.get("depart_st_pancras") if direction == "outbound" else svc.get("depart_destination")): svc + ( + svc.get("depart_st_pancras") + if direction == "outbound" + else svc.get("depart_destination") + ): svc for svc in eurostar_services } for row in rows: - key = row.get("depart_st_pancras") if direction == "outbound" else row.get("depart_destination") + key = ( + row.get("depart_st_pancras") + if direction == "outbound" + else row.get("depart_destination") + ) es = es_by_key.get(key, {}) row["eurostar_price"] = es.get("price") row["eurostar_seats"] = es.get("seats") @@ -701,7 +836,8 @@ def _results(station_crs, slug, travel_date, journey_type, return_date): } if render == "stream": - def generate(): + + def generate() -> Generator[str, None, None]: dt = date.fromisoformat(travel_date) prev_date = (dt - timedelta(days=1)).isoformat() next_date = (dt + timedelta(days=1)).isoformat() @@ -716,7 +852,10 @@ def _results(station_crs, slug, travel_date, journey_type, return_date): next_return_date = (return_dt + timedelta(days=1)).isoformat() eurostar_url = eurostar_scraper.search_url( - destination, travel_date, direction=journey_type, return_date=return_date + destination, + travel_date, + direction=journey_type, + return_date=return_date, ) rtt_url = RTT_PADDINGTON_URL.format(crs=station_crs, date=travel_date) rtt_station_url = RTT_STATION_URL.format(crs=station_crs, date=travel_date) @@ -727,16 +866,28 @@ def _results(station_crs, slug, travel_date, journey_type, return_date): url_es = None if es_class == DEFAULT_ES_CLASS else es_class if journey_type == "return": - common_url_args = { + common_url_args: dict[str, Any] = { "journey_type": journey_type, "return_date": return_date, "min_connection": url_min, "max_connection": url_max, - "min_connection_in": None if inbound_min_connection == INBOUND_MIN_CONNECTION_MINUTES else inbound_min_connection, - "nr_class_out": None if nr_class_out == DEFAULT_NR_CLASS else nr_class_out, - "nr_class_in": None if nr_class_in == DEFAULT_NR_CLASS else nr_class_in, - "es_class_out": None if es_class_out == DEFAULT_ES_CLASS else es_class_out, - "es_class_in": None if es_class_in == DEFAULT_ES_CLASS else es_class_in, + "min_connection_in": ( + None + if inbound_min_connection == INBOUND_MIN_CONNECTION_MINUTES + else inbound_min_connection + ), + "nr_class_out": ( + None if nr_class_out == DEFAULT_NR_CLASS else nr_class_out + ), + "nr_class_in": ( + None if nr_class_in == DEFAULT_NR_CLASS else nr_class_in + ), + "es_class_out": ( + None if es_class_out == DEFAULT_ES_CLASS else es_class_out + ), + "es_class_in": ( + None if es_class_in == DEFAULT_ES_CLASS else es_class_in + ), } else: common_url_args = { @@ -749,47 +900,90 @@ def _results(station_crs, slug, travel_date, journey_type, return_date): } prev_results_url = _results_url( - station_crs, slug, prev_date, + station_crs, + slug, + prev_date, **{**common_url_args, "return_date": prev_return_date}, ) next_results_url = _results_url( - station_crs, slug, next_date, + station_crs, + slug, + next_date, **{**common_url_args, "return_date": next_return_date}, ) - prev_outbound_url = _results_url(station_crs, slug, prev_date, **common_url_args) - next_outbound_url = _results_url(station_crs, slug, next_date, **common_url_args) + prev_outbound_url = _results_url( + station_crs, slug, prev_date, **common_url_args + ) + next_outbound_url = _results_url( + station_crs, slug, next_date, **common_url_args + ) prev_return_url = ( - _results_url(station_crs, slug, travel_date, **{**common_url_args, "return_date": prev_return_date}) - if return_date else None + _results_url( + station_crs, + slug, + travel_date, + **{**common_url_args, "return_date": prev_return_date}, + ) + if return_date + else None ) next_return_url = ( - _results_url(station_crs, slug, travel_date, **{**common_url_args, "return_date": next_return_date}) - if return_date else None + _results_url( + station_crs, + slug, + travel_date, + **{**common_url_args, "return_date": next_return_date}, + ) + if return_date + else None ) destination_links = [ ( destination_slug, destination_name, - _results_url(station_crs, destination_slug, travel_date, **common_url_args), + _results_url( + station_crs, destination_slug, travel_date, **common_url_args + ), ) for destination_slug, destination_name in DESTINATIONS.items() ] results_base_url = _results_url( - station_crs, slug, travel_date, - journey_type=journey_type, return_date=return_date, + station_crs, + slug, + travel_date, + journey_type=journey_type, + return_date=return_date, ) if journey_type == "return": shell_sections = [ - {"id": "outbound", "direction": "outbound", "min_connection": min_connection, "max_connection": max_connection}, - {"id": "inbound", "direction": "inbound", "min_connection": inbound_min_connection, "max_connection": INBOUND_MAX_CONNECTION_MINUTES}, + { + "id": "outbound", + "direction": "outbound", + "min_connection": min_connection, + "max_connection": max_connection, + }, + { + "id": "inbound", + "direction": "inbound", + "min_connection": inbound_min_connection, + "max_connection": INBOUND_MAX_CONNECTION_MINUTES, + }, ] shell_nr_classes = {"outbound": nr_class_out, "inbound": nr_class_in} shell_es_classes = {"outbound": es_class_out, "inbound": es_class_in} - shell_section_directions = {"outbound": "outbound", "inbound": "inbound"} + shell_section_directions = { + "outbound": "outbound", + "inbound": "inbound", + } else: shell_sections = [ - {"id": "main", "direction": journey_type, "min_connection": min_connection, "max_connection": max_connection}, + { + "id": "main", + "direction": journey_type, + "min_connection": min_connection, + "max_connection": max_connection, + }, ] shell_nr_classes = {"main": nr_class} shell_es_classes = {"main": es_class} @@ -830,22 +1024,34 @@ def _results(station_crs, slug, travel_date, journey_type, return_date): valid_min_connections=sorted(valid_min), valid_max_connections=sorted(valid_max), inbound_min_connection=inbound_min_connection, - valid_inbound_return_min_connections=sorted(VALID_INBOUND_RETURN_MIN_CONNECTIONS), + valid_inbound_return_min_connections=sorted( + VALID_INBOUND_RETURN_MIN_CONNECTIONS + ), ) yield f"data: {json.dumps({'type': 'shell', 'html': shell_html})}\n\n" if journey_type == "return": + assert return_date is not None _fetch_es_return() - sections_spec = [ - ("outbound", "outbound", travel_date, es_return.get("outbound", [])), + sections_spec: list[ + tuple[str, str, str, list[dict[str, Any]] | None] + ] = [ + ( + "outbound", + "outbound", + travel_date, + es_return.get("outbound", []), + ), ("inbound", "inbound", return_date, es_return.get("inbound", [])), ] else: sections_spec = [("main", journey_type, travel_date, None)] - built_sections = [] + built_sections: list[dict[str, Any]] = [] for section_id, direction, section_date, eurostar_services in sections_spec: - section = build_section(section_id, direction, section_date, eurostar_services) + section = build_section( + section_id, direction, section_date, eurostar_services + ) built_sections.append(section) section_html = render_template( "results_section.html", @@ -872,16 +1078,23 @@ def _results(station_crs, slug, travel_date, journey_type, return_date): journey_type=journey_type if journey_type == "inbound" else None, ) - summary_html = _build_summary_html(built_sections, journey_type, from_cache_parts, provisional_timetable) + summary_html = _build_summary_html( + built_sections, journey_type, from_cache_parts, provisional_timetable + ) yield f"data: {json.dumps({'type': 'done', 'timetable_refresh_url': timetable_refresh_url, 'provisional_timetable': provisional_timetable, 'summary_html': summary_html})}\n\n" return Response(stream_with_context(generate()), mimetype="text/event-stream") if journey_type == "return": + assert return_date is not None _fetch_es_return() sections = [ - build_section("outbound", "outbound", travel_date, es_return.get("outbound", [])), - build_section("inbound", "inbound", return_date, es_return.get("inbound", [])), + build_section( + "outbound", "outbound", travel_date, es_return.get("outbound", []) + ), + build_section( + "inbound", "inbound", return_date, es_return.get("inbound", []) + ), ] else: sections = [build_section("main", journey_type, travel_date)] @@ -892,8 +1105,12 @@ def _results(station_crs, slug, travel_date, journey_type, return_date): for section in sections: direction = section["direction"] section_directions[section["id"]] = direction - nr_classes[section["id"]] = nr_class_out if direction == "outbound" else nr_class_in - es_classes[section["id"]] = es_class_out if direction == "outbound" else es_class_in + nr_classes[section["id"]] = ( + nr_class_out if direction == "outbound" else nr_class_in + ) + es_classes[section["id"]] = ( + es_class_out if direction == "outbound" else es_class_in + ) no_prices_note = None all_es_prices = [ @@ -902,8 +1119,14 @@ def _results(station_crs, slug, travel_date, journey_type, return_date): for row in section["rows"] if row.get("row_type") == "trip" ] - if not provisional_timetable and all_es_prices and all(price is None for price in all_es_prices): - no_prices_note = "Eurostar prices not yet available — tickets may not be on sale yet." + if ( + not provisional_timetable + and all_es_prices + and all(price is None for price in all_es_prices) + ): + no_prices_note = ( + "Eurostar prices not yet available — tickets may not be on sale yet." + ) dt = date.fromisoformat(travel_date) prev_date = (dt - timedelta(days=1)).isoformat() @@ -928,17 +1151,22 @@ def _results(station_crs, slug, travel_date, journey_type, return_date): url_max = None if max_connection == default_max else max_connection url_nr = None if nr_class == DEFAULT_NR_CLASS else nr_class url_es = None if es_class == DEFAULT_ES_CLASS else es_class + common_url_args: dict[str, Any] if journey_type == "return": common_url_args = { "journey_type": journey_type, "return_date": return_date, "min_connection": url_min, "max_connection": url_max, - "min_connection_in": None if inbound_min_connection == INBOUND_MIN_CONNECTION_MINUTES else inbound_min_connection, + "min_connection_in": ( + None + if inbound_min_connection == INBOUND_MIN_CONNECTION_MINUTES + else inbound_min_connection + ), "nr_class_out": None if nr_class_out == DEFAULT_NR_CLASS else nr_class_out, - "nr_class_in": None if nr_class_in == DEFAULT_NR_CLASS else nr_class_in, + "nr_class_in": None if nr_class_in == DEFAULT_NR_CLASS else nr_class_in, "es_class_out": None if es_class_out == DEFAULT_ES_CLASS else es_class_out, - "es_class_in": None if es_class_in == DEFAULT_ES_CLASS else es_class_in, + "es_class_in": None if es_class_in == DEFAULT_ES_CLASS else es_class_in, } else: common_url_args = { @@ -961,12 +1189,8 @@ def _results(station_crs, slug, travel_date, journey_type, return_date): next_date, **{**common_url_args, "return_date": next_return_date}, ) - prev_outbound_url = _results_url( - station_crs, slug, prev_date, **common_url_args - ) - next_outbound_url = _results_url( - station_crs, slug, next_date, **common_url_args - ) + prev_outbound_url = _results_url(station_crs, slug, prev_date, **common_url_args) + next_outbound_url = _results_url(station_crs, slug, next_date, **common_url_args) prev_return_url = ( _results_url( station_crs, @@ -1034,14 +1258,18 @@ def _results(station_crs, slug, travel_date, journey_type, return_date): else None ) es_plus = ( - {"price": row["eurostar_plus_price"], "seats": row.get("eurostar_plus_seats")} + { + "price": row["eurostar_plus_price"], + "seats": row.get("eurostar_plus_seats"), + } if row.get("eurostar_plus_price") is not None else None ) trip_fares[row["row_key"]] = { "section": section["id"], "eurostar_key": row.get("eurostar_key"), - "advance_key": row.get("depart_bristol") or row.get("depart_paddington"), + "advance_key": row.get("depart_bristol") + or row.get("depart_paddington"), "walkon": walkon, "es_standard": es_std, "es_plus": es_plus, @@ -1122,17 +1350,25 @@ def _results(station_crs, slug, travel_date, journey_type, return_date): advance_api_urls_json=json.dumps(advance_api_urls), advance_stream_urls_json=json.dumps(advance_stream_urls), timetable_refresh_url=timetable_refresh_url, - advance_fares_api_url=url_for("api_advance_fares", station_crs=station_crs, travel_date=travel_date), - advance_fares_stream_url=url_for("api_advance_fares_stream", station_crs=station_crs, travel_date=travel_date), + advance_fares_api_url=url_for( + "api_advance_fares", station_crs=station_crs, travel_date=travel_date + ), + advance_fares_stream_url=url_for( + "api_advance_fares_stream", station_crs=station_crs, travel_date=travel_date + ), valid_min_connections=sorted(valid_min), valid_max_connections=sorted(valid_max), inbound_min_connection=inbound_min_connection, default_inbound_min_connection=INBOUND_MIN_CONNECTION_MINUTES, - valid_inbound_return_min_connections=sorted(VALID_INBOUND_RETURN_MIN_CONNECTIONS), + valid_inbound_return_min_connections=sorted( + VALID_INBOUND_RETURN_MIN_CONNECTIONS + ), ) -def _fetch_exact_nr_timetable(station_crs, section_date, direction, user_agent): +def _fetch_exact_nr_timetable( + station_crs: str, section_date: str, direction: str, user_agent: str +) -> list[dict[str, Any]]: rtt_direction = "to_paddington" if direction == "outbound" else "from_paddington" exact_key = _nr_exact_cache_key(rtt_direction, station_crs, section_date) weekday_key = _nr_weekday_cache_key(rtt_direction, station_crs, section_date) @@ -1146,7 +1382,9 @@ def _fetch_exact_nr_timetable(station_crs, section_date, direction, user_agent): return trains -def _fetch_exact_eurostar_single(destination, section_date, direction): +def _fetch_exact_eurostar_single( + destination: str, section_date: str, direction: str +) -> list[dict[str, Any]]: exact_key = _eurostar_exact_cache_key(direction, section_date, destination) weekday_key = _eurostar_weekday_cache_key(direction, section_date, destination) services = ( @@ -1159,9 +1397,13 @@ def _fetch_exact_eurostar_single(destination, section_date, direction): return services -def _fetch_exact_eurostar_return(destination, travel_date, return_date): +def _fetch_exact_eurostar_return( + destination: str, travel_date: str, return_date: str +) -> dict[str, list[dict[str, Any]]]: exact_key = _eurostar_return_exact_cache_key(travel_date, return_date, destination) - weekday_key = _eurostar_return_weekday_cache_key(travel_date, return_date, destination) + weekday_key = _eurostar_return_weekday_cache_key( + travel_date, return_date, destination + ) services = eurostar_scraper.fetch_return(destination, travel_date, return_date) set_cached(exact_key, services) set_cached(weekday_key, _strip_eurostar_return_timetable(services)) @@ -1169,7 +1411,9 @@ def _fetch_exact_eurostar_return(destination, travel_date, return_date): @app.route("/api/walkon_fares//") -def api_walkon_fares(station_crs, travel_date): +def api_walkon_fares( + station_crs: str, travel_date: str +) -> Response | tuple[Response, int]: if station_crs not in STATION_BY_CRS: abort(404) direction = request.args.get("direction", "to_paddington") @@ -1196,16 +1440,28 @@ def api_walkon_fares(station_crs, travel_date): @app.route("/api/results_refresh///") -def api_results_refresh(station_crs, slug, travel_date): - return _api_results_refresh(station_crs, slug, travel_date, request.args.get("return_date")) +def api_results_refresh(station_crs: str, slug: str, travel_date: str) -> Response: + return _api_results_refresh( + station_crs, slug, travel_date, request.args.get("return_date") + ) -@app.route("/api/results_refresh////return/") -def api_return_results_refresh(station_crs, slug, travel_date, return_date): +@app.route( + "/api/results_refresh////return/" +) +def api_return_results_refresh( + station_crs: str, slug: str, travel_date: str, return_date: str +) -> Response: return _api_results_refresh(station_crs, slug, travel_date, return_date, "return") -def _api_results_refresh(station_crs, slug, travel_date, return_date=None, path_journey_type=None): +def _api_results_refresh( + station_crs: str, + slug: str, + travel_date: str, + return_date: str | None = None, + path_journey_type: str | None = None, +) -> Response: if station_crs not in STATION_BY_CRS: abort(404) destination = DESTINATIONS.get(slug) @@ -1221,16 +1477,26 @@ def _api_results_refresh(station_crs, slug, travel_date, return_date=None, path_ user_agent = request.headers.get("User-Agent", rtt_scraper.DEFAULT_UA) - def generate(): + def generate() -> Generator[str, None, None]: try: - old_es_weekdays = {} + old_es_weekdays: dict[str, Any] = {} if journey_type == "return": + assert return_date is not None old_es_weekdays["return"] = get_cached( - _eurostar_return_weekday_cache_key(travel_date, return_date, destination) + _eurostar_return_weekday_cache_key( + travel_date, return_date, destination + ) + ) + es_return = _fetch_exact_eurostar_return( + destination, travel_date, return_date ) - es_return = _fetch_exact_eurostar_return(destination, travel_date, return_date) sections = [ - ("outbound", "outbound", travel_date, es_return.get("outbound", [])), + ( + "outbound", + "outbound", + travel_date, + es_return.get("outbound", []), + ), ("inbound", "inbound", return_date, es_return.get("inbound", [])), ] else: @@ -1239,7 +1505,9 @@ def _api_results_refresh(station_crs, slug, travel_date, return_date=None, path_ old_es_weekdays["main"] = get_cached( _eurostar_weekday_cache_key(direction, section_date, destination) ) - es_services = _fetch_exact_eurostar_single(destination, section_date, direction) + es_services = _fetch_exact_eurostar_single( + destination, section_date, direction + ) sections = [("main", direction, section_date, es_services)] reload_needed = False @@ -1251,20 +1519,32 @@ def _api_results_refresh(station_crs, slug, travel_date, return_date=None, path_ section_date, ) old_nr_weekday = get_cached(nr_weekday_key) - exact_nr = _fetch_exact_nr_timetable(station_crs, section_date, direction, user_agent) - if old_nr_weekday is not None and _timetable_signature(old_nr_weekday) != _timetable_signature(_strip_nr_timetable(exact_nr)): + exact_nr = _fetch_exact_nr_timetable( + station_crs, section_date, direction, user_agent + ) + if old_nr_weekday is not None and _timetable_signature( + old_nr_weekday + ) != _timetable_signature(_strip_nr_timetable(exact_nr)): reload_needed = True - old_es_weekday = old_es_weekdays["return"] if journey_type == "return" else old_es_weekdays[section_id] + old_es_weekday = ( + old_es_weekdays["return"] + if journey_type == "return" + else old_es_weekdays[section_id] + ) exact_es_timetable = ( _strip_eurostar_return_timetable(es_return) if journey_type == "return" else _strip_eurostar_timetable(es_services) ) - if old_es_weekday is not None and _timetable_signature(old_es_weekday) != _timetable_signature(exact_es_timetable): + if old_es_weekday is not None and _timetable_signature( + old_es_weekday + ) != _timetable_signature(exact_es_timetable): reload_needed = True - eurostar_prices.update(_eurostar_prices_by_row(section_id, direction, es_services)) + eurostar_prices.update( + _eurostar_prices_by_row(section_id, direction, es_services) + ) if reload_needed: yield f"data: {json.dumps({'type': 'reload'})}\n\n" @@ -1275,14 +1555,18 @@ def _api_results_refresh(station_crs, slug, travel_date, return_date=None, path_ yield f"data: {json.dumps({'type': 'eurostar_prices', 'prices': eurostar_prices})}\n\n" for section_id, direction, section_date, _es_services in sections: - fare_direction = "to_paddington" if direction == "outbound" else "from_paddington" + fare_direction = ( + "to_paddington" if direction == "outbound" else "from_paddington" + ) cache_key = f"gwr_fares_{fare_direction}_{station_crs}_{section_date}" cached = get_cached(cache_key, ttl=30 * 24 * 3600) if cached is None: cached = ( gwr_fares_scraper.fetch(station_crs, section_date) if fare_direction == "to_paddington" - else gwr_fares_scraper.fetch(station_crs, section_date, direction=fare_direction) + else gwr_fares_scraper.fetch( + station_crs, section_date, direction=fare_direction + ) ) set_cached(cache_key, cached) yield f"data: {json.dumps({'type': 'walkon_fares', 'section': section_id, 'fares': cached})}\n\n" @@ -1296,7 +1580,9 @@ def _api_results_refresh(station_crs, slug, travel_date, return_date=None, path_ @app.route("/api/advance_fares//") -def api_advance_fares(station_crs, travel_date): +def api_advance_fares( + station_crs: str, travel_date: str +) -> Response | tuple[Response, int]: if station_crs not in STATION_BY_CRS: abort(404) direction = request.args.get("direction", "to_paddington") @@ -1310,7 +1596,9 @@ def api_advance_fares(station_crs, travel_date): fares = ( gwr_fares_scraper.fetch_advance(station_crs, travel_date) if direction == "to_paddington" - else gwr_fares_scraper.fetch_advance(station_crs, travel_date, direction=direction) + else gwr_fares_scraper.fetch_advance( + station_crs, travel_date, direction=direction + ) ) set_cached(cache_key, fares) return jsonify(fares) @@ -1319,7 +1607,7 @@ def api_advance_fares(station_crs, travel_date): @app.route("/api/advance_fares_stream//") -def api_advance_fares_stream(station_crs, travel_date): +def api_advance_fares_stream(station_crs: str, travel_date: str) -> Response: if station_crs not in STATION_BY_CRS: abort(404) direction = request.args.get("direction", "to_paddington") @@ -1327,14 +1615,14 @@ def api_advance_fares_stream(station_crs, travel_date): direction = "to_paddington" cache_key = f"gwr_advance_{direction}_{station_crs}_{travel_date}" - def generate(): + def generate() -> Generator[str, None, None]: cached = get_cached(cache_key, ttl=24 * 3600) if cached is not None: yield f"data: {json.dumps({'type': 'fares', 'fares': cached})}\n\n" yield f"data: {json.dumps({'type': 'done'})}\n\n" return - accumulated: dict = {} + accumulated: dict[str, Any] = {} try: stream = ( gwr_fares_scraper.fetch_advance_streaming(station_crs, travel_date) @@ -1346,7 +1634,10 @@ def api_advance_fares_stream(station_crs, travel_date): for page_fares in stream: for dep_time, fare_data in page_fares.items(): if dep_time not in accumulated: - accumulated[dep_time] = {"advance_std": None, "advance_1st": None} + accumulated[dep_time] = { + "advance_std": None, + "advance_1st": None, + } if fare_data.get("advance_std"): accumulated[dep_time]["advance_std"] = fare_data["advance_std"] if fare_data.get("advance_1st"): diff --git a/cache.py b/cache.py index 89a3f48..6b09fdb 100644 --- a/cache.py +++ b/cache.py @@ -2,16 +2,17 @@ import json import os import time import uuid +from typing import Any from config.default import CACHE_DIR # overridden by app config after import def _cache_path(key: str) -> str: - safe_key = key.replace('/', '_').replace(' ', '_') + safe_key = key.replace("/", "_").replace(" ", "_") return os.path.join(CACHE_DIR, f"{safe_key}.json") -def get_cached(key: str, ttl: int | None = None): +def get_cached(key: str, ttl: int | None = None) -> Any: """Return cached data, or None if missing or older than ttl seconds.""" path = _cache_path(key) try: @@ -25,10 +26,10 @@ def get_cached(key: str, ttl: int | None = None): return None -def set_cached(key: str, data) -> None: +def set_cached(key: str, data: Any) -> None: os.makedirs(CACHE_DIR, exist_ok=True) path = _cache_path(key) tmp_path = f"{path}.{os.getpid()}.{uuid.uuid4().hex}.tmp" - with open(tmp_path, 'w') as f: + with open(tmp_path, "w") as f: json.dump(data, f, indent=2) os.replace(tmp_path, path) diff --git a/circle_line.py b/circle_line.py index 76baaf9..eb5a42d 100644 --- a/circle_line.py +++ b/circle_line.py @@ -3,16 +3,21 @@ Circle Line timetable between Paddington (H&C Line) and King's Cross St Pancras. Parses the TransXChange XML file on first use and caches the result in memory. """ + import os import re import xml.etree.ElementTree as ET from datetime import datetime, timedelta +from typing import Any -_PAD_STOP = '9400ZZLUPAH1' # Paddington (H&C Line) -_KXP_STOP = '9400ZZLUKSX3' # King's Cross St Pancras +_PAD_STOP = "9400ZZLUPAH1" # Paddington (H&C Line) +_KXP_STOP = "9400ZZLUKSX3" # King's Cross St Pancras -from config.default import CIRCLE_LINE_XML as _TXC_XML # overridden by app config after import -_NS = {'t': 'http://www.transxchange.org.uk/'} +from config.default import ( + CIRCLE_LINE_XML as _TXC_XML, +) # overridden by app config after import + +_NS = {"t": "http://www.transxchange.org.uk/"} # Populated on first call to next_service(); maps direction -> day-type -> sorted # list of (origin_depart_seconds, destination_arrive_seconds) measured from midnight. @@ -22,8 +27,11 @@ _timetable: dict[str, dict[str, list[tuple[int, int]]]] | None = None def _parse_duration(s: str | None) -> int: if not s: return 0 - m = re.match(r'PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?', s) - return int(m.group(1) or 0) * 3600 + int(m.group(2) or 0) * 60 + int(m.group(3) or 0) + m = re.match(r"PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?", s) + assert m is not None + return ( + int(m.group(1) or 0) * 3600 + int(m.group(2) or 0) * 60 + int(m.group(3) or 0) + ) def _load_timetable() -> dict[str, dict[str, list[tuple[int, int]]]]: @@ -31,23 +39,31 @@ def _load_timetable() -> dict[str, dict[str, list[tuple[int, int]]]]: root = tree.getroot() # Build JPS id -> [(from_stop, to_stop, runtime_secs, wait_secs)] - jps_map: dict[str, list[tuple]] = {} - for jps_el in root.find('t:JourneyPatternSections', _NS): + jps_map: dict[str, list[tuple[str | None, str | None, int, int]]] = {} + jps_sections = root.find("t:JourneyPatternSections", _NS) + assert jps_sections is not None + for jps_el in jps_sections: links = [] - for link in jps_el.findall('t:JourneyPatternTimingLink', _NS): - fr = link.find('t:From/t:StopPointRef', _NS) - to = link.find('t:To/t:StopPointRef', _NS) - rt = link.find('t:RunTime', _NS) - wait = link.find('t:From/t:WaitTime', _NS) - links.append(( - fr.text if fr is not None else None, - to.text if to is not None else None, - _parse_duration(rt.text if rt is not None else None), - _parse_duration(wait.text if wait is not None else None), - )) - jps_map[jps_el.get('id')] = links + for link in jps_el.findall("t:JourneyPatternTimingLink", _NS): + fr = link.find("t:From/t:StopPointRef", _NS) + to = link.find("t:To/t:StopPointRef", _NS) + rt = link.find("t:RunTime", _NS) + wait = link.find("t:From/t:WaitTime", _NS) + links.append( + ( + fr.text if fr is not None else None, + to.text if to is not None else None, + _parse_duration(rt.text if rt is not None else None), + _parse_duration(wait.text if wait is not None else None), + ) + ) + jps_id = jps_el.get("id") + assert jps_id is not None + jps_map[jps_id] = links - def _seconds_to_depart(links, stop): + def _seconds_to_depart( + links: list[tuple[str | None, str | None, int, int]], stop: str | None + ) -> int | None: """Seconds from journey start until departure from *stop*.""" elapsed = 0 for fr, to, rt, wait in links: @@ -57,7 +73,9 @@ def _load_timetable() -> dict[str, dict[str, list[tuple[int, int]]]]: elapsed += rt return None - def _seconds_to_arrive(links, stop): + def _seconds_to_arrive( + links: list[tuple[str | None, str | None, int, int]], stop: str | None + ) -> int | None: """Seconds from journey start until arrival at *stop*.""" elapsed = 0 for fr, to, rt, wait in links: @@ -68,12 +86,14 @@ def _load_timetable() -> dict[str, dict[str, list[tuple[int, int]]]]: # Map JP id -> [(direction, origin_depart_offset_secs, destination_arrive_offset_secs)]. jp_offsets: dict[str, list[tuple[str, int, int]]] = {} - for svc in root.find('t:Services', _NS): - for jp in svc.findall('.//t:JourneyPattern', _NS): - jps_ref = jp.find('t:JourneyPatternSectionRefs', _NS) + services_el = root.find("t:Services", _NS) + assert services_el is not None + for svc in services_el: + for jp in svc.findall(".//t:JourneyPattern", _NS): + jps_ref = jp.find("t:JourneyPatternSectionRefs", _NS) if jps_ref is None: continue - links = jps_map.get(jps_ref.text, []) + links = jps_map.get(jps_ref.text or "", []) stops = [l[0] for l in links] + ([links[-1][1]] if links else []) offsets = [] if ( @@ -84,7 +104,7 @@ def _load_timetable() -> dict[str, dict[str, list[tuple[int, int]]]]: pad_off = _seconds_to_depart(links, _PAD_STOP) kxp_off = _seconds_to_arrive(links, _KXP_STOP) if pad_off is not None and kxp_off is not None: - offsets.append(('pad_to_kx', pad_off, kxp_off)) + offsets.append(("pad_to_kx", pad_off, kxp_off)) if ( _PAD_STOP in stops and _KXP_STOP in stops @@ -93,42 +113,50 @@ def _load_timetable() -> dict[str, dict[str, list[tuple[int, int]]]]: kxp_off = _seconds_to_depart(links, _KXP_STOP) pad_off = _seconds_to_arrive(links, _PAD_STOP) if kxp_off is not None and pad_off is not None: - offsets.append(('kx_to_pad', kxp_off, pad_off)) + offsets.append(("kx_to_pad", kxp_off, pad_off)) if offsets: - jp_offsets[jp.get('id')] = offsets + jp_id = jp.get("id") + assert jp_id is not None + jp_offsets[jp_id] = offsets result: dict[str, dict[str, list[tuple[int, int]]]] = { - 'pad_to_kx': { - 'MondayToFriday': [], - 'Saturday': [], - 'Sunday': [], + "pad_to_kx": { + "MondayToFriday": [], + "Saturday": [], + "Sunday": [], }, - 'kx_to_pad': { - 'MondayToFriday': [], - 'Saturday': [], - 'Sunday': [], + "kx_to_pad": { + "MondayToFriday": [], + "Saturday": [], + "Sunday": [], }, } - for vj in root.find('t:VehicleJourneys', _NS): - jp_ref = vj.find('t:JourneyPatternRef', _NS) - dep_time = vj.find('t:DepartureTime', _NS) - op = vj.find('t:OperatingProfile', _NS) + vehicle_journeys = root.find("t:VehicleJourneys", _NS) + assert vehicle_journeys is not None + for vj in vehicle_journeys: + jp_ref = vj.find("t:JourneyPatternRef", _NS) + dep_time = vj.find("t:DepartureTime", _NS) + op = vj.find("t:OperatingProfile", _NS) if jp_ref is None or dep_time is None or jp_ref.text not in jp_offsets: continue - h, m, s = map(int, dep_time.text.split(':')) + if dep_time.text is None: + continue + h, m, s = map(int, dep_time.text.split(":")) dep_secs = h * 3600 + m * 60 + s - rdt = op.find('.//t:DaysOfWeek', _NS) if op is not None else None + rdt = op.find(".//t:DaysOfWeek", _NS) if op is not None else None if rdt is None: continue for day_el in rdt: - day_type = day_el.tag.split('}')[-1] + day_type = day_el.tag.split("}")[-1] for direction, origin_off, dest_off in jp_offsets[jp_ref.text]: if day_type in result[direction]: - result[direction][day_type].append(( - dep_secs + origin_off, - dep_secs + dest_off, - )) + result[direction][day_type].append( + ( + dep_secs + origin_off, + dep_secs + dest_off, + ) + ) for direction in result: for key in result[direction]: @@ -145,12 +173,12 @@ def _get_timetable() -> dict[str, dict[str, list[tuple[int, int]]]]: def _day_type(weekday: int) -> str: if weekday < 5: - return 'MondayToFriday' - return 'Saturday' if weekday == 5 else 'Sunday' + return "MondayToFriday" + return "Saturday" if weekday == 5 else "Sunday" def next_service( - earliest_board: datetime, direction: str = 'pad_to_kx' + earliest_board: datetime, direction: str = "pad_to_kx" ) -> tuple[datetime, datetime] | None: """ Given the earliest time a passenger can board at Paddington (H&C Line), @@ -167,7 +195,7 @@ def next_service( def upcoming_services( earliest_board: datetime, count: int = 2, - direction: str = 'pad_to_kx', + direction: str = "pad_to_kx", preceding: int = 0, ) -> list[tuple[datetime, datetime]]: """ @@ -179,9 +207,7 @@ def upcoming_services( """ timetable = _get_timetable().get(direction, {})[_day_type(earliest_board.weekday())] board_secs = ( - earliest_board.hour * 3600 - + earliest_board.minute * 60 - + earliest_board.second + earliest_board.hour * 3600 + earliest_board.minute * 60 + earliest_board.second ) midnight = earliest_board.replace(hour=0, minute=0, second=0, microsecond=0) pre_results = [] diff --git a/config/default.py b/config/default.py index dfdb4fa..3d9c4e2 100644 --- a/config/default.py +++ b/config/default.py @@ -1,13 +1,13 @@ import os # Directory containing TfL reference data (TransXChange XML files etc.) -TFL_DATA_DIR = os.path.expanduser('~/lib/data/tfl') +TFL_DATA_DIR = os.path.expanduser("~/lib/data/tfl") # Directory for caching scraped train times -CACHE_DIR = os.path.expanduser('~/lib/data/tfl/cache') +CACHE_DIR = os.path.expanduser("~/lib/data/tfl/cache") # TransXChange timetable file for the Circle Line -CIRCLE_LINE_XML = os.path.join(TFL_DATA_DIR, 'output_txc_01CIR_.xml') +CIRCLE_LINE_XML = os.path.join(TFL_DATA_DIR, "output_txc_01CIR_.xml") # Default connection window (minutes) between Paddington arrival and St Pancras departure DEFAULT_MIN_CONNECTION = 70 diff --git a/scraper/eurostar.py b/scraper/eurostar.py index 6e2f82a..27d4f9f 100644 --- a/scraper/eurostar.py +++ b/scraper/eurostar.py @@ -6,8 +6,10 @@ NewBookingSearch) returns departure time, arrival time, train number, Eurostar Standard fare price, and seats remaining at that price for every service on the requested date. """ + import random import string +from typing import Any import requests @@ -16,19 +18,19 @@ DEFAULT_UA = ( "(KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36" ) -ST_PANCRAS_STATION_ID = '7015400' +ST_PANCRAS_STATION_ID = "7015400" ORIGIN_STATION_ID = ST_PANCRAS_STATION_ID DESTINATION_STATION_IDS = { - 'Paris Gare du Nord': '8727100', - 'Brussels Midi': '8814001', - 'Lille Europe': '8722326', - 'Amsterdam Centraal': '8400058', - 'Rotterdam Centraal': '8400530', - 'Cologne Hbf': '8015458', + "Paris Gare du Nord": "8727100", + "Brussels Midi": "8814001", + "Lille Europe": "8722326", + "Amsterdam Centraal": "8400058", + "Rotterdam Centraal": "8400530", + "Cologne Hbf": "8015458", } -_GATEWAY_URL = 'https://site-api.eurostar.com/gateway' +_GATEWAY_URL = "https://site-api.eurostar.com/gateway" # Query requesting timing, train identity, and Standard fare price + seats. # Variable names and argument names match the site's own query so the @@ -42,7 +44,7 @@ _GQL_QUERY = ( "journeySearch(" "outboundDate:$outbound inboundDate:$inbound origin:$origin destination:$destination" " adults:$adult currency:$currency" - " productFamilies:[\"PUB\"] contractCode:\"EIL_ALL\"" + ' productFamilies:["PUB"] contractCode:"EIL_ALL"' " adults16Plus:0 children:0 youths:0 children4Only:0 children5To11:0" " infants:0 adultsWheelchair:0 childrenWheelchair:0 guideDogs:0" " wheelchairCompanions:0 nonWheelchairCompanions:0" @@ -85,11 +87,16 @@ _GQL_QUERY = ( "}" ) -_STANDARD = 'STANDARD' -_STANDARD_PLUS = 'PLUS' +_STANDARD = "STANDARD" +_STANDARD_PLUS = "PLUS" -def search_url(destination: str, travel_date: str, direction: str = "outbound", return_date: str | None = None) -> str: +def search_url( + destination: str, + travel_date: str, + direction: str = "outbound", + return_date: str | None = None, +) -> str: dest_id = DESTINATION_STATION_IDS[destination] origin = ST_PANCRAS_STATION_ID destination_id = dest_id @@ -99,18 +106,20 @@ def search_url(destination: str, travel_date: str, direction: str = "outbound", origin, destination_id = dest_id, ST_PANCRAS_STATION_ID inbound = None return ( - f'https://www.eurostar.com/search/uk-en' - f'?adult=1&origin={origin}&destination={destination_id}&outbound={outbound}' - + (f'&inbound={inbound}' if inbound else '') + f"https://www.eurostar.com/search/uk-en" + f"?adult=1&origin={origin}&destination={destination_id}&outbound={outbound}" + + (f"&inbound={inbound}" if inbound else "") ) def _generate_cid() -> str: chars = string.ascii_letters + string.digits - return 'SRCH-' + ''.join(random.choices(chars, k=22)) + return "SRCH-" + "".join(random.choices(chars, k=22)) -def _parse_journeys(journeys: list[dict], destination: str, direction: str) -> list[dict]: +def _parse_journeys( + journeys: list[dict[str, Any]], destination: str, direction: str +) -> list[dict[str, Any]]: """ Parse a NewBookingSearch GraphQL response into a list of service dicts. @@ -121,101 +130,108 @@ def _parse_journeys(journeys: list[dict], destination: str, direction: str) -> l connecting trains); we keep the entry with the earliest arrival. Multi-leg train numbers are joined with ' + ' (e.g. 'ES 9116 + ER 9329'). """ - best: dict[str, dict] = {} + best: dict[str, dict[str, Any]] = {} for journey in journeys: - dep = journey['timing']['departureTime'] - arr = journey['timing']['arrivalTime'] + dep = journey["timing"]["departureTime"] + arr = journey["timing"]["arrivalTime"] std_price = std_seats = plus_price = plus_seats = None - train_number = '' - for fare in (journey.get('fares') or []): - cos = fare['classOfService']['code'] - p = fare.get('prices') - price = float(p['displayPrice']) if p and p.get('displayPrice') else None - seats = fare.get('seats') + train_number = "" + for fare in journey.get("fares") or []: + cos = fare["classOfService"]["code"] + p = fare.get("prices") + price = float(p["displayPrice"]) if p and p.get("displayPrice") else None + seats = fare.get("seats") if not train_number: - legs = fare.get('legs') or [] - train_number = ' + '.join( + legs = fare.get("legs") or [] + train_number = " + ".join( f"{(leg.get('serviceType') or {}).get('code', 'ES')} {leg['serviceName']}" - for leg in legs if leg.get('serviceName') + for leg in legs + if leg.get("serviceName") ) if cos == _STANDARD: std_price, std_seats = price, seats elif cos == _STANDARD_PLUS: plus_price, plus_seats = price, seats - if direction == 'inbound': + if direction == "inbound": service = { - 'depart_destination': dep, - 'arrive_st_pancras': arr, - 'destination': destination, - 'train_number': train_number, - 'price': std_price, - 'seats': std_seats, - 'plus_price': plus_price, - 'plus_seats': plus_seats, + "depart_destination": dep, + "arrive_st_pancras": arr, + "destination": destination, + "train_number": train_number, + "price": std_price, + "seats": std_seats, + "plus_price": plus_price, + "plus_seats": plus_seats, } key = dep - arrive_key = 'arrive_st_pancras' + arrive_key = "arrive_st_pancras" else: service = { - 'depart_st_pancras': dep, - 'arrive_destination': arr, - 'destination': destination, - 'train_number': train_number, - 'price': std_price, - 'seats': std_seats, - 'plus_price': plus_price, - 'plus_seats': plus_seats, + "depart_st_pancras": dep, + "arrive_destination": arr, + "destination": destination, + "train_number": train_number, + "price": std_price, + "seats": std_seats, + "plus_price": plus_price, + "plus_seats": plus_seats, } key = dep - arrive_key = 'arrive_destination' + arrive_key = "arrive_destination" if key not in best or arr < best[key][arrive_key]: best[key] = service - sort_key = 'depart_destination' if direction == 'inbound' else 'depart_st_pancras' + sort_key = "depart_destination" if direction == "inbound" else "depart_st_pancras" return sorted(best.values(), key=lambda s: s[sort_key]) -def _parse_graphql(data: dict, destination: str) -> list[dict]: - journeys = data['data']['journeySearch']['outbound']['journeys'] - return _parse_journeys(journeys, destination, 'outbound') +def _parse_graphql(data: dict[str, Any], destination: str) -> list[dict[str, Any]]: + journeys = data["data"]["journeySearch"]["outbound"]["journeys"] + return _parse_journeys(journeys, destination, "outbound") -def _parse_graphql_leg(data: dict, destination: str, leg: str, direction: str) -> list[dict]: - journeys = data['data']['journeySearch'][leg]['journeys'] +def _parse_graphql_leg( + data: dict[str, Any], destination: str, leg: str, direction: str +) -> list[dict[str, Any]]: + journeys = data["data"]["journeySearch"][leg]["journeys"] return _parse_journeys(journeys, destination, direction) -def _payload(origin: str, destination_id: str, outbound: str, inbound: str | None = None) -> dict: - variables = { - 'origin': origin, - 'destination': destination_id, - 'outbound': outbound, - 'inbound': inbound, - 'currency': 'GBP', - 'adult': 1, - 'filteredClassesOfService': [_STANDARD, _STANDARD_PLUS], +def _payload( + origin: str, destination_id: str, outbound: str, inbound: str | None = None +) -> dict[str, Any]: + variables: dict[str, Any] = { + "origin": origin, + "destination": destination_id, + "outbound": outbound, + "inbound": inbound, + "currency": "GBP", + "adult": 1, + "filteredClassesOfService": [_STANDARD, _STANDARD_PLUS], } return { - 'operationName': 'NewBookingSearch', - 'variables': variables, - 'query': _GQL_QUERY, + "operationName": "NewBookingSearch", + "variables": variables, + "query": _GQL_QUERY, } -def _headers() -> dict: +def _headers() -> dict[str, str]: return { - 'User-Agent': DEFAULT_UA, - 'Content-Type': 'application/json', - 'Accept': '*/*', - 'Accept-Language':'en-GB', - 'Referer': 'https://www.eurostar.com/', - 'x-platform': 'web', - 'x-market-code': 'uk', - 'x-source-url': 'search-app/', - 'cid': _generate_cid(), + "User-Agent": DEFAULT_UA, + "Content-Type": "application/json", + "Accept": "*/*", + "Accept-Language": "en-GB", + "Referer": "https://www.eurostar.com/", + "x-platform": "web", + "x-market-code": "uk", + "x-source-url": "search-app/", + "cid": _generate_cid(), } -def fetch(destination: str, travel_date: str, direction: str = 'outbound') -> list[dict]: +def fetch( + destination: str, travel_date: str, direction: str = "outbound" +) -> list[dict[str, Any]]: """ Return all Eurostar services for destination on travel_date. @@ -223,7 +239,7 @@ def fetch(destination: str, travel_date: str, direction: str = 'outbound') -> li train_number) plus pricing (price, seats) from a single GraphQL call. """ dest_id = DESTINATION_STATION_IDS[destination] - if direction == 'inbound': + if direction == "inbound": origin, destination_id = dest_id, ST_PANCRAS_STATION_ID else: origin, destination_id = ST_PANCRAS_STATION_ID, dest_id @@ -234,11 +250,13 @@ def fetch(destination: str, travel_date: str, direction: str = 'outbound') -> li timeout=20, ) resp.raise_for_status() - leg_direction = 'inbound' if direction == 'inbound' else 'outbound' - return _parse_graphql_leg(resp.json(), destination, 'outbound', leg_direction) + leg_direction = "inbound" if direction == "inbound" else "outbound" + return _parse_graphql_leg(resp.json(), destination, "outbound", leg_direction) -def fetch_return(destination: str, outbound_date: str, return_date: str) -> dict[str, list[dict]]: +def fetch_return( + destination: str, outbound_date: str, return_date: str +) -> dict[str, list[dict[str, Any]]]: dest_id = DESTINATION_STATION_IDS[destination] resp = requests.post( _GATEWAY_URL, @@ -249,6 +267,6 @@ def fetch_return(destination: str, outbound_date: str, return_date: str) -> dict resp.raise_for_status() data = resp.json() return { - 'outbound': _parse_graphql_leg(data, destination, 'outbound', 'outbound'), - 'inbound': _parse_graphql_leg(data, destination, 'inbound', 'inbound'), + "outbound": _parse_graphql_leg(data, destination, "outbound", "outbound"), + "inbound": _parse_graphql_leg(data, destination, "inbound", "inbound"), } diff --git a/scraper/gwr_fares.py b/scraper/gwr_fares.py index 7d3cc26..3aa3f11 100644 --- a/scraper/gwr_fares.py +++ b/scraper/gwr_fares.py @@ -6,6 +6,8 @@ Returns per-train cheapest standard-class fare with restrictions already applied Cache for 30 days — fares rarely change. """ +from typing import Any, Generator + import httpx _API_URL = "https://api.gwr.com/api/shopping/journeysearch" @@ -16,7 +18,7 @@ _WALKON_CODES = {"SSS", "SVS", "SDS", "CDS"} _MAX_PAGES = 20 -def _headers() -> dict: +def _headers() -> dict[str, str]: return { "user-agent": ( "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " @@ -37,7 +39,7 @@ def _request_body( travel_date: str, conversation_token: str | None, later: bool, -) -> dict: +) -> dict[str, Any]: return { "IsNextOutward": False, "IsPreviousOutward": False, @@ -83,7 +85,7 @@ def _run_pages( travel_date: str, first_class: bool = False, direction: str = "to_paddington", -): +) -> Generator[tuple[str, list[Any]], None, None]: """ Iterate all pages of GWR journey search results. @@ -96,7 +98,9 @@ def _run_pages( later = False from_code, to_code = _od_codes(station_crs, direction) for _ in range(_MAX_PAGES): - body = _request_body(from_code, to_code, travel_date, conversation_token, later) + body = _request_body( + from_code, to_code, travel_date, conversation_token, later + ) if first_class: body["firstclass"] = True body["standardclass"] = False @@ -121,7 +125,7 @@ def _run_pages_batched( travel_date: str, first_class: bool = False, direction: str = "to_paddington", -): +) -> Generator[list[tuple[str, list[Any]]], None, None]: """ Like _run_pages but yields one list of (dep_time, fares_list) per API page call, allowing callers to stream results a page at a time. @@ -132,7 +136,9 @@ def _run_pages_batched( later = False from_code, to_code = _od_codes(station_crs, direction) for _ in range(_MAX_PAGES): - body = _request_body(from_code, to_code, travel_date, conversation_token, later) + body = _request_body( + from_code, to_code, travel_date, conversation_token, later + ) if first_class: body["firstclass"] = True body["standardclass"] = False @@ -157,7 +163,7 @@ def _run_pages_batched( def fetch( station_crs: str, travel_date: str, direction: str = "to_paddington" -) -> dict[str, dict]: +) -> dict[str, dict[str, Any]]: """ Fetch GWR walk-on single fares for the selected Paddington direction. @@ -165,7 +171,7 @@ def fetch( where price is in £ and only the cheapest available standard-class walk-on ticket per departure (with restrictions already applied by GWR) is kept. """ - result: dict[str, dict] = {} + result: dict[str, dict[str, Any]] = {} for dep_time, fares in _run_pages(station_crs, travel_date, direction=direction): cheapest = None for fare in fares: @@ -193,7 +199,7 @@ def fetch( def fetch_advance( station_crs: str, travel_date: str, direction: str = "to_paddington" -) -> dict[str, dict]: +) -> dict[str, dict[str, Any]]: """ Fetch advance fares: cheapest standard advance and first-class advance per departure. @@ -201,7 +207,7 @@ def fetch_advance( Returns {departure_time: {'advance_std': dict or None, 'advance_1st': dict or None}} where each sub-dict has keys 'ticket', 'price', 'code'. """ - std_advance: dict[str, dict] = {} + std_advance: dict[str, dict[str, Any]] = {} for dep_time, fares in _run_pages( station_crs, travel_date, first_class=False, direction=direction ): @@ -227,7 +233,7 @@ def fetch_advance( "code": cheapest["code"], } - first_advance: dict[str, dict] = {} + first_advance: dict[str, dict[str, Any]] = {} for dep_time, fares in _run_pages( station_crs, travel_date, first_class=True, direction=direction ): @@ -260,7 +266,7 @@ def fetch_advance( def fetch_advance_streaming( station_crs: str, travel_date: str, direction: str = "to_paddington" -): +) -> Generator[dict[str, dict[str, Any]], None, None]: """ Generator yielding partial advance fare dicts one GWR API page at a time. @@ -272,7 +278,7 @@ def fetch_advance_streaming( for batch in _run_pages_batched( station_crs, travel_date, first_class=False, direction=direction ): - page: dict[str, dict] = {} + page: dict[str, dict[str, Any]] = {} for dep_time, fares in batch: cheapest = None for fare in fares: diff --git a/scraper/realtime_trains.py b/scraper/realtime_trains.py index 6613bbf..75132f1 100644 --- a/scraper/realtime_trains.py +++ b/scraper/realtime_trains.py @@ -7,9 +7,12 @@ Two fetches: PAD/from/BRI → arrival times at Paddington (div.time.plan.a) Matched by train ID (div.tid). """ + import re +from typing import Any + import httpx -import lxml.html +import lxml.html # type: ignore[import-untyped] _TO_PAD_TMPL = ( "https://www.realtimetrains.co.uk/search/detailed/" @@ -38,7 +41,7 @@ DEFAULT_UA = ( ) -def _browser_headers(user_agent: str) -> dict: +def _browser_headers(user_agent: str) -> dict[str, str]: return { "User-Agent": user_agent, "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", @@ -55,7 +58,7 @@ def _browser_headers(user_agent: str) -> dict: def _fmt(hhmm: str) -> str: """Convert '0830' → '08:30'.""" - hhmm = re.sub(r'[^0-9]', '', hhmm) + hhmm = re.sub(r"[^0-9]", "", hhmm) if len(hhmm) == 4: return f"{hhmm[:2]}:{hhmm[2:]}" return hhmm @@ -64,12 +67,12 @@ def _fmt(hhmm: str) -> str: def _parse_services(html: str, time_selector: str) -> dict[str, str]: """Return {train_id: time_string} from a servicelist page.""" root = lxml.html.fromstring(html) - sl = root.cssselect('div.servicelist') + sl = root.cssselect("div.servicelist") if not sl: return {} result = {} - for svc in sl[0].cssselect('a.service'): - tid_els = svc.cssselect('div.tid') + for svc in sl[0].cssselect("a.service"): + tid_els = svc.cssselect("div.tid") time_els = svc.cssselect(time_selector) if tid_els and time_els: tid = tid_els[0].text_content().strip() @@ -79,56 +82,58 @@ def _parse_services(html: str, time_selector: str) -> dict[str, str]: return result -def _parse_arrivals(html: str) -> dict[str, dict]: +def _parse_arrivals(html: str) -> dict[str, dict[str, str]]: """Return {train_id: {'time': ..., 'platform': ...}} from an arrivals page.""" root = lxml.html.fromstring(html) - sl = root.cssselect('div.servicelist') + sl = root.cssselect("div.servicelist") if not sl: return {} result = {} - for svc in sl[0].cssselect('a.service'): - tid_els = svc.cssselect('div.tid') - time_els = svc.cssselect('div.time.plan.a') + for svc in sl[0].cssselect("a.service"): + tid_els = svc.cssselect("div.tid") + time_els = svc.cssselect("div.time.plan.a") if not (tid_els and time_els): continue time_text = time_els[0].text_content().strip() if not time_text: continue - plat_els = svc.cssselect('div.platform') - platform = plat_els[0].text_content().strip() if plat_els else '' + plat_els = svc.cssselect("div.platform") + platform = plat_els[0].text_content().strip() if plat_els else "" result[tid_els[0].text_content().strip()] = { - 'time': _fmt(time_text), - 'platform': platform, + "time": _fmt(time_text), + "platform": platform, } return result -def fetch(date: str, user_agent: str = DEFAULT_UA, station_crs: str = 'BRI') -> list[dict]: +def fetch( + date: str, user_agent: str = DEFAULT_UA, station_crs: str = "BRI" +) -> list[dict[str, Any]]: """Fetch trains from station_crs to PAD.""" headers = _browser_headers(user_agent) with httpx.Client(headers=headers, follow_redirects=True, timeout=30) as client: r_bri = client.get(_TO_PAD_TMPL.format(crs=station_crs, date=date)) r_pad = client.get(_PAD_FROM_TMPL.format(crs=station_crs, date=date)) - departures = _parse_services(r_bri.text, 'div.time.plan.d') - arrivals = _parse_arrivals(r_pad.text) + departures = _parse_services(r_bri.text, "div.time.plan.d") + arrivals = _parse_arrivals(r_pad.text) trains = [ { - 'depart_bristol': dep, - 'arrive_paddington': arrivals[tid]['time'], - 'arrive_platform': arrivals[tid]['platform'], - 'headcode': tid, + "depart_bristol": dep, + "arrive_paddington": arrivals[tid]["time"], + "arrive_platform": arrivals[tid]["platform"], + "headcode": tid, } for tid, dep in departures.items() if tid in arrivals ] - return sorted(trains, key=lambda t: t['depart_bristol']) + return sorted(trains, key=lambda t: t["depart_bristol"]) def fetch_to_paddington( - date: str, user_agent: str = DEFAULT_UA, station_crs: str = 'BRI' -) -> list[dict]: + date: str, user_agent: str = DEFAULT_UA, station_crs: str = "BRI" +) -> list[dict[str, Any]]: """Fetch trains from station_crs to PAD using generic field names.""" return [ { @@ -143,15 +148,15 @@ def fetch_to_paddington( def fetch_from_paddington( - date: str, user_agent: str = DEFAULT_UA, station_crs: str = 'BRI' -) -> list[dict]: + date: str, user_agent: str = DEFAULT_UA, station_crs: str = "BRI" +) -> list[dict[str, Any]]: """Fetch trains from PAD to station_crs.""" headers = _browser_headers(user_agent) with httpx.Client(headers=headers, follow_redirects=True, timeout=30) as client: r_pad = client.get(_PAD_TO_TMPL.format(crs=station_crs, date=date)) r_station = client.get(_FROM_PAD_TMPL.format(crs=station_crs, date=date)) - departures = _parse_services(r_pad.text, 'div.time.plan.d') + departures = _parse_services(r_pad.text, "div.time.plan.d") arrivals = _parse_arrivals(r_station.text) trains = [ diff --git a/tests/test_app.py b/tests/test_app.py index 7a19613..a7ea82b 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -1,231 +1,325 @@ from datetime import datetime +from typing import Any import app as app_module import trip_planner as trip_planner_module +rtt_scraper: Any = app_module.rtt_scraper # type: ignore[attr-defined] +gwr_fares_scraper: Any = app_module.gwr_fares_scraper # type: ignore[attr-defined] +eurostar_scraper: Any = app_module.eurostar_scraper # type: ignore[attr-defined] +circle_line: Any = trip_planner_module.circle_line # type: ignore[attr-defined] -def _client(): - app_module.app.config['TESTING'] = True + +def _client() -> Any: + app_module.app.config["TESTING"] = True return app_module.app.test_client() -def _stub_data(monkeypatch, prices=None, gwr_fares=None): - monkeypatch.setattr(app_module, 'get_cached', lambda key, ttl=None: None) - monkeypatch.setattr(app_module, 'set_cached', lambda key, data: None) +def _stub_data(monkeypatch: Any, prices: Any = None, gwr_fares: Any = None) -> None: + monkeypatch.setattr(app_module, "get_cached", lambda key, ttl=None: None) + monkeypatch.setattr(app_module, "set_cached", lambda key, data: None) monkeypatch.setattr( - app_module.rtt_scraper, - 'fetch', - lambda travel_date, user_agent, station_crs='BRI': [ - {'depart_bristol': '07:00', 'arrive_paddington': '08:45', 'headcode': '1A23'}, + rtt_scraper, + "fetch", + lambda travel_date, user_agent, station_crs="BRI": [ + { + "depart_bristol": "07:00", + "arrive_paddington": "08:45", + "headcode": "1A23", + }, ], ) monkeypatch.setattr( - app_module.gwr_fares_scraper, - 'fetch', - lambda station_crs, travel_date: gwr_fares or {'07:00': {'ticket': 'Anytime Day Single', 'price': 138.70, 'code': 'SDS'}}, + gwr_fares_scraper, + "fetch", + lambda station_crs, travel_date: gwr_fares + or {"07:00": {"ticket": "Anytime Day Single", "price": 138.70, "code": "SDS"}}, ) - p = (prices or {}).get('10:01', {}) + p = (prices or {}).get("10:01", {}) monkeypatch.setattr( - app_module.eurostar_scraper, - 'fetch', + eurostar_scraper, + "fetch", lambda destination, travel_date: [ { - 'depart_st_pancras': '10:01', - 'arrive_destination': '13:34', - 'destination': destination, - 'train_number': 'ES 9014', - 'price': p.get('price') if isinstance(p, dict) else None, - 'seats': p.get('seats') if isinstance(p, dict) else None, - 'plus_price': p.get('plus_price') if isinstance(p, dict) else None, - 'plus_seats': p.get('plus_seats') if isinstance(p, dict) else None, + "depart_st_pancras": "10:01", + "arrive_destination": "13:34", + "destination": destination, + "train_number": "ES 9014", + "price": p.get("price") if isinstance(p, dict) else None, + "seats": p.get("seats") if isinstance(p, dict) else None, + "plus_price": p.get("plus_price") if isinstance(p, dict) else None, + "plus_seats": p.get("plus_seats") if isinstance(p, dict) else None, }, ], ) -def test_index_shows_station_dropdown_and_destination_radios(): +def test_index_shows_station_dropdown_and_destination_radios() -> None: client = _client() - resp = client.get('/') + resp = client.get("/") html = resp.get_data(as_text=True) assert resp.status_code == 200 - assert 'Departure point' in html - assert 'Bristol Temple Meads' in html + assert "Departure point" in html + assert "Bristol Temple Meads" in html assert 'name="station_crs"' in html assert html.count('type="radio"') == len(app_module.DESTINATIONS) - assert 'destination-rotterdam' in html + assert "destination-rotterdam" in html -def test_search_redirects_to_results_with_selected_params(): +def test_search_redirects_to_results_with_selected_params() -> None: client = _client() - resp = client.get('/search?destination=rotterdam&travel_date=2026-04-10&min_connection=60&max_connection=120&station_crs=BRI') + resp = client.get( + "/search?destination=rotterdam&travel_date=2026-04-10&min_connection=60&max_connection=120&station_crs=BRI" + ) assert resp.status_code == 302 - assert resp.headers['Location'].endswith( - '/results/BRI/rotterdam/2026-04-10?min_connection=60&max_connection=120' + assert resp.headers["Location"].endswith( + "/results/BRI/rotterdam/2026-04-10?min_connection=60&max_connection=120" ) -def test_search_redirects_return_with_return_date(): +def test_search_redirects_return_with_return_date() -> None: client = _client() - resp = client.get('/search?journey_type=return&destination=paris&travel_date=2026-04-10&return_date=2026-04-17&station_crs=BRI') + resp = client.get( + "/search?journey_type=return&destination=paris&travel_date=2026-04-10&return_date=2026-04-17&station_crs=BRI" + ) assert resp.status_code == 302 - assert resp.headers['Location'].endswith( - '/results/BRI/paris/2026-04-10/return/2026-04-17' + assert resp.headers["Location"].endswith( + "/results/BRI/paris/2026-04-10/return/2026-04-17" ) -def test_nr_weekday_cache_key_includes_timetable_period(): +def test_nr_weekday_cache_key_includes_timetable_period() -> None: key = app_module._nr_weekday_cache_key("to_paddington", "BRI", "2026-06-22") assert key == "weekday_rtt_to_paddington_BRI_2026-05-17_2026-12-12_mon" -def test_results_shows_same_day_destination_switcher(monkeypatch): +def test_results_shows_same_day_destination_switcher(monkeypatch: Any) -> None: _stub_data(monkeypatch) client = _client() - resp = client.get('/results/BRI/paris/2026-04-10?min_connection=60&max_connection=120') + resp = client.get( + "/results/BRI/paris/2026-04-10?min_connection=60&max_connection=120" + ) html = resp.get_data(as_text=True) assert resp.status_code == 200 - assert 'Switch destination for Friday 10 April 2026' in html + assert "Switch destination for Friday 10 April 2026" in html assert 'Paris Gare du Nord' in html - assert '/results/BRI/brussels/2026-04-10?min_connection=60&max_connection=120' in html - assert '/results/BRI/rotterdam/2026-04-10?min_connection=60&max_connection=120' in html - assert 'ES 9014' in html + assert ( + "/results/BRI/brussels/2026-04-10?min_connection=60&max_connection=120" + in html + ) + assert ( + "/results/BRI/rotterdam/2026-04-10?min_connection=60&max_connection=120" + in html + ) + assert "ES 9014" in html -def test_results_can_render_from_weekday_timetable_cache(monkeypatch): +def test_results_can_render_from_weekday_timetable_cache(monkeypatch: Any) -> None: travel_date = "2026-06-22" - cache = { + cache: dict[str, Any] = { app_module._nr_weekday_cache_key("to_paddington", "BRI", travel_date): [ - {'depart_bristol': '07:00', 'arrive_paddington': '08:45', 'headcode': '1A23'}, + { + "depart_bristol": "07:00", + "arrive_paddington": "08:45", + "headcode": "1A23", + }, ], - app_module._eurostar_weekday_cache_key("outbound", travel_date, "Paris Gare du Nord"): [ - {'depart_st_pancras': '10:01', 'arrive_destination': '13:34', - 'destination': 'Paris Gare du Nord', 'train_number': 'ES 9014'}, + app_module._eurostar_weekday_cache_key( + "outbound", travel_date, "Paris Gare du Nord" + ): [ + { + "depart_st_pancras": "10:01", + "arrive_destination": "13:34", + "destination": "Paris Gare du Nord", + "train_number": "ES 9014", + }, ], } - monkeypatch.setattr(app_module, 'get_cached', lambda key, ttl=None: cache.get(key)) - monkeypatch.setattr(app_module, 'set_cached', lambda key, data: None) + monkeypatch.setattr(app_module, "get_cached", lambda key, ttl=None: cache.get(key)) + monkeypatch.setattr(app_module, "set_cached", lambda key, data: None) monkeypatch.setattr( - app_module.rtt_scraper, - 'fetch', - lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("should use weekday NR cache")), + rtt_scraper, + "fetch", + lambda *args, **kwargs: (_ for _ in ()).throw( + AssertionError("should use weekday NR cache") + ), ) monkeypatch.setattr( - app_module.eurostar_scraper, - 'fetch', - lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("should use weekday Eurostar cache")), + eurostar_scraper, + "fetch", + lambda *args, **kwargs: (_ for _ in ()).throw( + AssertionError("should use weekday Eurostar cache") + ), ) monkeypatch.setattr( - app_module.gwr_fares_scraper, - 'fetch', - lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("should stream prices later")), + gwr_fares_scraper, + "fetch", + lambda *args, **kwargs: (_ for _ in ()).throw( + AssertionError("should stream prices later") + ), ) client = _client() - resp = client.get('/results/BRI/paris/2026-06-22?min_connection=60&max_connection=120') + resp = client.get( + "/results/BRI/paris/2026-06-22?min_connection=60&max_connection=120" + ) html = resp.get_data(as_text=True) assert resp.status_code == 200 - assert '07:00 → 08:45' in html - assert '10:01 → 13:34' in html - assert 'ES 9014' in html - assert 'checking exact timetable' in html - assert '/api/results_refresh/BRI/paris/2026-06-22' in html - assert 'refreshFullResults()' in html - assert 'window.location.reload()' not in html - assert 'Eurostar prices not yet available' not in html + assert "07:00 → 08:45" in html + assert "10:01 → 13:34" in html + assert "ES 9014" in html + assert "checking exact timetable" in html + assert "/api/results_refresh/BRI/paris/2026-06-22" in html + assert "refreshFullResults()" in html + assert "window.location.reload()" not in html + assert "Eurostar prices not yet available" not in html -def test_results_refresh_reloads_when_exact_timetable_differs(monkeypatch): +def test_results_refresh_reloads_when_exact_timetable_differs(monkeypatch: Any) -> None: travel_date = "2026-06-22" - cache = { + cache: dict[str, Any] = { app_module._nr_weekday_cache_key("to_paddington", "BRI", travel_date): [ - {'depart_bristol': '07:00', 'arrive_paddington': '08:45', 'headcode': '1A23'}, + { + "depart_bristol": "07:00", + "arrive_paddington": "08:45", + "headcode": "1A23", + }, ], - app_module._eurostar_weekday_cache_key("outbound", travel_date, "Paris Gare du Nord"): [ - {'depart_st_pancras': '10:01', 'arrive_destination': '13:34', - 'destination': 'Paris Gare du Nord', 'train_number': 'ES 9014'}, + app_module._eurostar_weekday_cache_key( + "outbound", travel_date, "Paris Gare du Nord" + ): [ + { + "depart_st_pancras": "10:01", + "arrive_destination": "13:34", + "destination": "Paris Gare du Nord", + "train_number": "ES 9014", + }, ], } - monkeypatch.setattr(app_module, 'get_cached', lambda key, ttl=None: cache.get(key)) - monkeypatch.setattr(app_module, 'set_cached', lambda key, data: cache.__setitem__(key, data)) + monkeypatch.setattr(app_module, "get_cached", lambda key, ttl=None: cache.get(key)) monkeypatch.setattr( - app_module.rtt_scraper, - 'fetch', - lambda travel_date, user_agent, station_crs='BRI': [ - {'depart_bristol': '07:05', 'arrive_paddington': '08:50', 'headcode': '1A24'}, + app_module, "set_cached", lambda key, data: cache.__setitem__(key, data) + ) + monkeypatch.setattr( + rtt_scraper, + "fetch", + lambda travel_date, user_agent, station_crs="BRI": [ + { + "depart_bristol": "07:05", + "arrive_paddington": "08:50", + "headcode": "1A24", + }, ], ) monkeypatch.setattr( - app_module.eurostar_scraper, - 'fetch', + eurostar_scraper, + "fetch", lambda destination, travel_date: [ - {'depart_st_pancras': '10:01', 'arrive_destination': '13:34', - 'destination': destination, 'train_number': 'ES 9014', - 'price': 59, 'seats': 42, 'plus_price': 89, 'plus_seats': 5}, + { + "depart_st_pancras": "10:01", + "arrive_destination": "13:34", + "destination": destination, + "train_number": "ES 9014", + "price": 59, + "seats": 42, + "plus_price": 89, + "plus_seats": 5, + }, ], ) monkeypatch.setattr( - app_module.gwr_fares_scraper, - 'fetch', - lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("reload should stop before fare fetch")), + gwr_fares_scraper, + "fetch", + lambda *args, **kwargs: (_ for _ in ()).throw( + AssertionError("reload should stop before fare fetch") + ), ) client = _client() - resp = client.get('/api/results_refresh/BRI/paris/2026-06-22') + resp = client.get("/api/results_refresh/BRI/paris/2026-06-22") body = resp.get_data(as_text=True) assert resp.status_code == 200 assert '"type": "reload"' in body - assert cache[app_module._nr_exact_cache_key("to_paddington", "BRI", travel_date)][0]['depart_bristol'] == '07:05' - assert cache[app_module._nr_weekday_cache_key("to_paddington", "BRI", travel_date)][0]['depart_bristol'] == '07:05' + assert ( + cache[app_module._nr_exact_cache_key("to_paddington", "BRI", travel_date)][0][ + "depart_bristol" + ] + == "07:05" + ) + assert ( + cache[app_module._nr_weekday_cache_key("to_paddington", "BRI", travel_date)][0][ + "depart_bristol" + ] + == "07:05" + ) -def test_results_refresh_streams_prices_when_timetable_matches(monkeypatch): +def test_results_refresh_streams_prices_when_timetable_matches( + monkeypatch: Any, +) -> None: travel_date = "2026-06-22" nr_timetable = [ - {'depart_bristol': '07:00', 'arrive_paddington': '08:45', 'headcode': '1A23'}, + {"depart_bristol": "07:00", "arrive_paddington": "08:45", "headcode": "1A23"}, ] es_timetable = [ - {'depart_st_pancras': '10:01', 'arrive_destination': '13:34', - 'destination': 'Paris Gare du Nord', 'train_number': 'ES 9014'}, + { + "depart_st_pancras": "10:01", + "arrive_destination": "13:34", + "destination": "Paris Gare du Nord", + "train_number": "ES 9014", + }, ] - cache = { - app_module._nr_weekday_cache_key("to_paddington", "BRI", travel_date): nr_timetable, - app_module._eurostar_weekday_cache_key("outbound", travel_date, "Paris Gare du Nord"): es_timetable, + cache: dict[str, Any] = { + app_module._nr_weekday_cache_key( + "to_paddington", "BRI", travel_date + ): nr_timetable, + app_module._eurostar_weekday_cache_key( + "outbound", travel_date, "Paris Gare du Nord" + ): es_timetable, } - monkeypatch.setattr(app_module, 'get_cached', lambda key, ttl=None: cache.get(key)) - monkeypatch.setattr(app_module, 'set_cached', lambda key, data: cache.__setitem__(key, data)) + monkeypatch.setattr(app_module, "get_cached", lambda key, ttl=None: cache.get(key)) monkeypatch.setattr( - app_module.rtt_scraper, - 'fetch', - lambda travel_date, user_agent, station_crs='BRI': nr_timetable, + app_module, "set_cached", lambda key, data: cache.__setitem__(key, data) ) monkeypatch.setattr( - app_module.eurostar_scraper, - 'fetch', + rtt_scraper, + "fetch", + lambda travel_date, user_agent, station_crs="BRI": nr_timetable, + ) + monkeypatch.setattr( + eurostar_scraper, + "fetch", lambda destination, travel_date: [ - {**es_timetable[0], 'price': 59, 'seats': 42, 'plus_price': 89, 'plus_seats': 5}, + { + **es_timetable[0], + "price": 59, + "seats": 42, + "plus_price": 89, + "plus_seats": 5, + }, ], ) monkeypatch.setattr( - app_module.gwr_fares_scraper, - 'fetch', + gwr_fares_scraper, + "fetch", lambda station_crs, travel_date: { - '07:00': {'ticket': 'Anytime Day Single', 'price': 138.70, 'code': 'SDS'}, + "07:00": {"ticket": "Anytime Day Single", "price": 138.70, "code": "SDS"}, }, ) client = _client() - resp = client.get('/api/results_refresh/BRI/paris/2026-06-22') + resp = client.get("/api/results_refresh/BRI/paris/2026-06-22") body = resp.get_data(as_text=True) assert resp.status_code == 200 @@ -237,176 +331,295 @@ def test_results_refresh_streams_prices_when_timetable_matches(monkeypatch): assert '"price": 138.7' in body -def test_results_progressive_shell_loads_without_scraping(monkeypatch): - def fail_fetch(*args, **kwargs): +def test_results_progressive_shell_loads_without_scraping(monkeypatch: Any) -> None: + def fail_fetch(*args: Any, **kwargs: Any) -> None: raise AssertionError("progressive shell should not fetch data") - monkeypatch.setattr(app_module.rtt_scraper, 'fetch', fail_fetch) - monkeypatch.setattr(app_module.eurostar_scraper, 'fetch', fail_fetch) - monkeypatch.setattr(app_module.gwr_fares_scraper, 'fetch', fail_fetch) + monkeypatch.setattr(rtt_scraper, "fetch", fail_fetch) + monkeypatch.setattr(eurostar_scraper, "fetch", fail_fetch) + monkeypatch.setattr(gwr_fares_scraper, "fetch", fail_fetch) client = _client() - resp = client.get('/results/BRI/paris/2026-04-10?progressive=1') + resp = client.get("/results/BRI/paris/2026-04-10?progressive=1") html = resp.get_data(as_text=True) assert resp.status_code == 200 - assert 'Loading train times and fares' in html - assert 'render=full' in html + assert "Loading train times and fares" in html + assert "render=full" in html -def test_return_progressive_shell_formats_return_date(monkeypatch): - def fail_fetch(*args, **kwargs): +def test_return_progressive_shell_formats_return_date(monkeypatch: Any) -> None: + def fail_fetch(*args: Any, **kwargs: Any) -> None: raise AssertionError("progressive shell should not fetch data") - monkeypatch.setattr(app_module.rtt_scraper, 'fetch', fail_fetch) - monkeypatch.setattr(app_module.eurostar_scraper, 'fetch_return', fail_fetch) - monkeypatch.setattr(app_module.gwr_fares_scraper, 'fetch', fail_fetch) + monkeypatch.setattr(rtt_scraper, "fetch", fail_fetch) + monkeypatch.setattr(eurostar_scraper, "fetch_return", fail_fetch) + monkeypatch.setattr(gwr_fares_scraper, "fetch", fail_fetch) client = _client() - resp = client.get('/results/BRI/paris/2026-04-10/return/2026-04-17?progressive=1') + resp = client.get("/results/BRI/paris/2026-04-10/return/2026-04-17?progressive=1") html = resp.get_data(as_text=True) assert resp.status_code == 200 - assert 'Friday 10 April 2026 to Friday 17 April 2026' in html - assert 'to 2026-04-17' not in html + assert "Friday 10 April 2026 to Friday 17 April 2026" in html + assert "to 2026-04-17" not in html -def test_results_title_and_social_meta_include_destination(monkeypatch): +def test_results_title_and_social_meta_include_destination(monkeypatch: Any) -> None: _stub_data(monkeypatch) client = _client() - resp = client.get('/results/BRI/lille/2026-04-10?min_connection=60&max_connection=120') + resp = client.get( + "/results/BRI/lille/2026-04-10?min_connection=60&max_connection=120" + ) html = resp.get_data(as_text=True) assert resp.status_code == 200 - assert 'Bristol Temple Meads to Lille Europe via Eurostar' in html - assert '' in html + assert "Bristol Temple Meads to Lille Europe via Eurostar" in html + assert ( + '' + in html + ) assert ( '' ) in html - assert '' in html + assert ( + '' + in html + ) -def test_results_marks_trips_within_five_minutes_of_fastest_and_slowest(monkeypatch): - monkeypatch.setattr(app_module, 'get_cached', lambda key, ttl=None: None) - monkeypatch.setattr(app_module, 'set_cached', lambda key, data: None) - monkeypatch.setattr(app_module.gwr_fares_scraper, 'fetch', lambda s, d: {}) +def test_results_marks_trips_within_five_minutes_of_fastest_and_slowest( + monkeypatch: Any, +) -> None: + monkeypatch.setattr(app_module, "get_cached", lambda key, ttl=None: None) + monkeypatch.setattr(app_module, "set_cached", lambda key, data: None) + monkeypatch.setattr(gwr_fares_scraper, "fetch", lambda s, d: {}) monkeypatch.setattr( - app_module.rtt_scraper, - 'fetch', - lambda travel_date, user_agent, station_crs='BRI': [ - {'depart_bristol': '07:00', 'arrive_paddington': '08:30', 'headcode': '1A01'}, - {'depart_bristol': '07:05', 'arrive_paddington': '08:36', 'headcode': '1A02'}, - {'depart_bristol': '07:10', 'arrive_paddington': '08:46', 'headcode': '1A03'}, - {'depart_bristol': '07:15', 'arrive_paddington': '08:56', 'headcode': '1A04'}, - {'depart_bristol': '07:20', 'arrive_paddington': '09:06', 'headcode': '1A05'}, + rtt_scraper, + "fetch", + lambda travel_date, user_agent, station_crs="BRI": [ + { + "depart_bristol": "07:00", + "arrive_paddington": "08:30", + "headcode": "1A01", + }, + { + "depart_bristol": "07:05", + "arrive_paddington": "08:36", + "headcode": "1A02", + }, + { + "depart_bristol": "07:10", + "arrive_paddington": "08:46", + "headcode": "1A03", + }, + { + "depart_bristol": "07:15", + "arrive_paddington": "08:56", + "headcode": "1A04", + }, + { + "depart_bristol": "07:20", + "arrive_paddington": "09:06", + "headcode": "1A05", + }, ], ) monkeypatch.setattr( - app_module.eurostar_scraper, - 'fetch', + eurostar_scraper, + "fetch", lambda destination, travel_date: [ - {'depart_st_pancras': '09:30', 'arrive_destination': '11:50', 'destination': destination, 'train_number': 'ES 1001', 'price': None, 'seats': None}, - {'depart_st_pancras': '09:40', 'arrive_destination': '12:00', 'destination': destination, 'train_number': 'ES 1002', 'price': None, 'seats': None}, - {'depart_st_pancras': '09:50', 'arrive_destination': '12:20', 'destination': destination, 'train_number': 'ES 1003', 'price': None, 'seats': None}, - {'depart_st_pancras': '10:00', 'arrive_destination': '12:35', 'destination': destination, 'train_number': 'ES 1004', 'price': None, 'seats': None}, - {'depart_st_pancras': '10:10', 'arrive_destination': '12:45', 'destination': destination, 'train_number': 'ES 1005', 'price': None, 'seats': None}, + { + "depart_st_pancras": "09:30", + "arrive_destination": "11:50", + "destination": destination, + "train_number": "ES 1001", + "price": None, + "seats": None, + }, + { + "depart_st_pancras": "09:40", + "arrive_destination": "12:00", + "destination": destination, + "train_number": "ES 1002", + "price": None, + "seats": None, + }, + { + "depart_st_pancras": "09:50", + "arrive_destination": "12:20", + "destination": destination, + "train_number": "ES 1003", + "price": None, + "seats": None, + }, + { + "depart_st_pancras": "10:00", + "arrive_destination": "12:35", + "destination": destination, + "train_number": "ES 1004", + "price": None, + "seats": None, + }, + { + "depart_st_pancras": "10:10", + "arrive_destination": "12:45", + "destination": destination, + "train_number": "ES 1005", + "price": None, + "seats": None, + }, ], ) client = _client() - resp = client.get('/results/BRI/paris/2026-04-10?min_connection=60&max_connection=120') + resp = client.get( + "/results/BRI/paris/2026-04-10?min_connection=60&max_connection=120" + ) html = resp.get_data(as_text=True) assert resp.status_code == 200 assert html.count('title="Fastest journey"') == 2 assert html.count('title="Slowest journey"') == 2 - assert '4h 50m ⚡' in html - assert '4h 55m ⚡' in html - assert '5h 20m 🐢' in html - assert '5h 25m 🐢' in html - assert '5h 10m ⚡' not in html - assert '5h 10m 🐢' not in html + assert "4h 50m ⚡" in html + assert "4h 55m ⚡" in html + assert "5h 20m 🐢" in html + assert "5h 25m 🐢" in html + assert "5h 10m ⚡" not in html + assert "5h 10m 🐢" not in html -def test_results_shows_only_pre_first_reachable_unreachable_services(monkeypatch): +def test_results_shows_only_pre_first_reachable_unreachable_services( + monkeypatch: Any, +) -> None: # GWR arrives 08:45; min=60 → earliest viable Eurostar 09:45; max=120 → latest 10:45. # 09:30 too early → shown as "Too early" # 10:15 reachable → shown as a trip (needs circle line XML, so not tested here) # 12:30 after first reachable → hidden - monkeypatch.setattr(app_module, 'get_cached', lambda key, ttl=None: None) - monkeypatch.setattr(app_module, 'set_cached', lambda key, data: None) - monkeypatch.setattr(app_module.gwr_fares_scraper, 'fetch', lambda s, d: {}) + monkeypatch.setattr(app_module, "get_cached", lambda key, ttl=None: None) + monkeypatch.setattr(app_module, "set_cached", lambda key, data: None) + monkeypatch.setattr(gwr_fares_scraper, "fetch", lambda s, d: {}) monkeypatch.setattr( - app_module.rtt_scraper, - 'fetch', - lambda travel_date, user_agent, station_crs='BRI': [ - {'depart_bristol': '07:00', 'arrive_paddington': '08:45', 'headcode': '1A23'}, + rtt_scraper, + "fetch", + lambda travel_date, user_agent, station_crs="BRI": [ + { + "depart_bristol": "07:00", + "arrive_paddington": "08:45", + "headcode": "1A23", + }, ], ) monkeypatch.setattr( - app_module.eurostar_scraper, - 'fetch', + eurostar_scraper, + "fetch", lambda destination, travel_date: [ - {'depart_st_pancras': '09:30', 'arrive_destination': '12:00', 'destination': destination, 'train_number': 'ES 9001', 'price': None, 'seats': None}, - {'depart_st_pancras': '10:15', 'arrive_destination': '13:40', 'destination': destination, 'train_number': 'ES 9002', 'price': None, 'seats': None}, - {'depart_st_pancras': '12:30', 'arrive_destination': '15:55', 'destination': destination, 'train_number': 'ES 9003', 'price': None, 'seats': None}, + { + "depart_st_pancras": "09:30", + "arrive_destination": "12:00", + "destination": destination, + "train_number": "ES 9001", + "price": None, + "seats": None, + }, + { + "depart_st_pancras": "10:15", + "arrive_destination": "13:40", + "destination": destination, + "train_number": "ES 9002", + "price": None, + "seats": None, + }, + { + "depart_st_pancras": "12:30", + "arrive_destination": "15:55", + "destination": destination, + "train_number": "ES 9003", + "price": None, + "seats": None, + }, ], ) client = _client() - resp = client.get('/results/BRI/paris/2026-04-10?min_connection=60&max_connection=120') + resp = client.get( + "/results/BRI/paris/2026-04-10?min_connection=60&max_connection=120" + ) html = resp.get_data(as_text=True) assert resp.status_code == 200 - assert 'ES 9001' in html # before first reachable → shown - assert 'Too early' in html - assert 'ES 9003' not in html # after first reachable → hidden + assert "ES 9001" in html # before first reachable → shown + assert "Too early" in html + assert "ES 9003" not in html # after first reachable → hidden -def test_results_shows_eurostar_price_and_total(monkeypatch): +def test_results_shows_eurostar_price_and_total(monkeypatch: Any) -> None: # 07:00 on Friday 2026-04-10 → Anytime £138.70 walk-on + ES £59.00 - _stub_data(monkeypatch, prices={'10:01': {'price': 59, 'seats': 42}}) + _stub_data(monkeypatch, prices={"10:01": {"price": 59, "seats": 42}}) client = _client() - resp = client.get('/results/BRI/paris/2026-04-10?min_connection=60&max_connection=120') + resp = client.get( + "/results/BRI/paris/2026-04-10?min_connection=60&max_connection=120" + ) html = resp.get_data(as_text=True) assert resp.status_code == 200 - assert '£59' in html # Eurostar Standard price in initial render - assert '£138.70' not in html # Walk-on price is streamed, not server-rendered + assert "£59" in html # Eurostar Standard price in initial render + assert "£138.70" not in html # Walk-on price is streamed, not server-rendered assert 'data-es-std="59"' in html - assert '/api/walkon_fares/BRI/' in html # client will fetch walk-on fares + assert "/api/walkon_fares/BRI/" in html # client will fetch walk-on fares -def test_results_uses_unique_row_keys_for_same_eurostar(monkeypatch): - monkeypatch.setattr(app_module, 'get_cached', lambda key, ttl=None: None) - monkeypatch.setattr(app_module, 'set_cached', lambda key, data: None) - monkeypatch.setattr(app_module.gwr_fares_scraper, 'fetch', lambda s, d: { - '07:00': {'ticket': 'Anytime Day Single', 'price': 138.70, 'code': 'SDS'}, - '07:30': {'ticket': 'Anytime Day Single', 'price': 138.70, 'code': 'SDS'}, - }) +def test_results_uses_unique_row_keys_for_same_eurostar(monkeypatch: Any) -> None: + monkeypatch.setattr(app_module, "get_cached", lambda key, ttl=None: None) + monkeypatch.setattr(app_module, "set_cached", lambda key, data: None) monkeypatch.setattr( - app_module.rtt_scraper, - 'fetch', - lambda travel_date, user_agent, station_crs='BRI': [ - {'depart_bristol': '07:00', 'arrive_paddington': '08:30', 'headcode': '1A01'}, - {'depart_bristol': '07:30', 'arrive_paddington': '09:00', 'headcode': '1A02'}, + gwr_fares_scraper, + "fetch", + lambda s, d: { + "07:00": {"ticket": "Anytime Day Single", "price": 138.70, "code": "SDS"}, + "07:30": {"ticket": "Anytime Day Single", "price": 138.70, "code": "SDS"}, + }, + ) + monkeypatch.setattr( + rtt_scraper, + "fetch", + lambda travel_date, user_agent, station_crs="BRI": [ + { + "depart_bristol": "07:00", + "arrive_paddington": "08:30", + "headcode": "1A01", + }, + { + "depart_bristol": "07:30", + "arrive_paddington": "09:00", + "headcode": "1A02", + }, ], ) monkeypatch.setattr( - app_module.eurostar_scraper, - 'fetch', + eurostar_scraper, + "fetch", lambda destination, travel_date: [ - {'depart_st_pancras': '10:01', 'arrive_destination': '13:34', - 'destination': destination, 'train_number': 'ES 9014', - 'price': 59, 'seats': 42, 'plus_price': 89, 'plus_seats': 5}, + { + "depart_st_pancras": "10:01", + "arrive_destination": "13:34", + "destination": destination, + "train_number": "ES 9014", + "price": 59, + "seats": 42, + "plus_price": 89, + "plus_seats": 5, + }, ], ) client = _client() - resp = client.get('/results/BRI/paris/2026-04-10?min_connection=60&max_connection=120') + resp = client.get( + "/results/BRI/paris/2026-04-10?min_connection=60&max_connection=120" + ) html = resp.get_data(as_text=True) assert resp.status_code == 200 @@ -416,194 +629,264 @@ def test_results_uses_unique_row_keys_for_same_eurostar(monkeypatch): assert '"main:07:30:10:01"' in html -def test_results_shows_unreachable_service_when_no_trips(monkeypatch): +def test_results_shows_unreachable_service_when_no_trips(monkeypatch: Any) -> None: # Only one Eurostar at 09:30; GWR arrives 08:45 with min=60 → unreachable. # No trips at all, so the unreachable service is shown as "Too early". - monkeypatch.setattr(app_module, 'get_cached', lambda key, ttl=None: None) - monkeypatch.setattr(app_module, 'set_cached', lambda key, data: None) - monkeypatch.setattr(app_module.gwr_fares_scraper, 'fetch', lambda s, d: {}) + monkeypatch.setattr(app_module, "get_cached", lambda key, ttl=None: None) + monkeypatch.setattr(app_module, "set_cached", lambda key, data: None) + monkeypatch.setattr(gwr_fares_scraper, "fetch", lambda s, d: {}) monkeypatch.setattr( - app_module.rtt_scraper, - 'fetch', - lambda travel_date, user_agent, station_crs='BRI': [ - {'depart_bristol': '07:00', 'arrive_paddington': '08:45', 'headcode': '1A23'}, + rtt_scraper, + "fetch", + lambda travel_date, user_agent, station_crs="BRI": [ + { + "depart_bristol": "07:00", + "arrive_paddington": "08:45", + "headcode": "1A23", + }, ], ) monkeypatch.setattr( - app_module.eurostar_scraper, - 'fetch', + eurostar_scraper, + "fetch", lambda destination, travel_date: [ - {'depart_st_pancras': '09:30', 'arrive_destination': '12:00', 'destination': destination, 'train_number': 'ES 9001', 'price': None, 'seats': None}, + { + "depart_st_pancras": "09:30", + "arrive_destination": "12:00", + "destination": destination, + "train_number": "ES 9001", + "price": None, + "seats": None, + }, ], ) client = _client() - resp = client.get('/results/BRI/paris/2026-04-10?min_connection=60&max_connection=120') + resp = client.get( + "/results/BRI/paris/2026-04-10?min_connection=60&max_connection=120" + ) html = resp.get_data(as_text=True) assert resp.status_code == 200 - assert 'ES 9001' in html - assert 'Too early' in html - assert 'No valid journeys found.' not in html + assert "ES 9001" in html + assert "Too early" in html + assert "No valid journeys found." not in html -def test_results_shows_eurostar_plus_price(monkeypatch): - _stub_data(monkeypatch, prices={'10:01': {'price': 59, 'seats': 42, 'plus_price': 89, 'plus_seats': 5}}) +def test_results_shows_eurostar_plus_price(monkeypatch: Any) -> None: + _stub_data( + monkeypatch, + prices={"10:01": {"price": 59, "seats": 42, "plus_price": 89, "plus_seats": 5}}, + ) client = _client() - resp = client.get('/results/BRI/paris/2026-04-10?min_connection=60&max_connection=120') + resp = client.get( + "/results/BRI/paris/2026-04-10?min_connection=60&max_connection=120" + ) html = resp.get_data(as_text=True) assert resp.status_code == 200 - assert '£59' in html # Standard price - assert '£89' in html # Plus price - assert 'Plus' in html # Plus label + assert "£59" in html # Standard price + assert "£89" in html # Plus price + assert "Plus" in html # Plus label -def test_results_selectors_present(monkeypatch): +def test_results_selectors_present(monkeypatch: Any) -> None: _stub_data(monkeypatch) client = _client() - resp = client.get('/results/BRI/paris/2026-04-10?min_connection=60&max_connection=120') + resp = client.get( + "/results/BRI/paris/2026-04-10?min_connection=60&max_connection=120" + ) html = resp.get_data(as_text=True) assert resp.status_code == 200 - assert 'nr-type-select' in html - assert 'es-type-select' in html - assert 'Load advance prices' in html - assert 'Plus' in html + assert "nr-type-select" in html + assert "es-type-select" in html + assert "Load advance prices" in html + assert "Plus" in html -def test_results_preloads_cached_advance_fares(monkeypatch): +def test_results_preloads_cached_advance_fares(monkeypatch: Any) -> None: advance_data = { - '07:00': { - 'advance_std': {'ticket': 'Advance Single', 'price': 45.0, 'code': 'ADV'}, - 'advance_1st': None, + "07:00": { + "advance_std": {"ticket": "Advance Single", "price": 45.0, "code": "ADV"}, + "advance_1st": None, } } - def fake_get_cached(key, ttl=None): - if 'gwr_advance' in key: + + def fake_get_cached(key: str, ttl: Any = None) -> Any: + if "gwr_advance" in key: return advance_data return None - monkeypatch.setattr(app_module, 'get_cached', fake_get_cached) - monkeypatch.setattr(app_module, 'set_cached', lambda key, data: None) + + monkeypatch.setattr(app_module, "get_cached", fake_get_cached) + monkeypatch.setattr(app_module, "set_cached", lambda key, data: None) monkeypatch.setattr( - app_module.rtt_scraper, 'fetch', - lambda travel_date, user_agent, station_crs='BRI': [ - {'depart_bristol': '07:00', 'arrive_paddington': '08:45', 'headcode': '1A23'}, + rtt_scraper, + "fetch", + lambda travel_date, user_agent, station_crs="BRI": [ + { + "depart_bristol": "07:00", + "arrive_paddington": "08:45", + "headcode": "1A23", + }, ], ) - monkeypatch.setattr(app_module.gwr_fares_scraper, 'fetch', lambda s, d: {}) + monkeypatch.setattr(gwr_fares_scraper, "fetch", lambda s, d: {}) monkeypatch.setattr( - app_module.eurostar_scraper, 'fetch', + eurostar_scraper, + "fetch", lambda destination, travel_date: [ - {'depart_st_pancras': '10:01', 'arrive_destination': '13:34', - 'destination': destination, 'train_number': 'ES 9014', - 'price': None, 'seats': None, 'plus_price': None, 'plus_seats': None}, + { + "depart_st_pancras": "10:01", + "arrive_destination": "13:34", + "destination": destination, + "train_number": "ES 9014", + "price": None, + "seats": None, + "plus_price": None, + "plus_seats": None, + }, ], ) client = _client() - resp = client.get('/results/BRI/paris/2026-04-10?min_connection=60&max_connection=120') + resp = client.get( + "/results/BRI/paris/2026-04-10?min_connection=60&max_connection=120" + ) html = resp.get_data(as_text=True) assert resp.status_code == 200 # Cached advance fares are embedded in the page JS assert '"advance_std"' in html - assert '45.0' in html + assert "45.0" in html # Button is absent (hidden via cachedAdvanceFares check in JS) # The JS will hide it on load; the data is present for applyAdvanceFares() - assert 'cachedAdvanceFares' in html + assert "cachedAdvanceFares" in html -def test_results_inbound_uses_reverse_legs(monkeypatch): - monkeypatch.setattr(app_module, 'get_cached', lambda key, ttl=None: None) - monkeypatch.setattr(app_module, 'set_cached', lambda key, data: None) +def test_results_inbound_uses_reverse_legs(monkeypatch: Any) -> None: + monkeypatch.setattr(app_module, "get_cached", lambda key, ttl=None: None) + monkeypatch.setattr(app_module, "set_cached", lambda key, data: None) monkeypatch.setattr( - app_module.rtt_scraper, - 'fetch_from_paddington', - lambda travel_date, user_agent, station_crs='BRI': [ - {'depart_paddington': '17:15', 'arrive_destination': '18:55', 'headcode': '1B99'}, + rtt_scraper, + "fetch_from_paddington", + lambda travel_date, user_agent, station_crs="BRI": [ + { + "depart_paddington": "17:15", + "arrive_destination": "18:55", + "headcode": "1B99", + }, ], ) monkeypatch.setattr( - app_module.gwr_fares_scraper, - 'fetch', - lambda station_crs, travel_date, direction='to_paddington': { - '17:15': {'ticket': 'Off-Peak Single', 'price': 63.60, 'code': 'SVS'} + gwr_fares_scraper, + "fetch", + lambda station_crs, travel_date, direction="to_paddington": { + "17:15": {"ticket": "Off-Peak Single", "price": 63.60, "code": "SVS"} }, ) monkeypatch.setattr( - app_module.eurostar_scraper, - 'fetch', - lambda destination, travel_date, direction='outbound': [ - {'depart_destination': '15:12', 'arrive_st_pancras': '16:30', - 'destination': destination, 'train_number': 'ES 9035', - 'price': 49, 'seats': 43, 'plus_price': None, 'plus_seats': None}, + eurostar_scraper, + "fetch", + lambda destination, travel_date, direction="outbound": [ + { + "depart_destination": "15:12", + "arrive_st_pancras": "16:30", + "destination": destination, + "train_number": "ES 9035", + "price": 49, + "seats": 43, + "plus_price": None, + "plus_seats": None, + }, ], ) client = _client() - resp = client.get('/results/BRI/paris/2026-04-10?journey_type=inbound') + resp = client.get("/results/BRI/paris/2026-04-10?journey_type=inbound") html = resp.get_data(as_text=True) assert resp.status_code == 200 - assert 'Paris Gare du Nord → Bristol Temple Meads' in html - assert '15:12 → 16:30' in html - assert '17:15 → 18:55' in html - assert 'ES 9035' in html + assert "Paris Gare du Nord → Bristol Temple Meads" in html + assert "15:12 → 16:30" in html + assert "17:15 → 18:55" in html + assert "ES 9035" in html -def test_results_return_renders_outbound_and_inbound_tables(monkeypatch): - monkeypatch.setattr(app_module, 'get_cached', lambda key, ttl=None: None) - monkeypatch.setattr(app_module, 'set_cached', lambda key, data: None) +def test_results_return_renders_outbound_and_inbound_tables(monkeypatch: Any) -> None: + monkeypatch.setattr(app_module, "get_cached", lambda key, ttl=None: None) + monkeypatch.setattr(app_module, "set_cached", lambda key, data: None) monkeypatch.setattr( - app_module.rtt_scraper, - 'fetch', - lambda travel_date, user_agent, station_crs='BRI': [ - {'depart_bristol': '07:00', 'arrive_paddington': '08:45', 'headcode': '1A23'}, + rtt_scraper, + "fetch", + lambda travel_date, user_agent, station_crs="BRI": [ + { + "depart_bristol": "07:00", + "arrive_paddington": "08:45", + "headcode": "1A23", + }, ], ) monkeypatch.setattr( - app_module.rtt_scraper, - 'fetch_from_paddington', - lambda travel_date, user_agent, station_crs='BRI': [ - {'depart_paddington': '17:15', 'arrive_destination': '18:55', 'headcode': '1B99'}, + rtt_scraper, + "fetch_from_paddington", + lambda travel_date, user_agent, station_crs="BRI": [ + { + "depart_paddington": "17:15", + "arrive_destination": "18:55", + "headcode": "1B99", + }, ], ) monkeypatch.setattr( - app_module.gwr_fares_scraper, - 'fetch', - lambda station_crs, travel_date, direction='to_paddington': { - '07:00': {'ticket': 'Anytime Day Single', 'price': 138.70, 'code': 'SDS'}, - '17:15': {'ticket': 'Off-Peak Single', 'price': 63.60, 'code': 'SVS'}, + gwr_fares_scraper, + "fetch", + lambda station_crs, travel_date, direction="to_paddington": { + "07:00": {"ticket": "Anytime Day Single", "price": 138.70, "code": "SDS"}, + "17:15": {"ticket": "Off-Peak Single", "price": 63.60, "code": "SVS"}, }, ) monkeypatch.setattr( - app_module.eurostar_scraper, - 'fetch_return', + eurostar_scraper, + "fetch_return", lambda destination, outbound_date, return_date: { - 'outbound': [ - {'depart_st_pancras': '10:01', 'arrive_destination': '13:34', - 'destination': destination, 'train_number': 'ES 9014', - 'price': 59, 'seats': 42, 'plus_price': None, 'plus_seats': None}, + "outbound": [ + { + "depart_st_pancras": "10:01", + "arrive_destination": "13:34", + "destination": destination, + "train_number": "ES 9014", + "price": 59, + "seats": 42, + "plus_price": None, + "plus_seats": None, + }, ], - 'inbound': [ - {'depart_destination': '15:12', 'arrive_st_pancras': '16:30', - 'destination': destination, 'train_number': 'ES 9035', - 'price': 49, 'seats': 43, 'plus_price': None, 'plus_seats': None}, + "inbound": [ + { + "depart_destination": "15:12", + "arrive_st_pancras": "16:30", + "destination": destination, + "train_number": "ES 9035", + "price": 49, + "seats": 43, + "plus_price": None, + "plus_seats": None, + }, ], }, ) monkeypatch.setattr( - trip_planner_module.circle_line, - 'upcoming_services', - lambda earliest_board, count=2, direction='pad_to_kx', preceding=0: ( + circle_line, + "upcoming_services", + lambda earliest_board, count=2, direction="pad_to_kx", preceding=0: ( [ (datetime(2026, 4, 10, 9, 10), datetime(2026, 4, 10, 9, 25)), (datetime(2026, 4, 10, 9, 15), datetime(2026, 4, 10, 9, 30)), ] - if direction == 'pad_to_kx' + if direction == "pad_to_kx" else [ (datetime(2026, 4, 17, 16, 40), datetime(2026, 4, 17, 16, 55)), (datetime(2026, 4, 17, 16, 45), datetime(2026, 4, 17, 17, 0)), @@ -612,72 +895,76 @@ def test_results_return_renders_outbound_and_inbound_tables(monkeypatch): ) client = _client() - resp = client.get('/results/BRI/paris/2026-04-10/return/2026-04-17') + resp = client.get("/results/BRI/paris/2026-04-10/return/2026-04-17") html = resp.get_data(as_text=True) assert resp.status_code == 200 - assert 'Outbound: Bristol Temple Meads → Paris Gare du Nord' in html - assert 'Return: Paris Gare du Nord → Bristol Temple Meads' in html - assert 'Friday 10 April 2026' in html - assert 'Friday 17 April 2026' in html - assert '/results/BRI/paris/2026-04-09/return/2026-04-17' in html - assert '/results/BRI/paris/2026-04-11/return/2026-04-17' in html - assert '/results/BRI/paris/2026-04-10/return/2026-04-16' in html - assert '/results/BRI/paris/2026-04-10/return/2026-04-18' in html + assert "Outbound: Bristol Temple Meads → Paris Gare du Nord" in html + assert "Return: Paris Gare du Nord → Bristol Temple Meads" in html + assert "Friday 10 April 2026" in html + assert "Friday 17 April 2026" in html + assert "/results/BRI/paris/2026-04-09/return/2026-04-17" in html + assert "/results/BRI/paris/2026-04-11/return/2026-04-17" in html + assert "/results/BRI/paris/2026-04-10/return/2026-04-16" in html + assert "/results/BRI/paris/2026-04-10/return/2026-04-18" in html assert "/results/BRI/paris/2026-04-10/return/2026-04-17" in html - assert 'journey_type=return' not in html - assert 'return_date=2026-04-17' not in html - assert 'Circle 09:10 → KX 09:25' in html - assert 'next 09:15 → KX 09:30' in html - assert 'Circle 16:40 → PAD 16:55' in html - assert 'next 16:45 → PAD 17:00' in html + assert "journey_type=return" not in html + assert "return_date=2026-04-17" not in html + assert "Circle 09:10 → KX 09:25" in html + assert "next 09:15 → KX 09:30" in html + assert "Circle 16:40 → PAD 16:55" in html + assert "next 16:45 → PAD 17:00" in html assert 'title="Tight connection">⚠️' in html - assert 'ES 9014' in html - assert 'ES 9035' in html + assert "ES 9014" in html + assert "ES 9035" in html -def test_api_advance_fares_returns_json(monkeypatch): - monkeypatch.setattr(app_module, 'get_cached', lambda key, ttl=None: None) - monkeypatch.setattr(app_module, 'set_cached', lambda key, data: None) +def test_api_advance_fares_returns_json(monkeypatch: Any) -> None: + monkeypatch.setattr(app_module, "get_cached", lambda key, ttl=None: None) + monkeypatch.setattr(app_module, "set_cached", lambda key, data: None) monkeypatch.setattr( - app_module.gwr_fares_scraper, - 'fetch_advance', + gwr_fares_scraper, + "fetch_advance", lambda station_crs, travel_date: { - '07:00': { - 'advance_std': {'ticket': 'Advance Single', 'price': 45.0, 'code': 'ADV'}, - 'advance_1st': {'ticket': '1st Advance', 'price': 65.0, 'code': 'AFA'}, + "07:00": { + "advance_std": { + "ticket": "Advance Single", + "price": 45.0, + "code": "ADV", + }, + "advance_1st": {"ticket": "1st Advance", "price": 65.0, "code": "AFA"}, } }, ) client = _client() - resp = client.get('/api/advance_fares/BRI/2026-04-10') + resp = client.get("/api/advance_fares/BRI/2026-04-10") data = resp.get_json() assert resp.status_code == 200 - assert '07:00' in data - assert data['07:00']['advance_std']['price'] == 45.0 - assert data['07:00']['advance_1st']['price'] == 65.0 + assert "07:00" in data + assert data["07:00"]["advance_std"]["price"] == 45.0 + assert data["07:00"]["advance_1st"]["price"] == 65.0 -def test_api_advance_fares_404_for_unknown_station(monkeypatch): +def test_api_advance_fares_404_for_unknown_station() -> None: client = _client() - resp = client.get('/api/advance_fares/XYZ/2026-04-10') + resp = client.get("/api/advance_fares/XYZ/2026-04-10") assert resp.status_code == 404 -def test_api_advance_fares_returns_error_on_scraper_failure(monkeypatch): - monkeypatch.setattr(app_module, 'get_cached', lambda key, ttl=None: None) - monkeypatch.setattr(app_module, 'set_cached', lambda key, data: None) +def test_api_advance_fares_returns_error_on_scraper_failure(monkeypatch: Any) -> None: + monkeypatch.setattr(app_module, "get_cached", lambda key, ttl=None: None) + monkeypatch.setattr(app_module, "set_cached", lambda key, data: None) monkeypatch.setattr( - app_module.gwr_fares_scraper, - 'fetch_advance', - lambda s, d: (_ for _ in ()).throw(Exception('network error')), + gwr_fares_scraper, + "fetch_advance", + lambda s, d: (_ for _ in ()).throw(Exception("network error")), ) client = _client() - resp = client.get('/api/advance_fares/BRI/2026-04-10') + resp = client.get("/api/advance_fares/BRI/2026-04-10") data = resp.get_json() assert resp.status_code == 500 - assert 'error' in data + assert "error" in data diff --git a/tests/test_cache.py b/tests/test_cache.py index f6dd29e..9434942 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -1,49 +1,53 @@ import os import time +from pathlib import Path +from typing import Any + import pytest from cache import get_cached, set_cached @pytest.fixture -def tmp_cache(tmp_path, monkeypatch): +def tmp_cache(tmp_path: Path, monkeypatch: Any) -> Path: import cache as cache_module - monkeypatch.setattr(cache_module, 'CACHE_DIR', str(tmp_path)) + + monkeypatch.setattr(cache_module, "CACHE_DIR", str(tmp_path)) return tmp_path -def test_get_cached_returns_none_for_missing_key(tmp_cache): - assert get_cached('no_such_key') is None +def test_get_cached_returns_none_for_missing_key(tmp_cache: Path) -> None: + assert get_cached("no_such_key") is None -def test_set_and_get_cached_roundtrip(tmp_cache): - set_cached('my_key', {'a': 1}) - assert get_cached('my_key') == {'a': 1} +def test_set_and_get_cached_roundtrip(tmp_cache: Path) -> None: + set_cached("my_key", {"a": 1}) + assert get_cached("my_key") == {"a": 1} -def test_get_cached_no_ttl_never_expires(tmp_cache): - set_cached('k', [1, 2, 3]) +def test_get_cached_no_ttl_never_expires(tmp_cache: Path) -> None: + set_cached("k", [1, 2, 3]) # Backdate the file by 2 days - path = tmp_cache / 'k.json' + path = tmp_cache / "k.json" old = time.time() - 2 * 86400 os.utime(path, (old, old)) - assert get_cached('k') == [1, 2, 3] + assert get_cached("k") == [1, 2, 3] -def test_get_cached_within_ttl(tmp_cache): - set_cached('k', 'fresh') - assert get_cached('k', ttl=3600) == 'fresh' +def test_get_cached_within_ttl(tmp_cache: Path) -> None: + set_cached("k", "fresh") + assert get_cached("k", ttl=3600) == "fresh" -def test_get_cached_expired_returns_none(tmp_cache): - set_cached('k', 'stale') - path = tmp_cache / 'k.json' +def test_get_cached_expired_returns_none(tmp_cache: Path) -> None: + set_cached("k", "stale") + path = tmp_cache / "k.json" old = time.time() - 25 * 3600 # 25 hours ago os.utime(path, (old, old)) - assert get_cached('k', ttl=24 * 3600) is None + assert get_cached("k", ttl=24 * 3600) is None -def test_get_cached_invalid_json_returns_none(tmp_cache): - path = tmp_cache / 'broken.json' +def test_get_cached_invalid_json_returns_none(tmp_cache: Path) -> None: + path = tmp_cache / "broken.json" path.write_text('{"not": "finished"') - assert get_cached('broken') is None + assert get_cached("broken") is None diff --git a/tests/test_eurostar_scraper.py b/tests/test_eurostar_scraper.py index 3e1475b..64eff91 100644 --- a/tests/test_eurostar_scraper.py +++ b/tests/test_eurostar_scraper.py @@ -1,30 +1,47 @@ +from typing import Any + import pytest from scraper.eurostar import _parse_graphql, _parse_graphql_leg, search_url -def _gql_response(journeys: list) -> dict: - return {'data': {'journeySearch': {'outbound': {'journeys': journeys}}}} +def _gql_response(journeys: list[dict[str, Any]]) -> dict[str, Any]: + return {"data": {"journeySearch": {"outbound": {"journeys": journeys}}}} -def _journey(departs: str, arrives: str, price=None, seats=None, service_name='', carrier='ES', - plus_price=None, plus_seats=None) -> dict: - fares = [{ - 'classOfService': {'code': 'STANDARD'}, - 'prices': {'displayPrice': price}, - 'seats': seats, - 'legs': [{'serviceName': service_name, 'serviceType': {'code': carrier}}] - if service_name else [], - }] +def _journey( + departs: str, + arrives: str, + price: float | None = None, + seats: int | None = None, + service_name: str = "", + carrier: str = "ES", + plus_price: float | None = None, + plus_seats: int | None = None, +) -> dict[str, Any]: + fares: list[dict[str, Any]] = [ + { + "classOfService": {"code": "STANDARD"}, + "prices": {"displayPrice": price}, + "seats": seats, + "legs": ( + [{"serviceName": service_name, "serviceType": {"code": carrier}}] + if service_name + else [] + ), + } + ] if plus_price is not None or plus_seats is not None: - fares.append({ - 'classOfService': {'code': 'PLUS'}, - 'prices': {'displayPrice': plus_price}, - 'seats': plus_seats, - 'legs': [], - }) + fares.append( + { + "classOfService": {"code": "PLUS"}, + "prices": {"displayPrice": plus_price}, + "seats": plus_seats, + "legs": [], + } + ) return { - 'timing': {'departureTime': departs, 'arrivalTime': arrives}, - 'fares': fares, + "timing": {"departureTime": departs, "arrivalTime": arrives}, + "fares": fares, } @@ -32,114 +49,149 @@ def _journey(departs: str, arrives: str, price=None, seats=None, service_name='' # _parse_graphql # --------------------------------------------------------------------------- -def test_parse_graphql_single_journey(): - data = _gql_response([_journey('09:31', '12:55', price=156, seats=37, service_name='9014')]) - services = _parse_graphql(data, 'Paris Gare du Nord') + +def test_parse_graphql_single_journey() -> None: + data = _gql_response( + [_journey("09:31", "12:55", price=156, seats=37, service_name="9014")] + ) + services = _parse_graphql(data, "Paris Gare du Nord") assert len(services) == 1 s = services[0] - assert s['depart_st_pancras'] == '09:31' - assert s['arrive_destination'] == '12:55' - assert s['destination'] == 'Paris Gare du Nord' - assert s['train_number'] == 'ES 9014' - assert s['price'] == 156.0 - assert s['seats'] == 37 - assert s['plus_price'] is None - assert s['plus_seats'] is None + assert s["depart_st_pancras"] == "09:31" + assert s["arrive_destination"] == "12:55" + assert s["destination"] == "Paris Gare du Nord" + assert s["train_number"] == "ES 9014" + assert s["price"] == 156.0 + assert s["seats"] == 37 + assert s["plus_price"] is None + assert s["plus_seats"] is None -def test_parse_graphql_standard_premier_price(): - data = _gql_response([_journey('09:31', '12:55', price=156, seats=37, service_name='9014', - plus_price=220, plus_seats=12)]) - services = _parse_graphql(data, 'Paris Gare du Nord') +def test_parse_graphql_standard_premier_price() -> None: + data = _gql_response( + [ + _journey( + "09:31", + "12:55", + price=156, + seats=37, + service_name="9014", + plus_price=220, + plus_seats=12, + ) + ] + ) + services = _parse_graphql(data, "Paris Gare du Nord") assert len(services) == 1 s = services[0] - assert s['price'] == 156.0 - assert s['seats'] == 37 - assert s['plus_price'] == 220.0 - assert s['plus_seats'] == 12 + assert s["price"] == 156.0 + assert s["seats"] == 37 + assert s["plus_price"] == 220.0 + assert s["plus_seats"] == 12 -def test_parse_graphql_plus_price_none_when_not_returned(): - data = _gql_response([_journey('09:31', '12:55', price=156, seats=37)]) - services = _parse_graphql(data, 'Paris Gare du Nord') - assert services[0]['plus_price'] is None - assert services[0]['plus_seats'] is None +def test_parse_graphql_plus_price_none_when_not_returned() -> None: + data = _gql_response([_journey("09:31", "12:55", price=156, seats=37)]) + services = _parse_graphql(data, "Paris Gare du Nord") + assert services[0]["plus_price"] is None + assert services[0]["plus_seats"] is None -def test_parse_graphql_half_pound_price(): - data = _gql_response([_journey('09:01', '14:20', price=192.5, seats=25, service_name='9116')]) - services = _parse_graphql(data, 'Amsterdam Centraal') - assert services[0]['price'] == 192.5 +def test_parse_graphql_half_pound_price() -> None: + data = _gql_response( + [_journey("09:01", "14:20", price=192.5, seats=25, service_name="9116")] + ) + services = _parse_graphql(data, "Amsterdam Centraal") + assert services[0]["price"] == 192.5 -def test_parse_graphql_null_price(): - data = _gql_response([_journey('06:16', '11:09', price=None, seats=0)]) - services = _parse_graphql(data, 'Amsterdam Centraal') - assert services[0]['price'] is None - assert services[0]['seats'] == 0 +def test_parse_graphql_null_price() -> None: + data = _gql_response([_journey("06:16", "11:09", price=None, seats=0)]) + services = _parse_graphql(data, "Amsterdam Centraal") + assert services[0]["price"] is None + assert services[0]["seats"] == 0 -def test_parse_graphql_sorted_by_departure(): - data = _gql_response([ - _journey('10:31', '13:55'), - _journey('07:31', '10:59'), - ]) - services = _parse_graphql(data, 'Paris Gare du Nord') - assert services[0]['depart_st_pancras'] == '07:31' - assert services[1]['depart_st_pancras'] == '10:31' +def test_parse_graphql_sorted_by_departure() -> None: + data = _gql_response( + [ + _journey("10:31", "13:55"), + _journey("07:31", "10:59"), + ] + ) + services = _parse_graphql(data, "Paris Gare du Nord") + assert services[0]["depart_st_pancras"] == "07:31" + assert services[1]["depart_st_pancras"] == "10:31" -def test_parse_graphql_deduplicates_same_departure_time(): - data = _gql_response([ - _journey('06:16', '11:09', price=None, seats=0), - _journey('06:16', '11:09', price=None, seats=0), - _journey('06:16', '11:09', price=None, seats=0), - ]) - services = _parse_graphql(data, 'Amsterdam Centraal') +def test_parse_graphql_deduplicates_same_departure_time() -> None: + data = _gql_response( + [ + _journey("06:16", "11:09", price=None, seats=0), + _journey("06:16", "11:09", price=None, seats=0), + _journey("06:16", "11:09", price=None, seats=0), + ] + ) + services = _parse_graphql(data, "Amsterdam Centraal") assert len(services) == 1 -def test_parse_graphql_no_legs_gives_empty_train_number(): - data = _gql_response([_journey('09:31', '12:55', price=156, seats=37, service_name='')]) - services = _parse_graphql(data, 'Paris Gare du Nord') - assert services[0]['train_number'] == '' +def test_parse_graphql_no_legs_gives_empty_train_number() -> None: + data = _gql_response( + [_journey("09:31", "12:55", price=156, seats=37, service_name="")] + ) + services = _parse_graphql(data, "Paris Gare du Nord") + assert services[0]["train_number"] == "" -def test_parse_graphql_empty_journeys(): +def test_parse_graphql_empty_journeys() -> None: data = _gql_response([]) - assert _parse_graphql(data, 'Paris Gare du Nord') == [] + assert _parse_graphql(data, "Paris Gare du Nord") == [] -def test_parse_graphql_inbound_leg(): - data = {'data': {'journeySearch': {'inbound': {'journeys': [ - _journey('17:12', '18:30', price=49, seats=43, service_name='9035') - ]}}}} - services = _parse_graphql_leg(data, 'Paris Gare du Nord', 'inbound', 'inbound') +def test_parse_graphql_inbound_leg() -> None: + data: dict[str, Any] = { + "data": { + "journeySearch": { + "inbound": { + "journeys": [ + _journey( + "17:12", "18:30", price=49, seats=43, service_name="9035" + ) + ] + } + } + } + } + services = _parse_graphql_leg(data, "Paris Gare du Nord", "inbound", "inbound") - assert services == [{ - 'depart_destination': '17:12', - 'arrive_st_pancras': '18:30', - 'destination': 'Paris Gare du Nord', - 'train_number': 'ES 9035', - 'price': 49.0, - 'seats': 43, - 'plus_price': None, - 'plus_seats': None, - }] + assert services == [ + { + "depart_destination": "17:12", + "arrive_st_pancras": "18:30", + "destination": "Paris Gare du Nord", + "train_number": "ES 9035", + "price": 49.0, + "seats": 43, + "plus_price": None, + "plus_seats": None, + } + ] # --------------------------------------------------------------------------- # search_url # --------------------------------------------------------------------------- -def test_search_url(): - url = search_url('Paris Gare du Nord', '2026-04-10') + +def test_search_url() -> None: + url = search_url("Paris Gare du Nord", "2026-04-10") assert url == ( - 'https://www.eurostar.com/search/uk-en' - '?adult=1&origin=7015400&destination=8727100&outbound=2026-04-10' + "https://www.eurostar.com/search/uk-en" + "?adult=1&origin=7015400&destination=8727100&outbound=2026-04-10" ) -def test_search_url_return(): - url = search_url('Paris Gare du Nord', '2026-04-10', return_date='2026-04-17') - assert url.endswith('&outbound=2026-04-10&inbound=2026-04-17') +def test_search_url_return() -> None: + url = search_url("Paris Gare du Nord", "2026-04-10", return_date="2026-04-17") + assert url.endswith("&outbound=2026-04-10&inbound=2026-04-17") diff --git a/tests/test_playwright_return_fares.py b/tests/test_playwright_return_fares.py index ee6e0d8..ee4eeae 100644 --- a/tests/test_playwright_return_fares.py +++ b/tests/test_playwright_return_fares.py @@ -1,4 +1,5 @@ import threading +from typing import Any, Generator import pytest from werkzeug.serving import make_server @@ -8,12 +9,16 @@ import app as app_module playwright_sync = pytest.importorskip("playwright.sync_api") sync_playwright = playwright_sync.sync_playwright +rtt_scraper: Any = app_module.rtt_scraper # type: ignore[attr-defined] +gwr_fares_scraper: Any = app_module.gwr_fares_scraper # type: ignore[attr-defined] +eurostar_scraper: Any = app_module.eurostar_scraper # type: ignore[attr-defined] -def _stub_return_data(monkeypatch): + +def _stub_return_data(monkeypatch: Any) -> None: monkeypatch.setattr(app_module, "get_cached", lambda key, ttl=None: None) monkeypatch.setattr(app_module, "set_cached", lambda key, data: None) monkeypatch.setattr( - app_module.rtt_scraper, + rtt_scraper, "fetch", lambda travel_date, user_agent, station_crs="BRI": [ { @@ -24,7 +29,7 @@ def _stub_return_data(monkeypatch): ], ) monkeypatch.setattr( - app_module.rtt_scraper, + rtt_scraper, "fetch_from_paddington", lambda travel_date, user_agent, station_crs="BRI": [ { @@ -35,7 +40,7 @@ def _stub_return_data(monkeypatch): ], ) monkeypatch.setattr( - app_module.gwr_fares_scraper, + gwr_fares_scraper, "fetch", lambda station_crs, travel_date, direction="to_paddington": { "07:00": { @@ -51,7 +56,11 @@ def _stub_return_data(monkeypatch): }, ) - def fake_advance_streaming(station_crs, travel_date, direction="to_paddington"): + def fake_advance_streaming( + station_crs: str, + travel_date: str, + direction: str = "to_paddington", + ) -> Generator[dict[str, Any], None, None]: if direction == "from_paddington": yield { "17:15": { @@ -84,18 +93,20 @@ def _stub_return_data(monkeypatch): } monkeypatch.setattr( - app_module.gwr_fares_scraper, + gwr_fares_scraper, "fetch_advance_streaming", fake_advance_streaming, ) - def fake_advance(station_crs, travel_date, direction="to_paddington"): + def fake_advance( + station_crs: str, travel_date: str, direction: str = "to_paddington" + ) -> dict[str, Any]: pages = list(fake_advance_streaming(station_crs, travel_date, direction)) return pages[0] if pages else {} - monkeypatch.setattr(app_module.gwr_fares_scraper, "fetch_advance", fake_advance) + monkeypatch.setattr(gwr_fares_scraper, "fetch_advance", fake_advance) monkeypatch.setattr( - app_module.eurostar_scraper, + eurostar_scraper, "fetch_return", lambda destination, outbound_date, return_date: { "outbound": [ @@ -126,11 +137,11 @@ def _stub_return_data(monkeypatch): ) -def _stub_single_data(monkeypatch): +def _stub_single_data(monkeypatch: Any) -> None: monkeypatch.setattr(app_module, "get_cached", lambda key, ttl=None: None) monkeypatch.setattr(app_module, "set_cached", lambda key, data: None) monkeypatch.setattr( - app_module.rtt_scraper, + rtt_scraper, "fetch", lambda travel_date, user_agent, station_crs="BRI": [ { @@ -141,7 +152,7 @@ def _stub_single_data(monkeypatch): ], ) monkeypatch.setattr( - app_module.gwr_fares_scraper, + gwr_fares_scraper, "fetch", lambda station_crs, travel_date: { "07:00": { @@ -151,7 +162,7 @@ def _stub_single_data(monkeypatch): }, }, ) - advance_fares = { + advance_fares: dict[str, Any] = { "07:00": { "advance_std": { "ticket": "Advance Single", @@ -166,17 +177,17 @@ def _stub_single_data(monkeypatch): }, } monkeypatch.setattr( - app_module.gwr_fares_scraper, + gwr_fares_scraper, "fetch_advance", lambda station_crs, travel_date: advance_fares, ) monkeypatch.setattr( - app_module.gwr_fares_scraper, + gwr_fares_scraper, "fetch_advance_streaming", lambda station_crs, travel_date: iter([advance_fares]), ) monkeypatch.setattr( - app_module.eurostar_scraper, + eurostar_scraper, "fetch", lambda destination, travel_date: [ { @@ -194,7 +205,7 @@ def _stub_single_data(monkeypatch): @pytest.fixture -def local_server(monkeypatch): +def local_server(monkeypatch: Any) -> Generator[str, None, None]: _stub_return_data(monkeypatch) app_module.app.config["TESTING"] = True server = make_server("127.0.0.1", 0, app_module.app) @@ -208,7 +219,7 @@ def local_server(monkeypatch): @pytest.fixture -def single_server(monkeypatch): +def single_server(monkeypatch: Any) -> Generator[str, None, None]: _stub_single_data(monkeypatch) app_module.app.config["TESTING"] = True server = make_server("127.0.0.1", 0, app_module.app) @@ -221,14 +232,14 @@ def single_server(monkeypatch): thread.join(timeout=5) -def _launch_browser(playwright): +def _launch_browser(playwright: Any) -> Any: try: return playwright.chromium.launch(headless=True) except Exception as exc: pytest.skip(f"Chromium browser unavailable for Playwright: {exc}") -def test_single_advance_standard_totals_after_click(single_server): +def test_single_advance_standard_totals_after_click(single_server: str) -> None: with sync_playwright() as p: browser = _launch_browser(p) page = browser.new_page() @@ -250,11 +261,13 @@ def test_single_advance_standard_totals_after_click(single_server): browser.close() -def test_single_next_date_advance_standard_labels_unreachable_rows(monkeypatch): +def test_single_next_date_advance_standard_labels_unreachable_rows( + monkeypatch: Any, +) -> None: monkeypatch.setattr(app_module, "get_cached", lambda key, ttl=None: None) monkeypatch.setattr(app_module, "set_cached", lambda key, data: None) monkeypatch.setattr( - app_module.rtt_scraper, + rtt_scraper, "fetch", lambda travel_date, user_agent, station_crs="BRI": [ { @@ -265,7 +278,7 @@ def test_single_next_date_advance_standard_labels_unreachable_rows(monkeypatch): ], ) monkeypatch.setattr( - app_module.gwr_fares_scraper, + gwr_fares_scraper, "fetch", lambda station_crs, travel_date: { "07:00": { @@ -275,7 +288,7 @@ def test_single_next_date_advance_standard_labels_unreachable_rows(monkeypatch): }, }, ) - advance_fares = { + advance_fares: dict[str, Any] = { "07:00": { "advance_std": { "ticket": "Advance Single", @@ -286,17 +299,17 @@ def test_single_next_date_advance_standard_labels_unreachable_rows(monkeypatch): }, } monkeypatch.setattr( - app_module.gwr_fares_scraper, + gwr_fares_scraper, "fetch_advance", lambda station_crs, travel_date: advance_fares, ) monkeypatch.setattr( - app_module.gwr_fares_scraper, + gwr_fares_scraper, "fetch_advance_streaming", lambda station_crs, travel_date: iter([advance_fares]), ) monkeypatch.setattr( - app_module.eurostar_scraper, + eurostar_scraper, "fetch", lambda destination, travel_date: [ { @@ -352,7 +365,9 @@ def test_single_next_date_advance_standard_labels_unreachable_rows(monkeypatch): thread.join(timeout=5) -def test_single_advance_standard_premier_totals_on_initial_url(single_server): +def test_single_advance_standard_premier_totals_on_initial_url( + single_server: str, +) -> None: with sync_playwright() as p: browser = _launch_browser(p) page = browser.new_page() @@ -372,33 +387,47 @@ def test_single_advance_standard_premier_totals_on_initial_url(single_server): browser.close() -def test_single_advance_first_falls_back_to_walkon_when_unavailable(monkeypatch): +def test_single_advance_first_falls_back_to_walkon_when_unavailable( + monkeypatch: Any, +) -> None: monkeypatch.setattr(app_module, "get_cached", lambda key, ttl=None: None) monkeypatch.setattr(app_module, "set_cached", lambda key, data: None) monkeypatch.setattr( - app_module.rtt_scraper, + rtt_scraper, "fetch", lambda travel_date, user_agent, station_crs="BRI": [ - {"depart_bristol": "07:00", "arrive_paddington": "08:45", "headcode": "1A23"}, + { + "depart_bristol": "07:00", + "arrive_paddington": "08:45", + "headcode": "1A23", + }, ], ) monkeypatch.setattr( - app_module.gwr_fares_scraper, + gwr_fares_scraper, "fetch", lambda station_crs, travel_date: { "07:00": {"ticket": "Anytime Day Single", "price": 138.70, "code": "SDS"}, }, ) - advance_fares = { + advance_fares: dict[str, Any] = { "07:00": { "advance_std": {"ticket": "Advance Single", "price": 50.0, "code": "ADV"}, "advance_1st": None, }, } - monkeypatch.setattr(app_module.gwr_fares_scraper, "fetch_advance", lambda station_crs, travel_date: advance_fares) - monkeypatch.setattr(app_module.gwr_fares_scraper, "fetch_advance_streaming", lambda station_crs, travel_date: iter([advance_fares])) monkeypatch.setattr( - app_module.eurostar_scraper, + gwr_fares_scraper, + "fetch_advance", + lambda station_crs, travel_date: advance_fares, + ) + monkeypatch.setattr( + gwr_fares_scraper, + "fetch_advance_streaming", + lambda station_crs, travel_date: iter([advance_fares]), + ) + monkeypatch.setattr( + eurostar_scraper, "fetch", lambda destination, travel_date: [ { @@ -441,7 +470,7 @@ def test_single_advance_first_falls_back_to_walkon_when_unavailable(monkeypatch) thread.join(timeout=5) -def test_return_advance_first_standard_premier_totals(local_server): +def test_return_advance_first_standard_premier_totals(local_server: str) -> None: with sync_playwright() as p: browser = _launch_browser(p) page = browser.new_page() @@ -477,7 +506,9 @@ def test_return_advance_first_standard_premier_totals(local_server): browser.close() -def test_return_advance_first_standard_premier_totals_on_initial_url(local_server): +def test_return_advance_first_standard_premier_totals_on_initial_url( + local_server: str, +) -> None: with sync_playwright() as p: browser = _launch_browser(p) page = browser.new_page() diff --git a/tests/test_rtt_scraper.py b/tests/test_rtt_scraper.py index 7bceb61..98590f6 100644 --- a/tests/test_rtt_scraper.py +++ b/tests/test_rtt_scraper.py @@ -1,71 +1,74 @@ import pytest from scraper.realtime_trains import _fmt, _parse_services - # --------------------------------------------------------------------------- # _fmt # --------------------------------------------------------------------------- -def test_fmt_four_digits(): - assert _fmt('0830') == '08:30' -def test_fmt_already_colon(): - assert _fmt('08:30') == '08:30' +def test_fmt_four_digits() -> None: + assert _fmt("0830") == "08:30" -def test_fmt_strips_non_digits(): - assert _fmt('08h30') == '08:30' + +def test_fmt_already_colon() -> None: + assert _fmt("08:30") == "08:30" + + +def test_fmt_strips_non_digits() -> None: + assert _fmt("08h30") == "08:30" # --------------------------------------------------------------------------- # _parse_services # --------------------------------------------------------------------------- + def _make_html(services: list[tuple[str, str]], time_class: str) -> str: """Build a minimal servicelist HTML with (train_id, time) pairs.""" - items = '' + items = "" for tid, time in services: - items += f''' + items += f"""
{tid}
{time}
-
''' + """ return f'
{items}
' -def test_parse_services_departures(): - html = _make_html([('1A23', '0700'), ('2B45', '0830')], 'd') - result = _parse_services(html, 'div.time.plan.d') - assert result == {'1A23': '07:00', '2B45': '08:30'} +def test_parse_services_departures() -> None: + html = _make_html([("1A23", "0700"), ("2B45", "0830")], "d") + result = _parse_services(html, "div.time.plan.d") + assert result == {"1A23": "07:00", "2B45": "08:30"} -def test_parse_services_arrivals(): - html = _make_html([('1A23', '0845')], 'a') - result = _parse_services(html, 'div.time.plan.a') - assert result == {'1A23': '08:45'} +def test_parse_services_arrivals() -> None: + html = _make_html([("1A23", "0845")], "a") + result = _parse_services(html, "div.time.plan.a") + assert result == {"1A23": "08:45"} -def test_parse_services_no_servicelist(): - assert _parse_services('', 'div.time.plan.d') == {} +def test_parse_services_no_servicelist() -> None: + assert _parse_services("", "div.time.plan.d") == {} -def test_parse_services_skips_missing_time(): - html = ''' +def test_parse_services_skips_missing_time() -> None: + html = """ ''' - result = _parse_services(html, 'div.time.plan.d') - assert '1A23' not in result - assert result == {'2B45': '09:00'} + """ + result = _parse_services(html, "div.time.plan.d") + assert "1A23" not in result + assert result == {"2B45": "09:00"} -def test_parse_services_skips_empty_time(): - html = ''' +def test_parse_services_skips_empty_time() -> None: + html = """ ''' - result = _parse_services(html, 'div.time.plan.d') + """ + result = _parse_services(html, "div.time.plan.d") assert result == {} diff --git a/tests/test_trip_planner.py b/tests/test_trip_planner.py index 7b6c43d..91d8988 100644 --- a/tests/test_trip_planner.py +++ b/tests/test_trip_planner.py @@ -6,64 +6,80 @@ from trip_planner import ( _fmt_duration, ) -DATE = '2026-03-30' +DATE = "2026-03-30" # --------------------------------------------------------------------------- # _fmt_duration # --------------------------------------------------------------------------- -def test_fmt_duration_hours_and_minutes(): - assert _fmt_duration(95) == '1h 35m' -def test_fmt_duration_exact_hours(): - assert _fmt_duration(120) == '2h' +def test_fmt_duration_hours_and_minutes() -> None: + assert _fmt_duration(95) == "1h 35m" -def test_fmt_duration_minutes_only(): - assert _fmt_duration(45) == '45m' + +def test_fmt_duration_exact_hours() -> None: + assert _fmt_duration(120) == "2h" + + +def test_fmt_duration_minutes_only() -> None: + assert _fmt_duration(45) == "45m" # --------------------------------------------------------------------------- # combine_trips — basic pairing # --------------------------------------------------------------------------- -GWR_FAST = {'depart_bristol': '07:00', 'arrive_paddington': '08:45'} # 1h 45m -GWR_SLOW = {'depart_bristol': '07:00', 'arrive_paddington': '09:26'} # 2h 26m — connection too short for ES_PARIS +GWR_FAST = {"depart_bristol": "07:00", "arrive_paddington": "08:45"} # 1h 45m +GWR_SLOW = { + "depart_bristol": "07:00", + "arrive_paddington": "09:26", +} # 2h 26m — connection too short for ES_PARIS -ES_PARIS = {'depart_st_pancras': '10:01', 'arrive_destination': '13:34', 'destination': 'Paris Gare du Nord'} -ES_EARLY = {'depart_st_pancras': '09:00', 'arrive_destination': '12:00', 'destination': 'Paris Gare du Nord'} +ES_PARIS = { + "depart_st_pancras": "10:01", + "arrive_destination": "13:34", + "destination": "Paris Gare du Nord", +} +ES_EARLY = { + "depart_st_pancras": "09:00", + "arrive_destination": "12:00", + "destination": "Paris Gare du Nord", +} -def test_valid_trip_is_returned(): +def test_valid_trip_is_returned() -> None: trips = combine_trips([GWR_FAST], [ES_PARIS], DATE) assert len(trips) == 1 t = trips[0] - assert t['depart_bristol'] == '07:00' - assert t['arrive_paddington'] == '08:45' - assert t['depart_st_pancras'] == '10:01' - assert t['arrive_destination'] == '13:34' - assert t['destination'] == 'Paris Gare du Nord' + assert t["depart_bristol"] == "07:00" + assert t["arrive_paddington"] == "08:45" + assert t["depart_st_pancras"] == "10:01" + assert t["arrive_destination"] == "13:34" + assert t["destination"] == "Paris Gare du Nord" -def test_gwr_too_slow_excluded(): +def test_gwr_too_slow_excluded() -> None: # arrive 09:26, Eurostar 10:01 → 35 min connection < 50 min minimum trips = combine_trips([GWR_SLOW], [ES_PARIS], DATE) assert trips == [] -def test_eurostar_too_early_excluded(): +def test_eurostar_too_early_excluded() -> None: # Eurostar departs before min connection time has elapsed trips = combine_trips([GWR_FAST], [ES_EARLY], DATE) assert trips == [] -def test_no_trains_returns_empty(): +def test_no_trains_returns_empty() -> None: assert combine_trips([], [], DATE) == [] -def test_no_gwr_returns_empty(): + +def test_no_gwr_returns_empty() -> None: assert combine_trips([], [ES_PARIS], DATE) == [] -def test_no_eurostar_returns_empty(): + +def test_no_eurostar_returns_empty() -> None: assert combine_trips([GWR_FAST], [], DATE) == [] @@ -71,140 +87,211 @@ def test_no_eurostar_returns_empty(): # Connection window constraints # --------------------------------------------------------------------------- -def test_min_connection_enforced(): + +def test_min_connection_enforced() -> None: # Arrive Paddington 08:45, need 75 min → earliest St Pancras 10:00 # ES at 09:59 should be excluded, 10:00 should be included - es_too_close = {'depart_st_pancras': '09:59', 'arrive_destination': '13:00', 'destination': 'Paris Gare du Nord'} - es_ok = {'depart_st_pancras': '10:00', 'arrive_destination': '13:00', 'destination': 'Paris Gare du Nord'} - assert combine_trips([GWR_FAST], [es_too_close], DATE, min_connection_minutes=75) == [] + es_too_close = { + "depart_st_pancras": "09:59", + "arrive_destination": "13:00", + "destination": "Paris Gare du Nord", + } + es_ok = { + "depart_st_pancras": "10:00", + "arrive_destination": "13:00", + "destination": "Paris Gare du Nord", + } + assert ( + combine_trips([GWR_FAST], [es_too_close], DATE, min_connection_minutes=75) == [] + ) trips = combine_trips([GWR_FAST], [es_ok], DATE, min_connection_minutes=75) assert len(trips) == 1 -def test_max_connection_enforced(): +def test_max_connection_enforced() -> None: # Arrive Paddington 08:45, max 140 min → latest St Pancras 11:05 - es_ok = {'depart_st_pancras': '11:05', 'arrive_destination': '14:00', 'destination': 'Paris Gare du Nord'} - es_too_late = {'depart_st_pancras': '11:06', 'arrive_destination': '14:00', 'destination': 'Paris Gare du Nord'} + es_ok = { + "depart_st_pancras": "11:05", + "arrive_destination": "14:00", + "destination": "Paris Gare du Nord", + } + es_too_late = { + "depart_st_pancras": "11:06", + "arrive_destination": "14:00", + "destination": "Paris Gare du Nord", + } trips = combine_trips([GWR_FAST], [es_ok], DATE, max_connection_minutes=140) assert len(trips) == 1 - assert combine_trips([GWR_FAST], [es_too_late], DATE, max_connection_minutes=140) == [] + assert ( + combine_trips([GWR_FAST], [es_too_late], DATE, max_connection_minutes=140) == [] + ) # --------------------------------------------------------------------------- # Only earliest valid Eurostar per GWR departure # --------------------------------------------------------------------------- -def test_only_earliest_eurostar_per_gwr(): - es1 = {'depart_st_pancras': '10:01', 'arrive_destination': '13:34', 'destination': 'Paris Gare du Nord'} - es2 = {'depart_st_pancras': '11:01', 'arrive_destination': '14:34', 'destination': 'Paris Gare du Nord'} + +def test_only_earliest_eurostar_per_gwr() -> None: + es1 = { + "depart_st_pancras": "10:01", + "arrive_destination": "13:34", + "destination": "Paris Gare du Nord", + } + es2 = { + "depart_st_pancras": "11:01", + "arrive_destination": "14:34", + "destination": "Paris Gare du Nord", + } trips = combine_trips([GWR_FAST], [es1, es2], DATE) assert len(trips) == 1 - assert trips[0]['depart_st_pancras'] == '10:01' + assert trips[0]["depart_st_pancras"] == "10:01" # --------------------------------------------------------------------------- # Multiple GWR trains → multiple trips # --------------------------------------------------------------------------- -def test_multiple_gwr_trains(): - gwr2 = {'depart_bristol': '08:00', 'arrive_paddington': '09:45'} - es = {'depart_st_pancras': '11:01', 'arrive_destination': '14:34', 'destination': 'Paris Gare du Nord'} + +def test_multiple_gwr_trains() -> None: + gwr2 = {"depart_bristol": "08:00", "arrive_paddington": "09:45"} + es = { + "depart_st_pancras": "11:01", + "arrive_destination": "14:34", + "destination": "Paris Gare du Nord", + } trips = combine_trips([GWR_FAST, gwr2], [es], DATE, max_connection_minutes=140) assert len(trips) == 2 - assert trips[0]['depart_bristol'] == '07:00' - assert trips[1]['depart_bristol'] == '08:00' + assert trips[0]["depart_bristol"] == "07:00" + assert trips[1]["depart_bristol"] == "08:00" # --------------------------------------------------------------------------- # Duration fields # --------------------------------------------------------------------------- -def test_gwr_duration_in_trip(): + +def test_gwr_duration_in_trip() -> None: trips = combine_trips([GWR_FAST], [ES_PARIS], DATE) - assert trips[0]['gwr_duration'] == '1h 45m' + assert trips[0]["gwr_duration"] == "1h 45m" -def test_total_duration_in_trip(): +def test_total_duration_in_trip() -> None: # depart 07:00, arrive 13:34 → 6h 34m trips = combine_trips([GWR_FAST], [ES_PARIS], DATE) - assert trips[0]['total_duration'] == '6h 34m' + assert trips[0]["total_duration"] == "6h 34m" -def test_connection_duration_in_trip(): +def test_connection_duration_in_trip() -> None: # arrive Paddington 08:45, depart St Pancras 10:01 → 1h 16m trips = combine_trips([GWR_FAST], [ES_PARIS], DATE) - assert trips[0]['connection_duration'] == '1h 16m' + assert trips[0]["connection_duration"] == "1h 16m" -def test_find_unreachable_eurostars_excludes_connectable_services(): +def test_find_unreachable_eurostars_excludes_connectable_services() -> None: # GWR arrives 08:45; default min=50/max=110 → viable window 09:35–10:35. # 09:30 too early, 10:15 connectable, 12:30 beyond max connection. gwr = [ - {'depart_bristol': '07:00', 'arrive_paddington': '08:45'}, + {"depart_bristol": "07:00", "arrive_paddington": "08:45"}, ] eurostar = [ - {'depart_st_pancras': '09:30', 'arrive_destination': '12:00', 'destination': 'Paris Gare du Nord', 'train_number': 'ES 9001'}, - {'depart_st_pancras': '10:15', 'arrive_destination': '13:40', 'destination': 'Paris Gare du Nord', 'train_number': 'ES 9002'}, - {'depart_st_pancras': '12:30', 'arrive_destination': '15:55', 'destination': 'Paris Gare du Nord', 'train_number': 'ES 9003'}, + { + "depart_st_pancras": "09:30", + "arrive_destination": "12:00", + "destination": "Paris Gare du Nord", + "train_number": "ES 9001", + }, + { + "depart_st_pancras": "10:15", + "arrive_destination": "13:40", + "destination": "Paris Gare du Nord", + "train_number": "ES 9002", + }, + { + "depart_st_pancras": "12:30", + "arrive_destination": "15:55", + "destination": "Paris Gare du Nord", + "train_number": "ES 9003", + }, ] unreachable = find_unreachable_morning_eurostars(gwr, eurostar, DATE) - assert [s['depart_st_pancras'] for s in unreachable] == ['09:30', '12:30'] + assert [s["depart_st_pancras"] for s in unreachable] == ["09:30", "12:30"] -def test_combine_trips_includes_ticket_fields(): +def test_combine_trips_includes_ticket_fields() -> None: trips = combine_trips([GWR_FAST], [ES_PARIS], DATE) assert len(trips) == 1 t = trips[0] - assert 'ticket_name' in t - assert 'ticket_price' in t - assert 'ticket_code' in t + assert "ticket_name" in t + assert "ticket_price" in t + assert "ticket_code" in t -def test_combine_trips_uses_gwr_fares_when_provided(): - fares = {'07:00': {'ticket': 'Super Off-Peak Single', 'price': 49.30, 'code': 'SSS'}} + +def test_combine_trips_uses_gwr_fares_when_provided() -> None: + fares = { + "07:00": {"ticket": "Super Off-Peak Single", "price": 49.30, "code": "SSS"} + } trips = combine_trips([GWR_FAST], [ES_PARIS], DATE, gwr_fares=fares) assert len(trips) == 1 - assert trips[0]['ticket_price'] == 49.30 - assert trips[0]['ticket_code'] == 'SSS' + assert trips[0]["ticket_price"] == 49.30 + assert trips[0]["ticket_code"] == "SSS" -def test_combine_trips_ticket_price_none_when_no_fares(): + +def test_combine_trips_ticket_price_none_when_no_fares() -> None: trips = combine_trips([GWR_FAST], [ES_PARIS], DATE, gwr_fares={}) assert len(trips) == 1 - assert trips[0]['ticket_price'] is None + assert trips[0]["ticket_price"] is None -def test_find_unreachable_eurostars_returns_empty_when_all_connectable(): +def test_find_unreachable_eurostars_returns_empty_when_all_connectable() -> None: gwr = [ - {'depart_bristol': '07:00', 'arrive_paddington': '08:45'}, + {"depart_bristol": "07:00", "arrive_paddington": "08:45"}, ] eurostar = [ - {'depart_st_pancras': '10:15', 'arrive_destination': '13:40', 'destination': 'Paris Gare du Nord', 'train_number': 'ES 9002'}, + { + "depart_st_pancras": "10:15", + "arrive_destination": "13:40", + "destination": "Paris Gare du Nord", + "train_number": "ES 9002", + }, ] assert find_unreachable_morning_eurostars(gwr, eurostar, DATE) == [] -def test_combine_inbound_trips_pairs_eurostar_to_paddington_departure(): - eurostar = [{ - 'depart_destination': '15:12', - 'arrive_st_pancras': '16:30', - 'destination': 'Paris Gare du Nord', - 'train_number': 'ES 9035', - }] - gwr = [{ - 'depart_paddington': '17:15', - 'arrive_destination': '18:55', - 'headcode': '1B99', - }] - fares = {'17:15': {'ticket': 'Off-Peak Single', 'price': 63.60, 'code': 'SVS'}} +def test_combine_inbound_trips_pairs_eurostar_to_paddington_departure() -> None: + eurostar = [ + { + "depart_destination": "15:12", + "arrive_st_pancras": "16:30", + "destination": "Paris Gare du Nord", + "train_number": "ES 9035", + } + ] + gwr = [ + { + "depart_paddington": "17:15", + "arrive_destination": "18:55", + "headcode": "1B99", + } + ] + fares = {"17:15": {"ticket": "Off-Peak Single", "price": 63.60, "code": "SVS"}} - trips = combine_inbound_trips(eurostar, gwr, DATE, min_connection_minutes=30, max_connection_minutes=120, gwr_fares=fares) + trips = combine_inbound_trips( + eurostar, + gwr, + DATE, + min_connection_minutes=30, + max_connection_minutes=120, + gwr_fares=fares, + ) assert len(trips) == 1 - assert trips[0]['depart_destination'] == '15:12' - assert trips[0]['arrive_st_pancras'] == '16:30' - assert trips[0]['depart_paddington'] == '17:15' - assert trips[0]['arrive_uk_station'] == '18:55' - assert trips[0]['ticket_price'] == 63.60 - assert trips[0]['check_in_by'] == '14:42' + assert trips[0]["depart_destination"] == "15:12" + assert trips[0]["arrive_st_pancras"] == "16:30" + assert trips[0]["depart_paddington"] == "17:15" + assert trips[0]["arrive_uk_station"] == "18:55" + assert trips[0]["ticket_price"] == 63.60 + assert trips[0]["check_in_by"] == "14:42" diff --git a/trip_planner.py b/trip_planner.py index d858a82..155eda5 100644 --- a/trip_planner.py +++ b/trip_planner.py @@ -3,6 +3,7 @@ Combine GWR station→Paddington trains with Eurostar St Pancras→destination t """ from datetime import datetime, timedelta +from typing import Any import circle_line from tfl_fare import circle_line_fare @@ -15,14 +16,16 @@ DATE_FMT = "%Y-%m-%d" TIME_FMT = "%H:%M" PAD_WALK_TO_UNDERGROUND_MINUTES = 8 # GWR platform → Paddington (H&C Line) platform -KX_WALK_TO_UNDERGROUND_MINUTES = 10 # St Pancras arrivals → King's Cross St Pancras Underground +KX_WALK_TO_UNDERGROUND_MINUTES = ( + 10 # St Pancras arrivals → King's Cross St Pancras Underground +) def _parse_dt(date: str, time: str) -> datetime: return datetime.strptime(f"{date} {time}", f"{DATE_FMT} {TIME_FMT}") -def _circle_line_services(arrive_paddington: datetime) -> list[dict]: +def _circle_line_services(arrive_paddington: datetime) -> list[dict[str, Any]]: """ Given GWR arrival at Paddington, return up to 2 upcoming Circle line services as [{'depart': 'HH:MM', 'arrive_kx': 'HH:MM'}, ...]. @@ -33,7 +36,9 @@ def _circle_line_services(arrive_paddington: datetime) -> list[dict]: earliest_board = arrive_paddington + timedelta( minutes=PAD_WALK_TO_UNDERGROUND_MINUTES ) - services = circle_line.upcoming_services(earliest_board, count=2, direction='pad_to_kx') + services = circle_line.upcoming_services( + earliest_board, count=2, direction="pad_to_kx" + ) return [ { "depart": dep.strftime(TIME_FMT), @@ -44,24 +49,32 @@ def _circle_line_services(arrive_paddington: datetime) -> list[dict]: ] -PAD_WALK_FROM_UNDERGROUND_MINUTES = 5 # Circle line platform → GWR platform at Paddington -INBOUND_COMFORTABLE_MIN_CONN = 40 # threshold above which we apply the platform walk buffer +PAD_WALK_FROM_UNDERGROUND_MINUTES = ( + 5 # Circle line platform → GWR platform at Paddington +) +INBOUND_COMFORTABLE_MIN_CONN = ( + 40 # threshold above which we apply the platform walk buffer +) def _circle_line_services_to_paddington( arrive_st_pancras: datetime, dep_paddington: datetime | None = None, min_conn_minutes: int = INBOUND_MIN_CONNECTION_MINUTES, -) -> list[dict]: +) -> list[dict[str, Any]]: earliest_board = arrive_st_pancras + timedelta( minutes=KX_WALK_TO_UNDERGROUND_MINUTES ) if min_conn_minutes >= INBOUND_COMFORTABLE_MIN_CONN and dep_paddington is not None: cutoff = dep_paddington - timedelta(minutes=PAD_WALK_FROM_UNDERGROUND_MINUTES) - candidates = circle_line.upcoming_services(earliest_board, count=4, direction='kx_to_pad') + candidates = circle_line.upcoming_services( + earliest_board, count=4, direction="kx_to_pad" + ) services = [(dep, arr) for dep, arr in candidates if arr <= cutoff][:2] else: - services = circle_line.upcoming_services(earliest_board, count=1, direction='kx_to_pad', preceding=1) + services = circle_line.upcoming_services( + earliest_board, count=1, direction="kx_to_pad", preceding=1 + ) return [ { "depart": dep.strftime(TIME_FMT), @@ -82,8 +95,8 @@ def _fmt_duration(minutes: int) -> str: def _is_viable_connection( - gwr: dict, - eurostar: dict, + gwr: dict[str, Any], + eurostar: dict[str, Any], travel_date: str, min_connection_minutes: int, max_connection_minutes: int, @@ -112,8 +125,8 @@ def _is_viable_connection( def _is_viable_inbound_connection( - eurostar: dict, - gwr: dict, + eurostar: dict[str, Any], + gwr: dict[str, Any], travel_date: str, min_connection_minutes: int, max_connection_minutes: int, @@ -143,13 +156,13 @@ def _is_viable_inbound_connection( def combine_trips( - gwr_trains: list[dict], - eurostar_trains: list[dict], + gwr_trains: list[dict[str, Any]], + eurostar_trains: list[dict[str, Any]], travel_date: str, min_connection_minutes: int = MIN_CONNECTION_MINUTES, max_connection_minutes: int = MAX_CONNECTION_MINUTES, - gwr_fares: dict | None = None, -) -> list[dict]: + gwr_fares: dict[str, Any] | None = None, +) -> list[dict[str, Any]]: """ Return a list of valid combined trips, sorted by Bristol departure time. @@ -217,13 +230,13 @@ def combine_trips( def combine_inbound_trips( - eurostar_trains: list[dict], - gwr_trains: list[dict], + eurostar_trains: list[dict[str, Any]], + gwr_trains: list[dict[str, Any]], travel_date: str, min_connection_minutes: int = INBOUND_MIN_CONNECTION_MINUTES, max_connection_minutes: int = INBOUND_MAX_CONNECTION_MINUTES, - gwr_fares: dict | None = None, -) -> list[dict]: + gwr_fares: dict[str, Any] | None = None, +) -> list[dict[str, Any]]: """Return valid continent→UK combined trips.""" trips = [] @@ -243,12 +256,16 @@ def combine_inbound_trips( total_mins = int((arr_station - dep_dest).total_seconds() / 60) + 60 eurostar_mins = int((arr_stp - dep_dest).total_seconds() / 60) + 60 fare = (gwr_fares or {}).get(gwr["depart_paddington"]) - circle_svcs = _circle_line_services_to_paddington(arr_stp, dep_pad, min_connection_minutes) + circle_svcs = _circle_line_services_to_paddington( + arr_stp, dep_pad, min_connection_minutes + ) trips.append( { "direction": "inbound", "depart_destination": es["depart_destination"], - "check_in_by": (dep_dest - timedelta(minutes=30)).strftime(TIME_FMT), + "check_in_by": (dep_dest - timedelta(minutes=30)).strftime( + TIME_FMT + ), "arrive_st_pancras": es["arrive_st_pancras"], "depart_paddington": gwr["depart_paddington"], "arrive_uk_station": gwr["arrive_destination"], @@ -279,12 +296,12 @@ def combine_inbound_trips( def find_unreachable_morning_eurostars( - gwr_trains: list[dict], - eurostar_trains: list[dict], + gwr_trains: list[dict[str, Any]], + eurostar_trains: list[dict[str, Any]], travel_date: str, min_connection_minutes: int = MIN_CONNECTION_MINUTES, max_connection_minutes: int = MAX_CONNECTION_MINUTES, -) -> list[dict]: +) -> list[dict[str, Any]]: unreachable = [] for es in eurostar_trains: @@ -311,12 +328,12 @@ def find_unreachable_morning_eurostars( def find_unreachable_inbound_eurostars( - eurostar_trains: list[dict], - gwr_trains: list[dict], + eurostar_trains: list[dict[str, Any]], + gwr_trains: list[dict[str, Any]], travel_date: str, min_connection_minutes: int = INBOUND_MIN_CONNECTION_MINUTES, max_connection_minutes: int = INBOUND_MAX_CONNECTION_MINUTES, -) -> list[dict]: +) -> list[dict[str, Any]]: unreachable = [] for es in eurostar_trains: