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.
Files changed (54) hide show
  1. {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/PKG-INFO +1 -1
  2. {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/pyproject.toml +1 -1
  3. {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/openpublictransport/providers/national_rail.py +64 -17
  4. {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/python_openpublictransport.egg-info/PKG-INFO +1 -1
  5. {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/tests/test_national_rail_parse.py +7 -0
  6. {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/tests/test_national_rail_static.py +16 -5
  7. {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/setup.cfg +0 -0
  8. {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/openpublictransport/__init__.py +0 -0
  9. {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/openpublictransport/const.py +0 -0
  10. {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/openpublictransport/data/uk_stations.json +0 -0
  11. {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/openpublictransport/exceptions.py +0 -0
  12. {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/openpublictransport/models.py +0 -0
  13. {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/openpublictransport/parsers.py +0 -0
  14. {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/openpublictransport/providers/__init__.py +0 -0
  15. {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/openpublictransport/providers/avv.py +0 -0
  16. {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/openpublictransport/providers/base.py +0 -0
  17. {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/openpublictransport/providers/beg.py +0 -0
  18. {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/openpublictransport/providers/bsvg.py +0 -0
  19. {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/openpublictransport/providers/bvg.py +0 -0
  20. {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/openpublictransport/providers/db.py +0 -0
  21. {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/openpublictransport/providers/ding.py +0 -0
  22. {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/openpublictransport/providers/efa_base.py +0 -0
  23. {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/openpublictransport/providers/fptf_base.py +0 -0
  24. {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/openpublictransport/providers/gtfsde.py +0 -0
  25. {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/openpublictransport/providers/hvv.py +0 -0
  26. {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/openpublictransport/providers/kvv.py +0 -0
  27. {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/openpublictransport/providers/mvv.py +0 -0
  28. {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/openpublictransport/providers/nta.py +0 -0
  29. {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/openpublictransport/providers/nvbw.py +0 -0
  30. {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/openpublictransport/providers/nwl.py +0 -0
  31. {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/openpublictransport/providers/oebb.py +0 -0
  32. {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/openpublictransport/providers/otp.py +0 -0
  33. {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/openpublictransport/providers/otp_base.py +0 -0
  34. {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/openpublictransport/providers/otp_custom.py +0 -0
  35. {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/openpublictransport/providers/rejseplanen.py +0 -0
  36. {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/openpublictransport/providers/rmv.py +0 -0
  37. {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/openpublictransport/providers/rvv.py +0 -0
  38. {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/openpublictransport/providers/sbb.py +0 -0
  39. {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/openpublictransport/providers/trafiklab.py +0 -0
  40. {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/openpublictransport/providers/transitous.py +0 -0
  41. {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/openpublictransport/providers/trias_base.py +0 -0
  42. {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/openpublictransport/providers/vagfr.py +0 -0
  43. {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/openpublictransport/providers/vbn.py +0 -0
  44. {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/openpublictransport/providers/vgn.py +0 -0
  45. {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/openpublictransport/providers/vrn.py +0 -0
  46. {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/openpublictransport/providers/vrr.py +0 -0
  47. {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/openpublictransport/providers/vvo.py +0 -0
  48. {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/openpublictransport/providers/vvs.py +0 -0
  49. {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/openpublictransport/py.typed +0 -0
  50. {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/python_openpublictransport.egg-info/SOURCES.txt +0 -0
  51. {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/python_openpublictransport.egg-info/dependency_links.txt +0 -0
  52. {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/python_openpublictransport.egg-info/requires.txt +0 -0
  53. {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/src/python_openpublictransport.egg-info/top_level.txt +0 -0
  54. {python_openpublictransport-0.1.10 → python_openpublictransport-0.1.12}/tests/test_national_rail.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-openpublictransport
3
- Version: 0.1.10
3
+ Version: 0.1.12
4
4
  Summary: Python library for public transport APIs (EFA, OTP2, TRIAS, FPTF, HAFAS, GTFS-RT)
5
5
  License: MIT
6
6
  Requires-Python: >=3.11
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-openpublictransport"
7
- version = "0.1.10"
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
- _ENDPOINT = "https://lite.realtime.nationalrail.co.uk/OpenLDBWS/ldb11.asmx"
26
- _SOAP_ACTION = "http://thalesgroup.com/RTTI/2017-10-01/ldb/GetDepartureBoard"
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/2017-10-01/ldb/"
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
- _LOGGER.warning("%s: HTTP %s for CRS %s", self.provider_name, resp.status, crs)
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, rate-limited, or returns nothing do we fall back to a
324
- bundled offline snapshot — see :func:`_load_static_stations`.
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 down / throttled / empty → use the offline snapshot.
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 returned nothing for %r — served %d result(s) from the "
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
- stations = _load_static_stations()
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", []):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-openpublictransport
3
- Version: 0.1.10
3
+ Version: 0.1.12
4
4
  Summary: Python library for public transport APIs (EFA, OTP2, TRIAS, FPTF, HAFAS, GTFS-RT)
5
5
  License: MIT
6
6
  Requires-Python: >=3.11
@@ -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 len(stations) > 2000
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)