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.
Files changed (50) hide show
  1. fli/__init__.py +0 -0
  2. fli/cli/__init__.py +5 -0
  3. fli/cli/commands/__init__.py +6 -0
  4. fli/cli/commands/airports.py +60 -0
  5. fli/cli/commands/book.py +109 -0
  6. fli/cli/commands/dates.py +510 -0
  7. fli/cli/commands/explore.py +235 -0
  8. fli/cli/commands/flights.py +534 -0
  9. fli/cli/commands/multi.py +341 -0
  10. fli/cli/console.py +5 -0
  11. fli/cli/enums.py +20 -0
  12. fli/cli/errors.py +101 -0
  13. fli/cli/main.py +66 -0
  14. fli/cli/utils.py +665 -0
  15. fli/core/__init__.py +67 -0
  16. fli/core/airports.py +186 -0
  17. fli/core/builders.py +213 -0
  18. fli/core/currency.py +150 -0
  19. fli/core/links.py +125 -0
  20. fli/core/parsers.py +383 -0
  21. fli/core/selectors.py +53 -0
  22. fli/mcp/__init__.py +29 -0
  23. fli/mcp/_entry.py +35 -0
  24. fli/mcp/server.py +1871 -0
  25. fli/models/__init__.py +57 -0
  26. fli/models/airline.py +1126 -0
  27. fli/models/airport.py +7905 -0
  28. fli/models/google_flights/__init__.py +58 -0
  29. fli/models/google_flights/_encoding.py +37 -0
  30. fli/models/google_flights/base.py +613 -0
  31. fli/models/google_flights/dates.py +312 -0
  32. fli/models/google_flights/flights.py +284 -0
  33. fli/search/__init__.py +20 -0
  34. fli/search/_concurrency.py +224 -0
  35. fli/search/_decoders.py +589 -0
  36. fli/search/_helpers.py +45 -0
  37. fli/search/_proto.py +374 -0
  38. fli/search/_tracking.py +95 -0
  39. fli/search/_urls.py +19 -0
  40. fli/search/_wire.py +103 -0
  41. fli/search/client.py +248 -0
  42. fli/search/dates.py +275 -0
  43. fli/search/exceptions.py +30 -0
  44. fli/search/explore.py +67 -0
  45. fli/search/flights.py +581 -0
  46. openfly-0.1.0.dist-info/METADATA +574 -0
  47. openfly-0.1.0.dist-info/RECORD +50 -0
  48. openfly-0.1.0.dist-info/WHEEL +4 -0
  49. openfly-0.1.0.dist-info/entry_points.txt +4 -0
  50. 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,5 @@
1
+ """CLI module for the fli package."""
2
+
3
+ from fli.cli.main import cli
4
+
5
+ __all__ = ["cli"]
@@ -0,0 +1,6 @@
1
+ """Command functions for the CLI."""
2
+
3
+ from fli.cli.commands.dates import dates
4
+ from fli.cli.commands.flights import flights
5
+
6
+ __all__ = ["flights", "dates"]
@@ -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)
@@ -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