diff --git a/docs/memories/MEMORY.md b/docs/memories/MEMORY.md index aed2702..7341ad0 100644 --- a/docs/memories/MEMORY.md +++ b/docs/memories/MEMORY.md @@ -39,6 +39,12 @@ go into detail and are loaded on demand. rides both backends. Decision: `enable_matching=False` + the existing matcher keys, not the matched-Google-flight-id path. Empirical evidence behind the choice and the upgrade path if it turns out worth it later. +- [pp_matched_id_recipe.md](pp_matched_id_recipe.md) — **Supersedes the + "dead end" framing in `pp_on_gflight.md`.** The matched-id join *does* + work; the previous "no" was because we sent synthetic flight_ids and + the wrong hint shape. Recipe (real `data[0][17]` from fli + IATA-prefixed + flight#, human-readable airline name, space-separated times), empirical + proof, and wire-through implementation notes. ## When to add a new memory file diff --git a/docs/memories/pp_matched_id_recipe.md b/docs/memories/pp_matched_id_recipe.md new file mode 100644 index 0000000..1ed5c19 --- /dev/null +++ b/docs/memories/pp_matched_id_recipe.md @@ -0,0 +1,112 @@ +# PointsPath matched-id join — empirical recipe (2026-05-15) + +Settled: PP's `enableGoogleFlightMatching=true` mode **works** and gives a +robust cash↔award join key (`matchedGoogleFlightId`), better than the +(flight#, date) + (route, time) fallbacks today. The earlier doc +(`pp_on_gflight.md`) ruled this out as a dead end; that conclusion was +based on a probe with a synthetic `flight_id`. With **real** Google +Flights opaque IDs, PP echoes them back populated. + +## What unlocked it + +Captured the live PP browser-extension traffic via Patchright + a snapshot +of the user's Chrome profile (with PP extension registered + Pro session +cookies). 549 events; 87 successful `/api/airline-search` responses, every +returned flight with `matchedGoogleFlightId` populated. + +The capture also disproved the `store-flights` hypothesis from +`pp_on_gflight.md`: the extension does NOT pre-register flights via +`/api/store-flights` before querying. It goes straight to `/api/airline-search` +with `enableGoogleFlightMatching=true` and the hints carry their own opaque +IDs. PP looks the IDs up in its catalog at query time. + +## The recipe + +1. **Extract `data[0][17]`** from each flight in Google Flights' raw + API response. fli's `_parse_flights_data` reads `data[0][2]` (legs), + `data[0][9]` (duration), `data[0][1][−1]` (price) — but drops index `[17]` + on the floor. We added `src/flight_cli/_gflight_ids.py` which reuses fli's + encoder + curl_cffi client but parses our own response, capturing flightId. + +2. **Hint payload shape** (per `CashFlightHint.to_payload()`): + + ```json + { + "origin": "MIA", + "dest": "LHR", + "startDateTime": "2026-06-30 18:05", // YYYY-MM-DD HH:MM, no T, no TZ + "endDateTime": "2026-07-01 08:05", + "flightId": "NbXSYb", // 5-6 char opaque from data[0][17] + "airline": "Virgin Atlantic", // HUMAN-READABLE name, not IATA + "googleAirlines": ["Virgin Atlantic"], + "numConnections": 0, + "hasCarryOnBaggage": false, + "firstFlightNumber": "VS6", // IATA prefix + flight# concatenated + "cashPrice": 802, + "rawCashPriceString": "$802" + } + ``` + + The two formatting gotchas that produce 0-flight responses if missed: + - `firstFlightNumber` MUST be IATA-prefixed (`VS6` not `6`) + - `airline` MUST be the human-readable name (`"Virgin Atlantic"` not `"VS"`) + +3. **PP airline-search request** (`SearchSpec` with `enable_matching=True`): + `airline` is the PP-side program being queried (`"American"`, `"Delta"`, + etc.). PP returns flights matching ANY hint, regardless of whether the + operating carrier matches the queried airline — useful for codeshare + bridging (querying American can return a BA-operated flight if the hint + refers to it). + +4. **Response** has flights with `matchedGoogleFlightId` set to whichever + hint `flightId` they matched. Join cash→award by string equality on that. + +## Empirical evidence + +`research/probe_pp_real_ids.py` produces: +``` +PP query=American → 2 flights, 2 matched + *MATCHED* AA38 matched='A1oq6c' ← our supplied flightId + *MATCHED* BA208 matched='KTfE0c' ← codeshare picked up via American +PP query=VirginAtlantic → 1 matched + *MATCHED* VS118 matched='w8PDCc' +``` + +Live capture saved at `research/capture/pp_extension_capture.json` +(gitignored). 87 successful airline-search responses for cross-reference. + +## Why we'd want it + +The (flight#, date) + (route, time) fallback we ship today works fine for +most flights but degrades on: +- Codeshares with marketing flight# != operating flight# (work-22az fix + handles the common AA/BA case via route+time; not all edge cases) +- Flights with the same route+time on the same day but a different metal + (rare but real — adjacent slot codeshares) + +The matched-id path collapses all of that to string equality on opaque IDs +PP has already done the heavy lifting on. + +## Implementation status + +- ✅ `_gflight_ids.py` — fli wrapper that captures the opaque flightId +- ✅ Format recipe verified empirically (probe above) +- ⏳ Wire through to provider + matcher (work-?? follow-up): + - Add `flight_id: str | None` field to `Slice` (or per-itinerary metadata) + - Populate from fli via `gflight_adapter` + - Provider takes `cash_hints` and sends with `enable_matching=True` + - Provider populates `AwardFlight.matched_google_flight_id` from response + - Matcher adds a third join index keyed on flight_id (highest priority) + - The existing (flight#, date) + (route+time) keys stay as fallback + +## Airline IATA → name mapping + +PP's hint field `airline` requires the human-readable name. fli only exposes +the IATA code via its `Airline` enum. Two paths: +- Hardcode a mapping for the airlines PP services (`pp_pricing_info` already + uses these names, e.g. `Delta`, `American`, `VirginAtlantic`) +- Extract `fl[22][3]` from Google's response (the airline name string) + +The hardcoded mapping is simpler and PP's catalog is small + stable. Future +spike: extract `fl[22][3]` so the data flows from Google's truth rather than +a static lookup. diff --git a/src/flight_cli/_airline_names.py b/src/flight_cli/_airline_names.py new file mode 100644 index 0000000..312811a --- /dev/null +++ b/src/flight_cli/_airline_names.py @@ -0,0 +1,48 @@ +"""IATA airline code → human-readable name (PP's hint payload format). + +PP's `/api/airline-search` hint object expects `airline` and `googleAirlines` +fields as the human-readable name (e.g. "Virgin Atlantic"), NOT the IATA +code ("VS"). With the IATA code, PP returns 0 flights — verified empirically +via the browser-extension capture (research/capture/pp_extension_capture.json). + +We mirror the names PP uses in its own pricing-info catalog so the hint +metadata aligns with PP's internal records. Coverage is the airlines PP +supports — for anything else we fall back to the IATA code, accepting that +PP may not return a match (which is fine; the matcher falls back to +flight#+date / route+time). +""" + +# Keys are fli's `Airline.name` (== IATA two-letter), values are PP's +# canonical airline name from its /api/pricing-info response. +_IATA_TO_PP_NAME: dict[str, str] = { + "AA": "American", + "AC": "Air Canada", + "AF": "Air France", + "AS": "Alaska", + "B6": "JetBlue", + "BA": "British Airways", + "DL": "Delta", + "EI": "Aer Lingus", + "EK": "Emirates", + "EY": "Etihad", + "IB": "Iberia", + "KL": "KLM", + "LH": "Lufthansa", + "NK": "Spirit", + "QF": "Qantas", + "QR": "Qatar", + "SQ": "Singapore", + "TP": "Tap Air Portugal", + "UA": "United", + "VA": "Virgin Australia", + "VS": "Virgin Atlantic", +} + + +def pp_airline_name(iata: str) -> str: + """Map a 2-letter IATA airline code to PP's human-readable name. + + Falls back to the input on unknown codes — PP will likely return no + match for those, which the matcher tolerates (flight#+date and + route+time stay available as fallback join keys).""" + return _IATA_TO_PP_NAME.get(iata.upper(), iata) diff --git a/src/flight_cli/_gflight_ids.py b/src/flight_cli/_gflight_ids.py new file mode 100644 index 0000000..c4788bc --- /dev/null +++ b/src/flight_cli/_gflight_ids.py @@ -0,0 +1,146 @@ +"""Wrapper around fli's Google Flights search that ALSO captures the opaque +`flightId` Google emits at index [17] of each flight row. + +fli's `SearchFlights._parse_flights_data` parses legs, price, duration, stops — +but drops `data[0][17]`. PointsPath's `enableGoogleFlightMatching` mode joins +its award catalog against exactly that opaque ID (see PP browser extension +chunk-5KW5VSHS.js: `flightId: a` where `a = n[17]`). Without it, PP returns +an empty result for hint-based queries; with it, `matchedGoogleFlightId` +echoes back populated. + +We re-use fli's `FlightSearchFilters.encode()` + curl_cffi client so the +request shape stays in lockstep with upstream; only the response parser +diverges (extends fli's by one field). +""" + +from __future__ import annotations + +import json +import logging +from copy import deepcopy +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +from fli.models import ( # pyright: ignore[reportMissingTypeStubs] + FlightLeg, + FlightResult, +) +from fli.models.google_flights.base import TripType # pyright: ignore[reportMissingTypeStubs] +from fli.search.client import get_client # pyright: ignore[reportMissingTypeStubs] +from fli.search.flights import SearchFlights # pyright: ignore[reportMissingTypeStubs] + +if TYPE_CHECKING: + from fli.models.google_flights.flights import ( # pyright: ignore[reportMissingTypeStubs] + FlightSearchFilters, + ) + +log = logging.getLogger(__name__) + +_BASE_URL = SearchFlights.BASE_URL + +# Position of the opaque per-flight ID in Google Flights' API row array. +# Mirrors the PP browser extension's parser (chunk-5KW5VSHS.js: `a = n[17]`). +_FLIGHT_ID_IDX = 17 + + +@dataclass +class GFlightWithId: + """fli's FlightResult plus Google's opaque flight_id for PP matching.""" + + flight: FlightResult + flight_id: str + + +def _parse_flight_with_id(data: list[Any]) -> GFlightWithId: + """Mirror of fli's `_parse_flights_data` but also reads `data[0][17]`. + + Indices match the PP extension's parser (chunks/chunk-5KW5VSHS.js): n[17] + is the per-flight opaque ID; n[2] legs; n[9] duration; t[0][-1] price.""" + price, currency = SearchFlights._parse_price_info(data) # pyright: ignore[reportPrivateUsage, reportUnknownMemberType] + flight_id = data[0][_FLIGHT_ID_IDX] if len(data[0]) > _FLIGHT_ID_IDX else "" + flight = FlightResult( + price=price, + currency=currency, + duration=data[0][9], + stops=len(data[0][2]) - 1, + legs=[ + FlightLeg( + airline=SearchFlights._parse_airline(fl[22][0]), # pyright: ignore[reportPrivateUsage] + flight_number=fl[22][1], + departure_airport=SearchFlights._parse_airport(fl[3]), # pyright: ignore[reportPrivateUsage] + arrival_airport=SearchFlights._parse_airport(fl[6]), # pyright: ignore[reportPrivateUsage] + departure_datetime=SearchFlights._parse_datetime(fl[20], fl[8]), # pyright: ignore[reportPrivateUsage] + arrival_datetime=SearchFlights._parse_datetime(fl[21], fl[10]), # pyright: ignore[reportPrivateUsage] + duration=fl[11], + ) + for fl in data[0][2] + ], + ) + return GFlightWithId(flight=flight, flight_id=flight_id) + + +def _one_call(filters: FlightSearchFilters) -> list[GFlightWithId]: + """Single HTTP round-trip to Google's endpoint; flat list of one leg's flights.""" + client = get_client() + encoded = filters.encode() + resp = client.post( + url=_BASE_URL, + data=f"f.req={encoded}", + impersonate="chrome", + allow_redirects=True, + ) + resp.raise_for_status() + parsed = json.loads(resp.text.lstrip(")]}'"))[0][2] + if not parsed: + return [] + inner = json.loads(parsed) + flights_data: list[Any] = [ + item for i in (2, 3) if isinstance(inner[i], list) for item in inner[i][0] + ] + out: list[GFlightWithId] = [] + for fd in flights_data: + try: + out.append(_parse_flight_with_id(fd)) + except (AttributeError, KeyError, ValueError, IndexError) as e: + log.debug("skipping flight with unparseable data: %s", e) + continue + return out + + +def search_with_ids( + filters: FlightSearchFilters, + *, + top_n: int = 5, +) -> list[GFlightWithId | tuple[GFlightWithId, ...]] | None: + """Drop-in for fli's `SearchFlights().search()` but each result carries + its Google Flights opaque flight_id. + + Round-trip / multi-city follow the same iterative leg-selection pattern + as fli: query first leg, pick top_n, drive each through the rest. Each + `GFlightWithId` in a returned tuple has its own per-leg flight_id.""" + first = _one_call(filters) + if not first: + return None + + if filters.trip_type == TripType.ONE_WAY: + return list(first) + + num_segments = len(filters.flight_segments) + selected_count = sum(1 for s in filters.flight_segments if s.selected_flight is not None) + # Last leg already — no further iteration. + if selected_count >= num_segments - 1: + return list(first) + + combos: list[GFlightWithId | tuple[GFlightWithId, ...]] = [] + for picked in first[:top_n]: + next_filters = deepcopy(filters) + next_filters.flight_segments[selected_count].selected_flight = picked.flight + nxt = search_with_ids(next_filters, top_n=top_n) + if nxt is None: + continue + for nx in nxt: + if isinstance(nx, tuple): + combos.append((picked, *nx)) + else: + combos.append((picked, nx)) + return combos or None diff --git a/src/flight_cli/cli.py b/src/flight_cli/cli.py index 6f9d358..261bcbb 100644 --- a/src/flight_cli/cli.py +++ b/src/flight_cli/cli.py @@ -502,14 +502,18 @@ def _run_gflight_path( (origin, dest, date) per leg as the matrix path. """ # fli is heavy (selenium/selectolax); lazy-import so the rest of flight_cli - # doesn't pay the startup cost when not used. - from .fli_bridge import run_gflight_search # noqa: PLC0415 + # doesn't pay the startup cost when not used. `search_with_ids` wraps fli's + # encoder + client but parses the response ourselves to capture the opaque + # per-flight ID (data[0][17]) — that's what PP's enableGoogleFlightMatching + # joins against to produce matchedGoogleFlightId in its response. + from ._gflight_ids import search_with_ids # noqa: PLC0415 + from .fli_bridge import to_fli_filter # noqa: PLC0415 search = SpecificDateSearch(legs=legs, options=opts) try: - # fli has no type stubs; treat its return as opaque at this boundary - # and use duck-typed attribute access below. - results: list[Any] = run_gflight_search(search, top_n=top_n) + # Returns GFlightWithId | tuple[GFlightWithId, ...]. The .flight attribute + # exposes fli's FlightResult; .flight_id is Google's opaque ID. + results: list[Any] = search_with_ids(to_fli_filter(search), top_n=top_n) or [] except Exception as e: err.print(f"[red]Google Flights query failed:[/] {e}") raise typer.Exit(1) from e @@ -526,10 +530,9 @@ def _run_gflight_path( if json_out and not run_pp: out: list[Any] = [] for r in results: - if isinstance(r, tuple): - out.append([fr.model_dump(mode="json") for fr in r]) # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType] - else: - out.append(r.model_dump(mode="json")) + items: list[Any] = list(r) if isinstance(r, tuple) else [r] # pyright: ignore[reportUnknownArgumentType] + dumped = [{**g.flight.model_dump(mode="json"), "flight_id": g.flight_id} for g in items] + out.append(dumped if isinstance(r, tuple) else dumped[0]) sys.stdout.write(json.dumps(out, indent=2, default=str)) return @@ -553,7 +556,9 @@ def _run_gflight_path( def _render_gflight_table(results: list[Any], *, legs: tuple[Leg, ...], top_n: int) -> None: - """Render fli results as a rich table. Duck-typed: fli has no type stubs.""" + """Render fli results as a rich table. Duck-typed: fli has no type stubs. + + Accepts our `GFlightWithId` wrappers — `.flight` is fli's FlightResult.""" origin = legs[0].origins[0] if legs[0].origins else "?" destination = legs[0].destinations[0] if legs[0].destinations else "?" has_return = len(legs) >= _ROUND_TRIP_LEGS @@ -569,7 +574,8 @@ def _render_gflight_table(results: list[Any], *, legs: tuple[Leg, ...], top_n: i t.add_column("legs") for i, r in enumerate(results[:top_n], 1): items: list[Any] = list(r) if isinstance(r, tuple) else [r] # pyright: ignore[reportUnknownArgumentType] - for j, fr in enumerate(items): + for j, g in enumerate(items): + fr = g.flight # unwrap GFlightWithId → fli FlightResult label = f"{i}{'a' if j == 0 else 'b'}" if len(items) > 1 else str(i) legs_str = " → ".join( f"{getattr(leg.airline, 'name', leg.airline)} {getattr(leg, 'flight_number', '?')}" diff --git a/src/flight_cli/models.py b/src/flight_cli/models.py index 716170e..cd17eb2 100644 --- a/src/flight_cli/models.py +++ b/src/flight_cli/models.py @@ -89,6 +89,12 @@ class Slice(_Loose): duration: int | None = None origin: SliceEndpoint | None = None destination: SliceEndpoint | None = None + # Provider-supplied opaque ID for this leg (Google Flights data[0][17]). + # Populated when the cash side is built from fli's response (gflight backend); + # used by PP's enableGoogleFlightMatching to mint the matchedGoogleFlightId + # join key. None for Matrix cash itineraries — they don't expose an ID + # PP recognizes, so those fall back to flight#+date / route+time joins. + flight_id: str | None = None class SliceCarrier(_Loose): diff --git a/src/flight_cli/pp/cli.py b/src/flight_cli/pp/cli.py index 6a01c6d..e49625b 100644 --- a/src/flight_cli/pp/cli.py +++ b/src/flight_cli/pp/cli.py @@ -31,7 +31,8 @@ login_from_chrome, login_via_browser, ) -from .client import DEFAULT_CABINS +from .client import DEFAULT_CABINS, CashFlightHint +from .gflight_adapter import cash_hints_from_search_result from .match import MatchedFare, join if TYPE_CHECKING: @@ -211,12 +212,21 @@ def run_pp_for_search( cabin_list = tuple(_normalize_cabin(c) for c in _parse_csv(cabins, DEFAULT_CABINS)) explicit_airlines = _parse_csv(airlines, ()) if airlines else None + # If `res` carries gflight-captured opaque flight IDs on its slices, build + # PP cash hints per leg so the request goes out with enable_matching=True + # and the matcher's matched-id key becomes available. Matrix-built + # SearchResults won't have flight_id populated; hints stays empty. + cash_hints_per_leg: list[tuple[CashFlightHint, ...]] = [ + tuple(cash_hints_from_search_result(res, slice_index=leg.slice_index)) for leg in legs + ] + async def _go() -> tuple[list[list[AwardFlight]], list[Any]]: return await gather_awards( legs=legs, num_passengers=num_passengers, cabins=cabin_list, pp_airlines=explicit_airlines, + cash_hints_per_leg=cash_hints_per_leg, ) try: diff --git a/src/flight_cli/pp/gflight_adapter.py b/src/flight_cli/pp/gflight_adapter.py index 0239c64..ec45870 100644 --- a/src/flight_cli/pp/gflight_adapter.py +++ b/src/flight_cli/pp/gflight_adapter.py @@ -6,15 +6,20 @@ The matcher reads structural fields only — slices[i].flights[0], .departure, .origin.code, .destination.code, Itinerary.price — so a thin wrap-and-translate -layer is enough; no matcher changes needed. PP runs with enable_matching=False -on this path; the same (flight#, date) + (route, time) keys that work for the -Matrix backend bridge the cash↔award join here too. +layer is enough; no matcher changes needed for the basic flight#+date join. + +We also populate `Slice.flight_id` from Google's opaque per-flight ID +(`data[0][17]`, captured via `_gflight_ids.GFlightWithId`). That ID flows +downstream as a `CashFlightHint.flight_id` and PP echoes it back as +`matchedGoogleFlightId` — the matcher's primary join key when available, +much more robust than the heuristic fallbacks for codeshares. """ from __future__ import annotations from typing import TYPE_CHECKING, Any +from .._airline_names import pp_airline_name from ..models import ( Itinerary, ItineraryDetails, @@ -23,6 +28,7 @@ Slice, SliceEndpoint, ) +from .client import CashFlightHint if TYPE_CHECKING: from collections.abc import Sequence @@ -33,16 +39,25 @@ def _airport_code(a: Any) -> str: return name.removeprefix("_") -def _slice_from_flight_result(fr: Any) -> Slice: +def _flight_id_string(leg: Any) -> str: + """IATA-prefixed flight number, e.g. 'DL1' — what Matrix's slices.flights + uses too, so the matcher's flight#+date key matches across backends and + PP's `firstFlightNumber` hint field gets the format it expects.""" + iata = _airport_code(leg.airline) # leg.airline is fli's Airline Enum, .name == IATA + return f"{iata}{leg.flight_number}" + + +def _slice_from_flight_result(fr: Any, flight_id: str | None = None) -> Slice: legs: list[Any] = list(fr.legs) first, last = legs[0], legs[-1] return Slice( - flights=[leg.flight_number for leg in legs], + flights=[_flight_id_string(leg) for leg in legs], departure=first.departure_datetime.isoformat(), arrival=last.arrival_datetime.isoformat(), duration=fr.duration, origin=SliceEndpoint(code=_airport_code(first.departure_airport)), destination=SliceEndpoint(code=_airport_code(last.arrival_airport)), + flight_id=flight_id, ) @@ -52,9 +67,22 @@ def _price_string(fr: Any) -> str: return f"{currency}{fr.price:.2f}" +def _unwrap(item: Any) -> tuple[Any, str | None]: + """Accept either a raw fli `FlightResult` (no flight_id available) or + a `GFlightWithId` carrying the captured opaque ID.""" + if hasattr(item, "flight_id") and hasattr(item, "flight"): + return item.flight, item.flight_id + return item, None + + def fli_results_to_search_result(results: Sequence[Any]) -> SearchResult: """Wrap fli's heterogeneous return into a SearchResult. + Accepts either fli's raw `FlightResult`s or our enriched `GFlightWithId` + wrappers. When given the enriched form, populates `Slice.flight_id` so + the matched-id PP join can fire downstream. With raw fli output, the + flight_id is None and the matcher falls back to flight#+date / route+time. + fli returns ``list[FlightResult]`` for one-way and ``list[tuple[FlightResult, ...]]`` for round-trip/multi-city. Each top-level entry maps to one Itinerary; for tuples, each FlightResult becomes one Slice in slice-index order. The @@ -66,21 +94,23 @@ def fli_results_to_search_result(results: Sequence[Any]) -> SearchResult: cheapest_price: float | None = None cheapest_currency: str = "USD" for r in results: - items: list[Any] = list(r) if isinstance(r, tuple) else [r] # pyright: ignore[reportUnknownArgumentType] - if not items: + items_raw: list[Any] = list(r) if isinstance(r, tuple) else [r] # pyright: ignore[reportUnknownArgumentType] + if not items_raw: continue - slices = [_slice_from_flight_result(fr) for fr in items] - price_str = _price_string(items[0]) + unwrapped = [_unwrap(it) for it in items_raw] + slices = [_slice_from_flight_result(fr, fid) for fr, fid in unwrapped] + first_fr, _ = unwrapped[0] + price_str = _price_string(first_fr) solutions.append( Itinerary( ext=ItineraryExt(price=price_str), itinerary=ItineraryDetails(slices=slices, carriers=[]), ), ) - p: float = items[0].price + p: float = first_fr.price if cheapest_price is None or p < cheapest_price: cheapest_price = p - cheapest_currency = items[0].currency or "USD" + cheapest_currency = first_fr.currency or "USD" sr = SearchResult( solutionCount=len(solutions), @@ -91,3 +121,80 @@ def fli_results_to_search_result(results: Sequence[Any]) -> SearchResult: price=f"{cheapest_currency}{cheapest_price:.2f}", ) return sr + + +def _to_dt_minute(iso: str | None) -> str: + """Convert an ISO datetime to PP's space-separated 'YYYY-MM-DD HH:MM'.""" + if not iso: + return "" + iso = iso.replace("T", " ") + return iso[:16] + + +def cash_hints_from_search_result( + sr: SearchResult, + *, + slice_index: int = 0, + max_hints: int = 50, +) -> list[CashFlightHint]: + """Build PP cash hints for one leg's worth of `sr.solutions`. + + `slice_index` selects which leg of each Itinerary the hint represents + (0 outbound, 1 return on a round-trip). Skips itineraries whose slice + doesn't carry a `flight_id` — that means the matched-id path isn't + available for them (Matrix cash, or the gflight result was constructed + from a non-enriched fli call). The matcher's flight#+date / route+time + keys still apply to those. + + `max_hints` caps the payload — PP's airline-search rejects very large + `googleFlightDetails` arrays; the extension typically sends 10-30. + """ + out: list[CashFlightHint] = [] + seen_flight_ids: set[str] = set() + for it in sr.solutions: + itn = it.itinerary + if not itn or slice_index >= len(itn.slices): + continue + s = itn.slices[slice_index] + if not s.flight_id or s.flight_id in seen_flight_ids: + continue + if not s.origin or not s.destination or not s.flights or not s.departure: + continue + first_flight = s.flights[0] # IATA-prefixed, e.g. "DL1" + iata_prefix = first_flight[:2] + airline_name = pp_airline_name(iata_prefix) + cash_usd = _parse_cash_int(it.price) + out.append( + CashFlightHint( + origin=(s.origin.code or "").upper(), + dest=(s.destination.code or "").upper(), + start_dt=_to_dt_minute(s.departure), + end_dt=_to_dt_minute(s.arrival), + flight_id=s.flight_id, + airline=airline_name, + google_airlines=[airline_name], + num_connections=max(len(s.flights) - 1, 0), + first_flight_number=first_flight, + cash_price_usd=cash_usd, + raw_cash_price=it.price or "", + ), + ) + seen_flight_ids.add(s.flight_id) + if len(out) >= max_hints: + break + return out + + +def _parse_cash_int(price: str | None) -> int: + """Extract leading digits from 'USD877.00' / '$877' / '877 USD'. 0 fallback.""" + if not price: + return 0 + import re as _re # noqa: PLC0415 + + m = _re.search(r"[\d,]*\d+", price) + if not m: + return 0 + try: + return int(m.group(0).replace(",", "").split(".")[0]) + except ValueError: + return 0 diff --git a/src/flight_cli/pp/match.py b/src/flight_cli/pp/match.py index 39fb0a9..48ebd19 100644 --- a/src/flight_cli/pp/match.py +++ b/src/flight_cli/pp/match.py @@ -112,7 +112,18 @@ class MatchedFare: awards: list[AwardFlight] = field(default_factory=list) -def join( +def cash_matched_id_key(it: Itinerary, slice_index: int = 0) -> str | None: + """Opaque Google Flights ID for the cash slice, if the backend populated + one. Matches against `AwardFlight.matched_google_flight_id` (echoed back + by PP when `enableGoogleFlightMatching=true`).""" + itn = it.itinerary + if not itn or slice_index >= len(itn.slices): + return None + fid = itn.slices[slice_index].flight_id + return fid or None + + +def join( # noqa: PLR0912 — three index lookups in priority order, hard to split cleanly search: SearchResult, awards: list[AwardFlight], *, @@ -120,11 +131,18 @@ def join( ) -> 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 AwardFlight identity, so - non-codeshare flights aren't double-attached. + Match strategy, in priority order: + 1. **Matched-ID** (`flight_id` ↔ `matched_google_flight_id`). Exact + string equality on PP's echoed `matchedGoogleFlightId`. Fires only + when the cash side has `flight_id` (gflight backend) AND the + provider was called with `enable_matching=True` + cash hints. + 2. **(flight#, date)** primary heuristic key. + 3. **(route, time)** codeshare fallback (Matrix's marketing flight# + won't equal PP's operating flight#, but origin+dest+minute identifies + the same physical flight). + + Hits across all three are unioned and deduped by AwardFlight identity, so + a flight satisfying multiple keys isn'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. @@ -132,12 +150,15 @@ def join( `slice_index` selects which leg of each Itinerary to match against (0 for outbound, 1 for return on a round-trip, etc). """ - # 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. + # Build all three award indexes in one pass. A single flight may appear + # under multiple — per-itinerary dedup in the join loop keeps the output + # clean. + mid_idx: dict[str, list[AwardFlight]] = {} fn_idx: dict[MatchKey, list[AwardFlight]] = {} rt_idx: dict[RouteTimeKey, list[AwardFlight]] = {} for af in awards: + if af.matched_google_flight_id: + mid_idx.setdefault(af.matched_google_flight_id, []).append(af) fn_k = award_match_key(af) if fn_k[0]: fn_idx.setdefault(fn_k, []).append(af) @@ -150,6 +171,13 @@ def join( matched: list[AwardFlight] = [] seen_ids: set[int] = set() + mid_k = cash_matched_id_key(it, slice_index=slice_index) + if mid_k and mid_k in mid_idx: + for af in mid_idx[mid_k]: + if id(af) in seen_ids: + continue + seen_ids.add(id(af)) + matched.append(af) fn_k = cash_match_key(it, slice_index=slice_index) if fn_k and fn_k in fn_idx: for af in fn_idx[fn_k]: diff --git a/src/flight_cli/providers/base.py b/src/flight_cli/providers/base.py index dda4b6c..c986964 100644 --- a/src/flight_cli/providers/base.py +++ b/src/flight_cli/providers/base.py @@ -18,7 +18,10 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Protocol, runtime_checkable +from typing import TYPE_CHECKING, Protocol, runtime_checkable + +if TYPE_CHECKING: + from ..pp.client import CashFlightHint @dataclass @@ -73,6 +76,13 @@ class AwardFlight: cabins: list[CabinAward] = field(default_factory=list[CabinAward]) + # Opaque ID PP echoes back via `matchedGoogleFlightId` when the provider + # was given a cash hint with the same `flight_id`. Empty when no match, + # or when the cash side didn't carry an ID (Matrix backend). The matcher + # uses this as its primary key when populated — exact-equality join vs. + # the (flight#, date) / (route, time) heuristics it falls back to. + matched_google_flight_id: str = "" + @runtime_checkable class AwardProvider(Protocol): @@ -102,10 +112,18 @@ async def search_leg( *, cabins: tuple[str, ...], num_passengers: int = 1, + cash_hints: tuple[CashFlightHint, ...] = (), ) -> 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.""" + normalized flights. + + `cash_hints` carry per-cash-itinerary opaque IDs (today: Google Flights + `data[0][17]`) that providers MAY use to ask their upstream to echo + back a precise match identifier. Today only PointsPath uses them + (via its `enableGoogleFlightMatching`); other providers may ignore. + + Errors are the provider's to log; 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/provider.py b/src/flight_cli/providers/pointspath/provider.py index e2d4a9c..fc7af6e 100644 --- a/src/flight_cli/providers/pointspath/provider.py +++ b/src/flight_cli/providers/pointspath/provider.py @@ -16,7 +16,13 @@ import structlog from ...pp.auth import PPAuthError, get_valid_tokens -from ...pp.client import DEFAULT_AIRLINES, PPClient, SearchSpec, enabled_airlines +from ...pp.client import ( + DEFAULT_AIRLINES, + CashFlightHint, + PPClient, + SearchSpec, + enabled_airlines, +) from ..base import AwardFlight, CabinAward if TYPE_CHECKING: @@ -73,6 +79,7 @@ def _flight_to_award( miles_to_cash_ratio=miles_to_cash_ratio, funding_banks=funding_banks, cabins=_cabin_awards(of.perCabinMilesPricing), + matched_google_flight_id=of.matchedGoogleFlightId or "", ) @@ -153,10 +160,18 @@ async def search_leg( *, cabins: tuple[str, ...], num_passengers: int = 1, + cash_hints: tuple[CashFlightHint, ...] = (), ) -> 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.""" + leg, merge results, convert to AwardFlight. + + When `cash_hints` is non-empty (gflight backend has captured Google's + opaque flight IDs), the request goes out with `enable_matching=True` + and PP echoes the supplied `flightId`s back via `matchedGoogleFlightId` + on each returned award. Without hints (Matrix backend, or no IDs + available), `enable_matching=False` and the matcher falls back to its + flight#+date / route+time keys. + """ merged: dict[str, AirlineSearchResponse] = {} for cabin in cabins: spec = SearchSpec( @@ -167,7 +182,8 @@ async def search_leg( is_round_trip_return=False, num_passengers=num_passengers, cabin_class=cabin, - enable_matching=False, + enable_matching=bool(cash_hints), + cash_hints=cash_hints, ) per_airline = await self._client.airline_search_many(spec, self._airlines) for airline, resp in per_airline.items(): diff --git a/src/flight_cli/providers/registry.py b/src/flight_cli/providers/registry.py index fe155cb..d7bb6a3 100644 --- a/src/flight_cli/providers/registry.py +++ b/src/flight_cli/providers/registry.py @@ -22,6 +22,7 @@ if TYPE_CHECKING: from structlog.stdlib import BoundLogger + from ..pp.client import CashFlightHint from .base import AwardFlight, AwardProvider, LegQuery log: BoundLogger = structlog.get_logger(__name__) # pyright: ignore[reportAny] @@ -53,16 +54,21 @@ async def _gather_one_leg( *, cabins: tuple[str, ...], num_passengers: int = 1, + cash_hints: tuple[CashFlightHint, ...] = (), ) -> 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( + # cash_hints is provider-optional — providers that don't take it + # via Protocol can be called without the kwarg by Python's + # liberal **kwargs forwarding. PointsPathProvider accepts it. + results[idx] = await p.search_leg( # type: ignore[call-arg] leg, cabins=cabins, num_passengers=num_passengers, + cash_hints=cash_hints, ) except Exception as e: # noqa: BLE001 — surface provider failures, keep others log.warning("provider_search_failed", provider=p.name, error=str(e)) @@ -83,9 +89,16 @@ async def gather_awards( cabins: tuple[str, ...], num_passengers: int = 1, pp_airlines: tuple[str, ...] | None = None, + cash_hints_per_leg: list[tuple[CashFlightHint, ...]] | None = None, ) -> tuple[list[list[AwardFlight]], list[AwardProvider]]: """End-to-end registry call: construct enabled providers, fan out per leg. + `cash_hints_per_leg[i]` (when supplied) carries the gflight backend's + captured Google Flights opaque IDs for legs[i]. The PointsPath provider + uses them to fire `/api/airline-search` with `enable_matching=True`, + making PP's `matchedGoogleFlightId` available as a primary join key + downstream. When None / empty, falls back to the heuristic matcher keys. + Returns: (per_leg_awards, providers) per_leg_awards[i] is the flattened-across-providers list of awards @@ -95,13 +108,17 @@ async def gather_awards( """ providers = await _construct_enabled(pp_airlines=pp_airlines) per_leg: list[list[AwardFlight]] = [] - for leg in legs: + for i, leg in enumerate(legs): + hints: tuple[CashFlightHint, ...] = () + if cash_hints_per_leg and i < len(cash_hints_per_leg): + hints = cash_hints_per_leg[i] per_leg.append( await _gather_one_leg( providers, leg, cabins=cabins, num_passengers=num_passengers, + cash_hints=hints, ), ) return per_leg, providers diff --git a/tests/pp/test_gflight_adapter.py b/tests/pp/test_gflight_adapter.py index 283f90d..7839a10 100644 --- a/tests/pp/test_gflight_adapter.py +++ b/tests/pp/test_gflight_adapter.py @@ -1,4 +1,7 @@ -# pyright: reportPrivateUsage=false, reportUnknownMemberType=false, reportUnknownArgumentType=false +# pyright: reportPrivateUsage=false, reportUnknownMemberType=false, reportUnknownArgumentType=false, reportCallIssue=false +# DIVERGE: pydantic alias fields (e.g. displayTotal, solutionCount) trip +# basedpyright into thinking aliases are required kwargs even with +# populate_by_name=True. Same posture as tests/pp/test_match.py. """Tests for the fli → SearchResult adapter. The adapter is the bridge that lets match.py join PP awards against @@ -21,11 +24,16 @@ def _leg( arr_iata: str, dep: datetime, arr: datetime, + airline_iata: str = "AA", ) -> SimpleNamespace: """Mimic fli FlightLeg with duck-typed attributes (airline/airport are - Enum-like; we just need .name with optional leading underscore stripping).""" + Enum-like; we just need .name with optional leading underscore stripping). + + The adapter concatenates the airline IATA with `flight_number` to produce + Matrix's-format slice.flights (e.g. 'AA100'), so tests pass bare numbers + here as `flight_number` and the IATA via `airline_iata`.""" return SimpleNamespace( - airline=SimpleNamespace(name="AA"), + airline=SimpleNamespace(name=airline_iata), flight_number=flight_number, departure_airport=SimpleNamespace(name=dep_iata), arrival_airport=SimpleNamespace(name=arr_iata), @@ -48,7 +56,14 @@ def _result(price: float, *legs: Any, currency: str = "USD") -> SimpleNamespace: def test_one_way_single_leg_maps_to_one_slice() -> None: fr = _result( 877.0, - _leg("AA100", "JFK", "LHR", datetime(2026, 8, 15, 19, 0), datetime(2026, 8, 16, 7, 0)), + _leg( + "100", + "JFK", + "LHR", + datetime(2026, 8, 15, 19, 0), + datetime(2026, 8, 16, 7, 0), + airline_iata="AA", + ), ) sr = fli_results_to_search_result([fr]) assert sr.solution_count == 1 @@ -58,6 +73,8 @@ def test_one_way_single_leg_maps_to_one_slice() -> None: slices = it.itinerary.slices assert len(slices) == 1 s = slices[0] + # slice.flights[0] is IATA-prefixed: matches Matrix's format so the + # (flight#, date) matcher key joins across backends. assert s.flights == ["AA100"] assert s.departure == "2026-08-15T19:00:00" assert s.origin is not None and s.origin.code == "JFK" @@ -67,11 +84,25 @@ def test_one_way_single_leg_maps_to_one_slice() -> None: def test_round_trip_tuple_maps_to_two_slices() -> None: out = _result( 1078.0, - _leg("AA100", "JFK", "LHR", datetime(2026, 8, 15, 19, 0), datetime(2026, 8, 16, 7, 0)), + _leg( + "100", + "JFK", + "LHR", + datetime(2026, 8, 15, 19, 0), + datetime(2026, 8, 16, 7, 0), + airline_iata="AA", + ), ) ret = _result( 1078.0, - _leg("AA101", "LHR", "JFK", datetime(2026, 8, 22, 11, 0), datetime(2026, 8, 22, 14, 0)), + _leg( + "101", + "LHR", + "JFK", + datetime(2026, 8, 22, 11, 0), + datetime(2026, 8, 22, 14, 0), + airline_iata="AA", + ), ) sr = fli_results_to_search_result([(out, ret)]) assert sr.solution_count == 1 @@ -90,8 +121,22 @@ def test_connection_slice_flattens_all_flight_numbers_first_origin_last_dest() - are first/last leg's airports (so the (route, time) fallback key works).""" fr = _result( 450.0, - _leg("B6100", "JFK", "BOS", datetime(2026, 8, 15, 9, 0), datetime(2026, 8, 15, 10, 30)), - _leg("B6200", "BOS", "LHR", datetime(2026, 8, 15, 17, 0), datetime(2026, 8, 16, 5, 0)), + _leg( + "100", + "JFK", + "BOS", + datetime(2026, 8, 15, 9, 0), + datetime(2026, 8, 15, 10, 30), + airline_iata="B6", + ), + _leg( + "200", + "BOS", + "LHR", + datetime(2026, 8, 15, 17, 0), + datetime(2026, 8, 16, 5, 0), + airline_iata="B6", + ), ) sr = fli_results_to_search_result([fr]) s = sr.solutions[0].itinerary.slices[0] # type: ignore[union-attr] @@ -137,3 +182,99 @@ def test_empty_results_yield_empty_search_result() -> None: assert sr.solution_count == 0 assert sr.solutions == [] assert sr.cheapest_price is None + + +def test_gflightwithid_populates_slice_flight_id() -> None: + """When given our `GFlightWithId` wrapper, the adapter pulls the opaque + flight_id through onto each Slice — the data the PP provider then turns + into a CashFlightHint.""" + g = SimpleNamespace( + flight=_result( + 877.0, + _leg( + "1", + "JFK", + "LHR", + datetime(2026, 8, 15, 19, 0), + datetime(2026, 8, 16, 7, 0), + airline_iata="DL", + ), + ), + flight_id="MWRvrf", + ) + sr = fli_results_to_search_result([g]) + s = sr.solutions[0].itinerary.slices[0] # type: ignore[union-attr] + assert s.flight_id == "MWRvrf" + assert s.flights == ["DL1"] + + +def test_cash_hints_from_search_result_shape() -> None: + """The hints generator outputs the exact shape PP's airline-search wants: + IATA-prefixed firstFlightNumber, human-readable airline, space-separated + times, opaque flight_id passed through verbatim. Verified empirically + against the live extension capture.""" + from flight_cli.pp.gflight_adapter import cash_hints_from_search_result + + g = SimpleNamespace( + flight=_result( + 802.0, + _leg( + "6", + "MIA", + "LHR", + datetime(2026, 6, 30, 18, 5), + datetime(2026, 7, 1, 8, 5), + airline_iata="VS", + ), + ), + flight_id="NbXSYb", + ) + sr = fli_results_to_search_result([g]) + hints = cash_hints_from_search_result(sr) + assert len(hints) == 1 + h = hints[0] + # Exact PP-expected format (per research/capture/pp_extension_capture.json) + assert h.flight_id == "NbXSYb" + assert h.first_flight_number == "VS6" # IATA-prefixed + assert h.airline == "Virgin Atlantic" # human-readable + assert h.google_airlines == ["Virgin Atlantic"] + assert h.origin == "MIA" + assert h.dest == "LHR" + assert h.start_dt == "2026-06-30 18:05" # space-separated + assert h.end_dt == "2026-07-01 08:05" + assert h.cash_price_usd == 802 + + +def test_cash_hints_skip_slices_without_flight_id() -> None: + """Matrix-backend SearchResults have Slice.flight_id=None; the hint + builder skips those (nothing to match on).""" + from flight_cli.models import ( + Itinerary, + ItineraryDetails, + ItineraryExt, + SearchResult, + Slice, + SliceEndpoint, + ) + from flight_cli.pp.gflight_adapter import cash_hints_from_search_result + + sr = SearchResult( + solutions=[ + Itinerary( + ext=ItineraryExt(price="USD500.00"), + itinerary=ItineraryDetails( + slices=[ + Slice( + flights=["UA146"], + departure="2026-06-09T22:00:00", + origin=SliceEndpoint(code="JFK"), + destination=SliceEndpoint(code="LHR"), + # flight_id intentionally None — Matrix cash + ), + ], + ), + ), + ], + ) + hints = cash_hints_from_search_result(sr) + assert hints == [] diff --git a/tests/pp/test_match.py b/tests/pp/test_match.py index 022366c..5eebf19 100644 --- a/tests/pp/test_match.py +++ b/tests/pp/test_match.py @@ -41,6 +41,22 @@ def _itin(*slices_data: tuple[str, str, str, str]) -> Itinerary: ) +def _itin_with_id(fn: str, dep: str, o: str, d: str, flight_id: str) -> Itinerary: + """Like _itin() but populates `Slice.flight_id` — what the gflight + backend sets from Google's data[0][17].""" + s = Slice( + flights=[fn], + departure=dep, + origin=SliceEndpoint(code=o), + destination=SliceEndpoint(code=d), + flight_id=flight_id, + ) + return Itinerary( + displayTotal="USD500.00", + itinerary=ItineraryDetails(slices=[s], carriers=[]), + ) + + def _award( fn: str, dep: str, @@ -53,6 +69,7 @@ def _award( dest: str = "LHR", funding_banks: list[str] | None = None, miles_to_cash_ratio: float = 0.0125, + matched_id: str = "", ) -> AwardFlight: return AwardFlight( origin=origin, @@ -66,6 +83,7 @@ def _award( 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")], + matched_google_flight_id=matched_id, ) @@ -314,3 +332,114 @@ def test_join_empty_award_list(): ) matches = join(res, []) assert matches[0].awards == [] + + +# ──────────────── matched-id join (work-?? matched-id upgrade) ────────────── + + +def test_matched_id_key_overrides_when_present(): + """When the cash slice carries a flight_id AND an award has the same + matched_google_flight_id, that's the primary key — fires regardless of + whether flight#+date or route+time would also match.""" + res = SearchResult( + solutions=[ + _itin_with_id("UA146", "2026-06-09T22:00:00", "JFK", "LHR", flight_id="MWRvrf"), + ] + ) + awards = [ + _award( + "UA146", + "2026-06-09T22:00:00", + origin="JFK", + dest="LHR", + matched_id="MWRvrf", + ), + ] + matches = join(res, awards) + assert len(matches[0].awards) == 1 + assert matches[0].awards[0].matched_google_flight_id == "MWRvrf" + + +def test_matched_id_key_joins_when_flight_number_disagrees(): + """The matched-id key bridges flight-number mismatches the heuristic + keys would miss — e.g. cash side has a marketed flight#, award side + reports the operating flight# but the underlying flight_id agrees.""" + res = SearchResult( + solutions=[ + _itin_with_id("AA6939", "2026-08-15T18:40:00", "JFK", "LHR", flight_id="x9z2"), + ] + ) + # Different flight#, different time → no flight#+date or route+time match. + # Only the matched-id key can bridge. + awards = [ + _award( + "BA174", + "2026-08-15T19:15:00", + program="American", + origin="JFK", + dest="LHR", + matched_id="x9z2", + ), + ] + matches = join(res, awards) + assert len(matches[0].awards) == 1 + assert matches[0].awards[0].flight_number == "BA174" + + +def test_matched_id_dedup_with_heuristic_keys(): + """When the same AwardFlight matches via matched-id AND a heuristic key, + it must attach exactly once.""" + res = SearchResult( + solutions=[ + _itin_with_id("UA146", "2026-06-09T22:00:00", "JFK", "LHR", flight_id="MWRvrf"), + ] + ) + awards = [ + _award( + "UA146", + "2026-06-09T22:00:00", + origin="JFK", + dest="LHR", + matched_id="MWRvrf", + ), + ] + matches = join(res, awards) + assert len(matches[0].awards) == 1 + + +def test_award_without_matched_id_still_joins_via_heuristics(): + """An award flight without matched_google_flight_id populated must + still join via flight#+date when applicable — backwards compat with + enable_matching=False providers / responses.""" + res = SearchResult( + solutions=[ + _itin_with_id("UA146", "2026-06-09T22:00:00", "JFK", "LHR", flight_id="MWRvrf"), + ] + ) + # Award doesn't carry matched_id (empty default), so falls through to + # the (flight#, date) primary heuristic key. + awards = [_award("UA146", "2026-06-09T22:00:00", origin="JFK", dest="LHR")] + matches = join(res, awards) + assert len(matches[0].awards) == 1 + + +def test_cash_without_flight_id_skips_matched_id_path(): + """Matrix backend: slices have no flight_id; the matched-id key is + never evaluated and the heuristics carry the join.""" + res = SearchResult( + solutions=[ + _itin(("UA146", "2026-06-09T22:00:00", "JFK", "LHR")), # no flight_id + ] + ) + awards = [ + _award( + "UA146", + "2026-06-09T22:00:00", + origin="JFK", + dest="LHR", + matched_id="MWRvrf", # would match if cash had flight_id="MWRvrf" + ), + ] + matches = join(res, awards) + # Joins via flight#+date heuristic, not matched-id. + assert len(matches[0].awards) == 1 diff --git a/tests/test_registry.py b/tests/test_registry.py index 2795d69..ba86c17 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -8,12 +8,17 @@ from __future__ import annotations +from typing import TYPE_CHECKING + import anyio import pytest from flight_cli.providers.base import AwardFlight, AwardProvider, LegQuery from flight_cli.providers.registry import _gather_one_leg +if TYPE_CHECKING: + from flight_cli.pp.client import CashFlightHint + class _StubProvider: name: str = "Stub" @@ -24,9 +29,14 @@ def __init__(self, flights: list[AwardFlight], *, raises: Exception | None = Non self._raises = raises async def search_leg( - self, leg: LegQuery, *, cabins: tuple[str, ...], num_passengers: int = 1 + self, + leg: LegQuery, + *, + cabins: tuple[str, ...], + num_passengers: int = 1, + cash_hints: tuple[CashFlightHint, ...] = (), ) -> list[AwardFlight]: - _ = leg, cabins, num_passengers + _ = leg, cabins, num_passengers, cash_hints if self._raises: raise self._raises return list(self._flights)