flightbyte 1.0.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.
- flightbyte/__init__.py +136 -0
- flightbyte/_client.py +157 -0
- flightbyte/_countries.py +57 -0
- flightbyte/_errors.py +311 -0
- flightbyte/_flights.py +84 -0
- flightbyte/_models.py +644 -0
- flightbyte/_rate_limit.py +75 -0
- flightbyte/_subscriptions.py +317 -0
- flightbyte/_transport.py +429 -0
- flightbyte/_util.py +28 -0
- flightbyte/_version.py +3 -0
- flightbyte/_webhook.py +233 -0
- flightbyte/py.typed +0 -0
- flightbyte-1.0.0.dist-info/METADATA +423 -0
- flightbyte-1.0.0.dist-info/RECORD +17 -0
- flightbyte-1.0.0.dist-info/WHEEL +4 -0
- flightbyte-1.0.0.dist-info/licenses/LICENSE +21 -0
flightbyte/__init__.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""The official Python client for the FlightByte flight status API.
|
|
2
|
+
|
|
3
|
+
Look flights up, watch them with signed webhooks and verify every delivery::
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from flightbyte import FlightByte
|
|
7
|
+
|
|
8
|
+
client = FlightByte(api_key=os.environ["FLIGHTBYTE_API_KEY"])
|
|
9
|
+
flight = client.flights.get("TK", "1848", "2026-09-08")
|
|
10
|
+
print(flight["status"]["label"], flight["departure"].get("gate"))
|
|
11
|
+
|
|
12
|
+
Webhooks are verified with ``Webhook.construct_event``. Every error is a
|
|
13
|
+
``FlightByteError``.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from ._client import FlightByte
|
|
17
|
+
from ._countries import Countries
|
|
18
|
+
from ._errors import (
|
|
19
|
+
APIConnectionError,
|
|
20
|
+
APIError,
|
|
21
|
+
AuthenticationError,
|
|
22
|
+
BadRequestError,
|
|
23
|
+
FlightByteError,
|
|
24
|
+
NotFoundError,
|
|
25
|
+
PaymentRequiredError,
|
|
26
|
+
PermissionDeniedError,
|
|
27
|
+
RateLimitError,
|
|
28
|
+
ServerError,
|
|
29
|
+
UnexpectedStatusError,
|
|
30
|
+
UpstreamError,
|
|
31
|
+
WebhookSignatureError,
|
|
32
|
+
)
|
|
33
|
+
from ._flights import Flights
|
|
34
|
+
from ._models import (
|
|
35
|
+
DELIVERY_STATUSES,
|
|
36
|
+
FLIGHT_CHANGE_TYPES,
|
|
37
|
+
FLIGHT_PHASES,
|
|
38
|
+
SUBSCRIPTION_STATUSES,
|
|
39
|
+
WEBHOOK_EVENT_TYPES,
|
|
40
|
+
AirportRef,
|
|
41
|
+
AmbiguousLeg,
|
|
42
|
+
CarrierRef,
|
|
43
|
+
CodeshareRef,
|
|
44
|
+
Country,
|
|
45
|
+
CountryRef,
|
|
46
|
+
CreatedSubscription,
|
|
47
|
+
DeliveryRecord,
|
|
48
|
+
DeliveryStatus,
|
|
49
|
+
DurationInfo,
|
|
50
|
+
EndpointTimes,
|
|
51
|
+
EquipmentInfo,
|
|
52
|
+
FlightChange,
|
|
53
|
+
FlightChangeType,
|
|
54
|
+
FlightEndpoint,
|
|
55
|
+
FlightLegRef,
|
|
56
|
+
FlightPhase,
|
|
57
|
+
FlightSnapshot,
|
|
58
|
+
FlightStatusInfo,
|
|
59
|
+
FlightTime,
|
|
60
|
+
FlightTimeline,
|
|
61
|
+
Subscription,
|
|
62
|
+
SubscriptionPage,
|
|
63
|
+
SubscriptionStatus,
|
|
64
|
+
TimelineEntry,
|
|
65
|
+
WebhookEvent,
|
|
66
|
+
WebhookEventType,
|
|
67
|
+
best_gate_time,
|
|
68
|
+
is_terminal_phase,
|
|
69
|
+
)
|
|
70
|
+
from ._rate_limit import RateLimit, RateLimitWindow
|
|
71
|
+
from ._subscriptions import Subscriptions, mask_secret
|
|
72
|
+
from ._transport import NOT_GIVEN, NotGiven
|
|
73
|
+
from ._version import __version__
|
|
74
|
+
from ._webhook import Webhook, WebhookPayload
|
|
75
|
+
|
|
76
|
+
__all__ = [
|
|
77
|
+
"DELIVERY_STATUSES",
|
|
78
|
+
"FLIGHT_CHANGE_TYPES",
|
|
79
|
+
"FLIGHT_PHASES",
|
|
80
|
+
"NOT_GIVEN",
|
|
81
|
+
"SUBSCRIPTION_STATUSES",
|
|
82
|
+
"WEBHOOK_EVENT_TYPES",
|
|
83
|
+
"APIConnectionError",
|
|
84
|
+
"APIError",
|
|
85
|
+
"AirportRef",
|
|
86
|
+
"AmbiguousLeg",
|
|
87
|
+
"AuthenticationError",
|
|
88
|
+
"BadRequestError",
|
|
89
|
+
"CarrierRef",
|
|
90
|
+
"CodeshareRef",
|
|
91
|
+
"Countries",
|
|
92
|
+
"Country",
|
|
93
|
+
"CountryRef",
|
|
94
|
+
"CreatedSubscription",
|
|
95
|
+
"DeliveryRecord",
|
|
96
|
+
"DeliveryStatus",
|
|
97
|
+
"DurationInfo",
|
|
98
|
+
"EndpointTimes",
|
|
99
|
+
"EquipmentInfo",
|
|
100
|
+
"FlightByte",
|
|
101
|
+
"FlightByteError",
|
|
102
|
+
"FlightChange",
|
|
103
|
+
"FlightChangeType",
|
|
104
|
+
"FlightEndpoint",
|
|
105
|
+
"FlightLegRef",
|
|
106
|
+
"FlightPhase",
|
|
107
|
+
"FlightSnapshot",
|
|
108
|
+
"FlightStatusInfo",
|
|
109
|
+
"FlightTime",
|
|
110
|
+
"FlightTimeline",
|
|
111
|
+
"Flights",
|
|
112
|
+
"NotFoundError",
|
|
113
|
+
"NotGiven",
|
|
114
|
+
"PaymentRequiredError",
|
|
115
|
+
"PermissionDeniedError",
|
|
116
|
+
"RateLimit",
|
|
117
|
+
"RateLimitError",
|
|
118
|
+
"RateLimitWindow",
|
|
119
|
+
"ServerError",
|
|
120
|
+
"Subscription",
|
|
121
|
+
"SubscriptionPage",
|
|
122
|
+
"SubscriptionStatus",
|
|
123
|
+
"Subscriptions",
|
|
124
|
+
"TimelineEntry",
|
|
125
|
+
"UnexpectedStatusError",
|
|
126
|
+
"UpstreamError",
|
|
127
|
+
"Webhook",
|
|
128
|
+
"WebhookEvent",
|
|
129
|
+
"WebhookEventType",
|
|
130
|
+
"WebhookPayload",
|
|
131
|
+
"WebhookSignatureError",
|
|
132
|
+
"__version__",
|
|
133
|
+
"best_gate_time",
|
|
134
|
+
"is_terminal_phase",
|
|
135
|
+
"mask_secret",
|
|
136
|
+
]
|
flightbyte/_client.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""The FlightByte client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import urllib.parse
|
|
6
|
+
from datetime import date, datetime, tzinfo
|
|
7
|
+
from typing import Final
|
|
8
|
+
|
|
9
|
+
from ._countries import Countries
|
|
10
|
+
from ._flights import Flights
|
|
11
|
+
from ._rate_limit import RateLimit
|
|
12
|
+
from ._subscriptions import Subscriptions
|
|
13
|
+
from ._transport import Transport, check_header_value, check_timeout
|
|
14
|
+
from ._version import __version__
|
|
15
|
+
|
|
16
|
+
__all__ = ["FlightByte"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class FlightByte:
|
|
20
|
+
"""A client for the FlightByte API. Create one and share it.
|
|
21
|
+
|
|
22
|
+
::
|
|
23
|
+
|
|
24
|
+
import os
|
|
25
|
+
from flightbyte import FlightByte
|
|
26
|
+
|
|
27
|
+
client = FlightByte(api_key=os.environ["FLIGHTBYTE_API_KEY"])
|
|
28
|
+
flight = client.flights.get("TK", "1848", "2026-09-08")
|
|
29
|
+
print(flight["status"]["label"])
|
|
30
|
+
|
|
31
|
+
A client is safe to share across threads. It keeps no connection open, so
|
|
32
|
+
there is nothing to close.
|
|
33
|
+
|
|
34
|
+
Keep API keys on a server you control. A key shipped inside a browser or
|
|
35
|
+
mobile app can be extracted by anyone who has the app.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
api_key: Your API key, sent as ``x-api-key``.
|
|
39
|
+
base_url: The API root. Defaults to ``FlightByte.DEFAULT_BASE_URL``.
|
|
40
|
+
timeout: Bounds each request, response body included, in seconds.
|
|
41
|
+
``None`` or ``0`` turns it off. Defaults to 30. Every method takes
|
|
42
|
+
a ``timeout`` of its own too.
|
|
43
|
+
user_agent: Put in front of this package's own user agent, for example
|
|
44
|
+
``my-app/1.2``.
|
|
45
|
+
|
|
46
|
+
Raises:
|
|
47
|
+
TypeError: ``api_key`` is not a string.
|
|
48
|
+
ValueError: ``api_key`` is empty, or another argument is invalid.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
DEFAULT_BASE_URL: Final = "https://api.flightbyte.dev/v1"
|
|
52
|
+
"""The production API root."""
|
|
53
|
+
|
|
54
|
+
flights: Flights
|
|
55
|
+
"""Flight lookups."""
|
|
56
|
+
subscriptions: Subscriptions
|
|
57
|
+
"""Watched flights and their webhooks."""
|
|
58
|
+
countries: Countries
|
|
59
|
+
"""Countries and flags."""
|
|
60
|
+
|
|
61
|
+
def __init__(
|
|
62
|
+
self,
|
|
63
|
+
api_key: str,
|
|
64
|
+
*,
|
|
65
|
+
base_url: str | None = None,
|
|
66
|
+
timeout: float | None = 30.0,
|
|
67
|
+
user_agent: str | None = None,
|
|
68
|
+
) -> None:
|
|
69
|
+
if not isinstance(api_key, str):
|
|
70
|
+
raise TypeError("flightbyte: api_key is required and must be a string.")
|
|
71
|
+
key = check_header_value("api_key", api_key.strip())
|
|
72
|
+
if not key:
|
|
73
|
+
raise ValueError("flightbyte: api_key is required.")
|
|
74
|
+
|
|
75
|
+
own_agent = f"flightbyte-python/{__version__}"
|
|
76
|
+
prefix = (user_agent or "").strip()
|
|
77
|
+
agent = check_header_value("user_agent", f"{prefix} {own_agent}" if prefix else own_agent)
|
|
78
|
+
|
|
79
|
+
self._transport = Transport(
|
|
80
|
+
api_key=key,
|
|
81
|
+
base_url=_check_base_url(self.DEFAULT_BASE_URL if base_url is None else base_url),
|
|
82
|
+
timeout=check_timeout("timeout", timeout),
|
|
83
|
+
user_agent=agent,
|
|
84
|
+
)
|
|
85
|
+
self.flights = Flights(self._transport)
|
|
86
|
+
self.subscriptions = Subscriptions(self._transport)
|
|
87
|
+
self.countries = Countries(self._transport)
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def base_url(self) -> str:
|
|
91
|
+
"""The API root this client talks to."""
|
|
92
|
+
return self._transport.base_url
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def timeout(self) -> float | None:
|
|
96
|
+
"""The timeout of each request, in seconds, or ``None`` when there is none."""
|
|
97
|
+
return self._transport.timeout
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def last_rate_limit(self) -> RateLimit | None:
|
|
101
|
+
"""The quota state from the latest response that reported one.
|
|
102
|
+
|
|
103
|
+
``None`` before any did. It is immutable, so reading it never changes
|
|
104
|
+
what the client or another thread sees.
|
|
105
|
+
"""
|
|
106
|
+
return self._transport.last_rate_limit
|
|
107
|
+
|
|
108
|
+
def __repr__(self) -> str:
|
|
109
|
+
return f"FlightByte(base_url={self.base_url!r})"
|
|
110
|
+
|
|
111
|
+
@staticmethod
|
|
112
|
+
def format_date(value: date | datetime, tz: str | tzinfo | None = None) -> str:
|
|
113
|
+
"""Formats ``value`` as the ``YYYY-MM-DD`` the API expects for flight dates.
|
|
114
|
+
|
|
115
|
+
Pass the origin airport's time zone, an IANA name such as
|
|
116
|
+
``Europe/Istanbul`` or a ``tzinfo``, to get the date there. Without
|
|
117
|
+
one, a ``datetime`` keeps its own date; a naive one is read as local
|
|
118
|
+
time when converted. A ``date`` is formatted as it is.
|
|
119
|
+
|
|
120
|
+
On Windows, IANA names need the ``tzdata`` package, or pass a ``tzinfo``.
|
|
121
|
+
"""
|
|
122
|
+
if isinstance(value, datetime):
|
|
123
|
+
if tz is not None:
|
|
124
|
+
value = value.astimezone(_zone(tz))
|
|
125
|
+
return value.date().isoformat()
|
|
126
|
+
if isinstance(value, date):
|
|
127
|
+
return value.isoformat()
|
|
128
|
+
raise TypeError("flightbyte: format_date takes a date or a datetime.")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _zone(tz: str | tzinfo) -> tzinfo:
|
|
132
|
+
if isinstance(tz, tzinfo):
|
|
133
|
+
return tz
|
|
134
|
+
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
135
|
+
|
|
136
|
+
try:
|
|
137
|
+
return ZoneInfo(tz)
|
|
138
|
+
except (ZoneInfoNotFoundError, ValueError) as error:
|
|
139
|
+
raise ValueError(
|
|
140
|
+
f"flightbyte: unknown time zone {tz!r}. On Windows, install the tzdata package."
|
|
141
|
+
) from error
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _check_base_url(value: str) -> str:
|
|
145
|
+
if not isinstance(value, str):
|
|
146
|
+
raise TypeError("flightbyte: base_url must be a string.")
|
|
147
|
+
text = value.strip()
|
|
148
|
+
try:
|
|
149
|
+
parts = urllib.parse.urlsplit(text)
|
|
150
|
+
parts.port # noqa: B018 - raises ValueError for a port that is not a number
|
|
151
|
+
except ValueError as error:
|
|
152
|
+
raise ValueError(f"flightbyte: base_url {value!r} is not a URL.") from error
|
|
153
|
+
if parts.scheme.lower() not in ("http", "https") or not parts.hostname:
|
|
154
|
+
raise ValueError(f"flightbyte: base_url {value!r} must be an http or https URL.")
|
|
155
|
+
if parts.query or parts.fragment or "?" in text or "#" in text:
|
|
156
|
+
raise ValueError(f"flightbyte: base_url {value!r} must not have a query or fragment.")
|
|
157
|
+
return text.rstrip("/")
|
flightbyte/_countries.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Country names, emoji and flags."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import builtins
|
|
6
|
+
from typing import cast
|
|
7
|
+
|
|
8
|
+
from ._models import Country
|
|
9
|
+
from ._transport import NOT_GIVEN, NotGiven, Transport, required
|
|
10
|
+
from ._util import data_of
|
|
11
|
+
|
|
12
|
+
__all__ = ["Countries"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Countries:
|
|
16
|
+
"""Country names, emoji and flags. Reach it as ``client.countries``."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, transport: Transport) -> None:
|
|
19
|
+
self._transport = transport
|
|
20
|
+
|
|
21
|
+
def list(
|
|
22
|
+
self,
|
|
23
|
+
*,
|
|
24
|
+
lang: str | None = None,
|
|
25
|
+
timeout: float | NotGiven | None = NOT_GIVEN,
|
|
26
|
+
) -> builtins.list[Country]:
|
|
27
|
+
"""Every country, sorted by name.
|
|
28
|
+
|
|
29
|
+
``lang`` is the language of the names, for example ``tr``; the API's
|
|
30
|
+
default is ``en``.
|
|
31
|
+
"""
|
|
32
|
+
result = self._transport.send("GET", ["countries"], query={"lang": lang}, timeout=timeout)
|
|
33
|
+
return cast("builtins.list[Country]", data_of(result, "GET /countries"))
|
|
34
|
+
|
|
35
|
+
def get(
|
|
36
|
+
self,
|
|
37
|
+
code: str,
|
|
38
|
+
*,
|
|
39
|
+
lang: str | None = None,
|
|
40
|
+
timeout: float | NotGiven | None = NOT_GIVEN,
|
|
41
|
+
) -> Country:
|
|
42
|
+
"""One country by its ISO 3166-1 alpha-2 code."""
|
|
43
|
+
result = self._transport.send(
|
|
44
|
+
"GET", ["countries", required("code", code)], query={"lang": lang}, timeout=timeout
|
|
45
|
+
)
|
|
46
|
+
return cast(Country, result)
|
|
47
|
+
|
|
48
|
+
def flag_url(self, code: str, *, square: bool = False) -> str:
|
|
49
|
+
"""The URL of a country's SVG flag: 4:3, or 1:1 with ``square``.
|
|
50
|
+
|
|
51
|
+
Flags need no API key, so the URL can go straight into an ``<img>``.
|
|
52
|
+
Nothing is sent.
|
|
53
|
+
"""
|
|
54
|
+
return self._transport.url(
|
|
55
|
+
["flags", f"{required('code', code).lower()}.svg"],
|
|
56
|
+
{"shape": "1x1" if square else None},
|
|
57
|
+
)
|
flightbyte/_errors.py
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"""The errors this package raises."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
import re
|
|
8
|
+
from collections.abc import Mapping
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from ._models import AmbiguousLeg
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"APIConnectionError",
|
|
15
|
+
"APIError",
|
|
16
|
+
"AuthenticationError",
|
|
17
|
+
"BadRequestError",
|
|
18
|
+
"FlightByteError",
|
|
19
|
+
"NotFoundError",
|
|
20
|
+
"PaymentRequiredError",
|
|
21
|
+
"PermissionDeniedError",
|
|
22
|
+
"RateLimitError",
|
|
23
|
+
"ServerError",
|
|
24
|
+
"UnexpectedStatusError",
|
|
25
|
+
"UpstreamError",
|
|
26
|
+
"WebhookSignatureError",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class FlightByteError(Exception):
|
|
31
|
+
"""Base class of every error this package raises."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class APIConnectionError(FlightByteError):
|
|
35
|
+
"""A request got no HTTP response.
|
|
36
|
+
|
|
37
|
+
No network, a DNS failure, a refused connection, a TLS failure, or the
|
|
38
|
+
timeout ran out. The underlying error is in ``__cause__``.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
timed_out: bool
|
|
42
|
+
"""Whether the client's own timeout ran out."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, message: str, *, timed_out: bool = False) -> None:
|
|
45
|
+
super().__init__(message)
|
|
46
|
+
self.timed_out = timed_out
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class APIError(FlightByteError):
|
|
50
|
+
"""The API answered with a non-2xx status.
|
|
51
|
+
|
|
52
|
+
The attributes mirror the API's error body. Catch the subclass for the
|
|
53
|
+
status you want to handle::
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
client.flights.get("TK", "1", "2026-09-08")
|
|
57
|
+
except flightbyte.NotFoundError:
|
|
58
|
+
print("No such flight that day")
|
|
59
|
+
except flightbyte.RateLimitError as err:
|
|
60
|
+
time.sleep(err.retry_after or 5)
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
status: int
|
|
64
|
+
"""The HTTP status."""
|
|
65
|
+
messages: list[str]
|
|
66
|
+
"""Every message the API sent. Validation errors send one per field."""
|
|
67
|
+
body: dict[str, Any]
|
|
68
|
+
"""The decoded error body; empty when it was not a JSON object."""
|
|
69
|
+
raw_body: str
|
|
70
|
+
"""The body exactly as received."""
|
|
71
|
+
headers: dict[str, str]
|
|
72
|
+
"""The response headers, with lower-case names."""
|
|
73
|
+
|
|
74
|
+
def __init__(
|
|
75
|
+
self,
|
|
76
|
+
status: int,
|
|
77
|
+
reason: str,
|
|
78
|
+
raw_body: str,
|
|
79
|
+
headers: Mapping[str, str],
|
|
80
|
+
) -> None:
|
|
81
|
+
body = _decode_body(raw_body)
|
|
82
|
+
messages = _read_messages(body)
|
|
83
|
+
lower_headers = {name.lower(): value for name, value in headers.items()}
|
|
84
|
+
request_id = _string_field(body, "requestId") or lower_headers.get("x-request-id")
|
|
85
|
+
# HTTP/2 and some servers leave the reason empty; the API's own phrase fills in.
|
|
86
|
+
phrase = reason or _string_field(body, "error") or ""
|
|
87
|
+
label = f"{status} {phrase}".strip()
|
|
88
|
+
message = f"{label}: {'; '.join(messages)}" if messages else label
|
|
89
|
+
if request_id:
|
|
90
|
+
message += f" (request {request_id})"
|
|
91
|
+
super().__init__(message)
|
|
92
|
+
self.status = status
|
|
93
|
+
self.messages = messages
|
|
94
|
+
self.body = body
|
|
95
|
+
self.raw_body = raw_body
|
|
96
|
+
self.headers = lower_headers
|
|
97
|
+
self._reason = reason
|
|
98
|
+
|
|
99
|
+
def __reduce__(self) -> tuple[Any, ...]:
|
|
100
|
+
# The constructor takes the response, not the message, so pickling
|
|
101
|
+
# (multiprocessing, task queues) needs to be told how to rebuild it.
|
|
102
|
+
return (type(self), (self.status, self._reason, self.raw_body, self.headers))
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def code(self) -> str | None:
|
|
106
|
+
"""A machine-readable reason, when the API sends one.
|
|
107
|
+
|
|
108
|
+
For example ``watched_flight_limit`` or ``account_suspended``.
|
|
109
|
+
"""
|
|
110
|
+
return _string_field(self.body, "code")
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def request_id(self) -> str | None:
|
|
114
|
+
"""Identifies the request; quote it when contacting support."""
|
|
115
|
+
return _string_field(self.body, "requestId") or self.headers.get("x-request-id")
|
|
116
|
+
|
|
117
|
+
@staticmethod
|
|
118
|
+
def from_response(
|
|
119
|
+
status: int,
|
|
120
|
+
reason: str,
|
|
121
|
+
raw_body: str,
|
|
122
|
+
headers: Mapping[str, str],
|
|
123
|
+
) -> APIError:
|
|
124
|
+
"""Builds the subclass for ``status``."""
|
|
125
|
+
return _error_class_for(status)(status, reason, raw_body, headers)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class BadRequestError(APIError):
|
|
129
|
+
"""400: the request was invalid.
|
|
130
|
+
|
|
131
|
+
When a flight number flies several legs that day and no ``origin`` chose
|
|
132
|
+
one, ``legs`` lists them.
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def legs(self) -> list[AmbiguousLeg]:
|
|
137
|
+
"""The legs to choose from; pass one's ``origin`` or ``flightId`` and try again."""
|
|
138
|
+
legs = self.body.get("legs")
|
|
139
|
+
if not isinstance(legs, list):
|
|
140
|
+
return []
|
|
141
|
+
result: list[AmbiguousLeg] = []
|
|
142
|
+
for item in legs:
|
|
143
|
+
if not isinstance(item, dict):
|
|
144
|
+
continue
|
|
145
|
+
leg: AmbiguousLeg = {
|
|
146
|
+
"origin": _string_field(item, "origin") or "",
|
|
147
|
+
"destination": _string_field(item, "destination") or "",
|
|
148
|
+
}
|
|
149
|
+
carrier = _string_field(item, "carrier")
|
|
150
|
+
departure = _string_field(item, "scheduledDepartureLocal")
|
|
151
|
+
flight_id = _string_field(item, "flightId")
|
|
152
|
+
if carrier is not None:
|
|
153
|
+
leg["carrier"] = carrier
|
|
154
|
+
if departure is not None:
|
|
155
|
+
leg["scheduledDepartureLocal"] = departure
|
|
156
|
+
if flight_id is not None:
|
|
157
|
+
leg["flightId"] = flight_id
|
|
158
|
+
result.append(leg)
|
|
159
|
+
return result
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class AuthenticationError(APIError):
|
|
163
|
+
"""401: the API key is missing, invalid, revoked or expired."""
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class PaymentRequiredError(APIError):
|
|
167
|
+
"""402: no watched flights are left.
|
|
168
|
+
|
|
169
|
+
``code`` says why: ``watched_flight_limit`` (the plan's cap),
|
|
170
|
+
``overage_cap_reached`` (the account's overage cap) or
|
|
171
|
+
``credits_exhausted`` (prepaid credits used up).
|
|
172
|
+
"""
|
|
173
|
+
|
|
174
|
+
@property
|
|
175
|
+
def limit(self) -> int | float | None:
|
|
176
|
+
"""Watched flights the usage period allows. Not with ``credits_exhausted``."""
|
|
177
|
+
return _number_field(self.body, "limit")
|
|
178
|
+
|
|
179
|
+
@property
|
|
180
|
+
def used(self) -> int | float | None:
|
|
181
|
+
"""Watched flights created this usage period. Not with ``credits_exhausted``."""
|
|
182
|
+
return _number_field(self.body, "used")
|
|
183
|
+
|
|
184
|
+
@property
|
|
185
|
+
def resets_at(self) -> str | None:
|
|
186
|
+
"""When the usage period, and so the count, resets, ISO 8601."""
|
|
187
|
+
return _string_field(self.body, "resetsAt")
|
|
188
|
+
|
|
189
|
+
@property
|
|
190
|
+
def cap_cents(self) -> int | float | None:
|
|
191
|
+
"""The overage cap in US cents; only with ``overage_cap_reached``."""
|
|
192
|
+
return _number_field(self.body, "capCents")
|
|
193
|
+
|
|
194
|
+
@property
|
|
195
|
+
def balance(self) -> int | float | None:
|
|
196
|
+
"""Prepaid credits left; only with ``credits_exhausted``."""
|
|
197
|
+
return _number_field(self.body, "balance")
|
|
198
|
+
|
|
199
|
+
@property
|
|
200
|
+
def expires_at(self) -> str | None:
|
|
201
|
+
"""When the credits expire or expired, ISO 8601; only with ``credits_exhausted``."""
|
|
202
|
+
return _string_field(self.body, "expiresAt")
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class PermissionDeniedError(APIError):
|
|
206
|
+
"""403: the account is suspended or a token lacks the scope.
|
|
207
|
+
|
|
208
|
+
``code`` is ``account_suspended`` or ``insufficient_scope``.
|
|
209
|
+
"""
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class NotFoundError(APIError):
|
|
213
|
+
"""404: the flight did not operate that day, or no such subscription."""
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class RateLimitError(APIError):
|
|
217
|
+
"""429: a minute, day or month quota is used up."""
|
|
218
|
+
|
|
219
|
+
@property
|
|
220
|
+
def window(self) -> str | None:
|
|
221
|
+
"""The window that ran out: ``minute``, ``day`` or ``month``."""
|
|
222
|
+
return _string_field(self.body, "window")
|
|
223
|
+
|
|
224
|
+
@property
|
|
225
|
+
def retry_after(self) -> int | float | None:
|
|
226
|
+
"""How many seconds to wait before retrying.
|
|
227
|
+
|
|
228
|
+
From the body's ``retryAfterSeconds``, else the ``Retry-After`` header.
|
|
229
|
+
"""
|
|
230
|
+
from_body = _number_field(self.body, "retryAfterSeconds")
|
|
231
|
+
if from_body is not None:
|
|
232
|
+
return from_body
|
|
233
|
+
match = _LEADING_INTEGER.match(self.headers.get("retry-after", ""))
|
|
234
|
+
return int(match.group(1)) if match else None
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
class ServerError(APIError):
|
|
238
|
+
"""5xx: the API failed. ``request_id`` is what support asks for."""
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
class UpstreamError(ServerError):
|
|
242
|
+
"""502: the upstream flight data provider failed.
|
|
243
|
+
|
|
244
|
+
Safe to retry after a short wait. It is also a ``ServerError``.
|
|
245
|
+
"""
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
class UnexpectedStatusError(APIError):
|
|
249
|
+
"""Any other non-2xx status, including a redirect this client does not follow.
|
|
250
|
+
|
|
251
|
+
A redirect is not followed when it points to another origin, or when the
|
|
252
|
+
request was not a GET or HEAD. Its target is in ``location``.
|
|
253
|
+
"""
|
|
254
|
+
|
|
255
|
+
@property
|
|
256
|
+
def location(self) -> str | None:
|
|
257
|
+
"""Where a 3xx pointed, when it did."""
|
|
258
|
+
return self.headers.get("location")
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
class WebhookSignatureError(FlightByteError):
|
|
262
|
+
"""The webhook signature did not verify."""
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
_STATUS_CLASSES: dict[int, type[APIError]] = {
|
|
266
|
+
400: BadRequestError,
|
|
267
|
+
401: AuthenticationError,
|
|
268
|
+
402: PaymentRequiredError,
|
|
269
|
+
403: PermissionDeniedError,
|
|
270
|
+
404: NotFoundError,
|
|
271
|
+
429: RateLimitError,
|
|
272
|
+
502: UpstreamError,
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
_LEADING_INTEGER = re.compile(r"\s*([+-]?[0-9]{1,15})")
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _error_class_for(status: int) -> type[APIError]:
|
|
279
|
+
error_class = _STATUS_CLASSES.get(status)
|
|
280
|
+
if error_class is not None:
|
|
281
|
+
return error_class
|
|
282
|
+
return ServerError if 500 <= status <= 599 else UnexpectedStatusError
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _decode_body(raw_body: str) -> dict[str, Any]:
|
|
286
|
+
try:
|
|
287
|
+
parsed = json.loads(raw_body)
|
|
288
|
+
except ValueError:
|
|
289
|
+
return {}
|
|
290
|
+
return parsed if isinstance(parsed, dict) else {}
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _read_messages(body: Mapping[str, Any]) -> list[str]:
|
|
294
|
+
message = body.get("message")
|
|
295
|
+
if isinstance(message, str):
|
|
296
|
+
return [message]
|
|
297
|
+
if isinstance(message, list):
|
|
298
|
+
return [item if isinstance(item, str) else json.dumps(item) for item in message]
|
|
299
|
+
return []
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _string_field(mapping: Mapping[str, Any], key: str) -> str | None:
|
|
303
|
+
value = mapping.get(key)
|
|
304
|
+
return value if isinstance(value, str) else None
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _number_field(mapping: Mapping[str, Any], key: str) -> int | float | None:
|
|
308
|
+
value = mapping.get(key)
|
|
309
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
310
|
+
return None
|
|
311
|
+
return value if math.isfinite(value) else None
|