opensky-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
opensky/display.py ADDED
@@ -0,0 +1,358 @@
1
+ from __future__ import annotations
2
+
3
+ import csv
4
+ import io
5
+ import json
6
+
7
+ from rich.box import ROUNDED
8
+ from rich.console import Console
9
+ from rich.progress import BarColumn, MofNCompleteColumn, Progress, TextColumn, TimeRemainingColumn
10
+ from rich.table import Table
11
+ from rich.text import Text as RichText
12
+
13
+ from opensky.airports import city_name
14
+ from opensky.models import ConflictZone, RiskLevel, ScoredFlight
15
+
16
+ console = Console()
17
+
18
+ RISK_COLORS = {
19
+ RiskLevel.SAFE: "green",
20
+ RiskLevel.CAUTION: "yellow",
21
+ RiskLevel.HIGH_RISK: "red",
22
+ RiskLevel.DO_NOT_FLY: "bold red",
23
+ }
24
+
25
+ RISK_LABELS = {
26
+ RiskLevel.SAFE: "Safe",
27
+ RiskLevel.CAUTION: "Caution",
28
+ RiskLevel.HIGH_RISK: "Risky",
29
+ RiskLevel.DO_NOT_FLY: "Avoid",
30
+ }
31
+
32
+
33
+ def format_duration(minutes: int) -> str:
34
+ h, m = divmod(minutes, 60)
35
+ return f"{h}h {m}m" if m else f"{h}h"
36
+
37
+
38
+ def format_price(price: float, currency: str) -> str:
39
+ if price <= 0:
40
+ return "[dim]--[/dim]"
41
+ symbols = {"EUR": "\u20ac", "USD": "$", "GBP": "\u00a3", "INR": "\u20b9"}
42
+ sym = symbols.get(currency, currency + " ")
43
+ return f"{sym}{price:,.0f}"
44
+
45
+
46
+ def _format_flight_numbers(legs: list) -> str:
47
+ return ", ".join(f"{leg.airline} {leg.flight_number}" for leg in legs)
48
+
49
+
50
+ def _friendly_date(iso_date: str) -> str:
51
+ """Convert '2026-03-10' to 'Tue Mar 10'."""
52
+ from datetime import date
53
+
54
+ try:
55
+ d = date.fromisoformat(iso_date)
56
+ return f"{d.strftime('%a')} {d.strftime('%b')} {d.day}"
57
+ except (ValueError, TypeError):
58
+ return iso_date
59
+
60
+
61
+ def _safety_cell(sf: ScoredFlight) -> str:
62
+ risk_color = RISK_COLORS[sf.risk.risk_level]
63
+ label = RISK_LABELS[sf.risk.risk_level]
64
+
65
+ if sf.risk.flagged_airports:
66
+ flagged = ", ".join(city_name(a.code) for a in sf.risk.flagged_airports)
67
+ return f"[{risk_color}]{label}[/{risk_color}] [dim]{flagged}[/dim]"
68
+
69
+ return f"[{risk_color}]{label}[/{risk_color}]"
70
+
71
+
72
+ def flights_table(
73
+ flights: list[ScoredFlight],
74
+ title: str = "Flights",
75
+ show_count: bool = True,
76
+ ) -> None:
77
+ if not flights:
78
+ console.print("[dim]No flights found.[/dim]")
79
+ return
80
+
81
+ # Check if all flights share the same date (single search vs scan)
82
+ dates = {sf.date for sf in flights}
83
+ show_date = len(dates) > 1
84
+
85
+ # Show source column when results come from multiple providers
86
+ providers = {sf.flight.provider for sf in flights if sf.flight.provider}
87
+ show_source = len(providers) > 1
88
+
89
+ # Only show safety column when there's something worth flagging
90
+ risk_levels = {sf.risk.risk_level for sf in flights}
91
+ show_safety = risk_levels != {RiskLevel.SAFE}
92
+
93
+ table = Table(title=title, box=ROUNDED, show_lines=False, pad_edge=True, title_style="bold")
94
+ table.add_column("Price", justify="right", style="bold green")
95
+ table.add_column("Duration", justify="right")
96
+ table.add_column("Stops", justify="center", style="dim")
97
+ if show_date:
98
+ table.add_column("Date")
99
+ table.add_column("Route")
100
+ table.add_column("Flight #", style="dim")
101
+ if show_source:
102
+ table.add_column("Source", style="dim")
103
+ if show_safety:
104
+ table.add_column("Safety", justify="center")
105
+
106
+ for sf in flights:
107
+ transit_note = ""
108
+ if sf.transit_hours > 0:
109
+ transit_note = f" [dim]+{sf.transit_hours:.1f}h[/dim]"
110
+
111
+ flights_col = _format_flight_numbers(sf.flight.legs)
112
+
113
+ row = [
114
+ format_price(sf.flight.price, sf.flight.currency),
115
+ format_duration(sf.flight.duration_minutes) + transit_note,
116
+ str(sf.flight.stops),
117
+ ]
118
+ if show_date:
119
+ row.append(_friendly_date(sf.date))
120
+ row += [sf.route, flights_col]
121
+ if show_source:
122
+ row.append(sf.flight.provider or "-")
123
+ if show_safety:
124
+ row.append(_safety_cell(sf))
125
+ table.add_row(*row)
126
+
127
+ console.print(table)
128
+ if show_count:
129
+ console.print(f"[dim]{len(flights)} flights[/dim]")
130
+ if any(sf.flight.price <= 0 for sf in flights):
131
+ console.print("[dim]-- = price not available[/dim]")
132
+
133
+
134
+ def flights_json(flights: list[ScoredFlight]) -> str:
135
+ return json.dumps(
136
+ [sf.model_dump() for sf in flights],
137
+ indent=2,
138
+ default=str,
139
+ )
140
+
141
+
142
+ def flights_csv(flights: list[ScoredFlight]) -> str:
143
+ buf = io.StringIO()
144
+ writer = csv.writer(buf)
145
+ writer.writerow([
146
+ "price", "currency", "duration_min", "stops", "date",
147
+ "origin", "destination", "route", "flights",
148
+ "provider", "risk_level", "score",
149
+ ])
150
+ for sf in flights:
151
+ writer.writerow([
152
+ sf.flight.price,
153
+ sf.flight.currency,
154
+ sf.flight.duration_minutes,
155
+ sf.flight.stops,
156
+ sf.date,
157
+ sf.origin,
158
+ sf.destination,
159
+ sf.route,
160
+ _format_flight_numbers(sf.flight.legs),
161
+ sf.flight.provider,
162
+ sf.risk.risk_level.value,
163
+ f"{sf.score:.1f}",
164
+ ])
165
+ return buf.getvalue()
166
+
167
+
168
+ def _scan_stats(flights: list[ScoredFlight], currency: str) -> None:
169
+ if not flights:
170
+ console.print("[dim]No flights found.[/dim]")
171
+ return
172
+
173
+ destinations = {sf.destination for sf in flights}
174
+ origins = {sf.origin for sf in flights}
175
+ dates = {sf.date for sf in flights}
176
+ priced = [sf.flight.price for sf in flights if sf.flight.price > 0]
177
+
178
+ parts = [
179
+ f"[bold]{len(flights)}[/bold] flights across",
180
+ f"[bold]{len(destinations)}[/bold] destinations,",
181
+ f"[bold]{len(origins)}[/bold] origins,",
182
+ f"[bold]{len(dates)}[/bold] dates.",
183
+ ]
184
+ if priced:
185
+ lo_val, hi_val = min(priced), max(priced)
186
+ lo = format_price(lo_val, currency)
187
+ if lo_val == hi_val:
188
+ parts.append(f"Price: {lo}")
189
+ else:
190
+ hi = format_price(hi_val, currency)
191
+ parts.append(f"Price range: {lo}-{hi}")
192
+
193
+ console.print(" ".join(parts))
194
+
195
+
196
+ def _best_per_destination(flights: list[ScoredFlight]) -> None:
197
+ if not flights:
198
+ return
199
+
200
+ best: dict[str, ScoredFlight] = {}
201
+ for sf in flights:
202
+ if sf.destination not in best or sf.score < best[sf.destination].score:
203
+ best[sf.destination] = sf
204
+
205
+ risk_levels = {sf.risk.risk_level for sf in best.values()}
206
+ show_safety = risk_levels != {RiskLevel.SAFE}
207
+
208
+ table = Table(title="Best per Destination", box=ROUNDED, show_lines=False, pad_edge=True, title_style="bold")
209
+ table.add_column("Destination", style="bold")
210
+ table.add_column("Price", justify="right", style="bold green")
211
+ table.add_column("Date")
212
+ table.add_column("Duration", justify="right")
213
+ table.add_column("Route")
214
+ if show_safety:
215
+ table.add_column("Safety", justify="center")
216
+
217
+ for dest in sorted(best):
218
+ sf = best[dest]
219
+ transit_note = ""
220
+ if sf.transit_hours > 0:
221
+ transit_note = f" [dim]+{sf.transit_hours:.1f}h[/dim]"
222
+
223
+ row = [
224
+ city_name(dest),
225
+ format_price(sf.flight.price, sf.flight.currency),
226
+ _friendly_date(sf.date),
227
+ format_duration(sf.flight.duration_minutes) + transit_note,
228
+ sf.route,
229
+ ]
230
+ if show_safety:
231
+ row.append(_safety_cell(sf))
232
+ table.add_row(*row)
233
+
234
+ console.print(table)
235
+
236
+
237
+ def _date_matrix(flights: list[ScoredFlight], currency: str, max_dates: int = 14) -> None:
238
+ priced = [sf for sf in flights if sf.flight.price > 0]
239
+ if not priced:
240
+ return
241
+
242
+ destinations = sorted({sf.destination for sf in priced})
243
+ dates = sorted({sf.date for sf in priced})
244
+
245
+ if not destinations or not dates:
246
+ return
247
+
248
+ truncated = len(dates) > max_dates
249
+ if truncated:
250
+ dates = dates[:max_dates]
251
+
252
+ # Build cheapest price lookup: (dest, date) -> price
253
+ cheapest: dict[tuple[str, str], float] = {}
254
+ for sf in priced:
255
+ key = (sf.destination, sf.date)
256
+ if key not in cheapest or sf.flight.price < cheapest[key]:
257
+ cheapest[key] = sf.flight.price
258
+
259
+ # Find cheapest date per destination for highlighting
260
+ best_per_dest: dict[str, float] = {}
261
+ for (dest, _dt), price in cheapest.items():
262
+ if dest not in best_per_dest or price < best_per_dest[dest]:
263
+ best_per_dest[dest] = price
264
+
265
+ # Shorten date headers to MM-DD
266
+ short_dates = [d[5:] for d in dates]
267
+
268
+ table = Table(title="Prices by Date", box=ROUNDED, show_lines=False, pad_edge=True, title_style="bold")
269
+ table.add_column("Destination", style="bold")
270
+ for sd in short_dates:
271
+ table.add_column(sd, justify="right")
272
+
273
+ sym = {"EUR": "\u20ac", "USD": "$", "GBP": "\u00a3", "INR": "\u20b9"}.get(currency, currency + " ")
274
+
275
+ for dest in destinations:
276
+ row = [city_name(dest)]
277
+ for dt in dates:
278
+ price = cheapest.get((dest, dt))
279
+ if price is None:
280
+ row.append("[dim]-[/dim]")
281
+ elif price == best_per_dest.get(dest):
282
+ row.append(f"[green bold]{sym}{price:,.0f}[/green bold]")
283
+ else:
284
+ row.append(f"{sym}{price:,.0f}")
285
+ table.add_row(*row)
286
+
287
+ console.print(table)
288
+ note = f"[dim]Prices in {currency}. Green = cheapest per destination."
289
+ if truncated:
290
+ note += f" Showing first {max_dates} dates."
291
+ note += "[/dim]"
292
+ console.print(note)
293
+
294
+
295
+ def _top_flights(flights: list[ScoredFlight], n: int = 10) -> None:
296
+ flights_table(flights[:n], title=f"Top {min(n, len(flights))} Flights", show_count=False)
297
+
298
+
299
+ def scan_summary(flights: list[ScoredFlight], currency: str) -> None:
300
+ if not flights:
301
+ console.print("[dim]No flights found.[/dim]")
302
+ return
303
+
304
+ _scan_stats(flights, currency)
305
+ console.print()
306
+ _best_per_destination(flights)
307
+ console.print()
308
+ _date_matrix(flights, currency)
309
+ console.print()
310
+ _top_flights(flights)
311
+
312
+
313
+ def zones_table(zones: list[ConflictZone]) -> None:
314
+ table = Table(title="Conflict Zones", box=ROUNDED, show_lines=False, pad_edge=True, title_style="bold")
315
+ table.add_column("Zone", style="bold")
316
+ table.add_column("Risk", justify="center")
317
+ table.add_column("Airports")
318
+ table.add_column("Source", style="dim")
319
+ table.add_column("Updated", style="dim")
320
+
321
+ for zone in sorted(zones, key=lambda z: (-z.risk_level.severity, z.name)):
322
+ rl = zone.risk_level
323
+ color = RISK_COLORS[rl]
324
+ airports_str = ", ".join(
325
+ f"{a} ({city_name(a)})" for a in zone.airports[:6]
326
+ ) if zone.airports else "-"
327
+ if len(zone.airports) > 6:
328
+ airports_str += f" +{len(zone.airports) - 6} more"
329
+ table.add_row(
330
+ zone.name,
331
+ f"[{color}]{RISK_LABELS[rl]}[/{color}]",
332
+ airports_str,
333
+ zone.source,
334
+ zone.updated,
335
+ )
336
+
337
+ console.print(table)
338
+
339
+
340
+ class _ErrorColumn(TextColumn):
341
+ """Only show error count when errors > 0."""
342
+
343
+ def render(self, task):
344
+ errors = task.fields.get("errors", 0)
345
+ if errors > 0:
346
+ return RichText(f"{errors} errors", style="red")
347
+ return RichText("")
348
+
349
+
350
+ def scan_progress() -> Progress:
351
+ return Progress(
352
+ TextColumn("[bold]{task.description}"),
353
+ BarColumn(),
354
+ MofNCompleteColumn(),
355
+ _ErrorColumn(""),
356
+ TimeRemainingColumn(),
357
+ console=console,
358
+ )
opensky/models.py ADDED
@@ -0,0 +1,90 @@
1
+ from __future__ import annotations
2
+
3
+ from enum import Enum
4
+
5
+ from pydantic import BaseModel
6
+
7
+
8
+ class RiskLevel(str, Enum):
9
+ SAFE = "safe"
10
+ CAUTION = "caution"
11
+ HIGH_RISK = "high_risk"
12
+ DO_NOT_FLY = "do_not_fly"
13
+
14
+ @property
15
+ def severity(self) -> int:
16
+ return {
17
+ RiskLevel.SAFE: 0,
18
+ RiskLevel.CAUTION: 1,
19
+ RiskLevel.HIGH_RISK: 2,
20
+ RiskLevel.DO_NOT_FLY: 3,
21
+ }[self]
22
+
23
+ def __ge__(self, other: RiskLevel) -> bool:
24
+ return self.severity >= other.severity
25
+
26
+ def __gt__(self, other: RiskLevel) -> bool:
27
+ return self.severity > other.severity
28
+
29
+ def __le__(self, other: RiskLevel) -> bool:
30
+ return self.severity <= other.severity
31
+
32
+ def __lt__(self, other: RiskLevel) -> bool:
33
+ return self.severity < other.severity
34
+
35
+
36
+ class FlaggedAirport(BaseModel):
37
+ code: str
38
+ country: str
39
+ zone_name: str
40
+ risk_level: RiskLevel
41
+
42
+
43
+ class RiskAssessment(BaseModel):
44
+ risk_level: RiskLevel = RiskLevel.SAFE
45
+ flagged_airports: list[FlaggedAirport] = []
46
+
47
+ @property
48
+ def is_safe(self) -> bool:
49
+ return self.risk_level == RiskLevel.SAFE
50
+
51
+
52
+ class FlightLeg(BaseModel):
53
+ airline: str
54
+ flight_number: str
55
+ departure_airport: str
56
+ arrival_airport: str
57
+ departure_time: str
58
+ arrival_time: str
59
+ duration_minutes: int
60
+
61
+
62
+ class FlightResult(BaseModel):
63
+ price: float
64
+ currency: str
65
+ duration_minutes: int
66
+ stops: int
67
+ legs: list[FlightLeg]
68
+ provider: str = ""
69
+
70
+
71
+ class ScoredFlight(BaseModel):
72
+ flight: FlightResult
73
+ origin: str
74
+ destination: str
75
+ date: str
76
+ route: str
77
+ risk: RiskAssessment
78
+ score: float = 0.0
79
+ transit_hours: float = 0.0
80
+
81
+
82
+ class ConflictZone(BaseModel):
83
+ id: str
84
+ name: str
85
+ risk_level: RiskLevel
86
+ countries: list[str] = []
87
+ airports: list[str] = []
88
+ source: str = ""
89
+ details: str = ""
90
+ updated: str = ""
@@ -0,0 +1,81 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import os
5
+ import re
6
+ from typing import Protocol, runtime_checkable
7
+
8
+ from opensky.models import FlightResult
9
+
10
+ log = logging.getLogger(__name__)
11
+
12
+
13
+ def parse_iso_duration(iso: str) -> int:
14
+ """Parse ISO 8601 duration like PT2H26M to minutes."""
15
+ m = re.match(r"PT(?:(\d+)H)?(?:(\d+)M)?", iso or "")
16
+ if not m:
17
+ return 0
18
+ hours = int(m.group(1) or 0)
19
+ minutes = int(m.group(2) or 0)
20
+ return hours * 60 + minutes
21
+
22
+
23
+ @runtime_checkable
24
+ class FlightProvider(Protocol):
25
+ name: str
26
+
27
+ def search(
28
+ self,
29
+ origin: str,
30
+ dest: str,
31
+ date: str,
32
+ cabin: str,
33
+ currency: str,
34
+ max_stops: int | None,
35
+ ) -> list[FlightResult]: ...
36
+
37
+ def close(self) -> None: ...
38
+
39
+
40
+ def configured_providers(
41
+ currency: str = "EUR",
42
+ proxy: str | None = None,
43
+ only: str | None = None,
44
+ ) -> list[FlightProvider]:
45
+ """Build the list of active providers based on env vars.
46
+
47
+ If *only* is set, return just that one provider (or raise ValueError).
48
+ Otherwise, return all providers whose credentials are configured.
49
+ Google is always available (no key needed).
50
+ """
51
+ providers: list[FlightProvider] = []
52
+
53
+ # Google: always available
54
+ if only is None or only == "google":
55
+ from opensky.providers.google import GoogleProvider
56
+
57
+ providers.append(GoogleProvider(currency=currency, proxy=proxy))
58
+
59
+ # Duffel
60
+ duffel_token = os.environ.get("OPENSKY_DUFFEL_TOKEN")
61
+ if only == "duffel" or (only is None and duffel_token):
62
+ if not duffel_token:
63
+ raise ValueError("OPENSKY_DUFFEL_TOKEN not set")
64
+ from opensky.providers.duffel import DuffelProvider
65
+
66
+ providers.append(DuffelProvider(token=duffel_token))
67
+
68
+ # Amadeus
69
+ amadeus_key = os.environ.get("OPENSKY_AMADEUS_KEY")
70
+ amadeus_secret = os.environ.get("OPENSKY_AMADEUS_SECRET")
71
+ if only == "amadeus" or (only is None and amadeus_key and amadeus_secret):
72
+ if not amadeus_key or not amadeus_secret:
73
+ raise ValueError("OPENSKY_AMADEUS_KEY and OPENSKY_AMADEUS_SECRET must both be set")
74
+ from opensky.providers.amadeus import AmadeusProvider
75
+
76
+ providers.append(AmadeusProvider(key=amadeus_key, secret=amadeus_secret))
77
+
78
+ if only and not providers:
79
+ raise ValueError(f"Unknown provider: {only}")
80
+
81
+ return providers
@@ -0,0 +1,150 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import time
5
+
6
+ import httpx
7
+ from ratelimit import limits, sleep_and_retry
8
+
9
+ from opensky.models import FlightLeg, FlightResult
10
+ from opensky.providers import parse_iso_duration
11
+
12
+ log = logging.getLogger(__name__)
13
+
14
+ DEFAULT_BASE_URL = "https://test.api.amadeus.com"
15
+
16
+ CABIN_MAP: dict[str, str] = {
17
+ "economy": "ECONOMY",
18
+ "premium_economy": "PREMIUM_ECONOMY",
19
+ "business": "BUSINESS",
20
+ "first": "FIRST",
21
+ }
22
+
23
+
24
+ def _convert_offer(offer: dict, currency: str) -> FlightResult:
25
+ """Convert an Amadeus flight-offer dict to a FlightResult."""
26
+ price = float(offer.get("price", {}).get("grandTotal", 0))
27
+ offer_currency = offer.get("price", {}).get("currency", currency)
28
+
29
+ itineraries = offer.get("itineraries", [])
30
+ if not itineraries:
31
+ return FlightResult(
32
+ price=price, currency=offer_currency,
33
+ duration_minutes=0, stops=0, legs=[], provider="amadeus",
34
+ )
35
+
36
+ itin = itineraries[0]
37
+ total_duration = parse_iso_duration(itin.get("duration", ""))
38
+ segments = itin.get("segments", [])
39
+
40
+ legs: list[FlightLeg] = []
41
+ for seg in segments:
42
+ dep = seg.get("departure", {})
43
+ arr = seg.get("arrival", {})
44
+ dur = parse_iso_duration(seg.get("duration", ""))
45
+
46
+ carrier = seg.get("operating", {}).get("carrierCode", seg.get("carrierCode", ""))
47
+ flight_num = seg.get("number", "")
48
+
49
+ legs.append(FlightLeg(
50
+ airline=carrier,
51
+ flight_number=flight_num,
52
+ departure_airport=dep.get("iataCode", ""),
53
+ arrival_airport=arr.get("iataCode", ""),
54
+ departure_time=dep.get("at", ""),
55
+ arrival_time=arr.get("at", ""),
56
+ duration_minutes=dur,
57
+ ))
58
+
59
+ return FlightResult(
60
+ price=price,
61
+ currency=offer_currency,
62
+ duration_minutes=total_duration,
63
+ stops=max(len(legs) - 1, 0),
64
+ legs=legs,
65
+ provider="amadeus",
66
+ )
67
+
68
+
69
+ class AmadeusProvider:
70
+ name = "amadeus"
71
+
72
+ def __init__(
73
+ self,
74
+ key: str,
75
+ secret: str,
76
+ base_url: str = DEFAULT_BASE_URL,
77
+ ):
78
+ self._key = key
79
+ self._secret = secret
80
+ self._base_url = base_url
81
+ self._client = httpx.Client(base_url=base_url, timeout=30.0)
82
+ self._token: str | None = None
83
+ self._token_expires: float = 0
84
+
85
+ def _authenticate(self) -> str:
86
+ """Get a valid access token, refreshing if needed."""
87
+ if self._token and time.time() < self._token_expires - 60:
88
+ return self._token
89
+
90
+ resp = self._client.post(
91
+ "/v1/security/oauth2/token",
92
+ data={
93
+ "grant_type": "client_credentials",
94
+ "client_id": self._key,
95
+ "client_secret": self._secret,
96
+ },
97
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
98
+ )
99
+ resp.raise_for_status()
100
+ data = resp.json()
101
+ self._token = data["access_token"]
102
+ self._token_expires = time.time() + data.get("expires_in", 1799)
103
+ return self._token
104
+
105
+ @sleep_and_retry
106
+ @limits(calls=10, period=1)
107
+ def search(
108
+ self,
109
+ origin: str,
110
+ dest: str,
111
+ date: str,
112
+ cabin: str,
113
+ currency: str,
114
+ max_stops: int | None,
115
+ ) -> list[FlightResult]:
116
+ token = self._authenticate()
117
+ travel_class = CABIN_MAP.get(cabin, "ECONOMY")
118
+
119
+ params: dict[str, str | int | bool] = {
120
+ "originLocationCode": origin,
121
+ "destinationLocationCode": dest,
122
+ "departureDate": date,
123
+ "adults": 1,
124
+ "currencyCode": currency,
125
+ "travelClass": travel_class,
126
+ "max": 50,
127
+ }
128
+
129
+ # Amadeus only supports nonStop boolean, not max_stops count
130
+ if max_stops == 0:
131
+ params["nonStop"] = "true"
132
+
133
+ resp = self._client.get(
134
+ "/v2/shopping/flight-offers",
135
+ params=params,
136
+ headers={"Authorization": f"Bearer {token}"},
137
+ )
138
+ resp.raise_for_status()
139
+ offers = resp.json().get("data", [])
140
+
141
+ results = [_convert_offer(o, currency) for o in offers]
142
+
143
+ # Client-side stops filter for 1-2 stop limits
144
+ if max_stops is not None and max_stops > 0:
145
+ results = [r for r in results if r.stops <= max_stops]
146
+
147
+ return results
148
+
149
+ def close(self) -> None:
150
+ self._client.close()