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 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("/")
@@ -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