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/cli.py
ADDED
|
@@ -0,0 +1,690 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import calendar
|
|
4
|
+
import sys
|
|
5
|
+
from datetime import date as date_cls, timedelta
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Annotated, Optional
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
|
|
12
|
+
from opensky import __version__
|
|
13
|
+
|
|
14
|
+
app = typer.Typer(
|
|
15
|
+
name="opensky",
|
|
16
|
+
help="Search hundreds of flights in minutes, not hours.",
|
|
17
|
+
no_args_is_help=False,
|
|
18
|
+
add_completion=False,
|
|
19
|
+
)
|
|
20
|
+
console = Console()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
VALID_PROVIDERS = ("google", "duffel", "amadeus")
|
|
24
|
+
VALID_CABINS = {"economy", "premium", "premium_economy", "business", "first"}
|
|
25
|
+
|
|
26
|
+
# Map friendly stops values to the internal names
|
|
27
|
+
STOPS_ALIASES: dict[str, str] = {
|
|
28
|
+
"any": "any",
|
|
29
|
+
"nonstop": "non_stop",
|
|
30
|
+
"non_stop": "non_stop",
|
|
31
|
+
"0": "non_stop",
|
|
32
|
+
"1": "one_stop_or_fewer",
|
|
33
|
+
"one_stop_or_fewer": "one_stop_or_fewer",
|
|
34
|
+
"2": "two_or_fewer_stops",
|
|
35
|
+
"two_or_fewer_stops": "two_or_fewer_stops",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
# Day names for _parse_date
|
|
39
|
+
_DAYS = {name.lower(): i for i, name in enumerate(calendar.day_name)}
|
|
40
|
+
# Month names for _parse_date
|
|
41
|
+
_MONTHS = {name.lower(): i for i, name in enumerate(calendar.month_name) if name}
|
|
42
|
+
_MONTHS_ABBR = {name.lower(): i for i, name in enumerate(calendar.month_abbr) if name}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _normalize_stops(value: str) -> str:
|
|
46
|
+
"""Map friendly stop values to internal names. Exits on invalid input."""
|
|
47
|
+
result = STOPS_ALIASES.get(value)
|
|
48
|
+
if result is None:
|
|
49
|
+
console.print(f"[red]Unknown stops value: {value}. Use: any, nonstop, 0, 1, 2.[/red]")
|
|
50
|
+
raise typer.Exit(1)
|
|
51
|
+
return result
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _validate_cabin(value: str) -> str:
|
|
55
|
+
"""Validate cabin class. Exits on invalid input."""
|
|
56
|
+
if value not in VALID_CABINS:
|
|
57
|
+
console.print(f"[red]Unknown class: {value}. Use: economy, premium, business, first.[/red]")
|
|
58
|
+
raise typer.Exit(1)
|
|
59
|
+
return value
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _parse_date(s: str) -> str:
|
|
63
|
+
"""Parse flexible date input to YYYY-MM-DD.
|
|
64
|
+
|
|
65
|
+
Accepts: YYYY-MM-DD, "tomorrow", "next monday", "mar 15", "march 15".
|
|
66
|
+
Returns YYYY-MM-DD string. Raises ValueError on unrecognized input.
|
|
67
|
+
"""
|
|
68
|
+
s = s.strip().lower()
|
|
69
|
+
today = date_cls.today()
|
|
70
|
+
|
|
71
|
+
# Standard ISO format
|
|
72
|
+
try:
|
|
73
|
+
date_cls.fromisoformat(s)
|
|
74
|
+
return s
|
|
75
|
+
except ValueError:
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
# "today"
|
|
79
|
+
if s == "today":
|
|
80
|
+
return today.isoformat()
|
|
81
|
+
|
|
82
|
+
# "tomorrow"
|
|
83
|
+
if s == "tomorrow":
|
|
84
|
+
return (today + timedelta(days=1)).isoformat()
|
|
85
|
+
|
|
86
|
+
# "next monday", "next tuesday", etc.
|
|
87
|
+
if s.startswith("next "):
|
|
88
|
+
day_name = s[5:].strip()
|
|
89
|
+
if day_name in _DAYS:
|
|
90
|
+
target = _DAYS[day_name]
|
|
91
|
+
days_ahead = (target - today.weekday()) % 7
|
|
92
|
+
if days_ahead == 0:
|
|
93
|
+
days_ahead = 7
|
|
94
|
+
return (today + timedelta(days=days_ahead)).isoformat()
|
|
95
|
+
|
|
96
|
+
# "mar 15", "march 15", "Mar 15"
|
|
97
|
+
parts = s.split()
|
|
98
|
+
if len(parts) == 2:
|
|
99
|
+
month_str, day_str = parts
|
|
100
|
+
month = _MONTHS.get(month_str) or _MONTHS_ABBR.get(month_str)
|
|
101
|
+
if month and day_str.isdigit():
|
|
102
|
+
day = int(day_str)
|
|
103
|
+
# Pick year: this year if future, next year if past
|
|
104
|
+
try:
|
|
105
|
+
candidate = date_cls(today.year, month, day)
|
|
106
|
+
if candidate < today:
|
|
107
|
+
candidate = date_cls(today.year + 1, month, day)
|
|
108
|
+
return candidate.isoformat()
|
|
109
|
+
except ValueError:
|
|
110
|
+
pass
|
|
111
|
+
|
|
112
|
+
raise ValueError(f"Cannot parse date: {s}. Use YYYY-MM-DD, tomorrow, next monday, mar 15, etc.")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _friendly_date(iso_date: str) -> str:
|
|
116
|
+
"""Format '2026-03-10' as 'Mar 10'."""
|
|
117
|
+
try:
|
|
118
|
+
d = date_cls.fromisoformat(iso_date)
|
|
119
|
+
return f"{d.strftime('%b')} {d.day}"
|
|
120
|
+
except (ValueError, TypeError):
|
|
121
|
+
return iso_date
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _validate_provider(provider: str | None) -> None:
|
|
125
|
+
if provider is not None and provider not in VALID_PROVIDERS:
|
|
126
|
+
console.print(f"[red]Unknown provider: {provider}. Choose from: {', '.join(VALID_PROVIDERS)}.[/red]")
|
|
127
|
+
console.print("[dim]Set the required env vars (see README).[/dim]")
|
|
128
|
+
raise typer.Exit(1)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def version_callback(value: bool) -> None:
|
|
132
|
+
if value:
|
|
133
|
+
console.print(f"opensky {__version__}")
|
|
134
|
+
raise typer.Exit()
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@app.callback(invoke_without_command=True)
|
|
138
|
+
def main(
|
|
139
|
+
ctx: typer.Context,
|
|
140
|
+
version: Annotated[
|
|
141
|
+
Optional[bool],
|
|
142
|
+
typer.Option("--version", "-v", callback=version_callback, is_eager=True),
|
|
143
|
+
] = None,
|
|
144
|
+
) -> None:
|
|
145
|
+
if ctx.invoked_subcommand is None:
|
|
146
|
+
console.print(
|
|
147
|
+
"\n[bold]opensky[/bold] - Search hundreds of flights in minutes.\n\n"
|
|
148
|
+
"Quick start:\n"
|
|
149
|
+
" [green]opensky demo[/green] See example output\n"
|
|
150
|
+
" [green]opensky config init[/green] Set up a multi-route scan\n"
|
|
151
|
+
" [green]opensky search city city YYYY-MM-DD[/green] Search one route\n\n"
|
|
152
|
+
"opensky --help for all options.\n"
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@app.command()
|
|
157
|
+
def search(
|
|
158
|
+
origin: Annotated[str, typer.Argument(help="Origin airport or city name")],
|
|
159
|
+
destination: Annotated[str, typer.Argument(help="Destination airport or city name")],
|
|
160
|
+
date: Annotated[str, typer.Argument(help="Travel date (YYYY-MM-DD, tomorrow, mar 15, etc.)")],
|
|
161
|
+
currency: Annotated[str, typer.Option("--currency", "-c", help="Currency code (EUR, USD, GBP, etc.)")] = "EUR",
|
|
162
|
+
cabin: Annotated[str, typer.Option("--class", "--cabin", help="economy, premium, business, or first")] = "economy",
|
|
163
|
+
stops: Annotated[str, typer.Option("--stops", help="nonstop, 1, 2, or any (default)")] = "any",
|
|
164
|
+
max_price: Annotated[float, typer.Option("--max-price", help="Maximum price (e.g. 500). 0 = no limit")] = 0,
|
|
165
|
+
include_risky: Annotated[bool, typer.Option("--include-risky", "--show-risky", help="Include flights through conflict zones")] = False,
|
|
166
|
+
json_output: Annotated[bool, typer.Option("--json", help="Output as JSON")] = False,
|
|
167
|
+
csv_output: Annotated[bool, typer.Option("--csv", help="Output as CSV")] = False,
|
|
168
|
+
fresh: Annotated[bool, typer.Option("--fresh", "--no-cache", help="Skip cached results, search fresh")] = False,
|
|
169
|
+
proxy: Annotated[Optional[str], typer.Option("--proxy", help="HTTP proxy (e.g. http://host:port)")] = None,
|
|
170
|
+
source: Annotated[Optional[str], typer.Option("--source", "--provider", "-p", help="Flight data source: google, duffel, amadeus")] = None,
|
|
171
|
+
output: Annotated[Optional[str], typer.Option("--output", "-o", help="Save results to file")] = None,
|
|
172
|
+
) -> None:
|
|
173
|
+
"""Search flights for a single route."""
|
|
174
|
+
from opensky.airports import city_name, resolve_airport
|
|
175
|
+
from opensky import display
|
|
176
|
+
from opensky.models import RiskLevel
|
|
177
|
+
from opensky.safety import zones_age_warning
|
|
178
|
+
from opensky.search import SearchEngine
|
|
179
|
+
|
|
180
|
+
try:
|
|
181
|
+
origin = resolve_airport(origin)
|
|
182
|
+
destination = resolve_airport(destination)
|
|
183
|
+
except ValueError as e:
|
|
184
|
+
console.print(f"[red]{e}[/red]")
|
|
185
|
+
raise typer.Exit(1)
|
|
186
|
+
stops = _normalize_stops(stops)
|
|
187
|
+
cabin = _validate_cabin(cabin)
|
|
188
|
+
|
|
189
|
+
origin_city = city_name(origin)
|
|
190
|
+
dest_city = city_name(destination)
|
|
191
|
+
|
|
192
|
+
# Parse flexible date input
|
|
193
|
+
try:
|
|
194
|
+
date = _parse_date(date)
|
|
195
|
+
except ValueError as e:
|
|
196
|
+
console.print(f"[red]{e}[/red]")
|
|
197
|
+
raise typer.Exit(1)
|
|
198
|
+
|
|
199
|
+
date_label = _friendly_date(date)
|
|
200
|
+
|
|
201
|
+
# Validate date is not in the past
|
|
202
|
+
d = date_cls.fromisoformat(date)
|
|
203
|
+
if d < date_cls.today():
|
|
204
|
+
console.print(f"[red]Date {date} is in the past.[/red]")
|
|
205
|
+
raise typer.Exit(1)
|
|
206
|
+
|
|
207
|
+
_validate_provider(source)
|
|
208
|
+
|
|
209
|
+
warning = zones_age_warning()
|
|
210
|
+
if warning:
|
|
211
|
+
console.print(f"[yellow]{warning}[/yellow]")
|
|
212
|
+
|
|
213
|
+
try:
|
|
214
|
+
engine = SearchEngine(
|
|
215
|
+
currency=currency,
|
|
216
|
+
proxy=proxy,
|
|
217
|
+
use_cache=not fresh,
|
|
218
|
+
seat=cabin,
|
|
219
|
+
stops=stops,
|
|
220
|
+
provider=source,
|
|
221
|
+
)
|
|
222
|
+
except ValueError as e:
|
|
223
|
+
msg = str(e)
|
|
224
|
+
if "not set" in msg or "must both be set" in msg:
|
|
225
|
+
console.print(f"[red]{source.capitalize() if source else 'Provider'} not configured. Set the required env vars (see README).[/red]")
|
|
226
|
+
else:
|
|
227
|
+
console.print(f"[red]{e}[/red]")
|
|
228
|
+
raise typer.Exit(1)
|
|
229
|
+
|
|
230
|
+
console.print(f"[dim]Searching flights from {origin_city} to {dest_city} on {date_label}...[/dim]")
|
|
231
|
+
|
|
232
|
+
# Always search unfiltered, then split safe/risky in CLI for messaging
|
|
233
|
+
try:
|
|
234
|
+
all_results = engine.search_scored(
|
|
235
|
+
origin, destination, date,
|
|
236
|
+
risk_threshold=None,
|
|
237
|
+
max_price=max_price,
|
|
238
|
+
)
|
|
239
|
+
except Exception as e:
|
|
240
|
+
console.print(f"[red]Search failed: {e}[/red]")
|
|
241
|
+
raise typer.Exit(1)
|
|
242
|
+
finally:
|
|
243
|
+
engine.close()
|
|
244
|
+
|
|
245
|
+
if not all_results:
|
|
246
|
+
if max_price > 0:
|
|
247
|
+
console.print(f"[dim]No flights found from {origin_city} to {dest_city} on {date_label} under {display.format_price(max_price, currency)}.[/dim]")
|
|
248
|
+
else:
|
|
249
|
+
console.print(f"[dim]No flights found from {origin_city} to {dest_city} on {date_label}.[/dim]")
|
|
250
|
+
raise typer.Exit()
|
|
251
|
+
|
|
252
|
+
safe = [sf for sf in all_results if sf.risk.risk_level < RiskLevel.HIGH_RISK]
|
|
253
|
+
risky = [sf for sf in all_results if sf.risk.risk_level >= RiskLevel.HIGH_RISK]
|
|
254
|
+
results = all_results if include_risky else safe
|
|
255
|
+
|
|
256
|
+
if not results:
|
|
257
|
+
console.print("[dim]No safe flights found. Use --include-risky to see all options.[/dim]")
|
|
258
|
+
raise typer.Exit()
|
|
259
|
+
|
|
260
|
+
results.sort(key=lambda x: x.score)
|
|
261
|
+
|
|
262
|
+
if json_output:
|
|
263
|
+
text = display.flights_json(results)
|
|
264
|
+
if output:
|
|
265
|
+
Path(output).write_text(text)
|
|
266
|
+
console.print(f"Saved to {output}")
|
|
267
|
+
else:
|
|
268
|
+
print(text)
|
|
269
|
+
elif csv_output:
|
|
270
|
+
text = display.flights_csv(results)
|
|
271
|
+
if output:
|
|
272
|
+
Path(output).write_text(text)
|
|
273
|
+
console.print(f"Saved to {output}")
|
|
274
|
+
else:
|
|
275
|
+
print(text)
|
|
276
|
+
else:
|
|
277
|
+
display.flights_table(results, title="Flights")
|
|
278
|
+
if not include_risky and risky:
|
|
279
|
+
console.print(
|
|
280
|
+
f"[dim]{len(risky)} more flights available via conflict zones. "
|
|
281
|
+
f"Use --include-risky to see all.[/dim]"
|
|
282
|
+
)
|
|
283
|
+
if output:
|
|
284
|
+
text = display.flights_json(results)
|
|
285
|
+
Path(output).write_text(text)
|
|
286
|
+
console.print(f"Saved to {output}")
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
@app.command()
|
|
290
|
+
def scan(
|
|
291
|
+
config_path: Annotated[str, typer.Option("--config", "-f", help="TOML config file")],
|
|
292
|
+
workers: Annotated[int, typer.Option("--workers", "-w", help="Parallel searches (default: 3)")] = 3,
|
|
293
|
+
delay: Annotated[float, typer.Option("--delay", help="Seconds between batches (default: 1.0)")] = 1.0,
|
|
294
|
+
max_price: Annotated[float, typer.Option("--max-price", help="Maximum price, overrides config (0 = no limit)")] = 0,
|
|
295
|
+
all_flights: Annotated[bool, typer.Option("--all-flights", "--detail", help="Show all individual flights instead of summary")] = False,
|
|
296
|
+
include_risky: Annotated[bool, typer.Option("--include-risky", "--show-risky", help="Include flights through conflict zones")] = False,
|
|
297
|
+
json_output: Annotated[bool, typer.Option("--json", help="Output as JSON")] = False,
|
|
298
|
+
csv_output: Annotated[bool, typer.Option("--csv", help="Output as CSV")] = False,
|
|
299
|
+
fresh: Annotated[bool, typer.Option("--fresh", "--no-cache", help="Skip cached results, search fresh")] = False,
|
|
300
|
+
proxy: Annotated[Optional[str], typer.Option("--proxy", help="HTTP proxy (e.g. http://host:port)")] = None,
|
|
301
|
+
source: Annotated[Optional[str], typer.Option("--source", "--provider", "-p", help="Flight data source: google, duffel, amadeus")] = None,
|
|
302
|
+
output: Annotated[Optional[str], typer.Option("--output", "-o", help="Save results to file")] = None,
|
|
303
|
+
) -> None:
|
|
304
|
+
"""Search every combination of origins, destinations, and dates at once."""
|
|
305
|
+
from opensky import display
|
|
306
|
+
from opensky.config import load_config
|
|
307
|
+
from opensky.models import RiskLevel
|
|
308
|
+
from opensky.safety import zones_age_warning
|
|
309
|
+
from opensky.search import SearchEngine
|
|
310
|
+
|
|
311
|
+
_validate_provider(source)
|
|
312
|
+
|
|
313
|
+
warning = zones_age_warning()
|
|
314
|
+
if warning:
|
|
315
|
+
console.print(f"[yellow]{warning}[/yellow]")
|
|
316
|
+
|
|
317
|
+
try:
|
|
318
|
+
cfg = load_config(config_path)
|
|
319
|
+
except FileNotFoundError:
|
|
320
|
+
console.print(f"[red]Config file not found: {config_path}[/red]")
|
|
321
|
+
console.print("[dim]Run 'opensky config init' to create one.[/dim]")
|
|
322
|
+
raise typer.Exit(1)
|
|
323
|
+
except Exception as e:
|
|
324
|
+
console.print(f"[red]Invalid config file: {e}[/red]")
|
|
325
|
+
raise typer.Exit(1)
|
|
326
|
+
|
|
327
|
+
# CLI --max-price overrides config value
|
|
328
|
+
if max_price > 0:
|
|
329
|
+
cfg.search.max_price = max_price
|
|
330
|
+
|
|
331
|
+
dates = cfg.search.date_range.dates()
|
|
332
|
+
total = len(cfg.search.origins) * len(cfg.search.destinations) * len(dates)
|
|
333
|
+
|
|
334
|
+
try:
|
|
335
|
+
engine = SearchEngine(
|
|
336
|
+
currency=cfg.search.currency,
|
|
337
|
+
proxy=proxy,
|
|
338
|
+
use_cache=not fresh,
|
|
339
|
+
seat=cfg.search.cabin,
|
|
340
|
+
stops=cfg.search.stops,
|
|
341
|
+
provider=source,
|
|
342
|
+
)
|
|
343
|
+
except ValueError as e:
|
|
344
|
+
msg = str(e)
|
|
345
|
+
if "not set" in msg or "must both be set" in msg:
|
|
346
|
+
console.print(f"[red]{source.capitalize() if source else 'Provider'} not configured. Set the required env vars (see README).[/red]")
|
|
347
|
+
else:
|
|
348
|
+
console.print(f"[red]{e}[/red]")
|
|
349
|
+
raise typer.Exit(1)
|
|
350
|
+
|
|
351
|
+
names = ", ".join(p.name for p in engine._providers)
|
|
352
|
+
provider_info = f" via {names}"
|
|
353
|
+
|
|
354
|
+
console.print(
|
|
355
|
+
f"Scanning {len(cfg.search.origins)} origins x "
|
|
356
|
+
f"{len(cfg.search.destinations)} destinations x "
|
|
357
|
+
f"{len(dates)} dates = {total} combos{provider_info}"
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
progress = display.scan_progress()
|
|
361
|
+
with progress:
|
|
362
|
+
task = progress.add_task("Scanning", total=total, errors=0)
|
|
363
|
+
|
|
364
|
+
def on_progress(completed: int, total: int, errors: int) -> None:
|
|
365
|
+
progress.update(task, completed=completed, errors=errors)
|
|
366
|
+
|
|
367
|
+
scan_kwargs = {}
|
|
368
|
+
if include_risky:
|
|
369
|
+
scan_kwargs["risk_threshold_override"] = None
|
|
370
|
+
|
|
371
|
+
try:
|
|
372
|
+
results = engine.scan(
|
|
373
|
+
cfg,
|
|
374
|
+
workers=workers,
|
|
375
|
+
delay=delay,
|
|
376
|
+
on_progress=on_progress,
|
|
377
|
+
**scan_kwargs,
|
|
378
|
+
)
|
|
379
|
+
except KeyboardInterrupt:
|
|
380
|
+
console.print("\n[yellow]Scan interrupted. Cached results preserved for resume.[/yellow]")
|
|
381
|
+
raise typer.Exit(1)
|
|
382
|
+
finally:
|
|
383
|
+
engine.close()
|
|
384
|
+
|
|
385
|
+
if json_output:
|
|
386
|
+
text = display.flights_json(results)
|
|
387
|
+
if output:
|
|
388
|
+
Path(output).write_text(text)
|
|
389
|
+
console.print(f"Saved to {output}")
|
|
390
|
+
else:
|
|
391
|
+
print(text)
|
|
392
|
+
elif csv_output:
|
|
393
|
+
text = display.flights_csv(results)
|
|
394
|
+
if output:
|
|
395
|
+
Path(output).write_text(text)
|
|
396
|
+
console.print(f"Saved to {output}")
|
|
397
|
+
else:
|
|
398
|
+
print(text)
|
|
399
|
+
elif all_flights:
|
|
400
|
+
display.flights_table(
|
|
401
|
+
results,
|
|
402
|
+
title=f"Scan Results ({len(results)} flights)",
|
|
403
|
+
)
|
|
404
|
+
if output:
|
|
405
|
+
text = display.flights_json(results)
|
|
406
|
+
Path(output).write_text(text)
|
|
407
|
+
console.print(f"Saved to {output}")
|
|
408
|
+
else:
|
|
409
|
+
display.scan_summary(results, cfg.search.currency)
|
|
410
|
+
if output:
|
|
411
|
+
text = display.flights_json(results)
|
|
412
|
+
Path(output).write_text(text)
|
|
413
|
+
console.print(f"Saved to {output}")
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
@app.command()
|
|
417
|
+
def zones(
|
|
418
|
+
update: Annotated[bool, typer.Option("--update", help="Fetch latest conflict zone data")] = False,
|
|
419
|
+
) -> None:
|
|
420
|
+
"""Display active conflict zones."""
|
|
421
|
+
from opensky import display
|
|
422
|
+
from opensky.safety import load_zones, save_cached_zones
|
|
423
|
+
|
|
424
|
+
if update:
|
|
425
|
+
import urllib.request
|
|
426
|
+
|
|
427
|
+
url = "https://raw.githubusercontent.com/federicodeponte/opensky/main/src/opensky/data/conflict_zones.json"
|
|
428
|
+
console.print(f"Fetching from {url}...")
|
|
429
|
+
try:
|
|
430
|
+
with urllib.request.urlopen(url, timeout=10) as resp:
|
|
431
|
+
data = resp.read().decode()
|
|
432
|
+
save_cached_zones(data)
|
|
433
|
+
console.print("[green]Conflict zone data updated.[/green]")
|
|
434
|
+
except Exception as e:
|
|
435
|
+
console.print(f"[red]Update failed: {e}[/red]")
|
|
436
|
+
console.print("Using bundled data instead.")
|
|
437
|
+
|
|
438
|
+
zone_list = load_zones(force_bundled=not update)
|
|
439
|
+
display.zones_table(zone_list)
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
@app.command()
|
|
443
|
+
def demo(
|
|
444
|
+
include_risky: Annotated[bool, typer.Option("--include-risky", "--show-risky", help="Include flights through conflict zones")] = False,
|
|
445
|
+
json_output: Annotated[bool, typer.Option("--json")] = False,
|
|
446
|
+
csv_output: Annotated[bool, typer.Option("--csv")] = False,
|
|
447
|
+
) -> None:
|
|
448
|
+
"""See example output with bundled data (no API calls needed)."""
|
|
449
|
+
import json
|
|
450
|
+
from importlib import resources
|
|
451
|
+
|
|
452
|
+
from opensky.airports import city_name
|
|
453
|
+
from opensky import display
|
|
454
|
+
from opensky._vendor.google_flights import SearchFlights
|
|
455
|
+
from opensky.models import RiskLevel
|
|
456
|
+
from opensky.providers.google import _convert_result
|
|
457
|
+
from opensky.search import SearchEngine
|
|
458
|
+
|
|
459
|
+
# Dynamic date: today + 7 days
|
|
460
|
+
demo_date = (date_cls.today() + timedelta(days=7)).isoformat()
|
|
461
|
+
demo_date_label = _friendly_date(demo_date)
|
|
462
|
+
|
|
463
|
+
# Load bundled fixture
|
|
464
|
+
fixture_path = resources.files("opensky") / "data" / "demo_flights.json"
|
|
465
|
+
flights_data = json.loads(fixture_path.read_text())
|
|
466
|
+
parsed = SearchFlights._deduplicate(
|
|
467
|
+
[SearchFlights._parse_flight(f) for f in flights_data]
|
|
468
|
+
)
|
|
469
|
+
domain_results = [_convert_result(f, "EUR") for f in parsed]
|
|
470
|
+
|
|
471
|
+
# Score through the real engine (safety filtering, scoring)
|
|
472
|
+
engine = SearchEngine(currency="EUR", use_cache=False)
|
|
473
|
+
from unittest.mock import MagicMock
|
|
474
|
+
mock_provider = MagicMock()
|
|
475
|
+
mock_provider.name = "google"
|
|
476
|
+
mock_provider.search.return_value = domain_results
|
|
477
|
+
engine._providers = [mock_provider]
|
|
478
|
+
|
|
479
|
+
all_results = engine.search_scored(
|
|
480
|
+
"BLR", "HAM", demo_date, risk_threshold=None,
|
|
481
|
+
)
|
|
482
|
+
engine.close()
|
|
483
|
+
|
|
484
|
+
safe = [sf for sf in all_results if sf.risk.risk_level < RiskLevel.HIGH_RISK]
|
|
485
|
+
risky = [sf for sf in all_results if sf.risk.risk_level >= RiskLevel.HIGH_RISK]
|
|
486
|
+
results = all_results if include_risky else safe
|
|
487
|
+
|
|
488
|
+
if not results:
|
|
489
|
+
console.print("[dim]No flights to show.[/dim]")
|
|
490
|
+
raise typer.Exit()
|
|
491
|
+
|
|
492
|
+
results.sort(key=lambda x: x.score)
|
|
493
|
+
|
|
494
|
+
origin_city = city_name("BLR")
|
|
495
|
+
dest_city = city_name("HAM")
|
|
496
|
+
console.print(f"[dim]Searching flights from {origin_city} to {dest_city} on {demo_date_label}...[/dim]\n")
|
|
497
|
+
|
|
498
|
+
if json_output:
|
|
499
|
+
print(display.flights_json(results))
|
|
500
|
+
elif csv_output:
|
|
501
|
+
print(display.flights_csv(results))
|
|
502
|
+
else:
|
|
503
|
+
display.flights_table(results, title="Flights")
|
|
504
|
+
if not include_risky and risky:
|
|
505
|
+
console.print(
|
|
506
|
+
f"[dim]{len(risky)} more flights available via conflict zones. "
|
|
507
|
+
f"Use --include-risky to see all.[/dim]"
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
# Mini scan summary with example data
|
|
511
|
+
console.print()
|
|
512
|
+
console.print("[bold]Scan summary (example)[/bold]")
|
|
513
|
+
console.print(
|
|
514
|
+
f"[dim]3 origins x 3 destinations x 5 dates = 45 combos[/dim]\n"
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
from rich.table import Table
|
|
518
|
+
from rich.box import ROUNDED
|
|
519
|
+
|
|
520
|
+
# Best per destination example
|
|
521
|
+
table = Table(title="Best per Destination", box=ROUNDED, show_lines=False, pad_edge=True, title_style="bold")
|
|
522
|
+
table.add_column("Destination", style="bold")
|
|
523
|
+
table.add_column("Price", justify="right", style="bold green")
|
|
524
|
+
table.add_column("Date")
|
|
525
|
+
table.add_column("Route")
|
|
526
|
+
|
|
527
|
+
d1 = _friendly_date((date_cls.today() + timedelta(days=9)).isoformat())
|
|
528
|
+
d2 = _friendly_date((date_cls.today() + timedelta(days=8)).isoformat())
|
|
529
|
+
d3 = _friendly_date((date_cls.today() + timedelta(days=10)).isoformat())
|
|
530
|
+
|
|
531
|
+
table.add_row("Hamburg", "\u20ac357", d1, "Bangalore \u2192 Dubai \u2192 Hamburg")
|
|
532
|
+
table.add_row("Frankfurt", "\u20ac298", d2, "Bangkok \u2192 Frankfurt")
|
|
533
|
+
table.add_row("Amsterdam", "\u20ac289", d3, "Bangkok \u2192 Amsterdam")
|
|
534
|
+
console.print(table)
|
|
535
|
+
|
|
536
|
+
# Small date matrix
|
|
537
|
+
dates_for_matrix = [
|
|
538
|
+
(date_cls.today() + timedelta(days=7 + i)).isoformat()[5:]
|
|
539
|
+
for i in range(5)
|
|
540
|
+
]
|
|
541
|
+
|
|
542
|
+
matrix = Table(title="Prices by Date", box=ROUNDED, show_lines=False, pad_edge=True, title_style="bold")
|
|
543
|
+
matrix.add_column("Destination", style="bold")
|
|
544
|
+
for d in dates_for_matrix:
|
|
545
|
+
matrix.add_column(d, justify="right")
|
|
546
|
+
|
|
547
|
+
matrix.add_row("Hamburg", "\u20ac412", "[green bold]\u20ac357[/green bold]", "\u20ac389", "\u20ac445", "\u20ac401")
|
|
548
|
+
matrix.add_row("Frankfurt", "[green bold]\u20ac298[/green bold]", "\u20ac312", "\u20ac345", "\u20ac378", "\u20ac401")
|
|
549
|
+
matrix.add_row("Amsterdam", "\u20ac356", "\u20ac345", "\u20ac312", "[green bold]\u20ac289[/green bold]", "\u20ac345")
|
|
550
|
+
console.print(matrix)
|
|
551
|
+
|
|
552
|
+
console.print("\n[dim]Example data. Run opensky search or opensky scan for real results.[/dim]")
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
@app.command(name="cache")
|
|
556
|
+
def cache_cmd(
|
|
557
|
+
action: Annotated[str, typer.Argument(help="Action: clear | stats")] = "stats",
|
|
558
|
+
) -> None:
|
|
559
|
+
"""Manage the search cache (clear or view stats)."""
|
|
560
|
+
from opensky import cache as cache_mod
|
|
561
|
+
|
|
562
|
+
if action == "clear":
|
|
563
|
+
cache_mod.clear()
|
|
564
|
+
console.print("Cache cleared.")
|
|
565
|
+
elif action == "stats":
|
|
566
|
+
s = cache_mod.stats()
|
|
567
|
+
console.print(f"Entries: {s['size']}")
|
|
568
|
+
console.print(f"Directory: {s['directory']}")
|
|
569
|
+
console.print(f"Size: {s['volume'] / 1024:.1f} KB")
|
|
570
|
+
else:
|
|
571
|
+
console.print(f"[red]Unknown action: {action}. Use 'clear' or 'stats'.[/red]")
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
@app.command(name="config")
|
|
575
|
+
def config_cmd(
|
|
576
|
+
action: Annotated[str, typer.Argument(help="Action: init")] = "init",
|
|
577
|
+
output: Annotated[str, typer.Option("--output", "-o")] = "scan.toml",
|
|
578
|
+
force: Annotated[bool, typer.Option("--force", "-f", help="Overwrite existing file")] = False,
|
|
579
|
+
quick: Annotated[bool, typer.Option("--quick", help="Dump template without interactive prompts")] = False,
|
|
580
|
+
) -> None:
|
|
581
|
+
"""Generate a scan config file (interactive or template)."""
|
|
582
|
+
from opensky.config import EXAMPLE_CONFIG
|
|
583
|
+
|
|
584
|
+
if action != "init":
|
|
585
|
+
console.print(f"[red]Unknown action: {action}. Use 'init'.[/red]")
|
|
586
|
+
raise typer.Exit(1)
|
|
587
|
+
|
|
588
|
+
p = Path(output)
|
|
589
|
+
if p.exists() and not force:
|
|
590
|
+
console.print(f"[yellow]{output} already exists. Use --force to overwrite, or -o to pick a different name.[/yellow]")
|
|
591
|
+
raise typer.Exit(1)
|
|
592
|
+
|
|
593
|
+
# Non-interactive: dump template
|
|
594
|
+
if quick or not sys.stdin.isatty():
|
|
595
|
+
p.write_text(EXAMPLE_CONFIG)
|
|
596
|
+
console.print(f"Created {output}")
|
|
597
|
+
return
|
|
598
|
+
|
|
599
|
+
# Interactive config builder
|
|
600
|
+
from opensky.airports import resolve_airport, city_name
|
|
601
|
+
|
|
602
|
+
def _prompt_airports(label: str) -> list[tuple[str, str]]:
|
|
603
|
+
"""Prompt for comma-separated cities, resolve each. Returns (code, city) pairs."""
|
|
604
|
+
while True:
|
|
605
|
+
try:
|
|
606
|
+
raw = input(f"{label} (comma-separated cities or codes): ").strip()
|
|
607
|
+
except (EOFError, KeyboardInterrupt):
|
|
608
|
+
print()
|
|
609
|
+
raise typer.Exit(1)
|
|
610
|
+
if not raw:
|
|
611
|
+
console.print("[dim]Enter at least one city or airport code.[/dim]")
|
|
612
|
+
continue
|
|
613
|
+
results = []
|
|
614
|
+
for part in raw.split(","):
|
|
615
|
+
part = part.strip()
|
|
616
|
+
if not part:
|
|
617
|
+
continue
|
|
618
|
+
try:
|
|
619
|
+
code = resolve_airport(part, interactive=True)
|
|
620
|
+
results.append((code, city_name(code)))
|
|
621
|
+
except (ValueError, SystemExit):
|
|
622
|
+
# resolve_airport might sys.exit on truly unknown, catch it
|
|
623
|
+
console.print(f"[red]Could not resolve '{part}'. Try again.[/red]")
|
|
624
|
+
results = []
|
|
625
|
+
break
|
|
626
|
+
if results:
|
|
627
|
+
return results
|
|
628
|
+
|
|
629
|
+
def _prompt_date(label: str) -> str:
|
|
630
|
+
"""Prompt for a date, accepting flexible formats."""
|
|
631
|
+
while True:
|
|
632
|
+
try:
|
|
633
|
+
raw = input(f"{label}: ").strip()
|
|
634
|
+
except (EOFError, KeyboardInterrupt):
|
|
635
|
+
print()
|
|
636
|
+
raise typer.Exit(1)
|
|
637
|
+
if not raw:
|
|
638
|
+
console.print("[dim]Enter a date (YYYY-MM-DD, tomorrow, mar 15, etc.)[/dim]")
|
|
639
|
+
continue
|
|
640
|
+
try:
|
|
641
|
+
return _parse_date(raw)
|
|
642
|
+
except ValueError:
|
|
643
|
+
console.print("[red]Invalid date. Use YYYY-MM-DD, tomorrow, next monday, mar 15, etc.[/red]")
|
|
644
|
+
|
|
645
|
+
console.print("[bold]opensky config init[/bold]\n")
|
|
646
|
+
|
|
647
|
+
origins = _prompt_airports("Where are you flying from?")
|
|
648
|
+
destinations = _prompt_airports("Where to?")
|
|
649
|
+
|
|
650
|
+
start_date = _prompt_date("Start date (YYYY-MM-DD)")
|
|
651
|
+
while True:
|
|
652
|
+
end_date = _prompt_date("End date (YYYY-MM-DD)")
|
|
653
|
+
if end_date >= start_date:
|
|
654
|
+
break
|
|
655
|
+
console.print("[red]End date must be on or after start date.[/red]")
|
|
656
|
+
|
|
657
|
+
try:
|
|
658
|
+
currency_input = input("Currency [EUR]: ").strip().upper()
|
|
659
|
+
except (EOFError, KeyboardInterrupt):
|
|
660
|
+
print()
|
|
661
|
+
raise typer.Exit(1)
|
|
662
|
+
currency = currency_input or "EUR"
|
|
663
|
+
|
|
664
|
+
# Build TOML
|
|
665
|
+
origin_codes = ", ".join(f'"{c}"' for c, _ in origins)
|
|
666
|
+
origin_comments = ", ".join(name for _, name in origins)
|
|
667
|
+
dest_codes = ", ".join(f'"{c}"' for c, _ in destinations)
|
|
668
|
+
dest_comments = ", ".join(name for _, name in destinations)
|
|
669
|
+
|
|
670
|
+
toml = f"""\
|
|
671
|
+
[search]
|
|
672
|
+
origins = [{origin_codes}] # {origin_comments}
|
|
673
|
+
destinations = [{dest_codes}] # {dest_comments}
|
|
674
|
+
cabin = "economy"
|
|
675
|
+
currency = "{currency}"
|
|
676
|
+
stops = "any"
|
|
677
|
+
|
|
678
|
+
[search.date_range]
|
|
679
|
+
start = "{start_date}"
|
|
680
|
+
end = "{end_date}"
|
|
681
|
+
|
|
682
|
+
[safety]
|
|
683
|
+
risk_threshold = "risky"
|
|
684
|
+
|
|
685
|
+
[scoring]
|
|
686
|
+
price_weight = 1.0
|
|
687
|
+
duration_weight = 0.5
|
|
688
|
+
"""
|
|
689
|
+
p.write_text(toml)
|
|
690
|
+
console.print(f"\nCreated {output}. Next: [green]opensky scan --config {output}[/green]")
|