From e19b389a8e13c666c9c7e4e604e30d981b536768 Mon Sep 17 00:00:00 2001 From: ak2k <19240940+ak2k@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:30:52 -0400 Subject: [PATCH 01/26] flight-cli: require carrier corroboration on the route+time award fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A live MSY→MIA search rendered `9.1k Delta + $6` as the economy award on AA3539 — a redemption that cannot exist, since Delta can't ticket American metal. The route+time codeshare fallback in match.py keyed solely on (origin, destination, departure-to-the-minute), documented as "a near-tight identity." It isn't. On a dense domestic route two carriers routinely push back from the same airport pair at the same minute: MSY→MIA 2026-09-09T10:45 carries both AA3539 and DL1424. Both hash to the same key, the join unions them, and the renderer's lowest-miles-per-cabin pick surfaced Delta's price on the American row. The number was real — just for a different aircraft. Gate the fallback on `same_metal`: the two flight numbers must share an IATA prefix, or their carriers must be co-members of an alliance / bilateral partnership. Identical-carrier covers the ordinary case; the partner table preserves the genuine AA-marketed/BA-operated codeshare the fallback exists to bridge. Unparseable carrier fails closed — a missed match shows no award, a wrong one invents an unbookable price. Scoped to the route+time fallback only. The (flight#, date) primary key is self-corroborating and untouched, so partner redemptions on an exact flight match (Alaska miles on AA867) still render. Verified live: AA3539 economy now reads `11.0k American Airlines + $6`; all other rows, including the Alaska-on-AA oneworld awards, are unchanged. The 5 new regression tests fail against the pre-fix matcher and pass after. --- src/flight_cli/pp/match.py | 139 ++++++++++++++++++++++++++--- tests/pp/test_match.py | 178 +++++++++++++++++++++++++++++++++++++ 2 files changed, 305 insertions(+), 12 deletions(-) diff --git a/src/flight_cli/pp/match.py b/src/flight_cli/pp/match.py index 48ebd19..2bbefb2 100644 --- a/src/flight_cli/pp/match.py +++ b/src/flight_cli/pp/match.py @@ -3,13 +3,24 @@ 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. -Codeshare-fallback key: (origin, destination, departure datetime to minute). -Matrix returns codeshares under the *marketing* flight number (e.g. AA6939 -JFK→LHR), while PointsPath returns the same physical aircraft under the -*operating* flight number (e.g. BA174 — surfaced inside the American PP -query, because PP attributes codeshares to the operator). Flight-number -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. +Codeshare-fallback key: (origin, destination, departure datetime to minute, +operating-carrier partner group). Matrix returns codeshares under the +*marketing* flight number (e.g. AA6939 JFK→LHR), while PointsPath returns the +same physical aircraft under the *operating* flight number (e.g. BA174 — +surfaced inside the American PP query, because PP attributes codeshares to the +operator). Flight-number keys can't bridge that, but route+time can: both +sources read the same airline-published schedule. + +Route+time alone is NOT an identity, though. On a dense domestic route two +carriers routinely schedule different metal off the same airport at the same +minute — MSY→MIA 2026-09-09T10:45 carries both AA3539 and DL1424. Joining on +route+time alone attached Delta's 9.1k SkyMiles price to the American +itinerary, a redemption that cannot exist (no DL/AA interline award +agreement). So the fallback additionally requires the two carriers to be +plausibly the same metal: identical IATA prefix, or co-members of an alliance +/ bilateral partnership (`_PARTNER_GROUPS`). Identical-carrier is the common +case; the partner check is what keeps the genuine AA-marketed/BA-operated +codeshare joining. The join is provider-neutral: it takes a flat `list[AwardFlight]` (each provider produces these from its own raw shape — see providers/base.py) @@ -26,15 +37,98 @@ 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") +# (ORIGIN_UPPER, DEST_UPPER, "YYYY-MM-DDTHH:MM") — carrier is checked +# separately (see `same_metal`), not folded into the key, because a +# codeshare's two carriers differ by design and a dict key can't express +# "equal OR partnered". +RouteTimeKey = tuple[str, str, str] _ISO_MINUTE_LEN = 16 # "YYYY-MM-DDTHH:MM" +_IATA_PREFIX_LEN = 2 + +# Carriers that may appear as marketing/operating counterparts for the same +# physical flight. Alliance membership plus the bilateral JVs and equity +# partnerships that actually produce codeshares on routes we search. Keyed by +# IATA code; membership is symmetric (a shared group ⇒ plausibly same metal). +# +# This gates the route+time fallback ONLY. The (flight#, date) primary key is +# unaffected — an exact flight-number hit needs no carrier corroboration. +# Being absent here costs a codeshare match; being wrongly present costs a +# fabricated award price, which is the failure this table exists to prevent. +# Erring toward omission is deliberate. +_PARTNER_GROUPS: tuple[frozenset[str], ...] = ( + # oneworld + frozenset({"AA", "AS", "BA", "AY", "IB", "JL", "MH", "QF", "QR", "RJ", "UL", "CX"}), + # Star Alliance + frozenset( + { + "UA", + "AC", + "LH", + "OS", + "LX", + "SN", + "SK", + "TP", + "TK", + "NH", + "OZ", + "SQ", + "TG", + "NZ", + "ET", + "MS", + "AV", + "CM", + "ZH", + "CA", + "A3", + "BR", + }, + ), + # SkyTeam + frozenset({"DL", "AF", "KL", "AZ", "AM", "KE", "MU", "CZ", "SU", "VN", "RO", "UX", "GA"}), + # Non-alliance bilaterals that codeshare heavily + frozenset({"DL", "VS"}), # Delta / Virgin Atlantic JV + frozenset({"DL", "WS"}), # Delta / WestJet + frozenset({"AA", "GF"}), # American / Gulf Air + frozenset({"AS", "B6"}), # Alaska / JetBlue (Northeast Alliance remnant) + frozenset({"UA", "EI"}), # United / Aer Lingus + frozenset({"EK", "QF"}), # Emirates / Qantas +) def _norm_fn(fn: str | None) -> str: return (fn or "").upper().replace(" ", "") +def _carrier(fn: str | None) -> str: + """IATA carrier prefix of a flight number ('AA3539' → 'AA'). + + Returns '' when the input is missing or too short to carry one, which + `same_metal` treats as unknown-and-therefore-not-joinable. + """ + n = _norm_fn(fn) + return n[:_IATA_PREFIX_LEN] if len(n) > _IATA_PREFIX_LEN else "" + + +def same_metal(cash_fn: str | None, award_fn: str | None) -> bool: + """Could these two flight numbers denote the same physical aircraft? + + True when the carriers are identical (the ordinary case — both sides + reporting the same flight) or co-members of `_PARTNER_GROUPS` (the + codeshare case the route+time fallback exists to bridge). False when + either carrier is unparseable: an unknown carrier can't be corroborated, + and a wrong join fabricates an unbookable award price. + """ + c, a = _carrier(cash_fn), _carrier(award_fn) + if not c or not a: + return False + if c == a: + return True + return any(c in g and a in g for g in _PARTNER_GROUPS) + + def _iso_date(s: str | None) -> str: """Best-effort isolate the YYYY-MM-DD prefix from various formats.""" if not s: @@ -82,7 +176,9 @@ def cash_route_time_key(it: Itinerary, slice_index: int = 0) -> RouteTimeKey | N """Codeshare-fallback key: origin, destination, minute-precision departure. Doesn't require `slice.flights` to be populated — origin/dest/departure - are enough to anchor the same physical flight on the award side.""" + are enough to anchor a candidate. The carrier corroboration that makes + the candidate a *match* is applied separately in `join` (`same_metal`), + which does need `flights[0]`.""" itn = it.itinerary if not itn or not itn.slices or slice_index >= len(itn.slices): return None @@ -95,6 +191,15 @@ def cash_route_time_key(it: Itinerary, slice_index: int = 0) -> RouteTimeKey | N return (o, d, t) +def cash_first_flight_number(it: Itinerary, slice_index: int = 0) -> str: + """First marketing flight number on the slice, '' when absent.""" + itn = it.itinerary + if not itn or not itn.slices or slice_index >= len(itn.slices): + return "" + flights = itn.slices[slice_index].flights or [] + return _norm_fn(flights[0]) if flights else "" + + def award_route_time_key(af: AwardFlight) -> RouteTimeKey | None: o = (af.origin or "").upper() d = (af.destination or "").upper() @@ -137,9 +242,13 @@ def join( # noqa: PLR0912 — three index lookups in priority order, hard to sp 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). + 3. **(route, time) + carrier corroboration** codeshare fallback. + Matrix's marketing flight# won't equal PP's operating flight#, so + origin+dest+minute anchors the candidate — but only counts as a + match when `same_metal` holds (identical carrier, or partners). + Without that guard, two carriers departing the same airport pair at + the same minute cross-contaminate: MSY→MIA 10:45 has both AA3539 + and DL1424, and Delta's price landed on the AA row. Hits across all three are unioned and deduped by AwardFlight identity, so a flight satisfying multiple keys isn't double-attached. @@ -187,9 +296,15 @@ def join( # noqa: PLR0912 — three index lookups in priority order, hard to sp matched.append(af) rt_k = cash_route_time_key(it, slice_index=slice_index) if rt_k and rt_k in rt_idx: + cash_fn = cash_first_flight_number(it, slice_index=slice_index) for af in rt_idx[rt_k]: if id(af) in seen_ids: continue + # Same route + same minute is only a candidate; require the + # carriers to be the same or partnered before believing it's + # the same aircraft. + if not same_metal(cash_fn, af.flight_number): + continue seen_ids.add(id(af)) matched.append(af) diff --git a/tests/pp/test_match.py b/tests/pp/test_match.py index 5eebf19..98a9023 100644 --- a/tests/pp/test_match.py +++ b/tests/pp/test_match.py @@ -443,3 +443,181 @@ def test_cash_without_flight_id_skips_matched_id_path(): matches = join(res, awards) # Joins via flight#+date heuristic, not matched-id. assert len(matches[0].awards) == 1 + + +# ────────────── carrier corroboration on the route+time fallback ────────────── +# +# Regression: a live MSY→MIA search attached a 9.1k Delta SkyMiles price to +# American's AA3539. Delta cannot ticket AA metal — no interline award +# agreement — and the two are genuinely different aircraft that happen to +# push back at the same minute. Route+time alone can't tell them apart. + + +def test_join_route_time_rejects_different_carrier_at_same_minute(): + """The bug, reduced. AA3539 and DL1424 both depart MSY→MIA at 10:45 on + 2026-09-09. Only the American award may attach.""" + res = SearchResult( + solutions=[ + _itin(("AA3539", "2026-09-09T10:45:00", "MSY", "MIA")), + ] + ) + awards = [ + _award( + "DL1424", + "2026-09-09T10:45:00", + program="Delta", + miles=9100, + origin="MSY", + dest="MIA", + ), + ] + matches = join(res, awards) + assert matches[0].awards == [] + + +def test_join_route_time_keeps_same_carrier_at_same_minute(): + """The other half of the collision: the AA-programmed award on the same + route+minute still attaches.""" + res = SearchResult( + solutions=[ + _itin(("AA3539", "2026-09-09T10:45:00", "MSY", "MIA")), + ] + ) + awards = [ + _award( + "AA3539", + "2026-09-09T10:45:00", + program="American", + origin="MSY", + dest="MIA", + ), + ] + matches = join(res, awards) + assert len(matches[0].awards) == 1 + assert matches[0].awards[0].program == "American" + + +def test_join_route_time_partitions_a_real_collision(): + """Both flights present at once, as the live payload had them: each cash + itinerary keeps only its own carrier's award.""" + res = SearchResult( + solutions=[ + _itin(("AA3539", "2026-09-09T10:45:00", "MSY", "MIA")), + _itin(("DL1424", "2026-09-09T10:45:00", "MSY", "MIA")), + ] + ) + awards = [ + _award("DL1424", "2026-09-09T10:45:00", program="Delta", origin="MSY", dest="MIA"), + _award("AA3539", "2026-09-09T10:45:00", program="American", origin="MSY", dest="MIA"), + ] + matches = join(res, awards) + assert [a.program for a in matches[0].awards] == ["American"] + assert [a.program for a in matches[1].awards] == ["Delta"] + + +def test_join_route_time_still_bridges_oneworld_codeshare(): + """The fallback's reason for existing must survive the guard: AA-marketed + / BA-operated is a partner pair, so it still joins.""" + res = SearchResult( + solutions=[ + _itin(("AA6939", "2026-08-15T18:40:00", "JFK", "LHR")), + ] + ) + 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_number == "BA174" + + +def test_join_route_time_rejects_cross_alliance_pair(): + """United (Star) must not pick up an American (oneworld) award, even on + an identical route+minute.""" + res = SearchResult( + solutions=[ + _itin(("UA1122", "2026-08-15T18:40:00", "JFK", "LHR")), + ] + ) + awards = [ + _award("AA100", "2026-08-15T18:40:00", program="American", origin="JFK", dest="LHR"), + ] + matches = join(res, awards) + assert matches[0].awards == [] + + +def test_join_route_time_allows_non_alliance_bilateral(): + """Delta/Virgin Atlantic codeshare across alliance lines via the JV.""" + res = SearchResult( + solutions=[ + _itin(("DL4321", "2026-08-15T18:40:00", "JFK", "LHR")), + ] + ) + awards = [ + _award("VS26", "2026-08-15T18:40:00", program="Delta", origin="JFK", dest="LHR"), + ] + matches = join(res, awards) + assert len(matches[0].awards) == 1 + + +def test_join_route_time_skipped_when_cash_slice_has_no_flight_number(): + """No cash flight number ⇒ no carrier to corroborate against ⇒ the + fallback can't fire. Fails closed: a wrong join invents an unbookable + price, a missed join just shows no award.""" + res = SearchResult( + solutions=[ + Itinerary( + displayTotal="USD500.00", + itinerary=ItineraryDetails( + slices=[ + Slice( + flights=[], + departure="2026-09-09T10:45:00", + origin=SliceEndpoint(code="MSY"), + destination=SliceEndpoint(code="MIA"), + ), + ], + carriers=[], + ), + ), + ] + ) + awards = [ + _award("AA3539", "2026-09-09T10:45:00", program="American", origin="MSY", dest="MIA"), + ] + matches = join(res, awards) + assert matches[0].awards == [] + + +def test_join_flight_number_key_needs_no_carrier_corroboration(): + """The guard is scoped to the route+time fallback. An exact (flight#, + date) hit is self-corroborating and must be untouched — including when + the award's *program* differs from the operating carrier, which is the + normal partner-redemption case (Alaska miles on an AA flight).""" + res = SearchResult( + solutions=[ + _itin(("AA867", "2026-09-09T06:00:00", "MSY", "MIA")), + ] + ) + awards = [ + _award( + "AA867", + "2026-09-09T06:00:00", + program="Alaska", + miles=4500, + origin="MSY", + dest="MIA", + ), + ] + matches = join(res, awards) + assert len(matches[0].awards) == 1 + assert matches[0].awards[0].program == "Alaska" + + +def test_same_metal_helper_rejects_unparseable_carrier(): + from flight_cli.pp.match import same_metal + + assert same_metal("AA3539", "AA3539") is True + assert same_metal("AA3539", "DL1424") is False + assert same_metal("", "AA3539") is False + assert same_metal("AA", "AA3539") is False # too short to carry a number From fb59713dfe2dd792b40005157c69ba5bbc1e2acc Mon Sep 17 00:00:00 2001 From: ak2k <19240940+ak2k@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:31:28 -0400 Subject: [PATCH 02/26] flight-cli: resolve award route+time buckets instead of filtering them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 1 gated the route+time fallback on `same_metal` and claimed the collision class was closed. Code review (correctness + adversarial, both reproducing against the worktree) showed it wasn't — the guard relocated the bug rather than removing it, and made it less visible: a Delta price on an AA row reads as obviously wrong, an Alaska price does not. Four confirmed defects, all now pinned by tests that fail against round 1: 1. Intra-alliance collisions survived. Same-route/same-minute competition is DENSEST inside an alliance, which is exactly what `same_metal` admits. Cash AA118 JFK->LAX 10:45 with awards AA118/American/25k and AS17/Alaska/ 7.5k attached both, and the renderer's lowest-miles pick showed 7.5k Alaska on the American row. Fix: `_pick_metal` resolves a bucket rather than filtering it — an exact-carrier award wins outright and suppresses the partners beside it; a bucket of two or more DIFFERENT partner carriers is unresolvable and attaches nothing. 2. The alliance table rejected the codeshares the fallback exists for. Regional operators are not alliance members, so mainline-marketed / regional-operated — the dominant real shape, and the repo's own documented LH9498/EN8858 example — stopped joining. Fix: `_REGIONAL_OPERATORS`, directional on purpose (a symmetric table would pair MQ with OH, two unrelated AA regionals). 3. Connection count was never compared. A 1-stop award is cheaper than the nonstop it renders beside, so it won the lowest-miles pick and printed under a "nonstop" label taken from the cash slice. 4. `(flight#, date)` omitted route, so an AA100 JFK->LHR row could attach an AA100 MIA->DFW award. Flight numbers are unique per route per day, not per day. Pre-existing, but round 1 newly asserted this key was "self-corroborating" — the claim was wrong, so the key is now (flight#, date, origin, dest). Deliberate fail-closed change: cash AA6939 with both a BA174 and an AA6939 award in one bucket now keeps only AA6939. That bucket is structurally identical to the AA118/AS17 collision, so the two cannot be told apart from (route, time, flight number) alone. Cost is a hidden second booking option for one seat; the alternative cost is another aircraft's price on the row. Only `Slice.legs[i].operating_carrier` ground truth can distinguish them — see the characterization test, which should flip back when the join reads it. Live: AA3553 economy 4.5k Alaska -> 9.5k American, AA867 business 9.0k Alaska -> 25.5k American. Round 1 was still rendering wrong prices on those rows. make check green: 528 tests, ruff + basedpyright clean. --- src/flight_cli/pp/match.py | 191 +++++++++++++++++++++------- tests/pp/test_match.py | 246 ++++++++++++++++++++++++++++++++++--- 2 files changed, 373 insertions(+), 64 deletions(-) diff --git a/src/flight_cli/pp/match.py b/src/flight_cli/pp/match.py index 2bbefb2..530e3e0 100644 --- a/src/flight_cli/pp/match.py +++ b/src/flight_cli/pp/match.py @@ -33,10 +33,11 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from ..models import Itinerary, SearchResult + from ..models import Itinerary, SearchResult, Slice from ..providers.base import AwardFlight -MatchKey = tuple[str, str] # (FLIGHT_NUMBER_UPPER_NOSPACE, "YYYY-MM-DD") +# (FLIGHT_NUMBER_UPPER_NOSPACE, "YYYY-MM-DD", ORIGIN_UPPER, DEST_UPPER) +MatchKey = tuple[str, str, str, str] # (ORIGIN_UPPER, DEST_UPPER, "YYYY-MM-DDTHH:MM") — carrier is checked # separately (see `same_metal`), not folded into the key, because a # codeshare's two carriers differ by design and a dict key can't express @@ -97,6 +98,28 @@ frozenset({"EK", "QF"}), # Emirates / Qantas ) +# Mainline → the regional carriers that operate its metal under its flight +# numbers. This is the DOMINANT real codeshare shape (LH9498 marketed / +# EN8858 operated is the repo's own documented example — see +# docs/memories/gf_routing_and_carriers.md), and an alliance table misses it +# entirely: a feeder is not an alliance member. +# +# Directional and asymmetric ON PURPOSE. Folding these into the symmetric +# `_PARTNER_GROUPS` would make MQ and OH (two unrelated AA regionals) match +# each other. Lookup is `award_carrier in _REGIONAL_OPERATORS[cash_carrier]`, +# so mainline-marketed / regional-operated joins and nothing else does. +# Last verified: 2026-07. +_REGIONAL_OPERATORS: dict[str, frozenset[str]] = { + "AA": frozenset({"MQ", "OH", "YX", "PT", "ZW", "G7", "9K"}), + "DL": frozenset({"9E", "OO", "YX", "G7", "EM"}), + "UA": frozenset({"OO", "YX", "ZW", "C5", "AX", "G7", "EM"}), + "AS": frozenset({"QX", "OO"}), + "LH": frozenset({"EN", "CL", "WK", "EW", "VL"}), + "AF": frozenset({"A5", "XK"}), + "BA": frozenset({"CJ", "SN"}), + "AC": frozenset({"QK", "RV", "ZX", "8P"}), +} + def _norm_fn(fn: str | None) -> str: return (fn or "").upper().replace(" ", "") @@ -112,23 +135,77 @@ def _carrier(fn: str | None) -> str: return n[:_IATA_PREFIX_LEN] if len(n) > _IATA_PREFIX_LEN else "" +def same_carrier(cash_fn: str | None, award_fn: str | None) -> bool: + """Exact IATA-prefix equality, with '' (unparseable) never matching.""" + c, a = _carrier(cash_fn), _carrier(award_fn) + return bool(c) and c == a + + def same_metal(cash_fn: str | None, award_fn: str | None) -> bool: """Could these two flight numbers denote the same physical aircraft? True when the carriers are identical (the ordinary case — both sides - reporting the same flight) or co-members of `_PARTNER_GROUPS` (the - codeshare case the route+time fallback exists to bridge). False when - either carrier is unparseable: an unknown carrier can't be corroborated, - and a wrong join fabricates an unbookable award price. + reporting the same flight), when one is the other's regional operator + (`_REGIONAL_OPERATORS` — the dominant real codeshare shape), or when they + are co-members of `_PARTNER_GROUPS`. False when either carrier is + unparseable: an unknown carrier can't be corroborated, and a wrong join + fabricates an unbookable award price. + + NOTE: this is a *candidate* predicate, deliberately loose. Partner + agreement does not mean same aircraft — two oneworld carriers routinely + fly the same route at the same minute on different metal. `_pick_metal` + is what resolves a bucket of candidates down to the right one; calling + `same_metal` alone as an accept/reject test reintroduces the collision + (cash AA118 + award AS17 at the same minute are both "same metal" here). """ c, a = _carrier(cash_fn), _carrier(award_fn) if not c or not a: return False if c == a: return True + if a in _REGIONAL_OPERATORS.get(c, frozenset()): + return True return any(c in g and a in g for g in _PARTNER_GROUPS) +def _slice_stop_count(s: Slice) -> int: + """Number of connections on a cash slice. `stops` is authoritative when + populated; otherwise derive from the segment count.""" + if s.stops: + return len(s.stops) + return max(len(s.flights) - 1, 0) + + +def _pick_metal(cash_fn: str, candidates: list[AwardFlight]) -> list[AwardFlight]: + """Resolve one route+time bucket to the awards that are plausibly the + cash flight's own metal. + + A bucket keyed on (origin, destination, minute) can hold several genuinely + different aircraft — same-alliance carriers compete head-to-head on dense + routes, so AA118 and AS17 can both leave JFK for LAX at 10:45. Accepting + every partner in the bucket is what let a 7.5k Alaska price render on the + American row. + + Resolution: + 1. If any candidate is the *same carrier* as the cash flight, that IS + the flight — keep only those and discard the partners beside it. + 2. Otherwise the bucket is codeshare-shaped (the fallback's reason for + existing: marketing AA6939 ↔ operating BA174). Accept it only when + the surviving partners resolve to a single carrier; two different + partner carriers in one bucket means we cannot tell which is the + metal, so we take neither. + """ + exact = [af for af in candidates if same_carrier(cash_fn, af.flight_number)] + if exact: + return exact + partners = [af for af in candidates if same_metal(cash_fn, af.flight_number)] + if not partners: + return [] + if len({_carrier(af.flight_number) for af in partners}) > 1: + return [] + return partners + + def _iso_date(s: str | None) -> str: """Best-effort isolate the YYYY-MM-DD prefix from various formats.""" if not s: @@ -153,6 +230,11 @@ def cash_match_key(it: Itinerary, slice_index: int = 0) -> MatchKey | None: Default slice_index=0 = outbound leg. For round-trips pass 1 to match the return leg; for multi-city pass 2, 3, etc. + + Route is part of the key: a flight number is only unique *per route* on a + given date. Airlines reuse numbers across the day's rotations, so without + origin/dest an `AA100 JFK→LHR 18:00` cash row matched an `AA100 MIA→DFW + 06:30` award — same number, same date, different flight. """ itn = it.itinerary if not itn or not itn.slices or slice_index >= len(itn.slices): @@ -163,13 +245,21 @@ def cash_match_key(it: Itinerary, slice_index: int = 0) -> MatchKey | None: return None fn = _norm_fn(flights[0]) dep = _iso_date(s.departure) - if not fn or not dep: + o = ((s.origin.code if s.origin else None) or "").upper() + d = ((s.destination.code if s.destination else None) or "").upper() + if not fn or not dep or not o or not d: return None - return (fn, dep) + return (fn, dep, o, d) -def award_match_key(af: AwardFlight) -> MatchKey: - return (_norm_fn(af.flight_number), _iso_date(af.departure)) +def award_match_key(af: AwardFlight) -> MatchKey | None: + o = (af.origin or "").upper() + d = (af.destination or "").upper() + fn = _norm_fn(af.flight_number) + dep = _iso_date(af.departure) + if not fn or not dep or not o or not d: + return None + return (fn, dep, o, d) def cash_route_time_key(it: Itinerary, slice_index: int = 0) -> RouteTimeKey | None: @@ -228,7 +318,15 @@ def cash_matched_id_key(it: Itinerary, slice_index: int = 0) -> str | None: return fid or None -def join( # noqa: PLR0912 — three index lookups in priority order, hard to split cleanly +def _cash_slice(it: Itinerary, slice_index: int) -> Slice | None: + """Bounds-checked slice access — the guard every cash_* key repeats.""" + itn = it.itinerary + if not itn or not itn.slices or slice_index >= len(itn.slices): + return None + return itn.slices[slice_index] + + +def join( search: SearchResult, awards: list[AwardFlight], *, @@ -236,22 +334,27 @@ def join( # noqa: PLR0912 — three index lookups in priority order, hard to sp ) -> list[MatchedFare]: """Outer-join cash itineraries onto award flights. - Match strategy, in priority order: + Match strategy. Keys 1 and 2 are exact identities; key 3 is a heuristic + that must be resolved, not merely filtered: 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) + carrier corroboration** codeshare fallback. - Matrix's marketing flight# won't equal PP's operating flight#, so - origin+dest+minute anchors the candidate — but only counts as a - match when `same_metal` holds (identical carrier, or partners). - Without that guard, two carriers departing the same airport pair at - the same minute cross-contaminate: MSY→MIA 10:45 has both AA3539 - and DL1424, and Delta's price landed on the AA row. + 2. **(flight#, date, origin, dest)** primary key. Route is part of the + identity — flight numbers are only unique per route per day. + 3. **(route, time) → `_pick_metal`** codeshare fallback. Matrix's + marketing flight# won't equal PP's operating flight#, so + origin+dest+minute anchors the *bucket* — but a bucket can hold + several genuinely different aircraft (MSY→MIA 10:45 held both + AA3539 and DL1424; JFK→LAX 10:45 holds both AA118 and AS17). + `_pick_metal` resolves the bucket: same-carrier wins outright, and + an ambiguous multi-partner bucket resolves to nothing. Hits across all three are unioned and deduped by AwardFlight identity, so - a flight satisfying multiple keys isn't double-attached. + a flight satisfying multiple keys isn't double-attached. Every attached + award must also agree with the cash slice on connection count — a 1-stop + award is cheaper than the nonstop it would be rendered beside, so it wins + the renderer's lowest-miles pick and prints under a "nonstop" label. Cash itineraries with no award match keep an empty `awards` list — caller decides whether to render them or filter to inner-join. @@ -269,7 +372,7 @@ def join( # noqa: PLR0912 — three index lookups in priority order, hard to sp 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]: + if fn_k: fn_idx.setdefault(fn_k, []).append(af) rt_k = award_route_time_key(af) if rt_k: @@ -277,36 +380,30 @@ def join( # noqa: PLR0912 — three index lookups in priority order, hard to sp out: list[MatchedFare] = [] for it in search.solutions: - matched: list[AwardFlight] = [] - seen_ids: set[int] = set() + s = _cash_slice(it, slice_index) + cash_stops = _slice_stop_count(s) if s else 0 + candidates: list[AwardFlight] = [] 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) + if mid_k: + candidates.extend(mid_idx.get(mid_k, ())) 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]: - if id(af) in seen_ids: - continue - seen_ids.add(id(af)) - matched.append(af) + if fn_k: + candidates.extend(fn_idx.get(fn_k, ())) rt_k = cash_route_time_key(it, slice_index=slice_index) - if rt_k and rt_k in rt_idx: + if rt_k: cash_fn = cash_first_flight_number(it, slice_index=slice_index) - for af in rt_idx[rt_k]: - if id(af) in seen_ids: - continue - # Same route + same minute is only a candidate; require the - # carriers to be the same or partnered before believing it's - # the same aircraft. - if not same_metal(cash_fn, af.flight_number): - continue - seen_ids.add(id(af)) - matched.append(af) + candidates.extend(_pick_metal(cash_fn, list(rt_idx.get(rt_k, ())))) + + matched: list[AwardFlight] = [] + seen_ids: set[int] = set() + for af in candidates: + if id(af) in seen_ids: + continue + if af.num_connections != cash_stops: + continue + seen_ids.add(id(af)) + matched.append(af) out.append(MatchedFare(itinerary=it, awards=matched)) return out diff --git a/tests/pp/test_match.py b/tests/pp/test_match.py index 98a9023..a8b968c 100644 --- a/tests/pp/test_match.py +++ b/tests/pp/test_match.py @@ -19,6 +19,7 @@ cash_match_key, cash_route_time_key, join, + same_metal, ) from flight_cli.providers.base import AwardFlight, CabinAward @@ -65,11 +66,12 @@ def _award( miles: int = 47000, tax: float = 250.0, cabin: str = "Economy", - origin: str = "EWR", + origin: str = "JFK", dest: str = "LHR", funding_banks: list[str] | None = None, miles_to_cash_ratio: float = 0.0125, matched_id: str = "", + num_connections: int = 0, ) -> AwardFlight: return AwardFlight( origin=origin, @@ -77,7 +79,7 @@ def _award( departure=dep, arrival=dep, flight_number=fn, - num_connections=0, + num_connections=num_connections, provider="PointsPath", program=program, miles_to_cash_ratio=miles_to_cash_ratio, @@ -92,7 +94,7 @@ def _award( def test_cash_match_key_uppercases_and_strips_whitespace(): it = _itin(("ua 146", "2026-06-09T22:00:00", "JFK", "LHR")) - assert cash_match_key(it) == ("UA146", "2026-06-09") + assert cash_match_key(it) == ("UA146", "2026-06-09", "JFK", "LHR") def test_cash_match_key_empty_when_no_flights(): @@ -103,7 +105,7 @@ def test_cash_match_key_empty_when_no_flights(): def test_cash_match_key_handles_space_separated_iso(): """Some Matrix payloads return 'YYYY-MM-DD HH:MM' instead of ISO 'T'.""" it = _itin(("UA146", "2026-06-09 22:00", "JFK", "LHR")) - assert cash_match_key(it) == ("UA146", "2026-06-09") + assert cash_match_key(it) == ("UA146", "2026-06-09", "JFK", "LHR") def test_cash_match_key_uses_slice_index_for_return_leg(): @@ -111,8 +113,8 @@ def test_cash_match_key_uses_slice_index_for_return_leg(): ("UA146", "2026-06-09T22:00:00", "JFK", "LHR"), # outbound ("UA147", "2026-06-12T10:00:00", "LHR", "JFK"), # return ) - assert cash_match_key(it, slice_index=0) == ("UA146", "2026-06-09") - assert cash_match_key(it, slice_index=1) == ("UA147", "2026-06-12") + assert cash_match_key(it, slice_index=0) == ("UA146", "2026-06-09", "JFK", "LHR") + assert cash_match_key(it, slice_index=1) == ("UA147", "2026-06-12", "LHR", "JFK") def test_cash_match_key_out_of_range_slice_returns_none(): @@ -122,7 +124,7 @@ def test_cash_match_key_out_of_range_slice_returns_none(): def test_award_match_key_normalizes_consistently(): af = _award("ua 146", "2026-06-09T22:00:00") - assert award_match_key(af) == ("UA146", "2026-06-09") + assert award_match_key(af) == ("UA146", "2026-06-09", "JFK", "LHR") # ───────────────────────── route+time key ────────────────────────────────── @@ -244,25 +246,38 @@ def test_join_route_time_fallback_requires_minute_precision(): assert matches[0].awards == [] -def test_join_route_time_unions_with_flight_number_match(): - """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.""" +def test_join_exact_carrier_award_suppresses_partner_in_same_bucket(): + """Characterization of a deliberate fail-closed choice. + + Cash AA6939 with two awards in the same route+time bucket: BA174 (the + operating carrier of the codeshare) and AA6939 (the marketing number). + The old behavior attached both, on the premise that they describe one + aircraft. + + We can no longer assume that. This bucket is structurally IDENTICAL to + the collision case — cash AA118 with awards AA118 (American) and AS17 + (Alaska) — where the two awards are different aircraft that merely share + a departure minute. From (route, time, flight number) alone the two are + indistinguishable, so resolving them differently is not possible here. + + We keep the exact-carrier award and drop the partner. The cost is a + hidden second booking option for one physical seat; the alternative cost + is rendering another aircraft's price on this row. Only `operating_carrier` + ground truth (models.py:105, gflight backend only) can tell these apart — + when the join learns to read it, this test should flip back. + """ res = SearchResult( solutions=[ _itin(("AA6939", "2026-08-15T18:40:00", "JFK", "LHR")), ] ) awards = [ - # Same metal under the OPERATING number (matches via route+time): _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"} + assert programs == {"VirginAtlantic"} # ────────────────────────────── join semantics ───────────────────────────── @@ -615,9 +630,206 @@ def test_join_flight_number_key_needs_no_carrier_corroboration(): def test_same_metal_helper_rejects_unparseable_carrier(): - from flight_cli.pp.match import same_metal - assert same_metal("AA3539", "AA3539") is True assert same_metal("AA3539", "DL1424") is False assert same_metal("", "AA3539") is False assert same_metal("AA", "AA3539") is False # too short to carry a number + + +# ───────── review round 2: bucket resolution, regionals, stop count ───────── +# +# The first fix gated the route+time fallback on `same_metal` and claimed the +# collision class was closed. It wasn't: same-alliance carriers compete on the +# same route at the same minute more often than cross-alliance ones do, so the +# guard relocated the bug instead of removing it — and made it less visible +# (an Alaska price on an AA row reads plausible; a Delta price did not). + + +def test_join_rejects_intra_alliance_collision_via_exact_carrier_preference(): + """The relocated bug. Cash AA118 JFK->LAX 10:45 with two awards in the + bucket: the real AA118 award and an AS17 award (different aircraft, same + minute, both oneworld). Only the American award may attach — otherwise + the renderer's lowest-miles pick shows 7.5k Alaska on the AA row.""" + res = SearchResult( + solutions=[ + _itin(("AA118", "2026-08-15T10:45:00", "JFK", "LAX")), + ] + ) + awards = [ + _award( + "AA118", + "2026-08-15T10:45:00", + program="American", + miles=25000, + origin="JFK", + dest="LAX", + ), + _award( + "AS17", "2026-08-15T10:45:00", program="Alaska", miles=7500, origin="JFK", dest="LAX" + ), + ] + matches = join(res, awards) + assert [a.program for a in matches[0].awards] == ["American"] + + +def test_join_rejects_ambiguous_multi_partner_bucket(): + """No exact-carrier award, and two DIFFERENT partner carriers share the + bucket. We cannot tell which is the cash flight's metal, so neither + attaches — guessing would be a coin flip on a price the user might book.""" + res = SearchResult( + solutions=[ + _itin(("AA118", "2026-08-15T10:45:00", "JFK", "LAX")), + ] + ) + awards = [ + _award("AS17", "2026-08-15T10:45:00", program="Alaska", origin="JFK", dest="LAX"), + _award("BA99", "2026-08-15T10:45:00", program="British Airways", origin="JFK", dest="LAX"), + ] + matches = join(res, awards) + assert matches[0].awards == [] + + +def test_join_still_bridges_unambiguous_codeshare(): + """The fallback's reason for existing survives: a single partner carrier + alone in the bucket (no competing exact-carrier award) still joins.""" + res = SearchResult( + solutions=[ + _itin(("AA6939", "2026-08-15T18:40:00", "JFK", "LHR")), + ] + ) + 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_number == "BA174" + + +def test_join_bridges_mainline_marketed_regional_operated(): + """The dominant real codeshare shape, and the repo's own documented + example (docs/memories/gf_routing_and_carriers.md): LH9498 marketed, + EN8858 (Air Dolomiti) operated. An alliance-only table rejected this — + a feeder is not an alliance member.""" + res = SearchResult( + solutions=[ + _itin(("LH9498", "2026-08-15T09:15:00", "FRA", "FLR")), + ] + ) + awards = [ + _award("EN8858", "2026-08-15T09:15:00", program="United", origin="FRA", dest="FLR"), + ] + matches = join(res, awards) + assert len(matches[0].awards) == 1 + assert matches[0].awards[0].flight_number == "EN8858" + + +def test_regional_operator_mapping_is_directional_not_symmetric(): + """MQ and OH are both American regionals but have no relationship with + each other. A symmetric table would wrongly pair them.""" + assert same_metal("AA3539", "MQ3539") is True # mainline -> its regional + assert same_metal("MQ3539", "OH1234") is False # two regionals of the same mainline + assert same_metal("MQ3539", "DL1424") is False # regional -> unrelated mainline + + +def test_join_rejects_connecting_award_on_nonstop_cash_row(): + """A 1-stop award is cheaper than the nonstop it would render beside, so + it wins the lowest-miles pick — and the row still prints 'nonstop', + because stops come from the cash slice. Connection count must agree.""" + res = SearchResult( + solutions=[ + _itin(("AA100", "2026-08-15T18:30:00", "JFK", "LHR")), + ] + ) + awards = [ + _award( + "AA100", + "2026-08-15T18:30:00", + program="American", + miles=12000, + origin="JFK", + dest="LHR", + num_connections=1, + ), + ] + matches = join(res, awards) + assert matches[0].awards == [] + + +def test_join_accepts_award_whose_stop_count_agrees(): + """The other side of the stop-count check: a genuine nonstop award on a + nonstop cash row still attaches.""" + res = SearchResult( + solutions=[ + _itin(("AA100", "2026-08-15T18:30:00", "JFK", "LHR")), + ] + ) + awards = [ + _award( + "AA100", + "2026-08-15T18:30:00", + program="American", + origin="JFK", + dest="LHR", + num_connections=0, + ), + ] + matches = join(res, awards) + assert len(matches[0].awards) == 1 + + +def test_join_flight_number_key_requires_matching_route(): + """Flight numbers repeat across a carrier's daily rotations, so + (flight#, date) alone is not an identity: an AA100 JFK->LHR cash row + used to attach an AA100 MIA->DFW award.""" + res = SearchResult( + solutions=[ + _itin(("AA100", "2026-08-15T18:00:00", "JFK", "LHR")), + ] + ) + awards = [ + _award( + "AA100", "2026-08-15T06:30:00", program="American", miles=7500, origin="MIA", dest="DFW" + ), + ] + matches = join(res, awards) + assert matches[0].awards == [] + + +def test_partner_groups_have_no_unintended_transitivity(): + """Membership is tested per-group, so overlapping groups (DL is in both + SkyTeam and the DL/VS bilateral) must not chain: VS must not reach AF.""" + assert same_metal("DL1", "VS2") is True # direct bilateral + assert same_metal("VS1", "AF2") is False # would require chaining through DL + assert same_metal("B61", "AA2") is False # via AS: B6-AS bilateral, AS-AA oneworld + assert same_metal("EK1", "AA2") is False # via QF: EK-QF bilateral, QF-AA oneworld + + +def test_partner_group_codes_are_well_formed(): + """Structural pin: a malformed entry would silently never match, since + `_carrier` only ever produces two-character prefixes.""" + # DIVERGE: reportPrivateUsage — these are module-internal reference + # tables, not API. A structural test is exactly the case where reaching + # in is correct; exporting them publicly to satisfy the rule would widen + # the surface for a test's benefit. + from flight_cli.pp.match import ( + _PARTNER_GROUPS, # pyright: ignore[reportPrivateUsage] + _REGIONAL_OPERATORS, # pyright: ignore[reportPrivateUsage] + ) + + for group in _PARTNER_GROUPS: + assert len(group) >= 2, f"degenerate group: {group}" + for code in group: + assert len(code) == 2, f"not a 2-char IATA code: {code!r}" + for mainline, regionals in _REGIONAL_OPERATORS.items(): + assert len(mainline) == 2, f"not a 2-char IATA code: {mainline!r}" + for code in regionals: + assert len(code) == 2, f"not a 2-char IATA code: {code!r}" + assert code != mainline, f"{mainline} lists itself as its own regional" + + +def test_same_metal_rejects_award_side_empty_flight_number(): + """Fails closed on an unparseable award-side carrier, mirroring the + cash-side case. A missed match shows no award; a wrong one invents a + price the user might try to book.""" + assert same_metal("AA3539", "") is False + assert same_metal("AA3539", None) is False From 2569c04b897de50d8d6715d859384d620e9f137a Mon Sep 17 00:00:00 2001 From: ak2k <19240940+ak2k@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:47:04 -0400 Subject: [PATCH 03/26] flight-cli: use arrival time as the award-match identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Answering "is failing closed on the AA6939/BA174 bucket optimal?" — no. The premise was wrong: I claimed (route, time, flight number) was all we had, but `arrival` is present on BOTH sides (`AwardFlight.arrival`, `Slice.arrival`) and was simply unused. It is also a near-perfect discriminator. Two aircraft sharing a departure minute on the same route essentially never share an arrival minute — both sides read the same published schedule. Measured on a live MSY→MIA/FLL payload: keying on route+departure left 3 multi-carrier buckets out of 41; adding arrival left 0 out of 91. So `_pick_metal` now filters the bucket by arrival first, and the deliberate regression from the previous commit is gone: cash AA6939 + a BA174 award at the same arrival resolves to the codeshare (attached), while an AS99 award sharing only the departure minute is correctly excluded. The characterization test that recorded the fail-closed behavior is replaced by tests asserting the resolution. Carrier logic (same-carrier-wins, then single-partner) stays as the backstop: `Slice.arrival` is nullable and providers may omit it. The arrival filter also only applies when it matched something, so an award side without arrivals degrades to the old path instead of wiping the bucket. This caught one more live error the previous round missed: AA3539 economy was still reading 11.0k, picked up from the AA2566 award — same route, same 10:45 departure, arrival 13:47 vs 13:46. Correct value is 15.5k. Only arrival separated them. make check green: 530 tests, ruff + basedpyright clean. --- src/flight_cli/pp/match.py | 59 +++++++++++++++-------- tests/pp/test_match.py | 98 +++++++++++++++++++++++++++----------- 2 files changed, 111 insertions(+), 46 deletions(-) diff --git a/src/flight_cli/pp/match.py b/src/flight_cli/pp/match.py index 530e3e0..04d3e57 100644 --- a/src/flight_cli/pp/match.py +++ b/src/flight_cli/pp/match.py @@ -176,25 +176,45 @@ def _slice_stop_count(s: Slice) -> int: return max(len(s.flights) - 1, 0) -def _pick_metal(cash_fn: str, candidates: list[AwardFlight]) -> list[AwardFlight]: - """Resolve one route+time bucket to the awards that are plausibly the - cash flight's own metal. - - A bucket keyed on (origin, destination, minute) can hold several genuinely - different aircraft — same-alliance carriers compete head-to-head on dense - routes, so AA118 and AS17 can both leave JFK for LAX at 10:45. Accepting - every partner in the bucket is what let a 7.5k Alaska price render on the - American row. - - Resolution: - 1. If any candidate is the *same carrier* as the cash flight, that IS - the flight — keep only those and discard the partners beside it. - 2. Otherwise the bucket is codeshare-shaped (the fallback's reason for - existing: marketing AA6939 ↔ operating BA174). Accept it only when - the surviving partners resolve to a single carrier; two different - partner carriers in one bucket means we cannot tell which is the - metal, so we take neither. +def _pick_metal( + cash_fn: str, + cash_arrival: str, + candidates: list[AwardFlight], +) -> list[AwardFlight]: + """Resolve one route+time bucket to the awards that are the cash flight's + own metal. + + A bucket keyed on (origin, destination, departure minute) can hold several + genuinely different aircraft — same-alliance carriers compete head-to-head + on dense routes, so AA118 and AS17 can both leave JFK for LAX at 10:45. + Accepting every partner in the bucket is what let a 7.5k Alaska price + render on the American row. + + Resolution, in order: + 1. **Arrival time.** Two aircraft sharing a departure minute on the same + route essentially never share an arrival minute too — both sides read + the same published schedule, so this is the real identity. Measured + on a live MSY→MIA/FLL payload: keying on route+departure alone left 3 + multi-carrier buckets out of 41; adding arrival left 0 out of 91. + When the cash side supplies an arrival, it filters the bucket, and + that is normally enough to resolve it outright. + 2. **Same carrier wins.** A same-carrier award at this route+minute IS + the flight; partners beside it are different metal. + 3. **Single partner.** Otherwise the bucket is codeshare-shaped (the + fallback's reason for existing: marketing AA6939 ↔ operating BA174). + Accept only when the survivors resolve to ONE carrier — two distinct + partner carriers means we cannot tell which is the metal. + + Steps 2-3 still run after step 1 because arrival is not guaranteed: + `Slice.arrival` is optional and award providers may omit it, so the + carrier logic remains the backstop when the times are missing. """ + if cash_arrival: + timed = [af for af in candidates if _iso_minute(af.arrival) == cash_arrival] + # Only trust the filter when it actually resolved something; an award + # side that omits arrival would otherwise wipe the bucket. + if timed: + candidates = timed exact = [af for af in candidates if same_carrier(cash_fn, af.flight_number)] if exact: return exact @@ -393,7 +413,8 @@ def join( rt_k = cash_route_time_key(it, slice_index=slice_index) if rt_k: cash_fn = cash_first_flight_number(it, slice_index=slice_index) - candidates.extend(_pick_metal(cash_fn, list(rt_idx.get(rt_k, ())))) + cash_arr = _iso_minute(s.arrival) if s else "" + candidates.extend(_pick_metal(cash_fn, cash_arr, list(rt_idx.get(rt_k, ())))) matched: list[AwardFlight] = [] seen_ids: set[int] = set() diff --git a/tests/pp/test_match.py b/tests/pp/test_match.py index a8b968c..cda6d18 100644 --- a/tests/pp/test_match.py +++ b/tests/pp/test_match.py @@ -24,17 +24,19 @@ from flight_cli.providers.base import AwardFlight, CabinAward -def _itin(*slices_data: tuple[str, str, str, str]) -> Itinerary: +def _itin(*slices_data: tuple[str, ...]) -> Itinerary: """Build an Itinerary with the given slices. Each tuple is - (flight_number, departure_iso, origin_iata, destination_iata).""" + (flight_number, departure_iso, origin_iata, destination_iata) with an + optional 5th element for arrival_iso.""" slcs = [ Slice( - flights=[fn], - departure=dep, - origin=SliceEndpoint(code=o), - destination=SliceEndpoint(code=d), + flights=[t[0]], + departure=t[1], + origin=SliceEndpoint(code=t[2]), + destination=SliceEndpoint(code=t[3]), + arrival=(t[4] if len(t) > 4 else None), ) - for fn, dep, o, d in slices_data + for t in slices_data ] return Itinerary( displayTotal="USD500.00", @@ -72,12 +74,13 @@ def _award( miles_to_cash_ratio: float = 0.0125, matched_id: str = "", num_connections: int = 0, + arrival: str | None = None, ) -> AwardFlight: return AwardFlight( origin=origin, destination=dest, departure=dep, - arrival=dep, + arrival=arrival if arrival is not None else dep, flight_number=fn, num_connections=num_connections, provider="PointsPath", @@ -246,26 +249,50 @@ def test_join_route_time_fallback_requires_minute_precision(): assert matches[0].awards == [] -def test_join_exact_carrier_award_suppresses_partner_in_same_bucket(): - """Characterization of a deliberate fail-closed choice. +def test_join_arrival_time_resolves_codeshare_and_partner_in_one_bucket(): + """Arrival time is the real identity, so the codeshare bucket resolves + rather than failing closed. - Cash AA6939 with two awards in the same route+time bucket: BA174 (the - operating carrier of the codeshare) and AA6939 (the marketing number). - The old behavior attached both, on the premise that they describe one - aircraft. + Cash AA6939 arrives 06:30. The BA174 award is the same physical aircraft + (same arrival) and attaches; an AS99 award sharing only the departure + minute is a different aircraft (different arrival) and does not. Keying + on route+departure alone could not tell these apart — on a live MSY + payload that key left 3 multi-carrier buckets of 41, and adding arrival + left 0 of 91. + """ + res = SearchResult( + solutions=[ + _itin(("AA6939", "2026-08-15T18:40:00", "JFK", "LHR", "2026-08-16T06:30:00")), + ] + ) + awards = [ + _award( + "BA174", + "2026-08-15T18:40:00", + program="American", + origin="JFK", + dest="LHR", + arrival="2026-08-16T06:30:00", + ), + _award( + "AS99", + "2026-08-15T18:40:00", + program="Alaska", + miles=7500, + origin="JFK", + dest="LHR", + arrival="2026-08-16T07:55:00", + ), + ] + matches = join(res, awards) + assert [a.flight_number for a in matches[0].awards] == ["BA174"] - We can no longer assume that. This bucket is structurally IDENTICAL to - the collision case — cash AA118 with awards AA118 (American) and AS17 - (Alaska) — where the two awards are different aircraft that merely share - a departure minute. From (route, time, flight number) alone the two are - indistinguishable, so resolving them differently is not possible here. - We keep the exact-carrier award and drop the partner. The cost is a - hidden second booking option for one physical seat; the alternative cost - is rendering another aircraft's price on this row. Only `operating_carrier` - ground truth (models.py:105, gflight backend only) can tell these apart — - when the join learns to read it, this test should flip back. - """ +def test_join_falls_back_to_carrier_logic_when_arrival_missing(): + """Arrival is optional on both sides (`Slice.arrival` is nullable and a + provider may omit it), so the carrier resolution stays the backstop. With + no arrival anywhere, cash AA6939 + a BA174 and an AA6939 award is + unresolvable by time, and same-carrier-wins keeps only AA6939.""" res = SearchResult( solutions=[ _itin(("AA6939", "2026-08-15T18:40:00", "JFK", "LHR")), @@ -276,8 +303,25 @@ def test_join_exact_carrier_award_suppresses_partner_in_same_bucket(): _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 == {"VirginAtlantic"} + assert {a.program for a in matches[0].awards} == {"VirginAtlantic"} + + +def test_join_arrival_filter_does_not_wipe_bucket_when_awards_omit_arrival(): + """A cash arrival paired with awards that carry none must not zero the + bucket — the filter only applies when it actually matched something.""" + res = SearchResult( + solutions=[ + _itin(("AA6939", "2026-08-15T18:40:00", "JFK", "LHR", "2026-08-16T06:30:00")), + ] + ) + awards = [ + _award( + "BA174", "2026-08-15T18:40:00", program="American", origin="JFK", dest="LHR", arrival="" + ), + ] + matches = join(res, awards) + assert len(matches[0].awards) == 1 + assert matches[0].awards[0].flight_number == "BA174" # ────────────────────────────── join semantics ───────────────────────────── From a6657d9635b3663eb32b314c9dc3287db5796310 Mon Sep 17 00:00:00 2001 From: ak2k <19240940+ak2k@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:52:01 -0400 Subject: [PATCH 04/26] flight-cli: stop re-querying airlines PointsPath doesn't support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every award-enabled run emitted ~20 warning lines and burned ~10 pointless round-trips: pp_airline_search_failed airline=ThaiAirways body='{"error":"unsupported airline"}' status=400 (x9, twice per leg) PP's /api/pricing-info advertises a superset of what /api/airline-search serves. Nine of its entries — ANA, Aeromexico, BritishAirways, CathayPacific, EvaAir, Finnair, JapanAirlines, Southwest, ThaiAirways — carry no `enable` feature flag, and `enabled_airlines` treats a missing flag as always-on (correct for American/Delta/United/JetBlue/Alaska). So we called them every run and collected a 400 every time. A hardcoded exclusion list would be wrong: from the flags alone the two cases are indistinguishable. AirFrance also has no exact-name flag (only `enableAirFranceV2`) and DOES serve results. So instead of guessing, record the server's own verdict — a 400 whose body names the airline as unsupported — in a 7-day negative cache and skip those airlines up front. Deliberately narrow and fail-open: - Only a 400 *and* an "unsupported airline" body qualifies. A 429 or 503 with the same body must not blacklist a working airline, so status is checked too (tested). - Any cache read problem (missing, corrupt, wrong shape, mixed types) yields the empty set: a stale-empty cache costs one wasted request, whereas a wrongly-populated one would silently hide real awards. - The 7-day TTL means PP adding support, or a tier change, heals on its own with nothing for the user to clear. - The remaining genuine failures still log at warning; the expected steady-state ones drop to debug. Live: warnings on a warm run go 18 -> 0, the fan-out drops 9 requests per leg, and the rendered award table is unchanged. make check green: 535 tests, ruff + basedpyright clean. --- src/flight_cli/pp/client.py | 96 +++++++++++++++++++++++++++++++++---- tests/pp/test_client.py | 59 ++++++++++++++++++++++- 2 files changed, 145 insertions(+), 10 deletions(-) diff --git a/src/flight_cli/pp/client.py b/src/flight_cli/pp/client.py index 6133337..8fd2af1 100644 --- a/src/flight_cli/pp/client.py +++ b/src/flight_cli/pp/client.py @@ -16,7 +16,7 @@ from dataclasses import dataclass from http import HTTPStatus from pathlib import Path -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast import anyio import httpx @@ -44,6 +44,23 @@ EXT_CONFIG_TTL_SECS = 7 * 24 * 3600 EXT_CONFIG_VERSION = "1.10.4" +# Airlines the server has told us it does not support, learned at runtime. +# +# PP's /api/pricing-info advertises a superset of what /api/airline-search will +# actually serve: ~10 of its entries (ANA, BritishAirways, CathayPacific, …) +# have no `enable` feature flag, so `enabled_airlines` treats them as +# always-on and every search fans out to them and collects a 400 "unsupported +# airline". That is ~10 wasted round-trips and ~20 warning lines per run. +# +# A hardcoded exclusion list would be wrong: the two conditions look identical +# from the flags alone. AirFrance also has no exact-name flag (only +# `enableAirFranceV2`) and DOES serve results, so "no flag" cannot be the +# predicate. Instead we record the server's own verdict — a 400 naming the +# airline as unsupported — and skip that airline until the TTL expires. The +# list self-heals when PP adds support or the user's tier changes. +UNSUPPORTED_CACHE = Path.home() / ".cache" / "flight-cli" / "pp_unsupported_airlines.json" +UNSUPPORTED_TTL_SECS = 7 * 24 * 3600 + # Airlines observed firing in a single GFlights international search. Used as # the default fan-out set when --pp-airlines isn't provided. Real catalog # probably differs by Pro tier — the extension's /api/extension-config feature @@ -219,12 +236,20 @@ async def airline_search(self, spec: SearchSpec, airline: str) -> AirlineSearchR if r.status_code == HTTPStatus.NO_CONTENT or not r.content: return AirlineSearchResponse() if r.status_code >= HTTPStatus.BAD_REQUEST: - log.warning( - "pp_airline_search_failed", - airline=airline, - status=r.status_code, - body=r.text[:200], - ) + if is_unsupported_airline_response(r.status_code, r.text): + # A permanent "we don't serve this airline", not a transient + # failure — remember it so later runs skip the call entirely. + # Logged at debug: it's an expected steady state, not a problem + # the user can act on. + remember_unsupported_airline(airline) + log.debug("pp_airline_unsupported", airline=airline) + else: + log.warning( + "pp_airline_search_failed", + airline=airline, + status=r.status_code, + body=r.text[:200], + ) return AirlineSearchResponse() return AirlineSearchResponse.model_validate(r.json()) @@ -233,8 +258,16 @@ async def airline_search_many( spec: SearchSpec, airlines: tuple[str, ...], ) -> dict[str, AirlineSearchResponse]: - """Fan out one request per airline; concurrency-bounded by the semaphore.""" + """Fan out one request per airline; concurrency-bounded by the semaphore. + + Airlines the server has previously rejected as unsupported are skipped + without a request — see `UNSUPPORTED_CACHE`. + """ out: dict[str, AirlineSearchResponse] = {} + skip = load_unsupported_airlines() + to_call = tuple(a for a in airlines if a not in skip) + if skipped := tuple(a for a in airlines if a in skip): + log.debug("pp_airline_search_skipped_unsupported", airlines=skipped) async def runner(airline: str) -> None: try: @@ -243,7 +276,7 @@ async def runner(airline: str) -> None: log.warning("pp_airline_search_exception", airline=airline, error=str(e)) async with anyio.create_task_group() as tg: - for a in airlines: + for a in to_call: tg.start_soon(runner, a) return out @@ -287,6 +320,51 @@ async def extension_config(self, *, force_refresh: bool = False) -> dict[str, An _AIRLINE_FLAG_RE = re.compile(r"^enable(?P[A-Z][A-Za-z]+?)(?:V\d+)?$") +def is_unsupported_airline_response(status: int, body: str) -> bool: + """True when a 4xx says the airline itself isn't served, as opposed to a + transient/auth/route-specific failure. + + Deliberately narrow: it must be a 400 AND the body must name the airline + as unsupported. Broadening this to "any 4xx" would let a rate-limit or an + expired token permanently blacklist a working airline. + """ + if status != HTTPStatus.BAD_REQUEST: + return False + return "unsupported airline" in body.lower() + + +def load_unsupported_airlines() -> frozenset[str]: + """Airlines the server rejected as unsupported, if the note is still fresh. + + Any read problem (missing, corrupt, wrong shape) yields the empty set — + the cost of a stale-empty result is one wasted round-trip per airline, + versus wrongly suppressing a working airline's awards. + """ + try: + if time.time() - UNSUPPORTED_CACHE.stat().st_mtime >= UNSUPPORTED_TTL_SECS: + return frozenset() + raw: Any = json.loads(UNSUPPORTED_CACHE.read_text()) + except (OSError, ValueError): + return frozenset() + if not isinstance(raw, list): + return frozenset() + return frozenset(x for x in cast("list[Any]", raw) if isinstance(x, str)) + + +def remember_unsupported_airline(airline: str) -> None: + """Add one airline to the unsupported note. Best-effort: a failure here + only costs the next run a redundant request.""" + current = set(load_unsupported_airlines()) + if airline in current: + return + current.add(airline) + try: + UNSUPPORTED_CACHE.parent.mkdir(parents=True, exist_ok=True) + UNSUPPORTED_CACHE.write_text(json.dumps(sorted(current), indent=2)) + except OSError as e: + log.debug("pp_unsupported_cache_write_failed", error=str(e)) + + def enabled_airlines( pricing: PricingInfoResponse, ext_config: dict[str, Any], diff --git a/tests/pp/test_client.py b/tests/pp/test_client.py index f42b301..f7f27a5 100644 --- a/tests/pp/test_client.py +++ b/tests/pp/test_client.py @@ -6,7 +6,12 @@ import pathlib from typing import Any -from flight_cli.pp.client import enabled_airlines +from flight_cli.pp.client import ( + enabled_airlines, + is_unsupported_airline_response, + load_unsupported_airlines, + remember_unsupported_airline, +) from flight_cli.pp.models import PricingInfoResponse FIX = pathlib.Path(__file__).parent / "fixtures" @@ -69,3 +74,55 @@ def test_enabled_returns_pricing_order(): pricing_order = [p.airline for p in _pricing().pricingInfos] expected = [a for a in pricing_order if a in set(enabled)] assert list(enabled) == expected + + +# ───────────── unsupported-airline negative cache (400 spam fix) ───────────── + + +def test_is_unsupported_airline_response_matches_the_real_body() -> None: + """The exact shape PP returns for an airline it doesn't serve.""" + assert is_unsupported_airline_response(400, '{"error":"unsupported airline"}') is True + assert is_unsupported_airline_response(400, '{"error":"Unsupported Airline"}') is True + + +def test_is_unsupported_airline_response_ignores_other_failures() -> None: + """Must stay narrow: a transient or auth failure would otherwise + permanently blacklist a working airline.""" + assert is_unsupported_airline_response(400, '{"error":"bad route"}') is False + assert is_unsupported_airline_response(429, '{"error":"unsupported airline"}') is False + assert is_unsupported_airline_response(500, "") is False + assert is_unsupported_airline_response(503, '{"error":"unsupported airline"}') is False + + +def test_unsupported_cache_roundtrips(tmp_path: pathlib.Path, monkeypatch: Any) -> None: + cache = tmp_path / "unsupported.json" + monkeypatch.setattr("flight_cli.pp.client.UNSUPPORTED_CACHE", cache) + assert load_unsupported_airlines() == frozenset() + remember_unsupported_airline("ThaiAirways") + remember_unsupported_airline("ANA") + remember_unsupported_airline("ThaiAirways") # idempotent + assert load_unsupported_airlines() == frozenset({"ANA", "ThaiAirways"}) + written: Any = json.loads(cache.read_text()) + assert written == ["ANA", "ThaiAirways"] + + +def test_unsupported_cache_expires(tmp_path: pathlib.Path, monkeypatch: Any) -> None: + """Past the TTL the note is ignored, so PP re-adding an airline (or a tier + change) heals without the user clearing anything.""" + cache = tmp_path / "unsupported.json" + cache.write_text(json.dumps(["ANA"])) + monkeypatch.setattr("flight_cli.pp.client.UNSUPPORTED_CACHE", cache) + monkeypatch.setattr("flight_cli.pp.client.UNSUPPORTED_TTL_SECS", -1) + assert load_unsupported_airlines() == frozenset() + + +def test_unsupported_cache_tolerates_corrupt_file(tmp_path: pathlib.Path, monkeypatch: Any) -> None: + """Fails open — a bad cache costs a wasted request, never a hidden award.""" + cache = tmp_path / "unsupported.json" + monkeypatch.setattr("flight_cli.pp.client.UNSUPPORTED_CACHE", cache) + cache.write_text("not json{") + assert load_unsupported_airlines() == frozenset() + cache.write_text('{"airlines": ["ANA"]}') # wrong shape + assert load_unsupported_airlines() == frozenset() + cache.write_text('["ANA", 42, null]') # mixed types + assert load_unsupported_airlines() == frozenset({"ANA"}) From 62a466239e2120c2796861f4615f3828c9d32f69 Mon Sep 17 00:00:00 2001 From: ak2k <19240940+ak2k@users.noreply.github.com> Date: Wed, 29 Jul 2026 22:13:15 -0400 Subject: [PATCH 05/26] flight-cli: per-entry TTL on the unsupported-airline cache, + review fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second ce-code-review pass. Findings from reliability, maintainability, and testing; the two highest-stakes reviewers are still running. P2 (reliability) — the negative cache could never expire. The TTL was keyed on the FILE's mtime, so learning any new airline refreshed every existing entry. Since a run that learns one airline rewrites the file, in steady state nothing aged out and the self-healing the TTL exists for did not happen. Reproduced: with a 2s TTL, an entry 2.4s old survived because a sibling was written at 1.2s. Each entry now carries its own learned-at timestamp. The legacy flat-list format is still read (entries treated as learned now) so upgrading doesn't re-query every unsupported airline. Also on that path: the write is now write-then-rename, so a crash mid-write leaves the previous cache rather than a truncated file that reads as empty. Documented the no-await invariant that makes the read-modify-write safe under the anyio fan-out — a future async file API there would open a real race. P2 (maintainability) — `_cash_slice` was extracted last round but never wired in; all three cash_* key functions still re-derived the bounds check inline. Now routed through it, and moved above its first use. P2 x2 (testing) — the connection-count check was only ever exercised at zero stops, and the negative cache had no end-to-end coverage. Added multi-stop match/reject tests, a `_slice_stop_count` fallback test, and a new tests/pp/test_client_unsupported_airline.py driving the real MockTransport path: 400 records, cached airline is never re-requested, a non-verdict 400 and a 429 both refuse to blacklist, and five concurrent rejections all survive the fan-out. Also pinned the arrival comparison against Matrix's real offset-suffixed wire format ('2026-09-09T09:04-04:00' vs PP's naive '2026-09-09T09:04:00'), which was previously only tested with naive strings — a format drift there would silently turn the arrival discriminator into a no-op. Testing reviewer independently reproduced all three of the prior rounds' before/after claims exactly (12 fail, 1 fail, 535 pass). make check green: 550 tests, ruff + basedpyright clean. --- src/flight_cli/pp/client.py | 67 +++++++-- src/flight_cli/pp/match.py | 32 ++--- tests/pp/test_client.py | 66 ++++++++- tests/pp/test_client_unsupported_airline.py | 145 +++++++++++++++++++ tests/pp/test_match.py | 150 ++++++++++++++++++++ 5 files changed, 424 insertions(+), 36 deletions(-) create mode 100644 tests/pp/test_client_unsupported_airline.py diff --git a/src/flight_cli/pp/client.py b/src/flight_cli/pp/client.py index 8fd2af1..e192cbb 100644 --- a/src/flight_cli/pp/client.py +++ b/src/flight_cli/pp/client.py @@ -333,34 +333,71 @@ def is_unsupported_airline_response(status: int, body: str) -> bool: return "unsupported airline" in body.lower() +def _load_unsupported_raw() -> dict[str, float]: + """`{airline: epoch_seconds_learned}`, unfiltered. {} on any read problem. + + Tolerates the legacy flat-list format written by the first version of this + cache by treating those entries as learned now — one extra TTL period of + staleness on upgrade, versus re-querying every unsupported airline again. + """ + try: + raw: Any = json.loads(UNSUPPORTED_CACHE.read_text()) + except (OSError, ValueError): + return {} + if isinstance(raw, list): # legacy: ["ANA", "Finnair", ...] + now = time.time() + return {x: now for x in cast("list[Any]", raw) if isinstance(x, str)} + if not isinstance(raw, dict): + return {} + out: dict[str, float] = {} + for k, v in cast("dict[Any, Any]", raw).items(): + if isinstance(k, str) and isinstance(v, (int, float)) and not isinstance(v, bool): + out[k] = float(v) + return out + + def load_unsupported_airlines() -> frozenset[str]: - """Airlines the server rejected as unsupported, if the note is still fresh. + """Airlines the server rejected as unsupported, whose note is still fresh. + + Each entry carries its OWN learned-at timestamp. Keying the TTL off the + file's mtime instead would mean any new rejection refreshed every existing + entry — and since a run that learns one airline rewrites the file, entries + would never expire in steady state, defeating the self-healing the TTL is + there to provide. Any read problem (missing, corrupt, wrong shape) yields the empty set — the cost of a stale-empty result is one wasted round-trip per airline, versus wrongly suppressing a working airline's awards. """ - try: - if time.time() - UNSUPPORTED_CACHE.stat().st_mtime >= UNSUPPORTED_TTL_SECS: - return frozenset() - raw: Any = json.loads(UNSUPPORTED_CACHE.read_text()) - except (OSError, ValueError): - return frozenset() - if not isinstance(raw, list): - return frozenset() - return frozenset(x for x in cast("list[Any]", raw) if isinstance(x, str)) + cutoff = time.time() - UNSUPPORTED_TTL_SECS + return frozenset(a for a, learned_at in _load_unsupported_raw().items() if learned_at > cutoff) def remember_unsupported_airline(airline: str) -> None: - """Add one airline to the unsupported note. Best-effort: a failure here - only costs the next run a redundant request.""" - current = set(load_unsupported_airlines()) + """Stamp one airline as unsupported as of now. Best-effort: a failure here + only costs the next run a redundant request. + + Existing entries keep their original timestamps so each expires on its own + schedule. + + NOTE: read-modify-write with no lock. Safe within one process — there is no + `await` between the read and the write, so anyio's cooperative scheduling + serializes concurrent callers in `airline_search_many`. Keep it that way: + introducing an async file API here would open a real lost-update race. + Across two concurrent CLI invocations a lost update is possible, and costs + exactly one redundant request next run. + """ + current = _load_unsupported_raw() if airline in current: return - current.add(airline) + current[airline] = time.time() try: UNSUPPORTED_CACHE.parent.mkdir(parents=True, exist_ok=True) - UNSUPPORTED_CACHE.write_text(json.dumps(sorted(current), indent=2)) + # Write-then-rename: a crash mid-write leaves the old file intact + # rather than a truncated one that reads as an empty cache. + tmp = UNSUPPORTED_CACHE.with_suffix(".json.tmp") + tmp.write_text(json.dumps(dict(sorted(current.items())), indent=2)) + tmp.replace(UNSUPPORTED_CACHE) except OSError as e: log.debug("pp_unsupported_cache_write_failed", error=str(e)) diff --git a/src/flight_cli/pp/match.py b/src/flight_cli/pp/match.py index 04d3e57..6ab7f92 100644 --- a/src/flight_cli/pp/match.py +++ b/src/flight_cli/pp/match.py @@ -245,6 +245,14 @@ def _iso_minute(s: str | None) -> str: return s[:_ISO_MINUTE_LEN] if len(s) >= _ISO_MINUTE_LEN else "" +def _cash_slice(it: Itinerary, slice_index: int) -> Slice | None: + """Bounds-checked slice access — the guard every cash_* key repeats.""" + itn = it.itinerary + if not itn or not itn.slices or slice_index >= len(itn.slices): + return None + return itn.slices[slice_index] + + def cash_match_key(it: Itinerary, slice_index: int = 0) -> MatchKey | None: """Build the match key from a Matrix itinerary's slice's first flight. @@ -256,10 +264,9 @@ def cash_match_key(it: Itinerary, slice_index: int = 0) -> MatchKey | None: origin/dest an `AA100 JFK→LHR 18:00` cash row matched an `AA100 MIA→DFW 06:30` award — same number, same date, different flight. """ - itn = it.itinerary - if not itn or not itn.slices or slice_index >= len(itn.slices): + s = _cash_slice(it, slice_index) + if s is None: return None - s = itn.slices[slice_index] flights = s.flights or [] if not flights: return None @@ -289,10 +296,9 @@ def cash_route_time_key(it: Itinerary, slice_index: int = 0) -> RouteTimeKey | N are enough to anchor a candidate. The carrier corroboration that makes the candidate a *match* is applied separately in `join` (`same_metal`), which does need `flights[0]`.""" - itn = it.itinerary - if not itn or not itn.slices or slice_index >= len(itn.slices): + s = _cash_slice(it, slice_index) + if s is None: return None - s = itn.slices[slice_index] o = ((s.origin.code if s.origin else None) or "").upper() d = ((s.destination.code if s.destination else None) or "").upper() t = _iso_minute(s.departure) @@ -303,10 +309,10 @@ def cash_route_time_key(it: Itinerary, slice_index: int = 0) -> RouteTimeKey | N def cash_first_flight_number(it: Itinerary, slice_index: int = 0) -> str: """First marketing flight number on the slice, '' when absent.""" - itn = it.itinerary - if not itn or not itn.slices or slice_index >= len(itn.slices): + s = _cash_slice(it, slice_index) + if s is None: return "" - flights = itn.slices[slice_index].flights or [] + flights = s.flights or [] return _norm_fn(flights[0]) if flights else "" @@ -338,14 +344,6 @@ def cash_matched_id_key(it: Itinerary, slice_index: int = 0) -> str | None: return fid or None -def _cash_slice(it: Itinerary, slice_index: int) -> Slice | None: - """Bounds-checked slice access — the guard every cash_* key repeats.""" - itn = it.itinerary - if not itn or not itn.slices or slice_index >= len(itn.slices): - return None - return itn.slices[slice_index] - - def join( search: SearchResult, awards: list[AwardFlight], diff --git a/tests/pp/test_client.py b/tests/pp/test_client.py index f7f27a5..707ad6f 100644 --- a/tests/pp/test_client.py +++ b/tests/pp/test_client.py @@ -4,6 +4,7 @@ import json import pathlib +import time from typing import Any from flight_cli.pp.client import ( @@ -103,26 +104,83 @@ def test_unsupported_cache_roundtrips(tmp_path: pathlib.Path, monkeypatch: Any) remember_unsupported_airline("ThaiAirways") # idempotent assert load_unsupported_airlines() == frozenset({"ANA", "ThaiAirways"}) written: Any = json.loads(cache.read_text()) - assert written == ["ANA", "ThaiAirways"] + assert sorted(written) == ["ANA", "ThaiAirways"] def test_unsupported_cache_expires(tmp_path: pathlib.Path, monkeypatch: Any) -> None: """Past the TTL the note is ignored, so PP re-adding an airline (or a tier change) heals without the user clearing anything.""" cache = tmp_path / "unsupported.json" - cache.write_text(json.dumps(["ANA"])) + cache.write_text(json.dumps({"ANA": time.time()})) monkeypatch.setattr("flight_cli.pp.client.UNSUPPORTED_CACHE", cache) monkeypatch.setattr("flight_cli.pp.client.UNSUPPORTED_TTL_SECS", -1) assert load_unsupported_airlines() == frozenset() +def test_unsupported_entries_expire_independently(tmp_path: pathlib.Path, monkeypatch: Any) -> None: + """Each entry ages on its own clock. + + Keying the TTL off the file mtime meant that learning ANY new airline + refreshed every existing entry. Since a run that learns one airline + rewrites the file, in steady state nothing ever expired and the cache + could not self-heal. + """ + cache = tmp_path / "unsupported.json" + monkeypatch.setattr("flight_cli.pp.client.UNSUPPORTED_CACHE", cache) + monkeypatch.setattr("flight_cli.pp.client.UNSUPPORTED_TTL_SECS", 100) + now = time.time() + cache.write_text(json.dumps({"ANA": now - 500})) # already stale + remember_unsupported_airline("Finnair") # rewrites the file, mtime = now + assert load_unsupported_airlines() == frozenset({"Finnair"}) + + +def test_unsupported_cache_reads_legacy_list_format( + tmp_path: pathlib.Path, monkeypatch: Any +) -> None: + """The first version wrote a flat list. Upgrading must not re-query every + unsupported airline; the entries are treated as learned now.""" + cache = tmp_path / "unsupported.json" + cache.write_text(json.dumps(["ANA", "Southwest"])) + monkeypatch.setattr("flight_cli.pp.client.UNSUPPORTED_CACHE", cache) + assert load_unsupported_airlines() == frozenset({"ANA", "Southwest"}) + + +def test_remember_preserves_existing_timestamps(tmp_path: pathlib.Path, monkeypatch: Any) -> None: + """Adding an entry must not restamp its siblings.""" + cache = tmp_path / "unsupported.json" + monkeypatch.setattr("flight_cli.pp.client.UNSUPPORTED_CACHE", cache) + original = time.time() - 42 + cache.write_text(json.dumps({"ANA": original})) + remember_unsupported_airline("Finnair") + written: Any = json.loads(cache.read_text()) + assert written["ANA"] == original + + def test_unsupported_cache_tolerates_corrupt_file(tmp_path: pathlib.Path, monkeypatch: Any) -> None: """Fails open — a bad cache costs a wasted request, never a hidden award.""" cache = tmp_path / "unsupported.json" monkeypatch.setattr("flight_cli.pp.client.UNSUPPORTED_CACHE", cache) cache.write_text("not json{") assert load_unsupported_airlines() == frozenset() - cache.write_text('{"airlines": ["ANA"]}') # wrong shape + cache.write_text("[1, 2, 3]") # right container, wrong element type assert load_unsupported_airlines() == frozenset() - cache.write_text('["ANA", 42, null]') # mixed types + cache.write_text('["ANA", 42, null]') # legacy list, mixed types assert load_unsupported_airlines() == frozenset({"ANA"}) + cache.write_text('{"ANA": "yesterday"}') # non-numeric timestamp + assert load_unsupported_airlines() == frozenset() + cache.write_text('{"ANA": true}') # bool is not a timestamp + assert load_unsupported_airlines() == frozenset() + + +def test_remember_survives_unwritable_cache(tmp_path: pathlib.Path, monkeypatch: Any) -> None: + """A cache-write failure is swallowed: the run continues and simply + re-queries that airline next time.""" + unwritable = tmp_path / "nodir" / "sub" / "unsupported.json" + monkeypatch.setattr("flight_cli.pp.client.UNSUPPORTED_CACHE", unwritable) + + def _boom(*_a: Any, **_kw: Any) -> None: + raise OSError("read-only fs") + + monkeypatch.setattr("flight_cli.pp.client.Path.mkdir", _boom) + remember_unsupported_airline("ANA") # must not raise + assert load_unsupported_airlines() == frozenset() diff --git a/tests/pp/test_client_unsupported_airline.py b/tests/pp/test_client_unsupported_airline.py new file mode 100644 index 0000000..74954be --- /dev/null +++ b/tests/pp/test_client_unsupported_airline.py @@ -0,0 +1,145 @@ +# pyright: reportPrivateUsage=false +"""End-to-end wiring of the unsupported-airline negative cache. + +The helpers are unit-tested in test_client.py; these pin the behaviour that +actually saves the round-trips — that a 400 "unsupported airline" is recorded +rather than warned about, and that a recorded airline is never requested +again.""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, Any + +import anyio +import httpx + +from flight_cli.pp.auth import Tokens +from flight_cli.pp.client import API_BASE, PPClient, SearchSpec + +if TYPE_CHECKING: + import pathlib + + +def _tokens() -> Tokens: + return Tokens( + access_token="TOKEN", # noqa: S106 — dummy test value + refresh_token="REFRESH", # noqa: S106 — dummy test value + expires_at=9999999999, + user_email="test@example.com", + ) + + +def _client(transport: httpx.MockTransport) -> PPClient: + pp = PPClient(_tokens()) + pp._client = httpx.AsyncClient( + base_url=API_BASE, transport=transport, headers=pp._client.headers + ) + return pp + + +def _spec() -> SearchSpec: + return SearchSpec(origin="MSY", destination="MIA", date="2026-09-09") + + +_UNSUPPORTED = '{"error":"unsupported airline"}' + + +def test_unsupported_400_is_recorded(tmp_path: pathlib.Path, monkeypatch: Any) -> None: + cache = tmp_path / "unsupported.json" + monkeypatch.setattr("flight_cli.pp.client.UNSUPPORTED_CACHE", cache) + + def handler(_req: httpx.Request) -> httpx.Response: + return httpx.Response(400, text=_UNSUPPORTED) + + async def go() -> None: + pp = _client(httpx.MockTransport(handler)) + await pp.airline_search(_spec(), "ThaiAirways") + await pp.aclose() + + anyio.run(go) + written: Any = json.loads(cache.read_text()) + assert "ThaiAirways" in written + + +def test_recorded_airline_is_not_requested_again(tmp_path: pathlib.Path, monkeypatch: Any) -> None: + """The point of the cache: the skipped airline costs zero HTTP calls.""" + cache = tmp_path / "unsupported.json" + monkeypatch.setattr("flight_cli.pp.client.UNSUPPORTED_CACHE", cache) + seen: list[str] = [] + + def handler(req: httpx.Request) -> httpx.Response: + body: Any = json.loads(req.content) + airline = str(body["airline"]) + seen.append(airline) + if airline == "ThaiAirways": + return httpx.Response(400, text=_UNSUPPORTED) + return httpx.Response(200, json={"outboundFlights": [], "inboundFlights": []}) + + async def go() -> None: + pp = _client(httpx.MockTransport(handler)) + await pp.airline_search_many(_spec(), ("American", "ThaiAirways")) + await pp.airline_search_many(_spec(), ("American", "ThaiAirways")) + await pp.aclose() + + anyio.run(go) + assert seen.count("ThaiAirways") == 1 # learned on run 1, skipped on run 2 + assert seen.count("American") == 2 # the working airline is unaffected + + +def test_transient_400_is_not_recorded(tmp_path: pathlib.Path, monkeypatch: Any) -> None: + """A 400 that isn't an unsupported-airline verdict must not blacklist a + working airline — it stays a warning and the airline is retried.""" + cache = tmp_path / "unsupported.json" + monkeypatch.setattr("flight_cli.pp.client.UNSUPPORTED_CACHE", cache) + + def handler(_req: httpx.Request) -> httpx.Response: + return httpx.Response(400, text='{"error":"bad route"}') + + async def go() -> None: + pp = _client(httpx.MockTransport(handler)) + await pp.airline_search(_spec(), "American") + await pp.aclose() + + anyio.run(go) + assert not cache.exists() + + +def test_rate_limit_with_unsupported_body_is_not_recorded( + tmp_path: pathlib.Path, monkeypatch: Any +) -> None: + """Status is checked as well as body: a 429 must never blacklist.""" + cache = tmp_path / "unsupported.json" + monkeypatch.setattr("flight_cli.pp.client.UNSUPPORTED_CACHE", cache) + + def handler(_req: httpx.Request) -> httpx.Response: + return httpx.Response(429, text=_UNSUPPORTED) + + async def go() -> None: + pp = _client(httpx.MockTransport(handler)) + await pp.airline_search(_spec(), "American") + await pp.aclose() + + anyio.run(go) + assert not cache.exists() + + +def test_concurrent_rejections_all_survive(tmp_path: pathlib.Path, monkeypatch: Any) -> None: + """airline_search_many fans out through a task group; every airline + rejected in one run must be recorded, not lost to read-modify-write + interleaving.""" + cache = tmp_path / "unsupported.json" + monkeypatch.setattr("flight_cli.pp.client.UNSUPPORTED_CACHE", cache) + rejected = ("ANA", "Finnair", "Southwest", "ThaiAirways", "CathayPacific") + + def handler(_req: httpx.Request) -> httpx.Response: + return httpx.Response(400, text=_UNSUPPORTED) + + async def go() -> None: + pp = _client(httpx.MockTransport(handler)) + await pp.airline_search_many(_spec(), rejected) + await pp.aclose() + + anyio.run(go) + written: Any = json.loads(cache.read_text()) + assert set(written) == set(rejected) diff --git a/tests/pp/test_match.py b/tests/pp/test_match.py index cda6d18..5f89f04 100644 --- a/tests/pp/test_match.py +++ b/tests/pp/test_match.py @@ -877,3 +877,153 @@ def test_same_metal_rejects_award_side_empty_flight_number(): price the user might try to book.""" assert same_metal("AA3539", "") is False assert same_metal("AA3539", None) is False + + +# ─────────── multi-stop slices + wire-format timestamps (review r2) ─────────── + + +def _itin_multi( + flights: list[str], + dep: str, + o: str, + d: str, + stops: list[str], + arrival: str | None = None, +) -> Itinerary: + """A connecting itinerary: N flights, N-1 intermediate stops.""" + s = Slice( + flights=flights, + departure=dep, + arrival=arrival, + origin=SliceEndpoint(code=o), + destination=SliceEndpoint(code=d), + stops=[SliceEndpoint(code=c) for c in stops], + ) + return Itinerary( + displayTotal="USD500.00", + itinerary=ItineraryDetails(slices=[s], carriers=[]), + ) + + +def test_join_matches_one_stop_award_to_one_stop_cash(): + """The stop-count check is an equality, not a nonstop-only filter: a + connecting cash slice must still match its own connecting award.""" + res = SearchResult( + solutions=[ + _itin_multi(["DL2542", "DL719"], "2026-09-09T13:00:00", "MSY", "MIA", ["ATL"]), + ] + ) + awards = [ + _award( + "DL2542", + "2026-09-09T13:00:00", + program="Delta", + origin="MSY", + dest="MIA", + num_connections=1, + ), + ] + matches = join(res, awards) + assert len(matches[0].awards) == 1 + + +def test_join_rejects_nonstop_award_on_connecting_cash_row(): + """The other direction: a nonstop award is a different (better) product + than the 1-stop cash itinerary it would render against.""" + res = SearchResult( + solutions=[ + _itin_multi(["DL2542", "DL719"], "2026-09-09T13:00:00", "MSY", "MIA", ["ATL"]), + ] + ) + awards = [ + _award( + "DL2542", + "2026-09-09T13:00:00", + program="Delta", + origin="MSY", + dest="MIA", + num_connections=0, + ), + ] + matches = join(res, awards) + assert matches[0].awards == [] + + +def test_slice_stop_count_falls_back_to_segment_count(): + """`stops` is authoritative when Matrix populates it; the gflight adapter + may not, so a 2-segment slice with no `stops` still counts as 1.""" + from flight_cli.pp.match import _slice_stop_count # pyright: ignore[reportPrivateUsage] + + populated = Slice(flights=["DL1", "DL2"], stops=[SliceEndpoint(code="ATL")]) + assert _slice_stop_count(populated) == 1 + derived = Slice(flights=["DL1", "DL2"]) # no stops[] + assert _slice_stop_count(derived) == 1 + nonstop = Slice(flights=["DL1"]) + assert _slice_stop_count(nonstop) == 0 + + +def test_arrival_match_survives_matrix_utc_offset_wire_format(): + """Matrix emits offset-aware local times ('2026-09-09T09:04-04:00') whose + offset can even differ from the departure's ('...T06:00-05:00' for the same + flight), while PointsPath emits naive local ('2026-09-09T09:04:00'). Both + are local-at-the-airport and `_iso_minute` truncates before the offset, so + they compare equal — this pins that, since a format drift here would + silently turn the arrival discriminator into a no-op.""" + res = SearchResult( + solutions=[ + _itin(("AA867", "2026-09-09T06:00-05:00", "MSY", "MIA", "2026-09-09T09:04-04:00")), + ] + ) + awards = [ + _award( + "AA867", + "2026-09-09T06:00:00", + program="American", + origin="MSY", + dest="MIA", + arrival="2026-09-09T09:04:00", + ), + # Same route+departure, different aircraft (later arrival) — must not win. + _award( + "AA999", + "2026-09-09T06:00:00", + program="Alaska", + miles=1000, + origin="MSY", + dest="MIA", + arrival="2026-09-09T10:30:00", + ), + ] + matches = join(res, awards) + assert [a.flight_number for a in matches[0].awards] == ["AA867"] + + +def test_cash_first_flight_number_direct(): + """Direct coverage of the guard branches, matching its siblings.""" + from flight_cli.pp.match import cash_first_flight_number + + it = _itin(("ua 146", "2026-06-09T22:00:00", "JFK", "LHR")) + assert cash_first_flight_number(it) == "UA146" + assert cash_first_flight_number(it, slice_index=5) == "" + no_flights = Itinerary(itinerary=ItineraryDetails(slices=[Slice(flights=[])])) + assert cash_first_flight_number(no_flights) == "" + + +def test_match_keys_require_complete_route(): + """A slice or award missing origin/destination yields no key rather than a + partial one that could collide with an unrelated flight.""" + partial = Itinerary( + itinerary=ItineraryDetails( + slices=[ + Slice( + flights=["AA100"], + departure="2026-08-15T18:00:00", + origin=SliceEndpoint(code="JFK"), + destination=None, + ), + ], + ), + ) + assert cash_match_key(partial) is None + assert award_match_key(_award("AA100", "2026-08-15T18:00:00", origin="", dest="LHR")) is None + assert award_match_key(_award("AA100", "", origin="JFK", dest="LHR")) is None From 4a8dac80875407371dd77cf4095617b7372a1739 Mon Sep 17 00:00:00 2001 From: ak2k <19240940+ak2k@users.noreply.github.com> Date: Wed, 29 Jul 2026 22:30:00 -0400 Subject: [PATCH 06/26] =?UTF-8?q?flight-cli:=20fix=20P0=20=E2=80=94=20arri?= =?UTF-8?q?val=20must=20narrow=20within=20carrier=20stages,=20not=20before?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The correctness reviewer found that commit 2569c04 (arrival as the award-match identity) reintroduced the exact bug this branch exists to eliminate. `_pick_metal` filtered the ENTIRE bucket by arrival before computing the exact-carrier set. So an award with the wrong metal whose arrival happened to match could survive, while the correct same-carrier award — one that merely omitted its arrival — was deleted before the carrier rule ever ran. Reproduced through the real renderer: cash AA6939 (arr 06:30) with awards BA174/American/60k (no arrival) and AS99/Alaska/7.5k (arr 06:30) rendered `7.5k Alaska + $6` on the American row. Round 2 returned a safe no-match for the same input, so this was a regression I introduced, not a pre-existing gap. It also re-opened the AA118/AS17 collision that fb59713's own test asserts is closed, whenever arrival coverage is partial. Fix: arrival now narrows WITHIN each carrier stage via `_narrow`, never ahead of them. `_narrow` also abstains unless every candidate carries an arrival — partial coverage is precisely the dangerous case, since filtering a mixed bucket silently drops the awards that only omit the field. And when nothing matches the cash arrival it keeps the bucket rather than zeroing it: the reviewer's cross-check of the seats.aero fixture against our live Matrix cache found AA106 disagreeing by 5 minutes (19:20 vs 19:15), so "both sides read the same schedule" is not exceptionless in our own data. Also documented, from the same review: seats.aero's `Z` suffix is mislabelled — those timestamps are local, not UTC. Harmless today because `_iso_minute` truncates the suffix away, but a future caller that genuinely parses them as instants gets a ~offset-sized error (~12h JFK→LHR block times instead of ~7h). `_narrow`'s docstring records this. Four new tests; the two covering the P0 fail against the pre-fix ordering. make check green: 554 tests, ruff + basedpyright clean. --- src/flight_cli/pp/match.py | 63 +++++++++++------ tests/pp/test_match.py | 134 +++++++++++++++++++++++++++++++++++++ 2 files changed, 177 insertions(+), 20 deletions(-) diff --git a/src/flight_cli/pp/match.py b/src/flight_cli/pp/match.py index 6ab7f92..16fc835 100644 --- a/src/flight_cli/pp/match.py +++ b/src/flight_cli/pp/match.py @@ -191,34 +191,28 @@ def _pick_metal( render on the American row. Resolution, in order: - 1. **Arrival time.** Two aircraft sharing a departure minute on the same - route essentially never share an arrival minute too — both sides read - the same published schedule, so this is the real identity. Measured - on a live MSY→MIA/FLL payload: keying on route+departure alone left 3 - multi-carrier buckets out of 41; adding arrival left 0 out of 91. - When the cash side supplies an arrival, it filters the bucket, and - that is normally enough to resolve it outright. - 2. **Same carrier wins.** A same-carrier award at this route+minute IS + 1. **Same carrier wins.** A same-carrier award at this route+minute IS the flight; partners beside it are different metal. - 3. **Single partner.** Otherwise the bucket is codeshare-shaped (the + 2. **Single partner.** Otherwise the bucket is codeshare-shaped (the fallback's reason for existing: marketing AA6939 ↔ operating BA174). Accept only when the survivors resolve to ONE carrier — two distinct partner carriers means we cannot tell which is the metal. - Steps 2-3 still run after step 1 because arrival is not guaranteed: - `Slice.arrival` is optional and award providers may omit it, so the - carrier logic remains the backstop when the times are missing. + Arrival time narrows WITHIN each stage rather than ahead of them (see + `_narrow`). Ordering matters and getting it wrong is not a near-miss: + filtering the whole bucket by arrival first lets a wrong-carrier award + whose arrival happens to match survive while the correct same-carrier + award — one that merely omits its arrival — is deleted before the + carrier rule ever sees it. That renders another aircraft's price on the + row, which is the exact defect this function exists to prevent. """ - if cash_arrival: - timed = [af for af in candidates if _iso_minute(af.arrival) == cash_arrival] - # Only trust the filter when it actually resolved something; an award - # side that omits arrival would otherwise wipe the bucket. - if timed: - candidates = timed exact = [af for af in candidates if same_carrier(cash_fn, af.flight_number)] if exact: - return exact - partners = [af for af in candidates if same_metal(cash_fn, af.flight_number)] + return _narrow(exact, cash_arrival) + partners = _narrow( + [af for af in candidates if same_metal(cash_fn, af.flight_number)], + cash_arrival, + ) if not partners: return [] if len({_carrier(af.flight_number) for af in partners}) > 1: @@ -226,6 +220,35 @@ def _pick_metal( return partners +def _narrow(candidates: list[AwardFlight], cash_arrival: str) -> list[AwardFlight]: + """Keep only the candidates arriving at the cash flight's arrival minute. + + Two aircraft sharing a departure minute on the same route essentially never + share an arrival minute too, so this separates same-carrier rotations the + carrier rules cannot. Measured on a live MSY→MIA/FLL payload: route+departure + alone left 3 multi-carrier buckets of 41; adding arrival left 0 of 91. + + Abstains — returns the input untouched — unless the cash side has an + arrival AND *every* candidate has one. Partial coverage is the dangerous + case: filtering a mixed bucket silently drops the awards that merely omit + the field, which is how a correct match gets deleted in favour of a wrong + one. Both sides being fully populated is the only state where a + non-match is real evidence of different metal rather than missing data. + + Note both sides express local time at the airport: Matrix sends an offset + ('...T09:04-04:00'), PointsPath naive ('...T09:04:00'), seats.aero a + misleading 'Z' on what is also local. `_iso_minute` truncates all three to + the same wall-clock key. If a caller ever starts genuinely parsing these + as instants, seats.aero's fake 'Z' becomes a real ~offset-sized bug. + """ + if not cash_arrival: + return candidates + if not all(_iso_minute(af.arrival) for af in candidates): + return candidates + timed = [af for af in candidates if _iso_minute(af.arrival) == cash_arrival] + return timed or candidates + + def _iso_date(s: str | None) -> str: """Best-effort isolate the YYYY-MM-DD prefix from various formats.""" if not s: diff --git a/tests/pp/test_match.py b/tests/pp/test_match.py index 5f89f04..39720b6 100644 --- a/tests/pp/test_match.py +++ b/tests/pp/test_match.py @@ -1027,3 +1027,137 @@ def test_match_keys_require_complete_route(): assert cash_match_key(partial) is None assert award_match_key(_award("AA100", "2026-08-15T18:00:00", origin="", dest="LHR")) is None assert award_match_key(_award("AA100", "", origin="JFK", dest="LHR")) is None + + +# ───────── arrival must narrow WITHIN carrier stages, not ahead of them ───────── +# +# The arrival discriminator was first applied to the whole bucket before the +# carrier rules ran. That let a wrong-carrier award whose arrival happened to +# match survive while the CORRECT same-carrier award — one that merely omitted +# its arrival — was deleted before the exact-carrier rule ever saw it, putting +# another aircraft's price on the row. Ordering here is load-bearing. + + +def test_arrival_filter_does_not_delete_correct_metal_in_mixed_bucket(): + """The P0. Cash AA6939 arrives 06:30. BA174 is the right metal but omits + its arrival; AS99 is different metal that happens to arrive 06:30. + Filtering by arrival first left only AS99 and rendered 7.5k Alaska on the + American row.""" + res = SearchResult( + solutions=[ + _itin(("AA6939", "2026-08-15T18:40:00", "JFK", "LHR", "2026-08-16T06:30:00")), + ] + ) + awards = [ + _award( + "BA174", + "2026-08-15T18:40:00", + program="American", + miles=60000, + origin="JFK", + dest="LHR", + arrival="", + ), + _award( + "AS99", + "2026-08-15T18:40:00", + program="Alaska", + miles=7500, + origin="JFK", + dest="LHR", + arrival="2026-08-16T06:30:00", + ), + ] + matches = join(res, awards) + assert [a.program for a in matches[0].awards] != ["Alaska"] + # Two distinct partner carriers with incomplete arrival data is genuinely + # unresolvable, so the safe answer is no award at all. + assert matches[0].awards == [] + + +def test_arrival_filter_abstains_on_partial_coverage_keeping_exact_carrier(): + """Same shape, but the same-carrier award is the one missing an arrival. + It must still win — partial arrival coverage is not evidence.""" + res = SearchResult( + solutions=[ + _itin(("AA118", "2026-08-15T10:45:00", "JFK", "LAX", "2026-08-15T14:05:00")), + ] + ) + awards = [ + _award( + "AA118", + "2026-08-15T10:45:00", + program="American", + miles=25000, + origin="JFK", + dest="LAX", + arrival="", + ), + _award( + "AS17", + "2026-08-15T10:45:00", + program="Alaska", + miles=7500, + origin="JFK", + dest="LAX", + arrival="2026-08-15T14:05:00", + ), + ] + matches = join(res, awards) + assert [a.program for a in matches[0].awards] == ["American"] + + +def test_arrival_separates_same_carrier_rotations(): + """What arrival is actually for: two AA flights the carrier rules cannot + tell apart, sharing a departure minute. Only the matching arrival wins.""" + res = SearchResult( + solutions=[ + _itin(("AA867", "2026-09-09T06:00-05:00", "MSY", "MIA", "2026-09-09T09:04-04:00")), + ] + ) + awards = [ + _award( + "AA867", + "2026-09-09T06:00:00", + program="American", + miles=25000, + origin="MSY", + dest="MIA", + arrival="2026-09-09T09:04:00", + ), + _award( + "AA999", + "2026-09-09T06:00:00", + program="American", + miles=9000, + origin="MSY", + dest="MIA", + arrival="2026-09-09T10:30:00", + ), + ] + matches = join(res, awards) + assert [a.flight_number for a in matches[0].awards] == ["AA867"] + + +def test_arrival_drift_does_not_drop_the_only_candidate(): + """Schedule sources disagree by a few minutes in real data (a live + cross-check found AA106 at 19:20 vs 19:15). When nothing matches the cash + arrival, keep the bucket rather than zeroing it — the carrier rules are + better evidence than a minute-precision timestamp.""" + res = SearchResult( + solutions=[ + _itin(("AA106", "2026-08-15T19:20:00", "JFK", "LHR", "2026-08-16T07:30:00")), + ] + ) + awards = [ + _award( + "AA106", + "2026-08-15T19:20:00", + program="American", + origin="JFK", + dest="LHR", + arrival="2026-08-16T07:25:00", + ), + ] + matches = join(res, awards) + assert len(matches[0].awards) == 1 From f6383e2ec04abe76c98d2e9b3e3adae0c3ccddb8 Mon Sep 17 00:00:00 2001 From: ak2k <19240940+ak2k@users.noreply.github.com> Date: Wed, 29 Jul 2026 22:48:15 -0400 Subject: [PATCH 07/26] flight-cli: resolve the primary and matched-id keys too, not just the fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial review: every commit on this branch hardened the route+time fallback and left the two HIGHER-priority keys unguarded. Both render unbookable prices, so the arrival discriminator added in 2569c04 was dead code whenever either of them hit. P0 — primary key. `(flight#, date, origin, dest)` uses the FIRST segment only, so every connecting journey starting on that flight collapses onto one key. Confirmed in this repo's own seats.aero fixture: three distinct AA1444 JFK→LHR journeys share a key, arriving 06:55 / 09:05 / 12:50, business fares 104k–115.5k. All three attached and the renderer's lowest-miles pick quoted the 12:50 journey's 104k on the 06:55 row. Ten more collided keys in the same 56-award payload. Now narrowed by arrival. P0 — matched-id path. Zero corroboration: PP mints matchedGoogleFlightId from a hint we supply and its matcher is documented as deliberately loose, so the echoed ID is a claim, not proof. It re-attached DL1424/Delta/9.1k to an AA3539 cash row — the branch's original bug, arriving by a different door. Now gated on `same_metal` (not exact, so codeshare bridging survives) plus arrival. Pre-existing, but this path becomes the default as gflight rolls out. P1 — `_narrow` had two escape hatches that re-admitted disproved awards: one member missing an arrival disabled the filter for the whole bucket, and a bucket where every arrival disagreed — the strongest possible evidence — was restored wholesale. Now per-candidate: an arrival that agrees or is absent is kept, one that disagrees is dropped. That last change reverses a call from the previous commit. Schedule drift is real (a fixture-vs-live cross-check found AA106 at 19:20 vs 19:15), so dropping on disagreement will cost some legitimate matches. Taken knowingly: an empty cell invites investigation, a wrong price invites a booking attempt. A tolerance window is the fix if drift proves common — not a wholesale fallback. The superseded test is replaced by one asserting the new behavior and recording why. Five new tests; three fail against the pre-fix matcher. make check green: 558 tests, ruff + basedpyright clean. Live output unchanged (AA3539 15.5k American, AA867 4.5k Alaska). --- src/flight_cli/pp/match.py | 45 ++++++++----- tests/pp/test_match.py | 125 +++++++++++++++++++++++++++++++++++-- 2 files changed, 151 insertions(+), 19 deletions(-) diff --git a/src/flight_cli/pp/match.py b/src/flight_cli/pp/match.py index 16fc835..c8a8468 100644 --- a/src/flight_cli/pp/match.py +++ b/src/flight_cli/pp/match.py @@ -228,12 +228,13 @@ def _narrow(candidates: list[AwardFlight], cash_arrival: str) -> list[AwardFligh carrier rules cannot. Measured on a live MSY→MIA/FLL payload: route+departure alone left 3 multi-carrier buckets of 41; adding arrival left 0 of 91. - Abstains — returns the input untouched — unless the cash side has an - arrival AND *every* candidate has one. Partial coverage is the dangerous - case: filtering a mixed bucket silently drops the awards that merely omit - the field, which is how a correct match gets deleted in favour of a wrong - one. Both sides being fully populated is the only state where a - non-match is real evidence of different metal rather than missing data. + Per-candidate, not all-or-nothing. A candidate is kept when it agrees with + the cash arrival OR carries no arrival at all: a missing arrival is absence + of evidence, while a *disagreeing* arrival is evidence of different metal. + Judging each candidate on its own data avoids two failure modes an + all-or-nothing filter has — one member missing an arrival disabling the + filter for the whole bucket, and a bucket where every arrival disagrees + (the strongest possible evidence) being restored wholesale. Note both sides express local time at the airport: Matrix sends an offset ('...T09:04-04:00'), PointsPath naive ('...T09:04:00'), seats.aero a @@ -243,10 +244,7 @@ def _narrow(candidates: list[AwardFlight], cash_arrival: str) -> list[AwardFligh """ if not cash_arrival: return candidates - if not all(_iso_minute(af.arrival) for af in candidates): - return candidates - timed = [af for af in candidates if _iso_minute(af.arrival) == cash_arrival] - return timed or candidates + return [af for af in candidates if _iso_minute(af.arrival) in ("", cash_arrival)] def _iso_date(s: str | None) -> str: @@ -424,17 +422,36 @@ def join( s = _cash_slice(it, slice_index) cash_stops = _slice_stop_count(s) if s else 0 + cash_fn = cash_first_flight_number(it, slice_index=slice_index) + cash_arr = _iso_minute(s.arrival) if s else "" + candidates: list[AwardFlight] = [] mid_k = cash_matched_id_key(it, slice_index=slice_index) if mid_k: - candidates.extend(mid_idx.get(mid_k, ())) + # PP mints this ID from a hint we supplied, and its own matcher is + # documented as deliberately loose, so an echoed ID is a claim and + # not proof. Corroborate the carrier before trusting it — + # `same_metal` rather than exact, since bridging codeshares is the + # reason this path exists. Route already agrees: the ID is derived + # from the cash slice. + candidates.extend( + _narrow( + [af for af in mid_idx.get(mid_k, ()) if same_metal(cash_fn, af.flight_number)], + cash_arr, + ), + ) fn_k = cash_match_key(it, slice_index=slice_index) if fn_k: - candidates.extend(fn_idx.get(fn_k, ())) + # (flight#, date, route) is NOT one journey. The key uses the first + # segment only, so every connecting itinerary that starts on this + # flight collapses together: the seats.aero fixture has three + # distinct AA1444 JFK→LHR journeys on one key, arriving 06:55, + # 09:05 and 12:50. Unresolved, the renderer's lowest-miles pick + # quotes the 12:50 journey's fare on the 06:55 row. Arrival is what + # separates them. + candidates.extend(_narrow(list(fn_idx.get(fn_k, ())), cash_arr)) rt_k = cash_route_time_key(it, slice_index=slice_index) if rt_k: - cash_fn = cash_first_flight_number(it, slice_index=slice_index) - cash_arr = _iso_minute(s.arrival) if s else "" candidates.extend(_pick_metal(cash_fn, cash_arr, list(rt_idx.get(rt_k, ())))) matched: list[AwardFlight] = [] diff --git a/tests/pp/test_match.py b/tests/pp/test_match.py index 39720b6..a200a51 100644 --- a/tests/pp/test_match.py +++ b/tests/pp/test_match.py @@ -1139,11 +1139,20 @@ def test_arrival_separates_same_carrier_rotations(): assert [a.flight_number for a in matches[0].awards] == ["AA867"] -def test_arrival_drift_does_not_drop_the_only_candidate(): - """Schedule sources disagree by a few minutes in real data (a live - cross-check found AA106 at 19:20 vs 19:15). When nothing matches the cash - arrival, keep the bucket rather than zeroing it — the carrier rules are - better evidence than a minute-precision timestamp.""" +def test_arrival_disagreement_drops_the_award_even_if_it_is_the_only_one(): + """Disagreeing arrival is evidence of different metal, and we act on it + even when that leaves the row with no award. + + Real schedule sources do drift: a cross-check of the seats.aero fixture + against the live Matrix cache found AA106 differing by 5 minutes (19:20 vs + 19:15). So this WILL cost some legitimate matches. That trade is deliberate + — a dropped award shows an empty cell the user can investigate, while a + re-admitted one prints a confident price for another aircraft. Absence of + evidence (no arrival at all) is still admitted; only contradiction is not. + + If drift turns out to be common in practice, the fix is a tolerance window + here, not restoring a wholesale fallback. + """ res = SearchResult( solutions=[ _itin(("AA106", "2026-08-15T19:20:00", "JFK", "LHR", "2026-08-16T07:30:00")), @@ -1160,4 +1169,110 @@ def test_arrival_drift_does_not_drop_the_only_candidate(): ), ] matches = join(res, awards) + assert matches[0].awards == [] + + +def test_arrival_missing_on_award_is_still_admitted(): + """Absence of evidence is not evidence: an award that omits its arrival + stays eligible and is resolved by the carrier rules.""" + res = SearchResult( + solutions=[ + _itin(("AA106", "2026-08-15T19:20:00", "JFK", "LHR", "2026-08-16T07:30:00")), + ] + ) + awards = [ + _award( + "AA106", "2026-08-15T19:20:00", program="American", origin="JFK", dest="LHR", arrival="" + ), + ] + matches = join(res, awards) + assert len(matches[0].awards) == 1 + + +def test_primary_key_resolves_sibling_journeys_by_arrival(): + """The second P0. (flight#, date, origin, dest) keys on the FIRST segment, + so every connecting journey starting on that flight collapses onto one + key. The repo's seats.aero fixture holds three distinct AA1444 JFK->LHR + journeys (arriving 06:55, 09:05, 12:50) — unresolved, the renderer's + lowest-miles pick quotes the 12:50 journey's fare on the 06:55 row.""" + res = SearchResult( + solutions=[ + _itin_multi( + ["AA1444", "AA200"], + "2026-08-15T18:00:00", + "JFK", + "LHR", + ["BOS"], + arrival="2026-08-16T06:55:00", + ), + ] + ) + awards = [ + _award( + "AA1444", + "2026-08-15T18:00:00", + program="American Airlines", + miles=115500, + origin="JFK", + dest="LHR", + arrival="2026-08-16T06:55:00", + num_connections=1, + ), + _award( + "AA1444", + "2026-08-15T18:00:00", + program="American Airlines", + miles=104000, + origin="JFK", + dest="LHR", + arrival="2026-08-16T12:50:00", + num_connections=1, + ), + ] + matches = join(res, awards) + assert [a.cabins[0].miles for a in matches[0].awards] == [115500] + + +def test_matched_id_path_requires_carrier_corroboration(): + """The third P0. PP mints matchedGoogleFlightId from a hint we supply and + its matcher is documented as loose, so the echoed ID is a claim, not proof. + A Delta award must not ride it onto an American row.""" + res = SearchResult( + solutions=[ + _itin_with_id("AA3539", "2026-09-09T10:45:00", "MSY", "MIA", flight_id="XyZ123"), + ] + ) + awards = [ + _award( + "DL1424", + "2026-09-09T10:45:00", + program="Delta", + miles=9100, + origin="MSY", + dest="MIA", + matched_id="XyZ123", + ), + ] + matches = join(res, awards) + assert matches[0].awards == [] + + +def test_matched_id_path_still_bridges_a_real_codeshare(): + """...but the path keeps working for the codeshare it exists to serve.""" + res = SearchResult( + solutions=[ + _itin_with_id("AA6939", "2026-08-15T18:40:00", "JFK", "LHR", flight_id="XyZ123"), + ] + ) + awards = [ + _award( + "BA174", + "2026-08-15T18:40:00", + program="American", + origin="JFK", + dest="LHR", + matched_id="XyZ123", + ), + ] + matches = join(res, awards) assert len(matches[0].awards) == 1 From 6a3530ee81f6f9383d0fb6ced57c584e5e4b3536 Mon Sep 17 00:00:00 2001 From: ak2k <19240940+ak2k@users.noreply.github.com> Date: Wed, 29 Jul 2026 22:53:02 -0400 Subject: [PATCH 08/26] flight-cli: normalize seats.aero timestamps + wrap PP catalog HTTP errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clearing the two items the last review left as follow-ups. Both were called "pre-existing," which was true and not a reason — the first is load-bearing for the arrival discriminator this branch just added, and the second is a written project rule. seats.aero timestamps. `DepartsAt`/`ArrivesAt` carry a 'Z' suffix but hold LOCAL time at each airport. Confirmed by fixture arithmetic: JFK->LHR nonstops read ~12.1h block time when the Z is honoured, against a real ~7h — and 09:35 local JFK plus 7h is 21:35 London, which is the 21:40 they report. Correctness today depends on `_iso_minute` happening to truncate the suffix away, i.e. on the mislabel staying benign. Now normalized at the provider boundary (`_local_naive`) so `AwardFlight.departure`/`.arrival` mean naive-local for every provider, and the models.py annotation says what the field actually is instead of repeating upstream's error. The wire-shape test still pins the 'Z' as what arrives; a new provider test pins that it doesn't survive. PP catalog errors. `pricing_info`/`extension_config` called `r.raise_for_status()` directly, leaking `httpx.HTTPStatusError` past the boundary — contra AGENTS.md Principle 1. Added `PPApiError` carrying endpoint and status, raised via a `_raise_for_status` helper with the httpx error as `__cause__`. Both callers already catch broad `Exception`, so behavior is unchanged; the error now names its domain and the traceback keeps the cause. Per-airline search failures are deliberately untouched — those are non-fatal and handled inline. make check green: 561 tests, ruff + basedpyright clean. Live output unchanged. --- src/flight_cli/pp/client.py | 34 +++++++++++++- src/flight_cli/providers/seats_aero/models.py | 6 ++- .../providers/seats_aero/provider.py | 21 ++++++++- tests/pp/test_client_request_retry.py | 45 +++++++++++++++++-- tests/seats_aero/test_models.py | 4 +- tests/seats_aero/test_provider.py | 14 ++++++ 6 files changed, 114 insertions(+), 10 deletions(-) diff --git a/src/flight_cli/pp/client.py b/src/flight_cli/pp/client.py index e192cbb..679a7d1 100644 --- a/src/flight_cli/pp/client.py +++ b/src/flight_cli/pp/client.py @@ -37,6 +37,23 @@ API_BASE = "https://api.pointspath.com" + +class PPApiError(Exception): + """A PointsPath endpoint returned an unusable response. + + Exists so `httpx.HTTPStatusError` never reaches a caller (AGENTS.md + Principle 1 — boundaries fail loudly, in domain terms). Per-airline + failures are non-fatal and handled inline; this is for the catalog + endpoints, where a failure means the award overlay cannot be built. + """ + + def __init__(self, message: str, *, endpoint: str, status: int | None = None) -> None: + super().__init__(message) + self.message = message + self.endpoint = endpoint + self.status = status + + PRICING_CACHE = Path.home() / ".cache" / "flight-cli" / "pp_pricing.json" PRICING_TTL_SECS = 24 * 3600 @@ -286,7 +303,7 @@ async def pricing_info(self, *, force_refresh: bool = False) -> PricingInfoRespo if age < PRICING_TTL_SECS: return PricingInfoResponse.model_validate(json.loads(PRICING_CACHE.read_text())) r = await self._request("GET", "/api/pricing-info") - r.raise_for_status() + _raise_for_status(r, "/api/pricing-info") PRICING_CACHE.parent.mkdir(parents=True, exist_ok=True) PRICING_CACHE.write_text(r.text) return PricingInfoResponse.model_validate(r.json()) @@ -307,7 +324,7 @@ async def extension_config(self, *, force_refresh: bool = False) -> dict[str, An "/api/extension-config", params={"v": EXT_CONFIG_VERSION}, ) - r.raise_for_status() + _raise_for_status(r, "/api/extension-config") EXT_CONFIG_CACHE.parent.mkdir(parents=True, exist_ok=True) EXT_CONFIG_CACHE.write_text(r.text) return r.json() @@ -320,6 +337,19 @@ async def extension_config(self, *, force_refresh: bool = False) -> dict[str, An _AIRLINE_FLAG_RE = re.compile(r"^enable(?P[A-Z][A-Za-z]+?)(?:V\d+)?$") +def _raise_for_status(r: httpx.Response, endpoint: str) -> None: + """Translate a failed response into `PPApiError` at the boundary, so + `httpx.HTTPStatusError` never escapes this module.""" + try: + _ = r.raise_for_status() + except httpx.HTTPStatusError as e: + raise PPApiError( + f"{endpoint} returned HTTP {r.status_code}", + endpoint=endpoint, + status=r.status_code, + ) from e + + def is_unsupported_airline_response(status: int, body: str) -> bool: """True when a 4xx says the airline itself isn't served, as opposed to a transient/auth/route-specific failure. diff --git a/src/flight_cli/providers/seats_aero/models.py b/src/flight_cli/providers/seats_aero/models.py index 9047818..93591c7 100644 --- a/src/flight_cli/providers/seats_aero/models.py +++ b/src/flight_cli/providers/seats_aero/models.py @@ -57,7 +57,11 @@ class SeatsAvailabilityTrip(_Loose): OriginAirport: str DestinationAirport: str - DepartsAt: str # ISO 8601 UTC, e.g. "2026-08-15T06:30:00Z" + # MISLABELLED UPSTREAM: carries a 'Z' suffix but the value is LOCAL time + # at the airport, not UTC. Verified against this repo's own fixture — + # honouring the Z gives ~12.1h JFK→LHR nonstops against a real ~7h. + # `provider._local_naive` strips the suffix on the way into AwardFlight. + DepartsAt: str # naive local despite the 'Z', e.g. "2026-08-15T06:30:00Z" ArrivesAt: str Cabin: str # lowercase: "economy", "premium", "business", "first" FlightNumbers: str # comma list, in segment order diff --git a/src/flight_cli/providers/seats_aero/provider.py b/src/flight_cli/providers/seats_aero/provider.py index 0ddba75..bbf8e70 100644 --- a/src/flight_cli/providers/seats_aero/provider.py +++ b/src/flight_cli/providers/seats_aero/provider.py @@ -84,6 +84,23 @@ def _cabin_label(slug: str) -> str: return _CABIN_LABELS.get(slug.lower(), slug.title()) +def _local_naive(ts: str) -> str: + """Strip seats.aero's spurious 'Z' suffix. + + Their `DepartsAt`/`ArrivesAt` are labelled UTC but carry LOCAL time at each + airport. Fixture arithmetic proves it: JFK→LHR nonstops read ~12.1h block + time when the Z is honoured, against a real ~7h — and 09:35 local JFK plus + 7h is 21:35 London, which is the 21:40 they report. + + The matcher compares these against Matrix cash times, which are local too, + so passing the Z through would be a latent ~offset-sized error the moment + anything parses these as instants instead of truncating the suffix away. + Normalizing here keeps `AwardFlight.departure`/`.arrival` meaning one + thing — naive local — across every provider. + """ + return ts.removesuffix("Z") + + def _first_flight_number(flight_numbers: str) -> str: """`"AA4671, BA216"` → `"AA4671"`. The matcher keys on the first marketing flight number, same convention as PointsPath. Multi-segment @@ -124,8 +141,8 @@ def _group_trips_to_awards( grouped[key] = AwardFlight( origin=t.OriginAirport, destination=t.DestinationAirport, - departure=t.DepartsAt, - arrival=t.ArrivesAt, + departure=_local_naive(t.DepartsAt), + arrival=_local_naive(t.ArrivesAt), flight_number=_first_flight_number(t.FlightNumbers), num_connections=t.Stops, provider="Seats.aero", diff --git a/tests/pp/test_client_request_retry.py b/tests/pp/test_client_request_retry.py index cf8e1dd..c01441a 100644 --- a/tests/pp/test_client_request_retry.py +++ b/tests/pp/test_client_request_retry.py @@ -8,20 +8,20 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any import anyio import httpx +import pytest from flight_cli.pp import client as client_mod from flight_cli.pp.auth import Tokens -from flight_cli.pp.client import API_BASE, PPClient +from flight_cli.pp.client import API_BASE, PPApiError, PPClient if TYPE_CHECKING: + import pathlib from collections.abc import Callable - import pytest - def _tokens(access: str = "OLD_TOKEN") -> Tokens: return Tokens( @@ -159,3 +159,40 @@ async def go() -> httpx.Response: assert r.status_code == 401 assert len(refresh_calls) == 1, "refresh runs once even when retry also 401s" assert len(captured) == 2, "retry-once semantics: no third request" + + +# ───────────── catalog endpoints wrap HTTP failures in a domain error ───────────── + + +def test_pricing_info_raises_domain_error_not_httpx( + tmp_path: pathlib.Path, monkeypatch: Any +) -> None: + """AGENTS.md Principle 1: httpx.HTTPStatusError must not reach a caller.""" + monkeypatch.setattr("flight_cli.pp.client.PRICING_CACHE", tmp_path / "pricing.json") + handler, _ = _scripted_handler([httpx.Response(500, text="boom")]) + + async def go() -> None: + pp = _client_with_transport(_tokens(), httpx.MockTransport(handler)) + with pytest.raises(PPApiError) as ei: + _ = await pp.pricing_info() + assert ei.value.status == 500 + assert ei.value.endpoint == "/api/pricing-info" + assert isinstance(ei.value.__cause__, httpx.HTTPStatusError) + await pp.aclose() + + anyio.run(go) + + +def test_extension_config_raises_domain_error_not_httpx( + tmp_path: pathlib.Path, monkeypatch: Any +) -> None: + monkeypatch.setattr("flight_cli.pp.client.EXT_CONFIG_CACHE", tmp_path / "ext.json") + handler, _ = _scripted_handler([httpx.Response(503, text="down")]) + + async def go() -> None: + pp = _client_with_transport(_tokens(), httpx.MockTransport(handler)) + with pytest.raises(PPApiError): + _ = await pp.extension_config() + await pp.aclose() + + anyio.run(go) diff --git a/tests/seats_aero/test_models.py b/tests/seats_aero/test_models.py index a0414e7..312c1a3 100644 --- a/tests/seats_aero/test_models.py +++ b/tests/seats_aero/test_models.py @@ -67,7 +67,9 @@ def test_with_trips_trip_fields() -> None: t = trips[0] # FlightNumbers is a comma-joined string in segment order. assert "," in t.FlightNumbers or "-" not in t.FlightNumbers - # DepartsAt is ISO 8601 UTC. + # DepartsAt arrives with a 'Z' suffix — which upstream mislabels: the + # value is local time at the airport. Pinned as the raw wire shape; the + # provider strips it (see test_seats_aero_timestamps_are_normalized_local). assert t.DepartsAt.endswith("Z") or "+" in t.DepartsAt # Cabin is lowercased. assert t.Cabin in {"economy", "premium", "business", "first"} diff --git a/tests/seats_aero/test_provider.py b/tests/seats_aero/test_provider.py index 9e4a160..6cd21e5 100644 --- a/tests/seats_aero/test_provider.py +++ b/tests/seats_aero/test_provider.py @@ -128,3 +128,17 @@ def test_group_converts_tax_cents_to_usd() -> None: def test_group_empty_input_returns_empty() -> None: assert _group_trips_to_awards([], tax_currency="USD") == [] + + +def test_seats_aero_timestamps_are_normalized_to_naive_local() -> None: + """seats.aero labels DepartsAt/ArrivesAt as UTC with a 'Z', but the values + are local time at each airport — honouring the Z yields ~12.1h JFK->LHR + nonstops against a real ~7h. The provider strips the suffix so + AwardFlight timestamps mean naive-local for every provider, matching what + Matrix and PointsPath supply and what the matcher compares. + """ + from flight_cli.providers.seats_aero.provider import _local_naive + + assert _local_naive("2026-08-15T06:30:00Z") == "2026-08-15T06:30:00" + assert _local_naive("2026-08-15T06:30:00") == "2026-08-15T06:30:00" + assert _local_naive("") == "" From 13c656427deb76a89204d9d1a37a490e25f12728 Mon Sep 17 00:00:00 2001 From: ak2k <19240940+ak2k@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:09:52 -0400 Subject: [PATCH 09/26] flight-cli: fix 3 P0s found by cross-model (codex/gpt-5.6) adversarial review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Independent adversarial pass from a different model family, prompted to refute rather than confirm. It broke the claim a fourth time — each finding reproduced here through the real renderer before being accepted. P0 — matched-ID buckets were filtered, not resolved. Several awards can share one matchedGoogleFlightId, and `same_metal` alone still admits a partner beside the true same-carrier award. On a RETURN leg (slice_index=1, which had almost no coverage): cash AA117 with AA117/25k and AS19/7.5k both attached and `_fmt_award_cell` printed 7.5k Alaska. Both this and the primary key now go through `_pick_metal`, the same resolver the route+time bucket uses. P0 — journeys sharing a first segment collapsed. Every key here identifies a journey by segment 0, and seats.aero discarded segments 2+ at the provider boundary. It returns both "AA1444, BA216" and "AA1444, AA100" on one JFK→LHR date; both attached to the [AA1444, BA216] cash row and the renderer printed the cheaper journey's 12k. `AwardFlight.segment_flight_numbers` now carries the ordered list, and `_by_segments` drops contradicting candidates. Providers that don't supply segments (PointsPath sends only the first) are unaffected — an empty list is absence of evidence, not disagreement. P0 — a cheaper no-arrival sibling beat the exact-arrival award. My previous "missing arrival is admitted" rule let a 10k candidate with no arrival sit beside the 60k award that positively confirmed the cash flight, and win the cell. `_narrow` now admits missing arrivals only when nothing matched exactly. Fixing that surfaced a subtlety worth naming: arrival is used two different ways and conflating them is itself a bug. Eliminating a candidate whose arrival CONTRADICTS the cash flight is sound evidence of different metal. Letting arrival CHOOSE between carriers is not — a partner that merely omits its arrival would lose to an unrelated carrier that happens to publish a matching one. Split into `_drop_contradicted` (safe before the ambiguity check) and `_narrow` (selects, so only within one carrier). P1 — legacy cache entries were immortal. The flat-list migration stamped them `now` on every READ, so they refreshed continuously and could never age out. They are now dated from the file's mtime, the only real timestamp they have. Nine new tests; all fail against the pre-fix code. make check green: 567 tests, ruff + basedpyright clean. Live output unchanged. --- src/flight_cli/pp/client.py | 12 +- src/flight_cli/pp/match.py | 99 +++++++-- src/flight_cli/providers/base.py | 9 + .../providers/seats_aero/provider.py | 11 + tests/pp/test_client.py | 18 +- tests/pp/test_match.py | 209 ++++++++++++++++++ 6 files changed, 329 insertions(+), 29 deletions(-) diff --git a/src/flight_cli/pp/client.py b/src/flight_cli/pp/client.py index 679a7d1..13a6aac 100644 --- a/src/flight_cli/pp/client.py +++ b/src/flight_cli/pp/client.py @@ -367,16 +367,20 @@ def _load_unsupported_raw() -> dict[str, float]: """`{airline: epoch_seconds_learned}`, unfiltered. {} on any read problem. Tolerates the legacy flat-list format written by the first version of this - cache by treating those entries as learned now — one extra TTL period of - staleness on upgrade, versus re-querying every unsupported airline again. + cache, dating those entries from the FILE's mtime — the only real timestamp + they have. Stamping them `now` instead would restamp on every read, so a + legacy file could never age out and the entries would be immortal. """ try: raw: Any = json.loads(UNSUPPORTED_CACHE.read_text()) except (OSError, ValueError): return {} if isinstance(raw, list): # legacy: ["ANA", "Finnair", ...] - now = time.time() - return {x: now for x in cast("list[Any]", raw) if isinstance(x, str)} + try: + learned_at = UNSUPPORTED_CACHE.stat().st_mtime + except OSError: + return {} + return {x: learned_at for x in cast("list[Any]", raw) if isinstance(x, str)} if not isinstance(raw, dict): return {} out: dict[str, float] = {} diff --git a/src/flight_cli/pp/match.py b/src/flight_cli/pp/match.py index c8a8468..dd65ede 100644 --- a/src/flight_cli/pp/match.py +++ b/src/flight_cli/pp/match.py @@ -46,6 +46,7 @@ _ISO_MINUTE_LEN = 16 # "YYYY-MM-DDTHH:MM" _IATA_PREFIX_LEN = 2 +_MIN_MULTI_SEGMENT = 2 # a slice needs 2+ flights before later segments can disagree # Carriers that may appear as marketing/operating counterparts for the same # physical flight. Alliance membership plus the bilateral JVs and equity @@ -209,15 +210,54 @@ def _pick_metal( exact = [af for af in candidates if same_carrier(cash_fn, af.flight_number)] if exact: return _narrow(exact, cash_arrival) - partners = _narrow( - [af for af in candidates if same_metal(cash_fn, af.flight_number)], - cash_arrival, - ) + partners = [af for af in candidates if same_metal(cash_fn, af.flight_number)] + # Arrival is used in two distinct ways here, and conflating them is a bug. + # Eliminating a candidate whose arrival CONTRADICTS the cash flight is + # sound — that is positive evidence of different metal. Letting arrival + # *choose* between carriers is not: a partner that merely omits its + # arrival would lose to an unrelated carrier that happens to publish a + # matching one. So contradicted candidates go first, then ambiguity is + # judged among the survivors. + partners = _drop_contradicted(partners, cash_arrival) if not partners: return [] if len({_carrier(af.flight_number) for af in partners}) > 1: return [] - return partners + return _narrow(partners, cash_arrival) + + +def _by_segments(candidates: list[AwardFlight], cash_flights: list[str]) -> list[AwardFlight]: + """Drop candidates whose segment list contradicts the cash slice's. + + Every key in this module identifies a journey by its FIRST segment, so two + journeys that share a first flight and diverge afterwards collapse together: + seats.aero returns both "AA1444, BA216" and "AA1444, AA100" on one + JFK→LHR date, and the renderer's lowest-miles pick then prints the cheaper + journey's fare on the other's row. + + Only providers that supply the full list are judged — PointsPath sends just + the first flight number, and an empty list means "no evidence", not + disagreement. Applied only when the cash slice itself is multi-segment: on + a nonstop there is nothing beyond segment 0 to contradict. + """ + if len(cash_flights) < _MIN_MULTI_SEGMENT: + return candidates + want = [_norm_fn(f) for f in cash_flights] + return [ + af + for af in candidates + if not af.segment_flight_numbers or [_norm_fn(f) for f in af.segment_flight_numbers] == want + ] + + +def _drop_contradicted(candidates: list[AwardFlight], cash_arrival: str) -> list[AwardFlight]: + """Remove candidates whose arrival positively disagrees with the cash + flight's. Missing arrivals survive — absence of evidence is not + contradiction. Unlike `_narrow` this never *selects* a winner, so it is + safe to run before carrier ambiguity is judged.""" + if not cash_arrival: + return candidates + return [af for af in candidates if _iso_minute(af.arrival) in ("", cash_arrival)] def _narrow(candidates: list[AwardFlight], cash_arrival: str) -> list[AwardFlight]: @@ -228,14 +268,20 @@ def _narrow(candidates: list[AwardFlight], cash_arrival: str) -> list[AwardFligh carrier rules cannot. Measured on a live MSY→MIA/FLL payload: route+departure alone left 3 multi-carrier buckets of 41; adding arrival left 0 of 91. - Per-candidate, not all-or-nothing. A candidate is kept when it agrees with - the cash arrival OR carries no arrival at all: a missing arrival is absence - of evidence, while a *disagreeing* arrival is evidence of different metal. + Per-candidate, not all-or-nothing. A *disagreeing* arrival is evidence of + different metal and is always dropped, even when that empties the bucket. Judging each candidate on its own data avoids two failure modes an all-or-nothing filter has — one member missing an arrival disabling the filter for the whole bucket, and a bucket where every arrival disagrees (the strongest possible evidence) being restored wholesale. + Candidates with NO arrival are the awkward middle: absence of evidence, not + evidence of absence. They are admitted only when nothing in the bucket + matched exactly. Otherwise a cheaper no-arrival sibling would sit beside + the award that positively confirmed the cash flight, and the renderer's + lowest-miles pick would show the sibling's price — silently preferring the + unverified candidate over the verified one. + Note both sides express local time at the airport: Matrix sends an offset ('...T09:04-04:00'), PointsPath naive ('...T09:04:00'), seats.aero a misleading 'Z' on what is also local. `_iso_minute` truncates all three to @@ -244,7 +290,10 @@ def _narrow(candidates: list[AwardFlight], cash_arrival: str) -> list[AwardFligh """ if not cash_arrival: return candidates - return [af for af in candidates if _iso_minute(af.arrival) in ("", cash_arrival)] + exact = [af for af in candidates if _iso_minute(af.arrival) == cash_arrival] + if exact: + return exact + return [af for af in candidates if not _iso_minute(af.arrival)] def _iso_date(s: str | None) -> str: @@ -424,22 +473,27 @@ def join( cash_fn = cash_first_flight_number(it, slice_index=slice_index) cash_arr = _iso_minute(s.arrival) if s else "" + cash_flights = list(s.flights or []) if s else [] + + def resolve( + bucket: list[AwardFlight], + _fn: str = cash_fn, + _arr: str = cash_arr, + _fl: list[str] = cash_flights, + ) -> list[AwardFlight]: + return _pick_metal(_fn, _arr, _by_segments(bucket, _fl)) candidates: list[AwardFlight] = [] mid_k = cash_matched_id_key(it, slice_index=slice_index) if mid_k: # PP mints this ID from a hint we supplied, and its own matcher is # documented as deliberately loose, so an echoed ID is a claim and - # not proof. Corroborate the carrier before trusting it — - # `same_metal` rather than exact, since bridging codeshares is the - # reason this path exists. Route already agrees: the ID is derived - # from the cash slice. - candidates.extend( - _narrow( - [af for af in mid_idx.get(mid_k, ()) if same_metal(cash_fn, af.flight_number)], - cash_arr, - ), - ) + # not proof — several awards can share one ID. Resolve the bucket + # exactly as the route+time bucket is resolved: filtering it with + # `same_metal` alone still admits a partner beside the true + # same-carrier award, and the renderer's lowest-miles pick then + # shows the partner's price. + candidates.extend(resolve(list(mid_idx.get(mid_k, ())))) fn_k = cash_match_key(it, slice_index=slice_index) if fn_k: # (flight#, date, route) is NOT one journey. The key uses the first @@ -447,12 +501,11 @@ def join( # flight collapses together: the seats.aero fixture has three # distinct AA1444 JFK→LHR journeys on one key, arriving 06:55, # 09:05 and 12:50. Unresolved, the renderer's lowest-miles pick - # quotes the 12:50 journey's fare on the 06:55 row. Arrival is what - # separates them. - candidates.extend(_narrow(list(fn_idx.get(fn_k, ())), cash_arr)) + # quotes the 12:50 journey's fare on the 06:55 row. + candidates.extend(resolve(list(fn_idx.get(fn_k, ())))) rt_k = cash_route_time_key(it, slice_index=slice_index) if rt_k: - candidates.extend(_pick_metal(cash_fn, cash_arr, list(rt_idx.get(rt_k, ())))) + candidates.extend(resolve(list(rt_idx.get(rt_k, ())))) matched: list[AwardFlight] = [] seen_ids: set[int] = set() diff --git a/src/flight_cli/providers/base.py b/src/flight_cli/providers/base.py index c986964..994e6d3 100644 --- a/src/flight_cli/providers/base.py +++ b/src/flight_cli/providers/base.py @@ -67,6 +67,15 @@ class AwardFlight: arrival: str flight_number: str num_connections: int = 0 + # Every segment's marketing flight number, in order, when the provider + # supplies them (seats.aero does; PointsPath returns only the first). + # `flight_number` is segment 0, so two journeys that share a first segment + # and diverge afterwards are indistinguishable without this — seats.aero + # returns both "AA1444, BA216" and "AA1444, AA100" on one route+date, and + # collapsing them lets the cheaper journey's price render on the other's + # row. Empty when the provider can't say, which the matcher treats as + # "no segment evidence" rather than agreement. + segment_flight_numbers: list[str] = field(default_factory=list[str]) # provider/program metadata — used for rendering only provider: str = "" # display name, e.g. "PointsPath", "seats.aero" diff --git a/src/flight_cli/providers/seats_aero/provider.py b/src/flight_cli/providers/seats_aero/provider.py index bbf8e70..ec78ec0 100644 --- a/src/flight_cli/providers/seats_aero/provider.py +++ b/src/flight_cli/providers/seats_aero/provider.py @@ -84,6 +84,16 @@ def _cabin_label(slug: str) -> str: return _CABIN_LABELS.get(slug.lower(), slug.title()) +def _segment_flight_numbers(flight_numbers: str) -> list[str]: + """`"AA4671, BA216"` -> `["AA4671", "BA216"]` — every segment, in order. + + The matcher needs the whole list to tell apart journeys that share a first + segment: seats.aero returns both "AA1444, BA216" and "AA1444, AA100" on one + JFK->LHR date, and keying on segment 0 alone collapses them. + """ + return [n.strip().upper().replace(" ", "") for n in flight_numbers.split(",") if n.strip()] + + def _local_naive(ts: str) -> str: """Strip seats.aero's spurious 'Z' suffix. @@ -144,6 +154,7 @@ def _group_trips_to_awards( departure=_local_naive(t.DepartsAt), arrival=_local_naive(t.ArrivesAt), flight_number=_first_flight_number(t.FlightNumbers), + segment_flight_numbers=_segment_flight_numbers(t.FlightNumbers), num_connections=t.Stops, provider="Seats.aero", program=_program_label(t.Source), diff --git a/tests/pp/test_client.py b/tests/pp/test_client.py index 707ad6f..2bca5be 100644 --- a/tests/pp/test_client.py +++ b/tests/pp/test_client.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import os import pathlib import time from typing import Any @@ -137,14 +138,27 @@ def test_unsupported_entries_expire_independently(tmp_path: pathlib.Path, monkey def test_unsupported_cache_reads_legacy_list_format( tmp_path: pathlib.Path, monkeypatch: Any ) -> None: - """The first version wrote a flat list. Upgrading must not re-query every - unsupported airline; the entries are treated as learned now.""" + """The first version wrote a flat list. A freshly-written one is honoured, + so upgrading doesn't re-query every unsupported airline.""" cache = tmp_path / "unsupported.json" cache.write_text(json.dumps(["ANA", "Southwest"])) monkeypatch.setattr("flight_cli.pp.client.UNSUPPORTED_CACHE", cache) assert load_unsupported_airlines() == frozenset({"ANA", "Southwest"}) +def test_legacy_list_entries_expire_by_file_mtime(tmp_path: pathlib.Path, monkeypatch: Any) -> None: + """Legacy entries carry no timestamp, so they are dated from the file's + mtime. Stamping them `now` on each read made them immortal: every read + refreshed them, so a legacy file could never age out.""" + cache = tmp_path / "unsupported.json" + cache.write_text(json.dumps(["ANA"])) + monkeypatch.setattr("flight_cli.pp.client.UNSUPPORTED_CACHE", cache) + monkeypatch.setattr("flight_cli.pp.client.UNSUPPORTED_TTL_SECS", 100) + old = time.time() - 500 + os.utime(cache, (old, old)) + assert load_unsupported_airlines() == frozenset() + + def test_remember_preserves_existing_timestamps(tmp_path: pathlib.Path, monkeypatch: Any) -> None: """Adding an entry must not restamp its siblings.""" cache = tmp_path / "unsupported.json" diff --git a/tests/pp/test_match.py b/tests/pp/test_match.py index a200a51..8e32bba 100644 --- a/tests/pp/test_match.py +++ b/tests/pp/test_match.py @@ -75,6 +75,7 @@ def _award( matched_id: str = "", num_connections: int = 0, arrival: str | None = None, + segment_flight_numbers: list[str] | None = None, ) -> AwardFlight: return AwardFlight( origin=origin, @@ -89,6 +90,7 @@ def _award( 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, + segment_flight_numbers=segment_flight_numbers or [], ) @@ -1276,3 +1278,210 @@ def test_matched_id_path_still_bridges_a_real_codeshare(): ] matches = join(res, awards) assert len(matches[0].awards) == 1 + + +# ─────────── codex adversarial pass: buckets on the other two keys ─────────── + + +def test_matched_id_bucket_is_resolved_not_just_filtered_on_return_leg(): + """Codex P0. Several awards can share one matchedGoogleFlightId. Filtering + that bucket with `same_metal` alone still admits a partner beside the true + same-carrier award, and the renderer's lowest-miles pick then shows the + partner's 7.5k Alaska price on an American row. Return leg, because + slice_index=1 had almost no coverage.""" + it = Itinerary( + displayTotal="USD500.00", + itinerary=ItineraryDetails( + slices=[ + Slice( + flights=["AA100"], + departure="2026-08-15T18:00:00", + origin=SliceEndpoint(code="JFK"), + destination=SliceEndpoint(code="LAX"), + ), + Slice( + flights=["AA117"], + departure="2026-08-20T10:00:00", + origin=SliceEndpoint(code="LAX"), + destination=SliceEndpoint(code="JFK"), + flight_id="RET1", + ), + ], + carriers=[], + ), + ) + awards = [ + _award( + "AA117", + "2026-08-20T10:00:00", + program="American", + miles=25000, + origin="LAX", + dest="JFK", + matched_id="RET1", + ), + _award( + "AS19", + "2026-08-20T10:00:00", + program="Alaska", + miles=7500, + origin="LAX", + dest="JFK", + matched_id="RET1", + ), + ] + matches = join(SearchResult(solutions=[it]), awards, slice_index=1) + assert [a.program for a in matches[0].awards] == ["American"] + + +def test_segment_disagreement_separates_journeys_sharing_first_flight(): + """Codex P0. seats.aero returns both "AA1444, BA216" and "AA1444, AA100" + on one JFK->LHR date. Every key here identifies a journey by segment 0, so + without the full segment list they collapse and the cheaper journey's fare + prints on the other's row.""" + res = SearchResult( + solutions=[ + _itin_multi( + ["AA1444", "BA216"], + "2026-08-15T18:00:00", + "JFK", + "LHR", + ["BOS"], + arrival="2026-08-16T06:55:00", + ), + ] + ) + awards = [ + _award( + "AA1444", + "2026-08-15T18:00:00", + program="American Airlines", + miles=60000, + origin="JFK", + dest="LHR", + arrival="2026-08-16T06:55:00", + num_connections=1, + segment_flight_numbers=["AA1444", "BA216"], + ), + _award( + "AA1444", + "2026-08-15T18:00:00", + program="American Airlines", + miles=12000, + origin="JFK", + dest="LHR", + arrival="2026-08-16T06:55:00", + num_connections=1, + segment_flight_numbers=["AA1444", "AA100"], + ), + ] + matches = join(res, awards) + assert [a.cabins[0].miles for a in matches[0].awards] == [60000] + + +def test_segment_check_admits_providers_that_omit_segments(): + """PointsPath sends only the first flight number. An empty segment list is + absence of evidence, so it must not be read as disagreement.""" + res = SearchResult( + solutions=[ + _itin_multi( + ["AA1444", "BA216"], + "2026-08-15T18:00:00", + "JFK", + "LHR", + ["BOS"], + arrival="2026-08-16T06:55:00", + ), + ] + ) + awards = [ + _award( + "AA1444", + "2026-08-15T18:00:00", + program="American", + miles=60000, + origin="JFK", + dest="LHR", + arrival="2026-08-16T06:55:00", + num_connections=1, + ), + ] + matches = join(res, awards) + assert len(matches[0].awards) == 1 + + +def test_exact_arrival_match_beats_cheaper_sibling_with_no_arrival(): + """Codex P0. A no-arrival sibling used to sit beside the award that + positively confirmed the cash flight, and its lower price won the cell — + silently preferring the unverified candidate over the verified one.""" + res = SearchResult( + solutions=[ + _itin_multi( + ["AA1444", "BA216"], + "2026-08-15T18:00:00", + "JFK", + "LHR", + ["BOS"], + arrival="2026-08-16T06:55:00", + ), + ] + ) + awards = [ + _award( + "AA1444", + "2026-08-15T18:00:00", + program="American Airlines", + miles=60000, + origin="JFK", + dest="LHR", + arrival="2026-08-16T06:55:00", + num_connections=1, + ), + _award( + "AA1444", + "2026-08-15T18:00:00", + program="American Airlines", + miles=10000, + origin="JFK", + dest="LHR", + arrival="", + num_connections=1, + ), + ] + matches = join(res, awards) + assert [a.cabins[0].miles for a in matches[0].awards] == [60000] + + +def test_arrival_never_picks_between_carriers(): + """The inverse trap. Arrival separates two rotations of the SAME carrier; + it must not choose between carriers, or a partner that merely omits its + arrival loses to an unrelated carrier that happens to publish a matching + one. Cash AA6939 + BA174 (no arrival) + AS99 (matching arrival) is + unresolvable, not an Alaska match.""" + res = SearchResult( + solutions=[ + _itin(("AA6939", "2026-08-15T18:40:00", "JFK", "LHR", "2026-08-16T06:30:00")), + ] + ) + awards = [ + _award( + "BA174", + "2026-08-15T18:40:00", + program="American", + miles=60000, + origin="JFK", + dest="LHR", + arrival="", + ), + _award( + "AS99", + "2026-08-15T18:40:00", + program="Alaska", + miles=7500, + origin="JFK", + dest="LHR", + arrival="2026-08-16T06:30:00", + ), + ] + matches = join(res, awards) + assert matches[0].awards == [] From 6e1f2a683fc6e9dae52a2d49fdd3f6442c84e685 Mon Sep 17 00:00:00 2001 From: ak2k <19240940+ak2k@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:28:48 -0400 Subject: [PATCH 10/26] flight-cli: resolve once over the union of all keys, not per-bucket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cross-model correctness pass (codex/gpt-5.6). Two more P0s, both reproduced through the renderer before being accepted. P0 — the architectural one. Each key's bucket was resolved independently and the winners unioned, so a bucket seeing only part of the field decided on partial information. Cash AA6939 with a BA174 award (found by route+time) and an AS99 award (found by matched-ID): route+time sees two partner carriers and correctly abstains, but the matched-ID bucket sees AS99 alone, calls it an unambiguous single partner, and admits it — rendering 7.5k Alaska on an American row, undoing the rejection the other bucket had just made. Keys are DISCOVERY; resolution is one judgment over everything they found. Now: gather the union, dedup, resolve once. I had probed this exact shape myself and got a clean result — but only with a *contradicted* award, which `_drop_contradicted` catches in every bucket. The leak needs a candidate rejected for AMBIGUITY, which is bucket-local state. A weak probe read as proof of absence. P0 — `same_carrier` compares only the IATA prefix, so AA999 claimed exact-match priority over cash AA867 and skipped to `_narrow`, which admits a missing arrival when nothing matched exactly. Stage 1 now requires the full normalized flight number. An airline does not sell one departure under two of its own numbers, so a same-carrier number mismatch is a different flight and must prove itself with a matching arrival; only a genuine codeshare (different carrier) explains a mismatch without one. P2 — connection-count mismatches are now dropped BEFORE resolution. Filtering them afterwards let an ineligible candidate make the field look ambiguous and suppress a valid codeshare that would have won on its own. P2 — an expired cache entry was never re-stamped: `remember_unsupported_airline` returned early on mere presence, so a lapsed airline was re-queried, rejected, and still looked stale next run — re-queried forever, the exact waste the cache exists to prevent. Now re-stamps only when actually expired, so repeated rejections can't extend a fresh entry past its TTL either. Seven new tests; four fail against the pre-fix code. make check green: 573 tests, ruff + basedpyright clean. Live output unchanged. --- src/flight_cli/pp/client.py | 9 ++- src/flight_cli/pp/match.py | 98 +++++++++++++++++----------- tests/pp/test_client.py | 31 +++++++++ tests/pp/test_match.py | 125 ++++++++++++++++++++++++++++++++++++ 4 files changed, 224 insertions(+), 39 deletions(-) diff --git a/src/flight_cli/pp/client.py b/src/flight_cli/pp/client.py index 13a6aac..184c915 100644 --- a/src/flight_cli/pp/client.py +++ b/src/flight_cli/pp/client.py @@ -422,9 +422,14 @@ def remember_unsupported_airline(airline: str) -> None: exactly one redundant request next run. """ current = _load_unsupported_raw() - if airline in current: + now = time.time() + # Re-stamp an EXPIRED entry. Returning early on mere presence meant a + # lapsed airline was re-queried, rejected, and then still looked stale on + # the next run — so it was re-queried forever, exactly what this cache + # exists to avoid. + if current.get(airline, 0.0) > now - UNSUPPORTED_TTL_SECS: return - current[airline] = time.time() + current[airline] = now try: UNSUPPORTED_CACHE.parent.mkdir(parents=True, exist_ok=True) # Write-then-rename: a crash mid-write leaves the old file intact diff --git a/src/flight_cli/pp/match.py b/src/flight_cli/pp/match.py index dd65ede..dd47970 100644 --- a/src/flight_cli/pp/match.py +++ b/src/flight_cli/pp/match.py @@ -33,6 +33,8 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: + from collections.abc import Sequence + from ..models import Itinerary, SearchResult, Slice from ..providers.base import AwardFlight @@ -207,10 +209,27 @@ def _pick_metal( carrier rule ever sees it. That renders another aircraft's price on the row, which is the exact defect this function exists to prevent. """ - exact = [af for af in candidates if same_carrier(cash_fn, af.flight_number)] + # Stage 1 requires the FULL flight number, not just the carrier prefix. + # `same_carrier` only compares the IATA prefix, so treating that as "this + # IS the flight" let AA999 claim exact-match priority over cash AA867 and + # skip straight to `_narrow`, which admits a missing arrival when nothing + # matched exactly. A different number on the same carrier is a different + # flight; it belongs in the partner stage where ambiguity is judged. + exact = [af for af in candidates if _norm_fn(af.flight_number) == _norm_fn(cash_fn)] if exact: return _narrow(exact, cash_arrival) partners = [af for af in candidates if same_metal(cash_fn, af.flight_number)] + # A DIFFERENT number on the SAME carrier is simply a different flight — + # an airline does not sell one departure under two of its own numbers. + # Only a genuine codeshare (different carrier) explains a number mismatch, + # so a same-carrier candidate that reached this stage must prove itself + # with a matching arrival; absence of evidence is not enough for it. + partners = [ + af + for af in partners + if not same_carrier(cash_fn, af.flight_number) + or (cash_arrival and _iso_minute(af.arrival) == cash_arrival) + ] # Arrival is used in two distinct ways here, and conflating them is a bug. # Eliminating a candidate whose arrival CONTRADICTS the cash flight is # sound — that is positive evidence of different metal. Letting arrival @@ -475,47 +494,52 @@ def join( cash_arr = _iso_minute(s.arrival) if s else "" cash_flights = list(s.flights or []) if s else [] - def resolve( - bucket: list[AwardFlight], - _fn: str = cash_fn, - _arr: str = cash_arr, - _fl: list[str] = cash_flights, - ) -> list[AwardFlight]: - return _pick_metal(_fn, _arr, _by_segments(bucket, _fl)) - - candidates: list[AwardFlight] = [] + # Gather the raw candidates from every applicable key FIRST, then run + # one resolution pass over the union. + # + # Resolving each bucket separately and unioning the winners is wrong: + # the resolver's job is to decide among competing claims, so a bucket + # that only sees part of the field decides on partial information. A + # concrete failure — cash AA6939 with a BA174 award (route+time) and an + # AS99 award (matched-ID): route+time sees two different partner + # carriers, correctly calls it ambiguous and yields nothing, but the + # matched-ID bucket sees AS99 alone, calls it an unambiguous single + # partner, and admits it. The union then renders 7.5k Alaska on an + # American row — precisely the rejection one bucket had just made. + # + # The keys are *discovery* mechanisms; resolution is a single judgment + # over everything they found. + # `mid`: PP mints this ID from a hint we supplied and its own matcher + # is documented as loose, so an echoed ID is a claim, not proof. + # `fn`: (flight#, date, route) keys on segment 0 only, so connecting + # journeys sharing a first flight collapse together. + # `rt`: route+departure-minute; two carriers can share both. + # None of the three is an identity on its own. + hits: list[Sequence[AwardFlight]] = [] mid_k = cash_matched_id_key(it, slice_index=slice_index) if mid_k: - # PP mints this ID from a hint we supplied, and its own matcher is - # documented as deliberately loose, so an echoed ID is a claim and - # not proof — several awards can share one ID. Resolve the bucket - # exactly as the route+time bucket is resolved: filtering it with - # `same_metal` alone still admits a partner beside the true - # same-carrier award, and the renderer's lowest-miles pick then - # shows the partner's price. - candidates.extend(resolve(list(mid_idx.get(mid_k, ())))) + hits.append(mid_idx.get(mid_k, ())) fn_k = cash_match_key(it, slice_index=slice_index) if fn_k: - # (flight#, date, route) is NOT one journey. The key uses the first - # segment only, so every connecting itinerary that starts on this - # flight collapses together: the seats.aero fixture has three - # distinct AA1444 JFK→LHR journeys on one key, arriving 06:55, - # 09:05 and 12:50. Unresolved, the renderer's lowest-miles pick - # quotes the 12:50 journey's fare on the 06:55 row. - candidates.extend(resolve(list(fn_idx.get(fn_k, ())))) + hits.append(fn_idx.get(fn_k, ())) rt_k = cash_route_time_key(it, slice_index=slice_index) if rt_k: - candidates.extend(resolve(list(rt_idx.get(rt_k, ())))) - - matched: list[AwardFlight] = [] - seen_ids: set[int] = set() - for af in candidates: - if id(af) in seen_ids: - continue - if af.num_connections != cash_stops: - continue - seen_ids.add(id(af)) - matched.append(af) - + hits.append(rt_idx.get(rt_k, ())) + + raw: list[AwardFlight] = [] + seen_raw: set[int] = set() + for hit in hits: + for af in hit: + if id(af) not in seen_raw: + seen_raw.add(id(af)) + raw.append(af) + + # Connection count is an objective property of the journey, so drop + # mismatches BEFORE resolution — otherwise an ineligible candidate can + # make the field look ambiguous and suppress a valid codeshare that + # would have won on its own. + raw = [af for af in raw if af.num_connections == cash_stops] + + matched = _pick_metal(cash_fn, cash_arr, _by_segments(raw, cash_flights)) out.append(MatchedFare(itinerary=it, awards=matched)) return out diff --git a/tests/pp/test_client.py b/tests/pp/test_client.py index 2bca5be..e90722d 100644 --- a/tests/pp/test_client.py +++ b/tests/pp/test_client.py @@ -198,3 +198,34 @@ def _boom(*_a: Any, **_kw: Any) -> None: monkeypatch.setattr("flight_cli.pp.client.Path.mkdir", _boom) remember_unsupported_airline("ANA") # must not raise assert load_unsupported_airlines() == frozenset() + + +def test_expired_entry_is_restamped_on_a_fresh_rejection( + tmp_path: pathlib.Path, monkeypatch: Any +) -> None: + """An entry past its TTL is re-queried; when the server rejects it again + the note must be refreshed. Returning early on mere presence left it + permanently stale, so the airline was re-queried on every run.""" + cache = tmp_path / "unsupported.json" + monkeypatch.setattr("flight_cli.pp.client.UNSUPPORTED_CACHE", cache) + monkeypatch.setattr("flight_cli.pp.client.UNSUPPORTED_TTL_SECS", 100) + stale = time.time() - 500 + cache.write_text(json.dumps({"ANA": stale})) + assert load_unsupported_airlines() == frozenset() # expired + remember_unsupported_airline("ANA") + assert load_unsupported_airlines() == frozenset({"ANA"}) # refreshed + written: Any = json.loads(cache.read_text()) + assert written["ANA"] > stale + + +def test_fresh_entry_is_not_restamped(tmp_path: pathlib.Path, monkeypatch: Any) -> None: + """A still-fresh entry keeps its original timestamp, so repeated + rejections can't extend it indefinitely past the TTL.""" + cache = tmp_path / "unsupported.json" + monkeypatch.setattr("flight_cli.pp.client.UNSUPPORTED_CACHE", cache) + monkeypatch.setattr("flight_cli.pp.client.UNSUPPORTED_TTL_SECS", 100) + original = time.time() - 10 + cache.write_text(json.dumps({"ANA": original})) + remember_unsupported_airline("ANA") + written: Any = json.loads(cache.read_text()) + assert written["ANA"] == original diff --git a/tests/pp/test_match.py b/tests/pp/test_match.py index 8e32bba..54a5c93 100644 --- a/tests/pp/test_match.py +++ b/tests/pp/test_match.py @@ -1485,3 +1485,128 @@ def test_arrival_never_picks_between_carriers(): ] matches = join(res, awards) assert matches[0].awards == [] + + +# ───────── codex correctness pass: single resolution over the union ───────── + + +def test_one_bucket_cannot_readmit_what_another_rejected(): + """Codex P0, and the architectural one. + + Resolving each key's bucket separately and unioning the winners lets a + bucket that sees only part of the field decide on partial information. + Cash AA6939 with a BA174 award (found by route+time) and an AS99 award + (found by matched-ID): route+time sees two different partner carriers and + correctly abstains, but the matched-ID bucket sees AS99 alone, calls it an + unambiguous single partner, and admits it — rendering 7.5k Alaska on an + American row. Keys are discovery; resolution is one judgment over + everything they found. + """ + it = _itin_with_id("AA6939", "2026-08-15T18:40:00", "JFK", "LHR", flight_id="F1") + awards = [ + _award( + "BA174", + "2026-08-15T18:40:00", + program="American", + miles=60000, + origin="JFK", + dest="LHR", + ), + _award( + "AS99", + "2026-08-15T18:40:00", + program="Alaska", + miles=7500, + origin="JFK", + dest="LHR", + matched_id="F1", + ), + ] + matches = join(SearchResult(solutions=[it]), awards) + assert matches[0].awards == [] + + +def test_same_carrier_different_number_needs_arrival_proof(): + """Codex P0. An airline does not sell one departure under two of its own + numbers, so AA999 is not cash AA867 — only a genuine codeshare (different + carrier) explains a number mismatch. Without a matching arrival it must + fail closed, not ride in on the missing-arrival allowance.""" + res = SearchResult( + solutions=[ + _itin(("AA867", "2026-09-09T06:00:00", "MSY", "MIA", "2026-09-09T09:04:00")), + ] + ) + awards = [ + _award( + "AA999", + "2026-09-09T06:00:00", + program="American", + miles=9000, + origin="MSY", + dest="MIA", + arrival="", + ), + ] + matches = join(res, awards) + assert matches[0].awards == [] + + +def test_same_carrier_different_number_accepted_with_matching_arrival(): + """The other side: a renumbered award for the same departure is accepted + once its arrival positively confirms the flight.""" + res = SearchResult( + solutions=[ + _itin(("AA867", "2026-09-09T06:00:00", "MSY", "MIA", "2026-09-09T09:04:00")), + ] + ) + awards = [ + _award( + "AA999", + "2026-09-09T06:00:00", + program="American", + miles=9000, + origin="MSY", + dest="MIA", + arrival="2026-09-09T09:04:00", + ), + ] + matches = join(res, awards) + assert [a.flight_number for a in matches[0].awards] == ["AA999"] + + +def test_wrong_stop_count_candidate_cannot_suppress_a_valid_codeshare(): + """Codex P2. Connection count is an objective property, so mismatches are + dropped BEFORE resolution. Filtering them afterwards let an ineligible + candidate make the field look ambiguous and suppress a codeshare that + would have won on its own.""" + res = SearchResult( + solutions=[ + _itin(("AA6939", "2026-08-15T18:40:00", "JFK", "LHR", "2026-08-16T06:30:00")), + ] + ) + awards = [ + # Valid nonstop codeshare — should win. + _award( + "BA174", + "2026-08-15T18:40:00", + program="American", + miles=60000, + origin="JFK", + dest="LHR", + arrival="2026-08-16T06:30:00", + ), + # Ineligible: 1 stop against a nonstop cash slice. A second carrier, + # so pre-filtering is what stops it looking like carrier ambiguity. + _award( + "AS99", + "2026-08-15T18:40:00", + program="Alaska", + miles=7500, + origin="JFK", + dest="LHR", + arrival="2026-08-16T06:30:00", + num_connections=1, + ), + ] + matches = join(res, awards) + assert [a.flight_number for a in matches[0].awards] == ["BA174"] From a38f608ecd9b4517851851d2aa84906d3c5cf488 Mon Sep 17 00:00:00 2001 From: ak2k <19240940+ak2k@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:38:29 -0400 Subject: [PATCH 11/26] flight-cli: use connection airports as the cross-provider journey identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the PointsPath segment-identity gap flagged at the end of the last review, using data PP was already sending and we were discarding. PP's /api/airline-search returns `stops` (connection airports in order) and `travelDurationMinutes`; neither was in our pydantic model, so `extra="ignore"` dropped them silently. Matrix populates the directly comparable `Slice.stops`, and seats.aero has `Connections` — so connection airport works across ALL providers, where segment flight numbers only ever worked for seats.aero. The gap was real. Live MSY->LHR returns four distinct AA1650 journeys sharing a 12:22 departure and one connection, separable only by hub and arrival. With PP supplying no segment list, a cheaper wrong-hub award attached and the renderer's lowest-miles pick printed it — reproduced at 9k/ORD displacing 60k/DFW, now rejected. `_by_segments` becomes `_by_journey_shape`, applying two independent checks — connection airports and segment flight numbers — each skipped when its evidence is absent, so a provider supplying neither is judged exactly as before and no previously-working match is lost. Also added, for the other flagged item: every match admitted by the hand-curated partner/regional tables now logs `award_match_via_partner_table` at debug. Those tables are the weakest evidence here and by construction only decide when nothing stronger applied, which makes a stale entry fail SILENTLY. Measured on live traffic: 0 of 471 awards lacked an arrival, so this path effectively never fires today — the log turns that assumption into evidence before anyone considers deleting the tables. Four new tests; three fail against the pre-fix code. make check green: 577 tests, ruff + basedpyright clean. Live output unchanged. --- src/flight_cli/pp/match.py | 88 ++++++++--- src/flight_cli/pp/models.py | 6 + src/flight_cli/providers/base.py | 10 ++ .../providers/pointspath/provider.py | 1 + .../providers/seats_aero/provider.py | 1 + tests/pp/test_match.py | 138 ++++++++++++++++++ 6 files changed, 224 insertions(+), 20 deletions(-) diff --git a/src/flight_cli/pp/match.py b/src/flight_cli/pp/match.py index dd47970..43d10ab 100644 --- a/src/flight_cli/pp/match.py +++ b/src/flight_cli/pp/match.py @@ -32,12 +32,19 @@ from dataclasses import dataclass, field from typing import TYPE_CHECKING +import structlog + if TYPE_CHECKING: from collections.abc import Sequence from ..models import Itinerary, SearchResult, Slice from ..providers.base import AwardFlight +if TYPE_CHECKING: + from structlog.stdlib import BoundLogger + +log: BoundLogger = structlog.get_logger(__name__) # pyright: ignore[reportAny] + # (FLIGHT_NUMBER_UPPER_NOSPACE, "YYYY-MM-DD", ORIGIN_UPPER, DEST_UPPER) MatchKey = tuple[str, str, str, str] # (ORIGIN_UPPER, DEST_UPPER, "YYYY-MM-DDTHH:MM") — carrier is checked @@ -219,6 +226,21 @@ def _pick_metal( if exact: return _narrow(exact, cash_arrival) partners = [af for af in candidates if same_metal(cash_fn, af.flight_number)] + # The hand-curated tables are the weakest evidence in this module, and by + # construction they only decide a match when nothing stronger applied. + # That makes a stale entry fail SILENTLY — the wrong price just appears. + # Log every table-arbitrated match so the failure mode is greppable, and + # so we can tell from real traffic whether these tables still earn their + # keep (measured 2026-07: 0 of 471 live awards lacked an arrival, i.e. + # this path effectively never fires today). + for af in partners: + if not same_carrier(cash_fn, af.flight_number): + log.debug( + "award_match_via_partner_table", + cash_flight=cash_fn, + award_flight=af.flight_number, + program=af.program, + ) # A DIFFERENT number on the SAME carrier is simply a different flight — # an airline does not sell one departure under two of its own numbers. # Only a genuine codeshare (different carrier) explains a number mismatch, @@ -245,28 +267,49 @@ def _pick_metal( return _narrow(partners, cash_arrival) -def _by_segments(candidates: list[AwardFlight], cash_flights: list[str]) -> list[AwardFlight]: - """Drop candidates whose segment list contradicts the cash slice's. +def _by_journey_shape( + candidates: list[AwardFlight], + cash_flights: list[str], + cash_stop_airports: list[str], +) -> list[AwardFlight]: + """Drop candidates whose journey shape contradicts the cash slice's. Every key in this module identifies a journey by its FIRST segment, so two - journeys that share a first flight and diverge afterwards collapse together: - seats.aero returns both "AA1444, BA216" and "AA1444, AA100" on one - JFK→LHR date, and the renderer's lowest-miles pick then prints the cheaper - journey's fare on the other's row. - - Only providers that supply the full list are judged — PointsPath sends just - the first flight number, and an empty list means "no evidence", not - disagreement. Applied only when the cash slice itself is multi-segment: on - a nonstop there is nothing beyond segment 0 to contradict. + journeys that share a first flight and diverge afterwards collapse + together. Both are real in live data: seats.aero returns "AA1444, BA216" + and "AA1444, AA100" on one JFK→LHR date, and PointsPath returns four + distinct AA1650 MSY→LHR journeys sharing a 12:22 departure and one stop, + separable only by where they connect and when they land. + + Two independent signals, because no single one is available everywhere: + + * **Connection airports** — Matrix fills `Slice.stops` and BOTH award + providers supply the equivalent, so this is the cross-provider check. + Only compared when the cash side is a connection and the candidate says + something; empty on the award side is "no evidence", not agreement. + * **Segment flight numbers** — stronger (it pins each leg, not just the + hub) but seats.aero-only; PointsPath sends the first number alone. + + Each is skipped when its evidence is absent, so a provider that supplies + neither is judged by the carrier and arrival rules as before — no match is + lost that used to succeed. """ - if len(cash_flights) < _MIN_MULTI_SEGMENT: - return candidates - want = [_norm_fn(f) for f in cash_flights] - return [ - af - for af in candidates - if not af.segment_flight_numbers or [_norm_fn(f) for f in af.segment_flight_numbers] == want - ] + if len(cash_flights) < _MIN_MULTI_SEGMENT and not cash_stop_airports: + return candidates # nonstop: nothing past segment 0 to contradict + want_stops = [c.upper() for c in cash_stop_airports] + want_flights = [_norm_fn(f) for f in cash_flights] + out: list[AwardFlight] = [] + for af in candidates: + if want_stops and af.stop_airports and [c.upper() for c in af.stop_airports] != want_stops: + continue + if ( + len(want_flights) >= _MIN_MULTI_SEGMENT + and af.segment_flight_numbers + and [_norm_fn(f) for f in af.segment_flight_numbers] != want_flights + ): + continue + out.append(af) + return out def _drop_contradicted(candidates: list[AwardFlight], cash_arrival: str) -> list[AwardFlight]: @@ -493,6 +536,7 @@ def join( cash_fn = cash_first_flight_number(it, slice_index=slice_index) cash_arr = _iso_minute(s.arrival) if s else "" cash_flights = list(s.flights or []) if s else [] + cash_stop_airports = [(e.code or "") for e in (s.stops or [])] if s else [] # Gather the raw candidates from every applicable key FIRST, then run # one resolution pass over the union. @@ -540,6 +584,10 @@ def join( # would have won on its own. raw = [af for af in raw if af.num_connections == cash_stops] - matched = _pick_metal(cash_fn, cash_arr, _by_segments(raw, cash_flights)) + matched = _pick_metal( + cash_fn, + cash_arr, + _by_journey_shape(raw, cash_flights, cash_stop_airports), + ) out.append(MatchedFare(itinerary=it, awards=matched)) return out diff --git a/src/flight_cli/pp/models.py b/src/flight_cli/pp/models.py index e038892..b2cdc41 100644 --- a/src/flight_cli/pp/models.py +++ b/src/flight_cli/pp/models.py @@ -62,12 +62,18 @@ class OutboundFlight(_Loose): firstFlightNumber: str googleAirlineName: str | None = None numConnections: int = 0 + # Connection airport codes in order, e.g. ["DFW"]. Empty for a nonstop. + # PP has always sent this; `extra="ignore"` was silently dropping it. It's + # the only journey-shape signal PP gives beyond the first flight number, + # and Matrix populates the comparable `Slice.stops`. + stops: list[str] = [] externalId: str | None = None matchedGoogleFlightId: str | None = None matchedGoogleFlightCashPriceUsd: float | None = None perCabinMilesPricing: list[PerCabinMilesPricing] = [] _none_pricing = field_validator("perCabinMilesPricing", mode="before")(_none_to_empty_list) + _none_stops = field_validator("stops", mode="before")(_none_to_empty_list) class AirlineSearchResponse(_Loose): diff --git a/src/flight_cli/providers/base.py b/src/flight_cli/providers/base.py index 994e6d3..7d5b9c1 100644 --- a/src/flight_cli/providers/base.py +++ b/src/flight_cli/providers/base.py @@ -76,6 +76,16 @@ class AwardFlight: # row. Empty when the provider can't say, which the matcher treats as # "no segment evidence" rather than agreement. segment_flight_numbers: list[str] = field(default_factory=list[str]) + # Connection airport codes in order (["DFW"]); empty for a nonstop OR when + # the provider doesn't say. Distinguishing those two states is the caller's + # job — see `_by_journey_shape`, which pairs this with `num_connections`. + # + # This is the ONLY journey-shape signal PointsPath gives beyond the first + # flight number, and Matrix populates the directly comparable + # `Slice.stops`, so it works cross-provider where segment numbers (which + # only seats.aero sends) do not. Live MSY->LHR has four distinct AA1650 + # journeys sharing a departure minute and connection count. + stop_airports: list[str] = field(default_factory=list[str]) # provider/program metadata — used for rendering only provider: str = "" # display name, e.g. "PointsPath", "seats.aero" diff --git a/src/flight_cli/providers/pointspath/provider.py b/src/flight_cli/providers/pointspath/provider.py index fc7af6e..5f43771 100644 --- a/src/flight_cli/providers/pointspath/provider.py +++ b/src/flight_cli/providers/pointspath/provider.py @@ -74,6 +74,7 @@ def _flight_to_award( arrival=of.localArrivalDateTime, flight_number=of.firstFlightNumber, num_connections=of.numConnections, + stop_airports=[c.upper() for c in of.stops], provider="PointsPath", program=program, miles_to_cash_ratio=miles_to_cash_ratio, diff --git a/src/flight_cli/providers/seats_aero/provider.py b/src/flight_cli/providers/seats_aero/provider.py index ec78ec0..21f3e5d 100644 --- a/src/flight_cli/providers/seats_aero/provider.py +++ b/src/flight_cli/providers/seats_aero/provider.py @@ -155,6 +155,7 @@ def _group_trips_to_awards( arrival=_local_naive(t.ArrivesAt), flight_number=_first_flight_number(t.FlightNumbers), segment_flight_numbers=_segment_flight_numbers(t.FlightNumbers), + stop_airports=[c.upper() for c in t.Connections], num_connections=t.Stops, provider="Seats.aero", program=_program_label(t.Source), diff --git a/tests/pp/test_match.py b/tests/pp/test_match.py index 54a5c93..534e2a8 100644 --- a/tests/pp/test_match.py +++ b/tests/pp/test_match.py @@ -1610,3 +1610,141 @@ def test_wrong_stop_count_candidate_cannot_suppress_a_valid_codeshare(): ] matches = join(res, awards) assert [a.flight_number for a in matches[0].awards] == ["BA174"] + + +# ───────── journey shape: connection airports (the cross-provider signal) ───────── + + +def _award_stops( + fn: str, + dep: str, + arr: str, + miles: int, + stops: list[str], + *, + conns: int = 1, + segs: list[str] | None = None, +) -> AwardFlight: + return AwardFlight( + origin="MSY", + destination="LHR", + departure=dep, + arrival=arr, + flight_number=fn, + num_connections=conns, + provider="PointsPath", + program="American", + miles_to_cash_ratio=0.0125, + funding_banks=["Chase"], + stop_airports=stops, + segment_flight_numbers=segs or [], + cabins=[CabinAward(cabin="Economy", miles=miles, tax_usd=5.6, tax_currency="USD")], + ) + + +def _cash_conn(flights: list[str], dep: str, stops: list[str], arr: str) -> Itinerary: + return Itinerary( + displayTotal="USD900.00", + itinerary=ItineraryDetails( + slices=[ + Slice( + flights=flights, + departure=dep, + arrival=arr, + origin=SliceEndpoint(code="MSY"), + destination=SliceEndpoint(code="LHR"), + stops=[SliceEndpoint(code=c) for c in stops], + ), + ], + carriers=[], + ), + ) + + +def test_connection_airport_separates_pointspath_journeys(): + """PointsPath sends no segment list, so segment numbers can't separate its + journeys — but it does send `stops`, and Matrix fills the comparable + `Slice.stops`. Live MSY->LHR has four AA1650 journeys sharing a 12:22 + departure and one connection, differing only in hub and arrival. + """ + res = SearchResult( + solutions=[ + _cash_conn(["AA1650", "AA100"], "2026-09-09T12:22:00", ["DFW"], "2026-09-10T06:20:00") + ] + ) + awards = [ + _award_stops("AA1650", "2026-09-09T12:22:00", "2026-09-10T06:20:00", 60000, ["DFW"]), + # Cheaper, so it would win the renderer's pick — but it routes via ORD. + _award_stops("AA1650", "2026-09-09T12:22:00", "2026-09-10T06:20:00", 9000, ["ORD"]), + ] + matches = join(res, awards) + assert [a.cabins[0].miles for a in matches[0].awards] == [60000] + + +def test_missing_stop_airports_is_not_disagreement(): + """A provider that omits connection airports must still match — absence of + evidence is not contradiction.""" + res = SearchResult( + solutions=[ + _cash_conn(["AA1650", "AA100"], "2026-09-09T12:22:00", ["DFW"], "2026-09-10T06:20:00") + ] + ) + awards = [ + _award_stops("AA1650", "2026-09-09T12:22:00", "2026-09-10T06:20:00", 60000, []), + ] + matches = join(res, awards) + assert len(matches[0].awards) == 1 + + +def test_both_shape_signals_apply_together(): + """Connection airports and segment numbers are independent checks; either + one contradicting is enough to drop a candidate.""" + res = SearchResult( + solutions=[ + _cash_conn(["AA1650", "AA100"], "2026-09-09T12:22:00", ["DFW"], "2026-09-10T06:20:00") + ] + ) + awards = [ + # Right hub, wrong second segment. + _award_stops( + "AA1650", + "2026-09-09T12:22:00", + "2026-09-10T06:20:00", + 9000, + ["DFW"], + segs=["AA1650", "AA999"], + ), + _award_stops( + "AA1650", + "2026-09-09T12:22:00", + "2026-09-10T06:20:00", + 60000, + ["DFW"], + segs=["AA1650", "AA100"], + ), + ] + matches = join(res, awards) + assert [a.cabins[0].miles for a in matches[0].awards] == [60000] + + +def test_nonstop_cash_row_is_unaffected_by_shape_checks(): + """A nonstop has nothing past segment 0 to contradict, so the shape checks + must not filter anything.""" + res = SearchResult( + solutions=[ + _itin(("AA867", "2026-09-09T06:00:00", "MSY", "MIA", "2026-09-09T09:04:00")), + ] + ) + awards = [ + _award( + "AA867", + "2026-09-09T06:00:00", + program="American", + miles=25000, + origin="MSY", + dest="MIA", + arrival="2026-09-09T09:04:00", + ), + ] + matches = join(res, awards) + assert len(matches[0].awards) == 1 From a9ad051033087917b749e28e656dbe949e1f71a4 Mon Sep 17 00:00:00 2001 From: ak2k <19240940+ak2k@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:52:11 -0400 Subject: [PATCH 12/26] flight-cli: one price-less Google row no longer discards the whole response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by a codex adversarial pass over `_gflight_ids.py` / the gflight adapter. Pre-existing, unrelated to the award matcher. fli types `FlightResult.price` as `NonNegativeFloat | None` — its own comment says "None when not surfaced", which Google does on some premium round-trip rows carrying an empty price head. `_price_string` formatted it unconditionally and raised TypeError: unsupported format string passed to NoneType.__format__ while building the SearchResult — so a single price-less row took down the entire search, discarding every other itinerary in the response rather than just its own. Now returns "" for an unsurfaced price and the cheapest-price notice skips those rows. The itinerary, its flights and its award overlay all remain useful with the cash price shown as unavailable. Two tests, both failing against the pre-fix code. Note on the same review: the index parsing in `_gflight_ids.py` was the actual target and came back CLEAN — the reviewer independently confirmed itinerary index 17 and leg indices 12-17/22 against the installed Legrooms+ extension. The remaining findings there (fli 0.9 makes NID cookie seed/persist a silent no-op; an unknown airline/airport code turns valid rows into a false "no results") are pre-existing robustness gaps, filed rather than fixed here. make check green: 579 tests, ruff + basedpyright clean. --- src/flight_cli/pp/gflight_adapter.py | 18 +++++++++++++--- tests/pp/test_gflight_adapter.py | 32 ++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/src/flight_cli/pp/gflight_adapter.py b/src/flight_cli/pp/gflight_adapter.py index 2c52baa..e383173 100644 --- a/src/flight_cli/pp/gflight_adapter.py +++ b/src/flight_cli/pp/gflight_adapter.py @@ -97,7 +97,19 @@ def _slice_from_flight_result( def _price_string(fr: Any) -> str: - """Match Matrix's price format ('USD877.00') so match._parse_cash works.""" + """Match Matrix's price format ('USD877.00') so match._parse_cash works. + + fli types `FlightResult.price` as `NonNegativeFloat | None` — "None when + not surfaced", which Google does for some premium round-trip rows that + carry an empty price head. Formatting that unconditionally raised + `TypeError: unsupported format string passed to NoneType.__format__` and + took down the whole search, discarding every other itinerary in the + response. Returning '' instead keeps the row: the itinerary, its flights + and its award overlay are all still useful with the cash price shown as + unavailable. + """ + if fr.price is None: + return "" currency = fr.currency or "USD" return f"{currency}{fr.price:.2f}" @@ -142,8 +154,8 @@ def fli_results_to_search_result(results: Sequence[Any]) -> SearchResult: itinerary=ItineraryDetails(slices=slices, carriers=[]), ), ) - p: float = first_fr.price - if cheapest_price is None or p < cheapest_price: + p: float | None = first_fr.price + if p is not None and (cheapest_price is None or p < cheapest_price): cheapest_price = p cheapest_currency = first_fr.currency or "USD" diff --git a/tests/pp/test_gflight_adapter.py b/tests/pp/test_gflight_adapter.py index 7839a10..9367d83 100644 --- a/tests/pp/test_gflight_adapter.py +++ b/tests/pp/test_gflight_adapter.py @@ -278,3 +278,35 @@ def test_cash_hints_skip_slices_without_flight_id() -> None: ) hints = cash_hints_from_search_result(sr) assert hints == [] + + +# ───────── a price-less row must not take down the whole search ───────── + + +def test_price_string_tolerates_unsurfaced_price() -> None: + """fli types `FlightResult.price` as `NonNegativeFloat | None` — "None when + not surfaced", which Google does on some premium round-trip rows.""" + from flight_cli.pp.gflight_adapter import _price_string + + assert _price_string(_result(None, currency="USD")) == "" # pyright: ignore[reportArgumentType] + assert _price_string(_result(877.0)) == "USD877.00" + + +def test_one_priceless_row_does_not_discard_the_whole_response() -> None: + """The crash was `TypeError: unsupported format string passed to + NoneType.__format__`, raised while building the SearchResult — so ONE + price-less row destroyed every other itinerary in the response, not just + its own.""" + dep = datetime(2026, 8, 15, 18, 0) + arr = datetime(2026, 8, 16, 6, 0) + priceless = _result(None, _leg("100", "JFK", "LHR", dep, arr)) # pyright: ignore[reportArgumentType] + priced = _result(877.0, _leg("200", "JFK", "LHR", dep, arr)) + + sr = fli_results_to_search_result([priceless, priced]) # pyright: ignore[reportArgumentType] + + assert sr.solution_count == 2 + prices = [(s.ext.price if s.ext else None) for s in sr.solutions] + assert prices == ["", "USD877.00"] + # The cheapest-price notice ignores the unpriced row rather than crashing. + assert sr.currency_notice.ext is not None + assert sr.currency_notice.ext.price == "USD877.00" From 49b7262834324e12d09c69ff5269ded2b8ed1565 Mon Sep 17 00:00:00 2001 From: ak2k <19240940+ak2k@users.noreply.github.com> Date: Thu, 30 Jul 2026 12:56:02 -0400 Subject: [PATCH 13/26] flight-cli: repair NID cookie seeding, silently broken by the fli 0.9 rename MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second finding from the codex pass over `_gflight_ids.py`. Pre-existing. fli <=0.8 exposed `Client._client`; 0.9 replaced it with a per-thread `Client._session()`. Both `_seed_cookies` and `_persist_cookies` still reached for the old attribute, so both raised AttributeError into their best-effort `except ... log.debug` and became NO-OPS. Verified against the installed client: it has no `_client`, and seeding set its once-per-process latch while installing nothing. That silently un-did a documented mitigation. This module's own comments put the cold-session empty rate at ~40% without a seeded NID versus ~0% with one — those empties are the "cold-session retries" the search then burns requests on. `_cookie_jar` now resolves either shape and RAISES when neither is present, so the next upstream rename fails loudly at the callers' debug log instead of degrading to a no-op indefinitely. Three tests, all failing against the pre-fix code. They run against the REAL installed fli client on purpose — a stub would have kept passing straight through the rename, which is precisely how this went unnoticed. make check green: 582 tests, ruff + basedpyright clean. --- src/flight_cli/_gflight_ids.py | 26 +++++++++++++++-- tests/test_gflight_cookie_jar.py | 49 ++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/src/flight_cli/_gflight_ids.py b/src/flight_cli/_gflight_ids.py index c6a5a39..3187cc1 100644 --- a/src/flight_cli/_gflight_ids.py +++ b/src/flight_cli/_gflight_ids.py @@ -434,8 +434,9 @@ def _seed_cookies_once(client: Any) -> None: log.debug("gflight cookie cache past TTL; re-warming") return try: + jar = _cookie_jar(client) for c in saved: - client._client.cookies.set( + jar.set( c["name"], c["value"], domain=c.get("domain", ".google.com"), @@ -445,6 +446,27 @@ def _seed_cookies_once(client: Any) -> None: log.debug("could not seed gflight cookies: %s", e) +def _cookie_jar(client: Any) -> Any: + """The session cookie jar, across fli client shapes. + + fli <=0.8 exposed `Client._client`; 0.9 replaced it with a per-thread + `Client._session()`. Both hand back an object with the same `.set()` / + `.jar` interface. Reaching for the old attribute silently raised + AttributeError into this module's best-effort `except`, which turned NID + seeding AND persistence into no-ops — so every process started cold, and + the comments above put the cold-start empty rate at ~40% versus ~0% warm. + + Raises AttributeError when neither shape is present, so a future upstream + rename fails loudly at the callers' `except` + debug log rather than + degrading silently forever. + """ + session = getattr(client, "_client", None) + if session is None: + # fli 0.9: per-thread session accessor replaced the old `_client` attr. + session = client._session() # pyright: ignore[reportAny] + return session.cookies # pyright: ignore[reportAny] + + def _persist_cookies(client: Any) -> None: """Write the session's allowlisted Google cookies (NID) to disk after a warm call, once per process, so the next invocation starts warm. Best-effort.""" @@ -458,7 +480,7 @@ def _persist_cookies(client: Any) -> None: "domain": str(ck.domain or ".google.com"), "path": str(ck.path or "/"), } - for ck in client._client.cookies.jar # pyright: ignore[reportAny] # fli/curl_cffi untyped + for ck in _cookie_jar(client).jar # pyright: ignore[reportAny] # fli/curl_cffi untyped if str(ck.name) in _PERSIST_COOKIE_NAMES and _GOOGLE_DOMAIN_SUFFIX in str(ck.domain or "") ] diff --git a/tests/test_gflight_cookie_jar.py b/tests/test_gflight_cookie_jar.py index d7c1193..a0f5c91 100644 --- a/tests/test_gflight_cookie_jar.py +++ b/tests/test_gflight_cookie_jar.py @@ -160,3 +160,52 @@ def test_persist_skips_when_no_allowlisted_cookie( warm = _FakeClient([_JarCookie("AEC", "x", ".google.com")]) # Google but not NID gfid._persist_cookies(warm) assert not (tmp_path / "gflight-cookies.json").exists() + + +# ───────── the jar must resolve against the REAL fli client ───────── + + +def test_cookie_jar_resolves_against_the_installed_fli_client() -> None: + """Regression: this module reached for `Client._client`, which fli 0.9 + replaced with a per-thread `Client._session()`. The AttributeError landed + in a best-effort `except` and was logged at debug, so NID seeding AND + persistence became silent no-ops — every process started cold, against a + documented ~40% cold-start empty rate versus ~0% warm. + + Deliberately exercised against the real installed client rather than a + stub: a stub would have kept passing through the upstream rename, which is + exactly how this went unnoticed. + """ + from fli.search.client import get_client # pyright: ignore[reportMissingTypeStubs] + + from flight_cli._gflight_ids import _cookie_jar + + jar = _cookie_jar(get_client()) # pyright: ignore[reportUnknownArgumentType] + assert hasattr(jar, "set") + assert hasattr(jar, "jar") + + +def test_cookie_jar_seeding_is_observable() -> None: + """A seeded cookie must actually be readable back — the property the + silent no-op destroyed.""" + from fli.search.client import get_client # pyright: ignore[reportMissingTypeStubs] + + from flight_cli._gflight_ids import _cookie_jar + + jar = _cookie_jar(get_client()) # pyright: ignore[reportUnknownArgumentType] + jar.set("NID", "sentinel-value", domain=".google.com", path="/") + names = [str(c.name) for c in jar.jar] + assert "NID" in names + + +def test_cookie_jar_raises_when_no_known_shape() -> None: + """Fails loudly on a future rename instead of degrading to a no-op again.""" + import pytest + + from flight_cli._gflight_ids import _cookie_jar + + class _Alien: + pass + + with pytest.raises(AttributeError): + _ = _cookie_jar(_Alien()) From f07645e7a718182c560b878c7e48d12cfc524c70 Mon Sep 17 00:00:00 2001 From: ak2k <19240940+ak2k@users.noreply.github.com> Date: Thu, 30 Jul 2026 12:58:31 -0400 Subject: [PATCH 14/26] flight-cli: renderer must state the truth about its own row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex adversarial pass over `pp/cli.py`, the renderer every matcher bug surfaced through. Eight findings, all pre-existing; the five with a concrete user-visible failure are fixed here. P1 — `--json` return leg described the OUTBOUND flight. `_serialize_matches` hardcoded `slices[0]` while the per-leg wrapper labelled the entry "return", so the return leg's awards were emitted beside the outbound flight number, route and departure. Now takes the leg's own `slice_index`. P1 — basic-economy awards rendered as unrestricted Economy. They carry different seat, bag and change rights, so showing one under the plain heading overstates what the miles buy — and being cheaper, it displaced real fares. Now excluded from the main pick; surfaced with an explicit "(basic)" label only when nothing unrestricted exists. P2 — equal-mile offers were ordered by provider, so a 30k + $500 award could beat an identical 30k + $6 one. Ranking is now (miles, tax): miles first, as the scarce currency, ties broken on cash out-of-pocket. P2 — the funding column unioned banks across every attached award, implying programs that fund hidden costlier offers also fund the displayed one (a 30k Amex winner beside a hidden 40k Chase offer rendered "Amex, Chase"). Now restricted to the awards actually shown. Five tests, all failing against the pre-fix code. Not fixed, filed: seats.aero "Premium" doesn't match the CLI's "Premium economy" column label (a canonical cabin enum is the real fix, wider than this change); non-USD taxes are printed as dollars and subtracted from USD cash; `_dedupe_per_leg` still keys on first-flight+date, so two connections sharing a first flight can collapse — same journey-identity weakness already fixed in the matcher, now visible one layer up. make check green: 587 tests, ruff + basedpyright clean. --- src/flight_cli/pp/cli.py | 83 ++++++++++++++++++++++++++----- tests/pp/test_cli.py | 104 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 176 insertions(+), 11 deletions(-) diff --git a/src/flight_cli/pp/cli.py b/src/flight_cli/pp/cli.py index 135c0a4..52ef784 100644 --- a/src/flight_cli/pp/cli.py +++ b/src/flight_cli/pp/cli.py @@ -455,14 +455,41 @@ def _fmt_iso_compact(s: str) -> str: def _best_award_for_cabin( award_flights: list[AwardFlight], want_cabin: str ) -> tuple[int, float, str, list[str]] | None: - """Cheapest (lowest miles) offer across providers for one cabin, or None.""" + """Cheapest offer across providers for one cabin, or None. + + Ranked on (miles, tax) — miles first, since that is the scarce currency, + but ties broken on cash out-of-pocket. Comparing miles alone let a + 30k + $500 offer beat an identical 30k + $6 one purely on provider order. + + Basic-economy fares are excluded here rather than silently rendered as + unrestricted Economy: they carry different seat, bag and change rights, so + presenting one under the plain "Economy" heading overstates what the + traveller gets. `_basic_economy_award_for_cabin` surfaces them explicitly. + """ + best: tuple[int, float, str, list[str]] | None = None + for af in award_flights: + for ca in af.cabins: + if ca.cabin != want_cabin or ca.is_basic_economy: + continue + key = (ca.miles, ca.tax_usd, af.program, af.funding_banks) + if best is None or (key[0], key[1]) < (best[0], best[1]): + best = key + return best + + +def _basic_economy_award_for_cabin( + award_flights: list[AwardFlight], want_cabin: str +) -> tuple[int, float, str, list[str]] | None: + """Cheapest BASIC-economy offer for one cabin — the fares + `_best_award_for_cabin` deliberately skips. Rendered with an explicit + label so the restriction is visible rather than implied.""" best: tuple[int, float, str, list[str]] | None = None for af in award_flights: for ca in af.cabins: - if ca.cabin != want_cabin: + if ca.cabin != want_cabin or not ca.is_basic_economy: continue key = (ca.miles, ca.tax_usd, af.program, af.funding_banks) - if best is None or key[0] < best[0]: + if best is None or (key[0], key[1]) < (best[0], best[1]): best = key return best @@ -479,10 +506,16 @@ def _fmt_award_cell( columns per cabin) overflows narrow terminals in multi-cabin renders. """ best = _best_award_for_cabin(award_flights, want_cabin) + label = "" + if best is None: + # Nothing unrestricted — show the basic-economy fare rather than an + # empty cell, but say so. + best = _basic_economy_award_for_cabin(award_flights, want_cabin) + label = " [dim](basic)[/]" if best is None: return "—" miles, tax, program, _banks = best - head = f"{_fmt_miles(miles)} {program} + ${tax:.0f}" + head = f"{_fmt_miles(miles)} {program} + ${tax:.0f}{label}" if cash_usd is None: return head cpm = _cents_per_mile(cash_usd, miles, tax) @@ -491,11 +524,31 @@ def _fmt_award_cell( return f"{head}\n[dim]{cpm:.1f}¢/mi[/]" -def _fmt_funding(award_flights: list[AwardFlight]) -> str: +def _fmt_funding(award_flights: list[AwardFlight], cabins: tuple[str, ...] = ()) -> str: + """Transfer partners that fund the award(s) actually DISPLAYED. + + Unioning banks across every attached award claimed that programs funding + hidden, more-expensive offers also funded the winning one: a 30k Amex + winner beside a hidden 40k Chase offer rendered "Amex, Chase", implying + Chase points could buy the 30k fare. Restricted to the offers the row + shows; `cabins` empty keeps the old union for callers with no cabin + context. + """ + winners: list[tuple[int, float, str, list[str]]] = [] + for cab in cabins: + for pick in ( + _best_award_for_cabin(award_flights, cab), + _basic_economy_award_for_cabin(award_flights, cab), + ): + if pick is not None: + winners.append(pick) + sources: list[list[str]] = ( + [w[3] for w in winners] if cabins else [af.funding_banks for af in award_flights] + ) banks: list[str] = [] seen: set[str] = set() - for af in award_flights: - for b in af.funding_banks: + for group in sources: + for b in group: if b not in seen: seen.add(b) banks.append(b) @@ -591,7 +644,7 @@ def _render_matches( # the award without a ¢/mi line. cells.append(_fmt_award_cell(m.awards, cab, per_cabin_cash.get(cab))) if show_funding: - cells.append(_fmt_funding(m.awards)) + cells.append(_fmt_funding(m.awards, tuple(cabin_list))) t.add_row(*cells) console.print(t) @@ -680,11 +733,19 @@ def _serialize_award(af: AwardFlight) -> dict[str, Any]: } -def _serialize_matches(matches: list[MatchedFare]) -> str: +def _serialize_matches(matches: list[MatchedFare], slice_index: int = 0) -> str: + """`slice_index` selects the leg to describe — it MUST match the leg whose + awards are being serialized. Hardcoding slice 0 made the `--json` return + leg report the OUTBOUND flight number, route and departure beside the + return leg's awards, while the wrapper labelled it "return".""" out: list[dict[str, Any]] = [] for m in matches: itn = m.itinerary.itinerary - s = itn.slices[0] if itn and itn.slices else None + s = ( + itn.slices[slice_index] + if itn and itn.slices and slice_index < len(itn.slices) + else None + ) out.append( { "flight": (s.flights[0] if s and s.flights else None), @@ -706,7 +767,7 @@ def _serialize_matches_per_leg( { "leg": leg.label, "slice_index": leg.slice_index, - "matches": json.loads(_serialize_matches(matches)), + "matches": json.loads(_serialize_matches(matches, leg.slice_index)), } for leg, matches in zip(legs, matches_per_leg, strict=True) ], diff --git a/tests/pp/test_cli.py b/tests/pp/test_cli.py index 7889ed1..93a6dad 100644 --- a/tests/pp/test_cli.py +++ b/tests/pp/test_cli.py @@ -3,18 +3,25 @@ from __future__ import annotations +import json +from typing import Any + import pytest +from flight_cli.models import Itinerary, ItineraryDetails, Slice, SliceEndpoint from flight_cli.pp.cli import ( _best_award_for_cabin, _fmt_award_cell, + _fmt_funding, _fmt_iso_compact, _fmt_stops, _normalize_cabin, _parse_cash, _parse_csv, _render_pp_only, + _serialize_matches, ) +from flight_cli.pp.match import MatchedFare from flight_cli.providers.base import AwardFlight, CabinAward # ───────────────────────────── _parse_cash ───────────────────────────────── @@ -229,3 +236,100 @@ def test_award_table_does_not_truncate_program_name(monkeypatch: pytest.MonkeyPa assert "American Airlines" in text # Compact, unambiguous departure time (not raw '2026-08-15T13:53'). assert "Aug15 13:53" in text + + +# ───────── renderer: a cell must be a true statement about its row ───────── + + +def _ra( + program: str, + miles: int, + tax: float = 6.0, + *, + cabin: str = "Economy", + basic: bool | None = None, + banks: list[str] | None = None, +) -> AwardFlight: + return AwardFlight( + origin="JFK", + destination="LHR", + departure="2026-08-15T18:00:00", + arrival="2026-08-16T06:00:00", + flight_number="AA100", + num_connections=0, + provider="PointsPath", + program=program, + miles_to_cash_ratio=0.0125, + funding_banks=banks or ["Chase"], + cabins=[ + CabinAward( + cabin=cabin, + miles=miles, + tax_usd=tax, + tax_currency="USD", + is_basic_economy=basic, + ), + ], + ) + + +def test_equal_miles_tie_breaks_on_cash_out_of_pocket() -> None: + """Ranking on miles alone let provider order decide between a 30k + $500 + offer and an identical 30k + $6 one.""" + cell = _fmt_award_cell([_ra("Pricey", 30000, 500.0), _ra("Cheap", 30000, 6.0)], "Economy") + assert "Cheap" in cell + assert "$6" in cell + + +def test_basic_economy_is_not_rendered_as_unrestricted_economy() -> None: + """A basic-economy award carries different seat, bag and change rights, so + showing it under the plain Economy heading overstates what is bought.""" + unrestricted = _fmt_award_cell([_ra("Real", 9500)], "Economy") + assert "(basic)" not in unrestricted + + basic_only = _fmt_award_cell([_ra("American", 9500, basic=True)], "Economy") + assert "(basic)" in basic_only + + +def test_unrestricted_award_wins_over_a_cheaper_basic_one() -> None: + """The cheaper basic fare must not silently displace a real one.""" + cell = _fmt_award_cell([_ra("Cheap", 5000, basic=True), _ra("Real", 9500)], "Economy") + assert "Real" in cell + assert "(basic)" not in cell + + +def test_funding_column_describes_only_the_displayed_award() -> None: + """Unioning banks across every attached award implied that programs + funding hidden, costlier offers also funded the winning one.""" + awards = [_ra("Amex", 30000, banks=["Amex"]), _ra("Chase", 40000, banks=["Chase"])] + assert _fmt_funding(awards, ("Economy",)) == "Amex" + + +def test_json_return_leg_describes_the_return_slice() -> None: + """`_serialize_matches` hardcoded slices[0], so the `--json` return leg + reported the OUTBOUND flight number and route beside the return leg's + awards, under a label saying "return".""" + it = Itinerary( + displayTotal="USD500.00", + itinerary=ItineraryDetails( + slices=[ + Slice( + flights=["AA100"], + departure="2026-08-15T18:00:00", + origin=SliceEndpoint(code="JFK"), + destination=SliceEndpoint(code="LHR"), + ), + Slice( + flights=["BA200"], + departure="2026-08-22T10:00:00", + origin=SliceEndpoint(code="LHR"), + destination=SliceEndpoint(code="JFK"), + ), + ], + carriers=[], + ), + ) + payload: Any = json.loads(_serialize_matches([MatchedFare(itinerary=it, awards=[])], 1)) + assert payload[0]["flight"] == "BA200" + assert payload[0]["origin"] == "LHR" + assert payload[0]["destination"] == "JFK" From f2ca073c4aabe04adfdd2271d02005badf660b36 Mon Sep 17 00:00:00 2001 From: ak2k <19240940+ak2k@users.noreply.github.com> Date: Thu, 30 Jul 2026 13:00:15 -0400 Subject: [PATCH 15/26] flight-cli: date a pinned segment by when it departs, not when it lands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex adversarial pass over `links.py` — the deep links the user clicks to go book. Seven findings; this is the one with a clean, contained fix. `extract_pin_segments_from_slice` dated the last segment of a slice by ARRIVAL whenever the slice spanned midnight. A nonstop trivially satisfies "last segment", so an overnight nonstop was pinned to its arrival date: BA178 JFK->LHR departing 2026-12-31 and landing 2027-01-01 decoded with segment date 2027-01-01 — the wrong day, and across a year boundary. The user follows the link and searches a date they never chose. A segment is dated by when it DEPARTS. That equals the arrival date only for the last leg of a genuine multi-segment slice that crosses midnight, which is now the precise condition. Verified by decoding the emitted URL: overnight nonstop 2026-12-31; overnight connection 2026-12-31 then 2027-01-01; same-day nonstop unchanged. Also removed three duplicated lines above the loop. Three tests; the overnight-nonstop one fails against the pre-fix code. Filed, NOT fixed — these need design decisions wider than this branch: - `--pick N` indexes `matrix_res[0]` while the table renders merged Matrix+Google rows, so a cheaper Google-only row shifts what N means and the link pins a different itinerary. Highest-severity of the set. - Google pinned links encode children and infants as adults (protobuf field 8 is [1,1] for two adults AND for one adult plus a child). - Two repeated --slice legs fold into a round trip, dropping the second route entirely; a three-leg itinerary silently loses leg 3 and is still labelled pinned. - Matrix links drop routing language, extension codes and arrival-date intent; a nonstop-only Google link is byte-identical to an unconstrained one. make check green: 590 tests, ruff + basedpyright clean. --- src/flight_cli/links.py | 14 ++++++--- tests/test_links_matrix_url.py | 56 +++++++++++++++++++++++++++++++++- 2 files changed, 65 insertions(+), 5 deletions(-) diff --git a/src/flight_cli/links.py b/src/flight_cli/links.py index e2ed5c4..49b7378 100644 --- a/src/flight_cli/links.py +++ b/src/flight_cli/links.py @@ -531,9 +531,6 @@ def extract_pin_segments_from_slice(s: Slice) -> list[dict[str, str]] | None: dep_date = s.departure[:10] arrival = s.arrival or s.departure arr_date = arrival[:10] - dep_date = s.departure[:10] - arrival = s.arrival or s.departure - arr_date = arrival[:10] out: list[dict[str, str]] = [] for i, fl in enumerate(s.flights): m = _FLIGHT_NUMBER_RE.match(fl) @@ -545,7 +542,16 @@ def extract_pin_segments_from_slice(s: Slice) -> list[dict[str, str]] | None: if has_exact_dates: seg_date = s.segment_dates[i] else: - seg_date = arr_date if (i == n - 1 and arr_date != dep_date) else dep_date + # A segment is dated by when it DEPARTS. The last segment of a + # multi-segment slice departs on the arrival date only when the + # slice spans midnight — and a NONSTOP is never that case, even + # though it satisfies `i == n - 1`: it departs on the departure + # date by definition. Treating an overnight nonstop as arrival- + # dated pinned BA178 JFK->LHR (dep 2026-12-31, arr 2027-01-01) to + # 2027-01-01, sending the user to a search for the wrong day. + spans_midnight = arr_date != dep_date + is_last_of_many = n > 1 and i == n - 1 + seg_date = arr_date if (is_last_of_many and spans_midnight) else dep_date out.append( { "origin": seg_origin, diff --git a/tests/test_links_matrix_url.py b/tests/test_links_matrix_url.py index c613ba8..a073dac 100644 --- a/tests/test_links_matrix_url.py +++ b/tests/test_links_matrix_url.py @@ -28,7 +28,8 @@ SearchOptions, SpecificDateSearch, ) -from flight_cli.links import matrix_deep_link, matrix_itinerary_url +from flight_cli.links import extract_pin_segments_from_slice, matrix_deep_link, matrix_itinerary_url +from flight_cli.models import Slice, SliceEndpoint FIXTURE_DIR = pathlib.Path(__file__).parent / "fixtures" / "matrix_url" @@ -174,3 +175,56 @@ def test_multi_city_keeps_one_slice_per_leg() -> None: assert dates["departureDate"] == iso # Multi-city slices keep returnDate empty — our benign superset. assert dates["returnDate"] == "" + + +# ───────── pinned segment dates: a segment is dated by when it DEPARTS ───────── + + +def _slice( + flights: list[str], dep: str, arr: str, o: str, d: str, stops: list[str] | None = None +) -> Slice: + return Slice( + flights=flights, + departure=dep, + arrival=arr, + origin=SliceEndpoint(code=o), + destination=SliceEndpoint(code=d), + stops=[SliceEndpoint(code=c) for c in (stops or [])], + ) + + +def test_overnight_nonstop_is_dated_by_departure() -> None: + """A nonstop satisfies `i == n - 1`, so the last-segment rule dated it by + ARRIVAL — pinning BA178 JFK->LHR (dep 2026-12-31, arr 2027-01-01) to + 2027-01-01 and sending the user to a search for the wrong day, across a + year boundary.""" + segs = extract_pin_segments_from_slice( + _slice(["BA178"], "2026-12-31T22:00-05:00", "2027-01-01T10:00+00:00", "JFK", "LHR"), + ) + assert segs is not None + assert [x["date"] for x in segs] == ["2026-12-31"] + + +def test_overnight_connection_still_dates_its_last_leg_by_arrival() -> None: + """The rule the nonstop case was over-applying is real for a genuine + connection: the second leg does depart on the following day.""" + segs = extract_pin_segments_from_slice( + _slice( + ["AA100", "BA200"], + "2026-12-31T18:00-05:00", + "2027-01-01T14:00+00:00", + "JFK", + "LHR", + ["BOS"], + ), + ) + assert segs is not None + assert [x["date"] for x in segs] == ["2026-12-31", "2027-01-01"] + + +def test_same_day_nonstop_unchanged() -> None: + segs = extract_pin_segments_from_slice( + _slice(["AA867"], "2026-09-09T06:00-05:00", "2026-09-09T09:04-04:00", "MSY", "MIA"), + ) + assert segs is not None + assert [x["date"] for x in segs] == ["2026-09-09"] From 8335cab7f0528d306e1f08e40943408bc81e92ee Mon Sep 17 00:00:00 2001 From: ak2k <19240940+ak2k@users.noreply.github.com> Date: Thu, 30 Jul 2026 13:52:30 -0400 Subject: [PATCH 16/26] flight-cli: --pick must index the rows the user actually saw MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The highest-severity finding from the codex pass over the link builders, and the one I had wrongly filed as "needs a design decision". It does not — the merged rows already carry their own itineraries, so this was a wiring gap. `_render_merged` numbers rows 1..N off the merged GF+Matrix list. `_emit_urls` indexed `matrix_res.solutions` — a different sequence. A Google-only row is cheaper, so it sorts first and shifts every Matrix row down one: `--pick 2` then emitted a link to the itinerary displayed at row 1. That link is the handoff to an actual purchase, so the failure is a user booking a flight they did not select, with nothing anywhere reporting an error. The pin source is now built from the rendered rows. `session` and `solution_set` are carried across, since Matrix pinned URLs need those server-generated identifiers and would otherwise degrade silently to a plain deep link. Awards-only runs render no merged table, so they keep indexing Matrix's own ordering — which is what the user saw in that mode. Extracted `_overlay_awards` to keep `_run_enriched_path` under the statement limit rather than suppressing the lint: the function sat exactly at 50 before this change, so the overflow is mine, not pre-existing. One test, pinning both the row order and the identifier passthrough. make check green: 591 tests, ruff + basedpyright clean. --- src/flight_cli/cli.py | 70 +++++++++++++++++++++++++-------- tests/test_links_gflight_pin.py | 55 ++++++++++++++++++++++++++ 2 files changed, 109 insertions(+), 16 deletions(-) diff --git a/src/flight_cli/cli.py b/src/flight_cli/cli.py index c6c600a..095d0cd 100644 --- a/src/flight_cli/cli.py +++ b/src/flight_cli/cli.py @@ -50,12 +50,13 @@ matrix_itinerary_url, ) from .log import configure as configure_logging +from .models import SearchResult from .pp.auth import load_tokens from .pp.cli import auth_app, run_pp_for_search from .providers.base import LegQuery if TYPE_CHECKING: - from .models import CalendarResult, LegInfo, Location, SearchResult, Slice + from .models import CalendarResult, LegInfo, Location, Slice # Tuple-length sentinels for `--slice` parser (`ORIGIN-DEST:DATE[:r=...:e=...]`). _SLICE_MIN_PARTS = 2 @@ -802,6 +803,51 @@ def _try_pinned_gflight_url(search: Search, result: SearchResult | None, idx: in return None +def _overlay_awards( + matrix_res: SearchResult, + *, + legs: tuple[Leg, ...], + opts: Any, + sel: Any, + awards_only: bool, +) -> None: + """Render the award overlay for an already-fetched Matrix result.""" + p = opts.pax + run_pp_for_search( + matrix_res, + legs=_build_pp_legs(legs), + num_passengers=p.adults + p.children + p.seniors + p.youth, + airlines=sel.pp_airlines() if sel is not None else None, + cabins=sel.pp_cabins() if sel is not None else None, + pp_only=awards_only, + json_out=False, + provider_filter=sel.provider_filter if sel is not None else None, + seats_sources=sel.seats_sources() if sel is not None else None, + cash_per_cabin=_cash_per_cabin_single(matrix_res, opts.cabin), + ) + + +def _pin_source_from_merged(rows: list[Any], top_n: int, src: SearchResult) -> SearchResult: + """The itineraries `--pick N` should index: exactly the rows rendered. + + `--pick N` names a row number the user just read off the merged GF+Matrix + table. Pinning from `matrix_res` instead indexes a DIFFERENT sequence — + a Google-only row is cheaper, so it sorts early and shifts every row after + it, and the emitted link then pins an itinerary the user never selected. + Since that link is the handoff to an actual booking, the two orderings + must be the same list. + """ + kept = rows[:top_n] + # `session` / `solution_set` are server-generated and required to build a + # Matrix pinned itinerary URL, so they must survive the re-key. + return SearchResult( # pyright: ignore[reportCallIssue] # DIVERGE: Field(alias=...) confuses basedpyright into requiring aliased names + solutionCount=len(kept), + solutions=[r.itinerary for r in kept], + session=src.session, + solutionSet=src.solution_set, + ) + + def _emit_urls( search: Search, *, @@ -1369,28 +1415,20 @@ async def _go() -> None: return matrix_res = cast("SearchResult", matrix_res) - # Repaint: reconciled GF + Matrix, prices attributed. + # Repaint: reconciled GF + Matrix, prices attributed. `--pick` indexes the + # rendered merged rows; with no merged table (awards-only) it falls back to + # Matrix's own ordering, which is then what the user saw. + pin_res = matrix_res if not awards_only: merged = merge_results(fli_results_to_search_result(gf), matrix_res) _render_merged(merged, legs=legs, top_n=top_n) + pin_res = _pin_source_from_merged(merged, top_n, matrix_res) if run_pp: - p = opts.pax - run_pp_for_search( - matrix_res, - legs=_build_pp_legs(legs), - num_passengers=p.adults + p.children + p.seniors + p.youth, - airlines=sel.pp_airlines() if sel is not None else None, - cabins=sel.pp_cabins() if sel is not None else None, - pp_only=awards_only, - json_out=False, - provider_filter=sel.provider_filter if sel is not None else None, - seats_sources=sel.seats_sources() if sel is not None else None, - cash_per_cabin=_cash_per_cabin_single(matrix_res, opts.cabin), - ) + _overlay_awards(matrix_res, legs=legs, opts=opts, sel=sel, awards_only=awards_only) _emit_urls( - matrix_search, matrix_url=matrix_url, google_url=google_url, result=matrix_res, pick=pick + matrix_search, matrix_url=matrix_url, google_url=google_url, result=pin_res, pick=pick ) diff --git a/tests/test_links_gflight_pin.py b/tests/test_links_gflight_pin.py index 1c8c987..00d55c0 100644 --- a/tests/test_links_gflight_pin.py +++ b/tests/test_links_gflight_pin.py @@ -219,3 +219,58 @@ def test_extract_pin_segments_bails_on_missing_data() -> None: stops=[], # 2 flights but 0 stops — invalid topology ) assert extract_pin_segments_from_slice(s) is None + + +# ───────── --pick must index the rows the user actually saw ───────── + + +def test_pin_source_follows_the_rendered_merged_order() -> None: + """`--pick N` names a row from the merged GF+Matrix table. Pinning from + `matrix_res` indexed a DIFFERENT sequence: a Google-only row is cheaper, so + it sorts first and shifts every Matrix row down by one — `--pick 2` then + linked the itinerary shown at row 1. That link is the handoff to a real + booking. + """ + from types import SimpleNamespace + + from flight_cli.cli import _pin_source_from_merged + from flight_cli.models import ( + Itinerary, + ItineraryDetails, + SearchResult, + Slice, + SliceEndpoint, + ) + + def _itin(fn: str) -> Itinerary: + return Itinerary( + displayTotal="USD500.00", + itinerary=ItineraryDetails( + slices=[ + Slice( + flights=[fn], + departure="2026-09-09T06:00:00", + origin=SliceEndpoint(code="MSY"), + destination=SliceEndpoint(code="MIA"), + ), + ], + carriers=[], + ), + ) + + gf_only = _itin("DL300") # cheaper: sorts to merged row 1 + matrix_a = _itin("AA500") + merged = [SimpleNamespace(itinerary=gf_only), SimpleNamespace(itinerary=matrix_a)] + + # Matrix's own list has AA500 first — the ordering that used to be pinned. + src = SearchResult( # pyright: ignore[reportCallIssue] + solutionCount=1, solutions=[matrix_a], session="S", solutionSet="SS" + ) + + pin = _pin_source_from_merged(merged, 10, src) + + assert [s.itinerary.slices[0].flights[0] for s in pin.solutions] == ["DL300", "AA500"] + # Server-generated identifiers must survive, or the Matrix pinned URL + # silently degrades to a plain deep link. + assert pin.session == "S" + assert pin.solution_set == "SS" From dbdb77e498e721a76aba167740449eb412d73464 Mon Sep 17 00:00:00 2001 From: ak2k <19240940+ak2k@users.noreply.github.com> Date: Thu, 30 Jul 2026 13:53:01 -0400 Subject: [PATCH 17/26] flight-cli: fix typecheck in the --pick ordering test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pushed the previous commit before reading the gate output; basedpyright flagged an Optional access and the private-symbol import. Both fixed — the suppression names the rule and its reason, per AGENTS.md Principle 2. make check green: 591 tests. --- tests/test_links_gflight_pin.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/test_links_gflight_pin.py b/tests/test_links_gflight_pin.py index 00d55c0..a73da26 100644 --- a/tests/test_links_gflight_pin.py +++ b/tests/test_links_gflight_pin.py @@ -233,7 +233,10 @@ def test_pin_source_follows_the_rendered_merged_order() -> None: """ from types import SimpleNamespace - from flight_cli.cli import _pin_source_from_merged + # DIVERGE: reportPrivateUsage — pinning the row-ordering contract is + # exactly the case for reaching into a module-internal helper; exporting + # it publicly to satisfy the rule would widen the API for a test. + from flight_cli.cli import _pin_source_from_merged # pyright: ignore[reportPrivateUsage] from flight_cli.models import ( Itinerary, ItineraryDetails, @@ -269,7 +272,8 @@ def _itin(fn: str) -> Itinerary: pin = _pin_source_from_merged(merged, 10, src) - assert [s.itinerary.slices[0].flights[0] for s in pin.solutions] == ["DL300", "AA500"] + flights = [s.itinerary.slices[0].flights[0] for s in pin.solutions if s.itinerary is not None] + assert flights == ["DL300", "AA500"] # Server-generated identifiers must survive, or the Matrix pinned URL # silently degrades to a plain deep link. assert pin.session == "S" From 8fa909a85113604370f289923268ae0ebec178a8 Mon Sep 17 00:00:00 2001 From: ak2k <19240940+ak2k@users.noreply.github.com> Date: Thu, 30 Jul 2026 15:02:38 -0400 Subject: [PATCH 18/26] flight-cli: diskcache-backed HTTP cache with a 15-minute TTL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The response cache under every network call had no expiry, no eviction, and cached application-level errors. A live cache inspected during review: 111 entries spanning 77 days, newest 16 hours old — so it had effectively never served a fresh hit — and 19 of those were memoized failures, including `Internal server error` and `QPX Warning. Bad route specification`, each replayed forever because nothing expired. Storage moves to diskcache; policy stays ours. Choosing the library was measured, not assumed. My first instinct was to keep hand-rolling, on the grounds that diskcache is sync-only and a thread hop would defeat a dedupe cache. That was wrong: through `anyio.to_thread` (which reuses its worker pool) a set costs ~0.15 ms against ~0.12 ms for the raw JSON write it replaces — noise beside a 30-45 s Matrix call. The upstream's own async caveat (grantjenks/python-diskcache#116) is about `asyncio.run()` spawning a fresh executor per call, which we don't do. In exchange: per-entry TTL, LRU eviction and a size cap from a tested implementation, in a project that had already shipped two expiry bugs in hand-rolled caches this month (mtime-refresh, restamp-on-read). What no cache library could have done for us is the error check. Matrix signals failure with HTTP 200 plus `{"error": ...}`, so neither a status code nor an HTTP-aware policy can see it — `is_cacheable_body` needs Matrix's own shape. That predicate was always going to be ours, which is why "the library doesn't fix the real bug" was not an argument against the library. TTL is 15 minutes: Matrix is research-only (booking hands off to the Google Flights link), so a slightly stale fare costs a re-search, never a bad purchase. Long enough to dedupe one command's repeat queries, short enough that nobody acts on old pricing. First test file for `_http.py`, which had none despite sitting under every request. Verified directly against the pre-change module: two identical requests returning an error body made 1 network call before (memoized, `cache_hit` logged) and 2 after. make check green: 597 tests, ruff + basedpyright clean. --- pyproject.toml | 1 + src/flight_cli/_http.py | 85 ++++++++++++++++++++------ tests/test_http_cache.py | 129 +++++++++++++++++++++++++++++++++++++++ uv.lock | 11 ++++ 4 files changed, 207 insertions(+), 19 deletions(-) create mode 100644 tests/test_http_cache.py diff --git a/pyproject.toml b/pyproject.toml index 0f5c6d5..a88221e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ dependencies = [ "flights>=0.9", # fli — drives Google Flights for booking handoff "fast-flights>=2.2", # tfs= URL encoder (Google Flights deep-link) "rookiepy>=0.5", # reads Chrome cookies for `auth pp login --from-chrome` + "diskcache>=5.6", # HTTP response cache: per-entry TTL, LRU, SQLite/WAL ] [project.optional-dependencies] diff --git a/src/flight_cli/_http.py b/src/flight_cli/_http.py index 5398d06..5bb1d1b 100644 --- a/src/flight_cli/_http.py +++ b/src/flight_cli/_http.py @@ -11,6 +11,8 @@ from typing import TYPE_CHECKING, Any, cast import anyio +import anyio.to_thread +import diskcache # pyright: ignore[reportMissingTypeStubs] # DIVERGE: no stubs shipped; Profile-B edge import httpx import stamina import structlog @@ -55,13 +57,49 @@ def _is_retryable(exc: Exception) -> bool: return False +# How long a cached search body may be served. Matrix is research-only — the +# booking handoff is the Google Flights link — so a slightly stale fare costs a +# re-search, never a bad purchase. Long enough to dedupe the repeat queries a +# single command makes (multi-cabin merge, calendar fan-out, a `--pick` +# re-render); short enough that nobody acts on a quarter-hour-old fare. +CACHE_TTL_SECS = 15 * 60 + +# Bounds the cache directory. Entries are ~20 KB, so this is generous; without +# it the store only grows, which is what the previous hand-rolled cache did +# (77 days of orphans, no eviction). +CACHE_SIZE_LIMIT_BYTES = 256 * 1024 * 1024 + + +def is_cacheable_body(data: dict[str, Any]) -> bool: + """Whether a decoded response body may be stored. + + Matrix signals failure with HTTP 200 plus `{"error": {...}}` (see + `MatrixApiError`), so status code alone cannot gate this and no HTTP-aware + cache library can either — the judgement needs Matrix's own shape. Caching + those bodies made a transient brownout permanent: a live cache inspected + during review held 19 such entries, including `Internal server error`, each + replayed forever because nothing expired. + """ + return "error" not in data + + class HttpTransport: """Wraps httpx + curl_cffi with rate-limit, retry, and optional disk cache. The cache is content-addressed by (url, sorted-JSON-body) and stores the - decoded response body as JSON. It exists so we can develop the CLI / parser - against captured responses while Matrix is in one of its frequent - empty-calendar brownouts. + decoded response body. It exists so we can develop the CLI / parser against + captured responses while Matrix is in one of its frequent empty-calendar + brownouts, and so one command's repeated queries hit the network once. + + Storage is `diskcache` (SQLite/WAL) rather than hand-rolled files. That + choice was measured, not assumed: `diskcache` is sync-only, but through + `anyio.to_thread` (which reuses its worker pool) a set costs ~0.15 ms + against ~0.12 ms for a raw JSON write — noise beside a 30-45 s Matrix + call, and the upstream's own async caveat about executor overhead applies + to `asyncio.run()` spawning a fresh pool per call, which we don't do. In + exchange we get per-entry TTL, LRU eviction and a size cap from a tested + implementation; this project had already shipped two expiry bugs in + hand-rolled caches (mtime-refresh, restamp-on-read). """ def __init__( @@ -75,6 +113,7 @@ def __init__( cache_dir: pathlib.Path | str | None = None, cache_read: bool = True, cache_write: bool = True, + cache_ttl: float = CACHE_TTL_SECS, ) -> None: self._transport = AsyncCurlTransport( # `impersonate` is a curl_cffi BrowserTypeLiteral string at runtime; @@ -100,8 +139,13 @@ def __init__( ) self._cache_dir = pathlib.Path(cache_dir) self._cache_dir.mkdir(parents=True, exist_ok=True) + self._cache: Any = diskcache.Cache( # pyright: ignore[reportUnknownMemberType] + str(self._cache_dir / "http"), + size_limit=CACHE_SIZE_LIMIT_BYTES, + ) self._cache_read = cache_read self._cache_write = cache_write + self._cache_ttl = cache_ttl async def __aenter__(self) -> HttpTransport: return self @@ -111,6 +155,7 @@ async def __aexit__(self, *_: object) -> None: async def aclose(self) -> None: await self._client.aclose() + self._cache.close() # ────────────────────────── cache helpers ───────────────────────────── @@ -121,23 +166,25 @@ def _cache_key(self, url: str, body: dict[str, Any] | None) -> str: h.update(json.dumps(body, sort_keys=True, separators=(",", ":")).encode()) return h.hexdigest()[:24] - def _cache_path(self, key: str) -> pathlib.Path: - return self._cache_dir / f"{key}.json" - - def _cache_get(self, key: str) -> dict[str, Any] | None: - p = self._cache_path(key) - if not p.exists(): - return None + async def _cache_get(self, key: str) -> dict[str, Any] | None: + """Fetch a live entry, or None. Expiry is diskcache's, keyed per entry.""" try: - return cast("dict[str, Any]", json.loads(p.read_text())) - except (OSError, json.JSONDecodeError) as e: + hit: Any = await anyio.to_thread.run_sync(self._cache.get, key) # pyright: ignore[reportUnknownArgumentType] + except Exception as e: # noqa: BLE001 — a cache read must never fail a request log.warning("cache_read_failed", key=key, error=str(e)) return None + return cast("dict[str, Any] | None", hit) - def _cache_put(self, key: str, value: dict[str, Any]) -> None: + async def _cache_put(self, key: str, value: dict[str, Any]) -> None: + """Store a response body unless it is an application-level error.""" + if not is_cacheable_body(value): + log.debug("cache_skip_error_body", key=key) + return try: - self._cache_path(key).write_text(json.dumps(value, indent=2)) - except OSError as e: + await anyio.to_thread.run_sync( + lambda: self._cache.set(key, value, expire=self._cache_ttl), # pyright: ignore[reportUnknownMemberType] + ) + except Exception as e: # noqa: BLE001 — a cache write must never fail a request log.warning("cache_write_failed", key=key, error=str(e)) # ───────────────────────────── public API ────────────────────────────── @@ -150,7 +197,7 @@ async def get_json( None, ) if cache and self._cache_read: - hit = self._cache_get(cache_key) + hit = await self._cache_get(cache_key) if hit is not None: log.debug("cache_hit", method="GET", url=url) return hit @@ -176,7 +223,7 @@ async def _send() -> httpx.Response: r.raise_for_status() data = r.json() if cache and self._cache_write: - self._cache_put(cache_key, data) + await self._cache_put(cache_key, data) return data async def post_json( @@ -196,7 +243,7 @@ async def post_json( body_for_hash, ) if cache and self._cache_read: - hit = self._cache_get(cache_key) + hit = await self._cache_get(cache_key) if hit is not None: log.debug("cache_hit", method="POST", url=url) return hit @@ -227,5 +274,5 @@ async def _send() -> httpx.Response: r.raise_for_status() data = r.json() if cache and self._cache_write: - self._cache_put(cache_key, data) + await self._cache_put(cache_key, data) return data diff --git a/tests/test_http_cache.py b/tests/test_http_cache.py new file mode 100644 index 0000000..d94b85b --- /dev/null +++ b/tests/test_http_cache.py @@ -0,0 +1,129 @@ +# pyright: reportPrivateUsage=false +# DIVERGE: swapping in a MockTransport requires reaching for `_client`, the +# same pattern tests/pp/test_client_request_retry.py established. The public +# constructor has no transport injection point. +"""Tests for HttpTransport's response cache. + +`_http.py` had no dedicated test file despite sitting under every network call +the tool makes. These pin the two properties that actually protect a user: an +application-level error is never memoized, and a cached fare eventually +expires. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import anyio +import httpx + +from flight_cli._http import HttpTransport, is_cacheable_body + +if TYPE_CHECKING: + import pathlib + + +def _transport(tmp: pathlib.Path, handler: Any, **kw: Any) -> HttpTransport: + t = HttpTransport(cache_dir=tmp, rps=1000.0, **kw) + # Swap in a mock transport; the constructor's client has done no I/O yet. + t._client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + return t + + +def _counting_handler(body: dict[str, Any]) -> tuple[Any, list[int]]: + calls = [0] + + def handler(_req: httpx.Request) -> httpx.Response: + calls[0] += 1 + return httpx.Response(200, json=body) + + return handler, calls + + +# ───────────────────────── error bodies are never stored ───────────────────── + + +def test_matrix_error_envelope_is_not_cacheable() -> None: + """Matrix signals failure with HTTP 200 + `{"error": ...}`, so no + status-code or HTTP-aware rule can catch it — the check needs Matrix's own + shape.""" + assert is_cacheable_body({"solutionList": {"solutions": []}}) is True + assert is_cacheable_body({"error": {"message": "Internal server error."}}) is False + assert is_cacheable_body({"error": {"message": "QPX Warning. Bad route"}}) is False + + +def test_error_response_is_refetched_not_memoized(tmp_path: pathlib.Path) -> None: + """The bug this prevents: a transient brownout became permanent. A live + cache inspected during review held 19 such entries, each replayed forever + because nothing expired.""" + handler, calls = _counting_handler({"error": {"message": "Internal server error."}}) + + async def go() -> None: + t = _transport(tmp_path, handler) + _ = await t.post_json("https://x.test/v1/search", {"q": 1}) + _ = await t.post_json("https://x.test/v1/search", {"q": 1}) + await t.aclose() + + anyio.run(go) + assert calls[0] == 2 # both requests hit the network + + +def test_successful_response_is_served_from_cache(tmp_path: pathlib.Path) -> None: + handler, calls = _counting_handler({"solutionList": {"solutions": [{"id": "a"}]}}) + + async def go() -> None: + t = _transport(tmp_path, handler) + first = await t.post_json("https://x.test/v1/search", {"q": 1}) + second = await t.post_json("https://x.test/v1/search", {"q": 1}) + assert first == second + await t.aclose() + + anyio.run(go) + assert calls[0] == 1 # second request served from cache + + +# ────────────────────────────── entries expire ─────────────────────────────── + + +def test_cached_entry_expires(tmp_path: pathlib.Path) -> None: + """A fare must not be served indefinitely. The previous hand-rolled cache + had no expiry at all: its newest entry was 16 hours old and its oldest 77 + days, all still live.""" + handler, calls = _counting_handler({"solutionList": {"solutions": []}}) + + async def go() -> None: + t = _transport(tmp_path, handler, cache_ttl=0.5) + _ = await t.post_json("https://x.test/v1/search", {"q": 1}) + await anyio.sleep(0.7) + _ = await t.post_json("https://x.test/v1/search", {"q": 1}) + await t.aclose() + + anyio.run(go) + assert calls[0] == 2 + + +def test_distinct_bodies_do_not_share_an_entry(tmp_path: pathlib.Path) -> None: + """Two different searches must never resolve to one cached response.""" + handler, calls = _counting_handler({"solutionList": {"solutions": []}}) + + async def go() -> None: + t = _transport(tmp_path, handler) + _ = await t.post_json("https://x.test/v1/search", {"origin": "MSY"}) + _ = await t.post_json("https://x.test/v1/search", {"origin": "JFK"}) + await t.aclose() + + anyio.run(go) + assert calls[0] == 2 + + +def test_cache_read_disabled_always_refetches(tmp_path: pathlib.Path) -> None: + handler, calls = _counting_handler({"solutionList": {"solutions": []}}) + + async def go() -> None: + t = _transport(tmp_path, handler, cache_read=False) + _ = await t.post_json("https://x.test/v1/search", {"q": 1}) + _ = await t.post_json("https://x.test/v1/search", {"q": 1}) + await t.aclose() + + anyio.run(go) + assert calls[0] == 2 diff --git a/uv.lock b/uv.lock index 47e77e1..eb42d51 100644 --- a/uv.lock +++ b/uv.lock @@ -267,6 +267,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/59/8c/36bbe06d66fa2b765e4a07199f643a59a9cd1a754207a96335402a9520f4/curl_cffi-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0b6c0543b993996670e9e4b78e305a2d60809d5681903ffb5568e21a387434d3", size = 1466312, upload-time = "2026-04-03T11:12:30.054Z" }, ] +[[package]] +name = "diskcache" +version = "5.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3f/21/1c1ffc1a039ddcc459db43cc108658f32c57d271d7289a2794e401d0fdb6/diskcache-5.6.3.tar.gz", hash = "sha256:2c3a3fa2743d8535d832ec61c2054a1641f41775aa7c556758a109941e33e4fc", size = 67916, upload-time = "2023-08-31T06:12:00.316Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/27/4570e78fc0bf5ea0ca45eb1de3818a23787af9b390c0b0a0033a1b8236f9/diskcache-5.6.3-py3-none-any.whl", hash = "sha256:5e31b2d5fbad117cc363ebaf6b689474db18a1f6438bc82358b024abd4c2ca19", size = 45550, upload-time = "2023-08-31T06:11:58.822Z" }, +] + [[package]] name = "fast-flights" version = "2.2" @@ -288,6 +297,7 @@ source = { editable = "." } dependencies = [ { name = "aiolimiter" }, { name = "anyio" }, + { name = "diskcache" }, { name = "fast-flights" }, { name = "flights" }, { name = "httpx" }, @@ -319,6 +329,7 @@ dev = [ requires-dist = [ { name = "aiolimiter", specifier = ">=1.1" }, { name = "anyio", specifier = ">=4" }, + { name = "diskcache", specifier = ">=5.6" }, { name = "fast-flights", specifier = ">=2.2" }, { name = "flights", specifier = ">=0.9" }, { name = "httpx", specifier = ">=0.27" }, From 709878a3cf7f995db649606958ffb8e6608e06f0 Mon Sep 17 00:00:00 2001 From: ak2k <19240940+ak2k@users.noreply.github.com> Date: Thu, 30 Jul 2026 19:05:40 -0400 Subject: [PATCH 19/26] flight-cli: support multi-city links instead of silently dropping legs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Answering the one open product question: both upstreams support multi-city (fli exposes TripType.MULTI_CITY=3, Matrix's SPA takes a slice per leg), so there was never a reason to reject the input — we were just encoding it wrong. Three bugs, all reproduced by decoding the emitted link: Matrix — `n == 2` meant round trip unconditionally, without checking the legs actually invert. Round-trip's SPA encoding folds both legs into ONE slice carrying two dates, which structurally cannot express a second route, so the second leg was DELETED: SFO->JFK plus LAX->HNL encoded as SFO->JFK with a return date, LAX and HNL gone. Byte-identical output to a real SFO<->JFK round trip. Now `_is_inverse_pair` requires the return to depart where the outbound landed and land where it started; anything else is multi-city, which keeps a slice per leg. Multi-airport legs count as inverse only on an exact set match. Google, trip type — `>= 2 slices` meant round trip, so a three-leg itinerary was labelled one and Google read only the first two slices. Leg 3 vanished from a link we still described as "pinned". Now one-way / round-trip / multi-city by slice count. Google, passengers — field 8 is a repeated varint carrying each occupant's TYPE, but we emitted a bare `1` per occupant. `1` is ADULT, so a pinned link for 1 adult + 1 child searched and priced as 2 adults: a different, costlier itinerary than the row the user selected. Enum values taken from fast_flights' generated protobuf (flights_pb2.Passenger) rather than guessed: ADULT=1, CHILD=2, INFANT_IN_SEAT=3, INFANT_ON_LAP=4. Six tests; four fail against the pre-fix code. make check green: 603 tests, ruff + basedpyright clean. --- src/flight_cli/links.py | 57 ++++++++++++++++--- tests/test_links_gflight_pin.py | 98 +++++++++++++++++++++++++++++++++ tests/test_links_matrix_url.py | 51 +++++++++++++++++ 3 files changed, 198 insertions(+), 8 deletions(-) diff --git a/src/flight_cli/links.py b/src/flight_cli/links.py index 49b7378..e7d3f0f 100644 --- a/src/flight_cli/links.py +++ b/src/flight_cli/links.py @@ -110,11 +110,28 @@ def _spa_specific_slices(legs: tuple[Leg, ...]) -> tuple[str, list[dict[str, Any n = len(legs) if n == 1: return "one-way", [_spa_specific_leg(legs[0])] - if n == _ROUND_TRIP_LEGS: + if n == _ROUND_TRIP_LEGS and _is_inverse_pair(legs[0], legs[1]): return "round-trip", [_spa_specific_leg(legs[0], return_leg=legs[1])] return "multi-city", [_spa_specific_leg(leg) for leg in legs] +def _is_inverse_pair(out: Leg, ret: Leg) -> bool: + """Whether two legs form a true round trip — the return departs where the + outbound landed AND lands where it started. + + Round-trip's SPA encoding folds both legs into ONE slice carrying two + dates, which structurally cannot express a second route. Treating any + 2-leg search as a round trip therefore DELETED the second leg: SFO->JFK + plus LAX->HNL encoded as SFO->JFK with a return date, and LAX/HNL simply + vanished from the emitted link. Multi-city keeps a slice per leg, so + anything that isn't a genuine inverse belongs there. + + Multi-airport legs count as inverse only when the sets match exactly; a + partial overlap is an itinerary we cannot faithfully fold. + """ + return set(out.destinations) == set(ret.origins) and set(out.origins) == set(ret.destinations) + + def _spa_calendar_leg( out: Leg, ret: Leg | None, start: date, end: date, duration_min: int, duration_max: int ) -> dict[str, Any]: @@ -276,6 +293,15 @@ def matrix_deep_link(s: Search) -> str: # tfs= trip-type enum (field 19). _GF_TRIP_ROUND_TRIP = 1 _GF_TRIP_ONE_WAY = 2 +_GF_TRIP_MULTI_CITY = 3 + +# tfs= field 8 is a repeated varint, one entry per occupant, carrying the +# passenger TYPE. Values are Google's own `Passenger` enum, read out of +# fast_flights' generated protobuf (flights_pb2.Passenger) rather than guessed. +_GF_PAX_ADULT = 1 +_GF_PAX_CHILD = 2 +_GF_PAX_INFANT_IN_SEAT = 3 +_GF_PAX_INFANT_ON_LAP = 4 # ───────────────── Google Flights tfs= protobuf (RE'd) ────────────────────── @@ -399,11 +425,18 @@ def _encode_gflight_pinned_tfs( s.message(14, dest_w) w.message(3, s) - # Field 8: one repeated varint=1 per adult (and similar for other pax types - # observed in the captured payload — 3 adults → three "8: 1" entries). - # We replicate the observed pattern: emit one `1` per total occupant. - for _ in range(adults + children + infants_in_seat + infants_on_lap): - w.varint(8, 1) + # Field 8: one repeated varint per occupant, carrying that occupant's TYPE. + # Emitting a bare `1` for everyone encoded children and infants as ADULTS, + # so a pinned link for 1 adult + 1 child priced and searched as 2 adults — + # a different, more expensive itinerary than the row the user picked. + for _type, _count in ( + (_GF_PAX_ADULT, adults), + (_GF_PAX_CHILD, children), + (_GF_PAX_INFANT_IN_SEAT, infants_in_seat), + (_GF_PAX_INFANT_ON_LAP, infants_on_lap), + ): + for _ in range(_count): + w.varint(8, _type) w.varint(9, cabin) w.varint(14, 1) @@ -413,8 +446,16 @@ def _encode_gflight_pinned_tfs( marker.varint(1, (1 << 64) - 1) w.message(16, marker) - # Field 19: trip type. TFS enum: 1 = round-trip, 2 = one-way. - trip_type = _GF_TRIP_ROUND_TRIP if len(slices) >= _ROUND_TRIP_LEGS else _GF_TRIP_ONE_WAY + # Field 19: trip type. TFS enum: 1 = round-trip, 2 = one-way, 3 = multi-city. + # `>= 2` meant round-trip, so a three-leg itinerary was labelled a round + # trip; Google then read only the first two slices and the third leg was + # silently dropped from a link we still described as "pinned". + if len(slices) == 1: + trip_type = _GF_TRIP_ONE_WAY + elif len(slices) == _ROUND_TRIP_LEGS: + trip_type = _GF_TRIP_ROUND_TRIP + else: + trip_type = _GF_TRIP_MULTI_CITY w.varint(19, trip_type) return bytes(w.buf) diff --git a/tests/test_links_gflight_pin.py b/tests/test_links_gflight_pin.py index a73da26..c1e032f 100644 --- a/tests/test_links_gflight_pin.py +++ b/tests/test_links_gflight_pin.py @@ -1,3 +1,7 @@ +# pyright: reportPrivateUsage=false +# DIVERGE: these pin wire-format contracts of the pinned-tfs encoder, which is +# module-internal by design. Exporting it to satisfy the rule would widen the +# API for a test. """Byte-exact regression test for the Google Flights pinned-itinerary `tfs=` protobuf encoder. @@ -15,6 +19,7 @@ from __future__ import annotations import pathlib +from typing import Any from flight_cli.links import ( _encode_gflight_pinned_tfs, # pyright: ignore[reportPrivateUsage] # test-only: lock byte-exact regression @@ -278,3 +283,96 @@ def _itin(fn: str) -> Itinerary: # silently degrades to a plain deep link. assert pin.session == "S" assert pin.solution_set == "SS" + + +# ───────── multi-city and passenger types survive into the pinned link ───────── + + +def _pin_slice(origin: str, dest: str, date: str) -> dict[str, Any]: + return { + "date": date, + "origin": origin, + "destination": dest, + "segments": [ + { + "origin": origin, + "date": date, + "destination": dest, + "carrier": "AA", + "flight": "100", + }, + ], + } + + +def _field8_values(buf: bytes) -> list[int]: + """Every top-level field-8 varint (tag byte 0x40) — the per-occupant types.""" + out: list[int] = [] + i = 0 + while i < len(buf): + if buf[i] == 0x40: + out.append(buf[i + 1]) + i += 2 + else: + i += 1 + return out + + +def test_passenger_types_are_not_all_encoded_as_adults() -> None: + """Field 8 carries each occupant's TYPE (Google's Passenger enum, read from + fast_flights' generated protobuf: ADULT=1, CHILD=2, INFANT_IN_SEAT=3, + INFANT_ON_LAP=4). Emitting a bare 1 for everyone made a pinned link for + 1 adult + 1 child search and price as 2 adults — a different, costlier + itinerary than the row the user picked.""" + from flight_cli.links import _encode_gflight_pinned_tfs # pyright: ignore[reportPrivateUsage] + + buf = _encode_gflight_pinned_tfs( + slices=[_pin_slice("SFO", "JFK", "2026-09-01")], + cabin=1, + adults=1, + children=1, + infants_in_seat=1, + infants_on_lap=1, + ) + assert _field8_values(buf) == [1, 2, 3, 4] + + +def test_two_adults_still_encode_as_two_adults() -> None: + from flight_cli.links import _encode_gflight_pinned_tfs # pyright: ignore[reportPrivateUsage] + + buf = _encode_gflight_pinned_tfs( + slices=[_pin_slice("SFO", "JFK", "2026-09-01")], + cabin=1, + adults=2, + children=0, + infants_in_seat=0, + infants_on_lap=0, + ) + assert _field8_values(buf) == [1, 1] + + +def test_three_leg_itinerary_is_multi_city_not_round_trip() -> None: + """`>= 2 slices` meant round-trip, so Google read only the first two and + leg 3 vanished from a link still described as "pinned".""" + from flight_cli.links import ( # pyright: ignore[reportPrivateUsage] + _GF_TRIP_MULTI_CITY, + _GF_TRIP_ONE_WAY, + _GF_TRIP_ROUND_TRIP, + _encode_gflight_pinned_tfs, + ) + + def trip_type(n_slices: int) -> int: + route = [("SFO", "JFK"), ("JFK", "LHR"), ("LHR", "SFO")][:n_slices] + buf = _encode_gflight_pinned_tfs( + slices=[_pin_slice(o, d, "2026-09-01") for o, d in route], + cabin=1, + adults=1, + children=0, + infants_in_seat=0, + infants_on_lap=0, + ) + return buf[-1] # field 19 is the last varint written + + assert trip_type(1) == _GF_TRIP_ONE_WAY + assert trip_type(2) == _GF_TRIP_ROUND_TRIP + assert trip_type(3) == _GF_TRIP_MULTI_CITY diff --git a/tests/test_links_matrix_url.py b/tests/test_links_matrix_url.py index a073dac..35af462 100644 --- a/tests/test_links_matrix_url.py +++ b/tests/test_links_matrix_url.py @@ -1,3 +1,7 @@ +# pyright: reportPrivateUsage=false +# DIVERGE: these pin wire-format contracts of the SPA slice builder, which is +# module-internal by design. Exporting it to satisfy the rule would widen the +# API for a test. """Regression tests for `matrix_deep_link()` — the SPA URL-state encoder. Round-trip ground truth was captured 2026-05-19 by driving Matrix's SPA via @@ -228,3 +232,50 @@ def test_same_day_nonstop_unchanged() -> None: ) assert segs is not None assert [x["date"] for x in segs] == ["2026-09-09"] + + +# ───────── a 2-leg search is only a round trip if it actually returns ───────── + + +def test_open_jaw_is_multi_city_not_a_collapsed_round_trip() -> None: + """Round-trip's SPA encoding folds both legs into ONE slice with two dates, + which cannot express a second route. Treating any 2-leg search as a round + trip therefore DELETED the second leg: SFO->JFK plus LAX->HNL encoded as + SFO->JFK with a return date, and LAX/HNL vanished entirely.""" + from flight_cli.domain import Leg + from flight_cli.links import _spa_specific_slices # pyright: ignore[reportPrivateUsage] + + legs = ( + Leg(origins=("SFO",), destinations=("JFK",), date=date(2026, 9, 1)), + Leg(origins=("LAX",), destinations=("HNL",), date=date(2026, 9, 5)), + ) + trip, slices = _spa_specific_slices(legs) + assert trip == "multi-city" + assert [(s["origin"][0], s["dest"][0]) for s in slices] == [("SFO", "JFK"), ("LAX", "HNL")] + + +def test_true_round_trip_still_folds_into_one_slice() -> None: + from flight_cli.domain import Leg + from flight_cli.links import _spa_specific_slices # pyright: ignore[reportPrivateUsage] + + legs = ( + Leg(origins=("SFO",), destinations=("JFK",), date=date(2026, 9, 1)), + Leg(origins=("JFK",), destinations=("SFO",), date=date(2026, 9, 5)), + ) + trip, slices = _spa_specific_slices(legs) + assert trip == "round-trip" + assert len(slices) == 1 + assert slices[0]["dates"]["returnDate"] == "2026-09-05" + + +def test_half_open_jaw_is_multi_city() -> None: + """Returns from where it landed but not to where it started.""" + from flight_cli.domain import Leg + from flight_cli.links import _spa_specific_slices # pyright: ignore[reportPrivateUsage] + + legs = ( + Leg(origins=("SFO",), destinations=("JFK",), date=date(2026, 9, 1)), + Leg(origins=("JFK",), destinations=("LAX",), date=date(2026, 9, 5)), + ) + trip, _ = _spa_specific_slices(legs) + assert trip == "multi-city" From c074c5abb4fc884e3f0b52006f990c14f67b378c Mon Sep 17 00:00:00 2001 From: ak2k <19240940+ak2k@users.noreply.github.com> Date: Sat, 1 Aug 2026 07:07:18 -0400 Subject: [PATCH 20/26] flight-cli: unambiguous cache key, honest tax currency, canonical cabin labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three mechanical fixes from the review backlog. Cache key — params were flattened with an unescaped `"&".join(f"{k}={v}")`, so a param VALUE containing a separator forged another request's hash: `{"a": "1&b=2"}` and `{"a": "1", "b": "2"}` produced the identical key, and whichever ran first served the other its flight data. Now hashes a canonical envelope of (method, url, sorted param pairs, body). Method is included so a GET and POST to one URL can never collide — they happened not to today, but only because an empty body and a missing body differ by accident. Tax currency — `CabinAward.tax_currency` was carried and never read, so a EUR tax printed as "$180" and was then subtracted from a USD fare to compute ¢/mi. Now prints in its own currency, and ¢/mi is suppressed when the tax is not USD rather than converted: there is no rate source here, and a wrong valuation is worse than a missing one. Cabin labels — the renderer selects awards by exact cabin-string equality, and seats.aero emitted "Premium" where the CLI canonicalizes to "Premium economy", so every seats.aero premium award silently vanished from that column. Fixed at the provider boundary. That exposed the inverse: the outbound API filter used a bare `.lower()`, which would have sent "premium economy" to an API expecting "premium" and matched nothing — so `_CABIN_SLUGS` now inverts the label map explicitly, and a test pins the round-trip. Seven tests, all failing against the pre-fix code. make check green: 609 tests, ruff + basedpyright clean. --- src/flight_cli/_http.py | 37 +++++++++++++------ src/flight_cli/pp/cli.py | 27 +++++++++----- .../providers/seats_aero/provider.py | 20 +++++++++- tests/pp/test_cli.py | 30 ++++++++++++++- tests/seats_aero/test_provider.py | 22 +++++++++++ tests/test_http_cache.py | 30 +++++++++++++++ 6 files changed, 142 insertions(+), 24 deletions(-) diff --git a/src/flight_cli/_http.py b/src/flight_cli/_http.py index 5bb1d1b..1e22186 100644 --- a/src/flight_cli/_http.py +++ b/src/flight_cli/_http.py @@ -159,11 +159,30 @@ async def aclose(self) -> None: # ────────────────────────── cache helpers ───────────────────────────── - def _cache_key(self, url: str, body: dict[str, Any] | None) -> str: + def _cache_key( + self, + method: str, + url: str, + params: dict[str, Any] | None, + body: dict[str, Any] | None, + ) -> str: + """Hash a canonical envelope of everything that varies the response. + + Method is included so a GET and a POST to one URL can never share an + entry. Params are hashed as structured pairs rather than flattened into + the URL: the old `"&".join(f"{k}={v}")` was unescaped, so a value + CONTAINING a separator forged another request's key — `{"a": "1&b=2"}` + and `{"a": "1", "b": "2"}` produced the identical hash, and whichever + ran first served the other its flight data. + """ + envelope = { + "method": method.upper(), + "url": url, + "params": sorted((str(k), str(v)) for k, v in (params or {}).items()), + "body": body, + } h = hashlib.sha256() - h.update(url.encode()) - if body is not None: - h.update(json.dumps(body, sort_keys=True, separators=(",", ":")).encode()) + h.update(json.dumps(envelope, sort_keys=True, separators=(",", ":")).encode()) return h.hexdigest()[:24] async def _cache_get(self, key: str) -> dict[str, Any] | None: @@ -192,10 +211,7 @@ async def _cache_put(self, key: str, value: dict[str, Any]) -> None: async def get_json( self, url: str, *, params: dict[str, Any] | None = None, cache: bool = True ) -> dict[str, Any]: - cache_key = self._cache_key( - url + "?" + "&".join(f"{k}={v}" for k, v in (params or {}).items()), - None, - ) + cache_key = self._cache_key("GET", url, params, None) if cache and self._cache_read: hit = await self._cache_get(cache_key) if hit is not None: @@ -238,10 +254,7 @@ async def post_json( # bodies; we strip that field before hashing so two semantically equal # requests share a cache entry. body_for_hash = {k: v for k, v in body.items() if k != "bgProgramResponse"} - cache_key = self._cache_key( - url + "?" + "&".join(f"{k}={v}" for k, v in (params or {}).items()), - body_for_hash, - ) + cache_key = self._cache_key("POST", url, params, body_for_hash) if cache and self._cache_read: hit = await self._cache_get(cache_key) if hit is not None: diff --git a/src/flight_cli/pp/cli.py b/src/flight_cli/pp/cli.py index 52ef784..00b7c31 100644 --- a/src/flight_cli/pp/cli.py +++ b/src/flight_cli/pp/cli.py @@ -454,7 +454,7 @@ def _fmt_iso_compact(s: str) -> str: def _best_award_for_cabin( award_flights: list[AwardFlight], want_cabin: str -) -> tuple[int, float, str, list[str]] | None: +) -> tuple[int, float, str, list[str], str] | None: """Cheapest offer across providers for one cabin, or None. Ranked on (miles, tax) — miles first, since that is the scarce currency, @@ -466,12 +466,12 @@ def _best_award_for_cabin( presenting one under the plain "Economy" heading overstates what the traveller gets. `_basic_economy_award_for_cabin` surfaces them explicitly. """ - best: tuple[int, float, str, list[str]] | None = None + best: tuple[int, float, str, list[str], str] | None = None for af in award_flights: for ca in af.cabins: if ca.cabin != want_cabin or ca.is_basic_economy: continue - key = (ca.miles, ca.tax_usd, af.program, af.funding_banks) + key = (ca.miles, ca.tax_usd, af.program, af.funding_banks, ca.tax_currency) if best is None or (key[0], key[1]) < (best[0], best[1]): best = key return best @@ -479,16 +479,16 @@ def _best_award_for_cabin( def _basic_economy_award_for_cabin( award_flights: list[AwardFlight], want_cabin: str -) -> tuple[int, float, str, list[str]] | None: +) -> tuple[int, float, str, list[str], str] | None: """Cheapest BASIC-economy offer for one cabin — the fares `_best_award_for_cabin` deliberately skips. Rendered with an explicit label so the restriction is visible rather than implied.""" - best: tuple[int, float, str, list[str]] | None = None + best: tuple[int, float, str, list[str], str] | None = None for af in award_flights: for ca in af.cabins: if ca.cabin != want_cabin or not ca.is_basic_economy: continue - key = (ca.miles, ca.tax_usd, af.program, af.funding_banks) + key = (ca.miles, ca.tax_usd, af.program, af.funding_banks, ca.tax_currency) if best is None or (key[0], key[1]) < (best[0], best[1]): best = key return best @@ -514,10 +514,19 @@ def _fmt_award_cell( label = " [dim](basic)[/]" if best is None: return "—" - miles, tax, program, _banks = best - head = f"{_fmt_miles(miles)} {program} + ${tax:.0f}{label}" + miles, tax, program, _banks, tax_ccy = best + # Print the tax in the currency it is actually denominated in. Formatting a + # EUR amount as "$" both misstates it and invites the reader to add it to a + # USD fare. + tax_str = f"${tax:.0f}" if tax_ccy in ("", "USD") else f"{tax:.0f} {tax_ccy}" + head = f"{_fmt_miles(miles)} {program} + {tax_str}{label}" if cash_usd is None: return head + # ¢/mi nets the tax off a USD cash fare, so a non-USD tax would silently + # subtract the wrong magnitude. Suppress rather than convert: we have no + # rate source, and a wrong valuation is worse than a missing one. + if tax_ccy not in ("", "USD"): + return head cpm = _cents_per_mile(cash_usd, miles, tax) if cpm is None: return head @@ -534,7 +543,7 @@ def _fmt_funding(award_flights: list[AwardFlight], cabins: tuple[str, ...] = ()) shows; `cabins` empty keeps the old union for callers with no cabin context. """ - winners: list[tuple[int, float, str, list[str]]] = [] + winners: list[tuple[int, float, str, list[str], str]] = [] for cab in cabins: for pick in ( _best_award_for_cabin(award_flights, cab), diff --git a/src/flight_cli/providers/seats_aero/provider.py b/src/flight_cli/providers/seats_aero/provider.py index 21f3e5d..02b809a 100644 --- a/src/flight_cli/providers/seats_aero/provider.py +++ b/src/flight_cli/providers/seats_aero/provider.py @@ -68,14 +68,30 @@ # Seats.aero cabin slug → CabinAward.cabin string. Aligns with PointsPath's # "Economy"/"Business"/"First"/"Premium" labels so the renderer doesn't # have to disambiguate by provider. +# Values MUST match the CLI's canonical cabin vocabulary (`_CABIN_ALIASES` in +# pp/cli.py), because the renderer selects awards by exact cabin-string +# equality. "Premium" did not equal the canonical "Premium economy", so every +# seats.aero premium award silently vanished from that column. _CABIN_LABELS: dict[str, str] = { "economy": "Economy", - "premium": "Premium", + "premium": "Premium economy", "business": "Business", "first": "First", } +# Canonical cabin label -> seats.aero's own query slug. The outbound filter +# needs the inverse of `_CABIN_LABELS`, and a bare `.lower()` no longer works +# now that the canonical name is "Premium economy": the API expects "premium", +# so lowercasing sent "premium economy" and the filter silently matched +# nothing. +_CABIN_SLUGS: dict[str, str] = {label: slug for slug, label in _CABIN_LABELS.items()} + + +def _cabin_slug(label: str) -> str: + return _CABIN_SLUGS.get(label, label.lower()) + + def _program_label(slug: str) -> str: return _PROGRAM_LABELS.get(slug, slug.title()) @@ -249,7 +265,7 @@ async def search_leg( usual. """ _ = num_passengers, cash_hints - cabin_slugs = tuple(c.lower() for c in cabins) if cabins else None + cabin_slugs = tuple(_cabin_slug(c) for c in cabins) if cabins else None try: page = await self._client.search( origin=leg.origin, diff --git a/tests/pp/test_cli.py b/tests/pp/test_cli.py index 93a6dad..0dd0e52 100644 --- a/tests/pp/test_cli.py +++ b/tests/pp/test_cli.py @@ -133,7 +133,7 @@ def test_best_award_for_cabin_picks_cheapest_in_miles(): ] best = _best_award_for_cabin(awards, "Economy") assert best is not None - miles, _tax, program, _banks = best + miles, _tax, program, _banks, _ccy = best assert (miles, program) == (30_000, "Cheap") @@ -333,3 +333,31 @@ def test_json_return_leg_describes_the_return_slice() -> None: assert payload[0]["flight"] == "BA200" assert payload[0]["origin"] == "LHR" assert payload[0]["destination"] == "JFK" + + +def test_non_usd_tax_is_labelled_and_suppresses_cpm() -> None: + """A EUR tax printed as "$" both misstates the amount and invites adding it + to a USD fare. ¢/mi nets tax off USD cash, so a foreign tax would subtract + the wrong magnitude — suppressed rather than converted, since there is no + rate source and a wrong valuation is worse than a missing one.""" + usd = _fmt_award_cell([_ra("American", 30000, 6.0)], "Economy", 500.0) + assert "$6" in usd + assert "¢/mi" in usd + + eur = AwardFlight( + origin="JFK", + destination="LHR", + departure="d", + arrival="a", + flight_number="AA100", + num_connections=0, + provider="X", + program="American", + miles_to_cash_ratio=0.0125, + funding_banks=["Chase"], + cabins=[CabinAward(cabin="Economy", miles=30000, tax_usd=180.0, tax_currency="EUR")], + ) + cell = _fmt_award_cell([eur], "Economy", 500.0) + assert "180 EUR" in cell + assert "$" not in cell + assert "¢/mi" not in cell diff --git a/tests/seats_aero/test_provider.py b/tests/seats_aero/test_provider.py index 6cd21e5..8cc0c31 100644 --- a/tests/seats_aero/test_provider.py +++ b/tests/seats_aero/test_provider.py @@ -142,3 +142,25 @@ def test_seats_aero_timestamps_are_normalized_to_naive_local() -> None: assert _local_naive("2026-08-15T06:30:00Z") == "2026-08-15T06:30:00" assert _local_naive("2026-08-15T06:30:00") == "2026-08-15T06:30:00" assert _local_naive("") == "" + + +def test_cabin_labels_match_the_cli_canonical_vocabulary() -> None: + """The renderer selects awards by exact cabin-string equality, so a + provider label that differs from the CLI's canonical name makes those + awards vanish from the column. seats.aero's "Premium" never equalled + "Premium economy".""" + from flight_cli.pp.cli import _normalize_cabin + from flight_cli.providers.seats_aero.provider import _cabin_label + + for slug in ("economy", "premium", "business", "first"): + assert _cabin_label(slug) == _normalize_cabin(slug) + + +def test_cabin_slug_round_trips_for_the_outbound_filter() -> None: + """The API filter needs seats.aero's own slug. A bare `.lower()` sent + "premium economy" once the canonical label gained a space, and matched + nothing.""" + from flight_cli.providers.seats_aero.provider import _cabin_label, _cabin_slug + + for slug in ("economy", "premium", "business", "first"): + assert _cabin_slug(_cabin_label(slug)) == slug diff --git a/tests/test_http_cache.py b/tests/test_http_cache.py index d94b85b..cc45495 100644 --- a/tests/test_http_cache.py +++ b/tests/test_http_cache.py @@ -127,3 +127,33 @@ async def go() -> None: anyio.run(go) assert calls[0] == 2 + + +# ──────────────────────── cache key is unambiguous ──────────────────────── + + +def test_param_value_containing_a_separator_cannot_forge_another_key( + tmp_path: pathlib.Path, +) -> None: + """The key flattened params with an unescaped `"&".join(f"{k}={v}")`, so a + VALUE containing a separator produced another request's hash — whichever + ran first served the other its flight data.""" + t = HttpTransport(cache_dir=tmp_path) + injected = t._cache_key("GET", "https://x.test/s", {"a": "1&b=2"}, None) + genuine = t._cache_key("GET", "https://x.test/s", {"a": "1", "b": "2"}, None) + assert injected != genuine + + +def test_get_and_post_never_share_an_entry(tmp_path: pathlib.Path) -> None: + t = HttpTransport(cache_dir=tmp_path) + assert t._cache_key("GET", "https://x.test/s", None, None) != t._cache_key( + "POST", "https://x.test/s", None, None + ) + + +def test_param_order_does_not_change_the_key(tmp_path: pathlib.Path) -> None: + """Same request, different dict ordering, must hit the same entry.""" + t = HttpTransport(cache_dir=tmp_path) + assert t._cache_key("GET", "https://x.test/s", {"b": "2", "a": "1"}, None) == t._cache_key( + "GET", "https://x.test/s", {"a": "1", "b": "2"}, None + ) From 6f69b8b07daa25fa79aaf0fab7aa4d33ae96e2df Mon Sep 17 00:00:00 2001 From: ak2k <19240940+ak2k@users.noreply.github.com> Date: Sat, 1 Aug 2026 07:36:05 -0400 Subject: [PATCH 21/26] flight-cli: carry the stop limit into the Google Flights search URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `--stops 0` never reached the emitted link: the nonstop-only URL was BYTE-IDENTICAL to an unconstrained one, so a result table filtered to nonstops handed the user a page that also offered connections. The cause was a wrong assumption about where the field lives. `FlightData` accepts a `max_stops` kwarg, which reads like the right home for it — but fast_flights ignores it there and serializes the limit from TFSData instead. Passing it per-leg changed the URL (so it looked fixed) while still failing to distinguish nonstop from one-stop. Verified by comparing all three encodings: nonstop, <=1 stop and unconstrained are now mutually distinct. One test covering all three. make check green: 610 tests, ruff + basedpyright clean. --- src/flight_cli/links.py | 5 +++++ tests/test_links_gflight_pin.py | 24 ++++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/src/flight_cli/links.py b/src/flight_cli/links.py index e7d3f0f..aa587ee 100644 --- a/src/flight_cli/links.py +++ b/src/flight_cli/links.py @@ -676,6 +676,11 @@ def google_flights_url(s: Search, *, currency: str = "USD", language: str = "en" infants_in_seat=p.infants_in_seat, infants_on_lap=p.infants_in_lap, ), + # The stop limit is a TFSData-level field, not per-FlightData. Omitting + # it made a `--stops 0` link byte-identical to an unconstrained one, so + # a nonstop-only result table handed the user a page that also offered + # connections. + max_stops=s.options.max_extra_stops, ) b64 = td.as_b64().decode() return ( diff --git a/tests/test_links_gflight_pin.py b/tests/test_links_gflight_pin.py index c1e032f..2934909 100644 --- a/tests/test_links_gflight_pin.py +++ b/tests/test_links_gflight_pin.py @@ -376,3 +376,27 @@ def trip_type(n_slices: int) -> int: assert trip_type(1) == _GF_TRIP_ONE_WAY assert trip_type(2) == _GF_TRIP_ROUND_TRIP assert trip_type(3) == _GF_TRIP_MULTI_CITY + + +def test_stop_limit_reaches_the_google_search_url() -> None: + """`max_stops` is a TFSData-level field, not per-FlightData. Omitting it + made a `--stops 0` link byte-identical to an unconstrained one, so a + nonstop-only result table handed the user a page that also offered + connections.""" + from datetime import date + + from flight_cli.domain import Leg, SearchOptions, SpecificDateSearch + from flight_cli.links import google_flights_url + + def url(max_extra_stops: int | None) -> str: + return google_flights_url( + SpecificDateSearch( + legs=(Leg(origins=("JFK",), destinations=("LHR",), date=date(2026, 9, 1)),), + options=SearchOptions(max_extra_stops=max_extra_stops), + ), + ) + + nonstop, one_stop, unconstrained = url(0), url(1), url(None) + assert nonstop != unconstrained + assert nonstop != one_stop + assert one_stop != unconstrained From db43197cd13c8a8c0695b120f573ae90ed011efb Mon Sep 17 00:00:00 2001 From: ak2k <19240940+ak2k@users.noreply.github.com> Date: Sat, 1 Aug 2026 07:46:19 -0400 Subject: [PATCH 22/26] flight-cli: carry routing, extension and arrival-date into the Matrix link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last blocked item, unblocked by capturing the real SPA rather than guessing. Driving the actual Matrix UI (patchright + real Chrome, Advanced controls, JFK->LHR with Routing=BA+ and Extension="MAXSTOPS 0") shows the URL state uses DIFFERENT names than the /batch API for the same values: concept /batch API SPA URL state routing language routeLanguage routing extension codes commandLine ext return-leg codes (own slice) routingRet / extRet arrival-date isArrivalDate departureDateType: "arrive" Encoding from the API-side names would have produced a link the app silently ignores — which is exactly the failure being fixed, so guessing was not an option. Round trip folds into one slice, hence the separate `*Ret` keys. Dropping these meant a link built from `--routing BA+ --ext "MAXSTOPS 0"` opened an UNCONSTRAINED search, offering the user itineraries the CLI had deliberately excluded. Presence is conditional and the existing byte-exact fixtures proved it: with no codes set the SPA omits all four keys (those fixtures are captures of that case), with any set it emits all four. `_spa_routing_fields` mirrors both, so our links stay byte-identical to the app's own either way — the two golden tests that broke on a first attempt were right, not stale. Adds the capture as a tracked fixture plus docs/memories/matrix_spa_url_state.md with the field-name table and the recipe (headless is bot-blocked; mat-input-* ids are regenerated per render; the form needs a human to submit). Three tests, all failing against the pre-fix code. make check green: 613 tests, ruff + basedpyright clean. --- docs/memories/matrix_spa_url_state.md | 41 +++++++++ src/flight_cli/links.py | 35 +++++++- .../matrix_url/spa_routing_jfk_lhr.txt | 46 ++++++++++ tests/test_links_matrix_url.py | 90 +++++++++++++++++++ 4 files changed, 210 insertions(+), 2 deletions(-) create mode 100644 docs/memories/matrix_spa_url_state.md create mode 100644 tests/fixtures/matrix_url/spa_routing_jfk_lhr.txt diff --git a/docs/memories/matrix_spa_url_state.md b/docs/memories/matrix_spa_url_state.md new file mode 100644 index 0000000..3b8dcf0 --- /dev/null +++ b/docs/memories/matrix_spa_url_state.md @@ -0,0 +1,41 @@ +# Matrix SPA URL state vs the /batch API + +Captured 2026-08-01 by driving the real Matrix UI (patchright + real Chrome, +Advanced controls, one-way JFK→LHR with `Routing=BA+`, `Extension=MAXSTOPS 0`). + +## The trap + +The SPA's URL state and the `/batch` API use **different names for the same +values**. Guessing from the API side gets you a link the app ignores. + +| Concept | `/batch` API (`wire.py`) | SPA URL state (`links.py`) | +|---|---|---| +| Routing language | `routeLanguage` | `routing` | +| Extension codes | `commandLine` | `ext` | +| Return-leg routing | (own slice) | `routingRet` | +| Return-leg extension | (own slice) | `extRet` | +| Arrival-date intent | `isArrivalDate: bool` | `departureDateType: "depart"|"arrive"` | + +Round trip folds into ONE slice, which is why the inbound leg needs the +separate `*Ret` keys rather than a second slice. + +## Presence is conditional + +With no routing codes set the SPA **omits all four keys**; with any set it +emits all four (blank string for the unused ones). `_spa_routing_fields` +mirrors that, so our links stay byte-identical to the app's own in both cases +— the tracked fixtures in `tests/fixtures/matrix_url/` cover both shapes. + +## Capture recipe + +Headless is blocked by `waa-pa` bot attestation; use real Chrome via +patchright (`AGENTS.md` has the full pattern). Watch two things at once: + +- `page.url` → decode the `search=` base64 for URL state +- `page.on("request")` filtered to `alkali`/`batch` → the API body + +Selector notes: `mat-input-*` ids are regenerated per render and useless; +`input[placeholder="Routing"]` / `"Extension"` are stable. Driving the whole +form programmatically is unreliable — the Search button stays disabled unless +the airport/date fields commit the way the Angular form expects. Filling the +routing boxes and having a human complete the search worked. diff --git a/src/flight_cli/links.py b/src/flight_cli/links.py index aa587ee..91dbff2 100644 --- a/src/flight_cli/links.py +++ b/src/flight_cli/links.py @@ -90,14 +90,45 @@ def _spa_specific_leg(leg: Leg, *, return_leg: Leg | None = None) -> dict[str, A "dates": { "searchDateType": "specific", "departureDate": leg.date.isoformat() if leg.date else "", - "departureDateType": "depart", + # "depart" | "arrive" — the SPA's encoding of arrival-date intent, + # the URL-state counterpart of the API's `isArrivalDate` bool. + "departureDateType": "arrive" if leg.is_arrival_date else "depart", "departureDateModifier": str(leg.date_minus), "departureDatePreferredTimes": [t.value for t in leg.time_ranges], "returnDate": return_date, - "returnDateType": "depart", + "returnDateType": "arrive" if (return_leg and return_leg.is_arrival_date) else "depart", "returnDateModifier": return_modifier, "returnDatePreferredTimes": return_times, }, + **_spa_routing_fields(leg, return_leg), + } + + +def _spa_routing_fields(leg: Leg, return_leg: Leg | None) -> dict[str, str]: + """Routing-language / extension-code keys for a SPA URL-state slice. + + The SPA names these `routing` / `ext` — NOT the `routeLanguage` / + `commandLine` the /batch API uses for the same values. Both shapes were + captured from the real UI: with codes set the slice carries all four keys + (`routingRet` / `extRet` hold the inbound leg's own codes); with none set + it omits them entirely, which is what the tracked fixtures show. We mirror + that, so a link is byte-identical to what the app itself would produce. + + Dropping these meant a link built from `--routing BA+ --ext "MAXSTOPS 0"` + opened an UNCONSTRAINED search — offering the user itineraries the CLI had + deliberately excluded. + """ + routing = leg.route_language or "" + ext = leg.extension or "" + routing_ret = (return_leg.route_language or "") if return_leg else "" + ext_ret = (return_leg.extension or "") if return_leg else "" + if not any((routing, ext, routing_ret, ext_ret)): + return {} + return { + "routing": routing, + "ext": ext, + "routingRet": routing_ret, + "extRet": ext_ret, } diff --git a/tests/fixtures/matrix_url/spa_routing_jfk_lhr.txt b/tests/fixtures/matrix_url/spa_routing_jfk_lhr.txt new file mode 100644 index 0000000..02d903c --- /dev/null +++ b/tests/fixtures/matrix_url/spa_routing_jfk_lhr.txt @@ -0,0 +1,46 @@ +# Captured 2026-08-01 by driving the real Matrix SPA (patchright + real +# Chrome), one-way JFK->LHR with Routing='BA+' and Extension='MAXSTOPS 0' +# set in Advanced controls. This is the shape the app writes into its own +# URL when routing codes ARE set; the other fixtures in this directory are +# captures with none set, where the SPA omits the four keys entirely. +# +# Note the names differ from the /batch API, which calls the same values +# routeLanguage / commandLine. + +{ + "type": "one-way", + "slices": [ + { + "origin": [ + "JFK" + ], + "dest": [ + "LHR" + ], + "routing": "BA+", + "ext": "MAXSTOPS 0", + "routingRet": "", + "extRet": "", + "dates": { + "searchDateType": "specific", + "departureDate": "2026-08-10", + "departureDateType": "depart", + "departureDateModifier": "0", + "departureDatePreferredTimes": [], + "returnDateType": "depart", + "returnDateModifier": "0", + "returnDatePreferredTimes": [] + } + } + ], + "options": { + "cabin": "COACH", + "stops": "-1", + "extraStops": "1", + "allowAirportChanges": "true", + "showOnlyAvailable": "true" + }, + "pax": { + "adults": "1" + } +} diff --git a/tests/test_links_matrix_url.py b/tests/test_links_matrix_url.py index 35af462..d73a3ee 100644 --- a/tests/test_links_matrix_url.py +++ b/tests/test_links_matrix_url.py @@ -279,3 +279,93 @@ def test_half_open_jaw_is_multi_city() -> None: ) trip, _ = _spa_specific_slices(legs) assert trip == "multi-city" + + +# ───────── routing / extension / arrival-date reach the deep link ───────── + + +def _decoded(s: object) -> dict[str, Any]: + import base64 + import json + import urllib.parse + + from flight_cli.links import matrix_deep_link + + q = urllib.parse.unquote(matrix_deep_link(s).split("search=", 1)[1]) # pyright: ignore[reportArgumentType] + return json.loads(base64.b64decode(q + "==")) + + +def test_routing_and_extension_reach_the_matrix_link() -> None: + """Field names captured from the real SPA (2026-08): the URL state calls + these `routing` / `ext`, NOT the `routeLanguage` / `commandLine` the /batch + API uses for the same values — see docs/memories/matrix_spa_url_state.md. + + Dropping them meant a link built from `--routing BA+ --ext "MAXSTOPS 0"` + opened an UNCONSTRAINED search, showing itineraries the CLI had excluded. + """ + from flight_cli.domain import Leg, SpecificDateSearch + + s = SpecificDateSearch( + legs=( + Leg( + origins=("JFK",), + destinations=("LHR",), + date=date(2026, 9, 1), + route_language="BA+", + extension="MAXSTOPS 0", + ), + ), + ) + sl = _decoded(s)["slices"][0] + assert sl["routing"] == "BA+" + assert sl["ext"] == "MAXSTOPS 0" + + +def test_return_leg_carries_its_own_routing_codes() -> None: + """The SPA folds a round trip into one slice, so the inbound leg's codes + live in the separate `routingRet` / `extRet` fields.""" + from flight_cli.domain import Leg, SpecificDateSearch + + s = SpecificDateSearch( + legs=( + Leg( + origins=("JFK",), + destinations=("LHR",), + date=date(2026, 9, 1), + route_language="BA+", + extension="MAXSTOPS 0", + ), + Leg( + origins=("LHR",), + destinations=("JFK",), + date=date(2026, 9, 8), + route_language="AA+", + extension="MAXCONNECT 2:00", + ), + ), + ) + sl = _decoded(s)["slices"][0] + assert (sl["routing"], sl["ext"]) == ("BA+", "MAXSTOPS 0") + assert (sl["routingRet"], sl["extRet"]) == ("AA+", "MAXCONNECT 2:00") + + +def test_arrival_date_intent_survives_into_the_link() -> None: + """The URL-state counterpart of the API's `isArrivalDate` bool is the + string `departureDateType: "arrive"`.""" + from flight_cli.domain import Leg, SpecificDateSearch + + def date_type(is_arrival: bool) -> str: + s = SpecificDateSearch( + legs=( + Leg( + origins=("JFK",), + destinations=("LHR",), + date=date(2026, 9, 1), + is_arrival_date=is_arrival, + ), + ), + ) + return _decoded(s)["slices"][0]["dates"]["departureDateType"] + + assert date_type(True) == "arrive" + assert date_type(False) == "depart" From 02c1f8f3d88bde8fa5216c4003fc65b06896998e Mon Sep 17 00:00:00 2001 From: ak2k <19240940+ak2k@users.noreply.github.com> Date: Sat, 1 Aug 2026 08:08:01 -0400 Subject: [PATCH 23/26] =?UTF-8?q?flight-cli:=20automate=20the=20Matrix=20S?= =?UTF-8?q?PA=20capture=20=E2=80=94=20no=20human=20step?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I said this needed a human to submit the form. That was wrong: I'd hit a disabled Search button, assumed the Angular form was resisting automation, and stopped. It was four ordinary selector/event mistakes, each of which silently leaves Search disabled: 1. Airports are an autocomplete — type, then CLICK the mat-option. `fill()` leaves the underlying model empty while the box looks populated. 2. The date input has NO placeholder; it needs `input.mat-datepicker-input`. 3. The date must be typed with `press_sequentially`. `fill()` sets the visible value but never fires the events Angular's form model listens for — so the date is plainly showing and Search is still disabled. This was the one that made it look like the app was fighting me. 4. `mat-input-*` ids regenerate per render, so any selector using them breaks on the next load. Order matters as well: pick airports BEFORE switching to One way, or the date control isn't rendered yet. `research/capture_matrix_spa.py` now runs the whole capture unattended and writes both surfaces — the SPA's URL state and the /batch request body. Verified end-to-end: it reproduced `routing: "BA+"`, `ext: "MAXSTOPS 0"` with no input from me. Whitelisted in .gitignore alongside the other kept research script, since the field names it recovers are what `links.py` is written from and the next SPA change will need it again. Memory updated with the recipe and all four traps. make check green: 613 tests. --- .gitignore | 1 + docs/memories/matrix_spa_url_state.md | 36 +++++-- research/capture_matrix_spa.py | 138 ++++++++++++++++++++++++++ 3 files changed, 164 insertions(+), 11 deletions(-) create mode 100644 research/capture_matrix_spa.py diff --git a/.gitignore b/.gitignore index 5194af0..7d9b518 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,7 @@ htmlcov/ # `research/*` not `research/` so git can still see inside the dir. research/* !research/extract_help_docs.py +!research/capture_matrix_spa.py # Claude Code per-session agent state. Project-shared `.claude/settings.json` # and `.claude/skills/` ARE checked in; per-machine override stays local. diff --git a/docs/memories/matrix_spa_url_state.md b/docs/memories/matrix_spa_url_state.md index 3b8dcf0..6e43521 100644 --- a/docs/memories/matrix_spa_url_state.md +++ b/docs/memories/matrix_spa_url_state.md @@ -28,14 +28,28 @@ mirrors that, so our links stay byte-identical to the app's own in both cases ## Capture recipe -Headless is blocked by `waa-pa` bot attestation; use real Chrome via -patchright (`AGENTS.md` has the full pattern). Watch two things at once: - -- `page.url` → decode the `search=` base64 for URL state -- `page.on("request")` filtered to `alkali`/`batch` → the API body - -Selector notes: `mat-input-*` ids are regenerated per render and useless; -`input[placeholder="Routing"]` / `"Extension"` are stable. Driving the whole -form programmatically is unreliable — the Search button stays disabled unless -the airport/date fields commit the way the Angular form expects. Filling the -routing boxes and having a human complete the search worked. +`research/capture_matrix_spa.py` does this **unattended** — re-run it whenever +the SPA changes: + + uv run --with patchright python research/capture_matrix_spa.py + +Headless is blocked by `waa-pa` bot attestation, so it drives real Chrome via +patchright, but needs no human. It records both surfaces at once: `page.url` +(decode the `search=` base64) and `page.on("request")` filtered to +`alkali`/`batch` (the API body). + +Four form-driving traps, each of which silently leaves Search **disabled**: + +1. Airports are an autocomplete — type, then **click the `mat-option`**. + `fill()` leaves the underlying model empty. +2. The date input has **no placeholder**; select it by + `input.mat-datepicker-input`. +3. The date must be typed with **`press_sequentially`**. `fill()` sets the + visible value but does not fire the events Angular's form model listens + for, so Search stays disabled with a date plainly showing — the most + misleading of the four. +4. `mat-input-*` ids are regenerated per render. Never select on them; + `input[placeholder="Routing"]` / `"Extension"` are stable. + +Order matters too: pick airports **before** switching to One way, or the date +control isn't rendered yet. diff --git a/research/capture_matrix_spa.py b/research/capture_matrix_spa.py new file mode 100644 index 0000000..c16798d --- /dev/null +++ b/research/capture_matrix_spa.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python +"""Capture the Matrix SPA's URL state + /batch request body, unattended. + +Why this exists: the SPA names things differently from the /batch API +(`routing`/`ext` vs `routeLanguage`/`commandLine`), so `links.py` cannot be +written from the API side — the field names have to be observed. See +docs/memories/matrix_spa_url_state.md. + +Run: uv run --with patchright python research/capture_matrix_spa.py + +Headless is blocked by Google's waa-pa bot attestation, so this drives real +Chrome via patchright. It still runs without supervision. + +Form-driving notes, each learned the hard way: + * Airports are an autocomplete: type, then CLICK the mat-option. `fill()` + leaves the model empty and Search stays disabled. + * The date input has NO placeholder — select it by `input.mat-datepicker-input`. + * The date must be typed with `press_sequentially`; `fill()` sets the value + but doesn't fire the events Angular's form model listens for, so Search + stays disabled with a date visibly present. + * `mat-input-*` ids are regenerated per render — never select on them. +""" + +from __future__ import annotations + +import base64 +import json +import pathlib +import sys +import urllib.parse + +from patchright.sync_api import Page, sync_playwright + +PROFILE = "/tmp/mx-capture-profile" +URL_OUT = pathlib.Path("/tmp/matrix_spa_state.json") +REQ_OUT = pathlib.Path("/tmp/matrix_batch_body.json") + + +def _decode_state(url: str) -> dict | None: + if "search=" not in url: + return None + q = urllib.parse.unquote(url.split("search=", 1)[1].split("&")[0]) + q += "=" * (-len(q) % 4) + try: + return json.loads(base64.b64decode(q)) + except Exception: + return None + + +def _pick_airport(pg: Page, index: int, code: str) -> None: + box = pg.locator('input[placeholder="Add airport"]').nth(index) + box.click() + box.type(code, delay=120) + pg.wait_for_timeout(1800) + option = pg.locator("mat-option, [role=option]").first + option.wait_for(state="visible", timeout=10_000) + option.click() + pg.wait_for_timeout(700) + + +def capture(origin: str, dest: str, date_mmddyyyy: str, routing: str, extension: str) -> int: + bodies: list[dict] = [] + + with sync_playwright() as p: + ctx = p.chromium.launch_persistent_context( + PROFILE, channel="chrome", headless=False, + viewport={"width": 1500, "height": 1000}, + ) + pg = ctx.pages[0] if ctx.pages else ctx.new_page() + + def on_request(req): + if "alkali" not in req.url and "batch" not in req.url: + return + body = req.post_data + if body and ("routeLanguage" in body or "commandLine" in body): + bodies.append({"url": req.url.split("?")[0], "body": body}) + + pg.on("request", on_request) + pg.goto("https://matrix.itasoftware.com/", wait_until="domcontentloaded") + pg.wait_for_timeout(5000) + + _pick_airport(pg, 0, origin) + _pick_airport(pg, 1, dest) + + pg.locator("text=One way").first.click() + pg.wait_for_timeout(1500) + + date_in = pg.locator("input.mat-datepicker-input").first + date_in.click() + pg.wait_for_timeout(600) + date_in.press_sequentially(date_mmddyyyy, delay=90) + pg.wait_for_timeout(800) + pg.keyboard.press("Tab") + pg.wait_for_timeout(1200) + + pg.locator("text=Advanced controls").first.click() + pg.wait_for_timeout(1500) + pg.locator('input[placeholder="Routing"]').first.fill(routing) + pg.locator('input[placeholder="Extension"]').first.fill(extension) + pg.wait_for_timeout(600) + + search = pg.locator('button:has-text("Search")').first + if search.get_attribute("disabled"): + print("Search still disabled — the form shape changed", file=sys.stderr) + ctx.close() + return 1 + search.click() + + state = None + for _ in range(90): + pg.wait_for_timeout(1000) + found = _decode_state(pg.url) + if found and (found.get("slices") or [{}])[0].get("routing"): + state = found + break + if found: + state = found + if bodies: + break + + if state: + URL_OUT.write_text(json.dumps(state, indent=1)) + print(f"URL state -> {URL_OUT}") + print(json.dumps(state["slices"][0], indent=1)) + if bodies: + REQ_OUT.write_text(json.dumps(bodies, indent=1)) + print(f"batch bodies -> {REQ_OUT} ({len(bodies)})") + ctx.close() + return 0 if state else 1 + + +if __name__ == "__main__": + raise SystemExit( + capture( + origin="JFK", dest="LHR", date_mmddyyyy="09/01/2026", + routing="BA+", extension="MAXSTOPS 0", + ) + ) From 48a7e36f762df01942792bc1113eff41d30d37b4 Mon Sep 17 00:00:00 2001 From: ak2k <19240940+ak2k@users.noreply.github.com> Date: Sat, 1 Aug 2026 23:32:37 -0400 Subject: [PATCH 24/26] flight-cli: create the PointsPath token file 0600 instead of widening it first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found while surveying what had NOT been reviewed. `save_tokens` wrote with `write_text` and then chmod'd to 0600 — but `write_text` creates at the process umask, so the file lands 0644 and the bearer token to a paid account is readable by group and other for the window between the two calls. Demonstrated directly: mode is 0o644 immediately after `write_text`, 0o600 only after the following line. Now opened with `os.open(..., O_CREAT | O_TRUNC, 0o600)` so the mode is in place at creation. The trailing chmod stays: an existing file keeps its old mode through O_CREAT, so a store written by an earlier version still gets tightened. Two tests — one asserting the creation mode (fails against the pre-fix code), one covering the save/load round trip, which had no coverage at all. make check green: 615 tests, ruff + basedpyright clean. --- src/flight_cli/pp/auth.py | 15 ++++++++-- tests/pp/test_auth_login.py | 58 +++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 2 deletions(-) diff --git a/src/flight_cli/pp/auth.py b/src/flight_cli/pp/auth.py index 5b375b8..6b3a456 100644 --- a/src/flight_cli/pp/auth.py +++ b/src/flight_cli/pp/auth.py @@ -116,9 +116,20 @@ def load_tokens() -> Tokens | None: def save_tokens(t: Tokens) -> None: + """Persist tokens 0600 — the file holds a bearer token to a paid account. + + Created 0600 rather than written and then chmod'd: `write_text` makes the + file at the process umask (0644 by default), so the previous + write-then-tighten left the token world-readable for the window between + those two calls. Opening with the mode up front closes it, and O_TRUNC + keeps the rewrite-in-place behaviour `write_text` had. + """ CONFIG_DIR.mkdir(parents=True, exist_ok=True) - TOKENS_PATH.write_text(json.dumps(t.to_json(), indent=2)) - # 0600 — contains a bearer token to a paid user account. + payload = json.dumps(t.to_json(), indent=2) + fd = os.open(TOKENS_PATH, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w") as fh: + _ = fh.write(payload) + # An existing file keeps its old mode through O_CREAT, so still enforce it. TOKENS_PATH.chmod(0o600) diff --git a/tests/pp/test_auth_login.py b/tests/pp/test_auth_login.py index 9d00222..4846f4f 100644 --- a/tests/pp/test_auth_login.py +++ b/tests/pp/test_auth_login.py @@ -10,6 +10,7 @@ import base64 import json +import os from typing import TYPE_CHECKING, Any import pytest @@ -172,3 +173,60 @@ def chrome(domains: list[str]) -> list[_Cookie]: t = login_from_chrome() assert t.user_email == "chrome@example.com" assert seen_domains == [["pointspath.com"]], "Should scope read to pointspath.com only" + + +# ───────── the token file is never briefly world-readable ───────── + + +def test_token_file_is_created_0600_not_widened_then_tightened( + tmp_path: pathlib.Path, monkeypatch: Any +) -> None: + """`write_text` creates at the process umask (0644 by default), so writing + then chmod'ing left a bearer token to a paid account world-readable for the + window between the two calls. The file must be created 0600.""" + import stat + + from flight_cli.pp import auth as auth_mod + + monkeypatch.setattr(auth_mod, "CONFIG_DIR", tmp_path) + monkeypatch.setattr(auth_mod, "TOKENS_PATH", tmp_path / "pp.json") + + modes: list[int] = [] + real_open = os.open + + def spy(path: Any, flags: int, mode: int = 0o777, **kw: Any) -> int: + if str(path).endswith("pp.json"): + modes.append(mode) + return real_open(path, flags, mode, **kw) + + monkeypatch.setattr(os, "open", spy) + auth_mod.save_tokens( + auth_mod.Tokens( + access_token="A", # noqa: S106 — dummy test value + refresh_token="R", # noqa: S106 — dummy test value + expires_at=9_999_999_999, + user_email="x@y.z", + ), + ) + # Requested at creation, not applied afterwards. + assert modes == [0o600] + final = stat.S_IMODE((tmp_path / "pp.json").stat().st_mode) + assert final == 0o600 + + +def test_saved_tokens_round_trip(tmp_path: pathlib.Path, monkeypatch: Any) -> None: + from flight_cli.pp import auth as auth_mod + + monkeypatch.setattr(auth_mod, "CONFIG_DIR", tmp_path) + monkeypatch.setattr(auth_mod, "TOKENS_PATH", tmp_path / "pp.json") + original = auth_mod.Tokens( + access_token="ACCESS", # noqa: S106 — dummy test value + refresh_token="REFRESH", # noqa: S106 — dummy test value + expires_at=9_999_999_999, + user_email="x@y.z", + ) + auth_mod.save_tokens(original) + loaded = auth_mod.load_tokens() + assert loaded is not None + assert loaded.access_token == "ACCESS" # noqa: S105 — asserting a dummy round-trip + assert loaded.user_email == "x@y.z" From fc32be1da8ac11ee89c563b9a01ee1b131c5dd4e Mon Sep 17 00:00:00 2001 From: ak2k <19240940+ak2k@users.noreply.github.com> Date: Sun, 2 Aug 2026 00:12:14 -0400 Subject: [PATCH 25/26] flight-cli: fix four cli.py defects found by cross-model adversarial review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `cli.py` was the last large module never adversarially reviewed. A codex pass found seven issues; a subagent independently reproduced all seven by execution before I touched anything. Four are fixed here, each demonstrated against the pre-fix code. `--pick` could name a row the user never saw. `_render_search` hardcoded `solutions[:10]` while `_pinned_solution_index` validated against `len(res.solutions)`. With `-n 15 --pick 15` the table printed 10 rows and the booking link then labelled itself "itinerary #15 pinned" — no out-of-range warning, because 15 WAS in range for the unrendered list. The render limit is now a parameter fed by `--page-size`, and `--pick` is bounded by what was actually rendered. Confirmed pre-fix: index 14 pinned from a 10-row table. Infant-in-seat was dropped from the award seat count. Five copies of `p.adults + p.children + p.seniors + p.youth` omitted it, so the award query asked for fewer seats than the cash query on the same run and an award with too little availability rendered as bookable. Replaced with one `_seated_pax` helper — an infant in seat buys a seat, only a lap infant doesn't. The Google Flights bridge invented an adult and dropped infants. `adults=(...) or 1` turned `--adults 0 --children 1` into a two-passenger search, and infants never reached `PassengerInfo` at all. fli permits `adults=0` and takes all four types, so the party now passes through as asked. Confirmed pre-fix: `(0,1)` became `(1,1,0,0)`; `infants_in_seat=1` became `0`. Routing post-filter ran after truncation. `-n 1 --routing AA+` fetched exactly one itinerary, discarded it for violating the routing, and reported no results while a qualifying one sat at rank 2. Now over-fetches (bounded 5x) when a Tier-2 filter will run, then re-slices to the user's `top_n`. Seven tests. Note the remaining three findings — silent calendar sub-query failures, the unconstrained multi-cabin Google link, and seats.aero ignoring party size — are real and reproduced but not yet fixed. make check green: 622 tests, ruff + basedpyright clean. --- src/flight_cli/cli.py | 89 +++++++++++++++++++++++----- src/flight_cli/fli_bridge.py | 9 ++- tests/test_cli_pax_and_pick.py | 103 +++++++++++++++++++++++++++++++++ 3 files changed, 184 insertions(+), 17 deletions(-) create mode 100644 tests/test_cli_pax_and_pick.py diff --git a/src/flight_cli/cli.py b/src/flight_cli/cli.py index 095d0cd..939ba80 100644 --- a/src/flight_cli/cli.py +++ b/src/flight_cli/cli.py @@ -730,7 +730,11 @@ async def _go() -> None: _emit_urls(search, matrix_url=matrix_url, google_url=google_url) -def _pinned_solution_index(result: SearchResult | None, pick: int | None) -> int | None: +def _pinned_solution_index( + result: SearchResult | None, + pick: int | None, + rendered: int | None = None, +) -> int | None: """0-based index into `result.solutions` of the itinerary to pin in a deep link. `pick` is the 1-based itinerary number the user saw in the table; None pins the cheapest (row 1). Out-of-range picks warn and fall back to @@ -740,9 +744,13 @@ def _pinned_solution_index(result: SearchResult | None, pick: int | None) -> int return None if pick is None: return 0 - if pick < 1 or pick > len(result.solutions): + # Bound by what the user could actually SEE. Validating against + # `len(result.solutions)` accepted a `--pick` beyond the rendered table and + # then labelled the link "itinerary #N pinned" for a row never displayed. + upper = len(result.solutions) if rendered is None else min(rendered, len(result.solutions)) + if pick < 1 or pick > upper: console.print( - f"[yellow]--pick {pick} is out of range (1-{len(result.solutions)}); " + f"[yellow]--pick {pick} is out of range (1-{upper}); " f"pinning the cheapest itinerary instead.[/]" ) return 0 @@ -816,7 +824,7 @@ def _overlay_awards( run_pp_for_search( matrix_res, legs=_build_pp_legs(legs), - num_passengers=p.adults + p.children + p.seniors + p.youth, + num_passengers=_seated_pax(p), airlines=sel.pp_airlines() if sel is not None else None, cabins=sel.pp_cabins() if sel is not None else None, pp_only=awards_only, @@ -855,8 +863,9 @@ def _emit_urls( google_url: bool, result: SearchResult | None = None, pick: int | None = None, + rendered: int | None = None, ) -> None: - idx = _pinned_solution_index(result, pick) + idx = _pinned_solution_index(result, pick, rendered) # Only claim "#N" when we actually honored the user's pick; an out-of-range # pick falls back to idx 0 and must not mislabel the cheapest as "#N". pinned_label = ( @@ -945,7 +954,29 @@ def _fmt_slice_cell(s: Slice) -> str: return f"{head}\n{tail}" if tail else head -def _render_search(res: SearchResult) -> None: +def _seated_pax(p: Pax) -> int: + """Occupants needing their own seat. + + An infant IN SEAT buys a seat, so it counts; only a LAP infant does not. + Omitting it made the award query ask for fewer seats than the cash query + on the same run, so an award with too little availability rendered as + bookable for the party. + """ + return p.adults + p.children + p.seniors + p.youth + p.infants_in_seat + + +_DEFAULT_RENDER_LIMIT = 10 # matches the `-n/--page-size` default + + +def _render_search(res: SearchResult, limit: int = _DEFAULT_RENDER_LIMIT) -> None: + """Render the itinerary table, showing at most `limit` rows. + + `limit` MUST be the same bound `--pick` is validated against. It was + hardcoded to 10 while `--pick` checked against `len(res.solutions)`, so + `-n 15 --pick 15` printed 10 rows and then emitted a booking link labelled + "itinerary #15 pinned" for a row the user never saw — with no out-of-range + warning, because 15 was in range for the unrendered list. + """ if res.solution_count == 0: console.print("[yellow]No solutions returned.[/]") return @@ -983,7 +1014,7 @@ def _render_search(res: SearchResult) -> None: st.add_column("carriers") st.add_column("outbound") st.add_column("return") - for i, it in enumerate(res.solutions[:10], 1): + for i, it in enumerate(res.solutions[:limit], 1): itn = it.itinerary slcs: list[Slice] = itn.slices if itn else [] it_carriers = ",".join((c.code or "?") for c in (itn.carriers if itn else [])) @@ -1174,13 +1205,13 @@ def _run_matrix_path( sys.stdout.write(json.dumps(res.raw, indent=2)) return if not sel.awards_only: - _render_search(res) + _render_search(res, opts.page_size) if run_pp: p = opts.pax run_pp_for_search( res, legs=_build_pp_legs(legs), - num_passengers=p.adults + p.children + p.seniors + p.youth, + num_passengers=_seated_pax(p), airlines=sel.pp_airlines(), cabins=sel.pp_cabins(), pp_only=sel.awards_only, @@ -1190,7 +1221,20 @@ def _run_matrix_path( cash_per_cabin=_cash_per_cabin_single(res, opts.cabin), ) # `res` was cast to SearchResult at the top of this function; safe to pass through. - _emit_urls(search, matrix_url=matrix_url, google_url=google_url, result=res, pick=pick) + _emit_urls( + search, + matrix_url=matrix_url, + google_url=google_url, + result=res, + pick=pick, + rendered=opts.page_size, + ) + + +# How much deeper to fetch when a Tier-2 routing post-filter will discard rows. +# Bounded rather than unlimited: Google's own result depth is finite and each +# extra page costs a round trip. +_POSTFILTER_OVERFETCH = 5 def _gflight_results(legs: tuple[Leg, ...], opts: SearchOptions, top_n: int) -> list[Any]: @@ -1211,12 +1255,20 @@ def _gflight_results(legs: tuple[Leg, ...], opts: SearchOptions, top_n: int) -> out_constraints = classify(legs[0].route_language, legs[0].extension) if legs else None if out_constraints and out_constraints.predicates: apply_gf_native_filters(fli_filter, out_constraints.predicates) - results: list[Any] = search_with_ids(fli_filter, top_n=top_n) or [] per_slice_preds = [list(classify(lg.route_language, lg.extension).predicates) for lg in legs] + + # Fetch deeper than we need when a Tier-2 post-filter will run, because it + # drops rows AFTER truncation: `-n 1 --routing AA+` fetched exactly one + # itinerary, discarded it for violating the routing, and reported "no + # results" while a qualifying one sat at rank 2. Over-fetching lets the + # filter choose from a real candidate pool; the final slice below still + # honours the user's `top_n`. + fetch_n = top_n * _POSTFILTER_OVERFETCH if any(per_slice_preds) else top_n + results: list[Any] = search_with_ids(fli_filter, top_n=fetch_n) or [] if results and any(per_slice_preds): keep = set(surviving_indices(fli_results_to_search_result(results), per_slice_preds)) results = [r for i, r in enumerate(results) if i in keep] - return results + return results[:top_n] _MERGE_SOURCE_TAG = {"both": "GF+MX", "matrix": "MX", "gf": "GF"} @@ -1320,7 +1372,7 @@ def _run_gflight_path( run_pp_for_search( sr, legs=_build_pp_legs(legs), - num_passengers=p.adults + p.children + p.seniors + p.youth, + num_passengers=_seated_pax(p), airlines=sel.pp_airlines() if sel is not None else None, cabins=sel.pp_cabins() if sel is not None else None, pp_only=awards_only, @@ -1428,7 +1480,12 @@ async def _go() -> None: _overlay_awards(matrix_res, legs=legs, opts=opts, sel=sel, awards_only=awards_only) _emit_urls( - matrix_search, matrix_url=matrix_url, google_url=google_url, result=pin_res, pick=pick + matrix_search, + matrix_url=matrix_url, + google_url=google_url, + result=pin_res, + pick=pick, + rendered=top_n, ) @@ -1720,7 +1777,7 @@ def _run_matrix_path_multi( run_pp_for_search( merged, legs=_build_pp_legs(legs), - num_passengers=p.adults + p.children + p.seniors + p.youth, + num_passengers=_seated_pax(p), airlines=sel.pp_airlines(), cabins=_pp_cabins_for_multi(sel, cabins), pp_only=sel.awards_only, @@ -1787,7 +1844,7 @@ def _run_gflight_path_multi( run_pp_for_search( merged, legs=_build_pp_legs(legs), - num_passengers=p.adults + p.children + p.seniors + p.youth, + num_passengers=_seated_pax(p), airlines=sel.pp_airlines(), cabins=_pp_cabins_for_multi(sel, cabins), pp_only=sel.awards_only, diff --git a/src/flight_cli/fli_bridge.py b/src/flight_cli/fli_bridge.py index 2d726dd..20cc1aa 100644 --- a/src/flight_cli/fli_bridge.py +++ b/src/flight_cli/fli_bridge.py @@ -111,9 +111,16 @@ def _seg(origin: str, dest: str, dt: str) -> FlightSegment: p = s.options.pax return FlightSearchFilters( + # fli's PassengerInfo takes all four types and permits adults=0, so + # pass the party through as asked. The old `or 1` SYNTHESIZED an adult + # for a child-only search — pricing a 2-passenger trip nobody + # requested — and infants were dropped entirely, so an infant-in-seat + # search silently priced one fewer seat than the Matrix side. passenger_info=PassengerInfo( - adults=(p.adults + p.seniors + p.youth) or 1, + adults=p.adults + p.seniors + p.youth, children=p.children, + infants_in_seat=p.infants_in_seat, + infants_on_lap=p.infants_in_lap, ), flight_segments=segs, stops=stops, diff --git a/tests/test_cli_pax_and_pick.py b/tests/test_cli_pax_and_pick.py new file mode 100644 index 0000000..07909f8 --- /dev/null +++ b/tests/test_cli_pax_and_pick.py @@ -0,0 +1,103 @@ +# pyright: reportPrivateUsage=false +"""Regressions for cli.py findings from the codex adversarial pass. + +All four were the house failure class: a plausible artifact making a claim +that isn't true of the search the user asked for. +""" + +from __future__ import annotations + +from datetime import date +from typing import Any + +from flight_cli.cli import _pinned_solution_index, _seated_pax +from flight_cli.domain import Leg, Pax, SearchOptions, SpecificDateSearch +from flight_cli.models import ( + Itinerary, + ItineraryDetails, + SearchResult, + Slice, + SliceEndpoint, +) + + +def _res(n: int) -> SearchResult: + sols = [ + Itinerary( + id=f"sol-{i}", + displayTotal=f"USD{i:03d}.00", + itinerary=ItineraryDetails( + slices=[ + Slice( + flights=[f"AA{i}"], + departure="2026-09-01T06:00", + origin=SliceEndpoint(code="JFK"), + destination=SliceEndpoint(code="LHR"), + ), + ], + carriers=[], + ), + ) + for i in range(1, n + 1) + ] + return SearchResult(solutionCount=n, solutions=sols) # pyright: ignore[reportCallIssue] + + +# ───────────── --pick must not name a row the user never saw ───────────── + + +def test_pick_beyond_the_rendered_table_falls_back() -> None: + """The table hardcoded 10 rows while `--pick` validated against the full + solution list, so `-n 15 --pick 15` printed 10 rows and then emitted a + booking link labelled "itinerary #15 pinned" — for a row never displayed, + and with no out-of-range warning because 15 was in range for the + unrendered list.""" + assert _pinned_solution_index(_res(15), 15, 10) == 0 # fell back to cheapest + + +def test_pick_within_the_rendered_table_is_honoured() -> None: + assert _pinned_solution_index(_res(15), 8, 10) == 7 + + +def test_pick_is_honoured_when_the_table_was_widened() -> None: + """`-n 15` renders 15 rows, so `--pick 15` is now legitimate.""" + assert _pinned_solution_index(_res(15), 15, 15) == 14 + + +# ───────────── every seated passenger reaches both backends ───────────── + + +def test_infant_in_seat_counts_toward_the_award_seat_count() -> None: + """An infant IN SEAT buys a seat; only a LAP infant doesn't. Omitting it + made the award query ask for fewer seats than the cash query on the same + run, so an award with too little availability rendered as bookable.""" + p = Pax(adults=2, children=1, infants_in_seat=1, infants_in_lap=1) + assert _seated_pax(p) == 4 + + +def test_lap_infant_does_not_consume_a_seat() -> None: + assert _seated_pax(Pax(adults=1, infants_in_lap=1)) == 1 + + +def _fli_pax(**kw: Any) -> Any: + from flight_cli.fli_bridge import to_fli_filter + + s = SpecificDateSearch( + legs=(Leg(origins=("JFK",), destinations=("LHR",), date=date(2026, 9, 1)),), + options=SearchOptions(pax=Pax(**kw)), + ) + return to_fli_filter(s).passenger_info + + +def test_child_only_search_does_not_synthesize_an_adult() -> None: + """`adults=(...) or 1` invented an adult for a child-only search, pricing a + two-passenger trip nobody asked for. fli permits adults=0.""" + pi = _fli_pax(adults=0, children=1) + assert (pi.adults, pi.children) == (0, 1) + + +def test_infants_reach_the_google_flights_bridge() -> None: + """Infants were dropped entirely on this path, so an infant-in-seat search + priced one fewer seat than the Matrix side of the same run.""" + pi = _fli_pax(adults=2, children=1, infants_in_seat=1, infants_in_lap=1) + assert (pi.adults, pi.children, pi.infants_in_seat, pi.infants_on_lap) == (2, 1, 1, 1) From 9d054eecbfd3e1503dc7ea54a73877e3fcc3edc7 Mon Sep 17 00:00:00 2001 From: ak2k <19240940+ak2k@users.noreply.github.com> Date: Sun, 2 Aug 2026 00:20:25 -0400 Subject: [PATCH 26/26] flight-cli: fix the remaining three cli.py findings + the null-price crash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the codex adversarial pass on cli.py. Calendar sub-query failures were invisible. `_gather_calendar` swallowed every exception to None and filtered it out, and `_run_calendar` returned the REQUESTED fan-out size regardless of how many succeeded. Two destinations could vanish from a multi-airport grid with an empty stderr, and "cheapest destination" was then computed over an incomplete set and presented as the answer. Failures are now named per route, the count reflects what actually succeeded, and the note says "origin/destination groups" — it was reporting pairs while claiming destinations. The Google link on a multi-airport routed search was quietly narrower than the table above it. `fast_flights`' tfs= encoder takes exactly one airport pair and has no routing field — a real encoder limit, not our bug — so rows flying EWR->LGW under `--routing AA+` sat beside a link searching JFK->LHR unconstrained, with the faithful Matrix link right next to it. Rather than drop the link (it is still a useful starting point, and booking hands off to Google), it now states each way it differs. seats.aero's seat count never reached the renderer. `RemainingSeats` was parsed and discarded, so a 57,500-mile business award with ONE seat rendered as available to a party of four. `CabinAward.remaining_seats` carries it and the cell flags an affirmative shortfall. `None` stays "not reported" — PointsPath never reports it and seats.aero's 0 is usually staleness, so neither becomes a claim of no availability. Also the one hard crash in the set: `_render_gflight_table` formatted `fr.price:.2f` unguarded while the adapter deliberately tolerates fli's `price=None`, so a single price-less row raised TypeError and took down the whole cash table. Now renders "—". Eight tests. All seven findings from this review are now fixed. make check green: 627 tests, ruff + basedpyright clean. --- src/flight_cli/cli.py | 61 ++++++++++++- src/flight_cli/pp/cli.py | 50 ++++++++--- src/flight_cli/providers/base.py | 5 ++ .../providers/seats_aero/provider.py | 3 + tests/pp/test_cli.py | 2 +- tests/test_cli_pax_and_pick.py | 89 +++++++++++++++++++ 6 files changed, 194 insertions(+), 16 deletions(-) diff --git a/src/flight_cli/cli.py b/src/flight_cli/cli.py index 939ba80..41bd5d1 100644 --- a/src/flight_cli/cli.py +++ b/src/flight_cli/cli.py @@ -560,18 +560,37 @@ async def _gather_calendar( just drops its destination from the merge rather than sinking the whole run.""" results: list[CalendarResult | None] = [None] * len(subs) + failures: list[tuple[int, str]] = [] + async def one(i: int, s: CalendarSearch) -> None: try: results[i] = cast("CalendarResult", await c.execute(s, cache=cache)) - except Exception: # noqa: BLE001 — a sub-query failure just drops that destination + except Exception as e: # noqa: BLE001 — a sub-query failure just drops that destination results[i] = None + failures.append((i, str(e) or type(e).__name__)) async with anyio.create_task_group() as tg: for i, s in enumerate(subs): tg.start_soon(one, i, s) + + # A dropped sub-query silently removes its destination from the grid, so + # "cheapest destination" would be computed over an incomplete set and + # presented as the answer. Name what is missing. + for i, msg in sorted(failures): + route = _calendar_route_label(subs[i]) + err.print(f"[yellow]{route}: sub-query failed, omitted from the grid — {msg}[/]") + return [r for r in results if r is not None] +def _calendar_route_label(s: CalendarSearch) -> str: + """`JFK,EWR→LHR` for a sub-query, for failure messages.""" + if not s.legs: + return "?" + leg = s.legs[0] + return f"{','.join(leg.origins)}→{','.join(leg.destinations)}" + + def _run_calendar( search: CalendarSearch, *, @@ -619,7 +638,9 @@ async def go() -> tuple[CalendarResult, int]: return cast("CalendarResult", await c.execute(search, cache=not no_cache)), 0 recovered = await _gather_calendar(c, subs, cache=not no_cache) merged = merge_calendar_results(recovered) - return (merged, n) if not is_empty_calendar(merged) else (merged, 0) + # Count what SUCCEEDED. Returning `n` (the requested fan-out size) + # reported a complete merge even when sub-queries had been dropped. + return (merged, len(recovered)) if not is_empty_calendar(merged) else (merged, 0) try: return anyio.run(go) @@ -894,6 +915,8 @@ def _emit_urls( else: console.print("[dim]Google Flights (tfs= structured):[/]") console.print(f" [link]{google_flights_url(search)}[/]") + for note in _gflight_url_caveats(search): + console.print(f" [yellow]note: {note}[/]") except Exception as e: # noqa: BLE001 - third-party undocumented errors; non-fatal fallback console.print(f"[dim]Google Flights link: {e}[/]") @@ -901,6 +924,32 @@ def _emit_urls( # ─────────────────────────── result renderers ────────────────────────────── +def _gflight_url_caveats(search: Search) -> list[str]: + """Ways the emitted Google link is NARROWER than the search it came from. + + `fast_flights`' tfs= encoder takes exactly one origin and one destination + per leg and has no routing-language field, so a multi-airport or routed + search silently degrades: rows flying EWR->LGW under `--routing AA+` sat + beside a link that searched JFK->LHR unconstrained, and nothing said so. + The Matrix link on the same output IS faithful, which made the two + disagree with no explanation. + + We still emit the link — it is a useful starting point, and booking hands + off to Google — but the ways it differs are now stated. + """ + legs: tuple[Leg, ...] = getattr(search, "legs", ()) or () + notes: list[str] = [] + if legs and any(len(lg.origins) > 1 or len(lg.destinations) > 1 for lg in legs): + first = legs[0] + notes.append( + f"multi-airport search narrowed to {first.origins[0]}→{first.destinations[0]} " + "(Google's link format takes one airport pair)" + ) + if any(lg.route_language or lg.extension for lg in legs): + notes.append("routing/extension codes are not expressible in a Google link") + return notes + + def _parse_iso(s: str) -> datetime | None: """Best-effort parse of a slice timestamp ("YYYY-MM-DDTHH:MM[:SS]").""" if not s: @@ -1955,7 +2004,11 @@ def _render_gflight_table( any_legroom = True t.add_row( label, - f"{fr.currency or 'USD'}{fr.price:.2f}", + # fli types `price` as `NonNegativeFloat | None` ("None when + # not surfaced"). The gflight adapter already tolerates that; + # this renderer formatted it unguarded and raised TypeError, + # so one price-less row took down the whole cash table. + (f"{fr.currency or 'USD'}{fr.price:.2f}" if fr.price is not None else "—"), str(fr.stops), dur, legs_str, @@ -2986,7 +3039,7 @@ def calendar( return if n_split: console.print( - f"[dim]Queried {n_split} destinations separately and merged — Matrix " + f"[dim]Queried {n_split} origin/destination groups separately and merged — Matrix " f"under-reports the combined multi-airport calendar grid.[/]" ) _render_calendar(res, dmin=dmin, dmax=dmax, origin=origins, destination=dests, sd=sd, ed=ed) diff --git a/src/flight_cli/pp/cli.py b/src/flight_cli/pp/cli.py index 00b7c31..7c587f3 100644 --- a/src/flight_cli/pp/cli.py +++ b/src/flight_cli/pp/cli.py @@ -387,6 +387,7 @@ async def _go() -> list[list[AwardFlight]]: cabin_list, slice_index=leg.slice_index, cash_per_cabin=cash_per_cabin, + num_passengers=num_passengers, ) @@ -454,7 +455,7 @@ def _fmt_iso_compact(s: str) -> str: def _best_award_for_cabin( award_flights: list[AwardFlight], want_cabin: str -) -> tuple[int, float, str, list[str], str] | None: +) -> tuple[int, float, str, list[str], str, int | None] | None: """Cheapest offer across providers for one cabin, or None. Ranked on (miles, tax) — miles first, since that is the scarce currency, @@ -466,12 +467,19 @@ def _best_award_for_cabin( presenting one under the plain "Economy" heading overstates what the traveller gets. `_basic_economy_award_for_cabin` surfaces them explicitly. """ - best: tuple[int, float, str, list[str], str] | None = None + best: tuple[int, float, str, list[str], str, int | None] | None = None for af in award_flights: for ca in af.cabins: if ca.cabin != want_cabin or ca.is_basic_economy: continue - key = (ca.miles, ca.tax_usd, af.program, af.funding_banks, ca.tax_currency) + key = ( + ca.miles, + ca.tax_usd, + af.program, + af.funding_banks, + ca.tax_currency, + ca.remaining_seats, + ) if best is None or (key[0], key[1]) < (best[0], best[1]): best = key return best @@ -479,23 +487,33 @@ def _best_award_for_cabin( def _basic_economy_award_for_cabin( award_flights: list[AwardFlight], want_cabin: str -) -> tuple[int, float, str, list[str], str] | None: +) -> tuple[int, float, str, list[str], str, int | None] | None: """Cheapest BASIC-economy offer for one cabin — the fares `_best_award_for_cabin` deliberately skips. Rendered with an explicit label so the restriction is visible rather than implied.""" - best: tuple[int, float, str, list[str], str] | None = None + best: tuple[int, float, str, list[str], str, int | None] | None = None for af in award_flights: for ca in af.cabins: if ca.cabin != want_cabin or not ca.is_basic_economy: continue - key = (ca.miles, ca.tax_usd, af.program, af.funding_banks, ca.tax_currency) + key = ( + ca.miles, + ca.tax_usd, + af.program, + af.funding_banks, + ca.tax_currency, + ca.remaining_seats, + ) if best is None or (key[0], key[1]) < (best[0], best[1]): best = key return best def _fmt_award_cell( - award_flights: list[AwardFlight], want_cabin: str, cash_usd: float | None = None + award_flights: list[AwardFlight], + want_cabin: str, + cash_usd: float | None = None, + pax: int = 1, ) -> str: """Render the best (lowest miles) offer across providers for one cabin. @@ -514,12 +532,21 @@ def _fmt_award_cell( label = " [dim](basic)[/]" if best is None: return "—" - miles, tax, program, _banks, tax_ccy = best + miles, tax, program, _banks, tax_ccy, seats = best # Print the tax in the currency it is actually denominated in. Formatting a # EUR amount as "$" both misstates it and invites the reader to add it to a # USD fare. tax_str = f"${tax:.0f}" if tax_ccy in ("", "USD") else f"{tax:.0f} {tax_ccy}" - head = f"{_fmt_miles(miles)} {program} + {tax_str}{label}" + # An award with fewer seats than the party cannot be booked for it. The + # provider reports this; we were discarding it, so a 1-seat fare rendered + # as available for a party of four. `None` means "not reported" (PointsPath + # never does), so only an affirmative shortfall is flagged. + short = ( + f" [yellow]({seats} seat{'s' if seats != 1 else ''})[/]" + if (seats is not None and pax > 0 and seats < pax) + else "" + ) + head = f"{_fmt_miles(miles)} {program} + {tax_str}{label}{short}" if cash_usd is None: return head # ¢/mi nets the tax off a USD cash fare, so a non-USD tax would silently @@ -543,7 +570,7 @@ def _fmt_funding(award_flights: list[AwardFlight], cabins: tuple[str, ...] = ()) shows; `cabins` empty keeps the old union for callers with no cabin context. """ - winners: list[tuple[int, float, str, list[str], str]] = [] + winners: list[tuple[int, float, str, list[str], str, int | None]] = [] for cab in cabins: for pick in ( _best_award_for_cabin(award_flights, cab), @@ -607,6 +634,7 @@ def _render_matches( *, slice_index: int = 0, cash_per_cabin: Mapping[int, Mapping[str, float]] | None = None, + num_passengers: int = 1, ) -> None: matches = _dedupe_per_leg(matches, slice_index=slice_index) if not matches: @@ -651,7 +679,7 @@ def _render_matches( # — otherwise the value would mix cabins (e.g. business miles vs # economy cash) and mislead. Cabins without per-cabin cash render # the award without a ¢/mi line. - cells.append(_fmt_award_cell(m.awards, cab, per_cabin_cash.get(cab))) + cells.append(_fmt_award_cell(m.awards, cab, per_cabin_cash.get(cab), num_passengers)) if show_funding: cells.append(_fmt_funding(m.awards, tuple(cabin_list))) t.add_row(*cells) diff --git a/src/flight_cli/providers/base.py b/src/flight_cli/providers/base.py index 7d5b9c1..e24b5ba 100644 --- a/src/flight_cli/providers/base.py +++ b/src/flight_cli/providers/base.py @@ -46,6 +46,11 @@ class CabinAward: tax_usd: float tax_currency: str is_basic_economy: bool | None = None + # Seats the provider says are left at this price, when it says. None means + # "not reported" — NOT "none available"; PointsPath doesn't expose it and + # seats.aero's value is often 0 through staleness rather than sell-out. + # The renderer uses it only to flag an award that cannot seat the party. + remaining_seats: int | None = None @dataclass diff --git a/src/flight_cli/providers/seats_aero/provider.py b/src/flight_cli/providers/seats_aero/provider.py index 02b809a..7b02c23 100644 --- a/src/flight_cli/providers/seats_aero/provider.py +++ b/src/flight_cli/providers/seats_aero/provider.py @@ -162,6 +162,9 @@ def _group_trips_to_awards( miles=t.MileageCost, tax_usd=t.TotalTaxes / 100.0, # seats.aero returns cents tax_currency=tax_currency, + # 0 is seats.aero's "unknown/stale", not a hard zero — see the + # field comment in models.py — so don't turn it into a claim. + remaining_seats=t.RemainingSeats or None, ) if af is None: grouped[key] = AwardFlight( diff --git a/tests/pp/test_cli.py b/tests/pp/test_cli.py index 0dd0e52..b7edf7f 100644 --- a/tests/pp/test_cli.py +++ b/tests/pp/test_cli.py @@ -133,7 +133,7 @@ def test_best_award_for_cabin_picks_cheapest_in_miles(): ] best = _best_award_for_cabin(awards, "Economy") assert best is not None - miles, _tax, program, _banks, _ccy = best + miles, _tax, program, _banks, _ccy, _seats = best assert (miles, program) == (30_000, "Cheap") diff --git a/tests/test_cli_pax_and_pick.py b/tests/test_cli_pax_and_pick.py index 07909f8..2fc4da9 100644 --- a/tests/test_cli_pax_and_pick.py +++ b/tests/test_cli_pax_and_pick.py @@ -101,3 +101,92 @@ def test_infants_reach_the_google_flights_bridge() -> None: priced one fewer seat than the Matrix side of the same run.""" pi = _fli_pax(adults=2, children=1, infants_in_seat=1, infants_in_lap=1) assert (pi.adults, pi.children, pi.infants_in_seat, pi.infants_on_lap) == (2, 1, 1, 1) + + +# ───────── the Google link states how it differs from the search ───────── + + +def test_multi_airport_and_routing_search_gets_caveats() -> None: + """`fast_flights`' tfs= format takes one airport pair and has no routing + field, so a multi-airport routed search silently degrades: rows flying + EWR->LGW under `--routing AA+` sat beside a link searching JFK->LHR + unconstrained, while the Matrix link on the same output was faithful.""" + from flight_cli.cli import _gflight_url_caveats + + s = SpecificDateSearch( + legs=( + Leg( + origins=("JFK", "EWR", "LGA"), + destinations=("LHR", "LGW"), + date=date(2026, 9, 1), + route_language="AA+", + extension="f bc=J", + ), + ), + ) + notes = _gflight_url_caveats(s) + assert any("multi-airport" in n and "JFK→LHR" in n for n in notes) + assert any("routing/extension" in n for n in notes) + + +def test_plain_search_gets_no_caveats() -> None: + """A search the link CAN express must not be annotated.""" + from flight_cli.cli import _gflight_url_caveats + + s = SpecificDateSearch( + legs=(Leg(origins=("JFK",), destinations=("LHR",), date=date(2026, 9, 1)),), + ) + assert _gflight_url_caveats(s) == [] + + +# ───────── an award without enough seats is flagged, not hidden ───────── + + +def _award(miles: int, seats: int | None) -> Any: + from flight_cli.providers.base import AwardFlight, CabinAward + + return AwardFlight( + origin="JFK", + destination="LHR", + departure="d", + arrival="a", + flight_number="AA100", + num_connections=0, + provider="Seats.aero", + program="American Airlines", + miles_to_cash_ratio=0.0125, + funding_banks=["Chase"], + cabins=[ + CabinAward( + cabin="Business", + miles=miles, + tax_usd=5.6, + tax_currency="USD", + remaining_seats=seats, + ), + ], + ) + + +def test_award_with_fewer_seats_than_the_party_is_flagged() -> None: + """seats.aero reports RemainingSeats and the provider discarded it, so a + 57,500-mile business award with ONE seat rendered as available to a party + of four.""" + from flight_cli.pp.cli import _fmt_award_cell + + cell = _fmt_award_cell([_award(57_500, 1)], "Business", None, 4) + assert "1 seat" in cell + + +def test_sufficient_seats_are_not_flagged() -> None: + from flight_cli.pp.cli import _fmt_award_cell + + assert "seat" not in _fmt_award_cell([_award(57_500, 4)], "Business", None, 4) + + +def test_unreported_seat_count_is_not_treated_as_zero() -> None: + """None means "not reported" — PointsPath never reports it, and + seats.aero's 0 is usually staleness. Neither is a claim of no seats.""" + from flight_cli.pp.cli import _fmt_award_cell + + assert "seat" not in _fmt_award_cell([_award(57_500, None)], "Business", None, 4)