python-openpublictransport 0.1.16__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.
- {python_openpublictransport-0.1.16 → python_openpublictransport-0.2.0}/PKG-INFO +1 -1
- {python_openpublictransport-0.1.16 → python_openpublictransport-0.2.0}/pyproject.toml +1 -1
- {python_openpublictransport-0.1.16 → python_openpublictransport-0.2.0}/src/openpublictransport/__init__.py +13 -1
- python_openpublictransport-0.2.0/src/openpublictransport/exceptions.py +81 -0
- python_openpublictransport-0.2.0/src/openpublictransport/providers/base.py +199 -0
- {python_openpublictransport-0.1.16 → python_openpublictransport-0.2.0}/src/openpublictransport/providers/efa_base.py +41 -84
- {python_openpublictransport-0.1.16 → python_openpublictransport-0.2.0}/src/openpublictransport/providers/entur.py +8 -31
- {python_openpublictransport-0.1.16 → python_openpublictransport-0.2.0}/src/openpublictransport/providers/fptf_base.py +33 -51
- {python_openpublictransport-0.1.16 → python_openpublictransport-0.2.0}/src/openpublictransport/providers/hafas_base.py +2 -28
- {python_openpublictransport-0.1.16 → python_openpublictransport-0.2.0}/src/openpublictransport/providers/hafas_mgate_base.py +23 -28
- {python_openpublictransport-0.1.16 → python_openpublictransport-0.2.0}/src/openpublictransport/providers/hvv_gti.py +21 -37
- {python_openpublictransport-0.1.16 → python_openpublictransport-0.2.0}/src/openpublictransport/providers/national_rail.py +35 -41
- python_openpublictransport-0.2.0/src/openpublictransport/providers/nta.py +243 -0
- {python_openpublictransport-0.1.16 → python_openpublictransport-0.2.0}/src/openpublictransport/providers/otp.py +7 -15
- {python_openpublictransport-0.1.16 → python_openpublictransport-0.2.0}/src/openpublictransport/providers/otp_base.py +30 -38
- {python_openpublictransport-0.1.16 → python_openpublictransport-0.2.0}/src/openpublictransport/providers/rejseplanen.py +10 -31
- {python_openpublictransport-0.1.16 → python_openpublictransport-0.2.0}/src/openpublictransport/providers/rmv.py +42 -78
- {python_openpublictransport-0.1.16 → python_openpublictransport-0.2.0}/src/openpublictransport/providers/sbb.py +24 -42
- python_openpublictransport-0.2.0/src/openpublictransport/providers/trafiklab.py +170 -0
- {python_openpublictransport-0.1.16 → python_openpublictransport-0.2.0}/src/openpublictransport/providers/transitous.py +24 -47
- {python_openpublictransport-0.1.16 → python_openpublictransport-0.2.0}/src/openpublictransport/providers/trias_base.py +12 -26
- {python_openpublictransport-0.1.16 → python_openpublictransport-0.2.0}/src/python_openpublictransport.egg-info/PKG-INFO +1 -1
- {python_openpublictransport-0.1.16 → python_openpublictransport-0.2.0}/src/python_openpublictransport.egg-info/SOURCES.txt +2 -0
- python_openpublictransport-0.2.0/tests/test_efa_mimetype.py +87 -0
- python_openpublictransport-0.2.0/tests/test_http_errors.py +222 -0
- {python_openpublictransport-0.1.16 → python_openpublictransport-0.2.0}/tests/test_hvv_gti.py +7 -3
- python_openpublictransport-0.1.16/src/openpublictransport/exceptions.py +0 -9
- python_openpublictransport-0.1.16/src/openpublictransport/providers/base.py +0 -77
- python_openpublictransport-0.1.16/src/openpublictransport/providers/nta.py +0 -291
- python_openpublictransport-0.1.16/src/openpublictransport/providers/trafiklab.py +0 -284
- {python_openpublictransport-0.1.16 → python_openpublictransport-0.2.0}/setup.cfg +0 -0
- {python_openpublictransport-0.1.16 → python_openpublictransport-0.2.0}/src/openpublictransport/const.py +0 -0
- {python_openpublictransport-0.1.16 → python_openpublictransport-0.2.0}/src/openpublictransport/data/uk_stations.json +0 -0
- {python_openpublictransport-0.1.16 → python_openpublictransport-0.2.0}/src/openpublictransport/models.py +0 -0
- {python_openpublictransport-0.1.16 → python_openpublictransport-0.2.0}/src/openpublictransport/parsers.py +0 -0
- {python_openpublictransport-0.1.16 → python_openpublictransport-0.2.0}/src/openpublictransport/providers/__init__.py +0 -0
- {python_openpublictransport-0.1.16 → python_openpublictransport-0.2.0}/src/openpublictransport/providers/avv.py +0 -0
- {python_openpublictransport-0.1.16 → python_openpublictransport-0.2.0}/src/openpublictransport/providers/bart.py +0 -0
- {python_openpublictransport-0.1.16 → python_openpublictransport-0.2.0}/src/openpublictransport/providers/beg.py +0 -0
- {python_openpublictransport-0.1.16 → python_openpublictransport-0.2.0}/src/openpublictransport/providers/bsvg.py +0 -0
- {python_openpublictransport-0.1.16 → python_openpublictransport-0.2.0}/src/openpublictransport/providers/bvg.py +0 -0
- {python_openpublictransport-0.1.16 → python_openpublictransport-0.2.0}/src/openpublictransport/providers/dart.py +0 -0
- {python_openpublictransport-0.1.16 → python_openpublictransport-0.2.0}/src/openpublictransport/providers/db.py +0 -0
- {python_openpublictransport-0.1.16 → python_openpublictransport-0.2.0}/src/openpublictransport/providers/ding.py +0 -0
- {python_openpublictransport-0.1.16 → python_openpublictransport-0.2.0}/src/openpublictransport/providers/gtfsde.py +0 -0
- {python_openpublictransport-0.1.16 → python_openpublictransport-0.2.0}/src/openpublictransport/providers/hvv.py +0 -0
- {python_openpublictransport-0.1.16 → python_openpublictransport-0.2.0}/src/openpublictransport/providers/irishrail.py +0 -0
- {python_openpublictransport-0.1.16 → python_openpublictransport-0.2.0}/src/openpublictransport/providers/kvv.py +0 -0
- {python_openpublictransport-0.1.16 → python_openpublictransport-0.2.0}/src/openpublictransport/providers/mobiliteit_lu.py +0 -0
- {python_openpublictransport-0.1.16 → python_openpublictransport-0.2.0}/src/openpublictransport/providers/mvv.py +0 -0
- {python_openpublictransport-0.1.16 → python_openpublictransport-0.2.0}/src/openpublictransport/providers/ns.py +0 -0
- {python_openpublictransport-0.1.16 → python_openpublictransport-0.2.0}/src/openpublictransport/providers/nvbw.py +0 -0
- {python_openpublictransport-0.1.16 → python_openpublictransport-0.2.0}/src/openpublictransport/providers/nwl.py +0 -0
- {python_openpublictransport-0.1.16 → python_openpublictransport-0.2.0}/src/openpublictransport/providers/oebb.py +0 -0
- {python_openpublictransport-0.1.16 → python_openpublictransport-0.2.0}/src/openpublictransport/providers/otp_custom.py +0 -0
- {python_openpublictransport-0.1.16 → python_openpublictransport-0.2.0}/src/openpublictransport/providers/rvv.py +0 -0
- {python_openpublictransport-0.1.16 → python_openpublictransport-0.2.0}/src/openpublictransport/providers/tpg.py +0 -0
- {python_openpublictransport-0.1.16 → python_openpublictransport-0.2.0}/src/openpublictransport/providers/vagfr.py +0 -0
- {python_openpublictransport-0.1.16 → python_openpublictransport-0.2.0}/src/openpublictransport/providers/vbn.py +0 -0
- {python_openpublictransport-0.1.16 → python_openpublictransport-0.2.0}/src/openpublictransport/providers/vgn.py +0 -0
- {python_openpublictransport-0.1.16 → python_openpublictransport-0.2.0}/src/openpublictransport/providers/vrn.py +0 -0
- {python_openpublictransport-0.1.16 → python_openpublictransport-0.2.0}/src/openpublictransport/providers/vrr.py +0 -0
- {python_openpublictransport-0.1.16 → python_openpublictransport-0.2.0}/src/openpublictransport/providers/vvo.py +0 -0
- {python_openpublictransport-0.1.16 → python_openpublictransport-0.2.0}/src/openpublictransport/providers/vvs.py +0 -0
- {python_openpublictransport-0.1.16 → python_openpublictransport-0.2.0}/src/openpublictransport/py.typed +0 -0
- {python_openpublictransport-0.1.16 → python_openpublictransport-0.2.0}/src/python_openpublictransport.egg-info/dependency_links.txt +0 -0
- {python_openpublictransport-0.1.16 → python_openpublictransport-0.2.0}/src/python_openpublictransport.egg-info/requires.txt +0 -0
- {python_openpublictransport-0.1.16 → python_openpublictransport-0.2.0}/src/python_openpublictransport.egg-info/top_level.txt +0 -0
- {python_openpublictransport-0.1.16 → python_openpublictransport-0.2.0}/tests/test_efa_platform.py +0 -0
- {python_openpublictransport-0.1.16 → python_openpublictransport-0.2.0}/tests/test_entur.py +0 -0
- {python_openpublictransport-0.1.16 → python_openpublictransport-0.2.0}/tests/test_hafas_base.py +0 -0
- {python_openpublictransport-0.1.16 → python_openpublictransport-0.2.0}/tests/test_hafas_mgate.py +0 -0
- {python_openpublictransport-0.1.16 → python_openpublictransport-0.2.0}/tests/test_national_rail.py +0 -0
- {python_openpublictransport-0.1.16 → python_openpublictransport-0.2.0}/tests/test_national_rail_parse.py +0 -0
- {python_openpublictransport-0.1.16 → python_openpublictransport-0.2.0}/tests/test_national_rail_static.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "python-openpublictransport"
|
|
7
|
-
version = "0.
|
|
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
|
|
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
|
|
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,45 +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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
_LOGGER.debug("%s API response missing 'stopEvents' field", name)
|
|
164
|
-
return {"stopEvents": []}
|
|
165
|
-
|
|
166
|
-
return json_data
|
|
167
|
-
except (ValueError, aiohttp.ContentTypeError) as e:
|
|
168
|
-
_LOGGER.warning("%s API returned invalid JSON: %s", name, e)
|
|
169
|
-
return None
|
|
170
|
-
except Exception as e:
|
|
171
|
-
_LOGGER.warning("%s API JSON parsing failed: %s", name, e)
|
|
172
|
-
return None
|
|
173
|
-
elif response.status == 404:
|
|
174
|
-
_LOGGER.warning("%s API endpoint not found (404)", name)
|
|
175
|
-
return None
|
|
176
|
-
elif response.status >= 500:
|
|
177
|
-
_LOGGER.warning("%s API server error (status %s)", name, response.status)
|
|
178
|
-
else:
|
|
179
|
-
_LOGGER.warning("%s API returned status %s", name, response.status)
|
|
180
|
-
|
|
181
|
-
except asyncio.TimeoutError:
|
|
182
|
-
_LOGGER.warning("%s API timeout on attempt %s", name, attempt)
|
|
183
|
-
except Exception as e:
|
|
184
|
-
_LOGGER.warning("%s attempt %s failed: %s", name, attempt, e)
|
|
185
|
-
|
|
186
|
-
if attempt < max_retries:
|
|
187
|
-
await asyncio.sleep(2**attempt)
|
|
188
|
-
|
|
189
|
-
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
|
|
190
161
|
|
|
191
162
|
def parse_departure(
|
|
192
163
|
self, stop: Dict[str, Any], tz: Union[ZoneInfo, Any], now: datetime
|
|
@@ -247,45 +218,31 @@ class EFABaseProvider(BaseProvider):
|
|
|
247
218
|
url = f"{self.sf_base_url}?{params}"
|
|
248
219
|
name = self.provider_name
|
|
249
220
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
"id": location.get("id", ""),
|
|
279
|
-
"name": location.get("name", ""),
|
|
280
|
-
"place": place,
|
|
281
|
-
"area_type": location.get("type", ""),
|
|
282
|
-
}
|
|
283
|
-
)
|
|
284
|
-
|
|
285
|
-
return results
|
|
286
|
-
else:
|
|
287
|
-
_LOGGER.error("%s API returned status %s", name, response.status)
|
|
288
|
-
except Exception as e:
|
|
289
|
-
_LOGGER.error("Error searching %s stops: %s", name, e, exc_info=True)
|
|
290
|
-
|
|
291
|
-
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
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|