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.
@@ -0,0 +1,122 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+
5
+ import httpx
6
+ from ratelimit import limits, sleep_and_retry
7
+
8
+ from opensky.models import FlightLeg, FlightResult
9
+ from opensky.providers import parse_iso_duration
10
+
11
+ log = logging.getLogger(__name__)
12
+
13
+ API_BASE = "https://api.duffel.com"
14
+
15
+ CABIN_MAP: dict[str, str] = {
16
+ "economy": "economy",
17
+ "premium_economy": "premium_economy",
18
+ "business": "business",
19
+ "first": "first",
20
+ }
21
+
22
+
23
+ def _convert_offer(offer: dict, currency: str) -> FlightResult:
24
+ """Convert a Duffel offer dict to a FlightResult."""
25
+ price = float(offer.get("total_amount", 0))
26
+ offer_currency = offer.get("total_currency", currency)
27
+
28
+ legs: list[FlightLeg] = []
29
+ total_duration = 0
30
+
31
+ for slc in offer.get("slices", []):
32
+ for seg in slc.get("segments", []):
33
+ dep = seg.get("departing_at", "")
34
+ arr = seg.get("arriving_at", "")
35
+ dur = parse_iso_duration(seg.get("duration", ""))
36
+ total_duration += dur
37
+
38
+ carrier = seg.get("operating_carrier", {})
39
+ airline = carrier.get("iata_code", seg.get("marketing_carrier", {}).get("iata_code", ""))
40
+ flight_num = seg.get("operating_carrier_flight_number", seg.get("marketing_carrier_flight_number", ""))
41
+
42
+ legs.append(FlightLeg(
43
+ airline=airline,
44
+ flight_number=flight_num,
45
+ departure_airport=seg.get("origin", {}).get("iata_code", ""),
46
+ arrival_airport=seg.get("destination", {}).get("iata_code", ""),
47
+ departure_time=dep,
48
+ arrival_time=arr,
49
+ duration_minutes=dur,
50
+ ))
51
+
52
+ return FlightResult(
53
+ price=price,
54
+ currency=offer_currency,
55
+ duration_minutes=total_duration,
56
+ stops=max(len(legs) - 1, 0),
57
+ legs=legs,
58
+ provider="duffel",
59
+ )
60
+
61
+
62
+ class DuffelProvider:
63
+ name = "duffel"
64
+
65
+ def __init__(self, token: str):
66
+ self._token = token
67
+ self._client = httpx.Client(
68
+ base_url=API_BASE,
69
+ headers={
70
+ "Authorization": f"Bearer {token}",
71
+ "Duffel-Version": "v2",
72
+ "Content-Type": "application/json",
73
+ "Accept": "application/json",
74
+ },
75
+ timeout=30.0,
76
+ )
77
+
78
+ @sleep_and_retry
79
+ @limits(calls=10, period=1)
80
+ def search(
81
+ self,
82
+ origin: str,
83
+ dest: str,
84
+ date: str,
85
+ cabin: str,
86
+ currency: str,
87
+ max_stops: int | None,
88
+ ) -> list[FlightResult]:
89
+ cabin_class = CABIN_MAP.get(cabin, "economy")
90
+
91
+ payload = {
92
+ "data": {
93
+ "slices": [
94
+ {
95
+ "origin": origin,
96
+ "destination": dest,
97
+ "departure_date": date,
98
+ }
99
+ ],
100
+ "passengers": [{"type": "adult"}],
101
+ "cabin_class": cabin_class,
102
+ "currency": currency,
103
+ "return_offers": True,
104
+ "max_connections": max_stops if max_stops is not None else 2,
105
+ }
106
+ }
107
+
108
+ resp = self._client.post("/air/offer_requests", json=payload)
109
+ resp.raise_for_status()
110
+ data = resp.json().get("data", {})
111
+ offers = data.get("offers", [])
112
+
113
+ results = [_convert_offer(o, currency) for o in offers]
114
+
115
+ # Client-side stops filter (Duffel max_connections is 0-2)
116
+ if max_stops is not None:
117
+ results = [r for r in results if r.stops <= max_stops]
118
+
119
+ return results
120
+
121
+ def close(self) -> None:
122
+ self._client.close()
@@ -0,0 +1,111 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from opensky._vendor.google_flights import (
6
+ FlightSearchFilters,
7
+ FlightSegment,
8
+ MaxStops,
9
+ PassengerInfo,
10
+ SeatType,
11
+ SearchFlights,
12
+ SortBy,
13
+ )
14
+ from opensky.models import FlightLeg, FlightResult
15
+
16
+ SEAT_MAP: dict[str, SeatType] = {
17
+ "economy": SeatType.ECONOMY,
18
+ "premium_economy": SeatType.PREMIUM_ECONOMY,
19
+ "premium": SeatType.PREMIUM_ECONOMY,
20
+ "business": SeatType.BUSINESS,
21
+ "first": SeatType.FIRST,
22
+ }
23
+
24
+ STOPS_MAP: dict[int | None, MaxStops] = {
25
+ None: MaxStops.ANY,
26
+ 0: MaxStops.NON_STOP,
27
+ 1: MaxStops.ONE_STOP_OR_FEWER,
28
+ 2: MaxStops.TWO_OR_FEWER_STOPS,
29
+ }
30
+
31
+
32
+ def _build_filters(
33
+ origin: str,
34
+ dest: str,
35
+ date: str,
36
+ seat: SeatType = SeatType.ECONOMY,
37
+ stops: MaxStops = MaxStops.ANY,
38
+ ) -> FlightSearchFilters:
39
+ return FlightSearchFilters(
40
+ flight_segments=[
41
+ FlightSegment(
42
+ departure_airport=[[origin, 0]],
43
+ arrival_airport=[[dest, 0]],
44
+ travel_date=date,
45
+ )
46
+ ],
47
+ passenger_info=PassengerInfo(adults=1),
48
+ seat_type=seat,
49
+ stops=stops,
50
+ sort_by=SortBy.CHEAPEST,
51
+ )
52
+
53
+
54
+ def _convert_result(raw: Any, currency: str) -> FlightResult:
55
+ legs = []
56
+ for leg in raw.legs:
57
+ legs.append(FlightLeg(
58
+ airline=leg.airline,
59
+ flight_number=leg.flight_number,
60
+ departure_airport=leg.departure_airport,
61
+ arrival_airport=leg.arrival_airport,
62
+ departure_time=leg.departure_datetime.isoformat(),
63
+ arrival_time=leg.arrival_datetime.isoformat(),
64
+ duration_minutes=leg.duration,
65
+ ))
66
+
67
+ return FlightResult(
68
+ price=raw.price,
69
+ currency=currency,
70
+ duration_minutes=raw.duration,
71
+ stops=raw.stops,
72
+ legs=legs,
73
+ provider="google",
74
+ )
75
+
76
+
77
+ class GoogleProvider:
78
+ name = "google"
79
+
80
+ def __init__(self, currency: str = "EUR", proxy: str | None = None):
81
+ self._currency = currency
82
+ self._proxy = proxy
83
+ self._api: SearchFlights | None = None
84
+
85
+ def _get_api(self) -> SearchFlights:
86
+ if self._api is None:
87
+ self._api = SearchFlights(currency=self._currency, proxy=self._proxy)
88
+ return self._api
89
+
90
+ def search(
91
+ self,
92
+ origin: str,
93
+ dest: str,
94
+ date: str,
95
+ cabin: str,
96
+ currency: str,
97
+ max_stops: int | None,
98
+ ) -> list[FlightResult]:
99
+ seat = SEAT_MAP.get(cabin, SeatType.ECONOMY)
100
+ stops = STOPS_MAP.get(max_stops, MaxStops.ANY)
101
+ filters = _build_filters(origin, dest, date, seat, stops)
102
+ api = self._get_api()
103
+ raw_results = api.search(filters)
104
+ if not raw_results:
105
+ return []
106
+ return [_convert_result(r, currency) for r in raw_results]
107
+
108
+ def close(self) -> None:
109
+ if self._api:
110
+ self._api.close()
111
+ self._api = None
opensky/safety.py ADDED
@@ -0,0 +1,138 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import time
5
+ from pathlib import Path
6
+
7
+ import airportsdata
8
+
9
+ from opensky.models import ConflictZone, FlaggedAirport, RiskAssessment, RiskLevel
10
+
11
+ _BUNDLED_PATH = Path(__file__).parent / "data" / "conflict_zones.json"
12
+ _CACHE_PATH = Path.home() / ".cache" / "opensky" / "conflict_zones.json"
13
+ _CACHE_MAX_AGE = 7 * 24 * 3600 # 7 days
14
+
15
+ _airports_db: dict[str, dict] | None = None
16
+ _zones: list[ConflictZone] | None = None
17
+ _country_risk: dict[str, tuple[RiskLevel, str]] | None = None
18
+ _airport_risk: dict[str, tuple[RiskLevel, str]] | None = None
19
+
20
+
21
+ def _get_airports_db() -> dict[str, dict]:
22
+ global _airports_db
23
+ if _airports_db is None:
24
+ _airports_db = airportsdata.load("IATA")
25
+ return _airports_db
26
+
27
+
28
+ def airport_country(iata: str) -> str | None:
29
+ db = _get_airports_db()
30
+ info = db.get(iata)
31
+ return info["country"] if info else None
32
+
33
+
34
+ def _load_zones_from_file(path: Path) -> list[ConflictZone]:
35
+ data = json.loads(path.read_text())
36
+ return [ConflictZone(**z) for z in data["zones"]]
37
+
38
+
39
+ def load_zones(force_bundled: bool = False) -> list[ConflictZone]:
40
+ global _zones, _country_risk, _airport_risk
41
+
42
+ if _zones is not None and not force_bundled:
43
+ return _zones
44
+
45
+ # Prefer cached version if fresh
46
+ source = _BUNDLED_PATH
47
+ if not force_bundled and _CACHE_PATH.exists():
48
+ age = time.time() - _CACHE_PATH.stat().st_mtime
49
+ if age < _CACHE_MAX_AGE:
50
+ source = _CACHE_PATH
51
+
52
+ _zones = _load_zones_from_file(source)
53
+
54
+ # Build lookup tables
55
+ _country_risk = {}
56
+ _airport_risk = {}
57
+ for zone in _zones:
58
+ rl = zone.risk_level
59
+ for cc in zone.countries:
60
+ existing = _country_risk.get(cc)
61
+ if existing is None or rl > existing[0]:
62
+ _country_risk[cc] = (rl, zone.name)
63
+ for ap in zone.airports:
64
+ existing = _airport_risk.get(ap)
65
+ if existing is None or rl > existing[0]:
66
+ _airport_risk[ap] = (rl, zone.name)
67
+
68
+ return _zones
69
+
70
+
71
+ def check_route(airports: list[str]) -> RiskAssessment:
72
+ load_zones()
73
+ assert _country_risk is not None and _airport_risk is not None
74
+
75
+ worst = RiskLevel.SAFE
76
+ flagged: list[FlaggedAirport] = []
77
+
78
+ for code in airports:
79
+ # Check specific airport first
80
+ ap_entry = _airport_risk.get(code)
81
+ if ap_entry:
82
+ rl, zone_name = ap_entry
83
+ country = airport_country(code) or "??"
84
+ flagged.append(FlaggedAirport(
85
+ code=code, country=country, zone_name=zone_name, risk_level=rl
86
+ ))
87
+ if rl > worst:
88
+ worst = rl
89
+ continue
90
+
91
+ # Check country
92
+ country = airport_country(code)
93
+ if country:
94
+ cc_entry = _country_risk.get(country)
95
+ if cc_entry:
96
+ rl, zone_name = cc_entry
97
+ flagged.append(FlaggedAirport(
98
+ code=code, country=country, zone_name=zone_name, risk_level=rl
99
+ ))
100
+ if rl > worst:
101
+ worst = rl
102
+
103
+ return RiskAssessment(risk_level=worst, flagged_airports=flagged)
104
+
105
+
106
+ def zones_age_warning() -> str | None:
107
+ """Return a warning string if the conflict zone data is stale, else None."""
108
+ load_zones()
109
+
110
+ # Check if using cached version
111
+ if _CACHE_PATH.exists():
112
+ age_days = (time.time() - _CACHE_PATH.stat().st_mtime) / 86400
113
+ if age_days > 7:
114
+ return f"Conflict zone data is {int(age_days)} days old. Run `opensky zones --update` to refresh."
115
+ return None
116
+
117
+ # Using bundled -- check its age from metadata
118
+ data = json.loads(_BUNDLED_PATH.read_text())
119
+ updated = data.get("metadata", {}).get("updated", "")
120
+ if updated:
121
+ from datetime import datetime
122
+ try:
123
+ updated_dt = datetime.strptime(updated, "%Y-%m-%d")
124
+ age_days = (datetime.now() - updated_dt).days
125
+ if age_days > 14:
126
+ return f"Bundled conflict zone data is {age_days} days old. Run `opensky zones --update` to refresh."
127
+ except ValueError:
128
+ pass
129
+
130
+ return None
131
+
132
+
133
+ def save_cached_zones(data: str) -> None:
134
+ _CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
135
+ _CACHE_PATH.write_text(data)
136
+ # Force reload
137
+ global _zones
138
+ _zones = None
opensky/search.py ADDED
@@ -0,0 +1,280 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import time
5
+ from collections.abc import Callable
6
+ from concurrent.futures import ThreadPoolExecutor, as_completed
7
+
8
+ from opensky import cache
9
+ from opensky.config import ScanConfig
10
+ from opensky.models import FlightLeg, FlightResult, RiskLevel, ScoredFlight
11
+ from opensky.providers import FlightProvider, configured_providers
12
+ from opensky.safety import check_route
13
+
14
+ log = logging.getLogger(__name__)
15
+
16
+
17
+ class _Unset:
18
+ """Sentinel for distinguishing 'not provided' from None."""
19
+
20
+ _UNSET = _Unset()
21
+
22
+ STOPS_INT: dict[str, int | None] = {
23
+ "any": None,
24
+ "non_stop": 0,
25
+ "one_stop_or_fewer": 1,
26
+ "two_or_fewer_stops": 2,
27
+ }
28
+
29
+
30
+ def _deduplicate(results: list[FlightResult]) -> list[FlightResult]:
31
+ """Deduplicate flights across providers.
32
+
33
+ Key = airline+flight_number per leg + departure date.
34
+ Same flight from two providers: keep the one with the lowest price
35
+ (prefer priced over unpriced).
36
+ """
37
+ best: dict[str, FlightResult] = {}
38
+ for flight in results:
39
+ if not flight.legs:
40
+ continue
41
+ dep_date = flight.legs[0].departure_time[:10] if flight.legs[0].departure_time else ""
42
+ key = dep_date + "|" + "|".join(
43
+ f"{leg.airline}{leg.flight_number}:{leg.departure_airport}-{leg.arrival_airport}"
44
+ for leg in flight.legs
45
+ )
46
+ existing = best.get(key)
47
+ if existing is None:
48
+ best[key] = flight
49
+ else:
50
+ # Prefer priced over unpriced; then cheapest
51
+ if existing.price <= 0 and flight.price > 0:
52
+ best[key] = flight
53
+ elif flight.price > 0 and (existing.price <= 0 or flight.price < existing.price):
54
+ best[key] = flight
55
+ return list(best.values())
56
+
57
+
58
+ def _route_string(origin: str, dest: str, legs: list[FlightLeg]) -> str:
59
+ from opensky.airports import city_name
60
+
61
+ airports = [origin]
62
+ for leg in legs:
63
+ if leg.departure_airport != airports[-1]:
64
+ airports.append(leg.departure_airport)
65
+ airports.append(leg.arrival_airport)
66
+ # Deduplicate consecutive
67
+ deduped = [airports[0]]
68
+ for a in airports[1:]:
69
+ if a != deduped[-1]:
70
+ deduped.append(a)
71
+ return " → ".join(city_name(code) for code in deduped)
72
+
73
+
74
+ def _all_airports(legs: list[FlightLeg]) -> list[str]:
75
+ airports = []
76
+ for leg in legs:
77
+ airports.append(leg.departure_airport)
78
+ airports.append(leg.arrival_airport)
79
+ return list(dict.fromkeys(airports))
80
+
81
+
82
+ class SearchEngine:
83
+ def __init__(
84
+ self,
85
+ currency: str = "EUR",
86
+ proxy: str | None = None,
87
+ use_cache: bool = True,
88
+ seat: str = "economy",
89
+ stops: str = "any",
90
+ provider: str | None = None,
91
+ ):
92
+ self.currency = currency
93
+ self.use_cache = use_cache
94
+ self.seat = seat
95
+ self.max_stops = STOPS_INT.get(stops, None)
96
+ self._providers: list[FlightProvider] = configured_providers(
97
+ currency=currency, proxy=proxy, only=provider,
98
+ )
99
+
100
+ def close(self) -> None:
101
+ for p in self._providers:
102
+ try:
103
+ p.close()
104
+ except Exception:
105
+ pass
106
+ self._providers = []
107
+
108
+ def search_one(
109
+ self,
110
+ origin: str,
111
+ dest: str,
112
+ date: str,
113
+ ) -> list[FlightResult]:
114
+ all_results: list[FlightResult] = []
115
+
116
+ for provider in self._providers:
117
+ ck = cache.cache_key(
118
+ origin, dest, date,
119
+ seat=self.seat, currency=self.currency,
120
+ stops=self.max_stops, provider=provider.name,
121
+ )
122
+
123
+ if self.use_cache:
124
+ cached = cache.get(ck)
125
+ if cached is not None:
126
+ all_results.extend(cached)
127
+ continue
128
+
129
+ try:
130
+ results = provider.search(
131
+ origin, dest, date,
132
+ cabin=self.seat,
133
+ currency=self.currency,
134
+ max_stops=self.max_stops,
135
+ )
136
+ except Exception as e:
137
+ log.warning("Provider %s failed for %s->%s %s: %s", provider.name, origin, dest, date, e)
138
+ continue
139
+
140
+ if self.use_cache:
141
+ cache.put(ck, results)
142
+
143
+ all_results.extend(results)
144
+
145
+ if len(self._providers) > 1:
146
+ all_results = _deduplicate(all_results)
147
+
148
+ return all_results
149
+
150
+ def search_scored(
151
+ self,
152
+ origin: str,
153
+ dest: str,
154
+ date: str,
155
+ risk_threshold: RiskLevel | None = RiskLevel.HIGH_RISK,
156
+ price_weight: float = 1.0,
157
+ duration_weight: float = 0.5,
158
+ transit_hours: dict[str, float] | None = None,
159
+ max_price: float = 0,
160
+ ) -> list[ScoredFlight]:
161
+ results = self.search_one(origin, dest, date)
162
+ scored = []
163
+
164
+ for flight in results:
165
+ if max_price > 0 and flight.price > 0 and flight.price > max_price:
166
+ continue
167
+
168
+ airports = _all_airports(flight.legs)
169
+ risk = check_route(airports)
170
+
171
+ # Skip flights at or above the risk threshold
172
+ if risk_threshold is not None and risk.risk_level >= risk_threshold:
173
+ continue
174
+
175
+ route = _route_string(origin, dest, flight.legs)
176
+ th = (transit_hours or {}).get(dest, 0.0)
177
+ total_hours = (flight.duration_minutes / 60) + th
178
+ price = flight.price if flight.price > 0 else 99999
179
+ score = (price_weight * price) + (duration_weight * total_hours)
180
+
181
+ sf = ScoredFlight(
182
+ flight=flight,
183
+ origin=origin,
184
+ destination=dest,
185
+ date=date,
186
+ route=route,
187
+ risk=risk,
188
+ score=score,
189
+ transit_hours=th,
190
+ )
191
+ scored.append(sf)
192
+
193
+ return scored
194
+
195
+ def scan(
196
+ self,
197
+ config: ScanConfig,
198
+ workers: int = 3,
199
+ delay: float = 1.0,
200
+ on_progress: Callable[[int, int, int], None] | None = None,
201
+ on_result: Callable[[ScoredFlight], None] | None = None,
202
+ risk_threshold_override: RiskLevel | None | _Unset = _UNSET,
203
+ ) -> list[ScoredFlight]:
204
+ dates = config.search.date_range.dates()
205
+ combos = [
206
+ (o, d, dt)
207
+ for o in config.search.origins
208
+ for d in config.search.destinations
209
+ for dt in dates
210
+ ]
211
+ total = len(combos)
212
+ if risk_threshold_override is not _UNSET:
213
+ risk_threshold = risk_threshold_override
214
+ else:
215
+ risk_threshold = RiskLevel(config.safety.risk_threshold)
216
+ transit_hours = config.connections.transit_hours
217
+ pw = config.scoring.price_weight
218
+ dw = config.scoring.duration_weight
219
+ mp = config.search.max_price
220
+
221
+ all_scored: list[ScoredFlight] = []
222
+ completed = 0
223
+ errors = 0
224
+ consecutive_errors = 0
225
+ effective_delay = delay
226
+
227
+ def _search_combo(combo: tuple[str, str, str]) -> tuple[str, str, str, list[ScoredFlight] | None]:
228
+ origin, dest, date = combo
229
+ try:
230
+ results = self.search_scored(
231
+ origin, dest, date,
232
+ risk_threshold=risk_threshold,
233
+ price_weight=pw,
234
+ duration_weight=dw,
235
+ transit_hours=transit_hours,
236
+ max_price=mp,
237
+ )
238
+ return (origin, dest, date, results)
239
+ except Exception as e:
240
+ log.warning("Search failed for %s->%s %s: %s", origin, dest, date, e)
241
+ return (origin, dest, date, None)
242
+
243
+ with ThreadPoolExecutor(max_workers=workers) as executor:
244
+ i = 0
245
+ while i < total:
246
+ batch = combos[i:i + workers]
247
+ futures = {executor.submit(_search_combo, c): c for c in batch}
248
+
249
+ for future in as_completed(futures):
250
+ origin, dest, date, results = future.result()
251
+ completed += 1
252
+
253
+ if results is None:
254
+ errors += 1
255
+ consecutive_errors += 1
256
+ if consecutive_errors >= 3:
257
+ effective_delay = min(effective_delay * 2, 10.0)
258
+ log.warning(
259
+ "Increasing delay to %.1fs after %d consecutive errors",
260
+ effective_delay, consecutive_errors,
261
+ )
262
+ else:
263
+ if consecutive_errors > 0:
264
+ consecutive_errors = 0
265
+ effective_delay = max(effective_delay * 0.8, delay)
266
+ all_scored.extend(results)
267
+
268
+ if on_progress:
269
+ on_progress(completed, total, errors)
270
+
271
+ if results and on_result:
272
+ for sf in results:
273
+ on_result(sf)
274
+
275
+ i += len(batch)
276
+ if i < total:
277
+ time.sleep(effective_delay)
278
+
279
+ all_scored.sort(key=lambda x: x.score)
280
+ return all_scored