python-openpublictransport 0.1.2__tar.gz → 0.1.4__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 (50) hide show
  1. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/PKG-INFO +1 -1
  2. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/pyproject.toml +1 -1
  3. python_openpublictransport-0.1.4/src/openpublictransport/__init__.py +12 -0
  4. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/src/openpublictransport/const.py +2 -0
  5. python_openpublictransport-0.1.4/src/openpublictransport/exceptions.py +9 -0
  6. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/src/openpublictransport/providers/__init__.py +6 -0
  7. python_openpublictransport-0.1.4/src/openpublictransport/providers/national_rail.py +315 -0
  8. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/src/openpublictransport/providers/nta.py +7 -4
  9. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/src/openpublictransport/providers/otp_base.py +6 -0
  10. python_openpublictransport-0.1.4/src/openpublictransport/providers/rejseplanen.py +241 -0
  11. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/src/openpublictransport/providers/rmv.py +12 -2
  12. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/src/openpublictransport/providers/trafiklab.py +10 -6
  13. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/src/openpublictransport/providers/trias_base.py +6 -0
  14. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/src/python_openpublictransport.egg-info/PKG-INFO +1 -1
  15. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/src/python_openpublictransport.egg-info/SOURCES.txt +3 -0
  16. python_openpublictransport-0.1.2/src/openpublictransport/__init__.py +0 -5
  17. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/setup.cfg +0 -0
  18. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/src/openpublictransport/models.py +0 -0
  19. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/src/openpublictransport/parsers.py +0 -0
  20. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/src/openpublictransport/providers/avv.py +0 -0
  21. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/src/openpublictransport/providers/base.py +0 -0
  22. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/src/openpublictransport/providers/beg.py +0 -0
  23. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/src/openpublictransport/providers/bsvg.py +0 -0
  24. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/src/openpublictransport/providers/bvg.py +0 -0
  25. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/src/openpublictransport/providers/db.py +0 -0
  26. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/src/openpublictransport/providers/ding.py +0 -0
  27. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/src/openpublictransport/providers/efa_base.py +0 -0
  28. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/src/openpublictransport/providers/fptf_base.py +0 -0
  29. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/src/openpublictransport/providers/gtfsde.py +0 -0
  30. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/src/openpublictransport/providers/hvv.py +0 -0
  31. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/src/openpublictransport/providers/kvv.py +0 -0
  32. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/src/openpublictransport/providers/mvv.py +0 -0
  33. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/src/openpublictransport/providers/nvbw.py +0 -0
  34. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/src/openpublictransport/providers/nwl.py +0 -0
  35. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/src/openpublictransport/providers/oebb.py +0 -0
  36. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/src/openpublictransport/providers/otp.py +0 -0
  37. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/src/openpublictransport/providers/otp_custom.py +0 -0
  38. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/src/openpublictransport/providers/rvv.py +0 -0
  39. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/src/openpublictransport/providers/sbb.py +0 -0
  40. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/src/openpublictransport/providers/transitous.py +0 -0
  41. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/src/openpublictransport/providers/vagfr.py +0 -0
  42. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/src/openpublictransport/providers/vbn.py +0 -0
  43. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/src/openpublictransport/providers/vgn.py +0 -0
  44. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/src/openpublictransport/providers/vrn.py +0 -0
  45. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/src/openpublictransport/providers/vrr.py +0 -0
  46. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/src/openpublictransport/providers/vvo.py +0 -0
  47. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/src/openpublictransport/providers/vvs.py +0 -0
  48. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/src/python_openpublictransport.egg-info/dependency_links.txt +0 -0
  49. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/src/python_openpublictransport.egg-info/requires.txt +0 -0
  50. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/src/python_openpublictransport.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-openpublictransport
3
- Version: 0.1.2
3
+ Version: 0.1.4
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.2"
7
+ version = "0.1.4"
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" }
@@ -0,0 +1,12 @@
1
+ """python-openpublictransport — public transport API library."""
2
+
3
+ from .exceptions import AuthenticationError
4
+ from .providers import get_all_provider_ids, get_provider, get_provider_class, register_provider
5
+
6
+ __all__ = [
7
+ "get_provider",
8
+ "get_provider_class",
9
+ "get_all_provider_ids",
10
+ "register_provider",
11
+ "AuthenticationError",
12
+ ]
@@ -29,6 +29,8 @@ PROVIDER_VBN_OTP = "vbn_otp"
29
29
  PROVIDER_VBN_TRIAS = "vbn_trias"
30
30
  PROVIDER_OPT = "openpublictransport"
31
31
  PROVIDER_OTP_CUSTOM = "otp_custom"
32
+ PROVIDER_NATIONAL_RAIL = "national_rail"
33
+ PROVIDER_REJSEPLANEN = "rejseplanen"
32
34
 
33
35
  # API base URLs
34
36
  API_BASE_URL_VRR = "https://openservice-test.vrr.de/static03/XML_DM_REQUEST"
@@ -0,0 +1,9 @@
1
+ """Exceptions for python-openpublictransport."""
2
+
3
+
4
+ class AuthenticationError(Exception):
5
+ """Raised when API authentication fails (HTTP 401 or 403).
6
+
7
+ Signals that the configured API key is invalid or expired.
8
+ Callers should prompt the user to re-enter credentials rather than retrying.
9
+ """
@@ -32,6 +32,8 @@ from ..const import (
32
32
  PROVIDER_VRR,
33
33
  PROVIDER_VVO,
34
34
  PROVIDER_VVS,
35
+ PROVIDER_NATIONAL_RAIL,
36
+ PROVIDER_REJSEPLANEN,
35
37
  )
36
38
  from .avv import AVVProvider
37
39
  from .base import BaseProvider
@@ -61,6 +63,8 @@ from .vrn import VRNProvider
61
63
  from .vrr import VRRProvider
62
64
  from .vvo import VVOProvider
63
65
  from .vvs import VVSProvider
66
+ from .national_rail import NationalRailProvider
67
+ from .rejseplanen import RejseplanenProvider
64
68
 
65
69
  _PROVIDER_REGISTRY: Dict[str, Type[BaseProvider]] = {}
66
70
 
@@ -134,3 +138,5 @@ register_provider(PROVIDER_VBN_OTP, VBNOTPProvider)
134
138
  register_provider(PROVIDER_VBN_TRIAS, VBNTriasProvider)
135
139
  register_provider(PROVIDER_OPT, OPTProvider)
136
140
  register_provider(PROVIDER_OTP_CUSTOM, OTPCustomProvider)
141
+ register_provider(PROVIDER_NATIONAL_RAIL, NationalRailProvider)
142
+ register_provider(PROVIDER_REJSEPLANEN, RejseplanenProvider)
@@ -0,0 +1,315 @@
1
+ """National Rail (UK) provider using OpenLDBWS SOAP API.
2
+
3
+ Stop search uses the Overpass API (OSM) to find UK railway stations with
4
+ ref:crs tags, returning the 3-letter CRS code used by OpenLDBWS.
5
+ """
6
+
7
+ import logging
8
+ import re
9
+ from datetime import datetime, timedelta
10
+ from typing import Any, Dict, List, Optional
11
+ from xml.etree import ElementTree as ET
12
+ from zoneinfo import ZoneInfo
13
+
14
+ import aiohttp
15
+
16
+ from ..const import PROVIDER_NATIONAL_RAIL
17
+ from ..exceptions import AuthenticationError
18
+ from ..models import UnifiedDeparture
19
+ from .base import BaseProvider
20
+
21
+ _LOGGER = logging.getLogger(__name__)
22
+
23
+ _ENDPOINT = "https://lite.realtime.nationalrail.co.uk/OpenLDBWS/ldb11.asmx"
24
+ _SOAP_ACTION = "http://thalesgroup.com/RTTI/2017-10-01/ldb/GetDepartureBoard"
25
+ _OVERPASS_URL = "https://overpass-api.de/api/interpreter"
26
+
27
+ # UK bounding box (lat_min, lon_min, lat_max, lon_max)
28
+ _UK_BBOX = "49,-11,62,2"
29
+
30
+ _SOAP_TEMPLATE = """<?xml version="1.0" encoding="UTF-8"?>
31
+ <soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/"
32
+ xmlns:ldb="http://thalesgroup.com/RTTI/2017-10-01/ldb/"
33
+ xmlns:tok="http://thalesgroup.com/RTTI/2013-11-28/Token/types">
34
+ <soap:Header>
35
+ <tok:AccessToken>
36
+ <tok:TokenValue>{api_key}</tok:TokenValue>
37
+ </tok:AccessToken>
38
+ </soap:Header>
39
+ <soap:Body>
40
+ <ldb:GetDepartureBoardRequest>
41
+ <ldb:numRows>{num_rows}</ldb:numRows>
42
+ <ldb:crs>{crs}</ldb:crs>
43
+ </ldb:GetDepartureBoardRequest>
44
+ </soap:Body>
45
+ </soap:Envelope>"""
46
+
47
+
48
+ def _strip_namespaces(xml_string: str) -> str:
49
+ """Remove XML namespace prefixes and declarations for simpler parsing."""
50
+ xml_string = re.sub(r' xmlns[^=]*="[^"]*"', "", xml_string)
51
+ xml_string = re.sub(r"<([a-zA-Z]+):", "<", xml_string)
52
+ xml_string = re.sub(r"</([a-zA-Z]+):", "</", xml_string)
53
+ return xml_string
54
+
55
+
56
+ def _text(el: Optional[ET.Element], tag: str, default: str = "") -> str:
57
+ """Get text of a direct child element."""
58
+ if el is None:
59
+ return default
60
+ child = el.find(tag)
61
+ return child.text if child is not None and child.text else default
62
+
63
+
64
+ class NationalRailProvider(BaseProvider):
65
+ """National Rail (UK) provider via OpenLDBWS."""
66
+
67
+ def __init__(
68
+ self,
69
+ session: aiohttp.ClientSession,
70
+ api_key: Optional[str] = None,
71
+ api_key_secondary: Optional[str] = None,
72
+ custom_url: Optional[str] = None,
73
+ ):
74
+ super().__init__(session, api_key=api_key, api_key_secondary=api_key_secondary, custom_url=custom_url)
75
+
76
+ @property
77
+ def provider_id(self) -> str:
78
+ return PROVIDER_NATIONAL_RAIL
79
+
80
+ @property
81
+ def provider_name(self) -> str:
82
+ return "National Rail (UK)"
83
+
84
+ @property
85
+ def requires_api_key(self) -> bool:
86
+ return True
87
+
88
+ def get_timezone(self) -> str:
89
+ return "Europe/London"
90
+
91
+ def get_transport_type_mapping(self) -> Dict:
92
+ return {}
93
+
94
+ async def fetch_departures(
95
+ self,
96
+ station_id: Optional[str],
97
+ place_dm: Optional[str],
98
+ name_dm: Optional[str],
99
+ departures_limit: int,
100
+ ) -> Optional[Dict]:
101
+ """Fetch departures via OpenLDBWS SOAP for a given CRS code."""
102
+ if not self.api_key or not station_id:
103
+ return None
104
+
105
+ crs = station_id.strip().upper()
106
+ body = _SOAP_TEMPLATE.format(
107
+ api_key=self.api_key,
108
+ num_rows=min(departures_limit, 150),
109
+ crs=crs,
110
+ )
111
+
112
+ try:
113
+ async with self.session.post(
114
+ _ENDPOINT,
115
+ data=body.encode("utf-8"),
116
+ headers={
117
+ "Content-Type": "text/xml; charset=utf-8",
118
+ "SOAPAction": _SOAP_ACTION,
119
+ },
120
+ timeout=aiohttp.ClientTimeout(total=15),
121
+ ) as resp:
122
+ if resp.status in (401, 403):
123
+ raise AuthenticationError(
124
+ f"{self.provider_name}: authentication failed (HTTP {resp.status}) — check API key"
125
+ )
126
+ if resp.status != 200:
127
+ _LOGGER.warning("%s: HTTP %s for CRS %s", self.provider_name, resp.status, crs)
128
+ return None
129
+ text = await resp.text()
130
+ except Exception as exc:
131
+ _LOGGER.warning("%s: request failed: %s", self.provider_name, exc)
132
+ return None
133
+
134
+ try:
135
+ root = ET.fromstring(_strip_namespaces(text))
136
+ except ET.ParseError as exc:
137
+ _LOGGER.warning("%s: XML parse error: %s", self.provider_name, exc)
138
+ return None
139
+
140
+ services = root.findall(".//service")
141
+ if not services:
142
+ return {"stopEvents": []}
143
+
144
+ tz = ZoneInfo(self.get_timezone())
145
+ now = datetime.now(tz)
146
+ board_date = now.date()
147
+
148
+ stop_events = []
149
+ for svc in services:
150
+ event = self._service_to_dict(svc, board_date, tz, now)
151
+ if event:
152
+ stop_events.append(event)
153
+
154
+ return {"stopEvents": stop_events}
155
+
156
+ def _service_to_dict(
157
+ self,
158
+ svc: ET.Element,
159
+ board_date: Any,
160
+ tz: ZoneInfo,
161
+ now: datetime,
162
+ ) -> Optional[Dict[str, Any]]:
163
+ """Convert an XML service element to a plain dict."""
164
+ std = _text(svc, "std")
165
+ if not std:
166
+ return None
167
+
168
+ etd = _text(svc, "etd")
169
+ platform = _text(svc, "platform") or None
170
+ operator_name = _text(svc, "operator")
171
+ operator_code = _text(svc, "operatorCode")
172
+ is_cancelled = _text(svc, "isCancelled") == "true"
173
+ delay_reason = _text(svc, "delayReason")
174
+ cancel_reason = _text(svc, "cancelReason")
175
+
176
+ destination = ""
177
+ dest_el = svc.find(".//destination")
178
+ if dest_el is not None:
179
+ loc = dest_el.find("location")
180
+ if loc is not None:
181
+ destination = _text(loc, "locationName")
182
+ if not destination:
183
+ destination = "Unknown"
184
+
185
+ return {
186
+ "std": std,
187
+ "etd": etd,
188
+ "platform": platform,
189
+ "operator": operator_name,
190
+ "operatorCode": operator_code,
191
+ "isCancelled": is_cancelled,
192
+ "delayReason": delay_reason,
193
+ "cancelReason": cancel_reason,
194
+ "destination": destination,
195
+ "boardDate": board_date.isoformat(),
196
+ }
197
+
198
+ def parse_departure(
199
+ self,
200
+ stop: Dict[str, Any],
201
+ tz: Any,
202
+ now: datetime,
203
+ ) -> Optional[UnifiedDeparture]:
204
+ """Map a service dict to UnifiedDeparture."""
205
+ std = stop.get("std", "")
206
+ etd = stop.get("etd", "")
207
+ if not std:
208
+ return None
209
+
210
+ try:
211
+ board_date_str = stop.get("boardDate", "")
212
+ from datetime import date as date_cls
213
+
214
+ board_date = date_cls.fromisoformat(board_date_str) if board_date_str else datetime.now(tz).date()
215
+ except ValueError:
216
+ board_date = datetime.now(tz).date()
217
+
218
+ def _parse_hhmm(hhmm: str) -> Optional[datetime]:
219
+ try:
220
+ h, m = map(int, hhmm.strip().split(":"))
221
+ dt = datetime(board_date.year, board_date.month, board_date.day, h, m, tzinfo=tz)
222
+ if (now - dt).total_seconds() > 3600:
223
+ dt += timedelta(days=1)
224
+ return dt
225
+ except (ValueError, TypeError):
226
+ return None
227
+
228
+ planned_dt = _parse_hhmm(std)
229
+ if planned_dt is None:
230
+ return None
231
+
232
+ is_cancelled = stop.get("isCancelled", False)
233
+ notices: List[str] = []
234
+ if stop.get("delayReason"):
235
+ notices.append(stop["delayReason"])
236
+ if stop.get("cancelReason"):
237
+ notices.append(stop["cancelReason"])
238
+
239
+ delay = 0
240
+ is_realtime = False
241
+ actual_dt = planned_dt
242
+
243
+ if is_cancelled:
244
+ notices.insert(0, "Cancelled")
245
+ is_realtime = True
246
+ elif etd == "On time":
247
+ is_realtime = True
248
+ elif etd == "Delayed":
249
+ is_realtime = True
250
+ notices.insert(0, "Delayed")
251
+ elif etd and ":" in etd:
252
+ actual_dt_parsed = _parse_hhmm(etd)
253
+ if actual_dt_parsed:
254
+ actual_dt = actual_dt_parsed
255
+ delay = max(0, int((actual_dt - planned_dt).total_seconds() / 60))
256
+ is_realtime = True
257
+
258
+ minutes_until = max(0, int((actual_dt - now).total_seconds() / 60))
259
+ line = stop.get("operatorCode", "") or stop.get("operator", "")
260
+
261
+ return UnifiedDeparture(
262
+ line=line,
263
+ destination=stop.get("destination", ""),
264
+ departure_time=actual_dt.strftime("%H:%M"),
265
+ planned_time=std,
266
+ delay=delay,
267
+ platform=stop.get("platform"),
268
+ transportation_type="train",
269
+ is_realtime=is_realtime,
270
+ minutes_until_departure=minutes_until,
271
+ departure_time_obj=actual_dt,
272
+ agency=stop.get("operator") or None,
273
+ notices=notices if notices else None,
274
+ )
275
+
276
+ 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
+ escaped = re.sub(r"[.*+?^${}()|[\]\\]", r"\\\g<0>", search_term)
279
+
280
+ query = f"""[out:json][timeout:15];
281
+ (
282
+ node["railway"="station"]["ref:crs"]["name"~"{escaped}",i]({_UK_BBOX});
283
+ node["railway"="station"]["ref:crs"]["official_name"~"{escaped}",i]({_UK_BBOX});
284
+ node["railway"="station"]["ref:crs"]["alt_name"~"{escaped}",i]({_UK_BBOX});
285
+ );
286
+ out 10;"""
287
+
288
+ try:
289
+ async with self.session.post(
290
+ _OVERPASS_URL,
291
+ data={"data": query},
292
+ headers={
293
+ "User-Agent": "openpublictransport-homeassistant/1.0 (github.com/NerdySoftPaw/openpublictransport)"
294
+ },
295
+ timeout=aiohttp.ClientTimeout(total=15),
296
+ ) as resp:
297
+ if resp.status != 200:
298
+ _LOGGER.warning("%s: Overpass HTTP %s", self.provider_name, resp.status)
299
+ return []
300
+ data = await resp.json(content_type=None)
301
+ except Exception as exc:
302
+ _LOGGER.warning("%s: stop search failed: %s", self.provider_name, exc)
303
+ return []
304
+
305
+ results = []
306
+ for element in data.get("elements", []):
307
+ tags = element.get("tags", {})
308
+ crs = tags.get("ref:crs", "").strip().upper()
309
+ name = tags.get("name") or tags.get("official_name", "")
310
+ if not crs or len(crs) != 3 or not name:
311
+ continue
312
+ operator = tags.get("operator") or tags.get("network", "")
313
+ results.append({"id": crs, "name": name, "place": operator, "area_type": "stop"})
314
+
315
+ return results[:10]
@@ -6,6 +6,8 @@ from datetime import datetime, timedelta, timezone
6
6
  from typing import Any, Dict, List, Optional, Union
7
7
  from zoneinfo import ZoneInfo
8
8
 
9
+ from ..exceptions import AuthenticationError
10
+
9
11
  import aiohttp
10
12
  from aiohttp import ClientConnectorError
11
13
 
@@ -217,13 +219,14 @@ class NTAProvider(BaseProvider):
217
219
  elif response.status == 404:
218
220
  _LOGGER.warning("NTA API endpoint not found (404)")
219
221
  return None
220
- elif response.status == 401:
222
+ elif response.status in (401, 403):
221
223
  if self.api_key_secondary and current_api_key == self.api_key:
222
- _LOGGER.info("NTA Primary API key failed (401), trying Secondary key...")
224
+ _LOGGER.info("NTA primary key failed (HTTP %s), trying secondary...", response.status)
223
225
  current_api_key = self.api_key_secondary
224
226
  continue
225
- _LOGGER.warning("NTA API authentication failed (401) - check API key(s)")
226
- return None
227
+ raise AuthenticationError(
228
+ f"NTA: authentication failed (HTTP {response.status}) — check API key(s)"
229
+ )
227
230
  elif response.status >= 500:
228
231
  _LOGGER.warning(
229
232
  "NTA API server error (status %s) on attempt %d/%d",
@@ -3,6 +3,8 @@
3
3
  import asyncio
4
4
  import logging
5
5
  from datetime import datetime, timezone
6
+
7
+ from ..exceptions import AuthenticationError
6
8
  from typing import Any, Dict, List, Optional, Tuple, Union
7
9
  from urllib.parse import quote
8
10
  from zoneinfo import ZoneInfo
@@ -86,6 +88,10 @@ class OTPBaseProvider(BaseProvider):
86
88
  return await resp.json()
87
89
  if resp.status == 204:
88
90
  return None
91
+ if resp.status in (401, 403):
92
+ raise AuthenticationError(
93
+ f"{self.provider_name}: authentication failed (HTTP {resp.status}) — check API key"
94
+ )
89
95
  _LOGGER.warning("%s OTP %s → HTTP %s", self.provider_name, url, resp.status)
90
96
  except aiohttp.ClientError as exc:
91
97
  _LOGGER.warning("%s OTP request failed: %s", self.provider_name, exc)
@@ -0,0 +1,241 @@
1
+ """Rejseplanen (Denmark) provider using the HAFAS REST API.
2
+
3
+ Requires a free API key from labs.rejseplanen.dk (50k calls/month, non-commercial).
4
+ """
5
+
6
+ import logging
7
+ from datetime import datetime
8
+ from typing import Any, Dict, List, Optional, Union
9
+ from urllib.parse import quote
10
+ from zoneinfo import ZoneInfo
11
+
12
+ import aiohttp
13
+
14
+ from ..const import PROVIDER_REJSEPLANEN
15
+ from ..exceptions import AuthenticationError
16
+ from ..models import UnifiedDeparture
17
+ from .base import BaseProvider
18
+
19
+ _LOGGER = logging.getLogger(__name__)
20
+
21
+ _API_BASE = "https://www.rejseplanen.dk/api"
22
+
23
+ _PRODUCT_MAPPING: Dict[str, str] = {
24
+ "IC": "train",
25
+ "LYN": "train",
26
+ "RE": "train",
27
+ "REG": "train",
28
+ "S": "train",
29
+ "TOG": "train",
30
+ "M": "subway",
31
+ "BUS": "bus",
32
+ "EXB": "bus",
33
+ "NB": "bus",
34
+ "TB": "bus",
35
+ "F": "ferry",
36
+ "T": "tram",
37
+ }
38
+
39
+
40
+ def _parse_hafas_time(date_str: str, time_str: str, tz: Any) -> Optional[datetime]:
41
+ """Parse Rejseplanen date (DD.MM.YY) and time (HH:MM) into a timezone-aware datetime."""
42
+ if not date_str or not time_str:
43
+ return None
44
+ try:
45
+ time_fmt = "%H:%M:%S" if time_str.count(":") == 2 else "%H:%M"
46
+ dt = datetime.strptime(f"{date_str} {time_str}", f"%d.%m.%y {time_fmt}")
47
+ return dt.replace(tzinfo=tz)
48
+ except ValueError:
49
+ return None
50
+
51
+
52
+ class RejseplanenProvider(BaseProvider):
53
+ """Rejseplanen (Denmark) provider via HAFAS REST API."""
54
+
55
+ def __init__(
56
+ self,
57
+ session: aiohttp.ClientSession,
58
+ api_key: Optional[str] = None,
59
+ api_key_secondary: Optional[str] = None,
60
+ custom_url: Optional[str] = None,
61
+ ):
62
+ super().__init__(session, api_key=api_key, api_key_secondary=api_key_secondary, custom_url=custom_url)
63
+
64
+ @property
65
+ def provider_id(self) -> str:
66
+ return PROVIDER_REJSEPLANEN
67
+
68
+ @property
69
+ def provider_name(self) -> str:
70
+ return "Rejseplanen (Denmark)"
71
+
72
+ @property
73
+ def requires_api_key(self) -> bool:
74
+ return True
75
+
76
+ def get_timezone(self) -> str:
77
+ return "Europe/Copenhagen"
78
+
79
+ def get_transport_type_mapping(self) -> Dict:
80
+ return _PRODUCT_MAPPING
81
+
82
+ async def fetch_departures(
83
+ self,
84
+ station_id: Optional[str],
85
+ place_dm: str,
86
+ name_dm: str,
87
+ departures_limit: int,
88
+ ) -> Optional[Dict[str, Any]]:
89
+ """Fetch departures from the Rejseplanen departureBoard endpoint."""
90
+ if not station_id:
91
+ _LOGGER.warning("%s: station_id required", self.provider_name)
92
+ return None
93
+ if not self.api_key:
94
+ _LOGGER.error("%s: API key required", self.provider_name)
95
+ return None
96
+
97
+ url = (
98
+ f"{_API_BASE}/departureBoard"
99
+ f"?accessId={self.api_key}"
100
+ f"&id={station_id}"
101
+ f"&format=json"
102
+ f"&duration=120"
103
+ f"&maxJourneys={departures_limit}"
104
+ )
105
+ try:
106
+ async with self.session.get(url, timeout=aiohttp.ClientTimeout(total=15)) as resp:
107
+ if resp.status in (401, 403):
108
+ raise AuthenticationError(
109
+ f"{self.provider_name}: authentication failed (HTTP {resp.status}) — check API key"
110
+ )
111
+ if resp.status != 200:
112
+ _LOGGER.warning("%s: HTTP %s for station %s", self.provider_name, resp.status, station_id)
113
+ return None
114
+ data = await resp.json(content_type=None)
115
+ except Exception as exc:
116
+ _LOGGER.warning("%s: request failed: %s", self.provider_name, exc)
117
+ return None
118
+
119
+ if not isinstance(data, dict):
120
+ return None
121
+ if "errorCode" in data:
122
+ _LOGGER.warning("%s: API error %s: %s", self.provider_name, data.get("errorCode"), data.get("errorText"))
123
+ return None
124
+
125
+ board = data.get("DepartureBoard", {})
126
+ departures = board.get("Departure", [])
127
+ if isinstance(departures, dict):
128
+ departures = [departures]
129
+
130
+ return {"stopEvents": departures}
131
+
132
+ def parse_departure(
133
+ self,
134
+ stop: Dict[str, Any],
135
+ tz: Union[ZoneInfo, Any],
136
+ now: datetime,
137
+ ) -> Optional[UnifiedDeparture]:
138
+ """Parse a single Rejseplanen departure dict into UnifiedDeparture."""
139
+ try:
140
+ date_str = stop.get("date", "")
141
+ time_str = stop.get("time", "")
142
+ rt_date_str = stop.get("rtDate", "")
143
+ rt_time_str = stop.get("rtTime", "")
144
+
145
+ planned_dt = _parse_hafas_time(date_str, time_str, tz)
146
+ if planned_dt is None:
147
+ return None
148
+
149
+ if rt_date_str and rt_time_str:
150
+ actual_dt = _parse_hafas_time(rt_date_str, rt_time_str, tz) or planned_dt
151
+ is_realtime = True
152
+ else:
153
+ actual_dt = planned_dt
154
+ is_realtime = False
155
+
156
+ delay = max(0, int((actual_dt - planned_dt).total_seconds() / 60))
157
+ minutes_until = max(0, int((actual_dt - now).total_seconds() / 60))
158
+
159
+ type_str = stop.get("type", "").upper()
160
+ transport_type = _PRODUCT_MAPPING.get(type_str, "train")
161
+
162
+ line = stop.get("name", "")
163
+ destination = stop.get("direction", stop.get("finalStop", ""))
164
+
165
+ track = stop.get("track", "") or ""
166
+ rt_track = stop.get("rtTrack", "") or ""
167
+ platform = rt_track if rt_track else track
168
+ platform_changed = bool(rt_track and track and rt_track != track)
169
+
170
+ notices: List[str] = []
171
+ if stop.get("cancelled") == "true" or stop.get("cancelled") is True:
172
+ notices.append("Cancelled / Aflyst")
173
+
174
+ for msg in stop.get("Messages", {}).get("Message", []):
175
+ if isinstance(msg, dict):
176
+ text = msg.get("head", "") or msg.get("text", "")
177
+ if text:
178
+ notices.append(text)
179
+
180
+ return UnifiedDeparture(
181
+ line=line,
182
+ destination=destination,
183
+ departure_time=actual_dt.strftime("%H:%M"),
184
+ planned_time=planned_dt.strftime("%H:%M"),
185
+ delay=delay,
186
+ platform=platform or None,
187
+ transportation_type=transport_type,
188
+ is_realtime=is_realtime,
189
+ minutes_until_departure=minutes_until,
190
+ departure_time_obj=actual_dt,
191
+ notices=notices if notices else None,
192
+ planned_platform=track if platform_changed else None,
193
+ platform_changed=platform_changed,
194
+ )
195
+ except Exception as exc:
196
+ _LOGGER.debug("%s: parse error: %s", self.provider_name, exc)
197
+ return None
198
+
199
+ async def search_stops(self, search_term: str) -> List[Dict[str, Any]]:
200
+ """Search for Danish stops using the Rejseplanen location.name endpoint."""
201
+ if not self.api_key:
202
+ _LOGGER.error("%s: API key required for stop search", self.provider_name)
203
+ return []
204
+
205
+ url = (
206
+ f"{_API_BASE}/location.name"
207
+ f"?accessId={self.api_key}"
208
+ f"&input={quote(search_term, safe='')}"
209
+ f"&format=json"
210
+ f"&maxNo=15"
211
+ f"&type=S"
212
+ )
213
+ try:
214
+ async with self.session.get(url, timeout=aiohttp.ClientTimeout(total=10)) as resp:
215
+ if resp.status != 200:
216
+ _LOGGER.warning("%s: stop search HTTP %s", self.provider_name, resp.status)
217
+ return []
218
+ data = await resp.json(content_type=None)
219
+ except Exception as exc:
220
+ _LOGGER.warning("%s: stop search failed: %s", self.provider_name, exc)
221
+ return []
222
+
223
+ if not isinstance(data, dict):
224
+ return []
225
+
226
+ location_list = data.get("LocationList", {})
227
+ stops = location_list.get("StopLocation", [])
228
+ if isinstance(stops, dict):
229
+ stops = [stops]
230
+
231
+ results = []
232
+ for loc in stops:
233
+ if not isinstance(loc, dict):
234
+ continue
235
+ station_id = loc.get("extId") or loc.get("id", "")
236
+ name = loc.get("name", "")
237
+ if not station_id or not name:
238
+ continue
239
+ results.append({"id": str(station_id), "name": name, "place": "", "area_type": "stop"})
240
+
241
+ return results[:10]
@@ -6,6 +6,8 @@ from typing import Any, Dict, List, Optional, Union
6
6
  from urllib.parse import quote
7
7
  from zoneinfo import ZoneInfo
8
8
 
9
+ from ..exceptions import AuthenticationError
10
+
9
11
  import aiohttp
10
12
 
11
13
  from ..const import PROVIDER_RMV
@@ -126,8 +128,10 @@ class RMVProvider(BaseProvider):
126
128
  if isinstance(departures, dict):
127
129
  departures = [departures]
128
130
  return {"stopEvents": departures}
129
- elif response.status == 401:
130
- _LOGGER.error("RMV API: invalid API key")
131
+ elif response.status in (401, 403):
132
+ raise AuthenticationError(
133
+ f"RMV: authentication failed (HTTP {response.status}) — check API key"
134
+ )
131
135
  else:
132
136
  _LOGGER.warning("RMV API returned status %s", response.status)
133
137
  except aiohttp.ClientError as e:
@@ -264,8 +268,14 @@ class RMVProvider(BaseProvider):
264
268
  }
265
269
  )
266
270
  return results
271
+ elif response.status in (401, 403):
272
+ raise AuthenticationError(
273
+ f"RMV: authentication failed (HTTP {response.status}) — check API key"
274
+ )
267
275
  else:
268
276
  _LOGGER.error("RMV API returned status %s", response.status)
277
+ except AuthenticationError:
278
+ raise
269
279
  except Exception as e:
270
280
  _LOGGER.error("Error searching RMV stops: %s", e)
271
281
 
@@ -7,6 +7,8 @@ from typing import Any, Dict, List, Optional, Union
7
7
  from urllib.parse import quote
8
8
  from zoneinfo import ZoneInfo
9
9
 
10
+ from ..exceptions import AuthenticationError
11
+
10
12
  import aiohttp
11
13
  from aiohttp import ClientConnectorError
12
14
 
@@ -131,9 +133,10 @@ class TrafiklabProvider(BaseProvider):
131
133
  elif response.status == 404:
132
134
  _LOGGER.warning("Trafiklab API endpoint not found (404)")
133
135
  return None
134
- elif response.status == 401:
135
- _LOGGER.warning("Trafiklab API authentication failed (401) - check API key")
136
- return None
136
+ elif response.status in (401, 403):
137
+ raise AuthenticationError(
138
+ f"Trafiklab: authentication failed (HTTP {response.status}) — check API key"
139
+ )
137
140
  elif response.status >= 500:
138
141
  _LOGGER.warning(
139
142
  "Trafiklab API server error (status %s) on attempt %d/%d",
@@ -243,9 +246,10 @@ class TrafiklabProvider(BaseProvider):
243
246
  results.append(result)
244
247
 
245
248
  return results
246
- elif response.status == 401:
247
- _LOGGER.error("Trafiklab API authentication failed (401) - check API key")
248
- return []
249
+ elif response.status in (401, 403):
250
+ raise AuthenticationError(
251
+ f"Trafiklab: authentication failed (HTTP {response.status}) — check API key"
252
+ )
249
253
  elif response.status == 404:
250
254
  _LOGGER.warning("Trafiklab API endpoint not found (404)")
251
255
  return []
@@ -3,6 +3,8 @@
3
3
  import logging
4
4
  from datetime import datetime
5
5
  from typing import Any, Dict, List, Optional, Union
6
+
7
+ from ..exceptions import AuthenticationError
6
8
  from xml.etree import ElementTree as ET
7
9
  from zoneinfo import ZoneInfo
8
10
 
@@ -140,6 +142,10 @@ class TRIASBaseProvider(BaseProvider):
140
142
  if response.status == 200:
141
143
  text = await response.text()
142
144
  return ET.fromstring(text)
145
+ elif response.status in (401, 403):
146
+ raise AuthenticationError(
147
+ f"{self.provider_name}: authentication failed (HTTP {response.status}) — check API key"
148
+ )
143
149
  else:
144
150
  _LOGGER.warning("%s TRIAS API returned status %s", self.provider_name, response.status)
145
151
  except aiohttp.ClientError as e:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-openpublictransport
3
- Version: 0.1.2
3
+ Version: 0.1.4
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
@@ -1,6 +1,7 @@
1
1
  pyproject.toml
2
2
  src/openpublictransport/__init__.py
3
3
  src/openpublictransport/const.py
4
+ src/openpublictransport/exceptions.py
4
5
  src/openpublictransport/models.py
5
6
  src/openpublictransport/parsers.py
6
7
  src/openpublictransport/providers/__init__.py
@@ -17,6 +18,7 @@ src/openpublictransport/providers/gtfsde.py
17
18
  src/openpublictransport/providers/hvv.py
18
19
  src/openpublictransport/providers/kvv.py
19
20
  src/openpublictransport/providers/mvv.py
21
+ src/openpublictransport/providers/national_rail.py
20
22
  src/openpublictransport/providers/nta.py
21
23
  src/openpublictransport/providers/nvbw.py
22
24
  src/openpublictransport/providers/nwl.py
@@ -24,6 +26,7 @@ src/openpublictransport/providers/oebb.py
24
26
  src/openpublictransport/providers/otp.py
25
27
  src/openpublictransport/providers/otp_base.py
26
28
  src/openpublictransport/providers/otp_custom.py
29
+ src/openpublictransport/providers/rejseplanen.py
27
30
  src/openpublictransport/providers/rmv.py
28
31
  src/openpublictransport/providers/rvv.py
29
32
  src/openpublictransport/providers/sbb.py
@@ -1,5 +0,0 @@
1
- """python-openpublictransport — public transport API library."""
2
-
3
- from .providers import get_all_provider_ids, get_provider, get_provider_class, register_provider
4
-
5
- __all__ = ["get_provider", "get_provider_class", "get_all_provider_ids", "register_provider"]