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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions fli/cli/commands/airports.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,15 +37,20 @@ def airports(
if json_output:
import json

output = [{"code": r.code, "name": r.name, "match_type": r.match_type} for r in results]
console.print(json.dumps(output, indent=2))
output = [
{"code": r.code.name, "name": r.name, "match_type": r.match_type} for r in results
]
# Plain print() bypasses Rich's markup parsing — JSON's `[` would
# otherwise be interpreted as a style tag in non-TTY environments
# (e.g. CI, pipes), suppressing output.
print(json.dumps(output, indent=2))
else:
table = Table(title=f"Airports matching '{query}'")
table.add_column("Code", style="bold cyan", width=6)
table.add_column("Airport Name", style="white")
table.add_column("Match", style="dim")

for result in results:
table.add_row(result.code, result.name, result.match_type)
table.add_row(result.code.name, result.name, result.match_type)

console.print(table)
106 changes: 66 additions & 40 deletions fli/core/airports.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
"""Airport search utilities for looking up airports by city or name."""

from typing import Literal

from pydantic import BaseModel, ConfigDict, Field

from fli.models import Airport

# Mapping of city names to IATA codes for airports where the city name
# is NOT in the airport name (e.g., JFK doesn't contain "New York")
# Curated mapping of city names and common abbreviations to IATA codes.
# Provides multi-airport groupings (e.g., "new york" -> JFK, LGA, EWR) and
# short aliases (e.g., "sf", "la", "nyc") that pure airport-name substring
# search would miss. Validated against the Airport enum at import time.
CITY_AIRPORTS: dict[str, list[str]] = {
"new york": ["JFK", "LGA", "EWR"],
"nyc": ["JFK", "LGA", "EWR"],
Expand Down Expand Up @@ -58,30 +64,43 @@
"honolulu": ["HNL"],
}

for _city, _codes in CITY_AIRPORTS.items():
for _code in _codes:
if _code not in Airport.__members__:
raise RuntimeError(f"CITY_AIRPORTS[{_city!r}] references unknown IATA code {_code!r}")

MatchType = Literal["iata_exact", "iata_prefix", "city", "name"]

class AirportMatch:

class AirportMatch(BaseModel):
"""A matched airport from a search query."""

def __init__(self, code: str, name: str, match_type: str, score: float):
"""Initialize a matched airport result."""
self.code = code
self.name = name
self.match_type = match_type # "code", "city", "name"
self.score = score

def __repr__(self) -> str:
"""Return a debug representation for the match."""
return (
f"AirportMatch(code={self.code!r}, name={self.name!r}, match_type={self.match_type!r})"
)
model_config = ConfigDict(frozen=True)

code: Airport
name: str
match_type: MatchType
score: float = Field(ge=0.0, le=100.0)


def search_airports(query: str, limit: int = 10) -> list[AirportMatch]:
"""Search airports by city name, airport name, or IATA code.

Results are ranked by a 5-priority cascade, scored 0-100 (higher = better):

1. iata_exact (score 100) - query is an exact IATA code (e.g. "JFK")
2. city (score 90) - query is an exact city/alias in CITY_AIRPORTS
3. city (score 80) - query is a prefix of a city in CITY_AIRPORTS
4. name (score <=70) - query is a substring of an airport's name;
earlier match position scores higher
5. iata_prefix (score 60) - query (<=3 chars) is a prefix of an IATA code

Within a single result list, each IATA code appears at most once: the
highest-priority match wins.

Args:
query: Search string (e.g., "new york", "san fran", "JFK", "heathrow")
limit: Maximum results to return (must be >= 1).
query: Search string (e.g., "new york", "san fran", "JFK", "heathrow").
limit: Maximum results to return. Values < 1 yield an empty list.

Returns:
List of matching airports sorted by relevance (best match first).
Expand All @@ -96,47 +115,50 @@ def search_airports(query: str, limit: int = 10) -> list[AirportMatch]:

# Priority 1: Exact IATA code match
query_upper = query.strip().upper()
try:
if query_upper in Airport.__members__:
airport = Airport[query_upper]
results.append(AirportMatch(query_upper, airport.value, "code", 100.0))
results.append(
AirportMatch(code=airport, name=airport.value, match_type="iata_exact", score=100.0)
)
seen_codes.add(query_upper)
except KeyError:
pass

# Priority 2: City name lookup (handles "new york" -> JFK, LGA, EWR)
# Priority 2: Exact city name lookup (handles "new york" -> JFK, LGA, EWR)
if query_lower in CITY_AIRPORTS:
for code in CITY_AIRPORTS[query_lower]:
if code not in seen_codes:
try:
airport = Airport[code]
results.append(AirportMatch(code, airport.value, "city", 90.0))
seen_codes.add(code)
except KeyError:
pass
airport = Airport[code]
results.append(
AirportMatch(code=airport, name=airport.value, match_type="city", score=90.0)
)
seen_codes.add(code)

# Priority 3: Partial city name match (handles "new yo" matching "new york")
if query_lower not in CITY_AIRPORTS:
for city, codes in CITY_AIRPORTS.items():
if city.startswith(query_lower):
for code in codes:
if code not in seen_codes:
try:
airport = Airport[code]
results.append(AirportMatch(code, airport.value, "city", 80.0))
seen_codes.add(code)
except KeyError:
pass
airport = Airport[code]
results.append(
AirportMatch(
code=airport, name=airport.value, match_type="city", score=80.0
)
)
seen_codes.add(code)

# Priority 4: Airport name substring match
for airport in Airport:
if airport.name in seen_codes:
continue
airport_name_lower = airport.value.lower()
if query_lower in airport_name_lower:
# Score based on how early the match occurs.
# 0.1-per-position weight keeps name matches (max ~70) below
# city matches (80) regardless of where the substring lands.
pos = airport_name_lower.find(query_lower)
score = 70.0 - (pos * 0.1) # Earlier matches score higher.
results.append(AirportMatch(airport.name, airport.value, "name", score))
score = 70.0 - (pos * 0.1)
Comment on lines 157 to +158

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The position-based score formula has no lower-bound guard. 70.0 - (pos * 0.1) becomes negative when pos > 700. While no real IATA airport name is that long today, Pydantic's ge=0.0 constraint on score would raise a ValidationError the moment it happens, turning search_airports into an unexpected exception in the CLI layer (the MCP layer would catch it via _find_airports_impl's try/except). A max() clamp costs nothing and makes the invariant self-documenting.

Suggested change
pos = airport_name_lower.find(query_lower)
score = 70.0 - (pos * 0.1) # Earlier matches score higher.
results.append(AirportMatch(airport.name, airport.value, "name", score))
score = 70.0 - (pos * 0.1)
pos = airport_name_lower.find(query_lower)
score = max(0.0, 70.0 - (pos * 0.1))
Prompt To Fix With AI
This is a comment left during a code review.
Path: fli/core/airports.py
Line: 157-158

Comment:
The position-based score formula has no lower-bound guard. `70.0 - (pos * 0.1)` becomes negative when `pos > 700`. While no real IATA airport name is that long today, Pydantic's `ge=0.0` constraint on `score` would raise a `ValidationError` the moment it happens, turning `search_airports` into an unexpected exception in the CLI layer (the MCP layer would catch it via `_find_airports_impl`'s try/except). A `max()` clamp costs nothing and makes the invariant self-documenting.

```suggestion
            pos = airport_name_lower.find(query_lower)
            score = max(0.0, 70.0 - (pos * 0.1))
```

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

Fix in Claude Code Fix in Cursor Fix in Codex

results.append(
AirportMatch(code=airport, name=airport.value, match_type="name", score=score)
)
seen_codes.add(airport.name)

# Priority 5: IATA code prefix match (handles "SF" matching "SFO")
Expand All @@ -145,9 +167,13 @@ def search_airports(query: str, limit: int = 10) -> list[AirportMatch]:
if airport.name in seen_codes:
continue
if airport.name.startswith(query_upper):
results.append(AirportMatch(airport.name, airport.value, "code", 60.0))
results.append(
AirportMatch(
code=airport, name=airport.value, match_type="iata_prefix", score=60.0
)
)
seen_codes.add(airport.name)

# Sort by score descending, then by code alphabetically
results.sort(key=lambda m: (-m.score, m.code))
# Sort by score descending, then by IATA code alphabetically.
results.sort(key=lambda m: (-m.score, m.code.name))
return results[:limit]
29 changes: 22 additions & 7 deletions fli/mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -589,6 +589,27 @@ def _search_dates_from_params(params: DateSearchParams) -> dict[str, Any]:
return _execute_date_search(params)


def _find_airports_impl(query: str, limit: int = 10) -> dict[str, Any]:
"""Run search_airports and shape the result into the MCP response dict."""
try:
results = search_airports(query, limit=limit)
except Exception as exc:
return {
"success": False,
"error": str(exc),
"query": query,
}

return {
"success": True,
"query": query,
"count": len(results),
"airports": [
{"code": r.code.name, "name": r.name, "match_type": r.match_type} for r in results
],
}


@mcp.tool(
annotations={
"title": "Search Airports",
Expand All @@ -613,13 +634,7 @@ def find_airports(
Supports city names (e.g., "new york" returns JFK, LGA, EWR),
airport names (e.g., "heathrow" returns LHR), and IATA codes.
"""
results = search_airports(query, limit=limit)

return {
"query": query,
"count": len(results),
"airports": [{"code": r.code, "name": r.name, "match_type": r.match_type} for r in results],
}
return _find_airports_impl(query, limit=limit)


# =============================================================================
Expand Down
44 changes: 44 additions & 0 deletions tests/cli/test_airports.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"""Tests for the `fli airports` CLI command."""

import json

import pytest
from typer.testing import CliRunner

from fli.cli.main import app


@pytest.fixture
def runner():
return CliRunner()


class TestAirportsCommand:
def test_exact_code_table_output(self, runner):
result = runner.invoke(app, ["airports", "JFK"])
assert result.exit_code == 0
assert "JFK" in result.stdout

def test_json_output(self, runner):
result = runner.invoke(app, ["airports", "JFK", "--json"])
assert result.exit_code == 0
payload = json.loads(result.stdout)
assert isinstance(payload, list)
assert payload[0]["code"] == "JFK"
assert payload[0]["match_type"] == "iata_exact"

def test_city_query_returns_all_airports(self, runner):
result = runner.invoke(app, ["airports", "new york", "--json"])
assert result.exit_code == 0
codes = {row["code"] for row in json.loads(result.stdout)}
assert {"JFK", "LGA", "EWR"} <= codes

def test_no_results_exits_with_error(self, runner):
result = runner.invoke(app, ["airports", "xyznonexistent"])
assert result.exit_code == 1
assert "No airports found" in result.stdout

def test_limit_caps_results(self, runner):
result = runner.invoke(app, ["airports", "international", "--limit", "2", "--json"])
assert result.exit_code == 0
assert len(json.loads(result.stdout)) <= 2
Loading
Loading