python-openpublictransport 0.1.0__py3-none-any.whl

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 (42) hide show
  1. openpublictransport/__init__.py +5 -0
  2. openpublictransport/const.py +75 -0
  3. openpublictransport/models.py +95 -0
  4. openpublictransport/parsers.py +122 -0
  5. openpublictransport/providers/__init__.py +127 -0
  6. openpublictransport/providers/avv.py +44 -0
  7. openpublictransport/providers/base.py +77 -0
  8. openpublictransport/providers/beg.py +46 -0
  9. openpublictransport/providers/bsvg.py +44 -0
  10. openpublictransport/providers/bvg.py +32 -0
  11. openpublictransport/providers/db.py +39 -0
  12. openpublictransport/providers/ding.py +44 -0
  13. openpublictransport/providers/efa_base.py +209 -0
  14. openpublictransport/providers/fptf_base.py +169 -0
  15. openpublictransport/providers/gtfsde.py +21 -0
  16. openpublictransport/providers/hvv.py +40 -0
  17. openpublictransport/providers/kvv.py +38 -0
  18. openpublictransport/providers/mvv.py +48 -0
  19. openpublictransport/providers/nta.py +288 -0
  20. openpublictransport/providers/nvbw.py +44 -0
  21. openpublictransport/providers/nwl.py +46 -0
  22. openpublictransport/providers/oebb.py +182 -0
  23. openpublictransport/providers/otp.py +378 -0
  24. openpublictransport/providers/otp_base.py +268 -0
  25. openpublictransport/providers/otp_custom.py +22 -0
  26. openpublictransport/providers/rmv.py +272 -0
  27. openpublictransport/providers/rvv.py +43 -0
  28. openpublictransport/providers/sbb.py +174 -0
  29. openpublictransport/providers/trafiklab.py +280 -0
  30. openpublictransport/providers/transitous.py +198 -0
  31. openpublictransport/providers/trias_base.py +323 -0
  32. openpublictransport/providers/vagfr.py +44 -0
  33. openpublictransport/providers/vbn.py +73 -0
  34. openpublictransport/providers/vgn.py +46 -0
  35. openpublictransport/providers/vrn.py +51 -0
  36. openpublictransport/providers/vrr.py +51 -0
  37. openpublictransport/providers/vvo.py +45 -0
  38. openpublictransport/providers/vvs.py +48 -0
  39. python_openpublictransport-0.1.0.dist-info/METADATA +14 -0
  40. python_openpublictransport-0.1.0.dist-info/RECORD +42 -0
  41. python_openpublictransport-0.1.0.dist-info/WHEEL +5 -0
  42. python_openpublictransport-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,288 @@
1
+ """NTA (National Transport Authority, Ireland) provider implementation."""
2
+
3
+ import asyncio
4
+ import logging
5
+ from datetime import datetime, timedelta, timezone
6
+ from typing import Any, Dict, List, Optional, Union
7
+ from zoneinfo import ZoneInfo
8
+
9
+ import aiohttp
10
+ from aiohttp import ClientConnectorError
11
+
12
+ from ..const import API_BASE_URL_NTA_GTFSR, NTA_TRANSPORTATION_TYPES, PROVIDER_NTA_IE
13
+ from ..models import UnifiedDeparture
14
+ from ..parsers import parse_departure_generic
15
+ from .base import BaseProvider
16
+
17
+ _LOGGER = logging.getLogger(__name__)
18
+
19
+
20
+ class NTAProvider(BaseProvider):
21
+ """NTA (National Transport Authority, Ireland) provider."""
22
+
23
+ @property
24
+ def provider_id(self) -> str:
25
+ return PROVIDER_NTA_IE
26
+
27
+ @property
28
+ def provider_name(self) -> str:
29
+ return "NTA (Ireland)"
30
+
31
+ @property
32
+ def requires_api_key(self) -> bool:
33
+ return True
34
+
35
+ def get_timezone(self) -> str:
36
+ return "Europe/Dublin"
37
+
38
+ async def cleanup(self) -> None:
39
+ pass
40
+
41
+ async def fetch_departures(
42
+ self,
43
+ station_id: Optional[str],
44
+ place_dm: str,
45
+ name_dm: str,
46
+ departures_limit: int,
47
+ ) -> Optional[Dict[str, Any]]:
48
+ if not self.api_key:
49
+ _LOGGER.error("NTA API key is required")
50
+ return None
51
+
52
+ if not station_id:
53
+ _LOGGER.error("NTA requires a station ID (stop_id)")
54
+ return None
55
+
56
+ url = f"{API_BASE_URL_NTA_GTFSR}/v2/TripUpdates"
57
+ params = {"format": "json"}
58
+
59
+ headers = {
60
+ "User-Agent": "Mozilla/5.0 (compatible; OpenPublicTransport NTA)",
61
+ "x-api-key": self.api_key,
62
+ }
63
+
64
+ max_retries = 3
65
+ current_api_key = self.api_key
66
+ for attempt in range(1, max_retries + 1):
67
+ try:
68
+ headers["x-api-key"] = current_api_key
69
+
70
+ async with self.session.get(
71
+ url, params=params, headers=headers, timeout=aiohttp.ClientTimeout(total=15)
72
+ ) as response:
73
+ if response.status == 200:
74
+ try:
75
+ json_data = await response.json()
76
+
77
+ if not isinstance(json_data, dict):
78
+ _LOGGER.warning("NTA API returned non-dict response: %s", type(json_data))
79
+ return None
80
+
81
+ entities = json_data.get("entity", [])
82
+ if not isinstance(entities, list):
83
+ _LOGGER.debug("NTA API response missing or invalid 'entity' field")
84
+ return {"stopEvents": []}
85
+
86
+ entity_count = len(entities)
87
+ if entity_count == 0:
88
+ _LOGGER.debug("NTA API returned empty entities list")
89
+ return {"stopEvents": []}
90
+
91
+ _LOGGER.info(
92
+ "NTA API returned %d entities (processing for stop %s)", entity_count, station_id
93
+ )
94
+
95
+ stop_events = []
96
+ target_stop_id = station_id
97
+ max_departures = departures_limit * 3
98
+ processed_entities = 0
99
+
100
+ now = datetime.now(timezone.utc)
101
+
102
+ for entity in entities:
103
+ if not isinstance(entity, dict):
104
+ continue
105
+
106
+ trip_update = entity.get("trip_update")
107
+ if not isinstance(trip_update, dict):
108
+ continue
109
+
110
+ stop_time_updates = trip_update.get("stop_time_update", [])
111
+ if not isinstance(stop_time_updates, list) or len(stop_time_updates) == 0:
112
+ continue
113
+
114
+ matching_stop_time = None
115
+ for stop_time_update in stop_time_updates:
116
+ if not isinstance(stop_time_update, dict):
117
+ continue
118
+ stop_id = stop_time_update.get("stop_id")
119
+ if stop_id == target_stop_id:
120
+ matching_stop_time = stop_time_update
121
+ break
122
+
123
+ if matching_stop_time is None:
124
+ continue
125
+
126
+ trip = trip_update.get("trip", {})
127
+ if not isinstance(trip, dict):
128
+ continue
129
+
130
+ stop_time_update = matching_stop_time
131
+
132
+ route_id = trip.get("route_id", "")
133
+ trip_id = trip.get("trip_id", "")
134
+ stop_id = stop_time_update.get("stop_id", target_stop_id)
135
+
136
+ route_short_name = route_id.split("_")[0] if route_id else ""
137
+
138
+ route_type = 3
139
+ if route_short_name and route_short_name.lower() in ["red", "green", "luas"]:
140
+ route_type = 0
141
+
142
+ departure = stop_time_update.get("departure", {})
143
+ arrival = stop_time_update.get("arrival", {})
144
+ delay_seconds = departure.get("delay") or arrival.get("delay") or 0
145
+
146
+ schedule_relationship = stop_time_update.get("schedule_relationship", "SCHEDULED")
147
+ if schedule_relationship in ["CANCELED", "SKIPPED"]:
148
+ continue
149
+
150
+ destination = route_short_name or "Unknown"
151
+
152
+ departure_time = departure.get("time")
153
+ arrival_time = arrival.get("time")
154
+
155
+ if departure_time:
156
+ try:
157
+ planned_time = datetime.fromtimestamp(departure_time, tz=now.tzinfo)
158
+ estimated_time = planned_time + timedelta(seconds=delay_seconds)
159
+ except (ValueError, OSError):
160
+ planned_time = now
161
+ estimated_time = now + timedelta(seconds=delay_seconds)
162
+ elif arrival_time:
163
+ try:
164
+ planned_time = datetime.fromtimestamp(arrival_time, tz=now.tzinfo)
165
+ estimated_time = planned_time + timedelta(seconds=delay_seconds)
166
+ except (ValueError, OSError):
167
+ planned_time = now
168
+ estimated_time = now + timedelta(seconds=delay_seconds)
169
+ else:
170
+ planned_time = now
171
+ estimated_time = now + timedelta(seconds=delay_seconds)
172
+
173
+ planned_time_str = planned_time.strftime("%Y-%m-%dT%H:%M:%S%z")
174
+ estimated_time_str = estimated_time.strftime("%Y-%m-%dT%H:%M:%S%z")
175
+
176
+ platform = (
177
+ stop_time_update.get("platform_code") or stop_time_update.get("platform") or ""
178
+ )
179
+
180
+ stop_event = {
181
+ "departureTimePlanned": planned_time_str,
182
+ "departureTimeEstimated": estimated_time_str,
183
+ "transportation": {
184
+ "number": route_short_name,
185
+ "description": "",
186
+ "destination": {"name": destination},
187
+ "product": {"class": route_type},
188
+ },
189
+ "platform": {"name": platform},
190
+ "realtimeStatus": ["MONITORED"] if delay_seconds != 0 else [],
191
+ "route_id": route_id,
192
+ "trip_id": trip_id,
193
+ "stop_id": stop_id,
194
+ "delay_seconds": delay_seconds,
195
+ }
196
+ stop_events.append(stop_event)
197
+ processed_entities += 1
198
+
199
+ if len(stop_events) >= max_departures:
200
+ break
201
+
202
+ _LOGGER.info(
203
+ "NTA: Processed %d/%d entities, found %d departures for stop %s",
204
+ processed_entities,
205
+ entity_count,
206
+ len(stop_events),
207
+ target_stop_id,
208
+ )
209
+ return {"stopEvents": stop_events}
210
+
211
+ except (ValueError, aiohttp.ContentTypeError) as e:
212
+ _LOGGER.warning("NTA API returned invalid JSON: %s", e)
213
+ return None
214
+ except Exception as e:
215
+ _LOGGER.warning("NTA API JSON parsing failed: %s", e, exc_info=True)
216
+ return None
217
+ elif response.status == 404:
218
+ _LOGGER.warning("NTA API endpoint not found (404)")
219
+ return None
220
+ elif response.status == 401:
221
+ if self.api_key_secondary and current_api_key == self.api_key:
222
+ _LOGGER.info("NTA Primary API key failed (401), trying Secondary key...")
223
+ current_api_key = self.api_key_secondary
224
+ continue
225
+ _LOGGER.warning("NTA API authentication failed (401) - check API key(s)")
226
+ return None
227
+ elif response.status >= 500:
228
+ _LOGGER.warning(
229
+ "NTA API server error (status %s) on attempt %d/%d",
230
+ response.status,
231
+ attempt,
232
+ max_retries,
233
+ )
234
+ if attempt < max_retries:
235
+ await asyncio.sleep(2**attempt)
236
+ continue
237
+ return None
238
+ else:
239
+ _LOGGER.warning(
240
+ "NTA API returned status %s on attempt %d/%d", response.status, attempt, max_retries
241
+ )
242
+ if attempt < max_retries:
243
+ await asyncio.sleep(2**attempt)
244
+ continue
245
+
246
+ except asyncio.TimeoutError:
247
+ _LOGGER.warning("NTA API timeout on attempt %d/%d", attempt, max_retries)
248
+ if attempt < max_retries:
249
+ await asyncio.sleep(2**attempt)
250
+ continue
251
+ except ClientConnectorError as e:
252
+ _LOGGER.warning("NTA API connection error on attempt %d/%d: %s", attempt, max_retries, e)
253
+ if attempt < max_retries:
254
+ await asyncio.sleep(2**attempt)
255
+ continue
256
+ except Exception as e:
257
+ _LOGGER.warning("NTA API attempt %d/%d failed: %s", attempt, max_retries, e)
258
+ if attempt < max_retries:
259
+ await asyncio.sleep(2**attempt)
260
+ continue
261
+
262
+ return None
263
+
264
+ def parse_departure(
265
+ self, stop: Dict[str, Any], tz: Union[ZoneInfo, Any], now: datetime
266
+ ) -> Optional[UnifiedDeparture]:
267
+ transportation = stop.get("transportation", {})
268
+ product = transportation.get("product", {})
269
+ route_type = product.get("class", 3)
270
+ transport_type = NTA_TRANSPORTATION_TYPES.get(route_type, "bus")
271
+
272
+ return parse_departure_generic(
273
+ stop,
274
+ tz,
275
+ now,
276
+ get_transport_type_fn=lambda t: transport_type,
277
+ get_platform_fn=lambda s: (
278
+ s.get("platform", {}).get("name", "")
279
+ if isinstance(s.get("platform"), dict)
280
+ else str(s.get("platform", ""))
281
+ ),
282
+ get_realtime_fn=lambda s, est, plan: "MONITORED" in s.get("realtimeStatus", []),
283
+ )
284
+
285
+ async def search_stops(self, search_term: str) -> List[Dict[str, Any]]:
286
+ """NTA stop search is not available without GTFS Static data."""
287
+ _LOGGER.warning("NTA stop search is not available without GTFS Static data. Please enter the stop_id directly.")
288
+ return []
@@ -0,0 +1,44 @@
1
+ """NVBW (Nahverkehrsgesellschaft Baden-Württemberg) provider implementation."""
2
+
3
+ from typing import Any, Callable, Dict, Optional
4
+
5
+ from ..const import PROVIDER_NVBW
6
+ from .efa_base import EFABaseProvider
7
+
8
+
9
+ class NVBWProvider(EFABaseProvider):
10
+ """NVBW (Baden-Württemberg) provider."""
11
+
12
+ @property
13
+ def provider_id(self) -> str:
14
+ return PROVIDER_NVBW
15
+
16
+ @property
17
+ def provider_name(self) -> str:
18
+ return "NVBW (Baden-Württemberg)"
19
+
20
+ @property
21
+ def dm_base_url(self) -> str:
22
+ return "https://www.efa-bw.de/nvbw/XML_DM_REQUEST"
23
+
24
+ @property
25
+ def sf_base_url(self) -> str:
26
+ return "https://www.efa-bw.de/nvbw/XML_STOPFINDER_REQUEST"
27
+
28
+ def get_timezone(self) -> str:
29
+ return "Europe/Berlin"
30
+
31
+ def get_transport_type_mapping(self) -> Dict[Any, str]:
32
+ return {
33
+ 0: "train", # High-speed trains (ICE, IC, EC)
34
+ 1: "train", # Regional trains (RE, RB)
35
+ 4: "tram", # Tram/Streetcar
36
+ 5: "bus", # City bus
37
+ 6: "bus", # Regional bus
38
+ 7: "bus", # Express bus
39
+ 8: "bus", # Night bus
40
+ 13: "train", # Regionalzug (RE)
41
+ }
42
+
43
+ def get_realtime_fn(self) -> Callable[[Dict[str, Any], Optional[str], Optional[str]], bool]:
44
+ return lambda s, est, plan: est != plan if est and plan else False
@@ -0,0 +1,46 @@
1
+ """NWL (Nahverkehr Westfalen-Lippe) provider implementation."""
2
+
3
+ from typing import Any, Callable, Dict, Optional
4
+
5
+ from ..const import PROVIDER_NWL
6
+ from .efa_base import EFABaseProvider
7
+
8
+
9
+ class NWLProvider(EFABaseProvider):
10
+ """NWL (Westfalen-Lippe) provider."""
11
+
12
+ @property
13
+ def provider_id(self) -> str:
14
+ return PROVIDER_NWL
15
+
16
+ @property
17
+ def provider_name(self) -> str:
18
+ return "NWL (Westfalen-Lippe)"
19
+
20
+ @property
21
+ def dm_base_url(self) -> str:
22
+ return "https://westfalenfahrplan.de/nwl-efa/XML_DM_REQUEST"
23
+
24
+ @property
25
+ def sf_base_url(self) -> str:
26
+ return "https://westfalenfahrplan.de/nwl-efa/XML_STOPFINDER_REQUEST"
27
+
28
+ def get_timezone(self) -> str:
29
+ return "Europe/Berlin"
30
+
31
+ def get_transport_type_mapping(self) -> Dict[Any, str]:
32
+ return {
33
+ 0: "train", # High-speed trains (ICE, IC, EC)
34
+ 1: "train", # Regional trains (RE, RB)
35
+ 2: "subway", # U-Bahn
36
+ 3: "subway", # U-Bahn variant
37
+ 4: "tram", # Tram/Streetcar
38
+ 5: "bus", # City bus
39
+ 6: "bus", # Regional bus
40
+ 7: "bus", # Express bus
41
+ 8: "bus", # Night bus
42
+ 13: "train", # Regionalzug (RE)
43
+ }
44
+
45
+ def get_realtime_fn(self) -> Callable[[Dict[str, Any], Optional[str], Optional[str]], bool]:
46
+ return lambda s, est, plan: est != plan if est and plan else False
@@ -0,0 +1,182 @@
1
+ """ÖBB (Österreichische Bundesbahnen) provider implementation."""
2
+
3
+ import logging
4
+ from datetime import datetime
5
+ from typing import Any, Dict, List, Optional, Union
6
+ from urllib.parse import quote
7
+ from zoneinfo import ZoneInfo
8
+
9
+ import aiohttp
10
+
11
+ from ..const import PROVIDER_OEBB
12
+ from ..models import UnifiedDeparture
13
+ from .base import BaseProvider
14
+
15
+ _LOGGER = logging.getLogger(__name__)
16
+
17
+ API_BASE = "https://oebb.macistry.com/api"
18
+
19
+ PRODUCT_MAPPING = {
20
+ "nationalExpress": "train",
21
+ "national": "train",
22
+ "interregional": "train",
23
+ "regional": "train",
24
+ "suburban": "train",
25
+ "subway": "subway",
26
+ "tram": "tram",
27
+ "bus": "bus",
28
+ "ferry": "ferry",
29
+ "onCall": "bus",
30
+ }
31
+
32
+
33
+ def _parse_dt(s: str) -> Optional[datetime]:
34
+ try:
35
+ return datetime.fromisoformat(s)
36
+ except (ValueError, TypeError):
37
+ return None
38
+
39
+
40
+ class OeBBProvider(BaseProvider):
41
+ """ÖBB (Austria) provider using FPTF REST API."""
42
+
43
+ @property
44
+ def provider_id(self) -> str:
45
+ return PROVIDER_OEBB
46
+
47
+ @property
48
+ def provider_name(self) -> str:
49
+ return "ÖBB (Österreich)"
50
+
51
+ def get_timezone(self) -> str:
52
+ return "Europe/Vienna"
53
+
54
+ async def fetch_departures(
55
+ self,
56
+ station_id: Optional[str],
57
+ place_dm: str,
58
+ name_dm: str,
59
+ departures_limit: int,
60
+ ) -> Optional[Dict[str, Any]]:
61
+ if not station_id:
62
+ _LOGGER.warning("ÖBB provider requires a station_id")
63
+ return None
64
+
65
+ url = f"{API_BASE}/stops/{station_id}/departures?results={departures_limit}&duration=120"
66
+
67
+ try:
68
+ async with self.session.get(url, timeout=aiohttp.ClientTimeout(total=15)) as response:
69
+ if response.status == 200:
70
+ data = await response.json()
71
+ if not isinstance(data, dict) or "departures" not in data:
72
+ return {"stopEvents": []}
73
+ return {"stopEvents": data["departures"]}
74
+ else:
75
+ _LOGGER.warning("ÖBB API returned status %s", response.status)
76
+ except aiohttp.ClientError as e:
77
+ _LOGGER.warning("ÖBB API request failed: %s", e)
78
+ except Exception as e:
79
+ _LOGGER.warning("ÖBB API error: %s", e)
80
+
81
+ return None
82
+
83
+ def parse_departure(
84
+ self, stop: Dict[str, Any], tz: Union[ZoneInfo, Any], now: datetime
85
+ ) -> Optional[UnifiedDeparture]:
86
+ try:
87
+ when_str = stop.get("when") or stop.get("plannedWhen")
88
+ planned_str = stop.get("plannedWhen")
89
+ if not when_str or not planned_str:
90
+ return None
91
+
92
+ when = _parse_dt(when_str)
93
+ planned = _parse_dt(planned_str)
94
+ if not when or not planned:
95
+ return None
96
+
97
+ when_local = when.astimezone(tz)
98
+ planned_local = planned.astimezone(tz)
99
+
100
+ delay_seconds = stop.get("delay") or 0
101
+ delay_minutes = int(delay_seconds / 60)
102
+
103
+ line_info = stop.get("line", {})
104
+ line_name = line_info.get("name", "")
105
+ product = line_info.get("product", "")
106
+ transport_type = PRODUCT_MAPPING.get(product, "unknown")
107
+
108
+ destination_info = stop.get("destination", {})
109
+ destination = destination_info.get("name", stop.get("direction", "Unknown"))
110
+
111
+ platform = stop.get("platform") or ""
112
+ planned_platform = stop.get("plannedPlatform") or ""
113
+ platform_changed = bool(platform and planned_platform and platform != planned_platform)
114
+
115
+ time_diff = when_local - now
116
+ minutes_until = max(0, int(time_diff.total_seconds() / 60))
117
+
118
+ is_realtime = stop.get("prognosisType") is not None
119
+
120
+ notices = []
121
+ for remark in stop.get("remarks", []):
122
+ if isinstance(remark, dict) and remark.get("type") == "warning":
123
+ text = remark.get("text") or remark.get("summary", "")
124
+ if text:
125
+ notices.append(text)
126
+
127
+ operator = line_info.get("operator", {})
128
+ agency = operator.get("name") if isinstance(operator, dict) else None
129
+
130
+ return UnifiedDeparture(
131
+ line=line_name,
132
+ destination=destination,
133
+ departure_time=when_local.strftime("%H:%M"),
134
+ planned_time=planned_local.strftime("%H:%M"),
135
+ delay=delay_minutes,
136
+ platform=platform,
137
+ transportation_type=transport_type,
138
+ is_realtime=is_realtime,
139
+ minutes_until_departure=minutes_until,
140
+ departure_time_obj=when_local,
141
+ description=stop.get("direction"),
142
+ agency=agency,
143
+ notices=notices if notices else None,
144
+ planned_platform=planned_platform if platform_changed else None,
145
+ platform_changed=platform_changed,
146
+ )
147
+ except Exception as e:
148
+ _LOGGER.debug("Error parsing ÖBB departure: %s", e)
149
+ return None
150
+
151
+ async def search_stops(self, search_term: str) -> List[Dict[str, Any]]:
152
+ url = f"{API_BASE}/locations?query={quote(search_term, safe='')}&results=15"
153
+
154
+ try:
155
+ async with self.session.get(url, timeout=aiohttp.ClientTimeout(total=10)) as response:
156
+ if response.status == 200:
157
+ data = await response.json()
158
+ if not isinstance(data, list):
159
+ return []
160
+
161
+ results = []
162
+ for location in data:
163
+ if not isinstance(location, dict):
164
+ continue
165
+ if location.get("type") != "stop":
166
+ continue
167
+ name = location.get("name", "")
168
+ results.append(
169
+ {
170
+ "id": str(location.get("id", "")),
171
+ "name": name,
172
+ "place": "",
173
+ "area_type": "stop",
174
+ }
175
+ )
176
+ return results
177
+ else:
178
+ _LOGGER.error("ÖBB API returned status %s", response.status)
179
+ except Exception as e:
180
+ _LOGGER.error("Error searching ÖBB stops: %s", e)
181
+
182
+ return []