diff --git a/.claude/skills/flight-search/SKILL.md b/.claude/skills/flight-search/SKILL.md index 2a6fbbe..b70ef59 100644 --- a/.claude/skills/flight-search/SKILL.md +++ b/.claude/skills/flight-search/SKILL.md @@ -15,7 +15,7 @@ intent into the right invocation **on the first try**. | Command | Purpose | |---|---| -| `flight search ORIGIN DEST --dep YYYY-MM-DD [--return YYYY-MM-DD]` | Specific-date search. Auto-picks Google Flights for plain cash queries and ITA Matrix when a Matrix-only flag is set (routing/extension/multi-city slice/time-of-day/extra pax types/PP config). Force with `--backend matrix\|gflight`. | +| `flight search ORIGIN DEST --dep YYYY-MM-DD [--return YYYY-MM-DD]` | Specific-date search. Auto-picks Google Flights for plain cash queries and ITA Matrix when a Matrix-only flag is set (routing/extension/multi-city slice/time-of-day/extra pax types). Force with `--backend matrix\|gflight`. PointsPath award overlay runs on both backends when tokens are present. | | `flight calendar ORIGIN DEST --start YYYY-MM-DD [--end ...] [-d 5-7]` | Lowest-fare grid across a date window. Matrix only. Default round-trip; `--one-way` flips. | | `flight detail ORIGIN DEST --dep YYYY-MM-DD --start ... --end ...` | Phase-2 of the calendar flow: full itineraries for a date picked from the grid. Matrix only. | | `flight airport QUERY` | IATA / partial-name autocomplete. | @@ -26,7 +26,7 @@ Global flags (every search-printing command): - `--json` — emit raw response JSON - `--no-cache` — bypass the on-disk response cache - `--matrix-url` / `--google-url` — toggle deep-link emission -- `--no-pp` (Matrix backend only) — skip the PointsPath award overlay even if tokens are logged in (PP runs implicitly when tokens are present) +- `--no-pp` — skip the PointsPath award overlay even if tokens are logged in (PP runs implicitly on both matrix and gflight backends when tokens are present) ## Intent → flag cheat sheet diff --git a/docs/memories/MEMORY.md b/docs/memories/MEMORY.md index 217aaaa..aed2702 100644 --- a/docs/memories/MEMORY.md +++ b/docs/memories/MEMORY.md @@ -35,6 +35,10 @@ go into detail and are loaded on demand. web search shows, this project is the only public wrapper of the Alkali endpoint. Implications: our fixtures are the spec; forward-compat is on us. What to do if Matrix changes shape. +- [pp_on_gflight.md](pp_on_gflight.md) — Why PointsPath overlay now + rides both backends. Decision: `enable_matching=False` + the existing + matcher keys, not the matched-Google-flight-id path. Empirical evidence + behind the choice and the upgrade path if it turns out worth it later. ## When to add a new memory file diff --git a/docs/memories/pp_on_gflight.md b/docs/memories/pp_on_gflight.md new file mode 100644 index 0000000..c5842eb --- /dev/null +++ b/docs/memories/pp_on_gflight.md @@ -0,0 +1,61 @@ +# PointsPath overlay rides both backends (work-qmx1) + +The PP augmenter is **backend-agnostic** at the matcher level. `pp/match.py` +joins by structural keys (flight#+date and route+time) that don't care +whether the cash side came from Matrix's Alkali response or fli's Google +Flights scrape. The adapter at `pp/gflight_adapter.py` wraps fli output +in a `SearchResult` shape so the existing matcher and renderer reuse +end-to-end — no duplication. + +## Decision: enable_matching=False, not True + +PointsPath's `airline-search` endpoint accepts a `CashFlightHint` payload +with `enableGoogleFlightMatching=true`, which is supposed to echo a +`matchedGoogleFlightId` per result so cash↔award join becomes trivial. We +considered using it but **didn't**, for three reasons: + +1. **fli has no opaque Google Flights ID**. `fli.models.FlightResult` + only carries structured fields (airline, flight_number, datetimes, + route, price). The "real Google Flights ID" framing in the bd issue + prompt doesn't map to a field fli actually exposes. +2. **The previous probe** (`research/probe_pp_matching.py`, + gitignored capture at `research/capture/pp_matching_probe.json`) + showed that synthetic `flight_id` values cause PP to return an empty + `outboundFlights` list entirely — PP filters on the hint, doesn't + just echo it. Without a verified real-ID format the matching path is + a footgun. +3. **The existing (flight#, date) + (route+time) keys already work** + on the Matrix backend, including for codeshare bridging (work-22az). + The same keys carry over to fli output unchanged. + +If a future spike confirms `matchedGoogleFlightId` populates for some +hint format that fli can produce, the upgrade path is small: add +`cash_hints=...` to `SearchSpec` in `pp/cli.py:_gather_pp` and a new +hit-merging branch in `match.py:join` that consumes the echoed match id. +Worth it only if codeshare-bridging on the gflight backend turns out to +be common — gflight is mostly used for plain ULCC/Frontier/Spirit kind +of queries, where matched-id robustness isn't load-bearing. + +`research/probe_pp_real_hints.py` (gitignored) is the prepared probe +for the upgrade question. It runs a real fli search, builds CashFlightHints +from the structured output, and sends them to PP with enable_matching=True. +Attempting it during work-qmx1 hit a stale refresh token (Supabase rotates +single-use; a parallel Chrome session had consumed the chain), so the +empirical answer is deferred. Re-run after `flight auth pp login` to settle. + +## Why: was forcing matrix on every PP query + +Before this change, any presence of a `--pp-*` flag forced the matrix +backend. That was a 26s+ tax (vs gflight's ~0.7s) for queries that +didn't need Matrix's richer routing/extension semantics. Plain +`--pp-only` users — the canonical "is there an award for this trip?" +gut-check — paid that tax in full. + +## How to apply + +- New search modes should reuse the matcher unchanged; add to + `gflight_adapter` only when adapting a new source's output. +- `_should_run_pp` is now backend-agnostic — only token presence and + `--no-pp/--pp-only` flag coherence gate the overlay. +- `_pick_backend` no longer accepts PP-related parameters; PP-* flags + don't influence backend choice. diff --git a/src/flight_cli/cli.py b/src/flight_cli/cli.py index 0055d0a..688668c 100644 --- a/src/flight_cli/cli.py +++ b/src/flight_cli/cli.py @@ -218,57 +218,42 @@ def _pick_backend( youth: int, inf_seat: int, inf_lap: int, - pp_only: bool, - pp_airlines: str | None, - pp_cabin: str | None, ) -> str: """Resolve --backend to a concrete backend. auto: matrix iff a Matrix-only flag is set, else gflight. Matrix-only set: --routing/--extension/--slice/--depart-times/--return-times, - any pax type beyond adults+children, any --pp-* configuration flag. - PP is currently Matrix-only (provider abstraction is work-z6zi). + any pax type beyond adults+children. PP overlay rides both backends now — + plain `--pp-only` stays on gflight for speed + ULCC inventory. Explicit --backend matrix: matrix. --backend gflight: gflight, unless a Matrix-only flag is also set (error — the request is inexpressible on fli).""" - matrix_only = bool( - routing - or extension - or slice_specs - or depart_times - or return_times - or pp_airlines - or pp_cabin - ) or (seniors > 0 or youth > 0 or inf_seat > 0 or inf_lap > 0 or pp_only) + matrix_only = bool(routing or extension or slice_specs or depart_times or return_times) or ( + seniors > 0 or youth > 0 or inf_seat > 0 or inf_lap > 0 + ) if backend == BACKEND_AUTO: return BACKEND_MATRIX if matrix_only else BACKEND_GFLIGHT if backend == BACKEND_GFLIGHT and matrix_only: raise typer.BadParameter( "--backend gflight is incompatible with Matrix-only flags " "(--routing/--extension/--slice/--depart-times/--return-times/" - "extra pax types/--pp-*). Drop them, or use --backend matrix.", + "extra pax types). Drop them, or use --backend matrix.", ) if backend not in _VALID_BACKENDS: raise typer.BadParameter(f"--backend must be one of {_VALID_BACKENDS}; got {backend!r}") return backend -def _should_run_pp(*, backend: str, no_pp: bool, pp_only: bool) -> bool: +def _should_run_pp(*, no_pp: bool, pp_only: bool) -> bool: """Decide whether PP augmentation runs. - Matrix backend + tokens present → True (unless --no-pp). + Tokens present → True (unless --no-pp). Both backends support PP overlay + now: matrix consumes its own SearchResult directly; gflight wraps fli + output via gflight_adapter into the same shape, so the matcher and + renderer reuse end-to-end. + --pp-only with missing tokens → hard error (user explicitly asked for PP). - gflight backend → False (PP needs Matrix's SearchResult shape; work-z6zi - introduces the AwardProvider abstraction that makes this provider-neutral). """ - if backend != BACKEND_MATRIX: - if pp_only: - err.print( - "[red]--pp-only requires Matrix backend; " - "drop the Matrix-only flag or use --backend matrix.[/]", - ) - raise typer.Exit(2) - return False if no_pp: if pp_only: err.print("[red]--pp-only and --no-pp are mutually exclusive.[/]") @@ -504,8 +489,17 @@ def _run_gflight_path( opts: SearchOptions, top_n: int, json_out: bool, + run_pp: bool = False, + pp_only: bool = False, + pp_airlines: str | None = None, + pp_cabin: str | None = None, ) -> None: - """Google Flights path: build fli filter → query → render. Single-leg or round-trip.""" + """Google Flights path: build fli filter → query → render. Single-leg or round-trip. + + When run_pp=True, fli's results are adapted into a SearchResult shape so + the existing PP matcher + renderer reuse cleanly. PP runs on the same + (origin, dest, date) per leg as the matrix path. + """ # fli is heavy (selenium/selectolax); lazy-import so the rest of flight_cli # doesn't pay the startup cost when not used. from .fli_bridge import run_gflight_search # noqa: PLC0415 @@ -528,7 +522,7 @@ def _run_gflight_path( # fli/fast_flights have no type stubs; results are duck-typed pydantic # models. Suppressing the noisy unknown-type chatter for this rendering # block keeps the boundary localized. - if json_out: + if json_out and not run_pp: out: list[Any] = [] for r in results: if isinstance(r, tuple): @@ -538,6 +532,27 @@ def _run_gflight_path( sys.stdout.write(json.dumps(out, indent=2, default=str)) return + if not pp_only: + _render_gflight_table(results, legs=legs, top_n=top_n) + + if run_pp: + from .pp.gflight_adapter import fli_results_to_search_result # noqa: PLC0415 + + sr = fli_results_to_search_result(results) + p = opts.pax + run_pp_for_search( + sr, + legs=_build_pp_legs(legs), + num_passengers=p.adults + p.children + p.seniors + p.youth, + airlines=pp_airlines, + cabins=pp_cabin, + pp_only=pp_only, + json_out=json_out, + ) + + +def _render_gflight_table(results: list[Any], *, legs: tuple[Leg, ...], top_n: int) -> None: + """Render fli results as a rich table. Duck-typed: fli has no type stubs.""" origin = legs[0].origins[0] if legs[0].origins else "?" destination = legs[0].destinations[0] if legs[0].destinations else "?" has_return = len(legs) >= _ROUND_TRIP_LEGS @@ -702,9 +717,6 @@ def search( youth=youth, inf_seat=inf_seat, inf_lap=inf_lap, - pp_only=pp_only, - pp_airlines=pp_airlines, - pp_cabin=pp_cabin, ) if slice_specs: legs = tuple(_parse_slice_spec(s) for s in slice_specs) @@ -751,10 +763,20 @@ def search( ) if resolved == BACKEND_GFLIGHT: - _run_gflight_path(legs=legs, opts=opts, top_n=page_size, json_out=json_out) + run_pp = _should_run_pp(no_pp=no_pp, pp_only=pp_only) + _run_gflight_path( + legs=legs, + opts=opts, + top_n=page_size, + json_out=json_out, + run_pp=run_pp, + pp_only=pp_only, + pp_airlines=pp_airlines, + pp_cabin=pp_cabin, + ) return - run_pp = _should_run_pp(backend=resolved, no_pp=no_pp, pp_only=pp_only) + run_pp = _should_run_pp(no_pp=no_pp, pp_only=pp_only) _run_matrix_path( legs=legs, opts=opts, @@ -914,7 +936,7 @@ def fare( show_only_available=only_available, page_size=page_size, ) - run_pp = _should_run_pp(backend=BACKEND_MATRIX, no_pp=no_pp, pp_only=pp_only) + run_pp = _should_run_pp(no_pp=no_pp, pp_only=pp_only) _run_matrix_path( legs=legs, opts=opts, diff --git a/src/flight_cli/pp/gflight_adapter.py b/src/flight_cli/pp/gflight_adapter.py new file mode 100644 index 0000000..0239c64 --- /dev/null +++ b/src/flight_cli/pp/gflight_adapter.py @@ -0,0 +1,93 @@ +# pyright: reportCallIssue=false +# DIVERGE: pydantic Field(alias=...) on _Loose models trips basedpyright into +# treating alias names as required kwargs even though populate_by_name=True is +# set. Same posture as tests/pp/test_match.py. +"""Adapt fli's Google Flights output into the SearchResult shape match.py expects. + +The matcher reads structural fields only — slices[i].flights[0], .departure, +.origin.code, .destination.code, Itinerary.price — so a thin wrap-and-translate +layer is enough; no matcher changes needed. PP runs with enable_matching=False +on this path; the same (flight#, date) + (route, time) keys that work for the +Matrix backend bridge the cash↔award join here too. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from ..models import ( + Itinerary, + ItineraryDetails, + ItineraryExt, + SearchResult, + Slice, + SliceEndpoint, +) + +if TYPE_CHECKING: + from collections.abc import Sequence + + +def _airport_code(a: Any) -> str: + name: str = getattr(a, "name", "") or "" + return name.removeprefix("_") + + +def _slice_from_flight_result(fr: Any) -> Slice: + legs: list[Any] = list(fr.legs) + first, last = legs[0], legs[-1] + return Slice( + flights=[leg.flight_number for leg in legs], + departure=first.departure_datetime.isoformat(), + arrival=last.arrival_datetime.isoformat(), + duration=fr.duration, + origin=SliceEndpoint(code=_airport_code(first.departure_airport)), + destination=SliceEndpoint(code=_airport_code(last.arrival_airport)), + ) + + +def _price_string(fr: Any) -> str: + """Match Matrix's price format ('USD877.00') so match._parse_cash works.""" + currency = fr.currency or "USD" + return f"{currency}{fr.price:.2f}" + + +def fli_results_to_search_result(results: Sequence[Any]) -> SearchResult: + """Wrap fli's heterogeneous return into a SearchResult. + + fli returns ``list[FlightResult]`` for one-way and ``list[tuple[FlightResult, ...]]`` + for round-trip/multi-city. Each top-level entry maps to one Itinerary; for + tuples, each FlightResult becomes one Slice in slice-index order. The + cheapest-cash price for the itinerary uses the outbound leg's price + (round-trip prices in fli are attached per-result; the outbound carries + the combined fare on round-trip queries). + """ + solutions: list[Itinerary] = [] + cheapest_price: float | None = None + cheapest_currency: str = "USD" + for r in results: + items: list[Any] = list(r) if isinstance(r, tuple) else [r] # pyright: ignore[reportUnknownArgumentType] + if not items: + continue + slices = [_slice_from_flight_result(fr) for fr in items] + price_str = _price_string(items[0]) + solutions.append( + Itinerary( + ext=ItineraryExt(price=price_str), + itinerary=ItineraryDetails(slices=slices, carriers=[]), + ), + ) + p: float = items[0].price + if cheapest_price is None or p < cheapest_price: + cheapest_price = p + cheapest_currency = items[0].currency or "USD" + + sr = SearchResult( + solutionCount=len(solutions), + solutions=solutions, + ) + if cheapest_price is not None: + sr.currency_notice.ext = ItineraryExt( + price=f"{cheapest_currency}{cheapest_price:.2f}", + ) + return sr diff --git a/tests/pp/test_gflight_adapter.py b/tests/pp/test_gflight_adapter.py new file mode 100644 index 0000000..283f90d --- /dev/null +++ b/tests/pp/test_gflight_adapter.py @@ -0,0 +1,139 @@ +# pyright: reportPrivateUsage=false, reportUnknownMemberType=false, reportUnknownArgumentType=false +"""Tests for the fli → SearchResult adapter. + +The adapter is the bridge that lets match.py join PP awards against +Google-Flights cash itineraries. The matcher only reads structural fields +(slices[i].flights[0], .departure, .origin.code, .destination.code) plus +the price string — so this test pins those exact fields end-to-end.""" + +from __future__ import annotations + +from datetime import datetime +from types import SimpleNamespace +from typing import Any + +from flight_cli.pp.gflight_adapter import fli_results_to_search_result + + +def _leg( + flight_number: str, + dep_iata: str, + arr_iata: str, + dep: datetime, + arr: datetime, +) -> SimpleNamespace: + """Mimic fli FlightLeg with duck-typed attributes (airline/airport are + Enum-like; we just need .name with optional leading underscore stripping).""" + return SimpleNamespace( + airline=SimpleNamespace(name="AA"), + flight_number=flight_number, + departure_airport=SimpleNamespace(name=dep_iata), + arrival_airport=SimpleNamespace(name=arr_iata), + departure_datetime=dep, + arrival_datetime=arr, + duration=(arr - dep).seconds // 60, + ) + + +def _result(price: float, *legs: Any, currency: str = "USD") -> SimpleNamespace: + return SimpleNamespace( + legs=list(legs), + price=price, + currency=currency, + duration=sum(leg.duration for leg in legs), + stops=len(legs) - 1, + ) + + +def test_one_way_single_leg_maps_to_one_slice() -> None: + fr = _result( + 877.0, + _leg("AA100", "JFK", "LHR", datetime(2026, 8, 15, 19, 0), datetime(2026, 8, 16, 7, 0)), + ) + sr = fli_results_to_search_result([fr]) + assert sr.solution_count == 1 + it = sr.solutions[0] + assert it.price == "USD877.00" + assert it.itinerary is not None + slices = it.itinerary.slices + assert len(slices) == 1 + s = slices[0] + assert s.flights == ["AA100"] + assert s.departure == "2026-08-15T19:00:00" + assert s.origin is not None and s.origin.code == "JFK" + assert s.destination is not None and s.destination.code == "LHR" + + +def test_round_trip_tuple_maps_to_two_slices() -> None: + out = _result( + 1078.0, + _leg("AA100", "JFK", "LHR", datetime(2026, 8, 15, 19, 0), datetime(2026, 8, 16, 7, 0)), + ) + ret = _result( + 1078.0, + _leg("AA101", "LHR", "JFK", datetime(2026, 8, 22, 11, 0), datetime(2026, 8, 22, 14, 0)), + ) + sr = fli_results_to_search_result([(out, ret)]) + assert sr.solution_count == 1 + it = sr.solutions[0] + assert it.itinerary is not None + slices = it.itinerary.slices + assert len(slices) == 2 + assert slices[0].flights == ["AA100"] + assert slices[1].flights == ["AA101"] + assert slices[1].origin is not None and slices[1].origin.code == "LHR" + assert slices[1].destination is not None and slices[1].destination.code == "JFK" + + +def test_connection_slice_flattens_all_flight_numbers_first_origin_last_dest() -> None: + """Connecting itinerary: slice.flights lists every leg; origin/destination + are first/last leg's airports (so the (route, time) fallback key works).""" + fr = _result( + 450.0, + _leg("B6100", "JFK", "BOS", datetime(2026, 8, 15, 9, 0), datetime(2026, 8, 15, 10, 30)), + _leg("B6200", "BOS", "LHR", datetime(2026, 8, 15, 17, 0), datetime(2026, 8, 16, 5, 0)), + ) + sr = fli_results_to_search_result([fr]) + s = sr.solutions[0].itinerary.slices[0] # type: ignore[union-attr] + assert s.flights == ["B6100", "B6200"] + assert s.origin is not None and s.origin.code == "JFK" + assert s.destination is not None and s.destination.code == "LHR" + assert s.departure == "2026-08-15T09:00:00" + + +def test_underscore_prefixed_airport_codes_stripped() -> None: + """fli prefixes numeric airport codes with `_` (since Python enum names + can't start with a digit). Adapter strips that so codes match Matrix.""" + fr = _result( + 300.0, + _leg( + "WN100", + "_4U", # imagined numeric-leading IATA + "JFK", + datetime(2026, 8, 15, 7, 0), + datetime(2026, 8, 15, 11, 0), + ), + ) + sr = fli_results_to_search_result([fr]) + s = sr.solutions[0].itinerary.slices[0] # type: ignore[union-attr] + assert s.origin is not None and s.origin.code == "4U" + + +def test_cheapest_price_tracks_min_across_results() -> None: + a = _result( + 500.0, + _leg("AA1", "JFK", "LAX", datetime(2026, 8, 15, 8, 0), datetime(2026, 8, 15, 11, 0)), + ) + b = _result( + 320.0, + _leg("DL1", "JFK", "LAX", datetime(2026, 8, 15, 9, 0), datetime(2026, 8, 15, 12, 0)), + ) + sr = fli_results_to_search_result([a, b]) + assert sr.cheapest_price == "USD320.00" + + +def test_empty_results_yield_empty_search_result() -> None: + sr = fli_results_to_search_result([]) + assert sr.solution_count == 0 + assert sr.solutions == [] + assert sr.cheapest_price is None diff --git a/tests/test_backend_dispatch.py b/tests/test_backend_dispatch.py index 388cf85..c20d088 100644 --- a/tests/test_backend_dispatch.py +++ b/tests/test_backend_dispatch.py @@ -32,9 +32,6 @@ def _call(backend: str = BACKEND_AUTO, **overrides: object) -> str: "youth": 0, "inf_seat": 0, "inf_lap": 0, - "pp_only": False, - "pp_airlines": None, - "pp_cabin": None, } defaults.update(overrides) return _pick_backend(backend=backend, **defaults) # type: ignore[arg-type] @@ -59,9 +56,6 @@ def test_auto_plain_search_picks_gflight() -> None: ("youth", 1), ("inf_seat", 1), ("inf_lap", 1), - ("pp_only", True), - ("pp_airlines", "United,Delta"), - ("pp_cabin", "Business"), ], ) def test_auto_matrix_only_flag_picks_matrix(flag: str, value: object) -> None: @@ -70,6 +64,11 @@ def test_auto_matrix_only_flag_picks_matrix(flag: str, value: object) -> None: assert _call(**{flag: value}) == BACKEND_MATRIX # pyright: ignore[reportArgumentType] +# PP-* flags no longer influence backend choice (work-qmx1): PP overlay runs on +# both backends, so `--pp-only` on a plain query stays on gflight (faster). The +# flags aren't in `_pick_backend`'s signature anymore — that's the test. + + # ──────────────────────────────── explicit ───────────────────────────────── diff --git a/tests/test_should_run_pp.py b/tests/test_should_run_pp.py new file mode 100644 index 0000000..42542b8 --- /dev/null +++ b/tests/test_should_run_pp.py @@ -0,0 +1,69 @@ +# pyright: reportPrivateUsage=false, reportUnusedFunction=false +"""Tests for `_should_run_pp`'s decision logic. + +The function decides whether PointsPath augmentation runs on a given query. +Before work-qmx1, this was gated to the matrix backend. Now both backends +support PP overlay, so the gating is purely token-presence + flag-coherence.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +import typer + +from flight_cli.cli import _should_run_pp + +if TYPE_CHECKING: + from collections.abc import Callable + from pathlib import Path + + from pytest import MonkeyPatch + + +@pytest.fixture(autouse=True) +def _tokens_path(tmp_path: Path, monkeypatch: MonkeyPatch) -> Path: + """Redirect the on-disk token path so tests can flip between + has-tokens and no-tokens without touching the real ~/.config file.""" + token_file = tmp_path / "pp.json" + monkeypatch.setattr("flight_cli.pp.auth.TOKENS_PATH", token_file) + # The cli imports load_tokens at module level, so patch the imported name too. + monkeypatch.setattr("flight_cli.cli.load_tokens", _make_loader(token_file)) + return token_file + + +def _make_loader(path: Path) -> Callable[[], object | None]: + """Return a function that reads `path` and returns a truthy Tokens-like + sentinel when present, None when absent. The function only cares about + truthiness, so we don't need real Tokens objects.""" + + def _load() -> object | None: + return object() if path.exists() else None + + return _load + + +def test_no_tokens_returns_false(_tokens_path: Path) -> None: + assert _should_run_pp(no_pp=False, pp_only=False) is False + + +def test_no_tokens_with_pp_only_errors(_tokens_path: Path) -> None: + with pytest.raises(typer.Exit) as ei: + _should_run_pp(no_pp=False, pp_only=True) + assert ei.value.exit_code == 2 + + +def test_tokens_present_returns_true(_tokens_path: Path) -> None: + _tokens_path.write_text("{}") # presence is enough for the test loader + assert _should_run_pp(no_pp=False, pp_only=False) is True + + +def test_no_pp_returns_false_even_with_tokens(_tokens_path: Path) -> None: + _tokens_path.write_text("{}") + assert _should_run_pp(no_pp=True, pp_only=False) is False + + +def test_no_pp_with_pp_only_is_mutually_exclusive(_tokens_path: Path) -> None: + with pytest.raises(typer.Exit) as ei: + _should_run_pp(no_pp=True, pp_only=True) + assert ei.value.exit_code == 2