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.
- pyrail_uk-0.1.0/PKG-INFO +37 -0
- pyrail_uk-0.1.0/README.md +8 -0
- pyrail_uk-0.1.0/pyproject.toml +51 -0
- pyrail_uk-0.1.0/pyrail_uk/NationalRail.py +38 -0
- pyrail_uk-0.1.0/pyrail_uk/__init__.py +0 -0
- pyrail_uk-0.1.0/pyrail_uk/api/__init__.py +0 -0
- pyrail_uk-0.1.0/pyrail_uk/api/client.py +29 -0
- pyrail_uk-0.1.0/pyrail_uk/api/departures.py +28 -0
- pyrail_uk-0.1.0/pyrail_uk/api/referencedata.py +35 -0
- pyrail_uk-0.1.0/pyrail_uk/api/types.py +92 -0
- pyrail_uk-0.1.0/pyrail_uk/api/url.py +39 -0
- pyrail_uk-0.1.0/pyrail_uk/core/__init__.py +0 -0
- pyrail_uk-0.1.0/pyrail_uk/core/exceptions.py +10 -0
- pyrail_uk-0.1.0/pyrail_uk/service/__init__.py +0 -0
- pyrail_uk-0.1.0/pyrail_uk/service/departures.py +117 -0
- pyrail_uk-0.1.0/pyrail_uk/service/stations.py +21 -0
- pyrail_uk-0.1.0/pyrail_uk/service/types.py +57 -0
- pyrail_uk-0.1.0/pyrail_uk/utils/__init__.py +0 -0
- pyrail_uk-0.1.0/pyrail_uk/utils/array.py +58 -0
- pyrail_uk-0.1.0/pyrail_uk.egg-info/PKG-INFO +37 -0
- pyrail_uk-0.1.0/pyrail_uk.egg-info/SOURCES.txt +31 -0
- pyrail_uk-0.1.0/pyrail_uk.egg-info/dependency_links.txt +1 -0
- pyrail_uk-0.1.0/pyrail_uk.egg-info/requires.txt +19 -0
- pyrail_uk-0.1.0/pyrail_uk.egg-info/top_level.txt +3 -0
- pyrail_uk-0.1.0/setup.cfg +4 -0
- pyrail_uk-0.1.0/tests/pyrail_uk/__init__.py +0 -0
- pyrail_uk-0.1.0/tests/pyrail_uk/api/__init__.py +0 -0
- pyrail_uk-0.1.0/tests/pyrail_uk/api/test_url.py +16 -0
- pyrail_uk-0.1.0/tests/pyrail_uk/service/__init__.py +0 -0
- pyrail_uk-0.1.0/tests/pyrail_uk/service/test_departures.py +148 -0
- pyrail_uk-0.1.0/tests/pyrail_uk/service/test_departures_integration.py +115 -0
- pyrail_uk-0.1.0/tests/pyrail_uk/service/test_stations.py +31 -0
- pyrail_uk-0.1.0/tests/pyrail_uk/service/testutils.py +138 -0
pyrail_uk-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
|
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 @@
|
|
|
1
|
+
|
|
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
|