aiomobilitydatabase 0.2.0__tar.gz → 0.3.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 (126) hide show
  1. {aiomobilitydatabase-0.2.0 → aiomobilitydatabase-0.3.0}/.pre-commit-config.yaml +3 -0
  2. {aiomobilitydatabase-0.2.0 → aiomobilitydatabase-0.3.0}/PKG-INFO +135 -21
  3. {aiomobilitydatabase-0.2.0 → aiomobilitydatabase-0.3.0}/README.md +134 -20
  4. aiomobilitydatabase-0.3.0/scripts/generate_rt_fixtures.py +507 -0
  5. {aiomobilitydatabase-0.2.0 → aiomobilitydatabase-0.3.0}/src/aiomobilitydatabase/const.py +1 -1
  6. {aiomobilitydatabase-0.2.0 → aiomobilitydatabase-0.3.0}/src/aiomobilitydatabase/feeds/__init__.py +26 -0
  7. {aiomobilitydatabase-0.2.0 → aiomobilitydatabase-0.3.0}/src/aiomobilitydatabase/feeds/client.py +82 -8
  8. {aiomobilitydatabase-0.2.0 → aiomobilitydatabase-0.3.0}/src/aiomobilitydatabase/feeds/gbfs.py +127 -16
  9. aiomobilitydatabase-0.3.0/src/aiomobilitydatabase/feeds/models.py +501 -0
  10. aiomobilitydatabase-0.3.0/src/aiomobilitydatabase/feeds/rt.py +655 -0
  11. aiomobilitydatabase-0.3.0/src/aiomobilitydatabase/feeds/static_index.py +1426 -0
  12. aiomobilitydatabase-0.3.0/src/aiomobilitydatabase/feeds/transit.py +1190 -0
  13. aiomobilitydatabase-0.3.0/tests/feeds/data/gbfs/discovery_23.json +27 -0
  14. aiomobilitydatabase-0.3.0/tests/feeds/data/gbfs/discovery_30.json +17 -0
  15. {aiomobilitydatabase-0.2.0 → aiomobilitydatabase-0.3.0}/tests/feeds/data/gbfs/free_bike_status_23.json +5 -1
  16. {aiomobilitydatabase-0.2.0 → aiomobilitydatabase-0.3.0}/tests/feeds/data/gbfs/station_info_23.json +6 -1
  17. {aiomobilitydatabase-0.2.0 → aiomobilitydatabase-0.3.0}/tests/feeds/data/gbfs/vehicle_status_30.json +6 -1
  18. aiomobilitydatabase-0.3.0/tests/feeds/data/rt/alerts_trip_scoped.pb +9 -0
  19. aiomobilitydatabase-0.3.0/tests/feeds/data/rt/trip_updates_freq_bare_cancel.pb +6 -0
  20. aiomobilitydatabase-0.3.0/tests/feeds/data/rt/trip_updates_freq_canceled_tomorrow.pb +6 -0
  21. aiomobilitydatabase-0.3.0/tests/feeds/data/rt/trip_updates_freq_matched.pb +10 -0
  22. aiomobilitydatabase-0.3.0/tests/feeds/data/rt/trip_updates_freq_unmatched.pb +9 -0
  23. aiomobilitydatabase-0.3.0/tests/feeds/data/rt/trip_updates_t1_both_ends.pb +6 -0
  24. aiomobilitydatabase-0.3.0/tests/feeds/data/rt/trip_updates_t1_canceled.pb +6 -0
  25. aiomobilitydatabase-0.3.0/tests/feeds/data/rt/trip_updates_t1_canceled_tomorrow.pb +6 -0
  26. aiomobilitydatabase-0.3.0/tests/feeds/data/rt/trip_updates_t1_dated_tomorrow_delay.pb +6 -0
  27. aiomobilitydatabase-0.3.0/tests/feeds/data/rt/trip_updates_t1_dest_arrival.pb +6 -0
  28. aiomobilitydatabase-0.3.0/tests/feeds/data/rt/trip_updates_t1_no_data_cut.pb +7 -0
  29. aiomobilitydatabase-0.3.0/tests/feeds/data/rt/trip_updates_t1_skip_s1.pb +6 -0
  30. aiomobilitydatabase-0.3.0/tests/feeds/data/rt/trip_updates_t1_skip_s2.pb +6 -0
  31. aiomobilitydatabase-0.3.0/tests/feeds/data/rt/trip_updates_t1_skip_s3.pb +6 -0
  32. aiomobilitydatabase-0.3.0/tests/feeds/data/rt/trip_updates_t1_trip_delay.pb +6 -0
  33. aiomobilitydatabase-0.3.0/tests/feeds/data/rt/vehicle_positions_status.pb +16 -0
  34. aiomobilitydatabase-0.3.0/tests/feeds/data/sample_feed/README.md +39 -0
  35. aiomobilitydatabase-0.3.0/tests/feeds/data/sample_feed/agency.txt +2 -0
  36. aiomobilitydatabase-0.3.0/tests/feeds/data/sample_feed/calendar.txt +3 -0
  37. aiomobilitydatabase-0.3.0/tests/feeds/data/sample_feed/calendar_dates.txt +2 -0
  38. aiomobilitydatabase-0.3.0/tests/feeds/data/sample_feed/fare_attributes.txt +3 -0
  39. aiomobilitydatabase-0.3.0/tests/feeds/data/sample_feed/fare_rules.txt +5 -0
  40. aiomobilitydatabase-0.3.0/tests/feeds/data/sample_feed/feed_info.txt +2 -0
  41. aiomobilitydatabase-0.3.0/tests/feeds/data/sample_feed/frequencies.txt +12 -0
  42. aiomobilitydatabase-0.3.0/tests/feeds/data/sample_feed/routes.txt +7 -0
  43. aiomobilitydatabase-0.3.0/tests/feeds/data/sample_feed/shapes.txt +1 -0
  44. aiomobilitydatabase-0.3.0/tests/feeds/data/sample_feed/stop_times.txt +29 -0
  45. aiomobilitydatabase-0.3.0/tests/feeds/data/sample_feed/stops.txt +10 -0
  46. aiomobilitydatabase-0.3.0/tests/feeds/data/sample_feed/transfers.txt +4 -0
  47. aiomobilitydatabase-0.3.0/tests/feeds/data/sample_feed/translations.txt +3 -0
  48. aiomobilitydatabase-0.3.0/tests/feeds/data/sample_feed/trips.txt +12 -0
  49. aiomobilitydatabase-0.3.0/tests/feeds/fixtures.py +247 -0
  50. aiomobilitydatabase-0.3.0/tests/feeds/test_descriptive_attributes.py +530 -0
  51. aiomobilitydatabase-0.3.0/tests/feeds/test_direct_urls.py +399 -0
  52. {aiomobilitydatabase-0.2.0 → aiomobilitydatabase-0.3.0}/tests/feeds/test_gbfs_stations.py +7 -0
  53. {aiomobilitydatabase-0.2.0 → aiomobilitydatabase-0.3.0}/tests/feeds/test_gbfs_vehicles.py +13 -0
  54. {aiomobilitydatabase-0.2.0 → aiomobilitydatabase-0.3.0}/tests/feeds/test_models.py +88 -7
  55. aiomobilitydatabase-0.3.0/tests/feeds/test_properties.py +3984 -0
  56. aiomobilitydatabase-0.3.0/tests/feeds/test_rt.py +678 -0
  57. aiomobilitydatabase-0.3.0/tests/feeds/test_sample_feed.py +555 -0
  58. {aiomobilitydatabase-0.2.0 → aiomobilitydatabase-0.3.0}/tests/feeds/test_static_arrivals.py +22 -0
  59. {aiomobilitydatabase-0.2.0 → aiomobilitydatabase-0.3.0}/tests/feeds/test_static_build.py +18 -5
  60. aiomobilitydatabase-0.3.0/tests/feeds/test_static_frequencies.py +232 -0
  61. aiomobilitydatabase-0.3.0/tests/feeds/test_static_trips.py +172 -0
  62. {aiomobilitydatabase-0.2.0 → aiomobilitydatabase-0.3.0}/tests/feeds/test_station_grouping.py +11 -2
  63. {aiomobilitydatabase-0.2.0 → aiomobilitydatabase-0.3.0}/tests/feeds/test_transit_arrivals.py +109 -4
  64. aiomobilitydatabase-0.3.0/tests/feeds/test_transit_frequencies.py +168 -0
  65. {aiomobilitydatabase-0.2.0 → aiomobilitydatabase-0.3.0}/tests/feeds/test_transit_resolution.py +1 -1
  66. aiomobilitydatabase-0.3.0/tests/feeds/test_transit_trips.py +299 -0
  67. {aiomobilitydatabase-0.2.0 → aiomobilitydatabase-0.3.0}/tests/feeds/test_transit_vehicles_alerts.py +20 -0
  68. {aiomobilitydatabase-0.2.0 → aiomobilitydatabase-0.3.0}/tests/mock_server.py +17 -1
  69. aiomobilitydatabase-0.2.0/scripts/generate_rt_fixtures.py +0 -160
  70. aiomobilitydatabase-0.2.0/src/aiomobilitydatabase/feeds/models.py +0 -170
  71. aiomobilitydatabase-0.2.0/src/aiomobilitydatabase/feeds/rt.py +0 -302
  72. aiomobilitydatabase-0.2.0/src/aiomobilitydatabase/feeds/static_index.py +0 -661
  73. aiomobilitydatabase-0.2.0/src/aiomobilitydatabase/feeds/transit.py +0 -482
  74. aiomobilitydatabase-0.2.0/tests/feeds/fixtures.py +0 -96
  75. aiomobilitydatabase-0.2.0/tests/feeds/test_properties.py +0 -1213
  76. aiomobilitydatabase-0.2.0/tests/feeds/test_rt.py +0 -205
  77. {aiomobilitydatabase-0.2.0 → aiomobilitydatabase-0.3.0}/.github/workflows/ci.yml +0 -0
  78. {aiomobilitydatabase-0.2.0 → aiomobilitydatabase-0.3.0}/.github/workflows/publish.yml +0 -0
  79. {aiomobilitydatabase-0.2.0 → aiomobilitydatabase-0.3.0}/.gitignore +0 -0
  80. {aiomobilitydatabase-0.2.0 → aiomobilitydatabase-0.3.0}/LICENSE +0 -0
  81. {aiomobilitydatabase-0.2.0 → aiomobilitydatabase-0.3.0}/pyproject.toml +0 -0
  82. {aiomobilitydatabase-0.2.0 → aiomobilitydatabase-0.3.0}/src/aiomobilitydatabase/__init__.py +0 -0
  83. {aiomobilitydatabase-0.2.0 → aiomobilitydatabase-0.3.0}/src/aiomobilitydatabase/client.py +0 -0
  84. {aiomobilitydatabase-0.2.0 → aiomobilitydatabase-0.3.0}/src/aiomobilitydatabase/exceptions.py +0 -0
  85. {aiomobilitydatabase-0.2.0 → aiomobilitydatabase-0.3.0}/src/aiomobilitydatabase/feeds/const.py +0 -0
  86. {aiomobilitydatabase-0.2.0 → aiomobilitydatabase-0.3.0}/src/aiomobilitydatabase/feeds/exceptions.py +0 -0
  87. {aiomobilitydatabase-0.2.0 → aiomobilitydatabase-0.3.0}/src/aiomobilitydatabase/feeds/geo.py +0 -0
  88. {aiomobilitydatabase-0.2.0 → aiomobilitydatabase-0.3.0}/src/aiomobilitydatabase/models.py +0 -0
  89. {aiomobilitydatabase-0.2.0 → aiomobilitydatabase-0.3.0}/src/aiomobilitydatabase/py.typed +0 -0
  90. {aiomobilitydatabase-0.2.0 → aiomobilitydatabase-0.3.0}/tests/__init__.py +0 -0
  91. {aiomobilitydatabase-0.2.0 → aiomobilitydatabase-0.3.0}/tests/conftest.py +0 -0
  92. {aiomobilitydatabase-0.2.0 → aiomobilitydatabase-0.3.0}/tests/feeds/__init__.py +0 -0
  93. {aiomobilitydatabase-0.2.0 → aiomobilitydatabase-0.3.0}/tests/feeds/conftest.py +0 -0
  94. {aiomobilitydatabase-0.2.0 → aiomobilitydatabase-0.3.0}/tests/feeds/data/catalog/gbfs_feed.json +0 -0
  95. {aiomobilitydatabase-0.2.0 → aiomobilitydatabase-0.3.0}/tests/feeds/data/catalog/gtfs_feed.json +0 -0
  96. {aiomobilitydatabase-0.2.0 → aiomobilitydatabase-0.3.0}/tests/feeds/data/catalog/gtfs_rt_feed.json +0 -0
  97. {aiomobilitydatabase-0.2.0 → aiomobilitydatabase-0.3.0}/tests/feeds/data/catalog/token_response.json +0 -0
  98. {aiomobilitydatabase-0.2.0 → aiomobilitydatabase-0.3.0}/tests/feeds/data/gbfs/station_status_23.json +0 -0
  99. {aiomobilitydatabase-0.2.0 → aiomobilitydatabase-0.3.0}/tests/feeds/data/gbfs/system_info_23.json +0 -0
  100. {aiomobilitydatabase-0.2.0 → aiomobilitydatabase-0.3.0}/tests/feeds/data/gbfs/system_info_30.json +0 -0
  101. {aiomobilitydatabase-0.2.0 → aiomobilitydatabase-0.3.0}/tests/feeds/data/gtfs/agency.txt +0 -0
  102. {aiomobilitydatabase-0.2.0 → aiomobilitydatabase-0.3.0}/tests/feeds/data/gtfs/calendar.txt +0 -0
  103. {aiomobilitydatabase-0.2.0 → aiomobilitydatabase-0.3.0}/tests/feeds/data/gtfs/calendar_dates.txt +0 -0
  104. {aiomobilitydatabase-0.2.0 → aiomobilitydatabase-0.3.0}/tests/feeds/data/gtfs/routes.txt +0 -0
  105. {aiomobilitydatabase-0.2.0 → aiomobilitydatabase-0.3.0}/tests/feeds/data/gtfs/stop_times.txt +0 -0
  106. {aiomobilitydatabase-0.2.0 → aiomobilitydatabase-0.3.0}/tests/feeds/data/gtfs/stops.txt +0 -0
  107. {aiomobilitydatabase-0.2.0 → aiomobilitydatabase-0.3.0}/tests/feeds/data/gtfs/trips.txt +0 -0
  108. {aiomobilitydatabase-0.2.0 → aiomobilitydatabase-0.3.0}/tests/feeds/data/rt/added_trips_s1.pb +0 -0
  109. {aiomobilitydatabase-0.2.0 → aiomobilitydatabase-0.3.0}/tests/feeds/data/rt/alerts.pb +0 -0
  110. {aiomobilitydatabase-0.2.0 → aiomobilitydatabase-0.3.0}/tests/feeds/data/rt/trip_updates_baseline.pb +0 -0
  111. {aiomobilitydatabase-0.2.0 → aiomobilitydatabase-0.3.0}/tests/feeds/data/rt/trip_updates_t1_delayed.pb +0 -0
  112. {aiomobilitydatabase-0.2.0 → aiomobilitydatabase-0.3.0}/tests/feeds/data/rt/vehicle_positions.pb +0 -0
  113. {aiomobilitydatabase-0.2.0 → aiomobilitydatabase-0.3.0}/tests/feeds/test_client.py +0 -0
  114. {aiomobilitydatabase-0.2.0 → aiomobilitydatabase-0.3.0}/tests/feeds/test_exceptions.py +0 -0
  115. {aiomobilitydatabase-0.2.0 → aiomobilitydatabase-0.3.0}/tests/feeds/test_extra_import_guard.py +0 -0
  116. {aiomobilitydatabase-0.2.0 → aiomobilitydatabase-0.3.0}/tests/feeds/test_geo.py +0 -0
  117. {aiomobilitydatabase-0.2.0 → aiomobilitydatabase-0.3.0}/tests/fixtures.py +0 -0
  118. {aiomobilitydatabase-0.2.0 → aiomobilitydatabase-0.3.0}/tests/test_catalog_properties.py +0 -0
  119. {aiomobilitydatabase-0.2.0 → aiomobilitydatabase-0.3.0}/tests/test_client_request.py +0 -0
  120. {aiomobilitydatabase-0.2.0 → aiomobilitydatabase-0.3.0}/tests/test_client_session.py +0 -0
  121. {aiomobilitydatabase-0.2.0 → aiomobilitydatabase-0.3.0}/tests/test_client_token.py +0 -0
  122. {aiomobilitydatabase-0.2.0 → aiomobilitydatabase-0.3.0}/tests/test_endpoints.py +0 -0
  123. {aiomobilitydatabase-0.2.0 → aiomobilitydatabase-0.3.0}/tests/test_exceptions.py +0 -0
  124. {aiomobilitydatabase-0.2.0 → aiomobilitydatabase-0.3.0}/tests/test_models.py +0 -0
  125. {aiomobilitydatabase-0.2.0 → aiomobilitydatabase-0.3.0}/tests/test_params.py +0 -0
  126. {aiomobilitydatabase-0.2.0 → aiomobilitydatabase-0.3.0}/tests/test_public_api.py +0 -0
@@ -9,5 +9,8 @@ repos:
9
9
  rev: v5.0.0
10
10
  hooks:
11
11
  - id: end-of-file-fixer
12
+ # Keep Google's canonical sample feed byte-identical to upstream.
13
+ exclude: ^tests/feeds/data/sample_feed/.*\.txt$
12
14
  - id: trailing-whitespace
15
+ exclude: ^tests/feeds/data/sample_feed/.*\.txt$
13
16
  - id: check-yaml
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aiomobilitydatabase
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Async Python client for the Mobility Database catalog API
5
5
  Project-URL: Source Code, https://github.com/raman325/aiomobilitydatabase
6
6
  Project-URL: Bug Reports, https://github.com/raman325/aiomobilitydatabase/issues
@@ -33,8 +33,9 @@ The core package wraps the catalog's
33
33
  with a fully typed asyncio client: search feeds, look up their metadata, find the
34
34
  hosted dataset URL. An optional `feeds` layer goes one step further and consumes
35
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.
36
+ arrivals, origin→destination trip queries, vehicle positions, service alerts,
37
+ GBFS stations and vehicles — giving you a feed ID (or a plain feed URL) in and
38
+ typed snapshots out.
38
39
 
39
40
  ## Installation
40
41
 
@@ -65,6 +66,9 @@ access tokens automatically (including proactive refresh before expiry). Both
65
66
  `MobilityDatabaseClient` and `MobilityFeedsClient` take the same refresh token —
66
67
  the feeds client wraps a catalog client internally and shares its authentication.
67
68
 
69
+ The token is only required for catalog operations: `MobilityFeedsClient`'s
70
+ [direct-URL methods](#direct-urls-no-catalog-account) work without one.
71
+
68
72
  ## Quick start — catalog
69
73
 
70
74
  ```python
@@ -116,10 +120,11 @@ async def main() -> None:
116
120
  # api_key authenticates with the PRODUCER (distinct from your catalog
117
121
  # refresh token); on_progress reports download/index build progress
118
122
  # for the first (uncached) fetch of a feed's static dataset.
123
+ # (p.fraction is None when the server doesn't announce a total.)
119
124
  transit = await client.get_transit_feed(
120
125
  "mdb-100",
121
126
  api_key="PRODUCER_API_KEY",
122
- on_progress=lambda p: print(f"{p.phase}: {p.fraction:.0%}"),
127
+ on_progress=lambda p: print(p.phase, p.fraction),
123
128
  )
124
129
 
125
130
  # Picker-style helpers: stops in a zone, routes serving a stop.
@@ -138,6 +143,15 @@ async def main() -> None:
138
143
  live = "live" if arrival.realtime else "scheduled"
139
144
  print(f"{arrival.route_name} -> {arrival.headsign}: {when} ({live})")
140
145
 
146
+ # Origin→destination: the next departures from stop A on trips that
147
+ # later reach stop B, with the same realtime overlay. is_first/is_last
148
+ # flag the first/last such departure of the service day.
149
+ trips = await transit.upcoming_trips(
150
+ nearby_stops[0].id, nearby_stops[1].id, limit=2
151
+ )
152
+ for trip in trips:
153
+ print(trip.route_name, trip.scheduled_departure, trip.is_last)
154
+
141
155
  vehicles = await transit.get_vehicles()
142
156
  alerts = await transit.get_alerts()
143
157
 
@@ -150,24 +164,108 @@ async def main() -> None:
150
164
  asyncio.run(main())
151
165
  ```
152
166
 
167
+ ### Direct URLs (no catalog account)
168
+
169
+ If you already know your feed URLs, you can skip the catalog — and the
170
+ refresh token — entirely. `get_transit_feed_from_urls` and
171
+ `get_gbfs_feed_from_url` return the exact same handle objects as the
172
+ catalog methods, so everything above works unchanged:
173
+
174
+ ```python
175
+ import asyncio
176
+
177
+ from aiomobilitydatabase.feeds import MobilityFeedsClient
178
+
179
+
180
+ async def main() -> None:
181
+ # No refresh token: only catalog operations need one.
182
+ async with MobilityFeedsClient(cache_dir="/path/to/cache") as client:
183
+ transit = await client.get_transit_feed_from_urls(
184
+ "https://agency.example/gtfs.zip",
185
+ trip_updates_urls=["https://agency.example/gtfs-rt/trip-updates.pb"],
186
+ vehicle_positions_urls=["https://agency.example/gtfs-rt/positions.pb"],
187
+ headers={"Authorization": "Bearer PRODUCER_TOKEN"}, # optional
188
+ )
189
+ arrivals = await transit.get_arrivals([transit.stops[0].id])
190
+
191
+ bikes = await client.get_gbfs_feed_from_url(
192
+ "https://bikes.example/gbfs/gbfs.json"
193
+ )
194
+ stations = await bikes.get_stations()
195
+
196
+
197
+ asyncio.run(main())
198
+ ```
199
+
200
+ Notes on direct mode:
201
+
202
+ - Catalog static datasets are served from the Mobility Database's own
203
+ storage; direct mode fetches **your** URL, with the optional `headers`
204
+ applied to the static download and every GTFS-RT/GBFS fetch the handle
205
+ makes.
206
+ - RT URLs are declared per layer (`trip_updates_urls`,
207
+ `vehicle_positions_urls`, `service_alerts_urls`), so each operation only
208
+ fetches sources that can serve it. A combined feed listed under several
209
+ layers is deduplicated and fetched once per operation; if you don't know
210
+ a producer's layer split, pass the same URL to every layer.
211
+ - Dataset identity comes from HTTP validators (`ETag`, then
212
+ `Last-Modified`, via a `HEAD` probe) instead of catalog dataset IDs;
213
+ servers offering neither fall back to hashing the downloaded bytes, so
214
+ `refresh_static()` still only rebuilds on real changes. The cache key is
215
+ derived from the static URL (`url-<hash>`, exposed as
216
+ `transit.static_feed_id` and accepted by `purge_cache()`), and
217
+ `transit.static_dataset` is `None` (there is no catalog metadata).
218
+ - Accessing `client.catalog` (or any catalog-backed method) on a tokenless
219
+ client raises `MobilityDatabaseError`.
220
+
153
221
  ## How it works
154
222
 
155
223
  - **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.
224
+ SQLite (agencies, stops, routes, trips, stop_times, calendars, frequencies,
225
+ feed_info) in a worker thread — the event loop is never blocked. Arrivals
226
+ work with or without realtime coverage; GTFS-RT TripUpdates overlay delays,
227
+ cancellations, and added trips when present, with cancellation always
228
+ winning over stale predictions.
229
+ - **frequencies.txt is materialized at build time**: headway-based trips
230
+ (common for metro/BRT) expand into concrete repetitions under synthetic
231
+ `{trip_id}#{start_secs}` ids, so every schedule query — arrivals,
232
+ origin→destination, routes-serving — sees them as ordinary trips.
233
+ - **Spec-correct realtime matching**: GTFS-RT updates are matched by
234
+ `(trip_id, start_date, start_time)`, so an update addresses exactly one
235
+ repetition of a frequency trip and exactly one service-day instance — a
236
+ "trip X is canceled tomorrow" posting never cancels today's run. A
237
+ StopTimeUpdate's delay propagates to subsequent stops until newer
238
+ information, `NO_DATA` makes a stop schedule-only, `SKIPPED` suppresses it
239
+ (and any origin→destination row boarding or alighting there), and the
240
+ trip-level delay covers stops no StopTimeUpdate reaches.
241
+ - **The full descriptive surface is exposed, typed by shape**: closed GTFS
242
+ vocabularies are enums (`WheelchairAccess`, `BikesAllowed`,
243
+ `PickupDropOffType`, `StopLocationType`, alert cause/effect/severity,
244
+ vehicle status/congestion/occupancy), `timepoint` is a bool with the spec's
245
+ absent-means-exact default, and open vocabularies (`route_type`,
246
+ `direction_id`) stay raw ints. Out-of-vocabulary values degrade to `None` —
247
+ descriptive metadata never fails a build. Consumers decide what to keep.
248
+ - **Static metadata accessors**: `transit.agencies`, `transit.feed_info`
249
+ (publisher, version, validity dates — useful for staleness checks), and
250
+ `headsigns_serving()` alongside the stop/route helpers.
251
+ - **Alert scoping**: `ServiceAlert` carries `route_ids`, `stop_ids`, and
252
+ `trip_ids`; an alert is agency-wide only when all three are empty.
253
+ `is_active(at)` evaluates its active periods.
254
+ - **GBFS extras**: `rental_uris` deep links on stations and vehicles,
255
+ `get_system_info()`, and TTL-based document caching.
256
+ - **`cache_dir` strongly recommended**: the static index is cached on disk
257
+ keyed by feed, validated against the dataset ID, so restarts are instant and
258
+ rebuilds only happen when the agency publishes a new dataset
259
+ (`await transit.refresh_static()` — call it daily; it swaps in the new index
260
+ only after it's built, so lookups never see a half-built database). Without
261
+ a `cache_dir` the index is built in memory on every startup.
166
262
  - **Pull, not push**: `TransitFeedHandle`/`GbfsFeedHandle` return snapshots on
167
263
  demand — there's no built-in polling loop or scheduler. Bring your own (e.g.
168
264
  Home Assistant's `DataUpdateCoordinator`).
169
265
  - **`purge_cache()`** deletes a feed's cached static data (or all feeds' when
170
266
  called with no argument) — call it on cleanup/removal of a configured feed.
267
+ `transit.close()` releases a handle's SQLite connection — call it when you
268
+ are done with a handle; the client's `close()` does not do it for you.
171
269
  - **Producer authentication**: pass `api_key=` to `get_transit_feed()`; it is
172
270
  applied per the catalog's `authentication_type` (query parameter or header) —
173
271
  distinct from the refresh token used to authenticate with the catalog itself.
@@ -209,21 +307,28 @@ feeds fetches (GTFS-RT polls, GBFS documents, dataset zip downloads) when
209
307
  passed to `MobilityFeedsClient`:
210
308
 
211
309
  ```python
310
+ import asyncio
311
+
212
312
  import aiohttp
213
313
 
214
314
  from aiomobilitydatabase import MobilityDatabaseClient
215
315
 
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
316
+
317
+ async def main() -> None:
318
+ async with aiohttp.ClientSession() as session:
319
+ client = MobilityDatabaseClient("YOUR_REFRESH_TOKEN", session)
320
+ try:
321
+ metadata = await client.get_metadata()
322
+ finally:
323
+ await client.close() # session remains open; you own it
324
+
325
+
326
+ asyncio.run(main())
222
327
  ```
223
328
 
224
329
  ## Testing methodology
225
330
 
226
- The suite (174 tests) combines example-based and property-based testing:
331
+ The suite combines example-based, property-based, and conformance testing:
227
332
 
228
333
  - **Example-based tests** cover the catalog's endpoint methods and the feeds
229
334
  layer's client/transit/GBFS/static-index modules against a real
@@ -240,6 +345,11 @@ The suite (174 tests) combines example-based and property-based testing:
240
345
  arithmetic against a naive oracle, calendar/calendar_dates service-id
241
346
  resolution, DST-transition handling) — this is where the original
242
347
  hardcoded `±1 day` scan-window bug was found;
348
+ - frequencies.txt materialization (arithmetic-progression oracles,
349
+ descriptor carry-through), GTFS-RT delay propagation (an independent
350
+ piecewise oracle over generated StopTimeUpdate sets, run against the pure
351
+ resolver at volume), and `start_date`/`start_time` instance matching over
352
+ windows containing two service-day instances of the same trip;
243
353
  - the circular-zone geometry (`in_circle`'s bbox prefilter is checked
244
354
  against the exact haversine distance at generated boundary points);
245
355
  - GBFS field parsing (`_localized`, `_version_key`, station/vehicle merging)
@@ -253,6 +363,10 @@ The suite (174 tests) combines example-based and property-based testing:
253
363
 
254
364
  Hypothesis is a `dev` dependency and part of the default suite (no separate
255
365
  opt-in marker); `.hypothesis/` (its example database) is gitignored.
366
+ - **Conformance tests** (`tests/feeds/test_sample_feed.py`) run the index
367
+ against Google's canonical GTFS sample feed — the feed the rest of the GTFS
368
+ ecosystem validates against — with hand-computed expected schedules,
369
+ frequency repetitions, and calendar exceptions pinned from the raw CSVs.
256
370
 
257
371
  ## Development
258
372
 
@@ -9,8 +9,9 @@ The core package wraps the catalog's
9
9
  with a fully typed asyncio client: search feeds, look up their metadata, find the
10
10
  hosted dataset URL. An optional `feeds` layer goes one step further and consumes
11
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.
12
+ arrivals, origin→destination trip queries, vehicle positions, service alerts,
13
+ GBFS stations and vehicles — giving you a feed ID (or a plain feed URL) in and
14
+ typed snapshots out.
14
15
 
15
16
  ## Installation
16
17
 
@@ -41,6 +42,9 @@ access tokens automatically (including proactive refresh before expiry). Both
41
42
  `MobilityDatabaseClient` and `MobilityFeedsClient` take the same refresh token —
42
43
  the feeds client wraps a catalog client internally and shares its authentication.
43
44
 
45
+ The token is only required for catalog operations: `MobilityFeedsClient`'s
46
+ [direct-URL methods](#direct-urls-no-catalog-account) work without one.
47
+
44
48
  ## Quick start — catalog
45
49
 
46
50
  ```python
@@ -92,10 +96,11 @@ async def main() -> None:
92
96
  # api_key authenticates with the PRODUCER (distinct from your catalog
93
97
  # refresh token); on_progress reports download/index build progress
94
98
  # for the first (uncached) fetch of a feed's static dataset.
99
+ # (p.fraction is None when the server doesn't announce a total.)
95
100
  transit = await client.get_transit_feed(
96
101
  "mdb-100",
97
102
  api_key="PRODUCER_API_KEY",
98
- on_progress=lambda p: print(f"{p.phase}: {p.fraction:.0%}"),
103
+ on_progress=lambda p: print(p.phase, p.fraction),
99
104
  )
100
105
 
101
106
  # Picker-style helpers: stops in a zone, routes serving a stop.
@@ -114,6 +119,15 @@ async def main() -> None:
114
119
  live = "live" if arrival.realtime else "scheduled"
115
120
  print(f"{arrival.route_name} -> {arrival.headsign}: {when} ({live})")
116
121
 
122
+ # Origin→destination: the next departures from stop A on trips that
123
+ # later reach stop B, with the same realtime overlay. is_first/is_last
124
+ # flag the first/last such departure of the service day.
125
+ trips = await transit.upcoming_trips(
126
+ nearby_stops[0].id, nearby_stops[1].id, limit=2
127
+ )
128
+ for trip in trips:
129
+ print(trip.route_name, trip.scheduled_departure, trip.is_last)
130
+
117
131
  vehicles = await transit.get_vehicles()
118
132
  alerts = await transit.get_alerts()
119
133
 
@@ -126,24 +140,108 @@ async def main() -> None:
126
140
  asyncio.run(main())
127
141
  ```
128
142
 
143
+ ### Direct URLs (no catalog account)
144
+
145
+ If you already know your feed URLs, you can skip the catalog — and the
146
+ refresh token — entirely. `get_transit_feed_from_urls` and
147
+ `get_gbfs_feed_from_url` return the exact same handle objects as the
148
+ catalog methods, so everything above works unchanged:
149
+
150
+ ```python
151
+ import asyncio
152
+
153
+ from aiomobilitydatabase.feeds import MobilityFeedsClient
154
+
155
+
156
+ async def main() -> None:
157
+ # No refresh token: only catalog operations need one.
158
+ async with MobilityFeedsClient(cache_dir="/path/to/cache") as client:
159
+ transit = await client.get_transit_feed_from_urls(
160
+ "https://agency.example/gtfs.zip",
161
+ trip_updates_urls=["https://agency.example/gtfs-rt/trip-updates.pb"],
162
+ vehicle_positions_urls=["https://agency.example/gtfs-rt/positions.pb"],
163
+ headers={"Authorization": "Bearer PRODUCER_TOKEN"}, # optional
164
+ )
165
+ arrivals = await transit.get_arrivals([transit.stops[0].id])
166
+
167
+ bikes = await client.get_gbfs_feed_from_url(
168
+ "https://bikes.example/gbfs/gbfs.json"
169
+ )
170
+ stations = await bikes.get_stations()
171
+
172
+
173
+ asyncio.run(main())
174
+ ```
175
+
176
+ Notes on direct mode:
177
+
178
+ - Catalog static datasets are served from the Mobility Database's own
179
+ storage; direct mode fetches **your** URL, with the optional `headers`
180
+ applied to the static download and every GTFS-RT/GBFS fetch the handle
181
+ makes.
182
+ - RT URLs are declared per layer (`trip_updates_urls`,
183
+ `vehicle_positions_urls`, `service_alerts_urls`), so each operation only
184
+ fetches sources that can serve it. A combined feed listed under several
185
+ layers is deduplicated and fetched once per operation; if you don't know
186
+ a producer's layer split, pass the same URL to every layer.
187
+ - Dataset identity comes from HTTP validators (`ETag`, then
188
+ `Last-Modified`, via a `HEAD` probe) instead of catalog dataset IDs;
189
+ servers offering neither fall back to hashing the downloaded bytes, so
190
+ `refresh_static()` still only rebuilds on real changes. The cache key is
191
+ derived from the static URL (`url-<hash>`, exposed as
192
+ `transit.static_feed_id` and accepted by `purge_cache()`), and
193
+ `transit.static_dataset` is `None` (there is no catalog metadata).
194
+ - Accessing `client.catalog` (or any catalog-backed method) on a tokenless
195
+ client raises `MobilityDatabaseError`.
196
+
129
197
  ## How it works
130
198
 
131
199
  - **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.
200
+ SQLite (agencies, stops, routes, trips, stop_times, calendars, frequencies,
201
+ feed_info) in a worker thread — the event loop is never blocked. Arrivals
202
+ work with or without realtime coverage; GTFS-RT TripUpdates overlay delays,
203
+ cancellations, and added trips when present, with cancellation always
204
+ winning over stale predictions.
205
+ - **frequencies.txt is materialized at build time**: headway-based trips
206
+ (common for metro/BRT) expand into concrete repetitions under synthetic
207
+ `{trip_id}#{start_secs}` ids, so every schedule query — arrivals,
208
+ origin→destination, routes-serving — sees them as ordinary trips.
209
+ - **Spec-correct realtime matching**: GTFS-RT updates are matched by
210
+ `(trip_id, start_date, start_time)`, so an update addresses exactly one
211
+ repetition of a frequency trip and exactly one service-day instance — a
212
+ "trip X is canceled tomorrow" posting never cancels today's run. A
213
+ StopTimeUpdate's delay propagates to subsequent stops until newer
214
+ information, `NO_DATA` makes a stop schedule-only, `SKIPPED` suppresses it
215
+ (and any origin→destination row boarding or alighting there), and the
216
+ trip-level delay covers stops no StopTimeUpdate reaches.
217
+ - **The full descriptive surface is exposed, typed by shape**: closed GTFS
218
+ vocabularies are enums (`WheelchairAccess`, `BikesAllowed`,
219
+ `PickupDropOffType`, `StopLocationType`, alert cause/effect/severity,
220
+ vehicle status/congestion/occupancy), `timepoint` is a bool with the spec's
221
+ absent-means-exact default, and open vocabularies (`route_type`,
222
+ `direction_id`) stay raw ints. Out-of-vocabulary values degrade to `None` —
223
+ descriptive metadata never fails a build. Consumers decide what to keep.
224
+ - **Static metadata accessors**: `transit.agencies`, `transit.feed_info`
225
+ (publisher, version, validity dates — useful for staleness checks), and
226
+ `headsigns_serving()` alongside the stop/route helpers.
227
+ - **Alert scoping**: `ServiceAlert` carries `route_ids`, `stop_ids`, and
228
+ `trip_ids`; an alert is agency-wide only when all three are empty.
229
+ `is_active(at)` evaluates its active periods.
230
+ - **GBFS extras**: `rental_uris` deep links on stations and vehicles,
231
+ `get_system_info()`, and TTL-based document caching.
232
+ - **`cache_dir` strongly recommended**: the static index is cached on disk
233
+ keyed by feed, validated against the dataset ID, so restarts are instant and
234
+ rebuilds only happen when the agency publishes a new dataset
235
+ (`await transit.refresh_static()` — call it daily; it swaps in the new index
236
+ only after it's built, so lookups never see a half-built database). Without
237
+ a `cache_dir` the index is built in memory on every startup.
142
238
  - **Pull, not push**: `TransitFeedHandle`/`GbfsFeedHandle` return snapshots on
143
239
  demand — there's no built-in polling loop or scheduler. Bring your own (e.g.
144
240
  Home Assistant's `DataUpdateCoordinator`).
145
241
  - **`purge_cache()`** deletes a feed's cached static data (or all feeds' when
146
242
  called with no argument) — call it on cleanup/removal of a configured feed.
243
+ `transit.close()` releases a handle's SQLite connection — call it when you
244
+ are done with a handle; the client's `close()` does not do it for you.
147
245
  - **Producer authentication**: pass `api_key=` to `get_transit_feed()`; it is
148
246
  applied per the catalog's `authentication_type` (query parameter or header) —
149
247
  distinct from the refresh token used to authenticate with the catalog itself.
@@ -185,21 +283,28 @@ feeds fetches (GTFS-RT polls, GBFS documents, dataset zip downloads) when
185
283
  passed to `MobilityFeedsClient`:
186
284
 
187
285
  ```python
286
+ import asyncio
287
+
188
288
  import aiohttp
189
289
 
190
290
  from aiomobilitydatabase import MobilityDatabaseClient
191
291
 
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
292
+
293
+ async def main() -> None:
294
+ async with aiohttp.ClientSession() as session:
295
+ client = MobilityDatabaseClient("YOUR_REFRESH_TOKEN", session)
296
+ try:
297
+ metadata = await client.get_metadata()
298
+ finally:
299
+ await client.close() # session remains open; you own it
300
+
301
+
302
+ asyncio.run(main())
198
303
  ```
199
304
 
200
305
  ## Testing methodology
201
306
 
202
- The suite (174 tests) combines example-based and property-based testing:
307
+ The suite combines example-based, property-based, and conformance testing:
203
308
 
204
309
  - **Example-based tests** cover the catalog's endpoint methods and the feeds
205
310
  layer's client/transit/GBFS/static-index modules against a real
@@ -216,6 +321,11 @@ The suite (174 tests) combines example-based and property-based testing:
216
321
  arithmetic against a naive oracle, calendar/calendar_dates service-id
217
322
  resolution, DST-transition handling) — this is where the original
218
323
  hardcoded `±1 day` scan-window bug was found;
324
+ - frequencies.txt materialization (arithmetic-progression oracles,
325
+ descriptor carry-through), GTFS-RT delay propagation (an independent
326
+ piecewise oracle over generated StopTimeUpdate sets, run against the pure
327
+ resolver at volume), and `start_date`/`start_time` instance matching over
328
+ windows containing two service-day instances of the same trip;
219
329
  - the circular-zone geometry (`in_circle`'s bbox prefilter is checked
220
330
  against the exact haversine distance at generated boundary points);
221
331
  - GBFS field parsing (`_localized`, `_version_key`, station/vehicle merging)
@@ -229,6 +339,10 @@ The suite (174 tests) combines example-based and property-based testing:
229
339
 
230
340
  Hypothesis is a `dev` dependency and part of the default suite (no separate
231
341
  opt-in marker); `.hypothesis/` (its example database) is gitignored.
342
+ - **Conformance tests** (`tests/feeds/test_sample_feed.py`) run the index
343
+ against Google's canonical GTFS sample feed — the feed the rest of the GTFS
344
+ ecosystem validates against — with hand-computed expected schedules,
345
+ frequency repetitions, and calendar exceptions pinned from the raw CSVs.
232
346
 
233
347
  ## Development
234
348