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.
Files changed (81) hide show
  1. {aiomobilitydatabase-0.1.0 → aiomobilitydatabase-0.2.0}/.gitignore +1 -0
  2. {aiomobilitydatabase-0.1.0 → aiomobilitydatabase-0.2.0}/.pre-commit-config.yaml +1 -1
  3. aiomobilitydatabase-0.2.0/PKG-INFO +268 -0
  4. aiomobilitydatabase-0.2.0/README.md +244 -0
  5. {aiomobilitydatabase-0.1.0 → aiomobilitydatabase-0.2.0}/pyproject.toml +13 -0
  6. aiomobilitydatabase-0.2.0/scripts/generate_rt_fixtures.py +160 -0
  7. {aiomobilitydatabase-0.1.0 → aiomobilitydatabase-0.2.0}/src/aiomobilitydatabase/client.py +30 -9
  8. {aiomobilitydatabase-0.1.0 → aiomobilitydatabase-0.2.0}/src/aiomobilitydatabase/const.py +1 -1
  9. {aiomobilitydatabase-0.1.0 → aiomobilitydatabase-0.2.0}/src/aiomobilitydatabase/exceptions.py +6 -1
  10. aiomobilitydatabase-0.2.0/src/aiomobilitydatabase/feeds/__init__.py +59 -0
  11. aiomobilitydatabase-0.2.0/src/aiomobilitydatabase/feeds/client.py +153 -0
  12. aiomobilitydatabase-0.2.0/src/aiomobilitydatabase/feeds/const.py +5 -0
  13. aiomobilitydatabase-0.2.0/src/aiomobilitydatabase/feeds/exceptions.py +35 -0
  14. aiomobilitydatabase-0.2.0/src/aiomobilitydatabase/feeds/gbfs.py +209 -0
  15. aiomobilitydatabase-0.2.0/src/aiomobilitydatabase/feeds/geo.py +61 -0
  16. aiomobilitydatabase-0.2.0/src/aiomobilitydatabase/feeds/models.py +170 -0
  17. aiomobilitydatabase-0.2.0/src/aiomobilitydatabase/feeds/rt.py +302 -0
  18. aiomobilitydatabase-0.2.0/src/aiomobilitydatabase/feeds/static_index.py +661 -0
  19. aiomobilitydatabase-0.2.0/src/aiomobilitydatabase/feeds/transit.py +482 -0
  20. {aiomobilitydatabase-0.1.0 → aiomobilitydatabase-0.2.0}/tests/conftest.py +2 -1
  21. aiomobilitydatabase-0.2.0/tests/feeds/__init__.py +0 -0
  22. aiomobilitydatabase-0.2.0/tests/feeds/conftest.py +33 -0
  23. aiomobilitydatabase-0.2.0/tests/feeds/data/catalog/gbfs_feed.json +30 -0
  24. aiomobilitydatabase-0.2.0/tests/feeds/data/catalog/gtfs_feed.json +14 -0
  25. aiomobilitydatabase-0.2.0/tests/feeds/data/catalog/gtfs_rt_feed.json +19 -0
  26. aiomobilitydatabase-0.2.0/tests/feeds/data/catalog/token_response.json +5 -0
  27. aiomobilitydatabase-0.2.0/tests/feeds/data/gbfs/free_bike_status_23.json +22 -0
  28. aiomobilitydatabase-0.2.0/tests/feeds/data/gbfs/station_info_23.json +22 -0
  29. aiomobilitydatabase-0.2.0/tests/feeds/data/gbfs/station_status_23.json +32 -0
  30. aiomobilitydatabase-0.2.0/tests/feeds/data/gbfs/system_info_23.json +10 -0
  31. aiomobilitydatabase-0.2.0/tests/feeds/data/gbfs/system_info_30.json +21 -0
  32. aiomobilitydatabase-0.2.0/tests/feeds/data/gbfs/vehicle_status_30.json +18 -0
  33. aiomobilitydatabase-0.2.0/tests/feeds/data/gtfs/agency.txt +2 -0
  34. aiomobilitydatabase-0.2.0/tests/feeds/data/gtfs/calendar.txt +3 -0
  35. aiomobilitydatabase-0.2.0/tests/feeds/data/gtfs/calendar_dates.txt +4 -0
  36. aiomobilitydatabase-0.2.0/tests/feeds/data/gtfs/routes.txt +3 -0
  37. aiomobilitydatabase-0.2.0/tests/feeds/data/gtfs/stop_times.txt +6 -0
  38. aiomobilitydatabase-0.2.0/tests/feeds/data/gtfs/stops.txt +5 -0
  39. aiomobilitydatabase-0.2.0/tests/feeds/data/gtfs/trips.txt +5 -0
  40. aiomobilitydatabase-0.2.0/tests/feeds/data/rt/added_trips_s1.pb +12 -0
  41. aiomobilitydatabase-0.2.0/tests/feeds/data/rt/alerts.pb +10 -0
  42. aiomobilitydatabase-0.2.0/tests/feeds/data/rt/trip_updates_baseline.pb +13 -0
  43. aiomobilitydatabase-0.2.0/tests/feeds/data/rt/trip_updates_t1_delayed.pb +13 -0
  44. aiomobilitydatabase-0.2.0/tests/feeds/data/rt/vehicle_positions.pb +0 -0
  45. aiomobilitydatabase-0.2.0/tests/feeds/fixtures.py +96 -0
  46. aiomobilitydatabase-0.2.0/tests/feeds/test_client.py +139 -0
  47. aiomobilitydatabase-0.2.0/tests/feeds/test_exceptions.py +31 -0
  48. aiomobilitydatabase-0.2.0/tests/feeds/test_extra_import_guard.py +38 -0
  49. aiomobilitydatabase-0.2.0/tests/feeds/test_gbfs_stations.py +198 -0
  50. aiomobilitydatabase-0.2.0/tests/feeds/test_gbfs_vehicles.py +67 -0
  51. aiomobilitydatabase-0.2.0/tests/feeds/test_geo.py +38 -0
  52. aiomobilitydatabase-0.2.0/tests/feeds/test_models.py +135 -0
  53. aiomobilitydatabase-0.2.0/tests/feeds/test_properties.py +1213 -0
  54. aiomobilitydatabase-0.2.0/tests/feeds/test_rt.py +205 -0
  55. aiomobilitydatabase-0.2.0/tests/feeds/test_static_arrivals.py +275 -0
  56. aiomobilitydatabase-0.2.0/tests/feeds/test_static_build.py +222 -0
  57. aiomobilitydatabase-0.2.0/tests/feeds/test_station_grouping.py +128 -0
  58. aiomobilitydatabase-0.2.0/tests/feeds/test_transit_arrivals.py +148 -0
  59. aiomobilitydatabase-0.2.0/tests/feeds/test_transit_resolution.py +249 -0
  60. aiomobilitydatabase-0.2.0/tests/feeds/test_transit_vehicles_alerts.py +259 -0
  61. aiomobilitydatabase-0.2.0/tests/mock_server.py +118 -0
  62. aiomobilitydatabase-0.2.0/tests/test_catalog_properties.py +211 -0
  63. {aiomobilitydatabase-0.1.0 → aiomobilitydatabase-0.2.0}/tests/test_client_request.py +1 -0
  64. {aiomobilitydatabase-0.1.0 → aiomobilitydatabase-0.2.0}/tests/test_client_session.py +1 -0
  65. {aiomobilitydatabase-0.1.0 → aiomobilitydatabase-0.2.0}/tests/test_client_token.py +1 -0
  66. {aiomobilitydatabase-0.1.0 → aiomobilitydatabase-0.2.0}/tests/test_endpoints.py +1 -0
  67. {aiomobilitydatabase-0.1.0 → aiomobilitydatabase-0.2.0}/tests/test_models.py +1 -0
  68. aiomobilitydatabase-0.1.0/PKG-INFO +0 -111
  69. aiomobilitydatabase-0.1.0/README.md +0 -89
  70. aiomobilitydatabase-0.1.0/tests/mock_server.py +0 -146
  71. {aiomobilitydatabase-0.1.0 → aiomobilitydatabase-0.2.0}/.github/workflows/ci.yml +0 -0
  72. {aiomobilitydatabase-0.1.0 → aiomobilitydatabase-0.2.0}/.github/workflows/publish.yml +0 -0
  73. {aiomobilitydatabase-0.1.0 → aiomobilitydatabase-0.2.0}/LICENSE +0 -0
  74. {aiomobilitydatabase-0.1.0 → aiomobilitydatabase-0.2.0}/src/aiomobilitydatabase/__init__.py +0 -0
  75. {aiomobilitydatabase-0.1.0 → aiomobilitydatabase-0.2.0}/src/aiomobilitydatabase/models.py +0 -0
  76. {aiomobilitydatabase-0.1.0 → aiomobilitydatabase-0.2.0}/src/aiomobilitydatabase/py.typed +0 -0
  77. {aiomobilitydatabase-0.1.0 → aiomobilitydatabase-0.2.0}/tests/__init__.py +0 -0
  78. {aiomobilitydatabase-0.1.0 → aiomobilitydatabase-0.2.0}/tests/fixtures.py +0 -0
  79. {aiomobilitydatabase-0.1.0 → aiomobilitydatabase-0.2.0}/tests/test_exceptions.py +0 -0
  80. {aiomobilitydatabase-0.1.0 → aiomobilitydatabase-0.2.0}/tests/test_params.py +0 -0
  81. {aiomobilitydatabase-0.1.0 → aiomobilitydatabase-0.2.0}/tests/test_public_api.py +0 -0
@@ -6,6 +6,7 @@ dist/
6
6
  .mypy_cache/
7
7
  .pytest_cache/
8
8
  .ruff_cache/
9
+ .hypothesis/
9
10
  .coverage
10
11
  coverage.xml
11
12
  uv.lock
@@ -1,6 +1,6 @@
1
1
  repos:
2
2
  - repo: https://github.com/astral-sh/ruff-pre-commit
3
- rev: v0.9.4
3
+ rev: v0.16.1
4
4
  hooks:
5
5
  - id: ruff
6
6
  args: [--fix]
@@ -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"]