pymapflights 0.2.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.
- pymapflights/__init__.py +76 -0
- pymapflights/client.py +376 -0
- pymapflights/const.py +26 -0
- pymapflights/exceptions.py +92 -0
- pymapflights/models.py +442 -0
- pymapflights/py.typed +0 -0
- pymapflights/throttle.py +117 -0
- pymapflights-0.2.0.dist-info/METADATA +148 -0
- pymapflights-0.2.0.dist-info/RECORD +11 -0
- pymapflights-0.2.0.dist-info/WHEEL +4 -0
- pymapflights-0.2.0.dist-info/licenses/LICENSE +21 -0
pymapflights/__init__.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Asynchronous Python client for the map.flights API (unofficial)."""
|
|
2
|
+
|
|
3
|
+
from .client import MapFlights, key_prefix
|
|
4
|
+
from .const import VERSION
|
|
5
|
+
from .exceptions import (
|
|
6
|
+
MapFlightsAuthError,
|
|
7
|
+
MapFlightsConnectionError,
|
|
8
|
+
MapFlightsError,
|
|
9
|
+
MapFlightsKeyDormantError,
|
|
10
|
+
MapFlightsNoCurrentFlightError,
|
|
11
|
+
MapFlightsNotFoundError,
|
|
12
|
+
MapFlightsNotInTariffError,
|
|
13
|
+
MapFlightsOutOfAreaError,
|
|
14
|
+
MapFlightsRateLimitError,
|
|
15
|
+
MapFlightsTariffError,
|
|
16
|
+
)
|
|
17
|
+
from .models import (
|
|
18
|
+
AircraftFlights,
|
|
19
|
+
Airline,
|
|
20
|
+
AirlinePage,
|
|
21
|
+
AirportRef,
|
|
22
|
+
Attribution,
|
|
23
|
+
DayBoard,
|
|
24
|
+
DayBoardRow,
|
|
25
|
+
Delay,
|
|
26
|
+
FlightDelay,
|
|
27
|
+
FlightPage,
|
|
28
|
+
FlightSummary,
|
|
29
|
+
LiveState,
|
|
30
|
+
Me,
|
|
31
|
+
MeKey,
|
|
32
|
+
MeLimits,
|
|
33
|
+
MeTariff,
|
|
34
|
+
MeUsage,
|
|
35
|
+
NearAircraft,
|
|
36
|
+
NearResponse,
|
|
37
|
+
RateLimitInfo,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
__version__ = VERSION
|
|
41
|
+
|
|
42
|
+
__all__ = [
|
|
43
|
+
"AircraftFlights",
|
|
44
|
+
"Airline",
|
|
45
|
+
"AirlinePage",
|
|
46
|
+
"AirportRef",
|
|
47
|
+
"Attribution",
|
|
48
|
+
"DayBoard",
|
|
49
|
+
"DayBoardRow",
|
|
50
|
+
"Delay",
|
|
51
|
+
"FlightDelay",
|
|
52
|
+
"FlightPage",
|
|
53
|
+
"FlightSummary",
|
|
54
|
+
"LiveState",
|
|
55
|
+
"MapFlights",
|
|
56
|
+
"MapFlightsAuthError",
|
|
57
|
+
"MapFlightsConnectionError",
|
|
58
|
+
"MapFlightsError",
|
|
59
|
+
"MapFlightsKeyDormantError",
|
|
60
|
+
"MapFlightsNoCurrentFlightError",
|
|
61
|
+
"MapFlightsNotFoundError",
|
|
62
|
+
"MapFlightsNotInTariffError",
|
|
63
|
+
"MapFlightsOutOfAreaError",
|
|
64
|
+
"MapFlightsRateLimitError",
|
|
65
|
+
"MapFlightsTariffError",
|
|
66
|
+
"Me",
|
|
67
|
+
"MeKey",
|
|
68
|
+
"MeLimits",
|
|
69
|
+
"MeTariff",
|
|
70
|
+
"MeUsage",
|
|
71
|
+
"NearAircraft",
|
|
72
|
+
"NearResponse",
|
|
73
|
+
"RateLimitInfo",
|
|
74
|
+
"__version__",
|
|
75
|
+
"key_prefix",
|
|
76
|
+
]
|
pymapflights/client.py
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
"""Asynchronous client for the map.flights API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import re
|
|
7
|
+
from contextlib import AbstractAsyncContextManager, nullcontext
|
|
8
|
+
from typing import Any, Literal
|
|
9
|
+
|
|
10
|
+
from aiohttp import ClientError, ClientResponse, ClientSession, ContentTypeError
|
|
11
|
+
from yarl import URL
|
|
12
|
+
|
|
13
|
+
from .const import (
|
|
14
|
+
AIRCRAFT_FLIGHTS_MAX_LIMIT,
|
|
15
|
+
AIRLINES_MAX_LIMIT,
|
|
16
|
+
DEFAULT_BASE_URL,
|
|
17
|
+
DEFAULT_TIMEOUT,
|
|
18
|
+
DEFAULT_USER_AGENT,
|
|
19
|
+
HEADER_API_KEY,
|
|
20
|
+
HEADER_ATTRIBUTION,
|
|
21
|
+
HEADER_RATE_LIMIT,
|
|
22
|
+
HEADER_RATE_REMAINING,
|
|
23
|
+
HEADER_RATE_RESET,
|
|
24
|
+
HEADER_RETRY_AFTER,
|
|
25
|
+
KEY_PREFIX_LENGTH,
|
|
26
|
+
NEAR_MAX_LIMIT,
|
|
27
|
+
NEAR_MAX_RADIUS_KM,
|
|
28
|
+
)
|
|
29
|
+
from .exceptions import (
|
|
30
|
+
MapFlightsAuthError,
|
|
31
|
+
MapFlightsConnectionError,
|
|
32
|
+
MapFlightsError,
|
|
33
|
+
MapFlightsKeyDormantError,
|
|
34
|
+
MapFlightsNoCurrentFlightError,
|
|
35
|
+
MapFlightsNotFoundError,
|
|
36
|
+
MapFlightsNotInTariffError,
|
|
37
|
+
MapFlightsOutOfAreaError,
|
|
38
|
+
MapFlightsRateLimitError,
|
|
39
|
+
MapFlightsTariffError,
|
|
40
|
+
)
|
|
41
|
+
from .models import (
|
|
42
|
+
AircraftFlights,
|
|
43
|
+
AirlinePage,
|
|
44
|
+
DayBoard,
|
|
45
|
+
FlightPage,
|
|
46
|
+
Me,
|
|
47
|
+
NearResponse,
|
|
48
|
+
RateLimitInfo,
|
|
49
|
+
)
|
|
50
|
+
from .throttle import SCOPE_LIVE, SCOPE_MINUTE, throttle_for
|
|
51
|
+
|
|
52
|
+
_NOT_IN_TARIFF = frozenset(
|
|
53
|
+
{"group_not_in_tariff", "endpoint_not_in_tariff", "format_not_in_tariff"}
|
|
54
|
+
)
|
|
55
|
+
_NO_CURRENT_FLIGHT = frozenset({"no current flight", "unknown flight"})
|
|
56
|
+
|
|
57
|
+
_AIRPORT_RE = re.compile(r"^[A-Za-z0-9-]{3,8}$")
|
|
58
|
+
_CALLSIGN_RE = re.compile(r"^[A-Za-z0-9-]{2,10}$")
|
|
59
|
+
_HEX_RE = re.compile(r"^[0-9a-fA-F]{6}$")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def key_prefix(api_key: str) -> str:
|
|
63
|
+
"""Non-secret prefix of a key (`mf_live_` + 8 hex), as `/me` reports it.
|
|
64
|
+
|
|
65
|
+
Unique per key and safe to log or use as an identifier. A new key of the
|
|
66
|
+
same account has a different prefix.
|
|
67
|
+
"""
|
|
68
|
+
api_key = api_key.strip()
|
|
69
|
+
if len(api_key) < KEY_PREFIX_LENGTH:
|
|
70
|
+
raise ValueError("API key is too short")
|
|
71
|
+
return api_key[:KEY_PREFIX_LENGTH]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _int_or_none(value: str | int | None) -> int | None:
|
|
75
|
+
if value is None:
|
|
76
|
+
return None
|
|
77
|
+
try:
|
|
78
|
+
return int(value)
|
|
79
|
+
except ValueError:
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _coord(value: float) -> str:
|
|
84
|
+
"""Format a path coordinate; 5 decimals are about 1 m."""
|
|
85
|
+
return str(round(value, 5))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class MapFlights:
|
|
89
|
+
"""Client for the map.flights API.
|
|
90
|
+
|
|
91
|
+
The aiohttp session is owned by the caller and never closed here. Rate
|
|
92
|
+
limits are enforced per key across all clients of the process: live
|
|
93
|
+
requests (`near`) wait for their 5 s slot, and requests that would break
|
|
94
|
+
the minute limit or a known 429 block raise a local
|
|
95
|
+
`MapFlightsRateLimitError` without being sent.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
def __init__(
|
|
99
|
+
self,
|
|
100
|
+
api_key: str,
|
|
101
|
+
*,
|
|
102
|
+
session: ClientSession,
|
|
103
|
+
user_agent: str = DEFAULT_USER_AGENT,
|
|
104
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
105
|
+
request_timeout: float = DEFAULT_TIMEOUT,
|
|
106
|
+
) -> None:
|
|
107
|
+
"""Initialize the client."""
|
|
108
|
+
self._api_key = api_key.strip()
|
|
109
|
+
self._session = session
|
|
110
|
+
self._user_agent = user_agent
|
|
111
|
+
self._base_url = URL(base_url)
|
|
112
|
+
self._request_timeout = request_timeout
|
|
113
|
+
self._throttle = throttle_for(self._api_key)
|
|
114
|
+
self.attribution: str | None = None
|
|
115
|
+
"""Full attribution from the last `x-data-attribution` header."""
|
|
116
|
+
self.live_rate_limit: RateLimitInfo | None = None
|
|
117
|
+
"""Rate-limit headers of the last live response (live-data bucket)."""
|
|
118
|
+
self.daily_rate_limit: RateLimitInfo | None = None
|
|
119
|
+
"""Rate-limit headers of the last other response (daily quota)."""
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def key_prefix(self) -> str:
|
|
123
|
+
"""Prefix of the client's key."""
|
|
124
|
+
return key_prefix(self._api_key)
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def remaining_today(self) -> int | None:
|
|
128
|
+
"""Daily quota left according to the last non-live response."""
|
|
129
|
+
if self.daily_rate_limit is None:
|
|
130
|
+
return None
|
|
131
|
+
return self.daily_rate_limit.remaining
|
|
132
|
+
|
|
133
|
+
async def near(
|
|
134
|
+
self,
|
|
135
|
+
lat: float,
|
|
136
|
+
lon: float,
|
|
137
|
+
radius_km: float,
|
|
138
|
+
*,
|
|
139
|
+
limit: int | None = None,
|
|
140
|
+
) -> NearResponse:
|
|
141
|
+
"""Aircraft within `radius_km` of a point, nearest first.
|
|
142
|
+
|
|
143
|
+
`limit` caps the returned list (API default 50); `total` of the
|
|
144
|
+
response is the real number of aircraft in the radius. Live group:
|
|
145
|
+
never counts against the daily quota.
|
|
146
|
+
"""
|
|
147
|
+
if not -90 <= lat <= 90: # noqa: PLR2004
|
|
148
|
+
raise ValueError("lat must be between -90 and 90")
|
|
149
|
+
if not -180 <= lon <= 180: # noqa: PLR2004
|
|
150
|
+
raise ValueError("lon must be between -180 and 180")
|
|
151
|
+
if not 0 < radius_km <= NEAR_MAX_RADIUS_KM:
|
|
152
|
+
raise ValueError(f"radius_km must be > 0 and <= {NEAR_MAX_RADIUS_KM}")
|
|
153
|
+
params: dict[str, str] = {}
|
|
154
|
+
if limit is not None:
|
|
155
|
+
if not 1 <= limit <= NEAR_MAX_LIMIT:
|
|
156
|
+
raise ValueError(f"limit must be between 1 and {NEAR_MAX_LIMIT}")
|
|
157
|
+
params["limit"] = str(limit)
|
|
158
|
+
|
|
159
|
+
data = await self._request(
|
|
160
|
+
f"/api/v1/near/{_coord(lat)}/{_coord(lon)}/{_coord(radius_km)}",
|
|
161
|
+
params=params,
|
|
162
|
+
live=True,
|
|
163
|
+
)
|
|
164
|
+
return NearResponse.from_dict(data)
|
|
165
|
+
|
|
166
|
+
async def me(self) -> Me:
|
|
167
|
+
"""State of the key: tariff, ceilings and today's usage.
|
|
168
|
+
|
|
169
|
+
Experimental. Counts against the daily quota; meant for setup and
|
|
170
|
+
diagnostics, not for polling.
|
|
171
|
+
"""
|
|
172
|
+
return Me.from_dict(await self._request("/api/v1/me"))
|
|
173
|
+
|
|
174
|
+
async def day_board(
|
|
175
|
+
self,
|
|
176
|
+
airport: str,
|
|
177
|
+
direction: Literal["arr", "dep"],
|
|
178
|
+
*,
|
|
179
|
+
day: Literal[-1, 0, 1] = 0,
|
|
180
|
+
) -> DayBoard:
|
|
181
|
+
"""Arrivals or departures of an airport for one local day.
|
|
182
|
+
|
|
183
|
+
Experimental. `airport` is an ICAO or IATA code; `day` is relative to
|
|
184
|
+
the airport's local today. Not limited to the key's area.
|
|
185
|
+
"""
|
|
186
|
+
airport = airport.strip().upper()
|
|
187
|
+
if not _AIRPORT_RE.match(airport):
|
|
188
|
+
raise ValueError("airport must be an ICAO or IATA code")
|
|
189
|
+
if direction not in ("arr", "dep"):
|
|
190
|
+
raise ValueError("direction must be 'arr' or 'dep'")
|
|
191
|
+
if day not in (-1, 0, 1):
|
|
192
|
+
raise ValueError("day must be -1, 0 or 1")
|
|
193
|
+
data = await self._request(
|
|
194
|
+
f"/api/v1/airports/{airport}/day-board",
|
|
195
|
+
params={"dir": direction, "day": str(day)},
|
|
196
|
+
)
|
|
197
|
+
return DayBoard.from_dict(data)
|
|
198
|
+
|
|
199
|
+
async def flight_by_callsign(self, callsign: str) -> FlightPage:
|
|
200
|
+
"""Return the current flight of an ICAO callsign or IATA flight number.
|
|
201
|
+
|
|
202
|
+
Experimental. Raises `MapFlightsNoCurrentFlightError` when there is no
|
|
203
|
+
current flight; that is a normal state and still counts against the
|
|
204
|
+
daily quota.
|
|
205
|
+
"""
|
|
206
|
+
callsign = callsign.replace(" ", "").upper()
|
|
207
|
+
if not _CALLSIGN_RE.match(callsign):
|
|
208
|
+
raise ValueError("callsign must be 2 to 10 letters or digits")
|
|
209
|
+
try:
|
|
210
|
+
data = await self._request(f"/api/v1/flights/by-callsign/{callsign}")
|
|
211
|
+
except MapFlightsNotFoundError as err:
|
|
212
|
+
reason = err.body.get("error")
|
|
213
|
+
if reason not in _NO_CURRENT_FLIGHT:
|
|
214
|
+
raise
|
|
215
|
+
last_flight = err.body.get("lastFlight")
|
|
216
|
+
raise MapFlightsNoCurrentFlightError(
|
|
217
|
+
str(err),
|
|
218
|
+
reason=reason,
|
|
219
|
+
last_flight=last_flight if isinstance(last_flight, dict) else None,
|
|
220
|
+
body=err.body,
|
|
221
|
+
) from err
|
|
222
|
+
return FlightPage.from_dict(data)
|
|
223
|
+
|
|
224
|
+
async def aircraft_flights(
|
|
225
|
+
self, hex_id: str, *, limit: int | None = None
|
|
226
|
+
) -> AircraftFlights:
|
|
227
|
+
"""Recent flights of an airframe, newest first."""
|
|
228
|
+
hex_id = hex_id.strip().lower()
|
|
229
|
+
if not _HEX_RE.match(hex_id):
|
|
230
|
+
raise ValueError("hex_id must be a 6-digit hex ICAO address")
|
|
231
|
+
params: dict[str, str] = {}
|
|
232
|
+
if limit is not None:
|
|
233
|
+
if not 1 <= limit <= AIRCRAFT_FLIGHTS_MAX_LIMIT:
|
|
234
|
+
raise ValueError(
|
|
235
|
+
f"limit must be between 1 and {AIRCRAFT_FLIGHTS_MAX_LIMIT}"
|
|
236
|
+
)
|
|
237
|
+
params["limit"] = str(limit)
|
|
238
|
+
data = await self._request(f"/api/v1/aircraft/{hex_id}/flights", params=params)
|
|
239
|
+
return AircraftFlights.from_dict(data)
|
|
240
|
+
|
|
241
|
+
async def airlines(
|
|
242
|
+
self,
|
|
243
|
+
*,
|
|
244
|
+
query: str | None = None,
|
|
245
|
+
limit: int | None = None,
|
|
246
|
+
offset: int = 0,
|
|
247
|
+
) -> AirlinePage:
|
|
248
|
+
"""One page of the observed airlines (for ICAO code to name)."""
|
|
249
|
+
params: dict[str, str] = {}
|
|
250
|
+
if query is not None:
|
|
251
|
+
params["q"] = query
|
|
252
|
+
if limit is not None:
|
|
253
|
+
if not 1 <= limit <= AIRLINES_MAX_LIMIT:
|
|
254
|
+
raise ValueError(f"limit must be between 1 and {AIRLINES_MAX_LIMIT}")
|
|
255
|
+
params["limit"] = str(limit)
|
|
256
|
+
if offset:
|
|
257
|
+
if offset < 0:
|
|
258
|
+
raise ValueError("offset must not be negative")
|
|
259
|
+
params["offset"] = str(offset)
|
|
260
|
+
return AirlinePage.from_dict(
|
|
261
|
+
await self._request("/api/v1/airlines", params=params)
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
async def _request(
|
|
265
|
+
self,
|
|
266
|
+
path: str,
|
|
267
|
+
*,
|
|
268
|
+
params: dict[str, str] | None = None,
|
|
269
|
+
live: bool = False,
|
|
270
|
+
) -> dict[str, Any]:
|
|
271
|
+
slot: AbstractAsyncContextManager[None]
|
|
272
|
+
if live:
|
|
273
|
+
slot = self._throttle.live()
|
|
274
|
+
else:
|
|
275
|
+
self._throttle.rest()
|
|
276
|
+
slot = nullcontext()
|
|
277
|
+
|
|
278
|
+
async with slot:
|
|
279
|
+
try:
|
|
280
|
+
_, body = await self._get(path, params, live=live)
|
|
281
|
+
except MapFlightsRateLimitError as err:
|
|
282
|
+
default = SCOPE_LIVE if live else SCOPE_MINUTE
|
|
283
|
+
self._throttle.block(err.scope or default, err.retry_after)
|
|
284
|
+
raise
|
|
285
|
+
|
|
286
|
+
if not isinstance(body, dict):
|
|
287
|
+
raise MapFlightsError("Unexpected response from map.flights")
|
|
288
|
+
return body
|
|
289
|
+
|
|
290
|
+
async def _get(
|
|
291
|
+
self, path: str, params: dict[str, str] | None, *, live: bool
|
|
292
|
+
) -> tuple[ClientResponse, object]:
|
|
293
|
+
url = self._base_url.with_path(path)
|
|
294
|
+
headers = {
|
|
295
|
+
HEADER_API_KEY: self._api_key,
|
|
296
|
+
"User-Agent": self._user_agent,
|
|
297
|
+
"Accept": "application/json",
|
|
298
|
+
}
|
|
299
|
+
try:
|
|
300
|
+
async with asyncio.timeout(self._request_timeout):
|
|
301
|
+
response = await self._session.get(url, params=params, headers=headers)
|
|
302
|
+
async with response:
|
|
303
|
+
self._read_headers(response, live=live)
|
|
304
|
+
body = await self._json(response)
|
|
305
|
+
except TimeoutError as err:
|
|
306
|
+
raise MapFlightsConnectionError(
|
|
307
|
+
"Timeout while talking to map.flights"
|
|
308
|
+
) from err
|
|
309
|
+
except ClientError as err:
|
|
310
|
+
raise MapFlightsConnectionError(
|
|
311
|
+
f"Error while talking to map.flights: {err}"
|
|
312
|
+
) from err
|
|
313
|
+
|
|
314
|
+
if response.status >= 400: # noqa: PLR2004
|
|
315
|
+
raise self._error(response, body)
|
|
316
|
+
return response, body
|
|
317
|
+
|
|
318
|
+
def _read_headers(self, response: ClientResponse, *, live: bool) -> None:
|
|
319
|
+
if attribution := response.headers.get(HEADER_ATTRIBUTION):
|
|
320
|
+
self.attribution = attribution
|
|
321
|
+
if HEADER_RATE_REMAINING not in response.headers:
|
|
322
|
+
return
|
|
323
|
+
info = RateLimitInfo(
|
|
324
|
+
limit=_int_or_none(response.headers.get(HEADER_RATE_LIMIT)),
|
|
325
|
+
remaining=_int_or_none(response.headers.get(HEADER_RATE_REMAINING)),
|
|
326
|
+
reset_s=_int_or_none(response.headers.get(HEADER_RATE_RESET)),
|
|
327
|
+
)
|
|
328
|
+
if live:
|
|
329
|
+
self.live_rate_limit = info
|
|
330
|
+
else:
|
|
331
|
+
self.daily_rate_limit = info
|
|
332
|
+
|
|
333
|
+
@staticmethod
|
|
334
|
+
async def _json(response: ClientResponse) -> object:
|
|
335
|
+
try:
|
|
336
|
+
return await response.json()
|
|
337
|
+
except (ContentTypeError, ValueError):
|
|
338
|
+
if response.status < 400: # noqa: PLR2004
|
|
339
|
+
raise MapFlightsError("map.flights did not answer with JSON") from None
|
|
340
|
+
return None
|
|
341
|
+
|
|
342
|
+
@staticmethod
|
|
343
|
+
def _error( # noqa: PLR0911
|
|
344
|
+
response: ClientResponse, body: object
|
|
345
|
+
) -> MapFlightsError:
|
|
346
|
+
info: dict[str, Any] = body if isinstance(body, dict) else {}
|
|
347
|
+
code = info.get("code")
|
|
348
|
+
message = str(
|
|
349
|
+
info.get("message") or info.get("error") or f"HTTP {response.status}"
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
if response.status == 401: # noqa: PLR2004
|
|
353
|
+
return MapFlightsAuthError(message)
|
|
354
|
+
if response.status == 403: # noqa: PLR2004
|
|
355
|
+
tariff = info.get("tariff")
|
|
356
|
+
code = str(code or "forbidden")
|
|
357
|
+
if code == "out_of_area":
|
|
358
|
+
return MapFlightsOutOfAreaError(message, code=code, tariff=tariff)
|
|
359
|
+
if code == "key_dormant":
|
|
360
|
+
return MapFlightsKeyDormantError(message, code=code, tariff=tariff)
|
|
361
|
+
if code in _NOT_IN_TARIFF:
|
|
362
|
+
return MapFlightsNotInTariffError(message, code=code, tariff=tariff)
|
|
363
|
+
return MapFlightsTariffError(message, code=code, tariff=tariff)
|
|
364
|
+
if response.status == 404: # noqa: PLR2004
|
|
365
|
+
return MapFlightsNotFoundError(message, body=info)
|
|
366
|
+
if response.status == 429: # noqa: PLR2004
|
|
367
|
+
retry_after = _int_or_none(response.headers.get(HEADER_RETRY_AFTER))
|
|
368
|
+
if retry_after is None:
|
|
369
|
+
retry_after = _int_or_none(info.get("retryAfterS"))
|
|
370
|
+
return MapFlightsRateLimitError(
|
|
371
|
+
message,
|
|
372
|
+
retry_after=retry_after,
|
|
373
|
+
scope=info.get("scope"),
|
|
374
|
+
limit=_int_or_none(info.get("limit")),
|
|
375
|
+
)
|
|
376
|
+
return MapFlightsError(f"HTTP {response.status}: {message}")
|
pymapflights/const.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Constants for the map.flights API client."""
|
|
2
|
+
|
|
3
|
+
from typing import Final
|
|
4
|
+
|
|
5
|
+
VERSION: Final = "0.2.0"
|
|
6
|
+
|
|
7
|
+
DEFAULT_BASE_URL: Final = "https://map.flights"
|
|
8
|
+
DEFAULT_USER_AGENT: Final = (
|
|
9
|
+
f"pymapflights/{VERSION} (+https://github.com/olli-dot-dev/pymapflights)"
|
|
10
|
+
)
|
|
11
|
+
DEFAULT_TIMEOUT: Final = 10.0
|
|
12
|
+
|
|
13
|
+
HEADER_API_KEY: Final = "x-api-key"
|
|
14
|
+
HEADER_ATTRIBUTION: Final = "x-data-attribution"
|
|
15
|
+
HEADER_RETRY_AFTER: Final = "Retry-After"
|
|
16
|
+
HEADER_RATE_LIMIT: Final = "X-RateLimit-Limit"
|
|
17
|
+
HEADER_RATE_REMAINING: Final = "X-RateLimit-Remaining"
|
|
18
|
+
HEADER_RATE_RESET: Final = "X-RateLimit-Reset"
|
|
19
|
+
|
|
20
|
+
KEY_PREFIX_LENGTH: Final = 16
|
|
21
|
+
"""`mf_live_` + 8 hex."""
|
|
22
|
+
|
|
23
|
+
NEAR_MAX_RADIUS_KM: Final = 500
|
|
24
|
+
NEAR_MAX_LIMIT: Final = 500
|
|
25
|
+
AIRCRAFT_FLIGHTS_MAX_LIMIT: Final = 50
|
|
26
|
+
AIRLINES_MAX_LIMIT: Final = 200
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Exceptions raised by the map.flights API client."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class MapFlightsError(Exception):
|
|
7
|
+
"""Base error of the map.flights client."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class MapFlightsConnectionError(MapFlightsError):
|
|
11
|
+
"""The API could not be reached or did not answer in time."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class MapFlightsAuthError(MapFlightsError):
|
|
15
|
+
"""The API key is unknown or revoked (401 `invalid_key`)."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class MapFlightsNotFoundError(MapFlightsError):
|
|
19
|
+
"""The requested object does not exist (404). Counts against the quota."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, message: str, *, body: dict[str, Any] | None = None) -> None:
|
|
22
|
+
"""Initialize with the parsed error body."""
|
|
23
|
+
super().__init__(message)
|
|
24
|
+
self.body: dict[str, Any] = body or {}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class MapFlightsNoCurrentFlightError(MapFlightsNotFoundError):
|
|
28
|
+
"""No current flight under a callsign (404 of `by-callsign`).
|
|
29
|
+
|
|
30
|
+
A normal state when tracking, not a failure. `reason` is `no current flight`
|
|
31
|
+
(only a flight older than 6 hours exists, see `last_flight`) or
|
|
32
|
+
`unknown flight` (nobody transmits the callsign and no flight is known).
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
message: str,
|
|
38
|
+
*,
|
|
39
|
+
reason: str,
|
|
40
|
+
last_flight: dict[str, Any] | None,
|
|
41
|
+
body: dict[str, Any] | None = None,
|
|
42
|
+
) -> None:
|
|
43
|
+
"""Initialize with the reason and the raw last flight (untyped in the spec)."""
|
|
44
|
+
super().__init__(message, body=body)
|
|
45
|
+
self.reason = reason
|
|
46
|
+
self.last_flight = last_flight
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class MapFlightsTariffError(MapFlightsError):
|
|
50
|
+
"""The key's tariff denies the request (403)."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, message: str, *, code: str, tariff: str | None) -> None:
|
|
53
|
+
"""Initialize with the machine-readable code and the key's tariff."""
|
|
54
|
+
super().__init__(message)
|
|
55
|
+
self.code = code
|
|
56
|
+
self.tariff = tariff
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class MapFlightsOutOfAreaError(MapFlightsTariffError):
|
|
60
|
+
"""The request lies completely outside the key's area (403 `out_of_area`)."""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class MapFlightsKeyDormantError(MapFlightsTariffError):
|
|
64
|
+
"""No station of the key's owner sent data for 7 days (403 `key_dormant`)."""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class MapFlightsNotInTariffError(MapFlightsTariffError):
|
|
68
|
+
"""The endpoint, its group or the format is not part of the tariff."""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class MapFlightsRateLimitError(MapFlightsError):
|
|
72
|
+
"""A rate limit was exceeded (429), or the client refused to exceed it.
|
|
73
|
+
|
|
74
|
+
`scope` is `live`, `minute` or `day`. With `local` the request was never
|
|
75
|
+
sent because the client knows it would be rejected.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
def __init__(
|
|
79
|
+
self,
|
|
80
|
+
message: str,
|
|
81
|
+
*,
|
|
82
|
+
retry_after: int | None,
|
|
83
|
+
scope: str | None,
|
|
84
|
+
limit: int | None,
|
|
85
|
+
local: bool = False,
|
|
86
|
+
) -> None:
|
|
87
|
+
"""Initialize with the wait time and which ceiling was hit."""
|
|
88
|
+
super().__init__(message)
|
|
89
|
+
self.local = local
|
|
90
|
+
self.retry_after = retry_after
|
|
91
|
+
self.scope = scope
|
|
92
|
+
self.limit = limit
|
pymapflights/models.py
ADDED
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
"""Data models of the map.flights API.
|
|
2
|
+
|
|
3
|
+
Derived from the OpenAPI specification (`/api/v1/openapi.json`). Unknown fields
|
|
4
|
+
are ignored, because stable operations may gain fields within `/api/v1`.
|
|
5
|
+
Models of experimental operations make every field optional, because those
|
|
6
|
+
operations may change within `/api/v1`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from datetime import UTC, datetime
|
|
13
|
+
|
|
14
|
+
from mashumaro import DataClassDictMixin, field_options
|
|
15
|
+
from mashumaro.types import SerializationStrategy
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class UnixMillis(SerializationStrategy):
|
|
19
|
+
"""Unix milliseconds (UTC) as an aware datetime."""
|
|
20
|
+
|
|
21
|
+
def serialize(self, value: datetime) -> int:
|
|
22
|
+
"""Convert a datetime to Unix milliseconds."""
|
|
23
|
+
return int(value.timestamp() * 1000)
|
|
24
|
+
|
|
25
|
+
def deserialize(self, value: int) -> datetime:
|
|
26
|
+
"""Convert Unix milliseconds to an aware datetime."""
|
|
27
|
+
return datetime.fromtimestamp(value / 1000, tz=UTC)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class _Model(DataClassDictMixin):
|
|
31
|
+
"""Base of all API models: camelCase aliases, timestamps as datetime."""
|
|
32
|
+
|
|
33
|
+
class Config:
|
|
34
|
+
"""mashumaro options."""
|
|
35
|
+
|
|
36
|
+
serialize_by_alias = True
|
|
37
|
+
serialization_strategy = {datetime: UnixMillis()} # noqa: RUF012
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class RateLimitInfo:
|
|
42
|
+
"""`X-RateLimit-*` headers of a response."""
|
|
43
|
+
|
|
44
|
+
limit: int | None
|
|
45
|
+
remaining: int | None
|
|
46
|
+
reset_s: int | None
|
|
47
|
+
"""Seconds until the ceiling resets (00:00 UTC for the daily quota)."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# --- /near ------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class NearAircraft(_Model):
|
|
55
|
+
"""One aircraft of `GET /near`. Units: ft, kt, degrees, km."""
|
|
56
|
+
|
|
57
|
+
hex: str
|
|
58
|
+
lat: float
|
|
59
|
+
lon: float
|
|
60
|
+
alt_baro: int | None = field(metadata=field_options(alias="altBaro"))
|
|
61
|
+
"""Barometric altitude in ft; None = on ground or unknown."""
|
|
62
|
+
on_ground: bool = field(metadata=field_options(alias="onGround"))
|
|
63
|
+
gs: float | None
|
|
64
|
+
"""Ground speed in kt."""
|
|
65
|
+
track: float | None
|
|
66
|
+
"""Track over ground in degrees."""
|
|
67
|
+
callsign: str | None
|
|
68
|
+
registration: str | None
|
|
69
|
+
typecode: str | None
|
|
70
|
+
"""ICAO type code."""
|
|
71
|
+
airline_icao: str | None = field(metadata=field_options(alias="airlineIcao"))
|
|
72
|
+
"""Airline ICAO derived from the callsign prefix."""
|
|
73
|
+
category: str | None
|
|
74
|
+
"""ADS-B emitter category A0-D7."""
|
|
75
|
+
kind: str
|
|
76
|
+
"""airline, cargo, biz, ga, heli, mil, other or ground (may grow)."""
|
|
77
|
+
emergency: str | None
|
|
78
|
+
origin: str | None
|
|
79
|
+
"""Origin ICAO from the callsign mapping (candidate, not observed)."""
|
|
80
|
+
dest: str | None
|
|
81
|
+
"""Destination ICAO from the callsign mapping (candidate, not observed)."""
|
|
82
|
+
distance_km: float = field(metadata=field_options(alias="distanceKm"))
|
|
83
|
+
bearing: float
|
|
84
|
+
"""Bearing from the query point in degrees from north."""
|
|
85
|
+
age_s: int | None = field(default=None, metadata=field_options(alias="ageS"))
|
|
86
|
+
"""Age of the last real observation in seconds, relative to `now`."""
|
|
87
|
+
estimated: bool = field(default=False, metadata=field_options(alias="est"))
|
|
88
|
+
"""Position is estimated outside coverage."""
|
|
89
|
+
ground_object: bool = field(default=False, metadata=field_options(alias="gndObj"))
|
|
90
|
+
"""Unknown ground object, not an aircraft."""
|
|
91
|
+
gs_estimated: bool = field(default=False, metadata=field_options(alias="gsEst"))
|
|
92
|
+
track_estimated: bool = field(
|
|
93
|
+
default=False, metadata=field_options(alias="trackEst")
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
@classmethod
|
|
97
|
+
def __pre_deserialize__(cls, d: dict[str, object]) -> dict[str, object]:
|
|
98
|
+
"""Treat `null` flags as absent (the live diff sends `null` to clear)."""
|
|
99
|
+
for key in ("est", "gndObj", "gsEst", "trackEst"):
|
|
100
|
+
if d.get(key) is None:
|
|
101
|
+
d.pop(key, None)
|
|
102
|
+
return d
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass(frozen=True)
|
|
106
|
+
class NearResponse(_Model):
|
|
107
|
+
"""Response of `GET /near/{lat}/{lon}/{radius}`."""
|
|
108
|
+
|
|
109
|
+
now: datetime
|
|
110
|
+
"""Server time of the snapshot."""
|
|
111
|
+
total: int
|
|
112
|
+
"""Aircraft within the radius in total; `aircraft` is capped by `limit`."""
|
|
113
|
+
aircraft: list[NearAircraft] = field(metadata=field_options(alias="ac"))
|
|
114
|
+
"""Nearest aircraft first, at most `limit` entries."""
|
|
115
|
+
data_delay_s: int | None = field(
|
|
116
|
+
default=None, metadata=field_options(alias="dataDelayS")
|
|
117
|
+
)
|
|
118
|
+
"""Tariff delays all data by this many seconds; None = live data."""
|
|
119
|
+
data_delay_ready: bool | None = field(
|
|
120
|
+
default=None, metadata=field_options(alias="dataDelayReady")
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def ready(self) -> bool:
|
|
125
|
+
"""False while a delay buffer is still filling (`aircraft` is then empty).
|
|
126
|
+
|
|
127
|
+
An empty list in that state does not mean there is no traffic.
|
|
128
|
+
"""
|
|
129
|
+
return self.data_delay_ready is not False
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def truncated(self) -> bool:
|
|
133
|
+
"""True when more aircraft are in the radius than were returned."""
|
|
134
|
+
return self.total > len(self.aircraft)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# --- /me (experimental) -------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@dataclass(frozen=True)
|
|
141
|
+
class MeKey(_Model):
|
|
142
|
+
"""The calling key. `label` is internal text and deliberately not mapped."""
|
|
143
|
+
|
|
144
|
+
prefix: str
|
|
145
|
+
tier: str | None = None
|
|
146
|
+
created: datetime | None = None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@dataclass(frozen=True)
|
|
150
|
+
class MeTariff(_Model):
|
|
151
|
+
"""Tariff of the key (announced by the operator, not yet in the spec)."""
|
|
152
|
+
|
|
153
|
+
id: str | None = None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@dataclass(frozen=True)
|
|
157
|
+
class MeLimits(_Model):
|
|
158
|
+
"""Ceilings of the key."""
|
|
159
|
+
|
|
160
|
+
per_minute: int | None = field(
|
|
161
|
+
default=None, metadata=field_options(alias="perMinute")
|
|
162
|
+
)
|
|
163
|
+
per_day: int | None = field(default=None, metadata=field_options(alias="perDay"))
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@dataclass(frozen=True)
|
|
167
|
+
class MeUsage(_Model):
|
|
168
|
+
"""Today's usage of the key."""
|
|
169
|
+
|
|
170
|
+
day: str | None = None
|
|
171
|
+
"""UTC day the counters belong to."""
|
|
172
|
+
total: int | None = None
|
|
173
|
+
remaining_day: int | None = field(
|
|
174
|
+
default=None, metadata=field_options(alias="remainingDay")
|
|
175
|
+
)
|
|
176
|
+
reset_s: int | None = field(default=None, metadata=field_options(alias="resetS"))
|
|
177
|
+
"""Seconds until the quota resets (00:00 UTC)."""
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@dataclass(frozen=True)
|
|
181
|
+
class Me(_Model):
|
|
182
|
+
"""Response of `GET /me`."""
|
|
183
|
+
|
|
184
|
+
key: MeKey
|
|
185
|
+
limits: MeLimits = field(default_factory=MeLimits)
|
|
186
|
+
usage: MeUsage = field(default_factory=MeUsage)
|
|
187
|
+
tariff: MeTariff | None = None
|
|
188
|
+
attribution: str | None = None
|
|
189
|
+
|
|
190
|
+
@property
|
|
191
|
+
def tariff_id(self) -> str | None:
|
|
192
|
+
"""`home` or `feeder`: `tariff.id`, falling back to `key.tier`."""
|
|
193
|
+
if self.tariff is not None and self.tariff.id:
|
|
194
|
+
return self.tariff.id
|
|
195
|
+
return self.key.tier
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
# --- shared ----------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@dataclass(frozen=True)
|
|
202
|
+
class AirportRef(_Model):
|
|
203
|
+
"""Airport as referenced from flights and boards."""
|
|
204
|
+
|
|
205
|
+
icao: str
|
|
206
|
+
iata: str | None = None
|
|
207
|
+
name: str | None = None
|
|
208
|
+
municipality: str | None = None
|
|
209
|
+
country: str | None = None
|
|
210
|
+
"""ISO 3166-1 alpha-2 country code."""
|
|
211
|
+
lat: float | None = None
|
|
212
|
+
lon: float | None = None
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
@dataclass(frozen=True)
|
|
216
|
+
class FlightSummary(_Model):
|
|
217
|
+
"""One flight as built from observed positions.
|
|
218
|
+
|
|
219
|
+
`takeoff_time` and `landing_time` are measured by map.flights; a landing
|
|
220
|
+
appears 20 to 35 minutes after touchdown, and only where receivers see the
|
|
221
|
+
airport down to the ground.
|
|
222
|
+
"""
|
|
223
|
+
|
|
224
|
+
id: int | None
|
|
225
|
+
"""Flight id; None while the flight is still being assembled."""
|
|
226
|
+
hex: str | None = None
|
|
227
|
+
callsign: str | None = None
|
|
228
|
+
registration: str | None = None
|
|
229
|
+
typecode: str | None = None
|
|
230
|
+
airline_icao: str | None = field(
|
|
231
|
+
default=None, metadata=field_options(alias="airlineIcao")
|
|
232
|
+
)
|
|
233
|
+
origin: AirportRef | None = None
|
|
234
|
+
dest: AirportRef | None = None
|
|
235
|
+
first_seen: datetime | None = field(
|
|
236
|
+
default=None, metadata=field_options(alias="firstSeen")
|
|
237
|
+
)
|
|
238
|
+
last_seen: datetime | None = field(
|
|
239
|
+
default=None, metadata=field_options(alias="lastSeen")
|
|
240
|
+
)
|
|
241
|
+
takeoff_time: datetime | None = field(
|
|
242
|
+
default=None, metadata=field_options(alias="takeoffTime")
|
|
243
|
+
)
|
|
244
|
+
landing_time: datetime | None = field(
|
|
245
|
+
default=None, metadata=field_options(alias="landingTime")
|
|
246
|
+
)
|
|
247
|
+
completeness: str | None = None
|
|
248
|
+
"""complete, no_takeoff, no_landing or mid_air_only."""
|
|
249
|
+
classification: str | None = None
|
|
250
|
+
diverted: bool = False
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
# --- /aircraft/{hex}/flights ----------------------------------------------------
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
@dataclass(frozen=True)
|
|
257
|
+
class AircraftFlights(_Model):
|
|
258
|
+
"""Response of `GET /aircraft/{hex}/flights`, newest first."""
|
|
259
|
+
|
|
260
|
+
hex: str
|
|
261
|
+
flights: list[FlightSummary]
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
# --- /flights/by-callsign (experimental) -----------------------------------------
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
@dataclass(frozen=True)
|
|
268
|
+
class LiveState(_Model):
|
|
269
|
+
"""Live state of the flight in progress. Units: ft, kt, ft/min."""
|
|
270
|
+
|
|
271
|
+
hex: str | None = None
|
|
272
|
+
callsign: str | None = None
|
|
273
|
+
lat: float | None = None
|
|
274
|
+
lon: float | None = None
|
|
275
|
+
alt_baro: int | None = field(default=None, metadata=field_options(alias="altBaro"))
|
|
276
|
+
on_ground: bool | None = field(
|
|
277
|
+
default=None, metadata=field_options(alias="onGround")
|
|
278
|
+
)
|
|
279
|
+
gs: float | None = None
|
|
280
|
+
track: float | None = None
|
|
281
|
+
baro_rate: float | None = field(
|
|
282
|
+
default=None, metadata=field_options(alias="baroRate")
|
|
283
|
+
)
|
|
284
|
+
squawk: str | None = None
|
|
285
|
+
emergency: str | None = None
|
|
286
|
+
updated: datetime | None = None
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
@dataclass(frozen=True)
|
|
290
|
+
class Delay(_Model):
|
|
291
|
+
"""Delay of one end of a flight against the plan."""
|
|
292
|
+
|
|
293
|
+
band: str | None = None
|
|
294
|
+
"""early, on_time, delayed_15, delayed_30, delayed_60; None = unknown."""
|
|
295
|
+
minutes: int | None = None
|
|
296
|
+
"""Deviation in minutes, positive = late."""
|
|
297
|
+
estimated: bool = False
|
|
298
|
+
"""The minute figure is an estimate: show the band, not the number."""
|
|
299
|
+
basis: str | None = None
|
|
300
|
+
"""schedule (airport feed) or baseline (median of observations)."""
|
|
301
|
+
sched_time: datetime | None = field(
|
|
302
|
+
default=None, metadata=field_options(alias="schedTime")
|
|
303
|
+
)
|
|
304
|
+
est_time: datetime | None = field(
|
|
305
|
+
default=None, metadata=field_options(alias="estTime")
|
|
306
|
+
)
|
|
307
|
+
actual_time: datetime | None = field(
|
|
308
|
+
default=None, metadata=field_options(alias="actualTime")
|
|
309
|
+
)
|
|
310
|
+
status: str | None = None
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
@dataclass(frozen=True)
|
|
314
|
+
class FlightDelay(_Model):
|
|
315
|
+
"""Departure and arrival delay of a flight."""
|
|
316
|
+
|
|
317
|
+
dep: Delay | None = None
|
|
318
|
+
arr: Delay | None = None
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
@dataclass(frozen=True)
|
|
322
|
+
class FlightPage(_Model):
|
|
323
|
+
"""Response of `GET /flights/by-callsign/{callsign}`."""
|
|
324
|
+
|
|
325
|
+
now: datetime | None = None
|
|
326
|
+
flight: FlightSummary | None = None
|
|
327
|
+
live: LiveState | None = None
|
|
328
|
+
"""Present only while this flight is the one in progress."""
|
|
329
|
+
delay: FlightDelay | None = None
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
# --- /airports/{icao}/day-board (experimental) -------------------------------------
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
@dataclass(frozen=True)
|
|
336
|
+
class Attribution(_Model):
|
|
337
|
+
"""Source credit of a board."""
|
|
338
|
+
|
|
339
|
+
text: str | None = None
|
|
340
|
+
url: str | None = None
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
@dataclass(frozen=True)
|
|
344
|
+
class DayBoardRow(_Model):
|
|
345
|
+
"""One row of an airport day board."""
|
|
346
|
+
|
|
347
|
+
key: str
|
|
348
|
+
basis: str | None = None
|
|
349
|
+
"""board (live board), schedule (timetable), usual (learned) or observed."""
|
|
350
|
+
plan_time: datetime | None = field(
|
|
351
|
+
default=None, metadata=field_options(alias="planTime")
|
|
352
|
+
)
|
|
353
|
+
est_time: datetime | None = field(
|
|
354
|
+
default=None, metadata=field_options(alias="estTime")
|
|
355
|
+
)
|
|
356
|
+
measured_time: datetime | None = field(
|
|
357
|
+
default=None, metadata=field_options(alias="measuredTime")
|
|
358
|
+
)
|
|
359
|
+
"""Take-off or landing measured by map.flights, never a board time."""
|
|
360
|
+
seen_time: datetime | None = field(
|
|
361
|
+
default=None, metadata=field_options(alias="seenTime")
|
|
362
|
+
)
|
|
363
|
+
flight_number: str | None = field(
|
|
364
|
+
default=None, metadata=field_options(alias="flightNumber")
|
|
365
|
+
)
|
|
366
|
+
callsign: str | None = None
|
|
367
|
+
codeshares: list[str] | None = None
|
|
368
|
+
airline_icao: str | None = field(
|
|
369
|
+
default=None, metadata=field_options(alias="airlineIcao")
|
|
370
|
+
)
|
|
371
|
+
airline_iata: str | None = field(
|
|
372
|
+
default=None, metadata=field_options(alias="airlineIata")
|
|
373
|
+
)
|
|
374
|
+
airline_name: str | None = field(
|
|
375
|
+
default=None, metadata=field_options(alias="airlineName")
|
|
376
|
+
)
|
|
377
|
+
counterpart: AirportRef | None = None
|
|
378
|
+
"""Origin for arrivals, destination for departures."""
|
|
379
|
+
gate: str | None = None
|
|
380
|
+
terminal: str | None = None
|
|
381
|
+
belt: str | None = None
|
|
382
|
+
status: str | None = None
|
|
383
|
+
band: str | None = None
|
|
384
|
+
"""early, on_time, delayed_15, delayed_30, delayed_60; None = unknown."""
|
|
385
|
+
band_basis: str | None = field(
|
|
386
|
+
default=None, metadata=field_options(alias="bandBasis")
|
|
387
|
+
)
|
|
388
|
+
typecode: str | None = None
|
|
389
|
+
flight_id: int | None = field(
|
|
390
|
+
default=None, metadata=field_options(alias="flightId")
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
@dataclass(frozen=True)
|
|
395
|
+
class DayBoard(_Model):
|
|
396
|
+
"""Response of `GET /airports/{icao}/day-board`."""
|
|
397
|
+
|
|
398
|
+
icao: str
|
|
399
|
+
iata: str | None = None
|
|
400
|
+
name: str | None = None
|
|
401
|
+
tz: str | None = None
|
|
402
|
+
"""IANA time zone of the airport."""
|
|
403
|
+
dir: str | None = None
|
|
404
|
+
day: int | None = None
|
|
405
|
+
date: str | None = None
|
|
406
|
+
"""Local date of the day (YYYY-MM-DD)."""
|
|
407
|
+
from_time: datetime | None = field(
|
|
408
|
+
default=None, metadata=field_options(alias="from")
|
|
409
|
+
)
|
|
410
|
+
to_time: datetime | None = field(default=None, metadata=field_options(alias="to"))
|
|
411
|
+
generated_at: datetime | None = field(
|
|
412
|
+
default=None, metadata=field_options(alias="generatedAt")
|
|
413
|
+
)
|
|
414
|
+
source: str | None = None
|
|
415
|
+
"""feed, observed or none (nothing known, rows empty)."""
|
|
416
|
+
rows: list[DayBoardRow] = field(default_factory=list)
|
|
417
|
+
truncated: bool = False
|
|
418
|
+
attribution: Attribution | None = None
|
|
419
|
+
board_fetched: datetime | None = field(
|
|
420
|
+
default=None, metadata=field_options(alias="boardFetched")
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
# --- /airlines -----------------------------------------------------------------
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
@dataclass(frozen=True)
|
|
428
|
+
class Airline(_Model):
|
|
429
|
+
"""One airline of `GET /airlines`."""
|
|
430
|
+
|
|
431
|
+
icao: str
|
|
432
|
+
iata: str | None = None
|
|
433
|
+
name: str | None = None
|
|
434
|
+
country: str | None = None
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
@dataclass(frozen=True)
|
|
438
|
+
class AirlinePage(_Model):
|
|
439
|
+
"""One page of `GET /airlines`."""
|
|
440
|
+
|
|
441
|
+
total: int
|
|
442
|
+
items: list[Airline]
|
pymapflights/py.typed
ADDED
|
File without changes
|
pymapflights/throttle.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Client-side enforcement of the map.flights rate limits, shared per key.
|
|
2
|
+
|
|
3
|
+
All clients created with the same key in one process share one `KeyThrottle`,
|
|
4
|
+
so no caller can bypass the live-data interval by creating a second client.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import math
|
|
11
|
+
import time
|
|
12
|
+
from collections import deque
|
|
13
|
+
from contextlib import asynccontextmanager
|
|
14
|
+
from typing import TYPE_CHECKING, Final
|
|
15
|
+
|
|
16
|
+
from .exceptions import MapFlightsRateLimitError
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from collections.abc import AsyncIterator
|
|
20
|
+
|
|
21
|
+
LIVE_INTERVAL_S: Final = 5.0
|
|
22
|
+
"""`/live` and `/near`: at most one request every 5 s per key."""
|
|
23
|
+
MINUTE_LIMIT: Final = 60
|
|
24
|
+
"""All endpoints together: at most 60 requests per minute per key."""
|
|
25
|
+
MINUTE_WINDOW_S: Final = 60.0
|
|
26
|
+
|
|
27
|
+
SCOPE_LIVE: Final = "live"
|
|
28
|
+
SCOPE_MINUTE: Final = "minute"
|
|
29
|
+
SCOPE_DAY: Final = "day"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class KeyThrottle:
|
|
33
|
+
"""Rate limits of one API key."""
|
|
34
|
+
|
|
35
|
+
def __init__(self) -> None:
|
|
36
|
+
"""Initialize with the limits in force at creation time."""
|
|
37
|
+
self.live_interval = LIVE_INTERVAL_S
|
|
38
|
+
self.minute_limit = MINUTE_LIMIT
|
|
39
|
+
self._lock: asyncio.Lock | None = None
|
|
40
|
+
self._lock_loop: asyncio.AbstractEventLoop | None = None
|
|
41
|
+
self._next_live = 0.0
|
|
42
|
+
self._minute: deque[float] = deque()
|
|
43
|
+
self._blocked: dict[str, float] = {}
|
|
44
|
+
|
|
45
|
+
def _live_lock(self) -> asyncio.Lock:
|
|
46
|
+
loop = asyncio.get_running_loop()
|
|
47
|
+
if self._lock is None or self._lock_loop is not loop:
|
|
48
|
+
self._lock = asyncio.Lock()
|
|
49
|
+
self._lock_loop = loop
|
|
50
|
+
return self._lock
|
|
51
|
+
|
|
52
|
+
@asynccontextmanager
|
|
53
|
+
async def live(self) -> AsyncIterator[None]:
|
|
54
|
+
"""Hold the live slot of the key for one request.
|
|
55
|
+
|
|
56
|
+
Waits for the next free slot when it is at most one interval away and
|
|
57
|
+
refuses (local 429) when the key is blocked for longer. The slot is used
|
|
58
|
+
up whatever the outcome; a `/near` outside the area costs it too.
|
|
59
|
+
"""
|
|
60
|
+
async with self._live_lock():
|
|
61
|
+
now = time.monotonic()
|
|
62
|
+
free_at = max(self._next_live, self._blocked.get(SCOPE_LIVE, 0.0))
|
|
63
|
+
wait = free_at - now
|
|
64
|
+
if wait > self.live_interval:
|
|
65
|
+
raise _local_error(SCOPE_LIVE, wait)
|
|
66
|
+
if wait > 0:
|
|
67
|
+
await asyncio.sleep(wait)
|
|
68
|
+
self._take_minute_slot()
|
|
69
|
+
try:
|
|
70
|
+
yield
|
|
71
|
+
finally:
|
|
72
|
+
self._next_live = time.monotonic() + self.live_interval
|
|
73
|
+
|
|
74
|
+
def rest(self) -> None:
|
|
75
|
+
"""Account for one request to an endpoint outside the live group."""
|
|
76
|
+
now = time.monotonic()
|
|
77
|
+
for scope in (SCOPE_DAY, SCOPE_MINUTE):
|
|
78
|
+
wait = self._blocked.get(scope, 0.0) - now
|
|
79
|
+
if wait > 0:
|
|
80
|
+
raise _local_error(scope, wait)
|
|
81
|
+
self._take_minute_slot()
|
|
82
|
+
|
|
83
|
+
def block(self, scope: str, retry_after: int | None) -> None:
|
|
84
|
+
"""Remember a 429 so that further requests are not sent too early."""
|
|
85
|
+
if retry_after is None or retry_after <= 0:
|
|
86
|
+
return
|
|
87
|
+
until = time.monotonic() + retry_after
|
|
88
|
+
self._blocked[scope] = max(self._blocked.get(scope, 0.0), until)
|
|
89
|
+
|
|
90
|
+
def _take_minute_slot(self) -> None:
|
|
91
|
+
now = time.monotonic()
|
|
92
|
+
while self._minute and self._minute[0] <= now - MINUTE_WINDOW_S:
|
|
93
|
+
self._minute.popleft()
|
|
94
|
+
if len(self._minute) >= self.minute_limit:
|
|
95
|
+
raise _local_error(SCOPE_MINUTE, self._minute[0] + MINUTE_WINDOW_S - now)
|
|
96
|
+
self._minute.append(now)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _local_error(scope: str, wait: float) -> MapFlightsRateLimitError:
|
|
100
|
+
retry_after = max(1, math.ceil(wait))
|
|
101
|
+
return MapFlightsRateLimitError(
|
|
102
|
+
f"Rate limit '{scope}' reached, retry in {retry_after} s",
|
|
103
|
+
retry_after=retry_after,
|
|
104
|
+
scope=scope,
|
|
105
|
+
limit=None,
|
|
106
|
+
local=True,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
_throttles: dict[str, KeyThrottle] = {}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def throttle_for(api_key: str) -> KeyThrottle:
|
|
114
|
+
"""Return the shared throttle of `api_key`."""
|
|
115
|
+
if (throttle := _throttles.get(api_key)) is None:
|
|
116
|
+
throttle = _throttles[api_key] = KeyThrottle()
|
|
117
|
+
return throttle
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pymapflights
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Asynchronous Python client for the map.flights API (unofficial)
|
|
5
|
+
Project-URL: Homepage, https://github.com/olli-dot-dev/pymapflights
|
|
6
|
+
Project-URL: Issues, https://github.com/olli-dot-dev/pymapflights/issues
|
|
7
|
+
Author-email: Olli <1@olli.dev>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: ads-b,aircraft,async,flights,home-assistant,map.flights
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Framework :: AsyncIO
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Typing :: Typed
|
|
18
|
+
Requires-Python: >=3.12
|
|
19
|
+
Requires-Dist: aiohttp>=3.9.0
|
|
20
|
+
Requires-Dist: mashumaro>=3.13
|
|
21
|
+
Requires-Dist: yarl>=1.9.0
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# pymapflights
|
|
25
|
+
|
|
26
|
+
Asynchronous Python client for the [map.flights](https://map.flights) API.
|
|
27
|
+
|
|
28
|
+
> **Unofficial.** This library is not made or endorsed by map.flights. It is the
|
|
29
|
+
> API layer of the Home Assistant integration
|
|
30
|
+
> [ha-mapflights](https://github.com/olli-dot-dev/ha-mapflights).
|
|
31
|
+
|
|
32
|
+
## Installation
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install pymapflights
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Usage
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
import asyncio
|
|
42
|
+
|
|
43
|
+
from aiohttp import ClientSession
|
|
44
|
+
|
|
45
|
+
from pymapflights import MapFlights
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
async def main() -> None:
|
|
49
|
+
async with ClientSession() as session:
|
|
50
|
+
client = MapFlights(
|
|
51
|
+
"YOUR_API_KEY",
|
|
52
|
+
session=session,
|
|
53
|
+
user_agent="my-app/1.0 (+https://example.com)",
|
|
54
|
+
)
|
|
55
|
+
result = await client.near(48.11, 16.57, 25, limit=100)
|
|
56
|
+
print(f"{result.total} aircraft within 25 km")
|
|
57
|
+
for aircraft in result.aircraft:
|
|
58
|
+
print(aircraft.callsign, aircraft.distance_km, aircraft.alt_baro)
|
|
59
|
+
print(client.attribution)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
asyncio.run(main())
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Notes:
|
|
66
|
+
|
|
67
|
+
- `total` is the number of aircraft in the radius; `aircraft` is capped by
|
|
68
|
+
`limit` (API default 50, max 500). `result.truncated` tells you when the list
|
|
69
|
+
is incomplete.
|
|
70
|
+
- When `result.ready` is `False`, a delayed tariff is still filling its buffer
|
|
71
|
+
and the empty list does not mean there is no traffic.
|
|
72
|
+
- The session belongs to you; the client never closes it.
|
|
73
|
+
|
|
74
|
+
## Endpoints
|
|
75
|
+
|
|
76
|
+
| Method | Endpoint | Stability | Quota |
|
|
77
|
+
|---|---|---|---|
|
|
78
|
+
| `near(lat, lon, radius_km, limit=)` | `/near/{lat}/{lon}/{radius}` | stable | live (1 per 5 s) |
|
|
79
|
+
| `aircraft_flights(hex_id, limit=)` | `/aircraft/{hex}/flights` | stable | daily |
|
|
80
|
+
| `airlines(query=, limit=, offset=)` | `/airlines` | stable | daily |
|
|
81
|
+
| `me()` | `/me` | experimental | daily |
|
|
82
|
+
| `day_board(airport, "arr" \| "dep", day=)` | `/airports/{icao}/day-board` | experimental | daily |
|
|
83
|
+
| `flight_by_callsign(callsign)` | `/flights/by-callsign/{callsign}` | experimental | daily |
|
|
84
|
+
|
|
85
|
+
Experimental endpoints may change within `/api/v1`; their models make every
|
|
86
|
+
field optional. `key_prefix(api_key)` returns the non-secret key prefix
|
|
87
|
+
(`mf_live_` + 8 hex) without a request.
|
|
88
|
+
|
|
89
|
+
`flight_by_callsign()` raises `MapFlightsNoCurrentFlightError` when there is
|
|
90
|
+
no current flight (`reason` is `no current flight` with `last_flight`, or
|
|
91
|
+
`unknown flight`). That is a normal state, but it still counts against the
|
|
92
|
+
daily quota. A landing appears there only 20 to 35 minutes after touchdown.
|
|
93
|
+
|
|
94
|
+
## API keys and terms
|
|
95
|
+
|
|
96
|
+
Keys are issued by map.flights (operators of a receiving station can create a
|
|
97
|
+
free key at <https://map.flights/account/api-key>). Use of the API is subject to
|
|
98
|
+
the [map.flights API terms](https://map.flights/api-terms). Attribution is
|
|
99
|
+
mandatory: show at least "Data: map.flights"; the full text of the last response
|
|
100
|
+
is in `client.attribution`.
|
|
101
|
+
|
|
102
|
+
### Rate limits
|
|
103
|
+
|
|
104
|
+
The client enforces the limits per key, shared by every client in the process
|
|
105
|
+
that uses the same key:
|
|
106
|
+
|
|
107
|
+
- **Live** (`near`): one request every 5 s. A call waits for its slot. Every
|
|
108
|
+
call uses the slot, even one that fails (a `near` outside the key's area too).
|
|
109
|
+
- **Minute**: at most 60 requests per minute over all endpoints.
|
|
110
|
+
- **After a 429** the client remembers `Retry-After` for that scope (`live`,
|
|
111
|
+
`minute` or `day`) and refuses further requests of that scope until then.
|
|
112
|
+
|
|
113
|
+
A refused request raises `MapFlightsRateLimitError` with `local=True` and is
|
|
114
|
+
never sent. `retry_after` and `scope` are set either way.
|
|
115
|
+
|
|
116
|
+
`client.live_rate_limit` and `client.daily_rate_limit` hold the
|
|
117
|
+
`X-RateLimit-*` headers of the last live and the last other response. For live
|
|
118
|
+
requests they describe the live bucket, not the daily quota, so
|
|
119
|
+
`client.remaining_today` only comes from non-live responses.
|
|
120
|
+
|
|
121
|
+
## Errors
|
|
122
|
+
|
|
123
|
+
| Exception | When |
|
|
124
|
+
|---|---|
|
|
125
|
+
| `MapFlightsAuthError` | 401, unknown or revoked key |
|
|
126
|
+
| `MapFlightsOutOfAreaError` | 403 `out_of_area` |
|
|
127
|
+
| `MapFlightsKeyDormantError` | 403 `key_dormant` |
|
|
128
|
+
| `MapFlightsNotInTariffError` | 403 `group_not_in_tariff`, `endpoint_not_in_tariff`, `format_not_in_tariff` |
|
|
129
|
+
| `MapFlightsTariffError` | base class of the 403 errors above, also for unknown codes |
|
|
130
|
+
| `MapFlightsNotFoundError` | 404 (parsed body in `body`) |
|
|
131
|
+
| `MapFlightsNoCurrentFlightError` | 404 of `flight_by_callsign`, a normal state |
|
|
132
|
+
| `MapFlightsRateLimitError` | 429, or refused locally (`local=True`) |
|
|
133
|
+
| `MapFlightsConnectionError` | timeout or network error |
|
|
134
|
+
| `MapFlightsError` | base class of everything above |
|
|
135
|
+
|
|
136
|
+
## Development
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
uv sync
|
|
140
|
+
uv run pytest
|
|
141
|
+
uv run ruff check .
|
|
142
|
+
uv run ruff format --check .
|
|
143
|
+
uv run mypy
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## License
|
|
147
|
+
|
|
148
|
+
MIT
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
pymapflights/__init__.py,sha256=sPqkFxZOim2u3VdP-Rqqx-Q2V6AZnzeLEB8zkNGRtNs,1547
|
|
2
|
+
pymapflights/client.py,sha256=Eri3xrtQol1YfdX13_P7PD5Gju2Fwo6PYEPMQFg1Fj4,13660
|
|
3
|
+
pymapflights/const.py,sha256=bhbYa2VB7f9NpP88uEc0ExRwmgqe7Yf9v4hONl9gHtg,755
|
|
4
|
+
pymapflights/exceptions.py,sha256=pf0tj_75PtBu3A_POlvd_7PJV6he1sW6nWGmAOhXTz8,2950
|
|
5
|
+
pymapflights/models.py,sha256=4ZgluIqspYz3OF_JyA2fi9TpNa8yjm-KW01v-0obgK0,14161
|
|
6
|
+
pymapflights/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
pymapflights/throttle.py,sha256=clFYUMHMlTjRzClDMMisgqjqApQseXapSxqc-FuE-CM,4087
|
|
8
|
+
pymapflights-0.2.0.dist-info/METADATA,sha256=qmtuam0kzUUxNKmupkSgGT81wdfpac_aiAlvMs3bD2A,5400
|
|
9
|
+
pymapflights-0.2.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
10
|
+
pymapflights-0.2.0.dist-info/licenses/LICENSE,sha256=lDYZn3ck-coHmrSgc_nApJuASm_uTCffTLQE-iZBhdY,1061
|
|
11
|
+
pymapflights-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Olli
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|