refactor: harden airport search (Pydantic AirportMatch + import-time validation)#152
Conversation
…-time validation
Breaking API changes to fli.core.airports (1-day-old public API, locking
in the right shape before external consumers grow):
- AirportMatch is now a frozen Pydantic BaseModel:
code: Airport (enum, was str)
match_type: Literal["iata_exact","iata_prefix","city","name"]
score: float = Field(ge=0, le=100)
- match_type "code" was overloaded across two priorities; split into
"iata_exact" (exact IATA match, score 100) and "iata_prefix"
(prefix match, score 60) so consumers can branch reliably.
Defensive hardening:
- CITY_AIRPORTS is validated against the Airport enum at import time;
a typo (e.g. JKF for JFK) now raises RuntimeError immediately instead
of silently producing empty results. The except KeyError: pass guards
in priorities 2 and 3 are removed -- they were masking developer bugs.
- find_airports MCP tool wraps search_airports in try/except for parity
with search_flights / search_dates, returning {success: False, error,
query} on failure.
Docs and consumers:
- Rewrote the misleading CITY_AIRPORTS module comment (the original
"city is NOT in the airport name" invariant was false for many
entries -- atlanta, denver, seattle, miami, berlin, etc.).
- search_airports docstring now documents the 5-priority cascade and
score bands.
- CLI and MCP consumers updated to use r.code.name (IATA string) since
code is now an Airport enum.
Tests (+19 net):
- tests/core/test_airports.py: rewrote for new shape; added coverage
for limit<1, surrounding whitespace, dedup across priorities,
priority-3 skip when exact city, monotonic score ordering, position
scoring, and AirportMatch frozen / equality / validation behavior.
- tests/mcp/test_find_airports.py: new -- MCP tool response shape,
success/error envelope, limit capping, no-results.
- tests/cli/test_airports.py: new -- CliRunner coverage for table
output, --json flag, no-results exit code, --limit.
uv.lock: drift correction (0.8.4 -> 0.8.5 to match pyproject.toml).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Test Results 4 files ± 0 4 suites ±0 43s ⏱️ -3s Results for commit f21a67c. ± Comparison against base commit 6337152. This pull request removes 5 and adds 24 tests. Note that renamed tests count towards both.♻️ This comment has been updated with latest results. |
| 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) |
There was a problem hiding this 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.
| 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.| def test_frozen(self): | ||
| m = AirportMatch(code=Airport.JFK, name="JFK Airport", match_type="iata_exact", score=100.0) | ||
| try: | ||
| m.score = 0.0 | ||
| except Exception: | ||
| return | ||
| raise AssertionError("AirportMatch should be frozen") | ||
|
|
||
| def test_equality(self): | ||
| a = AirportMatch(code=Airport.JFK, name="JFK", match_type="city", score=90.0) | ||
| b = AirportMatch(code=Airport.JFK, name="JFK", match_type="city", score=90.0) | ||
| assert a == b | ||
|
|
||
| def test_rejects_invalid_match_type(self): | ||
| try: | ||
| AirportMatch(code=Airport.JFK, name="JFK", match_type="bogus", score=50.0) # type: ignore[arg-type] | ||
| except Exception: | ||
| return | ||
| raise AssertionError("AirportMatch should reject unknown match_type") | ||
|
|
||
| def test_rejects_score_out_of_range(self): | ||
| try: | ||
| AirportMatch(code=Airport.JFK, name="JFK", match_type="iata_exact", score=101.0) | ||
| except Exception: | ||
| return | ||
| raise AssertionError("AirportMatch should reject score > 100") |
There was a problem hiding this comment.
These three
TestAirportMatch tests use a try/except Exception: return pattern rather than pytest.raises. The problem is that the pattern passes for any exception — if Pydantic raises (say) an internal ImportError or a TypeError for an unrelated reason, the test silently greens. Using pytest.raises also pins the expected exception type and gives a cleaner failure message.
| def test_frozen(self): | |
| m = AirportMatch(code=Airport.JFK, name="JFK Airport", match_type="iata_exact", score=100.0) | |
| try: | |
| m.score = 0.0 | |
| except Exception: | |
| return | |
| raise AssertionError("AirportMatch should be frozen") | |
| def test_equality(self): | |
| a = AirportMatch(code=Airport.JFK, name="JFK", match_type="city", score=90.0) | |
| b = AirportMatch(code=Airport.JFK, name="JFK", match_type="city", score=90.0) | |
| assert a == b | |
| def test_rejects_invalid_match_type(self): | |
| try: | |
| AirportMatch(code=Airport.JFK, name="JFK", match_type="bogus", score=50.0) # type: ignore[arg-type] | |
| except Exception: | |
| return | |
| raise AssertionError("AirportMatch should reject unknown match_type") | |
| def test_rejects_score_out_of_range(self): | |
| try: | |
| AirportMatch(code=Airport.JFK, name="JFK", match_type="iata_exact", score=101.0) | |
| except Exception: | |
| return | |
| raise AssertionError("AirportMatch should reject score > 100") | |
| def test_frozen(self): | |
| import pytest | |
| from pydantic import ValidationError | |
| m = AirportMatch(code=Airport.JFK, name="JFK Airport", match_type="iata_exact", score=100.0) | |
| with pytest.raises(Exception): | |
| m.score = 0.0 | |
| def test_equality(self): | |
| a = AirportMatch(code=Airport.JFK, name="JFK", match_type="city", score=90.0) | |
| b = AirportMatch(code=Airport.JFK, name="JFK", match_type="city", score=90.0) | |
| assert a == b | |
| def test_rejects_invalid_match_type(self): | |
| import pytest | |
| from pydantic import ValidationError | |
| with pytest.raises(ValidationError): | |
| AirportMatch(code=Airport.JFK, name="JFK", match_type="bogus", score=50.0) # type: ignore[arg-type] | |
| def test_rejects_score_out_of_range(self): | |
| import pytest | |
| from pydantic import ValidationError | |
| with pytest.raises(ValidationError): | |
| AirportMatch(code=Airport.JFK, name="JFK", match_type="iata_exact", score=101.0) |
Prompt To Fix With AI
This is a comment left during a code review.
Path: tests/core/test_airports.py
Line: 116-141
Comment:
These three `TestAirportMatch` tests use a `try/except Exception: return` pattern rather than `pytest.raises`. The problem is that the pattern passes for *any* exception — if Pydantic raises (say) an internal `ImportError` or a `TypeError` for an unrelated reason, the test silently greens. Using `pytest.raises` also pins the expected exception type and gives a cleaner failure message.
```suggestion
def test_frozen(self):
import pytest
from pydantic import ValidationError
m = AirportMatch(code=Airport.JFK, name="JFK Airport", match_type="iata_exact", score=100.0)
with pytest.raises(Exception):
m.score = 0.0
def test_equality(self):
a = AirportMatch(code=Airport.JFK, name="JFK", match_type="city", score=90.0)
b = AirportMatch(code=Airport.JFK, name="JFK", match_type="city", score=90.0)
assert a == b
def test_rejects_invalid_match_type(self):
import pytest
from pydantic import ValidationError
with pytest.raises(ValidationError):
AirportMatch(code=Airport.JFK, name="JFK", match_type="bogus", score=50.0) # type: ignore[arg-type]
def test_rejects_score_out_of_range(self):
import pytest
from pydantic import ValidationError
with pytest.raises(ValidationError):
AirportMatch(code=Airport.JFK, name="JFK", match_type="iata_exact", score=101.0)
```
How can I resolve this? If you propose a fix, please make it concise.Rich's Console interprets `[` as a markup tag start. In non-TTY
environments (CI, pipes), JSON output beginning with `[{` was being
parsed as malformed markup and suppressed entirely, leaving stdout
empty. CliRunner-based tests caught this with JSONDecodeError on empty
stdout.
Switching to plain print() bypasses Rich for the machine-readable
path, which is the right thing to do for --json anyway.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Summary
Follow-ups to #115 that lock in the right shape for the day-old
AirportMatchpublic API while it still has no external consumers, plus defensive hardening and the test coverage that #115 was missing.Breaking changes (in
fli.core.airports)AirportMatchis now a frozen PydanticBaseModel:code: Airport(enum, wasstr)match_type: Literal["iata_exact", "iata_prefix", "city", "name"]score: float = Field(ge=0, le=100)match_typeno longer overloads"code":"iata_exact""iata_prefix"Previously both used
"code", so consumers had to inspectscoreto tell exact from prefix.CLI and MCP consumers (
r.code.namefor the IATA string) are updated accordingly.Defensive hardening
CITY_AIRPORTSis validated at import time. A typo likeJKFforJFKwould previously be swallowed byexcept KeyError: passin priorities 2/3 and silently shrink results. Now it raisesRuntimeErrorimmediately. The silent guards in priorities 2 and 3 are removed.find_airportsMCP tool now wrapssearch_airportsintry/except, returning{success: False, error, query}on exception -- matching the envelope used bysearch_flightsandsearch_dates.Docs
CITY_AIRPORTSmodule comment. The original claim that "city is NOT in the airport name" was false for many entries (atlanta, denver, seattle, miami, berlin, melbourne, sydney, singapore -- their airport names do contain the city).search_airportsdocstring now documents the 5-priority cascade and score bands.Tests (+19 net)
tests/core/test_airports.py-- rewrote for new shape; added coverage forlimit<1, surrounding whitespace, dedup across priorities (san franciscomatches both city map and name substring), priority-3 skip when query is an exact city, monotonic score ordering, position-based scoring, andAirportMatchfrozen/equality/validation behaviour.tests/mcp/test_find_airports.py(new) -- MCP tool response shape, success/error envelope, limit capping, no-results.tests/cli/test_airports.py(new) --CliRunnercoverage for table output,--jsonflag, no-results exit code,--limit.Total suite: 220 passing (was 201 on
main).Test plan
uv run ruff format . --check && uv run ruff check .-> cleanuv run pytest tests/ --all -v --ignore=tests/search/-> 220 passedfind_airportsMCP tool against an AI assistant by querying a city + an IATA code🤖 Generated with Claude Code
Greptile Summary
This PR hardens the
AirportMatchpublic API by converting it to a frozen PydanticBaseModel, renamesmatch_typevalues ("code"→"iata_exact"/"iata_prefix"), adds import-time validation ofCITY_AIRPORTSagainst theAirportenum, wraps the MCPfind_airportstool in atry/excepterror envelope, and ships the test coverage that was missing from #115.AirportMatchis now a frozen Pydantic model withcode: Airport,match_type: Literal[...], andscore: float = Field(ge=0, le=100); CLI and MCP consumers updated to user.code.namefor the IATA string.CITY_AIRPORTSvalidation replaces the silentexcept KeyError: passguards in priorities 2/3, so a typo in the map raisesRuntimeErrorimmediately rather than shrinking results silently.AirportMatchfrozen/equality/validation behavior, MCP response envelope, and CLI--json/--limitflags.Confidence Score: 4/5
Safe to merge; the import-time validation and Pydantic model hardening are straightforward improvements with good test coverage.
The changes are well-scoped and the logic is sound. The only code concern is that
70.0 - (pos * 0.1)in Priority 4 has no explicit lower-bound clamp, meaning a sufficiently large match position would produce a score below0.0and trip the Pydanticge=0.0constraint — convertingsearch_airportsinto an unexpected exception in the CLI path. In practice no real IATA airport name is anywhere near long enough to trigger this, but the formula lacks themax(0.0, ...)guard that would make the invariant self-enforcing.fli/core/airports.py — score formula in Priority 4; tests/core/test_airports.py — TestAirportMatch validation tests use bare try/except instead of pytest.raises.
Important Files Changed
Flowchart
%%{init: {'theme': 'neutral'}}%% flowchart TD A["search_airports(query, limit)"] --> B{empty or limit lt 1?} B -- Yes --> Z["return []"] B -- No --> P1 P1["P1: Exact IATA\nscore=100, iata_exact"] --> P2 P2["P2: Exact city lookup\nscore=90, city"] --> P3 P3{query in CITY_AIRPORTS?} P3 -- No --> P3b["P3: Prefix city match\nscore=80, city"] P3 -- Yes --> P4 P3b --> P4 P4["P4: Airport name substring\nscore=70-pos*0.1, name"] --> P5 P5{len query_upper <= 3?} P5 -- Yes --> P5b["P5: IATA prefix\nscore=60, iata_prefix"] P5 -- No --> SORT P5b --> SORT SORT["Sort by -score then code.name\nslice to limit"] --> OUT["list of AirportMatch"]Prompt To Fix All With AI
Reviews (1): Last reviewed commit: "refactor: harden airport search with Pyd..." | Re-trigger Greptile