aiomobilitydatabase 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,101 @@
1
+ """Async Python client for the Mobility Database catalog API."""
2
+
3
+ from .client import MobilityDatabaseClient
4
+ from .const import __version__
5
+ from .exceptions import (
6
+ MobilityDatabaseApiError,
7
+ MobilityDatabaseAuthenticationError,
8
+ MobilityDatabaseConnectionError,
9
+ MobilityDatabaseError,
10
+ MobilityDatabaseNotFoundError,
11
+ MobilityDatabaseRateLimitError,
12
+ )
13
+ from .models import (
14
+ AccessToken,
15
+ BoundingBox,
16
+ BoundingFilterMethod,
17
+ DataType,
18
+ EntityType,
19
+ ExternalId,
20
+ Feed,
21
+ FeedRelatedLink,
22
+ FeedStatus,
23
+ GbfsEndpoint,
24
+ GbfsFeed,
25
+ GbfsValidationReport,
26
+ GbfsVersion,
27
+ GbfsVersionSource,
28
+ GtfsDataset,
29
+ GtfsFeed,
30
+ GtfsFeedAvailability,
31
+ GtfsFeedAvailabilityCheck,
32
+ GtfsRtFeed,
33
+ LatestDataset,
34
+ License,
35
+ LicenseRule,
36
+ LicenseRuleType,
37
+ LicenseWithRules,
38
+ Location,
39
+ LocationSearchResult,
40
+ LocationSearchResults,
41
+ LocationType,
42
+ MatchingLicense,
43
+ Metadata,
44
+ Redirect,
45
+ RequestMethod,
46
+ SearchFeedItemResult,
47
+ SearchResults,
48
+ SortOrder,
49
+ SourceInfo,
50
+ ValidationReport,
51
+ ValidationReportSummary,
52
+ )
53
+
54
+ __all__ = [
55
+ "AccessToken",
56
+ "BoundingBox",
57
+ "BoundingFilterMethod",
58
+ "DataType",
59
+ "EntityType",
60
+ "ExternalId",
61
+ "Feed",
62
+ "FeedRelatedLink",
63
+ "FeedStatus",
64
+ "GbfsEndpoint",
65
+ "GbfsFeed",
66
+ "GbfsValidationReport",
67
+ "GbfsVersion",
68
+ "GbfsVersionSource",
69
+ "GtfsDataset",
70
+ "GtfsFeed",
71
+ "GtfsFeedAvailability",
72
+ "GtfsFeedAvailabilityCheck",
73
+ "GtfsRtFeed",
74
+ "LatestDataset",
75
+ "License",
76
+ "LicenseRule",
77
+ "LicenseRuleType",
78
+ "LicenseWithRules",
79
+ "Location",
80
+ "LocationSearchResult",
81
+ "LocationSearchResults",
82
+ "LocationType",
83
+ "MatchingLicense",
84
+ "Metadata",
85
+ "MobilityDatabaseApiError",
86
+ "MobilityDatabaseAuthenticationError",
87
+ "MobilityDatabaseClient",
88
+ "MobilityDatabaseConnectionError",
89
+ "MobilityDatabaseError",
90
+ "MobilityDatabaseNotFoundError",
91
+ "MobilityDatabaseRateLimitError",
92
+ "Redirect",
93
+ "RequestMethod",
94
+ "SearchFeedItemResult",
95
+ "SearchResults",
96
+ "SortOrder",
97
+ "SourceInfo",
98
+ "ValidationReport",
99
+ "ValidationReportSummary",
100
+ "__version__",
101
+ ]
@@ -0,0 +1,532 @@
1
+ """Async client for the Mobility Database catalog API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from datetime import UTC, datetime, timedelta
7
+ from enum import Enum
8
+ from http import HTTPStatus
9
+ from types import TracebackType
10
+ from typing import Any, Self
11
+
12
+ import aiohttp
13
+
14
+ from .const import (
15
+ DEFAULT_TIMEOUT_SECONDS,
16
+ PROD_BASE_URL,
17
+ TOKEN_EXPIRY_SKEW_SECONDS,
18
+ TOKEN_PATH,
19
+ )
20
+ from .exceptions import (
21
+ MobilityDatabaseApiError,
22
+ MobilityDatabaseAuthenticationError,
23
+ MobilityDatabaseConnectionError,
24
+ MobilityDatabaseError,
25
+ MobilityDatabaseNotFoundError,
26
+ MobilityDatabaseRateLimitError,
27
+ )
28
+ from .models import (
29
+ AccessToken,
30
+ BoundingFilterMethod,
31
+ DataType,
32
+ EntityType,
33
+ Feed,
34
+ FeedStatus,
35
+ GbfsFeed,
36
+ GtfsDataset,
37
+ GtfsFeed,
38
+ GtfsFeedAvailability,
39
+ GtfsRtFeed,
40
+ License,
41
+ LicenseWithRules,
42
+ LocationSearchResults,
43
+ LocationType,
44
+ MatchingLicense,
45
+ Metadata,
46
+ SearchResults,
47
+ SortOrder,
48
+ )
49
+
50
+
51
+ def _encode_value(value: Any) -> str:
52
+ """Encode a single query parameter value as a string."""
53
+ if isinstance(value, bool):
54
+ return "true" if value else "false"
55
+ if isinstance(value, Enum):
56
+ return str(value.value)
57
+ if isinstance(value, datetime):
58
+ return value.isoformat()
59
+ if isinstance(value, list | tuple):
60
+ return ",".join(_encode_value(item) for item in value)
61
+ return str(value)
62
+
63
+
64
+ def encode_params(params: dict[str, Any]) -> dict[str, str]:
65
+ """Encode query parameters, dropping None values and empty containers.
66
+
67
+ Booleans become "true"/"false", enums use their value, datetimes use ISO
68
+ format, and lists/tuples are comma-joined (the API's convention for
69
+ multi-value filters). An empty list or tuple means "no filter" and is
70
+ dropped just like None, rather than encoding to an empty-string param.
71
+ """
72
+ return {
73
+ key: _encode_value(value)
74
+ for key, value in params.items()
75
+ if value is not None and not (isinstance(value, list | tuple) and not value)
76
+ }
77
+
78
+
79
+ class MobilityDatabaseClient:
80
+ """Async client for the Mobility Database catalog API.
81
+
82
+ If ``session`` is not provided, the client lazily creates its own
83
+ ``aiohttp.ClientSession`` and closes it in :meth:`close`. An injected
84
+ session is never closed by the client.
85
+ """
86
+
87
+ def __init__(
88
+ self,
89
+ refresh_token: str,
90
+ session: aiohttp.ClientSession | None = None,
91
+ *,
92
+ base_url: str = PROD_BASE_URL,
93
+ request_timeout: float = DEFAULT_TIMEOUT_SECONDS,
94
+ ) -> None:
95
+ """Initialize the client. Performs no I/O."""
96
+ self._refresh_token = refresh_token
97
+ self._session = session
98
+ self._owns_session = session is None
99
+ self._base_url = base_url.rstrip("/")
100
+ self._timeout = aiohttp.ClientTimeout(total=request_timeout)
101
+ self._access_token: str | None = None
102
+ self._token_expiration: datetime | None = None
103
+ self._token_lock = asyncio.Lock()
104
+
105
+ async def __aenter__(self) -> Self:
106
+ """Enter the async context manager."""
107
+ return self
108
+
109
+ async def __aexit__(
110
+ self,
111
+ exc_type: type[BaseException] | None,
112
+ exc: BaseException | None,
113
+ tb: TracebackType | None,
114
+ ) -> None:
115
+ """Exit the async context manager, closing owned resources."""
116
+ await self.close()
117
+
118
+ async def close(self) -> None:
119
+ """Close the underlying session if this client owns it. Idempotent."""
120
+ if (
121
+ self._owns_session
122
+ and self._session is not None
123
+ and not self._session.closed
124
+ ):
125
+ await self._session.close()
126
+ self._session = None
127
+
128
+ def _get_session(self) -> aiohttp.ClientSession:
129
+ """Return the session, lazily creating an owned one if needed."""
130
+ if self._session is None:
131
+ self._session = aiohttp.ClientSession()
132
+ return self._session
133
+
134
+ def _token_needs_refresh(self) -> bool:
135
+ """Return True if there is no token or it is at/near expiration."""
136
+ if self._access_token is None or self._token_expiration is None:
137
+ return True
138
+ skew = timedelta(seconds=TOKEN_EXPIRY_SKEW_SECONDS)
139
+ return datetime.now(UTC) >= self._token_expiration - skew
140
+
141
+ async def _async_ensure_token(
142
+ self, *, force: bool = False, stale_token: str | None = None
143
+ ) -> str:
144
+ """Return a valid access token, fetching or refreshing as needed.
145
+
146
+ Guarded by a lock so concurrent requests trigger exactly one token
147
+ request. NOTE: the live API returns HTTP 500 for an invalid refresh
148
+ token, so ANY non-200 here is treated as an authentication failure.
149
+
150
+ ``stale_token`` dedupes concurrent forced refreshes: when several
151
+ requests all hit a 401 on the same token, each calls this with
152
+ ``force=True`` and the token *it* used. Only the first to acquire the
153
+ lock actually refetches; the rest see that ``self._access_token`` no
154
+ longer matches their ``stale_token`` (a sibling already refreshed it)
155
+ and return the new token without another token request.
156
+ """
157
+ async with self._token_lock:
158
+ if not force and not self._token_needs_refresh():
159
+ assert self._access_token is not None # guarded above
160
+ return self._access_token
161
+ if (
162
+ force
163
+ and stale_token is not None
164
+ and self._access_token is not None
165
+ and self._access_token != stale_token
166
+ ):
167
+ return self._access_token
168
+ session = self._get_session()
169
+ try:
170
+ async with session.post(
171
+ f"{self._base_url}{TOKEN_PATH}",
172
+ json={"refresh_token": self._refresh_token},
173
+ timeout=self._timeout,
174
+ ) as resp:
175
+ if resp.status != HTTPStatus.OK:
176
+ body = await resp.text()
177
+ raise MobilityDatabaseAuthenticationError(
178
+ f"Unable to obtain access token ({resp.status}): {body}"
179
+ )
180
+ data = await resp.json()
181
+ except (TimeoutError, aiohttp.ClientError, ValueError) as err:
182
+ raise MobilityDatabaseConnectionError(
183
+ f"Error requesting access token: {err}"
184
+ ) from err
185
+ token = AccessToken.from_dict(data)
186
+ expiration = token.expiration_datetime_utc
187
+ if expiration.tzinfo is None:
188
+ expiration = expiration.replace(tzinfo=UTC)
189
+ self._access_token = token.access_token
190
+ self._token_expiration = expiration
191
+ return self._access_token
192
+
193
+ async def _request(
194
+ self,
195
+ method: str,
196
+ path: str,
197
+ *,
198
+ params: dict[str, Any] | None = None,
199
+ json_body: dict[str, Any] | None = None,
200
+ retry_on_auth_fail: bool = True,
201
+ ) -> Any:
202
+ """Perform an authenticated request and return the decoded JSON body."""
203
+ token = await self._async_ensure_token()
204
+ session = self._get_session()
205
+ try:
206
+ async with session.request(
207
+ method,
208
+ f"{self._base_url}{path}",
209
+ params=encode_params(params) if params else None,
210
+ json=json_body,
211
+ headers={"Authorization": f"Bearer {token}"},
212
+ timeout=self._timeout,
213
+ ) as resp:
214
+ if resp.status == HTTPStatus.UNAUTHORIZED:
215
+ if retry_on_auth_fail:
216
+ await self._async_ensure_token(force=True, stale_token=token)
217
+ return await self._request(
218
+ method,
219
+ path,
220
+ params=params,
221
+ json_body=json_body,
222
+ retry_on_auth_fail=False,
223
+ )
224
+ raise MobilityDatabaseAuthenticationError(
225
+ "Request unauthorized after token refresh"
226
+ )
227
+ if resp.status == HTTPStatus.NOT_FOUND:
228
+ raise MobilityDatabaseNotFoundError(f"Not found: {path}")
229
+ if resp.status == HTTPStatus.TOO_MANY_REQUESTS:
230
+ raise MobilityDatabaseRateLimitError("API rate limit exceeded")
231
+ if resp.status >= HTTPStatus.BAD_REQUEST:
232
+ raise MobilityDatabaseApiError(resp.status, await resp.text())
233
+ try:
234
+ return await resp.json()
235
+ except (aiohttp.ContentTypeError, ValueError) as err:
236
+ raise MobilityDatabaseConnectionError(
237
+ "API returned invalid JSON"
238
+ ) from err
239
+ except MobilityDatabaseError:
240
+ raise
241
+ except (TimeoutError, aiohttp.ClientError) as err:
242
+ raise MobilityDatabaseConnectionError(
243
+ f"Error communicating with API: {err}"
244
+ ) from err
245
+
246
+ async def get_metadata(self) -> Metadata:
247
+ """Get metadata about the API."""
248
+ data = await self._request("GET", "/v1/metadata")
249
+ return Metadata.from_dict(data)
250
+
251
+ async def get_feeds(
252
+ self,
253
+ *,
254
+ limit: int | None = None,
255
+ offset: int | None = None,
256
+ status: FeedStatus | None = None,
257
+ provider: str | None = None,
258
+ producer_url: str | None = None,
259
+ is_official: bool | None = None,
260
+ ) -> list[Feed]:
261
+ """Get feeds of all data types."""
262
+ data = await self._request(
263
+ "GET",
264
+ "/v1/feeds",
265
+ params={
266
+ "limit": limit,
267
+ "offset": offset,
268
+ "status": status,
269
+ "provider": provider,
270
+ "producer_url": producer_url,
271
+ "is_official": is_official,
272
+ },
273
+ )
274
+ return [Feed.from_dict(item) for item in data]
275
+
276
+ async def get_feed(self, feed_id: str) -> Feed:
277
+ """Get a single feed by ID."""
278
+ data = await self._request("GET", f"/v1/feeds/{feed_id}")
279
+ return Feed.from_dict(data)
280
+
281
+ async def get_gtfs_feeds(
282
+ self,
283
+ *,
284
+ limit: int | None = None,
285
+ offset: int | None = None,
286
+ provider: str | None = None,
287
+ producer_url: str | None = None,
288
+ country_code: str | None = None,
289
+ subdivision_name: str | None = None,
290
+ municipality: str | None = None,
291
+ dataset_latitudes: tuple[float, float] | None = None,
292
+ dataset_longitudes: tuple[float, float] | None = None,
293
+ bounding_filter_method: BoundingFilterMethod | None = None,
294
+ is_official: bool | None = None,
295
+ ) -> list[GtfsFeed]:
296
+ """Get GTFS schedule feeds."""
297
+ data = await self._request(
298
+ "GET",
299
+ "/v1/gtfs_feeds",
300
+ params={
301
+ "limit": limit,
302
+ "offset": offset,
303
+ "provider": provider,
304
+ "producer_url": producer_url,
305
+ "country_code": country_code,
306
+ "subdivision_name": subdivision_name,
307
+ "municipality": municipality,
308
+ "dataset_latitudes": dataset_latitudes,
309
+ "dataset_longitudes": dataset_longitudes,
310
+ "bounding_filter_method": bounding_filter_method,
311
+ "is_official": is_official,
312
+ },
313
+ )
314
+ return [GtfsFeed.from_dict(item) for item in data]
315
+
316
+ async def get_gtfs_feed(self, feed_id: str) -> GtfsFeed:
317
+ """Get a single GTFS feed by ID."""
318
+ data = await self._request("GET", f"/v1/gtfs_feeds/{feed_id}")
319
+ return GtfsFeed.from_dict(data)
320
+
321
+ async def get_gtfs_rt_feeds(
322
+ self,
323
+ *,
324
+ limit: int | None = None,
325
+ offset: int | None = None,
326
+ provider: str | None = None,
327
+ producer_url: str | None = None,
328
+ entity_types: list[EntityType] | None = None,
329
+ country_code: str | None = None,
330
+ subdivision_name: str | None = None,
331
+ municipality: str | None = None,
332
+ is_official: bool | None = None,
333
+ ) -> list[GtfsRtFeed]:
334
+ """Get GTFS Realtime feeds."""
335
+ data = await self._request(
336
+ "GET",
337
+ "/v1/gtfs_rt_feeds",
338
+ params={
339
+ "limit": limit,
340
+ "offset": offset,
341
+ "provider": provider,
342
+ "producer_url": producer_url,
343
+ "entity_types": entity_types,
344
+ "country_code": country_code,
345
+ "subdivision_name": subdivision_name,
346
+ "municipality": municipality,
347
+ "is_official": is_official,
348
+ },
349
+ )
350
+ return [GtfsRtFeed.from_dict(item) for item in data]
351
+
352
+ async def get_gtfs_rt_feed(self, feed_id: str) -> GtfsRtFeed:
353
+ """Get a single GTFS Realtime feed by ID."""
354
+ data = await self._request("GET", f"/v1/gtfs_rt_feeds/{feed_id}")
355
+ return GtfsRtFeed.from_dict(data)
356
+
357
+ async def get_gbfs_feeds(
358
+ self,
359
+ *,
360
+ limit: int | None = None,
361
+ offset: int | None = None,
362
+ provider: str | None = None,
363
+ producer_url: str | None = None,
364
+ country_code: str | None = None,
365
+ subdivision_name: str | None = None,
366
+ municipality: str | None = None,
367
+ system_id: str | None = None,
368
+ version: str | None = None,
369
+ ) -> list[GbfsFeed]:
370
+ """Get GBFS feeds."""
371
+ data = await self._request(
372
+ "GET",
373
+ "/v1/gbfs_feeds",
374
+ params={
375
+ "limit": limit,
376
+ "offset": offset,
377
+ "provider": provider,
378
+ "producer_url": producer_url,
379
+ "country_code": country_code,
380
+ "subdivision_name": subdivision_name,
381
+ "municipality": municipality,
382
+ "system_id": system_id,
383
+ "version": version,
384
+ },
385
+ )
386
+ return [GbfsFeed.from_dict(item) for item in data]
387
+
388
+ async def get_gbfs_feed(self, feed_id: str) -> GbfsFeed:
389
+ """Get a single GBFS feed by ID."""
390
+ data = await self._request("GET", f"/v1/gbfs_feeds/{feed_id}")
391
+ return GbfsFeed.from_dict(data)
392
+
393
+ async def get_gtfs_feed_datasets(
394
+ self,
395
+ feed_id: str,
396
+ *,
397
+ latest: bool | None = None,
398
+ limit: int | None = None,
399
+ offset: int | None = None,
400
+ downloaded_after: datetime | None = None,
401
+ downloaded_before: datetime | None = None,
402
+ ) -> list[GtfsDataset]:
403
+ """Get datasets for a GTFS feed, sorted newest to oldest."""
404
+ data = await self._request(
405
+ "GET",
406
+ f"/v1/gtfs_feeds/{feed_id}/datasets",
407
+ params={
408
+ "latest": latest,
409
+ "limit": limit,
410
+ "offset": offset,
411
+ "downloaded_after": downloaded_after,
412
+ "downloaded_before": downloaded_before,
413
+ },
414
+ )
415
+ return [GtfsDataset.from_dict(item) for item in data]
416
+
417
+ async def get_gtfs_feed_gtfs_rt_feeds(self, feed_id: str) -> list[GtfsRtFeed]:
418
+ """Get GTFS Realtime feeds related to a GTFS feed."""
419
+ data = await self._request("GET", f"/v1/gtfs_feeds/{feed_id}/gtfs_rt_feeds")
420
+ return [GtfsRtFeed.from_dict(item) for item in data]
421
+
422
+ async def get_gtfs_feed_availability(
423
+ self,
424
+ feed_id: str,
425
+ *,
426
+ checked_after: datetime | None = None,
427
+ checked_before: datetime | None = None,
428
+ limit: int | None = None,
429
+ offset: int | None = None,
430
+ sort: SortOrder | None = None,
431
+ ) -> GtfsFeedAvailability:
432
+ """Get historical availability checks for a GTFS feed."""
433
+ data = await self._request(
434
+ "GET",
435
+ f"/v1/gtfs_feeds/{feed_id}/availability",
436
+ params={
437
+ "from": checked_after,
438
+ "to": checked_before,
439
+ "limit": limit,
440
+ "offset": offset,
441
+ "sort": sort,
442
+ },
443
+ )
444
+ return GtfsFeedAvailability.from_dict(data)
445
+
446
+ async def get_dataset_gtfs(self, dataset_id: str) -> GtfsDataset:
447
+ """Get a single GTFS dataset by ID."""
448
+ data = await self._request("GET", f"/v1/datasets/gtfs/{dataset_id}")
449
+ return GtfsDataset.from_dict(data)
450
+
451
+ async def search_feeds(
452
+ self,
453
+ *,
454
+ search_query: str | None = None,
455
+ limit: int | None = None,
456
+ offset: int | None = None,
457
+ statuses: list[FeedStatus] | None = None,
458
+ feed_id: str | None = None,
459
+ data_types: list[DataType] | None = None,
460
+ is_official: bool | None = None,
461
+ version: str | None = None,
462
+ features: list[str] | None = None,
463
+ license_ids: list[str] | None = None,
464
+ license_is_spdx: bool | None = None,
465
+ license_tags: list[str] | None = None,
466
+ ) -> SearchResults:
467
+ """Full-text search for feeds by name, location, and provider."""
468
+ data = await self._request(
469
+ "GET",
470
+ "/v1/search",
471
+ params={
472
+ "search_query": search_query,
473
+ "limit": limit,
474
+ "offset": offset,
475
+ "status": statuses,
476
+ "feed_id": feed_id,
477
+ "data_type": data_types,
478
+ "is_official": is_official,
479
+ "version": version,
480
+ "feature": features,
481
+ "license_ids": license_ids,
482
+ "license_is_spdx": license_is_spdx,
483
+ "license_tags": license_tags,
484
+ },
485
+ )
486
+ return SearchResults.from_dict(data)
487
+
488
+ async def get_locations(
489
+ self,
490
+ *,
491
+ search_query: str | None = None,
492
+ country_code: str | None = None,
493
+ subdivision_code: str | None = None,
494
+ location_type: LocationType | None = None,
495
+ limit: int | None = None,
496
+ offset: int | None = None,
497
+ ) -> LocationSearchResults:
498
+ """Search locations (countries, subdivisions, municipalities)."""
499
+ data = await self._request(
500
+ "GET",
501
+ "/v1/locations",
502
+ params={
503
+ "search_query": search_query,
504
+ "country_code": country_code,
505
+ "subdivision_code": subdivision_code,
506
+ "location_type": location_type,
507
+ "limit": limit,
508
+ "offset": offset,
509
+ },
510
+ )
511
+ return LocationSearchResults.from_dict(data)
512
+
513
+ async def get_licenses(
514
+ self, *, limit: int | None = None, offset: int | None = None
515
+ ) -> list[License]:
516
+ """Get all licenses in the database."""
517
+ data = await self._request(
518
+ "GET", "/v1/licenses", params={"limit": limit, "offset": offset}
519
+ )
520
+ return [License.from_dict(item) for item in data]
521
+
522
+ async def get_license(self, license_id: str) -> LicenseWithRules:
523
+ """Get a single license, including its rules."""
524
+ data = await self._request("GET", f"/v1/licenses/{license_id}")
525
+ return LicenseWithRules.from_dict(data)
526
+
527
+ async def get_matching_licenses(self, license_url: str) -> list[MatchingLicense]:
528
+ """Match a license URL against the license database."""
529
+ data = await self._request(
530
+ "POST", "/v1/licenses:match", json_body={"license_url": license_url}
531
+ )
532
+ return [MatchingLicense.from_dict(item) for item in data]
@@ -0,0 +1,11 @@
1
+ """Constants for aiomobilitydatabase."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ PROD_BASE_URL = "https://api.mobilitydatabase.org"
6
+ QA_BASE_URL = "https://api-qa.mobilitydatabase.org"
7
+ DEV_BASE_URL = "https://api-dev.mobilitydatabase.org"
8
+
9
+ TOKEN_PATH = "/v1/tokens"
10
+ TOKEN_EXPIRY_SKEW_SECONDS = 30.0
11
+ DEFAULT_TIMEOUT_SECONDS = 30.0
@@ -0,0 +1,33 @@
1
+ """Exceptions for aiomobilitydatabase."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class MobilityDatabaseError(Exception):
7
+ """Base exception for all aiomobilitydatabase errors."""
8
+
9
+
10
+ class MobilityDatabaseConnectionError(MobilityDatabaseError):
11
+ """The API could not be reached or returned an invalid payload."""
12
+
13
+
14
+ class MobilityDatabaseAuthenticationError(MobilityDatabaseError):
15
+ """Authentication failed (invalid refresh token or unauthorized request)."""
16
+
17
+
18
+ class MobilityDatabaseNotFoundError(MobilityDatabaseError):
19
+ """The requested resource was not found (HTTP 404)."""
20
+
21
+
22
+ class MobilityDatabaseRateLimitError(MobilityDatabaseError):
23
+ """The API rate limit was exceeded (HTTP 429)."""
24
+
25
+
26
+ class MobilityDatabaseApiError(MobilityDatabaseError):
27
+ """The API returned an unexpected 4xx/5xx error."""
28
+
29
+ def __init__(self, status: int, body: str) -> None:
30
+ """Initialize with the HTTP status and raw response body."""
31
+ super().__init__(f"API error {status}: {body}")
32
+ self.status = status
33
+ self.body = body