python-openpublictransport 0.1.15__tar.gz → 0.1.17__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 (70) hide show
  1. {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.17}/PKG-INFO +1 -1
  2. {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.17}/pyproject.toml +1 -1
  3. {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.17}/src/openpublictransport/const.py +1 -0
  4. {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.17}/src/openpublictransport/providers/__init__.py +3 -0
  5. {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.17}/src/openpublictransport/providers/efa_base.py +6 -2
  6. python_openpublictransport-0.1.17/src/openpublictransport/providers/hvv_gti.py +358 -0
  7. {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.17}/src/python_openpublictransport.egg-info/PKG-INFO +1 -1
  8. {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.17}/src/python_openpublictransport.egg-info/SOURCES.txt +3 -0
  9. python_openpublictransport-0.1.17/tests/test_efa_mimetype.py +87 -0
  10. {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.17}/tests/test_efa_platform.py +4 -2
  11. python_openpublictransport-0.1.17/tests/test_hvv_gti.py +360 -0
  12. {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.17}/setup.cfg +0 -0
  13. {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.17}/src/openpublictransport/__init__.py +0 -0
  14. {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.17}/src/openpublictransport/data/uk_stations.json +0 -0
  15. {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.17}/src/openpublictransport/exceptions.py +0 -0
  16. {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.17}/src/openpublictransport/models.py +0 -0
  17. {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.17}/src/openpublictransport/parsers.py +0 -0
  18. {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.17}/src/openpublictransport/providers/avv.py +0 -0
  19. {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.17}/src/openpublictransport/providers/bart.py +0 -0
  20. {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.17}/src/openpublictransport/providers/base.py +0 -0
  21. {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.17}/src/openpublictransport/providers/beg.py +0 -0
  22. {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.17}/src/openpublictransport/providers/bsvg.py +0 -0
  23. {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.17}/src/openpublictransport/providers/bvg.py +0 -0
  24. {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.17}/src/openpublictransport/providers/dart.py +0 -0
  25. {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.17}/src/openpublictransport/providers/db.py +0 -0
  26. {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.17}/src/openpublictransport/providers/ding.py +0 -0
  27. {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.17}/src/openpublictransport/providers/entur.py +0 -0
  28. {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.17}/src/openpublictransport/providers/fptf_base.py +0 -0
  29. {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.17}/src/openpublictransport/providers/gtfsde.py +0 -0
  30. {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.17}/src/openpublictransport/providers/hafas_base.py +0 -0
  31. {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.17}/src/openpublictransport/providers/hafas_mgate_base.py +0 -0
  32. {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.17}/src/openpublictransport/providers/hvv.py +0 -0
  33. {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.17}/src/openpublictransport/providers/irishrail.py +0 -0
  34. {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.17}/src/openpublictransport/providers/kvv.py +0 -0
  35. {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.17}/src/openpublictransport/providers/mobiliteit_lu.py +0 -0
  36. {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.17}/src/openpublictransport/providers/mvv.py +0 -0
  37. {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.17}/src/openpublictransport/providers/national_rail.py +0 -0
  38. {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.17}/src/openpublictransport/providers/ns.py +0 -0
  39. {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.17}/src/openpublictransport/providers/nta.py +0 -0
  40. {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.17}/src/openpublictransport/providers/nvbw.py +0 -0
  41. {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.17}/src/openpublictransport/providers/nwl.py +0 -0
  42. {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.17}/src/openpublictransport/providers/oebb.py +0 -0
  43. {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.17}/src/openpublictransport/providers/otp.py +0 -0
  44. {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.17}/src/openpublictransport/providers/otp_base.py +0 -0
  45. {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.17}/src/openpublictransport/providers/otp_custom.py +0 -0
  46. {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.17}/src/openpublictransport/providers/rejseplanen.py +0 -0
  47. {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.17}/src/openpublictransport/providers/rmv.py +0 -0
  48. {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.17}/src/openpublictransport/providers/rvv.py +0 -0
  49. {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.17}/src/openpublictransport/providers/sbb.py +0 -0
  50. {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.17}/src/openpublictransport/providers/tpg.py +0 -0
  51. {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.17}/src/openpublictransport/providers/trafiklab.py +0 -0
  52. {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.17}/src/openpublictransport/providers/transitous.py +0 -0
  53. {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.17}/src/openpublictransport/providers/trias_base.py +0 -0
  54. {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.17}/src/openpublictransport/providers/vagfr.py +0 -0
  55. {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.17}/src/openpublictransport/providers/vbn.py +0 -0
  56. {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.17}/src/openpublictransport/providers/vgn.py +0 -0
  57. {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.17}/src/openpublictransport/providers/vrn.py +0 -0
  58. {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.17}/src/openpublictransport/providers/vrr.py +0 -0
  59. {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.17}/src/openpublictransport/providers/vvo.py +0 -0
  60. {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.17}/src/openpublictransport/providers/vvs.py +0 -0
  61. {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.17}/src/openpublictransport/py.typed +0 -0
  62. {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.17}/src/python_openpublictransport.egg-info/dependency_links.txt +0 -0
  63. {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.17}/src/python_openpublictransport.egg-info/requires.txt +0 -0
  64. {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.17}/src/python_openpublictransport.egg-info/top_level.txt +0 -0
  65. {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.17}/tests/test_entur.py +0 -0
  66. {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.17}/tests/test_hafas_base.py +0 -0
  67. {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.17}/tests/test_hafas_mgate.py +0 -0
  68. {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.17}/tests/test_national_rail.py +0 -0
  69. {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.17}/tests/test_national_rail_parse.py +0 -0
  70. {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.17}/tests/test_national_rail_static.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-openpublictransport
3
- Version: 0.1.15
3
+ Version: 0.1.17
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.15"
7
+ version = "0.1.17"
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 @@
4
4
  PROVIDER_VRR = "vrr"
5
5
  PROVIDER_KVV = "kvv"
6
6
  PROVIDER_HVV = "hvv"
7
+ PROVIDER_HVV_GTI = "hvv_gti" # HVV via the official Geofox GTI API (credentials required)
7
8
  PROVIDER_BVG = "bvg"
8
9
  PROVIDER_MVV = "mvv"
9
10
  PROVIDER_VVS = "vvs"
@@ -11,6 +11,7 @@ from ..const import (
11
11
  PROVIDER_DB,
12
12
  PROVIDER_DING,
13
13
  PROVIDER_HVV,
14
+ PROVIDER_HVV_GTI,
14
15
  PROVIDER_KVV,
15
16
  PROVIDER_MVV,
16
17
  PROVIDER_NTA_IE,
@@ -51,6 +52,7 @@ from .db import DBProvider
51
52
  from .ding import DINGProvider
52
53
  from .gtfsde import OPTProvider
53
54
  from .hvv import HVVProvider
55
+ from .hvv_gti import HVVGTIProvider
54
56
  from .kvv import KVVProvider
55
57
  from .mvv import MVVProvider
56
58
  from .nta import NTAProvider
@@ -161,3 +163,4 @@ register_provider(PROVIDER_BART_US, BARTProvider)
161
163
  register_provider(PROVIDER_DART_US, DARTProvider)
162
164
  register_provider(PROVIDER_IRISHRAIL_IE, IrishRailProvider)
163
165
  register_provider(PROVIDER_TPG_CH, TPGProvider)
166
+ register_provider(PROVIDER_HVV_GTI, HVVGTIProvider)
@@ -154,7 +154,9 @@ class EFABaseProvider(BaseProvider):
154
154
  async with self.session.get(url, headers=headers, timeout=aiohttp.ClientTimeout(total=10)) as response:
155
155
  if response.status == 200:
156
156
  try:
157
- json_data = await response.json()
157
+ # content_type=None: some deployments (VGN) send RapidJSON
158
+ # payloads with a "text/xml;;charset=utf-8" header (issue #79).
159
+ json_data = await response.json(content_type=None)
158
160
  if not isinstance(json_data, dict):
159
161
  _LOGGER.warning("%s API returned non-dict response: %s", name, type(json_data))
160
162
  return None
@@ -251,7 +253,9 @@ class EFABaseProvider(BaseProvider):
251
253
  async with self.session.get(url, timeout=aiohttp.ClientTimeout(total=10)) as response:
252
254
  if response.status == 200:
253
255
  try:
254
- data = await response.json()
256
+ # content_type=None: some deployments (VGN) send RapidJSON
257
+ # payloads with a "text/xml;;charset=utf-8" header (issue #79).
258
+ data = await response.json(content_type=None)
255
259
  except (ValueError, aiohttp.ContentTypeError) as e:
256
260
  _LOGGER.error("Invalid JSON response from %s API: %s", name, e)
257
261
  return []
@@ -0,0 +1,358 @@
1
+ """HVV via the official Geofox Thin Interface (GTI).
2
+
3
+ The `hvv` provider talks to the public EFA endpoint. This one uses HOCHBAHN's
4
+ official API on behalf of the HVV, which carries real-time delays, cancellations
5
+ and platform changes that EFA does not expose.
6
+
7
+ Credentials are free but must be requested by email from api@hochbahn.de; the
8
+ API is documented at https://gti.geofox.de/html/GTIHandbuch_p.html.
9
+
10
+ Requested for https://github.com/NerdySoftPaw/openpublictransport/issues/61.
11
+ """
12
+
13
+ import base64
14
+ import hashlib
15
+ import hmac
16
+ import json
17
+ import logging
18
+ from datetime import datetime, timedelta
19
+ from typing import Any, Dict, List, Optional, Union
20
+ from zoneinfo import ZoneInfo
21
+
22
+ import aiohttp
23
+
24
+ from ..const import PROVIDER_HVV_GTI
25
+ from ..exceptions import AuthenticationError
26
+ from ..models import UnifiedDeparture
27
+ from .base import BaseProvider
28
+
29
+ _LOGGER = logging.getLogger(__name__)
30
+
31
+ GTI_BASE_URL = "https://gti.geofox.de/gti/public"
32
+
33
+ # The API version the request bodies below are written against. The server
34
+ # tailors its response to this number, so it is pinned rather than left to the
35
+ # server default (which is the long-obsolete version 1).
36
+ GTI_API_VERSION = 63
37
+
38
+ # A line's type carries a granular value (`shortInfo`, and sometimes a granular
39
+ # `simpleType`) plus a coarse `simpleType`. The granular one is authoritative;
40
+ # the coarse one flattens U-Bahn, S-Bahn and AKN all into TRAIN, so it is only
41
+ # consulted after the line name has had a chance to disambiguate.
42
+ _GRANULAR_TYPE_MAP = {
43
+ "UBAHN": "subway",
44
+ "U": "subway",
45
+ "U-BAHN": "subway",
46
+ "SBAHN": "train",
47
+ "S": "train",
48
+ "S-BAHN": "train",
49
+ "AKN": "train",
50
+ "A": "train",
51
+ "RBAHN": "train",
52
+ "R": "train",
53
+ "FERNBAHN": "train",
54
+ "ZUG": "train",
55
+ "STADTBUS": "bus",
56
+ "METROBUS": "bus",
57
+ "SCHNELLBUS": "bus",
58
+ "NACHTBUS": "bus",
59
+ "XPRESSBUS": "bus",
60
+ "EILBUS": "bus",
61
+ "FAEHRE": "ferry",
62
+ "FÄHRE": "ferry",
63
+ "AST": "taxi",
64
+ }
65
+
66
+ _COARSE_TYPE_MAP = {
67
+ "BUS": "bus",
68
+ "TRAIN": "train",
69
+ "SHIP": "ferry",
70
+ }
71
+
72
+
73
+ def sign_request(password: str, body: bytes) -> str:
74
+ """Return the geofox-auth-signature for a request body.
75
+
76
+ HMAC-SHA1 over the UTF-8 request body, keyed with the password, Base64
77
+ encoded — GTI handbook §3 "Authentifikation".
78
+ """
79
+ digest = hmac.new(password.encode("utf-8"), body, hashlib.sha1).digest()
80
+ return base64.b64encode(digest).decode("ascii")
81
+
82
+
83
+ def _transport_type(line: Dict[str, Any]) -> str:
84
+ """Map a GTI line to a unified transportation type."""
85
+ line_type = line.get("type") if isinstance(line.get("type"), dict) else {}
86
+ short_info = str(line_type.get("shortInfo") or "").strip().upper()
87
+ simple_type = str(line_type.get("simpleType") or "").strip().upper()
88
+
89
+ for value in (short_info, simple_type):
90
+ if value in _GRANULAR_TYPE_MAP:
91
+ return _GRANULAR_TYPE_MAP[value]
92
+
93
+ # Only the coarse type is left, which lumps U-Bahn, S-Bahn and AKN together
94
+ # under TRAIN. Hamburg's line names are unambiguous, so use them first.
95
+ name = str(line.get("name") or "").strip().upper()
96
+ if len(name) > 1 and name[1].isdigit():
97
+ if name[0] == "U":
98
+ return "subway"
99
+ if name[0] in ("S", "A", "R"):
100
+ return "train"
101
+
102
+ if simple_type in _COARSE_TYPE_MAP:
103
+ return _COARSE_TYPE_MAP[simple_type]
104
+
105
+ _LOGGER.debug("HVV GTI: unmapped line type %s for line %r", line_type, line.get("name"))
106
+ return "unknown"
107
+
108
+
109
+ class HVVGTIProvider(BaseProvider):
110
+ """HVV via the official Geofox GTI API (real-time)."""
111
+
112
+ @property
113
+ def provider_id(self) -> str:
114
+ return PROVIDER_HVV_GTI
115
+
116
+ @property
117
+ def provider_name(self) -> str:
118
+ return "HVV Geofox GTI (Hamburg)"
119
+
120
+ @property
121
+ def requires_api_key(self) -> bool:
122
+ return True
123
+
124
+ def get_timezone(self) -> str:
125
+ return "Europe/Berlin"
126
+
127
+ # ── transport ────────────────────────────────────────────────────────────
128
+
129
+ async def _post(self, method: str, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
130
+ """Call one GTI method, signing the exact bytes that are sent.
131
+
132
+ The signature covers the serialized body, so the body is serialized
133
+ once and handed to aiohttp as raw bytes — letting aiohttp re-serialize
134
+ it via ``json=`` could change the bytes and invalidate the signature.
135
+ """
136
+ if not self.api_key or not self.api_key_secondary:
137
+ _LOGGER.error("%s: username and password are required", self.provider_name)
138
+ raise AuthenticationError(f"{self.provider_name}: username and password are required")
139
+
140
+ body = json.dumps(
141
+ {"version": GTI_API_VERSION, "language": "de", **payload},
142
+ ensure_ascii=False,
143
+ separators=(",", ":"),
144
+ ).encode("utf-8")
145
+
146
+ headers = {
147
+ "geofox-auth-type": "HmacSHA1",
148
+ "geofox-auth-user": self.api_key,
149
+ "geofox-auth-signature": sign_request(self.api_key_secondary, body),
150
+ "Content-Type": "application/json;charset=UTF-8",
151
+ "Accept": "application/json",
152
+ "X-Platform": "web",
153
+ }
154
+
155
+ url = f"{GTI_BASE_URL}/{method}"
156
+ try:
157
+ async with self.session.post(
158
+ url, data=body, headers=headers, timeout=aiohttp.ClientTimeout(total=15)
159
+ ) as response:
160
+ if response.status in (401, 403):
161
+ raise AuthenticationError(
162
+ f"{self.provider_name}: authentication failed (HTTP {response.status}) — "
163
+ "check the GTI username and password"
164
+ )
165
+ if response.status != 200:
166
+ _LOGGER.warning("%s: %s → HTTP %s", self.provider_name, method, response.status)
167
+ return None
168
+
169
+ data = await response.json(content_type=None)
170
+ except AuthenticationError:
171
+ raise
172
+ except aiohttp.ClientError as exc:
173
+ _LOGGER.warning("%s: %s request failed: %s", self.provider_name, method, exc)
174
+ return None
175
+ except Exception as exc:
176
+ _LOGGER.warning("%s: %s error: %s", self.provider_name, method, exc)
177
+ return None
178
+
179
+ if not isinstance(data, dict):
180
+ _LOGGER.warning("%s: %s returned %s, expected an object", self.provider_name, method, type(data))
181
+ return None
182
+
183
+ return_code = data.get("returnCode")
184
+ if return_code != "OK":
185
+ # GTI signals a bad key with a return code rather than an HTTP status.
186
+ if return_code in ("ERROR_COMM", "ERROR_TEXT") and "auth" in str(data.get("errorDevInfo", "")).lower():
187
+ raise AuthenticationError(f"{self.provider_name}: {data.get('errorText') or return_code}")
188
+ _LOGGER.warning(
189
+ "%s: %s returned %s — %s (%s)",
190
+ self.provider_name,
191
+ method,
192
+ return_code,
193
+ data.get("errorText") or "no message",
194
+ data.get("errorDevInfo") or "",
195
+ )
196
+ return None
197
+
198
+ return data
199
+
200
+ # ── stop search ──────────────────────────────────────────────────────────
201
+
202
+ async def search_stops(self, search_term: str) -> List[Dict[str, Any]]:
203
+ """Search stations via the checkName method."""
204
+ data = await self._post(
205
+ "checkName",
206
+ {
207
+ "theName": {"name": search_term, "type": "STATION"},
208
+ "maxList": 25,
209
+ "coordinateType": "EPSG_4326",
210
+ },
211
+ )
212
+ if not data:
213
+ return []
214
+
215
+ stops = []
216
+ for result in data.get("results") or []:
217
+ if not isinstance(result, dict) or result.get("type") != "STATION" or not result.get("id"):
218
+ continue
219
+ stops.append(
220
+ {
221
+ "id": result["id"],
222
+ "name": result.get("name", ""),
223
+ "place": result.get("city", ""),
224
+ "area_type": "stop",
225
+ }
226
+ )
227
+ return stops
228
+
229
+ # ── departures ───────────────────────────────────────────────────────────
230
+
231
+ async def fetch_departures(
232
+ self,
233
+ station_id: Optional[str],
234
+ place_dm: str,
235
+ name_dm: str,
236
+ departures_limit: int,
237
+ ) -> Optional[Dict[str, Any]]:
238
+ """Fetch a departure board via the departureList method."""
239
+ if not station_id:
240
+ _LOGGER.warning("%s: a station ID is required", self.provider_name)
241
+ return None
242
+
243
+ now = datetime.now(ZoneInfo(self.get_timezone()))
244
+ data = await self._post(
245
+ "departureList",
246
+ {
247
+ "station": {"id": station_id, "type": "STATION"},
248
+ "time": {"date": now.strftime("%d.%m.%Y"), "time": now.strftime("%H:%M")},
249
+ "maxList": max(departures_limit, 5),
250
+ "maxTimeOffset": 200,
251
+ "useRealtime": True,
252
+ "returnFilters": False,
253
+ "coordinateType": "EPSG_4326",
254
+ },
255
+ )
256
+ if not data:
257
+ return None
258
+
259
+ # Departures are offsets from the board's own reference time, not
260
+ # absolute timestamps, so it has to be resolved here while we still
261
+ # have the response that produced them.
262
+ reference = self._reference_time(data.get("time"), now)
263
+
264
+ stop_events = []
265
+ for departure in data.get("departures") or []:
266
+ event = self._to_stop_event(departure, reference)
267
+ if event:
268
+ stop_events.append(event)
269
+
270
+ stop_events.sort(key=lambda event: event["estimated"])
271
+ return {"stopEvents": stop_events[:departures_limit]}
272
+
273
+ def _reference_time(self, gti_time: Any, fallback: datetime) -> datetime:
274
+ """Parse the response's GTITime, falling back to the request time."""
275
+ if isinstance(gti_time, dict) and gti_time.get("date") and gti_time.get("time"):
276
+ try:
277
+ return datetime.strptime(
278
+ f"{gti_time['date']} {gti_time['time']}", "%d.%m.%Y %H:%M"
279
+ ).replace(tzinfo=ZoneInfo(self.get_timezone()))
280
+ except ValueError:
281
+ _LOGGER.debug("%s: unparseable reference time %s", self.provider_name, gti_time)
282
+ return fallback
283
+
284
+ def _to_stop_event(self, departure: Any, reference: datetime) -> Optional[Dict[str, Any]]:
285
+ """Normalise one GTI departure into this provider's stop-event shape."""
286
+ if not isinstance(departure, dict):
287
+ return None
288
+
289
+ line = departure.get("line") if isinstance(departure.get("line"), dict) else {}
290
+ time_offset = departure.get("timeOffset")
291
+ if not isinstance(time_offset, int):
292
+ _LOGGER.debug("%s: departure without a timeOffset: %s", self.provider_name, departure)
293
+ return None
294
+
295
+ # planned = board reference + timeOffset; the real-time deviation is a
296
+ # separate value in seconds on top (GTI handbook §2.4.2).
297
+ delay_seconds = departure.get("delay") if isinstance(departure.get("delay"), int) else 0
298
+ planned = reference + timedelta(minutes=time_offset)
299
+ estimated = planned + timedelta(seconds=delay_seconds)
300
+
301
+ platform = departure.get("realtimePlatform") or departure.get("platform") or ""
302
+ planned_platform = departure.get("platform") or ""
303
+
304
+ notices = []
305
+ for attribute in departure.get("attributes") or []:
306
+ if isinstance(attribute, dict):
307
+ text = attribute.get("value") or attribute.get("title") or ""
308
+ if text:
309
+ notices.append(str(text).strip())
310
+ if departure.get("cancelled"):
311
+ notices.append("Fahrt fällt aus")
312
+ if departure.get("extra"):
313
+ notices.append("Verstärkerfahrt")
314
+
315
+ return {
316
+ "line": str(line.get("name") or ""),
317
+ "destination": str(line.get("direction") or "Unknown"),
318
+ "transportType": _transport_type(line),
319
+ "planned": planned,
320
+ "estimated": estimated,
321
+ "delaySeconds": delay_seconds,
322
+ "isRealtime": departure.get("delay") is not None,
323
+ "platform": str(platform),
324
+ "plannedPlatform": str(planned_platform),
325
+ "cancelled": bool(departure.get("cancelled")),
326
+ "notices": notices or None,
327
+ }
328
+
329
+ def parse_departure(
330
+ self, stop: Dict[str, Any], tz: Union[ZoneInfo, Any], now: datetime
331
+ ) -> Optional[UnifiedDeparture]:
332
+ """Build a UnifiedDeparture from a stop event produced above."""
333
+ try:
334
+ planned = stop["planned"].astimezone(tz)
335
+ estimated = stop["estimated"].astimezone(tz)
336
+
337
+ platform = stop.get("platform") or ""
338
+ planned_platform = stop.get("plannedPlatform") or ""
339
+ platform_changed = bool(platform and planned_platform and platform != planned_platform)
340
+
341
+ return UnifiedDeparture(
342
+ line=stop.get("line", ""),
343
+ destination=stop.get("destination", "Unknown"),
344
+ departure_time=estimated.strftime("%H:%M"),
345
+ planned_time=planned.strftime("%H:%M"),
346
+ delay=int(stop.get("delaySeconds", 0) / 60),
347
+ platform=platform,
348
+ transportation_type=stop.get("transportType", "unknown"),
349
+ is_realtime=bool(stop.get("isRealtime")),
350
+ minutes_until_departure=max(0, int((estimated - now).total_seconds() / 60)),
351
+ departure_time_obj=estimated,
352
+ notices=stop.get("notices"),
353
+ planned_platform=planned_platform if platform_changed else None,
354
+ platform_changed=platform_changed,
355
+ )
356
+ except Exception as exc:
357
+ _LOGGER.debug("%s parse_departure error: %s", self.provider_name, exc)
358
+ return None
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-openpublictransport
3
- Version: 0.1.15
3
+ Version: 0.1.17
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
@@ -23,6 +23,7 @@ src/openpublictransport/providers/gtfsde.py
23
23
  src/openpublictransport/providers/hafas_base.py
24
24
  src/openpublictransport/providers/hafas_mgate_base.py
25
25
  src/openpublictransport/providers/hvv.py
26
+ src/openpublictransport/providers/hvv_gti.py
26
27
  src/openpublictransport/providers/irishrail.py
27
28
  src/openpublictransport/providers/kvv.py
28
29
  src/openpublictransport/providers/mobiliteit_lu.py
@@ -56,10 +57,12 @@ src/python_openpublictransport.egg-info/SOURCES.txt
56
57
  src/python_openpublictransport.egg-info/dependency_links.txt
57
58
  src/python_openpublictransport.egg-info/requires.txt
58
59
  src/python_openpublictransport.egg-info/top_level.txt
60
+ tests/test_efa_mimetype.py
59
61
  tests/test_efa_platform.py
60
62
  tests/test_entur.py
61
63
  tests/test_hafas_base.py
62
64
  tests/test_hafas_mgate.py
65
+ tests/test_hvv_gti.py
63
66
  tests/test_national_rail.py
64
67
  tests/test_national_rail_parse.py
65
68
  tests/test_national_rail_static.py
@@ -0,0 +1,87 @@
1
+ """EFA endpoints that mislabel their JSON payload (issue #79).
2
+
3
+ VGN answers ``outputFormat=RapidJSON`` requests with
4
+ ``Content-Type: text/xml;;charset=utf-8``. aiohttp raises ``ContentTypeError``
5
+ for that unless the caller passes ``content_type=None``, so station search
6
+ returned "no results found" even though the body was valid JSON.
7
+
8
+ The fake response below mimics aiohttp: it raises unless the strict content
9
+ type check is disabled — a mock that accepts the kwarg either way cannot catch
10
+ this regression.
11
+ """
12
+
13
+ import aiohttp
14
+ import pytest
15
+
16
+ from openpublictransport.providers.vgn import VGNProvider
17
+
18
+ STOPFINDER_PAYLOAD = {
19
+ "locations": [
20
+ {
21
+ "id": "de:09564:704",
22
+ "name": "Nürnberg, Rathenauplatz",
23
+ "disassembledName": "Rathenauplatz, Nürnberg",
24
+ "type": "stop",
25
+ }
26
+ ]
27
+ }
28
+
29
+ DM_PAYLOAD = {
30
+ "stopEvents": [
31
+ {
32
+ "departureTimePlanned": "2026-08-08T10:00:00Z",
33
+ "transportation": {"number": "U2", "product": {"class": 2, "name": "U-Bahn"}},
34
+ }
35
+ ]
36
+ }
37
+
38
+
39
+ class _XmlMimeResponse:
40
+ """Response whose body is JSON but whose header claims text/xml."""
41
+
42
+ def __init__(self, payload, status=200):
43
+ self.status = status
44
+ self._payload = payload
45
+
46
+ async def json(self, content_type="application/json"):
47
+ if content_type is not None:
48
+ raise aiohttp.ContentTypeError(
49
+ None,
50
+ (),
51
+ message=("Attempt to decode JSON with unexpected mimetype: text/xml;;charset=utf-8"),
52
+ )
53
+ return self._payload
54
+
55
+ async def __aenter__(self):
56
+ return self
57
+
58
+ async def __aexit__(self, *exc):
59
+ return False
60
+
61
+
62
+ class _FakeSession:
63
+ def __init__(self, payload):
64
+ self._payload = payload
65
+ self.urls = []
66
+
67
+ def get(self, url, headers=None, timeout=None):
68
+ self.urls.append(url)
69
+ return _XmlMimeResponse(self._payload)
70
+
71
+
72
+ @pytest.mark.asyncio
73
+ async def test_search_stops_accepts_xml_mimetype():
74
+ session = _FakeSession(STOPFINDER_PAYLOAD)
75
+ results = await VGNProvider(session).search_stops("Rathenauplatz, Nürnberg")
76
+
77
+ assert [r["id"] for r in results] == ["de:09564:704"]
78
+ assert results[0]["place"] == "Nürnberg"
79
+
80
+
81
+ @pytest.mark.asyncio
82
+ async def test_fetch_departures_accepts_xml_mimetype():
83
+ session = _FakeSession(DM_PAYLOAD)
84
+ data = await VGNProvider(session).fetch_departures("de:09564:704", "", "", 10)
85
+
86
+ assert data is not None
87
+ assert len(data["stopEvents"]) == 1
@@ -183,6 +183,8 @@ def test_normalize_platform():
183
183
  assert normalize_platform("Track 1") == "1"
184
184
  assert normalize_platform(None) == ""
185
185
  assert normalize_platform("") == ""
186
- # A platform genuinely named "Gleis" keeps a usable value
187
- assert normalize_platform("Gleis") == ""
188
186
  assert normalize_platform(3) == "3"
187
+ # A bare label with no identifier after it normalizes away to nothing.
188
+ # Harmless: an empty value never takes part in a platform_changed
189
+ # decision, which requires both sides to be non-empty.
190
+ assert normalize_platform("Gleis") == ""
@@ -0,0 +1,360 @@
1
+ """Tests for the HVV Geofox GTI provider.
2
+
3
+ The official HOCHBAHN/HVV API. Payloads follow the examples in the GTI handbook
4
+ (https://gti.geofox.de/html/GTIHandbuch_p.html).
5
+ See https://github.com/NerdySoftPaw/openpublictransport/issues/61.
6
+ """
7
+
8
+ import base64
9
+ import hashlib
10
+ import hmac
11
+ import json
12
+ from datetime import datetime
13
+ from types import SimpleNamespace
14
+ from zoneinfo import ZoneInfo
15
+
16
+ import pytest
17
+
18
+ from openpublictransport.exceptions import AuthenticationError
19
+ from openpublictransport.providers import get_provider
20
+ from openpublictransport.providers.hvv_gti import HVVGTIProvider, sign_request
21
+
22
+ TZ = ZoneInfo("Europe/Berlin")
23
+ NOW = datetime(2026, 8, 1, 21, 28, tzinfo=TZ)
24
+
25
+ # From the handbook's departureList example, extended with the real-time fields.
26
+ DEPARTURE_LIST_RESPONSE = {
27
+ "returnCode": "OK",
28
+ "time": {"date": "01.08.2026", "time": "21:28"},
29
+ "departures": [
30
+ {
31
+ "line": {
32
+ "name": "476",
33
+ "direction": "Ahrensburg, Auestieg",
34
+ "type": {"simpleType": "BUS", "shortInfo": "Bus"},
35
+ "id": "VHH:476_VHH",
36
+ },
37
+ "timeOffset": 20,
38
+ "serviceId": 200023670,
39
+ "platform": "5",
40
+ "delay": 120,
41
+ "extra": False,
42
+ "cancelled": False,
43
+ "realtimePlatform": "5",
44
+ },
45
+ {
46
+ "line": {
47
+ "name": "U1",
48
+ "direction": "Norderstedt Mitte",
49
+ "type": {"simpleType": "TRAIN", "shortInfo": "U"},
50
+ "id": "HHA-U:U1_HHA-U",
51
+ },
52
+ "timeOffset": 4,
53
+ "serviceId": 200023671,
54
+ "platform": "2",
55
+ "realtimePlatform": "3",
56
+ "delay": 0,
57
+ "extra": False,
58
+ "cancelled": False,
59
+ },
60
+ {
61
+ "line": {
62
+ "name": "S1",
63
+ "direction": "Wedel",
64
+ "type": {"simpleType": "TRAIN", "shortInfo": "S"},
65
+ "id": "DBAG-S:S1_DBAG-S",
66
+ },
67
+ "timeOffset": 9,
68
+ "serviceId": 200023672,
69
+ "platform": "3",
70
+ "cancelled": True,
71
+ "attributes": [{"title": "Hinweis", "value": "Bauarbeiten"}],
72
+ },
73
+ ],
74
+ "serviceTypes": ["BUS", "TRAIN"],
75
+ }
76
+
77
+ CHECK_NAME_RESPONSE = {
78
+ "returnCode": "OK",
79
+ "results": [
80
+ {
81
+ "name": "Christuskirche",
82
+ "city": "Hamburg",
83
+ "combinedName": "Christuskirche",
84
+ "id": "Master:84902",
85
+ "type": "STATION",
86
+ "coordinate": {"x": 9.93454, "y": 53.552405},
87
+ "serviceTypes": ["bus", "u"],
88
+ "hasStationInformation": True,
89
+ },
90
+ # Non-station suggestions must be filtered out
91
+ {"name": "Christuskirche 1", "city": "Hamburg", "id": "Coord:9.9,53.5", "type": "ADDRESS"},
92
+ ],
93
+ }
94
+
95
+
96
+ class _FakeResponse:
97
+ def __init__(self, status, payload):
98
+ self.status = status
99
+ self._payload = payload
100
+
101
+ async def json(self, content_type=None):
102
+ return self._payload
103
+
104
+ async def __aenter__(self):
105
+ return self
106
+
107
+ async def __aexit__(self, *exc):
108
+ return False
109
+
110
+
111
+ class _FakeSession:
112
+ """Records the exact bytes and headers of each POST."""
113
+
114
+ def __init__(self, status=200, payload=None):
115
+ self.status = status
116
+ self.payload = payload if payload is not None else {"returnCode": "OK"}
117
+ self.calls = []
118
+
119
+ def post(self, url, data=None, headers=None, timeout=None):
120
+ self.calls.append(SimpleNamespace(url=url, body=data, headers=headers))
121
+ return _FakeResponse(self.status, self.payload)
122
+
123
+
124
+ def _provider(session) -> HVVGTIProvider:
125
+ return HVVGTIProvider(session, api_key="testuser", api_key_secondary="s3cr3t")
126
+
127
+
128
+ # ── authentication ────────────────────────────────────────────────────────────
129
+
130
+
131
+ def test_sign_request_matches_hmac_sha1_base64():
132
+ body = b'{"version":63}'
133
+ expected = base64.b64encode(hmac.new(b"s3cr3t", body, hashlib.sha1).digest()).decode()
134
+
135
+ assert sign_request("s3cr3t", body) == expected
136
+
137
+
138
+ async def test_signature_covers_the_exact_bytes_sent():
139
+ """The body must be signed and transmitted as the same bytes.
140
+
141
+ Re-serialising the payload (e.g. handing aiohttp `json=`) would change the
142
+ bytes and the server would reject the signature.
143
+ """
144
+ session = _FakeSession(payload=CHECK_NAME_RESPONSE)
145
+ await _provider(session).search_stops("Christuskirche")
146
+
147
+ call = session.calls[0]
148
+ assert isinstance(call.body, bytes)
149
+ assert call.headers["geofox-auth-signature"] == sign_request("s3cr3t", call.body)
150
+ assert call.headers["geofox-auth-type"] == "HmacSHA1"
151
+ assert call.headers["geofox-auth-user"] == "testuser"
152
+
153
+
154
+ async def test_request_pins_the_api_version():
155
+ session = _FakeSession(payload=CHECK_NAME_RESPONSE)
156
+ await _provider(session).search_stops("Christuskirche")
157
+
158
+ body = json.loads(session.calls[0].body.decode("utf-8"))
159
+ assert body["version"] == 63
160
+ assert body["language"] == "de"
161
+
162
+
163
+ async def test_missing_credentials_raise():
164
+ provider = HVVGTIProvider(_FakeSession(), api_key="testuser", api_key_secondary=None)
165
+
166
+ with pytest.raises(AuthenticationError):
167
+ await provider.fetch_departures("Master:84902", "", "", 10)
168
+
169
+
170
+ @pytest.mark.parametrize("status", [401, 403])
171
+ async def test_http_401_403_raise_authentication_error(status):
172
+ provider = _provider(_FakeSession(status=status))
173
+
174
+ with pytest.raises(AuthenticationError):
175
+ await provider.search_stops("Christuskirche")
176
+
177
+
178
+ async def test_non_ok_return_code_is_not_fatal():
179
+ session = _FakeSession(payload={"returnCode": "ERROR_TEXT", "errorText": "Unbekannte Haltestelle"})
180
+
181
+ assert await _provider(session).search_stops("Nirgendwo") == []
182
+
183
+
184
+ async def test_http_error_returns_no_departures():
185
+ provider = _provider(_FakeSession(status=500))
186
+
187
+ assert await provider.fetch_departures("Master:84902", "", "", 10) is None
188
+
189
+
190
+ # ── stop search ───────────────────────────────────────────────────────────────
191
+
192
+
193
+ async def test_search_stops_keeps_only_stations():
194
+ session = _FakeSession(payload=CHECK_NAME_RESPONSE)
195
+
196
+ stops = await _provider(session).search_stops("Christuskirche")
197
+
198
+ assert stops == [
199
+ {"id": "Master:84902", "name": "Christuskirche", "place": "Hamburg", "area_type": "stop"}
200
+ ]
201
+ assert session.calls[0].url == "https://gti.geofox.de/gti/public/checkName"
202
+
203
+
204
+ # ── departures ────────────────────────────────────────────────────────────────
205
+
206
+
207
+ async def _departures(limit=10):
208
+ session = _FakeSession(payload=DEPARTURE_LIST_RESPONSE)
209
+ provider = _provider(session)
210
+ data = await provider.fetch_departures("Master:84902", "", "", limit)
211
+ return provider, session, data
212
+
213
+
214
+ async def test_departure_list_request_shape():
215
+ _, session, _ = await _departures()
216
+
217
+ body = json.loads(session.calls[0].body.decode("utf-8"))
218
+ assert session.calls[0].url == "https://gti.geofox.de/gti/public/departureList"
219
+ assert body["station"] == {"id": "Master:84902", "type": "STATION"}
220
+ assert body["useRealtime"] is True
221
+ assert body["maxTimeOffset"] == 200
222
+ # GTITime is dd.MM.yyyy / HH:mm, not ISO
223
+ assert len(body["time"]["date"].split(".")) == 3
224
+ assert ":" in body["time"]["time"]
225
+
226
+
227
+ async def test_station_id_is_required():
228
+ provider = _provider(_FakeSession(payload=DEPARTURE_LIST_RESPONSE))
229
+
230
+ assert await provider.fetch_departures(None, "Hamburg", "Christuskirche", 10) is None
231
+
232
+
233
+ async def test_departures_are_sorted_and_limited():
234
+ _, _, data = await _departures(limit=2)
235
+
236
+ assert [event["line"] for event in data["stopEvents"]] == ["U1", "S1"]
237
+
238
+
239
+ async def test_time_offset_minutes_plus_delay_seconds():
240
+ """planned = board reference + timeOffset; estimated adds the delay.
241
+
242
+ Matches how Home Assistant core's own hvv_departures integration reads
243
+ these two fields.
244
+ """
245
+ provider, _, data = await _departures()
246
+ bus = next(event for event in data["stopEvents"] if event["line"] == "476")
247
+
248
+ departure = provider.parse_departure(bus, TZ, NOW)
249
+
250
+ assert departure.planned_time == "21:48" # 21:28 + 20 min
251
+ assert departure.departure_time == "21:50" # + 120 s
252
+ assert departure.delay == 2
253
+ assert departure.is_realtime is True
254
+ assert departure.minutes_until_departure == 22
255
+
256
+
257
+ async def test_realtime_platform_wins_and_flags_a_change():
258
+ provider, _, data = await _departures()
259
+ subway = next(event for event in data["stopEvents"] if event["line"] == "U1")
260
+
261
+ departure = provider.parse_departure(subway, TZ, NOW)
262
+
263
+ assert departure.platform == "3"
264
+ assert departure.planned_platform == "2"
265
+ assert departure.platform_changed is True
266
+ assert departure.to_dict()["platform_changed"] is True
267
+
268
+
269
+ async def test_unchanged_platform_is_not_a_change():
270
+ provider, _, data = await _departures()
271
+ bus = next(event for event in data["stopEvents"] if event["line"] == "476")
272
+
273
+ departure = provider.parse_departure(bus, TZ, NOW)
274
+
275
+ assert departure.platform == "5"
276
+ assert departure.platform_changed is False
277
+
278
+
279
+ async def test_cancellation_and_attributes_become_notices():
280
+ provider, _, data = await _departures()
281
+ s_bahn = next(event for event in data["stopEvents"] if event["line"] == "S1")
282
+
283
+ departure = provider.parse_departure(s_bahn, TZ, NOW)
284
+
285
+ assert "Bauarbeiten" in departure.notices
286
+ assert "Fahrt fällt aus" in departure.notices
287
+ # No `delay` field at all → not real-time controlled
288
+ assert departure.is_realtime is False
289
+ assert departure.delay == 0
290
+
291
+
292
+ @pytest.mark.parametrize(
293
+ ("line", "expected"),
294
+ [
295
+ ({"name": "U1", "type": {"simpleType": "TRAIN", "shortInfo": "U"}}, "subway"),
296
+ ({"name": "S1", "type": {"simpleType": "TRAIN", "shortInfo": "S"}}, "train"),
297
+ ({"name": "A1", "type": {"simpleType": "TRAIN", "shortInfo": "AKN"}}, "train"),
298
+ ({"name": "476", "type": {"simpleType": "BUS"}}, "bus"),
299
+ ({"name": "X35", "type": {"simpleType": "BUS", "shortInfo": "XpressBus"}}, "bus"),
300
+ ({"name": "62", "type": {"simpleType": "SHIP", "shortInfo": "Fähre"}}, "ferry"),
301
+ ({"name": "8000", "type": {"simpleType": "BUS", "shortInfo": "AST"}}, "taxi"),
302
+ # Coarse TRAIN with no shortInfo — the line name settles it
303
+ ({"name": "U3", "type": {"simpleType": "TRAIN"}}, "subway"),
304
+ ({"name": "S31", "type": {"simpleType": "TRAIN"}}, "train"),
305
+ ({"name": "???", "type": {}}, "unknown"),
306
+ ],
307
+ )
308
+ def test_transport_type_mapping(line, expected):
309
+ from openpublictransport.providers.hvv_gti import _transport_type
310
+
311
+ assert _transport_type(line) == expected
312
+
313
+
314
+ async def test_departure_without_time_offset_is_skipped():
315
+ session = _FakeSession(
316
+ payload={
317
+ "returnCode": "OK",
318
+ "time": {"date": "01.08.2026", "time": "21:28"},
319
+ "departures": [{"line": {"name": "476"}}],
320
+ }
321
+ )
322
+
323
+ data = await _provider(session).fetch_departures("Master:84902", "", "", 10)
324
+
325
+ assert data == {"stopEvents": []}
326
+
327
+
328
+ async def test_unparseable_reference_time_falls_back():
329
+ session = _FakeSession(
330
+ payload={
331
+ "returnCode": "OK",
332
+ "time": {"date": "nonsense", "time": "??"},
333
+ "departures": [{"line": {"name": "476", "type": {"simpleType": "BUS"}}, "timeOffset": 5}],
334
+ }
335
+ )
336
+
337
+ data = await _provider(session).fetch_departures("Master:84902", "", "", 10)
338
+
339
+ assert len(data["stopEvents"]) == 1
340
+
341
+
342
+ # ── registry ──────────────────────────────────────────────────────────────────
343
+
344
+
345
+ def test_provider_is_registered_and_needs_a_key():
346
+ provider = get_provider("hvv_gti", None, api_key="u", api_key_secondary="p")
347
+
348
+ assert isinstance(provider, HVVGTIProvider)
349
+ assert provider.requires_api_key is True
350
+ assert provider.get_timezone() == "Europe/Berlin"
351
+
352
+
353
+ def test_efa_hvv_provider_is_untouched():
354
+ """The existing keyless `hvv` provider must keep working — the IDs are load-bearing."""
355
+ from openpublictransport.providers.hvv import HVVProvider
356
+
357
+ provider = get_provider("hvv", None)
358
+
359
+ assert isinstance(provider, HVVProvider)
360
+ assert provider.requires_api_key is False