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
3 changes: 2 additions & 1 deletion src/flight_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
279 changes: 110 additions & 169 deletions src/flight_cli/pp/cli.py

Large diffs are not rendered by default.

143 changes: 37 additions & 106 deletions src/flight_cli/pp/match.py
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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
Expand All @@ -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")
Expand Down Expand Up @@ -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:
Expand All @@ -100,139 +95,75 @@ 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
decides whether to render them or filter to inner-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
10 changes: 10 additions & 0 deletions src/flight_cli/providers/__init__.py
Original file line number Diff line number Diff line change
@@ -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.
"""
111 changes: 111 additions & 0 deletions src/flight_cli/providers/base.py
Original file line number Diff line number Diff line change
@@ -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 `--<provider>-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."""
...
11 changes: 11 additions & 0 deletions src/flight_cli/providers/pointspath/__init__.py
Original file line number Diff line number Diff line change
@@ -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"]
Loading
Loading