aiomobilitydatabase 0.3.0__tar.gz → 0.4.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 (123) hide show
  1. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/PKG-INFO +65 -9
  2. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/README.md +63 -7
  3. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/src/aiomobilitydatabase/const.py +1 -1
  4. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/src/aiomobilitydatabase/feeds/__init__.py +2 -0
  5. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/src/aiomobilitydatabase/feeds/client.py +31 -7
  6. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/src/aiomobilitydatabase/feeds/gbfs.py +152 -43
  7. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/src/aiomobilitydatabase/feeds/models.py +30 -0
  8. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/src/aiomobilitydatabase/feeds/rt.py +7 -3
  9. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/src/aiomobilitydatabase/feeds/static_index.py +175 -55
  10. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/src/aiomobilitydatabase/feeds/transit.py +469 -289
  11. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/test_client.py +107 -0
  12. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/test_direct_urls.py +5 -5
  13. aiomobilitydatabase-0.4.0/tests/feeds/test_gbfs_stations.py +453 -0
  14. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/test_gbfs_vehicles.py +70 -0
  15. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/test_models.py +40 -0
  16. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/test_properties.py +3528 -427
  17. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/test_rt.py +16 -0
  18. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/test_sample_feed.py +3 -3
  19. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/test_static_arrivals.py +34 -0
  20. aiomobilitydatabase-0.4.0/tests/feeds/test_static_build.py +596 -0
  21. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/test_static_trips.py +12 -0
  22. aiomobilitydatabase-0.4.0/tests/feeds/test_transit_arrivals.py +482 -0
  23. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/test_transit_frequencies.py +9 -8
  24. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/test_transit_trips.py +46 -6
  25. aiomobilitydatabase-0.4.0/tests/feeds/test_transit_vehicles_alerts.py +579 -0
  26. aiomobilitydatabase-0.4.0/tests/test_catalog_properties.py +383 -0
  27. aiomobilitydatabase-0.3.0/tests/feeds/test_gbfs_stations.py +0 -205
  28. aiomobilitydatabase-0.3.0/tests/feeds/test_static_build.py +0 -235
  29. aiomobilitydatabase-0.3.0/tests/feeds/test_transit_arrivals.py +0 -253
  30. aiomobilitydatabase-0.3.0/tests/feeds/test_transit_vehicles_alerts.py +0 -279
  31. aiomobilitydatabase-0.3.0/tests/test_catalog_properties.py +0 -211
  32. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/.github/workflows/ci.yml +0 -0
  33. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/.github/workflows/publish.yml +0 -0
  34. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/.gitignore +0 -0
  35. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/.pre-commit-config.yaml +0 -0
  36. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/LICENSE +0 -0
  37. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/pyproject.toml +0 -0
  38. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/scripts/generate_rt_fixtures.py +0 -0
  39. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/src/aiomobilitydatabase/__init__.py +0 -0
  40. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/src/aiomobilitydatabase/client.py +0 -0
  41. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/src/aiomobilitydatabase/exceptions.py +0 -0
  42. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/src/aiomobilitydatabase/feeds/const.py +0 -0
  43. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/src/aiomobilitydatabase/feeds/exceptions.py +0 -0
  44. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/src/aiomobilitydatabase/feeds/geo.py +0 -0
  45. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/src/aiomobilitydatabase/models.py +0 -0
  46. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/src/aiomobilitydatabase/py.typed +0 -0
  47. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/__init__.py +0 -0
  48. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/conftest.py +0 -0
  49. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/__init__.py +0 -0
  50. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/conftest.py +0 -0
  51. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/catalog/gbfs_feed.json +0 -0
  52. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/catalog/gtfs_feed.json +0 -0
  53. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/catalog/gtfs_rt_feed.json +0 -0
  54. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/catalog/token_response.json +0 -0
  55. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/gbfs/discovery_23.json +0 -0
  56. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/gbfs/discovery_30.json +0 -0
  57. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/gbfs/free_bike_status_23.json +0 -0
  58. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/gbfs/station_info_23.json +0 -0
  59. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/gbfs/station_status_23.json +0 -0
  60. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/gbfs/system_info_23.json +0 -0
  61. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/gbfs/system_info_30.json +0 -0
  62. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/gbfs/vehicle_status_30.json +0 -0
  63. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/gtfs/agency.txt +0 -0
  64. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/gtfs/calendar.txt +0 -0
  65. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/gtfs/calendar_dates.txt +0 -0
  66. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/gtfs/routes.txt +0 -0
  67. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/gtfs/stop_times.txt +0 -0
  68. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/gtfs/stops.txt +0 -0
  69. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/gtfs/trips.txt +0 -0
  70. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/rt/added_trips_s1.pb +0 -0
  71. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/rt/alerts.pb +0 -0
  72. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/rt/alerts_trip_scoped.pb +0 -0
  73. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/rt/trip_updates_baseline.pb +0 -0
  74. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/rt/trip_updates_freq_bare_cancel.pb +0 -0
  75. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/rt/trip_updates_freq_canceled_tomorrow.pb +0 -0
  76. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/rt/trip_updates_freq_matched.pb +0 -0
  77. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/rt/trip_updates_freq_unmatched.pb +0 -0
  78. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/rt/trip_updates_t1_both_ends.pb +0 -0
  79. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/rt/trip_updates_t1_canceled.pb +0 -0
  80. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/rt/trip_updates_t1_canceled_tomorrow.pb +0 -0
  81. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/rt/trip_updates_t1_dated_tomorrow_delay.pb +0 -0
  82. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/rt/trip_updates_t1_delayed.pb +0 -0
  83. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/rt/trip_updates_t1_dest_arrival.pb +0 -0
  84. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/rt/trip_updates_t1_no_data_cut.pb +0 -0
  85. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/rt/trip_updates_t1_skip_s1.pb +0 -0
  86. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/rt/trip_updates_t1_skip_s2.pb +0 -0
  87. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/rt/trip_updates_t1_skip_s3.pb +0 -0
  88. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/rt/trip_updates_t1_trip_delay.pb +0 -0
  89. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/rt/vehicle_positions.pb +0 -0
  90. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/rt/vehicle_positions_status.pb +0 -0
  91. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/sample_feed/README.md +0 -0
  92. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/sample_feed/agency.txt +0 -0
  93. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/sample_feed/calendar.txt +0 -0
  94. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/sample_feed/calendar_dates.txt +0 -0
  95. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/sample_feed/fare_attributes.txt +0 -0
  96. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/sample_feed/fare_rules.txt +0 -0
  97. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/sample_feed/feed_info.txt +0 -0
  98. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/sample_feed/frequencies.txt +0 -0
  99. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/sample_feed/routes.txt +0 -0
  100. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/sample_feed/shapes.txt +0 -0
  101. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/sample_feed/stop_times.txt +0 -0
  102. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/sample_feed/stops.txt +0 -0
  103. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/sample_feed/transfers.txt +0 -0
  104. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/sample_feed/translations.txt +0 -0
  105. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/sample_feed/trips.txt +0 -0
  106. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/fixtures.py +0 -0
  107. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/test_descriptive_attributes.py +0 -0
  108. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/test_exceptions.py +0 -0
  109. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/test_extra_import_guard.py +0 -0
  110. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/test_geo.py +0 -0
  111. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/test_static_frequencies.py +0 -0
  112. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/test_station_grouping.py +0 -0
  113. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/test_transit_resolution.py +0 -0
  114. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/fixtures.py +0 -0
  115. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/mock_server.py +0 -0
  116. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/test_client_request.py +0 -0
  117. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/test_client_session.py +0 -0
  118. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/test_client_token.py +0 -0
  119. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/test_endpoints.py +0 -0
  120. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/test_exceptions.py +0 -0
  121. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/test_models.py +0 -0
  122. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/test_params.py +0 -0
  123. {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/test_public_api.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: aiomobilitydatabase
3
- Version: 0.3.0
3
+ Version: 0.4.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
@@ -104,7 +104,7 @@ aiomobilitydatabase[feeds]`.
104
104
  ```python
105
105
  import asyncio
106
106
 
107
- from aiomobilitydatabase.feeds import Circle, MobilityFeedsClient
107
+ from aiomobilitydatabase.feeds import ArrivalsQuery, Circle, MobilityFeedsClient
108
108
 
109
109
 
110
110
  async def main() -> None:
@@ -137,7 +137,13 @@ async def main() -> None:
137
137
 
138
138
  # Scheduled arrivals merged with realtime (delays, cancellations,
139
139
  # RT-added trips) when the feed publishes GTFS-RT TripUpdates.
140
- arrivals = await transit.get_arrivals([nearby_stops[0].id], limit=2)
140
+ # Pass several ArrivalsQuery objects to get several boards (each
141
+ # with its own stops, route/headsign filters, and limit) from one
142
+ # realtime fetch; the result has one list per query, in order.
143
+ # Delayed trips stay listed until their prediction passes.
144
+ [arrivals] = await transit.get_arrivals(
145
+ [ArrivalsQuery([nearby_stops[0].id], limit=2)]
146
+ )
141
147
  for arrival in arrivals:
142
148
  when = arrival.predicted_departure or arrival.scheduled_departure
143
149
  live = "live" if arrival.realtime else "scheduled"
@@ -174,7 +180,7 @@ catalog methods, so everything above works unchanged:
174
180
  ```python
175
181
  import asyncio
176
182
 
177
- from aiomobilitydatabase.feeds import MobilityFeedsClient
183
+ from aiomobilitydatabase.feeds import ArrivalsQuery, MobilityFeedsClient
178
184
 
179
185
 
180
186
  async def main() -> None:
@@ -186,7 +192,7 @@ async def main() -> None:
186
192
  vehicle_positions_urls=["https://agency.example/gtfs-rt/positions.pb"],
187
193
  headers={"Authorization": "Bearer PRODUCER_TOKEN"}, # optional
188
194
  )
189
- arrivals = await transit.get_arrivals([transit.stops[0].id])
195
+ [arrivals] = await transit.get_arrivals([ArrivalsQuery([transit.stops[0].id])])
190
196
 
191
197
  bikes = await client.get_gbfs_feed_from_url(
192
198
  "https://bikes.example/gbfs/gbfs.json"
@@ -226,6 +232,15 @@ Notes on direct mode:
226
232
  work with or without realtime coverage; GTFS-RT TripUpdates overlay delays,
227
233
  cancellations, and added trips when present, with cancellation always
228
234
  winning over stale predictions.
235
+ - **Arrivals are batched and delay-aware**: `get_arrivals()` takes a sequence
236
+ of `ArrivalsQuery` and returns one board per query from a single schedule
237
+ read and a single realtime fetch, so several stops with different
238
+ route/headsign filters cost one round trip instead of one each. Each query's
239
+ filters apply *before* its `limit`, and `limit` caps that board as a whole
240
+ rather than per GTFS stop — so a narrow filter can never come back empty
241
+ because busier routes used up the limit. A `grace` window (default one hour)
242
+ keeps a delayed trip listed past its scheduled time until its prediction
243
+ passes, while a schedule-only departure still drops at its scheduled time.
229
244
  - **frequencies.txt is materialized at build time**: headway-based trips
230
245
  (common for metro/BRT) expand into concrete repetitions under synthetic
231
246
  `{trip_id}#{start_secs}` ids, so every schedule query — arrivals,
@@ -245,6 +260,14 @@ Notes on direct mode:
245
260
  absent-means-exact default, and open vocabularies (`route_type`,
246
261
  `direction_id`) stay raw ints. Out-of-vocabulary values degrade to `None` —
247
262
  descriptive metadata never fails a build. Consumers decide what to keep.
263
+ - **Numbers are ASCII-strict, and strictness follows the field's role**: a
264
+ GTFS number must be plain ASCII digits, so a Unicode decimal (`٨`), a sign
265
+ prefix, or Python's `1_0` underscore grouping is never quietly reinterpreted
266
+ as a plausible value. A malformed **structural** cell — `stop_times` times,
267
+ `stop_sequence`, `headway_secs`, `exception_type`, calendar weekday flags —
268
+ fails the build, because a wrong departure time or a wrong call order is
269
+ worse than no feed at all. A malformed **descriptive** cell still degrades
270
+ to `None`.
248
271
  - **Static metadata accessors**: `transit.agencies`, `transit.feed_info`
249
272
  (publisher, version, validity dates — useful for staleness checks), and
250
273
  `headsigns_serving()` alongside the stop/route helpers.
@@ -257,13 +280,22 @@ Notes on direct mode:
257
280
  keyed by feed, validated against the dataset ID, so restarts are instant and
258
281
  rebuilds only happen when the agency publishes a new dataset
259
282
  (`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.
283
+ only after it's built, so lookups never see a half-built database, and it is
284
+ safe to call while queries are in flight: the swap waits for in-flight reads
285
+ to finish, and two overlapping refreshes run back to back rather than
286
+ double-building). A corrupt or unreadable cache is treated as a cache miss
287
+ and rebuilt rather than raising. Without a `cache_dir` the index is built in
288
+ memory on every startup.
262
289
  - **Pull, not push**: `TransitFeedHandle`/`GbfsFeedHandle` return snapshots on
263
290
  demand — there's no built-in polling loop or scheduler. Bring your own (e.g.
264
291
  Home Assistant's `DataUpdateCoordinator`).
265
292
  - **`purge_cache()`** deletes a feed's cached static data (or all feeds' when
266
293
  called with no argument) — call it on cleanup/removal of a configured feed.
294
+ A `feed_id` that is not a bare child name of the cache directory raises
295
+ `ValueError` instead of deleting anything, so an absolute path, a `..`
296
+ component, a symlinked entry, or a degenerate `""`/`"."` cannot take out
297
+ every feed's cache. Purging a feed with no cache directory is a no-op, so
298
+ repeated calls are idempotent.
267
299
  `transit.close()` releases a handle's SQLite connection — call it when you
268
300
  are done with a handle; the client's `close()` does not do it for you.
269
301
  - **Producer authentication**: pass `api_key=` to `get_transit_feed()`; it is
@@ -290,9 +322,17 @@ each define their own subtree beneath it, so you can catch broadly
290
322
  | `MobilityFeedsError` | feeds | Base class for all feeds errors (subclasses `MobilityDatabaseError`) |
291
323
  | `SourceConnectionError` | feeds | Producer/GBFS endpoint unreachable or errored (`.status` when HTTP) |
292
324
  | `SourceAuthenticationError` | feeds | Producer rejected the feed's `api_key` |
293
- | `FeedParseError` | feeds | Undecodable protobuf, malformed GBFS JSON, or unreadable GTFS zip |
325
+ | `FeedParseError` | feeds | Undecodable protobuf, malformed GBFS JSON or envelope, unreadable GTFS zip, or a malformed scalar in a structural GTFS column |
294
326
  | `StaticDataUnavailableError` | feeds | Feed has no usable hosted static dataset |
295
327
 
328
+ The GBFS and GTFS-RT parse paths are **total** with respect to this table:
329
+ any producer document, however malformed, either returns a typed result or
330
+ raises one of the exceptions above — never a bare `KeyError`, `TypeError`,
331
+ `ValueError` or `OverflowError` from inside the library. The one deliberate
332
+ exception is `purge_cache()`, which raises the builtin `ValueError` for a
333
+ `feed_id` it will not accept (see above), because that is a caller mistake
334
+ rather than a feed problem.
335
+
296
336
  Note the two independent auth flows: a `MobilityDatabaseAuthenticationError`
297
337
  means your **catalog refresh token** is invalid; a `SourceAuthenticationError`
298
338
  means the **producer** rejected the feed-specific `api_key` you passed to
@@ -356,6 +396,22 @@ The suite combines example-based, property-based, and conformance testing:
356
396
  and GTFS-RT protobuf parsing, both checked for **totality** — every
357
397
  generated input must produce a typed result or a documented
358
398
  `MobilityFeedsError` subclass, never an unhandled exception;
399
+ - the batched arrivals contract: that batching is pure query factorization
400
+ (`get_arrivals([q1, q2])` equals the two single-query calls), that a batch
401
+ costs exactly one realtime fetch however many queries it carries, that
402
+ route and headsign filters apply before the limit, and that every returned
403
+ row's effective departure is still ahead of `now`;
404
+ - the origin→destination query against an oracle built from the stored
405
+ stop-call table: the self-join's direction rule (no return-trip rows) and
406
+ the earliest-destination-call rule for loop trips, neither of which any
407
+ test asserted before;
408
+ - window monotonicity — widening `lookahead` yields a prefix and widening
409
+ `grace` yields a superset — which guards the scan-window regression class
410
+ directly, without a hand-built oracle;
411
+ - the cache as a round trip: a built index and the same index reopened from
412
+ disk answer every query identically, including the stored timezone, and
413
+ `purge_cache` removes exactly the named feed and nothing else for any
414
+ generated `feed_id`;
359
415
  - error-contract fuzzing for malformed GTFS zips, RT payloads, and GBFS
360
416
  documents (garbage bytes, wrong types, out-of-range values) — the
361
417
  contract under test is "raises `FeedParseError`/`SourceConnectionError`",
@@ -80,7 +80,7 @@ aiomobilitydatabase[feeds]`.
80
80
  ```python
81
81
  import asyncio
82
82
 
83
- from aiomobilitydatabase.feeds import Circle, MobilityFeedsClient
83
+ from aiomobilitydatabase.feeds import ArrivalsQuery, Circle, MobilityFeedsClient
84
84
 
85
85
 
86
86
  async def main() -> None:
@@ -113,7 +113,13 @@ async def main() -> None:
113
113
 
114
114
  # Scheduled arrivals merged with realtime (delays, cancellations,
115
115
  # RT-added trips) when the feed publishes GTFS-RT TripUpdates.
116
- arrivals = await transit.get_arrivals([nearby_stops[0].id], limit=2)
116
+ # Pass several ArrivalsQuery objects to get several boards (each
117
+ # with its own stops, route/headsign filters, and limit) from one
118
+ # realtime fetch; the result has one list per query, in order.
119
+ # Delayed trips stay listed until their prediction passes.
120
+ [arrivals] = await transit.get_arrivals(
121
+ [ArrivalsQuery([nearby_stops[0].id], limit=2)]
122
+ )
117
123
  for arrival in arrivals:
118
124
  when = arrival.predicted_departure or arrival.scheduled_departure
119
125
  live = "live" if arrival.realtime else "scheduled"
@@ -150,7 +156,7 @@ catalog methods, so everything above works unchanged:
150
156
  ```python
151
157
  import asyncio
152
158
 
153
- from aiomobilitydatabase.feeds import MobilityFeedsClient
159
+ from aiomobilitydatabase.feeds import ArrivalsQuery, MobilityFeedsClient
154
160
 
155
161
 
156
162
  async def main() -> None:
@@ -162,7 +168,7 @@ async def main() -> None:
162
168
  vehicle_positions_urls=["https://agency.example/gtfs-rt/positions.pb"],
163
169
  headers={"Authorization": "Bearer PRODUCER_TOKEN"}, # optional
164
170
  )
165
- arrivals = await transit.get_arrivals([transit.stops[0].id])
171
+ [arrivals] = await transit.get_arrivals([ArrivalsQuery([transit.stops[0].id])])
166
172
 
167
173
  bikes = await client.get_gbfs_feed_from_url(
168
174
  "https://bikes.example/gbfs/gbfs.json"
@@ -202,6 +208,15 @@ Notes on direct mode:
202
208
  work with or without realtime coverage; GTFS-RT TripUpdates overlay delays,
203
209
  cancellations, and added trips when present, with cancellation always
204
210
  winning over stale predictions.
211
+ - **Arrivals are batched and delay-aware**: `get_arrivals()` takes a sequence
212
+ of `ArrivalsQuery` and returns one board per query from a single schedule
213
+ read and a single realtime fetch, so several stops with different
214
+ route/headsign filters cost one round trip instead of one each. Each query's
215
+ filters apply *before* its `limit`, and `limit` caps that board as a whole
216
+ rather than per GTFS stop — so a narrow filter can never come back empty
217
+ because busier routes used up the limit. A `grace` window (default one hour)
218
+ keeps a delayed trip listed past its scheduled time until its prediction
219
+ passes, while a schedule-only departure still drops at its scheduled time.
205
220
  - **frequencies.txt is materialized at build time**: headway-based trips
206
221
  (common for metro/BRT) expand into concrete repetitions under synthetic
207
222
  `{trip_id}#{start_secs}` ids, so every schedule query — arrivals,
@@ -221,6 +236,14 @@ Notes on direct mode:
221
236
  absent-means-exact default, and open vocabularies (`route_type`,
222
237
  `direction_id`) stay raw ints. Out-of-vocabulary values degrade to `None` —
223
238
  descriptive metadata never fails a build. Consumers decide what to keep.
239
+ - **Numbers are ASCII-strict, and strictness follows the field's role**: a
240
+ GTFS number must be plain ASCII digits, so a Unicode decimal (`٨`), a sign
241
+ prefix, or Python's `1_0` underscore grouping is never quietly reinterpreted
242
+ as a plausible value. A malformed **structural** cell — `stop_times` times,
243
+ `stop_sequence`, `headway_secs`, `exception_type`, calendar weekday flags —
244
+ fails the build, because a wrong departure time or a wrong call order is
245
+ worse than no feed at all. A malformed **descriptive** cell still degrades
246
+ to `None`.
224
247
  - **Static metadata accessors**: `transit.agencies`, `transit.feed_info`
225
248
  (publisher, version, validity dates — useful for staleness checks), and
226
249
  `headsigns_serving()` alongside the stop/route helpers.
@@ -233,13 +256,22 @@ Notes on direct mode:
233
256
  keyed by feed, validated against the dataset ID, so restarts are instant and
234
257
  rebuilds only happen when the agency publishes a new dataset
235
258
  (`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.
259
+ only after it's built, so lookups never see a half-built database, and it is
260
+ safe to call while queries are in flight: the swap waits for in-flight reads
261
+ to finish, and two overlapping refreshes run back to back rather than
262
+ double-building). A corrupt or unreadable cache is treated as a cache miss
263
+ and rebuilt rather than raising. Without a `cache_dir` the index is built in
264
+ memory on every startup.
238
265
  - **Pull, not push**: `TransitFeedHandle`/`GbfsFeedHandle` return snapshots on
239
266
  demand — there's no built-in polling loop or scheduler. Bring your own (e.g.
240
267
  Home Assistant's `DataUpdateCoordinator`).
241
268
  - **`purge_cache()`** deletes a feed's cached static data (or all feeds' when
242
269
  called with no argument) — call it on cleanup/removal of a configured feed.
270
+ A `feed_id` that is not a bare child name of the cache directory raises
271
+ `ValueError` instead of deleting anything, so an absolute path, a `..`
272
+ component, a symlinked entry, or a degenerate `""`/`"."` cannot take out
273
+ every feed's cache. Purging a feed with no cache directory is a no-op, so
274
+ repeated calls are idempotent.
243
275
  `transit.close()` releases a handle's SQLite connection — call it when you
244
276
  are done with a handle; the client's `close()` does not do it for you.
245
277
  - **Producer authentication**: pass `api_key=` to `get_transit_feed()`; it is
@@ -266,9 +298,17 @@ each define their own subtree beneath it, so you can catch broadly
266
298
  | `MobilityFeedsError` | feeds | Base class for all feeds errors (subclasses `MobilityDatabaseError`) |
267
299
  | `SourceConnectionError` | feeds | Producer/GBFS endpoint unreachable or errored (`.status` when HTTP) |
268
300
  | `SourceAuthenticationError` | feeds | Producer rejected the feed's `api_key` |
269
- | `FeedParseError` | feeds | Undecodable protobuf, malformed GBFS JSON, or unreadable GTFS zip |
301
+ | `FeedParseError` | feeds | Undecodable protobuf, malformed GBFS JSON or envelope, unreadable GTFS zip, or a malformed scalar in a structural GTFS column |
270
302
  | `StaticDataUnavailableError` | feeds | Feed has no usable hosted static dataset |
271
303
 
304
+ The GBFS and GTFS-RT parse paths are **total** with respect to this table:
305
+ any producer document, however malformed, either returns a typed result or
306
+ raises one of the exceptions above — never a bare `KeyError`, `TypeError`,
307
+ `ValueError` or `OverflowError` from inside the library. The one deliberate
308
+ exception is `purge_cache()`, which raises the builtin `ValueError` for a
309
+ `feed_id` it will not accept (see above), because that is a caller mistake
310
+ rather than a feed problem.
311
+
272
312
  Note the two independent auth flows: a `MobilityDatabaseAuthenticationError`
273
313
  means your **catalog refresh token** is invalid; a `SourceAuthenticationError`
274
314
  means the **producer** rejected the feed-specific `api_key` you passed to
@@ -332,6 +372,22 @@ The suite combines example-based, property-based, and conformance testing:
332
372
  and GTFS-RT protobuf parsing, both checked for **totality** — every
333
373
  generated input must produce a typed result or a documented
334
374
  `MobilityFeedsError` subclass, never an unhandled exception;
375
+ - the batched arrivals contract: that batching is pure query factorization
376
+ (`get_arrivals([q1, q2])` equals the two single-query calls), that a batch
377
+ costs exactly one realtime fetch however many queries it carries, that
378
+ route and headsign filters apply before the limit, and that every returned
379
+ row's effective departure is still ahead of `now`;
380
+ - the origin→destination query against an oracle built from the stored
381
+ stop-call table: the self-join's direction rule (no return-trip rows) and
382
+ the earliest-destination-call rule for loop trips, neither of which any
383
+ test asserted before;
384
+ - window monotonicity — widening `lookahead` yields a prefix and widening
385
+ `grace` yields a superset — which guards the scan-window regression class
386
+ directly, without a hand-built oracle;
387
+ - the cache as a round trip: a built index and the same index reopened from
388
+ disk answer every query identically, including the stored timezone, and
389
+ `purge_cache` removes exactly the named feed and nothing else for any
390
+ generated `feed_id`;
335
391
  - error-contract fuzzing for malformed GTFS zips, RT payloads, and GBFS
336
392
  documents (garbage bytes, wrong types, out-of-range values) — the
337
393
  contract under test is "raises `FeedParseError`/`SourceConnectionError`",
@@ -1,6 +1,6 @@
1
1
  """Constants for aiomobilitydatabase."""
2
2
 
3
- __version__ = "0.3.0"
3
+ __version__ = "0.4.0"
4
4
 
5
5
  PROD_BASE_URL = "https://api.mobilitydatabase.org"
6
6
  QA_BASE_URL = "https://api-qa.mobilitydatabase.org"
@@ -27,6 +27,7 @@ from .models import (
27
27
  AlertCause,
28
28
  AlertEffect,
29
29
  AlertSeverity,
30
+ ArrivalsQuery,
30
31
  BikesAllowed,
31
32
  CongestionLevel,
32
33
  FeedInfo,
@@ -54,6 +55,7 @@ __all__ = [
54
55
  "AlertCause",
55
56
  "AlertEffect",
56
57
  "AlertSeverity",
58
+ "ArrivalsQuery",
57
59
  "BikesAllowed",
58
60
  "Circle",
59
61
  "CongestionLevel",
@@ -124,18 +124,42 @@ class MobilityFeedsClient:
124
124
  storage at a different feed). ``feed_id`` is either a catalog feed
125
125
  ID or a direct handle's url-derived ``url-<sha256(url)[:16]>`` key
126
126
  — exactly what :attr:`TransitFeedHandle.static_feed_id` returns in
127
- both cases. No-op without a cache_dir. Raises ``ValueError`` if
128
- ``feed_id`` would resolve outside the cache directory (e.g. an
129
- absolute path or one containing ``..`` components). Missing
130
- targets are silently skipped; other filesystem errors (e.g.
131
- permissions) propagate to the caller.
127
+ both cases. No-op without a cache_dir. Purging a feed that has no
128
+ cache directory is a silent no-op, so repeated calls are
129
+ idempotent; other filesystem errors (e.g. permissions) propagate
130
+ to the caller.
131
+
132
+ Raises ``ValueError`` unless ``feed_id`` resolves to a direct
133
+ child of the cache directory. That rejects absolute paths, ``..``
134
+ components and symlinked entries, but also the degenerate ids
135
+ (``""``, ``"."``) that resolve to the cache root itself — a
136
+ containment-only check accepts those, because every path is
137
+ relative to itself, and would delete every feed's cache.
132
138
  """
133
139
  if self.cache_dir is None:
134
140
  return
135
141
  if feed_id is not None:
142
+ candidate = Path(feed_id)
136
143
  target = self.cache_dir / feed_id
137
- if not target.resolve().is_relative_to(self.cache_dir.resolve()):
138
- raise ValueError(f"feed_id {feed_id!r} escapes the cache directory")
144
+ root = self.cache_dir.resolve()
145
+ # The input itself must be a bare child name. Resolving first
146
+ # and checking only the destination accepts ids that reach a
147
+ # feed the caller cannot name directly (an absolute path, or
148
+ # one routed through ``..``), and hands symlinked entries to
149
+ # rmtree, which rejects them with OSError.
150
+ invalid = (
151
+ candidate.is_absolute()
152
+ or candidate.name != feed_id
153
+ or target.is_symlink()
154
+ )
155
+ if not invalid:
156
+ resolved = target.resolve()
157
+ invalid = resolved == root or resolved.parent != root
158
+ if invalid:
159
+ raise ValueError(
160
+ f"feed_id {feed_id!r} escapes the cache directory: "
161
+ "it does not name a direct child"
162
+ )
139
163
  targets = [target]
140
164
  else:
141
165
  targets = [path for path in self.cache_dir.iterdir() if path.is_dir()]
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import math
5
6
  import time
6
7
  from collections.abc import Mapping
7
8
  from http import HTTPStatus
@@ -31,6 +32,20 @@ def _entry_text(entry: dict[str, Any]) -> str | None:
31
32
  return None if text is None else str(text)
32
33
 
33
34
 
35
+ def _record_id(value: Any) -> str | None:
36
+ """One record's own id as a string, or None when it has none.
37
+
38
+ ``str(value)`` would synthesize the literal id ``"None"`` for an
39
+ id-less record — and those synthetic ids COLLIDE across records,
40
+ corrupting any consumer that keys on them (an entity registry, say).
41
+ A blank id collides identically, so it is None too. Same motivation
42
+ as :func:`_entry_text`: never surface a stringified None.
43
+ """
44
+ if value is None:
45
+ return None
46
+ return str(value) or None
47
+
48
+
34
49
  def _localized(value: Any) -> str | None:
35
50
  """Normalize GBFS text: 3.x localized lists vs 2.x plain strings."""
36
51
  if value is None or isinstance(value, str):
@@ -69,6 +84,63 @@ def _rental_uris(value: Any) -> dict[str, str] | None:
69
84
  return uris or None
70
85
 
71
86
 
87
+ def _coordinate(value: Any) -> float | None:
88
+ """Normalize one ``lat``/``lon`` cell to a finite float, else None (unknown).
89
+
90
+ :func:`~.geo.in_circle` compares the value and takes its cosine, so a
91
+ string coordinate would raise and a NaN would answer every comparison
92
+ False; both are UNKNOWN instead. Numeric strings are accepted because
93
+ producers ship them, and an integer literal too large for a float
94
+ (JSON allows one of any size) is UNKNOWN rather than an OverflowError.
95
+ """
96
+ if isinstance(value, bool) or not isinstance(value, int | float | str):
97
+ return None
98
+ try:
99
+ coordinate = float(value)
100
+ except (OverflowError, ValueError):
101
+ return None
102
+ return coordinate if math.isfinite(coordinate) else None
103
+
104
+
105
+ def _vehicle_types(value: Any) -> dict[str, int] | None:
106
+ """Normalize a station's ``vehicle_types_available`` to id -> count.
107
+
108
+ Entries without a usable type id, and counts that aren't parseable as
109
+ an int (``int(None)`` used to raise), are dropped rather than
110
+ coerced; an absent count is the spec's 0. Anything that isn't a list
111
+ with at least one usable entry is None, i.e. "not published". A count
112
+ of ``1e999`` parses as inf, which int() refuses, so it is dropped too.
113
+ """
114
+ if not isinstance(value, list):
115
+ return None
116
+ types: dict[str, int] = {}
117
+ for entry in value:
118
+ if not isinstance(entry, Mapping):
119
+ continue
120
+ type_id = _record_id(entry.get("vehicle_type_id"))
121
+ try:
122
+ count = int(entry.get("count", 0))
123
+ except (OverflowError, TypeError, ValueError):
124
+ continue
125
+ if type_id is not None:
126
+ types[type_id] = count
127
+ return types or None
128
+
129
+
130
+ def _rows(document: Mapping[str, Any], key: str) -> list[Mapping[str, Any]]:
131
+ """Extract the ``data.<key>`` row list, keeping object-shaped rows only.
132
+
133
+ A GBFS document's envelope is producer data: ``data`` may be a list,
134
+ the row list a string, a row an int. Every non-conforming shape
135
+ degrades to "no rows" so the snapshot methods stay total.
136
+ """
137
+ data = document.get("data")
138
+ rows = data.get(key) if isinstance(data, Mapping) else None
139
+ if not isinstance(rows, list):
140
+ return []
141
+ return [row for row in rows if isinstance(row, Mapping)]
142
+
143
+
72
144
  def _as_bool(value: Any) -> bool | None:
73
145
  """Coerce a GBFS status flag to bool without lying on ambiguous input.
74
146
 
@@ -81,7 +153,26 @@ def _as_bool(value: Any) -> bool | None:
81
153
  return None
82
154
 
83
155
 
84
- def _endpoints_from_discovery(document: dict[str, Any]) -> dict[str, str]:
156
+ def _ttl_seconds(value: Any) -> float:
157
+ """Micro-cache lifetime from a document's ``ttl``, leniently.
158
+
159
+ A ttl that isn't a usable, finite, non-negative number degrades to 0
160
+ (no caching) like every other malformed scalar at this boundary: a
161
+ bad cache HINT must not fail a document that otherwise parsed. Plain
162
+ numeric strings are accepted because producers do ship them, and an
163
+ integer literal too large for a float degrades like any other
164
+ unusable ttl.
165
+ """
166
+ if isinstance(value, bool) or not isinstance(value, int | float | str):
167
+ return 0.0
168
+ try:
169
+ ttl = float(value)
170
+ except (OverflowError, ValueError):
171
+ return 0.0
172
+ return ttl if math.isfinite(ttl) and ttl > 0 else 0.0
173
+
174
+
175
+ def _endpoints_from_discovery(document: Any) -> dict[str, str]:
85
176
  """Resolve the name->url endpoint table from a GBFS discovery document.
86
177
 
87
178
  GBFS 3.x publishes ``data.feeds`` directly; 2.x nests the feed list
@@ -90,26 +181,33 @@ def _endpoints_from_discovery(document: dict[str, Any]) -> dict[str, str]:
90
181
  carries feeds, mirroring the fallback order of :func:`_localized`.
91
182
  The result feeds the same endpoint table the catalog path builds from
92
183
  version metadata, so every handle method works identically after this.
184
+
185
+ Total over arbitrary JSON: any document that yields no usable
186
+ name/url pair raises :class:`FeedParseError` — including an absent or
187
+ wrongly typed ``data``, so a malformed discovery document can never
188
+ surface as a KeyError/TypeError to a caller.
93
189
  """
94
- data = document["data"]
190
+ data = document.get("data") if isinstance(document, Mapping) else None
95
191
  feeds: Any = None
96
- if isinstance(data, dict):
192
+ if isinstance(data, Mapping):
97
193
  feeds = data.get("feeds")
98
- if feeds is None: # 2.x language-keyed layout
194
+ if not isinstance(feeds, list): # 2.x language-keyed layout
99
195
  candidates = [
100
196
  block
101
197
  for block in data.values()
102
- if isinstance(block, dict) and isinstance(block.get("feeds"), list)
198
+ if isinstance(block, Mapping) and isinstance(block.get("feeds"), list)
103
199
  ]
104
200
  preferred = data.get(GBFS_LANGUAGE_PREFERENCE)
105
- if isinstance(preferred, dict) and isinstance(preferred.get("feeds"), list):
201
+ if isinstance(preferred, Mapping) and isinstance(
202
+ preferred.get("feeds"), list
203
+ ):
106
204
  candidates.insert(0, preferred)
107
205
  if candidates:
108
206
  feeds = candidates[0]["feeds"]
109
207
  endpoints = {
110
208
  str(feed["name"]): str(feed["url"])
111
209
  for feed in (feeds if isinstance(feeds, list) else [])
112
- if isinstance(feed, dict) and feed.get("name") and feed.get("url")
210
+ if isinstance(feed, Mapping) and feed.get("name") and feed.get("url")
113
211
  }
114
212
  if not endpoints:
115
213
  raise FeedParseError("GBFS discovery document lists no usable feeds")
@@ -219,15 +317,25 @@ class GbfsFeedHandle:
219
317
  document = await self._fetch_json_document(
220
318
  self._client, url, self._headers, f"GBFS {name} endpoint URL"
221
319
  )
222
- ttl = float(document.get("ttl") or 0)
320
+ ttl = _ttl_seconds(document.get("ttl"))
223
321
  self._doc_cache[name] = (time.monotonic(), ttl, document)
224
322
  return document
225
323
 
226
324
  async def get_system_info(self) -> SystemInfo:
227
- """GBFS system information."""
325
+ """GBFS system information.
326
+
327
+ A document without a usable ``system_id`` raises
328
+ :class:`FeedParseError`: the id is this system's identity, and a
329
+ synthesized one would silently key consumer state to nothing.
330
+ """
228
331
  data = (await self._document("system_information"))["data"]
332
+ system_id = _record_id(
333
+ data.get("system_id") if isinstance(data, Mapping) else None
334
+ )
335
+ if system_id is None:
336
+ raise FeedParseError("GBFS system_information document has no system_id")
229
337
  return SystemInfo(
230
- system_id=str(data.get("system_id")),
338
+ system_id=system_id,
231
339
  name=_localized(data.get("name")),
232
340
  operator=_localized(data.get("operator")),
233
341
  timezone=data.get("timezone"),
@@ -239,37 +347,32 @@ class GbfsFeedHandle:
239
347
  With ``zone``, only stations inside the circle are returned (stations
240
348
  without coordinates are excluded when filtering) — this powers both
241
349
  the config-flow station multi-select and zone-scoped station sensors.
350
+ Information rows without a usable ``station_id`` are skipped: the
351
+ merge and the returned ``id`` both key on it.
242
352
  """
243
- info_rows = (await self._document("station_information"))["data"].get(
244
- "stations", []
245
- )
246
- status_rows = (await self._document("station_status"))["data"].get(
247
- "stations", []
248
- )
249
- status_by_id = {row.get("station_id"): row for row in status_rows}
353
+ info_rows = _rows(await self._document("station_information"), "stations")
354
+ status_rows = _rows(await self._document("station_status"), "stations")
355
+ status_by_id = {_record_id(row.get("station_id")): row for row in status_rows}
250
356
  stations: list[Station] = []
251
357
  for info in info_rows:
252
- if zone is not None:
253
- lat, lon = info.get("lat"), info.get("lon")
254
- if lat is None or lon is None or not in_circle(zone, lat, lon):
255
- continue
256
- station_id = info.get("station_id")
257
- status = status_by_id.get(station_id, {})
258
- types_list = status.get("vehicle_types_available")
259
- types = (
260
- {
261
- str(entry.get("vehicle_type_id")): int(entry.get("count", 0))
262
- for entry in types_list
263
- }
264
- if types_list
265
- else None
266
- )
358
+ latitude = _coordinate(info.get("lat"))
359
+ longitude = _coordinate(info.get("lon"))
360
+ if zone is not None and (
361
+ latitude is None
362
+ or longitude is None
363
+ or not in_circle(zone, latitude, longitude)
364
+ ):
365
+ continue
366
+ station_id = _record_id(info.get("station_id"))
367
+ if station_id is None:
368
+ continue
369
+ status: Mapping[str, Any] = status_by_id.get(station_id, {})
267
370
  stations.append(
268
371
  Station(
269
- id=str(station_id),
372
+ id=station_id,
270
373
  name=_localized(info.get("name")),
271
- latitude=info.get("lat"),
272
- longitude=info.get("lon"),
374
+ latitude=latitude,
375
+ longitude=longitude,
273
376
  capacity=info.get("capacity"),
274
377
  bikes_available=status.get(
275
378
  "num_bikes_available", status.get("num_vehicles_available")
@@ -277,7 +380,9 @@ class GbfsFeedHandle:
277
380
  docks_available=status.get("num_docks_available"),
278
381
  is_renting=_as_bool(status.get("is_renting")),
279
382
  is_returning=_as_bool(status.get("is_returning")),
280
- vehicle_types_available=types,
383
+ vehicle_types_available=_vehicle_types(
384
+ status.get("vehicle_types_available")
385
+ ),
281
386
  rental_uris=_rental_uris(info.get("rental_uris")),
282
387
  )
283
388
  )
@@ -289,29 +394,33 @@ class GbfsFeedHandle:
289
394
  Uses ``vehicle_status`` (GBFS 3.x) when published, else falls back to
290
395
  ``free_bike_status`` (2.x). Returns [] for docked-only systems.
291
396
  Filtering is client-side: GBFS has no server-side geo-query.
397
+ Rows without a usable id are skipped, as are rows without
398
+ coordinates (a free-floating vehicle IS its position).
292
399
  """
293
400
  if "vehicle_status" in self._endpoints:
294
- rows = (await self._document("vehicle_status"))["data"].get("vehicles", [])
401
+ rows = _rows(await self._document("vehicle_status"), "vehicles")
295
402
  id_key = "vehicle_id"
296
403
  elif "free_bike_status" in self._endpoints:
297
- rows = (await self._document("free_bike_status"))["data"].get("bikes", [])
404
+ rows = _rows(await self._document("free_bike_status"), "bikes")
298
405
  id_key = "bike_id"
299
406
  else:
300
407
  return []
301
408
  vehicles: list[GbfsVehicle] = []
302
409
  for row in rows:
303
- latitude, longitude = row.get("lat"), row.get("lon")
304
- if latitude is None or longitude is None:
410
+ vehicle_id = _record_id(row.get(id_key))
411
+ latitude = _coordinate(row.get("lat"))
412
+ longitude = _coordinate(row.get("lon"))
413
+ if vehicle_id is None or latitude is None or longitude is None:
305
414
  continue
306
415
  if zone is not None and not in_circle(zone, latitude, longitude):
307
416
  continue
308
417
  vehicles.append(
309
418
  GbfsVehicle(
310
- id=str(row.get(id_key)),
419
+ id=vehicle_id,
311
420
  latitude=latitude,
312
421
  longitude=longitude,
313
- is_reserved=row.get("is_reserved"),
314
- is_disabled=row.get("is_disabled"),
422
+ is_reserved=_as_bool(row.get("is_reserved")),
423
+ is_disabled=_as_bool(row.get("is_disabled")),
315
424
  vehicle_type_id=row.get("vehicle_type_id"),
316
425
  current_range_m=row.get("current_range_meters"),
317
426
  rental_uris=_rental_uris(row.get("rental_uris")),