Skip to content
14 changes: 14 additions & 0 deletions src/flight_cli/_console.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
"""Shared Rich consoles for the CLI: `console` (stdout) and `err` (stderr).

A single stdout console and a single stderr console are shared across every CLI
module so human-facing output and diagnostics stay on the right streams. They are
imported back into `cli` as module globals so the commands and weaves reference
the same instances.
"""

from __future__ import annotations

from rich.console import Console

console = Console()
err = Console(stderr=True)
278 changes: 278 additions & 0 deletions src/flight_cli/_dispatch.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,278 @@
"""Backend selection and provider-selection resolution.

Turns the `--backend` flag into a concrete backend (`pick_backend`), and the
provider flags (`--providers` / `--cash-only` / `--awards-only` /
`--provider-opt`, plus the deprecated `--no-pp` / `--pp-only` / `--pp-airlines`
/ `--pp-cabin`) into a single `ProviderSelection` (`resolve_providers`) with a
gate for whether award providers actually run (`should_run_awards`). Imported
back into `cli` (under private-name aliases) so commands reference them as
module globals.
"""

from __future__ import annotations

from typing import Any, cast

import typer

from . import _config
from ._console import err

BACKEND_AUTO = "auto"
BACKEND_MATRIX = "matrix"
BACKEND_GFLIGHT = "gflight"
_VALID_BACKENDS = (BACKEND_AUTO, BACKEND_MATRIX, BACKEND_GFLIGHT)


def pick_backend(
*,
backend: str,
routing: str | None,
extension: str | None,
slice_specs: list[str] | None,
depart_times: str | None,
return_times: str | None,
seniors: int,
youth: int,
inf_seat: int,
inf_lap: int,
) -> str:
"""Resolve --backend to a concrete backend.

auto: matrix iff the request needs it, else gflight (~1s vs Matrix's ~45s).
`--routing`/`--extension` no longer force Matrix on their own — they're
parsed and classified, and Google Flights serves them when it can honor
every constraint (native filters + result post-filter; see routing_predicates
+ _gf_postfilter). Only fare-construction (fare basis / booking class) or a
constraint GF can't reconstruct sends routing to Matrix. Hard-Matrix flags —
`--slice` (multi-city), `--depart-times`/`--return-times`, any pax type beyond
adults+children — always force Matrix (the GF bridge doesn't map them yet).

Explicit --backend matrix: matrix. --backend gflight: gflight, unless the
request is inexpressible on GF (error)."""
from ._gf_postfilter import gf_can_serve # noqa: PLC0415
from .routing_predicates import classify # noqa: PLC0415

hard_matrix = bool(slice_specs or depart_times or return_times) or (
seniors > 0 or youth > 0 or inf_seat > 0 or inf_lap > 0
)
routing_needs_matrix = bool(routing or extension) and not gf_can_serve(
classify(routing, extension)
)
matrix_only = hard_matrix or routing_needs_matrix

if backend == BACKEND_AUTO:
return BACKEND_MATRIX if matrix_only else BACKEND_GFLIGHT
if backend == BACKEND_GFLIGHT and matrix_only:
reason = (
"--slice/--depart-times/--return-times or an extra pax type"
if hard_matrix
else "a --routing/--extension constraint Google Flights can't honor "
"(fare basis, booking class, or similar)"
)
raise typer.BadParameter(
f"--backend gflight can't serve this request: {reason}. "
"Drop it, or use --backend matrix.",
)
if backend not in _VALID_BACKENDS:
raise typer.BadParameter(f"--backend must be one of {_VALID_BACKENDS}; got {backend!r}")
return backend


class ProviderSelection:
"""Resolved provider selection: what runs, which subset, with what options.

`provider_filter` is a tuple of provider names to use (None = all enabled).
`cash_only` skips every award provider. `awards_only` suppresses the cash
table render. `provider_opts` is `{provider_name: {key: value}}` — e.g.
`{"pp": {"airlines": ["United", "Delta"], "cabins": ["Economy"]}}`.
"""

__slots__ = ("awards_only", "cash_only", "provider_filter", "provider_opts")

def __init__(
self,
*,
provider_filter: tuple[str, ...] | None,
cash_only: bool,
awards_only: bool,
provider_opts: dict[str, dict[str, Any]],
) -> None:
self.provider_filter = provider_filter
self.cash_only = cash_only
self.awards_only = awards_only
self.provider_opts = provider_opts

def pp_airlines(self) -> str | None:
"""Backward-compat shim: PP's `airlines` as CSV (None = use default)."""
v: Any = self.provider_opts.get("pp", {}).get("airlines")
if v is None:
return None
if isinstance(v, list):
return ",".join(str(x) for x in cast("list[Any]", v))
return str(v)

def pp_cabins(self) -> str | None:
"""Backward-compat shim: PP's `cabins` as CSV (None = use default)."""
v: Any = self.provider_opts.get("pp", {}).get("cabins")
if v is None:
return None
if isinstance(v, list):
return ",".join(str(x) for x in cast("list[Any]", v))
return str(v)

def seats_sources(self) -> tuple[str, ...] | None:
"""Seats.aero mileage-program filter (`--provider-opt seats-aero.sources=...`).

Maps to the API's `sources=` query param. None = no filter (return all
programs the route is monitored on). The canonical provider key is
`seats-aero`; user-facing aliases (`sa`, `seatsaero`, `seats.aero`)
are normalized at parse time so we only need to read one key here."""
v: Any = self.provider_opts.get("seats-aero", {}).get("sources")
if v is None:
return None
if isinstance(v, list):
return tuple(str(x).strip() for x in cast("list[Any]", v))
return (str(v).strip(),)


def resolve_providers( # noqa: PLR0912 — single-purpose validator + merge; splitting hurts readability
*,
providers: str | None,
cash_only: bool,
awards_only: bool,
provider_opt: tuple[str, ...],
# deprecated aliases — forwarded into the new shape:
legacy_no_pp: bool = False,
legacy_pp_only: bool = False,
legacy_pp_airlines: str | None = None,
legacy_pp_cabin: str | None = None,
) -> ProviderSelection:
"""Resolve the new + deprecated provider flags into one ProviderSelection.

Precedence for per-provider options: config.toml < --provider-opt CLI.
Deprecated --pp-airlines / --pp-cabin map onto the --provider-opt path
so legacy invocations land in the same downstream shape.
"""
# Conflict checks across the new surface.
if cash_only and awards_only:
err.print("[red]--cash-only and --awards-only are mutually exclusive.[/]")
raise typer.Exit(2)
# Conflict checks across the old + new surface.
new_surface_set = cash_only or awards_only or providers is not None
if legacy_no_pp and new_surface_set:
err.print(
"[red]--no-pp conflicts with --cash-only/--awards-only/--providers; use one.[/]",
)
raise typer.Exit(2)
if legacy_pp_only and new_surface_set:
err.print(
"[red]--pp-only conflicts with --cash-only/--awards-only/--providers; use one.[/]",
)
raise typer.Exit(2)
if legacy_no_pp and legacy_pp_only:
err.print("[red]--no-pp and --pp-only are mutually exclusive.[/]")
raise typer.Exit(2)

# Forward legacy intent.
if legacy_no_pp:
cash_only = True
if legacy_pp_only:
awards_only = True

# --providers parsing. Normalize each entry through the alias map so
# `--providers sa,pointspath` ends up the same as `--providers seats-aero,pp`.
provider_filter: tuple[str, ...] | None = None
if providers is not None:
provider_filter = tuple(
_config.canonical_provider(p) for p in providers.split(",") if p.strip()
)
if not provider_filter:
err.print("[red]--providers cannot be empty.[/]")
raise typer.Exit(2)

# Build provider_opts: config.toml < --provider-opt CLI. Section names in
# the config are normalized through the alias map so user-facing spellings
# (`[providers.sa]`) land at the canonical key the registry expects.
try:
config = _config.load()
except (OSError, ValueError) as e:
err.print(f"[red]Failed to load ~/.config/flight-cli/config.toml: {e}[/]")
raise typer.Exit(2) from e
base_opts: dict[str, dict[str, Any]] = {}
providers_section: Any = config.get("providers", {})
if isinstance(providers_section, dict):
for name, opts in cast("dict[str, Any]", providers_section).items():
if isinstance(opts, dict):
base_opts[_config.canonical_provider(name)] = dict(cast("dict[str, Any]", opts))

try:
cli_opts = _config.parse_provider_opt_overrides(list(provider_opt))
except ValueError as e:
err.print(f"[red]{e}[/]")
raise typer.Exit(2) from e
merged_opts = _config.merge_provider_options(base_opts, cli_opts)

# Forward legacy --pp-airlines / --pp-cabin into the merged opts.
# CLI --provider-opt still wins (no-op if user set both, since merged_opts
# already has the override from cli_opts).
pp_section: dict[str, Any] = dict(merged_opts.get("pp", {}))
if legacy_pp_airlines is not None and "airlines" not in pp_section:
pp_section["airlines"] = [v.strip() for v in legacy_pp_airlines.split(",") if v.strip()]
if legacy_pp_cabin is not None and "cabins" not in pp_section:
pp_section["cabins"] = [v.strip() for v in legacy_pp_cabin.split(",") if v.strip()]
if pp_section:
merged_opts["pp"] = pp_section

return ProviderSelection(
provider_filter=provider_filter,
cash_only=cash_only,
awards_only=awards_only,
provider_opts=merged_opts,
)


def should_run_awards(sel: ProviderSelection) -> bool:
"""Award providers run iff (a) not --cash-only, (b) at least one is
configured globally, and (c) the filter (if any) names at least one
configured provider.

Provider-blind by construction: every known provider has an
is_configured() check imported below; adding a new provider is one
import + one entry in the `known` map.
"""
if sel.cash_only:
return False
# Lazy-imported to avoid the registry/CLI import cycle and to keep PP's
# token-load (which touches disk) out of the cli module top-level.
from .providers.pointspath.provider import is_configured as pp_is_configured # noqa: PLC0415
from .providers.registry import has_any_configured # noqa: PLC0415
from .providers.seats_aero.auth import is_configured as seats_is_configured # noqa: PLC0415

known: dict[str, bool] = {
"pp": pp_is_configured(),
"seats-aero": seats_is_configured(),
}

if not has_any_configured():
if sel.awards_only:
err.print(
"[red]--awards-only set but no award provider is configured.[/] "
"Run `flight auth pp login` or `flight auth seats-aero key <KEY>` first.",
)
raise typer.Exit(2)
return False
# Filter matches at least one configured provider? Values are already
# canonical (normalized by resolve_providers), so a direct membership
# check is enough here. None filter ⇒ "all enabled", short-circuits to True.
if sel.provider_filter is not None and not any(
known.get(name, False) for name in sel.provider_filter
):
if sel.awards_only:
err.print(
f"[red]--awards-only set but --providers={sel.provider_filter} "
"matches no configured provider.[/]",
)
raise typer.Exit(2)
return False
return True
Loading
Loading