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/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]")