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/display.py
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import csv
|
|
4
|
+
import io
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
from rich.box import ROUNDED
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.progress import BarColumn, MofNCompleteColumn, Progress, TextColumn, TimeRemainingColumn
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
from rich.text import Text as RichText
|
|
12
|
+
|
|
13
|
+
from opensky.airports import city_name
|
|
14
|
+
from opensky.models import ConflictZone, RiskLevel, ScoredFlight
|
|
15
|
+
|
|
16
|
+
console = Console()
|
|
17
|
+
|
|
18
|
+
RISK_COLORS = {
|
|
19
|
+
RiskLevel.SAFE: "green",
|
|
20
|
+
RiskLevel.CAUTION: "yellow",
|
|
21
|
+
RiskLevel.HIGH_RISK: "red",
|
|
22
|
+
RiskLevel.DO_NOT_FLY: "bold red",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
RISK_LABELS = {
|
|
26
|
+
RiskLevel.SAFE: "Safe",
|
|
27
|
+
RiskLevel.CAUTION: "Caution",
|
|
28
|
+
RiskLevel.HIGH_RISK: "Risky",
|
|
29
|
+
RiskLevel.DO_NOT_FLY: "Avoid",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def format_duration(minutes: int) -> str:
|
|
34
|
+
h, m = divmod(minutes, 60)
|
|
35
|
+
return f"{h}h {m}m" if m else f"{h}h"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def format_price(price: float, currency: str) -> str:
|
|
39
|
+
if price <= 0:
|
|
40
|
+
return "[dim]--[/dim]"
|
|
41
|
+
symbols = {"EUR": "\u20ac", "USD": "$", "GBP": "\u00a3", "INR": "\u20b9"}
|
|
42
|
+
sym = symbols.get(currency, currency + " ")
|
|
43
|
+
return f"{sym}{price:,.0f}"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _format_flight_numbers(legs: list) -> str:
|
|
47
|
+
return ", ".join(f"{leg.airline} {leg.flight_number}" for leg in legs)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _friendly_date(iso_date: str) -> str:
|
|
51
|
+
"""Convert '2026-03-10' to 'Tue Mar 10'."""
|
|
52
|
+
from datetime import date
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
d = date.fromisoformat(iso_date)
|
|
56
|
+
return f"{d.strftime('%a')} {d.strftime('%b')} {d.day}"
|
|
57
|
+
except (ValueError, TypeError):
|
|
58
|
+
return iso_date
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _safety_cell(sf: ScoredFlight) -> str:
|
|
62
|
+
risk_color = RISK_COLORS[sf.risk.risk_level]
|
|
63
|
+
label = RISK_LABELS[sf.risk.risk_level]
|
|
64
|
+
|
|
65
|
+
if sf.risk.flagged_airports:
|
|
66
|
+
flagged = ", ".join(city_name(a.code) for a in sf.risk.flagged_airports)
|
|
67
|
+
return f"[{risk_color}]{label}[/{risk_color}] [dim]{flagged}[/dim]"
|
|
68
|
+
|
|
69
|
+
return f"[{risk_color}]{label}[/{risk_color}]"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def flights_table(
|
|
73
|
+
flights: list[ScoredFlight],
|
|
74
|
+
title: str = "Flights",
|
|
75
|
+
show_count: bool = True,
|
|
76
|
+
) -> None:
|
|
77
|
+
if not flights:
|
|
78
|
+
console.print("[dim]No flights found.[/dim]")
|
|
79
|
+
return
|
|
80
|
+
|
|
81
|
+
# Check if all flights share the same date (single search vs scan)
|
|
82
|
+
dates = {sf.date for sf in flights}
|
|
83
|
+
show_date = len(dates) > 1
|
|
84
|
+
|
|
85
|
+
# Show source column when results come from multiple providers
|
|
86
|
+
providers = {sf.flight.provider for sf in flights if sf.flight.provider}
|
|
87
|
+
show_source = len(providers) > 1
|
|
88
|
+
|
|
89
|
+
# Only show safety column when there's something worth flagging
|
|
90
|
+
risk_levels = {sf.risk.risk_level for sf in flights}
|
|
91
|
+
show_safety = risk_levels != {RiskLevel.SAFE}
|
|
92
|
+
|
|
93
|
+
table = Table(title=title, box=ROUNDED, show_lines=False, pad_edge=True, title_style="bold")
|
|
94
|
+
table.add_column("Price", justify="right", style="bold green")
|
|
95
|
+
table.add_column("Duration", justify="right")
|
|
96
|
+
table.add_column("Stops", justify="center", style="dim")
|
|
97
|
+
if show_date:
|
|
98
|
+
table.add_column("Date")
|
|
99
|
+
table.add_column("Route")
|
|
100
|
+
table.add_column("Flight #", style="dim")
|
|
101
|
+
if show_source:
|
|
102
|
+
table.add_column("Source", style="dim")
|
|
103
|
+
if show_safety:
|
|
104
|
+
table.add_column("Safety", justify="center")
|
|
105
|
+
|
|
106
|
+
for sf in flights:
|
|
107
|
+
transit_note = ""
|
|
108
|
+
if sf.transit_hours > 0:
|
|
109
|
+
transit_note = f" [dim]+{sf.transit_hours:.1f}h[/dim]"
|
|
110
|
+
|
|
111
|
+
flights_col = _format_flight_numbers(sf.flight.legs)
|
|
112
|
+
|
|
113
|
+
row = [
|
|
114
|
+
format_price(sf.flight.price, sf.flight.currency),
|
|
115
|
+
format_duration(sf.flight.duration_minutes) + transit_note,
|
|
116
|
+
str(sf.flight.stops),
|
|
117
|
+
]
|
|
118
|
+
if show_date:
|
|
119
|
+
row.append(_friendly_date(sf.date))
|
|
120
|
+
row += [sf.route, flights_col]
|
|
121
|
+
if show_source:
|
|
122
|
+
row.append(sf.flight.provider or "-")
|
|
123
|
+
if show_safety:
|
|
124
|
+
row.append(_safety_cell(sf))
|
|
125
|
+
table.add_row(*row)
|
|
126
|
+
|
|
127
|
+
console.print(table)
|
|
128
|
+
if show_count:
|
|
129
|
+
console.print(f"[dim]{len(flights)} flights[/dim]")
|
|
130
|
+
if any(sf.flight.price <= 0 for sf in flights):
|
|
131
|
+
console.print("[dim]-- = price not available[/dim]")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def flights_json(flights: list[ScoredFlight]) -> str:
|
|
135
|
+
return json.dumps(
|
|
136
|
+
[sf.model_dump() for sf in flights],
|
|
137
|
+
indent=2,
|
|
138
|
+
default=str,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def flights_csv(flights: list[ScoredFlight]) -> str:
|
|
143
|
+
buf = io.StringIO()
|
|
144
|
+
writer = csv.writer(buf)
|
|
145
|
+
writer.writerow([
|
|
146
|
+
"price", "currency", "duration_min", "stops", "date",
|
|
147
|
+
"origin", "destination", "route", "flights",
|
|
148
|
+
"provider", "risk_level", "score",
|
|
149
|
+
])
|
|
150
|
+
for sf in flights:
|
|
151
|
+
writer.writerow([
|
|
152
|
+
sf.flight.price,
|
|
153
|
+
sf.flight.currency,
|
|
154
|
+
sf.flight.duration_minutes,
|
|
155
|
+
sf.flight.stops,
|
|
156
|
+
sf.date,
|
|
157
|
+
sf.origin,
|
|
158
|
+
sf.destination,
|
|
159
|
+
sf.route,
|
|
160
|
+
_format_flight_numbers(sf.flight.legs),
|
|
161
|
+
sf.flight.provider,
|
|
162
|
+
sf.risk.risk_level.value,
|
|
163
|
+
f"{sf.score:.1f}",
|
|
164
|
+
])
|
|
165
|
+
return buf.getvalue()
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _scan_stats(flights: list[ScoredFlight], currency: str) -> None:
|
|
169
|
+
if not flights:
|
|
170
|
+
console.print("[dim]No flights found.[/dim]")
|
|
171
|
+
return
|
|
172
|
+
|
|
173
|
+
destinations = {sf.destination for sf in flights}
|
|
174
|
+
origins = {sf.origin for sf in flights}
|
|
175
|
+
dates = {sf.date for sf in flights}
|
|
176
|
+
priced = [sf.flight.price for sf in flights if sf.flight.price > 0]
|
|
177
|
+
|
|
178
|
+
parts = [
|
|
179
|
+
f"[bold]{len(flights)}[/bold] flights across",
|
|
180
|
+
f"[bold]{len(destinations)}[/bold] destinations,",
|
|
181
|
+
f"[bold]{len(origins)}[/bold] origins,",
|
|
182
|
+
f"[bold]{len(dates)}[/bold] dates.",
|
|
183
|
+
]
|
|
184
|
+
if priced:
|
|
185
|
+
lo_val, hi_val = min(priced), max(priced)
|
|
186
|
+
lo = format_price(lo_val, currency)
|
|
187
|
+
if lo_val == hi_val:
|
|
188
|
+
parts.append(f"Price: {lo}")
|
|
189
|
+
else:
|
|
190
|
+
hi = format_price(hi_val, currency)
|
|
191
|
+
parts.append(f"Price range: {lo}-{hi}")
|
|
192
|
+
|
|
193
|
+
console.print(" ".join(parts))
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _best_per_destination(flights: list[ScoredFlight]) -> None:
|
|
197
|
+
if not flights:
|
|
198
|
+
return
|
|
199
|
+
|
|
200
|
+
best: dict[str, ScoredFlight] = {}
|
|
201
|
+
for sf in flights:
|
|
202
|
+
if sf.destination not in best or sf.score < best[sf.destination].score:
|
|
203
|
+
best[sf.destination] = sf
|
|
204
|
+
|
|
205
|
+
risk_levels = {sf.risk.risk_level for sf in best.values()}
|
|
206
|
+
show_safety = risk_levels != {RiskLevel.SAFE}
|
|
207
|
+
|
|
208
|
+
table = Table(title="Best per Destination", box=ROUNDED, show_lines=False, pad_edge=True, title_style="bold")
|
|
209
|
+
table.add_column("Destination", style="bold")
|
|
210
|
+
table.add_column("Price", justify="right", style="bold green")
|
|
211
|
+
table.add_column("Date")
|
|
212
|
+
table.add_column("Duration", justify="right")
|
|
213
|
+
table.add_column("Route")
|
|
214
|
+
if show_safety:
|
|
215
|
+
table.add_column("Safety", justify="center")
|
|
216
|
+
|
|
217
|
+
for dest in sorted(best):
|
|
218
|
+
sf = best[dest]
|
|
219
|
+
transit_note = ""
|
|
220
|
+
if sf.transit_hours > 0:
|
|
221
|
+
transit_note = f" [dim]+{sf.transit_hours:.1f}h[/dim]"
|
|
222
|
+
|
|
223
|
+
row = [
|
|
224
|
+
city_name(dest),
|
|
225
|
+
format_price(sf.flight.price, sf.flight.currency),
|
|
226
|
+
_friendly_date(sf.date),
|
|
227
|
+
format_duration(sf.flight.duration_minutes) + transit_note,
|
|
228
|
+
sf.route,
|
|
229
|
+
]
|
|
230
|
+
if show_safety:
|
|
231
|
+
row.append(_safety_cell(sf))
|
|
232
|
+
table.add_row(*row)
|
|
233
|
+
|
|
234
|
+
console.print(table)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _date_matrix(flights: list[ScoredFlight], currency: str, max_dates: int = 14) -> None:
|
|
238
|
+
priced = [sf for sf in flights if sf.flight.price > 0]
|
|
239
|
+
if not priced:
|
|
240
|
+
return
|
|
241
|
+
|
|
242
|
+
destinations = sorted({sf.destination for sf in priced})
|
|
243
|
+
dates = sorted({sf.date for sf in priced})
|
|
244
|
+
|
|
245
|
+
if not destinations or not dates:
|
|
246
|
+
return
|
|
247
|
+
|
|
248
|
+
truncated = len(dates) > max_dates
|
|
249
|
+
if truncated:
|
|
250
|
+
dates = dates[:max_dates]
|
|
251
|
+
|
|
252
|
+
# Build cheapest price lookup: (dest, date) -> price
|
|
253
|
+
cheapest: dict[tuple[str, str], float] = {}
|
|
254
|
+
for sf in priced:
|
|
255
|
+
key = (sf.destination, sf.date)
|
|
256
|
+
if key not in cheapest or sf.flight.price < cheapest[key]:
|
|
257
|
+
cheapest[key] = sf.flight.price
|
|
258
|
+
|
|
259
|
+
# Find cheapest date per destination for highlighting
|
|
260
|
+
best_per_dest: dict[str, float] = {}
|
|
261
|
+
for (dest, _dt), price in cheapest.items():
|
|
262
|
+
if dest not in best_per_dest or price < best_per_dest[dest]:
|
|
263
|
+
best_per_dest[dest] = price
|
|
264
|
+
|
|
265
|
+
# Shorten date headers to MM-DD
|
|
266
|
+
short_dates = [d[5:] for d in dates]
|
|
267
|
+
|
|
268
|
+
table = Table(title="Prices by Date", box=ROUNDED, show_lines=False, pad_edge=True, title_style="bold")
|
|
269
|
+
table.add_column("Destination", style="bold")
|
|
270
|
+
for sd in short_dates:
|
|
271
|
+
table.add_column(sd, justify="right")
|
|
272
|
+
|
|
273
|
+
sym = {"EUR": "\u20ac", "USD": "$", "GBP": "\u00a3", "INR": "\u20b9"}.get(currency, currency + " ")
|
|
274
|
+
|
|
275
|
+
for dest in destinations:
|
|
276
|
+
row = [city_name(dest)]
|
|
277
|
+
for dt in dates:
|
|
278
|
+
price = cheapest.get((dest, dt))
|
|
279
|
+
if price is None:
|
|
280
|
+
row.append("[dim]-[/dim]")
|
|
281
|
+
elif price == best_per_dest.get(dest):
|
|
282
|
+
row.append(f"[green bold]{sym}{price:,.0f}[/green bold]")
|
|
283
|
+
else:
|
|
284
|
+
row.append(f"{sym}{price:,.0f}")
|
|
285
|
+
table.add_row(*row)
|
|
286
|
+
|
|
287
|
+
console.print(table)
|
|
288
|
+
note = f"[dim]Prices in {currency}. Green = cheapest per destination."
|
|
289
|
+
if truncated:
|
|
290
|
+
note += f" Showing first {max_dates} dates."
|
|
291
|
+
note += "[/dim]"
|
|
292
|
+
console.print(note)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _top_flights(flights: list[ScoredFlight], n: int = 10) -> None:
|
|
296
|
+
flights_table(flights[:n], title=f"Top {min(n, len(flights))} Flights", show_count=False)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def scan_summary(flights: list[ScoredFlight], currency: str) -> None:
|
|
300
|
+
if not flights:
|
|
301
|
+
console.print("[dim]No flights found.[/dim]")
|
|
302
|
+
return
|
|
303
|
+
|
|
304
|
+
_scan_stats(flights, currency)
|
|
305
|
+
console.print()
|
|
306
|
+
_best_per_destination(flights)
|
|
307
|
+
console.print()
|
|
308
|
+
_date_matrix(flights, currency)
|
|
309
|
+
console.print()
|
|
310
|
+
_top_flights(flights)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def zones_table(zones: list[ConflictZone]) -> None:
|
|
314
|
+
table = Table(title="Conflict Zones", box=ROUNDED, show_lines=False, pad_edge=True, title_style="bold")
|
|
315
|
+
table.add_column("Zone", style="bold")
|
|
316
|
+
table.add_column("Risk", justify="center")
|
|
317
|
+
table.add_column("Airports")
|
|
318
|
+
table.add_column("Source", style="dim")
|
|
319
|
+
table.add_column("Updated", style="dim")
|
|
320
|
+
|
|
321
|
+
for zone in sorted(zones, key=lambda z: (-z.risk_level.severity, z.name)):
|
|
322
|
+
rl = zone.risk_level
|
|
323
|
+
color = RISK_COLORS[rl]
|
|
324
|
+
airports_str = ", ".join(
|
|
325
|
+
f"{a} ({city_name(a)})" for a in zone.airports[:6]
|
|
326
|
+
) if zone.airports else "-"
|
|
327
|
+
if len(zone.airports) > 6:
|
|
328
|
+
airports_str += f" +{len(zone.airports) - 6} more"
|
|
329
|
+
table.add_row(
|
|
330
|
+
zone.name,
|
|
331
|
+
f"[{color}]{RISK_LABELS[rl]}[/{color}]",
|
|
332
|
+
airports_str,
|
|
333
|
+
zone.source,
|
|
334
|
+
zone.updated,
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
console.print(table)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
class _ErrorColumn(TextColumn):
|
|
341
|
+
"""Only show error count when errors > 0."""
|
|
342
|
+
|
|
343
|
+
def render(self, task):
|
|
344
|
+
errors = task.fields.get("errors", 0)
|
|
345
|
+
if errors > 0:
|
|
346
|
+
return RichText(f"{errors} errors", style="red")
|
|
347
|
+
return RichText("")
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def scan_progress() -> Progress:
|
|
351
|
+
return Progress(
|
|
352
|
+
TextColumn("[bold]{task.description}"),
|
|
353
|
+
BarColumn(),
|
|
354
|
+
MofNCompleteColumn(),
|
|
355
|
+
_ErrorColumn(""),
|
|
356
|
+
TimeRemainingColumn(),
|
|
357
|
+
console=console,
|
|
358
|
+
)
|
opensky/models.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from enum import Enum
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class RiskLevel(str, Enum):
|
|
9
|
+
SAFE = "safe"
|
|
10
|
+
CAUTION = "caution"
|
|
11
|
+
HIGH_RISK = "high_risk"
|
|
12
|
+
DO_NOT_FLY = "do_not_fly"
|
|
13
|
+
|
|
14
|
+
@property
|
|
15
|
+
def severity(self) -> int:
|
|
16
|
+
return {
|
|
17
|
+
RiskLevel.SAFE: 0,
|
|
18
|
+
RiskLevel.CAUTION: 1,
|
|
19
|
+
RiskLevel.HIGH_RISK: 2,
|
|
20
|
+
RiskLevel.DO_NOT_FLY: 3,
|
|
21
|
+
}[self]
|
|
22
|
+
|
|
23
|
+
def __ge__(self, other: RiskLevel) -> bool:
|
|
24
|
+
return self.severity >= other.severity
|
|
25
|
+
|
|
26
|
+
def __gt__(self, other: RiskLevel) -> bool:
|
|
27
|
+
return self.severity > other.severity
|
|
28
|
+
|
|
29
|
+
def __le__(self, other: RiskLevel) -> bool:
|
|
30
|
+
return self.severity <= other.severity
|
|
31
|
+
|
|
32
|
+
def __lt__(self, other: RiskLevel) -> bool:
|
|
33
|
+
return self.severity < other.severity
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class FlaggedAirport(BaseModel):
|
|
37
|
+
code: str
|
|
38
|
+
country: str
|
|
39
|
+
zone_name: str
|
|
40
|
+
risk_level: RiskLevel
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class RiskAssessment(BaseModel):
|
|
44
|
+
risk_level: RiskLevel = RiskLevel.SAFE
|
|
45
|
+
flagged_airports: list[FlaggedAirport] = []
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def is_safe(self) -> bool:
|
|
49
|
+
return self.risk_level == RiskLevel.SAFE
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class FlightLeg(BaseModel):
|
|
53
|
+
airline: str
|
|
54
|
+
flight_number: str
|
|
55
|
+
departure_airport: str
|
|
56
|
+
arrival_airport: str
|
|
57
|
+
departure_time: str
|
|
58
|
+
arrival_time: str
|
|
59
|
+
duration_minutes: int
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class FlightResult(BaseModel):
|
|
63
|
+
price: float
|
|
64
|
+
currency: str
|
|
65
|
+
duration_minutes: int
|
|
66
|
+
stops: int
|
|
67
|
+
legs: list[FlightLeg]
|
|
68
|
+
provider: str = ""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class ScoredFlight(BaseModel):
|
|
72
|
+
flight: FlightResult
|
|
73
|
+
origin: str
|
|
74
|
+
destination: str
|
|
75
|
+
date: str
|
|
76
|
+
route: str
|
|
77
|
+
risk: RiskAssessment
|
|
78
|
+
score: float = 0.0
|
|
79
|
+
transit_hours: float = 0.0
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class ConflictZone(BaseModel):
|
|
83
|
+
id: str
|
|
84
|
+
name: str
|
|
85
|
+
risk_level: RiskLevel
|
|
86
|
+
countries: list[str] = []
|
|
87
|
+
airports: list[str] = []
|
|
88
|
+
source: str = ""
|
|
89
|
+
details: str = ""
|
|
90
|
+
updated: str = ""
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
from typing import Protocol, runtime_checkable
|
|
7
|
+
|
|
8
|
+
from opensky.models import FlightResult
|
|
9
|
+
|
|
10
|
+
log = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def parse_iso_duration(iso: str) -> int:
|
|
14
|
+
"""Parse ISO 8601 duration like PT2H26M to minutes."""
|
|
15
|
+
m = re.match(r"PT(?:(\d+)H)?(?:(\d+)M)?", iso or "")
|
|
16
|
+
if not m:
|
|
17
|
+
return 0
|
|
18
|
+
hours = int(m.group(1) or 0)
|
|
19
|
+
minutes = int(m.group(2) or 0)
|
|
20
|
+
return hours * 60 + minutes
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@runtime_checkable
|
|
24
|
+
class FlightProvider(Protocol):
|
|
25
|
+
name: str
|
|
26
|
+
|
|
27
|
+
def search(
|
|
28
|
+
self,
|
|
29
|
+
origin: str,
|
|
30
|
+
dest: str,
|
|
31
|
+
date: str,
|
|
32
|
+
cabin: str,
|
|
33
|
+
currency: str,
|
|
34
|
+
max_stops: int | None,
|
|
35
|
+
) -> list[FlightResult]: ...
|
|
36
|
+
|
|
37
|
+
def close(self) -> None: ...
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def configured_providers(
|
|
41
|
+
currency: str = "EUR",
|
|
42
|
+
proxy: str | None = None,
|
|
43
|
+
only: str | None = None,
|
|
44
|
+
) -> list[FlightProvider]:
|
|
45
|
+
"""Build the list of active providers based on env vars.
|
|
46
|
+
|
|
47
|
+
If *only* is set, return just that one provider (or raise ValueError).
|
|
48
|
+
Otherwise, return all providers whose credentials are configured.
|
|
49
|
+
Google is always available (no key needed).
|
|
50
|
+
"""
|
|
51
|
+
providers: list[FlightProvider] = []
|
|
52
|
+
|
|
53
|
+
# Google: always available
|
|
54
|
+
if only is None or only == "google":
|
|
55
|
+
from opensky.providers.google import GoogleProvider
|
|
56
|
+
|
|
57
|
+
providers.append(GoogleProvider(currency=currency, proxy=proxy))
|
|
58
|
+
|
|
59
|
+
# Duffel
|
|
60
|
+
duffel_token = os.environ.get("OPENSKY_DUFFEL_TOKEN")
|
|
61
|
+
if only == "duffel" or (only is None and duffel_token):
|
|
62
|
+
if not duffel_token:
|
|
63
|
+
raise ValueError("OPENSKY_DUFFEL_TOKEN not set")
|
|
64
|
+
from opensky.providers.duffel import DuffelProvider
|
|
65
|
+
|
|
66
|
+
providers.append(DuffelProvider(token=duffel_token))
|
|
67
|
+
|
|
68
|
+
# Amadeus
|
|
69
|
+
amadeus_key = os.environ.get("OPENSKY_AMADEUS_KEY")
|
|
70
|
+
amadeus_secret = os.environ.get("OPENSKY_AMADEUS_SECRET")
|
|
71
|
+
if only == "amadeus" or (only is None and amadeus_key and amadeus_secret):
|
|
72
|
+
if not amadeus_key or not amadeus_secret:
|
|
73
|
+
raise ValueError("OPENSKY_AMADEUS_KEY and OPENSKY_AMADEUS_SECRET must both be set")
|
|
74
|
+
from opensky.providers.amadeus import AmadeusProvider
|
|
75
|
+
|
|
76
|
+
providers.append(AmadeusProvider(key=amadeus_key, secret=amadeus_secret))
|
|
77
|
+
|
|
78
|
+
if only and not providers:
|
|
79
|
+
raise ValueError(f"Unknown provider: {only}")
|
|
80
|
+
|
|
81
|
+
return providers
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
from ratelimit import limits, sleep_and_retry
|
|
8
|
+
|
|
9
|
+
from opensky.models import FlightLeg, FlightResult
|
|
10
|
+
from opensky.providers import parse_iso_duration
|
|
11
|
+
|
|
12
|
+
log = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
DEFAULT_BASE_URL = "https://test.api.amadeus.com"
|
|
15
|
+
|
|
16
|
+
CABIN_MAP: dict[str, str] = {
|
|
17
|
+
"economy": "ECONOMY",
|
|
18
|
+
"premium_economy": "PREMIUM_ECONOMY",
|
|
19
|
+
"business": "BUSINESS",
|
|
20
|
+
"first": "FIRST",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _convert_offer(offer: dict, currency: str) -> FlightResult:
|
|
25
|
+
"""Convert an Amadeus flight-offer dict to a FlightResult."""
|
|
26
|
+
price = float(offer.get("price", {}).get("grandTotal", 0))
|
|
27
|
+
offer_currency = offer.get("price", {}).get("currency", currency)
|
|
28
|
+
|
|
29
|
+
itineraries = offer.get("itineraries", [])
|
|
30
|
+
if not itineraries:
|
|
31
|
+
return FlightResult(
|
|
32
|
+
price=price, currency=offer_currency,
|
|
33
|
+
duration_minutes=0, stops=0, legs=[], provider="amadeus",
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
itin = itineraries[0]
|
|
37
|
+
total_duration = parse_iso_duration(itin.get("duration", ""))
|
|
38
|
+
segments = itin.get("segments", [])
|
|
39
|
+
|
|
40
|
+
legs: list[FlightLeg] = []
|
|
41
|
+
for seg in segments:
|
|
42
|
+
dep = seg.get("departure", {})
|
|
43
|
+
arr = seg.get("arrival", {})
|
|
44
|
+
dur = parse_iso_duration(seg.get("duration", ""))
|
|
45
|
+
|
|
46
|
+
carrier = seg.get("operating", {}).get("carrierCode", seg.get("carrierCode", ""))
|
|
47
|
+
flight_num = seg.get("number", "")
|
|
48
|
+
|
|
49
|
+
legs.append(FlightLeg(
|
|
50
|
+
airline=carrier,
|
|
51
|
+
flight_number=flight_num,
|
|
52
|
+
departure_airport=dep.get("iataCode", ""),
|
|
53
|
+
arrival_airport=arr.get("iataCode", ""),
|
|
54
|
+
departure_time=dep.get("at", ""),
|
|
55
|
+
arrival_time=arr.get("at", ""),
|
|
56
|
+
duration_minutes=dur,
|
|
57
|
+
))
|
|
58
|
+
|
|
59
|
+
return FlightResult(
|
|
60
|
+
price=price,
|
|
61
|
+
currency=offer_currency,
|
|
62
|
+
duration_minutes=total_duration,
|
|
63
|
+
stops=max(len(legs) - 1, 0),
|
|
64
|
+
legs=legs,
|
|
65
|
+
provider="amadeus",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class AmadeusProvider:
|
|
70
|
+
name = "amadeus"
|
|
71
|
+
|
|
72
|
+
def __init__(
|
|
73
|
+
self,
|
|
74
|
+
key: str,
|
|
75
|
+
secret: str,
|
|
76
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
77
|
+
):
|
|
78
|
+
self._key = key
|
|
79
|
+
self._secret = secret
|
|
80
|
+
self._base_url = base_url
|
|
81
|
+
self._client = httpx.Client(base_url=base_url, timeout=30.0)
|
|
82
|
+
self._token: str | None = None
|
|
83
|
+
self._token_expires: float = 0
|
|
84
|
+
|
|
85
|
+
def _authenticate(self) -> str:
|
|
86
|
+
"""Get a valid access token, refreshing if needed."""
|
|
87
|
+
if self._token and time.time() < self._token_expires - 60:
|
|
88
|
+
return self._token
|
|
89
|
+
|
|
90
|
+
resp = self._client.post(
|
|
91
|
+
"/v1/security/oauth2/token",
|
|
92
|
+
data={
|
|
93
|
+
"grant_type": "client_credentials",
|
|
94
|
+
"client_id": self._key,
|
|
95
|
+
"client_secret": self._secret,
|
|
96
|
+
},
|
|
97
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
98
|
+
)
|
|
99
|
+
resp.raise_for_status()
|
|
100
|
+
data = resp.json()
|
|
101
|
+
self._token = data["access_token"]
|
|
102
|
+
self._token_expires = time.time() + data.get("expires_in", 1799)
|
|
103
|
+
return self._token
|
|
104
|
+
|
|
105
|
+
@sleep_and_retry
|
|
106
|
+
@limits(calls=10, period=1)
|
|
107
|
+
def search(
|
|
108
|
+
self,
|
|
109
|
+
origin: str,
|
|
110
|
+
dest: str,
|
|
111
|
+
date: str,
|
|
112
|
+
cabin: str,
|
|
113
|
+
currency: str,
|
|
114
|
+
max_stops: int | None,
|
|
115
|
+
) -> list[FlightResult]:
|
|
116
|
+
token = self._authenticate()
|
|
117
|
+
travel_class = CABIN_MAP.get(cabin, "ECONOMY")
|
|
118
|
+
|
|
119
|
+
params: dict[str, str | int | bool] = {
|
|
120
|
+
"originLocationCode": origin,
|
|
121
|
+
"destinationLocationCode": dest,
|
|
122
|
+
"departureDate": date,
|
|
123
|
+
"adults": 1,
|
|
124
|
+
"currencyCode": currency,
|
|
125
|
+
"travelClass": travel_class,
|
|
126
|
+
"max": 50,
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
# Amadeus only supports nonStop boolean, not max_stops count
|
|
130
|
+
if max_stops == 0:
|
|
131
|
+
params["nonStop"] = "true"
|
|
132
|
+
|
|
133
|
+
resp = self._client.get(
|
|
134
|
+
"/v2/shopping/flight-offers",
|
|
135
|
+
params=params,
|
|
136
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
137
|
+
)
|
|
138
|
+
resp.raise_for_status()
|
|
139
|
+
offers = resp.json().get("data", [])
|
|
140
|
+
|
|
141
|
+
results = [_convert_offer(o, currency) for o in offers]
|
|
142
|
+
|
|
143
|
+
# Client-side stops filter for 1-2 stop limits
|
|
144
|
+
if max_stops is not None and max_stops > 0:
|
|
145
|
+
results = [r for r in results if r.stops <= max_stops]
|
|
146
|
+
|
|
147
|
+
return results
|
|
148
|
+
|
|
149
|
+
def close(self) -> None:
|
|
150
|
+
self._client.close()
|