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
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""The quota state the API sends in ``X-RateLimit-*`` response headers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
|
|
10
|
+
__all__ = ["RateLimit", "RateLimitWindow"]
|
|
11
|
+
|
|
12
|
+
_INTEGER = re.compile(r"[0-9]{1,18}")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class RateLimitWindow:
|
|
17
|
+
"""One quota window, from the ``X-RateLimit-*`` response headers."""
|
|
18
|
+
|
|
19
|
+
limit: int
|
|
20
|
+
"""Requests the window allows."""
|
|
21
|
+
remaining: int
|
|
22
|
+
"""Requests left in the window."""
|
|
23
|
+
reset_at: datetime | None
|
|
24
|
+
"""When the window resets, in UTC, or ``None`` when not reported."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class RateLimit:
|
|
29
|
+
"""The quota state sent with a response.
|
|
30
|
+
|
|
31
|
+
A window the key has no limit on is ``None``; keys without limits get no
|
|
32
|
+
headers at all. It is immutable, so the client can hand the same object to
|
|
33
|
+
every caller and none of them can change what the others see.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
minute: RateLimitWindow | None
|
|
37
|
+
"""The per-minute window."""
|
|
38
|
+
day: RateLimitWindow | None
|
|
39
|
+
"""The per-day window."""
|
|
40
|
+
month: RateLimitWindow | None
|
|
41
|
+
"""The per-month window."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def parse_rate_limit(headers: Mapping[str, str]) -> RateLimit | None:
|
|
45
|
+
"""Reads the quota headers, with lower-case names, or returns ``None`` when there are none."""
|
|
46
|
+
minute = _parse_window(headers, "minute")
|
|
47
|
+
day = _parse_window(headers, "day")
|
|
48
|
+
month = _parse_window(headers, "month")
|
|
49
|
+
if minute is None and day is None and month is None:
|
|
50
|
+
return None
|
|
51
|
+
return RateLimit(minute=minute, day=day, month=month)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _parse_window(headers: Mapping[str, str], name: str) -> RateLimitWindow | None:
|
|
55
|
+
limit = _parse_integer(headers.get(f"x-ratelimit-limit-{name}"))
|
|
56
|
+
remaining = _parse_integer(headers.get(f"x-ratelimit-remaining-{name}"))
|
|
57
|
+
if limit is None or remaining is None:
|
|
58
|
+
return None
|
|
59
|
+
reset = _parse_integer(headers.get(f"x-ratelimit-reset-{name}"))
|
|
60
|
+
reset_at = None
|
|
61
|
+
if reset is not None:
|
|
62
|
+
try:
|
|
63
|
+
reset_at = datetime.fromtimestamp(reset, tz=timezone.utc)
|
|
64
|
+
except (OverflowError, OSError, ValueError):
|
|
65
|
+
reset_at = None
|
|
66
|
+
return RateLimitWindow(limit=limit, remaining=remaining, reset_at=reset_at)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _parse_integer(value: str | None) -> int | None:
|
|
70
|
+
if value is None:
|
|
71
|
+
return None
|
|
72
|
+
value = value.strip()
|
|
73
|
+
# [0-9] and not \d, which also matches digits from other scripts; at most
|
|
74
|
+
# 18 of them, so int() stays cheap and the value fits a 64-bit integer.
|
|
75
|
+
return int(value) if _INTEGER.fullmatch(value) else None
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
"""Watched flights and their webhooks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import builtins
|
|
6
|
+
from collections.abc import Iterable, Iterator, Mapping
|
|
7
|
+
from typing import Any, cast
|
|
8
|
+
|
|
9
|
+
from ._models import (
|
|
10
|
+
CreatedSubscription,
|
|
11
|
+
DeliveryRecord,
|
|
12
|
+
FlightChangeType,
|
|
13
|
+
Subscription,
|
|
14
|
+
SubscriptionPage,
|
|
15
|
+
SubscriptionStatus,
|
|
16
|
+
WebhookEvent,
|
|
17
|
+
)
|
|
18
|
+
from ._transport import NOT_GIVEN, NotGiven, Transport, check_header_value, required
|
|
19
|
+
from ._util import data_of, string_of
|
|
20
|
+
|
|
21
|
+
__all__ = ["Subscriptions", "mask_secret"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Subscriptions:
|
|
25
|
+
"""Watched flights and their webhooks. Reach it as ``client.subscriptions``.
|
|
26
|
+
|
|
27
|
+
Every subscription is a billable watched flight.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, transport: Transport) -> None:
|
|
31
|
+
self._transport = transport
|
|
32
|
+
|
|
33
|
+
def create(
|
|
34
|
+
self,
|
|
35
|
+
carrier: str,
|
|
36
|
+
flight_number: str,
|
|
37
|
+
date: str,
|
|
38
|
+
webhook_url: str,
|
|
39
|
+
*,
|
|
40
|
+
origin: str | None = None,
|
|
41
|
+
destination: str | None = None,
|
|
42
|
+
flight_id: str | None = None,
|
|
43
|
+
secret: str | None = None,
|
|
44
|
+
events: Iterable[FlightChangeType] | None = None,
|
|
45
|
+
delay_threshold_minutes: int | None = None,
|
|
46
|
+
headers: Mapping[str, str] | None = None,
|
|
47
|
+
metadata: Mapping[str, str] | None = None,
|
|
48
|
+
idempotency_key: str | None = None,
|
|
49
|
+
timeout: float | NotGiven | None = NOT_GIVEN,
|
|
50
|
+
) -> CreatedSubscription:
|
|
51
|
+
"""Starts watching a flight.
|
|
52
|
+
|
|
53
|
+
The API posts a signed webhook to ``webhook_url``, a public HTTPS URL,
|
|
54
|
+
each time something about the flight changes. The response carries
|
|
55
|
+
the signing secret in full, this once.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
carrier: Carrier code, for example ``TK``.
|
|
59
|
+
flight_number: Flight number, for example ``1848``.
|
|
60
|
+
date: Local departure date at the origin, ``YYYY-MM-DD``.
|
|
61
|
+
webhook_url: Where webhooks are posted.
|
|
62
|
+
origin: Selects a leg, when the flight number flies several that day.
|
|
63
|
+
destination: Narrows the leg further.
|
|
64
|
+
flight_id: Selects a leg by its upstream id.
|
|
65
|
+
secret: The webhook signing secret. By default the API generates
|
|
66
|
+
one. Setting the same secret on every subscription lets one
|
|
67
|
+
receiver verify them all.
|
|
68
|
+
events: Change types that trigger a webhook. By default every type does.
|
|
69
|
+
delay_threshold_minutes: Ignore delay swings smaller than this, 1 to 240.
|
|
70
|
+
headers: Extra headers sent with every delivery.
|
|
71
|
+
metadata: Your own data, echoed back in every webhook.
|
|
72
|
+
idempotency_key: Makes retries safe: repeating the request with the
|
|
73
|
+
same key within 24 hours returns the original subscription with
|
|
74
|
+
``reused: True``, instead of creating and billing a second one.
|
|
75
|
+
timeout: Overrides the client's timeout for this request.
|
|
76
|
+
|
|
77
|
+
Raises:
|
|
78
|
+
PaymentRequiredError: No watched flights are left.
|
|
79
|
+
BadRequestError: With ``legs`` set, when the flight number flies
|
|
80
|
+
several legs that day and neither ``origin`` nor ``flight_id``
|
|
81
|
+
chose one.
|
|
82
|
+
"""
|
|
83
|
+
body = _without_none(
|
|
84
|
+
{
|
|
85
|
+
"carrier": required("carrier", carrier),
|
|
86
|
+
"flightNumber": required("flight_number", flight_number),
|
|
87
|
+
"date": required("date", date),
|
|
88
|
+
"webhookUrl": required("webhook_url", webhook_url),
|
|
89
|
+
"origin": origin,
|
|
90
|
+
"destination": destination,
|
|
91
|
+
"flightId": flight_id,
|
|
92
|
+
"secret": secret,
|
|
93
|
+
"events": None if events is None else builtins.list(events),
|
|
94
|
+
"delayThresholdMinutes": delay_threshold_minutes,
|
|
95
|
+
"headers": None if headers is None else dict(headers),
|
|
96
|
+
"metadata": None if metadata is None else dict(metadata),
|
|
97
|
+
}
|
|
98
|
+
)
|
|
99
|
+
request_headers = None
|
|
100
|
+
if idempotency_key:
|
|
101
|
+
request_headers = {
|
|
102
|
+
"idempotency-key": check_header_value("idempotency_key", idempotency_key)
|
|
103
|
+
}
|
|
104
|
+
result = self._transport.send(
|
|
105
|
+
"POST", ["subscriptions"], body=body, headers=request_headers, timeout=timeout
|
|
106
|
+
)
|
|
107
|
+
if isinstance(result, dict) and not result.get("secretHint"):
|
|
108
|
+
secret_value = result.get("secret")
|
|
109
|
+
if isinstance(secret_value, str) and secret_value:
|
|
110
|
+
result["secretHint"] = mask_secret(secret_value)
|
|
111
|
+
return cast(CreatedSubscription, result)
|
|
112
|
+
|
|
113
|
+
def list(
|
|
114
|
+
self,
|
|
115
|
+
*,
|
|
116
|
+
status: SubscriptionStatus | None = None,
|
|
117
|
+
carrier: str | None = None,
|
|
118
|
+
flight_number: str | None = None,
|
|
119
|
+
date: str | None = None,
|
|
120
|
+
limit: int | None = None,
|
|
121
|
+
offset: int | None = None,
|
|
122
|
+
timeout: float | NotGiven | None = NOT_GIVEN,
|
|
123
|
+
) -> SubscriptionPage:
|
|
124
|
+
"""One page of your subscriptions, newest first.
|
|
125
|
+
|
|
126
|
+
The filters keep subscriptions in that ``status``, for that
|
|
127
|
+
``carrier``, ``flight_number`` or ``date``. ``limit`` is the page size;
|
|
128
|
+
the API clamps it to 1 to 200 and defaults to 25. ``offset`` is the
|
|
129
|
+
number of subscriptions to skip.
|
|
130
|
+
"""
|
|
131
|
+
result = self._transport.send(
|
|
132
|
+
"GET",
|
|
133
|
+
["subscriptions"],
|
|
134
|
+
query={
|
|
135
|
+
"status": status,
|
|
136
|
+
"carrier": carrier,
|
|
137
|
+
"flightNumber": flight_number,
|
|
138
|
+
"date": date,
|
|
139
|
+
"limit": limit,
|
|
140
|
+
"offset": offset,
|
|
141
|
+
},
|
|
142
|
+
timeout=timeout,
|
|
143
|
+
)
|
|
144
|
+
return cast(SubscriptionPage, result)
|
|
145
|
+
|
|
146
|
+
def list_all(
|
|
147
|
+
self,
|
|
148
|
+
*,
|
|
149
|
+
status: SubscriptionStatus | None = None,
|
|
150
|
+
carrier: str | None = None,
|
|
151
|
+
flight_number: str | None = None,
|
|
152
|
+
date: str | None = None,
|
|
153
|
+
offset: int | None = None,
|
|
154
|
+
page_size: int = 200,
|
|
155
|
+
timeout: float | NotGiven | None = NOT_GIVEN,
|
|
156
|
+
) -> Iterator[Subscription]:
|
|
157
|
+
"""Every subscription matching the filters, fetched page by page as you iterate.
|
|
158
|
+
|
|
159
|
+
Nothing is sent until the first item is asked for, and nothing more
|
|
160
|
+
once you stop: ``break`` out of the loop and no further page is
|
|
161
|
+
fetched. ``page_size`` is 1 to 200; the default is 200. ``timeout``
|
|
162
|
+
applies to each page::
|
|
163
|
+
|
|
164
|
+
for sub in client.subscriptions.list_all(status="active"):
|
|
165
|
+
print(sub["flightKey"])
|
|
166
|
+
"""
|
|
167
|
+
next_offset = offset or 0
|
|
168
|
+
while True:
|
|
169
|
+
page = self.list(
|
|
170
|
+
status=status,
|
|
171
|
+
carrier=carrier,
|
|
172
|
+
flight_number=flight_number,
|
|
173
|
+
date=date,
|
|
174
|
+
limit=page_size,
|
|
175
|
+
offset=next_offset,
|
|
176
|
+
timeout=timeout,
|
|
177
|
+
)
|
|
178
|
+
data = page["data"]
|
|
179
|
+
yield from data
|
|
180
|
+
following = page["offset"] + len(data)
|
|
181
|
+
if not data or following >= page["total"]:
|
|
182
|
+
return
|
|
183
|
+
next_offset = following
|
|
184
|
+
|
|
185
|
+
def get(self, id: str, *, timeout: float | NotGiven | None = NOT_GIVEN) -> Subscription:
|
|
186
|
+
"""One subscription.
|
|
187
|
+
|
|
188
|
+
Raises:
|
|
189
|
+
NotFoundError: It does not exist or belongs to another key.
|
|
190
|
+
"""
|
|
191
|
+
return cast(Subscription, self._send("GET", id, timeout=timeout))
|
|
192
|
+
|
|
193
|
+
def update(
|
|
194
|
+
self,
|
|
195
|
+
id: str,
|
|
196
|
+
*,
|
|
197
|
+
webhook_url: str | None = None,
|
|
198
|
+
events: Iterable[FlightChangeType] | None = None,
|
|
199
|
+
delay_threshold_minutes: int | None = None,
|
|
200
|
+
headers: Mapping[str, str] | None = None,
|
|
201
|
+
metadata: Mapping[str, str] | None = None,
|
|
202
|
+
timeout: float | NotGiven | None = NOT_GIVEN,
|
|
203
|
+
) -> Subscription:
|
|
204
|
+
"""Changes a subscription. Only the arguments you pass change.
|
|
205
|
+
|
|
206
|
+
``webhook_url`` moves deliveries to another URL; ``events`` replaces
|
|
207
|
+
the change type filter; ``delay_threshold_minutes`` replaces the delay
|
|
208
|
+
threshold. ``headers`` and ``metadata`` replace the existing maps; the
|
|
209
|
+
API cannot clear them, so an empty map leaves them as they are.
|
|
210
|
+
"""
|
|
211
|
+
body = _without_none(
|
|
212
|
+
{
|
|
213
|
+
"webhookUrl": webhook_url,
|
|
214
|
+
"events": None if events is None else builtins.list(events),
|
|
215
|
+
"delayThresholdMinutes": delay_threshold_minutes,
|
|
216
|
+
"headers": None if headers is None else dict(headers),
|
|
217
|
+
"metadata": None if metadata is None else dict(metadata),
|
|
218
|
+
}
|
|
219
|
+
)
|
|
220
|
+
return cast(Subscription, self._send("PATCH", id, body=body, timeout=timeout))
|
|
221
|
+
|
|
222
|
+
def delete(self, id: str, *, timeout: float | NotGiven | None = NOT_GIVEN) -> None:
|
|
223
|
+
"""Stops watching the flight and deletes the subscription for good."""
|
|
224
|
+
self._send("DELETE", id, timeout=timeout)
|
|
225
|
+
|
|
226
|
+
def pause(self, id: str, *, timeout: float | NotGiven | None = NOT_GIVEN) -> Subscription:
|
|
227
|
+
"""Pauses polling and deliveries; ``resume`` picks up where it left off."""
|
|
228
|
+
return cast(Subscription, self._send("POST", id, "pause", timeout=timeout))
|
|
229
|
+
|
|
230
|
+
def resume(self, id: str, *, timeout: float | NotGiven | None = NOT_GIVEN) -> Subscription:
|
|
231
|
+
"""Resumes a paused or suspended subscription.
|
|
232
|
+
|
|
233
|
+
Raises:
|
|
234
|
+
BadRequestError: The subscription is completed and cannot be resumed.
|
|
235
|
+
"""
|
|
236
|
+
return cast(Subscription, self._send("POST", id, "resume", timeout=timeout))
|
|
237
|
+
|
|
238
|
+
def send_test(self, id: str, *, timeout: float | NotGiven | None = NOT_GIVEN) -> str:
|
|
239
|
+
"""Sends a signed ``subscription.test`` webhook and returns its event id."""
|
|
240
|
+
result = self._send("POST", id, "test", timeout=timeout)
|
|
241
|
+
return string_of(result, "eventId", "POST /subscriptions/{id}/test")
|
|
242
|
+
|
|
243
|
+
def deliveries(
|
|
244
|
+
self,
|
|
245
|
+
id: str,
|
|
246
|
+
*,
|
|
247
|
+
limit: int | None = None,
|
|
248
|
+
timeout: float | NotGiven | None = NOT_GIVEN,
|
|
249
|
+
) -> builtins.list[DeliveryRecord]:
|
|
250
|
+
"""The latest delivery attempts, newest first.
|
|
251
|
+
|
|
252
|
+
``limit`` is 1 to 100; the API's default is 25.
|
|
253
|
+
"""
|
|
254
|
+
result = self._send("GET", id, "deliveries", query={"limit": limit}, timeout=timeout)
|
|
255
|
+
return cast(
|
|
256
|
+
"builtins.list[DeliveryRecord]", data_of(result, "GET /subscriptions/{id}/deliveries")
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
def events(
|
|
260
|
+
self,
|
|
261
|
+
id: str,
|
|
262
|
+
*,
|
|
263
|
+
limit: int | None = None,
|
|
264
|
+
timeout: float | NotGiven | None = NOT_GIVEN,
|
|
265
|
+
) -> builtins.list[WebhookEvent]:
|
|
266
|
+
"""The latest events, exactly as they were posted, newest first.
|
|
267
|
+
|
|
268
|
+
``limit`` is 1 to 100; the API's default is 25.
|
|
269
|
+
"""
|
|
270
|
+
result = self._send("GET", id, "events", query={"limit": limit}, timeout=timeout)
|
|
271
|
+
return cast(
|
|
272
|
+
"builtins.list[WebhookEvent]", data_of(result, "GET /subscriptions/{id}/events")
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
def secret(self, id: str, *, timeout: float | NotGiven | None = NOT_GIVEN) -> str:
|
|
276
|
+
"""The full webhook signing secret."""
|
|
277
|
+
result = self._send("GET", id, "secret", timeout=timeout)
|
|
278
|
+
return string_of(result, "secret", "GET /subscriptions/{id}/secret")
|
|
279
|
+
|
|
280
|
+
def rotate_secret(self, id: str, *, timeout: float | NotGiven | None = NOT_GIVEN) -> str:
|
|
281
|
+
"""Replaces the webhook signing secret and returns the new one.
|
|
282
|
+
|
|
283
|
+
Deliveries are signed with it from now on, so update your receiver
|
|
284
|
+
right away.
|
|
285
|
+
"""
|
|
286
|
+
result = self._send("POST", id, "secret", "rotate", timeout=timeout)
|
|
287
|
+
return string_of(result, "secret", "POST /subscriptions/{id}/secret/rotate")
|
|
288
|
+
|
|
289
|
+
def _send(
|
|
290
|
+
self,
|
|
291
|
+
method: str,
|
|
292
|
+
id: str,
|
|
293
|
+
*rest: str,
|
|
294
|
+
query: dict[str, Any] | None = None,
|
|
295
|
+
body: object = None,
|
|
296
|
+
timeout: float | NotGiven | None,
|
|
297
|
+
) -> Any:
|
|
298
|
+
return self._transport.send(
|
|
299
|
+
method,
|
|
300
|
+
["subscriptions", required("id", id), *rest],
|
|
301
|
+
query=query,
|
|
302
|
+
body=body,
|
|
303
|
+
timeout=timeout,
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def mask_secret(secret: str) -> str:
|
|
308
|
+
"""Masks a signing secret the way the API does.
|
|
309
|
+
|
|
310
|
+
The first 11 characters, ``...``, and the last 4. Short secrets are
|
|
311
|
+
returned as they are.
|
|
312
|
+
"""
|
|
313
|
+
return secret if len(secret) <= 15 else f"{secret[:11]}...{secret[-4:]}"
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _without_none(fields: dict[str, Any]) -> dict[str, Any]:
|
|
317
|
+
return {key: value for key, value in fields.items() if value is not None}
|