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.
@@ -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}