aiotransperth 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.
- aiotransperth-0.1.0/.github/workflows/ci.yml +22 -0
- aiotransperth-0.1.0/.github/workflows/release.yml +22 -0
- aiotransperth-0.1.0/.gitignore +9 -0
- aiotransperth-0.1.0/CLAUDE.md +41 -0
- aiotransperth-0.1.0/LICENSE +21 -0
- aiotransperth-0.1.0/PKG-INFO +174 -0
- aiotransperth-0.1.0/README.md +156 -0
- aiotransperth-0.1.0/pyproject.toml +44 -0
- aiotransperth-0.1.0/src/aiotransperth/__init__.py +43 -0
- aiotransperth-0.1.0/src/aiotransperth/auth.py +98 -0
- aiotransperth-0.1.0/src/aiotransperth/client.py +287 -0
- aiotransperth-0.1.0/src/aiotransperth/const.py +29 -0
- aiotransperth-0.1.0/src/aiotransperth/exceptions.py +21 -0
- aiotransperth-0.1.0/src/aiotransperth/models.py +251 -0
- aiotransperth-0.1.0/src/aiotransperth/py.typed +0 -0
- aiotransperth-0.1.0/src/aiotransperth/stations.py +25 -0
- aiotransperth-0.1.0/tests/__init__.py +0 -0
- aiotransperth-0.1.0/tests/fixtures/live_train_times_page.html +18 -0
- aiotransperth-0.1.0/tests/fixtures/options.json +20 -0
- aiotransperth-0.1.0/tests/fixtures/stop_timetable.json +46 -0
- aiotransperth-0.1.0/tests/fixtures/train_status.json +37 -0
- aiotransperth-0.1.0/tests/fixtures/trip.json +39 -0
- aiotransperth-0.1.0/tests/live/__init__.py +0 -0
- aiotransperth-0.1.0/tests/live/test_contract.py +73 -0
- aiotransperth-0.1.0/tests/test_auth.py +73 -0
- aiotransperth-0.1.0/tests/test_bus_parsing.py +36 -0
- aiotransperth-0.1.0/tests/test_client_bus.py +70 -0
- aiotransperth-0.1.0/tests/test_client_core.py +86 -0
- aiotransperth-0.1.0/tests/test_client_route.py +96 -0
- aiotransperth-0.1.0/tests/test_client_train.py +92 -0
- aiotransperth-0.1.0/tests/test_exceptions.py +13 -0
- aiotransperth-0.1.0/tests/test_models.py +53 -0
- aiotransperth-0.1.0/tests/test_public_api.py +24 -0
- aiotransperth-0.1.0/tests/test_stations.py +29 -0
- aiotransperth-0.1.0/tests/test_train_parsing.py +32 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
strategy:
|
|
12
|
+
matrix:
|
|
13
|
+
python-version: ["3.12", "3.13"]
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
- uses: actions/setup-python@v5
|
|
17
|
+
with:
|
|
18
|
+
python-version: ${{ matrix.python-version }}
|
|
19
|
+
- run: pip install -e ".[dev]"
|
|
20
|
+
- run: ruff check src tests
|
|
21
|
+
- run: mypy src
|
|
22
|
+
- run: pytest -v # live tests are deselected via addopts
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
# One-time setup before this works: on pypi.org, add a "trusted publisher"
|
|
4
|
+
# for this repo (workflow: release.yml, environment: pypi), and create a
|
|
5
|
+
# GitHub environment named "pypi" in the repo settings. No API token needed.
|
|
6
|
+
on:
|
|
7
|
+
release:
|
|
8
|
+
types: [published]
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
publish:
|
|
12
|
+
runs-on: ubuntu-latest
|
|
13
|
+
environment: pypi
|
|
14
|
+
permissions:
|
|
15
|
+
id-token: write # PyPI trusted publishing
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@v4
|
|
18
|
+
- uses: actions/setup-python@v5
|
|
19
|
+
with:
|
|
20
|
+
python-version: "3.13"
|
|
21
|
+
- run: pip install build && python -m build
|
|
22
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# CLAUDE.md
|
|
2
|
+
|
|
3
|
+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
4
|
+
|
|
5
|
+
## What this is
|
|
6
|
+
|
|
7
|
+
`aiotransperth` — async Python client (aiohttp, Python 3.12+) for Transperth's **unofficial** website APIs, the only channel carrying Perth realtime bus/train data. Because the API is unofficial, response shapes are pinned by live contract tests rather than a spec.
|
|
8
|
+
|
|
9
|
+
## Commands
|
|
10
|
+
|
|
11
|
+
A `.venv` exists at the repo root (Python 3.14). Dev setup: `pip install -e ".[dev]"`.
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pytest # offline tests only (fixtures via aioresponses; live tests deselected via addopts)
|
|
15
|
+
pytest tests/test_client_bus.py # one file
|
|
16
|
+
pytest tests/test_models.py -k delay # one test by keyword
|
|
17
|
+
pytest -m live # contract tests against the REAL API — run sparingly, never in loops
|
|
18
|
+
ruff check src tests
|
|
19
|
+
mypy src # strict mode
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
**Live-test caution:** Transperth's HTTP 429 cooldown is sticky (>60 s) and shared with their public website. Don't re-run `-m live` in tight iteration; CI never runs it.
|
|
23
|
+
|
|
24
|
+
**Dev dependency pin:** `aiohttp<3.14` is constrained in the `dev` extra only, because aioresponses 0.7.9 can't mock aiohttp ≥3.14. Runtime stays `aiohttp>=3.9`. Don't "fix" this by bumping either side without checking aioresponses compatibility.
|
|
25
|
+
|
|
26
|
+
## Architecture
|
|
27
|
+
|
|
28
|
+
Single package `src/aiotransperth/`, all public API re-exported from `__init__.py` (keep `__all__` in sync — `tests/test_public_api.py` checks it).
|
|
29
|
+
|
|
30
|
+
Two distinct API families, both behind one `TransperthClient` (`client.py`):
|
|
31
|
+
|
|
32
|
+
- **Bus (SilverRail endpoints)** — form-POSTs requiring a CSRF token. `auth.py` owns this: tokens are scoped per `AuthContext` (ROUTE vs STOP — different ModuleId/TabId headers and issuing pages; using the wrong one gets HTTP 401). `TokenManager` caches one token per context, scraped by regex from the issuing HTML page. `_bus_post` retries exactly once on 401 after invalidating the token — that is the client's *only* retry; it never sleeps, and back-off policy deliberately belongs to the caller.
|
|
33
|
+
- **Train** — plain GETs with static headers (`const.py`), no token. Line/station catalogs have no JSON endpoint; `stations.py` regex-parses the server-rendered dropdowns on the Live Train Times page.
|
|
34
|
+
|
|
35
|
+
`models.py` holds frozen slotted dataclasses plus the time-parsing rules. All API times are naive Perth-local strings; **every datetime this library emits must be timezone-aware `Australia/Perth`** (`PERTH_TZ`). `parse_clock_near` anchors bare clock strings ("1:48pm") to a reference datetime and rolls forward a day for just-past-midnight displays; it returns `None` for unparseable text because the API puts status strings in time fields. Parsing is lenient by design: malformed trip entries are skipped, missing fields default to `""`/`None`.
|
|
36
|
+
|
|
37
|
+
All errors subclass `TransperthError` (`exceptions.py`): `RateLimitError` (429), `AuthError` (token issues, bus only), `NetworkError` (wraps `aiohttp.ClientError`), `InvalidStopError` (bad stop code / station name — trains signal this via "check spelling" text in the response, not a status code).
|
|
38
|
+
|
|
39
|
+
## Tests
|
|
40
|
+
|
|
41
|
+
Offline tests mock HTTP with `aioresponses` against captured payloads in `tests/fixtures/`. If the real API changes shape, update the fixture *and* verify with `pytest -m live` (`tests/live/test_contract.py`, fixed inputs: bus 414, stop 12627, Maylands Stn). pytest-asyncio runs in `asyncio_mode = "auto"` — no `@pytest.mark.asyncio` needed.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Chris
|
|
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,174 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: aiotransperth
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Async Python client for Transperth (Perth, WA) bus and train departures, with realtime data
|
|
5
|
+
Author: Chris
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Python: >=3.12
|
|
9
|
+
Requires-Dist: aiohttp>=3.9
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: aiohttp<3.14; extra == 'dev'
|
|
12
|
+
Requires-Dist: aioresponses>=0.7.6; extra == 'dev'
|
|
13
|
+
Requires-Dist: mypy>=1.11; extra == 'dev'
|
|
14
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
|
|
15
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
16
|
+
Requires-Dist: ruff>=0.7; extra == 'dev'
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# aiotransperth
|
|
20
|
+
|
|
21
|
+
Live Perth bus and train departures in Python — including realtime delays.
|
|
22
|
+
|
|
23
|
+
Transperth (Perth, Western Australia's public transport operator) publishes
|
|
24
|
+
no official realtime API. Live delay data exists in exactly one place: the
|
|
25
|
+
Transperth website itself. This library talks to the same internal endpoints
|
|
26
|
+
the website uses and hands you the results as plain, typed Python objects —
|
|
27
|
+
the only way to get Perth realtime data into your own code.
|
|
28
|
+
|
|
29
|
+
- **Buses** — upcoming departures at any stop, live delays for buses already
|
|
30
|
+
on the road, and stop-by-stop trip details.
|
|
31
|
+
- **Trains** — live departures for any station: destination, platform, delay
|
|
32
|
+
status, car count.
|
|
33
|
+
|
|
34
|
+
Async (`aiohttp` is the only dependency), fully typed, Python 3.12+, MIT.
|
|
35
|
+
|
|
36
|
+
## Getting started
|
|
37
|
+
|
|
38
|
+
Not on PyPI yet — install from a checkout:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
git clone https://github.com/Chris112/aiotransperth
|
|
42
|
+
cd aiotransperth
|
|
43
|
+
pip install .
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Then:
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
import asyncio
|
|
50
|
+
|
|
51
|
+
from aiotransperth import TransperthClient
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
async def main() -> None:
|
|
55
|
+
async with TransperthClient() as client:
|
|
56
|
+
# ---- Buses: next departures at stop 12627 ----
|
|
57
|
+
timetable = await client.get_stop_timetable("12627")
|
|
58
|
+
print(f"Stop {timetable.stop.code}: {timetable.stop.name}")
|
|
59
|
+
for dep in timetable.departures[:3]:
|
|
60
|
+
when = dep.estimated or dep.scheduled # live estimate if available
|
|
61
|
+
live = f"live, {dep.live.description}" if dep.live.is_live else "scheduled"
|
|
62
|
+
print(f" {dep.route:>4} to {dep.headsign:<20} {when:%H:%M} ({live})")
|
|
63
|
+
|
|
64
|
+
# ---- Trains: live status at Maylands on the Midland line ----
|
|
65
|
+
trains = await client.get_train_departures("Midland Line", "Maylands Stn")
|
|
66
|
+
print("Maylands Stn (Midland Line):")
|
|
67
|
+
for train in trains[:3]:
|
|
68
|
+
when = train.estimated or train.scheduled
|
|
69
|
+
print(
|
|
70
|
+
f" to {train.destination:<8} plat {train.platform} "
|
|
71
|
+
f"{when:%H:%M} ({train.live.description}, {train.cars} cars)"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
asyncio.run(main())
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Real output (Monday afternoon, Perth):
|
|
79
|
+
|
|
80
|
+
```
|
|
81
|
+
Stop 12627: Main St After Royal St
|
|
82
|
+
404 to Tuart Hill 15:20 (scheduled)
|
|
83
|
+
402 to Perth Busport 15:22 (scheduled)
|
|
84
|
+
414 to Glendalough Stn 15:33 (scheduled)
|
|
85
|
+
Maylands Stn (Midland Line):
|
|
86
|
+
to Midland plat 2 14:50 (On Time, 4 cars)
|
|
87
|
+
to Perth plat 1 14:57 (On Time, 4 cars)
|
|
88
|
+
to Midland plat 2 15:01 (On Time, 4 cars)
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
When a bus is live-tracked (typically the imminent departure), `dep.live.is_live`
|
|
92
|
+
is true, `dep.estimated` carries the actual expected time, and
|
|
93
|
+
`dep.delay_minutes` the difference — e.g. `3` for a bus running three minutes
|
|
94
|
+
late. Trains are always live-tracked while services run.
|
|
95
|
+
|
|
96
|
+
## Finding your inputs
|
|
97
|
+
|
|
98
|
+
- **Bus stop code** — the 5-digit number printed on the physical stop sign,
|
|
99
|
+
also shown when you click a stop in the
|
|
100
|
+
[Transperth journey planner](https://www.transperth.wa.gov.au). Verify one
|
|
101
|
+
with `await client.validate_stop("12627")`, which returns the stop's name or
|
|
102
|
+
raises `InvalidStopError`.
|
|
103
|
+
- **Train line and station names** — display names exactly as the Transperth
|
|
104
|
+
site spells them: lines like `"Midland Line"`, stations like `"Maylands Stn"`.
|
|
105
|
+
Don't guess; fetch the catalog:
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
lines = await client.get_train_lines() # ("Airport Line", ..., "Yanchep Line")
|
|
109
|
+
stations = await client.get_train_stations() # (TrainStation(id="130", name="Maylands Stn"), ...)
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## API
|
|
113
|
+
|
|
114
|
+
All methods are coroutines on `TransperthClient`. Construct it bare (it
|
|
115
|
+
manages its own `aiohttp` session as an async context manager) or pass your
|
|
116
|
+
own session, which will not be closed for you.
|
|
117
|
+
|
|
118
|
+
| Method | Returns | Notes |
|
|
119
|
+
|---|---|---|
|
|
120
|
+
| `get_stop_timetable(stop_code, *, when=None, max_trips=100)` | `StopTimetable` (stop + `BusDeparture` tuple) | Realtime always requested. `when` defaults to now (Perth). |
|
|
121
|
+
| `validate_stop(stop_code)` | `Stop` | Raises `InvalidStopError` for unknown codes. |
|
|
122
|
+
| `get_route_trips(route, *, when=None, max_options=4)` | `tuple[RouteTrip, ...]` | Upcoming trips for a route, e.g. `"414"`. |
|
|
123
|
+
| `get_trip_stops(trip)` | `tuple[TripStop, ...]` | Every stop on a trip, with times, boarding flags, GPS. |
|
|
124
|
+
| `get_train_departures(line, station)` | `tuple[TrainDeparture, ...]` | Live status; raises `InvalidStopError` for unknown stations. |
|
|
125
|
+
| `get_train_lines()` | `tuple[str, ...]` | The 8 line names. Fetched once, cached per client. |
|
|
126
|
+
| `get_train_stations()` | `tuple[TrainStation, ...]` | All ~80 stations. Fetched once, cached per client. |
|
|
127
|
+
|
|
128
|
+
Departure models share a core: `scheduled` and `estimated` (aware datetimes;
|
|
129
|
+
`estimated` is `None` when not live), `live` (a `LiveStatus` with `is_live`,
|
|
130
|
+
raw `status_code`, and a human `description`), and a `delay_minutes` property.
|
|
131
|
+
Buses add `route`, `headsign`, `origin`, `destination`, `trip_uid`; trains add
|
|
132
|
+
`line`, `destination`, `platform`, `cars`, `pattern`, `trip_id`.
|
|
133
|
+
|
|
134
|
+
## Errors
|
|
135
|
+
|
|
136
|
+
Everything raises a subclass of `TransperthError`:
|
|
137
|
+
|
|
138
|
+
| Exception | Meaning | What to do |
|
|
139
|
+
|---|---|---|
|
|
140
|
+
| `RateLimitError` | HTTP 429 — Transperth's cooldown is sticky (>60 s) and shared with their public website | Back off for minutes, not seconds |
|
|
141
|
+
| `AuthError` | A CSRF token couldn't be obtained or was rejected (bus endpoints only) | Usually transient; the client already retried once |
|
|
142
|
+
| `NetworkError` | DNS/timeout/connection failure | Retry with your own policy |
|
|
143
|
+
| `InvalidStopError` | Unknown stop code or station name | Fix the input |
|
|
144
|
+
|
|
145
|
+
The client itself never sleeps and never retries beyond one automatic token
|
|
146
|
+
refresh on HTTP 401 — back-off policy belongs to the caller.
|
|
147
|
+
|
|
148
|
+
## Data caveats
|
|
149
|
+
|
|
150
|
+
- This is an **unofficial** API; Transperth can change or break it without
|
|
151
|
+
notice. A `-m live` contract test suite pins the shapes this library reads.
|
|
152
|
+
- Individual departures the API garbles are skipped, not raised — a response
|
|
153
|
+
with one malformed entry still returns the rest.
|
|
154
|
+
- Bus realtime only lights up for vehicles already on the road — usually the
|
|
155
|
+
next imminent departure. Everything else is scheduled times.
|
|
156
|
+
- The bus timetable endpoint self-caps its response window (~2 hours of
|
|
157
|
+
departures) regardless of `max_trips`.
|
|
158
|
+
- Be respectful: one or two requests per query, no tight polling loops.
|
|
159
|
+
|
|
160
|
+
## Development
|
|
161
|
+
|
|
162
|
+
```bash
|
|
163
|
+
pip install -e ".[dev]"
|
|
164
|
+
pytest # offline tests only (fixtures, no network)
|
|
165
|
+
pytest -m live # contract tests against the real API — run sparingly
|
|
166
|
+
ruff check src tests && mypy src
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
The package version lives in `src/aiotransperth/__init__.py` (`__version__`);
|
|
170
|
+
`pyproject.toml` reads it from there, so bump it in one place only.
|
|
171
|
+
|
|
172
|
+
## License
|
|
173
|
+
|
|
174
|
+
MIT
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# aiotransperth
|
|
2
|
+
|
|
3
|
+
Live Perth bus and train departures in Python — including realtime delays.
|
|
4
|
+
|
|
5
|
+
Transperth (Perth, Western Australia's public transport operator) publishes
|
|
6
|
+
no official realtime API. Live delay data exists in exactly one place: the
|
|
7
|
+
Transperth website itself. This library talks to the same internal endpoints
|
|
8
|
+
the website uses and hands you the results as plain, typed Python objects —
|
|
9
|
+
the only way to get Perth realtime data into your own code.
|
|
10
|
+
|
|
11
|
+
- **Buses** — upcoming departures at any stop, live delays for buses already
|
|
12
|
+
on the road, and stop-by-stop trip details.
|
|
13
|
+
- **Trains** — live departures for any station: destination, platform, delay
|
|
14
|
+
status, car count.
|
|
15
|
+
|
|
16
|
+
Async (`aiohttp` is the only dependency), fully typed, Python 3.12+, MIT.
|
|
17
|
+
|
|
18
|
+
## Getting started
|
|
19
|
+
|
|
20
|
+
Not on PyPI yet — install from a checkout:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
git clone https://github.com/Chris112/aiotransperth
|
|
24
|
+
cd aiotransperth
|
|
25
|
+
pip install .
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Then:
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
import asyncio
|
|
32
|
+
|
|
33
|
+
from aiotransperth import TransperthClient
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
async def main() -> None:
|
|
37
|
+
async with TransperthClient() as client:
|
|
38
|
+
# ---- Buses: next departures at stop 12627 ----
|
|
39
|
+
timetable = await client.get_stop_timetable("12627")
|
|
40
|
+
print(f"Stop {timetable.stop.code}: {timetable.stop.name}")
|
|
41
|
+
for dep in timetable.departures[:3]:
|
|
42
|
+
when = dep.estimated or dep.scheduled # live estimate if available
|
|
43
|
+
live = f"live, {dep.live.description}" if dep.live.is_live else "scheduled"
|
|
44
|
+
print(f" {dep.route:>4} to {dep.headsign:<20} {when:%H:%M} ({live})")
|
|
45
|
+
|
|
46
|
+
# ---- Trains: live status at Maylands on the Midland line ----
|
|
47
|
+
trains = await client.get_train_departures("Midland Line", "Maylands Stn")
|
|
48
|
+
print("Maylands Stn (Midland Line):")
|
|
49
|
+
for train in trains[:3]:
|
|
50
|
+
when = train.estimated or train.scheduled
|
|
51
|
+
print(
|
|
52
|
+
f" to {train.destination:<8} plat {train.platform} "
|
|
53
|
+
f"{when:%H:%M} ({train.live.description}, {train.cars} cars)"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
asyncio.run(main())
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Real output (Monday afternoon, Perth):
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
Stop 12627: Main St After Royal St
|
|
64
|
+
404 to Tuart Hill 15:20 (scheduled)
|
|
65
|
+
402 to Perth Busport 15:22 (scheduled)
|
|
66
|
+
414 to Glendalough Stn 15:33 (scheduled)
|
|
67
|
+
Maylands Stn (Midland Line):
|
|
68
|
+
to Midland plat 2 14:50 (On Time, 4 cars)
|
|
69
|
+
to Perth plat 1 14:57 (On Time, 4 cars)
|
|
70
|
+
to Midland plat 2 15:01 (On Time, 4 cars)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
When a bus is live-tracked (typically the imminent departure), `dep.live.is_live`
|
|
74
|
+
is true, `dep.estimated` carries the actual expected time, and
|
|
75
|
+
`dep.delay_minutes` the difference — e.g. `3` for a bus running three minutes
|
|
76
|
+
late. Trains are always live-tracked while services run.
|
|
77
|
+
|
|
78
|
+
## Finding your inputs
|
|
79
|
+
|
|
80
|
+
- **Bus stop code** — the 5-digit number printed on the physical stop sign,
|
|
81
|
+
also shown when you click a stop in the
|
|
82
|
+
[Transperth journey planner](https://www.transperth.wa.gov.au). Verify one
|
|
83
|
+
with `await client.validate_stop("12627")`, which returns the stop's name or
|
|
84
|
+
raises `InvalidStopError`.
|
|
85
|
+
- **Train line and station names** — display names exactly as the Transperth
|
|
86
|
+
site spells them: lines like `"Midland Line"`, stations like `"Maylands Stn"`.
|
|
87
|
+
Don't guess; fetch the catalog:
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
lines = await client.get_train_lines() # ("Airport Line", ..., "Yanchep Line")
|
|
91
|
+
stations = await client.get_train_stations() # (TrainStation(id="130", name="Maylands Stn"), ...)
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## API
|
|
95
|
+
|
|
96
|
+
All methods are coroutines on `TransperthClient`. Construct it bare (it
|
|
97
|
+
manages its own `aiohttp` session as an async context manager) or pass your
|
|
98
|
+
own session, which will not be closed for you.
|
|
99
|
+
|
|
100
|
+
| Method | Returns | Notes |
|
|
101
|
+
|---|---|---|
|
|
102
|
+
| `get_stop_timetable(stop_code, *, when=None, max_trips=100)` | `StopTimetable` (stop + `BusDeparture` tuple) | Realtime always requested. `when` defaults to now (Perth). |
|
|
103
|
+
| `validate_stop(stop_code)` | `Stop` | Raises `InvalidStopError` for unknown codes. |
|
|
104
|
+
| `get_route_trips(route, *, when=None, max_options=4)` | `tuple[RouteTrip, ...]` | Upcoming trips for a route, e.g. `"414"`. |
|
|
105
|
+
| `get_trip_stops(trip)` | `tuple[TripStop, ...]` | Every stop on a trip, with times, boarding flags, GPS. |
|
|
106
|
+
| `get_train_departures(line, station)` | `tuple[TrainDeparture, ...]` | Live status; raises `InvalidStopError` for unknown stations. |
|
|
107
|
+
| `get_train_lines()` | `tuple[str, ...]` | The 8 line names. Fetched once, cached per client. |
|
|
108
|
+
| `get_train_stations()` | `tuple[TrainStation, ...]` | All ~80 stations. Fetched once, cached per client. |
|
|
109
|
+
|
|
110
|
+
Departure models share a core: `scheduled` and `estimated` (aware datetimes;
|
|
111
|
+
`estimated` is `None` when not live), `live` (a `LiveStatus` with `is_live`,
|
|
112
|
+
raw `status_code`, and a human `description`), and a `delay_minutes` property.
|
|
113
|
+
Buses add `route`, `headsign`, `origin`, `destination`, `trip_uid`; trains add
|
|
114
|
+
`line`, `destination`, `platform`, `cars`, `pattern`, `trip_id`.
|
|
115
|
+
|
|
116
|
+
## Errors
|
|
117
|
+
|
|
118
|
+
Everything raises a subclass of `TransperthError`:
|
|
119
|
+
|
|
120
|
+
| Exception | Meaning | What to do |
|
|
121
|
+
|---|---|---|
|
|
122
|
+
| `RateLimitError` | HTTP 429 — Transperth's cooldown is sticky (>60 s) and shared with their public website | Back off for minutes, not seconds |
|
|
123
|
+
| `AuthError` | A CSRF token couldn't be obtained or was rejected (bus endpoints only) | Usually transient; the client already retried once |
|
|
124
|
+
| `NetworkError` | DNS/timeout/connection failure | Retry with your own policy |
|
|
125
|
+
| `InvalidStopError` | Unknown stop code or station name | Fix the input |
|
|
126
|
+
|
|
127
|
+
The client itself never sleeps and never retries beyond one automatic token
|
|
128
|
+
refresh on HTTP 401 — back-off policy belongs to the caller.
|
|
129
|
+
|
|
130
|
+
## Data caveats
|
|
131
|
+
|
|
132
|
+
- This is an **unofficial** API; Transperth can change or break it without
|
|
133
|
+
notice. A `-m live` contract test suite pins the shapes this library reads.
|
|
134
|
+
- Individual departures the API garbles are skipped, not raised — a response
|
|
135
|
+
with one malformed entry still returns the rest.
|
|
136
|
+
- Bus realtime only lights up for vehicles already on the road — usually the
|
|
137
|
+
next imminent departure. Everything else is scheduled times.
|
|
138
|
+
- The bus timetable endpoint self-caps its response window (~2 hours of
|
|
139
|
+
departures) regardless of `max_trips`.
|
|
140
|
+
- Be respectful: one or two requests per query, no tight polling loops.
|
|
141
|
+
|
|
142
|
+
## Development
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
pip install -e ".[dev]"
|
|
146
|
+
pytest # offline tests only (fixtures, no network)
|
|
147
|
+
pytest -m live # contract tests against the real API — run sparingly
|
|
148
|
+
ruff check src tests && mypy src
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
The package version lives in `src/aiotransperth/__init__.py` (`__version__`);
|
|
152
|
+
`pyproject.toml` reads it from there, so bump it in one place only.
|
|
153
|
+
|
|
154
|
+
## License
|
|
155
|
+
|
|
156
|
+
MIT
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "aiotransperth"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Async Python client for Transperth (Perth, WA) bus and train departures, with realtime data"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.12"
|
|
12
|
+
authors = [{ name = "Chris" }]
|
|
13
|
+
dependencies = ["aiohttp>=3.9"]
|
|
14
|
+
|
|
15
|
+
[project.optional-dependencies]
|
|
16
|
+
dev = [
|
|
17
|
+
"pytest>=8",
|
|
18
|
+
"pytest-asyncio>=0.24",
|
|
19
|
+
"aioresponses>=0.7.6",
|
|
20
|
+
# aioresponses 0.7.9 can't mock aiohttp >=3.14 (ClientResponse signature
|
|
21
|
+
# change); constrain the test env only — runtime stays aiohttp>=3.9.
|
|
22
|
+
"aiohttp<3.14",
|
|
23
|
+
"ruff>=0.7",
|
|
24
|
+
"mypy>=1.11",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[tool.hatch.version]
|
|
28
|
+
# Single source of truth: __version__ in the package.
|
|
29
|
+
path = "src/aiotransperth/__init__.py"
|
|
30
|
+
|
|
31
|
+
[tool.pytest.ini_options]
|
|
32
|
+
testpaths = ["tests"]
|
|
33
|
+
asyncio_mode = "auto"
|
|
34
|
+
markers = ["live: hits the real Transperth API (deselected by default)"]
|
|
35
|
+
addopts = "-m 'not live'"
|
|
36
|
+
|
|
37
|
+
[tool.ruff]
|
|
38
|
+
target-version = "py312"
|
|
39
|
+
|
|
40
|
+
[tool.ruff.lint]
|
|
41
|
+
select = ["E", "F", "I", "B", "UP", "SIM", "RUF"]
|
|
42
|
+
|
|
43
|
+
[tool.mypy]
|
|
44
|
+
strict = true
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Async client for Transperth (Perth, WA) bus and train departures."""
|
|
2
|
+
|
|
3
|
+
from .client import TransperthClient
|
|
4
|
+
from .exceptions import (
|
|
5
|
+
AuthError,
|
|
6
|
+
InvalidStopError,
|
|
7
|
+
NetworkError,
|
|
8
|
+
RateLimitError,
|
|
9
|
+
TransperthError,
|
|
10
|
+
)
|
|
11
|
+
from .models import (
|
|
12
|
+
PERTH_TZ,
|
|
13
|
+
BusDeparture,
|
|
14
|
+
LiveStatus,
|
|
15
|
+
Mode,
|
|
16
|
+
RouteTrip,
|
|
17
|
+
Stop,
|
|
18
|
+
StopTimetable,
|
|
19
|
+
TrainDeparture,
|
|
20
|
+
TrainStation,
|
|
21
|
+
TripStop,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
__version__ = "0.1.0"
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"PERTH_TZ",
|
|
28
|
+
"AuthError",
|
|
29
|
+
"BusDeparture",
|
|
30
|
+
"InvalidStopError",
|
|
31
|
+
"LiveStatus",
|
|
32
|
+
"Mode",
|
|
33
|
+
"NetworkError",
|
|
34
|
+
"RateLimitError",
|
|
35
|
+
"RouteTrip",
|
|
36
|
+
"Stop",
|
|
37
|
+
"StopTimetable",
|
|
38
|
+
"TrainDeparture",
|
|
39
|
+
"TrainStation",
|
|
40
|
+
"TransperthClient",
|
|
41
|
+
"TransperthError",
|
|
42
|
+
"TripStop",
|
|
43
|
+
]
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""CSRF token management for the bus (SilverRail) endpoints.
|
|
2
|
+
|
|
3
|
+
Tokens are scoped to the page that issued them: route pages and stop pages
|
|
4
|
+
issue different tokens, sent with different ModuleId/TabId headers. Using
|
|
5
|
+
the wrong one returns HTTP 401. Train endpoints need no token.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from enum import Enum
|
|
13
|
+
|
|
14
|
+
import aiohttp
|
|
15
|
+
|
|
16
|
+
from .const import BASE_URL, USER_AGENT
|
|
17
|
+
from .exceptions import AuthError, NetworkError
|
|
18
|
+
|
|
19
|
+
_TOKEN_RE = re.compile(
|
|
20
|
+
r'<input\s+name="__RequestVerificationToken"\s+type="hidden"\s+value="([^"]+)"'
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AuthContext(Enum):
|
|
25
|
+
ROUTE = "route"
|
|
26
|
+
STOP = "stop"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class _ContextSpec:
|
|
31
|
+
module_id: str
|
|
32
|
+
tab_id: str
|
|
33
|
+
page_url: str # str.format template taking ref=
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
_SPECS: dict[AuthContext, _ContextSpec] = {
|
|
37
|
+
AuthContext.ROUTE: _ContextSpec(
|
|
38
|
+
"5345", "133", BASE_URL + "/timetables/details?Bus={ref}"
|
|
39
|
+
),
|
|
40
|
+
AuthContext.STOP: _ContextSpec(
|
|
41
|
+
"5310",
|
|
42
|
+
"141",
|
|
43
|
+
BASE_URL + "/Journey-Planner/Stops-Near-You?locationtype=stop&location={ref}",
|
|
44
|
+
),
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def bus_headers(context: AuthContext, token: str, ref: str) -> dict[str, str]:
|
|
49
|
+
"""Request headers for a SilverRail API call in the given auth context."""
|
|
50
|
+
spec = _SPECS[context]
|
|
51
|
+
headers = {
|
|
52
|
+
"RequestVerificationToken": token,
|
|
53
|
+
"ModuleId": spec.module_id,
|
|
54
|
+
"TabId": spec.tab_id,
|
|
55
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
56
|
+
"User-Agent": USER_AGENT,
|
|
57
|
+
"Accept": "application/json, text/javascript, */*; q=0.01",
|
|
58
|
+
"X-Requested-With": "XMLHttpRequest",
|
|
59
|
+
}
|
|
60
|
+
if context is AuthContext.STOP:
|
|
61
|
+
headers["Referer"] = spec.page_url.format(ref=ref)
|
|
62
|
+
headers["Origin"] = BASE_URL
|
|
63
|
+
return headers
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class TokenManager:
|
|
67
|
+
"""Caches one token per auth context; refetches after invalidate()."""
|
|
68
|
+
|
|
69
|
+
def __init__(self) -> None:
|
|
70
|
+
self._tokens: dict[AuthContext, str] = {}
|
|
71
|
+
|
|
72
|
+
async def get_token(
|
|
73
|
+
self,
|
|
74
|
+
session: aiohttp.ClientSession,
|
|
75
|
+
context: AuthContext,
|
|
76
|
+
ref: str,
|
|
77
|
+
timeout: aiohttp.ClientTimeout,
|
|
78
|
+
) -> str:
|
|
79
|
+
if context in self._tokens:
|
|
80
|
+
return self._tokens[context]
|
|
81
|
+
url = _SPECS[context].page_url.format(ref=ref)
|
|
82
|
+
try:
|
|
83
|
+
async with session.get(
|
|
84
|
+
url, headers={"User-Agent": USER_AGENT}, timeout=timeout
|
|
85
|
+
) as resp:
|
|
86
|
+
if resp.status != 200:
|
|
87
|
+
raise AuthError(f"Token page returned HTTP {resp.status}")
|
|
88
|
+
html = await resp.text()
|
|
89
|
+
except aiohttp.ClientError as err:
|
|
90
|
+
raise NetworkError(f"Could not reach Transperth: {err}") from err
|
|
91
|
+
match = _TOKEN_RE.search(html)
|
|
92
|
+
if not match:
|
|
93
|
+
raise AuthError(f"No verification token found on {url}")
|
|
94
|
+
self._tokens[context] = match.group(1)
|
|
95
|
+
return self._tokens[context]
|
|
96
|
+
|
|
97
|
+
def invalidate(self, context: AuthContext) -> None:
|
|
98
|
+
self._tokens.pop(context, None)
|