LTADatamall-py 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Joel Khor / TheReaper62
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,77 @@
1
+ Metadata-Version: 2.4
2
+ Name: LTADatamall-py
3
+ Version: 0.1.0
4
+ Summary: LTA Datamall API Wrapper
5
+ Author-email: FishballNoodles <joelkhor.work@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/TheReaper62/ltadatamall-py
8
+ Project-URL: Bug_Tracker, https://github.com/TheReaper62/ltadatamall-py/issues
9
+ Keywords: lta,datamall,bus,timing,arrival,train,service,passenger volume,taxi
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Topic :: Software Development :: Build Tools
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Requires-Python: >=3.9
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Requires-Dist: requests>=2.31.0
23
+ Requires-Dist: aiohttp>=3.9.0
24
+ Provides-Extra: docs
25
+ Requires-Dist: mkdocs>=1.5; extra == "docs"
26
+ Requires-Dist: mkdocs-material>=9.5; extra == "docs"
27
+ Requires-Dist: mkdocstrings[python]>=0.24; extra == "docs"
28
+ Provides-Extra: dev
29
+ Requires-Dist: pytest>=8.0; extra == "dev"
30
+ Requires-Dist: pytest-cov>=4.0; extra == "dev"
31
+ Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
32
+ Requires-Dist: python-dotenv>=1.0; extra == "dev"
33
+ Dynamic: license-file
34
+
35
+ ## LTA Datamall API Wrapper (Python)
36
+
37
+ > This is an Unoffical Wrapper and this repo has no relation nor endorsement with/from LTA
38
+ > ####v0.1.0
39
+ > **Library Features:**
40
+
41
+ - Sync/Async
42
+ - Lightweight
43
+ - Easy to Use
44
+ - Open Source
45
+
46
+ ### Setup
47
+
48
+ ---
49
+
50
+ ##### Windows
51
+
52
+ ```shell
53
+ >>> pip install LTADatamall_py
54
+ ```
55
+
56
+ ##### MacOS
57
+
58
+ ```shell
59
+ >>> pip3 install LTADatamall_py
60
+ ```
61
+
62
+ ### Services/Information Provided
63
+
64
+ ---
65
+
66
+ 1. Bus Related
67
+
68
+ - Bus Arrival
69
+ - Bus Stop
70
+ - Bus Route
71
+ 2. Passenger Volume
72
+
73
+ - Train Station
74
+ - Bus Stop
75
+ 3. Train Service Alert
76
+
77
+ - Train Service Alert Messages
@@ -0,0 +1,43 @@
1
+ ## LTA Datamall API Wrapper (Python)
2
+
3
+ > This is an Unoffical Wrapper and this repo has no relation nor endorsement with/from LTA
4
+ > ####v0.1.0
5
+ > **Library Features:**
6
+
7
+ - Sync/Async
8
+ - Lightweight
9
+ - Easy to Use
10
+ - Open Source
11
+
12
+ ### Setup
13
+
14
+ ---
15
+
16
+ ##### Windows
17
+
18
+ ```shell
19
+ >>> pip install LTADatamall_py
20
+ ```
21
+
22
+ ##### MacOS
23
+
24
+ ```shell
25
+ >>> pip3 install LTADatamall_py
26
+ ```
27
+
28
+ ### Services/Information Provided
29
+
30
+ ---
31
+
32
+ 1. Bus Related
33
+
34
+ - Bus Arrival
35
+ - Bus Stop
36
+ - Bus Route
37
+ 2. Passenger Volume
38
+
39
+ - Train Station
40
+ - Bus Stop
41
+ 3. Train Service Alert
42
+
43
+ - Train Service Alert Messages
@@ -0,0 +1,83 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "LTADatamall-py"
7
+ version = "0.1.0"
8
+ description = "LTA Datamall API Wrapper"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = {text = "MIT"}
12
+ authors = [
13
+ {name = "FishballNoodles", email = "joelkhor.work@gmail.com"}
14
+ ]
15
+ keywords = [
16
+ "lta", "datamall", "bus", "timing", "arrival", "train",
17
+ "service", "passenger volume", "taxi"
18
+ ]
19
+
20
+ dependencies = [
21
+ "requests>=2.31.0",
22
+ "aiohttp>=3.9.0",
23
+ ]
24
+
25
+ classifiers = [
26
+ "Development Status :: 4 - Beta",
27
+ "Intended Audience :: Developers",
28
+ "Topic :: Software Development :: Build Tools",
29
+ "License :: OSI Approved :: MIT License",
30
+ "Programming Language :: Python :: 3",
31
+ "Programming Language :: Python :: 3.9",
32
+ "Programming Language :: Python :: 3.10",
33
+ "Programming Language :: Python :: 3.11",
34
+ "Programming Language :: Python :: 3.12",
35
+ ]
36
+
37
+ [project.urls]
38
+ Homepage = "https://github.com/TheReaper62/ltadatamall-py"
39
+ Bug_Tracker = "https://github.com/TheReaper62/ltadatamall-py/issues"
40
+
41
+ [project.optional-dependencies]
42
+ docs = [
43
+ "mkdocs>=1.5",
44
+ "mkdocs-material>=9.5",
45
+ "mkdocstrings[python]>=0.24",
46
+ ]
47
+ dev = [
48
+ "pytest>=8.0",
49
+ "pytest-cov>=4.0",
50
+ "pytest-asyncio>=0.23",
51
+ "python-dotenv>=1.0",
52
+ ]
53
+
54
+ [tool.setuptools]
55
+ package-dir = {"" = "src"}
56
+
57
+ [tool.setuptools.packages.find]
58
+ where = ["src"]
59
+ namespaces = false
60
+
61
+ [tool.pytest.ini_options]
62
+ minversion = "8.0"
63
+ addopts = "-ra -q --cov=ltadatamall --cov-report=term-missing"
64
+ testpaths = ["tests"]
65
+ asyncio_mode = "auto"
66
+ markers = [
67
+ "core: marks tests as core functionality tests",
68
+ "async: marks tests that test asynchronous functionality",
69
+ ]
70
+
71
+ [tool.coverage.run]
72
+ source = ["ltadatamall"]
73
+ branch = true
74
+
75
+ [tool.coverage.report]
76
+ exclude_also = [
77
+ "def __repr__",
78
+ "if __name__ == .__main__.:",
79
+ "if TYPE_CHECKING:",
80
+ "raise AssertionError",
81
+ "raise NotImplementedError",
82
+ ]
83
+ fail_under = 90
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,77 @@
1
+ Metadata-Version: 2.4
2
+ Name: LTADatamall-py
3
+ Version: 0.1.0
4
+ Summary: LTA Datamall API Wrapper
5
+ Author-email: FishballNoodles <joelkhor.work@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/TheReaper62/ltadatamall-py
8
+ Project-URL: Bug_Tracker, https://github.com/TheReaper62/ltadatamall-py/issues
9
+ Keywords: lta,datamall,bus,timing,arrival,train,service,passenger volume,taxi
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Topic :: Software Development :: Build Tools
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Requires-Python: >=3.9
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Requires-Dist: requests>=2.31.0
23
+ Requires-Dist: aiohttp>=3.9.0
24
+ Provides-Extra: docs
25
+ Requires-Dist: mkdocs>=1.5; extra == "docs"
26
+ Requires-Dist: mkdocs-material>=9.5; extra == "docs"
27
+ Requires-Dist: mkdocstrings[python]>=0.24; extra == "docs"
28
+ Provides-Extra: dev
29
+ Requires-Dist: pytest>=8.0; extra == "dev"
30
+ Requires-Dist: pytest-cov>=4.0; extra == "dev"
31
+ Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
32
+ Requires-Dist: python-dotenv>=1.0; extra == "dev"
33
+ Dynamic: license-file
34
+
35
+ ## LTA Datamall API Wrapper (Python)
36
+
37
+ > This is an Unoffical Wrapper and this repo has no relation nor endorsement with/from LTA
38
+ > ####v0.1.0
39
+ > **Library Features:**
40
+
41
+ - Sync/Async
42
+ - Lightweight
43
+ - Easy to Use
44
+ - Open Source
45
+
46
+ ### Setup
47
+
48
+ ---
49
+
50
+ ##### Windows
51
+
52
+ ```shell
53
+ >>> pip install LTADatamall_py
54
+ ```
55
+
56
+ ##### MacOS
57
+
58
+ ```shell
59
+ >>> pip3 install LTADatamall_py
60
+ ```
61
+
62
+ ### Services/Information Provided
63
+
64
+ ---
65
+
66
+ 1. Bus Related
67
+
68
+ - Bus Arrival
69
+ - Bus Stop
70
+ - Bus Route
71
+ 2. Passenger Volume
72
+
73
+ - Train Station
74
+ - Bus Stop
75
+ 3. Train Service Alert
76
+
77
+ - Train Service Alert Messages
@@ -0,0 +1,18 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/LTADatamall_py.egg-info/PKG-INFO
5
+ src/LTADatamall_py.egg-info/SOURCES.txt
6
+ src/LTADatamall_py.egg-info/dependency_links.txt
7
+ src/LTADatamall_py.egg-info/requires.txt
8
+ src/LTADatamall_py.egg-info/top_level.txt
9
+ src/ltadatamall/__init__.py
10
+ src/ltadatamall/base.py
11
+ src/ltadatamall/bus_arrival.py
12
+ src/ltadatamall/bus_manager.py
13
+ src/ltadatamall/bus_route.py
14
+ src/ltadatamall/bus_service.py
15
+ src/ltadatamall/bus_stop.py
16
+ src/ltadatamall/passenger_volume.py
17
+ src/ltadatamall/taxi.py
18
+ src/ltadatamall/train_service_alert.py
@@ -0,0 +1,13 @@
1
+ requests>=2.31.0
2
+ aiohttp>=3.9.0
3
+
4
+ [dev]
5
+ pytest>=8.0
6
+ pytest-cov>=4.0
7
+ pytest-asyncio>=0.23
8
+ python-dotenv>=1.0
9
+
10
+ [docs]
11
+ mkdocs>=1.5
12
+ mkdocs-material>=9.5
13
+ mkdocstrings[python]>=0.24
@@ -0,0 +1,8 @@
1
+ '''
2
+ Authors: TheReaper62
3
+ '''
4
+
5
+ from .bus_manager import *
6
+ from .passenger_volume import *
7
+ from .taxi import *
8
+ from .train_service_alert import *
@@ -0,0 +1,64 @@
1
+ '''
2
+ Authors: TheReaper62
3
+ '''
4
+
5
+ from typing import Any, Optional
6
+
7
+ import httpx
8
+ from requests import Session, exceptions
9
+
10
+
11
+ class BaseModel:
12
+ """
13
+ Base class for all model classes.
14
+ Contains the send and async_send methods for sending requests to the API.
15
+ """
16
+
17
+ BASE_URL = "https://datamall2.mytransport.sg/ltaodataservice"
18
+
19
+ def __init__(self, api_key: str):
20
+ self.headers = {"Accept": "application/json", "AccountKey": api_key}
21
+
22
+ def send(
23
+ self,
24
+ path,
25
+ *,
26
+ params: Optional[dict[str, Any]] = None,
27
+ json: Optional[dict[str, Any]] = None,
28
+ ) -> dict[str, Any]:
29
+ """Sends a request to the API and returns the response as a JSON object."""
30
+ with Session() as session:
31
+ response = session.get(
32
+ BaseModel.BASE_URL + "/" + path,
33
+ params=params,
34
+ headers=self.headers,
35
+ json=json,
36
+ )
37
+ try:
38
+ response.raise_for_status()
39
+ return response.json()
40
+ except exceptions.HTTPError as error:
41
+ raise exceptions.HTTPError(f"An Error Occured: {error}") from None
42
+
43
+ async def async_send(
44
+ self, path: str, *, params: dict[str, Any], json: dict[str, Any]
45
+ ) -> dict[str, Any]:
46
+ """Sends an asynchronous request to the API and returns the response as a JSON object."""
47
+ url = f"{BaseModel.BASE_URL}/{path}"
48
+
49
+ async with httpx.AsyncClient() as client:
50
+ try:
51
+ response = await client.get(
52
+ url, params=params, headers=self.headers, json=json
53
+ )
54
+ response.raise_for_status()
55
+ return response.json()
56
+
57
+ except httpx.HTTPStatusError as error:
58
+ # Captures 4xx and 5xx responses specifically
59
+ raise exceptions.HTTPError(f"An Error Occurred: {error}") from None
60
+ except httpx.RequestError as error:
61
+ # Captures underlying network issues (e.g., connection timeouts)
62
+ raise exceptions.HTTPError(
63
+ f"A Network Error Occurred: {error}"
64
+ ) from None
@@ -0,0 +1,121 @@
1
+ from typing import Optional, Unpack, TypedDict
2
+ from datetime import datetime
3
+ import zoneinfo
4
+
5
+ __all__ = [
6
+ 'NextBus',
7
+ 'BusArrivalService',
8
+ ]
9
+
10
+
11
+ class NextBusPayload(TypedDict, total=False):
12
+ OriginCode: str
13
+ DestinationCode: str
14
+ EstimatedArrival: str
15
+ Latitude: str
16
+ Longitude: str
17
+ VisitNumber: str
18
+ Load: str
19
+ Feature: str
20
+ Type: str
21
+
22
+
23
+ class NextBus:
24
+ """
25
+ Represents an arriving bus service with parsed telemetry and capacity data.
26
+
27
+ This class ingests raw API response payloads via keyword arguments, normalizes
28
+ codes into human-readable strings, and parses arrival timestamps into timezone-aware
29
+ datetime objects.
30
+ """
31
+
32
+ def __init__(self, **kwargs: Unpack[NextBusPayload]) -> None:
33
+ load_map = {
34
+ "SEA": "Seats Available",
35
+ "SDA": "Standing Available",
36
+ "LSD": "Limited Standing",
37
+ }
38
+ type_map = {"SD": "Single Decker", "DD": "Double Decker", "BD": "Bendy"}
39
+
40
+ self.origin_code = kwargs.get("OriginCode", "Not Available")
41
+ self.destination_code = kwargs.get("DestinationCode", "Not Available")
42
+
43
+ self.estimated_arrival = (
44
+ datetime.strptime(kwargs["EstimatedArrival"], r"%Y-%m-%dT%H:%M:%S%z")
45
+ if kwargs.get("EstimatedArrival", "") != ""
46
+ else "No Estimated Time"
47
+ )
48
+ self.latitude = kwargs.get("Latitude", 0.0)
49
+ self.longitude = kwargs.get("Longitude", 0.0)
50
+
51
+ self.visit_number = (
52
+ int(kwargs["VisitNumber"])
53
+ if kwargs.get("VisitNumber", "") != ""
54
+ else "Not Available"
55
+ )
56
+ self.load = load_map.get(kwargs.get("Load", None), "Not Available")
57
+ self.feature = (
58
+ "Not Wheel-chair Accessible"
59
+ if kwargs.get("Feature", "") == ""
60
+ else "Wheel-chair Accessible"
61
+ )
62
+ self.type = type_map.get(kwargs.get("Type"), "Not Available")
63
+
64
+
65
+ class BusArrivalServicePayload(TypedDict, total=False):
66
+ ServiceNo: str
67
+ Operator: str
68
+ NextBus: NextBusPayload
69
+ NextBus2: NextBusPayload
70
+ NextBus3: NextBusPayload
71
+
72
+
73
+ class BusArrivalService:
74
+ """Model representing arrival information for a specific bus service at a bus stop.
75
+
76
+ This class parses metadata for a distinct transit route code and instantiates
77
+ the upcoming three scheduled arrival vehicle telemetry profiles.
78
+ """
79
+
80
+ def __init__(self, **kwargs: Unpack[BusArrivalServicePayload]) -> None:
81
+ operator_map: dict[str, str] = {
82
+ "SBST": "SBS Transit",
83
+ "SMRT": "SMRT Corporation",
84
+ "TTS": "Tower Transit Singapore",
85
+ "GAS": "Go Ahead Singapore",
86
+ }
87
+
88
+ # Handle explicit type coercions safely
89
+ raw_service_no = kwargs.get("ServiceNo")
90
+ self.service_no: Optional[int] = (
91
+ int(raw_service_no) if raw_service_no is not None else None
92
+ )
93
+
94
+ self.operator: str = operator_map.get(
95
+ kwargs.get("Operator", ""), "Not Available"
96
+ )
97
+
98
+ # 2. Extract nested structures and instantiate NextBus objects cleanly
99
+ self.next_1: NextBus = NextBus(**kwargs.get("NextBus", {}))
100
+ self.next_2: NextBus = NextBus(**kwargs.get("NextBus2", {}))
101
+ self.next_3: NextBus = NextBus(**kwargs.get("NextBus3", {}))
102
+
103
+ self.secs_to_arrival: Optional[int] = None
104
+ self._calculate_seconds_to_arrival()
105
+
106
+ def _calculate_seconds_to_arrival(self) -> None:
107
+ """Calculates time delta between now and next_1 arrival in seconds."""
108
+ if (
109
+ isinstance(self.next_1.estimated_arrival, str)
110
+ or self.next_1.estimated_arrival == "No Estimated Time"
111
+ ):
112
+ self.secs_to_arrival = None
113
+ return
114
+
115
+ sg_tz = zoneinfo.ZoneInfo("Asia/Singapore")
116
+ now_sg = datetime.now(sg_tz)
117
+
118
+ time_delta = self.next_1.estimated_arrival - now_sg
119
+ total_seconds = int(time_delta.total_seconds())
120
+
121
+ self.secs_to_arrival = total_seconds
@@ -0,0 +1,366 @@
1
+ from typing import Any
2
+
3
+ from .base import BaseModel
4
+
5
+ from .bus_stop import BusStop
6
+ from .bus_service import BusService
7
+ from .bus_route import BusRoute
8
+ from .bus_arrival import BusArrivalService
9
+
10
+ __all__ = (
11
+ 'BusManager',
12
+ )
13
+
14
+
15
+ class BusManager(BaseModel):
16
+ """Orchestrates API for tracking bus routes and operational status profiles."""
17
+
18
+ def get_bus_arrival(
19
+ self,
20
+ bus_stop_code: str | int | BusStop,
21
+ *,
22
+ service_no: str | int | None = None,
23
+ ) -> list[BusArrivalService]:
24
+ """Fetches real-time bus arrival timelines for a specific bus stop code.
25
+
26
+ Args:
27
+ bus_stop_code: The 5-digit unique transit stop code or a BusStop instance.
28
+ service_no: Optional specific transit service route number to filter results.
29
+
30
+ Returns:
31
+ A list containing active BusArrivalService status track records.
32
+ """
33
+ params = self._prepare_arrival_params(bus_stop_code, service_no)
34
+ response = self.send("v3/BusArrival", params=params)
35
+
36
+ services = response.get("Services")
37
+ if isinstance(services, list):
38
+ return [BusArrivalService(**item) for item in services]
39
+
40
+ raise ValueError(
41
+ f"LTA DataMall API did not yield any service items for parameters: {params}"
42
+ )
43
+
44
+ async def async_get_bus_arrival(
45
+ self,
46
+ bus_stop_code: str | int | BusStop,
47
+ *,
48
+ service_no: str | int | None = None,
49
+ ) -> list[BusArrivalService]:
50
+ """Asynchronously fetches real-time bus arrival timelines for a specific bus stop code."""
51
+ params = self._prepare_arrival_params(bus_stop_code, service_no)
52
+
53
+ response = await self.async_send("v3/BusArrival", params=params, json=None)
54
+
55
+ services = response.get("Services")
56
+ if isinstance(services, list):
57
+ return [BusArrivalService(**item) for item in services]
58
+
59
+ raise ValueError(
60
+ "LTA DataMall API did not yield any service items for the specified parameters."
61
+ )
62
+
63
+ def _prepare_arrival_params(
64
+ self, bus_stop_code: str | int | BusStop, service_no: str | int | None
65
+ ) -> dict[str, Any]:
66
+ """Internal helper logic block designed to standardise all codes to strings."""
67
+ code = (
68
+ bus_stop_code.bus_stop_code
69
+ if isinstance(bus_stop_code, BusStop)
70
+ else str(bus_stop_code)
71
+ )
72
+
73
+ params: dict[str, Any] = {"BusStopCode": code}
74
+ if service_no is not None:
75
+ params["ServiceNo"] = str(service_no)
76
+
77
+ return params
78
+
79
+ def get_services(
80
+ self,
81
+ services: list[str | int] | None = None
82
+ ) -> list[BusService]:
83
+ """Fetches comprehensive metadata for active bus routes.
84
+
85
+ Args:
86
+ services: Optional explicit list of route numbers to filter.
87
+ If None, all available services are returned.
88
+ """
89
+ response = self.send("BusServices")
90
+ value_list = response.get("value")
91
+
92
+ if not isinstance(value_list, list):
93
+ raise ValueError(
94
+ "LTA DataMall API did not yield a valid data list layout for BusServices."
95
+ )
96
+
97
+ if services is None:
98
+ return [BusService(**item) for item in value_list]
99
+
100
+ # Optimization: Use a hashed set lookup instead of rebuilding a map list inside a loop
101
+ target_services = {str(s) for s in services}
102
+ return [
103
+ BusService(**item)
104
+ for item in value_list
105
+ if str(item.get("ServiceNo")) in target_services
106
+ ]
107
+
108
+ async def async_get_services(
109
+ self,
110
+ services: list[str | int] | None = None
111
+ ) -> list[BusService]:
112
+ """Asynchronously fetches comprehensive metadata for active bus routes."""
113
+ response = await self.async_send("BusServices", params=None, json=None)
114
+ value_list = response.get("value")
115
+
116
+ if not isinstance(value_list, list):
117
+ raise ValueError(
118
+ "LTA DataMall API did not yield a valid data list layout for BusServices."
119
+ )
120
+
121
+ if services is None:
122
+ return [BusService(**item) for item in value_list]
123
+
124
+ target_services = {str(s) for s in services}
125
+ return [
126
+ BusService(**item)
127
+ for item in value_list
128
+ if str(item.get("ServiceNo")) in target_services
129
+ ]
130
+
131
+ def get_all_services(self) -> list[BusService]:
132
+ """Fetches all active bus service records by automatically paginating
133
+ through the 500-record payload threshold constraints.
134
+ """
135
+ services: list[BusService] = []
136
+ skip_offset = 0
137
+
138
+ while True:
139
+ response = self.send("BusServices", params={"$skip": skip_offset})
140
+ records = response.get("value")
141
+
142
+ if not isinstance(records, list) or not records:
143
+ if skip_offset == 0:
144
+ raise ValueError(
145
+ "LTA DataMall API returned an empty or invalid BusServices payload."
146
+ )
147
+ break
148
+ services.extend([BusService(**item) for item in records])
149
+
150
+ if len(records) < 500:
151
+ break
152
+ skip_offset += 500
153
+ return services
154
+
155
+ async def async_get_all_services(self) -> list[BusService]:
156
+ """Asynchronously fetches all active bus service records by handling pagination."""
157
+ services: list[BusService] = []
158
+ skip_offset = 0
159
+
160
+ while True:
161
+ response = await self.async_send(
162
+ "BusServices", params={"$skip": skip_offset}, json=None
163
+ )
164
+ records = response.get("value")
165
+
166
+ if not isinstance(records, list) or not records:
167
+ if skip_offset == 0:
168
+ raise ValueError(
169
+ "LTA DataMall API returned an empty or invalid BusServices payload."
170
+ )
171
+ break
172
+ services.extend([BusService(**item) for item in records])
173
+
174
+ if len(records) < 500:
175
+ break
176
+ skip_offset += 500
177
+ return services
178
+
179
+ def get_routes(self, **filters: Any) -> list[BusRoute]:
180
+ """Fetches a single page of bus route data and applies filters.
181
+
182
+ Args:
183
+ **filters: Optional keyword arguments matching model attributes
184
+ (e.g., service_no="190", bus_stop_code="65009").
185
+ """
186
+ # FIX: Standardized endpoint path from singular 'BusRoute' to plural 'BusRoutes'
187
+ response = self.send("BusRoutes")
188
+ value_list = response.get("value")
189
+
190
+ if not isinstance(value_list, list):
191
+ raise ValueError("LTA DataMall API did not yield a valid data list layout for BusRoutes.")
192
+
193
+ if not filters:
194
+ return [BusRoute(**item) for item in value_list]
195
+
196
+ # Map Python snake_case filters to LTA PascalCase keys
197
+ pascal_filters = self._normalize_filters(filters)
198
+
199
+ return [
200
+ BusRoute(**item)
201
+ for item in value_list
202
+ if all(item.get(k) == v for k, v in pascal_filters.items())
203
+ ]
204
+
205
+ async def async_get_routes(self, **filters: Any) -> list[BusRoute]:
206
+ """Asynchronously fetches a single page of bus route data and applies filters."""
207
+ # FIX 1: Added await keyword
208
+ # FIX 2: Corrected endpoint from singular to plural 'BusRoutes'
209
+ # FIX 3: Supplied json=None keyword matching BaseModel signature
210
+ response = await self.async_send("BusRoutes", params=None, json=None)
211
+ value_list = response.get("value")
212
+
213
+ if not isinstance(value_list, list):
214
+ raise ValueError("LTA DataMall API did not yield a valid data list layout for BusRoutes.")
215
+
216
+ if not filters:
217
+ return [BusRoute(**item) for item in value_list]
218
+
219
+ pascal_filters = self._normalize_filters(filters)
220
+
221
+ return [
222
+ BusRoute(**item)
223
+ for item in value_list
224
+ if all(item.get(k) == v for k, v in pascal_filters.items())
225
+ ]
226
+
227
+ def get_all_routes(self) -> list[BusRoute]:
228
+ """Fetches all bus route records by automatically handling the 500-record pagination limit."""
229
+ routes: list[BusRoute] = []
230
+ skip_offset = 0
231
+
232
+ while True:
233
+ response = self.send("BusRoutes", params={"$skip": skip_offset})
234
+ records = response.get("value")
235
+ if not isinstance(records, list) or not records:
236
+ if skip_offset == 0:
237
+ raise ValueError("LTA DataMall API returned an empty or invalid BusRoutes payload.")
238
+ break
239
+ routes.extend([BusRoute(**item) for item in records])
240
+
241
+ if len(records) < 500:
242
+ break
243
+ skip_offset += 500
244
+ return routes
245
+
246
+ async def async_get_all_routes(self) -> list[BusRoute]:
247
+ """Asynchronously fetches all bus route records by handling pagination constraints cleanly."""
248
+ routes: list[BusRoute] = []
249
+ skip_offset = 0
250
+
251
+ while True:
252
+ response = await self.async_send("BusRoutes", params={"$skip": skip_offset}, json=None)
253
+ records = response.get("value")
254
+ if not isinstance(records, list) or not records:
255
+ if skip_offset == 0:
256
+ raise ValueError("LTA DataMall API returned an empty or invalid BusRoutes payload.")
257
+ break
258
+ routes.extend([BusRoute(**item) for item in records])
259
+
260
+ if len(records) < 500:
261
+ break
262
+ skip_offset += 500
263
+ return routes
264
+
265
+ def _normalize_filters(self, filters: dict[str, Any]) -> dict[str, Any]:
266
+ """Helper to map common snake_case filter keys to LTA data field shapes."""
267
+ mapping = {
268
+ "service_no": "ServiceNo",
269
+ "operator": "Operator",
270
+ "direction": "Direction",
271
+ "stop_sequence": "StopSequence",
272
+ "bus_stop_code": "BusStopCode",
273
+ }
274
+ return {mapping.get(k, k): v for k, v in filters.items()}
275
+
276
+ def get_stops(self, bus_stop_codes: str | int | list[str | int]) -> list[BusStop] | BusStop | None:
277
+ """Retrieves targeted bus stop configurations from the complete database.
278
+
279
+ Args:
280
+ bus_stop_codes: A single stop code (str/int) or a sequence array of stop codes.
281
+
282
+ Returns:
283
+ A single BusStop object if a scalar input was provided, a list of BusStop
284
+ objects if a sequence was provided, or None if no matches were located.
285
+ """
286
+ raw_stops = self._fetch_raw_all_stops()
287
+
288
+ if isinstance(bus_stop_codes, (str, int)):
289
+ target_code = str(bus_stop_codes)
290
+ for item in raw_stops:
291
+ if str(item.get("BusStopCode")) == target_code:
292
+ return BusStop(**item)
293
+ return None
294
+
295
+ search_set = {str(code) for code in bus_stop_codes}
296
+ return [
297
+ BusStop(**item)
298
+ for item in raw_stops
299
+ if str(item.get("BusStopCode")) in search_set
300
+ ]
301
+
302
+ async def async_get_stops(self, bus_stop_codes: str | int | list[str | int]) -> list[BusStop] | BusStop | None:
303
+ """Asynchronously retrieves targeted bus stop configurations from the complete database."""
304
+ raw_stops = await self._async_fetch_raw_all_stops()
305
+
306
+ if isinstance(bus_stop_codes, (str, int)):
307
+ target_code = str(bus_stop_codes)
308
+ for item in raw_stops:
309
+ if str(item.get("BusStopCode")) == target_code:
310
+ return BusStop(**item)
311
+ return None
312
+
313
+ search_set = {str(code) for code in bus_stop_codes}
314
+ return [
315
+ BusStop(**item)
316
+ for item in raw_stops
317
+ if str(item.get("BusStopCode")) in search_set
318
+ ]
319
+
320
+ def get_all_stops(self) -> list[BusStop]:
321
+ """Fetches all bus stop configurations registered on the LTA network."""
322
+ return [BusStop(**item) for item in self._fetch_raw_all_stops()]
323
+
324
+ async def async_get_all_stops(self) -> list[BusStop]:
325
+ """Asynchronously fetches all bus stop configurations registered on the LTA network."""
326
+ return [BusStop(**item) for item in await self._async_fetch_raw_all_stops()]
327
+
328
+ def _fetch_raw_all_stops(self) -> list[dict[str, Any]]:
329
+ """Internal helper to download raw payload dictionaries before class instantiation."""
330
+ stops: list[dict[str, Any]] = []
331
+ skip_offset = 0
332
+
333
+ while True:
334
+ response = self.send("BusStops", params={"$skip": skip_offset})
335
+ records = response.get("value")
336
+
337
+ if not isinstance(records, list) or not records:
338
+ if skip_offset == 0:
339
+ raise ValueError("LTA DataMall API returned an empty or invalid BusStops payload.")
340
+ break
341
+ stops.extend(records)
342
+
343
+ if len(records) < 500:
344
+ break
345
+ skip_offset += 500
346
+ return stops
347
+
348
+ async def _async_fetch_raw_all_stops(self) -> list[dict[str, Any]]:
349
+ """Internal helper to asynchronously download raw payload dictionaries."""
350
+ stops: list[dict[str, Any]] = []
351
+ skip_offset = 0
352
+
353
+ while True:
354
+ response = await self.async_send("BusStops", params={"$skip": skip_offset}, json=None)
355
+ records = response.get("value")
356
+
357
+ if not isinstance(records, list) or not records:
358
+ if skip_offset == 0:
359
+ raise ValueError("LTA DataMall API returned an empty or invalid BusStops payload.")
360
+ break
361
+ stops.extend(records)
362
+
363
+ if len(records) < 500:
364
+ break
365
+ skip_offset += 500
366
+ return stops
@@ -0,0 +1,71 @@
1
+ from typing import TypedDict, Unpack
2
+
3
+ from .base import BaseModel
4
+
5
+ __all__ = [
6
+ "BusRoute",
7
+ ]
8
+
9
+
10
+ class BusRoutePayload(TypedDict, total=False):
11
+ ServiceNo: str
12
+ Operator: str
13
+ Direction: int | str
14
+ StopSequence: int | str
15
+ BusStopCode: str | int
16
+ Distance: float | str
17
+ WD_FirstBus: str
18
+ WD_LastBus: str
19
+ SAT_FirstBus: str
20
+ SAT_LastBus: str
21
+ SUN_FirstBus: str
22
+ SUN_LastBus: str
23
+
24
+
25
+ class BusRoute:
26
+ """Model representing a sequential bus route stop entry.
27
+
28
+ This class handles specific schedule timings, distance metrics, and sequencing
29
+ indices for unique service routes across Singapore's transit network.
30
+ """
31
+
32
+ def __init__(self, **kwargs: Unpack[BusRoutePayload]) -> None:
33
+ operator_map: dict[str, str] = {
34
+ "SBST": "SBS Transit",
35
+ "SMRT": "SMRT Corporation",
36
+ "TTS": "Tower Transit Singapore",
37
+ "GAS": "Go Ahead Singapore",
38
+ }
39
+
40
+ self.service_no: str | None = kwargs.get("ServiceNo", None)
41
+
42
+ self.operator: str = operator_map.get(
43
+ kwargs.get("Operator", ""), "Not Available"
44
+ )
45
+
46
+ self.direction: int | None = (
47
+ int(kwargs["Direction"]) if kwargs.get("Direction") is not None else None
48
+ )
49
+ self.stop_sequence: int | None = (
50
+ int(kwargs["StopSequence"])
51
+ if kwargs.get("StopSequence") is not None
52
+ else None
53
+ )
54
+
55
+ self.bus_stop_code: str | None = (
56
+ str(kwargs["BusStopCode"])
57
+ if kwargs.get("BusStopCode") is not None
58
+ else None
59
+ )
60
+
61
+ self.distance: float | None = (
62
+ float(kwargs["Distance"]) if kwargs.get("Distance") is not None else None
63
+ )
64
+
65
+ # Timeline schedule fields
66
+ self.wd_firstbus: str | None = kwargs.get("WD_FirstBus", None)
67
+ self.wd_lastbus: str | None = kwargs.get("WD_LastBus", None)
68
+ self.sat_firstbus: str | None = kwargs.get("SAT_FirstBus", None)
69
+ self.sat_lastbus: str | None = kwargs.get("SAT_LastBus", None)
70
+ self.sun_firstbus: str | None = kwargs.get("SUN_FirstBus", None)
71
+ self.sun_lastbus: str | None = kwargs.get("SUN_LastBus", None)
@@ -0,0 +1,53 @@
1
+ from typing import TypedDict, Unpack
2
+
3
+ from .base import BaseModel
4
+
5
+ __all__ = [
6
+ "BusService",
7
+ ]
8
+
9
+
10
+ class BusServicePayload(TypedDict, total=False):
11
+ ServiceNo: str
12
+ Operator: str
13
+ Direction: int | str
14
+ Category: str
15
+ OriginCode: str
16
+ DestinationCode: str
17
+ AM_Peak_Freq: str
18
+ AM_Offpeak_Freq: str
19
+ PM_Peak_Freq: str
20
+ PM_Offpeak_Freq: str
21
+ LoopDesc: str
22
+
23
+
24
+ class BusService:
25
+ """Model representing metadata for a specific bus route service configuration.
26
+
27
+ This class extracts the operating schedule frequency, service loop descriptors,
28
+ and handling agency constraints from raw LTA data data streams.
29
+ """
30
+
31
+ def __init__(self, **kwargs: Unpack[BusServicePayload]) -> None:
32
+ operator_map: dict[str, str] = {
33
+ "SBST": "SBS Transit",
34
+ "SMRT": "SMRT Corporation",
35
+ "TTS": "Tower Transit Singapore",
36
+ "GAS": "Go Ahead Singapore",
37
+ }
38
+
39
+ self.service_no: str | None = kwargs.get("ServiceNo", None)
40
+ self.operator: str = operator_map.get(
41
+ kwargs.get("Operator", ""), "Not Available"
42
+ )
43
+ self.direction: int | None = (
44
+ int(kwargs["Direction"]) if kwargs.get("Direction") is not None else None
45
+ )
46
+ self.category: str | None = kwargs.get("Category", None)
47
+ self.origin_code: str | None = kwargs.get("OriginCode", None)
48
+ self.destination_code: str | None = kwargs.get("DestinationCode", None)
49
+ self.am_peak_freq: str | None = kwargs.get("AM_Peak_Freq", None)
50
+ self.am_offpeak_freq: str | None = kwargs.get("AM_Offpeak_Freq", None)
51
+ self.pm_peak_freq: str | None = kwargs.get("PM_Peak_Freq", None)
52
+ self.pm_offpeak_freq: str | None = kwargs.get("PM_Offpeak_Freq", None)
53
+ self.loop_desc: str | None = kwargs.get("LoopDesc", None)
@@ -0,0 +1,31 @@
1
+ from typing import TypedDict, Unpack
2
+
3
+ from .base import BaseModel
4
+
5
+ __all__ = [
6
+ "BusStop",
7
+ ]
8
+
9
+
10
+ class BusStopPayload(TypedDict, total=False):
11
+ BusStopCode: str | int
12
+ RoadName: str
13
+ Description: str
14
+ Latitude: str | float
15
+ Longitude: str | float
16
+
17
+
18
+ class BusStop:
19
+ """Represents a physical bus stop containing metadata and geographic location coordinates.
20
+
21
+ This class ingests raw API response payloads via keyword arguments, normalizes
22
+ codes into human-readable strings, and forces coordinate parameters into float precision.
23
+ """
24
+
25
+ def __init__(self, **kwargs: Unpack[BusStopPayload]) -> None:
26
+ self.bus_stop_code: str = str(kwargs.get("BusStopCode", "Not Available"))
27
+ self.road_name: str = kwargs.get("RoadName", "Not Available")
28
+ self.description: str = kwargs.get("Description", "Not Available")
29
+
30
+ self.latitude: float = float(kwargs.get("Latitude", 0.0))
31
+ self.longitude: float = float(kwargs.get("Longitude", 0.0))
@@ -0,0 +1,83 @@
1
+ from .base import BaseModel
2
+
3
+ __all__ = [
4
+ "PassengerVolume",
5
+ ]
6
+
7
+
8
+ class PassengerVolume(BaseModel):
9
+ """Orchestrates LTA DataMall API bindings for monthly passenger volume downloads.
10
+
11
+ Provides dynamic downloadable asset links tracking transit volume matrices
12
+ across bus stops, train lines, and origin-destination nodes.
13
+ """
14
+
15
+ # 1. Passenger Volume By Bus Stop
16
+ def pv_bus_stop(self, date: str | None = None) -> str:
17
+ """Fetches the download URL link for passenger volumes by bus stops.
18
+
19
+ Args:
20
+ date: Targeted month in YYYYMM format (e.g., "202603"). Defaults
21
+ to the latest available month data from LTA if omitted.
22
+ """
23
+ params = {"Date": date} if date else None
24
+ response = self.send("PV/Bus", params=params)
25
+ return self._extract_download_link(response, "PV/Bus")
26
+
27
+ async def async_pv_bus_stop(self, date: str | None = None) -> str:
28
+ """Asynchronously fetches the download URL link for passenger volumes by bus stops."""
29
+ params = {"Date": date} if date else None
30
+ response = await self.async_send("PV/Bus", params=params, json=None)
31
+ return self._extract_download_link(response, "PV/Bus")
32
+
33
+ # 2. Passenger Volume By Origin-Destination Bus Stops
34
+ def pv_od_bus_stop(self, date: str | None = None) -> str:
35
+ """Fetches the download URL link for origin-destination passenger volumes by bus stops."""
36
+ params = {"Date": date} if date else None
37
+ response = self.send("PV/ODBus", params=params)
38
+ return self._extract_download_link(response, "PV/ODBus")
39
+
40
+ async def async_pv_od_bus_stop(self, date: str | None = None) -> str:
41
+ """Asynchronously fetches the download URL link for origin-destination passenger volumes by bus stops."""
42
+ params = {"Date": date} if date else None
43
+ response = await self.async_send("PV/ODBus", params=params, json=None)
44
+ return self._extract_download_link(response, "PV/ODBus")
45
+
46
+ # 3. Passenger Volume By Origin-Destination Train Stations
47
+ def pv_od_train_destination(self, date: str | None = None) -> str:
48
+ """Fetches the download URL link for origin-destination passenger volumes by train stations."""
49
+ params = {"Date": date} if date else None
50
+ response = self.send("PV/ODTrain", params=params)
51
+ return self._extract_download_link(response, "PV/ODTrain")
52
+
53
+ async def async_pv_od_train_destination(self, date: str | None = None) -> str:
54
+ """Asynchronously fetches the download URL link for origin-destination passenger volumes by train stations."""
55
+ params = {"Date": date} if date else None
56
+ response = await self.async_send("PV/ODTrain", params=params, json=None)
57
+ return self._extract_download_link(response, "PV/ODTrain")
58
+
59
+ # 4. Passenger Volume By Train Stations
60
+ def pv_train_station(self, date: str | None = None) -> str:
61
+ """Fetches the download URL link for passenger volumes by individual train stations."""
62
+ params = {"Date": date} if date else None
63
+ response = self.send("PV/Train", params=params)
64
+ return self._extract_download_link(response, "PV/Train")
65
+
66
+ async def async_pv_train_station(self, date: str | None = None) -> str:
67
+ """Asynchronously fetches the download URL link for passenger volumes by individual train stations."""
68
+ params = {"Date": date} if date else None
69
+ response = await self.async_send("PV/Train", params=params, json=None)
70
+ return self._extract_download_link(response, "PV/Train")
71
+
72
+ def _extract_download_link(self, response: dict[str, any], endpoint: str) -> str:
73
+ """Safely extracts the nested download URL link from LTA's response payload array wrapper."""
74
+ value_list = response.get("value")
75
+
76
+ if isinstance(value_list, list) and len(value_list) > 0:
77
+ link = value_list[0].get("Link")
78
+ if link:
79
+ return str(link)
80
+
81
+ raise ValueError(
82
+ f"LTA DataMall API did not yield a valid download link structural layout for endpoint: '{endpoint}'"
83
+ )
@@ -0,0 +1,62 @@
1
+ from typing import Union
2
+
3
+ from .base import BaseModel
4
+
5
+ __all = (
6
+ 'TaxiManager',
7
+ 'AvailableTaxi',
8
+ 'TaxiStand',
9
+ )
10
+
11
+ class AvailableTaxi:
12
+ def __init__(self,**kwargs):
13
+ self.latitude = float(kwargs.get('Latitude',0))
14
+ self.longitude = float(kwargs.get('Longitude',0))
15
+
16
+ class TaxiStand:
17
+ def __init__(self,**kwargs):
18
+ ownership_map = {
19
+ 'LTA' : "Land Transport Authority",
20
+ 'CCS' : "Clear Channel Singapore",
21
+ 'Private' : "Private",
22
+ }
23
+ self.taxi_code = kwargs.get('TaxiCode', "Not Available")
24
+ self.latitude = float(kwargs.get('Latitude',0))
25
+ self.longitude = float(kwargs.get('Longitude',0))
26
+ self.bfa = kwargs.get('Bfa',"Not Available")
27
+ self.ownership = ownership_map.get(kwargs.get('Ownership',None),"Not Available")
28
+ self.type = kwargs.get('Type',"Not Available")
29
+ self.name = kwargs.get('Name',"Not Available")
30
+
31
+ class TaxiManager(BaseModel):
32
+ def __init__(self, api_key: str):
33
+ super().__init__(api_key)
34
+
35
+ # Available Taxis
36
+ def get_availability(self):
37
+ response = self.send('Taxi-Availability')
38
+ if response.get('value',False):
39
+ return [AvailableTaxi(**i) for i in response['value']]
40
+ raise Exception("API Returned None")
41
+
42
+ async def async_get_availability(self):
43
+ response = await self.async_send('Taxi-Availability')
44
+ if response.get('value',False):
45
+ return [AvailableTaxi(**i) for i in response['value']]
46
+ raise Exception("API Returned None")
47
+
48
+ # Taxi Stands
49
+ def get_taxi_stands(self,taxi_codes:Union[str,list[str]]=[]):
50
+ response = self.send('TaxiStands')
51
+ taxi_codes = [taxi_codes] if not isinstance(taxi_codes,(tuple,list)) else taxi_codes
52
+ if response.get('value',False):
53
+ return [TaxiStand(**i) for i in response['value'] if i['TaxiCode'] in taxi_codes and taxi_codes!=[]]
54
+ raise Exception("API Returned None")
55
+
56
+ async def async_get_taxi_stands(self, taxi_codes: Union[str, list[str]] = []):
57
+ response = await self.async_send('TaxiStands')
58
+ taxi_codes = [taxi_codes] if not isinstance(
59
+ taxi_codes, (tuple, list)) else taxi_codes
60
+ if response.get('value', False):
61
+ return [TaxiStand(**i) for i in response['value'] if i['TaxiCode'] in taxi_codes and taxi_codes != []]
62
+ raise Exception("API Returned None")
@@ -0,0 +1,80 @@
1
+ from typing import TypedDict, Unpack, Any
2
+
3
+ from .base import BaseModel
4
+
5
+ __all__ = [
6
+ "TrainServiceAlert",
7
+ "TrainServiceAlertManager",
8
+ ]
9
+
10
+
11
+ class TrainServiceAlertPayload(TypedDict, total=False):
12
+ Status: str | int
13
+ Line: str
14
+ Direction: str
15
+ Stations: str
16
+ FreePublicBus: str
17
+ FreeMRTShuttle: str
18
+ MRTShuttleDirection: str
19
+ Message: str
20
+
21
+
22
+ class TrainServiceAlert:
23
+ """Model representing an active transit service disruption alert on the rail network."""
24
+
25
+ def __init__(self, **kwargs: Unpack[TrainServiceAlertPayload]) -> None:
26
+ self.status: str = str(kwargs.get("Status", "Not Available"))
27
+ self.line: str = kwargs.get("Line", "Not Available")
28
+ self.direction: str = kwargs.get("Direction", "Not Available")
29
+ self.mrt_shuttle_direction: str = kwargs.get(
30
+ "MRTShuttleDirection", "Not Available"
31
+ )
32
+ self.message: str = kwargs.get("Message", "No Message")
33
+
34
+ self.stations: list[str] | str = self._parse_comma_string(
35
+ kwargs.get("Stations")
36
+ )
37
+ self.free_public_bus: list[str] | str = self._parse_comma_string(
38
+ kwargs.get("FreePublicBus")
39
+ )
40
+ self.free_mrt_shuttle: list[str] | str = self._parse_comma_string(
41
+ kwargs.get("FreeMRTShuttle")
42
+ )
43
+
44
+ def _parse_comma_string(self, raw_value: Any) -> list[str] | str:
45
+ """Safely parses comma-separated API string parameters into clean lists."""
46
+ if raw_value is None or str(raw_value).strip() == "":
47
+ return "Not Available"
48
+ return [item.strip() for item in str(raw_value).split(",") if item.strip()]
49
+
50
+
51
+ class TrainServiceAlertManager(BaseModel):
52
+ """Orchestrates API bindings to monitor live rail transit network alert data streams."""
53
+
54
+ def get_alerts(self) -> list[TrainServiceAlert]:
55
+ """Fetches active train service disruption alerts.
56
+
57
+ Returns:
58
+ A list containing active TrainServiceAlert status tracking frameworks.
59
+ """
60
+ response = self.send("TrainServiceAlerts")
61
+ value_list = response.get("value")
62
+
63
+ if not isinstance(value_list, list):
64
+ raise ValueError(
65
+ "LTA DataMall API did not yield a valid data list layout for TrainServiceAlerts."
66
+ )
67
+
68
+ return [TrainServiceAlert(**alert) for alert in value_list]
69
+
70
+ async def async_get_alerts(self) -> list[TrainServiceAlert]:
71
+ """Asynchronously fetches active train service disruption alerts."""
72
+ response = await self.async_send("TrainServiceAlerts", params=None, json=None)
73
+ value_list = response.get("value")
74
+
75
+ if not isinstance(value_list, list):
76
+ raise ValueError(
77
+ "LTA DataMall API did not yield a valid data list layout for TrainServiceAlerts."
78
+ )
79
+
80
+ return [TrainServiceAlert(**alert) for alert in value_list]