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 ADDED
@@ -0,0 +1,3 @@
1
+ """opensky - Find the cheapest, safest flights from your terminal."""
2
+
3
+ __version__ = "0.1.0"
opensky/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from opensky.cli import app
2
+
3
+ app()
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