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
38 changes: 38 additions & 0 deletions rtw/airports.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""Shared airportsdata loader with fail-fast.

Loads the airportsdata IATA database exactly once at import time.
All consumer modules should import from here instead of loading
airportsdata directly.

If airportsdata is not installed, exits immediately with code 2
and a clear error message — without it, distances become 0,
continents become None, and every downstream calculation is wrong.
"""

import sys

try:
import airportsdata

airports_db: dict = airportsdata.load("IATA")
except ImportError:
try:
from rich.console import Console
from rich.panel import Panel

console = Console(stderr=True)
console.print(
Panel(
"Required library 'airportsdata' is not available.\n\n"
"Fix: pip install airportsdata",
title="Missing Dependency",
border_style="red",
)
)
except ImportError:
print(
"Error: Required library 'airportsdata' is not available.\n"
"Fix: pip install airportsdata",
file=sys.stderr,
)
sys.exit(2)
12 changes: 8 additions & 4 deletions rtw/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,11 +100,10 @@ def _setup_logging(verbose: bool = False, quiet: bool = False) -> None:
def _known_airport_codes() -> list[str]:
"""Return a list of known airport codes for fuzzy matching."""
try:
import airportsdata
from rtw.airports import airports_db

db = airportsdata.load("IATA")
return list(db.keys())
except Exception:
return list(airports_db.keys())
except SystemExit:
return []


Expand Down Expand Up @@ -259,6 +258,11 @@ def cost(
except typer.BadParameter:
raise
except Exception as exc:
from rtw.cost import FareLookupError

if isinstance(exc, FareLookupError):
_error_panel(str(exc))
raise typer.Exit(code=2)
_error_panel(str(exc))
raise typer.Exit(code=2)

Expand Down
8 changes: 1 addition & 7 deletions rtw/continents.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,7 @@

import yaml

try:
import airportsdata

_airports_db = airportsdata.load("IATA")
except Exception:
_airports_db = {}

from rtw.airports import airports_db as _airports_db
from rtw.models import Continent, TariffConference, CONTINENT_TO_TC

_DATA_DIR = Path(__file__).parent / "data"
Expand Down
57 changes: 14 additions & 43 deletions rtw/cost.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,54 +13,20 @@

import yaml

from rtw.airports import airports_db
from rtw.models import CostEstimate, Itinerary, TicketType

_DATA_DIR = Path(__file__).parent / "data"

# Major US airport codes for AA domestic zero-YQ rule

class FareLookupError(Exception):
"""Raised when a fare lookup returns $0 (missing data)."""

pass

# US airport codes for AA domestic zero-YQ rule (dynamic from airportsdata)
_US_AIRPORTS = {
"JFK",
"EWR",
"LGA",
"LAX",
"SFO",
"ORD",
"DFW",
"MIA",
"ATL",
"SEA",
"BOS",
"DEN",
"PHX",
"MCO",
"IAD",
"IAH",
"CLT",
"PHL",
"SAN",
"AUS",
"MSP",
"DTW",
"SLC",
"HNL",
"OGG",
"TPA",
"FLL",
"BWI",
"DCA",
"STL",
"PDX",
"BNA",
"RDU",
"CLE",
"PIT",
"IND",
"MCI",
"OAK",
"SJC",
"SMF",
"ABQ",
"ANC",
code for code, info in airports_db.items() if info.get("country") == "US"
}


Expand Down Expand Up @@ -177,6 +143,11 @@ def estimate_total(self, itinerary: Itinerary, plating_carrier: str = "AA") -> C
passengers = itinerary.ticket.passengers

base_fare = self.get_base_fare(origin, ticket_type)
if base_fare == 0.0:
raise FareLookupError(
f"No fare data for origin={origin} ticket_type={ticket_type.value}. "
f"Check rtw/data/fares.yaml."
)
total_yq = self.estimate_surcharges(itinerary, plating_carrier)
per_person = base_fare + total_yq
total_all = per_person * passengers
Expand Down
14 changes: 12 additions & 2 deletions rtw/data/carriers.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -141,15 +141,25 @@ UL:
rtw_booking_class: D
notes: "D class = 25% of distance for NTP."

WY:
name: Oman Air
alliance: oneworld
eligible: true
ntp_method: distance
yq_tier: low
yq_estimate_per_segment: 90
rtw_booking_class: D
notes: "Joined oneworld as full member June 2025. Distance-based NTP. D class = 12.5% (lowest in alliance for business)."

S7:
name: S7 Airlines
alliance: oneworld
eligible: true
eligible: false
ntp_method: distance
yq_tier: low
yq_estimate_per_segment: 40
rtw_booking_class: D
notes: "Russian carrier. Limited RTW utility due to sanctions."
notes: "Russian carrier. Suspended from oneworld Explorer due to EU/US/UK sanctions on Russian aviation. Technically still a oneworld member but flights cannot be ticketed on RTW itineraries."

# Ineligible carriers (for reference)
LA:
Expand Down
37 changes: 37 additions & 0 deletions rtw/data/fares.yaml
Original file line number Diff line number Diff line change
@@ -1,13 +1,22 @@
# oneworld Explorer base fares by origin city (approximate USD)
# Based on FlyerTalk data and fare filings as of 2025-2026
# Note: Fares fluctuate with currency rates and filing changes
#
# AONE (first class) fares: estimated at 1.6x DONE multiplier,
# rounded to nearest $100. Actual filed fares vary by origin currency
# and are not publicly published per-origin. The 1.6x ratio is derived
# from ex-UK GBP filings where AONE/DONE ratios range 1.4-1.8x.

origins:
CAI:
name: Cairo
currency: EGP
notes: "Historically cheapest. EGP devaluation advantage."
fares:
AONE3: 5600
AONE4: 6400
AONE5: 7000
AONE6: 8800
DONE3: 3500
DONE4: 4000
DONE5: 4400
Expand All @@ -22,6 +31,10 @@ origins:
currency: EUR
notes: "Norway filing advantage. Easy positioning from London."
fares:
AONE3: 7700
AONE4: 8600
AONE5: 9300
AONE6: 10400
DONE3: 4800
DONE4: 5400
DONE5: 5800
Expand All @@ -36,6 +49,10 @@ origins:
currency: ZAR
notes: "ZAR weakness. Good if Africa is on route."
fares:
AONE3: 6400
AONE4: 8000
AONE5: 9100
AONE6: 10700
DONE3: 4000
DONE4: 5000
DONE5: 5700
Expand All @@ -50,6 +67,10 @@ origins:
currency: JPY
notes: "Higher taxes/YQ than CAI/OSL."
fares:
AONE3: 8800
AONE4: 10200
AONE5: 11600
AONE6: 13600
DONE3: 5500
DONE4: 6360
DONE5: 7260
Expand All @@ -64,6 +85,10 @@ origins:
currency: LKR
notes: "SriLankan Airlines is oneworld. Occasionally cheap."
fares:
AONE3: 7200
AONE4: 8300
AONE5: 9600
AONE6: 11200
DONE3: 4500
DONE4: 5200
DONE5: 6000
Expand All @@ -78,6 +103,10 @@ origins:
currency: GBP
notes: "High UK departure taxes on premium long-haul."
fares:
AONE3: 11200
AONE4: 12800
AONE5: 14400
AONE6: 16800
DONE3: 7000
DONE4: 8000
DONE5: 9000
Expand All @@ -92,6 +121,10 @@ origins:
currency: USD
notes: "Most expensive origin. Consider positioning to CAI/OSL."
fares:
AONE3: 14400
AONE4: 16800
AONE5: 19200
AONE6: 22600
DONE3: 9000
DONE4: 10500
DONE5: 12000
Expand All @@ -106,6 +139,10 @@ origins:
currency: AUD
notes: "Ex-Japan is roughly half the price for same itinerary."
fares:
AONE3: 12000
AONE4: 14100
AONE5: 16000
AONE6: 19200
DONE3: 7500
DONE4: 8800
DONE5: 10000
Expand Down
17 changes: 17 additions & 0 deletions rtw/data/ntp_rates.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,23 @@ distance_based:
S: 2
V: 2

WY:
D: 12.5
J: 12.5
C: 12.5
I: 6
Z: 6
Y: 3.5
B: 3.5
H: 3.5
K: 2
L: 2
M: 2
N: 2
Q: 2
S: 2
V: 2

# BA bonus NTP (per segment, BA-marketed flights only)
# Permanent from 25 Nov 2025
ba_bonus:
Expand Down
9 changes: 2 additions & 7 deletions rtw/distance.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,9 @@
"""Great-circle distance between airports using IATA codes."""

try:
import airportsdata

_airports_db = airportsdata.load("IATA")
except Exception:
_airports_db = {}

from haversine import haversine, Unit

from rtw.airports import airports_db as _airports_db


class DistanceCalculator:
"""Calculate great-circle distances between airports."""
Expand Down
8 changes: 1 addition & 7 deletions rtw/search/query.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,10 @@
from datetime import date
from typing import Optional

from rtw.airports import airports_db as _airports_db
from rtw.models import CabinClass, TicketType
from rtw.search.models import SearchQuery

try:
import airportsdata

_airports_db = airportsdata.load("IATA")
except Exception:
_airports_db = {}


def _fuzzy_suggestion(code: str) -> str:
"""Suggest close airport codes."""
Expand Down
12 changes: 12 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,15 @@ def too_many_segments_itinerary(load_yaml):
def minimal_valid_itinerary(load_yaml):
"""Load the minimal valid routing."""
return load_yaml("minimal_valid.yaml")


@pytest.fixture
def done3_itinerary(load_yaml):
"""Load the DONE3 business 3-continent routing."""
return load_yaml("done3_cai_eastbound.yaml")


@pytest.fixture
def lone3_itinerary(load_yaml):
"""Load the LONE3 economy 3-continent routing."""
return load_yaml("lone3_osl_westbound.yaml")
38 changes: 38 additions & 0 deletions tests/fixtures/done3_cai_eastbound.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# DONE3 - Business Class, 3 continents ex-Cairo (Eastbound)
# Covers: EU/ME, Asia, SWP — minimum continent count
# Tests: DONE3 fare, 3-continent routing, business class
ticket:
type: DONE3
cabin: business
origin: CAI
passengers: 1

segments:
- from: CAI
to: AMM
carrier: RJ
type: stopover
- from: AMM
to: DOH
carrier: QR
type: transit
- from: DOH
to: NRT
carrier: QR
type: stopover
- from: NRT
to: SYD
carrier: JL
type: stopover
- from: SYD
to: SIN
carrier: QF
type: stopover
- from: SIN
to: DOH
carrier: QR
type: transit
- from: DOH
to: CAI
carrier: QR
type: final
Loading
Loading