From 40e96b9f3209a0146087de6daee7d0a42f2275b8 Mon Sep 17 00:00:00 2001 From: ak2k <19240940+ak2k@users.noreply.github.com> Date: Fri, 15 May 2026 19:31:33 -0400 Subject: [PATCH 1/2] Surface legroom + amenities + seatmap from gflight responses (work-5ewe) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Legrooms+ Chrome extension overlays cabin, pitch, and amenity info on Google Flights search results. Originally I assumed we'd need to capture a third-party API (api.travelarrow.io) to mirror that — but the extension's load_flight_data.js shows the data is already in-band in Google's own /GetShoppingResults response at per-leg tuple indices 12-17. Our gflight backend already hits that endpoint via fli; we just had to read the fields. The Legrooms+ extension v11.5.0's bit positions have drifted since the extension was last updated. I empirically recalibrated 2026-05 by driving Google Flights via Patchright, scraping the rendered amenity labels from the detail panel, and correlating against the bit array. The wifi signal in particular moved from a single Boolean at [0] to a three-state enum at [11] (1=none, 2=free, 3=paid). Validated end-to-end against AA 952 and B6 1072: every decoded field (pitch, legroom class, cabin, aircraft, power, video, wifi) exactly matches Google's UI labels. Seatmap URLs are a separate per-flight concern. `flight seatmap` is a new subcommand that hits travelarrow.io/api/s (the same endpoint the Legrooms+ extension uses on click) and resolves to a seatmaps.com URL. Field detail and the calibration story are in docs/memories/legroom_recipe.md. --- docs/memories/MEMORY.md | 6 + docs/memories/legroom_recipe.md | 205 ++++++++++++++++++++ src/flight_cli/_gflight_ids.py | 157 ++++++++++++++- src/flight_cli/cli.py | 211 +++++++++++++++++++- src/flight_cli/models.py | 19 ++ src/flight_cli/pp/gflight_adapter.py | 37 +++- src/flight_cli/seatmap.py | 100 ++++++++++ tests/test_legroom_parser.py | 276 +++++++++++++++++++++++++++ tests/test_seatmap.py | 98 ++++++++++ 9 files changed, 1093 insertions(+), 16 deletions(-) create mode 100644 docs/memories/legroom_recipe.md create mode 100644 src/flight_cli/seatmap.py create mode 100644 tests/test_legroom_parser.py create mode 100644 tests/test_seatmap.py diff --git a/docs/memories/MEMORY.md b/docs/memories/MEMORY.md index 7341ad0..a9d1630 100644 --- a/docs/memories/MEMORY.md +++ b/docs/memories/MEMORY.md @@ -45,6 +45,12 @@ go into detail and are loaded on demand. the wrong hint shape. Recipe (real `data[0][17]` from fli + IATA-prefixed flight#, human-readable airline name, space-separated times), empirical proof, and wire-through implementation notes. +- [legroom_recipe.md](legroom_recipe.md) — Per-leg legroom + amenities + + aircraft come back in-band in Google Flights' own response (no + travelarrow.io API call needed for the data itself). Index map for + `data[0][2][i]` 12-17, enum decodings, amenity bit positions, and the + seatmap URL contract (`/api/s` with M/D/YYYY dates). Read before + touching `_gflight_ids._parse_leg_amenities` or `seatmap.py`. ## When to add a new memory file diff --git a/docs/memories/legroom_recipe.md b/docs/memories/legroom_recipe.md new file mode 100644 index 0000000..60c52e9 --- /dev/null +++ b/docs/memories/legroom_recipe.md @@ -0,0 +1,205 @@ +# Legroom & amenities — recipe + +How `flight search` (gflight backend) surfaces per-leg legroom, cabin, and +in-flight amenities, mirroring what the Legrooms+ Chrome extension shows. + +**Headline finding (work-5ewe, 2026-05):** This data isn't fetched from a +third-party API. Google Flights' own `/GetShoppingResults` response already +carries it in the per-leg tuple at `data[0][2][i]` indices 12–17. The +Legrooms+ extension's `load_flight_data.js` hooks XHR and parses exactly +these indices. Our gflight backend hits the same endpoint (via `fli`), so +the same fields are available — we just had to read them. + +## Per-leg tuple indices + +In `data[0][2][i]` (each physical flight within an itinerary): + +| Index | Type | Meaning | +| ----- | --------------- | ------------------------------------------------------------------------------- | +| 12 | list (sparse) | Amenities. Positional bits decoded below. | +| 13 | int | Legroom class enum (see table). | +| 14 | str | Pitch — e.g. `"31 in"`. Occasionally bare int. | +| 15 | list | Codeshare carriers — `[['KL', '6101', None, 'KLM'], ...]`. We don't read it. | +| 16 | int | Cabin enum (see table). | +| 17 | str | Aircraft type — e.g. `"Airbus A330"`, `"Boeing 777-300ER"`. | + +Indices 3, 6, 8, 10, 11, 20, 21, 22 are already used by `fli`; see +`src/flight_cli/_gflight_ids.py` for the full map. + +## Legroom class enum (index 13) + +``` +1 → AVERAGE ← pitch shown in default color +2 → BELOW ← pitch tagged [red] +3 → ABOVE ← pitch tagged [green] +4 → Extra Reclining +5 → Lie Flat +6 → Suite +8 → Reclining +9 → Angled Flat +``` + +For coach, AVERAGE/BELOW/ABOVE pair with the pitch number — the renderer +collapses these to color on the pitch token (no text label) since they're +pitch-relative judgments and the number itself is the signal. For premium +cabins, the seat-type enums (Lie Flat / Suite / Angled Flat / Reclining) +print as text because they convey seat *construction* not relative pitch. + +## Cabin enum (index 16) + +``` +1 → ECONOMY +2 → PREMIUM (premium economy) +3 → BUSINESS +4 → FIRST +``` + +Mixed-cabin itineraries are real and worth surfacing: a "business" search +can return an outbound with `J Suite` on the long-haul leg and `Y 32" ABOVE` +on the regional connector. + +## Amenities array (index 12) + +A 12-element list with booleans (`True`/`None`) at fixed positions. +Decoded by `_decode_wifi` / `_decode_power` / `_decode_video`. + +**Our mapping diverges from the Legrooms+ extension v11.5.0** because +Google's bit positions have shifted since the extension was last +updated (v11.5.0 dates from before mid-2024). Empirically calibrated +2026-05 by driving Google Flights via Patchright, expanding flight +detail panels, scraping the rendered amenity labels, and correlating +against the bit array we receive from `/GetShoppingResults`. +See `research/probe_wifi_correlation.py` for the calibration tool. + +The key finding: **`t[12] = []` (empty array) when the aircraft has none +of {wifi, IFE, in-seat power}**. Frontier is the canonical wifi-less +mainline carrier and consistently returns `[]`. Every wifi-equipped +carrier returns a populated 12-element array. + +Within a populated array: + +| Position | Our reading | Google's UI label | +| -------- | ---------------------------------- | --------------------------------- | +| `[0]` | (deprecated, always None) | — | +| `[1]` | In-seat plug power | "In-seat power & USB outlets" | +| `[3]` | In-seat plug (alt — older aircraft)| "In-seat power & USB outlets" | +| `[5]` | USB-only power (when no plug) | "USB outlets" | +| `[8]` | Seatback live-TV stream | "Live TV" | +| `[9]` | Seatback on-demand video | "On-demand video" | +| `[10]` | BYOD streaming (to your device) | "Stream media to your device" | +| `[11]` | **Wifi enum: 1=none, 2=free, 3=paid** | (none) / "Free Wi-Fi" / "Wi-Fi for a fee" | + +Position `[11]` is the ground-internet wifi enum — empirically validated +2026-05 against AA 952 ([11]=2 → "Free Wi-Fi"), B6 1072 ([11]=2 → "Free +Wi-Fi"), F9 1875 (`[12]=[]` → no label), and additional probes covering +DL/EK/UA/AS/AC/OS/WN/KL/QR/RJ. The Legrooms+ extension reads `[0]` for +wifi which is consistently None on 2026 responses — its wifi icon +doesn't fire on current data. + +Mapped to a string per category, then to a glyph at render time: + +| Category | Decoder rule | Glyph | +| --------------- | ------------------------------------- | ------------------ | +| wifi=free | `[11] == 2` | `📶` | +| wifi=paid | `[11] == 3` | `📶$` (yellow) | +| power=plug | `[1]` or `[3]` truthy | `↯` | +| power=usb | `[5]` truthy (when no plug) | `⌁` | +| video=stream | `[8]` truthy | `▶` (live TV) | +| video=ondemand | `[9]` truthy (when no stream) | `▷` (seatback IFE)| +| video=byod | `[10]` truthy (when no [8]/[9]) | `◰` (your device) | + +Cabin letter (Y/W/J/F for Economy/Premium/Business/First) prefixes the +pitch token. So a coach leg with free wifi + plug + on-demand seatback +reads `Y 31" 📶↯▷`; the same leg with paid wifi reads `Y 31" 📶$↯▷` +(with the `📶$` rendered in yellow). Wifi uses the 2-col emoji `📶` +because it's the highest-value binary signal and 1-col abstract chars +weren't at-a-glance readable. Power and video keep 1-col Unicode chars +to preserve the finer distinctions without bloating column width. + +`flight search` prints a dim glyph-legend footer (`_LEGROOM_KEY` in +`src/flight_cli/cli.py`) below the gflight table whenever any legroom data +renders, so users don't have to memorize the mapping. + +## Ground-truth validation set (2026-05) + +Captured via Patchright by expanding flight detail panels in Google's web +UI and recording the visible labels. All decoded fields match exactly: + +| Flight | Aircraft | Google's UI labels | t[12] | +| ------ | -------- | ------------------ | ----- | +| AA 952 MIA→LGA | 737MAX 8 | Average legroom (30 in), Free Wi-Fi, In-seat power & USB outlets, Stream media to your device | `[N,T,N,N,N,N,N,N,N,N,T,2]` | +| B6 1072 FLL→LGA | A320 | Above average legroom (32 in), Free Wi-Fi, In-seat power & USB outlets, Live TV | `[N,T,N,N,N,N,N,N,T,N,N,2]` | +| F9 1875 MCO→LAS | A320neo | (no amenity labels — 28 in pitch is below-average) | `[]` | +| EK Emirates A380 | (full IFE biz) | (paid wifi, seatback ondemand expected) | `[N,T,N,N,N,N,N,N,N,T,N,3]` | +| WN Southwest 737MAX | (BYOD only) | (free wifi for streaming, USB power) | `[N,N,N,N,N,T,N,N,N,N,T,2]` | + +Positions `[2]`, `[4]`, `[6]`, `[7]` carry signals we don't decode. Most +remain None across all probed routes; speculatively duty-free / meal / +other carrier-info flags but no labeled reference to anchor the meaning. + +## Code locations + +- `src/flight_cli/_gflight_ids.py` — `_parse_leg_amenities`, `LegAmenities` + dataclass, `_LEGROOM_CLASS` / `_CABIN` decoders, `_decode_*` helpers. +- `src/flight_cli/pp/gflight_adapter.py` — `_leg_info()` packs `LegAmenities` + into `Slice.legs[i]: LegInfo` for PP matched-rendering. +- `src/flight_cli/cli.py:_fmt_gflight_legroom` — renders the inline column + in the `Google Flights` table. + +## Seatmap URL — separate concern + +The legroom data is in-band on the search response. The **seatmap** is +not — it's a separate per-flight artifact at `seatmaps.com`, fetched via +one HTTP round-trip to `https://www.travelarrow.io/api/s`. + +``` +GET https://www.travelarrow.io/api/s?from=JFK&to=LHR&flightno=1&carrier=DL&date=8/15/2026&aircraft=Airbus+A330 +→ {"seatmap_url": "airlines/dl-delta/airbus-a330-200/#"} + +Open as: https://seatmaps.com/ +``` + +**Date format is `M/D/YYYY`** with no zero-padding — that's what the +Legrooms+ extension sends (`load_flight_data.js: date=${n}/${i}/${e}` where +`[e,n,i] = raw[20]` is `[year, month, day]`). The travelarrow API also +accepts ISO, but we match the extension's wire shape to minimize +divergence risk. + +**Flight number is the bare number** ("1", not "DL1") when the +IATA-prefix matches the carrier; we keep prefixes intact for codeshares +(operating carrier ≠ marketing prefix). + +`src/flight_cli/seatmap.py`: +- `seatmap_api_url(...)` — deterministic URL builder, no HTTP. +- `fetch_seatmap_url(...)` — one GET, returns the seatmaps.com URL or + `None` (no seatmap on file for this aircraft). + +`flight seatmap --date YYYY-MM-DD [--aircraft ...]` +exposes the resolution flow. `--no-fetch` skips the HTTP and just prints +the travelarrow URL. + +## What we DON'T parse + +Decoding more amenity bits (`[2]`, `[4]`, `[6-8]`, `[11]`) would require +either: +1. A new Legrooms+ extension version that maps them in `load_flight_data.js` + (current 11.5.0 ignores them too). +2. Empirical probing against routes where we know the truth (e.g. comparing + a known meal-included flight against the bit pattern). + +Not worth the effort until a downstream feature actually needs them. + +## What CHANGED about the bd work-5ewe plan + +The original `work-5ewe` issue assumed we'd need to capture an external +API (`api.travelarrow.io/v3/deals`, `/metadata`) via Patchright + raw CDP +attach. That was based on partial information from a prior session that +saw outbound requests to `api.travelarrow.io` but couldn't capture +response bodies. **Those requests are for hotel pricing and analytics +telemetry, not legroom data.** The legroom-overlay path in the extension +is entirely client-side parsing of Google's response — no external API +involved beyond the seatmap link. + +We dropped the capture work entirely. Estimated effort: 5h. Actual: ~3h +(mostly because the work was upstream/visible in the extension source +and we found the in-band path during research instead of after capture). diff --git a/src/flight_cli/_gflight_ids.py b/src/flight_cli/_gflight_ids.py index c4788bc..4aa0310 100644 --- a/src/flight_cli/_gflight_ids.py +++ b/src/flight_cli/_gflight_ids.py @@ -42,13 +42,160 @@ # Mirrors the PP browser extension's parser (chunk-5KW5VSHS.js: `a = n[17]`). _FLIGHT_ID_IDX = 17 +# Per-leg field indices in `data[0][2][i]`. Mirrors the Legrooms+ extension's +# parser (load_flight_data.js function `u`). See docs/memories/legroom_recipe.md. +_LEG_AMENITIES_IDX = 12 # array — bit positions decoded into wifi/power/video +_LEG_LEGROOM_CLASS_IDX = 13 # int enum (see _LEGROOM_CLASS) +_LEG_PITCH_IDX = 14 # int (inches) +_LEG_CABIN_IDX = 16 # int enum (see _CABIN) +_LEG_AIRCRAFT_IDX = 17 # string + +_LEGROOM_CLASS: dict[int, str] = { + 1: "AVERAGE", + 2: "BELOW", + 3: "ABOVE", + 4: "Extra Reclining", + 5: "Lie Flat", + 6: "Suite", + 8: "Reclining", + 9: "Angled Flat", +} +_CABIN: dict[int, str] = {1: "ECONOMY", 2: "PREMIUM", 3: "BUSINESS", 4: "FIRST"} + + +@dataclass +class LegAmenities: + """Per-leg legroom + amenity extract, decoded from data[0][2][i] indices 12-17.""" + + aircraft: str | None = None + pitch_inches: int | None = None + legroom_class: str | None = None + cabin: str | None = None + wifi: str | None = None # "free" | "paid" | None (no ground-internet wifi) + power: str | None = None + video: str | None = None + + +def _decode_power(amenities: Any) -> str | None: + """t[12] amenity array — [1] or [3] truthy → in-seat plug; [5] → USB. + + Position [1] is the dominant power signal on current routes. [3] and [5] + are kept for legacy/edge-case routes the original Legrooms+ extension + mapped before Google's sparse-encoding shift (see legroom_recipe.md).""" + if not amenities: + return None + try: + if amenities[1] or amenities[3]: + return "plug" + if amenities[5]: + return "usb" + except (IndexError, TypeError): + return None + return None + + +def _decode_video(amenities: Any) -> str | None: + """Three-state video enum, validated 2026-05 against Google Flights' UI: + + - `[8]` True → "Live TV" (seatback, B6 DirecTV-style) → "stream" + - `[9]` True → "On-demand video" (seatback IFE, DL / EK / UA) → "ondemand" + - `[10]` True → "Stream media to your device" (BYOD, AA / WN) → "byod" + + Priority order matches Google's labelling preference: when more than one + delivery channel is available, the most-premium seatback option takes + the label slot. DIVERGES from Legrooms+ v11.5.0 (used [10] for stream). + """ + if not amenities: + return None + try: + if amenities[8]: + return "stream" + if amenities[9]: + return "ondemand" + if amenities[10]: + return "byod" + except (IndexError, TypeError): + return None + return None + + +_WIFI: dict[int, str] = {2: "free", 3: "paid"} + + +def _decode_wifi(amenities: Any) -> str | None: + """`amenities[11]` is the ground-internet wifi enum: 1=none, 2=free, 3=paid. + + Empirically calibrated 2026-05 by scraping Google Flights' detail-panel + labels for a sample of flights and correlating against the bit array: + + - `[11]=1` → no "Wi-Fi" label (e.g. F9 Frontier) + - `[11]=2` → "Free Wi-Fi" (e.g. AA / B6 / DL / KL / WN — modern US + mainline + some international) + - `[11]=3` → "Wi-Fi for a fee" (e.g. EK / AS / UA / AC / OS — paid + models, even where some elite tiers get it free) + + DIVERGES from the Legrooms+ extension v11.5.0 (which reads `[0]`). + Position `[0]` is consistently None on 2026 responses — the extension's + wifi icon doesn't fire at all on current data. The real signal moved + to `[11]` and became a three-state enum (was a Boolean). + + Returns None for "no wifi", "free", or "paid" — None is render-as-empty; + callers distinguish the two truthy states for UI presentation. + """ + if not amenities: + return None + try: + raw = amenities[11] + except (IndexError, TypeError): + return None + return _WIFI.get(raw) if isinstance(raw, int) else None + + +def _parse_pitch(raw: Any) -> int | None: + """Pitch arrives as '31 in' (Google's units-suffixed string) on most + routes, occasionally bare int. Normalize to int inches; None on + unrecognized shape.""" + if isinstance(raw, int): + return raw + if isinstance(raw, str): + for token in raw.split(): + if token.isdigit(): + return int(token) + return None + + +def _parse_leg_amenities(fl: list[Any]) -> LegAmenities: + """Defensive read of indices 12-17 from a leg tuple. Returns an + all-None LegAmenities if any single field is missing or wrong type — + Google's response shape drifts and a partial extract is better than + dropping the whole flight.""" + amenities = fl[_LEG_AMENITIES_IDX] if len(fl) > _LEG_AMENITIES_IDX else None + legroom_raw = fl[_LEG_LEGROOM_CLASS_IDX] if len(fl) > _LEG_LEGROOM_CLASS_IDX else None + pitch_raw = fl[_LEG_PITCH_IDX] if len(fl) > _LEG_PITCH_IDX else None + cabin_raw = fl[_LEG_CABIN_IDX] if len(fl) > _LEG_CABIN_IDX else None + aircraft = fl[_LEG_AIRCRAFT_IDX] if len(fl) > _LEG_AIRCRAFT_IDX else None + return LegAmenities( + aircraft=aircraft if isinstance(aircraft, str) and aircraft else None, + pitch_inches=_parse_pitch(pitch_raw), + legroom_class=_LEGROOM_CLASS.get(legroom_raw) if isinstance(legroom_raw, int) else None, + cabin=_CABIN.get(cabin_raw) if isinstance(cabin_raw, int) else None, + wifi=_decode_wifi(amenities), + power=_decode_power(amenities), + video=_decode_video(amenities), + ) + @dataclass class GFlightWithId: - """fli's FlightResult plus Google's opaque flight_id for PP matching.""" + """fli's FlightResult plus Google's opaque flight_id and per-leg amenities. + + `amenities[i]` aligns with the i-th leg in `flight.legs` — same index + in both lists points to the same physical segment. + """ flight: FlightResult flight_id: str + amenities: list[LegAmenities] def _parse_flight_with_id(data: list[Any]) -> GFlightWithId: @@ -58,11 +205,12 @@ def _parse_flight_with_id(data: list[Any]) -> GFlightWithId: is the per-flight opaque ID; n[2] legs; n[9] duration; t[0][-1] price.""" price, currency = SearchFlights._parse_price_info(data) # pyright: ignore[reportPrivateUsage, reportUnknownMemberType] flight_id = data[0][_FLIGHT_ID_IDX] if len(data[0]) > _FLIGHT_ID_IDX else "" + leg_tuples: list[list[Any]] = data[0][2] flight = FlightResult( price=price, currency=currency, duration=data[0][9], - stops=len(data[0][2]) - 1, + stops=len(leg_tuples) - 1, legs=[ FlightLeg( airline=SearchFlights._parse_airline(fl[22][0]), # pyright: ignore[reportPrivateUsage] @@ -73,10 +221,11 @@ def _parse_flight_with_id(data: list[Any]) -> GFlightWithId: arrival_datetime=SearchFlights._parse_datetime(fl[21], fl[10]), # pyright: ignore[reportPrivateUsage] duration=fl[11], ) - for fl in data[0][2] + for fl in leg_tuples ], ) - return GFlightWithId(flight=flight, flight_id=flight_id) + amenities = [_parse_leg_amenities(fl) for fl in leg_tuples] + return GFlightWithId(flight=flight, flight_id=flight_id, amenities=amenities) def _one_call(filters: FlightSearchFilters) -> list[GFlightWithId]: diff --git a/src/flight_cli/cli.py b/src/flight_cli/cli.py index 261bcbb..c95a0a6 100644 --- a/src/flight_cli/cli.py +++ b/src/flight_cli/cli.py @@ -44,7 +44,7 @@ from .providers.base import LegQuery if TYPE_CHECKING: - from .models import CalendarResult, Location, SearchResult, Slice + from .models import CalendarResult, LegInfo, Location, SearchResult, Slice # Tuple-length sentinels for `--slice` parser (`ORIGIN-DEST:DATE[:r=...:e=...]`). _SLICE_MIN_PARTS = 2 @@ -363,7 +363,9 @@ def _fmt(s: Slice) -> str: dur = f"{dur_min // 60}h{dur_min % 60:02d}m" if dur_min else "" o = (s.origin.code if s.origin else None) or "?" d = (s.destination.code if s.destination else None) or "?" - return f"{o}→{d} {'/'.join(s.flights) or '?'} {dep[:16]}→{arr[:16]} {dur}" + head = f"{o}→{d} {'/'.join(s.flights) or '?'} {dep[:16]}→{arr[:16]} {dur}" + tail = _fmt_legroom_lines(s) + return f"{head}\n{tail}" if tail else head out = _fmt(slcs[0]) if slcs else "—" ret = _fmt(slcs[1]) if len(slcs) > 1 else "—" @@ -371,6 +373,51 @@ def _fmt(s: Slice) -> str: console.print(st) +# ───────────────── legroom formatters (gflight-populated; Matrix slices noop) ── + + +def _fmt_legroom_one(flight_no: str, leg: LegInfo) -> str: + """Per-leg summary. Returns '' when no legroom fields are populated + (Matrix path — Matrix's response doesn't carry legroom). Uses the + same color-not-text policy as `_fmt_gflight_legroom`.""" + parts: list[str] = [] + cabin_short = _CABIN_LETTER.get(leg.cabin or "", "") + if cabin_short: + parts.append(cabin_short) + if leg.pitch_inches is not None: + token = f'{leg.pitch_inches}"' + color = _LEGROOM_AS_COLOR.get(leg.legroom_class or "") + if color: + token = f"[{color}]{token}[/]" + parts.append(token) + if leg.legroom_class and leg.legroom_class not in {"AVERAGE", "BELOW", "ABOVE"}: + parts.append(leg.legroom_class) + amenities: list[str] = [] + w = _WIFI_GLYPH.get(leg.wifi or "") + if w: + amenities.append(w) + p = _POWER_GLYPH.get(leg.power or "") + if p: + amenities.append(p) + v = _VIDEO_GLYPH.get(leg.video or "") + if v: + amenities.append(v) + if amenities: + parts.append("".join(amenities)) + if not parts: + return "" + return f" {flight_no:<6} " + " ".join(parts) + + +def _fmt_legroom_lines(s: Slice) -> str: + """Per-leg lines under a slice cell, one row per physical flight in the slice. + Empty when no legroom data populated (Matrix path).""" + if not s.legs: + return "" + rows = [_fmt_legroom_one(s.flights[i], leg) for i, leg in enumerate(s.legs)] + return "\n".join(r for r in rows if r) + + def _render_calendar( res: CalendarResult, *, @@ -558,7 +605,8 @@ def _run_gflight_path( def _render_gflight_table(results: list[Any], *, legs: tuple[Leg, ...], top_n: int) -> None: """Render fli results as a rich table. Duck-typed: fli has no type stubs. - Accepts our `GFlightWithId` wrappers — `.flight` is fli's FlightResult.""" + Accepts our `GFlightWithId` wrappers — `.flight` is fli's FlightResult, + `.amenities` is per-leg legroom data parsed from Google's response.""" 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 @@ -572,10 +620,13 @@ def _render_gflight_table(results: list[Any], *, legs: tuple[Leg, ...], top_n: i t.add_column("stops", justify="right") t.add_column("duration") t.add_column("legs") + t.add_column("legroom") + any_legroom = False for i, r in enumerate(results[:top_n], 1): items: list[Any] = list(r) if isinstance(r, tuple) else [r] # pyright: ignore[reportUnknownArgumentType] for j, g in enumerate(items): fr = g.flight # unwrap GFlightWithId → fli FlightResult + amenities = getattr(g, "amenities", []) or [] label = f"{i}{'a' if j == 0 else 'b'}" if len(items) > 1 else str(i) legs_str = " → ".join( f"{getattr(leg.airline, 'name', leg.airline)} {getattr(leg, 'flight_number', '?')}" @@ -583,8 +634,92 @@ def _render_gflight_table(results: list[Any], *, legs: tuple[Leg, ...], top_n: i ) mins = fr.duration dur = f"{mins // 60}h{mins % 60:02d}m" - t.add_row(label, f"{fr.currency or 'USD'}{fr.price:.2f}", str(fr.stops), dur, legs_str) + legroom_str = _fmt_gflight_legroom(fr.legs, amenities) + if legroom_str: + any_legroom = True + t.add_row( + label, + f"{fr.currency or 'USD'}{fr.price:.2f}", + str(fr.stops), + dur, + legs_str, + legroom_str, + ) console.print(t) + if any_legroom: + console.print(_LEGROOM_KEY) + + +# AVERAGE/BELOW/ABOVE are pitch-relative judgments — collapse them to color on +# the inches token so the eye picks out squeeze rows without text noise. The +# named premium-cabin enums describe seat construction (Lie Flat vs Suite vs +# Angled Flat aren't comparable on pitch alone) so those stay as text. +_LEGROOM_AS_COLOR = {"BELOW": "red", "ABOVE": "green"} +_CABIN_LETTER = {"ECONOMY": "Y", "PREMIUM": "W", "BUSINESS": "J", "FIRST": "F"} +# 📶 for wifi is the only emoji (2-col) — wifi is the highest-value binary signal +# and 📶 is universally read at-a-glance where ≋ is not. Power and video keep +# 1-col Unicode pairs so the plug-vs-USB and stream-vs-ondemand distinctions +# don't bloat the column. See `_LEGROOM_KEY` for the rendered legend. +_WIFI_GLYPH = {"free": "📶", "paid": "[yellow]📶$[/]"} +_POWER_GLYPH = {"plug": "↯", "usb": "⌁"} # ↯ is more lightning-y (=plug); ⌁ reads more like a connector (=USB) +_VIDEO_GLYPH = {"stream": "▶", "ondemand": "▷", "byod": "◰"} # ◰ = quadrant square, evokes a phone screen +_LEGROOM_KEY = ( + "[dim]Legroom glyphs: " + f"{_WIFI_GLYPH['free']} free wifi · " + f"{_WIFI_GLYPH['paid']}[dim] paid wifi · " + f"{_POWER_GLYPH['plug']} in-seat plug · " + f"{_POWER_GLYPH['usb']} USB only · " + f"{_VIDEO_GLYPH['stream']} live TV · " + f"{_VIDEO_GLYPH['ondemand']} on-demand · " + f"{_VIDEO_GLYPH['byod']} stream-to-device · " + "[red]red[/dim] = BELOW · [green]green[/] = ABOVE" + "[/]" +) + + +def _fmt_gflight_legroom(fli_legs: list[Any], amenities: list[Any]) -> str: + """One line per physical leg: ` " [seat-type] `. + + `amenities[i]` is a LegAmenities instance from _gflight_ids; misaligned + or empty inputs render as ''.""" + lines: list[str] = [] + for i, leg in enumerate(fli_legs): + a = amenities[i] if i < len(amenities) else None + if a is None: + continue + parts: list[str] = [] + cabin = _CABIN_LETTER.get(getattr(a, "cabin", None) or "", "") + if cabin: + parts.append(cabin) + pitch = getattr(a, "pitch_inches", None) + cls = getattr(a, "legroom_class", None) + if pitch is not None: + tok = f'{pitch}"' + color = _LEGROOM_AS_COLOR.get(cls or "") + if color: + tok = f"[{color}]{tok}[/]" + parts.append(tok) + if cls and cls not in {"AVERAGE", "BELOW", "ABOVE"}: + parts.append(cls) + glyphs: list[str] = [] + wifi_g = _WIFI_GLYPH.get(getattr(a, "wifi", None) or "") + if wifi_g: + glyphs.append(wifi_g) + power_g = _POWER_GLYPH.get(getattr(a, "power", None) or "") + if power_g: + glyphs.append(power_g) + video_g = _VIDEO_GLYPH.get(getattr(a, "video", None) or "") + if video_g: + glyphs.append(video_g) + if glyphs: + parts.append("".join(glyphs)) + if not parts: + continue + leg_label = ( + f"{getattr(leg.airline, 'name', leg.airline)}{getattr(leg, 'flight_number', '?')}" + ) + lines.append(f"{leg_label:<6} " + " ".join(parts)) + return "\n".join(lines) # ─────────────────────────────── commands ────────────────────────────────── @@ -1205,5 +1340,73 @@ async def go() -> list[Location]: console.print(t) +@app.command() +def seatmap( + origin: Annotated[str, typer.Argument(help="Origin IATA")], + destination: Annotated[str, typer.Argument(help="Destination IATA")], + flight: Annotated[str, typer.Argument(help="Flight number, bare or IATA-prefixed (e.g. AA100)")], + date: Annotated[str, typer.Option("--date", help="Flight date YYYY-MM-DD")], + aircraft: Annotated[ + str | None, + typer.Option("--aircraft", help="Aircraft type (e.g. 'Airbus A330') — improves match"), + ] = None, + carrier: Annotated[ + str | None, + typer.Option("--carrier", help="Carrier IATA. Inferred from flight# if it starts with letters."), + ] = None, + fetch: Annotated[ + bool, + typer.Option("--fetch/--no-fetch", help="Resolve to seatmaps.com URL via one HTTP GET (default)."), + ] = True, +) -> None: + """Get a seatmaps.com URL for a specific flight. + + Mirrors what the Legrooms+ Chrome extension does on click. Without + --no-fetch, makes one GET to travelarrow.io/api/s and prints the + seatmaps.com URL it resolves to. With --no-fetch, just prints the + travelarrow URL itself (cheap, but you'll get JSON not a seatmap). + """ + from .seatmap import fetch_seatmap_url, seatmap_api_url # noqa: PLC0415 + + flight = flight.upper() + if carrier is None: + prefix = "".join(c for c in flight[:3] if c.isalpha()) + if not prefix: + err.print("[red]Cannot infer carrier from flight number — pass --carrier IATA.[/]") + raise typer.Exit(2) + carrier = prefix + + parsed = _parse_date(date) + api_url = seatmap_api_url( + origin=origin, + dest=destination, + flight_number=flight, + carrier=carrier, + date=parsed, + aircraft=aircraft, + ) + if not fetch: + console.print(api_url) + return + try: + url = fetch_seatmap_url( + origin=origin, + dest=destination, + flight_number=flight, + carrier=carrier, + date=parsed, + aircraft=aircraft, + ) + except Exception as e: # noqa: BLE001 - third-party undocumented errors; show + degrade + err.print(f"[red]Seatmap lookup failed:[/] {e}") + console.print(f"[dim]API URL:[/] {api_url}") + raise typer.Exit(1) from e + if url is None: + err.print("[yellow]No seatmap on file for this flight/aircraft.[/]") + console.print(f"[dim]API URL:[/] {api_url}") + raise typer.Exit(1) + console.print(url) + + if __name__ == "__main__": app() diff --git a/src/flight_cli/models.py b/src/flight_cli/models.py index cd17eb2..757e3d9 100644 --- a/src/flight_cli/models.py +++ b/src/flight_cli/models.py @@ -82,6 +82,24 @@ class SliceEndpoint(_Loose): code: str | None = None +class LegInfo(_Loose): + """Per-leg amenities + legroom data from Google Flights /GetShoppingResults. + + Populated by the gflight backend from `data[0][2][i]` indices 12-17 (the + same indices the Legrooms+ Chrome extension parses). Matrix itineraries + leave this empty — Matrix's payload doesn't carry pitch or amenity data. + Index in `Slice.legs` aligns with the same index in `Slice.flights`. + """ + + aircraft: str | None = None + pitch_inches: int | None = None + legroom_class: str | None = None # AVERAGE / BELOW / ABOVE / Lie Flat / ... + cabin: str | None = None # ECONOMY / PREMIUM / BUSINESS / FIRST + wifi: str | None = None # "free" | "paid" | None (no ground-internet wifi) + power: str | None = None # "plug" | "usb" | None + video: str | None = None # "stream" | "ondemand" | None + + class Slice(_Loose): flights: list[str] = Field(default_factory=list[str]) departure: str | None = None @@ -95,6 +113,7 @@ class Slice(_Loose): # join key. None for Matrix cash itineraries — they don't expose an ID # PP recognizes, so those fall back to flight#+date / route+time joins. flight_id: str | None = None + legs: list[LegInfo] = Field(default_factory=list[LegInfo]) class SliceCarrier(_Loose): diff --git a/src/flight_cli/pp/gflight_adapter.py b/src/flight_cli/pp/gflight_adapter.py index ec45870..9d0b295 100644 --- a/src/flight_cli/pp/gflight_adapter.py +++ b/src/flight_cli/pp/gflight_adapter.py @@ -24,6 +24,7 @@ Itinerary, ItineraryDetails, ItineraryExt, + LegInfo, SearchResult, Slice, SliceEndpoint, @@ -33,6 +34,8 @@ if TYPE_CHECKING: from collections.abc import Sequence + from .._gflight_ids import LegAmenities + def _airport_code(a: Any) -> str: name: str = getattr(a, "name", "") or "" @@ -47,9 +50,26 @@ def _flight_id_string(leg: Any) -> str: return f"{iata}{leg.flight_number}" -def _slice_from_flight_result(fr: Any, flight_id: str | None = None) -> Slice: +def _leg_info(a: LegAmenities) -> LegInfo: + return LegInfo( + aircraft=a.aircraft, + pitch_inches=a.pitch_inches, + legroom_class=a.legroom_class, + cabin=a.cabin, + wifi=a.wifi, + power=a.power, + video=a.video, + ) + + +def _slice_from_flight_result( + fr: Any, + flight_id: str | None = None, + amenities: list[LegAmenities] | None = None, +) -> Slice: legs: list[Any] = list(fr.legs) first, last = legs[0], legs[-1] + leg_infos: list[LegInfo] = [_leg_info(a) for a in (amenities or [])] return Slice( flights=[_flight_id_string(leg) for leg in legs], departure=first.departure_datetime.isoformat(), @@ -58,6 +78,7 @@ def _slice_from_flight_result(fr: Any, flight_id: str | None = None) -> Slice: origin=SliceEndpoint(code=_airport_code(first.departure_airport)), destination=SliceEndpoint(code=_airport_code(last.arrival_airport)), flight_id=flight_id, + legs=leg_infos, ) @@ -67,12 +88,12 @@ def _price_string(fr: Any) -> str: return f"{currency}{fr.price:.2f}" -def _unwrap(item: Any) -> tuple[Any, str | None]: - """Accept either a raw fli `FlightResult` (no flight_id available) or - a `GFlightWithId` carrying the captured opaque ID.""" +def _unwrap(item: Any) -> tuple[Any, str | None, list[LegAmenities] | None]: + """Accept either a raw fli `FlightResult` (no flight_id / amenities) or + a `GFlightWithId` carrying the captured opaque ID + per-leg amenities.""" if hasattr(item, "flight_id") and hasattr(item, "flight"): - return item.flight, item.flight_id - return item, None + return item.flight, item.flight_id, getattr(item, "amenities", None) + return item, None, None def fli_results_to_search_result(results: Sequence[Any]) -> SearchResult: @@ -98,8 +119,8 @@ def fli_results_to_search_result(results: Sequence[Any]) -> SearchResult: if not items_raw: continue unwrapped = [_unwrap(it) for it in items_raw] - slices = [_slice_from_flight_result(fr, fid) for fr, fid in unwrapped] - first_fr, _ = unwrapped[0] + slices = [_slice_from_flight_result(fr, fid, am) for fr, fid, am in unwrapped] + first_fr = unwrapped[0][0] price_str = _price_string(first_fr) solutions.append( Itinerary( diff --git a/src/flight_cli/seatmap.py b/src/flight_cli/seatmap.py new file mode 100644 index 0000000..229addd --- /dev/null +++ b/src/flight_cli/seatmap.py @@ -0,0 +1,100 @@ +"""Seatmap URL helpers. + +The Legrooms+ Chrome extension fetches `https://www.travelarrow.io/api/s` +with per-flight params and `window.open`s `https://seatmaps.com/` +with the returned path. We mirror that two-step: + +- `seatmap_api_url(...)` constructs the deterministic API URL — no HTTP. +- `fetch_seatmap_url(...)` resolves it to the seatmaps.com URL via one GET. + +Decoupled because most callers just want a clickable link they can paste; +the indirection through travelarrow.io is acceptable for the cheap path. +Eager batch fetches across a result set are opt-in. +""" + +from __future__ import annotations + +import urllib.parse +from datetime import date as Date +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Mapping + +_SEATMAP_API = "https://www.travelarrow.io/api/s" +_SEATMAPS_BASE = "https://seatmaps.com" + + +def _format_date(d: Date | str) -> str: + """Legrooms+ extension sends `M/D/YYYY` (no zero-padding) — confirmed + from load_flight_data.js: `date=${n}/${i}/${e}` where [e, n, i] = raw[20] + is [year, month, day]. The travelarrow API tolerates ISO too, but we + match the extension's wire shape exactly to minimize divergence risk.""" + if isinstance(d, str): + d = Date.fromisoformat(d[:10]) + return f"{d.month}/{d.day}/{d.year}" + + +def seatmap_api_url( + *, + origin: str, + dest: str, + flight_number: str, + carrier: str, + date: Date | str, + aircraft: str | None = None, +) -> str: + """Build the travelarrow.io/api/s URL for a single physical leg. + + `flight_number` may be either bare ("100") or IATA-prefixed ("AA100"); + we strip the prefix if it matches `carrier` to match what the extension + sends (it passes `s.number` which is the bare number from data[0][2][i][22][1]). + """ + bare = flight_number + if bare[:2].upper() == carrier.upper(): + bare = bare[2:] + params: list[tuple[str, str]] = [ + ("from", origin.upper()), + ("to", dest.upper()), + ("flightno", bare), + ("carrier", carrier.upper()), + ("date", _format_date(date)), + ] + if aircraft: + params.append(("aircraft", aircraft.strip())) + return f"{_SEATMAP_API}?{urllib.parse.urlencode(params)}" + + +def fetch_seatmap_url( + *, + origin: str, + dest: str, + flight_number: str, + carrier: str, + date: Date | str, + aircraft: str | None = None, + timeout: float = 10.0, +) -> str | None: + """Resolve the travelarrow API to a seatmaps.com URL via one HTTP GET. + + Returns None if the API responds without a `seatmap_url` field (no + seatmap on file for this aircraft/cabin combination). Network errors + raise — callers that want graceful degradation should catch. + """ + import httpx # noqa: PLC0415 + + url = seatmap_api_url( + origin=origin, + dest=dest, + flight_number=flight_number, + carrier=carrier, + date=date, + aircraft=aircraft, + ) + resp = httpx.get(url, timeout=timeout) + resp.raise_for_status() + body: Mapping[str, object] = resp.json() + path = body.get("seatmap_url") + if not isinstance(path, str) or not path: + return None + return f"{_SEATMAPS_BASE}/{path.lstrip('/')}" diff --git a/tests/test_legroom_parser.py b/tests/test_legroom_parser.py new file mode 100644 index 0000000..311802e --- /dev/null +++ b/tests/test_legroom_parser.py @@ -0,0 +1,276 @@ +# pyright: reportPrivateUsage=false +"""Tests for the legroom-field extractor in _gflight_ids. + +Bit positions in `data[0][2][i]` (each leg tuple in Google Flights' +/GetShoppingResults response). Empirically calibrated 2026-05 by +correlating against Google Flights' detail-panel labels — see +`research/probe_wifi_correlation.py` and `docs/memories/legroom_recipe.md`.""" + +from __future__ import annotations + +from flight_cli._gflight_ids import ( + LegAmenities, + _decode_power, + _decode_video, + _decode_wifi, + _parse_leg_amenities, + _parse_pitch, +) + + +# ─────────────────────────── pitch parsing ───────────────────────────── + + +def test_pitch_string_with_unit_suffix() -> None: + assert _parse_pitch("31 in") == 31 + assert _parse_pitch("32 in") == 32 + assert _parse_pitch("28 in") == 28 + + +def test_pitch_bare_int_passthrough() -> None: + assert _parse_pitch(30) == 30 + + +def test_pitch_none_on_unrecognized() -> None: + assert _parse_pitch(None) is None + assert _parse_pitch("") is None + assert _parse_pitch([]) is None + assert _parse_pitch("very tight") is None + + +# ─────────────────────────── wifi enum ([11]) ────────────────────────── + + +def test_wifi_no_wifi_enum_1() -> None: + """F9 Frontier (no wifi): [11]=1 → None.""" + arr = [None, None, None, None, None, None, None, None, None, None, None, 1] + assert _decode_wifi(arr) is None + + +def test_wifi_free_enum_2() -> None: + """AA/B6/DL/WN/KL (free wifi): [11]=2 → "free".""" + arr = [None, True, None, None, None, None, None, None, None, None, True, 2] + assert _decode_wifi(arr) == "free" + + +def test_wifi_paid_enum_3() -> None: + """EK/UA/AS/AC/OS (paid wifi): [11]=3 → "paid".""" + arr = [None, True, None, None, None, None, None, None, None, True, None, 3] + assert _decode_wifi(arr) == "paid" + + +def test_wifi_empty_array_returns_none() -> None: + assert _decode_wifi([]) is None + assert _decode_wifi(None) is None + + +def test_wifi_unknown_enum_value_returns_none() -> None: + arr = [None] * 11 + [99] + assert _decode_wifi(arr) is None + + +def test_wifi_robust_to_short_array() -> None: + """Truncated amenities (length < 12) → None, no IndexError.""" + assert _decode_wifi([None, True]) is None + + +# ─────────────────────────── power decoder ───────────────────────────── + + +def test_power_plug_via_idx1() -> None: + """Live JFK→LHR DL1 sample: position [1]=True → "plug".""" + arr = [None, True, None, None, None, None, None, None, None, True, None, 2] + assert _decode_power(arr) == "plug" + + +def test_power_plug_via_idx3() -> None: + arr = [None, None, None, True, None, None, None, None, None, None, None, None] + assert _decode_power(arr) == "plug" + + +def test_power_usb_only_via_idx5() -> None: + """WN 1140 (737MAX, USB-only): [5]=True with [1]/[3] both False → "usb".""" + arr = [None, None, None, None, None, True, None, None, None, None, True, 2] + assert _decode_power(arr) == "usb" + + +def test_power_none_when_no_signal() -> None: + assert _decode_power([None] * 12) is None + assert _decode_power(None) is None + + +def test_power_robust_to_short_array() -> None: + """No IndexError on truncated input.""" + assert _decode_power([True]) is None # [1] OOB + assert _decode_power([]) is None + + +# ─────────────────────────── video decoder ───────────────────────────── + + +def test_video_live_stream_via_idx8() -> None: + """B6 (DirecTV seatback live TV): [8]=True → "stream". Validated against + B6 1072 FLL→LGA where Google's UI labelled it "Live TV".""" + arr = [None, True, None, None, None, None, None, None, True, None, None, 2] + assert _decode_video(arr) == "stream" + + +def test_video_ondemand_via_idx9() -> None: + """DL/EK/UA seatback IFE: [9]=True → "ondemand".""" + arr = [None, True, None, None, None, None, None, None, None, True, None, 2] + assert _decode_video(arr) == "ondemand" + + +def test_video_byod_via_idx10() -> None: + """AA/WN BYOD-to-device streaming: [10]=True → "byod". Validated against + AA 952 MIA→LGA where Google's UI labelled it "Stream media to your device".""" + arr = [None, True, None, None, None, None, None, None, None, None, True, 2] + assert _decode_video(arr) == "byod" + + +def test_video_seatback_beats_byod() -> None: + """When multiple delivery channels are set, the most-premium seatback + option wins — matches Google's label priority.""" + arr = [None] * 12 + arr[8] = True + arr[10] = True + assert _decode_video(arr) == "stream" + arr2 = [None] * 12 + arr2[9] = True + arr2[10] = True + assert _decode_video(arr2) == "ondemand" + + +def test_video_none_when_no_signal() -> None: + assert _decode_video([None] * 12) is None + assert _decode_video(None) is None + + +def test_video_robust_to_short_array() -> None: + assert _decode_video([True, True]) is None # [8] OOB + assert _decode_video([]) is None + + +# ─────────────────────────── full-tuple parsing ──────────────────────── + + +def _build_leg( + *, + amenities: list | None = None, + legroom_class: object = 1, + pitch: object = "31 in", + cabin: object = 1, + aircraft: object = "Airbus A330", +) -> list: + """Construct a 33-element leg tuple with the indices under test populated.""" + leg: list = [None] * 33 + leg[12] = amenities + leg[13] = legroom_class + leg[14] = pitch + leg[16] = cabin + leg[17] = aircraft + return leg + + +def test_groundtruth_aa952_mia_lga() -> None: + """AA 952 Boeing 737MAX 8, MIA→LGA on 2026-06-16. + + Google's UI shows: + Average legroom (30 in) · Free Wi-Fi + In-seat power & USB outlets · Stream media to your device + """ + leg = _build_leg( + amenities=[None, True, None, None, None, None, None, None, None, None, True, 2], + legroom_class=1, + pitch="30 in", + cabin=1, + aircraft="Boeing 737MAX 8 Passenger", + ) + a = _parse_leg_amenities(leg) + assert a == LegAmenities( + aircraft="Boeing 737MAX 8 Passenger", + pitch_inches=30, + legroom_class="AVERAGE", + cabin="ECONOMY", + wifi="free", + power="plug", + video="byod", + ) + + +def test_groundtruth_b61072_fll_lga() -> None: + """B6 1072 Airbus A320, FLL→LGA on 2026-06-16. + + Google's UI shows: + Above average legroom (32 in) · Free Wi-Fi + In-seat power & USB outlets · Live TV + """ + leg = _build_leg( + amenities=[None, True, None, None, None, None, None, None, True, None, None, 2], + legroom_class=3, + pitch="32 in", + cabin=1, + aircraft="Airbus A320", + ) + a = _parse_leg_amenities(leg) + assert a == LegAmenities( + aircraft="Airbus A320", + pitch_inches=32, + legroom_class="ABOVE", + cabin="ECONOMY", + wifi="free", + power="plug", + video="stream", + ) + + +def test_frontier_no_amenities_empty_array() -> None: + """F9 (Frontier) A320neo: t[12]=[]. No wifi/IFE/power; pitch shows BELOW.""" + leg = _build_leg( + amenities=[], + legroom_class=2, # BELOW + pitch="28 in", + cabin=1, + aircraft="Airbus A320neo", + ) + a = _parse_leg_amenities(leg) + assert a.wifi is None + assert a.power is None + assert a.video is None + assert a.pitch_inches == 28 + assert a.legroom_class == "BELOW" + + +def test_premium_cabin_suite_paid_wifi() -> None: + """Premium cabin (Emirates A380-style): paid wifi + on-demand seatback + suite.""" + leg = _build_leg( + amenities=[None, True, None, None, None, None, None, None, None, True, None, 3], + legroom_class=6, # Suite + pitch=78, + cabin=3, # BUSINESS + aircraft="Airbus A380-800", + ) + a = _parse_leg_amenities(leg) + assert a.legroom_class == "Suite" + assert a.cabin == "BUSINESS" + assert a.pitch_inches == 78 + assert a.wifi == "paid" + assert a.power == "plug" + assert a.video == "ondemand" + + +def test_missing_indices_handled_gracefully() -> None: + """Truncated leg: parser shouldn't raise, just returns None for absent fields.""" + short_leg: list = [None] * 13 + short_leg[12] = None + a = _parse_leg_amenities(short_leg) + assert a.aircraft is None + assert a.pitch_inches is None + assert a.cabin is None + + +def test_unknown_enums_become_none() -> None: + leg = _build_leg(legroom_class=999, cabin=999) + a = _parse_leg_amenities(leg) + assert a.legroom_class is None + assert a.cabin is None diff --git a/tests/test_seatmap.py b/tests/test_seatmap.py new file mode 100644 index 0000000..a4cde80 --- /dev/null +++ b/tests/test_seatmap.py @@ -0,0 +1,98 @@ +"""Tests for the seatmap URL helper. + +`fetch_seatmap_url` makes a real HTTP call; we cover it only via the +deterministic builder (`seatmap_api_url`) here. Live integration is exercised +via the `flight seatmap` smoke run, not in CI.""" + +from __future__ import annotations + +from datetime import date + +from flight_cli.seatmap import seatmap_api_url + + +def test_basic_url_shape() -> None: + url = seatmap_api_url( + origin="JFK", + dest="LHR", + flight_number="100", + carrier="AA", + date=date(2026, 8, 15), + ) + # Match what the Legrooms+ extension actually emits — same host, same + # param names, M/D/YYYY (no leading zeros) date format. + assert url.startswith("https://www.travelarrow.io/api/s?") + assert "from=JFK" in url + assert "to=LHR" in url + assert "flightno=100" in url + assert "carrier=AA" in url + assert "date=8%2F15%2F2026" in url # url-encoded "/" + + +def test_strips_iata_prefix_from_flight_number() -> None: + """User-supplied 'AA100' → wire form 'flightno=100' (extension drops prefix).""" + url = seatmap_api_url( + origin="JFK", + dest="LHR", + flight_number="AA100", + carrier="AA", + date=date(2026, 8, 15), + ) + assert "flightno=100" in url + + +def test_keeps_flight_number_when_prefix_does_not_match_carrier() -> None: + """Codeshare case: marketing carrier ≠ first 2 chars of flight#.""" + url = seatmap_api_url( + origin="JFK", + dest="LHR", + flight_number="DL1", # 'DL' prefix, but the carrier param is 'KL' + carrier="KL", + date=date(2026, 8, 15), + ) + assert "flightno=DL1" in url + + +def test_aircraft_param_optional() -> None: + url = seatmap_api_url( + origin="JFK", + dest="LHR", + flight_number="100", + carrier="AA", + date=date(2026, 8, 15), + ) + assert "aircraft=" not in url + + +def test_aircraft_param_included_when_set() -> None: + url = seatmap_api_url( + origin="JFK", + dest="LHR", + flight_number="100", + carrier="AA", + date=date(2026, 8, 15), + aircraft="Airbus A330", + ) + assert "aircraft=Airbus+A330" in url + + +def test_iso_string_date_accepted() -> None: + url = seatmap_api_url( + origin="JFK", + dest="LHR", + flight_number="100", + carrier="AA", + date="2026-08-15", + ) + assert "date=8%2F15%2F2026" in url + + +def test_codes_are_uppercased() -> None: + url = seatmap_api_url( + origin="jfk", + dest="lhr", + flight_number="100", + carrier="aa", + date=date(2026, 8, 15), + ) + assert "from=JFK" in url and "to=LHR" in url and "carrier=AA" in url From 79129108be0a2f2865b01a35e56b8de864178d33 Mon Sep 17 00:00:00 2001 From: ak2k <19240940+ak2k@users.noreply.github.com> Date: Fri, 15 May 2026 19:40:47 -0400 Subject: [PATCH 2/2] Fix lint: line length, unused noqa, naming, import sort --- src/flight_cli/cli.py | 23 +++++++++++++++++------ src/flight_cli/seatmap.py | 10 +++++----- tests/test_legroom_parser.py | 15 ++++++++------- 3 files changed, 30 insertions(+), 18 deletions(-) diff --git a/src/flight_cli/cli.py b/src/flight_cli/cli.py index c95a0a6..35ea2ac 100644 --- a/src/flight_cli/cli.py +++ b/src/flight_cli/cli.py @@ -661,8 +661,10 @@ def _render_gflight_table(results: list[Any], *, legs: tuple[Leg, ...], top_n: i # 1-col Unicode pairs so the plug-vs-USB and stream-vs-ondemand distinctions # don't bloat the column. See `_LEGROOM_KEY` for the rendered legend. _WIFI_GLYPH = {"free": "📶", "paid": "[yellow]📶$[/]"} -_POWER_GLYPH = {"plug": "↯", "usb": "⌁"} # ↯ is more lightning-y (=plug); ⌁ reads more like a connector (=USB) -_VIDEO_GLYPH = {"stream": "▶", "ondemand": "▷", "byod": "◰"} # ◰ = quadrant square, evokes a phone screen +# ↯ is more lightning-y (= plug power); ⌁ reads more like a connector (= USB). +_POWER_GLYPH = {"plug": "↯", "usb": "⌁"} +# ◰ (quadrant square) evokes a phone screen — stands in for BYOD streaming. +_VIDEO_GLYPH = {"stream": "▶", "ondemand": "▷", "byod": "◰"} _LEGROOM_KEY = ( "[dim]Legroom glyphs: " f"{_WIFI_GLYPH['free']} free wifi · " @@ -1344,7 +1346,10 @@ async def go() -> list[Location]: def seatmap( origin: Annotated[str, typer.Argument(help="Origin IATA")], destination: Annotated[str, typer.Argument(help="Destination IATA")], - flight: Annotated[str, typer.Argument(help="Flight number, bare or IATA-prefixed (e.g. AA100)")], + flight: Annotated[ + str, + typer.Argument(help="Flight number, bare or IATA-prefixed (e.g. AA100)"), + ], date: Annotated[str, typer.Option("--date", help="Flight date YYYY-MM-DD")], aircraft: Annotated[ str | None, @@ -1352,11 +1357,17 @@ def seatmap( ] = None, carrier: Annotated[ str | None, - typer.Option("--carrier", help="Carrier IATA. Inferred from flight# if it starts with letters."), + typer.Option( + "--carrier", + help="Carrier IATA. Inferred from flight# if it starts with letters.", + ), ] = None, fetch: Annotated[ bool, - typer.Option("--fetch/--no-fetch", help="Resolve to seatmaps.com URL via one HTTP GET (default)."), + typer.Option( + "--fetch/--no-fetch", + help="Resolve to seatmaps.com URL via one HTTP GET (default).", + ), ] = True, ) -> None: """Get a seatmaps.com URL for a specific flight. @@ -1397,7 +1408,7 @@ def seatmap( date=parsed, aircraft=aircraft, ) - except Exception as e: # noqa: BLE001 - third-party undocumented errors; show + degrade + except Exception as e: err.print(f"[red]Seatmap lookup failed:[/] {e}") console.print(f"[dim]API URL:[/] {api_url}") raise typer.Exit(1) from e diff --git a/src/flight_cli/seatmap.py b/src/flight_cli/seatmap.py index 229addd..7704fd5 100644 --- a/src/flight_cli/seatmap.py +++ b/src/flight_cli/seatmap.py @@ -14,8 +14,8 @@ from __future__ import annotations +import datetime as _dt import urllib.parse -from datetime import date as Date from typing import TYPE_CHECKING if TYPE_CHECKING: @@ -25,13 +25,13 @@ _SEATMAPS_BASE = "https://seatmaps.com" -def _format_date(d: Date | str) -> str: +def _format_date(d: _dt.date | str) -> str: """Legrooms+ extension sends `M/D/YYYY` (no zero-padding) — confirmed from load_flight_data.js: `date=${n}/${i}/${e}` where [e, n, i] = raw[20] is [year, month, day]. The travelarrow API tolerates ISO too, but we match the extension's wire shape exactly to minimize divergence risk.""" if isinstance(d, str): - d = Date.fromisoformat(d[:10]) + d = _dt.date.fromisoformat(d[:10]) return f"{d.month}/{d.day}/{d.year}" @@ -41,7 +41,7 @@ def seatmap_api_url( dest: str, flight_number: str, carrier: str, - date: Date | str, + date: _dt.date | str, aircraft: str | None = None, ) -> str: """Build the travelarrow.io/api/s URL for a single physical leg. @@ -71,7 +71,7 @@ def fetch_seatmap_url( dest: str, flight_number: str, carrier: str, - date: Date | str, + date: _dt.date | str, aircraft: str | None = None, timeout: float = 10.0, ) -> str | None: diff --git a/tests/test_legroom_parser.py b/tests/test_legroom_parser.py index 311802e..98aea8b 100644 --- a/tests/test_legroom_parser.py +++ b/tests/test_legroom_parser.py @@ -8,6 +8,8 @@ from __future__ import annotations +from typing import Any + from flight_cli._gflight_ids import ( LegAmenities, _decode_power, @@ -17,7 +19,6 @@ _parse_pitch, ) - # ─────────────────────────── pitch parsing ───────────────────────────── @@ -131,11 +132,11 @@ def test_video_byod_via_idx10() -> None: def test_video_seatback_beats_byod() -> None: """When multiple delivery channels are set, the most-premium seatback option wins — matches Google's label priority.""" - arr = [None] * 12 + arr: list[Any] = [None] * 12 arr[8] = True arr[10] = True assert _decode_video(arr) == "stream" - arr2 = [None] * 12 + arr2: list[Any] = [None] * 12 arr2[9] = True arr2[10] = True assert _decode_video(arr2) == "ondemand" @@ -156,14 +157,14 @@ def test_video_robust_to_short_array() -> None: def _build_leg( *, - amenities: list | None = None, + amenities: list[Any] | None = None, legroom_class: object = 1, pitch: object = "31 in", cabin: object = 1, aircraft: object = "Airbus A330", -) -> list: +) -> list[Any]: """Construct a 33-element leg tuple with the indices under test populated.""" - leg: list = [None] * 33 + leg: list[Any] = [None] * 33 leg[12] = amenities leg[13] = legroom_class leg[14] = pitch @@ -261,7 +262,7 @@ def test_premium_cabin_suite_paid_wifi() -> None: def test_missing_indices_handled_gracefully() -> None: """Truncated leg: parser shouldn't raise, just returns None for absent fields.""" - short_leg: list = [None] * 13 + short_leg: list[Any] = [None] * 13 short_leg[12] = None a = _parse_leg_amenities(short_leg) assert a.aircraft is None