python-openpublictransport 0.1.10__tar.gz → 0.1.12__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.10 → python_openpublictransport-0.1.12}/PKG-INFO +1 -1
- {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/pyproject.toml +1 -1
- {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/openpublictransport/providers/national_rail.py +64 -17
- {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/python_openpublictransport.egg-info/PKG-INFO +1 -1
- {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/tests/test_national_rail_parse.py +7 -0
- {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/tests/test_national_rail_static.py +16 -5
- {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/setup.cfg +0 -0
- {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/openpublictransport/__init__.py +0 -0
- {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/openpublictransport/const.py +0 -0
- {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/openpublictransport/data/uk_stations.json +0 -0
- {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/openpublictransport/exceptions.py +0 -0
- {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/openpublictransport/models.py +0 -0
- {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/openpublictransport/parsers.py +0 -0
- {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/openpublictransport/providers/__init__.py +0 -0
- {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/openpublictransport/providers/avv.py +0 -0
- {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/openpublictransport/providers/base.py +0 -0
- {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/openpublictransport/providers/beg.py +0 -0
- {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/openpublictransport/providers/bsvg.py +0 -0
- {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/openpublictransport/providers/bvg.py +0 -0
- {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/openpublictransport/providers/db.py +0 -0
- {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/openpublictransport/providers/ding.py +0 -0
- {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/openpublictransport/providers/efa_base.py +0 -0
- {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/openpublictransport/providers/fptf_base.py +0 -0
- {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/openpublictransport/providers/gtfsde.py +0 -0
- {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/openpublictransport/providers/hvv.py +0 -0
- {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/openpublictransport/providers/kvv.py +0 -0
- {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/openpublictransport/providers/mvv.py +0 -0
- {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/openpublictransport/providers/nta.py +0 -0
- {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/openpublictransport/providers/nvbw.py +0 -0
- {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/openpublictransport/providers/nwl.py +0 -0
- {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/openpublictransport/providers/oebb.py +0 -0
- {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/openpublictransport/providers/otp.py +0 -0
- {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/openpublictransport/providers/otp_base.py +0 -0
- {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/openpublictransport/providers/otp_custom.py +0 -0
- {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/openpublictransport/providers/rejseplanen.py +0 -0
- {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/openpublictransport/providers/rmv.py +0 -0
- {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/openpublictransport/providers/rvv.py +0 -0
- {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/openpublictransport/providers/sbb.py +0 -0
- {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/openpublictransport/providers/trafiklab.py +0 -0
- {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/openpublictransport/providers/transitous.py +0 -0
- {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/openpublictransport/providers/trias_base.py +0 -0
- {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/openpublictransport/providers/vagfr.py +0 -0
- {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/openpublictransport/providers/vbn.py +0 -0
- {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/openpublictransport/providers/vgn.py +0 -0
- {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/openpublictransport/providers/vrn.py +0 -0
- {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/openpublictransport/providers/vrr.py +0 -0
- {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/openpublictransport/providers/vvo.py +0 -0
- {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/openpublictransport/providers/vvs.py +0 -0
- {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/openpublictransport/py.typed +0 -0
- {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/python_openpublictransport.egg-info/SOURCES.txt +0 -0
- {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/python_openpublictransport.egg-info/dependency_links.txt +0 -0
- {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/python_openpublictransport.egg-info/requires.txt +0 -0
- {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/python_openpublictransport.egg-info/top_level.txt +0 -0
- {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/tests/test_national_rail.py +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.12"
|
|
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" }
|
|
@@ -4,6 +4,7 @@ Stop search uses the Overpass API (OSM) to find UK railway stations with
|
|
|
4
4
|
ref:crs tags, returning the 3-letter CRS code used by OpenLDBWS.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
+
import asyncio
|
|
7
8
|
import json
|
|
8
9
|
import logging
|
|
9
10
|
import re
|
|
@@ -22,8 +23,16 @@ from .base import BaseProvider
|
|
|
22
23
|
|
|
23
24
|
_LOGGER = logging.getLogger(__name__)
|
|
24
25
|
|
|
25
|
-
|
|
26
|
-
|
|
26
|
+
# Use the same LDBWS version triple as the reference OpenLDBWS clients:
|
|
27
|
+
# endpoint = ldb12.asmx (WSDL ver=2021-11-01)
|
|
28
|
+
# body ns = 2021-11-01/ldb/
|
|
29
|
+
# SOAPAction= 2012-01-13/ldb/GetDepartureBoard
|
|
30
|
+
# The SOAPAction is version-pinned to 2012-01-13 (it does NOT track the body's
|
|
31
|
+
# ldb version); sending a mismatched SOAPAction makes the ASMX service reject the
|
|
32
|
+
# request with an HTTP 500 SOAP fault ("did not recognize the value of the
|
|
33
|
+
# SOAPAction header").
|
|
34
|
+
_ENDPOINT = "https://lite.realtime.nationalrail.co.uk/OpenLDBWS/ldb12.asmx"
|
|
35
|
+
_SOAP_ACTION = "http://thalesgroup.com/RTTI/2012-01-13/ldb/GetDepartureBoard"
|
|
27
36
|
_OVERPASS_URL = "https://overpass-api.de/api/interpreter"
|
|
28
37
|
|
|
29
38
|
# UK bounding box (lat_min, lon_min, lat_max, lon_max)
|
|
@@ -31,7 +40,7 @@ _UK_BBOX = "49,-11,62,2"
|
|
|
31
40
|
|
|
32
41
|
_SOAP_TEMPLATE = """<?xml version="1.0" encoding="UTF-8"?>
|
|
33
42
|
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/"
|
|
34
|
-
xmlns:ldb="http://thalesgroup.com/RTTI/
|
|
43
|
+
xmlns:ldb="http://thalesgroup.com/RTTI/2021-11-01/ldb/"
|
|
35
44
|
xmlns:tok="http://thalesgroup.com/RTTI/2013-11-28/Token/types">
|
|
36
45
|
<soap:Header>
|
|
37
46
|
<tok:AccessToken>
|
|
@@ -81,6 +90,16 @@ def _static_result(station: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
81
90
|
}
|
|
82
91
|
|
|
83
92
|
|
|
93
|
+
def _soap_fault_string(body: str) -> str:
|
|
94
|
+
"""Best-effort extraction of a SOAP fault reason from an error response."""
|
|
95
|
+
if not body:
|
|
96
|
+
return ""
|
|
97
|
+
m = re.search(r"<(?:\w+:)?(?:faultstring|Text|Reason)[^>]*>(.*?)</", body, re.DOTALL | re.IGNORECASE)
|
|
98
|
+
if m:
|
|
99
|
+
return m.group(1).strip()[:200]
|
|
100
|
+
return body.strip()[:200]
|
|
101
|
+
|
|
102
|
+
|
|
84
103
|
def _strip_namespaces(xml_string: str) -> str:
|
|
85
104
|
"""Remove XML namespace prefixes and declarations for simpler parsing.
|
|
86
105
|
|
|
@@ -167,7 +186,21 @@ class NationalRailProvider(BaseProvider):
|
|
|
167
186
|
f"{self.provider_name}: authentication failed (HTTP {resp.status}) — check API key"
|
|
168
187
|
)
|
|
169
188
|
if resp.status != 200:
|
|
170
|
-
|
|
189
|
+
# Surface the SOAP fault reason (HTTP 500) instead of hiding it,
|
|
190
|
+
# so genuine request/token faults are diagnosable.
|
|
191
|
+
body = ""
|
|
192
|
+
try:
|
|
193
|
+
body = await resp.text()
|
|
194
|
+
except Exception: # pragma: no cover - defensive
|
|
195
|
+
pass
|
|
196
|
+
fault = _soap_fault_string(body)
|
|
197
|
+
_LOGGER.warning(
|
|
198
|
+
"%s: HTTP %s for CRS %s%s",
|
|
199
|
+
self.provider_name,
|
|
200
|
+
resp.status,
|
|
201
|
+
crs,
|
|
202
|
+
f" — {fault}" if fault else "",
|
|
203
|
+
)
|
|
171
204
|
return None
|
|
172
205
|
text = await resp.text()
|
|
173
206
|
except Exception as exc:
|
|
@@ -320,8 +353,11 @@ class NationalRailProvider(BaseProvider):
|
|
|
320
353
|
"""Find UK railway stations with their CRS codes.
|
|
321
354
|
|
|
322
355
|
The live Overpass API (OpenStreetMap) is the source of truth. Only if it
|
|
323
|
-
is unreachable
|
|
324
|
-
bundled offline snapshot — see
|
|
356
|
+
is unreachable or rate-limited (a *failure*, not a successful empty
|
|
357
|
+
result) do we fall back to a bundled offline snapshot — see
|
|
358
|
+
:func:`_load_static_stations`. When Overpass succeeds and legitimately
|
|
359
|
+
finds nothing, we return that empty result rather than serving
|
|
360
|
+
potentially out-of-date snapshot data.
|
|
325
361
|
|
|
326
362
|
A search term that looks like a 3-letter CRS code (e.g. "WIN", "RDG")
|
|
327
363
|
is matched directly against the CRS code; anything else is matched
|
|
@@ -332,14 +368,15 @@ class NationalRailProvider(BaseProvider):
|
|
|
332
368
|
return []
|
|
333
369
|
|
|
334
370
|
results = await self._search_overpass(term)
|
|
335
|
-
if results:
|
|
371
|
+
if results is not None:
|
|
372
|
+
# Overpass answered authoritatively (possibly with zero matches).
|
|
336
373
|
return results
|
|
337
374
|
|
|
338
|
-
# Overpass
|
|
339
|
-
fallback = self._search_static(term)
|
|
375
|
+
# Overpass failed (unreachable / throttled) → use the offline snapshot.
|
|
376
|
+
fallback = await self._search_static(term)
|
|
340
377
|
if fallback:
|
|
341
378
|
_LOGGER.info(
|
|
342
|
-
"%s: Overpass
|
|
379
|
+
"%s: Overpass unavailable for %r — served %d result(s) from the "
|
|
343
380
|
"bundled station snapshot (may be out of date)",
|
|
344
381
|
self.provider_name,
|
|
345
382
|
term,
|
|
@@ -347,9 +384,14 @@ class NationalRailProvider(BaseProvider):
|
|
|
347
384
|
)
|
|
348
385
|
return fallback
|
|
349
386
|
|
|
350
|
-
def _search_static(self, term: str) -> List[Dict[str, Any]]:
|
|
351
|
-
"""Match ``term`` against the bundled offline station snapshot.
|
|
352
|
-
|
|
387
|
+
async def _search_static(self, term: str) -> List[Dict[str, Any]]:
|
|
388
|
+
"""Match ``term`` against the bundled offline station snapshot.
|
|
389
|
+
|
|
390
|
+
The snapshot is read from disk on first use; that read is dispatched to
|
|
391
|
+
an executor so it never blocks the event loop (results are cached, so
|
|
392
|
+
subsequent calls do no I/O).
|
|
393
|
+
"""
|
|
394
|
+
stations = await asyncio.get_running_loop().run_in_executor(None, _load_static_stations)
|
|
353
395
|
if not stations:
|
|
354
396
|
return []
|
|
355
397
|
|
|
@@ -369,8 +411,13 @@ class NationalRailProvider(BaseProvider):
|
|
|
369
411
|
break
|
|
370
412
|
return results[:10]
|
|
371
413
|
|
|
372
|
-
async def _search_overpass(self, term: str) -> List[Dict[str, Any]]:
|
|
373
|
-
"""Query the live Overpass API for UK stations matching ``term``.
|
|
414
|
+
async def _search_overpass(self, term: str) -> Optional[List[Dict[str, Any]]]:
|
|
415
|
+
"""Query the live Overpass API for UK stations matching ``term``.
|
|
416
|
+
|
|
417
|
+
Returns a (possibly empty) list on a successful query, or ``None`` if
|
|
418
|
+
the request failed (non-200 / exception) so the caller can decide
|
|
419
|
+
whether to fall back to the offline snapshot.
|
|
420
|
+
"""
|
|
374
421
|
if re.fullmatch(r"[A-Za-z]{3}", term):
|
|
375
422
|
# Direct CRS code lookup (ref:crs is stored uppercase in OSM)
|
|
376
423
|
query = f"""[out:json][timeout:15];
|
|
@@ -397,11 +444,11 @@ out 10;"""
|
|
|
397
444
|
) as resp:
|
|
398
445
|
if resp.status != 200:
|
|
399
446
|
_LOGGER.warning("%s: Overpass HTTP %s", self.provider_name, resp.status)
|
|
400
|
-
return
|
|
447
|
+
return None
|
|
401
448
|
data = await resp.json(content_type=None)
|
|
402
449
|
except Exception as exc:
|
|
403
450
|
_LOGGER.warning("%s: stop search failed: %s", self.provider_name, exc)
|
|
404
|
-
return
|
|
451
|
+
return None
|
|
405
452
|
|
|
406
453
|
results = []
|
|
407
454
|
for element in data.get("elements", []):
|
|
@@ -55,6 +55,13 @@ SAMPLE = """<?xml version="1.0" encoding="utf-8"?>
|
|
|
55
55
|
</soap:Envelope>"""
|
|
56
56
|
|
|
57
57
|
|
|
58
|
+
def test_ldbws_request_uses_consistent_version_triple():
|
|
59
|
+
"""SOAPAction must be the 2012-01-13 value; a mismatch yields HTTP 500."""
|
|
60
|
+
assert nr._ENDPOINT.endswith("ldb12.asmx")
|
|
61
|
+
assert nr._SOAP_ACTION == "http://thalesgroup.com/RTTI/2012-01-13/ldb/GetDepartureBoard"
|
|
62
|
+
assert 'xmlns:ldb="http://thalesgroup.com/RTTI/2021-11-01/ldb/"' in nr._SOAP_TEMPLATE
|
|
63
|
+
|
|
64
|
+
|
|
58
65
|
def test_strip_namespaces_handles_digit_prefixes():
|
|
59
66
|
"""Digit-bearing prefixes must be stripped so the XML parses."""
|
|
60
67
|
stripped = nr._strip_namespaces(SAMPLE)
|
|
@@ -34,6 +34,8 @@ class _Session:
|
|
|
34
34
|
self.calls += 1
|
|
35
35
|
if self.mode == "down":
|
|
36
36
|
return _Resp(429, None)
|
|
37
|
+
if self.mode == "empty":
|
|
38
|
+
return _Resp(200, {"elements": []})
|
|
37
39
|
payload = {
|
|
38
40
|
"elements": [
|
|
39
41
|
{"tags": {"railway": "station", "ref:crs": "WIN",
|
|
@@ -45,23 +47,23 @@ class _Session:
|
|
|
45
47
|
|
|
46
48
|
def test_snapshot_loads_and_has_known_stations():
|
|
47
49
|
stations = nr._load_static_stations()
|
|
48
|
-
assert
|
|
50
|
+
assert stations # loads and is non-empty
|
|
49
51
|
by_crs = {s["crs"]: s for s in stations}
|
|
50
52
|
assert by_crs["WIN"]["name"] == "Winchester"
|
|
51
53
|
assert by_crs["RDG"]["name"] == "Reading"
|
|
52
54
|
|
|
53
55
|
|
|
54
|
-
def test_static_search_by_crs_code():
|
|
56
|
+
async def test_static_search_by_crs_code():
|
|
55
57
|
provider = NationalRailProvider(_Session("down"))
|
|
56
|
-
results = provider._search_static("rdg")
|
|
58
|
+
results = await provider._search_static("rdg")
|
|
57
59
|
assert results == [
|
|
58
60
|
{"id": "RDG", "name": "Reading", "place": "Network Rail", "area_type": "stop"}
|
|
59
61
|
]
|
|
60
62
|
|
|
61
63
|
|
|
62
|
-
def test_static_search_by_name():
|
|
64
|
+
async def test_static_search_by_name():
|
|
63
65
|
provider = NationalRailProvider(_Session("down"))
|
|
64
|
-
results = provider._search_static("winchester")
|
|
66
|
+
results = await provider._search_static("winchester")
|
|
65
67
|
assert any(r["id"] == "WIN" and r["name"] == "Winchester" for r in results)
|
|
66
68
|
|
|
67
69
|
|
|
@@ -73,6 +75,15 @@ async def test_overpass_success_does_not_use_fallback():
|
|
|
73
75
|
assert results[0]["name"] == "Winchester Overpass"
|
|
74
76
|
|
|
75
77
|
|
|
78
|
+
async def test_overpass_empty_result_does_not_fall_back():
|
|
79
|
+
"""A successful but empty Overpass result must NOT serve stale snapshot data."""
|
|
80
|
+
session = _Session("empty")
|
|
81
|
+
provider = NationalRailProvider(session)
|
|
82
|
+
results = await provider.search_stops("WIN")
|
|
83
|
+
assert session.calls == 1 # Overpass was queried
|
|
84
|
+
assert results == [] # authoritative "no match" — snapshot not consulted
|
|
85
|
+
|
|
86
|
+
|
|
76
87
|
async def test_falls_back_to_snapshot_when_overpass_down():
|
|
77
88
|
session = _Session("down")
|
|
78
89
|
provider = NationalRailProvider(session)
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/tests/test_national_rail.py
RENAMED
|
File without changes
|