diff --git a/src/flight_cli/cli.py b/src/flight_cli/cli.py index 688668c..6f9d358 100644 --- a/src/flight_cli/cli.py +++ b/src/flight_cli/cli.py @@ -40,7 +40,8 @@ from .links import google_flights_url, matrix_deep_link from .log import configure as configure_logging from .pp.auth import load_tokens -from .pp.cli import LegQuery, auth_app, run_pp_for_search +from .pp.cli import auth_app, run_pp_for_search +from .providers.base import LegQuery if TYPE_CHECKING: from .models import CalendarResult, Location, SearchResult, Slice diff --git a/src/flight_cli/pp/cli.py b/src/flight_cli/pp/cli.py index 097a620..df90613 100644 --- a/src/flight_cli/pp/cli.py +++ b/src/flight_cli/pp/cli.py @@ -1,16 +1,15 @@ -"""CLI surface for PointsPath: `auth pp ...` subcommands + implicit augmentation. +"""CLI surface for PointsPath: `auth pp ...` subcommands + augmentation entry. -The augmentation entry point (`run_pp_for_search`) is wired into `search` on -the Matrix backend: it runs implicitly whenever valid PP tokens are loaded -(opt out with `--no-pp`). Runs PP airline-search in parallel with the -finished Matrix result, joins via match.py, and renders. +`run_pp_for_search` is wired into `flight search` (both backends): it runs +implicitly whenever any provider's tokens are present (opt out with `--no-pp`). +Iterates the provider registry, fans out each leg's queries in parallel, +joins via match.py, and renders. """ from __future__ import annotations import json import sys -from dataclasses import dataclass from datetime import datetime from pathlib import Path # noqa: TC003 - typer evaluates annotations at runtime from typing import TYPE_CHECKING, Annotated, Any @@ -20,6 +19,7 @@ from rich.console import Console from rich.table import Table +from ..providers.registry import gather_awards from .auth import ( TOKENS_PATH, PPAuthError, @@ -31,31 +31,12 @@ login_from_chrome, login_via_browser, ) -from .client import ( - DEFAULT_AIRLINES, - DEFAULT_CABINS, - PPClient, - SearchSpec, - enabled_airlines, -) +from .client import DEFAULT_CABINS from .match import MatchedFare, join if TYPE_CHECKING: from ..models import SearchResult - from .match import AwardOption - from .models import AirlineSearchResponse, PricingInfoResponse - - -@dataclass -class LegQuery: - """One leg's PP query. slice_index points at the corresponding Slice in - each Itinerary returned by Matrix; label is the user-facing leg name.""" - - origin: str - destination: str - date: str # YYYY-MM-DD - slice_index: int - label: str # e.g. "outbound JFK→LHR" or "return LHR→JFK" + from ..providers.base import AwardFlight, LegQuery console = Console() @@ -212,12 +193,12 @@ def run_pp_for_search( pp_only: bool = False, json_out: bool = False, ) -> None: - """Called by the `fare` command when --pp is on. For each leg, runs PP - queries (one per airline x cabin), joins against the corresponding Slice - in each Itinerary, and renders one table per leg. + """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. Errors are non-fatal — print and continue so the user still sees their - Matrix results. + cash results. """ try: get_valid_tokens() # validate + refresh up-front, surface a clear error @@ -226,93 +207,53 @@ def run_pp_for_search( return cabin_list = tuple(_normalize_cabin(c) for c in _parse_csv(cabins, DEFAULT_CABINS)) - # Resolve airline list: explicit --pp-airlines wins; otherwise fetch the - # tier-enabled set from extension-config + pricing-info (cached). Static - # DEFAULT_AIRLINES is the last-resort fallback if discovery fails. explicit_airlines = _parse_csv(airlines, ()) if airlines else None - async def _go() -> tuple[list[dict[str, AirlineSearchResponse]], PricingInfoResponse]: - return await _gather_pp( + async def _go() -> tuple[list[list[AwardFlight]], list[Any]]: + return await gather_awards( legs=legs, num_passengers=num_passengers, - explicit_airlines=explicit_airlines, cabins=cabin_list, + pp_airlines=explicit_airlines, ) try: - per_leg, pricing = anyio.run(_go) + per_leg, providers = anyio.run(_go) except Exception as e: # noqa: BLE001 — surface anything to user, don't crash CLI - err.print(f"[red]--pp: PointsPath query failed: {e}[/]") + err.print(f"[red]--pp: award query failed: {e}[/]") return - if pp_only: - # JSON: dump per-leg award listings; pretty: one PP-only table per leg. + try: + if pp_only: + if json_out: + sys.stdout.write(_serialize_pp_only_per_leg(per_leg, legs)) + return + for leg, awards in zip(legs, per_leg, strict=True): + console.print(f"\n[bold]Leg: {leg.label}[/]") + _render_pp_only(awards) + return + + matches_per_leg: list[list[MatchedFare]] = [ + join(res, awards, slice_index=leg.slice_index) + for leg, awards in zip(legs, per_leg, strict=True) + ] if json_out: - sys.stdout.write(_serialize_pp_only_per_leg(per_leg, legs)) + sys.stdout.write(_serialize_matches_per_leg(matches_per_leg, legs)) return - for leg, merged in zip(legs, per_leg, strict=True): + for leg, matches in zip(legs, matches_per_leg, strict=True): console.print(f"\n[bold]Leg: {leg.label}[/]") - _render_pp_only(merged, pricing) - return + _render_matches(matches, cabin_list, slice_index=leg.slice_index) + finally: + # Providers hold HTTP keepalive; close them so the event loop doesn't + # warn about unclosed transports on exit. + anyio.run(_aclose_all, providers) - # Cash + award per leg. - matches_per_leg: list[list[MatchedFare]] = [ - join(res, merged, pricing, slice_index=leg.slice_index) - for leg, merged in zip(legs, per_leg, strict=True) - ] - if json_out: - sys.stdout.write(_serialize_matches_per_leg(matches_per_leg, legs)) - return - for leg, matches in zip(legs, matches_per_leg, strict=True): - console.print(f"\n[bold]Leg: {leg.label}[/]") - _render_matches(matches, cabin_list, slice_index=leg.slice_index) - -async def _gather_pp( - *, - legs: list[LegQuery], - num_passengers: int, - explicit_airlines: tuple[str, ...] | None, - cabins: tuple[str, ...], -) -> tuple[list[dict[str, AirlineSearchResponse]], PricingInfoResponse]: - """Per leg, fan out PP airline-search across (airlines x cabins). Returns - one merged airline→response map per leg, plus the shared pricing-info.""" - async with await PPClient.create() as c: - pricing = await c.pricing_info() - if explicit_airlines: - airlines = explicit_airlines - else: - try: - ext_cfg = await c.extension_config() - airlines = enabled_airlines(pricing, ext_cfg) - if not airlines: - airlines = DEFAULT_AIRLINES - except Exception: # noqa: BLE001 — fall back to static default - airlines = DEFAULT_AIRLINES - per_leg: list[dict[str, AirlineSearchResponse]] = [] - for leg in legs: - merged: dict[str, AirlineSearchResponse] = {} - for cabin in cabins: - spec = SearchSpec( - origin=leg.origin, - destination=leg.destination, - date=leg.date, - return_date="", - is_round_trip_return=False, - num_passengers=num_passengers, - cabin_class=cabin, - enable_matching=False, - ) - per_airline = await c.airline_search_many(spec, airlines) - for airline, resp in per_airline.items(): - if airline not in merged: - merged[airline] = resp - continue - # Append flights from later cabin queries; the matcher - # tolerates same-key duplicates without double-counting. - merged[airline].outboundFlights.extend(resp.outboundFlights) - per_leg.append(merged) - return per_leg, pricing +async def _aclose_all(providers: list[Any]) -> None: + for p in providers: + aclose = getattr(p, "aclose", None) + if aclose is not None: + await aclose() # ──────────────────────────── render: matched ────────────────────────────── @@ -348,27 +289,27 @@ def _fmt_miles(n: int) -> str: return f"{n / 1000:.1f}k" if n >= _MILES_K_THRESHOLD else str(n) -def _fmt_award_cell(award_options: list[AwardOption], want_cabin: str) -> str: - """Render the best (lowest miles) offer across airlines for one cabin.""" +def _fmt_award_cell(award_flights: list[AwardFlight], want_cabin: str) -> str: + """Render the best (lowest miles) offer across providers for one cabin.""" best: tuple[int, float, str, list[str]] | None = None - for ao in award_options: - for ca in ao.cabins: + for af in award_flights: + for ca in af.cabins: if ca.cabin != want_cabin: continue - key = (ca.miles, ca.tax_usd, ao.airline, ao.funding_banks) + key = (ca.miles, ca.tax_usd, af.program, af.funding_banks) if best is None or key[0] < best[0]: best = key if best is None: return "—" - miles, tax, airline, _banks = best - return f"{_fmt_miles(miles)} {airline} + ${tax:.0f}" + miles, tax, program, _banks = best + return f"{_fmt_miles(miles)} {program} + ${tax:.0f}" -def _fmt_funding(award_options: list[AwardOption]) -> str: +def _fmt_funding(award_flights: list[AwardFlight]) -> str: banks: list[str] = [] seen: set[str] = set() - for ao in award_options: - for b in ao.funding_banks: + for af in award_flights: + for b in af.funding_banks: if b not in seen: seen.add(b) banks.append(b) @@ -447,10 +388,10 @@ def _render_matches( for cab in cabin_list: cells.append(_fmt_award_cell(m.awards, cab)) if cab == "Economy" and cash_val is not None: - # CPM uses cheapest economy across airlines. + # CPM uses cheapest economy across providers. best_y: tuple[int, float] | None = None - for ao in m.awards: - for ca in ao.cabins: + for af in m.awards: + for ca in af.cabins: if ca.cabin == "Economy" and (best_y is None or ca.miles < best_y[0]): best_y = (ca.miles, ca.tax_usd) if best_y is not None: @@ -466,44 +407,63 @@ def _render_matches( # ──────────────────────────── render: pp-only ────────────────────────────── -def _render_pp_only( - merged: dict[str, AirlineSearchResponse], - pricing: PricingInfoResponse, -) -> None: - rows: list[tuple[str, str, str, str, str, int, float, str]] = [] - pricing_idx = {p.airline: p for p in pricing.pricingInfos} - for airline, resp in merged.items(): - pi = pricing_idx.get(airline) - banks = ", ".join(b.bank for b in (pi.bankPointsInfos if pi else [])) - for of in resp.outboundFlights: - for c in of.perCabinMilesPricing: - pp = c.perPassengerPricing - if not pp or pp.perPassengerMilesAmount <= 0: - continue - rows.append( - ( - airline, - of.firstFlightNumber, - f"{of.origin}→{of.destination}", - of.localDepartureDateTime[:16], - c.cabinClass, - pp.perPassengerMilesAmount, - pp.perPassengerTaxAmountUsd, - banks, - ) - ) - rows.sort(key=lambda r: (r[3], r[5])) # by departure, then miles - t = Table(title="PointsPath award availability", show_header=True, header_style="bold cyan") - for col in ("airline", "flight", "route", "departs", "cabin", "miles", "tax", "funded by"): +def _render_pp_only(awards: list[AwardFlight]) -> None: + """One leg's provider-merged awards as a flat table. Multi-provider + today is degenerate (PP only); the table just shows `provider | program` + so when seats.aero lands the surface doesn't need to change.""" + rows: list[tuple[str, str, str, str, str, str, int, float, str]] = [] + for af in awards: + for c in af.cabins: + if c.miles <= 0: + continue + rows.append( + ( + af.provider, + af.program, + af.flight_number, + f"{af.origin}→{af.destination}", + af.departure[:16], + c.cabin, + c.miles, + c.tax_usd, + ", ".join(af.funding_banks), + ), + ) + rows.sort(key=lambda r: (r[4], r[6])) # by departure, then miles + t = Table(title="Award availability", show_header=True, header_style="bold cyan") + cols = ("source", "program", "flight", "route", "departs", "cabin", "miles", "tax", "funded by") + for col in cols: t.add_column(col) for r in rows: - t.add_row(r[0], r[1], r[2], r[3], r[4], _fmt_miles(r[5]), f"${r[6]:.0f}", r[7]) + t.add_row(r[0], r[1], r[2], r[3], r[4], r[5], _fmt_miles(r[6]), f"${r[7]:.0f}", r[8]) console.print(t) # ─────────────────────────────── json shapes ─────────────────────────────── +def _serialize_award(af: AwardFlight) -> dict[str, Any]: + return { + "provider": af.provider, + "program": af.program, + "miles_to_cash_ratio": af.miles_to_cash_ratio, + "funding_banks": af.funding_banks, + "matched_origin": af.origin, + "matched_destination": af.destination, + "matched_departure": af.departure, + "flight_number": af.flight_number, + "cabins": [ + { + "cabin": c.cabin, + "miles": c.miles, + "tax_usd": c.tax_usd, + "tax_currency": c.tax_currency, + } + for c in af.cabins + ], + } + + def _serialize_matches(matches: list[MatchedFare]) -> str: out: list[dict[str, Any]] = [] for m in matches: @@ -516,27 +476,8 @@ def _serialize_matches(matches: list[MatchedFare]) -> str: "origin": (s.origin.code if s and s.origin else None), "destination": (s.destination.code if s and s.destination else None), "cash_price": m.itinerary.price, - "awards": [ - { - "airline": ao.airline, - "miles_to_cash_ratio": ao.miles_to_cash_ratio, - "funding_banks": ao.funding_banks, - "matched_origin": ao.flight.origin, - "matched_destination": ao.flight.destination, - "matched_departure": ao.flight.localDepartureDateTime, - "cabins": [ - { - "cabin": c.cabin, - "miles": c.miles, - "tax_usd": c.tax_usd, - "tax_currency": c.tax_currency, - } - for c in ao.cabins - ], - } - for ao in m.awards - ], - } + "awards": [_serialize_award(af) for af in m.awards], + }, ) return json.dumps(out, indent=2) @@ -558,7 +499,7 @@ def _serialize_matches_per_leg( def _serialize_pp_only_per_leg( - per_leg: list[dict[str, AirlineSearchResponse]], + per_leg: list[list[AwardFlight]], legs: list[LegQuery], ) -> str: return json.dumps( @@ -566,9 +507,9 @@ def _serialize_pp_only_per_leg( { "leg": leg.label, "slice_index": leg.slice_index, - "by_airline": {a: r.model_dump() for a, r in merged.items()}, + "awards": [_serialize_award(af) for af in awards], } - for leg, merged in zip(legs, per_leg, strict=True) + for leg, awards in zip(legs, per_leg, strict=True) ], indent=2, ) diff --git a/src/flight_cli/pp/match.py b/src/flight_cli/pp/match.py index cf10b2a..39fb0a9 100644 --- a/src/flight_cli/pp/match.py +++ b/src/flight_cli/pp/match.py @@ -1,4 +1,4 @@ -"""Join Matrix cash itineraries to PointsPath award flights. +"""Join Matrix cash itineraries to award flights. Primary key: (normalized first-segment flight number, ISO departure date). Same key can appear at most once per side per day, so a dict-lookup is enough. @@ -11,8 +11,9 @@ keys can't bridge that, but route+time can: both sources read the same airline-published schedule, so origin+dest+minute is a near-tight identity. -Outputs MatchedFare records, one per cash itinerary, with optional award -data attached. Caller renders. +The join is provider-neutral: it takes a flat `list[AwardFlight]` (each +provider produces these from its own raw shape — see providers/base.py) +and outputs MatchedFare records. """ from __future__ import annotations @@ -22,13 +23,7 @@ if TYPE_CHECKING: from ..models import Itinerary, SearchResult - from .models import ( - AirlineSearchResponse, - OutboundFlight, - PerCabinMilesPricing, - PricingInfo, - PricingInfoResponse, - ) + from ..providers.base import AwardFlight MatchKey = tuple[str, str] # (FLIGHT_NUMBER_UPPER_NOSPACE, "YYYY-MM-DD") RouteTimeKey = tuple[str, str, str] # (ORIGIN_UPPER, DEST_UPPER, "YYYY-MM-DDTHH:MM") @@ -79,8 +74,8 @@ def cash_match_key(it: Itinerary, slice_index: int = 0) -> MatchKey | None: return (fn, dep) -def award_match_key(of: OutboundFlight) -> MatchKey: - return (_norm_fn(of.firstFlightNumber), _iso_date(of.localDepartureDateTime)) +def award_match_key(af: AwardFlight) -> MatchKey: + return (_norm_fn(af.flight_number), _iso_date(af.departure)) def cash_route_time_key(it: Itinerary, slice_index: int = 0) -> RouteTimeKey | None: @@ -100,81 +95,35 @@ def cash_route_time_key(it: Itinerary, slice_index: int = 0) -> RouteTimeKey | N return (o, d, t) -def award_route_time_key(of: OutboundFlight) -> RouteTimeKey | None: - o = (of.origin or "").upper() - d = (of.destination or "").upper() - t = _iso_minute(of.localDepartureDateTime) +def award_route_time_key(af: AwardFlight) -> RouteTimeKey | None: + o = (af.origin or "").upper() + d = (af.destination or "").upper() + t = _iso_minute(af.departure) if not (o and d and t): return None return (o, d, t) -@dataclass -class CabinAward: - """One cabin's award price for a single flight.""" - - cabin: str # "Economy" / "Business" / etc. - miles: int - tax_usd: float - tax_currency: str - is_basic_economy: bool | None = None - - -@dataclass -class AwardOption: - """All cabin offerings for a single matched flight, plus transfer info.""" - - airline: str # PointsPath canonical name (e.g. "United") - miles_to_cash_ratio: float # PointsPath valuation (¢/mi) - flight: OutboundFlight - cabins: list[CabinAward] = field(default_factory=list) - funding_banks: list[str] = field(default_factory=list) - - @dataclass class MatchedFare: - """One cash itinerary with zero-or-more award options attached.""" + """One cash itinerary with zero-or-more award flights attached.""" itinerary: Itinerary - awards: list[AwardOption] = field(default_factory=list) - - -def _cabin_awards(pricing: list[PerCabinMilesPricing]) -> list[CabinAward]: - out: list[CabinAward] = [] - for p in pricing: - pp = p.perPassengerPricing - if not pp or pp.perPassengerMilesAmount <= 0: - continue - out.append( - CabinAward( - cabin=p.cabinClass, - miles=pp.perPassengerMilesAmount, - tax_usd=pp.perPassengerTaxAmountUsd, - tax_currency=pp.taxCurrencyCode or "USD", - is_basic_economy=pp.isBasicEconomyFare, - ) - ) - return out - - -def _index_pricing(pi: PricingInfoResponse) -> dict[str, PricingInfo]: - return {p.airline: p for p in pi.pricingInfos} + awards: list[AwardFlight] = field(default_factory=list) def join( search: SearchResult, - award_by_airline: dict[str, AirlineSearchResponse], - pricing: PricingInfoResponse, + awards: list[AwardFlight], *, slice_index: int = 0, - use_inbound: bool = False, ) -> list[MatchedFare]: """Outer-join cash itineraries onto award flights. Match strategy: primary by (flight#, date), then a route+time fallback to catch codeshares (Matrix's marketing flight# won't equal PP's operating flight#, but origin+dest+minute identifies the same physical flight). - Hits from both keys are unioned and deduped by OutboundFlight identity, so + Hits from both keys are unioned and deduped by AwardFlight identity, so non-codeshare flights aren't double-attached. Cash itineraries with no award match keep an empty `awards` list — caller @@ -182,57 +131,39 @@ def join( `slice_index` selects which leg of each Itinerary to match against (0 for outbound, 1 for return on a round-trip, etc). - - `use_inbound` reads from `inboundFlights` instead of `outboundFlights` — - set when joining the return leg of a round-trip query whose response is - a single bidirectional record. """ - pricing_idx = _index_pricing(pricing) - # Build both award indexes in one pass over the flights. A single flight # may appear under both — that's expected; per-itinerary dedup in the # join loop keeps the output clean. - fn_idx: dict[MatchKey, list[tuple[str, OutboundFlight]]] = {} - rt_idx: dict[RouteTimeKey, list[tuple[str, OutboundFlight]]] = {} - for airline, resp in award_by_airline.items(): - flights = resp.inboundFlights if use_inbound else resp.outboundFlights - for of in flights: - fn_k = award_match_key(of) - if fn_k[0]: - fn_idx.setdefault(fn_k, []).append((airline, of)) - rt_k = award_route_time_key(of) - if rt_k: - rt_idx.setdefault(rt_k, []).append((airline, of)) + fn_idx: dict[MatchKey, list[AwardFlight]] = {} + rt_idx: dict[RouteTimeKey, list[AwardFlight]] = {} + for af in awards: + fn_k = award_match_key(af) + if fn_k[0]: + fn_idx.setdefault(fn_k, []).append(af) + rt_k = award_route_time_key(af) + if rt_k: + rt_idx.setdefault(rt_k, []).append(af) out: list[MatchedFare] = [] for it in search.solutions: - awards: list[AwardOption] = [] + matched: list[AwardFlight] = [] seen_ids: set[int] = set() - # Collect (airline, OutboundFlight) hits from both keys, then convert - # to AwardOption once per unique flight. - hits: list[tuple[str, OutboundFlight]] = [] fn_k = cash_match_key(it, slice_index=slice_index) if fn_k and fn_k in fn_idx: - hits.extend(fn_idx[fn_k]) + for af in fn_idx[fn_k]: + if id(af) in seen_ids: + continue + seen_ids.add(id(af)) + matched.append(af) rt_k = cash_route_time_key(it, slice_index=slice_index) if rt_k and rt_k in rt_idx: - hits.extend(rt_idx[rt_k]) - - for airline, of in hits: - if id(of) in seen_ids: - continue - seen_ids.add(id(of)) - pi = pricing_idx.get(airline) - awards.append( - AwardOption( - airline=airline, - miles_to_cash_ratio=pi.milesToCashRatio if pi else 0.0, - flight=of, - cabins=_cabin_awards(of.perCabinMilesPricing), - funding_banks=[b.bank for b in (pi.bankPointsInfos if pi else [])], - ) - ) - - out.append(MatchedFare(itinerary=it, awards=awards)) + for af in rt_idx[rt_k]: + if id(af) in seen_ids: + continue + seen_ids.add(id(af)) + matched.append(af) + + out.append(MatchedFare(itinerary=it, awards=matched)) return out diff --git a/src/flight_cli/providers/__init__.py b/src/flight_cli/providers/__init__.py new file mode 100644 index 0000000..afa0510 --- /dev/null +++ b/src/flight_cli/providers/__init__.py @@ -0,0 +1,10 @@ +"""Award providers (PointsPath, seats.aero, ...). + +`base.py` defines the provider-neutral types and the `AwardProvider` Protocol. +`registry.py` discovers which providers are configured + enabled. +`pointspath/` holds the PointsPath-specific shim that adapts `pp/client.py` to +the Protocol. + +Existing imports from `flight_cli.pp` still work — the move of pp/{auth,client, +match,models}.py into providers/pointspath/ is a separate, mechanical follow-up. +""" diff --git a/src/flight_cli/providers/base.py b/src/flight_cli/providers/base.py new file mode 100644 index 0000000..dda4b6c --- /dev/null +++ b/src/flight_cli/providers/base.py @@ -0,0 +1,111 @@ +"""Provider-neutral types for the award-augmentation pipeline. + +`AwardFlight` is the normalized shape every provider produces: one +airline+flight+date+cabin-set bundle, plus per-provider metadata (program, +funding banks, valuation). `match.py:join` indexes a flat `list[AwardFlight]` +by both (flight#, date) and (origin, dest, departure-minute) keys to bridge +codeshares (the marketing flight# != operating flight# case). + +`AwardProvider` is the Protocol every source implements. Today: PointsPath. +Planned: seats.aero (work-2eoa). The `enabled` flag gates whether a provider +runs without raising — a provider with missing tokens reports `enabled=False` +and the registry skips it silently. + +`LegQuery` is the per-leg input. It carried over unchanged from `pp/cli.py` +and is provider-agnostic by construction. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Protocol, runtime_checkable + + +@dataclass +class LegQuery: + """One leg's award query. `slice_index` points at the corresponding Slice + in each cash Itinerary; `label` is the user-facing leg name (e.g. + "outbound JFK→LHR").""" + + origin: str + destination: str + date: str # YYYY-MM-DD + slice_index: int + label: str + + +@dataclass +class CabinAward: + """One cabin's award price for a single flight. Provider-neutral.""" + + cabin: str # "Economy" / "Business" / "First" / provider-specific synonyms + miles: int + tax_usd: float + tax_currency: str + is_basic_economy: bool | None = None + + +@dataclass +class AwardFlight: + """One award flight, normalized across providers. + + Identity fields (origin/destination/departure/flight_number) are what + `match.py` joins on. The rest is per-provider metadata that the renderer + consumes verbatim. + """ + + # identity — used for matching against cash itineraries. + # `departure` is ISO local "YYYY-MM-DDTHH:MM:SS" (or :HH:MM — matcher + # tolerates either). `flight_number` is the marketing flight number; + # matcher normalizes case + whitespace before comparison. + origin: str + destination: str + departure: str + arrival: str + flight_number: str + num_connections: int = 0 + + # provider/program metadata — used for rendering only + provider: str = "" # display name, e.g. "PointsPath", "seats.aero" + program: str = "" # mileage program, e.g. "United", "American Airlines" + miles_to_cash_ratio: float = 0.0 # provider's valuation in ¢/mi + funding_banks: list[str] = field(default_factory=list[str]) + + cabins: list[CabinAward] = field(default_factory=list[CabinAward]) + + +@runtime_checkable +class AwardProvider(Protocol): + """The interface every award source implements. + + A provider knows: (a) whether it's configured/enabled (tokens/keys), + (b) how to search a single leg and return a normalized AwardFlight list. + + The streaming `stream()` method in the issue body is deferred until we + actually have ≥2 providers with materially different latencies; one-shot + `search_leg` is enough for the cash → augmented render-replace pattern. + """ + + name: str + + @property + def enabled(self) -> bool: + """True iff tokens/keys are present and (best-effort) non-expired. + + A False provider is silently skipped by the registry — no hard error + unless the user explicitly asked for it via `---only`.""" + ... + + async def search_leg( + self, + leg: LegQuery, + *, + cabins: tuple[str, ...], + num_passengers: int = 1, + ) -> list[AwardFlight]: + """Run all per-airline (or whatever the provider's atomic unit is) + queries for one leg across the given cabins, return the merged + normalized flights. Errors are the provider's to log; the function + should return `[]` on failure rather than raising, so one provider's + outage doesn't sink the augmented render.""" + ... diff --git a/src/flight_cli/providers/pointspath/__init__.py b/src/flight_cli/providers/pointspath/__init__.py new file mode 100644 index 0000000..4d083b9 --- /dev/null +++ b/src/flight_cli/providers/pointspath/__init__.py @@ -0,0 +1,11 @@ +"""PointsPath as an AwardProvider. + +The provider lives in providers/pointspath/provider.py. Reads from +flight_cli.pp.{auth,client,models} for now; those files will physically +move into this package in a follow-up rename PR, with pp/ kept as a +deprecation shim re-exporting from here for one release. +""" + +from .provider import PointsPathProvider + +__all__ = ["PointsPathProvider"] diff --git a/src/flight_cli/providers/pointspath/provider.py b/src/flight_cli/providers/pointspath/provider.py new file mode 100644 index 0000000..e2d4a9c --- /dev/null +++ b/src/flight_cli/providers/pointspath/provider.py @@ -0,0 +1,215 @@ +"""PointsPath adapter: PPClient + pricing-info → list[AwardFlight]. + +One PPClient is reused across legs (open one HTTP client, share the +semaphore-bounded fan-out). Pricing-info is fetched once per +`enabled_airlines`-aware run and reused across legs/cabins. + +Failure model: any per-leg/per-airline error is swallowed and logged via the +existing structlog channels (PPClient already does this). The provider +returns `[]` on whole-provider failure so the registry can move on. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import structlog + +from ...pp.auth import PPAuthError, get_valid_tokens +from ...pp.client import DEFAULT_AIRLINES, PPClient, SearchSpec, enabled_airlines +from ..base import AwardFlight, CabinAward + +if TYPE_CHECKING: + from structlog.stdlib import BoundLogger + + from ...pp.models import ( + AirlineSearchResponse, + OutboundFlight, + PerCabinMilesPricing, + PricingInfoResponse, + ) + from ..base import LegQuery + +log: BoundLogger = structlog.get_logger(__name__) # pyright: ignore[reportAny] + + +def _cabin_awards(pricing: list[PerCabinMilesPricing]) -> list[CabinAward]: + """Lifted from pp.match._cabin_awards to keep the conversion close to the + provider. The matcher's copy stays for now to avoid breaking imports until + the matcher refactor lands; the duplication is intentionally short-lived.""" + out: list[CabinAward] = [] + for p in pricing: + pp = p.perPassengerPricing + if not pp or pp.perPassengerMilesAmount <= 0: + continue + out.append( + CabinAward( + cabin=p.cabinClass, + miles=pp.perPassengerMilesAmount, + tax_usd=pp.perPassengerTaxAmountUsd, + tax_currency=pp.taxCurrencyCode or "USD", + is_basic_economy=pp.isBasicEconomyFare, + ), + ) + return out + + +def _flight_to_award( + of: OutboundFlight, + *, + program: str, + miles_to_cash_ratio: float, + funding_banks: list[str], +) -> AwardFlight: + return AwardFlight( + origin=of.origin, + destination=of.destination, + departure=of.localDepartureDateTime, + arrival=of.localArrivalDateTime, + flight_number=of.firstFlightNumber, + num_connections=of.numConnections, + provider="PointsPath", + program=program, + miles_to_cash_ratio=miles_to_cash_ratio, + funding_banks=funding_banks, + cabins=_cabin_awards(of.perCabinMilesPricing), + ) + + +class PointsPathProvider: + """AwardProvider implementation wrapping pp.client.PPClient. + + Construct with `await PointsPathProvider.create(...)`; the factory + eagerly validates tokens so a stale-refresh case surfaces before the + registry hands the provider out for use. + + Accepts overrides for the airline + cabin set so the CLI's + --pp-airlines / --pp-cabin flags can pass-through unchanged. Without + overrides, the provider derives the airline universe from the user's + extension-config feature flags (the same logic the old run_pp_for_search + used). + """ + + name: str = "PointsPath" + + def __init__( + self, + client: PPClient, + pricing: PricingInfoResponse, + airlines: tuple[str, ...], + ) -> None: + self._client = client + self._pricing = pricing + self._airlines = airlines + # airline -> (miles_to_cash_ratio, funding_banks) for fast metadata lookup + self._meta: dict[str, tuple[float, list[str]]] = { + p.airline: (p.milesToCashRatio, [b.bank for b in p.bankPointsInfos]) + for p in pricing.pricingInfos + } + + @classmethod + async def create( + cls, + *, + explicit_airlines: tuple[str, ...] | None = None, + ) -> PointsPathProvider: + """Build a configured provider. Raises PPAuthError if tokens are + missing/expired — caller (registry) is expected to catch and skip.""" + get_valid_tokens() # surface auth errors up-front + client = await PPClient.create() + pricing = await client.pricing_info() + if explicit_airlines: + airlines = explicit_airlines + else: + try: + ext_cfg = await client.extension_config() + airlines = enabled_airlines(pricing, ext_cfg) + if not airlines: + airlines = DEFAULT_AIRLINES + except Exception as e: # noqa: BLE001 — falling back is non-fatal by design + log.warning("pp_provider_ext_config_fallback", error=str(e)) + airlines = DEFAULT_AIRLINES + return cls(client, pricing, airlines) + + async def aclose(self) -> None: + await self._client.aclose() + + @property + def pricing(self) -> PricingInfoResponse: + """Exposed so the PP-only renderer can keep using its provider- + specific table during the transition period. Removed when the + renderer migrates to AwardFlight-only inputs.""" + return self._pricing + + @property + def enabled(self) -> bool: + # Once constructed via create(), enabled is implied — create() raises + # PPAuthError otherwise. The Protocol still demands the property. + return True + + async def search_leg( + self, + leg: LegQuery, + *, + cabins: tuple[str, ...], + num_passengers: int = 1, + ) -> list[AwardFlight]: + """Fan out one airline-search call per (airline x cabin) for this + leg, merge results, convert to AwardFlight. Duplicate flights across + cabins are tolerated — the matcher dedupes by identity downstream.""" + merged: dict[str, AirlineSearchResponse] = {} + for cabin in cabins: + spec = SearchSpec( + origin=leg.origin, + destination=leg.destination, + date=leg.date, + return_date="", + is_round_trip_return=False, + num_passengers=num_passengers, + cabin_class=cabin, + enable_matching=False, + ) + per_airline = await self._client.airline_search_many(spec, self._airlines) + for airline, resp in per_airline.items(): + if airline not in merged: + merged[airline] = resp + continue + merged[airline].outboundFlights.extend(resp.outboundFlights) + + out: list[AwardFlight] = [] + for airline, resp in merged.items(): + ratio, banks = self._meta.get(airline, (0.0, [])) + for of in resp.outboundFlights: + out.append( + _flight_to_award( + of, + program=airline, + miles_to_cash_ratio=ratio, + funding_banks=banks, + ), + ) + return out + + def by_airline_for_leg(self, awards: list[AwardFlight]) -> dict[str, list[AwardFlight]]: + """Convenience view: group an AwardFlight list by program (airline). + Used by the PP-only renderer during the transition; removed once + the renderer takes AwardFlight directly.""" + out: dict[str, list[AwardFlight]] = {} + for a in awards: + out.setdefault(a.program, []).append(a) + return out + + +# Auto-detect entry point used by registry.py. +def is_configured() -> bool: + """True iff PP tokens are present and (best-effort) usable. + + Doesn't network: returns False if tokens are missing or fail the + in-memory validity check. Token refresh (which does network) happens + inside PPClient itself when the provider is actually invoked. + """ + try: + get_valid_tokens() + except PPAuthError: + return False + return True diff --git a/src/flight_cli/providers/registry.py b/src/flight_cli/providers/registry.py new file mode 100644 index 0000000..fe155cb --- /dev/null +++ b/src/flight_cli/providers/registry.py @@ -0,0 +1,115 @@ +"""Provider registry + per-leg fan-out. + +Today: hardcoded PointsPath entry. When seats.aero lands (work-2eoa) it +joins via the same `_discover` list. The auto-enable rule is: provider's +configuration check passes → instance constructed → leg fan-out includes it. + +The fan-out gathers awards from all enabled providers in parallel for each +leg, then concatenates them. The matcher is provider-blind: a flat +`list[AwardFlight]` is exactly what it consumes. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import anyio +import structlog + +from .pointspath.provider import PointsPathProvider +from .pointspath.provider import is_configured as pp_is_configured + +if TYPE_CHECKING: + from structlog.stdlib import BoundLogger + + from .base import AwardFlight, AwardProvider, LegQuery + +log: BoundLogger = structlog.get_logger(__name__) # pyright: ignore[reportAny] + + +async def _construct_enabled( + *, + pp_airlines: tuple[str, ...] | None = None, +) -> list[AwardProvider]: + """Build the list of enabled provider instances. + + Each provider's auto-enable check (`is_configured`) runs first; only + configured providers get instantiated (which is when network/auth + actually happens). Failures during construction are logged and swallowed + so one provider's outage can't take down the others. + """ + out: list[AwardProvider] = [] + if pp_is_configured(): + try: + out.append(await PointsPathProvider.create(explicit_airlines=pp_airlines)) + except Exception as e: # noqa: BLE001 — per-provider failures are non-fatal + log.warning("provider_init_failed", provider="PointsPath", error=str(e)) + return out + + +async def _gather_one_leg( + providers: list[AwardProvider], + leg: LegQuery, + *, + cabins: tuple[str, ...], + num_passengers: int = 1, +) -> list[AwardFlight]: + """Run all providers concurrently for one leg, concatenate results.""" + results: list[list[AwardFlight]] = [[] for _ in providers] + + async def runner(idx: int, p: AwardProvider) -> None: + try: + results[idx] = await p.search_leg( + leg, + cabins=cabins, + num_passengers=num_passengers, + ) + except Exception as e: # noqa: BLE001 — surface provider failures, keep others + log.warning("provider_search_failed", provider=p.name, error=str(e)) + + async with anyio.create_task_group() as tg: + for i, p in enumerate(providers): + tg.start_soon(runner, i, p) + + out: list[AwardFlight] = [] + for r in results: + out.extend(r) + return out + + +async def gather_awards( + legs: list[LegQuery], + *, + cabins: tuple[str, ...], + num_passengers: int = 1, + pp_airlines: tuple[str, ...] | None = None, +) -> tuple[list[list[AwardFlight]], list[AwardProvider]]: + """End-to-end registry call: construct enabled providers, fan out per leg. + + Returns: + (per_leg_awards, providers) + per_leg_awards[i] is the flattened-across-providers list of awards + for legs[i]. + providers is the constructed provider instances — the caller is + responsible for closing them (PointsPath uses HTTP keepalive). + """ + providers = await _construct_enabled(pp_airlines=pp_airlines) + per_leg: list[list[AwardFlight]] = [] + for leg in legs: + per_leg.append( + await _gather_one_leg( + providers, + leg, + cabins=cabins, + num_passengers=num_passengers, + ), + ) + return per_leg, providers + + +def has_any_configured() -> bool: + """Cheap check: is at least one provider's auto-enable predicate true? + + Used by the CLI's `--pp-only`/`--no-pp` gating in `_should_run_pp` so we + can keep that decision provider-blind.""" + return pp_is_configured() diff --git a/tests/pp/test_match.py b/tests/pp/test_match.py index 86c2ba3..022366c 100644 --- a/tests/pp/test_match.py +++ b/tests/pp/test_match.py @@ -6,9 +6,6 @@ from __future__ import annotations -import json -import pathlib - from flight_cli.models import ( Itinerary, ItineraryDetails, @@ -23,13 +20,7 @@ cash_route_time_key, join, ) -from flight_cli.pp.models import ( - AirlineSearchResponse, - OutboundFlight, - PricingInfoResponse, -) - -FIX = pathlib.Path(__file__).parent / "fixtures" +from flight_cli.providers.base import AwardFlight, CabinAward def _itin(*slices_data: tuple[str, str, str, str]) -> Itinerary: @@ -54,30 +45,27 @@ def _award( fn: str, dep: str, *, + program: str = "United", miles: int = 47000, tax: float = 250.0, cabin: str = "Economy", origin: str = "EWR", dest: str = "LHR", -) -> OutboundFlight: - return OutboundFlight.model_validate( - { - "origin": origin, - "destination": dest, - "localDepartureDateTime": dep, - "localArrivalDateTime": dep, - "firstFlightNumber": fn, - "perCabinMilesPricing": [ - { - "cabinClass": cabin, - "perPassengerPricing": { - "perPassengerMilesAmount": miles, - "perPassengerTaxAmountUsd": tax, - "taxCurrencyCode": "USD", - }, - } - ], - } + funding_banks: list[str] | None = None, + miles_to_cash_ratio: float = 0.0125, +) -> AwardFlight: + return AwardFlight( + origin=origin, + destination=dest, + departure=dep, + arrival=dep, + flight_number=fn, + num_connections=0, + provider="PointsPath", + program=program, + miles_to_cash_ratio=miles_to_cash_ratio, + funding_banks=funding_banks or ["Chase", "Bilt"], + cabins=[CabinAward(cabin=cabin, miles=miles, tax_usd=tax, tax_currency="USD")], ) @@ -115,8 +103,8 @@ def test_cash_match_key_out_of_range_slice_returns_none(): def test_award_match_key_normalizes_consistently(): - of = _award("ua 146", "2026-06-09T22:00:00") - assert award_match_key(of) == ("UA146", "2026-06-09") + af = _award("ua 146", "2026-06-09T22:00:00") + assert award_match_key(af) == ("UA146", "2026-06-09") # ───────────────────────── route+time key ────────────────────────────────── @@ -147,13 +135,6 @@ def test_cash_route_time_key_out_of_range_slice_returns_none(): def test_cash_route_time_key_works_when_flights_list_is_empty(): """Route+time doesn't need flight numbers — it should still produce a key for a slice that has origin/dest/departure but no flights[].""" - from flight_cli.models import ( - Itinerary, - ItineraryDetails, - Slice, - SliceEndpoint, - ) - it = Itinerary( itinerary=ItineraryDetails( slices=[ @@ -162,7 +143,7 @@ def test_cash_route_time_key_works_when_flights_list_is_empty(): departure="2026-08-15T18:40:00", origin=SliceEndpoint(code="JFK"), destination=SliceEndpoint(code="LHR"), - ) + ), ], ), ) @@ -170,8 +151,8 @@ def test_cash_route_time_key_works_when_flights_list_is_empty(): def test_award_route_time_key_normalizes_consistently(): - of = _award("BA174", "2026-08-15T18:40:00", origin="JFK", dest="LHR") - assert award_route_time_key(of) == ("JFK", "LHR", "2026-08-15T18:40") + af = _award("BA174", "2026-08-15T18:40:00", origin="JFK", dest="LHR") + assert award_route_time_key(af) == ("JFK", "LHR", "2026-08-15T18:40") # ───────────────── codeshare fallback (the work-22az fix) ────────────────── @@ -191,31 +172,27 @@ def test_join_codeshare_via_route_time_when_flight_numbers_differ(): ) # PP's American airline-search returns BA-operated codeshares under the # OPERATING flight number — this is what we observed in the probe. - award_resp = AirlineSearchResponse( - outboundFlights=[ - _award("BA174", "2026-08-15T18:40:00", origin="JFK", dest="LHR"), - ] - ) - matches = join(res, {"American": award_resp}, _pricing()) + awards = [ + _award("BA174", "2026-08-15T18:40:00", program="American", origin="JFK", dest="LHR"), + ] + matches = join(res, awards) assert len(matches[0].awards) == 1 - assert matches[0].awards[0].flight.firstFlightNumber == "BA174" + assert matches[0].awards[0].flight_number == "BA174" def test_join_does_not_double_attach_when_both_keys_match(): """The non-codeshare case: Matrix and PP both report UA146 at the same time, so both the flight# key AND the route+time key fire. Dedup must - keep it to one award per OutboundFlight.""" + keep it to one award per AwardFlight.""" res = SearchResult( solutions=[ _itin(("UA146", "2026-06-09T22:00:00", "JFK", "LHR")), ] ) - award_resp = AirlineSearchResponse( - outboundFlights=[ - _award("UA146", "2026-06-09T22:00:00", origin="JFK", dest="LHR"), - ] - ) - matches = join(res, {"United": award_resp}, _pricing()) + awards = [ + _award("UA146", "2026-06-09T22:00:00", origin="JFK", dest="LHR"), + ] + matches = join(res, awards) assert len(matches[0].awards) == 1 @@ -227,12 +204,10 @@ def test_join_route_time_fallback_does_not_match_different_route(): _itin(("AA6939", "2026-08-15T18:40:00", "JFK", "LHR")), ] ) - award_resp = AirlineSearchResponse( - outboundFlights=[ - _award("BA174", "2026-08-15T18:40:00", origin="JFK", dest="ORD"), - ] - ) - matches = join(res, {"American": award_resp}, _pricing()) + awards = [ + _award("BA174", "2026-08-15T18:40:00", program="American", origin="JFK", dest="ORD"), + ] + matches = join(res, awards) assert matches[0].awards == [] @@ -244,53 +219,39 @@ def test_join_route_time_fallback_requires_minute_precision(): _itin(("AA6939", "2026-08-15T18:40:00", "JFK", "LHR")), ] ) - award_resp = AirlineSearchResponse( - outboundFlights=[ - _award("BA174", "2026-08-15T18:45:00", origin="JFK", dest="LHR"), - ] - ) - matches = join(res, {"American": award_resp}, _pricing()) + awards = [ + _award("BA174", "2026-08-15T18:45:00", program="American", origin="JFK", dest="LHR"), + ] + matches = join(res, awards) assert matches[0].awards == [] def test_join_route_time_unions_with_flight_number_match(): - """If two airlines' PP responses describe overlapping award metal — - one matches by flight number, the other matches by route+time — both - should attach to the same cash itinerary.""" + """If two providers' awards describe overlapping metal — one matches by + flight number, the other matches by route+time — both attach to the same + cash itinerary.""" res = SearchResult( solutions=[ _itin(("AA6939", "2026-08-15T18:40:00", "JFK", "LHR")), ] ) - award_by_airline = { + awards = [ # Same metal under the OPERATING number (matches via route+time): - "American": AirlineSearchResponse( - outboundFlights=[ - _award("BA174", "2026-08-15T18:40:00", origin="JFK", dest="LHR"), - ] - ), - # Hypothetical second airline that happens to report the cash - # marketing number directly (matches via primary key): - "VirginAtlantic": AirlineSearchResponse( - outboundFlights=[ - _award("AA6939", "2026-08-15T18:40:00", origin="JFK", dest="LHR"), - ] - ), - } - matches = join(res, award_by_airline, _pricing()) - airlines = {ao.airline for ao in matches[0].awards} - assert airlines == {"American", "VirginAtlantic"} + _award("BA174", "2026-08-15T18:40:00", program="American", origin="JFK", dest="LHR"), + # Hypothetical second source reporting the cash marketing number directly + # (matches via primary key): + _award("AA6939", "2026-08-15T18:40:00", program="VirginAtlantic", origin="JFK", dest="LHR"), + ] + matches = join(res, awards) + programs = {ao.program for ao in matches[0].awards} + assert programs == {"American", "VirginAtlantic"} # ────────────────────────────── join semantics ───────────────────────────── -def _pricing() -> PricingInfoResponse: - return PricingInfoResponse.model_validate(json.loads((FIX / "pricing_info.json").read_text())) - - def test_join_outer_keeps_unmatched_cash(): - """A cash itinerary whose flight # has no PP award match should still + """A cash itinerary whose flight # has no award match should still surface in the output — with empty awards.""" res = SearchResult( solutions=[ @@ -298,82 +259,58 @@ def test_join_outer_keeps_unmatched_cash(): _itin(("BA178", "2026-06-09T07:50:00", "JFK", "LHR")), # no award ] ) - award_resp = AirlineSearchResponse( - outboundFlights=[ - _award("UA146", "2026-06-09T22:00:00"), - ] - ) - matches = join(res, {"United": award_resp}, _pricing()) + awards = [_award("UA146", "2026-06-09T22:00:00")] + matches = join(res, awards) assert len(matches) == 2 - assert matches[0].awards and matches[0].awards[0].airline == "United" + assert matches[0].awards and matches[0].awards[0].program == "United" assert matches[1].awards == [] # BA178 unmatched but preserved -def test_join_attaches_funding_banks_from_pricing(): +def test_join_preserves_funding_banks_and_ratio_from_award(): + """Funding banks and miles_to_cash_ratio travel with the AwardFlight + (set by the provider during conversion). The matcher attaches the + award unchanged.""" res = SearchResult( solutions=[ _itin(("UA146", "2026-06-09T22:00:00", "JFK", "LHR")), ] ) - award_resp = AirlineSearchResponse( - outboundFlights=[ - _award("UA146", "2026-06-09T22:00:00"), - ] - ) - matches = join(res, {"United": award_resp}, _pricing()) - [option] = matches[0].awards - assert sorted(option.funding_banks) == ["Bilt", "Chase"] - assert option.miles_to_cash_ratio == 0.0125 + awards = [ + _award( + "UA146", + "2026-06-09T22:00:00", + funding_banks=["Chase", "Bilt"], + miles_to_cash_ratio=0.0125, + ), + ] + matches = join(res, awards) + [matched] = matches[0].awards + assert sorted(matched.funding_banks) == ["Bilt", "Chase"] + assert matched.miles_to_cash_ratio == 0.0125 def test_join_codeshare_surfaces_multiple_airlines(): - """When two airlines (PP-side) report the same flight#xdate as their own - award, both options should attach to the cash itinerary.""" + """When two airlines report the same flight#xdate as their own award, + both options should attach to the cash itinerary.""" res = SearchResult( solutions=[ _itin(("BA178", "2026-06-09T07:50:00", "JFK", "LHR")), ] ) - award_by_airline = { - "American": AirlineSearchResponse( - outboundFlights=[ - _award("BA178", "2026-06-09T07:50:00", miles=30000, tax=308.0), - ] - ), - "British Airways": AirlineSearchResponse( - outboundFlights=[ - _award("BA178", "2026-06-09T07:50:00", miles=50000, tax=750.0), - ] - ), - } - matches = join(res, award_by_airline, _pricing()) - airlines = {ao.airline for ao in matches[0].awards} - assert airlines == {"American", "British Airways"} - - -def test_join_use_inbound_reads_inboundFlights(): - res = SearchResult( - solutions=[ - _itin( - ("UA146", "2026-06-09T22:00:00", "JFK", "LHR"), # outbound - ("UA147", "2026-06-12T10:00:00", "LHR", "JFK"), # return - ), - ] - ) - award_resp = AirlineSearchResponse( - outboundFlights=[], # nothing on outbound - inboundFlights=[_award("UA147", "2026-06-12T10:00:00", origin="LHR", dest="JFK")], - ) - # use_inbound=True flips the index source - matches = join(res, {"United": award_resp}, _pricing(), slice_index=1, use_inbound=True) - assert len(matches[0].awards) == 1 + awards = [ + _award("BA178", "2026-06-09T07:50:00", program="American", miles=30000, tax=308.0), + _award("BA178", "2026-06-09T07:50:00", program="British Airways", miles=50000, tax=750.0), + ] + matches = join(res, awards) + programs = {ao.program for ao in matches[0].awards} + assert programs == {"American", "British Airways"} -def test_join_empty_award_response(): +def test_join_empty_award_list(): res = SearchResult( solutions=[ _itin(("UA146", "2026-06-09T22:00:00", "JFK", "LHR")), ] ) - matches = join(res, {"United": AirlineSearchResponse()}, _pricing()) + matches = join(res, []) assert matches[0].awards == [] diff --git a/tests/test_registry.py b/tests/test_registry.py new file mode 100644 index 0000000..2795d69 --- /dev/null +++ b/tests/test_registry.py @@ -0,0 +1,97 @@ +# pyright: reportPrivateUsage=false +"""Tests for the provider registry's per-leg fan-out. + +The registry's contract: concatenate `list[AwardFlight]` from all enabled +providers per leg, swallow per-provider exceptions so one failure doesn't +sink the whole run. These tests use a stub provider (no PointsPath HTTP) +to pin the behavior.""" + +from __future__ import annotations + +import anyio +import pytest + +from flight_cli.providers.base import AwardFlight, AwardProvider, LegQuery +from flight_cli.providers.registry import _gather_one_leg + + +class _StubProvider: + name: str = "Stub" + enabled: bool = True + + def __init__(self, flights: list[AwardFlight], *, raises: Exception | None = None) -> None: + self._flights = flights + self._raises = raises + + async def search_leg( + self, leg: LegQuery, *, cabins: tuple[str, ...], num_passengers: int = 1 + ) -> list[AwardFlight]: + _ = leg, cabins, num_passengers + if self._raises: + raise self._raises + return list(self._flights) + + +def _af(fn: str) -> AwardFlight: + return AwardFlight( + origin="JFK", + destination="LHR", + departure="2026-08-15T19:00:00", + arrival="2026-08-16T07:00:00", + flight_number=fn, + provider="Stub", + program="Test", + ) + + +def _leg() -> LegQuery: + return LegQuery( + origin="JFK", + destination="LHR", + date="2026-08-15", + slice_index=0, + label="outbound JFK→LHR", + ) + + +def test_gather_one_leg_concatenates_across_providers() -> None: + p1 = _StubProvider([_af("AA1"), _af("AA2")]) + p2 = _StubProvider([_af("DL1")]) + + async def go() -> list[AwardFlight]: + return await _gather_one_leg([p1, p2], _leg(), cabins=("Economy",), num_passengers=1) + + out: list[AwardFlight] = anyio.run(go) + fn_numbers = sorted(a.flight_number for a in out) + assert fn_numbers == ["AA1", "AA2", "DL1"] + + +def test_gather_one_leg_isolates_per_provider_failures() -> None: + """One provider blowing up must not sink the others' results.""" + p_ok = _StubProvider([_af("AA1")]) + p_fail = _StubProvider([], raises=RuntimeError("simulated")) + + async def go() -> list[AwardFlight]: + return await _gather_one_leg([p_ok, p_fail], _leg(), cabins=("Economy",)) + + out: list[AwardFlight] = anyio.run(go) + assert [a.flight_number for a in out] == ["AA1"] + + +def test_gather_one_leg_empty_provider_list_returns_empty() -> None: + async def go() -> list[AwardFlight]: + return await _gather_one_leg([], _leg(), cabins=("Economy",)) + + assert anyio.run(go) == [] + + +@pytest.mark.parametrize("count", [1, 3, 5]) +def test_gather_one_leg_preserves_each_providers_full_output(count: int) -> None: + """The fan-out shouldn't drop or dedupe — it's a concat.""" + providers: list[AwardProvider] = [_StubProvider([_af(f"X{i}")]) for i in range(count)] + + async def go() -> list[AwardFlight]: + return await _gather_one_leg(providers, _leg(), cabins=("Economy",)) + + out: list[AwardFlight] = anyio.run(go) + assert len(out) == count