Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ ignore = [
# (Matrix, PointsPath) — the attr names ARE the contract here.
"src/flight_cli/wire.py" = ["N815"]
"src/flight_cli/pp/models.py" = ["N815"]
"src/flight_cli/providers/seats_aero/models.py" = ["N815"]

[tool.ruff.format]
docstring-code-format = true
Expand Down
71 changes: 64 additions & 7 deletions src/flight_cli/_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,16 +45,26 @@ def provider_options(name: str, *, config: dict[str, Any] | None = None) -> dict
"""Pull the `[providers.<name>]` table out of config, or {} if absent.

Caller is responsible for type-coercing the values (lists stay as lists,
strings stay as strings — TOML's native typing is preserved)."""
strings stay as strings — TOML's native typing is preserved).

`name` is normalized through the alias map, AND all section keys in the
config are normalized before lookup — so `[providers.sa]` and
`[providers.seats-aero]` (and any of the aliased spellings) both work."""
cfg = config if config is not None else load()
providers_any: Any = cfg.get("providers", {})
if not isinstance(providers_any, dict):
return {}
providers_dict = cast("dict[str, Any]", providers_any)
section_any: Any = providers_dict.get(name, {})
if not isinstance(section_any, dict):
return {}
return cast("dict[str, Any]", section_any)
target = canonical_provider(name)
# Walk all sections and pick whichever matches the canonical of `name`.
# Reverse lookup makes the user's config-file spelling forgiving without
# forcing them to know about the canonical names.
for section_name, section_any in providers_dict.items():
if canonical_provider(section_name) != target:
continue
if isinstance(section_any, dict):
return cast("dict[str, Any]", section_any)
return {}


def parse_provider_opt_overrides(raw: list[str]) -> dict[str, dict[str, Any]]:
Expand All @@ -63,6 +73,17 @@ def parse_provider_opt_overrides(raw: list[str]) -> dict[str, dict[str, Any]]:
`pp.airlines=United,Delta` → `{"pp": {"airlines": ["United", "Delta"]}}`.
A bare scalar (no comma) stays a string; comma-list becomes list[str].

Provider names are normalized through the alias map — `sa.cabins=Business`
and `seats-aero.cabins=Business` both land at
`{"seats-aero": {"cabins": "Business"}}` so downstream consumers don't
have to know about user-visible spellings.

Note: seats.aero's brand contains a dot, but `seats.aero.cabins=X` parses
as provider="seats" / key="aero.cabins" (split on first dot). Users who
want the seats.aero brand spelling should write `'seats.aero.cabins=X'`
AND know that we'll split incorrectly; the documented forms are
`seats-aero` or `sa`.

Repeated keys for the same provider merge at the outer dict; same inner
key wins last (caller is responsible for order if it matters).
"""
Expand All @@ -75,8 +96,8 @@ def parse_provider_opt_overrides(raw: list[str]) -> dict[str, dict[str, Any]]:
if "." not in path:
msg = f"--provider-opt {item!r} missing '.'; expected 'provider.key=value'"
raise ValueError(msg)
provider, key = path.split(".", 1)
provider = provider.strip()
provider_raw, key = path.split(".", 1)
provider = canonical_provider(provider_raw)
key = key.strip()
if not provider or not key:
msg = f"--provider-opt {item!r}: provider and key must be non-empty"
Expand Down Expand Up @@ -156,6 +177,42 @@ def cache_disabled(*, config: dict[str, Any] | None = None) -> bool:
return False


# ─────────────────────────── provider names & aliases ────────────────────

# Canonical provider names used by the registry, config sections, and the
# --providers CLI filter. The aliases below normalize a user-friendly spelling
# (case-insensitive) onto the canonical name. Adding a new provider: add the
# canonical name + any aliases here, register the provider in registry.py,
# done. The naming is intentionally asymmetric — `pp` is the company's own
# short identifier (everyone in points-world calls it PP), so the canonical
# is `pp` and `pointspath` is the alias. Seats.aero has no widely-used
# short form, so the canonical is `seats-aero` and `sa` is the short alias.
_PROVIDER_ALIASES: dict[str, str] = {
"pp": "pp",
"pointspath": "pp",
"points-path": "pp",
"seats-aero": "seats-aero",
"seatsaero": "seats-aero",
"seats.aero": "seats-aero",
"sa": "seats-aero",
}


def canonical_provider(name: str) -> str:
"""Normalize a user-supplied provider name to its canonical form.

Case-insensitive. Unknown names pass through unchanged (lowercased) so
upstream code can still emit clean error messages naming the typo."""
return _PROVIDER_ALIASES.get(name.strip().lower(), name.strip().lower())


def known_canonical_providers() -> set[str]:
"""Set of canonical provider names. Useful for `--providers X` validation
where X must match at least one known provider before we can sensibly
construct an instance."""
return set(_PROVIDER_ALIASES.values())


# ─────────────────────────── provider options ─────────────────────────────


Expand Down
60 changes: 47 additions & 13 deletions src/flight_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,20 @@ def pp_cabins(self) -> str | None:
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
*,
Expand Down Expand Up @@ -360,15 +374,20 @@ def _resolve_providers( # noqa: PLR0912 — single-purpose validator + merge; s
if legacy_pp_only:
awards_only = True

# --providers parsing.
# --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(p.strip() for p in providers.split(",") if p.strip())
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.
# 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:
Expand All @@ -379,7 +398,7 @@ def _resolve_providers( # noqa: PLR0912 — single-purpose validator + merge; s
if isinstance(providers_section, dict):
for name, opts in cast("dict[str, Any]", providers_section).items():
if isinstance(opts, dict):
base_opts[name] = dict(cast("dict[str, Any]", opts))
base_opts[_config.canonical_provider(name)] = dict(cast("dict[str, Any]", opts))

try:
cli_opts = _config.parse_provider_opt_overrides(list(provider_opt))
Expand Down Expand Up @@ -409,27 +428,40 @@ def _resolve_providers( # noqa: PLR0912 — single-purpose validator + merge; s

def _should_run_awards(sel: ProviderSelection) -> bool:
"""Award providers run iff (a) not --cash-only, (b) at least one is
configured, and (c) the filter (if any) names at least one configured
provider. Mirrors the old _should_run_pp semantics for the PP-only era;
generalizes naturally once more providers exist.
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
# Today we only know about PP. has_any_configured() is provider-blind by
# construction (see providers/registry.py) so this stays correct when
# work-2eoa adds seats.aero.
# 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 --tokens-file ...` first.",
"Run `flight auth pp login` or `flight auth seats-aero key <KEY>` first.",
)
raise typer.Exit(2)
return False
if sel.provider_filter is not None and "pp" not in sel.provider_filter:
# Filter excludes PP; nothing else is configured yet.
# 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} "
Expand Down Expand Up @@ -702,6 +734,7 @@ def _run_matrix_path(
pp_only=sel.awards_only,
json_out=json_out,
provider_filter=sel.provider_filter,
seats_sources=sel.seats_sources(),
)
_emit_urls(search, matrix_url=matrix_url, google_url=google_url)

Expand Down Expand Up @@ -774,6 +807,7 @@ def _run_gflight_path(
pp_only=awards_only,
json_out=json_out,
provider_filter=sel.provider_filter if sel is not None else None,
seats_sources=sel.seats_sources() if sel is not None else None,
)


Expand Down
124 changes: 115 additions & 9 deletions src/flight_cli/pp/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

import json
import sys
from datetime import datetime
from datetime import UTC, datetime
from pathlib import Path # noqa: TC003 - typer evaluates annotations at runtime
from typing import TYPE_CHECKING, Annotated, Any

Expand Down Expand Up @@ -49,7 +49,7 @@
auth_app = typer.Typer(
add_completion=False,
no_args_is_help=True,
help="Auth helpers per provider. Today: PointsPath only.",
help="Auth helpers per provider.",
)
pp_auth_app = typer.Typer(
add_completion=False,
Expand All @@ -58,6 +58,94 @@
)
auth_app.add_typer(pp_auth_app, name="pp")

# Seats.aero sub-app is registered inline here to avoid a circular import
# (providers/seats_aero/auth.py is leaner than pp/auth.py and we keep the
# Typer wiring in this file rather than fanning out per-provider auth CLIs).
from ..providers.seats_aero import auth as _seats_auth # noqa: E402

seats_auth_app = typer.Typer(
add_completion=False,
no_args_is_help=True,
help="Seats.aero API key management.",
)
# Canonical mount point. `sa` is registered below as a convenience alias —
# same sub-app, different name. Users can type either `flight auth seats-aero`
# or `flight auth sa`; both reach the same commands.
auth_app.add_typer(seats_auth_app, name="seats-aero")
auth_app.add_typer(seats_auth_app, name="sa")


@seats_auth_app.command("key")
def seats_key(api_key: Annotated[str, typer.Argument(help="Partner API key (pro_...)")]) -> None:
"""Save a Seats.aero Pro API key to ~/.config/flight-cli/seats.json.

Overwrites any existing value. The file is written with 0600 perms.
Alternative: set the SEATS_AERO_API_KEY env var, which takes precedence
over the on-disk value at runtime.
"""
_seats_auth.save_key(api_key)
console.print(f"[green]Saved Seats.aero key to {_seats_auth.KEY_PATH}.[/]")


@seats_auth_app.command("whoami")
def seats_whoami() -> None:
"""Show whether a key is configured and probe the current quota.

Hits /partnerapi/search with a tiny query to grab the latest
X-RateLimit-Remaining header. Uses ~1 of your 1000/day quota.
"""
key = _seats_auth.load_key()
if key is None:
err.print("[yellow]No Seats.aero key configured.[/]")
err.print(f"Run `flight auth seats-aero key <KEY>` or set {_seats_auth.API_KEY_ENV}.")
raise typer.Exit(1)
source = (
"env" if _seats_auth.os.environ.get(_seats_auth.API_KEY_ENV) else str(_seats_auth.KEY_PATH)
)
console.print(f"[green]Seats.aero key configured[/] (source: {source})")

# Probe the quota. We import lazily to avoid pulling httpx unless asked.
from ..providers.seats_aero.client import SeatsAeroClient, SeatsAeroError # noqa: PLC0415

async def _probe() -> None:
async with SeatsAeroClient(api_key=key) as c:
try:
# Smallest-possible call: JFK-LHR, today, take=1, no trips.
await c.search(
origin="JFK",
destination="LHR",
start_date=datetime.now(UTC).date().isoformat(),
end_date=datetime.now(UTC).date().isoformat(),
include_trips=False,
take=1,
)
except SeatsAeroError as e:
err.print(f"[red]Probe failed: HTTP {e.status}[/]")
raise typer.Exit(1) from e
rl = c.last_rate_limit
if rl is None:
console.print("[yellow]No rate-limit headers returned.[/]")
else:
console.print(
f"Quota: {rl.remaining}/{rl.limit} remaining (resets in {rl.reset_seconds}s)"
)

anyio.run(_probe)


@seats_auth_app.command("logout")
def seats_logout() -> None:
"""Delete the on-disk Seats.aero key file.

Does not affect SEATS_AERO_API_KEY env var (which takes precedence
when set). After logout, the provider's `is_configured()` returns
False and `flight search --providers seats` will error.
"""
if _seats_auth.clear_key():
console.print(f"[green]Deleted {_seats_auth.KEY_PATH}.[/]")
else:
console.print("[yellow]No on-disk Seats.aero key to delete.[/]")


@pp_auth_app.command("login")
def pp_login(
Expand Down Expand Up @@ -196,19 +284,36 @@ def run_pp_for_search(
pp_only: bool = False,
json_out: bool = False,
provider_filter: tuple[str, ...] | None = None,
seats_sources: tuple[str, ...] | None = None,
) -> None:
"""Run award augmentation through the provider registry, join against
`res`'s cash itineraries, render. Today the registry hands back
PointsPath only; future providers (seats.aero) join via the same path.
`res`'s cash itineraries, render. Registry hands back any configured
providers (PointsPath, Seats.aero, ...); the matcher is provider-blind.

Errors are non-fatal — print and continue so the user still sees their
cash results.

The `pp_only` arg is named for historical reasons; today it means
"render in awards-only mode" — applies to whatever providers were
selected, not just PP.
"""
try:
get_valid_tokens() # validate + refresh up-front, surface a clear error
except PPAuthError as e:
err.print(f"[red]--pp: {e}[/]")
return
# PP tokens are required only if PP is actually going to run. Skip the
# pre-flight check when the filter excludes PP — otherwise a seats-only
# invocation errors here before seats even gets a chance to run.
pp_in_filter = provider_filter is None or any(
p.strip().lower() == "pp" for p in provider_filter
)
if pp_in_filter:
try:
get_valid_tokens() # validate + refresh up-front, surface a clear error
except PPAuthError as e:
# Soft-warn if PP fails but other providers might still run.
# When PP is the only target (filter explicitly == "pp"), it's
# a hard error; otherwise log and continue.
if provider_filter == ("pp",):
err.print(f"[red]--pp: {e}[/]")
return
err.print(f"[yellow]PointsPath skipped: {e}[/]")

cabin_list = tuple(_normalize_cabin(c) for c in _parse_csv(cabins, DEFAULT_CABINS))
explicit_airlines = _parse_csv(airlines, ()) if airlines else None
Expand All @@ -227,6 +332,7 @@ async def _go() -> tuple[list[list[AwardFlight]], list[Any]]:
num_passengers=num_passengers,
cabins=cabin_list,
pp_airlines=explicit_airlines,
seats_sources=seats_sources,
cash_hints_per_leg=cash_hints_per_leg,
provider_filter=provider_filter,
)
Expand Down
Loading
Loading