Skip to content

Add library support for booking offers#102

Open
kvasdopil wants to merge 2 commits intopunitarani:mainfrom
kvasdopil:feat/booking-offers
Open

Add library support for booking offers#102
kvasdopil wants to merge 2 commits intopunitarani:mainfrom
kvasdopil:feat/booking-offers

Conversation

@kvasdopil
Copy link
Copy Markdown
Contributor

@kvasdopil kvasdopil commented Mar 31, 2026

What Changed

This PR adds Python library support for fetching booking offers for a specific itinerary returned by Google Flights search.

At a high level:

  • SearchFlights.search(...) now preserves the Google booking token on each FlightResult
  • SearchFlights.get_booking_offers(...) performs a single GetBookingResults lookup for an exact itinerary
  • booking-offer parsing and request-building live in a dedicated fli/search/booking_offers.py module
  • the returned offer data is exposed as a typed BookingOffer model
  • the Python docs now include examples for one-way and round-trip offer lookup

Why

Today the library can find itineraries, but it does not expose the next step a consumer usually needs: where that itinerary can actually be booked and at what merchant price.

Google Flights already returns enough information to do this in two steps:

  1. search for itineraries
  2. use the selected itinerary's booking token plus its leg selection to fetch booking offers

This PR exposes that flow in the Python library without changing the CLI or MCP surfaces.

API Shape

Example usage:

search = SearchFlights()
flights = search.search(filters)

offers = search.get_booking_offers(flights[0])

For round-trip results, pass the returned itinerary tuple directly:

outbound_and_return = flights[0]
offers = search.get_booking_offers(outbound_and_return)

Each BookingOffer includes:

  • merchant code and merchant name
  • display URL
  • Google clickthrough booking URL
  • price and currency
  • official/non-official flag
  • matching flight numbers

Scope

This PR is intentionally limited to the Python library:

  • no CLI changes
  • no MCP changes
  • no CLI/MCP docs updates

Notes

  • get_booking_offers(...) is a single booking-results lookup once you already have the itinerary from search()
  • the returned booking_url is Google Flights' clickthrough URL, not the final resolved merchant landing URL
  • booking_token remains on FlightResult for now so offer lookup stays explicit and stateless

Testing

  • /Users/lexa/projects/fli/.venv/bin/python -m pytest -q tests/search/test_booking_results.py
  • /Users/lexa/projects/fli/.venv/bin/python -m ruff check fli/models/__init__.py fli/models/google_flights/__init__.py fli/models/google_flights/base.py fli/search/flights.py fli/search/booking_offers.py tests/search/test_booking_results.py

Greptile Summary

This PR extends the fli Python library with a two-step booking-offer flow: SearchFlights.search() now preserves a booking_token on each FlightResult, and a new get_booking_offers() method uses that token to call Google Flights' GetBookingResults endpoint and return a list of typed BookingOffer objects. A dedicated fli/search/booking_offers.py module isolates request-building and response-parsing logic. The PR is intentionally scoped to the Python library surface — no CLI or MCP changes are included.

Key observations:

  • P1 – unhandled exceptions from filter building: build_booking_filter_block and build_booking_f_req are called outside the try/except block in get_booking_offers, so any ValidationError (e.g. past-dated flight), IndexError, or serialization failure escapes without the "Booking offer lookup failed" wrapper that the method promises.
  • P2 – empty-legs guard: build_booking_filter_block accesses flight.legs[0] and flight.legs[-1] unconditionally; a FlightResult with an empty legs list would raise a bare IndexError.
  • P2 – __import__ in tests: Uses __import__("urllib.parse").parse.unquote(...) instead of a top-level import urllib.parse.
  • P2 – incomplete docstring: get_booking_offers has only a one-liner; the other public methods on SearchFlights carry full Google-style Args/Returns/Raises documentation.

Confidence Score: 4/5

Safe to merge after moving filter-building calls inside the try/except block in get_booking_offers.

One P1 issue remains: exceptions from build_booking_filter_block and build_booking_f_req propagate unwrapped, breaking the error-handling contract that the rest of SearchFlights establishes. The fix is a small mechanical change. All other findings are P2 style/quality suggestions that do not block correctness.

fli/search/flights.py — the try/except scope in get_booking_offers needs to cover the filter-building phase.

Important Files Changed

Filename Overview
fli/search/flights.py Adds get_booking_offers and _parse_booking_token to SearchFlights; filter-building calls sit outside the try/except, so validation or serialization failures surface as unwrapped exceptions rather than the documented "Booking offer lookup failed" error.
fli/search/booking_offers.py New module providing full request-building and response-parsing pipeline for GetBookingResults; logic is well-structured, but build_booking_filter_block accesses flight.legs[0]/legs[-1] without guarding against an empty legs list.
fli/models/google_flights/base.py Adds optional booking_token to FlightResult and a new BookingOffer Pydantic model; changes are non-breaking and backward-compatible.
tests/search/test_booking_results.py New test file covers parsing, deduplication, round-trip token selection, and error wrapping; uses __import__ dynamic import instead of a top-level urllib.parse import.
fli/models/init.py Exports BookingOffer from the top-level models package; straightforward and complete.
README.md Adds a Booking Offers Example section with correct one-way and round-trip usage patterns.

Sequence Diagram

sequenceDiagram
    participant Caller
    participant SearchFlights
    participant booking_offers as booking_offers.py
    participant Google as Google Flights API

    Caller->>SearchFlights: search(filters)
    SearchFlights->>Google: POST GetShoppingResults (f.req)
    Google-->>SearchFlights: raw response (wrb.fr payloads)
    SearchFlights->>SearchFlights: _parse_flights_data() + _parse_booking_token()
    SearchFlights-->>Caller: list[FlightResult] (with booking_token)

    Caller->>SearchFlights: get_booking_offers(itinerary)
    SearchFlights->>booking_offers: build_booking_filter_block(flights, ...)
    booking_offers-->>SearchFlights: filter_block
    SearchFlights->>booking_offers: build_booking_f_req(booking_token, filter_block)
    booking_offers-->>SearchFlights: encoded f.req
    SearchFlights->>Google: POST GetBookingResults (f.req)
    Google-->>SearchFlights: raw response (wrb.fr payloads)
    SearchFlights->>booking_offers: parse_booking_results(response.text)
    booking_offers-->>SearchFlights: list[BookingOffer]
    SearchFlights-->>Caller: list[BookingOffer]
Loading
Prompt To Fix All With AI
This is a comment left during a code review.
Path: fli/search/flights.py
Line: 138-160

Comment:
**Errors from filter building are not wrapped by the exception handler**

`build_booking_filter_block` and `build_booking_f_req` are called *before* the `try/except` block that adds the `"Booking offer lookup failed"` context. Any exception raised during those phases — for example a Pydantic `ValidationError` if a flight's departure date has already passed (since `FlightSegment.validate_travel_date` rejects past dates), an `IndexError` if `flight.legs` is empty, or a serialization failure — will propagate to the caller as a bare, unwrapped exception.

The existing `search()` method wraps the entire processing pipeline including data parsing inside its `try/except`. Keeping the same contract here prevents callers from having to catch two different exception shapes.

Move `build_booking_filter_block` and `build_booking_f_req` inside the `try` block so all failure modes surface under the same `"Booking offer lookup failed"` exception.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: fli/search/booking_offers.py
Line: 220-231

Comment:
**Unguarded `legs[0]` / `legs[-1]` access on potentially empty list**

`build_booking_filter_block` accesses `flight.legs[0]` and `flight.legs[-1]` without checking that the list is non-empty. `FlightResult.legs` is typed as `list[FlightLeg]` with no minimum-length constraint, so a caller who constructs a `FlightResult` with an empty legs list would see an unhandled `IndexError` rather than a clean validation error.

```python
for flight in flights:
    if not flight.legs:
        raise ValueError("FlightResult must contain at least one leg")
    first_leg = flight.legs[0]
    last_leg = flight.legs[-1]
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: tests/search/test_booking_results.py
Line: 262-264

Comment:
**`__import__` used in place of a top-level import**

`__import__("urllib.parse").parse.unquote` is an unusual dynamic-import idiom that makes the test harder to read. `urllib.parse` should simply be imported at the top of the file alongside the other standard-library imports.

```suggestion
    wrapped = json.loads(urllib.parse.unquote(encoded))
```

(Add `import urllib.parse` at the top of the file.)

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: fli/search/flights.py
Line: 122-129

Comment:
**Docstring missing Args/Returns/Raises sections**

Every other public method on `SearchFlights` (e.g. `search`) includes a full Google-style docstring with `Args`, `Returns`, and `Raises` sections. `get_booking_offers` has only a one-liner, leaving callers without documentation for:
- what `itinerary` accepts (single `FlightResult` vs tuple for round-trips)
- what `passenger_info=None` defaults to at runtime (`PassengerInfo(adults=1)`)
- that an empty list is returned when `booking_token` is absent
- what exception shape is raised on failure

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "Add library support for booking offers" | Re-trigger Greptile

Greptile also left 4 inline comments on this PR.

(3/5) Reply to the agent's comments like "Can you suggest a fix for this @greptileai?" or ask follow-up questions!

Context used:

  • Context used - CLAUDE.md (source)

@kvasdopil kvasdopil force-pushed the feat/booking-offers branch from d0b0743 to 0e2d347 Compare March 31, 2026 14:50
@kvasdopil
Copy link
Copy Markdown
Contributor Author

Why no changes to mcp/cli?

Running get_booking_offers for each itinerary might cause rate limits making it impractical. So cli/mcp flow should look something lke

  1. search for flights and find a suitable offer
  2. retrieve url for that offer

That brings up 2 problems: how to reliably pass all search params from 1 to 2, including search_token which is needed for url retrieval.

One option would be storing those in short-lived local cache and exposing some sort of itinerary_id to user, then using that to retrieve url. Easy to do with MCP, but for cli that means storing local cache somewhere.

@punitarani any opinions on that?

@kvasdopil kvasdopil marked this pull request as ready for review March 31, 2026 15:02
@kvasdopil kvasdopil force-pushed the feat/booking-offers branch 3 times, most recently from 92c9f7a to 3ba6907 Compare April 1, 2026 07:10
@kvasdopil kvasdopil force-pushed the feat/booking-offers branch from 3ba6907 to 729748b Compare April 1, 2026 07:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant