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 <noreply@anthropic.com>
This commit is contained in:
Edward Betts 2026-05-25 21:48:53 +01:00
parent 453d6244ec
commit 13c4341f3a
14 changed files with 1802 additions and 974 deletions

View file

@ -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 = []