pyrail-uk 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. pyrail_uk-0.1.0/PKG-INFO +37 -0
  2. pyrail_uk-0.1.0/README.md +8 -0
  3. pyrail_uk-0.1.0/pyproject.toml +51 -0
  4. pyrail_uk-0.1.0/pyrail_uk/NationalRail.py +38 -0
  5. pyrail_uk-0.1.0/pyrail_uk/__init__.py +0 -0
  6. pyrail_uk-0.1.0/pyrail_uk/api/__init__.py +0 -0
  7. pyrail_uk-0.1.0/pyrail_uk/api/client.py +29 -0
  8. pyrail_uk-0.1.0/pyrail_uk/api/departures.py +28 -0
  9. pyrail_uk-0.1.0/pyrail_uk/api/referencedata.py +35 -0
  10. pyrail_uk-0.1.0/pyrail_uk/api/types.py +92 -0
  11. pyrail_uk-0.1.0/pyrail_uk/api/url.py +39 -0
  12. pyrail_uk-0.1.0/pyrail_uk/core/__init__.py +0 -0
  13. pyrail_uk-0.1.0/pyrail_uk/core/exceptions.py +10 -0
  14. pyrail_uk-0.1.0/pyrail_uk/service/__init__.py +0 -0
  15. pyrail_uk-0.1.0/pyrail_uk/service/departures.py +117 -0
  16. pyrail_uk-0.1.0/pyrail_uk/service/stations.py +21 -0
  17. pyrail_uk-0.1.0/pyrail_uk/service/types.py +57 -0
  18. pyrail_uk-0.1.0/pyrail_uk/utils/__init__.py +0 -0
  19. pyrail_uk-0.1.0/pyrail_uk/utils/array.py +58 -0
  20. pyrail_uk-0.1.0/pyrail_uk.egg-info/PKG-INFO +37 -0
  21. pyrail_uk-0.1.0/pyrail_uk.egg-info/SOURCES.txt +31 -0
  22. pyrail_uk-0.1.0/pyrail_uk.egg-info/dependency_links.txt +1 -0
  23. pyrail_uk-0.1.0/pyrail_uk.egg-info/requires.txt +19 -0
  24. pyrail_uk-0.1.0/pyrail_uk.egg-info/top_level.txt +3 -0
  25. pyrail_uk-0.1.0/setup.cfg +4 -0
  26. pyrail_uk-0.1.0/tests/pyrail_uk/__init__.py +0 -0
  27. pyrail_uk-0.1.0/tests/pyrail_uk/api/__init__.py +0 -0
  28. pyrail_uk-0.1.0/tests/pyrail_uk/api/test_url.py +16 -0
  29. pyrail_uk-0.1.0/tests/pyrail_uk/service/__init__.py +0 -0
  30. pyrail_uk-0.1.0/tests/pyrail_uk/service/test_departures.py +148 -0
  31. pyrail_uk-0.1.0/tests/pyrail_uk/service/test_departures_integration.py +115 -0
  32. pyrail_uk-0.1.0/tests/pyrail_uk/service/test_stations.py +31 -0
  33. pyrail_uk-0.1.0/tests/pyrail_uk/service/testutils.py +138 -0
@@ -0,0 +1,37 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyrail-uk
3
+ Version: 0.1.0
4
+ Summary: Python SDK for dealing with the National Rail API
5
+ Author-email: Christopher Khoo <khoojinnwei@gmail.com>
6
+ License: MIT
7
+ Project-URL: repository, https://github.com/thekhoo/pyrail-uk
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: OS Independent
11
+ Requires-Python: ~=3.13
12
+ Description-Content-Type: text/markdown
13
+ Requires-Dist: requests
14
+ Requires-Dist: cachetools
15
+ Provides-Extra: development
16
+ Requires-Dist: pytest; extra == "development"
17
+ Requires-Dist: pytest-sugar; extra == "development"
18
+ Requires-Dist: pyrefly; extra == "development"
19
+ Requires-Dist: ruff; extra == "development"
20
+ Requires-Dist: black; extra == "development"
21
+ Requires-Dist: isort; extra == "development"
22
+ Provides-Extra: local
23
+ Requires-Dist: boto3; extra == "local"
24
+ Requires-Dist: boto3-stubs[ssm]; extra == "local"
25
+ Requires-Dist: python-dotenv; extra == "local"
26
+ Provides-Extra: deploy
27
+ Requires-Dist: build; extra == "deploy"
28
+ Requires-Dist: twine; extra == "deploy"
29
+
30
+ # PyRail
31
+
32
+ An easier way to deal with the National Rail API
33
+
34
+ ## Supported APIs
35
+
36
+ - [Departure Board with Details](https://raildata.org.uk/dashboard/dataProduct/P-9a01dd96-7211-4912-bcbb-c1b5d2e35609/overview)
37
+ - [Reference Data (Stations Only)](https://raildata.org.uk/dashboard/dataProduct/P-c73f0d2a-c233-497d-846b-8354e2cac326/overview)
@@ -0,0 +1,8 @@
1
+ # PyRail
2
+
3
+ An easier way to deal with the National Rail API
4
+
5
+ ## Supported APIs
6
+
7
+ - [Departure Board with Details](https://raildata.org.uk/dashboard/dataProduct/P-9a01dd96-7211-4912-bcbb-c1b5d2e35609/overview)
8
+ - [Reference Data (Stations Only)](https://raildata.org.uk/dashboard/dataProduct/P-c73f0d2a-c233-497d-846b-8354e2cac326/overview)
@@ -0,0 +1,51 @@
1
+ [project]
2
+ name = "pyrail-uk"
3
+ version = "0.1.0"
4
+ description = "Python SDK for dealing with the National Rail API"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Christopher Khoo", email = "khoojinnwei@gmail.com" },
8
+ ]
9
+ license = {text = "MIT"}
10
+ dependencies = ["requests", "cachetools"]
11
+ requires-python = "~=3.13"
12
+ classifiers = [
13
+ "Programming Language :: Python :: 3",
14
+ "License :: OSI Approved :: MIT License",
15
+ "Operating System :: OS Independent"
16
+ ]
17
+
18
+ [project.urls]
19
+ repository = "https://github.com/thekhoo/pyrail-uk"
20
+
21
+ [project.optional-dependencies]
22
+ development = [
23
+ # unit tests
24
+ 'pytest',
25
+ 'pytest-sugar',
26
+ # linters
27
+ 'pyrefly',
28
+ 'ruff',
29
+ 'black',
30
+ 'isort'
31
+ ]
32
+ local=["boto3", "boto3-stubs[ssm]", "python-dotenv"]
33
+ deploy=["build", "twine"]
34
+
35
+
36
+ [tool.setuptools.packages.find]
37
+ exclude = ["tests"]
38
+
39
+ [tool.black]
40
+ line-length = 120
41
+
42
+ [tool.ruff]
43
+ # Directories to scan
44
+ include = ["pyrail_uk/**/*.py"]
45
+
46
+ [tool.pyrefly]
47
+ project-includes=["pyrail_uk/**/*.py"]
48
+
49
+ [build-system]
50
+ requires = ["setuptools>=42", "wheel"]
51
+ build-backend = "setuptools.build_meta"
@@ -0,0 +1,38 @@
1
+ import typing as t
2
+
3
+ from pyrail_uk.api.departures import NationalRailAPIRequestClient
4
+ from pyrail_uk.api.referencedata import NationalRailReferenceDataRequestClient
5
+
6
+ from .service.departures import simplify_departures
7
+ from .service.stations import find_crs_by_station_name, find_station_by_crs
8
+
9
+
10
+ class NationalRailClient:
11
+
12
+ def __init__(self, departure_board_token: str, reference_data_token: str):
13
+ self.dep_client = NationalRailAPIRequestClient(departure_board_token)
14
+ self.ref_data_client = NationalRailReferenceDataRequestClient(reference_data_token)
15
+
16
+ def get_trains(
17
+ self,
18
+ dep_crs: str,
19
+ arr_crs: t.Optional[str] = None,
20
+ timeoffset_mins: int = 0,
21
+ timewindow_mins: int = 120,
22
+ simplified: bool = False,
23
+ ):
24
+ res = self.dep_client.get_departures(dep_crs, arr_crs, timeoffset_mins, timewindow_mins)
25
+
26
+ # allow users the option to get the full response
27
+ if not simplified:
28
+ return res
29
+
30
+ return simplify_departures(departures_data=res)
31
+
32
+ def get_station_name_by_crs(self, crs: str) -> str:
33
+ stations = self.ref_data_client.get_stations()
34
+ return find_station_by_crs(stations, crs)
35
+
36
+ def get_crs_by_station_name(self, station_name: str) -> str:
37
+ stations = self.ref_data_client.get_stations()
38
+ return find_crs_by_station_name(stations, station_name)
File without changes
File without changes
@@ -0,0 +1,29 @@
1
+ import logging
2
+ import typing as t
3
+
4
+ import requests as r
5
+ from requests.adapters import HTTPAdapter
6
+ from urllib3.util.retry import Retry
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ class APIClient:
12
+
13
+ def __init__(self, token: str, headers: t.Optional[dict] = None, retry_strategy: t.Optional[Retry] = None):
14
+ self.token = token
15
+ self.session = r.Session()
16
+
17
+ if headers:
18
+ self.session.headers.update(headers)
19
+
20
+ if retry_strategy:
21
+ adapter = HTTPAdapter(max_retries=retry_strategy)
22
+ self.session.mount("https://", adapter)
23
+ self.session.mount("http://", adapter)
24
+
25
+ def get(self, url: str):
26
+ logger.debug(f"Making GET request to URL: {url}")
27
+ res = self.session.get(url=url)
28
+ res.raise_for_status()
29
+ return res.json()
@@ -0,0 +1,28 @@
1
+ import logging
2
+ import typing as t
3
+
4
+ from urllib3.util.retry import Retry
5
+
6
+ from .client import APIClient
7
+ from .types import DepBoardWithDetailsResponseTypeDef
8
+ from .url import get_departure_board_url
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class NationalRailAPIRequestClient(APIClient):
14
+
15
+ def __init__(self, token: str, retry_strategy: t.Optional[Retry] = None):
16
+ super().__init__(
17
+ token,
18
+ headers={"x-apikey": token, "Accept": "*/*", "User-Agent": "pyrail-sdk", "Origin": "localhost"},
19
+ retry_strategy=retry_strategy,
20
+ )
21
+
22
+ def get_departures(
23
+ self, dep_crs: str, arr_crs: t.Optional[str] = None, timeoffset_mins: int = 0, timewindow_mins: int = 120
24
+ ) -> DepBoardWithDetailsResponseTypeDef:
25
+ url = get_departure_board_url(
26
+ dep_crs, arr_crs=arr_crs, timeoffset_mins=timeoffset_mins, timewindow_mins=timewindow_mins
27
+ )
28
+ return self.get(url=url)
@@ -0,0 +1,35 @@
1
+ import logging
2
+ import typing as t
3
+
4
+ from cachetools import TTLCache
5
+ from urllib3.util.retry import Retry
6
+
7
+ from .client import APIClient
8
+ from .types import StationCRSTypeDef, StationListResponseTypeDef
9
+ from .url import get_station_list_url
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class NationalRailReferenceDataRequestClient(APIClient):
15
+
16
+ def __init__(self, token: str, retry_strategy: t.Optional[Retry] = None, cache_ttl_seconds: int = 300):
17
+ super().__init__(
18
+ token,
19
+ headers={"x-apikey": token, "Accept": "*/*", "User-Agent": "pyrail-sdk", "Origin": "localhost"},
20
+ retry_strategy=retry_strategy,
21
+ )
22
+
23
+ self.cache = TTLCache(maxsize=1, ttl=float(cache_ttl_seconds))
24
+
25
+ def get_stations(self) -> list[StationCRSTypeDef]:
26
+ if "stations" in self.cache:
27
+ logger.debug("stations data found within the cache, not requesting!")
28
+ return self.cache["stations"]
29
+
30
+ url = get_station_list_url()
31
+ res: StationListResponseTypeDef = self.get(url)
32
+
33
+ stations = res.get("StationList", [])
34
+ self.cache["stations"] = stations
35
+ return stations
@@ -0,0 +1,92 @@
1
+ from typing import NotRequired, TypedDict
2
+
3
+
4
+ class DepartureResponseMetadataTypeDef(TypedDict):
5
+ Count: int
6
+
7
+
8
+ class NRCCMessagesTypeDef(TypedDict):
9
+ Value: str
10
+
11
+
12
+ class DestinationTypeDef(TypedDict):
13
+ crs: str
14
+ locationName: str
15
+ assocIsCancelled: bool
16
+
17
+
18
+ class CallingPointTypeDef(TypedDict):
19
+ locationName: str
20
+ crs: str
21
+
22
+ st: str # Scheduled time
23
+ et: str # Estimated time
24
+
25
+ isCancelled: bool
26
+ length: int
27
+ detachFront: bool
28
+ delayReason: NotRequired[str]
29
+ affectedByDiversion: bool
30
+ rerouteDelay: int
31
+
32
+
33
+ class SubsequentCallingPointsTypeDef(TypedDict):
34
+ callingPoint: list[CallingPointTypeDef]
35
+ serviceType: str
36
+ serviceChangeRequired: bool
37
+ assocIsCancelled: bool
38
+
39
+
40
+ class TrainServiceTypeDef(TypedDict):
41
+ serviceID: str
42
+ cancelReason: NotRequired[str]
43
+ delayReason: NotRequired[str]
44
+ isReverseFormation: bool
45
+ detachFront: bool
46
+ length: int
47
+ serviceType: str
48
+ filterLocationCancelled: bool
49
+ isCancelled: bool
50
+ isCircularRoute: bool
51
+
52
+ operatorCode: str
53
+ operator: str
54
+
55
+ platform: NotRequired[str]
56
+ etd: str # Estimated Time of Departure
57
+ std: str # Scheduled Time of Departure
58
+ rsid: str
59
+
60
+ # i honestly have no idea what these 2 fields mean... they're always false
61
+ futureCancellation: bool
62
+ futureDelay: bool
63
+
64
+ currentDestinations: list[DestinationTypeDef]
65
+ origin: list[DestinationTypeDef]
66
+ destination: list[DestinationTypeDef]
67
+
68
+ subsequentCallingPoints: list[SubsequentCallingPointsTypeDef]
69
+
70
+
71
+ class DepBoardWithDetailsResponseTypeDef(TypedDict):
72
+ Xmlns: DepartureResponseMetadataTypeDef
73
+ areServicesAvailable: bool
74
+ crs: str
75
+ filterLocationName: str
76
+ filterType: str
77
+ filtercrs: str
78
+ generatedAt: str
79
+ locationName: str
80
+ nrccMessages: list[NRCCMessagesTypeDef]
81
+ platformAvailable: bool
82
+ trainServices: list[TrainServiceTypeDef]
83
+
84
+
85
+ class StationCRSTypeDef(TypedDict):
86
+ crs: str
87
+ Value: str
88
+
89
+
90
+ class StationListResponseTypeDef(TypedDict):
91
+ version: str
92
+ StationList: list[StationCRSTypeDef]
@@ -0,0 +1,39 @@
1
+ import typing as t
2
+
3
+ DEPARTURE_URL = (
4
+ "https://api1.raildata.org.uk/1010-live-departure-board-dep1_2/LDBWS/api/20220120/GetDepBoardWithDetails"
5
+ )
6
+
7
+ STATION_LIST_URL = "https://api1.raildata.org.uk/1010-reference-data1_0/LDBSVWS/api/ref/20211101/GetStationList"
8
+
9
+ # NOTE: This is an alternate URL that can be used for less verbose details
10
+ # it also has carriage information but weirdly, only for Elizabeth Line trains...
11
+ # DEPARTURE_URL = "https://api1.raildata.org.uk/1010-live-departure-board-dep1_2/LDBWS/api/20220120/GetDepartureBoard"
12
+
13
+
14
+ def get_departure_board_url(
15
+ dep_crs: str,
16
+ arr_crs: t.Optional[str] = None,
17
+ timeoffset_mins: t.Optional[int] = None,
18
+ timewindow_mins: t.Optional[int] = None,
19
+ ):
20
+ url = f"{DEPARTURE_URL}/{dep_crs}"
21
+ query_params = []
22
+
23
+ if arr_crs:
24
+ query_params.append(f"filterCrs={arr_crs}")
25
+
26
+ if timeoffset_mins:
27
+ query_params.append(f"timeOffset={timeoffset_mins}")
28
+
29
+ if timewindow_mins:
30
+ query_params.append(f"timeWindow={timewindow_mins}")
31
+
32
+ if query_params:
33
+ url += "?" + "&".join(query_params)
34
+
35
+ return url
36
+
37
+
38
+ def get_station_list_url(version: int = 1):
39
+ return f"{STATION_LIST_URL}/{version}"
File without changes
@@ -0,0 +1,10 @@
1
+ class NoTrainsFoundException(Exception):
2
+ pass
3
+
4
+
5
+ class CRSNotFoundException(Exception):
6
+ pass
7
+
8
+
9
+ class StationNotFoundException(Exception):
10
+ pass
File without changes
@@ -0,0 +1,117 @@
1
+ from typing import Optional
2
+
3
+ import pyrail_uk.utils.array as array
4
+ from pyrail_uk.api.types import DepBoardWithDetailsResponseTypeDef, TrainServiceTypeDef
5
+
6
+ from .types import DepartureServiceResponse, TrainService, TrainStatus
7
+
8
+ ON_TIME_ETD = "On time"
9
+ DEFAULT_DELAY_REASON = "No reason provided"
10
+ DEFAULT_CANCEL_REASON = "No reason provided"
11
+
12
+
13
+ def get_train_status_and_reason(service_info: TrainServiceTypeDef) -> tuple[TrainStatus, Optional[str]]:
14
+ if service_info.get("isCancelled", False) or service_info.get("filterLocationCancelled", False):
15
+ status = TrainStatus.CANCELLED
16
+ reason = service_info.get("cancelReason", DEFAULT_CANCEL_REASON)
17
+ return status, reason
18
+
19
+ if "subsequentCallingPoints" not in service_info:
20
+ etd = service_info.get("etd")
21
+ if etd == ON_TIME_ETD:
22
+ return TrainStatus.DEPARTED, None
23
+ else:
24
+ return TrainStatus.DELAYED_DEPARTED, None
25
+
26
+ etd = service_info.get("etd")
27
+ if etd == ON_TIME_ETD:
28
+ return TrainStatus.ON_TIME, None
29
+
30
+ return TrainStatus.DELAYED, service_info.get("delayReason", DEFAULT_DELAY_REASON)
31
+
32
+
33
+ def get_eta(service_info: TrainServiceTypeDef, target_crs: str) -> Optional[str]:
34
+ if "subsequentCallingPoints" not in service_info:
35
+ return None
36
+
37
+ calling_points = service_info["subsequentCallingPoints"][0]["callingPoint"]
38
+ target_point = array.findfirst(calling_points, lambda cp: cp["crs"] == target_crs)
39
+
40
+ if not target_point:
41
+ return None
42
+
43
+ return target_point.get("et")
44
+
45
+
46
+ def get_atd(service_info: TrainServiceTypeDef, train_status: TrainStatus) -> Optional[str]:
47
+ """Gets the actual time of departure for the train service, if it has departed."""
48
+ if train_status not in [TrainStatus.DEPARTED, TrainStatus.DELAYED_DEPARTED]:
49
+ return None
50
+
51
+ if train_status == TrainStatus.DELAYED_DEPARTED:
52
+ etd = service_info.get("etd")
53
+ return service_info.get("std") if etd == ON_TIME_ETD else etd
54
+
55
+ if train_status == TrainStatus.DEPARTED:
56
+ return service_info.get("std")
57
+
58
+
59
+ def simplify_service_info(service_info: TrainServiceTypeDef, target_crs: Optional[str]) -> TrainService:
60
+ origin = service_info["origin"][0]
61
+ destination = service_info["destination"][0]
62
+
63
+ operator_code = service_info["operatorCode"]
64
+ operator = service_info["operator"]
65
+
66
+ status, reason = get_train_status_and_reason(service_info)
67
+
68
+ atd = get_atd(service_info, status)
69
+ eta = get_eta(service_info, target_crs or destination["crs"]) # handle no user destination cases
70
+
71
+ return TrainService(
72
+ # this origin is where the train originated from
73
+ train_origin_crs=origin["crs"],
74
+ train_origin=origin["locationName"],
75
+ # this destination is the final destination of the train service
76
+ train_destination_crs=destination["crs"],
77
+ train_destination=destination["locationName"],
78
+ # general information
79
+ status=status,
80
+ status_reason=reason,
81
+ operator=operator,
82
+ operator_code=operator_code,
83
+ # time and location details,
84
+ platform=service_info.get("platform"),
85
+ std=service_info["std"],
86
+ etd=service_info["etd"],
87
+ atd=atd,
88
+ eta=eta,
89
+ )
90
+
91
+
92
+ def simplify_departures(departures_data: DepBoardWithDetailsResponseTypeDef):
93
+ # this is for the user's journey and not the train journey
94
+ # i.e. the user is departing from OXF to RDF
95
+ # but the train is going from WXP to PAD
96
+ origin_crs = departures_data.get("crs")
97
+ origin_name = departures_data.get("locationName")
98
+ destination_crs = departures_data.get("filtercrs", None)
99
+ destination_name = departures_data.get("filterLocationName", None)
100
+
101
+ services_raw = departures_data.get("trainServices", {})
102
+ services = [simplify_service_info(service, destination_crs) for service in services_raw]
103
+ num_services = departures_data.get("Xmlns", {}).get("Count", len(services))
104
+
105
+ # all the messages are an array of {"Value": "..."}
106
+ nrcc_warning_messages_raw = departures_data.get("nrccMessages", [])
107
+ warning_messages = [msg["Value"] for msg in nrcc_warning_messages_raw]
108
+
109
+ return DepartureServiceResponse(
110
+ origin_crs=origin_crs,
111
+ origin=origin_name,
112
+ destination_crs=destination_crs,
113
+ destination=destination_name,
114
+ warning_messages=warning_messages,
115
+ services=services,
116
+ num_services=num_services,
117
+ )
@@ -0,0 +1,21 @@
1
+ import pyrail_uk.utils.array as array
2
+
3
+ from ..api.types import StationCRSTypeDef
4
+ from ..core.exceptions import CRSNotFoundException, StationNotFoundException
5
+
6
+
7
+ def find_station_by_crs(stations: list[StationCRSTypeDef], crs: str) -> str:
8
+ station = array.findone(stations, lambda s: s["crs"] == crs.upper())
9
+ if not station:
10
+ raise CRSNotFoundException(f"{crs} is not a valid CRS")
11
+
12
+ return station.get("Value")
13
+
14
+
15
+ def find_crs_by_station_name(stations: list[StationCRSTypeDef], station_name: str) -> str:
16
+ station = array.findone(stations, lambda s: s["Value"].lower() == station_name.lower())
17
+
18
+ if not station:
19
+ raise StationNotFoundException(f"{station_name} is not a valid station, please check your spelling!")
20
+
21
+ return station.get("crs")
@@ -0,0 +1,57 @@
1
+ from dataclasses import dataclass
2
+ from enum import Enum
3
+ from typing import Optional
4
+
5
+
6
+ class TrainStatus(str, Enum):
7
+ ON_TIME = "ON_TIME"
8
+ DELAYED = "DELAYED"
9
+ CANCELLED = "CANCELLED"
10
+ DEPARTED = "DEPARTED"
11
+ DELAYED_DEPARTED = "DELAYED_DEPARTED"
12
+
13
+
14
+ @dataclass
15
+ class TrainService:
16
+ # information about the train journey
17
+ train_origin_crs: str
18
+ train_origin: str
19
+ train_destination_crs: str
20
+ train_destination: str
21
+
22
+ # train status
23
+ status: TrainStatus
24
+ status_reason: Optional[str]
25
+
26
+ # information about the train operator
27
+ operator_code: str
28
+ operator: str
29
+
30
+ # timing and platform information
31
+ # NOTE: std - scheduled time of departure
32
+ # etd - estimated time of departure
33
+ # atd - actual time of departure
34
+ # eta - estimated time of arrival
35
+ platform: Optional[str] # not present if train cancelled
36
+ std: str
37
+ etd: str
38
+ atd: Optional[str] # not present if train not departed
39
+ eta: Optional[str] # not present if train cancelled
40
+
41
+
42
+ @dataclass
43
+ class DepartureServiceResponse:
44
+ origin_crs: str
45
+ origin: str
46
+ destination_crs: str
47
+ destination: str
48
+ warning_messages: list[str]
49
+ # metadata about services
50
+ services: list[TrainService]
51
+ num_services: int
52
+
53
+
54
+ @dataclass
55
+ class Station:
56
+ crs: str
57
+ name: str
File without changes
@@ -0,0 +1,58 @@
1
+ import typing as t
2
+
3
+
4
+ class NotFoundException(Exception):
5
+ pass
6
+
7
+
8
+ class MultipleFoundException(Exception):
9
+ pass
10
+
11
+
12
+ T = t.TypeVar("T")
13
+
14
+
15
+ def findfirst(arr: list[T], condition: t.Callable, strict: bool = False) -> t.Optional[T]:
16
+ """Find the first element in an array that satisfies the given condition.
17
+
18
+ Args:
19
+ arr (list): The list to search through.
20
+ condition (Callable[[Any], bool]): A function that takes an element and returns True if it satisfies the condition.
21
+ strict (bool): If true, raises an error if no element is found. Defaults to False.
22
+
23
+ Returns:
24
+ Optional[Any]: The first element that satisfies the condition, or None if no such element is found.
25
+ """
26
+ for item in arr:
27
+ if condition(item):
28
+ return item
29
+
30
+ if strict:
31
+ raise NotFoundException("No element found that satisfies the condition.")
32
+
33
+ return None
34
+
35
+
36
+ def findone(arr: list[T], condition: t.Callable, strict: bool = False) -> t.Optional[T]:
37
+ """Find exactly one element in an array that satisfies the given condition.
38
+
39
+ Args:
40
+ arr (list): The list to search through.
41
+ condition (Callable[[Any], bool]): A function that takes an element and returns True if it satisfies the condition.
42
+ strict (bool): If true, raises an error if no element is found or if multiple elements are found. Defaults to False.
43
+
44
+ Returns:
45
+ Optional[Any]: The element that satisfies the condition, or None if no such element is found.
46
+ """
47
+ found_items = [item for item in arr if condition(item)]
48
+
49
+ if len(found_items) == 1:
50
+ return found_items[0]
51
+ elif len(found_items) == 0:
52
+ if strict:
53
+ raise NotFoundException("No element found that satisfies the condition.")
54
+ return None
55
+ else:
56
+ if strict:
57
+ raise MultipleFoundException("Multiple elements found that satisfy the condition.")
58
+ return None
@@ -0,0 +1,37 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyrail-uk
3
+ Version: 0.1.0
4
+ Summary: Python SDK for dealing with the National Rail API
5
+ Author-email: Christopher Khoo <khoojinnwei@gmail.com>
6
+ License: MIT
7
+ Project-URL: repository, https://github.com/thekhoo/pyrail-uk
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: OS Independent
11
+ Requires-Python: ~=3.13
12
+ Description-Content-Type: text/markdown
13
+ Requires-Dist: requests
14
+ Requires-Dist: cachetools
15
+ Provides-Extra: development
16
+ Requires-Dist: pytest; extra == "development"
17
+ Requires-Dist: pytest-sugar; extra == "development"
18
+ Requires-Dist: pyrefly; extra == "development"
19
+ Requires-Dist: ruff; extra == "development"
20
+ Requires-Dist: black; extra == "development"
21
+ Requires-Dist: isort; extra == "development"
22
+ Provides-Extra: local
23
+ Requires-Dist: boto3; extra == "local"
24
+ Requires-Dist: boto3-stubs[ssm]; extra == "local"
25
+ Requires-Dist: python-dotenv; extra == "local"
26
+ Provides-Extra: deploy
27
+ Requires-Dist: build; extra == "deploy"
28
+ Requires-Dist: twine; extra == "deploy"
29
+
30
+ # PyRail
31
+
32
+ An easier way to deal with the National Rail API
33
+
34
+ ## Supported APIs
35
+
36
+ - [Departure Board with Details](https://raildata.org.uk/dashboard/dataProduct/P-9a01dd96-7211-4912-bcbb-c1b5d2e35609/overview)
37
+ - [Reference Data (Stations Only)](https://raildata.org.uk/dashboard/dataProduct/P-c73f0d2a-c233-497d-846b-8354e2cac326/overview)
@@ -0,0 +1,31 @@
1
+ README.md
2
+ pyproject.toml
3
+ pyrail_uk/NationalRail.py
4
+ pyrail_uk/__init__.py
5
+ pyrail_uk.egg-info/PKG-INFO
6
+ pyrail_uk.egg-info/SOURCES.txt
7
+ pyrail_uk.egg-info/dependency_links.txt
8
+ pyrail_uk.egg-info/requires.txt
9
+ pyrail_uk.egg-info/top_level.txt
10
+ pyrail_uk/api/__init__.py
11
+ pyrail_uk/api/client.py
12
+ pyrail_uk/api/departures.py
13
+ pyrail_uk/api/referencedata.py
14
+ pyrail_uk/api/types.py
15
+ pyrail_uk/api/url.py
16
+ pyrail_uk/core/__init__.py
17
+ pyrail_uk/core/exceptions.py
18
+ pyrail_uk/service/__init__.py
19
+ pyrail_uk/service/departures.py
20
+ pyrail_uk/service/stations.py
21
+ pyrail_uk/service/types.py
22
+ pyrail_uk/utils/__init__.py
23
+ pyrail_uk/utils/array.py
24
+ tests/pyrail_uk/__init__.py
25
+ tests/pyrail_uk/api/__init__.py
26
+ tests/pyrail_uk/api/test_url.py
27
+ tests/pyrail_uk/service/__init__.py
28
+ tests/pyrail_uk/service/test_departures.py
29
+ tests/pyrail_uk/service/test_departures_integration.py
30
+ tests/pyrail_uk/service/test_stations.py
31
+ tests/pyrail_uk/service/testutils.py
@@ -0,0 +1,19 @@
1
+ requests
2
+ cachetools
3
+
4
+ [deploy]
5
+ build
6
+ twine
7
+
8
+ [development]
9
+ pytest
10
+ pytest-sugar
11
+ pyrefly
12
+ ruff
13
+ black
14
+ isort
15
+
16
+ [local]
17
+ boto3
18
+ boto3-stubs[ssm]
19
+ python-dotenv
@@ -0,0 +1,3 @@
1
+ dist
2
+ pyrail_uk
3
+ tests
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
File without changes
File without changes
@@ -0,0 +1,16 @@
1
+ import pyrail_uk.api.url as url
2
+
3
+
4
+ class Test_Get_URL:
5
+
6
+ def test_should_return_url_with_dep_crs_only(self):
7
+ actual_url = url.get_departure_board_url("RDG")
8
+ assert actual_url.endswith("/RDG")
9
+
10
+ def test_should_return_url_with_dep_and_arr_crs(self):
11
+ actual_url = url.get_departure_board_url(dep_crs="RDG", arr_crs="OXF")
12
+ assert actual_url.endswith("/RDG?filterCrs=OXF")
13
+
14
+ def test_should_return_url_with_multiple_query_params(self):
15
+ actual_url = url.get_departure_board_url(dep_crs="RDG", arr_crs="OXF", timeoffset_mins=15)
16
+ assert actual_url.endswith("/RDG?filterCrs=OXF&timeOffset=15")
File without changes
@@ -0,0 +1,148 @@
1
+ import pytest
2
+
3
+ import pyrail_uk.service.departures as departures
4
+ from pyrail_uk.service.types import TrainStatus
5
+
6
+ from .testutils import MockCallingPoint, MockTrainServiceData
7
+
8
+ CALLING_POINTS = [
9
+ MockCallingPoint("OXF", "16:30"),
10
+ MockCallingPoint("DID", "17:00"),
11
+ MockCallingPoint("RDG", "17:30"),
12
+ MockCallingPoint("PAD", "18:00"),
13
+ ]
14
+
15
+
16
+ class Test_Get_Train_Status_And_Reason:
17
+
18
+ class Test_When_Trains_Are_Cancelled:
19
+
20
+ def test_should_return_cancel_status_with_reason_when_available(self):
21
+ payload = (
22
+ MockTrainServiceData()
23
+ .set_cancelled()
24
+ .set_cancel_reason("Signal failure")
25
+ .set_subsequent_calling_points(CALLING_POINTS)
26
+ .build()
27
+ )
28
+ status, reason = departures.get_train_status_and_reason(payload)
29
+
30
+ assert status == TrainStatus.CANCELLED
31
+ assert reason == "Signal failure"
32
+
33
+ def test_should_return_cancel_status_with_default_reason_when_not_provided(self):
34
+ payload = MockTrainServiceData().set_cancelled().set_subsequent_calling_points(CALLING_POINTS).build()
35
+ status, reason = departures.get_train_status_and_reason(payload)
36
+
37
+ assert status == TrainStatus.CANCELLED
38
+ assert isinstance(reason, str)
39
+
40
+ # we need this test because the absence of subsequent calling points
41
+ # suggests that the train has already departed
42
+ def test_should_return_cancel_when_no_subsequent_calling_points_and_cancelled(self):
43
+ payload = (
44
+ MockTrainServiceData()
45
+ .set_cancelled()
46
+ .set_cancel_reason("Signal failure")
47
+ .clear_subsequent_calling_points()
48
+ .build()
49
+ )
50
+ status, reason = departures.get_train_status_and_reason(payload)
51
+
52
+ assert status == TrainStatus.CANCELLED
53
+ assert reason == "Signal failure"
54
+
55
+ class Test_When_ETD_Is_On_Time:
56
+
57
+ def test_should_return_on_time_status_with_no_reason(self):
58
+ payload = MockTrainServiceData().set_subsequent_calling_points(CALLING_POINTS).set_etd("On time").build()
59
+ status, reason = departures.get_train_status_and_reason(payload)
60
+
61
+ assert status == TrainStatus.ON_TIME
62
+ assert reason is None
63
+
64
+ class Test_When_ETD_Is_Not_On_Time:
65
+
66
+ @pytest.mark.parametrize("etd", ["18:31", "Delayed"])
67
+ def test_should_return_delayed_status_with_delay_reason_when_available(self, etd):
68
+ payload = (
69
+ MockTrainServiceData()
70
+ .set_subsequent_calling_points(CALLING_POINTS)
71
+ .set_etd(etd)
72
+ .set_delay_reason("trespassers on track")
73
+ .build()
74
+ )
75
+ status, reason = departures.get_train_status_and_reason(payload)
76
+
77
+ assert status == TrainStatus.DELAYED
78
+ assert reason == "trespassers on track"
79
+
80
+ def test_should_return_delayed_status_with_default_reason_when_not_provided(self):
81
+ payload = MockTrainServiceData().set_subsequent_calling_points(CALLING_POINTS).set_etd("Delayed").build()
82
+ status, reason = departures.get_train_status_and_reason(payload)
83
+
84
+ assert status == TrainStatus.DELAYED
85
+ assert reason == "No reason provided"
86
+
87
+ class Test_When_There_Are_No_Subsequent_Calling_Points:
88
+
89
+ def test_should_return_departed_status_with_no_reason_if_etd_on_time(self):
90
+ payload = MockTrainServiceData().clear_subsequent_calling_points().set_etd("On time").build()
91
+ status, reason = departures.get_train_status_and_reason(payload)
92
+
93
+ assert status == TrainStatus.DEPARTED
94
+ assert reason is None
95
+
96
+ def test_should_return_delayed_departed_status_with_no_reason_if_etd_not_on_time(self):
97
+ payload = MockTrainServiceData().clear_subsequent_calling_points().set_etd("18:31").build()
98
+ status, reason = departures.get_train_status_and_reason(payload)
99
+
100
+ assert status == TrainStatus.DELAYED_DEPARTED
101
+ assert reason is None
102
+
103
+
104
+ class Test_Get_ETA:
105
+
106
+ def test_should_return_none_if_no_subsequent_calling_points(self):
107
+ payload = MockTrainServiceData().clear_subsequent_calling_points().build()
108
+ eta = departures.get_eta(payload, "RDG")
109
+
110
+ assert eta is None
111
+
112
+ def test_should_return_none_if_target_crs_not_found(self):
113
+ crs = "LHR"
114
+ payload = MockTrainServiceData().set_subsequent_calling_points(CALLING_POINTS).build()
115
+ eta = departures.get_eta(payload, crs)
116
+
117
+ assert eta is None
118
+
119
+ def test_should_return_et_for_target_crs(self):
120
+ crs = "RDG"
121
+ payload = MockTrainServiceData().set_subsequent_calling_points(CALLING_POINTS).build()
122
+ eta = departures.get_eta(payload, crs)
123
+
124
+ assert eta == "17:30"
125
+
126
+
127
+ class Test_Get_ATD:
128
+
129
+ @pytest.mark.parametrize("train_status", [TrainStatus.ON_TIME, TrainStatus.DELAYED, TrainStatus.CANCELLED])
130
+ def test_should_return_none_if_train_not_departed(self, train_status):
131
+ payload = MockTrainServiceData().build()
132
+ atd = departures.get_atd(payload, train_status)
133
+ assert atd is None
134
+
135
+ def test_should_return_std_if_train_departed_on_time(self):
136
+ payload = MockTrainServiceData().set_etd("On time").set_std("17:20").build()
137
+ atd = departures.get_atd(payload, TrainStatus.DEPARTED)
138
+ assert atd == "17:20"
139
+
140
+ def test_should_return_etd_if_train_departed_delayed(self):
141
+ payload = MockTrainServiceData().set_etd("17:48").set_std("17:20").build()
142
+ atd = departures.get_atd(payload, TrainStatus.DELAYED_DEPARTED)
143
+ assert atd == "17:48"
144
+
145
+ def test_should_return_std_if_train_departed_delayed_but_etd_on_time(self):
146
+ payload = MockTrainServiceData().set_etd("On time").set_std("17:20").build()
147
+ atd = departures.get_atd(payload, TrainStatus.DELAYED_DEPARTED)
148
+ assert atd == "17:20"
@@ -0,0 +1,115 @@
1
+ import pyrail_uk.service.departures as departures
2
+ from pyrail_uk.service.types import TrainStatus
3
+
4
+ from .testutils import MockCallingPoint, MockDepartureData, MockTrainServiceData
5
+
6
+ CALLING_POINTS = [
7
+ MockCallingPoint("OXF", "16:30"),
8
+ MockCallingPoint("DID", "17:00"),
9
+ MockCallingPoint("RDG", "17:30"),
10
+ MockCallingPoint("PAD", "18:00"),
11
+ ]
12
+
13
+ CANCELLED_TRAIN_SERVICE = (
14
+ MockTrainServiceData()
15
+ .set_cancelled()
16
+ .set_cancel_reason("more trains than usual needing repairs")
17
+ .set_subsequent_calling_points(CALLING_POINTS)
18
+ .build()
19
+ )
20
+
21
+ ON_TIME_TRAIN_SERVICE = (
22
+ MockTrainServiceData().set_etd("On time").set_std("16:30").set_subsequent_calling_points(CALLING_POINTS).build()
23
+ )
24
+
25
+ DELAYED_TRAIN_SERVICE = (
26
+ MockTrainServiceData().set_etd("16:52").set_std("16:30").set_subsequent_calling_points(CALLING_POINTS).build()
27
+ )
28
+
29
+ DEPARTED_TRAIN_SERVICE = (
30
+ MockTrainServiceData().clear_subsequent_calling_points().set_std("17:22").set_etd("On time").build()
31
+ )
32
+
33
+ DELAYED_DEPARTED_TRAIN_SERVICE = (
34
+ MockTrainServiceData().clear_subsequent_calling_points().set_etd("17:32").set_std("17:22").build()
35
+ )
36
+
37
+
38
+ class Test_Simplify_Service_Info:
39
+
40
+ # test general properties
41
+ def test_returns_non_service_related_fields_correctly(self):
42
+ res = departures.simplify_service_info(ON_TIME_TRAIN_SERVICE, "RDG")
43
+
44
+ assert res.train_origin == "Oxford"
45
+ assert res.train_origin_crs == "OXF"
46
+ assert res.train_destination == "London Paddington"
47
+ assert res.train_destination_crs == "PAD"
48
+ assert res.operator == "Great Western Railway"
49
+ assert res.operator_code == "GW"
50
+ assert res.platform == "3"
51
+
52
+ def test_when_train_is_cancelled(self):
53
+ res = departures.simplify_service_info(CANCELLED_TRAIN_SERVICE, "RDG")
54
+ assert res.status == TrainStatus.CANCELLED
55
+ assert res.status_reason == "more trains than usual needing repairs"
56
+
57
+ def test_when_train_is_on_time(self):
58
+ res = departures.simplify_service_info(ON_TIME_TRAIN_SERVICE, "RDG")
59
+ assert res.status == TrainStatus.ON_TIME
60
+ assert res.status_reason is None
61
+ assert res.etd == "On time"
62
+ assert res.eta == "17:30"
63
+
64
+ def test_when_train_is_delayed(self):
65
+ res = departures.simplify_service_info(DELAYED_TRAIN_SERVICE, "RDG")
66
+ assert res.status == TrainStatus.DELAYED
67
+ assert res.status_reason == "No reason provided"
68
+
69
+ def test_when_train_has_departed(self):
70
+ res = departures.simplify_service_info(DEPARTED_TRAIN_SERVICE, "RDG")
71
+ assert res.status == TrainStatus.DEPARTED
72
+ assert res.status_reason is None
73
+ assert res.atd == "17:22"
74
+
75
+ def test_when_train_has_departed_late(self):
76
+ res = departures.simplify_service_info(DELAYED_DEPARTED_TRAIN_SERVICE, "RDG")
77
+ assert res.status == TrainStatus.DELAYED_DEPARTED
78
+ assert res.status_reason is None
79
+ assert res.atd == "17:32"
80
+
81
+
82
+ DEPARTURE_INFO_TRAINS_AVAILABLE = (
83
+ MockDepartureData()
84
+ .set_service_available()
85
+ .set_train_services([ON_TIME_TRAIN_SERVICE, CANCELLED_TRAIN_SERVICE, DELAYED_DEPARTED_TRAIN_SERVICE])
86
+ .set_count(3)
87
+ .set_nrcc_messages(["Delayed due to storm eowyn"])
88
+ .build()
89
+ )
90
+
91
+ DEPARTURE_INFO_NO_TRAINS = (
92
+ MockDepartureData().set_service_available().set_count(0).set_nrcc_messages(["Delayed due to storm eowyn"]).build()
93
+ )
94
+
95
+
96
+ class Test_Simplify_Departures:
97
+
98
+ def test_when_no_trains_available(self):
99
+ res = departures.simplify_departures(DEPARTURE_INFO_NO_TRAINS)
100
+ assert res.origin_crs == "RDG"
101
+ assert res.origin == "Reading"
102
+ assert res.destination_crs == "OXF"
103
+ assert res.destination == "Oxford"
104
+ assert res.warning_messages == ["Delayed due to storm eowyn"]
105
+ assert res.services == []
106
+
107
+ def test_when_trains_are_available(self):
108
+ res = departures.simplify_departures(DEPARTURE_INFO_TRAINS_AVAILABLE)
109
+ assert res.origin_crs == "RDG"
110
+ assert res.origin == "Reading"
111
+ assert res.destination_crs == "OXF"
112
+ assert res.destination == "Oxford"
113
+ assert res.warning_messages == ["Delayed due to storm eowyn"]
114
+ assert res.num_services == 3
115
+ assert len(res.services) == 3 # don't actually check the event, just make sure number is correct
@@ -0,0 +1,31 @@
1
+ import pytest
2
+
3
+ import pyrail_uk.core.exceptions as ex
4
+ import pyrail_uk.service.stations as st
5
+
6
+ STATIONS = [
7
+ {"crs": "RDG", "Value": "Reading"},
8
+ {"crs": "OXF", "Value": "Oxford"},
9
+ ]
10
+
11
+
12
+ class Test_Find_Station_By_CRS:
13
+
14
+ def test_should_raise_CRSNotFoundException_when_no_crs_found(self):
15
+ with pytest.raises(ex.CRSNotFoundException):
16
+ st.find_station_by_crs(STATIONS, "NAN")
17
+
18
+ @pytest.mark.parametrize("crs", ["rdg", "rDg", "RDG", "Rdg"])
19
+ def test_should_return_station_name_when_valid_crs_given(self, crs):
20
+ assert st.find_station_by_crs(STATIONS, crs) == "Reading"
21
+
22
+
23
+ class Test_Find_CRS_By_Station_Name:
24
+
25
+ def test_should_raise_StationNotFoundException_when_no_station_name_found(self):
26
+ with pytest.raises(ex.StationNotFoundException):
27
+ st.find_crs_by_station_name(STATIONS, "NotAStation")
28
+
29
+ @pytest.mark.parametrize("station_name", ["Oxford", "OXFORD", "oxford", "OxFoRd"])
30
+ def test_should_return_crs_when_valid_station_name_given(self, station_name):
31
+ assert st.find_crs_by_station_name(STATIONS, station_name) == "OXF"
@@ -0,0 +1,138 @@
1
+ class MockCallingPoint:
2
+
3
+ def __init__(self, crs: str, et: str):
4
+ self.event = {
5
+ "crs": crs,
6
+ "et": et,
7
+ }
8
+
9
+ def set_crs(self, crs: str):
10
+ self.event["crs"] = crs
11
+ return self
12
+
13
+ def set_et(self, et: str):
14
+ self.event["et"] = et
15
+ return self
16
+
17
+ def build(self):
18
+ return self.event
19
+
20
+
21
+ class MockTrainServiceData:
22
+ """
23
+ Not cancelled, delayed and has no subsequent calling points
24
+ by default.
25
+ """
26
+
27
+ def __init__(self):
28
+ self.event = {
29
+ "serviceID": "12345678",
30
+ "isReverseFormation": False,
31
+ "detachFront": False,
32
+ "length": 0,
33
+ "serviceType": "train",
34
+ "filterLocationCancelled": False,
35
+ "isCancelled": False,
36
+ "isCircularRoute": False,
37
+ # operator information
38
+ "operatorCode": "GW",
39
+ "operator": "Great Western Railway",
40
+ # platform
41
+ "platform": None,
42
+ "etd": "On time",
43
+ "std": "14:39",
44
+ "rsid": "GW1234",
45
+ # bogus
46
+ "futureCancellation": False,
47
+ "futureDelay": False,
48
+ # train information
49
+ "platform": "3",
50
+ "origin": [{"crs": "OXF", "locationName": "Oxford"}],
51
+ "destination": [{"crs": "PAD", "locationName": "London Paddington"}],
52
+ "currentDestinations": [{"crs": "RDG", "locationName": "Reading"}],
53
+ }
54
+
55
+ def build(self):
56
+ return self.event
57
+
58
+ def set_cancelled(self):
59
+ self.event["isCancelled"] = True
60
+ return self
61
+
62
+ def set_cancel_reason(self, reason: str):
63
+ self.event["cancelReason"] = reason
64
+ return self
65
+
66
+ def set_delay_reason(self, reason: str):
67
+ self.event["delayReason"] = reason
68
+ return self
69
+
70
+ def set_etd(self, etd: str):
71
+ self.event["etd"] = etd
72
+ return self
73
+
74
+ def set_std(self, std: str):
75
+ self.event["std"] = std
76
+ return self
77
+
78
+ def set_delay_reason(self, reason: str):
79
+ self.event["delayReason"] = reason
80
+ return self
81
+
82
+ def set_subsequent_calling_points(self, calling_points: list[MockCallingPoint]):
83
+ self.event["subsequentCallingPoints"] = [
84
+ {
85
+ "callingPoint": [cp.build() for cp in calling_points],
86
+ "serviceType": "train",
87
+ "serviceChangeRequired": False,
88
+ "assocIsCancelled": False,
89
+ }
90
+ ]
91
+ return self
92
+
93
+ def clear_subsequent_calling_points(self):
94
+ if "subsequentCallingPoints" in self.event:
95
+ del self.event["subsequentCallingPoints"]
96
+ return self
97
+
98
+ def set_filter_location_cancelled(self):
99
+ self.event["filterLocationCancelled"] = True
100
+ return self
101
+
102
+
103
+ class MockDepartureData:
104
+
105
+ def __init__(self):
106
+ self.event = {
107
+ "areServicesAvailable": True,
108
+ "Xmlns": {"Count": 5},
109
+ "crs": "RDG",
110
+ "locationName": "Reading",
111
+ "filtercrs": "OXF",
112
+ "filterLocationName": "Oxford",
113
+ "platformAvailable": True,
114
+ "trainServices": [],
115
+ }
116
+
117
+ def set_count(self, count: int):
118
+ self.event["Xmlns"] = {"Count": count}
119
+ return self
120
+
121
+ def set_train_services(self, train_services: list[dict]):
122
+ self.event["trainServices"] = train_services
123
+ return self
124
+
125
+ def set_service_available(self):
126
+ self.event["areServicesAvailable"] = True
127
+ return self
128
+
129
+ def set_service_not_available(self):
130
+ self.event["areServicesAvailable"] = False
131
+ return self
132
+
133
+ def set_nrcc_messages(self, messages: list[str]):
134
+ self.event["nrccMessages"] = [{"Value": message} for message in messages]
135
+ return self
136
+
137
+ def build(self):
138
+ return self.event