python-openpublictransport 0.1.17__tar.gz → 0.3.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.17 → python_openpublictransport-0.3.0}/PKG-INFO +1 -1
- {python_openpublictransport-0.1.17 → python_openpublictransport-0.3.0}/pyproject.toml +1 -1
- {python_openpublictransport-0.1.17 → python_openpublictransport-0.3.0}/src/openpublictransport/__init__.py +13 -1
- {python_openpublictransport-0.1.17 → python_openpublictransport-0.3.0}/src/openpublictransport/const.py +22 -2
- python_openpublictransport-0.3.0/src/openpublictransport/exceptions.py +81 -0
- {python_openpublictransport-0.1.17 → python_openpublictransport-0.3.0}/src/openpublictransport/providers/__init__.py +63 -3
- python_openpublictransport-0.3.0/src/openpublictransport/providers/base.py +199 -0
- python_openpublictransport-0.3.0/src/openpublictransport/providers/db.py +69 -0
- python_openpublictransport-0.3.0/src/openpublictransport/providers/digitransit.py +102 -0
- {python_openpublictransport-0.1.17 → python_openpublictransport-0.3.0}/src/openpublictransport/providers/efa_base.py +46 -92
- {python_openpublictransport-0.1.17 → python_openpublictransport-0.3.0}/src/openpublictransport/providers/entur.py +8 -31
- {python_openpublictransport-0.1.17 → python_openpublictransport-0.3.0}/src/openpublictransport/providers/fptf_base.py +33 -51
- python_openpublictransport-0.3.0/src/openpublictransport/providers/golemio.py +251 -0
- python_openpublictransport-0.3.0/src/openpublictransport/providers/gvh.py +55 -0
- {python_openpublictransport-0.1.17 → python_openpublictransport-0.3.0}/src/openpublictransport/providers/hafas_base.py +2 -28
- {python_openpublictransport-0.1.17 → python_openpublictransport-0.3.0}/src/openpublictransport/providers/hafas_mgate_base.py +166 -38
- python_openpublictransport-0.3.0/src/openpublictransport/providers/hongkong.py +364 -0
- {python_openpublictransport-0.1.17 → python_openpublictransport-0.3.0}/src/openpublictransport/providers/hvv_gti.py +21 -37
- python_openpublictransport-0.3.0/src/openpublictransport/providers/insa.py +29 -0
- python_openpublictransport-0.3.0/src/openpublictransport/providers/irail.py +210 -0
- python_openpublictransport-0.3.0/src/openpublictransport/providers/nahsh.py +29 -0
- {python_openpublictransport-0.1.17 → python_openpublictransport-0.3.0}/src/openpublictransport/providers/national_rail.py +35 -41
- python_openpublictransport-0.3.0/src/openpublictransport/providers/nextbus_base.py +260 -0
- python_openpublictransport-0.3.0/src/openpublictransport/providers/nta.py +243 -0
- python_openpublictransport-0.3.0/src/openpublictransport/providers/nvv.py +30 -0
- {python_openpublictransport-0.1.17 → python_openpublictransport-0.3.0}/src/openpublictransport/providers/otp.py +8 -16
- {python_openpublictransport-0.1.17 → python_openpublictransport-0.3.0}/src/openpublictransport/providers/otp_base.py +50 -41
- {python_openpublictransport-0.1.17 → python_openpublictransport-0.3.0}/src/openpublictransport/providers/otp_custom.py +2 -2
- {python_openpublictransport-0.1.17 → python_openpublictransport-0.3.0}/src/openpublictransport/providers/rejseplanen.py +10 -31
- {python_openpublictransport-0.1.17 → python_openpublictransport-0.3.0}/src/openpublictransport/providers/rmv.py +42 -78
- python_openpublictransport-0.3.0/src/openpublictransport/providers/saarvv.py +29 -0
- {python_openpublictransport-0.1.17 → python_openpublictransport-0.3.0}/src/openpublictransport/providers/sbb.py +46 -53
- python_openpublictransport-0.3.0/src/openpublictransport/providers/sl.py +219 -0
- python_openpublictransport-0.3.0/src/openpublictransport/providers/tfl.py +256 -0
- python_openpublictransport-0.3.0/src/openpublictransport/providers/tfnsw.py +205 -0
- python_openpublictransport-0.3.0/src/openpublictransport/providers/trafiklab.py +170 -0
- {python_openpublictransport-0.1.17 → python_openpublictransport-0.3.0}/src/openpublictransport/providers/transitous.py +25 -47
- {python_openpublictransport-0.1.17 → python_openpublictransport-0.3.0}/src/openpublictransport/providers/trias_base.py +12 -26
- python_openpublictransport-0.3.0/src/openpublictransport/providers/trimet.py +22 -0
- python_openpublictransport-0.3.0/src/openpublictransport/providers/ttc.py +41 -0
- python_openpublictransport-0.3.0/src/openpublictransport/providers/vao.py +34 -0
- python_openpublictransport-0.3.0/src/openpublictransport/providers/vbn_hafas.py +31 -0
- python_openpublictransport-0.3.0/src/openpublictransport/providers/vmt.py +29 -0
- python_openpublictransport-0.3.0/src/openpublictransport/providers/vmv.py +55 -0
- python_openpublictransport-0.3.0/src/openpublictransport/providers/vos.py +29 -0
- {python_openpublictransport-0.1.17 → python_openpublictransport-0.3.0}/src/openpublictransport/providers/vrr.py +2 -2
- python_openpublictransport-0.3.0/src/openpublictransport/providers/vsn.py +30 -0
- python_openpublictransport-0.3.0/src/openpublictransport/providers/wienerlinien.py +253 -0
- {python_openpublictransport-0.1.17 → python_openpublictransport-0.3.0}/src/python_openpublictransport.egg-info/PKG-INFO +1 -1
- {python_openpublictransport-0.1.17 → python_openpublictransport-0.3.0}/src/python_openpublictransport.egg-info/SOURCES.txt +36 -2
- python_openpublictransport-0.3.0/tests/test_digitransit.py +73 -0
- python_openpublictransport-0.3.0/tests/test_german_regional_providers.py +204 -0
- python_openpublictransport-0.3.0/tests/test_golemio.py +128 -0
- python_openpublictransport-0.3.0/tests/test_hongkong.py +148 -0
- python_openpublictransport-0.3.0/tests/test_http_errors.py +222 -0
- {python_openpublictransport-0.1.17 → python_openpublictransport-0.3.0}/tests/test_hvv_gti.py +7 -3
- python_openpublictransport-0.3.0/tests/test_irail.py +131 -0
- python_openpublictransport-0.3.0/tests/test_provider_expansion.py +154 -0
- python_openpublictransport-0.3.0/tests/test_sbb.py +54 -0
- python_openpublictransport-0.3.0/tests/test_sl.py +150 -0
- python_openpublictransport-0.3.0/tests/test_tfl.py +114 -0
- python_openpublictransport-0.3.0/tests/test_tfnsw.py +137 -0
- python_openpublictransport-0.3.0/tests/test_ttc.py +167 -0
- python_openpublictransport-0.3.0/tests/test_wienerlinien.py +160 -0
- python_openpublictransport-0.1.17/src/openpublictransport/exceptions.py +0 -9
- python_openpublictransport-0.1.17/src/openpublictransport/providers/base.py +0 -77
- python_openpublictransport-0.1.17/src/openpublictransport/providers/db.py +0 -39
- python_openpublictransport-0.1.17/src/openpublictransport/providers/gtfsde.py +0 -21
- python_openpublictransport-0.1.17/src/openpublictransport/providers/nta.py +0 -291
- python_openpublictransport-0.1.17/src/openpublictransport/providers/trafiklab.py +0 -284
- {python_openpublictransport-0.1.17 → python_openpublictransport-0.3.0}/setup.cfg +0 -0
- {python_openpublictransport-0.1.17 → python_openpublictransport-0.3.0}/src/openpublictransport/data/uk_stations.json +0 -0
- {python_openpublictransport-0.1.17 → python_openpublictransport-0.3.0}/src/openpublictransport/models.py +0 -0
- {python_openpublictransport-0.1.17 → python_openpublictransport-0.3.0}/src/openpublictransport/parsers.py +0 -0
- {python_openpublictransport-0.1.17 → python_openpublictransport-0.3.0}/src/openpublictransport/providers/avv.py +0 -0
- {python_openpublictransport-0.1.17 → python_openpublictransport-0.3.0}/src/openpublictransport/providers/bart.py +0 -0
- {python_openpublictransport-0.1.17 → python_openpublictransport-0.3.0}/src/openpublictransport/providers/beg.py +0 -0
- {python_openpublictransport-0.1.17 → python_openpublictransport-0.3.0}/src/openpublictransport/providers/bsvg.py +0 -0
- {python_openpublictransport-0.1.17 → python_openpublictransport-0.3.0}/src/openpublictransport/providers/bvg.py +0 -0
- {python_openpublictransport-0.1.17 → python_openpublictransport-0.3.0}/src/openpublictransport/providers/dart.py +0 -0
- {python_openpublictransport-0.1.17 → python_openpublictransport-0.3.0}/src/openpublictransport/providers/ding.py +0 -0
- {python_openpublictransport-0.1.17 → python_openpublictransport-0.3.0}/src/openpublictransport/providers/hvv.py +0 -0
- {python_openpublictransport-0.1.17 → python_openpublictransport-0.3.0}/src/openpublictransport/providers/irishrail.py +0 -0
- {python_openpublictransport-0.1.17 → python_openpublictransport-0.3.0}/src/openpublictransport/providers/kvv.py +0 -0
- {python_openpublictransport-0.1.17 → python_openpublictransport-0.3.0}/src/openpublictransport/providers/mobiliteit_lu.py +0 -0
- {python_openpublictransport-0.1.17 → python_openpublictransport-0.3.0}/src/openpublictransport/providers/mvv.py +0 -0
- {python_openpublictransport-0.1.17 → python_openpublictransport-0.3.0}/src/openpublictransport/providers/ns.py +0 -0
- {python_openpublictransport-0.1.17 → python_openpublictransport-0.3.0}/src/openpublictransport/providers/nvbw.py +0 -0
- {python_openpublictransport-0.1.17 → python_openpublictransport-0.3.0}/src/openpublictransport/providers/nwl.py +0 -0
- {python_openpublictransport-0.1.17 → python_openpublictransport-0.3.0}/src/openpublictransport/providers/oebb.py +0 -0
- {python_openpublictransport-0.1.17 → python_openpublictransport-0.3.0}/src/openpublictransport/providers/rvv.py +0 -0
- {python_openpublictransport-0.1.17 → python_openpublictransport-0.3.0}/src/openpublictransport/providers/tpg.py +0 -0
- {python_openpublictransport-0.1.17 → python_openpublictransport-0.3.0}/src/openpublictransport/providers/vagfr.py +0 -0
- {python_openpublictransport-0.1.17 → python_openpublictransport-0.3.0}/src/openpublictransport/providers/vbn.py +0 -0
- {python_openpublictransport-0.1.17 → python_openpublictransport-0.3.0}/src/openpublictransport/providers/vgn.py +0 -0
- {python_openpublictransport-0.1.17 → python_openpublictransport-0.3.0}/src/openpublictransport/providers/vrn.py +0 -0
- {python_openpublictransport-0.1.17 → python_openpublictransport-0.3.0}/src/openpublictransport/providers/vvo.py +0 -0
- {python_openpublictransport-0.1.17 → python_openpublictransport-0.3.0}/src/openpublictransport/providers/vvs.py +0 -0
- {python_openpublictransport-0.1.17 → python_openpublictransport-0.3.0}/src/openpublictransport/py.typed +0 -0
- {python_openpublictransport-0.1.17 → python_openpublictransport-0.3.0}/src/python_openpublictransport.egg-info/dependency_links.txt +0 -0
- {python_openpublictransport-0.1.17 → python_openpublictransport-0.3.0}/src/python_openpublictransport.egg-info/requires.txt +0 -0
- {python_openpublictransport-0.1.17 → python_openpublictransport-0.3.0}/src/python_openpublictransport.egg-info/top_level.txt +0 -0
- {python_openpublictransport-0.1.17 → python_openpublictransport-0.3.0}/tests/test_efa_mimetype.py +0 -0
- {python_openpublictransport-0.1.17 → python_openpublictransport-0.3.0}/tests/test_efa_platform.py +0 -0
- {python_openpublictransport-0.1.17 → python_openpublictransport-0.3.0}/tests/test_entur.py +0 -0
- {python_openpublictransport-0.1.17 → python_openpublictransport-0.3.0}/tests/test_hafas_base.py +0 -0
- {python_openpublictransport-0.1.17 → python_openpublictransport-0.3.0}/tests/test_hafas_mgate.py +0 -0
- {python_openpublictransport-0.1.17 → python_openpublictransport-0.3.0}/tests/test_national_rail.py +0 -0
- {python_openpublictransport-0.1.17 → python_openpublictransport-0.3.0}/tests/test_national_rail_parse.py +0 -0
- {python_openpublictransport-0.1.17 → python_openpublictransport-0.3.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.3.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
|
]
|
|
@@ -28,7 +28,6 @@ PROVIDER_TRANSITOUS = "transitous"
|
|
|
28
28
|
PROVIDER_DB = "db"
|
|
29
29
|
PROVIDER_VBN_OTP = "vbn_otp"
|
|
30
30
|
PROVIDER_VBN_TRIAS = "vbn_trias"
|
|
31
|
-
PROVIDER_OPT = "openpublictransport"
|
|
32
31
|
PROVIDER_OTP_CUSTOM = "otp_custom"
|
|
33
32
|
PROVIDER_NATIONAL_RAIL = "national_rail"
|
|
34
33
|
PROVIDER_REJSEPLANEN = "rejseplanen"
|
|
@@ -39,9 +38,30 @@ PROVIDER_BART_US = "bart_us"
|
|
|
39
38
|
PROVIDER_DART_US = "dart_us"
|
|
40
39
|
PROVIDER_IRISHRAIL_IE = "irishrail_ie"
|
|
41
40
|
PROVIDER_TPG_CH = "tpg_ch"
|
|
41
|
+
PROVIDER_VMV = "vmv"
|
|
42
|
+
PROVIDER_GVH = "gvh"
|
|
43
|
+
PROVIDER_NAHSH = "nahsh"
|
|
44
|
+
PROVIDER_INSA = "insa"
|
|
45
|
+
PROVIDER_VMT = "vmt"
|
|
46
|
+
PROVIDER_SAARVV = "saarvv"
|
|
47
|
+
PROVIDER_VAO_AT = "vao_at"
|
|
48
|
+
PROVIDER_VSN = "vsn"
|
|
49
|
+
PROVIDER_VOS = "vos"
|
|
50
|
+
PROVIDER_NVV = "nvv"
|
|
51
|
+
PROVIDER_VBN_HAFAS = "vbn_hafas"
|
|
52
|
+
PROVIDER_TRIMET_US = "trimet_us"
|
|
53
|
+
PROVIDER_IRAIL_BE = "irail_be"
|
|
54
|
+
PROVIDER_SL_SE = "sl_se"
|
|
55
|
+
PROVIDER_TTC_CA = "ttc_ca"
|
|
56
|
+
PROVIDER_HONGKONG = "hongkong"
|
|
57
|
+
PROVIDER_WIENERLINIEN = "wienerlinien_at"
|
|
58
|
+
PROVIDER_TFL = "tfl_uk"
|
|
59
|
+
PROVIDER_TFNSW = "tfnsw_au"
|
|
60
|
+
PROVIDER_DIGITRANSIT = "digitransit_fi"
|
|
61
|
+
PROVIDER_GOLEMIO = "golemio_cz"
|
|
42
62
|
|
|
43
63
|
# API base URLs
|
|
44
|
-
API_BASE_URL_VRR = "https://openservice
|
|
64
|
+
API_BASE_URL_VRR = "https://openservice.vrr.de/static03/XML_DM_REQUEST"
|
|
45
65
|
API_BASE_URL_KVV = "https://projekte.kvv-efa.de/sl3-alone/XSLT_DM_REQUEST"
|
|
46
66
|
API_BASE_URL_HVV = "https://hvv.efa.de/efa/XML_DM_REQUEST"
|
|
47
67
|
API_BASE_URL_TRAFIKLAB = "https://realtime-api.trafiklab.se/v1"
|
|
@@ -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
|
+
)
|
|
@@ -18,7 +18,6 @@ from ..const import (
|
|
|
18
18
|
PROVIDER_NVBW,
|
|
19
19
|
PROVIDER_NWL,
|
|
20
20
|
PROVIDER_OEBB,
|
|
21
|
-
PROVIDER_OPT,
|
|
22
21
|
PROVIDER_OTP_CUSTOM,
|
|
23
22
|
PROVIDER_RMV,
|
|
24
23
|
PROVIDER_RVV,
|
|
@@ -42,6 +41,27 @@ from ..const import (
|
|
|
42
41
|
PROVIDER_DART_US,
|
|
43
42
|
PROVIDER_IRISHRAIL_IE,
|
|
44
43
|
PROVIDER_TPG_CH,
|
|
44
|
+
PROVIDER_VMV,
|
|
45
|
+
PROVIDER_GVH,
|
|
46
|
+
PROVIDER_NAHSH,
|
|
47
|
+
PROVIDER_INSA,
|
|
48
|
+
PROVIDER_VMT,
|
|
49
|
+
PROVIDER_SAARVV,
|
|
50
|
+
PROVIDER_VBN_HAFAS,
|
|
51
|
+
PROVIDER_NVV,
|
|
52
|
+
PROVIDER_VOS,
|
|
53
|
+
PROVIDER_VSN,
|
|
54
|
+
PROVIDER_VAO_AT,
|
|
55
|
+
PROVIDER_TRIMET_US,
|
|
56
|
+
PROVIDER_IRAIL_BE,
|
|
57
|
+
PROVIDER_SL_SE,
|
|
58
|
+
PROVIDER_TTC_CA,
|
|
59
|
+
PROVIDER_HONGKONG,
|
|
60
|
+
PROVIDER_WIENERLINIEN,
|
|
61
|
+
PROVIDER_TFL,
|
|
62
|
+
PROVIDER_TFNSW,
|
|
63
|
+
PROVIDER_DIGITRANSIT,
|
|
64
|
+
PROVIDER_GOLEMIO,
|
|
45
65
|
)
|
|
46
66
|
from .avv import AVVProvider
|
|
47
67
|
from .base import BaseProvider
|
|
@@ -50,7 +70,6 @@ from .bsvg import BSVGProvider
|
|
|
50
70
|
from .bvg import BVGProvider
|
|
51
71
|
from .db import DBProvider
|
|
52
72
|
from .ding import DINGProvider
|
|
53
|
-
from .gtfsde import OPTProvider
|
|
54
73
|
from .hvv import HVVProvider
|
|
55
74
|
from .hvv_gti import HVVGTIProvider
|
|
56
75
|
from .kvv import KVVProvider
|
|
@@ -81,6 +100,27 @@ from .bart import BARTProvider
|
|
|
81
100
|
from .dart import DARTProvider
|
|
82
101
|
from .irishrail import IrishRailProvider
|
|
83
102
|
from .tpg import TPGProvider
|
|
103
|
+
from .vmv import VMVProvider
|
|
104
|
+
from .gvh import GVHProvider
|
|
105
|
+
from .nahsh import NAHSHProvider
|
|
106
|
+
from .insa import INSAProvider
|
|
107
|
+
from .vmt import VMTProvider
|
|
108
|
+
from .saarvv import SaarVVProvider
|
|
109
|
+
from .vbn_hafas import VBNHafasProvider
|
|
110
|
+
from .nvv import NVVProvider
|
|
111
|
+
from .vos import VOSProvider
|
|
112
|
+
from .vsn import VSNProvider
|
|
113
|
+
from .vao import VAOProvider
|
|
114
|
+
from .trimet import TriMetProvider
|
|
115
|
+
from .irail import IRailProvider
|
|
116
|
+
from .sl import SLProvider
|
|
117
|
+
from .ttc import TTCProvider
|
|
118
|
+
from .hongkong import HongKongProvider
|
|
119
|
+
from .wienerlinien import WienerLinienProvider
|
|
120
|
+
from .tfl import TfLProvider
|
|
121
|
+
from .tfnsw import TfNSWProvider
|
|
122
|
+
from .digitransit import DigitransitProvider
|
|
123
|
+
from .golemio import GolemioProvider
|
|
84
124
|
|
|
85
125
|
_PROVIDER_REGISTRY: Dict[str, Type[BaseProvider]] = {}
|
|
86
126
|
|
|
@@ -152,7 +192,6 @@ register_provider(PROVIDER_TRANSITOUS, TransitousProvider)
|
|
|
152
192
|
register_provider(PROVIDER_DB, DBProvider)
|
|
153
193
|
register_provider(PROVIDER_VBN_OTP, VBNOTPProvider)
|
|
154
194
|
register_provider(PROVIDER_VBN_TRIAS, VBNTriasProvider)
|
|
155
|
-
register_provider(PROVIDER_OPT, OPTProvider)
|
|
156
195
|
register_provider(PROVIDER_OTP_CUSTOM, OTPCustomProvider)
|
|
157
196
|
register_provider(PROVIDER_NATIONAL_RAIL, NationalRailProvider)
|
|
158
197
|
register_provider(PROVIDER_REJSEPLANEN, RejseplanenProvider)
|
|
@@ -163,4 +202,25 @@ register_provider(PROVIDER_BART_US, BARTProvider)
|
|
|
163
202
|
register_provider(PROVIDER_DART_US, DARTProvider)
|
|
164
203
|
register_provider(PROVIDER_IRISHRAIL_IE, IrishRailProvider)
|
|
165
204
|
register_provider(PROVIDER_TPG_CH, TPGProvider)
|
|
205
|
+
register_provider(PROVIDER_VMV, VMVProvider)
|
|
206
|
+
register_provider(PROVIDER_GVH, GVHProvider)
|
|
207
|
+
register_provider(PROVIDER_NAHSH, NAHSHProvider)
|
|
208
|
+
register_provider(PROVIDER_INSA, INSAProvider)
|
|
209
|
+
register_provider(PROVIDER_VMT, VMTProvider)
|
|
210
|
+
register_provider(PROVIDER_SAARVV, SaarVVProvider)
|
|
211
|
+
register_provider(PROVIDER_VBN_HAFAS, VBNHafasProvider)
|
|
212
|
+
register_provider(PROVIDER_NVV, NVVProvider)
|
|
213
|
+
register_provider(PROVIDER_VOS, VOSProvider)
|
|
214
|
+
register_provider(PROVIDER_VSN, VSNProvider)
|
|
215
|
+
register_provider(PROVIDER_VAO_AT, VAOProvider)
|
|
216
|
+
register_provider(PROVIDER_TRIMET_US, TriMetProvider)
|
|
217
|
+
register_provider(PROVIDER_IRAIL_BE, IRailProvider)
|
|
218
|
+
register_provider(PROVIDER_SL_SE, SLProvider)
|
|
219
|
+
register_provider(PROVIDER_TTC_CA, TTCProvider)
|
|
220
|
+
register_provider(PROVIDER_HONGKONG, HongKongProvider)
|
|
221
|
+
register_provider(PROVIDER_WIENERLINIEN, WienerLinienProvider)
|
|
222
|
+
register_provider(PROVIDER_TFL, TfLProvider)
|
|
223
|
+
register_provider(PROVIDER_TFNSW, TfNSWProvider)
|
|
224
|
+
register_provider(PROVIDER_DIGITRANSIT, DigitransitProvider)
|
|
225
|
+
register_provider(PROVIDER_GOLEMIO, GolemioProvider)
|
|
166
226
|
register_provider(PROVIDER_HVV_GTI, HVVGTIProvider)
|
|
@@ -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
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Deutsche Bahn provider implementation.
|
|
2
|
+
|
|
3
|
+
Served through Transitous (MOTIS 2, api.transitous.org), which carries the
|
|
4
|
+
nationwide DELFI timetable with DB real-time data. No API key required.
|
|
5
|
+
|
|
6
|
+
The provider used to talk to the community proxy v6.db.transport.rest, which
|
|
7
|
+
has been unavailable since autumn 2026. Entries created back then store a
|
|
8
|
+
numeric station number (IBNR) as stop id; those are resolved once by name to
|
|
9
|
+
the matching Transitous stop.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
from typing import Any, Dict, List, Optional
|
|
14
|
+
|
|
15
|
+
from ..const import PROVIDER_DB
|
|
16
|
+
from .transitous import TransitousProvider
|
|
17
|
+
|
|
18
|
+
_LOGGER = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class DBProvider(TransitousProvider):
|
|
22
|
+
"""Deutsche Bahn (Germany-wide) via Transitous."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
25
|
+
super().__init__(*args, **kwargs)
|
|
26
|
+
self._legacy_ids: Dict[str, str] = {}
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def provider_id(self) -> str:
|
|
30
|
+
return PROVIDER_DB
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def provider_name(self) -> str:
|
|
34
|
+
return "DB (Deutsche Bahn)"
|
|
35
|
+
|
|
36
|
+
def get_timezone(self) -> str:
|
|
37
|
+
return "Europe/Berlin"
|
|
38
|
+
|
|
39
|
+
async def search_stops(self, search_term: str) -> List[Dict[str, Any]]:
|
|
40
|
+
"""Transitous searches worldwide; keep the German stops."""
|
|
41
|
+
stops = await super().search_stops(search_term)
|
|
42
|
+
german = [stop for stop in stops if stop.get("country") in (None, "", "DE")]
|
|
43
|
+
return german or stops
|
|
44
|
+
|
|
45
|
+
async def fetch_departures(
|
|
46
|
+
self,
|
|
47
|
+
station_id: Optional[str],
|
|
48
|
+
place_dm: str,
|
|
49
|
+
name_dm: str,
|
|
50
|
+
departures_limit: int,
|
|
51
|
+
) -> Optional[Dict[str, Any]]:
|
|
52
|
+
if station_id and station_id.isdigit():
|
|
53
|
+
station_id = await self._resolve_legacy_id(station_id, name_dm or place_dm)
|
|
54
|
+
return await super().fetch_departures(station_id, place_dm, name_dm, departures_limit)
|
|
55
|
+
|
|
56
|
+
async def _resolve_legacy_id(self, ibnr: str, name: str) -> Optional[str]:
|
|
57
|
+
"""Map a v6.db.transport.rest station number to a Transitous stop id."""
|
|
58
|
+
if ibnr in self._legacy_ids:
|
|
59
|
+
return self._legacy_ids[ibnr]
|
|
60
|
+
if not name:
|
|
61
|
+
_LOGGER.warning("DB: cannot resolve legacy station %s without a stop name", ibnr)
|
|
62
|
+
return None
|
|
63
|
+
stops = await self.search_stops(name)
|
|
64
|
+
if not stops:
|
|
65
|
+
_LOGGER.warning("DB: no Transitous stop found for legacy station %s (%s)", ibnr, name)
|
|
66
|
+
return None
|
|
67
|
+
self._legacy_ids[ibnr] = stops[0]["id"]
|
|
68
|
+
_LOGGER.debug("DB: legacy station %s (%s) resolved to %s", ibnr, name, stops[0]["id"])
|
|
69
|
+
return stops[0]["id"]
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Digitransit (Finland) provider — OpenTripPlanner 2 GraphQL.
|
|
2
|
+
|
|
3
|
+
Requires a free subscription key from https://portal-api.digitransit.fi/,
|
|
4
|
+
sent as the ``digitransit-subscription-key`` header.
|
|
5
|
+
|
|
6
|
+
Default router is ``finland`` (all of Finland, incl. HSL Helsinki, Tampere,
|
|
7
|
+
Turku, Oulu, VR long-distance trains). Regional routers can be selected via
|
|
8
|
+
``custom_url``: either the full GraphQL URL or one of the shorthands
|
|
9
|
+
``"finland"``, ``"hsl"`` (Helsinki region) and ``"waltti"`` (Waltti cities).
|
|
10
|
+
|
|
11
|
+
Stop ids are GTFS ids (``HSL:1020453``); stops that share a parent station or
|
|
12
|
+
lie close together with the same name are combined with ``|`` like the other
|
|
13
|
+
OTP2 providers.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
from typing import Any, Dict, List, Optional
|
|
18
|
+
|
|
19
|
+
from .otp import OTPProvider, _smart_title
|
|
20
|
+
from ..const import PROVIDER_DIGITRANSIT
|
|
21
|
+
|
|
22
|
+
_LOGGER = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
ROUTER_URLS: Dict[str, str] = {
|
|
26
|
+
"finland": "https://api.digitransit.fi/routing/v2/finland/gtfs/v1",
|
|
27
|
+
"hsl": "https://api.digitransit.fi/routing/v2/hsl/gtfs/v1",
|
|
28
|
+
"waltti": "https://api.digitransit.fi/routing/v2/waltti/gtfs/v1",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
MODE_MAPPING: Dict[str, str] = {
|
|
32
|
+
"BUS": "bus",
|
|
33
|
+
"COACH": "bus",
|
|
34
|
+
"TROLLEYBUS": "bus",
|
|
35
|
+
"RAIL": "train",
|
|
36
|
+
"TRAM": "tram",
|
|
37
|
+
"SUBWAY": "subway",
|
|
38
|
+
"FERRY": "ferry",
|
|
39
|
+
"FUNICULAR": "train",
|
|
40
|
+
"GONDOLA": "tram",
|
|
41
|
+
"CABLE_CAR": "tram",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class DigitransitProvider(OTPProvider):
|
|
46
|
+
"""Digitransit Finland (HSL, Waltti, VR …)."""
|
|
47
|
+
|
|
48
|
+
otp_base_url: str = ROUTER_URLS["finland"]
|
|
49
|
+
geocode_countries: str = "fi"
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def provider_id(self) -> str:
|
|
53
|
+
return PROVIDER_DIGITRANSIT
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def provider_name(self) -> str:
|
|
57
|
+
return "Digitransit (Finnland)"
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def requires_api_key(self) -> bool:
|
|
61
|
+
return True
|
|
62
|
+
|
|
63
|
+
def get_timezone(self) -> str:
|
|
64
|
+
return "Europe/Helsinki"
|
|
65
|
+
|
|
66
|
+
def get_mode_mapping(self) -> Dict[str, str]:
|
|
67
|
+
return MODE_MAPPING
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def graphql_url(self) -> str:
|
|
71
|
+
custom = (self.custom_url or "").strip()
|
|
72
|
+
if custom:
|
|
73
|
+
return ROUTER_URLS.get(custom.lower(), custom.rstrip("/"))
|
|
74
|
+
return self.otp_base_url
|
|
75
|
+
|
|
76
|
+
def _auth_headers(self) -> Dict[str, str]:
|
|
77
|
+
headers = {"Accept": "application/json", "Content-Type": "application/json"}
|
|
78
|
+
if self.api_key:
|
|
79
|
+
headers["digitransit-subscription-key"] = self.api_key
|
|
80
|
+
return headers
|
|
81
|
+
|
|
82
|
+
async def _graphql(self, query: str) -> Optional[Dict[str, Any]]:
|
|
83
|
+
# Digitransit's endpoint *is* the GraphQL URL (no /index/graphql suffix).
|
|
84
|
+
return await self._request(
|
|
85
|
+
"post",
|
|
86
|
+
self.graphql_url,
|
|
87
|
+
json={"query": query},
|
|
88
|
+
headers=self._auth_headers(),
|
|
89
|
+
timeout=15,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
async def search_stops(self, search_term: str) -> List[Dict[str, Any]]:
|
|
93
|
+
"""Search by stop name; no German city-prefix / Nominatim fallbacks."""
|
|
94
|
+
term = search_term.strip()
|
|
95
|
+
if not term:
|
|
96
|
+
return []
|
|
97
|
+
smart = _smart_title(term)
|
|
98
|
+
for candidate in dict.fromkeys([term, smart]):
|
|
99
|
+
raw = await self._search_one(candidate)
|
|
100
|
+
if raw:
|
|
101
|
+
return self._group_by_name(raw)[:20]
|
|
102
|
+
return []
|