openfly 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.
- fli/__init__.py +0 -0
- fli/cli/__init__.py +5 -0
- fli/cli/commands/__init__.py +6 -0
- fli/cli/commands/airports.py +60 -0
- fli/cli/commands/book.py +109 -0
- fli/cli/commands/dates.py +510 -0
- fli/cli/commands/explore.py +235 -0
- fli/cli/commands/flights.py +534 -0
- fli/cli/commands/multi.py +341 -0
- fli/cli/console.py +5 -0
- fli/cli/enums.py +20 -0
- fli/cli/errors.py +101 -0
- fli/cli/main.py +66 -0
- fli/cli/utils.py +665 -0
- fli/core/__init__.py +67 -0
- fli/core/airports.py +186 -0
- fli/core/builders.py +213 -0
- fli/core/currency.py +150 -0
- fli/core/links.py +125 -0
- fli/core/parsers.py +383 -0
- fli/core/selectors.py +53 -0
- fli/mcp/__init__.py +29 -0
- fli/mcp/_entry.py +35 -0
- fli/mcp/server.py +1871 -0
- fli/models/__init__.py +57 -0
- fli/models/airline.py +1126 -0
- fli/models/airport.py +7905 -0
- fli/models/google_flights/__init__.py +58 -0
- fli/models/google_flights/_encoding.py +37 -0
- fli/models/google_flights/base.py +613 -0
- fli/models/google_flights/dates.py +312 -0
- fli/models/google_flights/flights.py +284 -0
- fli/search/__init__.py +20 -0
- fli/search/_concurrency.py +224 -0
- fli/search/_decoders.py +589 -0
- fli/search/_helpers.py +45 -0
- fli/search/_proto.py +374 -0
- fli/search/_tracking.py +95 -0
- fli/search/_urls.py +19 -0
- fli/search/_wire.py +103 -0
- fli/search/client.py +248 -0
- fli/search/dates.py +275 -0
- fli/search/exceptions.py +30 -0
- fli/search/explore.py +67 -0
- fli/search/flights.py +581 -0
- openfly-0.1.0.dist-info/METADATA +574 -0
- openfly-0.1.0.dist-info/RECORD +50 -0
- openfly-0.1.0.dist-info/WHEEL +4 -0
- openfly-0.1.0.dist-info/entry_points.txt +4 -0
- openfly-0.1.0.dist-info/licenses/LICENSE.txt +21 -0
fli/__init__.py
ADDED
|
File without changes
|
fli/cli/__init__.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Airport search command for looking up IATA codes."""
|
|
2
|
+
|
|
3
|
+
from typing import Annotated
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
|
|
9
|
+
from fli.cli.enums import OutputFormat
|
|
10
|
+
from fli.cli.utils import emit_json
|
|
11
|
+
from fli.core.airports import search_airports
|
|
12
|
+
|
|
13
|
+
console = Console()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def airports(
|
|
17
|
+
query: Annotated[
|
|
18
|
+
str,
|
|
19
|
+
typer.Argument(help="City name, airport name, or IATA code to search for"),
|
|
20
|
+
],
|
|
21
|
+
limit: Annotated[int, typer.Option("--limit", "-n", help="Maximum results")] = 10,
|
|
22
|
+
output_format: Annotated[
|
|
23
|
+
OutputFormat, typer.Option("--format", help="Output format (text or json)")
|
|
24
|
+
] = OutputFormat.TEXT,
|
|
25
|
+
json_output: Annotated[
|
|
26
|
+
bool,
|
|
27
|
+
typer.Option("--json", help="Deprecated alias for --format json", hidden=True),
|
|
28
|
+
] = False,
|
|
29
|
+
):
|
|
30
|
+
"""Search for airports by city name, airport name, or IATA code.
|
|
31
|
+
|
|
32
|
+
Example:
|
|
33
|
+
fli airports "new york"
|
|
34
|
+
fli airports tokyo
|
|
35
|
+
fli airports JFK
|
|
36
|
+
fli airports heathrow
|
|
37
|
+
|
|
38
|
+
"""
|
|
39
|
+
results = search_airports(query, limit=limit)
|
|
40
|
+
|
|
41
|
+
if not results:
|
|
42
|
+
console.print(f"[yellow]No airports found matching '{query}'[/yellow]")
|
|
43
|
+
raise typer.Exit(1)
|
|
44
|
+
|
|
45
|
+
if json_output or output_format == OutputFormat.JSON:
|
|
46
|
+
# Local serializer: search results carry ``match_type`` (which
|
|
47
|
+
# serialize_airport does not), so we keep this small shape here.
|
|
48
|
+
emit_json(
|
|
49
|
+
[{"code": r.code.name, "name": r.name, "match_type": r.match_type} for r in results]
|
|
50
|
+
)
|
|
51
|
+
else:
|
|
52
|
+
table = Table(title=f"Airports matching '{query}'")
|
|
53
|
+
table.add_column("Code", style="bold cyan", width=6)
|
|
54
|
+
table.add_column("Airport Name", style="white")
|
|
55
|
+
table.add_column("Match", style="dim")
|
|
56
|
+
|
|
57
|
+
for result in results:
|
|
58
|
+
table.add_row(result.code.name, result.name, result.match_type)
|
|
59
|
+
|
|
60
|
+
console.print(table)
|
fli/cli/commands/book.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""`fli book` — per-vendor booking options for a chosen itinerary.
|
|
2
|
+
|
|
3
|
+
The Python library and the MCP server already expose
|
|
4
|
+
:meth:`fli.search.SearchFlights.get_booking_options`; this brings the same
|
|
5
|
+
capability to the CLI. It runs a search, selects the itinerary identified by
|
|
6
|
+
``--flight-numbers`` (via the shared :func:`match_flight_by_numbers` selector),
|
|
7
|
+
then prints the airline-direct + OTA fares with their booking URLs.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from typing import Annotated, Any
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
|
|
14
|
+
from fli.cli.enums import OutputFormat
|
|
15
|
+
from fli.cli.errors import report_cli_error
|
|
16
|
+
from fli.cli.utils import emit_json, normalize_cli_date
|
|
17
|
+
from fli.core import (
|
|
18
|
+
build_flight_segments,
|
|
19
|
+
parse_cabin_class,
|
|
20
|
+
parse_max_stops,
|
|
21
|
+
resolve_airport,
|
|
22
|
+
)
|
|
23
|
+
from fli.core.parsers import ParseError
|
|
24
|
+
from fli.core.selectors import match_flight_by_numbers
|
|
25
|
+
from fli.models import FlightSearchFilters, PassengerInfo
|
|
26
|
+
from fli.search import SearchClientError, SearchFlights
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def book(
|
|
30
|
+
origin: Annotated[str, typer.Argument(help="Departure airport IATA code (e.g. JFK)")],
|
|
31
|
+
destination: Annotated[str, typer.Argument(help="Arrival airport IATA code (e.g. LHR)")],
|
|
32
|
+
departure_date: Annotated[str, typer.Argument(help="Outbound date YYYY-MM-DD")],
|
|
33
|
+
return_date: Annotated[
|
|
34
|
+
str | None, typer.Option("--return", "-r", help="Return date YYYY-MM-DD (round trip)")
|
|
35
|
+
] = None,
|
|
36
|
+
flight_numbers: Annotated[
|
|
37
|
+
list[str] | None,
|
|
38
|
+
typer.Option(
|
|
39
|
+
"--flight-numbers",
|
|
40
|
+
"-f",
|
|
41
|
+
help="Flight number(s) identifying the itinerary, e.g. -f BA178 (repeatable). "
|
|
42
|
+
"Omit to price the top result.",
|
|
43
|
+
),
|
|
44
|
+
] = None,
|
|
45
|
+
cabin_class: Annotated[str, typer.Option("--cabin", "-c", help="Cabin class")] = "ECONOMY",
|
|
46
|
+
max_stops: Annotated[str, typer.Option("--stops", "-s", help="Max stops")] = "ANY",
|
|
47
|
+
passengers: Annotated[int, typer.Option("--passengers", "-p", help="Adults")] = 1,
|
|
48
|
+
currency: Annotated[str, typer.Option("--currency", help="ISO 4217 currency")] = "USD",
|
|
49
|
+
language: Annotated[str | None, typer.Option("--language", help="BCP-47 hl param")] = None,
|
|
50
|
+
country: Annotated[str | None, typer.Option("--country", help="ISO 3166-1 gl param")] = None,
|
|
51
|
+
output_format: Annotated[
|
|
52
|
+
OutputFormat, typer.Option("--format", help="Output format")
|
|
53
|
+
] = OutputFormat.TEXT,
|
|
54
|
+
) -> None:
|
|
55
|
+
"""Get bookable fares (vendor, price, URL) for a specific itinerary."""
|
|
56
|
+
try:
|
|
57
|
+
currency = currency.upper()
|
|
58
|
+
dep = normalize_cli_date(departure_date)
|
|
59
|
+
ret = normalize_cli_date(return_date)
|
|
60
|
+
segments, trip_type = build_flight_segments(
|
|
61
|
+
origin=resolve_airport(origin),
|
|
62
|
+
destination=resolve_airport(destination),
|
|
63
|
+
departure_date=dep,
|
|
64
|
+
return_date=ret,
|
|
65
|
+
)
|
|
66
|
+
filters = FlightSearchFilters(
|
|
67
|
+
trip_type=trip_type,
|
|
68
|
+
passenger_info=PassengerInfo(adults=passengers),
|
|
69
|
+
flight_segments=segments,
|
|
70
|
+
stops=parse_max_stops(max_stops),
|
|
71
|
+
seat_type=parse_cabin_class(cabin_class),
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
search = SearchFlights()
|
|
75
|
+
results = search.search(filters, currency=currency, language=language, country=country)
|
|
76
|
+
if not results:
|
|
77
|
+
raise SearchClientError("No flights found for the given route and date.")
|
|
78
|
+
|
|
79
|
+
flight = match_flight_by_numbers(results, flight_numbers)
|
|
80
|
+
if flight is None:
|
|
81
|
+
raise SearchClientError(
|
|
82
|
+
f"No itinerary matched flight numbers {flight_numbers}. "
|
|
83
|
+
"Run `fli flights` first to see available flight numbers."
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
options = search.get_booking_options(
|
|
87
|
+
flight, filters, currency=currency, language=language, country=country
|
|
88
|
+
)
|
|
89
|
+
except (ParseError, ValueError, SearchClientError) as exc:
|
|
90
|
+
raise report_cli_error(exc, command="book") from exc
|
|
91
|
+
|
|
92
|
+
payload: dict[str, Any] = {
|
|
93
|
+
"booking_options": [opt.model_dump(exclude_none=True) for opt in options]
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if output_format == OutputFormat.JSON:
|
|
97
|
+
emit_json(payload)
|
|
98
|
+
return
|
|
99
|
+
|
|
100
|
+
if not options:
|
|
101
|
+
typer.echo("No bookable fares returned for this itinerary.")
|
|
102
|
+
return
|
|
103
|
+
typer.echo(f"Booking options ({len(options)}):")
|
|
104
|
+
for opt in options:
|
|
105
|
+
tag = " (airline)" if opt.is_airline_direct else ""
|
|
106
|
+
price = f"{opt.currency or currency} {opt.price:,.2f}" if opt.price is not None else "—"
|
|
107
|
+
typer.echo(f" {opt.vendor_name or opt.vendor_code or '?'}{tag}: {price}")
|
|
108
|
+
if opt.booking_url:
|
|
109
|
+
typer.echo(f" {opt.booking_url}")
|
|
@@ -0,0 +1,510 @@
|
|
|
1
|
+
"""Date search CLI command for finding cheapest travel dates."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timedelta
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from fli.cli.enums import DayOfWeek, OutputFormat
|
|
9
|
+
from fli.cli.utils import (
|
|
10
|
+
build_json_success_response,
|
|
11
|
+
display_date_results,
|
|
12
|
+
emit_cli_error,
|
|
13
|
+
emit_json,
|
|
14
|
+
filter_dates_by_days,
|
|
15
|
+
normalize_cli_date,
|
|
16
|
+
normalize_cli_time_range,
|
|
17
|
+
serialize_date_result,
|
|
18
|
+
validate_currency,
|
|
19
|
+
)
|
|
20
|
+
from fli.core import (
|
|
21
|
+
build_date_search_segments,
|
|
22
|
+
parse_airlines,
|
|
23
|
+
parse_alliances,
|
|
24
|
+
parse_cabin_class,
|
|
25
|
+
parse_emissions,
|
|
26
|
+
parse_max_stops,
|
|
27
|
+
resolve_airport,
|
|
28
|
+
)
|
|
29
|
+
from fli.models import (
|
|
30
|
+
BagsFilter,
|
|
31
|
+
DateSearchFilters,
|
|
32
|
+
LayoverRestrictions,
|
|
33
|
+
PassengerInfo,
|
|
34
|
+
PriceLimit,
|
|
35
|
+
TimeRestrictions,
|
|
36
|
+
TripType,
|
|
37
|
+
)
|
|
38
|
+
from fli.search import SearchDates
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _build_selected_days(
|
|
42
|
+
*,
|
|
43
|
+
monday: bool,
|
|
44
|
+
tuesday: bool,
|
|
45
|
+
wednesday: bool,
|
|
46
|
+
thursday: bool,
|
|
47
|
+
friday: bool,
|
|
48
|
+
saturday: bool,
|
|
49
|
+
sunday: bool,
|
|
50
|
+
) -> list[DayOfWeek]:
|
|
51
|
+
"""Build the selected day filters list."""
|
|
52
|
+
selected_days = []
|
|
53
|
+
if monday:
|
|
54
|
+
selected_days.append(DayOfWeek.MONDAY)
|
|
55
|
+
if tuesday:
|
|
56
|
+
selected_days.append(DayOfWeek.TUESDAY)
|
|
57
|
+
if wednesday:
|
|
58
|
+
selected_days.append(DayOfWeek.WEDNESDAY)
|
|
59
|
+
if thursday:
|
|
60
|
+
selected_days.append(DayOfWeek.THURSDAY)
|
|
61
|
+
if friday:
|
|
62
|
+
selected_days.append(DayOfWeek.FRIDAY)
|
|
63
|
+
if saturday:
|
|
64
|
+
selected_days.append(DayOfWeek.SATURDAY)
|
|
65
|
+
if sunday:
|
|
66
|
+
selected_days.append(DayOfWeek.SUNDAY)
|
|
67
|
+
return selected_days
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def dates(
|
|
71
|
+
origin: Annotated[str, typer.Argument(help="Departure airport IATA code (e.g., JFK)")],
|
|
72
|
+
destination: Annotated[str, typer.Argument(help="Arrival airport IATA code (e.g., LHR)")],
|
|
73
|
+
start_date: Annotated[
|
|
74
|
+
str,
|
|
75
|
+
typer.Option("--from", help="Start date (YYYY-MM-DD)"),
|
|
76
|
+
] = (datetime.now() + timedelta(days=1)).strftime("%Y-%m-%d"),
|
|
77
|
+
end_date: Annotated[str, typer.Option("--to", help="End date (YYYY-MM-DD)")] = (
|
|
78
|
+
datetime.now() + timedelta(days=60)
|
|
79
|
+
).strftime("%Y-%m-%d"),
|
|
80
|
+
trip_duration: Annotated[
|
|
81
|
+
int,
|
|
82
|
+
typer.Option(
|
|
83
|
+
"--duration",
|
|
84
|
+
"-d",
|
|
85
|
+
help="Trip duration in days",
|
|
86
|
+
),
|
|
87
|
+
] = 3,
|
|
88
|
+
airlines: Annotated[
|
|
89
|
+
list[str] | None,
|
|
90
|
+
typer.Option(
|
|
91
|
+
"--airlines",
|
|
92
|
+
"-a",
|
|
93
|
+
help="Airline IATA codes (e.g., BA,KL or repeated --airlines BA --airlines KL)",
|
|
94
|
+
),
|
|
95
|
+
] = None,
|
|
96
|
+
is_round_trip: Annotated[
|
|
97
|
+
bool,
|
|
98
|
+
typer.Option(
|
|
99
|
+
"--round",
|
|
100
|
+
"-R",
|
|
101
|
+
help="Search for round-trip flights",
|
|
102
|
+
),
|
|
103
|
+
] = False,
|
|
104
|
+
max_stops: Annotated[
|
|
105
|
+
str,
|
|
106
|
+
typer.Option(
|
|
107
|
+
"--stops",
|
|
108
|
+
"-s",
|
|
109
|
+
help="Maximum stops (ANY, 0 for non-stop, 1 for one stop, 2+ for two stops)",
|
|
110
|
+
),
|
|
111
|
+
] = "ANY",
|
|
112
|
+
cabin_class: Annotated[
|
|
113
|
+
str,
|
|
114
|
+
typer.Option(
|
|
115
|
+
"--class",
|
|
116
|
+
"-c",
|
|
117
|
+
help="Cabin class (ECONOMY, PREMIUM_ECONOMY, BUSINESS, FIRST)",
|
|
118
|
+
),
|
|
119
|
+
] = "ECONOMY",
|
|
120
|
+
sort_by_price: Annotated[
|
|
121
|
+
bool,
|
|
122
|
+
typer.Option(
|
|
123
|
+
"--sort",
|
|
124
|
+
help="Sort results by price (lowest to highest)",
|
|
125
|
+
),
|
|
126
|
+
] = False,
|
|
127
|
+
monday: Annotated[
|
|
128
|
+
bool,
|
|
129
|
+
typer.Option(
|
|
130
|
+
"--monday",
|
|
131
|
+
"-mon",
|
|
132
|
+
help="Include Mondays in results",
|
|
133
|
+
),
|
|
134
|
+
] = False,
|
|
135
|
+
tuesday: Annotated[
|
|
136
|
+
bool,
|
|
137
|
+
typer.Option(
|
|
138
|
+
"--tuesday",
|
|
139
|
+
"-tue",
|
|
140
|
+
help="Include Tuesdays in results",
|
|
141
|
+
),
|
|
142
|
+
] = False,
|
|
143
|
+
wednesday: Annotated[
|
|
144
|
+
bool,
|
|
145
|
+
typer.Option(
|
|
146
|
+
"--wednesday",
|
|
147
|
+
"-wed",
|
|
148
|
+
help="Include Wednesdays in results",
|
|
149
|
+
),
|
|
150
|
+
] = False,
|
|
151
|
+
thursday: Annotated[
|
|
152
|
+
bool,
|
|
153
|
+
typer.Option(
|
|
154
|
+
"--thursday",
|
|
155
|
+
"-thu",
|
|
156
|
+
help="Include Thursdays in results",
|
|
157
|
+
),
|
|
158
|
+
] = False,
|
|
159
|
+
friday: Annotated[
|
|
160
|
+
bool,
|
|
161
|
+
typer.Option(
|
|
162
|
+
"--friday",
|
|
163
|
+
"-fri",
|
|
164
|
+
help="Include Fridays in results",
|
|
165
|
+
),
|
|
166
|
+
] = False,
|
|
167
|
+
saturday: Annotated[
|
|
168
|
+
bool,
|
|
169
|
+
typer.Option(
|
|
170
|
+
"--saturday",
|
|
171
|
+
"-sat",
|
|
172
|
+
help="Include Saturdays in results",
|
|
173
|
+
),
|
|
174
|
+
] = False,
|
|
175
|
+
sunday: Annotated[
|
|
176
|
+
bool,
|
|
177
|
+
typer.Option(
|
|
178
|
+
"--sunday",
|
|
179
|
+
"-sun",
|
|
180
|
+
help="Include Sundays in results",
|
|
181
|
+
),
|
|
182
|
+
] = False,
|
|
183
|
+
departure_window: Annotated[
|
|
184
|
+
str | None,
|
|
185
|
+
typer.Option(
|
|
186
|
+
"--time",
|
|
187
|
+
"-time",
|
|
188
|
+
help="Departure time window in 24h format (e.g., 6-20)",
|
|
189
|
+
),
|
|
190
|
+
] = None,
|
|
191
|
+
output_format: Annotated[
|
|
192
|
+
OutputFormat,
|
|
193
|
+
typer.Option(
|
|
194
|
+
"--format",
|
|
195
|
+
help="Output format",
|
|
196
|
+
case_sensitive=False,
|
|
197
|
+
),
|
|
198
|
+
] = OutputFormat.TEXT,
|
|
199
|
+
currency: Annotated[
|
|
200
|
+
str,
|
|
201
|
+
typer.Option(
|
|
202
|
+
"--currency",
|
|
203
|
+
callback=validate_currency,
|
|
204
|
+
help="Currency code (USD, EUR, GBP, JPY...). Passed to Google as `curr=`.",
|
|
205
|
+
),
|
|
206
|
+
] = "USD",
|
|
207
|
+
language: Annotated[
|
|
208
|
+
str | None,
|
|
209
|
+
typer.Option(
|
|
210
|
+
"--language",
|
|
211
|
+
help="Optional BCP-47 language code (e.g., 'en-GB') passed to Google as `hl=`.",
|
|
212
|
+
),
|
|
213
|
+
] = None,
|
|
214
|
+
country: Annotated[
|
|
215
|
+
str | None,
|
|
216
|
+
typer.Option(
|
|
217
|
+
"--country",
|
|
218
|
+
help="Optional ISO 3166-1 alpha-2 country code (e.g., 'GB') passed to Google as `gl=`.",
|
|
219
|
+
),
|
|
220
|
+
] = None,
|
|
221
|
+
exclude_airlines: Annotated[
|
|
222
|
+
list[str] | None,
|
|
223
|
+
typer.Option(
|
|
224
|
+
"--exclude-airlines",
|
|
225
|
+
"-A",
|
|
226
|
+
help="Airline IATA codes to EXCLUDE from results.",
|
|
227
|
+
),
|
|
228
|
+
] = None,
|
|
229
|
+
alliance: Annotated[
|
|
230
|
+
list[str] | None,
|
|
231
|
+
typer.Option(
|
|
232
|
+
"--alliance",
|
|
233
|
+
help="Restrict to alliances: ONEWORLD, SKYTEAM, STAR_ALLIANCE.",
|
|
234
|
+
),
|
|
235
|
+
] = None,
|
|
236
|
+
exclude_alliance: Annotated[
|
|
237
|
+
list[str] | None,
|
|
238
|
+
typer.Option(
|
|
239
|
+
"--exclude-alliance",
|
|
240
|
+
help="Alliance names to EXCLUDE (ONEWORLD, SKYTEAM, STAR_ALLIANCE).",
|
|
241
|
+
),
|
|
242
|
+
] = None,
|
|
243
|
+
min_layover: Annotated[
|
|
244
|
+
int | None,
|
|
245
|
+
typer.Option(
|
|
246
|
+
"--min-layover",
|
|
247
|
+
help="Minimum layover duration in minutes (multi-stop trips only).",
|
|
248
|
+
min=1,
|
|
249
|
+
),
|
|
250
|
+
] = None,
|
|
251
|
+
max_layover: Annotated[
|
|
252
|
+
int | None,
|
|
253
|
+
typer.Option(
|
|
254
|
+
"--max-layover",
|
|
255
|
+
help="Maximum layover duration in minutes (multi-stop trips only).",
|
|
256
|
+
min=1,
|
|
257
|
+
),
|
|
258
|
+
] = None,
|
|
259
|
+
layover: Annotated[
|
|
260
|
+
list[str] | None,
|
|
261
|
+
typer.Option("--layover", help="Restrict layover to these airports (e.g., --layover ORD)"),
|
|
262
|
+
] = None,
|
|
263
|
+
emissions: Annotated[
|
|
264
|
+
str, typer.Option("--emissions", help="Filter by emissions level (ALL, LESS)")
|
|
265
|
+
] = "ALL",
|
|
266
|
+
checked_bags: Annotated[
|
|
267
|
+
int,
|
|
268
|
+
typer.Option("--bags", "-b", help="Checked bags to include in price (0, 1, or 2)", min=0,
|
|
269
|
+
max=2),
|
|
270
|
+
] = 0,
|
|
271
|
+
carry_on: Annotated[
|
|
272
|
+
bool, typer.Option("--carry-on", help="Include carry-on bag fee in price")
|
|
273
|
+
] = False,
|
|
274
|
+
max_price: Annotated[
|
|
275
|
+
int | None,
|
|
276
|
+
typer.Option("--max-price", help="Maximum price in the search currency.", min=1),
|
|
277
|
+
] = None,
|
|
278
|
+
adults: Annotated[
|
|
279
|
+
int, typer.Option("--adults", help="Number of adult passengers", min=1)
|
|
280
|
+
] = 1,
|
|
281
|
+
children: Annotated[
|
|
282
|
+
int, typer.Option("--children", help="Number of children (2-11 years)", min=0)
|
|
283
|
+
] = 0,
|
|
284
|
+
infants_in_seat: Annotated[
|
|
285
|
+
int, typer.Option("--infants-seat", help="Number of infants in their own seat", min=0)
|
|
286
|
+
] = 0,
|
|
287
|
+
infants_on_lap: Annotated[
|
|
288
|
+
int, typer.Option("--infants-lap", help="Number of lap infants", min=0)
|
|
289
|
+
] = 0,
|
|
290
|
+
):
|
|
291
|
+
"""Find the cheapest dates to fly between two airports.
|
|
292
|
+
|
|
293
|
+
Example:
|
|
294
|
+
fli dates LAX MIA --class BUSINESS --stops NON_STOP --friday
|
|
295
|
+
fli dates LAX MIA --alliance ONEWORLD --currency EUR
|
|
296
|
+
fli dates LAX MIA --exclude-airlines DL --max-layover 240
|
|
297
|
+
|
|
298
|
+
"""
|
|
299
|
+
# Built from raw inputs so it survives an early ParseError (before any
|
|
300
|
+
# parsing/normalization has run); attached to every error response.
|
|
301
|
+
error_query = {
|
|
302
|
+
"origin": origin,
|
|
303
|
+
"destination": destination,
|
|
304
|
+
"start_date": start_date,
|
|
305
|
+
"end_date": end_date,
|
|
306
|
+
"trip_duration": trip_duration,
|
|
307
|
+
"is_round_trip": is_round_trip,
|
|
308
|
+
"cabin_class": cabin_class,
|
|
309
|
+
"max_stops": max_stops,
|
|
310
|
+
"departure_window": (
|
|
311
|
+
f"{departure_window[0]}-{departure_window[1]}"
|
|
312
|
+
if isinstance(departure_window, tuple)
|
|
313
|
+
else departure_window
|
|
314
|
+
),
|
|
315
|
+
"airlines": airlines,
|
|
316
|
+
"sort_by_price": sort_by_price,
|
|
317
|
+
"days": [
|
|
318
|
+
day.value
|
|
319
|
+
for day in _build_selected_days(
|
|
320
|
+
monday=monday,
|
|
321
|
+
tuesday=tuesday,
|
|
322
|
+
wednesday=wednesday,
|
|
323
|
+
thursday=thursday,
|
|
324
|
+
friday=friday,
|
|
325
|
+
saturday=saturday,
|
|
326
|
+
sunday=sunday,
|
|
327
|
+
)
|
|
328
|
+
],
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
try:
|
|
332
|
+
start_date = normalize_cli_date(start_date)
|
|
333
|
+
end_date = normalize_cli_date(end_date)
|
|
334
|
+
departure_window = normalize_cli_time_range(departure_window)
|
|
335
|
+
|
|
336
|
+
# Parse parameters using shared utilities
|
|
337
|
+
origin_airport = resolve_airport(origin)
|
|
338
|
+
destination_airport = resolve_airport(destination)
|
|
339
|
+
trip_type = TripType.ROUND_TRIP if is_round_trip else TripType.ONE_WAY
|
|
340
|
+
stops = parse_max_stops(max_stops)
|
|
341
|
+
seat_type = parse_cabin_class(cabin_class)
|
|
342
|
+
parsed_airlines = parse_airlines(airlines)
|
|
343
|
+
parsed_exclude_airlines = parse_airlines(exclude_airlines)
|
|
344
|
+
parsed_alliances = parse_alliances(alliance)
|
|
345
|
+
parsed_exclude_alliances = parse_alliances(exclude_alliance)
|
|
346
|
+
selected_days = _build_selected_days(
|
|
347
|
+
monday=monday,
|
|
348
|
+
tuesday=tuesday,
|
|
349
|
+
wednesday=wednesday,
|
|
350
|
+
thursday=thursday,
|
|
351
|
+
friday=friday,
|
|
352
|
+
saturday=saturday,
|
|
353
|
+
sunday=sunday,
|
|
354
|
+
)
|
|
355
|
+
query = {
|
|
356
|
+
"origin": origin_airport.name,
|
|
357
|
+
"destination": destination_airport.name,
|
|
358
|
+
"start_date": start_date,
|
|
359
|
+
"end_date": end_date,
|
|
360
|
+
"trip_duration": trip_duration,
|
|
361
|
+
"is_round_trip": is_round_trip,
|
|
362
|
+
"cabin_class": seat_type.name,
|
|
363
|
+
"max_stops": stops.name,
|
|
364
|
+
"departure_window": (
|
|
365
|
+
f"{departure_window[0]}-{departure_window[1]}" if departure_window else None
|
|
366
|
+
),
|
|
367
|
+
"airlines": (
|
|
368
|
+
[airline.name.lstrip("_") for airline in parsed_airlines]
|
|
369
|
+
if parsed_airlines
|
|
370
|
+
else None
|
|
371
|
+
),
|
|
372
|
+
"sort_by_price": sort_by_price,
|
|
373
|
+
"days": [day.value for day in selected_days],
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
# Build time restrictions from tuple
|
|
377
|
+
time_restrictions = None
|
|
378
|
+
if departure_window:
|
|
379
|
+
start_hour, end_hour = departure_window
|
|
380
|
+
time_restrictions = TimeRestrictions(
|
|
381
|
+
earliest_departure=start_hour,
|
|
382
|
+
latest_departure=end_hour,
|
|
383
|
+
earliest_arrival=None,
|
|
384
|
+
latest_arrival=None,
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
# Build flight segments using shared builder
|
|
388
|
+
segments, trip_type = build_date_search_segments(
|
|
389
|
+
origin=origin_airport,
|
|
390
|
+
destination=destination_airport,
|
|
391
|
+
start_date=start_date,
|
|
392
|
+
trip_duration=trip_duration,
|
|
393
|
+
is_round_trip=is_round_trip,
|
|
394
|
+
time_restrictions=time_restrictions,
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
# Build layover constraints (airports + min / max duration).
|
|
398
|
+
layover_airports = [resolve_airport(code) for code in layover] if layover else None
|
|
399
|
+
layover_restrictions = None
|
|
400
|
+
if layover_airports or min_layover is not None or max_layover is not None:
|
|
401
|
+
layover_restrictions = LayoverRestrictions(
|
|
402
|
+
airports=layover_airports,
|
|
403
|
+
min_duration=min_layover,
|
|
404
|
+
max_duration=max_layover,
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
# Build bags filter
|
|
408
|
+
bags_filter = None
|
|
409
|
+
if checked_bags > 0 or carry_on:
|
|
410
|
+
bags_filter = BagsFilter(checked_bags=checked_bags, carry_on=carry_on)
|
|
411
|
+
|
|
412
|
+
# Create search filters
|
|
413
|
+
filters = DateSearchFilters(
|
|
414
|
+
trip_type=trip_type,
|
|
415
|
+
passenger_info=PassengerInfo(
|
|
416
|
+
adults=adults,
|
|
417
|
+
children=children,
|
|
418
|
+
infants_in_seat=infants_in_seat,
|
|
419
|
+
infants_on_lap=infants_on_lap,
|
|
420
|
+
),
|
|
421
|
+
flight_segments=segments,
|
|
422
|
+
stops=stops,
|
|
423
|
+
seat_type=seat_type,
|
|
424
|
+
airlines=parsed_airlines,
|
|
425
|
+
airlines_exclude=parsed_exclude_airlines,
|
|
426
|
+
alliances=parsed_alliances,
|
|
427
|
+
alliances_exclude=parsed_exclude_alliances,
|
|
428
|
+
layover_restrictions=layover_restrictions,
|
|
429
|
+
price_limit=PriceLimit(max_price=max_price) if max_price else None,
|
|
430
|
+
emissions=parse_emissions(emissions),
|
|
431
|
+
bags=bags_filter,
|
|
432
|
+
from_date=start_date,
|
|
433
|
+
to_date=end_date,
|
|
434
|
+
duration=trip_duration if trip_type == TripType.ROUND_TRIP else None,
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
# Perform search; pass currency/language/country through as URL params.
|
|
438
|
+
search_client = SearchDates()
|
|
439
|
+
results = search_client.search(
|
|
440
|
+
filters,
|
|
441
|
+
currency=currency,
|
|
442
|
+
language=language,
|
|
443
|
+
country=country,
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
if not results:
|
|
447
|
+
results = []
|
|
448
|
+
|
|
449
|
+
if selected_days:
|
|
450
|
+
results = filter_dates_by_days(results, selected_days, trip_type)
|
|
451
|
+
|
|
452
|
+
# Sort dates by price if sort flag is enabled
|
|
453
|
+
if sort_by_price:
|
|
454
|
+
results.sort(key=lambda x: x.price)
|
|
455
|
+
|
|
456
|
+
origin_code = origin_airport.name.lstrip("_")
|
|
457
|
+
destination_code = destination_airport.name.lstrip("_")
|
|
458
|
+
|
|
459
|
+
if output_format == OutputFormat.JSON:
|
|
460
|
+
emit_json(
|
|
461
|
+
build_json_success_response(
|
|
462
|
+
search_type="dates",
|
|
463
|
+
trip_type=trip_type,
|
|
464
|
+
query=query,
|
|
465
|
+
results_key="dates",
|
|
466
|
+
results=[
|
|
467
|
+
serialize_date_result(
|
|
468
|
+
result,
|
|
469
|
+
trip_type,
|
|
470
|
+
default_currency=currency,
|
|
471
|
+
origin=origin_code,
|
|
472
|
+
destination=destination_code,
|
|
473
|
+
currency=currency,
|
|
474
|
+
language=language,
|
|
475
|
+
country=country,
|
|
476
|
+
)
|
|
477
|
+
for result in results
|
|
478
|
+
],
|
|
479
|
+
)
|
|
480
|
+
)
|
|
481
|
+
return
|
|
482
|
+
|
|
483
|
+
if not results:
|
|
484
|
+
message = (
|
|
485
|
+
"No flights found for the selected days."
|
|
486
|
+
if selected_days
|
|
487
|
+
else "No flights found for these dates."
|
|
488
|
+
)
|
|
489
|
+
typer.echo(message)
|
|
490
|
+
raise typer.Exit(1)
|
|
491
|
+
|
|
492
|
+
display_date_results(
|
|
493
|
+
results,
|
|
494
|
+
trip_type,
|
|
495
|
+
default_currency=currency,
|
|
496
|
+
origin=origin_code,
|
|
497
|
+
destination=destination_code,
|
|
498
|
+
currency=currency,
|
|
499
|
+
language=language,
|
|
500
|
+
country=country,
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
except Exception as e: # noqa: BLE001 — emit_cli_error classifies + reports
|
|
504
|
+
raise emit_cli_error(
|
|
505
|
+
e,
|
|
506
|
+
search_type="dates",
|
|
507
|
+
command="dates",
|
|
508
|
+
output_format=output_format,
|
|
509
|
+
query=error_query,
|
|
510
|
+
) from e
|