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.
Files changed (35) hide show
  1. aiotransperth-0.1.0/.github/workflows/ci.yml +22 -0
  2. aiotransperth-0.1.0/.github/workflows/release.yml +22 -0
  3. aiotransperth-0.1.0/.gitignore +9 -0
  4. aiotransperth-0.1.0/CLAUDE.md +41 -0
  5. aiotransperth-0.1.0/LICENSE +21 -0
  6. aiotransperth-0.1.0/PKG-INFO +174 -0
  7. aiotransperth-0.1.0/README.md +156 -0
  8. aiotransperth-0.1.0/pyproject.toml +44 -0
  9. aiotransperth-0.1.0/src/aiotransperth/__init__.py +43 -0
  10. aiotransperth-0.1.0/src/aiotransperth/auth.py +98 -0
  11. aiotransperth-0.1.0/src/aiotransperth/client.py +287 -0
  12. aiotransperth-0.1.0/src/aiotransperth/const.py +29 -0
  13. aiotransperth-0.1.0/src/aiotransperth/exceptions.py +21 -0
  14. aiotransperth-0.1.0/src/aiotransperth/models.py +251 -0
  15. aiotransperth-0.1.0/src/aiotransperth/py.typed +0 -0
  16. aiotransperth-0.1.0/src/aiotransperth/stations.py +25 -0
  17. aiotransperth-0.1.0/tests/__init__.py +0 -0
  18. aiotransperth-0.1.0/tests/fixtures/live_train_times_page.html +18 -0
  19. aiotransperth-0.1.0/tests/fixtures/options.json +20 -0
  20. aiotransperth-0.1.0/tests/fixtures/stop_timetable.json +46 -0
  21. aiotransperth-0.1.0/tests/fixtures/train_status.json +37 -0
  22. aiotransperth-0.1.0/tests/fixtures/trip.json +39 -0
  23. aiotransperth-0.1.0/tests/live/__init__.py +0 -0
  24. aiotransperth-0.1.0/tests/live/test_contract.py +73 -0
  25. aiotransperth-0.1.0/tests/test_auth.py +73 -0
  26. aiotransperth-0.1.0/tests/test_bus_parsing.py +36 -0
  27. aiotransperth-0.1.0/tests/test_client_bus.py +70 -0
  28. aiotransperth-0.1.0/tests/test_client_core.py +86 -0
  29. aiotransperth-0.1.0/tests/test_client_route.py +96 -0
  30. aiotransperth-0.1.0/tests/test_client_train.py +92 -0
  31. aiotransperth-0.1.0/tests/test_exceptions.py +13 -0
  32. aiotransperth-0.1.0/tests/test_models.py +53 -0
  33. aiotransperth-0.1.0/tests/test_public_api.py +24 -0
  34. aiotransperth-0.1.0/tests/test_stations.py +29 -0
  35. 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,9 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv
4
+ dist/
5
+ *.egg-info/
6
+ .pytest_cache/
7
+ .mypy_cache/
8
+ .ruff_cache/
9
+ .claude/settings.local.json
@@ -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)