flightpowers 0.1.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,23 @@
1
+ """Official FlightPowers client: live Google Flights, Booking.com, Airbnb and Google Hotels data.
2
+
3
+ >>> from flightpowers import FlightPowers
4
+ >>> fp = FlightPowers(rapidapi_key="...")
5
+ >>> res = fp.flights.oneway(from_airport="JFK", to_airport="LHR", departure_date="2027-01-20")
6
+ """
7
+
8
+ from ._client import BOOKING_FILTERS, HOSTS, SOURCE_TAG, VERSION, FlightPowers, Response, ResponseMeta
9
+ from ._errors import FlightPowersError
10
+
11
+ __version__ = VERSION
12
+
13
+ __all__ = [
14
+ "BOOKING_FILTERS",
15
+ "FlightPowers",
16
+ "FlightPowersError",
17
+ "Response",
18
+ "ResponseMeta",
19
+ "HOSTS",
20
+ "SOURCE_TAG",
21
+ "VERSION",
22
+ "__version__",
23
+ ]
@@ -0,0 +1,551 @@
1
+ """The FlightPowers client (standard library only: urllib + json)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import time
7
+ import urllib.error
8
+ import urllib.request
9
+ from dataclasses import dataclass, field
10
+ from typing import Any, Callable, Dict, Generic, List, Mapping, Optional, Sequence, TypeVar, Union
11
+
12
+ from ._errors import FlightPowersError
13
+
14
+ __all__ = [
15
+ "BOOKING_FILTERS",
16
+ "VERSION",
17
+ "SOURCE_TAG",
18
+ "HOSTS",
19
+ "FlightPowers",
20
+ "Response",
21
+ "ResponseMeta",
22
+ ]
23
+
24
+ VERSION = "0.1.0"
25
+
26
+ #: Value of ``_fp_source`` on every request this package sends.
27
+ SOURCE_TAG = "sdk-py"
28
+
29
+ #: The RapidAPI host of each listing. One key works on every listing you subscribe to.
30
+ HOSTS: Mapping[str, str] = {
31
+ "flights": "google-flights-live-api.p.rapidapi.com",
32
+ "booking": "booking-live-api.p.rapidapi.com",
33
+ "airbnb": "airbnb-live-api.p.rapidapi.com",
34
+ "google_hotels": "google-hotels-live-api.p.rapidapi.com",
35
+ }
36
+
37
+ #: The 24 Booking.com search filters ``booking.search(filters=...)`` accepts
38
+ #: (docs: https://flightpowers.com/llms-full.txt, ``/v1/hotels/search``).
39
+ BOOKING_FILTERS = (
40
+ "free_cancellation", "breakfast_included", "breakfast_and_lunch", "breakfast_and_dinner",
41
+ "all_meals_included", "all_inclusive", "free_wifi", "swimming_pool", "gym", "parking",
42
+ "front_desk_24h", "review_score_7", "review_score_8", "review_score_9", "private_bathroom",
43
+ "air_conditioning", "stars_3", "stars_4", "stars_5", "pets_allowed", "adults_only",
44
+ "sauna", "very_good_breakfast", "accepts_online_payment",
45
+ )
46
+
47
+ #: Most retries one call may make, whatever ``max_retries`` says.
48
+ MAX_RETRIES_CAP = 10
49
+ #: Longest wait before a retry, computed or from ``Retry-After``, in seconds.
50
+ MAX_RETRY_WAIT = 60.0
51
+
52
+ T = TypeVar("T")
53
+ AirportList = Union[str, Sequence[str]]
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class ResponseMeta:
58
+ """Search metadata from the response headers (or a body envelope)."""
59
+
60
+ #: ``X-Search-Status`` (ok | empty | partial | degraded), or the body's ``search_status``.
61
+ search_status: Optional[str] = None
62
+ #: ``X-Search-Reason``, or the body's ``search_reason``.
63
+ search_reason: Optional[str] = None
64
+ #: ``X-Search-Results`` as an int.
65
+ search_results: Optional[int] = None
66
+ #: ``X-Search-Notes`` (e.g. ``via_rpc``).
67
+ search_notes: Optional[str] = None
68
+ #: ``X-Search-Fallback`` (e.g. ``used``).
69
+ search_fallback: Optional[str] = None
70
+ #: RapidAPI quota headers (``x-ratelimit-*``, ``x-plan-*``), names lower-cased.
71
+ quota: Dict[str, str] = field(default_factory=dict)
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class Response(Generic[T]):
76
+ """What every SDK method returns."""
77
+
78
+ #: The parsed JSON body.
79
+ data: T
80
+ #: HTTP status code.
81
+ status: int
82
+ meta: ResponseMeta
83
+ #: All response headers, names lower-cased.
84
+ headers: Dict[str, str]
85
+
86
+
87
+ Transport = Callable[[str, bytes, Dict[str, str], float], "tuple[int, Dict[str, str], bytes]"]
88
+
89
+
90
+ def _urllib_transport(url: str, body: bytes, headers: Dict[str, str], timeout: float):
91
+ """POST with urllib. Returns (status, headers, body) for every HTTP answer, 4xx/5xx included."""
92
+ request = urllib.request.Request(url, data=body, headers=headers, method="POST")
93
+ try:
94
+ with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310 (https host)
95
+ return response.status, dict(response.headers.items()), response.read()
96
+ except urllib.error.HTTPError as error:
97
+ payload = error.read() if error.fp is not None else b""
98
+ return error.code, dict(error.headers.items()) if error.headers else {}, payload
99
+
100
+
101
+ def _read_meta(headers: Mapping[str, str], body: Any) -> ResponseMeta:
102
+ def get(name: str) -> Optional[str]:
103
+ value = headers.get(name)
104
+ return value if value else None
105
+
106
+ envelope = body if isinstance(body, dict) else {}
107
+
108
+ def from_body(key: str) -> Optional[str]:
109
+ value = envelope.get(key)
110
+ return value if isinstance(value, str) else None
111
+
112
+ results_raw = get("x-search-results")
113
+ try:
114
+ results = int(results_raw) if results_raw is not None else None
115
+ except ValueError:
116
+ results = None
117
+ quota = {k: v for k, v in headers.items() if k.startswith("x-ratelimit-") or k.startswith("x-plan-")}
118
+ return ResponseMeta(
119
+ search_status=get("x-search-status") or from_body("search_status"),
120
+ search_reason=get("x-search-reason") or from_body("search_reason"),
121
+ search_results=results,
122
+ search_notes=get("x-search-notes"),
123
+ search_fallback=get("x-search-fallback"),
124
+ quota=quota,
125
+ )
126
+
127
+
128
+ def _error_message(tool: str, status: int, body: Any) -> str:
129
+ detail = ""
130
+ if isinstance(body, dict):
131
+ candidate = next((body[k] for k in ("detail", "message", "error") if k in body), None)
132
+ if isinstance(candidate, str):
133
+ detail = candidate
134
+ elif candidate is not None:
135
+ detail = json.dumps(candidate)
136
+ elif isinstance(body, str):
137
+ detail = body[:300]
138
+ return "FlightPowers {} answered HTTP {}{}".format(tool, status, ": " + detail if detail else "")
139
+
140
+
141
+ def _clamp_retries(value: Any) -> int:
142
+ if isinstance(value, float) and value != value: # NaN
143
+ return 0
144
+ if isinstance(value, float) and value in (float("inf"), float("-inf")):
145
+ return MAX_RETRIES_CAP if value > 0 else 0
146
+ return min(MAX_RETRIES_CAP, max(0, int(value)))
147
+
148
+
149
+ def _retry_after_seconds(headers: Mapping[str, str]) -> Optional[float]:
150
+ raw = headers.get("retry-after")
151
+ if raw is None:
152
+ return None
153
+ try:
154
+ seconds = float(raw)
155
+ except ValueError:
156
+ return None
157
+ return min(max(seconds, 0.0), MAX_RETRY_WAIT)
158
+
159
+
160
+ def _compact(params: Mapping[str, Any]) -> Dict[str, Any]:
161
+ """Drop arguments left at None, so the API applies its own defaults."""
162
+ return {k: v for k, v in params.items() if v is not None}
163
+
164
+
165
+ class FlightPowers:
166
+ """The FlightPowers client.
167
+
168
+ >>> fp = FlightPowers(rapidapi_key="...")
169
+ >>> res = fp.flights.oneway(from_airport="JFK", to_airport="LHR", departure_date="2027-01-20")
170
+ >>> res.meta.search_status, res.data[0]["price"]
171
+ """
172
+
173
+ def __init__(
174
+ self,
175
+ rapidapi_key: str,
176
+ *,
177
+ max_retries: int = 0,
178
+ retry_delay: float = 1.0,
179
+ timeout: float = 180.0,
180
+ base_urls: Optional[Mapping[str, str]] = None,
181
+ transport: Optional[Transport] = None,
182
+ sleep: Callable[[float], None] = time.sleep,
183
+ ) -> None:
184
+ """
185
+ :param rapidapi_key: your RapidAPI key (https://rapidapi.com/developer/apps).
186
+ :param max_retries: how many times to repeat a request that answered HTTP 503
187
+ with ``"retry": true`` in its body. Default 0 (never), capped at 10. Nothing else is retried.
188
+ :param retry_delay: base wait in seconds before a retry, doubled each time and capped
189
+ at 60 s; a ``Retry-After`` header wins (also capped at 60 s).
190
+ :param timeout: socket timeout in seconds (default 180, RapidAPI's own limit). It bounds
191
+ the connect and each read, not the whole request: a body that keeps trickling in
192
+ can take longer in total.
193
+ :param base_urls: override a listing's base URL (tests, proxies), keyed like ``HOSTS``.
194
+ :param transport: replace the HTTP layer (tests).
195
+ """
196
+ if not isinstance(rapidapi_key, str) or not rapidapi_key.strip():
197
+ raise ValueError("FlightPowers: rapidapi_key is required")
198
+ self._key = rapidapi_key.strip()
199
+ self._max_retries = _clamp_retries(max_retries)
200
+ self._retry_delay = max(0.0, float(retry_delay))
201
+ self._timeout = float(timeout)
202
+ self._base_urls = {name: "https://" + host for name, host in HOSTS.items()}
203
+ for name, url in (base_urls or {}).items():
204
+ if name not in HOSTS:
205
+ raise ValueError("FlightPowers: unknown product {!r} in base_urls".format(name))
206
+ self._base_urls[name] = url.rstrip("/")
207
+ self._transport = transport or _urllib_transport
208
+ self._sleep = sleep
209
+ self.flights = Flights(self)
210
+ self.booking = Booking(self)
211
+ self.airbnb = Airbnb(self)
212
+ self.google_hotels = GoogleHotels(self)
213
+
214
+ def request(
215
+ self,
216
+ product: str,
217
+ path: str,
218
+ params: Mapping[str, Any],
219
+ tool: str,
220
+ *,
221
+ max_retries: Optional[int] = None,
222
+ timeout: Optional[float] = None,
223
+ ) -> Response[Any]:
224
+ """Send one POST. Exposed for endpoints this version does not wrap yet."""
225
+ if product not in HOSTS:
226
+ raise ValueError("FlightPowers: unknown product {!r}".format(product))
227
+ url = self._base_urls[product] + path
228
+ payload = dict(params)
229
+ payload["_fp_source"] = SOURCE_TAG
230
+ payload["_fp_tool"] = tool
231
+ body = json.dumps(payload).encode("utf-8")
232
+ headers = {
233
+ "content-type": "application/json",
234
+ "accept": "application/json",
235
+ "user-agent": "flightpowers-python/" + VERSION,
236
+ "x-rapidapi-key": self._key,
237
+ "x-rapidapi-host": HOSTS[product],
238
+ }
239
+ retries = self._max_retries if max_retries is None else _clamp_retries(max_retries)
240
+ wait_budget = self._timeout if timeout is None else float(timeout)
241
+
242
+ attempt = 0
243
+ while True:
244
+ try:
245
+ status, raw_headers, raw_body = self._transport(url, body, headers, wait_budget)
246
+ except Exception as cause: # network error, timeout
247
+ raise FlightPowersError(
248
+ "FlightPowers {}: request failed ({})".format(tool, cause), status=0
249
+ ) from cause
250
+
251
+ lower = {k.lower(): v for k, v in raw_headers.items()}
252
+ text = raw_body.decode("utf-8", errors="replace") if raw_body else ""
253
+ parsed: Any
254
+ if text == "":
255
+ parsed = None
256
+ else:
257
+ try:
258
+ parsed = json.loads(text)
259
+ except ValueError:
260
+ parsed = text
261
+ meta = _read_meta(lower, parsed)
262
+
263
+ if 200 <= status < 300:
264
+ return Response(data=parsed, status=status, meta=meta, headers=lower)
265
+
266
+ error = FlightPowersError(
267
+ _error_message(tool, status, parsed), status=status, body=parsed, meta=meta, headers=lower
268
+ )
269
+ if status == 503 and error.retry is True and attempt < retries:
270
+ delay = _retry_after_seconds(lower)
271
+ if delay is None:
272
+ delay = min(MAX_RETRY_WAIT, self._retry_delay * (2**attempt))
273
+ self._sleep(delay)
274
+ attempt += 1
275
+ continue
276
+ raise error
277
+
278
+
279
+ class _Namespace:
280
+ _product = ""
281
+
282
+ def __init__(self, client: FlightPowers) -> None:
283
+ self._client = client
284
+
285
+ def _post(self, path: str, tool: str, params: Mapping[str, Any], extra: Mapping[str, Any]) -> Response[Any]:
286
+ merged = dict(params)
287
+ merged.update(extra)
288
+ return self._client.request(self._product, path, _compact(merged), tool)
289
+
290
+
291
+ class Flights(_Namespace):
292
+ """Google Flights Live API (https://rapidapi.com/mtnrabi/api/google-flights-live-api)."""
293
+
294
+ _product = "flights"
295
+
296
+ def oneway(
297
+ self,
298
+ *,
299
+ from_airport: str,
300
+ to_airport: str,
301
+ departure_date: str,
302
+ max_stops: Optional[int] = None,
303
+ airline_codes: Optional[List[str]] = None,
304
+ exclude_airline_codes: Optional[List[str]] = None,
305
+ departure_time_min: Optional[int] = None,
306
+ departure_time_max: Optional[int] = None,
307
+ departure_arrival_time_min: Optional[int] = None,
308
+ departure_arrival_time_max: Optional[int] = None,
309
+ sort_type: Optional[str] = None,
310
+ currency: Optional[str] = None,
311
+ max_price: Optional[int] = None,
312
+ seat_type: Optional[int] = None,
313
+ passengers: Optional[List[int]] = None,
314
+ limit: Optional[int] = None,
315
+ use_fallback: Optional[bool] = None,
316
+ strict: Optional[bool] = None,
317
+ use_ext_proxy: Optional[bool] = None,
318
+ **extra: Any,
319
+ ) -> Response[List[Dict[str, Any]]]:
320
+ """One-way itineraries for a route and date (``YYYY-MM-DD``)."""
321
+ params = dict(locals())
322
+ return self._post("/api/google_flights/oneway/v1", "flights.oneway", _args(params), extra)
323
+
324
+ def roundtrip(
325
+ self,
326
+ *,
327
+ from_airport: str,
328
+ to_airport: str,
329
+ departure_date: str,
330
+ return_date: str,
331
+ max_departure_stops: Optional[int] = None,
332
+ max_return_stops: Optional[int] = None,
333
+ departure_airline_codes: Optional[List[str]] = None,
334
+ return_airline_codes: Optional[List[str]] = None,
335
+ departure_exclude_airline_codes: Optional[List[str]] = None,
336
+ return_exclude_airline_codes: Optional[List[str]] = None,
337
+ departure_departure_time_min: Optional[int] = None,
338
+ departure_departure_time_max: Optional[int] = None,
339
+ departure_arrival_time_min: Optional[int] = None,
340
+ departure_arrival_time_max: Optional[int] = None,
341
+ return_departure_time_min: Optional[int] = None,
342
+ return_departure_time_max: Optional[int] = None,
343
+ return_arrival_time_min: Optional[int] = None,
344
+ return_arrival_time_max: Optional[int] = None,
345
+ sort_type: Optional[str] = None,
346
+ currency: Optional[str] = None,
347
+ max_price: Optional[int] = None,
348
+ seat_type: Optional[int] = None,
349
+ passengers: Optional[List[int]] = None,
350
+ limit: Optional[int] = None,
351
+ returns_per_outbound: Optional[int] = None,
352
+ use_fallback: Optional[bool] = None,
353
+ strict: Optional[bool] = None,
354
+ use_ext_proxy: Optional[bool] = None,
355
+ **extra: Any,
356
+ ) -> Response[List[Dict[str, Any]]]:
357
+ """Round trips: both legs paired and priced together, one total per row."""
358
+ params = dict(locals())
359
+ return self._post("/api/google_flights/roundtrip/v1", "flights.roundtrip", _args(params), extra)
360
+
361
+ def oneway_multi_airport(
362
+ self, *, from_airport: AirportList, to_airport: AirportList, departure_date: str, **params: Any
363
+ ) -> Response[List[Dict[str, Any]]]:
364
+ """One-way with 1 to 4 airports a side (at most 8 pairs), one billed request.
365
+
366
+ Other fields are the same as :meth:`oneway`; ``use_fallback`` must be False or absent.
367
+ """
368
+ base = {"from_airport": _airports(from_airport), "to_airport": _airports(to_airport), "departure_date": departure_date}
369
+ return self._post("/api/google_flights/oneway_multi_airport/v1", "flights.oneway_multi_airport", base, params)
370
+
371
+ def roundtrip_multi_airport(
372
+ self,
373
+ *,
374
+ from_airport: AirportList,
375
+ to_airport: AirportList,
376
+ departure_date: str,
377
+ return_date: str,
378
+ **params: Any,
379
+ ) -> Response[List[Dict[str, Any]]]:
380
+ """Round trip with 1 to 4 airports a side (at most 8 pairs), one billed request."""
381
+ base = {
382
+ "from_airport": _airports(from_airport),
383
+ "to_airport": _airports(to_airport),
384
+ "departure_date": departure_date,
385
+ "return_date": return_date,
386
+ }
387
+ return self._post("/api/google_flights/roundtrip_multi_airport/v1", "flights.roundtrip_multi_airport", base, params)
388
+
389
+
390
+ class Booking(_Namespace):
391
+ """Booking Live API (https://rapidapi.com/mtnrabi/api/booking-live-api)."""
392
+
393
+ _product = "booking"
394
+
395
+ def search(
396
+ self,
397
+ *,
398
+ destination: str,
399
+ checkin_date: str,
400
+ checkout_date: str,
401
+ adults: Optional[int] = None,
402
+ children: Optional[int] = None,
403
+ currency: Optional[str] = None,
404
+ proxy_country: Optional[str] = None,
405
+ budget_per_night: Optional[float] = None,
406
+ filters: Optional[List[str]] = None,
407
+ sort: Optional[str] = None,
408
+ **extra: Any,
409
+ ) -> Response[Dict[str, Any]]:
410
+ """Hotels at a destination (the field is ``destination``, not ``location``).
411
+
412
+ ``filters`` takes names from ``BOOKING_FILTERS``.
413
+ """
414
+ params = dict(locals())
415
+ return self._post("/search", "booking.search", _args(params), extra)
416
+
417
+ def hotel_by_name(
418
+ self,
419
+ *,
420
+ hotel_name: str,
421
+ checkin_date: str,
422
+ checkout_date: str,
423
+ area: Optional[str] = None,
424
+ adults: Optional[int] = None,
425
+ children: Optional[int] = None,
426
+ currency: Optional[str] = None,
427
+ proxy_country: Optional[str] = None,
428
+ free_cancellation: Optional[bool] = None,
429
+ **extra: Any,
430
+ ) -> Response[Dict[str, Any]]:
431
+ """One hotel by name: availability and price for the stay."""
432
+ params = dict(locals())
433
+ return self._post("/hotel_by_name", "booking.hotel_by_name", _args(params), extra)
434
+
435
+ def resolve(self, *, hotel_name: str, proxy_country: Optional[str] = None, **extra: Any) -> Response[Dict[str, Any]]:
436
+ """Turn a hotel name into the ``hotel_booking_id`` that :meth:`rooms` takes."""
437
+ params = dict(locals())
438
+ return self._post("/resolve", "booking.resolve", _args(params), extra)
439
+
440
+ def rooms(
441
+ self,
442
+ *,
443
+ hotel_booking_id: str,
444
+ checkin_date: str,
445
+ checkout_date: str,
446
+ adults: Optional[int] = None,
447
+ children: Optional[int] = None,
448
+ currency: Optional[str] = None,
449
+ proxy_country: Optional[str] = None,
450
+ free_cancellation: Optional[bool] = None,
451
+ **extra: Any,
452
+ ) -> Response[Dict[str, Any]]:
453
+ """Every bookable room type for one hotel, with prices and board."""
454
+ params = dict(locals())
455
+ return self._post("/hotel", "booking.rooms", _args(params), extra)
456
+
457
+
458
+ class Airbnb(_Namespace):
459
+ """Airbnb Live API (https://rapidapi.com/mtnrabi/api/airbnb-live-api)."""
460
+
461
+ _product = "airbnb"
462
+
463
+ def search(
464
+ self,
465
+ *,
466
+ destination: str,
467
+ checkin_date: str,
468
+ checkout_date: str,
469
+ adults: Optional[int] = None,
470
+ children: Optional[int] = None,
471
+ infants: Optional[int] = None,
472
+ pets: Optional[int] = None,
473
+ currency: Optional[str] = None,
474
+ price_min: Optional[float] = None,
475
+ price_max: Optional[float] = None,
476
+ min_bedrooms: Optional[int] = None,
477
+ min_beds: Optional[int] = None,
478
+ min_bathrooms: Optional[float] = None,
479
+ room_types: Optional[List[str]] = None,
480
+ min_rating: Optional[float] = None,
481
+ sort: Optional[str] = None,
482
+ proxy_country: Optional[str] = None,
483
+ **extra: Any,
484
+ ) -> Response[Dict[str, Any]]:
485
+ """Listings at a destination, with Airbnb's own filters."""
486
+ params = dict(locals())
487
+ return self._post("/search", "airbnb.search", _args(params), extra)
488
+
489
+ def details(
490
+ self,
491
+ *,
492
+ listing_id: str,
493
+ checkin_date: Optional[str] = None,
494
+ checkout_date: Optional[str] = None,
495
+ adults: Optional[int] = None,
496
+ currency: Optional[str] = None,
497
+ proxy_country: Optional[str] = None,
498
+ **extra: Any,
499
+ ) -> Response[Dict[str, Any]]:
500
+ """One listing by its id (a search row's ``provider_property_id``)."""
501
+ params = dict(locals())
502
+ return self._post("/details", "airbnb.details", _args(params), extra)
503
+
504
+
505
+ class GoogleHotels(_Namespace):
506
+ """Google Hotels Live API (https://rapidapi.com/mtnrabi/api/google-hotels-live-api)."""
507
+
508
+ _product = "google_hotels"
509
+
510
+ def search(
511
+ self,
512
+ *,
513
+ destination: str,
514
+ checkin_date: str,
515
+ checkout_date: str,
516
+ adults: Optional[int] = None,
517
+ children_ages: Optional[List[int]] = None,
518
+ currency: Optional[str] = None,
519
+ **extra: Any,
520
+ ) -> Response[Dict[str, Any]]:
521
+ """One page of hotels at a destination."""
522
+ params = dict(locals())
523
+ return self._post("/search", "google_hotels.search", _args(params), extra)
524
+
525
+ def rates(
526
+ self,
527
+ *,
528
+ checkin_date: str,
529
+ checkout_date: str,
530
+ hotel_name: Optional[str] = None,
531
+ destination: Optional[str] = None,
532
+ entity_id: Optional[str] = None,
533
+ adults: Optional[int] = None,
534
+ children_ages: Optional[List[int]] = None,
535
+ currency: Optional[str] = None,
536
+ **extra: Any,
537
+ ) -> Response[Dict[str, Any]]:
538
+ """One hotel's rate on each site Google lists for it (by ``hotel_name`` or ``entity_id``)."""
539
+ if not hotel_name and not entity_id:
540
+ raise ValueError("google_hotels.rates: pass hotel_name or entity_id")
541
+ params = dict(locals())
542
+ return self._post("/rates", "google_hotels.rates", _args(params), extra)
543
+
544
+
545
+ def _args(captured: Dict[str, Any]) -> Dict[str, Any]:
546
+ """The method's named arguments, minus ``self`` and ``extra``."""
547
+ return {k: v for k, v in captured.items() if k not in ("self", "extra", "params")}
548
+
549
+
550
+ def _airports(value: AirportList) -> Union[str, List[str]]:
551
+ return value if isinstance(value, str) else list(value)
@@ -0,0 +1,49 @@
1
+ """The one exception this package raises for an API answer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any, Dict, Optional
6
+
7
+ if TYPE_CHECKING: # pragma: no cover
8
+ from ._client import ResponseMeta
9
+
10
+ __all__ = ["FlightPowersError"]
11
+
12
+
13
+ class FlightPowersError(Exception):
14
+ """Raised for any non-2xx answer, and for a request that got no answer (``status == 0``).
15
+
16
+ Carries the HTTP status, the search headers and the parsed body, so you can
17
+ tell a degraded search (retry it) from a bad request (fix it) from a spent
18
+ quota (upgrade).
19
+ """
20
+
21
+ def __init__(
22
+ self,
23
+ message: str,
24
+ *,
25
+ status: int,
26
+ body: Any = None,
27
+ meta: "Optional[ResponseMeta]" = None,
28
+ headers: Optional[Dict[str, str]] = None,
29
+ ) -> None:
30
+ super().__init__(message)
31
+ self.message = message
32
+ #: HTTP status code (0 when the request never got an answer).
33
+ self.status = status
34
+ #: Parsed JSON body, or the raw text when it was not JSON.
35
+ self.body = body
36
+ self.meta = meta
37
+ self.headers = headers or {}
38
+ #: ``X-Search-Status`` or the body's ``search_status``.
39
+ self.search_status = meta.search_status if meta is not None else None
40
+ #: ``X-Search-Reason`` or the body's ``search_reason``.
41
+ self.search_reason = meta.search_reason if meta is not None else None
42
+ flag = body.get("retry") if isinstance(body, dict) else None
43
+ #: The API's own ``retry`` flag from the body, when it sent one.
44
+ self.retry = flag if isinstance(flag, bool) else None
45
+
46
+ def __repr__(self) -> str:
47
+ return "FlightPowersError(status={!r}, search_status={!r}, message={!r})".format(
48
+ self.status, self.search_status, self.message
49
+ )
flightpowers/py.typed ADDED
File without changes
@@ -0,0 +1,144 @@
1
+ Metadata-Version: 2.4
2
+ Name: flightpowers
3
+ Version: 0.1.0
4
+ Summary: Official FlightPowers client: live Google Flights, Booking.com, Airbnb and Google Hotels data through your RapidAPI key.
5
+ Author: Matan Rabi
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://flightpowers.com
8
+ Project-URL: Documentation, https://flightpowers.com/docs
9
+ Project-URL: Source, https://github.com/mtnrabi/flightpowers-sdk
10
+ Project-URL: Issues, https://github.com/mtnrabi/flightpowers-sdk/issues
11
+ Keywords: flights,google-flights,hotels,booking,airbnb,google-hotels,travel,rapidapi,api,sdk
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Topic :: Internet :: WWW/HTTP
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: >=3.9
25
+ Description-Content-Type: text/markdown
26
+ License-File: LICENSE
27
+ Dynamic: license-file
28
+
29
+ # flightpowers (Python)
30
+
31
+ Official client for the FlightPowers APIs: live Google Flights, Booking.com, Airbnb and Google Hotels data as JSON, through your own RapidAPI key.
32
+
33
+ - Python 3.9+, standard library only (no dependencies). Type hints included.
34
+ - Docs: https://flightpowers.com/docs
35
+
36
+ ## Install
37
+
38
+ ```sh
39
+ pip install flightpowers
40
+ ```
41
+
42
+ ## Get a key
43
+
44
+ Subscribe to the listing you need on RapidAPI and copy your key. Every listing has a free BASIC plan, then plans from $10 a month on each RapidAPI listing (see https://flightpowers.com/pricing). One key works on every listing you subscribe to.
45
+
46
+ | Namespace | RapidAPI listing |
47
+ |---|---|
48
+ | `flights` | https://rapidapi.com/mtnrabi/api/google-flights-live-api |
49
+ | `booking` | https://rapidapi.com/mtnrabi/api/booking-live-api |
50
+ | `airbnb` | https://rapidapi.com/mtnrabi/api/airbnb-live-api |
51
+ | `google_hotels` | https://rapidapi.com/mtnrabi/api/google-hotels-live-api |
52
+
53
+ ## Quickstart
54
+
55
+ ### Google Flights
56
+
57
+ ```python
58
+ import os
59
+ from flightpowers import FlightPowers
60
+ fp = FlightPowers(os.environ["RAPIDAPI_KEY"])
61
+ res = fp.flights.oneway(from_airport="JFK", to_airport="LHR", departure_date="2027-01-20")
62
+ print(res.meta.search_status)
63
+ if res.data: # an empty search (search_status "empty") returns []
64
+ print(res.data[0]["price"], res.data[0].get("price_range_in_relation_to_other_periods"))
65
+ ```
66
+
67
+ Round trips (`fp.flights.roundtrip`) come back with both legs paired and one total per row. `oneway_multi_airport` / `roundtrip_multi_airport` take 1 to 4 airports a side in one request.
68
+
69
+ ### Booking.com
70
+
71
+ ```python
72
+ import os
73
+ from flightpowers import FlightPowers
74
+ fp = FlightPowers(os.environ["RAPIDAPI_KEY"])
75
+ res = fp.booking.search(destination="Paris", checkin_date="2027-01-20", checkout_date="2027-01-23")
76
+ for p in res.data["properties"][:3]: print(p["name"], p["price_string"], p.get("review_score"))
77
+ # Also: fp.booking.hotel_by_name, fp.booking.resolve, fp.booking.rooms
78
+ ```
79
+
80
+ ### Airbnb
81
+
82
+ ```python
83
+ import os
84
+ from flightpowers import FlightPowers
85
+ fp = FlightPowers(os.environ["RAPIDAPI_KEY"])
86
+ res = fp.airbnb.search(destination="Lisbon", checkin_date="2027-01-20", checkout_date="2027-01-23", min_bedrooms=2)
87
+ for p in res.data["properties"][:3]: print(p.get("name"), p.get("price_total"), p.get("currency"), p.get("link"))
88
+ # Also: fp.airbnb.details(listing_id=...)
89
+ ```
90
+
91
+ ### Google Hotels
92
+
93
+ ```python
94
+ import os
95
+ from flightpowers import FlightPowers
96
+ fp = FlightPowers(os.environ["RAPIDAPI_KEY"])
97
+ res = fp.google_hotels.rates(hotel_name="Hotel Arts Barcelona", destination="Barcelona", checkin_date="2027-01-20", checkout_date="2027-01-23")
98
+ for s in (res.data["properties"][0]["sites"] if res.data["properties"] else []): print(s.get("site"), s.get("price_total"), s.get("link"))
99
+ # Also: fp.google_hotels.search(destination=..., checkin_date=..., checkout_date=...)
100
+ ```
101
+
102
+ Every method takes the API's own field names as keyword arguments; arguments left at `None` are not sent, so the API applies its defaults. Fields this version does not name yet can be passed as extra keyword arguments.
103
+
104
+ ## Responses
105
+
106
+ Every method returns a `Response` with `data`, `status`, `meta` and `headers`:
107
+
108
+ - `data`: the parsed JSON body.
109
+ - `meta.search_status`: `ok`, `empty`, `partial` or `degraded`, from the `X-Search-Status` header (or the body's `search_status` on Airbnb and Google Hotels). `empty` means the search finished and there is nothing; `degraded` means the search did not finish, so an empty list says nothing about availability.
110
+ - Booking.com sends no search status, so `meta.search_status` is `None` on every Booking method. A Booking search whose page gave no properties answers HTTP 404 (`No properties found...`), which raises `FlightPowersError`; a search where every room is over `budget_per_night` comes back as an empty list.
111
+ - `meta.search_reason`, `meta.search_results`, `meta.search_notes`, `meta.search_fallback`: the other `X-Search-*` headers.
112
+ - `meta.quota`: the RapidAPI `x-ratelimit-*` / `x-plan-*` headers, when present.
113
+
114
+ ## Errors and retries
115
+
116
+ Any non-2xx answer raises `FlightPowersError` with `status`, `search_status`, `search_reason`, `retry` (the API's own flag from the body) and `body`. A request that got no answer has `status` 0.
117
+
118
+ ```python
119
+ from flightpowers import FlightPowersError
120
+ try:
121
+ fp.flights.oneway(from_airport="JFK", to_airport="LHR", departure_date="2027-01-20", strict=True)
122
+ except FlightPowersError as err:
123
+ print(err.status, err.search_status, err.search_reason, err.retry)
124
+ ```
125
+
126
+ Retries are off by default. `FlightPowers(key, max_retries=2)` repeats a request only when it answered HTTP 503 with `"retry": true` in its body, waiting `Retry-After` when sent, else 1 s, then 2 s (each wait capped at 60 s, at most 10 retries). Nothing else is retried: a 429 would spend more quota, and a 4xx needs a fixed request.
127
+
128
+ Options: `timeout` (socket timeout in seconds, default 180: it bounds the connect and each read, not the whole request), `retry_delay` (seconds, default 1), `base_urls` (tests and proxies), `transport` (replace the HTTP layer).
129
+
130
+ ## Usage attribution
131
+
132
+ Each request body carries two extra fields, `_fp_source: "sdk-py"` and `_fp_tool: "<namespace>.<method>"`, so we can count how the SDK is used. The API removes them before it reads your request; they never change a search.
133
+
134
+ ## Docs
135
+
136
+ - Flights: https://flightpowers.com/flights-api and https://flightpowers.com/flights-api/multi-airport
137
+ - Booking.com: https://flightpowers.com/hotels-api and https://flightpowers.com/hotels-api/by-name
138
+ - Airbnb: https://flightpowers.com/hotels-api/airbnb
139
+ - Google Hotels: https://flightpowers.com/hotels-api/google-hotels
140
+ - Full reference: https://flightpowers.com/llms-full.txt
141
+
142
+ ## License
143
+
144
+ MIT
@@ -0,0 +1,9 @@
1
+ flightpowers/__init__.py,sha256=xdoopuvxqlyg1pZEN3euRwa0u4_WmAJZEzupkPrQweA,632
2
+ flightpowers/_client.py,sha256=vZYjYZ7Hdsgvd31uEnqpTayw_hxNhLl10e8m9XASIqg,21099
3
+ flightpowers/_errors.py,sha256=O9PXY2SusjuzPCnQAkHjmo0zG9XDBIJz5Dmj9l1GHTk,1806
4
+ flightpowers/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ flightpowers-0.1.0.dist-info/licenses/LICENSE,sha256=ym-BCGK8ojgVdYFwfYssYLP9g_wuinJSaUqzgRB-nD8,1067
6
+ flightpowers-0.1.0.dist-info/METADATA,sha256=5d3Kzu3w82C8MySH89Kj_Oqtw9kGHDVgTya5NK0ol68,6921
7
+ flightpowers-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
8
+ flightpowers-0.1.0.dist-info/top_level.txt,sha256=TU3Zfgm_z6fUvN88U53lr94NWEgdy9de2Bnu33hz8fA,13
9
+ flightpowers-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Matan Rabi
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.
@@ -0,0 +1 @@
1
+ flightpowers