opensky-cli 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.
- opensky/__init__.py +3 -0
- opensky/__main__.py +3 -0
- opensky/_vendor/__init__.py +0 -0
- opensky/_vendor/google_flights.py +489 -0
- opensky/airports.py +183 -0
- opensky/cache.py +49 -0
- opensky/cli.py +690 -0
- opensky/config.py +151 -0
- opensky/data/conflict_zones.json +203 -0
- opensky/data/demo_flights.json +3355 -0
- opensky/display.py +358 -0
- opensky/models.py +90 -0
- opensky/providers/__init__.py +81 -0
- opensky/providers/amadeus.py +150 -0
- opensky/providers/duffel.py +122 -0
- opensky/providers/google.py +111 -0
- opensky/safety.py +138 -0
- opensky/search.py +280 -0
- opensky_cli-0.1.0.dist-info/METADATA +231 -0
- opensky_cli-0.1.0.dist-info/RECORD +23 -0
- opensky_cli-0.1.0.dist-info/WHEEL +4 -0
- opensky_cli-0.1.0.dist-info/entry_points.txt +2 -0
- opensky_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
opensky/__init__.py
ADDED
opensky/__main__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
"""Vendored and simplified from fli (punitarani/fli), MIT license.
|
|
2
|
+
|
|
3
|
+
https://github.com/punitarani/fli
|
|
4
|
+
|
|
5
|
+
Changes from original:
|
|
6
|
+
- Combined client.py, models/google_flights/base.py, models/google_flights/flights.py,
|
|
7
|
+
and search/flights.py into a single file
|
|
8
|
+
- Replaced Airport/Airline enums with plain strings (eliminates 10k+ line enum files)
|
|
9
|
+
- Added currency support via URL query parameter
|
|
10
|
+
- Added Google consent wall detection
|
|
11
|
+
- Reduced default rate limit from 10/s to 3/s for scan workloads
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import urllib.parse
|
|
16
|
+
from copy import deepcopy
|
|
17
|
+
from datetime import datetime
|
|
18
|
+
from enum import Enum
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from curl_cffi import requests
|
|
22
|
+
from pydantic import (
|
|
23
|
+
BaseModel,
|
|
24
|
+
NonNegativeFloat,
|
|
25
|
+
NonNegativeInt,
|
|
26
|
+
PositiveInt,
|
|
27
|
+
ValidationInfo,
|
|
28
|
+
field_validator,
|
|
29
|
+
model_validator,
|
|
30
|
+
)
|
|
31
|
+
from ratelimit import limits, sleep_and_retry
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
# Enums
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
class SeatType(Enum):
|
|
39
|
+
ECONOMY = 1
|
|
40
|
+
PREMIUM_ECONOMY = 2
|
|
41
|
+
BUSINESS = 3
|
|
42
|
+
FIRST = 4
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class SortBy(Enum):
|
|
46
|
+
NONE = 0
|
|
47
|
+
TOP_FLIGHTS = 1
|
|
48
|
+
CHEAPEST = 2
|
|
49
|
+
DEPARTURE_TIME = 3
|
|
50
|
+
ARRIVAL_TIME = 4
|
|
51
|
+
DURATION = 5
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class TripType(Enum):
|
|
55
|
+
ROUND_TRIP = 1
|
|
56
|
+
ONE_WAY = 2
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class MaxStops(Enum):
|
|
60
|
+
ANY = 0
|
|
61
|
+
NON_STOP = 1
|
|
62
|
+
ONE_STOP_OR_FEWER = 2
|
|
63
|
+
TWO_OR_FEWER_STOPS = 3
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
# Pydantic models
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
class TimeRestrictions(BaseModel):
|
|
71
|
+
earliest_departure: NonNegativeInt | None = None
|
|
72
|
+
latest_departure: PositiveInt | None = None
|
|
73
|
+
earliest_arrival: NonNegativeInt | None = None
|
|
74
|
+
latest_arrival: PositiveInt | None = None
|
|
75
|
+
|
|
76
|
+
@field_validator("latest_departure", "latest_arrival")
|
|
77
|
+
@classmethod
|
|
78
|
+
def validate_latest_times(
|
|
79
|
+
cls, v: PositiveInt | None, info: ValidationInfo
|
|
80
|
+
) -> PositiveInt | None:
|
|
81
|
+
if v is None:
|
|
82
|
+
return v
|
|
83
|
+
field_prefix = "earliest_" + info.field_name[7:]
|
|
84
|
+
earliest = info.data.get(field_prefix)
|
|
85
|
+
if earliest is not None and earliest > v:
|
|
86
|
+
info.data[field_prefix] = v
|
|
87
|
+
return earliest
|
|
88
|
+
return v
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class PassengerInfo(BaseModel):
|
|
92
|
+
adults: NonNegativeInt = 1
|
|
93
|
+
children: NonNegativeInt = 0
|
|
94
|
+
infants_in_seat: NonNegativeInt = 0
|
|
95
|
+
infants_on_lap: NonNegativeInt = 0
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class PriceLimit(BaseModel):
|
|
99
|
+
max_price: PositiveInt
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class LayoverRestrictions(BaseModel):
|
|
103
|
+
airports: list[str] | None = None
|
|
104
|
+
max_duration: PositiveInt | None = None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class FlightLeg(BaseModel):
|
|
108
|
+
airline: str
|
|
109
|
+
flight_number: str
|
|
110
|
+
departure_airport: str
|
|
111
|
+
arrival_airport: str
|
|
112
|
+
departure_datetime: datetime
|
|
113
|
+
arrival_datetime: datetime
|
|
114
|
+
duration: PositiveInt
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class FlightResult(BaseModel):
|
|
118
|
+
legs: list[FlightLeg]
|
|
119
|
+
price: NonNegativeFloat
|
|
120
|
+
duration: PositiveInt
|
|
121
|
+
stops: NonNegativeInt
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class FlightSegment(BaseModel):
|
|
125
|
+
departure_airport: list[list[str | int]]
|
|
126
|
+
arrival_airport: list[list[str | int]]
|
|
127
|
+
travel_date: str
|
|
128
|
+
time_restrictions: TimeRestrictions | None = None
|
|
129
|
+
selected_flight: FlightResult | None = None
|
|
130
|
+
|
|
131
|
+
@field_validator("travel_date")
|
|
132
|
+
@classmethod
|
|
133
|
+
def validate_travel_date(cls, v: str) -> str:
|
|
134
|
+
travel_date = datetime.strptime(v, "%Y-%m-%d").date()
|
|
135
|
+
if travel_date < datetime.now().date():
|
|
136
|
+
raise ValueError("Travel date cannot be in the past")
|
|
137
|
+
return v
|
|
138
|
+
|
|
139
|
+
@model_validator(mode="after")
|
|
140
|
+
def validate_airports(self) -> "FlightSegment":
|
|
141
|
+
if not self.departure_airport or not self.arrival_airport:
|
|
142
|
+
raise ValueError("Both departure and arrival airports must be specified")
|
|
143
|
+
dep = self.departure_airport[0][0] if isinstance(self.departure_airport[0][0], str) else None
|
|
144
|
+
arr = self.arrival_airport[0][0] if isinstance(self.arrival_airport[0][0], str) else None
|
|
145
|
+
if dep and arr and dep == arr:
|
|
146
|
+
raise ValueError("Departure and arrival airports must be different")
|
|
147
|
+
return self
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# ---------------------------------------------------------------------------
|
|
151
|
+
# Filter encoder (protobuf-like structure for Google Flights API)
|
|
152
|
+
# ---------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
class FlightSearchFilters(BaseModel):
|
|
155
|
+
trip_type: TripType = TripType.ONE_WAY
|
|
156
|
+
passenger_info: PassengerInfo = PassengerInfo()
|
|
157
|
+
flight_segments: list[FlightSegment]
|
|
158
|
+
stops: MaxStops = MaxStops.ANY
|
|
159
|
+
seat_type: SeatType = SeatType.ECONOMY
|
|
160
|
+
price_limit: PriceLimit | None = None
|
|
161
|
+
airlines: list[str] | None = None
|
|
162
|
+
max_duration: PositiveInt | None = None
|
|
163
|
+
layover_restrictions: LayoverRestrictions | None = None
|
|
164
|
+
sort_by: SortBy = SortBy.CHEAPEST
|
|
165
|
+
|
|
166
|
+
def format(self) -> list:
|
|
167
|
+
def serialize(obj: Any) -> Any:
|
|
168
|
+
if isinstance(obj, Enum):
|
|
169
|
+
return obj.value
|
|
170
|
+
if isinstance(obj, list):
|
|
171
|
+
return [serialize(item) for item in obj]
|
|
172
|
+
if isinstance(obj, dict):
|
|
173
|
+
return {key: serialize(value) for key, value in obj.items()}
|
|
174
|
+
if isinstance(obj, BaseModel):
|
|
175
|
+
return serialize(obj.model_dump(exclude_none=True))
|
|
176
|
+
return obj
|
|
177
|
+
|
|
178
|
+
formatted_segments = []
|
|
179
|
+
for segment in self.flight_segments:
|
|
180
|
+
segment_filters = [
|
|
181
|
+
[
|
|
182
|
+
[
|
|
183
|
+
[serialize(airport[0]), serialize(airport[1])]
|
|
184
|
+
for airport in segment.departure_airport
|
|
185
|
+
]
|
|
186
|
+
],
|
|
187
|
+
[
|
|
188
|
+
[
|
|
189
|
+
[serialize(airport[0]), serialize(airport[1])]
|
|
190
|
+
for airport in segment.arrival_airport
|
|
191
|
+
]
|
|
192
|
+
],
|
|
193
|
+
]
|
|
194
|
+
|
|
195
|
+
time_filters = None
|
|
196
|
+
if segment.time_restrictions:
|
|
197
|
+
time_filters = [
|
|
198
|
+
segment.time_restrictions.earliest_departure,
|
|
199
|
+
segment.time_restrictions.latest_departure,
|
|
200
|
+
segment.time_restrictions.earliest_arrival,
|
|
201
|
+
segment.time_restrictions.latest_arrival,
|
|
202
|
+
]
|
|
203
|
+
|
|
204
|
+
airlines_filters = None
|
|
205
|
+
if self.airlines:
|
|
206
|
+
airlines_filters = sorted(self.airlines)
|
|
207
|
+
|
|
208
|
+
layover_airports = (
|
|
209
|
+
list(self.layover_restrictions.airports)
|
|
210
|
+
if self.layover_restrictions and self.layover_restrictions.airports
|
|
211
|
+
else None
|
|
212
|
+
)
|
|
213
|
+
layover_duration = (
|
|
214
|
+
self.layover_restrictions.max_duration if self.layover_restrictions else None
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
selected_flights = None
|
|
218
|
+
if self.trip_type == TripType.ROUND_TRIP and segment.selected_flight is not None:
|
|
219
|
+
selected_flights = [
|
|
220
|
+
[
|
|
221
|
+
leg.departure_airport,
|
|
222
|
+
leg.departure_datetime.strftime("%Y-%m-%d"),
|
|
223
|
+
leg.arrival_airport,
|
|
224
|
+
None,
|
|
225
|
+
leg.airline,
|
|
226
|
+
leg.flight_number,
|
|
227
|
+
]
|
|
228
|
+
for leg in segment.selected_flight.legs
|
|
229
|
+
]
|
|
230
|
+
|
|
231
|
+
segment_formatted = [
|
|
232
|
+
segment_filters[0],
|
|
233
|
+
segment_filters[1],
|
|
234
|
+
time_filters,
|
|
235
|
+
serialize(self.stops.value),
|
|
236
|
+
airlines_filters,
|
|
237
|
+
None,
|
|
238
|
+
segment.travel_date,
|
|
239
|
+
[self.max_duration] if self.max_duration else None,
|
|
240
|
+
selected_flights,
|
|
241
|
+
layover_airports,
|
|
242
|
+
None,
|
|
243
|
+
None,
|
|
244
|
+
layover_duration,
|
|
245
|
+
None,
|
|
246
|
+
3,
|
|
247
|
+
]
|
|
248
|
+
formatted_segments.append(segment_formatted)
|
|
249
|
+
|
|
250
|
+
filters = [
|
|
251
|
+
[],
|
|
252
|
+
[
|
|
253
|
+
None,
|
|
254
|
+
None,
|
|
255
|
+
serialize(self.trip_type.value),
|
|
256
|
+
None,
|
|
257
|
+
[],
|
|
258
|
+
serialize(self.seat_type.value),
|
|
259
|
+
[
|
|
260
|
+
self.passenger_info.adults,
|
|
261
|
+
self.passenger_info.children,
|
|
262
|
+
self.passenger_info.infants_on_lap,
|
|
263
|
+
self.passenger_info.infants_in_seat,
|
|
264
|
+
],
|
|
265
|
+
[None, self.price_limit.max_price] if self.price_limit else None,
|
|
266
|
+
None,
|
|
267
|
+
None,
|
|
268
|
+
None,
|
|
269
|
+
None,
|
|
270
|
+
None,
|
|
271
|
+
formatted_segments,
|
|
272
|
+
None,
|
|
273
|
+
None,
|
|
274
|
+
None,
|
|
275
|
+
1,
|
|
276
|
+
],
|
|
277
|
+
serialize(self.sort_by.value),
|
|
278
|
+
0,
|
|
279
|
+
0,
|
|
280
|
+
2,
|
|
281
|
+
]
|
|
282
|
+
|
|
283
|
+
return filters
|
|
284
|
+
|
|
285
|
+
def encode(self) -> str:
|
|
286
|
+
formatted_filters = self.format()
|
|
287
|
+
formatted_json = json.dumps(formatted_filters, separators=(",", ":"))
|
|
288
|
+
wrapped_filters = [None, formatted_json]
|
|
289
|
+
return urllib.parse.quote(json.dumps(wrapped_filters, separators=(",", ":")))
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
# ---------------------------------------------------------------------------
|
|
293
|
+
# HTTP client with rate limiting and retry
|
|
294
|
+
# ---------------------------------------------------------------------------
|
|
295
|
+
|
|
296
|
+
class GoogleConsentWallError(Exception):
|
|
297
|
+
"""Google returned a consent/cookie page instead of API data."""
|
|
298
|
+
pass
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
class RateLimitError(Exception):
|
|
302
|
+
"""Google returned a 429 or captcha response."""
|
|
303
|
+
pass
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
class Client:
|
|
307
|
+
DEFAULT_HEADERS = {
|
|
308
|
+
"content-type": "application/x-www-form-urlencoded;charset=UTF-8",
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
def __init__(self, proxy: str | None = None):
|
|
312
|
+
self._client = requests.Session()
|
|
313
|
+
self._client.headers.update(self.DEFAULT_HEADERS)
|
|
314
|
+
self._proxy = {"https": proxy, "http": proxy} if proxy else None
|
|
315
|
+
self._consecutive_429s = 0
|
|
316
|
+
self._backoff_delay = 0.0
|
|
317
|
+
|
|
318
|
+
def close(self) -> None:
|
|
319
|
+
self._client.close()
|
|
320
|
+
|
|
321
|
+
def __del__(self) -> None:
|
|
322
|
+
if hasattr(self, "_client"):
|
|
323
|
+
self._client.close()
|
|
324
|
+
|
|
325
|
+
@sleep_and_retry
|
|
326
|
+
@limits(calls=3, period=1)
|
|
327
|
+
def post(self, url: str, **kwargs: Any) -> requests.Response:
|
|
328
|
+
import time as _time
|
|
329
|
+
|
|
330
|
+
if self._backoff_delay > 0:
|
|
331
|
+
_time.sleep(self._backoff_delay)
|
|
332
|
+
|
|
333
|
+
if self._proxy:
|
|
334
|
+
kwargs.setdefault("proxies", self._proxy)
|
|
335
|
+
|
|
336
|
+
max_attempts = 4
|
|
337
|
+
for attempt in range(max_attempts):
|
|
338
|
+
try:
|
|
339
|
+
response = self._client.post(url, **kwargs)
|
|
340
|
+
except Exception:
|
|
341
|
+
if attempt == max_attempts - 1:
|
|
342
|
+
raise
|
|
343
|
+
_time.sleep(2 ** attempt)
|
|
344
|
+
continue
|
|
345
|
+
|
|
346
|
+
if response.status_code == 429:
|
|
347
|
+
self._consecutive_429s += 1
|
|
348
|
+
delay = min(2 ** self._consecutive_429s, 30)
|
|
349
|
+
if self._consecutive_429s >= 3:
|
|
350
|
+
self._backoff_delay = 5.0
|
|
351
|
+
_time.sleep(delay)
|
|
352
|
+
if attempt == max_attempts - 1:
|
|
353
|
+
raise RateLimitError(
|
|
354
|
+
f"Rate limited after {max_attempts} retries. "
|
|
355
|
+
"Reduce --workers or increase --delay."
|
|
356
|
+
)
|
|
357
|
+
continue
|
|
358
|
+
|
|
359
|
+
response.raise_for_status()
|
|
360
|
+
self._consecutive_429s = max(0, self._consecutive_429s - 1)
|
|
361
|
+
if self._consecutive_429s == 0:
|
|
362
|
+
self._backoff_delay = 0.0
|
|
363
|
+
return response
|
|
364
|
+
|
|
365
|
+
raise RateLimitError("Max retries exceeded")
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
# ---------------------------------------------------------------------------
|
|
369
|
+
# Flight search: request + response parsing
|
|
370
|
+
# ---------------------------------------------------------------------------
|
|
371
|
+
|
|
372
|
+
class SearchFlights:
|
|
373
|
+
BASE_URL = (
|
|
374
|
+
"https://www.google.com/_/FlightsFrontendUi/data/"
|
|
375
|
+
"travel.frontend.flights.FlightsFrontendService/GetShoppingResults"
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
def __init__(self, currency: str = "USD", proxy: str | None = None):
|
|
379
|
+
self.client = Client(proxy=proxy)
|
|
380
|
+
self.currency = currency
|
|
381
|
+
|
|
382
|
+
def close(self) -> None:
|
|
383
|
+
self.client.close()
|
|
384
|
+
|
|
385
|
+
def _build_url(self) -> str:
|
|
386
|
+
params = {"curr": self.currency, "hl": "en"}
|
|
387
|
+
return f"{self.BASE_URL}?{urllib.parse.urlencode(params)}"
|
|
388
|
+
|
|
389
|
+
def search(
|
|
390
|
+
self, filters: FlightSearchFilters, top_n: int = 5
|
|
391
|
+
) -> list[FlightResult] | None:
|
|
392
|
+
encoded_filters = filters.encode()
|
|
393
|
+
url = self._build_url()
|
|
394
|
+
|
|
395
|
+
response = self.client.post(
|
|
396
|
+
url=url,
|
|
397
|
+
data=f"f.req={encoded_filters}",
|
|
398
|
+
impersonate="chrome",
|
|
399
|
+
allow_redirects=True,
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
text = response.text
|
|
403
|
+
if "<html" in text[:500] or "consent.google.com" in text[:2000]:
|
|
404
|
+
raise GoogleConsentWallError(
|
|
405
|
+
"Google blocked this request (consent wall detected). "
|
|
406
|
+
"Use a residential internet connection or set --proxy."
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
parsed = json.loads(text.lstrip(")]}'"))[0][2]
|
|
410
|
+
if not parsed:
|
|
411
|
+
return None
|
|
412
|
+
|
|
413
|
+
data = json.loads(parsed)
|
|
414
|
+
flights_data = [
|
|
415
|
+
item
|
|
416
|
+
for i in [2, 3]
|
|
417
|
+
if isinstance(data[i], list)
|
|
418
|
+
for item in data[i][0]
|
|
419
|
+
]
|
|
420
|
+
flights = [self._parse_flight(f) for f in flights_data]
|
|
421
|
+
flights = self._deduplicate(flights)
|
|
422
|
+
|
|
423
|
+
if (
|
|
424
|
+
filters.trip_type == TripType.ONE_WAY
|
|
425
|
+
or filters.flight_segments[0].selected_flight is not None
|
|
426
|
+
):
|
|
427
|
+
return flights
|
|
428
|
+
|
|
429
|
+
# Round-trip: fetch return flights for top N outbound options
|
|
430
|
+
flight_pairs = []
|
|
431
|
+
for selected_flight in flights[:top_n]:
|
|
432
|
+
return_filters = deepcopy(filters)
|
|
433
|
+
return_filters.flight_segments[0].selected_flight = selected_flight
|
|
434
|
+
return_flights = self.search(return_filters, top_n=top_n)
|
|
435
|
+
if return_flights:
|
|
436
|
+
flight_pairs.extend(
|
|
437
|
+
(selected_flight, rf) for rf in return_flights
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
return flight_pairs if flight_pairs else flights
|
|
441
|
+
|
|
442
|
+
@staticmethod
|
|
443
|
+
def _deduplicate(flights: list[FlightResult]) -> list[FlightResult]:
|
|
444
|
+
seen: set[str] = set()
|
|
445
|
+
result = []
|
|
446
|
+
for f in flights:
|
|
447
|
+
key = "|".join(
|
|
448
|
+
f"{leg.airline}{leg.flight_number}:{leg.departure_airport}-{leg.arrival_airport}"
|
|
449
|
+
for leg in f.legs
|
|
450
|
+
)
|
|
451
|
+
if key not in seen:
|
|
452
|
+
seen.add(key)
|
|
453
|
+
result.append(f)
|
|
454
|
+
return result
|
|
455
|
+
|
|
456
|
+
@staticmethod
|
|
457
|
+
def _parse_flight(data: list) -> FlightResult:
|
|
458
|
+
return FlightResult(
|
|
459
|
+
price=SearchFlights._parse_price(data),
|
|
460
|
+
duration=data[0][9],
|
|
461
|
+
stops=len(data[0][2]) - 1,
|
|
462
|
+
legs=[
|
|
463
|
+
FlightLeg(
|
|
464
|
+
airline=fl[22][0],
|
|
465
|
+
flight_number=fl[22][1],
|
|
466
|
+
departure_airport=fl[3],
|
|
467
|
+
arrival_airport=fl[6],
|
|
468
|
+
departure_datetime=SearchFlights._parse_datetime(fl[20], fl[8]),
|
|
469
|
+
arrival_datetime=SearchFlights._parse_datetime(fl[21], fl[10]),
|
|
470
|
+
duration=fl[11],
|
|
471
|
+
)
|
|
472
|
+
for fl in data[0][2]
|
|
473
|
+
],
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
@staticmethod
|
|
477
|
+
def _parse_price(data: list) -> float:
|
|
478
|
+
try:
|
|
479
|
+
if data[1] and data[1][0]:
|
|
480
|
+
raw = data[1][0][-1]
|
|
481
|
+
if raw and raw > 0:
|
|
482
|
+
return float(raw)
|
|
483
|
+
except (IndexError, TypeError):
|
|
484
|
+
pass
|
|
485
|
+
return 0.0
|
|
486
|
+
|
|
487
|
+
@staticmethod
|
|
488
|
+
def _parse_datetime(date_arr: list[int], time_arr: list[int]) -> datetime:
|
|
489
|
+
return datetime(*(x or 0 for x in date_arr), *(x or 0 for x in time_arr))
|
opensky/airports.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Airport code resolution and city name lookup."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
import airportsdata
|
|
9
|
+
|
|
10
|
+
_db: dict[str, dict] | None = None
|
|
11
|
+
_city_index: dict[str, list[str]] | None = None
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _get_db() -> dict[str, dict]:
|
|
15
|
+
global _db
|
|
16
|
+
if _db is None:
|
|
17
|
+
_db = airportsdata.load("IATA")
|
|
18
|
+
return _db
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _get_city_index() -> dict[str, list[str]]:
|
|
22
|
+
"""Build a lowercase city name -> list of IATA codes index."""
|
|
23
|
+
global _city_index
|
|
24
|
+
if _city_index is None:
|
|
25
|
+
_city_index = {}
|
|
26
|
+
for iata, info in _get_db().items():
|
|
27
|
+
city = info.get("city", "").lower()
|
|
28
|
+
if city:
|
|
29
|
+
_city_index.setdefault(city, []).append(iata)
|
|
30
|
+
return _city_index
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _is_commercial(code: str) -> bool:
|
|
34
|
+
"""Filter out military bases and non-commercial airports."""
|
|
35
|
+
db = _get_db()
|
|
36
|
+
info = db.get(code, {})
|
|
37
|
+
name = info.get("name", "").lower()
|
|
38
|
+
# Filter military, RAF, AFB, heliports
|
|
39
|
+
skip = ("raf ", "afb ", "air force", "military", "heliport", "air base")
|
|
40
|
+
return not any(kw in name for kw in skip)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _pick_main_airport(codes: list[str], city: str) -> str | None:
|
|
44
|
+
"""If one airport is clearly the main commercial one, return it.
|
|
45
|
+
|
|
46
|
+
Filters non-commercial airports first. Among remaining, prefers
|
|
47
|
+
the airport whose name matches "[City] (International) Airport".
|
|
48
|
+
For multi-country matches (e.g. London UK vs London Canada),
|
|
49
|
+
picks the country with more airports (likely the major city).
|
|
50
|
+
"""
|
|
51
|
+
db = _get_db()
|
|
52
|
+
commercial = [c for c in codes if _is_commercial(c)]
|
|
53
|
+
if not commercial:
|
|
54
|
+
return None
|
|
55
|
+
if len(commercial) == 1:
|
|
56
|
+
return commercial[0]
|
|
57
|
+
|
|
58
|
+
# Group by country, pick the country with the most airports
|
|
59
|
+
by_country: dict[str, list[str]] = {}
|
|
60
|
+
for c in commercial:
|
|
61
|
+
country = db[c].get("country", "")
|
|
62
|
+
by_country.setdefault(country, []).append(c)
|
|
63
|
+
|
|
64
|
+
if len(by_country) > 1:
|
|
65
|
+
# Pick the country group with most airports (London UK > London Canada)
|
|
66
|
+
biggest = max(by_country.values(), key=len)
|
|
67
|
+
if len(biggest) == 1:
|
|
68
|
+
return biggest[0]
|
|
69
|
+
commercial = biggest
|
|
70
|
+
|
|
71
|
+
simple_pattern = re.compile(
|
|
72
|
+
rf"^{re.escape(city)}\s+(international\s+)?airport$", re.IGNORECASE,
|
|
73
|
+
)
|
|
74
|
+
candidates = [c for c in commercial if simple_pattern.match(db[c].get("name", ""))]
|
|
75
|
+
if len(candidates) == 1:
|
|
76
|
+
return candidates[0]
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _format_options(codes: list[str]) -> list[tuple[str, str]]:
|
|
81
|
+
"""Return (code, label) pairs for airport codes."""
|
|
82
|
+
db = _get_db()
|
|
83
|
+
return [
|
|
84
|
+
(c, f"{c} {db[c].get('name', c)} ({db[c].get('country', '')})")
|
|
85
|
+
for c in sorted(codes)
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _prompt_choice(codes: list[str], query: str) -> str:
|
|
90
|
+
"""Show numbered list and prompt user to pick an airport."""
|
|
91
|
+
options = _format_options(codes)
|
|
92
|
+
print(f"Multiple airports for '{query}':", file=sys.stderr)
|
|
93
|
+
for i, (code, label) in enumerate(options, 1):
|
|
94
|
+
print(f" {i}. {label}", file=sys.stderr)
|
|
95
|
+
while True:
|
|
96
|
+
try:
|
|
97
|
+
choice = input("Pick a number: ").strip()
|
|
98
|
+
except (EOFError, KeyboardInterrupt):
|
|
99
|
+
print(file=sys.stderr)
|
|
100
|
+
sys.exit(1)
|
|
101
|
+
if choice.isdigit() and 1 <= int(choice) <= len(options):
|
|
102
|
+
return options[int(choice) - 1][0]
|
|
103
|
+
print(f"Enter 1-{len(options)}.", file=sys.stderr)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _ambiguity_error(codes: list[str], query: str) -> ValueError:
|
|
107
|
+
"""Build a ValueError with suggestions for non-interactive mode."""
|
|
108
|
+
db = _get_db()
|
|
109
|
+
labels = ", ".join(f"{c} ({db[c].get('city', c)})" for c in sorted(codes)[:10])
|
|
110
|
+
return ValueError(f"Ambiguous airport '{query}'. Options: {labels}")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def resolve_airport(query: str, interactive: bool = True) -> str:
|
|
114
|
+
"""Resolve a city name or IATA code to an IATA code.
|
|
115
|
+
|
|
116
|
+
- 3-letter uppercase code in airportsdata: return as-is
|
|
117
|
+
- Otherwise: case-insensitive match against city names
|
|
118
|
+
- Exactly one match: return it
|
|
119
|
+
- Multiple matches from same city with one "main" airport: return it
|
|
120
|
+
- Multiple matches + interactive + tty: prompt user to pick
|
|
121
|
+
- Multiple matches otherwise: raise ValueError with suggestions
|
|
122
|
+
- No match: raise ValueError (interactive) or sys.exit (legacy)
|
|
123
|
+
"""
|
|
124
|
+
if not query or not query.strip():
|
|
125
|
+
raise ValueError("Airport or city name cannot be empty.")
|
|
126
|
+
|
|
127
|
+
db = _get_db()
|
|
128
|
+
|
|
129
|
+
# Direct IATA code (case-insensitive)
|
|
130
|
+
upper = query.upper()
|
|
131
|
+
if len(upper) == 3 and upper in db:
|
|
132
|
+
return upper
|
|
133
|
+
|
|
134
|
+
# Fuzzy match against city names
|
|
135
|
+
q = query.lower()
|
|
136
|
+
idx = _get_city_index()
|
|
137
|
+
|
|
138
|
+
# Exact city match first
|
|
139
|
+
if q in idx:
|
|
140
|
+
codes = idx[q]
|
|
141
|
+
if len(codes) == 1:
|
|
142
|
+
return codes[0]
|
|
143
|
+
# Try to pick the main commercial airport
|
|
144
|
+
main = _pick_main_airport(codes, query)
|
|
145
|
+
if main:
|
|
146
|
+
return main
|
|
147
|
+
# Show only commercial airports with city + country
|
|
148
|
+
commercial = [c for c in codes if _is_commercial(c)]
|
|
149
|
+
if not commercial:
|
|
150
|
+
commercial = codes
|
|
151
|
+
if interactive and sys.stdin.isatty():
|
|
152
|
+
return _prompt_choice(commercial, query)
|
|
153
|
+
raise _ambiguity_error(commercial, query)
|
|
154
|
+
|
|
155
|
+
# Substring match
|
|
156
|
+
matches: list[str] = []
|
|
157
|
+
for city, codes in idx.items():
|
|
158
|
+
if q in city:
|
|
159
|
+
matches.extend(codes)
|
|
160
|
+
|
|
161
|
+
if len(matches) == 1:
|
|
162
|
+
return matches[0]
|
|
163
|
+
|
|
164
|
+
if len(matches) > 1:
|
|
165
|
+
# Deduplicate while preserving order
|
|
166
|
+
seen: set[str] = set()
|
|
167
|
+
unique: list[str] = []
|
|
168
|
+
for code in matches:
|
|
169
|
+
if code not in seen:
|
|
170
|
+
seen.add(code)
|
|
171
|
+
unique.append(code)
|
|
172
|
+
if interactive and sys.stdin.isatty():
|
|
173
|
+
return _prompt_choice(unique[:10], query)
|
|
174
|
+
raise _ambiguity_error(unique, query)
|
|
175
|
+
|
|
176
|
+
raise ValueError(f"Unknown airport or city: {query}")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def city_name(iata: str) -> str:
|
|
180
|
+
"""Return city name for an IATA code, or the code itself if not found."""
|
|
181
|
+
db = _get_db()
|
|
182
|
+
info = db.get(iata)
|
|
183
|
+
return info["city"] if info and info.get("city") else iata
|