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,280 @@
1
+ """Trafiklab (Sweden) provider implementation."""
2
+
3
+ import asyncio
4
+ import logging
5
+ from datetime import datetime
6
+ from typing import Any, Dict, List, Optional, Union
7
+ from urllib.parse import quote
8
+ from zoneinfo import ZoneInfo
9
+
10
+ import aiohttp
11
+ from aiohttp import ClientConnectorError
12
+
13
+ from ..const import API_BASE_URL_TRAFIKLAB, PROVIDER_TRAFIKLAB_SE, TRAFIKLAB_TRANSPORTATION_TYPES
14
+ from ..models import UnifiedDeparture
15
+ from ..parsers import parse_departure_generic
16
+ from .base import BaseProvider
17
+
18
+ _LOGGER = logging.getLogger(__name__)
19
+
20
+
21
+ class TrafiklabProvider(BaseProvider):
22
+ """Trafiklab (Sweden) provider."""
23
+
24
+ @property
25
+ def provider_id(self) -> str:
26
+ return PROVIDER_TRAFIKLAB_SE
27
+
28
+ @property
29
+ def provider_name(self) -> str:
30
+ return "Trafiklab (Sweden)"
31
+
32
+ @property
33
+ def requires_api_key(self) -> bool:
34
+ return True
35
+
36
+ def get_timezone(self) -> str:
37
+ return "Europe/Stockholm"
38
+
39
+ async def fetch_departures(
40
+ self,
41
+ station_id: Optional[str],
42
+ place_dm: str,
43
+ name_dm: str,
44
+ departures_limit: int,
45
+ ) -> Optional[Dict[str, Any]]:
46
+ if not self.api_key:
47
+ _LOGGER.error("Trafiklab API key is required")
48
+ return None
49
+
50
+ if not station_id:
51
+ _LOGGER.error("Trafiklab requires a station ID")
52
+ return None
53
+
54
+ url = f"{API_BASE_URL_TRAFIKLAB}/departures/{station_id}"
55
+ params = {"key": self.api_key}
56
+
57
+ headers = {"User-Agent": "Mozilla/5.0 (compatible; OpenPublicTransport Trafiklab)"}
58
+
59
+ max_retries = 3
60
+ for attempt in range(1, max_retries + 1):
61
+ try:
62
+ async with self.session.get(
63
+ url, params=params, headers=headers, timeout=aiohttp.ClientTimeout(total=10)
64
+ ) as response:
65
+ if response.status == 200:
66
+ try:
67
+ json_data = await response.json()
68
+ if not isinstance(json_data, dict):
69
+ _LOGGER.warning("Trafiklab API returned non-dict response: %s", type(json_data))
70
+ return None
71
+
72
+ if "departures" not in json_data:
73
+ _LOGGER.debug("Trafiklab API response missing 'departures' field")
74
+ return {"stopEvents": []}
75
+
76
+ departures = json_data.get("departures", [])
77
+ _LOGGER.debug("Trafiklab API returned %d departures", len(departures))
78
+ stop_events = []
79
+
80
+ stockholm_tz = ZoneInfo("Europe/Stockholm")
81
+ now_stockholm = datetime.now(stockholm_tz)
82
+ offset = now_stockholm.strftime("%z")
83
+ offset_formatted = f"{offset[:3]}:{offset[3:]}" # +0100 -> +01:00
84
+
85
+ for dep in departures:
86
+ if not isinstance(dep, dict):
87
+ continue
88
+
89
+ scheduled_time = dep.get("scheduled")
90
+ realtime_time = dep.get("realtime")
91
+ route = dep.get("route") or {}
92
+ platform_data = dep.get("scheduled_platform") or dep.get("realtime_platform") or {}
93
+ transport_mode = route.get("transport_mode", "BUS") if route else "BUS"
94
+
95
+ destination_obj = route.get("destination") if route else None
96
+ destination_name = (
97
+ destination_obj.get("name", "Unknown")
98
+ if isinstance(destination_obj, dict)
99
+ else "Unknown"
100
+ )
101
+
102
+ if scheduled_time and "+" not in scheduled_time and "Z" not in scheduled_time:
103
+ scheduled_time = f"{scheduled_time}{offset_formatted}"
104
+ if realtime_time and "+" not in realtime_time and "Z" not in realtime_time:
105
+ realtime_time = f"{realtime_time}{offset_formatted}"
106
+
107
+ stop_event = {
108
+ "departureTimePlanned": scheduled_time,
109
+ "departureTimeEstimated": realtime_time or scheduled_time,
110
+ "transportation": {
111
+ "number": route.get("designation", "") if route else "",
112
+ "description": (
113
+ (route.get("name") or route.get("direction", "")) if route else ""
114
+ ),
115
+ "destination": {"name": destination_name},
116
+ "product": {"class": 0},
117
+ },
118
+ "platform": {"name": platform_data.get("designation", "") if platform_data else ""},
119
+ "realtimeStatus": ["MONITORED"] if dep.get("is_realtime") else [],
120
+ "transportMode": transport_mode,
121
+ }
122
+ stop_events.append(stop_event)
123
+
124
+ return {"stopEvents": stop_events}
125
+ except (ValueError, aiohttp.ContentTypeError) as e:
126
+ _LOGGER.warning("Trafiklab API returned invalid JSON: %s", e)
127
+ return None
128
+ except Exception as e:
129
+ _LOGGER.warning("Trafiklab API JSON parsing failed: %s", e)
130
+ return None
131
+ elif response.status == 404:
132
+ _LOGGER.warning("Trafiklab API endpoint not found (404)")
133
+ return None
134
+ elif response.status == 401:
135
+ _LOGGER.warning("Trafiklab API authentication failed (401) - check API key")
136
+ return None
137
+ elif response.status >= 500:
138
+ _LOGGER.warning(
139
+ "Trafiklab API server error (status %s) on attempt %d/%d",
140
+ response.status,
141
+ attempt,
142
+ max_retries,
143
+ )
144
+ if attempt < max_retries:
145
+ await asyncio.sleep(2**attempt)
146
+ continue
147
+ return None
148
+ else:
149
+ _LOGGER.warning(
150
+ "Trafiklab API returned status %s on attempt %d/%d", response.status, attempt, max_retries
151
+ )
152
+ if attempt < max_retries:
153
+ await asyncio.sleep(2**attempt)
154
+ continue
155
+
156
+ except asyncio.TimeoutError:
157
+ _LOGGER.warning("Trafiklab API timeout on attempt %d/%d", attempt, max_retries)
158
+ if attempt < max_retries:
159
+ await asyncio.sleep(2**attempt)
160
+ continue
161
+ except ClientConnectorError as e:
162
+ _LOGGER.warning("Trafiklab API connection error on attempt %d/%d: %s", attempt, max_retries, e)
163
+ if attempt < max_retries:
164
+ await asyncio.sleep(2**attempt)
165
+ continue
166
+ except Exception as e:
167
+ _LOGGER.warning("Attempt %d/%d failed: %s", attempt, max_retries, e)
168
+ if attempt < max_retries:
169
+ await asyncio.sleep(2**attempt)
170
+ continue
171
+
172
+ return None
173
+
174
+ def parse_departure(
175
+ self, stop: Dict[str, Any], tz: Union[ZoneInfo, Any], now: datetime
176
+ ) -> Optional[UnifiedDeparture]:
177
+ transport_mode = stop.get("transportMode", "BUS")
178
+ transport_type = TRAFIKLAB_TRANSPORTATION_TYPES.get(transport_mode, "bus")
179
+
180
+ return parse_departure_generic(
181
+ stop,
182
+ tz,
183
+ now,
184
+ get_transport_type_fn=lambda t: transport_type,
185
+ get_platform_fn=lambda s: (
186
+ s.get("platform", {}).get("name", "")
187
+ if isinstance(s.get("platform"), dict)
188
+ else str(s.get("platform", ""))
189
+ ),
190
+ get_realtime_fn=lambda s, est, plan: est != plan if est and plan else False,
191
+ )
192
+
193
+ async def search_stops(self, search_term: str) -> List[Dict[str, Any]]:
194
+ if not self.api_key:
195
+ _LOGGER.error("Trafiklab API key is required for stop search")
196
+ return []
197
+
198
+ encoded_search = quote(search_term, safe="")
199
+ url = f"{API_BASE_URL_TRAFIKLAB}/stops/name/{encoded_search}"
200
+ params = {"key": self.api_key}
201
+
202
+ max_retries = 3
203
+ for attempt in range(1, max_retries + 1):
204
+ try:
205
+ async with self.session.get(url, params=params, timeout=aiohttp.ClientTimeout(total=10)) as response:
206
+ if response.status == 200:
207
+ try:
208
+ data = await response.json()
209
+ except (ValueError, aiohttp.ContentTypeError) as e:
210
+ _LOGGER.error("Invalid JSON response from Trafiklab API: %s", e)
211
+ if attempt < max_retries:
212
+ await asyncio.sleep(2**attempt)
213
+ continue
214
+ return []
215
+
216
+ if not isinstance(data, dict):
217
+ _LOGGER.error("Trafiklab API returned non-dict response: %s", type(data))
218
+ if attempt < max_retries:
219
+ await asyncio.sleep(2**attempt)
220
+ continue
221
+ return []
222
+
223
+ stop_groups = data.get("stop_groups", [])
224
+ results = []
225
+
226
+ for stop_group in stop_groups:
227
+ if not isinstance(stop_group, dict):
228
+ continue
229
+
230
+ stops = stop_group.get("stops", [])
231
+ place = None
232
+ if stops and isinstance(stops[0], dict):
233
+ stop_name = stop_group.get("name", "")
234
+ place = stop_name.split(",")[-1].strip() if "," in stop_name else None
235
+
236
+ result = {
237
+ "id": stop_group.get("id", ""),
238
+ "name": stop_group.get("name", ""),
239
+ "place": place or "",
240
+ "area_type": stop_group.get("area_type", ""),
241
+ "transport_modes": stop_group.get("transport_modes", []),
242
+ }
243
+ results.append(result)
244
+
245
+ return results
246
+ elif response.status == 401:
247
+ _LOGGER.error("Trafiklab API authentication failed (401) - check API key")
248
+ return []
249
+ elif response.status == 404:
250
+ _LOGGER.warning("Trafiklab API endpoint not found (404)")
251
+ return []
252
+ elif response.status >= 500:
253
+ _LOGGER.warning(
254
+ "Trafiklab API server error (status %s) on attempt %d/%d",
255
+ response.status,
256
+ attempt,
257
+ max_retries,
258
+ )
259
+ if attempt < max_retries:
260
+ await asyncio.sleep(2**attempt)
261
+ continue
262
+ else:
263
+ _LOGGER.warning(
264
+ "Trafiklab API returned status %s on attempt %d/%d", response.status, attempt, max_retries
265
+ )
266
+ if attempt < max_retries:
267
+ await asyncio.sleep(2**attempt)
268
+ continue
269
+ except asyncio.TimeoutError:
270
+ _LOGGER.error("Trafiklab API request timeout")
271
+ if attempt < max_retries:
272
+ await asyncio.sleep(2**attempt)
273
+ continue
274
+ except Exception as e:
275
+ _LOGGER.error("Error searching stops: %s", e, exc_info=True)
276
+ if attempt < max_retries:
277
+ await asyncio.sleep(2**attempt)
278
+ continue
279
+
280
+ return []
@@ -0,0 +1,198 @@
1
+ """Transitous (MOTIS2) 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_TRANSITOUS
12
+ from ..models import UnifiedDeparture
13
+ from .base import BaseProvider
14
+
15
+ _LOGGER = logging.getLogger(__name__)
16
+
17
+ API_BASE = "https://api.transitous.org/api"
18
+ USER_AGENT = "OpenPublicTransport/1.0 (github.com/NerdySoftPaw/openpublictransport)"
19
+
20
+ MODE_MAPPING = {
21
+ "HIGHSPEED_RAIL": "train",
22
+ "LONG_DISTANCE": "train",
23
+ "COACH": "bus",
24
+ "NIGHT_RAIL": "train",
25
+ "REGIONAL_FAST_RAIL": "train",
26
+ "REGIONAL_RAIL": "train",
27
+ "SUBURBAN": "train",
28
+ "SUBWAY": "subway",
29
+ "TRAM": "tram",
30
+ "BUS": "bus",
31
+ "FERRY": "ferry",
32
+ "ODM": "bus",
33
+ "FLEXIBLE": "bus",
34
+ "FUNICULAR": "train",
35
+ "GONDOLA": "train",
36
+ "CABLE_CAR": "train",
37
+ "MONORAIL": "train",
38
+ "TROLLEYBUS": "bus",
39
+ }
40
+
41
+
42
+ def _parse_dt(s: str) -> Optional[datetime]:
43
+ try:
44
+ return datetime.fromisoformat(s)
45
+ except (ValueError, TypeError):
46
+ return None
47
+
48
+
49
+ class TransitousProvider(BaseProvider):
50
+ """Transitous provider — worldwide public transport via MOTIS2."""
51
+
52
+ @property
53
+ def provider_id(self) -> str:
54
+ return PROVIDER_TRANSITOUS
55
+
56
+ @property
57
+ def provider_name(self) -> str:
58
+ return "Transitous (Weltweit)"
59
+
60
+ def get_timezone(self) -> str:
61
+ return "Europe/Berlin"
62
+
63
+ async def fetch_departures(
64
+ self,
65
+ station_id: Optional[str],
66
+ place_dm: str,
67
+ name_dm: str,
68
+ departures_limit: int,
69
+ ) -> Optional[Dict[str, Any]]:
70
+ if not station_id:
71
+ _LOGGER.warning("Transitous provider requires a station_id")
72
+ return None
73
+
74
+ url = f"{API_BASE}/v5/stoptimes?stopId={quote(station_id, safe='')}&n={departures_limit}"
75
+
76
+ try:
77
+ async with self.session.get(
78
+ url, headers={"User-Agent": USER_AGENT}, timeout=aiohttp.ClientTimeout(total=15)
79
+ ) as response:
80
+ if response.status == 200:
81
+ data = await response.json()
82
+ if not isinstance(data, dict):
83
+ return None
84
+ return {"stopEvents": data.get("stopTimes", [])}
85
+ else:
86
+ _LOGGER.warning("Transitous API returned status %s", response.status)
87
+ except aiohttp.ClientError as e:
88
+ _LOGGER.warning("Transitous API request failed: %s", e)
89
+ except Exception as e:
90
+ _LOGGER.warning("Transitous API error: %s", e)
91
+
92
+ return None
93
+
94
+ def parse_departure(
95
+ self, stop: Dict[str, Any], tz: Union[ZoneInfo, Any], now: datetime
96
+ ) -> Optional[UnifiedDeparture]:
97
+ try:
98
+ place = stop.get("place", {})
99
+ dep_str = place.get("departure") or place.get("scheduledDeparture")
100
+ sched_str = place.get("scheduledDeparture")
101
+
102
+ if not dep_str:
103
+ return None
104
+
105
+ dep_dt = _parse_dt(dep_str)
106
+ sched_dt = _parse_dt(sched_str) if sched_str else dep_dt
107
+ if not dep_dt or not sched_dt:
108
+ return None
109
+
110
+ stop_tz_str = place.get("tz")
111
+ if stop_tz_str:
112
+ try:
113
+ stop_tz = ZoneInfo(stop_tz_str)
114
+ except (KeyError, ValueError):
115
+ stop_tz = tz
116
+ else:
117
+ stop_tz = tz
118
+
119
+ dep_local = dep_dt.astimezone(stop_tz)
120
+ sched_local = sched_dt.astimezone(stop_tz)
121
+
122
+ delay_minutes = int((dep_local - sched_local).total_seconds() / 60)
123
+
124
+ mode = stop.get("mode", "")
125
+ transport_type = MODE_MAPPING.get(mode, "unknown")
126
+
127
+ line = stop.get("routeShortName") or stop.get("displayName", "")
128
+ destination = stop.get("headsign", "Unknown")
129
+
130
+ track = place.get("track", "")
131
+ sched_track = place.get("scheduledTrack", "")
132
+ platform_changed = bool(track and sched_track and track != sched_track)
133
+
134
+ time_diff = dep_local - now
135
+ minutes_until = max(0, int(time_diff.total_seconds() / 60))
136
+
137
+ is_realtime = stop.get("realTime", False)
138
+ is_cancelled = stop.get("cancelled", False) or stop.get("tripCancelled", False)
139
+
140
+ notices = []
141
+ if is_cancelled:
142
+ notices.append("Fällt aus / Cancelled")
143
+
144
+ agency = stop.get("agencyName", "")
145
+
146
+ return UnifiedDeparture(
147
+ line=line,
148
+ destination=destination,
149
+ departure_time=dep_local.strftime("%H:%M"),
150
+ planned_time=sched_local.strftime("%H:%M"),
151
+ delay=delay_minutes,
152
+ platform=track,
153
+ transportation_type=transport_type,
154
+ is_realtime=is_realtime,
155
+ minutes_until_departure=minutes_until,
156
+ departure_time_obj=dep_local,
157
+ description=stop.get("routeLongName"),
158
+ agency=agency if agency else None,
159
+ notices=notices if notices else None,
160
+ planned_platform=sched_track if platform_changed else None,
161
+ platform_changed=platform_changed,
162
+ )
163
+ except Exception as e:
164
+ _LOGGER.debug("Error parsing Transitous departure: %s", e)
165
+ return None
166
+
167
+ async def search_stops(self, search_term: str) -> List[Dict[str, Any]]:
168
+ url = f"{API_BASE}/v1/geocode?text={quote(search_term, safe='')}&type=STOP"
169
+
170
+ try:
171
+ async with self.session.get(
172
+ url, headers={"User-Agent": USER_AGENT}, timeout=aiohttp.ClientTimeout(total=10)
173
+ ) as response:
174
+ if response.status == 200:
175
+ data = await response.json()
176
+ if not isinstance(data, list):
177
+ return []
178
+
179
+ results = []
180
+ for location in data:
181
+ if not isinstance(location, dict):
182
+ continue
183
+ name = location.get("name", "")
184
+ results.append(
185
+ {
186
+ "id": location.get("id", ""),
187
+ "name": name,
188
+ "place": "",
189
+ "area_type": "stop",
190
+ }
191
+ )
192
+ return results
193
+ else:
194
+ _LOGGER.error("Transitous API returned status %s", response.status)
195
+ except Exception as e:
196
+ _LOGGER.error("Error searching Transitous stops: %s", e)
197
+
198
+ return []