From 8fc8798c8417d8b11cd0006e0a3f71eb329c5154 Mon Sep 17 00:00:00 2001 From: Pushpinder Pal Singh Date: Mon, 29 Sep 2025 23:50:42 -0700 Subject: [PATCH 1/3] Add a CLI interface for Fast Flight --- docs/cli.md | 188 ++++++++++++++++++++++++++++ fast_flights/cli.py | 291 ++++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 4 + setup.py | 8 +- 4 files changed, 490 insertions(+), 1 deletion(-) create mode 100644 docs/cli.md create mode 100644 fast_flights/cli.py diff --git a/docs/cli.md b/docs/cli.md new file mode 100644 index 00000000..475d16dd --- /dev/null +++ b/docs/cli.md @@ -0,0 +1,188 @@ +# :material-console-line: flights-cli + +`flights-cli` is the command-line interface for the `fast-flights` library. + +# ๐Ÿš€ Quick start + +```bash +pip install fast-flights + +flights-cli --round \ + --segment "date=2026-01-01 from=DEL to=SFO" \ + --segment "date=2026-01-10 from=SFO to=DEL" \ + --class economy --max-stops 1 --fetch-mode fallback --best-only +``` + +# ๐Ÿ“ฆ Installation + +Install the CLI alongside the Python package: + +```bash +pip install fast-flights +``` + +This installs the `flights-cli` executable and exposes the module entry point so you can run it directly as well: + +```bash +python -m fast_flights.cli --help +``` + +> Refer to the [filters documentation](filters.md) to take control over detailed options for flights with `flights-cli` and the [fallbacks documentation](fallbacks.md) for additional fetching strategies. + +# ๐Ÿงญ Commands + +## `flights-cli` + +### Options Overview + +| Option | Description | Default | +| --- | --- | --- | +| `--trip {one-way,round-trip,multi-city}` | Explicit trip type. Overrides shortcut flags. | Automatically inferred from segments | +| `--one` | Shortcut for `--trip one-way` | โ€” | +| `--round` | Shortcut for `--trip round-trip` | โ€” | +| `--multi` | Shortcut for `--trip multi-city` | โ€” | +| `--segment "date=YYYY-MM-DD from=AAA to=BBB [max_stops=N] [airlines=AA,BB]"` | Flight segment definition. Repeat for multiple segments. | None (required) | +| `--max-stops MAX_STOPS` | Maximum stops applied globally. | None | +| `--class {economy,premium-economy,business,first}` | Choose cabin class. | `economy` | +| `--fetch-mode {common,fallback,force-fallback,local,bright-data}` | Select backend strategy. See [fallbacks](fallbacks.md). | `common` | +| `--data-source {html,js}` | Switch between HTML parsing and decoded JS data. | `html` | +| `-a/--adults` | Number of adult passengers. | `1` | +| `-c/--children` | Number of children passengers. | `0` | +| `--infants-in-seat` | Number of infants in seats. | `0` | +| `--infants-on-lap` | Number of infants on lap. Must not exceed adults. | `0` | +| `--best-only` | Return only the best flight(s). Maps to `is_best`. | `False` | +| `--json` | Output structured JSON. | `False` | +| `--pretty` | Format JSON with indentation. | `False` | + +### Segment format + +Segments map to [`FlightData`](filters.md#flightdata) objects. Pass at least one `--segment` option to build the request. For round trips, specify two segments. For multi-city itineraries, add more segments. + +```bash +flights-cli --segment "date=2026-06-01 from=LHR to=JFK max_stops=0" +``` + +Segments accept the following keys: + +| Key | Required | Description | +| --- | --- | --- | +| `date` | โœ… | Departure date (YYYY-MM-DD). | +| `from` / `from_airport` | โœ… | Origin airport IATA code. | +| `to` / `to_airport` | โœ… | Destination airport IATA code. | +| `max_stops` | Optional | Maximum stops for the segment. Overrides global `--max-stops`. | +| `airlines` | Optional | Comma-separated airline codes (`AA,DL,...`) or alliances (`SKYTEAM`). | + +Wrap the segment expression in quotes so the shell treats it as one value. Combine multiple `--segment` flags in the order that the legs should be flown. + +### Passenger details + +Passengers are captured by the `fast_flights.Passengers` class. CLI options map directly to its constructor: + +```bash +flights-cli --segment "date=2026-08-05 from=BOS to=LAX" \ + -a 2 -c 1 --infants-in-seat 1 +``` + +The CLI enforces the same validations: total passengers โ‰ค 9 and adult coverage for infants on lap. + +### Trip type shortcuts + +Use either the main `--trip` flag or the dedicated shortcuts: + +```bash +flights-cli --round --segment "date=2026-01-01 from=DEL to=SFO" --segment "date=2026-01-10 from=SFO to=DEL" + +flights-cli --one --segment "date=2026-02-14 from=JFK to=CDG" +``` + +If you omit trip flags, the CLI infers `round-trip` for two segments, otherwise `one-way`. + +### Seats and cabins + +Select the cabin with `--class`: + +```bash +flights-cli --segment "date=2026-04-01 from=SYD to=NRT" --class business +``` + +### Fetch strategy + +`--fetch-mode` controls which scraper implementation runs under the hood: + +* `common`: default direct HTTP fetch. +* `fallback`: try direct fetch, fall back to Playwright Serverless if required. +* `force-fallback`: always use serverless Playwright. +* `local`: requires local Playwright setup; see [local mode](local.md). +* `bright-data`: uses the Bright Data proxy integration; see [fallbacks](fallbacks.md#bright-data). + +### Data sources + +`--data-source html` (default) returns high-level summaries matching `fast_flights.schema.Result`. `--data-source js` returns the raw decoded structure (`DecodedResult`) with detailed itinerary metadata, including layovers and aircraft. Both can be combined with `--json` output. + +### Best-only responses + +Use `--best-only` to get only the top recommendation. For HTML responses, this includes flights with the `is_best` flag. For JS responses, it surfaces only the `best` itineraries list. + +```bash +flights-cli --segment "date=2026-09-09 from=SJC to=HNL" --best-only +``` + +### JSON output + +Switch to machine-readable responses with `--json` and optionally format them with `--pretty`: + +```bash +flights-cli --segment "date=2026-01-01 from=DEL to=SFO" --json --pretty +``` + +For `--best-only` JSON responses, the CLI filters the payload accordingly. + +# ๐Ÿงช Examples + +## One-way economy search + +```bash +flights-cli --one \ + --segment "date=2026-02-05 from=JFK to=LHR" \ + --class economy -a 1 +``` + +## Round-trip premium economy with fallback fetch + +```bash +flights-cli --round \ + --segment "date=2026-06-15 from=LAX to=ICN" \ + --segment "date=2026-07-01 from=ICN to=LAX" \ + --class premium-economy --max-stops 1 --fetch-mode fallback +``` + +## Multi-city itinerary with different segments + +```bash +flights-cli --multi \ + --segment "date=2026-03-01 from=SEA to=NRT" \ + --segment "date=2026-03-05 from=NRT to=HKG" \ + --segment "date=2026-03-10 from=HKG to=SEA" +``` + +## Force Playwright fallback + +```bash +flights-cli --segment "date=2026-05-10 from=CDG to=BOM" --fetch-mode force-fallback +``` + +## Retrieve raw itineraries via JS decoder + +```bash +flights-cli --segment "date=2026-02-01 from=SFO to=DEL" --data-source js --json --pretty +``` + +# ๐Ÿ” References + +- [`filters.md`](filters.md): deeper control over segment definitions and airlines. +- [`airports.md`](airports.md): working with airport enums and helpers. +- [`fallbacks.md`](fallbacks.md): detail on fetch modes and infrastructure setup. +- [`local.md`](local.md): how to run the local Playwright variant. + +For Python usage examples, see `example.py` and [`README.md`](../README.md). + diff --git a/fast_flights/cli.py b/fast_flights/cli.py new file mode 100644 index 00000000..3effd336 --- /dev/null +++ b/fast_flights/cli.py @@ -0,0 +1,291 @@ +from __future__ import annotations + +import argparse +import json +import shlex +import sys +from dataclasses import asdict +from typing import Any, Dict, List, Optional + +from .core import get_flights +from .decoder import DecodedResult, Itinerary +from .flights_impl import FlightData, Passengers +from .schema import Flight, Result + + +def _parse_segment(segment: str) -> Dict[str, Any]: + tokens = shlex.split(segment) + if not tokens: + raise ValueError("Segment cannot be empty") + + data: Dict[str, str] = {} + key_map = { + "date": "date", + "from": "from_airport", + "from_airport": "from_airport", + "to": "to_airport", + "to_airport": "to_airport", + "max_stops": "max_stops", + "airlines": "airlines", + } + + for token in tokens: + if "=" not in token: + raise ValueError( + f"Segment token '{token}' must follow key=value format. " + "Enclose the whole segment in quotes." + ) + raw_key, value = token.split("=", 1) + key = raw_key.lower() + if key not in key_map: + raise ValueError( + f"Unsupported segment key '{raw_key}'. Allowed keys: date, from, to, max_stops, airlines." + ) + mapped_key = key_map[key] + if mapped_key in data: + raise ValueError(f"Duplicate '{raw_key}' in segment definition.") + data[mapped_key] = value + + missing = {field for field in ("date", "from_airport", "to_airport") if field not in data} + if missing: + pretty_missing = ", ".join(sorted(missing)) + raise ValueError(f"Missing required keys in segment: {pretty_missing}") + + parsed: Dict[str, Any] = { + "date": data["date"], + "from_airport": data["from_airport"], + "to_airport": data["to_airport"], + } + + if "max_stops" in data and data["max_stops"]: + try: + parsed["max_stops"] = int(data["max_stops"]) + except ValueError as exc: + raise ValueError("max_stops must be an integer") from exc + + if "airlines" in data and data["airlines"]: + airlines = [code.strip().upper() for code in data["airlines"].split(",") if code.strip()] + parsed["airlines"] = airlines if airlines else None + + return parsed + + +def _result_to_dict(result: Any) -> Dict[str, Any]: + if isinstance(result, Result): + return { + "current_price": result.current_price, + "flights": [asdict(flight) for flight in result.flights], + } + + if isinstance(result, DecodedResult): + def itinerary_to_dict(itinerary: Itinerary) -> Dict[str, Any]: + data = asdict(itinerary) + summary = itinerary.itinerary_summary + data["itinerary_summary"] = { + "flights": summary.flights, + "price": summary.price, + "currency": summary.currency, + } + return data + + return { + "best": [itinerary_to_dict(itin) for itin in result.best], + "other": [itinerary_to_dict(itin) for itin in result.other], + "raw": result.raw, + } + + raise TypeError("Unsupported result type returned from get_flights") + + +def _print_text_result(result: Any, *, best_only: bool) -> None: + if isinstance(result, Result): + flights: List[Flight] = result.flights + if best_only: + flights = [flight for flight in flights if flight.is_best] or flights[:1] + print(f"Current price trend: {result.current_price}") + if not flights: + print("No flights returned.") + return + for index, flight in enumerate(flights, start=1): + marker = "*" if flight.is_best else "-" + price = flight.price or "N/A" + delay = f" | Delay: {flight.delay}" if flight.delay else "" + print( + f"{marker} {index}. {flight.name} โ€” {flight.departure} โ†’ {flight.arrival}" + f" | Duration: {flight.duration} | Stops: {flight.stops} | Price: {price}{delay}" + ) + return + + if isinstance(result, DecodedResult): + itineraries: List[Itinerary] = result.best if best_only else result.best + result.other + if best_only and not itineraries: + print("No best itineraries returned.") + return + if not itineraries: + print("No itineraries returned.") + return + print("Itineraries:") + for index, itinerary in enumerate(itineraries, start=1): + summary = itinerary.itinerary_summary + airlines = ", ".join(itinerary.airline_names) or itinerary.airline_code + print( + f"- {index}. {itinerary.departure_airport} โ†’ {itinerary.arrival_airport}" + f" on {airlines} | Travel time: {itinerary.travel_time} mins" + f" | Price: {summary.price} {summary.currency}" + ) + return + + raise TypeError("Unsupported result type returned from get_flights") + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Search Google Flights data via fast-flights", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + + trip_group = parser.add_mutually_exclusive_group() + trip_group.add_argument( + "--trip", + choices=["one-way", "round-trip", "multi-city"], + help="Trip type. Overrides --one, --round, --multi if provided.", + ) + trip_group.add_argument("--one", dest="trip_one", action="store_true", help="Shortcut for --trip one-way") + trip_group.add_argument("--round", dest="trip_round", action="store_true", help="Shortcut for --trip round-trip") + trip_group.add_argument("--multi", dest="trip_multi", action="store_true", help="Shortcut for --trip multi-city") + + parser.add_argument( + "--segment", + action="append", + metavar='"date=YYYY-MM-DD from=AAA to=BBB [max_stops=N] [airlines=AA,BB]"', + help="Define a flight segment. Provide the flag multiple times for multi-leg trips.", + ) + + parser.add_argument("--max-stops", type=int, default=None, help="Apply a maximum number of stops to the entire search.") + + parser.add_argument( + "--class", + dest="seat", + choices=["economy", "premium-economy", "business", "first"], + default="economy", + help="Seat class (cabin).", + ) + + parser.add_argument("-a", "--adults", type=int, default=1, help="Number of adult passengers.") + parser.add_argument("-c", "--children", type=int, default=0, help="Number of child passengers.") + parser.add_argument("--infants-in-seat", type=int, default=0, help="Number of infants in their own seats.") + parser.add_argument("--infants-on-lap", type=int, default=0, help="Number of infants on lap.") + + parser.add_argument( + "--fetch-mode", + choices=["common", "fallback", "force-fallback", "local", "bright-data"], + default="common", + help="Backend fetch strategy.", + ) + + parser.add_argument( + "--data-source", + choices=["html", "js"], + default="html", + help="Choose the data extraction strategy: HTML parser or JS decoded payload.", + ) + + parser.add_argument("--best-only", action="store_true", help="Return only the flights or itineraries marked as best.") + parser.add_argument("--json", action="store_true", help="Print the response as JSON.") + parser.add_argument("--pretty", action="store_true", help="Pretty-print JSON output when --json is supplied.") + + parser.add_argument( + "--timeout", + type=float, + default=None, + help="Reserved for future use. Currently unused.", + ) + + return parser + + +def _resolve_trip(args: argparse.Namespace) -> str: + if args.trip: + return args.trip + if args.trip_one: + return "one-way" + if args.trip_round: + return "round-trip" + if args.trip_multi: + return "multi-city" + # Default to round-trip if two segments, else one-way + if args.segment and len(args.segment) == 2: + return "round-trip" + return "one-way" + + +def main(argv: Optional[List[str]] = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + + if not args.segment: + parser.error("At least one --segment must be provided.") + + segments: List[FlightData] = [] + for index, segment_spec in enumerate(args.segment, start=1): + try: + parsed_segment = _parse_segment(segment_spec) + except ValueError as exc: + parser.error(f"Invalid --segment #{index}: {exc}") + + segments.append( + FlightData( + date=parsed_segment["date"], + from_airport=parsed_segment["from_airport"], + to_airport=parsed_segment["to_airport"], + max_stops=parsed_segment.get("max_stops"), + airlines=parsed_segment.get("airlines"), + ) + ) + + try: + passengers = Passengers( + adults=args.adults, + children=args.children, + infants_in_seat=args.infants_in_seat, + infants_on_lap=args.infants_on_lap, + ) + except AssertionError as exc: + parser.error(str(exc)) + + trip = _resolve_trip(args) + + try: + result = get_flights( + flight_data=segments, + trip=trip, + passengers=passengers, + seat=args.seat, + fetch_mode=args.fetch_mode, + max_stops=args.max_stops, + data_source=args.data_source, + ) + except Exception as exc: # pragma: no cover - surface error message + parser.exit(1, f"Failed to fetch flights: {exc}\n") + + if result is None: + parser.exit(1, "No result returned from fast-flights.\n") + + if args.json: + data = _result_to_dict(result) + if args.best_only and isinstance(result, Result): + data["flights"] = [flight for flight in data["flights"] if flight.get("is_best")] + if args.best_only and isinstance(result, DecodedResult): + data = {"best": data.get("best", [])} + json.dump(data, sys.stdout, indent=2 if args.pretty else None) + if args.pretty: + sys.stdout.write("\n") + else: + _print_text_result(result, best_only=args.best_only) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) + diff --git a/pyproject.toml b/pyproject.toml index ab85e376..9f9b36e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,8 +22,12 @@ dependencies = [ "primp", "protobuf>=5.27.0", "selectolax", + "typing_extensions>=4.0", ] +[project.scripts] +flights-cli = "fast_flights.cli:main" + [project.optional-dependencies] local = [ "playwright" diff --git a/setup.py b/setup.py index 8417e931..87170a7d 100644 --- a/setup.py +++ b/setup.py @@ -1,6 +1,12 @@ from setuptools import setup if __name__ == "__main__": - setup() + setup( + entry_points={ + "console_scripts": [ + "flights-cli=fast_flights.cli:main", + ], + } + ) # testing From 0731c640ee6c6dff2507aa141f4729b1d3c42d07 Mon Sep 17 00:00:00 2001 From: Pushpinder Pal Singh Date: Tue, 30 Sep 2025 00:56:21 -0700 Subject: [PATCH 2/3] Change CLI installation command to GitHub link Updated installation instructions for the CLI to use the GitHub repository. --- docs/cli.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 475d16dd..04993188 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -5,7 +5,7 @@ # ๐Ÿš€ Quick start ```bash -pip install fast-flights +pip install git+https://github.com/swiftlysingh/flights-cli flights-cli --round \ --segment "date=2026-01-01 from=DEL to=SFO" \ @@ -18,7 +18,7 @@ flights-cli --round \ Install the CLI alongside the Python package: ```bash -pip install fast-flights +pip install git+https://github.com/swiftlysingh/flights-cli ``` This installs the `flights-cli` executable and exposes the module entry point so you can run it directly as well: From 4111b64e746a38bc70c0eeef022ac47f3e6532ec Mon Sep 17 00:00:00 2001 From: Pushpinder Pal Singh Date: Tue, 30 Sep 2025 00:57:05 -0700 Subject: [PATCH 3/3] Update header formatting in cli.md --- docs/cli.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/cli.md b/docs/cli.md index 04993188..86be0679 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1,4 +1,4 @@ -# :material-console-line: flights-cli +# flights-cli `flights-cli` is the command-line interface for the `fast-flights` library.