python-openpublictransport 0.3.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 (107) hide show
  1. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/PKG-INFO +1 -1
  2. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/pyproject.toml +1 -1
  3. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/const.py +1 -0
  4. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/parsers.py +25 -1
  5. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/__init__.py +3 -0
  6. python_openpublictransport-0.3.1/src/openpublictransport/providers/cta.py +400 -0
  7. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/otp_base.py +6 -1
  8. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/transitous.py +7 -0
  9. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/python_openpublictransport.egg-info/PKG-INFO +1 -1
  10. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/python_openpublictransport.egg-info/SOURCES.txt +3 -0
  11. python_openpublictransport-0.3.1/tests/test_cta.py +349 -0
  12. python_openpublictransport-0.3.1/tests/test_issue_fixes_0_3_1.py +84 -0
  13. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/setup.cfg +0 -0
  14. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/__init__.py +0 -0
  15. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/data/uk_stations.json +0 -0
  16. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/exceptions.py +0 -0
  17. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/models.py +0 -0
  18. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/avv.py +0 -0
  19. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/bart.py +0 -0
  20. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/base.py +0 -0
  21. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/beg.py +0 -0
  22. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/bsvg.py +0 -0
  23. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/bvg.py +0 -0
  24. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/dart.py +0 -0
  25. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/db.py +0 -0
  26. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/digitransit.py +0 -0
  27. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/ding.py +0 -0
  28. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/efa_base.py +0 -0
  29. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/entur.py +0 -0
  30. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/fptf_base.py +0 -0
  31. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/golemio.py +0 -0
  32. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/gvh.py +0 -0
  33. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/hafas_base.py +0 -0
  34. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/hafas_mgate_base.py +0 -0
  35. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/hongkong.py +0 -0
  36. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/hvv.py +0 -0
  37. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/hvv_gti.py +0 -0
  38. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/insa.py +0 -0
  39. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/irail.py +0 -0
  40. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/irishrail.py +0 -0
  41. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/kvv.py +0 -0
  42. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/mobiliteit_lu.py +0 -0
  43. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/mvv.py +0 -0
  44. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/nahsh.py +0 -0
  45. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/national_rail.py +0 -0
  46. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/nextbus_base.py +0 -0
  47. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/ns.py +0 -0
  48. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/nta.py +0 -0
  49. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/nvbw.py +0 -0
  50. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/nvv.py +0 -0
  51. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/nwl.py +0 -0
  52. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/oebb.py +0 -0
  53. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/otp.py +0 -0
  54. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/otp_custom.py +0 -0
  55. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/rejseplanen.py +0 -0
  56. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/rmv.py +0 -0
  57. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/rvv.py +0 -0
  58. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/saarvv.py +0 -0
  59. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/sbb.py +0 -0
  60. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/sl.py +0 -0
  61. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/tfl.py +0 -0
  62. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/tfnsw.py +0 -0
  63. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/tpg.py +0 -0
  64. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/trafiklab.py +0 -0
  65. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/trias_base.py +0 -0
  66. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/trimet.py +0 -0
  67. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/ttc.py +0 -0
  68. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/vagfr.py +0 -0
  69. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/vao.py +0 -0
  70. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/vbn.py +0 -0
  71. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/vbn_hafas.py +0 -0
  72. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/vgn.py +0 -0
  73. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/vmt.py +0 -0
  74. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/vmv.py +0 -0
  75. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/vos.py +0 -0
  76. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/vrn.py +0 -0
  77. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/vrr.py +0 -0
  78. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/vsn.py +0 -0
  79. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/vvo.py +0 -0
  80. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/vvs.py +0 -0
  81. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/wienerlinien.py +0 -0
  82. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/py.typed +0 -0
  83. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/python_openpublictransport.egg-info/dependency_links.txt +0 -0
  84. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/python_openpublictransport.egg-info/requires.txt +0 -0
  85. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/python_openpublictransport.egg-info/top_level.txt +0 -0
  86. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/tests/test_digitransit.py +0 -0
  87. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/tests/test_efa_mimetype.py +0 -0
  88. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/tests/test_efa_platform.py +0 -0
  89. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/tests/test_entur.py +0 -0
  90. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/tests/test_german_regional_providers.py +0 -0
  91. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/tests/test_golemio.py +0 -0
  92. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/tests/test_hafas_base.py +0 -0
  93. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/tests/test_hafas_mgate.py +0 -0
  94. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/tests/test_hongkong.py +0 -0
  95. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/tests/test_http_errors.py +0 -0
  96. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/tests/test_hvv_gti.py +0 -0
  97. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/tests/test_irail.py +0 -0
  98. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/tests/test_national_rail.py +0 -0
  99. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/tests/test_national_rail_parse.py +0 -0
  100. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/tests/test_national_rail_static.py +0 -0
  101. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/tests/test_provider_expansion.py +0 -0
  102. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/tests/test_sbb.py +0 -0
  103. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/tests/test_sl.py +0 -0
  104. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/tests/test_tfl.py +0 -0
  105. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/tests/test_tfnsw.py +0 -0
  106. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/tests/test_ttc.py +0 -0
  107. {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/tests/test_wienerlinien.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-openpublictransport
3
- Version: 0.3.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.3.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" }
@@ -59,6 +59,7 @@ PROVIDER_TFL = "tfl_uk"
59
59
  PROVIDER_TFNSW = "tfnsw_au"
60
60
  PROVIDER_DIGITRANSIT = "digitransit_fi"
61
61
  PROVIDER_GOLEMIO = "golemio_cz"
62
+ PROVIDER_CTA_US = "cta_us"
62
63
 
63
64
  # API base URLs
64
65
  API_BASE_URL_VRR = "https://openservice.vrr.de/static03/XML_DM_REQUEST"
@@ -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
 
@@ -62,6 +62,7 @@ from ..const import (
62
62
  PROVIDER_TFNSW,
63
63
  PROVIDER_DIGITRANSIT,
64
64
  PROVIDER_GOLEMIO,
65
+ PROVIDER_CTA_US,
65
66
  )
66
67
  from .avv import AVVProvider
67
68
  from .base import BaseProvider
@@ -121,6 +122,7 @@ from .tfl import TfLProvider
121
122
  from .tfnsw import TfNSWProvider
122
123
  from .digitransit import DigitransitProvider
123
124
  from .golemio import GolemioProvider
125
+ from .cta import CTAProvider
124
126
 
125
127
  _PROVIDER_REGISTRY: Dict[str, Type[BaseProvider]] = {}
126
128
 
@@ -223,4 +225,5 @@ register_provider(PROVIDER_TFL, TfLProvider)
223
225
  register_provider(PROVIDER_TFNSW, TfNSWProvider)
224
226
  register_provider(PROVIDER_DIGITRANSIT, DigitransitProvider)
225
227
  register_provider(PROVIDER_GOLEMIO, GolemioProvider)
228
+ register_provider(PROVIDER_CTA_US, CTAProvider)
226
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
+ )
@@ -57,6 +57,11 @@ class OTPBaseProvider(BaseProvider):
57
57
  # ISO country code(s) Nominatim restricts the stop-name geocoding to.
58
58
  geocode_countries: str = "de"
59
59
  stop_search_radius: int = 500
60
+ # How far ahead stoptimes are requested. The result is sorted and cut to the
61
+ # requested number of departures, so a wide window only matters for rarely
62
+ # served stops — with the former 2 h an hourly train showed one or two
63
+ # departures (issue #92).
64
+ stoptimes_time_range: int = 12 * 3600
60
65
 
61
66
  def get_timezone(self) -> str:
62
67
  return "Europe/Berlin"
@@ -167,7 +172,7 @@ class OTPBaseProvider(BaseProvider):
167
172
  self._get(
168
173
  self._index_url(f"stops/{encoded_id}/stoptimes"),
169
174
  {
170
- "timeRange": 7200,
175
+ "timeRange": self.stoptimes_time_range,
171
176
  "numberOfDepartures": max(departures_limit, 5),
172
177
  "omitNonPickups": "true",
173
178
  },
@@ -16,6 +16,8 @@ _LOGGER = logging.getLogger(__name__)
16
16
  API_BASE = "https://api.transitous.org/api"
17
17
  USER_AGENT = "OpenPublicTransport/1.0 (github.com/NerdySoftPaw/openpublictransport)"
18
18
 
19
+ _LONG_DISTANCE_MODES = frozenset({"HIGHSPEED_RAIL", "LONG_DISTANCE", "NIGHT_RAIL"})
20
+
19
21
  MODE_MAPPING = {
20
22
  "HIGHSPEED_RAIL": "train",
21
23
  "LONG_DISTANCE": "train",
@@ -113,6 +115,11 @@ class TransitousProvider(BaseProvider):
113
115
  transport_type = MODE_MAPPING.get(mode, "unknown")
114
116
 
115
117
  line = stop.get("routeShortName") or stop.get("displayName", "")
118
+ display_name = stop.get("displayName") or ""
119
+ if mode in _LONG_DISTANCE_MODES and line.isdigit() and display_name and not display_name.isdigit():
120
+ # Long-distance trains carry the DB line number ("29") as short
121
+ # name; the readable "ICE 1105" is only in displayName (issue #99).
122
+ line = display_name
116
123
  destination = stop.get("headsign", "Unknown")
117
124
 
118
125
  track = place.get("track", "")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-openpublictransport
3
- Version: 0.3.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
@@ -13,6 +13,7 @@ src/openpublictransport/providers/base.py
13
13
  src/openpublictransport/providers/beg.py
14
14
  src/openpublictransport/providers/bsvg.py
15
15
  src/openpublictransport/providers/bvg.py
16
+ src/openpublictransport/providers/cta.py
16
17
  src/openpublictransport/providers/dart.py
17
18
  src/openpublictransport/providers/db.py
18
19
  src/openpublictransport/providers/digitransit.py
@@ -78,6 +79,7 @@ src/python_openpublictransport.egg-info/SOURCES.txt
78
79
  src/python_openpublictransport.egg-info/dependency_links.txt
79
80
  src/python_openpublictransport.egg-info/requires.txt
80
81
  src/python_openpublictransport.egg-info/top_level.txt
82
+ tests/test_cta.py
81
83
  tests/test_digitransit.py
82
84
  tests/test_efa_mimetype.py
83
85
  tests/test_efa_platform.py
@@ -90,6 +92,7 @@ tests/test_hongkong.py
90
92
  tests/test_http_errors.py
91
93
  tests/test_hvv_gti.py
92
94
  tests/test_irail.py
95
+ tests/test_issue_fixes_0_3_1.py
93
96
  tests/test_national_rail.py
94
97
  tests/test_national_rail_parse.py
95
98
  tests/test_national_rail_static.py