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.
@@ -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
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.