python-openpublictransport 0.1.14__tar.gz → 0.1.15__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.15}/PKG-INFO +1 -1
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.15}/pyproject.toml +1 -1
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.15}/src/openpublictransport/models.py +5 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.15}/src/openpublictransport/parsers.py +41 -3
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.15}/src/openpublictransport/providers/efa_base.py +59 -3
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.15}/src/openpublictransport/providers/hvv.py +2 -4
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.15}/src/openpublictransport/providers/kvv.py +3 -2
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.15}/src/python_openpublictransport.egg-info/PKG-INFO +1 -1
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.15}/src/python_openpublictransport.egg-info/SOURCES.txt +1 -0
- python_openpublictransport-0.1.15/tests/test_efa_platform.py +188 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.15}/setup.cfg +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.15}/src/openpublictransport/__init__.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.15}/src/openpublictransport/const.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.15}/src/openpublictransport/data/uk_stations.json +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.15}/src/openpublictransport/exceptions.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.15}/src/openpublictransport/providers/__init__.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.15}/src/openpublictransport/providers/avv.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.15}/src/openpublictransport/providers/bart.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.15}/src/openpublictransport/providers/base.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.15}/src/openpublictransport/providers/beg.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.15}/src/openpublictransport/providers/bsvg.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.15}/src/openpublictransport/providers/bvg.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.15}/src/openpublictransport/providers/dart.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.15}/src/openpublictransport/providers/db.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.15}/src/openpublictransport/providers/ding.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.15}/src/openpublictransport/providers/entur.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.15}/src/openpublictransport/providers/fptf_base.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.15}/src/openpublictransport/providers/gtfsde.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.15}/src/openpublictransport/providers/hafas_base.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.15}/src/openpublictransport/providers/hafas_mgate_base.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.15}/src/openpublictransport/providers/irishrail.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.15}/src/openpublictransport/providers/mobiliteit_lu.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.15}/src/openpublictransport/providers/mvv.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.15}/src/openpublictransport/providers/national_rail.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.15}/src/openpublictransport/providers/ns.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.15}/src/openpublictransport/providers/nta.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.15}/src/openpublictransport/providers/nvbw.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.15}/src/openpublictransport/providers/nwl.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.15}/src/openpublictransport/providers/oebb.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.15}/src/openpublictransport/providers/otp.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.15}/src/openpublictransport/providers/otp_base.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.15}/src/openpublictransport/providers/otp_custom.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.15}/src/openpublictransport/providers/rejseplanen.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.15}/src/openpublictransport/providers/rmv.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.15}/src/openpublictransport/providers/rvv.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.15}/src/openpublictransport/providers/sbb.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.15}/src/openpublictransport/providers/tpg.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.15}/src/openpublictransport/providers/trafiklab.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.15}/src/openpublictransport/providers/transitous.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.15}/src/openpublictransport/providers/trias_base.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.15}/src/openpublictransport/providers/vagfr.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.15}/src/openpublictransport/providers/vbn.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.15}/src/openpublictransport/providers/vgn.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.15}/src/openpublictransport/providers/vrn.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.15}/src/openpublictransport/providers/vrr.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.15}/src/openpublictransport/providers/vvo.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.15}/src/openpublictransport/providers/vvs.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.15}/src/openpublictransport/py.typed +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.15}/src/python_openpublictransport.egg-info/dependency_links.txt +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.15}/src/python_openpublictransport.egg-info/requires.txt +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.15}/src/python_openpublictransport.egg-info/top_level.txt +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.15}/tests/test_entur.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.15}/tests/test_hafas_base.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.15}/tests/test_hafas_mgate.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.15}/tests/test_national_rail.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.15}/tests/test_national_rail_parse.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.15}/tests/test_national_rail_static.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "python-openpublictransport"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.15"
|
|
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" }
|
|
@@ -38,6 +38,9 @@ class UnifiedDeparture:
|
|
|
38
38
|
notices: Optional[list[str]] = None
|
|
39
39
|
planned_platform: Optional[str] = None
|
|
40
40
|
platform_changed: bool = False
|
|
41
|
+
# Human-readable form of `platform` when the provider offers one
|
|
42
|
+
# ("Gleis 3" next to the technical "3")
|
|
43
|
+
platform_name: Optional[str] = None
|
|
41
44
|
line_color: Optional[str] = None
|
|
42
45
|
line_text_color: Optional[str] = None
|
|
43
46
|
|
|
@@ -60,6 +63,8 @@ class UnifiedDeparture:
|
|
|
60
63
|
result["agency"] = self.agency
|
|
61
64
|
if self.notices:
|
|
62
65
|
result["notices"] = self.notices
|
|
66
|
+
if self.platform_name and self.platform_name != self.platform:
|
|
67
|
+
result["platform_name"] = self.platform_name
|
|
63
68
|
if self.platform_changed:
|
|
64
69
|
result["planned_platform"] = self.planned_platform
|
|
65
70
|
result["platform_changed"] = True
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Common parsing utilities for all providers."""
|
|
2
2
|
|
|
3
3
|
import logging
|
|
4
|
+
import re
|
|
4
5
|
from datetime import datetime, timezone
|
|
5
6
|
from typing import Any, Callable, Dict, Optional, Union
|
|
6
7
|
from zoneinfo import ZoneInfo
|
|
@@ -9,6 +10,29 @@ from .models import UnifiedDeparture
|
|
|
9
10
|
|
|
10
11
|
_LOGGER = logging.getLogger(__name__)
|
|
11
12
|
|
|
13
|
+
# Human-readable platform labels APIs prefix onto the bare track identifier:
|
|
14
|
+
# "Gleis 3", "Bstg. 12", "Steig B", "Platform 4", "Pl. 2", "Track 1".
|
|
15
|
+
_PLATFORM_LABEL_RE = re.compile(
|
|
16
|
+
r"^(?:gleis|bstg\.?|bahnsteig|steig|platform|plattform|pl\.|track|quai|voie|spor|perron)\s*",
|
|
17
|
+
re.IGNORECASE,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def normalize_platform(value: Any) -> str:
|
|
22
|
+
"""Reduce a platform value to its bare identifier, lowercased.
|
|
23
|
+
|
|
24
|
+
``"Gleis 3"``, ``"gleis 3"`` and ``"3"`` all normalize to ``"3"``. Used to
|
|
25
|
+
compare a technical platform value against a human-readable one without
|
|
26
|
+
reporting a spurious platform change, and re-used by consumers that want to
|
|
27
|
+
match user input like ``platform: 3`` against whatever the provider returns.
|
|
28
|
+
"""
|
|
29
|
+
if value is None:
|
|
30
|
+
return ""
|
|
31
|
+
text = str(value).strip()
|
|
32
|
+
if not text:
|
|
33
|
+
return ""
|
|
34
|
+
return _PLATFORM_LABEL_RE.sub("", text).strip().casefold()
|
|
35
|
+
|
|
12
36
|
|
|
13
37
|
def _parse_dt(s: str) -> Optional[datetime]:
|
|
14
38
|
"""Parse an ISO datetime string, returning None on failure."""
|
|
@@ -18,6 +42,13 @@ def _parse_dt(s: str) -> Optional[datetime]:
|
|
|
18
42
|
return None
|
|
19
43
|
|
|
20
44
|
|
|
45
|
+
def _default_planned_platform_fn(stop: Dict[str, Any]) -> str:
|
|
46
|
+
"""Legacy planned-platform lookup used when a provider supplies none."""
|
|
47
|
+
platform = stop.get("platform")
|
|
48
|
+
planned_name = platform.get("plannedName") if isinstance(platform, dict) else None
|
|
49
|
+
return stop.get("plannedPlatformName") or planned_name or ""
|
|
50
|
+
|
|
51
|
+
|
|
21
52
|
def parse_departure_generic(
|
|
22
53
|
stop: Dict[str, Any],
|
|
23
54
|
tz: Union[ZoneInfo, Any],
|
|
@@ -25,6 +56,8 @@ def parse_departure_generic(
|
|
|
25
56
|
get_transport_type_fn: Callable[[Dict[str, Any]], str],
|
|
26
57
|
get_platform_fn: Callable[[Dict[str, Any]], str],
|
|
27
58
|
get_realtime_fn: Callable[[Dict[str, Any], Optional[str], Optional[str]], bool],
|
|
59
|
+
get_planned_platform_fn: Optional[Callable[[Dict[str, Any]], str]] = None,
|
|
60
|
+
get_platform_name_fn: Optional[Callable[[Dict[str, Any]], str]] = None,
|
|
28
61
|
) -> Optional[UnifiedDeparture]:
|
|
29
62
|
"""Generic parser for departure data — shared logic across all providers."""
|
|
30
63
|
try:
|
|
@@ -75,6 +108,7 @@ def parse_departure_generic(
|
|
|
75
108
|
|
|
76
109
|
transport_type = get_transport_type_fn(transportation)
|
|
77
110
|
platform = get_platform_fn(stop)
|
|
111
|
+
platform_name = get_platform_name_fn(stop) if get_platform_name_fn else ""
|
|
78
112
|
|
|
79
113
|
time_diff = estimated_local - now
|
|
80
114
|
minutes_until = max(0, int(time_diff.total_seconds() / 60))
|
|
@@ -93,10 +127,13 @@ def parse_departure_generic(
|
|
|
93
127
|
if text and isinstance(text, str):
|
|
94
128
|
notices.append(text.strip())
|
|
95
129
|
|
|
96
|
-
|
|
97
|
-
|
|
130
|
+
planned_fn = get_planned_platform_fn or _default_planned_platform_fn
|
|
131
|
+
planned_platform = planned_fn(stop)
|
|
132
|
+
# Compare normalized values: providers mix the technical identifier
|
|
133
|
+
# ("3") with the human-readable label ("Gleis 3") across the actual and
|
|
134
|
+
# planned fields, and those are the same platform, not a change.
|
|
98
135
|
platform_changed = bool(
|
|
99
|
-
planned_platform and
|
|
136
|
+
planned_platform and platform and normalize_platform(planned_platform) != normalize_platform(platform)
|
|
100
137
|
)
|
|
101
138
|
|
|
102
139
|
return UnifiedDeparture(
|
|
@@ -115,6 +152,7 @@ def parse_departure_generic(
|
|
|
115
152
|
notices=notices if notices else None,
|
|
116
153
|
planned_platform=str(planned_platform).strip() if planned_platform and platform_changed else None,
|
|
117
154
|
platform_changed=platform_changed,
|
|
155
|
+
platform_name=str(platform_name).strip() or None,
|
|
118
156
|
)
|
|
119
157
|
|
|
120
158
|
except Exception as e:
|
|
@@ -4,7 +4,7 @@ import asyncio
|
|
|
4
4
|
import logging
|
|
5
5
|
from abc import abstractmethod
|
|
6
6
|
from datetime import datetime
|
|
7
|
-
from typing import Any, Callable, Dict, List, Optional, Union
|
|
7
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
|
|
8
8
|
from urllib.parse import quote
|
|
9
9
|
from zoneinfo import ZoneInfo
|
|
10
10
|
|
|
@@ -39,6 +39,52 @@ def _transport_type_from_name(name: str) -> str:
|
|
|
39
39
|
return "unknown"
|
|
40
40
|
|
|
41
41
|
|
|
42
|
+
def _location_and_properties(stop: Dict[str, Any]) -> Tuple[Dict[str, Any], Dict[str, Any]]:
|
|
43
|
+
"""Return (location, location.properties) from an EFA stop event."""
|
|
44
|
+
location = stop.get("location")
|
|
45
|
+
if not isinstance(location, dict):
|
|
46
|
+
return {}, {}
|
|
47
|
+
properties = location.get("properties")
|
|
48
|
+
return location, properties if isinstance(properties, dict) else {}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _efa_platform(stop: Dict[str, Any]) -> str:
|
|
52
|
+
"""Technical platform/track of an EFA stop event.
|
|
53
|
+
|
|
54
|
+
EFA's RapidJSON carries the track under ``location.properties.platform``.
|
|
55
|
+
Prefer that technical value ("3") over the human-readable ``platformName``
|
|
56
|
+
/ ``disassembledName`` ("Gleis 3"): the readable form is only populated for
|
|
57
|
+
rail platforms, while bus and tram stops still have the technical
|
|
58
|
+
identifier (issue #56).
|
|
59
|
+
|
|
60
|
+
The last two fallbacks are the pre-RapidJSON shape, kept so older or
|
|
61
|
+
non-standard EFA deployments keep working.
|
|
62
|
+
"""
|
|
63
|
+
location, properties = _location_and_properties(stop)
|
|
64
|
+
legacy = stop.get("platform")
|
|
65
|
+
legacy_name = legacy.get("name") if isinstance(legacy, dict) else None
|
|
66
|
+
return (
|
|
67
|
+
properties.get("platform")
|
|
68
|
+
or properties.get("platformName")
|
|
69
|
+
or location.get("disassembledName")
|
|
70
|
+
or legacy_name
|
|
71
|
+
or stop.get("platformName")
|
|
72
|
+
or ""
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _efa_platform_name(stop: Dict[str, Any]) -> str:
|
|
77
|
+
"""Human-readable platform label ("Gleis 3"), when EFA supplies one."""
|
|
78
|
+
location, properties = _location_and_properties(stop)
|
|
79
|
+
return properties.get("platformName") or location.get("disassembledName") or ""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _efa_planned_platform(stop: Dict[str, Any]) -> str:
|
|
83
|
+
"""Scheduled platform, for detecting a real-time platform change."""
|
|
84
|
+
_, properties = _location_and_properties(stop)
|
|
85
|
+
return properties.get("plannedPlatform") or properties.get("plannedPlatformName") or ""
|
|
86
|
+
|
|
87
|
+
|
|
42
88
|
class EFABaseProvider(BaseProvider):
|
|
43
89
|
"""Base class for all EFA-based providers (VRR, KVV, HVV, MVV, etc.)."""
|
|
44
90
|
|
|
@@ -53,8 +99,16 @@ class EFABaseProvider(BaseProvider):
|
|
|
53
99
|
"""Return the base URL for stop finder requests."""
|
|
54
100
|
|
|
55
101
|
def get_platform_fn(self) -> Callable[[Dict[str, Any]], str]:
|
|
56
|
-
"""Return function to extract platform from stop event."""
|
|
57
|
-
return
|
|
102
|
+
"""Return function to extract the technical platform from a stop event."""
|
|
103
|
+
return _efa_platform
|
|
104
|
+
|
|
105
|
+
def get_platform_name_fn(self) -> Callable[[Dict[str, Any]], str]:
|
|
106
|
+
"""Return function to extract the human-readable platform label."""
|
|
107
|
+
return _efa_platform_name
|
|
108
|
+
|
|
109
|
+
def get_planned_platform_fn(self) -> Callable[[Dict[str, Any]], str]:
|
|
110
|
+
"""Return function to extract the scheduled platform."""
|
|
111
|
+
return _efa_planned_platform
|
|
58
112
|
|
|
59
113
|
def get_realtime_fn(self) -> Callable[[Dict[str, Any], Optional[str], Optional[str]], bool]:
|
|
60
114
|
"""Return function to detect realtime data."""
|
|
@@ -163,6 +217,8 @@ class EFABaseProvider(BaseProvider):
|
|
|
163
217
|
get_transport_type_fn=determine_transport_type,
|
|
164
218
|
get_platform_fn=self.get_platform_fn(),
|
|
165
219
|
get_realtime_fn=self.get_realtime_fn(),
|
|
220
|
+
get_planned_platform_fn=self.get_planned_platform_fn(),
|
|
221
|
+
get_platform_name_fn=self.get_platform_name_fn(),
|
|
166
222
|
)
|
|
167
223
|
|
|
168
224
|
async def search_stops(self, search_term: str) -> List[Dict[str, Any]]:
|
|
@@ -31,10 +31,8 @@ class HVVProvider(EFABaseProvider):
|
|
|
31
31
|
def get_transport_type_mapping(self) -> Dict[Any, str]:
|
|
32
32
|
return HVV_TRANSPORTATION_TYPES
|
|
33
33
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
s.get("location", {}).get("properties", {}).get("platform") or s.get("location", {}).get("platformName", "")
|
|
37
|
-
)
|
|
34
|
+
# location.properties.platform is now the EFABaseProvider default, so this
|
|
35
|
+
# provider no longer needs its own override.
|
|
38
36
|
|
|
39
37
|
def get_realtime_fn(self) -> Callable[[Dict[str, Any], Optional[str], Optional[str]], bool]:
|
|
40
38
|
return lambda s, est, plan: est != plan if est and plan else False
|
|
@@ -31,8 +31,9 @@ class KVVProvider(EFABaseProvider):
|
|
|
31
31
|
def get_transport_type_mapping(self) -> Dict[Any, str]:
|
|
32
32
|
return KVV_TRANSPORTATION_TYPES
|
|
33
33
|
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
# Platform comes from EFABaseProvider: location.properties.platform, with
|
|
35
|
+
# location.disassembledName ("Gleis 3") still a fallback and now exposed
|
|
36
|
+
# separately as platform_name.
|
|
36
37
|
|
|
37
38
|
def get_realtime_fn(self) -> Callable[[Dict[str, Any], Optional[str], Optional[str]], bool]:
|
|
38
39
|
return lambda s, est, plan: s.get("isRealtimeControlled", False)
|
|
@@ -56,6 +56,7 @@ src/python_openpublictransport.egg-info/SOURCES.txt
|
|
|
56
56
|
src/python_openpublictransport.egg-info/dependency_links.txt
|
|
57
57
|
src/python_openpublictransport.egg-info/requires.txt
|
|
58
58
|
src/python_openpublictransport.egg-info/top_level.txt
|
|
59
|
+
tests/test_efa_platform.py
|
|
59
60
|
tests/test_entur.py
|
|
60
61
|
tests/test_hafas_base.py
|
|
61
62
|
tests/test_hafas_mgate.py
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Tests for EFA platform/track extraction.
|
|
2
|
+
|
|
3
|
+
EFA's RapidJSON keeps the track under ``location.properties.platform``, but the
|
|
4
|
+
base provider used to read ``platform.name`` / ``platformName``, which that
|
|
5
|
+
response shape never has — so every EFA provider without its own override
|
|
6
|
+
reported an empty platform.
|
|
7
|
+
See https://github.com/NerdySoftPaw/openpublictransport/issues/56.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from zoneinfo import ZoneInfo
|
|
12
|
+
|
|
13
|
+
from openpublictransport.parsers import normalize_platform
|
|
14
|
+
from openpublictransport.providers.hvv import HVVProvider
|
|
15
|
+
from openpublictransport.providers.kvv import KVVProvider
|
|
16
|
+
from openpublictransport.providers.vrr import VRRProvider
|
|
17
|
+
from openpublictransport.providers.vvs import VVSProvider
|
|
18
|
+
|
|
19
|
+
TZ = ZoneInfo("Europe/Berlin")
|
|
20
|
+
NOW = datetime(2026, 7, 18, 20, 8, tzinfo=TZ)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# The exact stop event pasted into issue #56 (VVS Vaihingen, S1 to Plochingen).
|
|
24
|
+
VVS_STOP_EVENT = {
|
|
25
|
+
"location": {
|
|
26
|
+
"id": "de:08111:6002:1:3",
|
|
27
|
+
"isGlobalId": True,
|
|
28
|
+
"name": "Vaihingen",
|
|
29
|
+
"disassembledName": "Gleis 3",
|
|
30
|
+
"type": "platform",
|
|
31
|
+
"pointType": "TRACK",
|
|
32
|
+
"properties": {
|
|
33
|
+
"stopId": "5006002",
|
|
34
|
+
"platform": "3",
|
|
35
|
+
"platformName": "Gleis 3",
|
|
36
|
+
"plannedPlatformName": "Gleis 3",
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
"departureTimePlanned": "2026-07-18T18:25:00Z",
|
|
40
|
+
"departureTimeEstimated": "2026-07-18T18:26:00Z",
|
|
41
|
+
"isRealtimeControlled": True,
|
|
42
|
+
"transportation": {
|
|
43
|
+
"name": "S-Bahn S1",
|
|
44
|
+
"number": "S1",
|
|
45
|
+
"description": "Herrenberg - Stuttgart - Plochingen - Kirchheim (T)",
|
|
46
|
+
"destination": {"name": "Plochingen"},
|
|
47
|
+
"product": {"class": 1, "name": "S-Bahn"},
|
|
48
|
+
},
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _bus_stop_event() -> dict:
|
|
53
|
+
"""A bus departure: technical platform present, readable label empty."""
|
|
54
|
+
return {
|
|
55
|
+
"location": {
|
|
56
|
+
"id": "de:08111:6002:0:A",
|
|
57
|
+
"name": "Vaihingen",
|
|
58
|
+
"disassembledName": "",
|
|
59
|
+
"type": "platform",
|
|
60
|
+
"pointType": "BUS_POINT",
|
|
61
|
+
"properties": {"stopId": "5006002", "platform": "A"},
|
|
62
|
+
},
|
|
63
|
+
"departureTimePlanned": "2026-07-18T18:25:00Z",
|
|
64
|
+
"departureTimeEstimated": "2026-07-18T18:25:00Z",
|
|
65
|
+
"transportation": {
|
|
66
|
+
"number": "92",
|
|
67
|
+
"destination": {"name": "Rohr"},
|
|
68
|
+
"product": {"class": 5, "name": "Stadtbus"},
|
|
69
|
+
},
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_vvs_maps_technical_platform():
|
|
74
|
+
"""The platform attribute is the technical value from properties.platform."""
|
|
75
|
+
departure = VVSProvider(None).parse_departure(VVS_STOP_EVENT, TZ, NOW)
|
|
76
|
+
|
|
77
|
+
assert departure is not None
|
|
78
|
+
assert departure.platform == "3"
|
|
79
|
+
assert departure.line == "S1"
|
|
80
|
+
assert departure.destination == "Plochingen"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_vvs_exposes_readable_platform_name_separately():
|
|
84
|
+
"""The human-readable label is kept, but not as `platform`."""
|
|
85
|
+
departure = VVSProvider(None).parse_departure(VVS_STOP_EVENT, TZ, NOW)
|
|
86
|
+
|
|
87
|
+
assert departure.platform_name == "Gleis 3"
|
|
88
|
+
assert departure.to_dict()["platform"] == "3"
|
|
89
|
+
assert departure.to_dict()["platform_name"] == "Gleis 3"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_planned_platform_name_is_not_a_platform_change():
|
|
93
|
+
""""3" vs "Gleis 3" is the same platform, not a real-time change."""
|
|
94
|
+
departure = VVSProvider(None).parse_departure(VVS_STOP_EVENT, TZ, NOW)
|
|
95
|
+
|
|
96
|
+
assert departure.platform_changed is False
|
|
97
|
+
assert departure.planned_platform is None
|
|
98
|
+
assert "planned_platform" not in departure.to_dict()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_real_platform_change_is_detected():
|
|
102
|
+
"""A genuinely different scheduled track still reports a change."""
|
|
103
|
+
event = {
|
|
104
|
+
**VVS_STOP_EVENT,
|
|
105
|
+
"location": {
|
|
106
|
+
**VVS_STOP_EVENT["location"],
|
|
107
|
+
"properties": {
|
|
108
|
+
**VVS_STOP_EVENT["location"]["properties"],
|
|
109
|
+
"platform": "5",
|
|
110
|
+
"platformName": "Gleis 5",
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
departure = VVSProvider(None).parse_departure(event, TZ, NOW)
|
|
116
|
+
|
|
117
|
+
assert departure.platform == "5"
|
|
118
|
+
assert departure.platform_changed is True
|
|
119
|
+
assert departure.planned_platform == "Gleis 3"
|
|
120
|
+
assert departure.to_dict()["platform_changed"] is True
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_bus_stop_keeps_technical_platform_without_readable_label():
|
|
124
|
+
"""Bus stops have no "Gleis N" label but do have the technical value."""
|
|
125
|
+
departure = VVSProvider(None).parse_departure(_bus_stop_event(), TZ, NOW)
|
|
126
|
+
|
|
127
|
+
assert departure.platform == "A"
|
|
128
|
+
assert departure.platform_name is None
|
|
129
|
+
assert "platform_name" not in departure.to_dict()
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_all_efa_providers_share_the_fix():
|
|
133
|
+
"""The fix lives in EFABaseProvider, so it is not VVS-specific."""
|
|
134
|
+
for provider in (VVSProvider(None), VRRProvider(None), KVVProvider(None), HVVProvider(None)):
|
|
135
|
+
departure = provider.parse_departure(VVS_STOP_EVENT, TZ, NOW)
|
|
136
|
+
assert departure is not None, provider.provider_id
|
|
137
|
+
assert departure.platform == "3", provider.provider_id
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_legacy_platform_shape_still_works():
|
|
141
|
+
"""Pre-RapidJSON responses that use platform.name keep working."""
|
|
142
|
+
event = {
|
|
143
|
+
"departureTimePlanned": "2026-07-18T18:25:00Z",
|
|
144
|
+
"departureTimeEstimated": "2026-07-18T18:25:00Z",
|
|
145
|
+
"platform": {"name": "2"},
|
|
146
|
+
"transportation": {
|
|
147
|
+
"number": "U79",
|
|
148
|
+
"destination": {"name": "Duisburg Hbf"},
|
|
149
|
+
"product": {"class": 2, "name": "U-Bahn"},
|
|
150
|
+
},
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
departure = VRRProvider(None).parse_departure(event, TZ, NOW)
|
|
154
|
+
|
|
155
|
+
assert departure.platform == "2"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def test_missing_platform_is_empty_not_an_error():
|
|
159
|
+
event = {
|
|
160
|
+
"departureTimePlanned": "2026-07-18T18:25:00Z",
|
|
161
|
+
"departureTimeEstimated": "2026-07-18T18:25:00Z",
|
|
162
|
+
"location": {"properties": None},
|
|
163
|
+
"transportation": {
|
|
164
|
+
"number": "92",
|
|
165
|
+
"destination": {"name": "Rohr"},
|
|
166
|
+
"product": {"class": 5, "name": "Stadtbus"},
|
|
167
|
+
},
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
departure = VVSProvider(None).parse_departure(event, TZ, NOW)
|
|
171
|
+
|
|
172
|
+
assert departure.platform == ""
|
|
173
|
+
assert departure.platform_changed is False
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def test_normalize_platform():
|
|
177
|
+
assert normalize_platform("Gleis 3") == "3"
|
|
178
|
+
assert normalize_platform("gleis 3") == "3"
|
|
179
|
+
assert normalize_platform("3") == "3"
|
|
180
|
+
assert normalize_platform(" Bstg. 12 ") == "12"
|
|
181
|
+
assert normalize_platform("Steig B") == "b"
|
|
182
|
+
assert normalize_platform("Platform 4") == "4"
|
|
183
|
+
assert normalize_platform("Track 1") == "1"
|
|
184
|
+
assert normalize_platform(None) == ""
|
|
185
|
+
assert normalize_platform("") == ""
|
|
186
|
+
# A platform genuinely named "Gleis" keeps a usable value
|
|
187
|
+
assert normalize_platform("Gleis") == ""
|
|
188
|
+
assert normalize_platform(3) == "3"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_openpublictransport-0.1.14 → python_openpublictransport-0.1.15}/tests/test_hafas_base.py
RENAMED
|
File without changes
|
{python_openpublictransport-0.1.14 → python_openpublictransport-0.1.15}/tests/test_hafas_mgate.py
RENAMED
|
File without changes
|
{python_openpublictransport-0.1.14 → python_openpublictransport-0.1.15}/tests/test_national_rail.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|