find-flight 0.1.0 → 0.2.0

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.
package/airports.py ADDED
@@ -0,0 +1,80 @@
1
+ #!/usr/bin/env python3
2
+ """Lightweight airport & country lookup for the find-flight runner.
3
+
4
+ Given a 3-letter IATA airport code, resolve the authoritative name, city,
5
+ state/region and ISO-3166-1 alpha-2 country code (and its English country
6
+ name). This is the disambiguation source that prevents mis-mapping a code to
7
+ the wrong city — e.g. ECN is Ercan (North Cyprus), not Edinburgh; MYJ is
8
+ Matsuyama (Japan), not Mysore.
9
+
10
+ Data comes from the bundled JSON files generated from the upstream airport
11
+ list (`enums/airports.csv` in AWeirdDev/flights, ~9.7k rows):
12
+ * airports.json { code: [name, city, state, country_id] }
13
+ * countries.json { ISO2: "English country name" }
14
+
15
+ The files resolve relative to THIS module, so the lookup works regardless of
16
+ the process CWD (important because the extension runs the runner from an
17
+ arbitrary directory). If a file is missing or a code is unknown, the lookup
18
+ returns a minimal record built from the code itself rather than raising, so
19
+ the search never hard-fails on metadata.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import os
25
+
26
+ _THIS = os.path.dirname(os.path.abspath(__file__))
27
+
28
+ _AIRPORTS = None # cached dict: code -> [name, city, state, country]
29
+ _COUNTRIES = None # cached dict: ISO2 -> "English name"
30
+
31
+
32
+ def _load(path: str):
33
+ try:
34
+ with open(os.path.join(_THIS, path), encoding="utf-8") as fh:
35
+ return json.load(fh)
36
+ except (OSError, ValueError):
37
+ return None
38
+
39
+
40
+ def _airports() -> dict:
41
+ global _AIRPORTS
42
+ if _AIRPORTS is None:
43
+ _AIRPORTS = _load("airports.json") or {}
44
+ return _AIRPORTS
45
+
46
+
47
+ def _countries() -> dict:
48
+ global _COUNTRIES
49
+ if _COUNTRIES is None:
50
+ _COUNTRIES = _load("countries.json") or {}
51
+ return _COUNTRIES
52
+
53
+
54
+ def country_name(iso2: str) -> str:
55
+ """ISO-3166-1 alpha-2 code -> English country name ('' if unknown)."""
56
+ return _countries().get((iso2 or "").upper(), "")
57
+
58
+
59
+ def resolve(code: str) -> dict:
60
+ """Resolve a 3-letter IATA code to name/city/state/country metadata.
61
+
62
+ Never raises: unknown/empty codes return a record keyed on the code with
63
+ empty fields, so callers can still proceed (or choose to reject on 3-char
64
+ validation elsewhere).
65
+ """
66
+ code = (code or "").strip().upper()
67
+ rec = _airports().get(code, [])
68
+ name, city, state, iso2 = (rec + ["", "", "", ""])[:4]
69
+ return {
70
+ "code": code,
71
+ "name": name,
72
+ "city": city,
73
+ "state": state,
74
+ "country": iso2.upper(),
75
+ "country_name": country_name(iso2),
76
+ }
77
+
78
+
79
+ def is_known(code: str) -> bool:
80
+ return (code or "").strip().upper() in _airports()
package/countries.json ADDED
@@ -0,0 +1 @@
1
+ {"AD":"Andorra","AE":"United","Arab":"Emirates","AF":"Afghanistan","AG":"Antigua","and":"the","AI":"Anguilla","AL":"Albania","AM":"Armenia","AO":"Angola","AQ":"Antarctica","AR":"Argentina","AS":"American","Samoa":"YE","Austria":"AU","Australia":"AW","Aruba":"AX","Aland":"Islands","AZ":"Azerbaijan","BA":"Bosnia","BB":"Barbados","BD":"Bangladesh","BE":"Belgium","BF":"Burkina","Faso":"BG","Bulgaria":"BH","Bahrain":"BI","Burundi":"BJ","Benin":"BL","Saint":"Vincent","BM":"Bermuda","BN":"Brunei","Darussalam":"BO","Bolivia":"BQ","Bonaire":"(Caribbean","Netherlands)":"BR","Brazil":"BS","Bahamas":"BT","Bhutan":"BV","Bouvet":"Island","BW":"Botswana","BY":"Belarus","BZ":"Belize","CA":"Canada","CC":"Cocos","(Keeling)":"Islands","CD":"Democratic","Republic":"DE","the":"Congo","CF":"Central","African":"Republic","CG":"Republic","of":"the","Congo":"CH","Switzerland":"CI","Cote":"d'Ivoire","CK":"Cook","Islands":"VN","Chile":"CM","Cameroon":"CN","China":"TZ","Colombia":"CR","Costa":"Rica","CU":"Cuba","CV":"Cape","Verde":"CW","Curacao":"CX","Christmas":"Island","CY":"Cyprus","CZ":"Czech","Germany":"DJ","Djibouti":"DK","Denmark":"DM","Dominica":"DO","Dominican":"Republic","DZ":"Algeria","EC":"Ecuador","EE":"Estonia","EG":"Egypt","EH":"Western","Sahara":"ER","Eritrea":"ES","Spain":"ET","Ethiopia":"FI","Finland":"FJ","Fiji":"FK","Falkland":"Islands","(Malvinas)":"FM","Micronesia":"FO","Faroe":"Islands","FR":"France","GA":"Gabon","GB":"United","Kingdom":"GD","Grenada":"GE","Georgia":"GF","French":"Guiana","GG":"Guernsey","GH":"Ghana","GI":"Gibraltar","GL":"Greenland","GM":"Gambia","GN":"Guinea","GP":"Guadeloupe","GQ":"Equatorial","Guinea":"PH","Greece":"GS","South":"Africa","Guatemala":"GU","Guam":"GW","Guinea-Bissau":"GY","Guyana":"HK","Hong":"Kong","HM":"Heard","Honduras":"HR","Croatia":"HT","Haiti":"HU","Hungary":"ID","Indonesia":"IE","Ireland":"IL","Israel":"IM","Isle":"of","Man":"IN","India":"IO","British":"Virgin","Ocean":"Territory","IQ":"Iraq","IR":"Iran","IS":"Iceland","IT":"Italy","JE":"Jersey","JM":"Jamaica","JO":"Jordan","JP":"Japan","KE":"Kenya","KG":"Kyrgyzstan","KH":"Cambodia","KI":"Kiribati","KM":"Comoros","KN":"Saint","Kitts":"and","Nevis":"KP","North":"Korea","KR":"South","Korea":"KW","Kuwait":"KY","Cayman":"Islands","KZ":"Kazakhstan","LA":"Laos","LB":"Lebanon","LC":"Saint","Lucia":"LI","Liechtenstein":"LK","Sri":"Lanka","LR":"Liberia","LS":"Lesotho","LT":"Lithuania","LU":"Luxembourg","LV":"Latvia","LY":"Libya","MA":"Morocco","MC":"Monaco","MD":"Moldova","ME":"Montenegro","MF":"Saint","Martin":"MG","Madagascar":"MH","Marshall":"Islands","MK":"North","Macedonia":"ML","Mali":"MM","Myanmar":"MN","Mongolia":"MO","Macao":"MP","Northern":"Mariana","Martinique":"MR","Mauritania":"MS","Montserrat":"MT","Malta":"MU","Mauritius":"MV","Maldives":"MW","Malawi":"MX","Mexico":"MY","Malaysia":"MZ","Mozambique":"NA","Namibia":"NC","New":"Zealand","NE":"Niger","NF":"Norfolk","Island":"NG","Nigeria":"NI","Nicaragua":"NL","Netherlands":"NO","Norway":"NP","Nepal":"NR","Nauru":"NU","Niue":"NZ","OM":"Oman","PA":"Panama","PE":"Peru","PF":"French","Polynesia":"PG","Papua":"New","Philippines":"PK","Pakistan":"PL","Poland":"PM","PN":"Pitcairn","PR":"Puerto","Rico":"PS","Palestine":"(West","Bank":"and","Gaza)":"PT","Portugal":"PW","Palau":"PY","Paraguay":"QA","Qatar":"RE","Reunion":"RO","Romania":"RS","Serbia":"RU","Russia":"RW","Rwanda":"SA","Saudi":"Arabia","SB":"Solomon","Seychelles":"SD","Sudan":"SE","Sweden":"SG","Singapore":"SH","SI":"Slovenia","SJ":"Svalbard","Mayen":"SK","Slovakia":"SL","Sierra":"Leone","SM":"San","Marino":"SN","Senegal":"SO","Somalia":"SR","Suriname":"SS","ST":"Sao","Tome":"and","Principe":"SV","El":"Salvador","SX":"Sint","Maarten":"SY","Syria":"SZ","Swaziland":"TC","Turks":"and","Caicos":"Islands","TD":"Chad","TF":"French","Southern":"Territories","TG":"Togo","TH":"Thailand","TJ":"Tajikistan","TK":"Tokelau","TL":"Timor-Leste","TM":"Turkmenistan","TN":"Tunisia","TO":"Tonga","TR":"Turkey","TT":"Trinidad","TV":"Tuvalu","TW":"Taiwan,","Province":"of","Tanzania":"UA","Ukraine":"UG","Uganda":"UM","U.S.":"Virgin","Outlying":"Islands","US":"United","States":"UY","Uruguay":"UZ","Uzbekistan":"VA","Holy":"See","(Vatican":"City","State)":"VC","Grenadines":"VE","Venezuela":"VG","Vietnam":"VU","Vanuatu":"WF","Wallis":"and","Futuna":"WS","Yemen":"YT","Mayotte":"ZA","ZM":"Zambia","ZW":"Zimbabwe"}
@@ -90,6 +90,28 @@ def total_duration(flight) -> int:
90
90
  return sum(seg.duration for seg in flight.flights)
91
91
 
92
92
 
93
+ def load_airports():
94
+ """Try to ""import airports"" for city/country metadata; fall back to a no-op."""
95
+ try:
96
+ import airports as a
97
+ return a
98
+ except Exception:
99
+ return None
100
+
101
+
102
+ AP = load_airports()
103
+
104
+
105
+ def place(code: str) -> str:
106
+ if AP:
107
+ r = AP.resolve(code)
108
+ if r["country"]:
109
+ return f"{code} {r['city']}, {r['country']}".strip()
110
+ if r["name"]:
111
+ return f"{code} ({r['name']})"
112
+ return code
113
+
114
+
93
115
  def print_result(result: ResultList, currency: str = "") -> None:
94
116
  if not result:
95
117
  print("No flights found.")
@@ -100,7 +122,7 @@ def print_result(result: ResultList, currency: str = "") -> None:
100
122
  for seg in flight.flights:
101
123
  dep = f"{seg.departure.date[0]}-{seg.departure.date[1]:02d}-{seg.departure.date[2]:02d} {seg.departure.time[0]:02d}:{seg.departure.time[1]:02d}"
102
124
  arr = f"{seg.arrival.date[0]}-{seg.arrival.date[1]:02d}-{seg.arrival.date[2]:02d} {seg.arrival.time[0]:02d}:{seg.arrival.time[1]:02d}"
103
- print(f" {seg.from_airport.code} {dep} -> {seg.to_airport.code} {arr} "
125
+ print(f" {place(seg.from_airport.code)} {dep} -> {place(seg.to_airport.code)} {arr} "
104
126
  f"({seg.duration} min, {seg.plane_type})")
105
127
  print(f"\n{len(result)} itinerary/ies")
106
128
 
@@ -88,11 +88,11 @@ const search_flights = {
88
88
  name: "search_flights",
89
89
  label: "Search Flights",
90
90
  description:
91
- "Search real Google Flights availability between two airports on given dates via fast-flights. Returns structured JSON: price (currency), airline(s), number of stops, and each flight leg with airports, times, duration and plane type. For round-trips, price is the two-way total and only the outbound legs are returned. Use for finding flights, checking prices/availability, comparing routes, dates, seats, or airlines.",
91
+ "Search real Google Flights availability between two airports on given dates via fast-flights. Pass 3-letter IATA airport codes (from_airport/to_airport); the tool resolves each code to its airport name, city and ISO-3166 country so you know exactly which city the code refers to before reading results. Returns structured JSON: the resolved route (airport/city/country), price (currency), airline(s), number of stops, and each flight leg with airports, city/country, times, duration and plane type. For round-trips it runs a native round-trip query and returns the TRUE two-way total price with the outbound legs, plus one-way reference fares showing return-leg schedules (return_options). Use for finding flights, checking prices/availability, comparing routes, dates, seats, or airlines.",
92
92
  promptSnippet: "Search live Google Flights for price/availability between airports",
93
93
  promptGuidelines: [
94
94
  "Use search_flights for real flight prices and availability instead of guessing or writing ad-hoc scraping scripts.",
95
- "Before calling search_flights, verify airport codes (from_airport/to_airport must be 3-letter IATA codes) and resolve dates to YYYY-MM-DD; ask the user if the code, trip type, or dates are unclear.",
95
+ "Pass 3-letter IATA airport codes (from_airport/to_airport); the tool resolves each code to its airport name, city and ISO-3166 country so you can confirm a code really means the city the user wants (e.g. ECN = Ercan/Cyprus). Resolve dates to YYYY-MM-DD; ask the user if the code, trip type, or dates are unclear.",
96
96
  "search_flights prices carry the currency you pass; pass a currency (e.g. 'EUR', 'TRY', 'USD') unless the user prefers Google's default.",
97
97
  ],
98
98
  parameters: Type.Object({
@@ -137,34 +137,84 @@ const search_flights = {
137
137
  const obj = data as Record<string, unknown>;
138
138
  if (obj.error) throw new Error(`flight search error: ${obj.error}`);
139
139
 
140
- const flights = (obj.flights ?? []) as Array<Record<string, unknown>>;
141
- if (flights.length === 0) {
140
+ const currency = (obj.currency as string | undefined) ?? "";
141
+
142
+ // Human-friendly duration: show minutes too when under 2h (e.g. 1h 35m).
143
+ const fmtDuration = (min: number) => {
144
+ const m = Math.round(min || 0);
145
+ const h = Math.floor(m / 60);
146
+ const r = m % 60;
147
+ return h > 0 ? `${h}h${r ? ` ${r}m` : ""}` : `${r}m`;
148
+ };
149
+
150
+ // Render one direction of flights (outbound or return).
151
+ const renderDirection = (
152
+ label: string,
153
+ flights: Array<Record<string, unknown>>,
154
+ routeSummary?: string,
155
+ ) => {
156
+ const sorted = [...flights].sort((a, b) => Number(a.price) - Number(b.price));
157
+ const top = sorted.slice(0, 10);
158
+ const header = `## ${label}${routeSummary ? ` — ${routeSummary}` : ""}`;
159
+ if (top.length === 0) return `${header}\nNo ${label.toLowerCase()} flights found.`;
160
+ const lines = top.map((f, i) => {
161
+ const legs = (f.legs as Array<Record<string, string>>) ?? [];
162
+ const where = (l: Record<string, string>, prefix: "from" | "to") => {
163
+ const code = l[prefix];
164
+ const city = l[`${prefix}_city`] ?? "";
165
+ const country = l[`${prefix}_country`] ?? "";
166
+ const ctx = city || country ? ` (${city}, ${country})` : "";
167
+ return `${code}${ctx}`;
168
+ };
169
+ const seg = legs
170
+ .map(
171
+ (l) =>
172
+ `${where(l, "from")} ${l.departure} → ${where(l, "to")} ${l.arrival} ${l.date} ` +
173
+ `(${fmtDuration(Number(l.duration_minutes) || 0)}, ${l.plane})`,
174
+ )
175
+ .join(", ");
176
+ const airline = (f.airlines as string[] | undefined)?.join("/") ?? "";
177
+ const stops = Number(f.stops) || 0;
178
+ return `${i + 1}. ${f.currency ?? currency} ${f.price} · ${airline} · ${stops === 0 ? "direct" : `${stops} stop${stops > 1 ? "s" : ""}`}\n ${seg}`;
179
+ });
180
+ return `${header}\n${lines.join("\n")}\n\n${sorted.length} itinerary/ies (showing cheapest ${top.length})`;
181
+ };
182
+
183
+ // ---- round-trip: native RT query (true two-way price) + return reference ----
184
+ if (obj.trip === "round-trip") {
185
+ const rt = (obj.round_trip ?? []) as Array<Record<string, unknown>>;
186
+ const retOpts = (obj.return_options ?? []) as Array<Record<string, unknown>>;
187
+ const or = obj.outbound_route as Record<string, unknown> | undefined;
188
+ const rr = obj.return_route as Record<string, unknown> | undefined;
189
+ const outboundSummary = (or?.summary as string | undefined) ?? "";
190
+ const returnSummary = (rr?.summary as string | undefined) ?? "";
191
+ const parts: string[] = [];
192
+ parts.push(renderDirection("Round-trip (true two-way total, outbound legs shown)", rt, outboundSummary));
193
+ parts.push(renderDirection("Return leg options (one-way reference fares — NOT round-trip prices)", retOpts, returnSummary));
194
+ const text = parts.join("\n\n");
142
195
  return {
143
- content: [{ type: "text", text: "No flights found for this search." }],
144
- details: { currency: obj.currency, count: 0 },
196
+ content: [{ type: "text", text }],
197
+ details: {
198
+ currency,
199
+ trip: "round-trip",
200
+ outbound_route: or,
201
+ return_route: rr,
202
+ round_trip: rt,
203
+ return_options: retOpts,
204
+ note: obj.note,
205
+ },
145
206
  };
146
207
  }
147
- flights.sort((a, b) => Number(a.price) - Number(b.price));
148
- const top = flights.slice(0, 10);
149
-
150
- const lines = top.map((f, i) => {
151
- const legs = (f.legs as Array<Record<string, string>>) ?? [];
152
- const seg = legs.map(
153
- (l) =>
154
- `${l.from} ${l.departure} → ${l.to} ${l.arrival} ${l.date} (${Math.round(
155
- (Number(l.duration_minutes) || 0) / 60,
156
- )}h, ${l.plane})`,
157
- ).join(", ");
158
- const rt = f.is_round_trip ? " (round-trip total)" : "";
159
- const airline = (f.airlines as string[] | undefined)?.join("/") ?? "";
160
- const stops = Number(f.stops) || 0;
161
- return `${i + 1}. ${f.currency} ${f.price}${rt} · ${airline} · ${stops === 0 ? "direct" : `${stops} stop`}\n ${seg}`;
162
- });
163
- const summary = `${lines.join("\n")}\n\n${flights.length} itinerary/ies (showing cheapest ${top.length})`;
164
208
 
209
+ // ---- one-way (and legacy flat shape) ----
210
+ const route = obj.route as Record<string, unknown> | undefined;
211
+ const routeSummary = (route?.summary as string | undefined) ?? "";
212
+ const flights = (obj.flights ?? []) as Array<Record<string, unknown>>;
213
+ const rendered = renderDirection("Flights", flights, routeSummary);
214
+ const text = `${rendered}\n\nRoute: ${routeSummary}`;
165
215
  return {
166
- content: [{ type: "text", text: summary }],
167
- details: { currency: obj.currency, flights },
216
+ content: [{ type: "text", text }],
217
+ details: { currency, trip: "one-way", route, flights },
168
218
  };
169
219
  },
170
220
  };
package/flight_runner.py CHANGED
@@ -21,20 +21,44 @@ Query keys (all optional unless noted):
21
21
  Errors are reported as JSON with {"error": "..."} and a non-zero exit code, so
22
22
  the caller can distinguish "no flights" (valid empty result) from a failure.
23
23
 
24
- Output shape (ResultList -> list):
25
- [{ "price": int, "currency": str, "is_round_trip": bool, "airlines": [..],
26
- "type": str, "carbon": {"typical_on_route": int, "emission": int},
27
- "stops": int, "legs": [leg...] }]
24
+ Output shapes:
28
25
 
29
- IMPORTANT (fast-flights behavior, verified): `flight.flights` contains ONLY the
30
- OUTBOUND legs. For a round-trip, `price` is the TOTAL round-trip fare, but no
31
- return-leg detail is present in the parsed data. So the output describes the
32
- outbound itinerary; set `is_round_trip` when return was requested so the caller
33
- knows `price` is the two-way total.
26
+ one-way -> { currency, trip: "one-way", route, flights: [flight...] }
27
+ round-trip-> { currency, trip: "round-trip", outbound_route, return_route,
28
+ round_trip: [flight...], return_options: [flight...], note }
29
+
30
+ Each flight: { "price": int, "currency": str, "price_type": str,
31
+ "airlines": [..], "type": str,
32
+ "carbon": {"typical_on_route": int, "emission": int},
33
+ "stops": int, "legs": [leg...] }
34
+
35
+ ROUND-TRIP PRICING (fast-flights behavior, verified 2026-09-11): a single
36
+ native round-trip query (two FlightQuery legs, trip="round-trip") returns
37
+ flight.price as the TRUE two-way total fare, and flight.flights with ONLY the
38
+ outbound legs — the return direction is not in the data (no booking token
39
+ links a specific outbound with a specific return). NEVER estimate a
40
+ round-trip price by summing two one-way fares: one-way fares (especially
41
+ business) are often far higher than half the RT fare (e.g. IST<->BRU direct
42
+ business 2026-11-20/22 was USD 855 RT vs ~USD 1422 as a sum of one-ways).
43
+
44
+ So for round-trips the runner:
45
+ 1. runs the native RT query -> `round_trip[]`: true two-way totals with
46
+ outbound legs (price_type "round-trip-total") — this is the price to
47
+ present;
48
+ 2. runs ONE one-way search for the return direction -> `return_options[]`:
49
+ real return-leg schedules priced at one-way fares for reference ONLY
50
+ (price_type "one-way-reference").
34
51
 
35
52
  Each leg: { "from": code, "from_name": str, "to": code, "to_name": str,
53
+ "from_city": str, "to_city": str,
54
+ "from_country": str, "to_country": str,
55
+ "from_country_name": str, "to_country_name": str,
36
56
  "date": "YYYY-MM-DD", "departure": "HH:MM", "arrival": "HH:MM",
37
57
  "duration_minutes": int, "plane": str }
58
+
59
+ Metadata: airport names/cities and ISO-3166 country codes are resolved from
60
+ the bundled lookup (airports.py + airports.json/countries.json) so the caller
61
+ knows exactly which city and country each code refers to (disambiguation).
38
62
  """
39
63
  from __future__ import annotations
40
64
 
@@ -49,6 +73,8 @@ from fast_flights import (
49
73
  get_flights,
50
74
  )
51
75
 
76
+ import airports as airport_lookup
77
+
52
78
  SEATS = ("economy", "premium-economy", "business", "first")
53
79
  TRIPS = ("one-way", "round-trip")
54
80
 
@@ -58,11 +84,21 @@ def _hhmm(t) -> str:
58
84
 
59
85
 
60
86
  def _leg(seg) -> dict:
87
+ f = airport_lookup.resolve(seg.from_airport.code)
88
+ t = airport_lookup.resolve(seg.to_airport.code)
61
89
  return {
62
90
  "from": seg.from_airport.code,
63
- "from_name": seg.from_airport.name,
91
+ "from_name": f["name"] or seg.from_airport.name,
92
+ "from_city": f["city"],
93
+ "from_state": f["state"],
94
+ "from_country": f["country"],
95
+ "from_country_name": f["country_name"],
64
96
  "to": seg.to_airport.code,
65
- "to_name": seg.to_airport.name,
97
+ "to_name": t["name"] or seg.to_airport.name,
98
+ "to_city": t["city"],
99
+ "to_state": t["state"],
100
+ "to_country": t["country"],
101
+ "to_country_name": t["country_name"],
66
102
  "date": "%04d-%02d-%02d" % seg.departure.date,
67
103
  "departure": _hhmm(seg.departure.time),
68
104
  "arrival": _hhmm(seg.arrival.time),
@@ -71,32 +107,10 @@ def _leg(seg) -> dict:
71
107
  }
72
108
 
73
109
 
74
- def run(q: dict) -> dict:
75
- seat = q.get("seat", "economy")
76
- trip = q.get("trip", "one-way")
77
- if seat not in SEATS:
78
- raise ValueError(f"seat must be one of {SEATS}")
79
- if trip not in TRIPS:
80
- raise ValueError(f"trip must be one of {TRIPS}")
81
-
82
- outbound_date = q["outbound_date"]
83
- src = q["from_airport"].strip().upper()
84
- dst = q["to_airport"].strip().upper()
85
- for code in (src, dst):
86
- if not (code.isalpha() and len(code) == 3):
87
- raise ValueError(f"airport code must be 3 letters, got {code!r}")
88
-
89
- legs = [FlightQuery(date=outbound_date, from_airport=src, to_airport=dst)]
90
- return_date = q.get("return_date")
91
- if trip == "round-trip":
92
- if not return_date:
93
- raise ValueError("return_date is required for round-trip")
94
- legs.append(FlightQuery(date=return_date, from_airport=dst, to_airport=src))
95
-
96
- query = create_query(
97
- flights=legs,
98
- trip=trip,
99
- seat=seat,
110
+ def _query_params(q: dict) -> dict:
111
+ """Common fast-flights params shared by both trip directions."""
112
+ return dict(
113
+ seat=q.get("seat", "economy"),
100
114
  passengers=Passengers(
101
115
  adults=int(q.get("adults", 1)),
102
116
  children=int(q.get("children", 0)),
@@ -113,17 +127,29 @@ def run(q: dict) -> dict:
113
127
  exclude_basic_economy=bool(q.get("exclude_basic_economy", False)),
114
128
  )
115
129
 
130
+
131
+ def _search_direction(date: str, src: str, dst: str, q: dict) -> list:
132
+ """Run a single one-way search src->dst on date and return the flight list.
133
+
134
+ One-way fares. Used for trip="one-way", and (for round-trips) as a
135
+ reference search for the RETURN direction's leg schedules — the native RT
136
+ query does not include return legs.
137
+ """
138
+ query = create_query(
139
+ flights=[FlightQuery(date=date, from_airport=src, to_airport=dst)],
140
+ trip="one-way",
141
+ **_query_params(q),
142
+ )
116
143
  try:
117
144
  result = get_flights(query)
118
145
  except FlightsNotFound:
119
- return {"currency": q.get("currency", "") or "default", "flights": []}
120
-
146
+ return []
121
147
  currency = q.get("currency", "") or "default"
122
- out = [
148
+ return [
123
149
  {
124
150
  "price": f.price,
125
151
  "currency": currency,
126
- "is_round_trip": trip == "round-trip",
152
+ "price_type": "one-way",
127
153
  "airlines": f.airlines,
128
154
  "type": f.type,
129
155
  "carbon": {
@@ -135,7 +161,113 @@ def run(q: dict) -> dict:
135
161
  }
136
162
  for f in result
137
163
  ]
138
- return {"currency": currency, "flights": out}
164
+
165
+
166
+ def _search_round_trip(out_date: str, ret_date: str, src: str, dst: str, q: dict) -> list:
167
+ """Native fast-flights round-trip query (both legs in ONE query).
168
+
169
+ Returns flights whose `price` is the TRUE two-way round-trip total fare.
170
+ `legs` contain only the OUTBOUND direction (fast-flights limitation,
171
+ verified) — return-leg schedules must come from a separate one-way search.
172
+ """
173
+ query = create_query(
174
+ flights=[
175
+ FlightQuery(date=out_date, from_airport=src, to_airport=dst),
176
+ FlightQuery(date=ret_date, from_airport=dst, to_airport=src),
177
+ ],
178
+ trip="round-trip",
179
+ **_query_params(q),
180
+ )
181
+ try:
182
+ result = get_flights(query)
183
+ except FlightsNotFound:
184
+ return []
185
+ currency = q.get("currency", "") or "default"
186
+ return [
187
+ {
188
+ "price": f.price, # true two-way round-trip total
189
+ "price_type": "round-trip-total",
190
+ "currency": currency,
191
+ "airlines": f.airlines,
192
+ "type": f.type,
193
+ "carbon": {
194
+ "typical_on_route": f.carbon.typical_on_route,
195
+ "emission": f.carbon.emission,
196
+ },
197
+ "stops": len(f.flights) - 1,
198
+ "legs": [_leg(x) for x in f.flights], # outbound legs only
199
+ }
200
+ for f in result
201
+ ]
202
+
203
+
204
+ def _route(src: str, dst: str) -> dict:
205
+ """Resolved endpoint metadata + a ready-to-read summary."""
206
+ route = {
207
+ "from": airport_lookup.resolve(src),
208
+ "to": airport_lookup.resolve(dst),
209
+ }
210
+ route["summary"] = (
211
+ f"{route['from']['code']} {route['from']['city']}, "
212
+ f"{route['from']['country_name'] or route['from']['country']} -> "
213
+ f"{route['to']['code']} {route['to']['city']}, "
214
+ f"{route['to']['country_name'] or route['to']['country']}"
215
+ )
216
+ return route
217
+
218
+
219
+ def run(q: dict) -> dict:
220
+ seat = q.get("seat", "economy")
221
+ trip = q.get("trip", "one-way")
222
+ if seat not in SEATS:
223
+ raise ValueError(f"seat must be one of {SEATS}")
224
+ if trip not in TRIPS:
225
+ raise ValueError(f"trip must be one of {TRIPS}")
226
+
227
+ outbound_date = q["outbound_date"]
228
+ src = q["from_airport"].strip().upper()
229
+ dst = q["to_airport"].strip().upper()
230
+ for code in (src, dst):
231
+ if not (code.isalpha() and len(code) == 3):
232
+ raise ValueError(f"airport code must be 3 letters, got {code!r}")
233
+ if not airport_lookup.is_known(code):
234
+ raise ValueError(
235
+ f"unknown airport code {code!r}: not in the airport database "
236
+ f"(resolve it to a valid IATA code first)"
237
+ )
238
+
239
+ currency = q.get("currency", "") or "default"
240
+
241
+ if trip == "round-trip":
242
+ return_date = q.get("return_date")
243
+ if not return_date:
244
+ raise ValueError("return_date is required for round-trip")
245
+ # Native RT query for the TRUE two-way price (+outbound legs), plus a
246
+ # one-way reference search so return-leg schedules are still visible.
247
+ payload = {
248
+ "currency": currency,
249
+ "trip": "round-trip",
250
+ "outbound_route": _route(src, dst),
251
+ "return_route": _route(dst, src),
252
+ "round_trip": _search_round_trip(outbound_date, return_date, src, dst, q),
253
+ "return_options": _search_direction(return_date, dst, src, q),
254
+ "note": (
255
+ "round_trip[].price is the true two-way round-trip total from a "
256
+ "native fast-flights RT query; its legs cover the outbound "
257
+ "direction only. return_options[] are one-way fares for the "
258
+ "return direction (schedule reference, NOT round-trip prices). "
259
+ "Do not add one-way fares to estimate round-trip prices."
260
+ ),
261
+ }
262
+ return payload
263
+
264
+ # one-way: keep the flat, backward-compatible shape.
265
+ return {
266
+ "currency": currency,
267
+ "trip": "one-way",
268
+ "route": _route(src, dst),
269
+ "flights": _search_direction(outbound_date, src, dst, q),
270
+ }
139
271
 
140
272
 
141
273
  def main() -> int:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "find-flight",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Google Flights search for agents — a pi extension (search_flights tool) + portable skill, powered by the fast-flights scraper.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -25,12 +25,15 @@
25
25
  "extensions/",
26
26
  "skills/",
27
27
  "flight_runner.py",
28
+ "airports.py",
29
+ "airports.json",
30
+ "countries.json",
28
31
  "examples/",
29
32
  "README.md",
30
33
  "LICENSE"
31
34
  ],
32
35
  "scripts": {
33
- "verify": "python3 -c \"import json,sys; from pathlib import Path; Path('flight_runner.py')\" && python3 -m py_compile flight_runner.py && echo \"runner OK\"",
36
+ "verify": "python3 -c \"import json,sys; from pathlib import Path; Path('flight_runner.py')\" && python3 -m py_compile flight_runner.py airports.py && python3 -c \"import airports; assert airports.is_known('IST') and airports.country_name('TR')=='Turkey'\" && echo \"runner OK\"",
34
37
  "prepublishOnly": "npm run verify"
35
38
  },
36
39
  "publishConfig": {
@@ -35,7 +35,11 @@ pip install fast-flights
35
35
  ```
36
36
  Then resolve relative/ambiguous dates ("next Friday", "11-13 September") against this real year. Format every date as `YYYY-MM-DD`.
37
37
  2. **Confirm one-way vs round-trip.** If the user didn't explicitly say, ASK: one-way (1 leg, needs only `date`+`from`+`to`) vs round-trip (2+ legs, needs a `return_date` too). Do not assume. If a stay duration or "until" date is given, that implies round-trip — but still confirm the dates.
38
- 3. **Verify the airport codes — never guess what a 3-letter IATA code means.** Models routinely map codes to the wrong city (e.g. misreading `ECN` as Edinburgh when it is really Ercan, or inventing a code). Before searching, confirm what `from_airport`/`to_airport` actually are: look the code up in the upstream airport list (`enums/airports.csv` in `AWeirdDev/flights`, ~9.7k rows, by code + name) or against a known route. If you can't verify a code, ask the user instead of assuming.
38
+ 3. **Verify the airport codes — never guess what a 3-letter IATA code means.** Models routinely map codes to the wrong city (e.g. misreading `ECN` as Edinburgh when it is really Ercan, or inventing a code). Before searching, confirm what `from_airport`/`to_airport` actually are: look the code up in the upstream airport list (`enums/airports.csv` in `AWeirdDev/flights`, ~9.7k rows, by code + name + country) or against a known route. If you can't verify a code, ask the user instead of assuming.
39
+
40
+ **Country & airport awareness is built into the tools in this package.** The `search_flights` tool and `flight_runner.py` bundle a code → (airport name, city, state, ISO-3166 country) lookup (`airports.py` + `airports.json`/`countries.json`). Every search resolves both endpoint codes to their city and country **before** calling Google Flights, rejects an unknown code with a clear error, and returns per-leg `*_city`, `*_state`, `*_country` and `*_country_name` fields plus a human-readable `route.summary` (e.g. `IST Istanbul, Turkey -> ECN Nicosia, Cyprus`). So the agent never has to guess a code's meaning — the tool surfaces it.
41
+
42
+ Still treat output resolution as a *cross-check*, not a replacement for intent: an ambiguous request (e.g. user says "St. John's" but a code resolves elsewhere) should still be confirmed against the user's intent.
39
43
 
40
44
  ## Core Recipe — structured query
41
45
 
@@ -101,6 +105,8 @@ query = create_query(
101
105
  )
102
106
  ```
103
107
 
108
+ `flight.price` on the result is the **true two-way total fare**; `flight.flights` contains only the **outbound** legs (no return segments are in the data).
109
+
104
110
  ## Natural-language path (agent-friendly)
105
111
 
106
112
  `get_flights` also accepts a plain sentence instead of a `Query`:
@@ -135,9 +141,13 @@ for flight in result:
135
141
  leg.plane_type # str
136
142
  ```
137
143
 
144
+ **Country & airport codes (the bundled tools add these).** When you call `search_flights` or `flight_runner.py` (not raw fast-flights), each leg is enriched with the resolved airport metadata from the bundled lookup — `from_city`, `from_state`, `from_country` (ISO-3166 alpha-2), `from_country_name`, and the matching `to_*` fields — plus `from_name`/`to_name`. A top-level `route` object gives both endpoints' resolved metadata and a ready-to-read `route.summary` like `IST Istanbul, Turkey -> ECN Nicosia, Cyprus`. This is how the agent knows the country and airport code with certainty instead of guessing.
145
+
138
146
  **Currency is NOT attached to prices.** `flight.price` is a bare `int` with no currency symbol or code. The currency that the price is quoted in is whatever you passed to `create_query(currency=...)` (or Google's default if you passed `""` — often the local currency, e.g. Turkish Lira for a Turkey-originating route). When presenting prices, surface the currency explicitly: track the `currency` you passed (or the user's expected currency) and label every price with it. Never hardcode a currency symbol in your output format — it may not match the price's actual currency.
139
147
 
140
- **Round-trip: `price` is the total, but only OUTBOUND legs are returned (verified).** For a `round-trip` query, `flight.flights` contains **only the outbound legs** — the parsed data does not include return-leg detail. `flight.price` is the **two-way total fare**. So: count stops and display legs from `flight.flights` as the outbound itinerary, and tell the user that `price` covers both directions but only outbound flight times are available. Do NOT invent or assume return-leg segments that aren't in the data.
148
+ **Round-trip legs raw `fast-flights` gives only OUTBOUND (verified).** A single `create_query(..., trip="round-trip")` returns `flight.flights` with **only the outbound legs** — no return-leg detail — and `flight.price` as the **two-way total fare**. So on raw fast-flights, count stops/display legs as the outbound itinerary; do NOT invent return segments that aren't in the data.
149
+
150
+ **Round-trip legs — the bundled tools price the true RT fare.** `search_flights` / `flight_runner.py` run the **native round-trip query** (both legs in one `create_query`) as the price source: `round_trip[].price` is the **true two-way total** and its `legs` cover the **outbound direction only**. They additionally run **one one-way search for the return direction** (`return_options[]`) so real return-leg schedules are visible; those prices are **one-way reference fares, NOT round-trip prices** (a `price_type` field marks each entry: `round-trip-total` vs `one-way`). Each direction gets its own `outbound_route` / `return_route` metadata. No pairing is manufactured between a specific outbound and a specific return, because the API exposes no booking token linking them.
141
151
 
142
152
  `FlightsNotFound` is raised when nothing matches (or Google returns an error status).
143
153
 
@@ -193,13 +203,14 @@ best = result[0] # or result[:3] for top-N
193
203
  | Expecting `multi-city` to work | Unimplemented — treat as not supported today |
194
204
  | Passing `airlines` to multiple legs expecting per-leg filtering | Only first leg's `airlines` is applied |
195
205
  | Assuming 24h timestamps | Times are `(hour, minute)` on a 0–23 clock |
196
- | Guessing what a 3-letter airport code means | Verify it against the upstream `enums/airports.csv` (or a known route) before searching |
206
+ | Guessing what a 3-letter airport code means | Verify it against the upstream `enums/airports.csv` (or a known route) before searching; the bundled `search_flights` tool resolves codes to city + country for you |
197
207
  | Hardcoding a currency symbol in output | `price` has no currency — track/pass `currency` and label prices with it |
198
- | Assuming return legs are in the round-trip data | `flights` holds only outbound legs; `price` is the two-way total label it as such |
208
+ | Summing two one-way fares to quote a round-trip price | One-way fares (esp. business) are often far higher than half the RT fare (verified: IST↔BRU direct business 2026-11-20/22 = USD 855 RT vs ~USD 1,422 summed one-ways). Always use the native round-trip query / `round_trip[].price` |
209
+ | Assuming return legs are in raw round-trip data | Raw `fast-flights` returns only outbound legs (but with the true two-way price); the bundled `search_flights` tool adds a one-way return-direction search (`return_options[]`) so return-leg schedules ARE shown there |
199
210
 
200
211
  ## Real-world notes
201
212
 
202
213
  - Version: `fast-flights` 3.x. See upstream repo `AWeirdDev/flights` and docs `aweirddev.github.io/flights`.
203
214
  - Run a ready-made CLI: `python examples/search_flights.py --from TPE --to MYJ --date 2025-01-01 --seat economy`.
204
- - **Round-trip data (verified):** the parsed result contains only outbound legs; `price` is the two-way total. Present it as such.
215
+ - **Round-trip pricing (verified 2026-09-11):** a single native `create_query(trip="round-trip")` returns the **true two-way total** in `flight.price` with **outbound legs only**. The bundled `search_flights` / `flight_runner.py` use that native query as the price source (`round_trip[]`) plus a one-way return-direction search for leg schedules (`return_options[]`, one-way reference fares). Never estimate RT prices by summing one-ways.
205
216
  - **pi extension:** if the `search_flights` custom tool is active (via `extensions/flight-search.ts`), prefer calling it over writing ad-hoc Python — it returns structured, correctly-labelled results and leaves no scratch files. `flight_runner.py` is the equivalent standalone executor.