aiomobilitydatabase 0.1.0__tar.gz → 0.2.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.
- {aiomobilitydatabase-0.1.0 → aiomobilitydatabase-0.2.0}/.gitignore +1 -0
- {aiomobilitydatabase-0.1.0 → aiomobilitydatabase-0.2.0}/.pre-commit-config.yaml +1 -1
- aiomobilitydatabase-0.2.0/PKG-INFO +268 -0
- aiomobilitydatabase-0.2.0/README.md +244 -0
- {aiomobilitydatabase-0.1.0 → aiomobilitydatabase-0.2.0}/pyproject.toml +13 -0
- aiomobilitydatabase-0.2.0/scripts/generate_rt_fixtures.py +160 -0
- {aiomobilitydatabase-0.1.0 → aiomobilitydatabase-0.2.0}/src/aiomobilitydatabase/client.py +30 -9
- {aiomobilitydatabase-0.1.0 → aiomobilitydatabase-0.2.0}/src/aiomobilitydatabase/const.py +1 -1
- {aiomobilitydatabase-0.1.0 → aiomobilitydatabase-0.2.0}/src/aiomobilitydatabase/exceptions.py +6 -1
- aiomobilitydatabase-0.2.0/src/aiomobilitydatabase/feeds/__init__.py +59 -0
- aiomobilitydatabase-0.2.0/src/aiomobilitydatabase/feeds/client.py +153 -0
- aiomobilitydatabase-0.2.0/src/aiomobilitydatabase/feeds/const.py +5 -0
- aiomobilitydatabase-0.2.0/src/aiomobilitydatabase/feeds/exceptions.py +35 -0
- aiomobilitydatabase-0.2.0/src/aiomobilitydatabase/feeds/gbfs.py +209 -0
- aiomobilitydatabase-0.2.0/src/aiomobilitydatabase/feeds/geo.py +61 -0
- aiomobilitydatabase-0.2.0/src/aiomobilitydatabase/feeds/models.py +170 -0
- aiomobilitydatabase-0.2.0/src/aiomobilitydatabase/feeds/rt.py +302 -0
- aiomobilitydatabase-0.2.0/src/aiomobilitydatabase/feeds/static_index.py +661 -0
- aiomobilitydatabase-0.2.0/src/aiomobilitydatabase/feeds/transit.py +482 -0
- {aiomobilitydatabase-0.1.0 → aiomobilitydatabase-0.2.0}/tests/conftest.py +2 -1
- aiomobilitydatabase-0.2.0/tests/feeds/__init__.py +0 -0
- aiomobilitydatabase-0.2.0/tests/feeds/conftest.py +33 -0
- aiomobilitydatabase-0.2.0/tests/feeds/data/catalog/gbfs_feed.json +30 -0
- aiomobilitydatabase-0.2.0/tests/feeds/data/catalog/gtfs_feed.json +14 -0
- aiomobilitydatabase-0.2.0/tests/feeds/data/catalog/gtfs_rt_feed.json +19 -0
- aiomobilitydatabase-0.2.0/tests/feeds/data/catalog/token_response.json +5 -0
- aiomobilitydatabase-0.2.0/tests/feeds/data/gbfs/free_bike_status_23.json +22 -0
- aiomobilitydatabase-0.2.0/tests/feeds/data/gbfs/station_info_23.json +22 -0
- aiomobilitydatabase-0.2.0/tests/feeds/data/gbfs/station_status_23.json +32 -0
- aiomobilitydatabase-0.2.0/tests/feeds/data/gbfs/system_info_23.json +10 -0
- aiomobilitydatabase-0.2.0/tests/feeds/data/gbfs/system_info_30.json +21 -0
- aiomobilitydatabase-0.2.0/tests/feeds/data/gbfs/vehicle_status_30.json +18 -0
- aiomobilitydatabase-0.2.0/tests/feeds/data/gtfs/agency.txt +2 -0
- aiomobilitydatabase-0.2.0/tests/feeds/data/gtfs/calendar.txt +3 -0
- aiomobilitydatabase-0.2.0/tests/feeds/data/gtfs/calendar_dates.txt +4 -0
- aiomobilitydatabase-0.2.0/tests/feeds/data/gtfs/routes.txt +3 -0
- aiomobilitydatabase-0.2.0/tests/feeds/data/gtfs/stop_times.txt +6 -0
- aiomobilitydatabase-0.2.0/tests/feeds/data/gtfs/stops.txt +5 -0
- aiomobilitydatabase-0.2.0/tests/feeds/data/gtfs/trips.txt +5 -0
- aiomobilitydatabase-0.2.0/tests/feeds/data/rt/added_trips_s1.pb +12 -0
- aiomobilitydatabase-0.2.0/tests/feeds/data/rt/alerts.pb +10 -0
- aiomobilitydatabase-0.2.0/tests/feeds/data/rt/trip_updates_baseline.pb +13 -0
- aiomobilitydatabase-0.2.0/tests/feeds/data/rt/trip_updates_t1_delayed.pb +13 -0
- aiomobilitydatabase-0.2.0/tests/feeds/data/rt/vehicle_positions.pb +0 -0
- aiomobilitydatabase-0.2.0/tests/feeds/fixtures.py +96 -0
- aiomobilitydatabase-0.2.0/tests/feeds/test_client.py +139 -0
- aiomobilitydatabase-0.2.0/tests/feeds/test_exceptions.py +31 -0
- aiomobilitydatabase-0.2.0/tests/feeds/test_extra_import_guard.py +38 -0
- aiomobilitydatabase-0.2.0/tests/feeds/test_gbfs_stations.py +198 -0
- aiomobilitydatabase-0.2.0/tests/feeds/test_gbfs_vehicles.py +67 -0
- aiomobilitydatabase-0.2.0/tests/feeds/test_geo.py +38 -0
- aiomobilitydatabase-0.2.0/tests/feeds/test_models.py +135 -0
- aiomobilitydatabase-0.2.0/tests/feeds/test_properties.py +1213 -0
- aiomobilitydatabase-0.2.0/tests/feeds/test_rt.py +205 -0
- aiomobilitydatabase-0.2.0/tests/feeds/test_static_arrivals.py +275 -0
- aiomobilitydatabase-0.2.0/tests/feeds/test_static_build.py +222 -0
- aiomobilitydatabase-0.2.0/tests/feeds/test_station_grouping.py +128 -0
- aiomobilitydatabase-0.2.0/tests/feeds/test_transit_arrivals.py +148 -0
- aiomobilitydatabase-0.2.0/tests/feeds/test_transit_resolution.py +249 -0
- aiomobilitydatabase-0.2.0/tests/feeds/test_transit_vehicles_alerts.py +259 -0
- aiomobilitydatabase-0.2.0/tests/mock_server.py +118 -0
- aiomobilitydatabase-0.2.0/tests/test_catalog_properties.py +211 -0
- {aiomobilitydatabase-0.1.0 → aiomobilitydatabase-0.2.0}/tests/test_client_request.py +1 -0
- {aiomobilitydatabase-0.1.0 → aiomobilitydatabase-0.2.0}/tests/test_client_session.py +1 -0
- {aiomobilitydatabase-0.1.0 → aiomobilitydatabase-0.2.0}/tests/test_client_token.py +1 -0
- {aiomobilitydatabase-0.1.0 → aiomobilitydatabase-0.2.0}/tests/test_endpoints.py +1 -0
- {aiomobilitydatabase-0.1.0 → aiomobilitydatabase-0.2.0}/tests/test_models.py +1 -0
- aiomobilitydatabase-0.1.0/PKG-INFO +0 -111
- aiomobilitydatabase-0.1.0/README.md +0 -89
- aiomobilitydatabase-0.1.0/tests/mock_server.py +0 -146
- {aiomobilitydatabase-0.1.0 → aiomobilitydatabase-0.2.0}/.github/workflows/ci.yml +0 -0
- {aiomobilitydatabase-0.1.0 → aiomobilitydatabase-0.2.0}/.github/workflows/publish.yml +0 -0
- {aiomobilitydatabase-0.1.0 → aiomobilitydatabase-0.2.0}/LICENSE +0 -0
- {aiomobilitydatabase-0.1.0 → aiomobilitydatabase-0.2.0}/src/aiomobilitydatabase/__init__.py +0 -0
- {aiomobilitydatabase-0.1.0 → aiomobilitydatabase-0.2.0}/src/aiomobilitydatabase/models.py +0 -0
- {aiomobilitydatabase-0.1.0 → aiomobilitydatabase-0.2.0}/src/aiomobilitydatabase/py.typed +0 -0
- {aiomobilitydatabase-0.1.0 → aiomobilitydatabase-0.2.0}/tests/__init__.py +0 -0
- {aiomobilitydatabase-0.1.0 → aiomobilitydatabase-0.2.0}/tests/fixtures.py +0 -0
- {aiomobilitydatabase-0.1.0 → aiomobilitydatabase-0.2.0}/tests/test_exceptions.py +0 -0
- {aiomobilitydatabase-0.1.0 → aiomobilitydatabase-0.2.0}/tests/test_params.py +0 -0
- {aiomobilitydatabase-0.1.0 → aiomobilitydatabase-0.2.0}/tests/test_public_api.py +0 -0
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: aiomobilitydatabase
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Async Python client for the Mobility Database catalog API
|
|
5
|
+
Project-URL: Source Code, https://github.com/raman325/aiomobilitydatabase
|
|
6
|
+
Project-URL: Bug Reports, https://github.com/raman325/aiomobilitydatabase/issues
|
|
7
|
+
Author: Raman Gupta
|
|
8
|
+
License-Expression: Apache-2.0
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: asyncio,gbfs,gtfs,gtfs-rt,mobility-database,transit
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
18
|
+
Requires-Python: >=3.12
|
|
19
|
+
Requires-Dist: aiohttp>=3.9
|
|
20
|
+
Requires-Dist: mashumaro>=3.13
|
|
21
|
+
Provides-Extra: feeds
|
|
22
|
+
Requires-Dist: gtfs-realtime-bindings>=1.0.0; extra == 'feeds'
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# aiomobilitydatabase
|
|
26
|
+
|
|
27
|
+
Async Python client for the [Mobility Database](https://mobilitydatabase.org/) — the
|
|
28
|
+
catalog MobilityData stewards of GTFS, GTFS Realtime, and GBFS feeds from transit
|
|
29
|
+
agencies and bike/scooter share systems around the world.
|
|
30
|
+
|
|
31
|
+
The core package wraps the catalog's
|
|
32
|
+
[REST API](https://mobilitydata.github.io/mobility-feed-api/SwaggerUI/index.html)
|
|
33
|
+
with a fully typed asyncio client: search feeds, look up their metadata, find the
|
|
34
|
+
hosted dataset URL. An optional `feeds` layer goes one step further and consumes
|
|
35
|
+
the actual transit/bike-share data those feeds point to — scheduled and live
|
|
36
|
+
arrivals, vehicle positions, service alerts, GBFS stations and vehicles — giving you
|
|
37
|
+
a feed ID in and typed snapshots out.
|
|
38
|
+
|
|
39
|
+
## Installation
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install aiomobilitydatabase
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
That installs the catalog client only. To also consume feed data (schedules,
|
|
46
|
+
GTFS-RT, GBFS), install the `feeds` extra, which pulls in
|
|
47
|
+
[`gtfs-realtime-bindings`](https://pypi.org/project/gtfs-realtime-bindings/) for
|
|
48
|
+
protobuf parsing:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pip install aiomobilitydatabase[feeds]
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Importing `aiomobilitydatabase.feeds` without the extra installed raises an
|
|
55
|
+
`ImportError` telling you to install it — there is no silent partial-functionality
|
|
56
|
+
mode.
|
|
57
|
+
|
|
58
|
+
Requires Python 3.12+.
|
|
59
|
+
|
|
60
|
+
## Authentication
|
|
61
|
+
|
|
62
|
+
Sign up at [mobilitydatabase.org](https://mobilitydatabase.org) and copy your
|
|
63
|
+
**refresh token** from the account page. The client exchanges it for short-lived
|
|
64
|
+
access tokens automatically (including proactive refresh before expiry). Both
|
|
65
|
+
`MobilityDatabaseClient` and `MobilityFeedsClient` take the same refresh token —
|
|
66
|
+
the feeds client wraps a catalog client internally and shares its authentication.
|
|
67
|
+
|
|
68
|
+
## Quick start — catalog
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
import asyncio
|
|
72
|
+
|
|
73
|
+
from aiomobilitydatabase import DataType, MobilityDatabaseClient
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
async def main() -> None:
|
|
77
|
+
async with MobilityDatabaseClient("YOUR_REFRESH_TOKEN") as client:
|
|
78
|
+
# Free-text search across feed name, provider, and location
|
|
79
|
+
results = await client.search_feeds(
|
|
80
|
+
search_query="new york", data_types=[DataType.GTFS_RT], limit=10
|
|
81
|
+
)
|
|
82
|
+
for item in results.results:
|
|
83
|
+
print(item.id, item.provider, item.status)
|
|
84
|
+
|
|
85
|
+
# Full detail for one feed, including the producer URL
|
|
86
|
+
feed = await client.get_gtfs_rt_feed(results.results[0].id)
|
|
87
|
+
assert feed.source_info is not None
|
|
88
|
+
print(feed.source_info.producer_url)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
asyncio.run(main())
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Quick start — feeds (optional extra)
|
|
95
|
+
|
|
96
|
+
`MobilityFeedsClient` resolves a feed ID (GTFS, GTFS-RT, or GBFS) into a handle
|
|
97
|
+
that fetches and caches the actual data. Requires `pip install
|
|
98
|
+
aiomobilitydatabase[feeds]`.
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
import asyncio
|
|
102
|
+
|
|
103
|
+
from aiomobilitydatabase.feeds import Circle, MobilityFeedsClient
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
async def main() -> None:
|
|
107
|
+
async with MobilityFeedsClient(
|
|
108
|
+
"YOUR_REFRESH_TOKEN", cache_dir="/path/to/cache"
|
|
109
|
+
) as client:
|
|
110
|
+
# The full catalog client is available via .catalog:
|
|
111
|
+
results = await client.catalog.search_feeds(search_query="portland", limit=5)
|
|
112
|
+
for item in results.results:
|
|
113
|
+
print(item.id, item.provider, item.data_type)
|
|
114
|
+
|
|
115
|
+
# Transit: accepts a GTFS or GTFS-RT feed ID; resolves the sibling.
|
|
116
|
+
# api_key authenticates with the PRODUCER (distinct from your catalog
|
|
117
|
+
# refresh token); on_progress reports download/index build progress
|
|
118
|
+
# for the first (uncached) fetch of a feed's static dataset.
|
|
119
|
+
transit = await client.get_transit_feed(
|
|
120
|
+
"mdb-100",
|
|
121
|
+
api_key="PRODUCER_API_KEY",
|
|
122
|
+
on_progress=lambda p: print(f"{p.phase}: {p.fraction:.0%}"),
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
# Picker-style helpers: stops in a zone, routes serving a stop.
|
|
126
|
+
# stations_in collapses GTFS station hierarchies (platforms group
|
|
127
|
+
# under their parent station, entrances are dropped) for clean UIs.
|
|
128
|
+
home = Circle(latitude=45.52, longitude=-122.68, radius_m=800)
|
|
129
|
+
stations = transit.stations_in(home)
|
|
130
|
+
nearby_stops = transit.stops_in(home)
|
|
131
|
+
routes = await transit.routes_serving(stations[0].stop_ids[0])
|
|
132
|
+
|
|
133
|
+
# Scheduled arrivals merged with realtime (delays, cancellations,
|
|
134
|
+
# RT-added trips) when the feed publishes GTFS-RT TripUpdates.
|
|
135
|
+
arrivals = await transit.get_arrivals([nearby_stops[0].id], limit=2)
|
|
136
|
+
for arrival in arrivals:
|
|
137
|
+
when = arrival.predicted_departure or arrival.scheduled_departure
|
|
138
|
+
live = "live" if arrival.realtime else "scheduled"
|
|
139
|
+
print(f"{arrival.route_name} -> {arrival.headsign}: {when} ({live})")
|
|
140
|
+
|
|
141
|
+
vehicles = await transit.get_vehicles()
|
|
142
|
+
alerts = await transit.get_alerts()
|
|
143
|
+
|
|
144
|
+
# GBFS bike/scooter share:
|
|
145
|
+
bikes = await client.get_gbfs_feed("gbfs-300")
|
|
146
|
+
stations = await bikes.get_stations(zone=home)
|
|
147
|
+
free_vehicles = await bikes.get_vehicles(zone=home)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
asyncio.run(main())
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
## How it works
|
|
154
|
+
|
|
155
|
+
- **Scheduled arrivals for every feed**: the hosted GTFS zip is indexed into
|
|
156
|
+
SQLite (stops, routes, trips, stop_times, calendars) in a worker thread — the
|
|
157
|
+
event loop is never blocked. Arrivals work with or without realtime coverage;
|
|
158
|
+
GTFS-RT TripUpdates overlay delays, cancellations, and added trips when
|
|
159
|
+
present, with cancellation always winning over stale predictions.
|
|
160
|
+
- **`cache_dir` strongly recommended**: the static index is cached on disk keyed
|
|
161
|
+
by dataset ID, so restarts are instant and rebuilds only happen when the
|
|
162
|
+
agency publishes a new dataset (`await transit.refresh_static()` — call it
|
|
163
|
+
daily; it swaps in the new index only after it's built, so lookups never see
|
|
164
|
+
a half-built database). Without a `cache_dir` the index is built in memory
|
|
165
|
+
on every startup.
|
|
166
|
+
- **Pull, not push**: `TransitFeedHandle`/`GbfsFeedHandle` return snapshots on
|
|
167
|
+
demand — there's no built-in polling loop or scheduler. Bring your own (e.g.
|
|
168
|
+
Home Assistant's `DataUpdateCoordinator`).
|
|
169
|
+
- **`purge_cache()`** deletes a feed's cached static data (or all feeds' when
|
|
170
|
+
called with no argument) — call it on cleanup/removal of a configured feed.
|
|
171
|
+
- **Producer authentication**: pass `api_key=` to `get_transit_feed()`; it is
|
|
172
|
+
applied per the catalog's `authentication_type` (query parameter or header) —
|
|
173
|
+
distinct from the refresh token used to authenticate with the catalog itself.
|
|
174
|
+
- **Zones are circles** (matching Home Assistant zone entities); GBFS vehicle
|
|
175
|
+
filtering happens client-side because GBFS feeds are full-system dumps by
|
|
176
|
+
design, with no server-side geo-query.
|
|
177
|
+
- **Timestamps are tz-aware UTC** throughout the feeds layer.
|
|
178
|
+
|
|
179
|
+
## Error handling
|
|
180
|
+
|
|
181
|
+
All errors derive from `MobilityDatabaseError`. The catalog and feeds layers
|
|
182
|
+
each define their own subtree beneath it, so you can catch broadly
|
|
183
|
+
(`MobilityDatabaseError`) or narrowly per layer:
|
|
184
|
+
|
|
185
|
+
| Exception | Layer | Meaning |
|
|
186
|
+
| --- | --- | --- |
|
|
187
|
+
| `MobilityDatabaseConnectionError` | catalog | Network failure, timeout, or invalid response body |
|
|
188
|
+
| `MobilityDatabaseAuthenticationError` | catalog | Invalid refresh token, or unauthorized after a token refresh |
|
|
189
|
+
| `MobilityDatabaseNotFoundError` | catalog | Resource not found (404) |
|
|
190
|
+
| `MobilityDatabaseRateLimitError` | catalog | Rate limited (429) |
|
|
191
|
+
| `MobilityDatabaseApiError` | catalog | Any other 4xx/5xx (carries `.status` and `.body`) |
|
|
192
|
+
| `MobilityFeedsError` | feeds | Base class for all feeds errors (subclasses `MobilityDatabaseError`) |
|
|
193
|
+
| `SourceConnectionError` | feeds | Producer/GBFS endpoint unreachable or errored (`.status` when HTTP) |
|
|
194
|
+
| `SourceAuthenticationError` | feeds | Producer rejected the feed's `api_key` |
|
|
195
|
+
| `FeedParseError` | feeds | Undecodable protobuf, malformed GBFS JSON, or unreadable GTFS zip |
|
|
196
|
+
| `StaticDataUnavailableError` | feeds | Feed has no usable hosted static dataset |
|
|
197
|
+
|
|
198
|
+
Note the two independent auth flows: a `MobilityDatabaseAuthenticationError`
|
|
199
|
+
means your **catalog refresh token** is invalid; a `SourceAuthenticationError`
|
|
200
|
+
means the **producer** rejected the feed-specific `api_key` you passed to
|
|
201
|
+
`get_transit_feed()`. They fail independently and are never confused for one
|
|
202
|
+
another.
|
|
203
|
+
|
|
204
|
+
## Session injection
|
|
205
|
+
|
|
206
|
+
With an externally managed session (e.g. Home Assistant's shared session), the
|
|
207
|
+
client never closes it. One shared session serves both catalog requests and
|
|
208
|
+
feeds fetches (GTFS-RT polls, GBFS documents, dataset zip downloads) when
|
|
209
|
+
passed to `MobilityFeedsClient`:
|
|
210
|
+
|
|
211
|
+
```python
|
|
212
|
+
import aiohttp
|
|
213
|
+
|
|
214
|
+
from aiomobilitydatabase import MobilityDatabaseClient
|
|
215
|
+
|
|
216
|
+
session = aiohttp.ClientSession()
|
|
217
|
+
client = MobilityDatabaseClient("YOUR_REFRESH_TOKEN", session)
|
|
218
|
+
try:
|
|
219
|
+
metadata = await client.get_metadata()
|
|
220
|
+
finally:
|
|
221
|
+
await client.close() # session remains open; you own it
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
## Testing methodology
|
|
225
|
+
|
|
226
|
+
The suite (174 tests) combines example-based and property-based testing:
|
|
227
|
+
|
|
228
|
+
- **Example-based tests** cover the catalog's endpoint methods and the feeds
|
|
229
|
+
layer's client/transit/GBFS/static-index modules against a real
|
|
230
|
+
`aiohttp.test_utils.TestServer` running on loopback (`tests/mock_server.py`)
|
|
231
|
+
— scripted per-`(method, path)` responses, with every request recorded for
|
|
232
|
+
assertions on headers, query params, and bodies. This replaces
|
|
233
|
+
[aioresponses](https://github.com/pnuckowski/aioresponses), which cannot
|
|
234
|
+
construct mocked responses under aiohttp >=3.14.
|
|
235
|
+
- **Property-based tests** (`tests/feeds/test_properties.py`, via
|
|
236
|
+
[Hypothesis](https://hypothesis.readthedocs.io/)) target the modules where
|
|
237
|
+
correctness is a matter of an invariant holding across a wide input space
|
|
238
|
+
rather than a handful of hand-picked cases:
|
|
239
|
+
- the static-index date/time math (GTFS elapsed-seconds-since-service-day
|
|
240
|
+
arithmetic against a naive oracle, calendar/calendar_dates service-id
|
|
241
|
+
resolution, DST-transition handling) — this is where the original
|
|
242
|
+
hardcoded `±1 day` scan-window bug was found;
|
|
243
|
+
- the circular-zone geometry (`in_circle`'s bbox prefilter is checked
|
|
244
|
+
against the exact haversine distance at generated boundary points);
|
|
245
|
+
- GBFS field parsing (`_localized`, `_version_key`, station/vehicle merging)
|
|
246
|
+
and GTFS-RT protobuf parsing, both checked for **totality** — every
|
|
247
|
+
generated input must produce a typed result or a documented
|
|
248
|
+
`MobilityFeedsError` subclass, never an unhandled exception;
|
|
249
|
+
- error-contract fuzzing for malformed GTFS zips, RT payloads, and GBFS
|
|
250
|
+
documents (garbage bytes, wrong types, out-of-range values) — the
|
|
251
|
+
contract under test is "raises `FeedParseError`/`SourceConnectionError`",
|
|
252
|
+
not "does anything in particular with the garbage."
|
|
253
|
+
|
|
254
|
+
Hypothesis is a `dev` dependency and part of the default suite (no separate
|
|
255
|
+
opt-in marker); `.hypothesis/` (its example database) is gitignored.
|
|
256
|
+
|
|
257
|
+
## Development
|
|
258
|
+
|
|
259
|
+
```bash
|
|
260
|
+
uv sync --dev
|
|
261
|
+
uv run pytest
|
|
262
|
+
uv run ruff check . && uv run ruff format --check .
|
|
263
|
+
uv run mypy src
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
## License
|
|
267
|
+
|
|
268
|
+
[Apache-2.0](LICENSE)
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
# aiomobilitydatabase
|
|
2
|
+
|
|
3
|
+
Async Python client for the [Mobility Database](https://mobilitydatabase.org/) — the
|
|
4
|
+
catalog MobilityData stewards of GTFS, GTFS Realtime, and GBFS feeds from transit
|
|
5
|
+
agencies and bike/scooter share systems around the world.
|
|
6
|
+
|
|
7
|
+
The core package wraps the catalog's
|
|
8
|
+
[REST API](https://mobilitydata.github.io/mobility-feed-api/SwaggerUI/index.html)
|
|
9
|
+
with a fully typed asyncio client: search feeds, look up their metadata, find the
|
|
10
|
+
hosted dataset URL. An optional `feeds` layer goes one step further and consumes
|
|
11
|
+
the actual transit/bike-share data those feeds point to — scheduled and live
|
|
12
|
+
arrivals, vehicle positions, service alerts, GBFS stations and vehicles — giving you
|
|
13
|
+
a feed ID in and typed snapshots out.
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pip install aiomobilitydatabase
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
That installs the catalog client only. To also consume feed data (schedules,
|
|
22
|
+
GTFS-RT, GBFS), install the `feeds` extra, which pulls in
|
|
23
|
+
[`gtfs-realtime-bindings`](https://pypi.org/project/gtfs-realtime-bindings/) for
|
|
24
|
+
protobuf parsing:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install aiomobilitydatabase[feeds]
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Importing `aiomobilitydatabase.feeds` without the extra installed raises an
|
|
31
|
+
`ImportError` telling you to install it — there is no silent partial-functionality
|
|
32
|
+
mode.
|
|
33
|
+
|
|
34
|
+
Requires Python 3.12+.
|
|
35
|
+
|
|
36
|
+
## Authentication
|
|
37
|
+
|
|
38
|
+
Sign up at [mobilitydatabase.org](https://mobilitydatabase.org) and copy your
|
|
39
|
+
**refresh token** from the account page. The client exchanges it for short-lived
|
|
40
|
+
access tokens automatically (including proactive refresh before expiry). Both
|
|
41
|
+
`MobilityDatabaseClient` and `MobilityFeedsClient` take the same refresh token —
|
|
42
|
+
the feeds client wraps a catalog client internally and shares its authentication.
|
|
43
|
+
|
|
44
|
+
## Quick start — catalog
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
import asyncio
|
|
48
|
+
|
|
49
|
+
from aiomobilitydatabase import DataType, MobilityDatabaseClient
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def main() -> None:
|
|
53
|
+
async with MobilityDatabaseClient("YOUR_REFRESH_TOKEN") as client:
|
|
54
|
+
# Free-text search across feed name, provider, and location
|
|
55
|
+
results = await client.search_feeds(
|
|
56
|
+
search_query="new york", data_types=[DataType.GTFS_RT], limit=10
|
|
57
|
+
)
|
|
58
|
+
for item in results.results:
|
|
59
|
+
print(item.id, item.provider, item.status)
|
|
60
|
+
|
|
61
|
+
# Full detail for one feed, including the producer URL
|
|
62
|
+
feed = await client.get_gtfs_rt_feed(results.results[0].id)
|
|
63
|
+
assert feed.source_info is not None
|
|
64
|
+
print(feed.source_info.producer_url)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
asyncio.run(main())
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Quick start — feeds (optional extra)
|
|
71
|
+
|
|
72
|
+
`MobilityFeedsClient` resolves a feed ID (GTFS, GTFS-RT, or GBFS) into a handle
|
|
73
|
+
that fetches and caches the actual data. Requires `pip install
|
|
74
|
+
aiomobilitydatabase[feeds]`.
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
import asyncio
|
|
78
|
+
|
|
79
|
+
from aiomobilitydatabase.feeds import Circle, MobilityFeedsClient
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
async def main() -> None:
|
|
83
|
+
async with MobilityFeedsClient(
|
|
84
|
+
"YOUR_REFRESH_TOKEN", cache_dir="/path/to/cache"
|
|
85
|
+
) as client:
|
|
86
|
+
# The full catalog client is available via .catalog:
|
|
87
|
+
results = await client.catalog.search_feeds(search_query="portland", limit=5)
|
|
88
|
+
for item in results.results:
|
|
89
|
+
print(item.id, item.provider, item.data_type)
|
|
90
|
+
|
|
91
|
+
# Transit: accepts a GTFS or GTFS-RT feed ID; resolves the sibling.
|
|
92
|
+
# api_key authenticates with the PRODUCER (distinct from your catalog
|
|
93
|
+
# refresh token); on_progress reports download/index build progress
|
|
94
|
+
# for the first (uncached) fetch of a feed's static dataset.
|
|
95
|
+
transit = await client.get_transit_feed(
|
|
96
|
+
"mdb-100",
|
|
97
|
+
api_key="PRODUCER_API_KEY",
|
|
98
|
+
on_progress=lambda p: print(f"{p.phase}: {p.fraction:.0%}"),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
# Picker-style helpers: stops in a zone, routes serving a stop.
|
|
102
|
+
# stations_in collapses GTFS station hierarchies (platforms group
|
|
103
|
+
# under their parent station, entrances are dropped) for clean UIs.
|
|
104
|
+
home = Circle(latitude=45.52, longitude=-122.68, radius_m=800)
|
|
105
|
+
stations = transit.stations_in(home)
|
|
106
|
+
nearby_stops = transit.stops_in(home)
|
|
107
|
+
routes = await transit.routes_serving(stations[0].stop_ids[0])
|
|
108
|
+
|
|
109
|
+
# Scheduled arrivals merged with realtime (delays, cancellations,
|
|
110
|
+
# RT-added trips) when the feed publishes GTFS-RT TripUpdates.
|
|
111
|
+
arrivals = await transit.get_arrivals([nearby_stops[0].id], limit=2)
|
|
112
|
+
for arrival in arrivals:
|
|
113
|
+
when = arrival.predicted_departure or arrival.scheduled_departure
|
|
114
|
+
live = "live" if arrival.realtime else "scheduled"
|
|
115
|
+
print(f"{arrival.route_name} -> {arrival.headsign}: {when} ({live})")
|
|
116
|
+
|
|
117
|
+
vehicles = await transit.get_vehicles()
|
|
118
|
+
alerts = await transit.get_alerts()
|
|
119
|
+
|
|
120
|
+
# GBFS bike/scooter share:
|
|
121
|
+
bikes = await client.get_gbfs_feed("gbfs-300")
|
|
122
|
+
stations = await bikes.get_stations(zone=home)
|
|
123
|
+
free_vehicles = await bikes.get_vehicles(zone=home)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
asyncio.run(main())
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## How it works
|
|
130
|
+
|
|
131
|
+
- **Scheduled arrivals for every feed**: the hosted GTFS zip is indexed into
|
|
132
|
+
SQLite (stops, routes, trips, stop_times, calendars) in a worker thread — the
|
|
133
|
+
event loop is never blocked. Arrivals work with or without realtime coverage;
|
|
134
|
+
GTFS-RT TripUpdates overlay delays, cancellations, and added trips when
|
|
135
|
+
present, with cancellation always winning over stale predictions.
|
|
136
|
+
- **`cache_dir` strongly recommended**: the static index is cached on disk keyed
|
|
137
|
+
by dataset ID, so restarts are instant and rebuilds only happen when the
|
|
138
|
+
agency publishes a new dataset (`await transit.refresh_static()` — call it
|
|
139
|
+
daily; it swaps in the new index only after it's built, so lookups never see
|
|
140
|
+
a half-built database). Without a `cache_dir` the index is built in memory
|
|
141
|
+
on every startup.
|
|
142
|
+
- **Pull, not push**: `TransitFeedHandle`/`GbfsFeedHandle` return snapshots on
|
|
143
|
+
demand — there's no built-in polling loop or scheduler. Bring your own (e.g.
|
|
144
|
+
Home Assistant's `DataUpdateCoordinator`).
|
|
145
|
+
- **`purge_cache()`** deletes a feed's cached static data (or all feeds' when
|
|
146
|
+
called with no argument) — call it on cleanup/removal of a configured feed.
|
|
147
|
+
- **Producer authentication**: pass `api_key=` to `get_transit_feed()`; it is
|
|
148
|
+
applied per the catalog's `authentication_type` (query parameter or header) —
|
|
149
|
+
distinct from the refresh token used to authenticate with the catalog itself.
|
|
150
|
+
- **Zones are circles** (matching Home Assistant zone entities); GBFS vehicle
|
|
151
|
+
filtering happens client-side because GBFS feeds are full-system dumps by
|
|
152
|
+
design, with no server-side geo-query.
|
|
153
|
+
- **Timestamps are tz-aware UTC** throughout the feeds layer.
|
|
154
|
+
|
|
155
|
+
## Error handling
|
|
156
|
+
|
|
157
|
+
All errors derive from `MobilityDatabaseError`. The catalog and feeds layers
|
|
158
|
+
each define their own subtree beneath it, so you can catch broadly
|
|
159
|
+
(`MobilityDatabaseError`) or narrowly per layer:
|
|
160
|
+
|
|
161
|
+
| Exception | Layer | Meaning |
|
|
162
|
+
| --- | --- | --- |
|
|
163
|
+
| `MobilityDatabaseConnectionError` | catalog | Network failure, timeout, or invalid response body |
|
|
164
|
+
| `MobilityDatabaseAuthenticationError` | catalog | Invalid refresh token, or unauthorized after a token refresh |
|
|
165
|
+
| `MobilityDatabaseNotFoundError` | catalog | Resource not found (404) |
|
|
166
|
+
| `MobilityDatabaseRateLimitError` | catalog | Rate limited (429) |
|
|
167
|
+
| `MobilityDatabaseApiError` | catalog | Any other 4xx/5xx (carries `.status` and `.body`) |
|
|
168
|
+
| `MobilityFeedsError` | feeds | Base class for all feeds errors (subclasses `MobilityDatabaseError`) |
|
|
169
|
+
| `SourceConnectionError` | feeds | Producer/GBFS endpoint unreachable or errored (`.status` when HTTP) |
|
|
170
|
+
| `SourceAuthenticationError` | feeds | Producer rejected the feed's `api_key` |
|
|
171
|
+
| `FeedParseError` | feeds | Undecodable protobuf, malformed GBFS JSON, or unreadable GTFS zip |
|
|
172
|
+
| `StaticDataUnavailableError` | feeds | Feed has no usable hosted static dataset |
|
|
173
|
+
|
|
174
|
+
Note the two independent auth flows: a `MobilityDatabaseAuthenticationError`
|
|
175
|
+
means your **catalog refresh token** is invalid; a `SourceAuthenticationError`
|
|
176
|
+
means the **producer** rejected the feed-specific `api_key` you passed to
|
|
177
|
+
`get_transit_feed()`. They fail independently and are never confused for one
|
|
178
|
+
another.
|
|
179
|
+
|
|
180
|
+
## Session injection
|
|
181
|
+
|
|
182
|
+
With an externally managed session (e.g. Home Assistant's shared session), the
|
|
183
|
+
client never closes it. One shared session serves both catalog requests and
|
|
184
|
+
feeds fetches (GTFS-RT polls, GBFS documents, dataset zip downloads) when
|
|
185
|
+
passed to `MobilityFeedsClient`:
|
|
186
|
+
|
|
187
|
+
```python
|
|
188
|
+
import aiohttp
|
|
189
|
+
|
|
190
|
+
from aiomobilitydatabase import MobilityDatabaseClient
|
|
191
|
+
|
|
192
|
+
session = aiohttp.ClientSession()
|
|
193
|
+
client = MobilityDatabaseClient("YOUR_REFRESH_TOKEN", session)
|
|
194
|
+
try:
|
|
195
|
+
metadata = await client.get_metadata()
|
|
196
|
+
finally:
|
|
197
|
+
await client.close() # session remains open; you own it
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
## Testing methodology
|
|
201
|
+
|
|
202
|
+
The suite (174 tests) combines example-based and property-based testing:
|
|
203
|
+
|
|
204
|
+
- **Example-based tests** cover the catalog's endpoint methods and the feeds
|
|
205
|
+
layer's client/transit/GBFS/static-index modules against a real
|
|
206
|
+
`aiohttp.test_utils.TestServer` running on loopback (`tests/mock_server.py`)
|
|
207
|
+
— scripted per-`(method, path)` responses, with every request recorded for
|
|
208
|
+
assertions on headers, query params, and bodies. This replaces
|
|
209
|
+
[aioresponses](https://github.com/pnuckowski/aioresponses), which cannot
|
|
210
|
+
construct mocked responses under aiohttp >=3.14.
|
|
211
|
+
- **Property-based tests** (`tests/feeds/test_properties.py`, via
|
|
212
|
+
[Hypothesis](https://hypothesis.readthedocs.io/)) target the modules where
|
|
213
|
+
correctness is a matter of an invariant holding across a wide input space
|
|
214
|
+
rather than a handful of hand-picked cases:
|
|
215
|
+
- the static-index date/time math (GTFS elapsed-seconds-since-service-day
|
|
216
|
+
arithmetic against a naive oracle, calendar/calendar_dates service-id
|
|
217
|
+
resolution, DST-transition handling) — this is where the original
|
|
218
|
+
hardcoded `±1 day` scan-window bug was found;
|
|
219
|
+
- the circular-zone geometry (`in_circle`'s bbox prefilter is checked
|
|
220
|
+
against the exact haversine distance at generated boundary points);
|
|
221
|
+
- GBFS field parsing (`_localized`, `_version_key`, station/vehicle merging)
|
|
222
|
+
and GTFS-RT protobuf parsing, both checked for **totality** — every
|
|
223
|
+
generated input must produce a typed result or a documented
|
|
224
|
+
`MobilityFeedsError` subclass, never an unhandled exception;
|
|
225
|
+
- error-contract fuzzing for malformed GTFS zips, RT payloads, and GBFS
|
|
226
|
+
documents (garbage bytes, wrong types, out-of-range values) — the
|
|
227
|
+
contract under test is "raises `FeedParseError`/`SourceConnectionError`",
|
|
228
|
+
not "does anything in particular with the garbage."
|
|
229
|
+
|
|
230
|
+
Hypothesis is a `dev` dependency and part of the default suite (no separate
|
|
231
|
+
opt-in marker); `.hypothesis/` (its example database) is gitignored.
|
|
232
|
+
|
|
233
|
+
## Development
|
|
234
|
+
|
|
235
|
+
```bash
|
|
236
|
+
uv sync --dev
|
|
237
|
+
uv run pytest
|
|
238
|
+
uv run ruff check . && uv run ruff format --check .
|
|
239
|
+
uv run mypy src
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
## License
|
|
243
|
+
|
|
244
|
+
[Apache-2.0](LICENSE)
|
|
@@ -26,12 +26,17 @@ dependencies = [
|
|
|
26
26
|
]
|
|
27
27
|
dynamic = ["version"]
|
|
28
28
|
|
|
29
|
+
[project.optional-dependencies]
|
|
30
|
+
feeds = ["gtfs-realtime-bindings>=1.0.0"]
|
|
31
|
+
|
|
29
32
|
[project.urls]
|
|
30
33
|
"Source Code" = "https://github.com/raman325/aiomobilitydatabase"
|
|
31
34
|
"Bug Reports" = "https://github.com/raman325/aiomobilitydatabase/issues"
|
|
32
35
|
|
|
33
36
|
[dependency-groups]
|
|
34
37
|
dev = [
|
|
38
|
+
"gtfs-realtime-bindings>=1.0.0",
|
|
39
|
+
"hypothesis>=6.100",
|
|
35
40
|
"mypy>=1.14",
|
|
36
41
|
"pre-commit>=4.0",
|
|
37
42
|
"pytest>=8.3",
|
|
@@ -55,6 +60,10 @@ testpaths = ["tests"]
|
|
|
55
60
|
strict = true
|
|
56
61
|
warn_unreachable = true
|
|
57
62
|
|
|
63
|
+
[[tool.mypy.overrides]]
|
|
64
|
+
module = "google.transit.*"
|
|
65
|
+
ignore_missing_imports = true
|
|
66
|
+
|
|
58
67
|
[tool.ruff]
|
|
59
68
|
target-version = "py312"
|
|
60
69
|
|
|
@@ -68,3 +77,7 @@ ignore = [
|
|
|
68
77
|
|
|
69
78
|
[tool.ruff.lint.per-file-ignores]
|
|
70
79
|
"tests/*" = ["D", "PLR2004"]
|
|
80
|
+
|
|
81
|
+
[tool.ruff.lint.isort]
|
|
82
|
+
known-first-party = ["aiomobilitydatabase"]
|
|
83
|
+
known-local-folder = ["tests"]
|