python-openpublictransport 0.1.7__tar.gz → 0.1.9__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.
- {python_openpublictransport-0.1.7 → python_openpublictransport-0.1.9}/PKG-INFO +1 -1
- {python_openpublictransport-0.1.7 → python_openpublictransport-0.1.9}/pyproject.toml +1 -1
- {python_openpublictransport-0.1.7 → python_openpublictransport-0.1.9}/src/openpublictransport/providers/national_rail.py +26 -7
- {python_openpublictransport-0.1.7 → python_openpublictransport-0.1.9}/src/python_openpublictransport.egg-info/PKG-INFO +1 -1
- {python_openpublictransport-0.1.7 → python_openpublictransport-0.1.9}/src/python_openpublictransport.egg-info/SOURCES.txt +3 -1
- python_openpublictransport-0.1.9/tests/test_national_rail.py +91 -0
- python_openpublictransport-0.1.9/tests/test_national_rail_parse.py +92 -0
- {python_openpublictransport-0.1.7 → python_openpublictransport-0.1.9}/setup.cfg +0 -0
- {python_openpublictransport-0.1.7 → python_openpublictransport-0.1.9}/src/openpublictransport/__init__.py +0 -0
- {python_openpublictransport-0.1.7 → python_openpublictransport-0.1.9}/src/openpublictransport/const.py +0 -0
- {python_openpublictransport-0.1.7 → python_openpublictransport-0.1.9}/src/openpublictransport/exceptions.py +0 -0
- {python_openpublictransport-0.1.7 → python_openpublictransport-0.1.9}/src/openpublictransport/models.py +0 -0
- {python_openpublictransport-0.1.7 → python_openpublictransport-0.1.9}/src/openpublictransport/parsers.py +0 -0
- {python_openpublictransport-0.1.7 → python_openpublictransport-0.1.9}/src/openpublictransport/providers/__init__.py +0 -0
- {python_openpublictransport-0.1.7 → python_openpublictransport-0.1.9}/src/openpublictransport/providers/avv.py +0 -0
- {python_openpublictransport-0.1.7 → python_openpublictransport-0.1.9}/src/openpublictransport/providers/base.py +0 -0
- {python_openpublictransport-0.1.7 → python_openpublictransport-0.1.9}/src/openpublictransport/providers/beg.py +0 -0
- {python_openpublictransport-0.1.7 → python_openpublictransport-0.1.9}/src/openpublictransport/providers/bsvg.py +0 -0
- {python_openpublictransport-0.1.7 → python_openpublictransport-0.1.9}/src/openpublictransport/providers/bvg.py +0 -0
- {python_openpublictransport-0.1.7 → python_openpublictransport-0.1.9}/src/openpublictransport/providers/db.py +0 -0
- {python_openpublictransport-0.1.7 → python_openpublictransport-0.1.9}/src/openpublictransport/providers/ding.py +0 -0
- {python_openpublictransport-0.1.7 → python_openpublictransport-0.1.9}/src/openpublictransport/providers/efa_base.py +0 -0
- {python_openpublictransport-0.1.7 → python_openpublictransport-0.1.9}/src/openpublictransport/providers/fptf_base.py +0 -0
- {python_openpublictransport-0.1.7 → python_openpublictransport-0.1.9}/src/openpublictransport/providers/gtfsde.py +0 -0
- {python_openpublictransport-0.1.7 → python_openpublictransport-0.1.9}/src/openpublictransport/providers/hvv.py +0 -0
- {python_openpublictransport-0.1.7 → python_openpublictransport-0.1.9}/src/openpublictransport/providers/kvv.py +0 -0
- {python_openpublictransport-0.1.7 → python_openpublictransport-0.1.9}/src/openpublictransport/providers/mvv.py +0 -0
- {python_openpublictransport-0.1.7 → python_openpublictransport-0.1.9}/src/openpublictransport/providers/nta.py +0 -0
- {python_openpublictransport-0.1.7 → python_openpublictransport-0.1.9}/src/openpublictransport/providers/nvbw.py +0 -0
- {python_openpublictransport-0.1.7 → python_openpublictransport-0.1.9}/src/openpublictransport/providers/nwl.py +0 -0
- {python_openpublictransport-0.1.7 → python_openpublictransport-0.1.9}/src/openpublictransport/providers/oebb.py +0 -0
- {python_openpublictransport-0.1.7 → python_openpublictransport-0.1.9}/src/openpublictransport/providers/otp.py +0 -0
- {python_openpublictransport-0.1.7 → python_openpublictransport-0.1.9}/src/openpublictransport/providers/otp_base.py +0 -0
- {python_openpublictransport-0.1.7 → python_openpublictransport-0.1.9}/src/openpublictransport/providers/otp_custom.py +0 -0
- {python_openpublictransport-0.1.7 → python_openpublictransport-0.1.9}/src/openpublictransport/providers/rejseplanen.py +0 -0
- {python_openpublictransport-0.1.7 → python_openpublictransport-0.1.9}/src/openpublictransport/providers/rmv.py +0 -0
- {python_openpublictransport-0.1.7 → python_openpublictransport-0.1.9}/src/openpublictransport/providers/rvv.py +0 -0
- {python_openpublictransport-0.1.7 → python_openpublictransport-0.1.9}/src/openpublictransport/providers/sbb.py +0 -0
- {python_openpublictransport-0.1.7 → python_openpublictransport-0.1.9}/src/openpublictransport/providers/trafiklab.py +0 -0
- {python_openpublictransport-0.1.7 → python_openpublictransport-0.1.9}/src/openpublictransport/providers/transitous.py +0 -0
- {python_openpublictransport-0.1.7 → python_openpublictransport-0.1.9}/src/openpublictransport/providers/trias_base.py +0 -0
- {python_openpublictransport-0.1.7 → python_openpublictransport-0.1.9}/src/openpublictransport/providers/vagfr.py +0 -0
- {python_openpublictransport-0.1.7 → python_openpublictransport-0.1.9}/src/openpublictransport/providers/vbn.py +0 -0
- {python_openpublictransport-0.1.7 → python_openpublictransport-0.1.9}/src/openpublictransport/providers/vgn.py +0 -0
- {python_openpublictransport-0.1.7 → python_openpublictransport-0.1.9}/src/openpublictransport/providers/vrn.py +0 -0
- {python_openpublictransport-0.1.7 → python_openpublictransport-0.1.9}/src/openpublictransport/providers/vrr.py +0 -0
- {python_openpublictransport-0.1.7 → python_openpublictransport-0.1.9}/src/openpublictransport/providers/vvo.py +0 -0
- {python_openpublictransport-0.1.7 → python_openpublictransport-0.1.9}/src/openpublictransport/providers/vvs.py +0 -0
- {python_openpublictransport-0.1.7 → python_openpublictransport-0.1.9}/src/openpublictransport/py.typed +0 -0
- {python_openpublictransport-0.1.7 → python_openpublictransport-0.1.9}/src/python_openpublictransport.egg-info/dependency_links.txt +0 -0
- {python_openpublictransport-0.1.7 → python_openpublictransport-0.1.9}/src/python_openpublictransport.egg-info/requires.txt +0 -0
- {python_openpublictransport-0.1.7 → python_openpublictransport-0.1.9}/src/python_openpublictransport.egg-info/top_level.txt +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "python-openpublictransport"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.9"
|
|
8
8
|
description = "Python library for public transport APIs (EFA, OTP2, TRIAS, FPTF, HAFAS, GTFS-RT)"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT" }
|
|
@@ -46,10 +46,17 @@ _SOAP_TEMPLATE = """<?xml version="1.0" encoding="UTF-8"?>
|
|
|
46
46
|
|
|
47
47
|
|
|
48
48
|
def _strip_namespaces(xml_string: str) -> str:
|
|
49
|
-
"""Remove XML namespace prefixes and declarations for simpler parsing.
|
|
49
|
+
"""Remove XML namespace prefixes and declarations for simpler parsing.
|
|
50
|
+
|
|
51
|
+
Real OpenLDBWS responses use namespace prefixes that contain digits
|
|
52
|
+
(``lt4:``, ``lt5:``, ``lt7:``, ``lt8:`` …). The prefix pattern must
|
|
53
|
+
therefore allow digits — matching only ``[a-zA-Z]+`` leaves those tags
|
|
54
|
+
prefixed after their xmlns declarations have been stripped, producing an
|
|
55
|
+
"unbound prefix" XML parse error and an empty departure board.
|
|
56
|
+
"""
|
|
50
57
|
xml_string = re.sub(r' xmlns[^=]*="[^"]*"', "", xml_string)
|
|
51
|
-
xml_string = re.sub(r"<([
|
|
52
|
-
xml_string = re.sub(r"</([
|
|
58
|
+
xml_string = re.sub(r"<([A-Za-z][\w.\-]*):", "<", xml_string)
|
|
59
|
+
xml_string = re.sub(r"</([A-Za-z][\w.\-]*):", "</", xml_string)
|
|
53
60
|
return xml_string
|
|
54
61
|
|
|
55
62
|
|
|
@@ -274,10 +281,22 @@ class NationalRailProvider(BaseProvider):
|
|
|
274
281
|
)
|
|
275
282
|
|
|
276
283
|
async def search_stops(self, search_term: str) -> List[Dict[str, Any]]:
|
|
277
|
-
"""Find UK railway stations with CRS codes via Overpass API (OSM).
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
284
|
+
"""Find UK railway stations with CRS codes via Overpass API (OSM).
|
|
285
|
+
|
|
286
|
+
A search term that looks like a 3-letter CRS code (e.g. "WIN", "RDG")
|
|
287
|
+
is matched directly against the ``ref:crs`` tag; anything else is
|
|
288
|
+
matched against the station name(s).
|
|
289
|
+
"""
|
|
290
|
+
term = search_term.strip()
|
|
291
|
+
|
|
292
|
+
if re.fullmatch(r"[A-Za-z]{3}", term):
|
|
293
|
+
# Direct CRS code lookup (ref:crs is stored uppercase in OSM)
|
|
294
|
+
query = f"""[out:json][timeout:15];
|
|
295
|
+
node["railway"="station"]["ref:crs"="{term.upper()}"]({_UK_BBOX});
|
|
296
|
+
out 10;"""
|
|
297
|
+
else:
|
|
298
|
+
escaped = re.sub(r"[.*+?^${}()|[\]\\]", r"\\\g<0>", term)
|
|
299
|
+
query = f"""[out:json][timeout:15];
|
|
281
300
|
(
|
|
282
301
|
node["railway"="station"]["ref:crs"]["name"~"{escaped}",i]({_UK_BBOX});
|
|
283
302
|
node["railway"="station"]["ref:crs"]["official_name"~"{escaped}",i]({_UK_BBOX});
|
|
@@ -45,4 +45,6 @@ src/python_openpublictransport.egg-info/PKG-INFO
|
|
|
45
45
|
src/python_openpublictransport.egg-info/SOURCES.txt
|
|
46
46
|
src/python_openpublictransport.egg-info/dependency_links.txt
|
|
47
47
|
src/python_openpublictransport.egg-info/requires.txt
|
|
48
|
-
src/python_openpublictransport.egg-info/top_level.txt
|
|
48
|
+
src/python_openpublictransport.egg-info/top_level.txt
|
|
49
|
+
tests/test_national_rail.py
|
|
50
|
+
tests/test_national_rail_parse.py
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Tests for the National Rail (UK) provider stop search.
|
|
2
|
+
|
|
3
|
+
Focus: a 3-letter CRS code (e.g. "WIN") must be looked up directly against the
|
|
4
|
+
OSM ``ref:crs`` tag, while a longer term is matched against station names.
|
|
5
|
+
Regression test for https://github.com/NerdySoftPaw/openpublictransport/issues/39
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from openpublictransport.providers.national_rail import NationalRailProvider
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class _FakeResponse:
|
|
14
|
+
def __init__(self, payload):
|
|
15
|
+
self._payload = payload
|
|
16
|
+
self.status = 200
|
|
17
|
+
|
|
18
|
+
async def __aenter__(self):
|
|
19
|
+
return self
|
|
20
|
+
|
|
21
|
+
async def __aexit__(self, *exc):
|
|
22
|
+
return False
|
|
23
|
+
|
|
24
|
+
async def json(self, content_type=None):
|
|
25
|
+
return self._payload
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class _FakeSession:
|
|
29
|
+
"""Records the Overpass query and returns a canned payload."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, payload):
|
|
32
|
+
self._payload = payload
|
|
33
|
+
self.last_query = None
|
|
34
|
+
|
|
35
|
+
def post(self, url, data=None, headers=None, timeout=None):
|
|
36
|
+
# The Overpass query is sent as form data under the "data" key.
|
|
37
|
+
self.last_query = (data or {}).get("data", "")
|
|
38
|
+
return _FakeResponse(self._payload)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
_PAYLOAD = {
|
|
42
|
+
"elements": [
|
|
43
|
+
{
|
|
44
|
+
"type": "node",
|
|
45
|
+
"tags": {
|
|
46
|
+
"railway": "station",
|
|
47
|
+
"ref:crs": "WIN",
|
|
48
|
+
"name": "Winchester",
|
|
49
|
+
"operator": "South Western Railway",
|
|
50
|
+
},
|
|
51
|
+
}
|
|
52
|
+
]
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
async def _search(term):
|
|
57
|
+
session = _FakeSession(_PAYLOAD)
|
|
58
|
+
provider = NationalRailProvider(session)
|
|
59
|
+
results = await provider.search_stops(term)
|
|
60
|
+
return results, session.last_query
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
async def test_crs_code_search_queries_ref_crs():
|
|
64
|
+
"""A 3-letter code searches ref:crs directly, not the name."""
|
|
65
|
+
results, query = await _search("WIN")
|
|
66
|
+
|
|
67
|
+
assert '["ref:crs"="WIN"]' in query
|
|
68
|
+
assert '"name"~' not in query
|
|
69
|
+
|
|
70
|
+
assert results == [
|
|
71
|
+
{
|
|
72
|
+
"id": "WIN",
|
|
73
|
+
"name": "Winchester",
|
|
74
|
+
"place": "South Western Railway",
|
|
75
|
+
"area_type": "stop",
|
|
76
|
+
}
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
async def test_lowercase_code_is_uppercased():
|
|
81
|
+
"""CRS codes are case-insensitive and normalised to uppercase."""
|
|
82
|
+
_, query = await _search("win")
|
|
83
|
+
assert '["ref:crs"="WIN"]' in query
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
async def test_name_search_queries_name_tags():
|
|
87
|
+
"""A longer term matches the name tags, not an exact CRS code."""
|
|
88
|
+
_, query = await _search("Winchester")
|
|
89
|
+
|
|
90
|
+
assert '"name"~"Winchester"' in query
|
|
91
|
+
assert '["ref:crs"="' not in query
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Tests for National Rail (UK) OpenLDBWS response parsing.
|
|
2
|
+
|
|
3
|
+
Regression test for the departure board coming back empty ("Invalid or empty
|
|
4
|
+
API response") for every user: real OpenLDBWS responses use namespace prefixes
|
|
5
|
+
containing digits (`lt4:`, `lt5:`, `lt7:` …), which the namespace-stripping
|
|
6
|
+
regex did not handle, causing an "unbound prefix" XML parse error.
|
|
7
|
+
See https://github.com/NerdySoftPaw/openpublictransport/issues/39
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from xml.etree import ElementTree as ET
|
|
12
|
+
from zoneinfo import ZoneInfo
|
|
13
|
+
|
|
14
|
+
import openpublictransport.providers.national_rail as nr
|
|
15
|
+
|
|
16
|
+
# A realistic OpenLDBWS GetDepartureBoardResponse: digit-bearing prefixes
|
|
17
|
+
# (lt4/lt5/lt7) and their xmlns declarations, exactly as the live service emits.
|
|
18
|
+
SAMPLE = """<?xml version="1.0" encoding="utf-8"?>
|
|
19
|
+
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
|
|
20
|
+
<soap:Body>
|
|
21
|
+
<GetDepartureBoardResponse xmlns="http://thalesgroup.com/RTTI/2017-10-01/ldb/">
|
|
22
|
+
<GetStationBoardResult xmlns:lt="http://thalesgroup.com/RTTI/2012-01-13/ldb/types" xmlns:lt4="http://thalesgroup.com/RTTI/2015-11-27/ldb/types" xmlns:lt5="http://thalesgroup.com/RTTI/2016-02-16/ldb/types" xmlns:lt7="http://thalesgroup.com/RTTI/2017-02-02/ldb/types">
|
|
23
|
+
<lt4:locationName>Winchester</lt4:locationName>
|
|
24
|
+
<lt4:crs>WIN</lt4:crs>
|
|
25
|
+
<lt7:trainServices>
|
|
26
|
+
<lt7:service>
|
|
27
|
+
<lt4:std>18:05</lt4:std>
|
|
28
|
+
<lt4:etd>On time</lt4:etd>
|
|
29
|
+
<lt4:platform>2</lt4:platform>
|
|
30
|
+
<lt4:operator>South Western Railway</lt4:operator>
|
|
31
|
+
<lt4:operatorCode>SW</lt4:operatorCode>
|
|
32
|
+
<lt5:destination>
|
|
33
|
+
<lt4:location>
|
|
34
|
+
<lt4:locationName>London Waterloo</lt4:locationName>
|
|
35
|
+
<lt4:crs>WAT</lt4:crs>
|
|
36
|
+
</lt4:location>
|
|
37
|
+
</lt5:destination>
|
|
38
|
+
</lt7:service>
|
|
39
|
+
<lt7:service>
|
|
40
|
+
<lt4:std>18:20</lt4:std>
|
|
41
|
+
<lt4:etd>18:27</lt4:etd>
|
|
42
|
+
<lt4:operator>Great Western Railway</lt4:operator>
|
|
43
|
+
<lt4:operatorCode>GW</lt4:operatorCode>
|
|
44
|
+
<lt5:destination>
|
|
45
|
+
<lt4:location>
|
|
46
|
+
<lt4:locationName>Southampton Central</lt4:locationName>
|
|
47
|
+
<lt4:crs>SOU</lt4:crs>
|
|
48
|
+
</lt4:location>
|
|
49
|
+
</lt5:destination>
|
|
50
|
+
</lt7:service>
|
|
51
|
+
</lt7:trainServices>
|
|
52
|
+
</GetStationBoardResult>
|
|
53
|
+
</GetDepartureBoardResponse>
|
|
54
|
+
</soap:Body>
|
|
55
|
+
</soap:Envelope>"""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_strip_namespaces_handles_digit_prefixes():
|
|
59
|
+
"""Digit-bearing prefixes must be stripped so the XML parses."""
|
|
60
|
+
stripped = nr._strip_namespaces(SAMPLE)
|
|
61
|
+
# No prefixed tags and no dangling xmlns declarations should remain.
|
|
62
|
+
assert "lt4:" not in stripped
|
|
63
|
+
assert "lt7:" not in stripped
|
|
64
|
+
assert "xmlns" not in stripped
|
|
65
|
+
# Must now be parseable (previously raised "unbound prefix").
|
|
66
|
+
root = ET.fromstring(stripped)
|
|
67
|
+
assert len(root.findall(".//service")) == 2
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_parses_services_into_departures():
|
|
71
|
+
"""The full parse path yields the expected departures."""
|
|
72
|
+
root = ET.fromstring(nr._strip_namespaces(SAMPLE))
|
|
73
|
+
provider = nr.NationalRailProvider(session=None)
|
|
74
|
+
tz = ZoneInfo("Europe/London")
|
|
75
|
+
now = datetime(2026, 7, 2, 18, 0, tzinfo=tz)
|
|
76
|
+
|
|
77
|
+
deps = []
|
|
78
|
+
for svc in root.findall(".//service"):
|
|
79
|
+
d = provider._service_to_dict(svc, now.date(), tz, now)
|
|
80
|
+
assert d is not None
|
|
81
|
+
deps.append(provider.parse_departure(d, tz, now))
|
|
82
|
+
|
|
83
|
+
assert deps[0].destination == "London Waterloo"
|
|
84
|
+
assert deps[0].planned_time == "18:05"
|
|
85
|
+
assert deps[0].platform == "2"
|
|
86
|
+
assert deps[0].line == "SW"
|
|
87
|
+
assert deps[0].delay == 0
|
|
88
|
+
|
|
89
|
+
# Second service is estimated 7 minutes late (18:05 std -> 18:27 etd on 18:20).
|
|
90
|
+
assert deps[1].destination == "Southampton Central"
|
|
91
|
+
assert deps[1].delay == 7
|
|
92
|
+
assert deps[1].is_realtime is True
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|