python-openpublictransport 0.1.17__tar.gz → 0.2.0__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 (75) hide show
  1. {python_openpublictransport-0.1.17 → python_openpublictransport-0.2.0}/PKG-INFO +1 -1
  2. {python_openpublictransport-0.1.17 → python_openpublictransport-0.2.0}/pyproject.toml +1 -1
  3. {python_openpublictransport-0.1.17 → python_openpublictransport-0.2.0}/src/openpublictransport/__init__.py +13 -1
  4. python_openpublictransport-0.2.0/src/openpublictransport/exceptions.py +81 -0
  5. python_openpublictransport-0.2.0/src/openpublictransport/providers/base.py +199 -0
  6. {python_openpublictransport-0.1.17 → python_openpublictransport-0.2.0}/src/openpublictransport/providers/efa_base.py +41 -88
  7. {python_openpublictransport-0.1.17 → python_openpublictransport-0.2.0}/src/openpublictransport/providers/entur.py +8 -31
  8. {python_openpublictransport-0.1.17 → python_openpublictransport-0.2.0}/src/openpublictransport/providers/fptf_base.py +33 -51
  9. {python_openpublictransport-0.1.17 → python_openpublictransport-0.2.0}/src/openpublictransport/providers/hafas_base.py +2 -28
  10. {python_openpublictransport-0.1.17 → python_openpublictransport-0.2.0}/src/openpublictransport/providers/hafas_mgate_base.py +23 -28
  11. {python_openpublictransport-0.1.17 → python_openpublictransport-0.2.0}/src/openpublictransport/providers/hvv_gti.py +21 -37
  12. {python_openpublictransport-0.1.17 → python_openpublictransport-0.2.0}/src/openpublictransport/providers/national_rail.py +35 -41
  13. python_openpublictransport-0.2.0/src/openpublictransport/providers/nta.py +243 -0
  14. {python_openpublictransport-0.1.17 → python_openpublictransport-0.2.0}/src/openpublictransport/providers/otp.py +7 -15
  15. {python_openpublictransport-0.1.17 → python_openpublictransport-0.2.0}/src/openpublictransport/providers/otp_base.py +30 -38
  16. {python_openpublictransport-0.1.17 → python_openpublictransport-0.2.0}/src/openpublictransport/providers/rejseplanen.py +10 -31
  17. {python_openpublictransport-0.1.17 → python_openpublictransport-0.2.0}/src/openpublictransport/providers/rmv.py +42 -78
  18. {python_openpublictransport-0.1.17 → python_openpublictransport-0.2.0}/src/openpublictransport/providers/sbb.py +24 -42
  19. python_openpublictransport-0.2.0/src/openpublictransport/providers/trafiklab.py +170 -0
  20. {python_openpublictransport-0.1.17 → python_openpublictransport-0.2.0}/src/openpublictransport/providers/transitous.py +24 -47
  21. {python_openpublictransport-0.1.17 → python_openpublictransport-0.2.0}/src/openpublictransport/providers/trias_base.py +12 -26
  22. {python_openpublictransport-0.1.17 → python_openpublictransport-0.2.0}/src/python_openpublictransport.egg-info/PKG-INFO +1 -1
  23. {python_openpublictransport-0.1.17 → python_openpublictransport-0.2.0}/src/python_openpublictransport.egg-info/SOURCES.txt +1 -0
  24. python_openpublictransport-0.2.0/tests/test_http_errors.py +222 -0
  25. {python_openpublictransport-0.1.17 → python_openpublictransport-0.2.0}/tests/test_hvv_gti.py +7 -3
  26. python_openpublictransport-0.1.17/src/openpublictransport/exceptions.py +0 -9
  27. python_openpublictransport-0.1.17/src/openpublictransport/providers/base.py +0 -77
  28. python_openpublictransport-0.1.17/src/openpublictransport/providers/nta.py +0 -291
  29. python_openpublictransport-0.1.17/src/openpublictransport/providers/trafiklab.py +0 -284
  30. {python_openpublictransport-0.1.17 → python_openpublictransport-0.2.0}/setup.cfg +0 -0
  31. {python_openpublictransport-0.1.17 → python_openpublictransport-0.2.0}/src/openpublictransport/const.py +0 -0
  32. {python_openpublictransport-0.1.17 → python_openpublictransport-0.2.0}/src/openpublictransport/data/uk_stations.json +0 -0
  33. {python_openpublictransport-0.1.17 → python_openpublictransport-0.2.0}/src/openpublictransport/models.py +0 -0
  34. {python_openpublictransport-0.1.17 → python_openpublictransport-0.2.0}/src/openpublictransport/parsers.py +0 -0
  35. {python_openpublictransport-0.1.17 → python_openpublictransport-0.2.0}/src/openpublictransport/providers/__init__.py +0 -0
  36. {python_openpublictransport-0.1.17 → python_openpublictransport-0.2.0}/src/openpublictransport/providers/avv.py +0 -0
  37. {python_openpublictransport-0.1.17 → python_openpublictransport-0.2.0}/src/openpublictransport/providers/bart.py +0 -0
  38. {python_openpublictransport-0.1.17 → python_openpublictransport-0.2.0}/src/openpublictransport/providers/beg.py +0 -0
  39. {python_openpublictransport-0.1.17 → python_openpublictransport-0.2.0}/src/openpublictransport/providers/bsvg.py +0 -0
  40. {python_openpublictransport-0.1.17 → python_openpublictransport-0.2.0}/src/openpublictransport/providers/bvg.py +0 -0
  41. {python_openpublictransport-0.1.17 → python_openpublictransport-0.2.0}/src/openpublictransport/providers/dart.py +0 -0
  42. {python_openpublictransport-0.1.17 → python_openpublictransport-0.2.0}/src/openpublictransport/providers/db.py +0 -0
  43. {python_openpublictransport-0.1.17 → python_openpublictransport-0.2.0}/src/openpublictransport/providers/ding.py +0 -0
  44. {python_openpublictransport-0.1.17 → python_openpublictransport-0.2.0}/src/openpublictransport/providers/gtfsde.py +0 -0
  45. {python_openpublictransport-0.1.17 → python_openpublictransport-0.2.0}/src/openpublictransport/providers/hvv.py +0 -0
  46. {python_openpublictransport-0.1.17 → python_openpublictransport-0.2.0}/src/openpublictransport/providers/irishrail.py +0 -0
  47. {python_openpublictransport-0.1.17 → python_openpublictransport-0.2.0}/src/openpublictransport/providers/kvv.py +0 -0
  48. {python_openpublictransport-0.1.17 → python_openpublictransport-0.2.0}/src/openpublictransport/providers/mobiliteit_lu.py +0 -0
  49. {python_openpublictransport-0.1.17 → python_openpublictransport-0.2.0}/src/openpublictransport/providers/mvv.py +0 -0
  50. {python_openpublictransport-0.1.17 → python_openpublictransport-0.2.0}/src/openpublictransport/providers/ns.py +0 -0
  51. {python_openpublictransport-0.1.17 → python_openpublictransport-0.2.0}/src/openpublictransport/providers/nvbw.py +0 -0
  52. {python_openpublictransport-0.1.17 → python_openpublictransport-0.2.0}/src/openpublictransport/providers/nwl.py +0 -0
  53. {python_openpublictransport-0.1.17 → python_openpublictransport-0.2.0}/src/openpublictransport/providers/oebb.py +0 -0
  54. {python_openpublictransport-0.1.17 → python_openpublictransport-0.2.0}/src/openpublictransport/providers/otp_custom.py +0 -0
  55. {python_openpublictransport-0.1.17 → python_openpublictransport-0.2.0}/src/openpublictransport/providers/rvv.py +0 -0
  56. {python_openpublictransport-0.1.17 → python_openpublictransport-0.2.0}/src/openpublictransport/providers/tpg.py +0 -0
  57. {python_openpublictransport-0.1.17 → python_openpublictransport-0.2.0}/src/openpublictransport/providers/vagfr.py +0 -0
  58. {python_openpublictransport-0.1.17 → python_openpublictransport-0.2.0}/src/openpublictransport/providers/vbn.py +0 -0
  59. {python_openpublictransport-0.1.17 → python_openpublictransport-0.2.0}/src/openpublictransport/providers/vgn.py +0 -0
  60. {python_openpublictransport-0.1.17 → python_openpublictransport-0.2.0}/src/openpublictransport/providers/vrn.py +0 -0
  61. {python_openpublictransport-0.1.17 → python_openpublictransport-0.2.0}/src/openpublictransport/providers/vrr.py +0 -0
  62. {python_openpublictransport-0.1.17 → python_openpublictransport-0.2.0}/src/openpublictransport/providers/vvo.py +0 -0
  63. {python_openpublictransport-0.1.17 → python_openpublictransport-0.2.0}/src/openpublictransport/providers/vvs.py +0 -0
  64. {python_openpublictransport-0.1.17 → python_openpublictransport-0.2.0}/src/openpublictransport/py.typed +0 -0
  65. {python_openpublictransport-0.1.17 → python_openpublictransport-0.2.0}/src/python_openpublictransport.egg-info/dependency_links.txt +0 -0
  66. {python_openpublictransport-0.1.17 → python_openpublictransport-0.2.0}/src/python_openpublictransport.egg-info/requires.txt +0 -0
  67. {python_openpublictransport-0.1.17 → python_openpublictransport-0.2.0}/src/python_openpublictransport.egg-info/top_level.txt +0 -0
  68. {python_openpublictransport-0.1.17 → python_openpublictransport-0.2.0}/tests/test_efa_mimetype.py +0 -0
  69. {python_openpublictransport-0.1.17 → python_openpublictransport-0.2.0}/tests/test_efa_platform.py +0 -0
  70. {python_openpublictransport-0.1.17 → python_openpublictransport-0.2.0}/tests/test_entur.py +0 -0
  71. {python_openpublictransport-0.1.17 → python_openpublictransport-0.2.0}/tests/test_hafas_base.py +0 -0
  72. {python_openpublictransport-0.1.17 → python_openpublictransport-0.2.0}/tests/test_hafas_mgate.py +0 -0
  73. {python_openpublictransport-0.1.17 → python_openpublictransport-0.2.0}/tests/test_national_rail.py +0 -0
  74. {python_openpublictransport-0.1.17 → python_openpublictransport-0.2.0}/tests/test_national_rail_parse.py +0 -0
  75. {python_openpublictransport-0.1.17 → python_openpublictransport-0.2.0}/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.17
3
+ Version: 0.2.0
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.17"
7
+ version = "0.2.0"
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" }
@@ -1,6 +1,13 @@
1
1
  """python-openpublictransport — public transport API library."""
2
2
 
3
- from .exceptions import AuthenticationError
3
+ from .exceptions import (
4
+ ApiConnectionError,
5
+ ApiError,
6
+ ApiResponseError,
7
+ ApiTimeoutError,
8
+ AuthenticationError,
9
+ OpenPublicTransportError,
10
+ )
4
11
  from .providers import get_all_provider_ids, get_provider, get_provider_class, register_provider
5
12
 
6
13
  __all__ = [
@@ -8,5 +15,10 @@ __all__ = [
8
15
  "get_provider_class",
9
16
  "get_all_provider_ids",
10
17
  "register_provider",
18
+ "OpenPublicTransportError",
19
+ "ApiError",
20
+ "ApiConnectionError",
21
+ "ApiTimeoutError",
22
+ "ApiResponseError",
11
23
  "AuthenticationError",
12
24
  ]
@@ -0,0 +1,81 @@
1
+ """Exceptions for python-openpublictransport.
2
+
3
+ Every failure raised by this library derives from :class:`OpenPublicTransportError`.
4
+ Callers can therefore catch the whole family with a single ``except`` clause, or
5
+ discriminate on the concrete type to tell a server-side outage apart from a
6
+ network problem or an empty result.
7
+
8
+ Empty results are *not* errors: ``search_stops()`` returns ``[]`` and
9
+ ``fetch_departures()`` returns ``None`` only when the provider genuinely had no
10
+ data to give.
11
+ """
12
+
13
+ from typing import Optional
14
+
15
+
16
+ class OpenPublicTransportError(Exception):
17
+ """Base class for every error raised by this library."""
18
+
19
+
20
+ class ApiError(OpenPublicTransportError):
21
+ """Raised when a provider API answers with a non-200 HTTP status.
22
+
23
+ The HTTP status is available as :attr:`status` so callers can report it to
24
+ the user instead of silently treating the failure as "no results".
25
+ :attr:`body` holds a truncated copy of the error response, which is often
26
+ the only place a provider explains what it disliked.
27
+ """
28
+
29
+ def __init__(
30
+ self,
31
+ provider: str,
32
+ status: int,
33
+ message: Optional[str] = None,
34
+ body: Optional[str] = None,
35
+ ) -> None:
36
+ self.provider = provider
37
+ self.status = status
38
+ self.body = body
39
+ if message is None:
40
+ message = f"{provider}: API returned HTTP {status}"
41
+ if body:
42
+ message = f"{message} — {body}"
43
+ super().__init__(message)
44
+
45
+
46
+ class ApiConnectionError(OpenPublicTransportError):
47
+ """Raised when the provider API cannot be reached (DNS, TCP or TLS failure)."""
48
+
49
+
50
+ class ApiTimeoutError(ApiConnectionError):
51
+ """Raised when the provider API does not answer within the timeout."""
52
+
53
+
54
+ class ApiResponseError(OpenPublicTransportError):
55
+ """Raised when a 200 response carries a payload that cannot be used.
56
+
57
+ Covers undecodable JSON/XML as well as a well-formed body whose shape does
58
+ not match what the provider protocol requires.
59
+ """
60
+
61
+
62
+ class AuthenticationError(ApiError):
63
+ """Raised when API authentication fails (HTTP 401 or 403).
64
+
65
+ Signals that the configured API key is invalid or expired.
66
+ Callers should prompt the user to re-enter credentials rather than retrying.
67
+ """
68
+
69
+ def __init__(
70
+ self,
71
+ provider: str,
72
+ status: int = 401,
73
+ message: Optional[str] = None,
74
+ body: Optional[str] = None,
75
+ ) -> None:
76
+ super().__init__(
77
+ provider,
78
+ status,
79
+ message or f"{provider}: authentication failed (HTTP {status}) — check the API key",
80
+ body=body,
81
+ )
@@ -0,0 +1,199 @@
1
+ """Base class for all public transport providers."""
2
+
3
+ import asyncio
4
+ import logging
5
+
6
+ import aiohttp
7
+ from abc import ABC, abstractmethod
8
+ from datetime import datetime
9
+ from typing import Any, Dict, List, Optional, Union
10
+ from zoneinfo import ZoneInfo
11
+
12
+ from ..exceptions import (
13
+ ApiConnectionError,
14
+ ApiError,
15
+ ApiResponseError,
16
+ ApiTimeoutError,
17
+ AuthenticationError,
18
+ )
19
+ from ..models import UnifiedDeparture
20
+
21
+ _LOGGER = logging.getLogger(__name__)
22
+
23
+
24
+ class BaseProvider(ABC):
25
+ """Abstract base class for all public transport providers."""
26
+
27
+ def __init__(
28
+ self,
29
+ session: aiohttp.ClientSession,
30
+ api_key: Optional[str] = None,
31
+ api_key_secondary: Optional[str] = None,
32
+ custom_url: Optional[str] = None,
33
+ ):
34
+ self.session = session
35
+ self.api_key = api_key
36
+ self.api_key_secondary = api_key_secondary
37
+ self.custom_url = custom_url
38
+
39
+ async def _request(
40
+ self,
41
+ method: str,
42
+ url: str,
43
+ *,
44
+ retries: int = 1,
45
+ timeout: int = 10,
46
+ response_format: str = "json",
47
+ json_content_type: Optional[str] = None,
48
+ **kwargs: Any,
49
+ ) -> Any:
50
+ """Perform an HTTP request, returning the decoded payload or raising.
51
+
52
+ This is the single place where HTTP status codes are turned into
53
+ exceptions. Providers must not swallow the result: an empty list or
54
+ ``None`` returned by a provider means "no data", never "request failed".
55
+
56
+ Args:
57
+ method: ``"get"`` or ``"post"``.
58
+ url: Absolute request URL.
59
+ retries: Total number of attempts. Only 5xx responses, timeouts and
60
+ connection errors are retried, with ``2 ** attempt`` backoff.
61
+ 4xx responses fail immediately — retrying a bad key or a wrong
62
+ endpoint only delays the error the user needs to see.
63
+ timeout: Total request timeout in seconds.
64
+ response_format: ``"json"``, ``"text"`` or ``"bytes"``.
65
+ json_content_type: Passed to ``response.json()``. The default
66
+ ``None`` disables aiohttp's strict content-type check, because
67
+ some EFA deployments send RapidJSON with an XML header (#79).
68
+ **kwargs: Forwarded to the aiohttp session method (``params``,
69
+ ``headers``, ``data``, ``json``, ...).
70
+
71
+ Returns:
72
+ The decoded payload, or ``None`` for an empty 204 response.
73
+
74
+ Raises:
75
+ AuthenticationError: HTTP 401 or 403. Never retried.
76
+ ApiError: Any other non-2xx status, carrying ``.status``.
77
+ ApiTimeoutError: The request timed out on every attempt.
78
+ ApiConnectionError: The provider could not be reached.
79
+ ApiResponseError: A 2xx response whose body could not be decoded.
80
+ """
81
+ request = getattr(self.session, method.lower())
82
+ name = self.provider_name
83
+ last_error: Optional[Exception] = None
84
+
85
+ for attempt in range(1, retries + 1):
86
+ try:
87
+ async with request(url, timeout=aiohttp.ClientTimeout(total=timeout), **kwargs) as response:
88
+ status = response.status
89
+
90
+ if status in (401, 403):
91
+ raise AuthenticationError(name, status, body=await self._error_body(response))
92
+
93
+ if status >= 500:
94
+ last_error = ApiError(name, status, body=await self._error_body(response))
95
+ elif status >= 400:
96
+ raise ApiError(name, status, body=await self._error_body(response))
97
+ elif status == 204:
98
+ return None
99
+ else:
100
+ return await self._decode_response(response, response_format, json_content_type)
101
+
102
+ except asyncio.TimeoutError as exc:
103
+ last_error = ApiTimeoutError(f"{name}: request timed out after {timeout}s")
104
+ last_error.__cause__ = exc
105
+ except aiohttp.ClientError as exc:
106
+ last_error = ApiConnectionError(f"{name}: connection failed ({exc})")
107
+ last_error.__cause__ = exc
108
+
109
+ if attempt < retries:
110
+ _LOGGER.debug("%s: attempt %s/%s failed (%s), retrying", name, attempt, retries, last_error)
111
+ await asyncio.sleep(2**attempt)
112
+
113
+ raise last_error if last_error else ApiConnectionError(f"{name}: request failed")
114
+
115
+ @staticmethod
116
+ async def _error_body(response: Any, limit: int = 300) -> Optional[str]:
117
+ """Return a truncated error body, or None if it cannot be read.
118
+
119
+ Providers often explain a rejection only in the body (a SOAP fault, a
120
+ JSON ``errorText``), so it is worth carrying into the exception.
121
+ """
122
+ try:
123
+ text = (await response.text() or "").strip()
124
+ except Exception: # noqa: BLE001 — diagnostics must never mask the real error
125
+ return None
126
+ if not text:
127
+ return None
128
+ return text[:limit] + ("…" if len(text) > limit else "")
129
+
130
+ async def _decode_response(
131
+ self,
132
+ response: Any,
133
+ response_format: str,
134
+ json_content_type: Optional[str],
135
+ ) -> Any:
136
+ """Decode a successful response, raising ApiResponseError on bad payloads."""
137
+ try:
138
+ if response_format == "json":
139
+ return await response.json(content_type=json_content_type)
140
+ if response_format == "text":
141
+ return await response.text()
142
+ if response_format == "bytes":
143
+ return await response.read()
144
+ except (ValueError, aiohttp.ClientError) as exc:
145
+ raise ApiResponseError(f"{self.provider_name}: could not decode response ({exc})") from exc
146
+
147
+ raise ValueError(f"Unknown response_format: {response_format}")
148
+
149
+ @property
150
+ @abstractmethod
151
+ def provider_id(self) -> str:
152
+ """Return the provider identifier (e.g., 'vrr', 'kvv')."""
153
+ pass
154
+
155
+ @property
156
+ @abstractmethod
157
+ def provider_name(self) -> str:
158
+ """Return the human-readable provider name."""
159
+ pass
160
+
161
+ @property
162
+ def requires_api_key(self) -> bool:
163
+ """Return True if this provider requires an API key."""
164
+ return False
165
+
166
+ @abstractmethod
167
+ async def fetch_departures(
168
+ self,
169
+ station_id: Optional[str],
170
+ place_dm: str,
171
+ name_dm: str,
172
+ departures_limit: int,
173
+ ) -> Optional[Dict[str, Any]]:
174
+ """Fetch departure data from the provider's API."""
175
+ pass
176
+
177
+ @abstractmethod
178
+ def parse_departure(
179
+ self, stop: Dict[str, Any], tz: Union[ZoneInfo, Any], now: datetime
180
+ ) -> Optional[UnifiedDeparture]:
181
+ """Parse a single departure from the provider's API response."""
182
+ pass
183
+
184
+ @abstractmethod
185
+ async def search_stops(self, search_term: str) -> List[Dict[str, Any]]:
186
+ """Search for stops/stations."""
187
+ pass
188
+
189
+ def get_timezone(self) -> str:
190
+ """Return the timezone for this provider (e.g., 'Europe/Berlin')."""
191
+ return "Europe/Berlin"
192
+
193
+ def get_transport_type_mapping(self) -> Dict[Any, str]:
194
+ """Return the transportation type mapping for this provider."""
195
+ return {}
196
+
197
+ async def cleanup(self) -> None:
198
+ """Cleanup provider resources."""
199
+ pass
@@ -1,6 +1,5 @@
1
1
  """Base class for EFA (Electronic Fahrplan-Auskunft) providers."""
2
2
 
3
- import asyncio
4
3
  import logging
5
4
  from abc import abstractmethod
6
5
  from datetime import datetime
@@ -8,8 +7,7 @@ from typing import Any, Callable, Dict, List, Optional, Tuple, Union
8
7
  from urllib.parse import quote
9
8
  from zoneinfo import ZoneInfo
10
9
 
11
- import aiohttp
12
-
10
+ from ..exceptions import ApiResponseError
13
11
  from ..models import UnifiedDeparture
14
12
  from ..parsers import parse_departure_generic
15
13
  from .base import BaseProvider
@@ -148,47 +146,18 @@ class EFABaseProvider(BaseProvider):
148
146
 
149
147
  headers = {"User-Agent": f"Mozilla/5.0 (compatible; OpenPublicTransport {self.provider_id.upper()})"}
150
148
 
151
- max_retries = 3
152
- for attempt in range(1, max_retries + 1):
153
- try:
154
- async with self.session.get(url, headers=headers, timeout=aiohttp.ClientTimeout(total=10)) as response:
155
- if response.status == 200:
156
- try:
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)
160
- if not isinstance(json_data, dict):
161
- _LOGGER.warning("%s API returned non-dict response: %s", name, type(json_data))
162
- return None
163
-
164
- if "stopEvents" not in json_data:
165
- _LOGGER.debug("%s API response missing 'stopEvents' field", name)
166
- return {"stopEvents": []}
167
-
168
- return json_data
169
- except (ValueError, aiohttp.ContentTypeError) as e:
170
- _LOGGER.warning("%s API returned invalid JSON: %s", name, e)
171
- return None
172
- except Exception as e:
173
- _LOGGER.warning("%s API JSON parsing failed: %s", name, e)
174
- return None
175
- elif response.status == 404:
176
- _LOGGER.warning("%s API endpoint not found (404)", name)
177
- return None
178
- elif response.status >= 500:
179
- _LOGGER.warning("%s API server error (status %s)", name, response.status)
180
- else:
181
- _LOGGER.warning("%s API returned status %s", name, response.status)
182
-
183
- except asyncio.TimeoutError:
184
- _LOGGER.warning("%s API timeout on attempt %s", name, attempt)
185
- except Exception as e:
186
- _LOGGER.warning("%s attempt %s failed: %s", name, attempt, e)
187
-
188
- if attempt < max_retries:
189
- await asyncio.sleep(2**attempt)
190
-
191
- return None
149
+ # content_type=None: some deployments (VGN) send RapidJSON payloads with
150
+ # a "text/xml;;charset=utf-8" header (issue #79).
151
+ json_data = await self._request("get", url, headers=headers, retries=3, timeout=10)
152
+
153
+ if not isinstance(json_data, dict):
154
+ raise ApiResponseError(f"{name}: API returned {type(json_data).__name__} instead of an object")
155
+
156
+ if "stopEvents" not in json_data:
157
+ _LOGGER.debug("%s API response missing 'stopEvents' field", name)
158
+ return {"stopEvents": []}
159
+
160
+ return json_data
192
161
 
193
162
  def parse_departure(
194
163
  self, stop: Dict[str, Any], tz: Union[ZoneInfo, Any], now: datetime
@@ -249,47 +218,31 @@ class EFABaseProvider(BaseProvider):
249
218
  url = f"{self.sf_base_url}?{params}"
250
219
  name = self.provider_name
251
220
 
252
- try:
253
- async with self.session.get(url, timeout=aiohttp.ClientTimeout(total=10)) as response:
254
- if response.status == 200:
255
- try:
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)
259
- except (ValueError, aiohttp.ContentTypeError) as e:
260
- _LOGGER.error("Invalid JSON response from %s API: %s", name, e)
261
- return []
262
-
263
- if not isinstance(data, dict):
264
- _LOGGER.error("%s API returned non-dict response: %s", name, type(data))
265
- return []
266
-
267
- locations = data.get("locations", [])
268
- results = []
269
-
270
- for location in locations:
271
- if not isinstance(location, dict):
272
- continue
273
-
274
- disassembled_name = location.get("disassembledName", "")
275
- place = ""
276
- if "," in disassembled_name:
277
- parts = disassembled_name.rsplit(",", 1)
278
- place = parts[-1].strip() if len(parts) > 1 else ""
279
-
280
- results.append(
281
- {
282
- "id": location.get("id", ""),
283
- "name": location.get("name", ""),
284
- "place": place,
285
- "area_type": location.get("type", ""),
286
- }
287
- )
288
-
289
- return results
290
- else:
291
- _LOGGER.error("%s API returned status %s", name, response.status)
292
- except Exception as e:
293
- _LOGGER.error("Error searching %s stops: %s", name, e, exc_info=True)
294
-
295
- return []
221
+ # content_type=None: some deployments (VGN) send RapidJSON payloads with
222
+ # a "text/xml;;charset=utf-8" header (issue #79).
223
+ data = await self._request("get", url, timeout=10)
224
+
225
+ if not isinstance(data, dict):
226
+ raise ApiResponseError(f"{name}: API returned {type(data).__name__} instead of an object")
227
+
228
+ results = []
229
+ for location in data.get("locations", []):
230
+ if not isinstance(location, dict):
231
+ continue
232
+
233
+ disassembled_name = location.get("disassembledName", "")
234
+ place = ""
235
+ if "," in disassembled_name:
236
+ parts = disassembled_name.rsplit(",", 1)
237
+ place = parts[-1].strip() if len(parts) > 1 else ""
238
+
239
+ results.append(
240
+ {
241
+ "id": location.get("id", ""),
242
+ "name": location.get("name", ""),
243
+ "place": place,
244
+ "area_type": location.get("type", ""),
245
+ }
246
+ )
247
+
248
+ return results
@@ -16,8 +16,6 @@ from typing import Any, Dict, List, Optional, Union
16
16
  from urllib.parse import quote
17
17
  from zoneinfo import ZoneInfo
18
18
 
19
- import aiohttp
20
-
21
19
  from ..const import PROVIDER_ENTUR_NO
22
20
  from ..models import UnifiedDeparture
23
21
  from .base import BaseProvider
@@ -84,18 +82,7 @@ class EnturProvider(BaseProvider):
84
82
  f"{GEOCODER_URL}?text={quote(search_term, safe='')}"
85
83
  "&size=12&lang=en&layers=venue"
86
84
  )
87
- try:
88
- async with self.session.get(
89
- url, headers=self._headers(), timeout=aiohttp.ClientTimeout(total=10)
90
- ) as response:
91
- if response.status != 200:
92
- _LOGGER.error("Entur geocoder returned status %s", response.status)
93
- return []
94
- data = await response.json()
95
- except Exception as e: # noqa: BLE001
96
- _LOGGER.error("Error searching Entur stops: %s", e)
97
- return []
98
-
85
+ data = await self._request("get", url, headers=self._headers(), timeout=10)
99
86
  return self._stops_from_geocoder(data)
100
87
 
101
88
  @staticmethod
@@ -135,23 +122,13 @@ class EnturProvider(BaseProvider):
135
122
  "query": _DEPARTURES_QUERY,
136
123
  "variables": {"id": station_id, "n": departures_limit},
137
124
  }
138
- try:
139
- async with self.session.post(
140
- JOURNEY_PLANNER_URL,
141
- json=payload,
142
- headers=self._headers(),
143
- timeout=aiohttp.ClientTimeout(total=15),
144
- ) as response:
145
- if response.status != 200:
146
- _LOGGER.warning("Entur journey planner returned status %s", response.status)
147
- return None
148
- data = await response.json()
149
- except aiohttp.ClientError as e:
150
- _LOGGER.warning("Entur journey planner request failed: %s", e)
151
- return None
152
- except Exception as e: # noqa: BLE001
153
- _LOGGER.warning("Entur journey planner error: %s", e)
154
- return None
125
+ data = await self._request(
126
+ "post",
127
+ JOURNEY_PLANNER_URL,
128
+ json=payload,
129
+ headers=self._headers(),
130
+ timeout=15,
131
+ )
155
132
 
156
133
  stop_place = (data.get("data") or {}).get("stopPlace")
157
134
  if not stop_place:
@@ -6,8 +6,7 @@ from typing import Any, Dict, List, Optional, Union
6
6
  from urllib.parse import quote
7
7
  from zoneinfo import ZoneInfo
8
8
 
9
- import aiohttp
10
-
9
+ from ..exceptions import ApiResponseError
11
10
  from ..models import UnifiedDeparture
12
11
  from .base import BaseProvider
13
12
 
@@ -45,22 +44,13 @@ class FPTFBaseProvider(BaseProvider):
45
44
 
46
45
  url = f"{self.API_BASE}/stops/{station_id}/departures?results={departures_limit}&duration=120"
47
46
 
48
- try:
49
- async with self.session.get(url, timeout=aiohttp.ClientTimeout(total=15)) as response:
50
- if response.status == 200:
51
- data = await response.json()
52
- if not isinstance(data, dict) or "departures" not in data:
53
- _LOGGER.warning("%s API unexpected response format", self.provider_name)
54
- return {"stopEvents": []}
55
- return {"stopEvents": data["departures"]}
56
- else:
57
- _LOGGER.warning("%s API returned status %s", self.provider_name, response.status)
58
- except aiohttp.ClientError as e:
59
- _LOGGER.warning("%s API request failed: %s", self.provider_name, e)
60
- except Exception as e:
61
- _LOGGER.warning("%s API error: %s", self.provider_name, e)
47
+ data = await self._request("get", url, timeout=15)
62
48
 
63
- return None
49
+ if not isinstance(data, dict) or "departures" not in data:
50
+ _LOGGER.debug("%s API response missing 'departures' field", self.provider_name)
51
+ return {"stopEvents": []}
52
+
53
+ return {"stopEvents": data["departures"]}
64
54
 
65
55
  def parse_departure(
66
56
  self, stop: Dict[str, Any], tz: Union[ZoneInfo, Any], now: datetime
@@ -133,37 +123,29 @@ class FPTFBaseProvider(BaseProvider):
133
123
  async def search_stops(self, search_term: str) -> List[Dict[str, Any]]:
134
124
  url = f"{self.API_BASE}/locations?query={quote(search_term, safe='')}&results=15"
135
125
 
136
- try:
137
- async with self.session.get(url, timeout=aiohttp.ClientTimeout(total=10)) as response:
138
- if response.status == 200:
139
- data = await response.json()
140
- if not isinstance(data, list):
141
- return []
142
-
143
- results = []
144
- for location in data:
145
- if not isinstance(location, dict):
146
- continue
147
- if location.get("type") not in ("stop", "station"):
148
- continue
149
-
150
- name = location.get("name", "")
151
- place = ""
152
- if "(" in name and name.endswith(")"):
153
- place = name[name.rfind("(") + 1 : -1]
154
-
155
- results.append(
156
- {
157
- "id": location.get("id", ""),
158
- "name": name,
159
- "place": place,
160
- "area_type": "stop",
161
- }
162
- )
163
- return results
164
- else:
165
- _LOGGER.error("%s API returned status %s", self.provider_name, response.status)
166
- except Exception as e:
167
- _LOGGER.error("Error searching %s stops: %s", self.provider_name, e)
168
-
169
- return []
126
+ data = await self._request("get", url, timeout=10)
127
+
128
+ if not isinstance(data, list):
129
+ raise ApiResponseError(f"{self.provider_name}: API returned {type(data).__name__} instead of a list")
130
+
131
+ results = []
132
+ for location in data:
133
+ if not isinstance(location, dict):
134
+ continue
135
+ if location.get("type") not in ("stop", "station"):
136
+ continue
137
+
138
+ name = location.get("name", "")
139
+ place = ""
140
+ if "(" in name and name.endswith(")"):
141
+ place = name[name.rfind("(") + 1 : -1]
142
+
143
+ results.append(
144
+ {
145
+ "id": location.get("id", ""),
146
+ "name": name,
147
+ "place": place,
148
+ "area_type": "stop",
149
+ }
150
+ )
151
+ return results