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.
- ltadatamall_py-0.1.0/LICENSE +21 -0
- ltadatamall_py-0.1.0/PKG-INFO +77 -0
- ltadatamall_py-0.1.0/README.md +43 -0
- ltadatamall_py-0.1.0/pyproject.toml +83 -0
- ltadatamall_py-0.1.0/setup.cfg +4 -0
- ltadatamall_py-0.1.0/src/LTADatamall_py.egg-info/PKG-INFO +77 -0
- ltadatamall_py-0.1.0/src/LTADatamall_py.egg-info/SOURCES.txt +18 -0
- ltadatamall_py-0.1.0/src/LTADatamall_py.egg-info/dependency_links.txt +1 -0
- ltadatamall_py-0.1.0/src/LTADatamall_py.egg-info/requires.txt +13 -0
- ltadatamall_py-0.1.0/src/LTADatamall_py.egg-info/top_level.txt +1 -0
- ltadatamall_py-0.1.0/src/ltadatamall/__init__.py +8 -0
- ltadatamall_py-0.1.0/src/ltadatamall/base.py +64 -0
- ltadatamall_py-0.1.0/src/ltadatamall/bus_arrival.py +121 -0
- ltadatamall_py-0.1.0/src/ltadatamall/bus_manager.py +366 -0
- ltadatamall_py-0.1.0/src/ltadatamall/bus_route.py +71 -0
- ltadatamall_py-0.1.0/src/ltadatamall/bus_service.py +53 -0
- ltadatamall_py-0.1.0/src/ltadatamall/bus_stop.py +31 -0
- ltadatamall_py-0.1.0/src/ltadatamall/passenger_volume.py +83 -0
- ltadatamall_py-0.1.0/src/ltadatamall/taxi.py +62 -0
- ltadatamall_py-0.1.0/src/ltadatamall/train_service_alert.py +80 -0
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ltadatamall
|
|
@@ -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]
|