Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ flight gflight JFK LHR --dep 2026-08-15 --return 2026-08-22

# IATA autocomplete
flight airport LON

# overlay PointsPath award prices on cash search results (requires PointsPath
# subscription + one-time `flight auth pp login`)
flight fare JFK LHR --dep 2026-08-15 --pp
```

Every result-printing command supports:
Expand All @@ -55,6 +59,57 @@ Every result-printing command supports:
- **Time-of-day filters** (`--depart-times`, `--return-times`): `morning,evening` etc.
- **Calendar-mode duration ranges** (`-d 5-7`): one search returns prices for 5-, 6-, and 7-night trips at every starting day.

## PointsPath integration

`flight fare ... --pp` overlays award prices from [PointsPath](https://pointspath.com) onto the cash itineraries Matrix returns. For each cash flight, you see the airline-native miles cost, taxes, the banks whose points transfer to that program, and cents-per-mile valuation. Round-trips render one table per leg.

```sh
# one-way with award overlay
flight fare JFK LHR --dep 2026-08-15 --pp

# limit the cabin set (default: Economy + Business)
flight fare JFK LHR --dep 2026-08-15 --pp --pp-cabin Economy

# limit the airline set (default: discovered from your account's enabled list)
flight fare JFK LHR --dep 2026-08-15 --pp --pp-airlines United,Delta,American

# award-only listing (still runs Matrix to show the cash table; pass --pp-only
# to skip the cash render)
flight fare JFK LHR --dep 2026-08-15 --pp --pp-only
```

### Setup

PointsPath requires a paid subscription (free tier is the browser extension only). Today the only login mode is to import tokens captured from a logged-in Chrome session:

```sh
# 1. Capture your Supabase tokens from the browser. The extension is on
# pointspath.com under cookies named `sb-hxjqzkcirzhjvtubefie-auth-token.0/.1`;
# reassemble and save as JSON like:
# {"access_token": "...", "refresh_token": "...", "user": {"email": "..."}}
# 2. Import into the local token store:
flight auth pp login --tokens-file ~/Downloads/pp_tokens.json
flight auth pp whoami # confirm
```

A browser-based `flight auth pp login` is planned but not yet shipped; once you have tokens cached, refresh is automatic for the lifetime of your refresh token.

### How airline selection works

On each `--pp` invocation (cached for 24h / 7d respectively):

1. `GET /api/pricing-info` — universe of supported airlines + their transfer-partner banks
2. `GET /api/extension-config` — your account's enabled feature flags
3. The airlines fanned out are: pricing-info entries minus those with `enable<Airline>=0` in the feature flags. Always-on airlines (American, Delta, United, JetBlue, Alaska) have no toggle and are always included.

Pass `--pp-airlines United,Delta,...` to skip discovery and call only the named set.

### What it doesn't do

- Browser-based login (use `--tokens-file` for now)
- `calendar --pp` (lowest-fare-calendar overlay) — fan-out is N days × M airlines; deserves its own design
- Match against airlines we don't yet support (the few in pricing-info but not enabled for your tier are silently skipped)

## Architecture

The codebase is a small pydantic discriminated union with match-based
Expand All @@ -72,9 +127,16 @@ src/flight_cli/
cli.py typer commands
models.py response models
_http.py httpx + curl_cffi + aiolimiter + stamina
pp/ PointsPath integration (--pp on `fare` + `auth pp` subapp)
auth.py Supabase JWT store + refresh
client.py airline-search / pricing-info / extension-config (cached)
match.py cash↔award join by (flight#, date)
cli.py auth subapp + augmenter for `fare`
models.py PointsPath response shapes
tests/
fixtures/ captured SPA wire bodies (golden files)
test_wire_round_trip.py
pp/ PointsPath model + match + helper unit tests
```

Run tests with `pytest tests/`.
Expand Down
9 changes: 5 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -90,11 +90,12 @@ ignore = [
"N", # test function names may mirror wire-field names (camelCase)
"PLC0415", # internal lazy imports are fine for accessing private helpers
]
# DIVERGE: wire.py mirrors Matrix's JSON shape directly with camelCase attrs
# (instead of snake_case + Field(alias=...) on every field). N815 doesn't
# apply to models.py either — its alias= strings aren't attr names — or to
# domain.py, which is snake_case throughout.
# DIVERGE: wire.py and pp/models.py mirror their respective upstream JSON
# shapes directly with camelCase attrs (instead of snake_case + Field(alias=...)
# on every field). Profile-B-edge: reverse-engineered, undocumented upstreams
# (Matrix, PointsPath) — the attr names ARE the contract here.
"src/flight_cli/wire.py" = ["N815"]
"src/flight_cli/pp/models.py" = ["N815"]

[tool.ruff.format]
docstring-code-format = true
Expand Down
70 changes: 68 additions & 2 deletions src/flight_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,15 @@
)
from .links import google_flights_url, matrix_deep_link
from .log import configure as configure_logging
from .pp.cli import LegQuery, auth_app, run_pp_for_search

if TYPE_CHECKING:
from .models import CalendarResult, Location, SearchResult, Slice

# Tuple-length sentinels for `--slice` parser (`ORIGIN-DEST:DATE[:r=...:e=...]`).
_SLICE_MIN_PARTS = 2
_SLICE_MAX_PARTS = 3
_ROUND_TRIP_LEGS = 2 # 2 legs = round-trip; 1 = one-way; >2 = multi-city

# Matrix returns prices as 'USD877.00' (ISO-4217 prefix + decimal). We split
# the prefix off for rendering so tables can show the currency once in the
Expand All @@ -67,6 +69,7 @@ def _amount(s: str | None) -> str:
app = typer.Typer(
add_completion=False, rich_markup_mode="rich", help="CLI for ITA Matrix's Alkali backend."
)
app.add_typer(auth_app, name="auth")
console = Console()
err = Console(stderr=True)

Expand Down Expand Up @@ -403,6 +406,30 @@ def fare(
matrix_url: bool = typer.Option(True, "--matrix-url/--no-matrix-url"),
google_url: bool = typer.Option(True, "--google-url/--no-google-url"),
no_cache: bool = typer.Option(False, "--no-cache"),
pp: bool = typer.Option(
False,
"--pp",
help="Augment results with PointsPath award prices (requires `auth pp login`).",
),
pp_only: bool = typer.Option(
False,
"--pp-only",
help="Show only PointsPath award availability; skip Matrix table render.",
),
pp_airlines: str | None = typer.Option(
None,
"--pp-airlines",
help=(
"CSV of PointsPath airline names (e.g. United,Delta). "
"Default: discovered from your account's enabled airline set "
"via /api/extension-config + /api/pricing-info."
),
),
pp_cabin: str | None = typer.Option(
None,
"--pp-cabin",
help="CSV of cabins to query (Economy,Business,First). Default: Economy,Business.",
),
) -> None:
"""Specific-date search. One leg = one-way, two = round-trip, N = multi-city."""
if slice_specs:
Expand Down Expand Up @@ -451,10 +478,49 @@ def fare(
search = SpecificDateSearch(legs=legs, options=opts)
# SpecificDateSearch → SearchResult by client._parse_response dispatch.
res = cast("SearchResult", _run(search, rps, impersonate, no_cache))
if json_out:
if json_out and not pp:
sys.stdout.write(json.dumps(res.raw, indent=2))
return
_render_search(res)
if not pp_only:
_render_search(res)
if pp:
# Build one PP query per Matrix leg. PP's airline-search is per-direction,
# so a round-trip → 2 queries, multi-city → N queries. Each LegQuery
# carries its slice_index so the matcher knows which Itinerary slice to
# join against.
pp_legs: list[LegQuery] = []
for i, leg in enumerate(legs):
if not leg.date or not leg.origins or not leg.destinations:
continue # shouldn't happen for SpecificDateSearch; defensive
n = len(legs)
label_kind = (
"outbound"
if i == 0 and n > 1
else "return"
if i == 1 and n == _ROUND_TRIP_LEGS
else f"leg {i + 1}"
if n > _ROUND_TRIP_LEGS
else "one-way"
)
iso = leg.date.isoformat()
pp_legs.append(
LegQuery(
origin=leg.origins[0],
destination=leg.destinations[0],
date=iso,
slice_index=i,
label=f"{label_kind} {leg.origins[0]}→{leg.destinations[0]} {iso}",
)
)
run_pp_for_search(
res,
legs=pp_legs,
num_passengers=adults + children + seniors + youth,
airlines=pp_airlines,
cabins=pp_cabin,
pp_only=pp_only,
json_out=json_out,
)
_emit_urls(search, matrix_url=matrix_url, google_url=google_url)


Expand Down
6 changes: 6 additions & 0 deletions src/flight_cli/pp/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""PointsPath integration: award prices joined to ITA Matrix cash itineraries.

Architecture mirrors the rest of flight_cli — Pydantic models in models.py,
async HTTP in client.py, CLI surface in cli.py. Auth + token refresh is
isolated in auth.py so the rest of the package stays unaware of Supabase.
"""
Loading
Loading