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.
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/PKG-INFO +65 -9
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/README.md +63 -7
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/src/aiomobilitydatabase/const.py +1 -1
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/src/aiomobilitydatabase/feeds/__init__.py +2 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/src/aiomobilitydatabase/feeds/client.py +31 -7
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/src/aiomobilitydatabase/feeds/gbfs.py +152 -43
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/src/aiomobilitydatabase/feeds/models.py +30 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/src/aiomobilitydatabase/feeds/rt.py +7 -3
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/src/aiomobilitydatabase/feeds/static_index.py +175 -55
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/src/aiomobilitydatabase/feeds/transit.py +469 -289
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/test_client.py +107 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/test_direct_urls.py +5 -5
- aiomobilitydatabase-0.4.0/tests/feeds/test_gbfs_stations.py +453 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/test_gbfs_vehicles.py +70 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/test_models.py +40 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/test_properties.py +3528 -427
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/test_rt.py +16 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/test_sample_feed.py +3 -3
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/test_static_arrivals.py +34 -0
- aiomobilitydatabase-0.4.0/tests/feeds/test_static_build.py +596 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/test_static_trips.py +12 -0
- aiomobilitydatabase-0.4.0/tests/feeds/test_transit_arrivals.py +482 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/test_transit_frequencies.py +9 -8
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/test_transit_trips.py +46 -6
- aiomobilitydatabase-0.4.0/tests/feeds/test_transit_vehicles_alerts.py +579 -0
- aiomobilitydatabase-0.4.0/tests/test_catalog_properties.py +383 -0
- aiomobilitydatabase-0.3.0/tests/feeds/test_gbfs_stations.py +0 -205
- aiomobilitydatabase-0.3.0/tests/feeds/test_static_build.py +0 -235
- aiomobilitydatabase-0.3.0/tests/feeds/test_transit_arrivals.py +0 -253
- aiomobilitydatabase-0.3.0/tests/feeds/test_transit_vehicles_alerts.py +0 -279
- aiomobilitydatabase-0.3.0/tests/test_catalog_properties.py +0 -211
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/.github/workflows/ci.yml +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/.github/workflows/publish.yml +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/.gitignore +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/.pre-commit-config.yaml +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/LICENSE +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/pyproject.toml +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/scripts/generate_rt_fixtures.py +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/src/aiomobilitydatabase/__init__.py +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/src/aiomobilitydatabase/client.py +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/src/aiomobilitydatabase/exceptions.py +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/src/aiomobilitydatabase/feeds/const.py +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/src/aiomobilitydatabase/feeds/exceptions.py +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/src/aiomobilitydatabase/feeds/geo.py +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/src/aiomobilitydatabase/models.py +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/src/aiomobilitydatabase/py.typed +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/__init__.py +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/conftest.py +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/__init__.py +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/conftest.py +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/catalog/gbfs_feed.json +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/catalog/gtfs_feed.json +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/catalog/gtfs_rt_feed.json +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/catalog/token_response.json +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/gbfs/discovery_23.json +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/gbfs/discovery_30.json +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/gbfs/free_bike_status_23.json +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/gbfs/station_info_23.json +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/gbfs/station_status_23.json +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/gbfs/system_info_23.json +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/gbfs/system_info_30.json +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/gbfs/vehicle_status_30.json +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/gtfs/agency.txt +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/gtfs/calendar.txt +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/gtfs/calendar_dates.txt +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/gtfs/routes.txt +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/gtfs/stop_times.txt +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/gtfs/stops.txt +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/gtfs/trips.txt +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/rt/added_trips_s1.pb +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/rt/alerts.pb +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/rt/alerts_trip_scoped.pb +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/rt/trip_updates_baseline.pb +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/rt/trip_updates_freq_bare_cancel.pb +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/rt/trip_updates_freq_canceled_tomorrow.pb +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/rt/trip_updates_freq_matched.pb +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/rt/trip_updates_freq_unmatched.pb +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/rt/trip_updates_t1_both_ends.pb +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/rt/trip_updates_t1_canceled.pb +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/rt/trip_updates_t1_canceled_tomorrow.pb +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/rt/trip_updates_t1_dated_tomorrow_delay.pb +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/rt/trip_updates_t1_delayed.pb +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/rt/trip_updates_t1_dest_arrival.pb +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/rt/trip_updates_t1_no_data_cut.pb +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/rt/trip_updates_t1_skip_s1.pb +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/rt/trip_updates_t1_skip_s2.pb +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/rt/trip_updates_t1_skip_s3.pb +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/rt/trip_updates_t1_trip_delay.pb +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/rt/vehicle_positions.pb +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/rt/vehicle_positions_status.pb +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/sample_feed/README.md +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/sample_feed/agency.txt +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/sample_feed/calendar.txt +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/sample_feed/calendar_dates.txt +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/sample_feed/fare_attributes.txt +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/sample_feed/fare_rules.txt +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/sample_feed/feed_info.txt +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/sample_feed/frequencies.txt +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/sample_feed/routes.txt +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/sample_feed/shapes.txt +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/sample_feed/stop_times.txt +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/sample_feed/stops.txt +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/sample_feed/transfers.txt +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/sample_feed/translations.txt +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/data/sample_feed/trips.txt +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/fixtures.py +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/test_descriptive_attributes.py +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/test_exceptions.py +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/test_extra_import_guard.py +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/test_geo.py +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/test_static_frequencies.py +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/test_station_grouping.py +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/feeds/test_transit_resolution.py +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/fixtures.py +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/mock_server.py +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/test_client_request.py +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/test_client_session.py +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/test_client_token.py +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/test_endpoints.py +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/test_exceptions.py +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/test_models.py +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/test_params.py +0 -0
- {aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/tests/test_public_api.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: aiomobilitydatabase
|
|
3
|
-
Version: 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
|
-
|
|
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
|
|
261
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
237
|
-
|
|
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
|
|
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`",
|
{aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/src/aiomobilitydatabase/feeds/__init__.py
RENAMED
|
@@ -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",
|
{aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/src/aiomobilitydatabase/feeds/client.py
RENAMED
|
@@ -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.
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
138
|
-
|
|
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()]
|
{aiomobilitydatabase-0.3.0 → aiomobilitydatabase-0.4.0}/src/aiomobilitydatabase/feeds/gbfs.py
RENAMED
|
@@ -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
|
|
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
|
|
190
|
+
data = document.get("data") if isinstance(document, Mapping) else None
|
|
95
191
|
feeds: Any = None
|
|
96
|
-
if isinstance(data,
|
|
192
|
+
if isinstance(data, Mapping):
|
|
97
193
|
feeds = data.get("feeds")
|
|
98
|
-
if feeds
|
|
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,
|
|
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,
|
|
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,
|
|
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 =
|
|
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=
|
|
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")
|
|
244
|
-
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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=
|
|
372
|
+
id=station_id,
|
|
270
373
|
name=_localized(info.get("name")),
|
|
271
|
-
latitude=
|
|
272
|
-
longitude=
|
|
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=
|
|
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")
|
|
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")
|
|
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
|
-
|
|
304
|
-
|
|
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=
|
|
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")),
|