python-openpublictransport 0.2.0__tar.gz → 0.3.1__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 (109) hide show
  1. {python_openpublictransport-0.2.0 → python_openpublictransport-0.3.1}/PKG-INFO +1 -1
  2. {python_openpublictransport-0.2.0 → python_openpublictransport-0.3.1}/pyproject.toml +1 -1
  3. {python_openpublictransport-0.2.0 → python_openpublictransport-0.3.1}/src/openpublictransport/const.py +23 -2
  4. {python_openpublictransport-0.2.0 → python_openpublictransport-0.3.1}/src/openpublictransport/parsers.py +25 -1
  5. {python_openpublictransport-0.2.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/__init__.py +66 -3
  6. python_openpublictransport-0.3.1/src/openpublictransport/providers/cta.py +400 -0
  7. python_openpublictransport-0.3.1/src/openpublictransport/providers/db.py +69 -0
  8. python_openpublictransport-0.3.1/src/openpublictransport/providers/digitransit.py +102 -0
  9. {python_openpublictransport-0.2.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/efa_base.py +8 -7
  10. python_openpublictransport-0.3.1/src/openpublictransport/providers/golemio.py +251 -0
  11. python_openpublictransport-0.3.1/src/openpublictransport/providers/gvh.py +55 -0
  12. {python_openpublictransport-0.2.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/hafas_mgate_base.py +143 -10
  13. python_openpublictransport-0.3.1/src/openpublictransport/providers/hongkong.py +364 -0
  14. python_openpublictransport-0.3.1/src/openpublictransport/providers/insa.py +29 -0
  15. python_openpublictransport-0.3.1/src/openpublictransport/providers/irail.py +210 -0
  16. python_openpublictransport-0.3.1/src/openpublictransport/providers/nahsh.py +29 -0
  17. python_openpublictransport-0.3.1/src/openpublictransport/providers/nextbus_base.py +260 -0
  18. python_openpublictransport-0.3.1/src/openpublictransport/providers/nvv.py +30 -0
  19. {python_openpublictransport-0.2.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/otp.py +1 -1
  20. {python_openpublictransport-0.2.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/otp_base.py +26 -4
  21. {python_openpublictransport-0.2.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/otp_custom.py +2 -2
  22. python_openpublictransport-0.3.1/src/openpublictransport/providers/saarvv.py +29 -0
  23. {python_openpublictransport-0.2.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/sbb.py +22 -11
  24. python_openpublictransport-0.3.1/src/openpublictransport/providers/sl.py +219 -0
  25. python_openpublictransport-0.3.1/src/openpublictransport/providers/tfl.py +256 -0
  26. python_openpublictransport-0.3.1/src/openpublictransport/providers/tfnsw.py +205 -0
  27. {python_openpublictransport-0.2.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/transitous.py +8 -0
  28. python_openpublictransport-0.3.1/src/openpublictransport/providers/trimet.py +22 -0
  29. python_openpublictransport-0.3.1/src/openpublictransport/providers/ttc.py +41 -0
  30. python_openpublictransport-0.3.1/src/openpublictransport/providers/vao.py +34 -0
  31. python_openpublictransport-0.3.1/src/openpublictransport/providers/vbn_hafas.py +31 -0
  32. python_openpublictransport-0.3.1/src/openpublictransport/providers/vmt.py +29 -0
  33. python_openpublictransport-0.3.1/src/openpublictransport/providers/vmv.py +55 -0
  34. python_openpublictransport-0.3.1/src/openpublictransport/providers/vos.py +29 -0
  35. {python_openpublictransport-0.2.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/vrr.py +2 -2
  36. python_openpublictransport-0.3.1/src/openpublictransport/providers/vsn.py +30 -0
  37. python_openpublictransport-0.3.1/src/openpublictransport/providers/wienerlinien.py +253 -0
  38. {python_openpublictransport-0.2.0 → python_openpublictransport-0.3.1}/src/python_openpublictransport.egg-info/PKG-INFO +1 -1
  39. {python_openpublictransport-0.2.0 → python_openpublictransport-0.3.1}/src/python_openpublictransport.egg-info/SOURCES.txt +38 -2
  40. python_openpublictransport-0.3.1/tests/test_cta.py +349 -0
  41. python_openpublictransport-0.3.1/tests/test_digitransit.py +73 -0
  42. python_openpublictransport-0.3.1/tests/test_german_regional_providers.py +204 -0
  43. python_openpublictransport-0.3.1/tests/test_golemio.py +128 -0
  44. python_openpublictransport-0.3.1/tests/test_hongkong.py +148 -0
  45. python_openpublictransport-0.3.1/tests/test_irail.py +131 -0
  46. python_openpublictransport-0.3.1/tests/test_issue_fixes_0_3_1.py +84 -0
  47. python_openpublictransport-0.3.1/tests/test_provider_expansion.py +154 -0
  48. python_openpublictransport-0.3.1/tests/test_sbb.py +54 -0
  49. python_openpublictransport-0.3.1/tests/test_sl.py +150 -0
  50. python_openpublictransport-0.3.1/tests/test_tfl.py +114 -0
  51. python_openpublictransport-0.3.1/tests/test_tfnsw.py +137 -0
  52. python_openpublictransport-0.3.1/tests/test_ttc.py +167 -0
  53. python_openpublictransport-0.3.1/tests/test_wienerlinien.py +160 -0
  54. python_openpublictransport-0.2.0/src/openpublictransport/providers/db.py +0 -39
  55. python_openpublictransport-0.2.0/src/openpublictransport/providers/gtfsde.py +0 -21
  56. {python_openpublictransport-0.2.0 → python_openpublictransport-0.3.1}/setup.cfg +0 -0
  57. {python_openpublictransport-0.2.0 → python_openpublictransport-0.3.1}/src/openpublictransport/__init__.py +0 -0
  58. {python_openpublictransport-0.2.0 → python_openpublictransport-0.3.1}/src/openpublictransport/data/uk_stations.json +0 -0
  59. {python_openpublictransport-0.2.0 → python_openpublictransport-0.3.1}/src/openpublictransport/exceptions.py +0 -0
  60. {python_openpublictransport-0.2.0 → python_openpublictransport-0.3.1}/src/openpublictransport/models.py +0 -0
  61. {python_openpublictransport-0.2.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/avv.py +0 -0
  62. {python_openpublictransport-0.2.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/bart.py +0 -0
  63. {python_openpublictransport-0.2.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/base.py +0 -0
  64. {python_openpublictransport-0.2.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/beg.py +0 -0
  65. {python_openpublictransport-0.2.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/bsvg.py +0 -0
  66. {python_openpublictransport-0.2.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/bvg.py +0 -0
  67. {python_openpublictransport-0.2.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/dart.py +0 -0
  68. {python_openpublictransport-0.2.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/ding.py +0 -0
  69. {python_openpublictransport-0.2.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/entur.py +0 -0
  70. {python_openpublictransport-0.2.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/fptf_base.py +0 -0
  71. {python_openpublictransport-0.2.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/hafas_base.py +0 -0
  72. {python_openpublictransport-0.2.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/hvv.py +0 -0
  73. {python_openpublictransport-0.2.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/hvv_gti.py +0 -0
  74. {python_openpublictransport-0.2.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/irishrail.py +0 -0
  75. {python_openpublictransport-0.2.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/kvv.py +0 -0
  76. {python_openpublictransport-0.2.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/mobiliteit_lu.py +0 -0
  77. {python_openpublictransport-0.2.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/mvv.py +0 -0
  78. {python_openpublictransport-0.2.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/national_rail.py +0 -0
  79. {python_openpublictransport-0.2.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/ns.py +0 -0
  80. {python_openpublictransport-0.2.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/nta.py +0 -0
  81. {python_openpublictransport-0.2.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/nvbw.py +0 -0
  82. {python_openpublictransport-0.2.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/nwl.py +0 -0
  83. {python_openpublictransport-0.2.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/oebb.py +0 -0
  84. {python_openpublictransport-0.2.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/rejseplanen.py +0 -0
  85. {python_openpublictransport-0.2.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/rmv.py +0 -0
  86. {python_openpublictransport-0.2.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/rvv.py +0 -0
  87. {python_openpublictransport-0.2.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/tpg.py +0 -0
  88. {python_openpublictransport-0.2.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/trafiklab.py +0 -0
  89. {python_openpublictransport-0.2.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/trias_base.py +0 -0
  90. {python_openpublictransport-0.2.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/vagfr.py +0 -0
  91. {python_openpublictransport-0.2.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/vbn.py +0 -0
  92. {python_openpublictransport-0.2.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/vgn.py +0 -0
  93. {python_openpublictransport-0.2.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/vrn.py +0 -0
  94. {python_openpublictransport-0.2.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/vvo.py +0 -0
  95. {python_openpublictransport-0.2.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/vvs.py +0 -0
  96. {python_openpublictransport-0.2.0 → python_openpublictransport-0.3.1}/src/openpublictransport/py.typed +0 -0
  97. {python_openpublictransport-0.2.0 → python_openpublictransport-0.3.1}/src/python_openpublictransport.egg-info/dependency_links.txt +0 -0
  98. {python_openpublictransport-0.2.0 → python_openpublictransport-0.3.1}/src/python_openpublictransport.egg-info/requires.txt +0 -0
  99. {python_openpublictransport-0.2.0 → python_openpublictransport-0.3.1}/src/python_openpublictransport.egg-info/top_level.txt +0 -0
  100. {python_openpublictransport-0.2.0 → python_openpublictransport-0.3.1}/tests/test_efa_mimetype.py +0 -0
  101. {python_openpublictransport-0.2.0 → python_openpublictransport-0.3.1}/tests/test_efa_platform.py +0 -0
  102. {python_openpublictransport-0.2.0 → python_openpublictransport-0.3.1}/tests/test_entur.py +0 -0
  103. {python_openpublictransport-0.2.0 → python_openpublictransport-0.3.1}/tests/test_hafas_base.py +0 -0
  104. {python_openpublictransport-0.2.0 → python_openpublictransport-0.3.1}/tests/test_hafas_mgate.py +0 -0
  105. {python_openpublictransport-0.2.0 → python_openpublictransport-0.3.1}/tests/test_http_errors.py +0 -0
  106. {python_openpublictransport-0.2.0 → python_openpublictransport-0.3.1}/tests/test_hvv_gti.py +0 -0
  107. {python_openpublictransport-0.2.0 → python_openpublictransport-0.3.1}/tests/test_national_rail.py +0 -0
  108. {python_openpublictransport-0.2.0 → python_openpublictransport-0.3.1}/tests/test_national_rail_parse.py +0 -0
  109. {python_openpublictransport-0.2.0 → python_openpublictransport-0.3.1}/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.2.0
3
+ Version: 0.3.1
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.2.0"
7
+ version = "0.3.1"
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" }
@@ -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,31 @@ 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"
62
+ PROVIDER_CTA_US = "cta_us"
42
63
 
43
64
  # API base URLs
44
- API_BASE_URL_VRR = "https://openservice-test.vrr.de/static03/XML_DM_REQUEST"
65
+ API_BASE_URL_VRR = "https://openservice.vrr.de/static03/XML_DM_REQUEST"
45
66
  API_BASE_URL_KVV = "https://projekte.kvv-efa.de/sl3-alone/XSLT_DM_REQUEST"
46
67
  API_BASE_URL_HVV = "https://hvv.efa.de/efa/XML_DM_REQUEST"
47
68
  API_BASE_URL_TRAFIKLAB = "https://realtime-api.trafiklab.se/v1"
@@ -10,6 +10,30 @@ from .models import UnifiedDeparture
10
10
 
11
11
  _LOGGER = logging.getLogger(__name__)
12
12
 
13
+ # Train categories that are shown in front of a bare train number ("ICE 620").
14
+ # Buses and trams are deliberately left out: "11" reads better than "Bus 11".
15
+ RAIL_CATEGORIES = frozenset({
16
+ "ICE", "IC", "EC", "ECE", "EN", "NJ", "RJ", "RJX", "FLX", "TGV", "THA", "EST", "WB",
17
+ "IR", "IRE", "D", "RE", "RB", "MEX", "S",
18
+ })
19
+
20
+
21
+ def rail_line_label(number: str, name: str) -> str:
22
+ """Prefix a bare train number with its category taken from the long name.
23
+
24
+ EFA reports long-distance trains as ``number="620"`` and
25
+ ``name="ICE 620 InterCityExpress"``, so the board showed just "620".
26
+ Returns ``number`` unchanged unless it is purely numeric and ``name``
27
+ starts with a known rail category followed by that number.
28
+ """
29
+ if not number.isdigit() or not name:
30
+ return number
31
+ parts = name.split()
32
+ if len(parts) >= 2 and parts[1] == number and parts[0].upper() in RAIL_CATEGORIES:
33
+ return f"{parts[0]} {number}"
34
+ return number
35
+
36
+
13
37
  # Human-readable platform labels APIs prefix onto the bare track identifier:
14
38
  # "Gleis 3", "Bstg. 12", "Steig B", "Platform 4", "Pl. 2", "Track 1".
15
39
  _PLATFORM_LABEL_RE = re.compile(
@@ -102,7 +126,7 @@ def parse_departure_generic(
102
126
  destination_obj = {}
103
127
  destination = destination_obj.get("name", "Unknown")
104
128
 
105
- line_number = str(transportation.get("number", ""))
129
+ line_number = rail_line_label(str(transportation.get("number", "")), str(transportation.get("name", "")))
106
130
  description = str(transportation.get("description", ""))
107
131
  agency = stop.get("agency")
108
132
 
@@ -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,28 @@ 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,
65
+ PROVIDER_CTA_US,
45
66
  )
46
67
  from .avv import AVVProvider
47
68
  from .base import BaseProvider
@@ -50,7 +71,6 @@ from .bsvg import BSVGProvider
50
71
  from .bvg import BVGProvider
51
72
  from .db import DBProvider
52
73
  from .ding import DINGProvider
53
- from .gtfsde import OPTProvider
54
74
  from .hvv import HVVProvider
55
75
  from .hvv_gti import HVVGTIProvider
56
76
  from .kvv import KVVProvider
@@ -81,6 +101,28 @@ from .bart import BARTProvider
81
101
  from .dart import DARTProvider
82
102
  from .irishrail import IrishRailProvider
83
103
  from .tpg import TPGProvider
104
+ from .vmv import VMVProvider
105
+ from .gvh import GVHProvider
106
+ from .nahsh import NAHSHProvider
107
+ from .insa import INSAProvider
108
+ from .vmt import VMTProvider
109
+ from .saarvv import SaarVVProvider
110
+ from .vbn_hafas import VBNHafasProvider
111
+ from .nvv import NVVProvider
112
+ from .vos import VOSProvider
113
+ from .vsn import VSNProvider
114
+ from .vao import VAOProvider
115
+ from .trimet import TriMetProvider
116
+ from .irail import IRailProvider
117
+ from .sl import SLProvider
118
+ from .ttc import TTCProvider
119
+ from .hongkong import HongKongProvider
120
+ from .wienerlinien import WienerLinienProvider
121
+ from .tfl import TfLProvider
122
+ from .tfnsw import TfNSWProvider
123
+ from .digitransit import DigitransitProvider
124
+ from .golemio import GolemioProvider
125
+ from .cta import CTAProvider
84
126
 
85
127
  _PROVIDER_REGISTRY: Dict[str, Type[BaseProvider]] = {}
86
128
 
@@ -152,7 +194,6 @@ register_provider(PROVIDER_TRANSITOUS, TransitousProvider)
152
194
  register_provider(PROVIDER_DB, DBProvider)
153
195
  register_provider(PROVIDER_VBN_OTP, VBNOTPProvider)
154
196
  register_provider(PROVIDER_VBN_TRIAS, VBNTriasProvider)
155
- register_provider(PROVIDER_OPT, OPTProvider)
156
197
  register_provider(PROVIDER_OTP_CUSTOM, OTPCustomProvider)
157
198
  register_provider(PROVIDER_NATIONAL_RAIL, NationalRailProvider)
158
199
  register_provider(PROVIDER_REJSEPLANEN, RejseplanenProvider)
@@ -163,4 +204,26 @@ register_provider(PROVIDER_BART_US, BARTProvider)
163
204
  register_provider(PROVIDER_DART_US, DARTProvider)
164
205
  register_provider(PROVIDER_IRISHRAIL_IE, IrishRailProvider)
165
206
  register_provider(PROVIDER_TPG_CH, TPGProvider)
207
+ register_provider(PROVIDER_VMV, VMVProvider)
208
+ register_provider(PROVIDER_GVH, GVHProvider)
209
+ register_provider(PROVIDER_NAHSH, NAHSHProvider)
210
+ register_provider(PROVIDER_INSA, INSAProvider)
211
+ register_provider(PROVIDER_VMT, VMTProvider)
212
+ register_provider(PROVIDER_SAARVV, SaarVVProvider)
213
+ register_provider(PROVIDER_VBN_HAFAS, VBNHafasProvider)
214
+ register_provider(PROVIDER_NVV, NVVProvider)
215
+ register_provider(PROVIDER_VOS, VOSProvider)
216
+ register_provider(PROVIDER_VSN, VSNProvider)
217
+ register_provider(PROVIDER_VAO_AT, VAOProvider)
218
+ register_provider(PROVIDER_TRIMET_US, TriMetProvider)
219
+ register_provider(PROVIDER_IRAIL_BE, IRailProvider)
220
+ register_provider(PROVIDER_SL_SE, SLProvider)
221
+ register_provider(PROVIDER_TTC_CA, TTCProvider)
222
+ register_provider(PROVIDER_HONGKONG, HongKongProvider)
223
+ register_provider(PROVIDER_WIENERLINIEN, WienerLinienProvider)
224
+ register_provider(PROVIDER_TFL, TfLProvider)
225
+ register_provider(PROVIDER_TFNSW, TfNSWProvider)
226
+ register_provider(PROVIDER_DIGITRANSIT, DigitransitProvider)
227
+ register_provider(PROVIDER_GOLEMIO, GolemioProvider)
228
+ register_provider(PROVIDER_CTA_US, CTAProvider)
166
229
  register_provider(PROVIDER_HVV_GTI, HVVGTIProvider)
@@ -0,0 +1,400 @@
1
+ """CTA provider (Chicago Transit Authority) — real-time 'L' trains and buses.
2
+
3
+ Departures come from CTA's own real-time APIs, which use separate keys:
4
+
5
+ * Train Tracker Arrivals API (``api_key_secondary``):
6
+ https://www.transitchicago.com/developers/ttdocs/
7
+ * Bus Tracker API v3 (``api_key``):
8
+ https://www.transitchicago.com/developers/bustracker/
9
+
10
+ Stop search needs no key: it uses the Transitous geocoder (which carries
11
+ CTA's GTFS stop ids) and keeps only CTA stops. Per CTA's docs, stop ids
12
+ 0–29999 are bus stops, 30000–39999 'L' platforms and 40000–49999 'L'
13
+ parent stations. Ids are encoded as ``train:<id>`` or ``bus:<id>``.
14
+
15
+ Both CTA APIs report errors (including an invalid key) with HTTP 200 and an
16
+ error object in the body, so the payload is inspected here.
17
+ """
18
+
19
+ import logging
20
+ from datetime import datetime
21
+ from typing import Any, Dict, List, Optional, Tuple, Union
22
+ from urllib.parse import quote
23
+ from zoneinfo import ZoneInfo
24
+
25
+ from ..exceptions import ApiError, ApiResponseError, AuthenticationError
26
+ from ..models import UnifiedDeparture
27
+ from .base import BaseProvider
28
+ from .transitous import API_BASE as TRANSITOUS_API_BASE
29
+ from .transitous import USER_AGENT
30
+ from ..const import PROVIDER_CTA_US
31
+
32
+ _LOGGER = logging.getLogger(__name__)
33
+
34
+ PROVIDER_ID = PROVIDER_CTA_US
35
+ PROVIDER_NAME = "CTA (Chicago)"
36
+ TIMEZONE = "America/Chicago"
37
+
38
+ TRAIN_API_URL = "https://lapi.transitchicago.com/api/1.0/ttarrivals.aspx"
39
+ BUS_API_URL = "https://www.ctabustracker.com/bustime/api/v3/getpredictions"
40
+
41
+ # Transitous feed prefix for CTA GTFS stop ids.
42
+ TRANSITOUS_PREFIX = "us-il-CTA_"
43
+ # Downtown Chicago, used to bias the geocoder towards CTA stops.
44
+ CHICAGO_PLACE = "41.8781,-87.6298"
45
+
46
+ KIND_TRAIN = "train"
47
+ KIND_BUS = "bus"
48
+
49
+ # Train Tracker route codes → (display name, background colour, text colour).
50
+ TRAIN_ROUTES: Dict[str, Tuple[str, str, str]] = {
51
+ "Red": ("Red Line", "#C60C30", "#FFFFFF"),
52
+ "Blue": ("Blue Line", "#00A1DE", "#FFFFFF"),
53
+ "Brn": ("Brown Line", "#62361B", "#FFFFFF"),
54
+ "G": ("Green Line", "#009B3A", "#FFFFFF"),
55
+ "Org": ("Orange Line", "#F9461C", "#FFFFFF"),
56
+ "P": ("Purple Line", "#522398", "#FFFFFF"),
57
+ "Pink": ("Pink Line", "#E27EA6", "#FFFFFF"),
58
+ "Y": ("Yellow Line", "#F9E300", "#000000"),
59
+ }
60
+
61
+ TRANSPORT_TYPE_MAPPING = {KIND_TRAIN: "subway", KIND_BUS: "bus"}
62
+
63
+ # Train Tracker errCd values (docs: "Error Codes").
64
+ TRAIN_AUTH_ERRORS = {"101"}
65
+ TRAIN_QUOTA_ERRORS = {"102"}
66
+
67
+ # Bus Tracker dyn values (docs: "Dynamic Action Types").
68
+ BUS_DYN_CANCELLED = {1, 18}
69
+ BUS_DYN_EXPRESSED = {4}
70
+ BUS_DYN_HIDDEN = {16, 17} # invalidated trips: "should not be shown to the public"
71
+
72
+ # Bus Tracker error messages (docs: "Error codes"), matched case-insensitively.
73
+ BUS_AUTH_ERRORS = ("invalid api access key", "no api access key")
74
+ BUS_QUOTA_ERRORS = ("transaction limit",)
75
+ BUS_EMPTY_ERRORS = ("no arrival times", "no service scheduled", "no data found")
76
+
77
+ MAX_TRAIN_RESULTS = 50
78
+ MAX_BUS_RESULTS = 30
79
+
80
+ _SOURCE_TZ = ZoneInfo(TIMEZONE)
81
+
82
+
83
+ def stop_kind(stop_id: str) -> str:
84
+ """Return ``train`` or ``bus`` for a bare CTA stop id, using CTA's id ranges."""
85
+ try:
86
+ number = int(stop_id)
87
+ except (TypeError, ValueError):
88
+ return KIND_BUS
89
+ return KIND_TRAIN if 30000 <= number <= 49999 else KIND_BUS
90
+
91
+
92
+ def encode_stop_id(stop_id: str) -> str:
93
+ """Return the ``train:<id>`` / ``bus:<id>`` form used as station_id."""
94
+ return f"{stop_kind(stop_id)}:{stop_id}"
95
+
96
+
97
+ def decode_stop_id(station_id: str) -> Tuple[str, str]:
98
+ """Split a station_id into (kind, bare id). Bare and Transitous ids are accepted."""
99
+ value = str(station_id).strip()
100
+ if value.startswith(TRANSITOUS_PREFIX):
101
+ value = value[len(TRANSITOUS_PREFIX):]
102
+ kind, sep, rest = value.partition(":")
103
+ if sep and kind in (KIND_TRAIN, KIND_BUS) and rest:
104
+ return kind, rest
105
+ return stop_kind(value), value
106
+
107
+
108
+ def _flag(value: Any) -> bool:
109
+ """CTA encodes booleans as "0"/"1", "true"/"false" or real booleans."""
110
+ if isinstance(value, bool):
111
+ return value
112
+ return str(value).strip().lower() in ("1", "true")
113
+
114
+
115
+ def _parse_train_time(value: Any) -> Optional[datetime]:
116
+ """Parse arrT: the JSON feed uses ISO, the docs table "yyyyMMdd HH:mm:ss"."""
117
+ if not value or not isinstance(value, str):
118
+ return None
119
+ for fmt in ("%Y-%m-%dT%H:%M:%S", "%Y%m%d %H:%M:%S", "%Y%m%d %H:%M"):
120
+ try:
121
+ return datetime.strptime(value.strip(), fmt).replace(tzinfo=_SOURCE_TZ)
122
+ except ValueError:
123
+ continue
124
+ return None
125
+
126
+
127
+ def _parse_bus_time(value: Any) -> Optional[datetime]:
128
+ """Parse prdtm ("yyyyMMdd HH:mm", optionally with seconds), Chicago local time."""
129
+ if not value or not isinstance(value, str):
130
+ return None
131
+ for fmt in ("%Y%m%d %H:%M", "%Y%m%d %H:%M:%S"):
132
+ try:
133
+ return datetime.strptime(value.strip(), fmt).replace(tzinfo=_SOURCE_TZ)
134
+ except ValueError:
135
+ continue
136
+ return None
137
+
138
+
139
+ class CTAProvider(BaseProvider):
140
+ """Chicago Transit Authority: Train Tracker ('L') and Bus Tracker predictions.
141
+
142
+ ``api_key`` is the Bus Tracker key, ``api_key_secondary`` the Train
143
+ Tracker key. Each is only needed for the matching stop type.
144
+ """
145
+
146
+ @property
147
+ def provider_id(self) -> str:
148
+ return PROVIDER_ID
149
+
150
+ @property
151
+ def provider_name(self) -> str:
152
+ return PROVIDER_NAME
153
+
154
+ @property
155
+ def requires_api_key(self) -> bool:
156
+ return True
157
+
158
+ def get_timezone(self) -> str:
159
+ return TIMEZONE
160
+
161
+ def get_transport_type_mapping(self) -> Dict[Any, str]:
162
+ return dict(TRANSPORT_TYPE_MAPPING)
163
+
164
+ # ------------------------------------------------------------------ search
165
+
166
+ async def _geocode(self, text: str) -> List[Dict[str, Any]]:
167
+ url = (
168
+ f"{TRANSITOUS_API_BASE}/v1/geocode?text={quote(text, safe='')}"
169
+ f"&type=STOP&place={CHICAGO_PLACE}&placeBias=5"
170
+ )
171
+ data = await self._request("get", url, headers={"User-Agent": USER_AGENT}, timeout=10)
172
+ if not isinstance(data, list):
173
+ raise ApiResponseError(f"{PROVIDER_NAME}: geocoder returned {type(data).__name__} instead of a list")
174
+ return [loc for loc in data if isinstance(loc, dict)]
175
+
176
+ @staticmethod
177
+ def _cta_stops(locations: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
178
+ results: List[Dict[str, Any]] = []
179
+ seen = set()
180
+ for loc in locations:
181
+ raw_id = str(loc.get("id") or "")
182
+ if not raw_id.startswith(TRANSITOUS_PREFIX):
183
+ continue
184
+ bare = raw_id[len(TRANSITOUS_PREFIX):]
185
+ if not bare or bare in seen:
186
+ continue
187
+ seen.add(bare)
188
+ kind = stop_kind(bare)
189
+ results.append(
190
+ {
191
+ "id": f"{kind}:{bare}",
192
+ "name": loc.get("name") or bare,
193
+ "place": "Chicago",
194
+ "area_type": "stop",
195
+ "transport_modes": [TRANSPORT_TYPE_MAPPING[kind]],
196
+ }
197
+ )
198
+ return results
199
+
200
+ async def search_stops(self, search_term: str) -> List[Dict[str, Any]]:
201
+ term = (search_term or "").strip()
202
+ if not term:
203
+ return []
204
+ results = self._cta_stops(await self._geocode(term))
205
+ if not results and "chicago" not in term.lower():
206
+ # A short or ambiguous term can be crowded out by other cities.
207
+ results = self._cta_stops(await self._geocode(f"{term}, Chicago"))
208
+ return results
209
+
210
+ # -------------------------------------------------------------- departures
211
+
212
+ def _require_key(self, kind: str) -> str:
213
+ key = self.api_key_secondary if kind == KIND_TRAIN else self.api_key
214
+ if not key:
215
+ if kind == KIND_TRAIN:
216
+ message = f"{PROVIDER_NAME}: a Train Tracker key is required for 'L' stations"
217
+ else:
218
+ message = f"{PROVIDER_NAME}: a Bus Tracker key is required for bus stops"
219
+ raise AuthenticationError(PROVIDER_NAME, 401, message=message)
220
+ return key
221
+
222
+ async def fetch_departures(
223
+ self,
224
+ station_id: Optional[str],
225
+ place_dm: str,
226
+ name_dm: str,
227
+ departures_limit: int,
228
+ ) -> Optional[Dict[str, Any]]:
229
+ if not station_id:
230
+ matches = await self.search_stops(name_dm or place_dm)
231
+ if not matches:
232
+ return {"stopEvents": []}
233
+ station_id = matches[0]["id"]
234
+
235
+ kind, stop_id = decode_stop_id(station_id)
236
+ key = self._require_key(kind)
237
+ if kind == KIND_TRAIN:
238
+ events = await self._fetch_train(key, stop_id, departures_limit)
239
+ else:
240
+ events = await self._fetch_bus(key, stop_id, departures_limit)
241
+ return {"stopEvents": events[: max(0, departures_limit)]}
242
+
243
+ async def _fetch_train(self, key: str, stop_id: str, limit: int) -> List[Dict[str, Any]]:
244
+ # Parent stations (4xxxx) are queried by mapid, platforms (3xxxx) by stpid.
245
+ id_param = "mapid" if stop_id.startswith("4") else "stpid"
246
+ params = {
247
+ "key": key,
248
+ id_param: stop_id,
249
+ "max": max(1, min(limit, MAX_TRAIN_RESULTS)),
250
+ "outputType": "JSON",
251
+ }
252
+ data = await self._request("get", TRAIN_API_URL, params=params, timeout=15)
253
+ root = data.get("ctatt") if isinstance(data, dict) else None
254
+ if not isinstance(root, dict):
255
+ raise ApiResponseError(f"{PROVIDER_NAME}: unexpected Train Tracker payload")
256
+
257
+ code = str(root.get("errCd") or "0").strip()
258
+ if code != "0":
259
+ message = f"{PROVIDER_NAME}: Train Tracker error {code} ({root.get('errNm') or 'unknown'})"
260
+ if code in TRAIN_AUTH_ERRORS:
261
+ raise AuthenticationError(PROVIDER_NAME, 401, message=message)
262
+ if code in TRAIN_QUOTA_ERRORS:
263
+ raise ApiError(PROVIDER_NAME, 429, message=message)
264
+ raise ApiResponseError(message)
265
+
266
+ etas = root.get("eta") or []
267
+ if isinstance(etas, dict):
268
+ etas = [etas]
269
+ return [{**eta, "_cta": KIND_TRAIN} for eta in etas if isinstance(eta, dict)]
270
+
271
+ async def _fetch_bus(self, key: str, stop_id: str, limit: int) -> List[Dict[str, Any]]:
272
+ params = {
273
+ "key": key,
274
+ "stpid": stop_id,
275
+ "format": "json",
276
+ "top": max(1, min(limit, MAX_BUS_RESULTS)),
277
+ }
278
+ data = await self._request("get", BUS_API_URL, params=params, timeout=15)
279
+ root = data.get("bustime-response") if isinstance(data, dict) else None
280
+ if not isinstance(root, dict):
281
+ raise ApiResponseError(f"{PROVIDER_NAME}: unexpected Bus Tracker payload")
282
+
283
+ predictions = root.get("prd") or []
284
+ if isinstance(predictions, dict):
285
+ predictions = [predictions]
286
+ errors = root.get("error") or []
287
+ if isinstance(errors, dict):
288
+ errors = [errors]
289
+
290
+ if errors and not predictions:
291
+ messages = [str(e.get("msg") or "") for e in errors if isinstance(e, dict)]
292
+ lowered = " | ".join(messages).lower()
293
+ message = f"{PROVIDER_NAME}: Bus Tracker error ({'; '.join(m for m in messages if m) or 'unknown'})"
294
+ if any(token in lowered for token in BUS_AUTH_ERRORS):
295
+ raise AuthenticationError(PROVIDER_NAME, 401, message=message)
296
+ if any(token in lowered for token in BUS_QUOTA_ERRORS):
297
+ raise ApiError(PROVIDER_NAME, 429, message=message)
298
+ if any(token in lowered for token in BUS_EMPTY_ERRORS):
299
+ return []
300
+ raise ApiResponseError(message)
301
+
302
+ events = []
303
+ for prd in predictions:
304
+ if not isinstance(prd, dict):
305
+ continue
306
+ try:
307
+ if int(prd.get("dyn") or 0) in BUS_DYN_HIDDEN:
308
+ continue
309
+ except (TypeError, ValueError):
310
+ pass
311
+ events.append({**prd, "_cta": KIND_BUS})
312
+ return events
313
+
314
+ # ----------------------------------------------------------------- parsing
315
+
316
+ def parse_departure(
317
+ self, stop: Dict[str, Any], tz: Union[ZoneInfo, Any], now: datetime
318
+ ) -> Optional[UnifiedDeparture]:
319
+ try:
320
+ kind = stop.get("_cta") or (KIND_TRAIN if "arrT" in stop else KIND_BUS)
321
+ if kind == KIND_TRAIN:
322
+ return self._parse_train(stop, tz, now)
323
+ return self._parse_bus(stop, tz, now)
324
+ except Exception as e: # noqa: BLE001
325
+ _LOGGER.debug("Error parsing CTA departure: %s", e)
326
+ return None
327
+
328
+ def _parse_train(self, eta: Dict[str, Any], tz: Any, now: datetime) -> Optional[UnifiedDeparture]:
329
+ arrival = _parse_train_time(eta.get("arrT"))
330
+ if arrival is None:
331
+ return None
332
+ dep_local = arrival.astimezone(tz)
333
+
334
+ route = str(eta.get("rt") or "")
335
+ line_name, color, text_color = TRAIN_ROUTES.get(route, (route, None, None))
336
+
337
+ notices: List[str] = []
338
+ if _flag(eta.get("isDly")):
339
+ notices.append("Delayed")
340
+
341
+ return UnifiedDeparture(
342
+ line=line_name,
343
+ destination=eta.get("destNm") or "Unknown",
344
+ departure_time=dep_local.strftime("%H:%M"),
345
+ planned_time=dep_local.strftime("%H:%M"),
346
+ delay=0,
347
+ platform=None,
348
+ transportation_type=TRANSPORT_TYPE_MAPPING[KIND_TRAIN],
349
+ is_realtime=not _flag(eta.get("isSch")),
350
+ minutes_until_departure=max(0, int((dep_local - now).total_seconds() / 60)),
351
+ departure_time_obj=dep_local,
352
+ description=eta.get("stpDe") or None,
353
+ agency="CTA",
354
+ notices=notices or None,
355
+ line_color=color,
356
+ line_text_color=text_color,
357
+ )
358
+
359
+ def _parse_bus(self, prd: Dict[str, Any], tz: Any, now: datetime) -> Optional[UnifiedDeparture]:
360
+ predicted = _parse_bus_time(prd.get("prdtm"))
361
+ if predicted is None:
362
+ return None
363
+ dep_local = predicted.astimezone(tz)
364
+
365
+ try:
366
+ dyn = int(prd.get("dyn") or 0)
367
+ except (TypeError, ValueError):
368
+ dyn = 0
369
+
370
+ notices: List[str] = []
371
+ if dyn in BUS_DYN_CANCELLED:
372
+ notices.append("Cancelled")
373
+ if _flag(prd.get("dly")) or str(prd.get("prdctdn") or "").upper() == "DLY":
374
+ notices.append("Delayed")
375
+ if dyn in BUS_DYN_EXPRESSED:
376
+ notices.append("Drop-off only")
377
+
378
+ countdown = str(prd.get("prdctdn") or "").strip()
379
+ if countdown.upper() == "DUE":
380
+ minutes_until = 0
381
+ elif countdown.isdigit():
382
+ minutes_until = int(countdown)
383
+ else:
384
+ minutes_until = max(0, int((dep_local - now).total_seconds() / 60))
385
+
386
+ return UnifiedDeparture(
387
+ line=str(prd.get("rtdd") or prd.get("rt") or ""),
388
+ destination=prd.get("des") or "Unknown",
389
+ departure_time=dep_local.strftime("%H:%M"),
390
+ planned_time=dep_local.strftime("%H:%M"),
391
+ delay=0,
392
+ platform=None,
393
+ transportation_type=TRANSPORT_TYPE_MAPPING[KIND_BUS],
394
+ is_realtime=True,
395
+ minutes_until_departure=minutes_until,
396
+ departure_time_obj=dep_local,
397
+ description=prd.get("rtdir") or None,
398
+ agency="CTA",
399
+ notices=notices or None,
400
+ )
@@ -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"]