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.
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/PKG-INFO +1 -1
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/pyproject.toml +1 -1
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/const.py +1 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/parsers.py +25 -1
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/__init__.py +3 -0
- python_openpublictransport-0.3.1/src/openpublictransport/providers/cta.py +400 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/otp_base.py +6 -1
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/transitous.py +7 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/python_openpublictransport.egg-info/PKG-INFO +1 -1
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/python_openpublictransport.egg-info/SOURCES.txt +3 -0
- python_openpublictransport-0.3.1/tests/test_cta.py +349 -0
- python_openpublictransport-0.3.1/tests/test_issue_fixes_0_3_1.py +84 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/setup.cfg +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/__init__.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/data/uk_stations.json +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/exceptions.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/models.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/avv.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/bart.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/base.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/beg.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/bsvg.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/bvg.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/dart.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/db.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/digitransit.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/ding.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/efa_base.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/entur.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/fptf_base.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/golemio.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/gvh.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/hafas_base.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/hafas_mgate_base.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/hongkong.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/hvv.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/hvv_gti.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/insa.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/irail.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/irishrail.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/kvv.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/mobiliteit_lu.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/mvv.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/nahsh.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/national_rail.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/nextbus_base.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/ns.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/nta.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/nvbw.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/nvv.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/nwl.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/oebb.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/otp.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/otp_custom.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/rejseplanen.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/rmv.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/rvv.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/saarvv.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/sbb.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/sl.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/tfl.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/tfnsw.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/tpg.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/trafiklab.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/trias_base.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/trimet.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/ttc.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/vagfr.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/vao.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/vbn.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/vbn_hafas.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/vgn.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/vmt.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/vmv.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/vos.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/vrn.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/vrr.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/vsn.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/vvo.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/vvs.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/providers/wienerlinien.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/openpublictransport/py.typed +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/python_openpublictransport.egg-info/dependency_links.txt +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/python_openpublictransport.egg-info/requires.txt +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/src/python_openpublictransport.egg-info/top_level.txt +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/tests/test_digitransit.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/tests/test_efa_mimetype.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/tests/test_efa_platform.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/tests/test_entur.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/tests/test_german_regional_providers.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/tests/test_golemio.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/tests/test_hafas_base.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/tests/test_hafas_mgate.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/tests/test_hongkong.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/tests/test_http_errors.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/tests/test_hvv_gti.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/tests/test_irail.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/tests/test_national_rail.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/tests/test_national_rail_parse.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/tests/test_national_rail_static.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/tests/test_provider_expansion.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/tests/test_sbb.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/tests/test_sl.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/tests/test_tfl.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/tests/test_tfnsw.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/tests/test_ttc.py +0 -0
- {python_openpublictransport-0.3.0 → python_openpublictransport-0.3.1}/tests/test_wienerlinien.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.3.
|
|
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" }
|
|
@@ -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":
|
|
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", "")
|
|
@@ -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
|