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.
- flightpowers/__init__.py +23 -0
- flightpowers/_client.py +551 -0
- flightpowers/_errors.py +49 -0
- flightpowers/py.typed +0 -0
- flightpowers-0.1.0.dist-info/METADATA +144 -0
- flightpowers-0.1.0.dist-info/RECORD +9 -0
- flightpowers-0.1.0.dist-info/WHEEL +5 -0
- flightpowers-0.1.0.dist-info/licenses/LICENSE +21 -0
- flightpowers-0.1.0.dist-info/top_level.txt +1 -0
flightpowers/__init__.py
ADDED
|
@@ -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
|
+
]
|
flightpowers/_client.py
ADDED
|
@@ -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)
|
flightpowers/_errors.py
ADDED
|
@@ -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,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
|