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.
- aiomobilitydatabase/__init__.py +101 -0
- aiomobilitydatabase/client.py +532 -0
- aiomobilitydatabase/const.py +11 -0
- aiomobilitydatabase/exceptions.py +33 -0
- aiomobilitydatabase/models.py +443 -0
- aiomobilitydatabase/py.typed +0 -0
- aiomobilitydatabase-0.1.0.dist-info/METADATA +111 -0
- aiomobilitydatabase-0.1.0.dist-info/RECORD +10 -0
- aiomobilitydatabase-0.1.0.dist-info/WHEEL +4 -0
- aiomobilitydatabase-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -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
|