farepy 0.1.1__tar.gz
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.
- farepy-0.1.1/PKG-INFO +22 -0
- farepy-0.1.1/farepy/__init__.py +13 -0
- farepy-0.1.1/farepy/base.py +110 -0
- farepy-0.1.1/farepy/batch.py +140 -0
- farepy-0.1.1/farepy/cache.py +148 -0
- farepy-0.1.1/farepy/core.py +227 -0
- farepy-0.1.1/farepy/sources/__init__.py +32 -0
- farepy-0.1.1/farepy/sources/google_flights_source.py +215 -0
- farepy-0.1.1/farepy/sources/kayak_source.py +312 -0
- farepy-0.1.1/farepy/sources/ryanair_source.py +119 -0
- farepy-0.1.1/farepy/util.py +112 -0
- farepy-0.1.1/farepy.egg-info/PKG-INFO +22 -0
- farepy-0.1.1/farepy.egg-info/SOURCES.txt +16 -0
- farepy-0.1.1/farepy.egg-info/dependency_links.txt +1 -0
- farepy-0.1.1/farepy.egg-info/requires.txt +17 -0
- farepy-0.1.1/farepy.egg-info/top_level.txt +2 -0
- farepy-0.1.1/pyproject.toml +35 -0
- farepy-0.1.1/setup.cfg +4 -0
farepy-0.1.1/PKG-INFO
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: farepy
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Multi-source flight search with normalized results, caching, and batch support
|
|
5
|
+
Author: Thor Whalen
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/thorwhalen/farepy
|
|
8
|
+
Project-URL: Repository, https://github.com/thorwhalen/farepy
|
|
9
|
+
Project-URL: Issues, https://github.com/thorwhalen/farepy/issues
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Provides-Extra: google-flights
|
|
12
|
+
Requires-Dist: fast-flights; extra == "google-flights"
|
|
13
|
+
Provides-Extra: ryanair
|
|
14
|
+
Requires-Dist: ryanair-py; extra == "ryanair"
|
|
15
|
+
Provides-Extra: kayak
|
|
16
|
+
Requires-Dist: playwright; extra == "kayak"
|
|
17
|
+
Provides-Extra: all
|
|
18
|
+
Requires-Dist: fast-flights; extra == "all"
|
|
19
|
+
Requires-Dist: ryanair-py; extra == "all"
|
|
20
|
+
Requires-Dist: playwright; extra == "all"
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: pytest; extra == "dev"
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""farepy -- Multi-source flight search with normalized results, caching, and batch support."""
|
|
2
|
+
|
|
3
|
+
from farepy.core import search_flights as search_flights
|
|
4
|
+
from farepy.batch import (
|
|
5
|
+
batch_search as batch_search,
|
|
6
|
+
batch_from_file as batch_from_file,
|
|
7
|
+
)
|
|
8
|
+
from farepy.sources import available_sources as available_sources
|
|
9
|
+
from farepy.cache import (
|
|
10
|
+
list_cached_searches as list_cached_searches,
|
|
11
|
+
get_cached_result as get_cached_result,
|
|
12
|
+
clear_cache as clear_cache,
|
|
13
|
+
)
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Normalized data model for multi-source flight search."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Protocol
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class Segment:
|
|
9
|
+
"""A single flight leg (one takeoff-to-landing)."""
|
|
10
|
+
|
|
11
|
+
departure_airport: str # IATA code, e.g. "MRS"
|
|
12
|
+
arrival_airport: str # IATA code, e.g. "REK"
|
|
13
|
+
departure_time: str # ISO 8601: "2026-04-18T06:30:00"
|
|
14
|
+
arrival_time: str # ISO 8601: "2026-04-18T10:45:00"
|
|
15
|
+
carrier: str # Airline IATA code, e.g. "FI"
|
|
16
|
+
carrier_name: str | None = None # Full name if available
|
|
17
|
+
flight_number: str | None = None # e.g. "FI543"
|
|
18
|
+
duration_minutes: int | None = None
|
|
19
|
+
aircraft: str | None = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class Itinerary:
|
|
24
|
+
"""One direction of travel (outbound or return)."""
|
|
25
|
+
|
|
26
|
+
segments: list[Segment]
|
|
27
|
+
duration_minutes: int | None = None # Total itinerary duration
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def departure_time(self) -> str:
|
|
31
|
+
return self.segments[0].departure_time
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def arrival_time(self) -> str:
|
|
35
|
+
return self.segments[-1].arrival_time
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def departure_airport(self) -> str:
|
|
39
|
+
return self.segments[0].departure_airport
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def arrival_airport(self) -> str:
|
|
43
|
+
return self.segments[-1].arrival_airport
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def stops(self) -> int:
|
|
47
|
+
return len(self.segments) - 1
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class FlightOffer:
|
|
52
|
+
"""A single bookable flight option, normalized across all sources."""
|
|
53
|
+
|
|
54
|
+
source: str # "google_flights" | "ryanair" | "kayak"
|
|
55
|
+
outbound: Itinerary
|
|
56
|
+
price: float
|
|
57
|
+
currency: str
|
|
58
|
+
airlines: list[str] # Unique carrier codes across all segments
|
|
59
|
+
inbound: Itinerary | None = None # None for one-way
|
|
60
|
+
booking_url: str | None = None
|
|
61
|
+
raw: dict | None = field(default=None, repr=False)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class SearchRequest:
|
|
66
|
+
"""Normalized search parameters."""
|
|
67
|
+
|
|
68
|
+
origin: str # IATA code
|
|
69
|
+
destination: str # IATA code
|
|
70
|
+
departure_date: str # YYYY-MM-DD
|
|
71
|
+
return_date: str | None = None
|
|
72
|
+
adults: int = 1
|
|
73
|
+
currency: str = "EUR"
|
|
74
|
+
max_results: int = 50
|
|
75
|
+
non_stop: bool | None = None
|
|
76
|
+
# Time range filters (HH:MM format)
|
|
77
|
+
outbound_departure_after: str | None = None
|
|
78
|
+
outbound_departure_before: str | None = None
|
|
79
|
+
outbound_arrival_after: str | None = None
|
|
80
|
+
outbound_arrival_before: str | None = None
|
|
81
|
+
inbound_departure_after: str | None = None
|
|
82
|
+
inbound_departure_before: str | None = None
|
|
83
|
+
inbound_arrival_after: str | None = None
|
|
84
|
+
inbound_arrival_before: str | None = None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass
|
|
88
|
+
class SearchResult:
|
|
89
|
+
"""Container for search results with metadata."""
|
|
90
|
+
|
|
91
|
+
request: SearchRequest
|
|
92
|
+
offers: list[FlightOffer]
|
|
93
|
+
sources_queried: list[str]
|
|
94
|
+
sources_failed: dict[str, str] # source -> error message
|
|
95
|
+
searched_at: str # ISO 8601 timestamp
|
|
96
|
+
cached: bool = False
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class FlightSource(Protocol):
|
|
100
|
+
"""Protocol that all source adapters implement."""
|
|
101
|
+
|
|
102
|
+
name: str
|
|
103
|
+
|
|
104
|
+
def search(self, request: SearchRequest) -> list[FlightOffer]:
|
|
105
|
+
"""Search for flights. Returns empty list on no results."""
|
|
106
|
+
...
|
|
107
|
+
|
|
108
|
+
def is_available(self) -> tuple[bool, str]:
|
|
109
|
+
"""Check availability. Returns (available, message)."""
|
|
110
|
+
...
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Batch flight search -- cartesian combinations and file parsing."""
|
|
2
|
+
|
|
3
|
+
from itertools import product
|
|
4
|
+
|
|
5
|
+
from farepy.core import search_flights
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def batch_search(
|
|
9
|
+
legs: list[str],
|
|
10
|
+
departure_dates: list[str],
|
|
11
|
+
*,
|
|
12
|
+
return_dates: list[str] | None = None,
|
|
13
|
+
sources: list[str] | None = None,
|
|
14
|
+
currency: str = "EUR",
|
|
15
|
+
adults: int = 1,
|
|
16
|
+
non_stop: bool | None = None,
|
|
17
|
+
max_results: int = 50,
|
|
18
|
+
use_cache: bool = True,
|
|
19
|
+
cache_dir: str | None = None,
|
|
20
|
+
cache_ttl_hours: float = 24,
|
|
21
|
+
) -> list[dict]:
|
|
22
|
+
"""Run a cartesian product of legs x departure_dates x return_dates.
|
|
23
|
+
|
|
24
|
+
Each combination is searched sequentially to respect API rate limits.
|
|
25
|
+
Caching ensures repeated combinations are not re-queried.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
legs: List of IATA pairs, e.g. ["MRS-REK", "CDG-KEF"]
|
|
29
|
+
departure_dates: List of YYYY-MM-DD dates
|
|
30
|
+
return_dates: List of YYYY-MM-DD dates (None for one-way)
|
|
31
|
+
sources: Which sources to query (None = all available)
|
|
32
|
+
currency: ISO 4217 currency code
|
|
33
|
+
adults: Number of adult travelers
|
|
34
|
+
non_stop: Direct flights only
|
|
35
|
+
max_results: Max results per source per search
|
|
36
|
+
use_cache: Use cached results
|
|
37
|
+
cache_dir: Override cache directory
|
|
38
|
+
cache_ttl_hours: Cache TTL
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
List of SearchResult dicts, one per combination.
|
|
42
|
+
"""
|
|
43
|
+
ret_dates = return_dates or [None]
|
|
44
|
+
combos = list(product(legs, departure_dates, ret_dates))
|
|
45
|
+
|
|
46
|
+
results = []
|
|
47
|
+
for leg, dep_date, ret_date in combos:
|
|
48
|
+
result = search_flights(
|
|
49
|
+
leg=leg,
|
|
50
|
+
departure_date=dep_date,
|
|
51
|
+
return_date=ret_date,
|
|
52
|
+
sources=sources,
|
|
53
|
+
currency=currency,
|
|
54
|
+
adults=adults,
|
|
55
|
+
non_stop=non_stop,
|
|
56
|
+
max_results=max_results,
|
|
57
|
+
use_cache=use_cache,
|
|
58
|
+
cache_dir=cache_dir,
|
|
59
|
+
cache_ttl_hours=cache_ttl_hours,
|
|
60
|
+
)
|
|
61
|
+
results.append(result)
|
|
62
|
+
|
|
63
|
+
return results
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def parse_batch_file(file_content: str) -> list[dict]:
|
|
67
|
+
"""Parse a batch file into search parameter dicts.
|
|
68
|
+
|
|
69
|
+
Format: one search per line
|
|
70
|
+
ORIGIN-DEST YYYY-MM-DD [YYYY-MM-DD]
|
|
71
|
+
|
|
72
|
+
Examples:
|
|
73
|
+
MRS-REK 2026-04-18
|
|
74
|
+
MRS-REK 2026-04-18 2026-04-30
|
|
75
|
+
CDG-KEF 2026-05-01 2026-05-15
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
List of dicts with keys: leg, departure_date, return_date
|
|
79
|
+
"""
|
|
80
|
+
searches = []
|
|
81
|
+
for line_num, line in enumerate(file_content.strip().splitlines(), 1):
|
|
82
|
+
line = line.strip()
|
|
83
|
+
if not line or line.startswith("#"):
|
|
84
|
+
continue
|
|
85
|
+
parts = line.split()
|
|
86
|
+
if len(parts) < 2:
|
|
87
|
+
raise ValueError(
|
|
88
|
+
f'Line {line_num}: expected "LEG DATE [RETURN_DATE]", got: {line!r}'
|
|
89
|
+
)
|
|
90
|
+
searches.append(
|
|
91
|
+
{
|
|
92
|
+
"leg": parts[0],
|
|
93
|
+
"departure_date": parts[1],
|
|
94
|
+
"return_date": parts[2] if len(parts) > 2 else None,
|
|
95
|
+
}
|
|
96
|
+
)
|
|
97
|
+
return searches
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def batch_from_file(
|
|
101
|
+
file_content: str,
|
|
102
|
+
*,
|
|
103
|
+
sources: list[str] | None = None,
|
|
104
|
+
currency: str = "EUR",
|
|
105
|
+
adults: int = 1,
|
|
106
|
+
non_stop: bool | None = None,
|
|
107
|
+
max_results: int = 50,
|
|
108
|
+
use_cache: bool = True,
|
|
109
|
+
cache_dir: str | None = None,
|
|
110
|
+
cache_ttl_hours: float = 24,
|
|
111
|
+
) -> list[dict]:
|
|
112
|
+
"""Parse a batch file and run all searches.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
file_content: The text content of the batch file.
|
|
116
|
+
(other args same as batch_search)
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
List of SearchResult dicts.
|
|
120
|
+
"""
|
|
121
|
+
searches = parse_batch_file(file_content)
|
|
122
|
+
|
|
123
|
+
results = []
|
|
124
|
+
for s in searches:
|
|
125
|
+
result = search_flights(
|
|
126
|
+
leg=s["leg"],
|
|
127
|
+
departure_date=s["departure_date"],
|
|
128
|
+
return_date=s["return_date"],
|
|
129
|
+
sources=sources,
|
|
130
|
+
currency=currency,
|
|
131
|
+
adults=adults,
|
|
132
|
+
non_stop=non_stop,
|
|
133
|
+
max_results=max_results,
|
|
134
|
+
use_cache=use_cache,
|
|
135
|
+
cache_dir=cache_dir,
|
|
136
|
+
cache_ttl_hours=cache_ttl_hours,
|
|
137
|
+
)
|
|
138
|
+
results.append(result)
|
|
139
|
+
|
|
140
|
+
return results
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""JSON file-based caching for flight search results."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
from dataclasses import asdict
|
|
6
|
+
from datetime import datetime, timedelta
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from farepy.base import SearchRequest, SearchResult
|
|
10
|
+
|
|
11
|
+
DEFAULT_CACHE_DIR = Path.home() / ".local" / "share" / "farepy" / "cache"
|
|
12
|
+
DEFAULT_TTL_HOURS = 24
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _cache_dir(cache_dir: str | None = None) -> Path:
|
|
16
|
+
d = Path(cache_dir) if cache_dir else DEFAULT_CACHE_DIR
|
|
17
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
18
|
+
return d
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def cache_key(request: SearchRequest, sources: list[str]) -> str:
|
|
22
|
+
"""Generate a deterministic cache key from search parameters."""
|
|
23
|
+
key_data = {
|
|
24
|
+
"origin": request.origin,
|
|
25
|
+
"destination": request.destination,
|
|
26
|
+
"departure_date": request.departure_date,
|
|
27
|
+
"return_date": request.return_date,
|
|
28
|
+
"currency": request.currency,
|
|
29
|
+
"adults": request.adults,
|
|
30
|
+
"non_stop": request.non_stop,
|
|
31
|
+
"sources": sorted(sources),
|
|
32
|
+
}
|
|
33
|
+
return hashlib.sha256(json.dumps(key_data, sort_keys=True).encode()).hexdigest()[
|
|
34
|
+
:12
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _cache_filename(request: SearchRequest, sources: list[str]) -> str:
|
|
39
|
+
h = cache_key(request, sources)
|
|
40
|
+
ret = f"_{request.return_date}" if request.return_date else ""
|
|
41
|
+
return (
|
|
42
|
+
f"{request.departure_date}_{request.origin}_{request.destination}{ret}_{h}.json"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def get_cached(
|
|
47
|
+
request: SearchRequest,
|
|
48
|
+
sources: list[str],
|
|
49
|
+
*,
|
|
50
|
+
cache_dir: str | None = None,
|
|
51
|
+
ttl_hours: float = DEFAULT_TTL_HOURS,
|
|
52
|
+
) -> dict | None:
|
|
53
|
+
"""Return cached result dict if fresh, else None."""
|
|
54
|
+
d = _cache_dir(cache_dir)
|
|
55
|
+
fname = _cache_filename(request, sources)
|
|
56
|
+
path = d / fname
|
|
57
|
+
|
|
58
|
+
if not path.exists():
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
data = json.loads(path.read_text())
|
|
63
|
+
except (json.JSONDecodeError, OSError):
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
# Check TTL
|
|
67
|
+
searched_at = data.get("searched_at", "")
|
|
68
|
+
if searched_at:
|
|
69
|
+
try:
|
|
70
|
+
ts = datetime.fromisoformat(searched_at.replace("Z", "+00:00"))
|
|
71
|
+
if datetime.now(ts.tzinfo) - ts > timedelta(hours=ttl_hours):
|
|
72
|
+
return None # Expired
|
|
73
|
+
except (ValueError, TypeError):
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
data["cached"] = True
|
|
77
|
+
return data
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def put_cache(
|
|
81
|
+
result: SearchResult,
|
|
82
|
+
sources: list[str],
|
|
83
|
+
*,
|
|
84
|
+
cache_dir: str | None = None,
|
|
85
|
+
) -> str:
|
|
86
|
+
"""Cache a SearchResult. Returns the cache filename."""
|
|
87
|
+
d = _cache_dir(cache_dir)
|
|
88
|
+
fname = _cache_filename(result.request, sources)
|
|
89
|
+
path = d / fname
|
|
90
|
+
|
|
91
|
+
data = asdict(result)
|
|
92
|
+
# Strip raw source data from cache to save space
|
|
93
|
+
for offer in data.get("offers", []):
|
|
94
|
+
offer.pop("raw", None)
|
|
95
|
+
|
|
96
|
+
path.write_text(json.dumps(data, indent=2, default=str))
|
|
97
|
+
return fname
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def list_cached_searches(*, cache_dir: str | None = None) -> list[dict]:
|
|
101
|
+
"""Return summaries of all cached results."""
|
|
102
|
+
d = _cache_dir(cache_dir)
|
|
103
|
+
results = []
|
|
104
|
+
|
|
105
|
+
for path in sorted(d.glob("*.json"), reverse=True):
|
|
106
|
+
try:
|
|
107
|
+
data = json.loads(path.read_text())
|
|
108
|
+
req = data.get("request", {})
|
|
109
|
+
results.append(
|
|
110
|
+
{
|
|
111
|
+
"cache_id": path.stem,
|
|
112
|
+
"filename": path.name,
|
|
113
|
+
"origin": req.get("origin", ""),
|
|
114
|
+
"destination": req.get("destination", ""),
|
|
115
|
+
"departure_date": req.get("departure_date", ""),
|
|
116
|
+
"return_date": req.get("return_date"),
|
|
117
|
+
"num_offers": len(data.get("offers", [])),
|
|
118
|
+
"sources_queried": data.get("sources_queried", []),
|
|
119
|
+
"searched_at": data.get("searched_at", ""),
|
|
120
|
+
}
|
|
121
|
+
)
|
|
122
|
+
except (json.JSONDecodeError, OSError):
|
|
123
|
+
continue
|
|
124
|
+
|
|
125
|
+
return results
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def get_cached_result(cache_id: str, *, cache_dir: str | None = None) -> dict:
|
|
129
|
+
"""Get a specific cached result by its ID (filename stem)."""
|
|
130
|
+
d = _cache_dir(cache_dir)
|
|
131
|
+
path = d / f"{cache_id}.json"
|
|
132
|
+
|
|
133
|
+
if not path.exists():
|
|
134
|
+
raise FileNotFoundError(f"Cache entry not found: {cache_id}")
|
|
135
|
+
|
|
136
|
+
data = json.loads(path.read_text())
|
|
137
|
+
data["cached"] = True
|
|
138
|
+
return data
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def clear_cache(*, cache_dir: str | None = None) -> dict:
|
|
142
|
+
"""Clear all cached results. Returns count of files removed."""
|
|
143
|
+
d = _cache_dir(cache_dir)
|
|
144
|
+
count = 0
|
|
145
|
+
for path in d.glob("*.json"):
|
|
146
|
+
path.unlink()
|
|
147
|
+
count += 1
|
|
148
|
+
return {"cleared": count}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""Search orchestration -- the main entry point for flight searches."""
|
|
2
|
+
|
|
3
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
4
|
+
from dataclasses import asdict
|
|
5
|
+
|
|
6
|
+
from farepy.base import FlightOffer, SearchRequest, SearchResult
|
|
7
|
+
from farepy.cache import get_cached, put_cache
|
|
8
|
+
from farepy.sources import ALL_SOURCES, make_source
|
|
9
|
+
from farepy.util import extract_time, now_iso, parse_leg, time_in_range
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def search_flights(
|
|
13
|
+
leg: str,
|
|
14
|
+
departure_date: str,
|
|
15
|
+
*,
|
|
16
|
+
return_date: str | None = None,
|
|
17
|
+
sources: list[str] | None = None,
|
|
18
|
+
currency: str = "EUR",
|
|
19
|
+
adults: int = 1,
|
|
20
|
+
non_stop: bool | None = None,
|
|
21
|
+
max_results: int = 50,
|
|
22
|
+
outbound_departure_after: str | None = None,
|
|
23
|
+
outbound_departure_before: str | None = None,
|
|
24
|
+
outbound_arrival_after: str | None = None,
|
|
25
|
+
outbound_arrival_before: str | None = None,
|
|
26
|
+
inbound_departure_after: str | None = None,
|
|
27
|
+
inbound_departure_before: str | None = None,
|
|
28
|
+
inbound_arrival_after: str | None = None,
|
|
29
|
+
inbound_arrival_before: str | None = None,
|
|
30
|
+
use_cache: bool = True,
|
|
31
|
+
cache_dir: str | None = None,
|
|
32
|
+
cache_ttl_hours: float = 24,
|
|
33
|
+
) -> dict:
|
|
34
|
+
"""Search for flights across selected sources.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
leg: IATA pair like "MRS-REK"
|
|
38
|
+
departure_date: YYYY-MM-DD
|
|
39
|
+
return_date: YYYY-MM-DD or None for one-way
|
|
40
|
+
sources: List of source names (e.g. ["google_flights", "ryanair"]).
|
|
41
|
+
None means all available.
|
|
42
|
+
currency: ISO 4217 currency code
|
|
43
|
+
adults: Number of adult travelers
|
|
44
|
+
non_stop: If True, direct flights only
|
|
45
|
+
max_results: Max results per source
|
|
46
|
+
outbound_departure_after: HH:MM filter
|
|
47
|
+
outbound_departure_before: HH:MM filter
|
|
48
|
+
outbound_arrival_after: HH:MM filter
|
|
49
|
+
outbound_arrival_before: HH:MM filter
|
|
50
|
+
inbound_departure_after: HH:MM filter
|
|
51
|
+
inbound_departure_before: HH:MM filter
|
|
52
|
+
inbound_arrival_after: HH:MM filter
|
|
53
|
+
inbound_arrival_before: HH:MM filter
|
|
54
|
+
use_cache: Whether to use cached results
|
|
55
|
+
cache_dir: Override default cache directory
|
|
56
|
+
cache_ttl_hours: Cache time-to-live in hours
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
SearchResult as a dict.
|
|
60
|
+
"""
|
|
61
|
+
origin, destination = parse_leg(leg)
|
|
62
|
+
|
|
63
|
+
request = SearchRequest(
|
|
64
|
+
origin=origin,
|
|
65
|
+
destination=destination,
|
|
66
|
+
departure_date=departure_date,
|
|
67
|
+
return_date=return_date,
|
|
68
|
+
adults=adults,
|
|
69
|
+
currency=currency,
|
|
70
|
+
max_results=max_results,
|
|
71
|
+
non_stop=non_stop,
|
|
72
|
+
outbound_departure_after=outbound_departure_after,
|
|
73
|
+
outbound_departure_before=outbound_departure_before,
|
|
74
|
+
outbound_arrival_after=outbound_arrival_after,
|
|
75
|
+
outbound_arrival_before=outbound_arrival_before,
|
|
76
|
+
inbound_departure_after=inbound_departure_after,
|
|
77
|
+
inbound_departure_before=inbound_departure_before,
|
|
78
|
+
inbound_arrival_after=inbound_arrival_after,
|
|
79
|
+
inbound_arrival_before=inbound_arrival_before,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# Determine which sources to query
|
|
83
|
+
if sources is None:
|
|
84
|
+
sources_to_query = [
|
|
85
|
+
name for name in ALL_SOURCES if make_source(name).is_available()[0]
|
|
86
|
+
]
|
|
87
|
+
else:
|
|
88
|
+
sources_to_query = [s for s in sources if s in ALL_SOURCES]
|
|
89
|
+
|
|
90
|
+
if not sources_to_query:
|
|
91
|
+
return asdict(
|
|
92
|
+
SearchResult(
|
|
93
|
+
request=request,
|
|
94
|
+
offers=[],
|
|
95
|
+
sources_queried=[],
|
|
96
|
+
sources_failed={
|
|
97
|
+
"all": "No sources available. Install a source package: "
|
|
98
|
+
"pip install farepy[google_flights] or pip install farepy[ryanair]"
|
|
99
|
+
},
|
|
100
|
+
searched_at=now_iso(),
|
|
101
|
+
)
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
# Check cache (time filters are applied post-query, so cache key ignores them)
|
|
105
|
+
if use_cache:
|
|
106
|
+
cached = get_cached(
|
|
107
|
+
request,
|
|
108
|
+
sources_to_query,
|
|
109
|
+
cache_dir=cache_dir,
|
|
110
|
+
ttl_hours=cache_ttl_hours,
|
|
111
|
+
)
|
|
112
|
+
if cached is not None:
|
|
113
|
+
# Apply time filters to cached results
|
|
114
|
+
cached["offers"] = _apply_time_filters(cached.get("offers", []), request)
|
|
115
|
+
return cached
|
|
116
|
+
|
|
117
|
+
# Query sources in parallel
|
|
118
|
+
all_offers: list[FlightOffer] = []
|
|
119
|
+
sources_failed: dict[str, str] = {}
|
|
120
|
+
|
|
121
|
+
def _query_source(name: str) -> tuple[str, list[FlightOffer] | str]:
|
|
122
|
+
try:
|
|
123
|
+
source = make_source(name)
|
|
124
|
+
return name, source.search(request)
|
|
125
|
+
except Exception as e:
|
|
126
|
+
return name, str(e)
|
|
127
|
+
|
|
128
|
+
with ThreadPoolExecutor(max_workers=len(sources_to_query)) as executor:
|
|
129
|
+
futures = {
|
|
130
|
+
executor.submit(_query_source, name): name for name in sources_to_query
|
|
131
|
+
}
|
|
132
|
+
for future in as_completed(futures):
|
|
133
|
+
name, result = future.result()
|
|
134
|
+
if isinstance(result, str):
|
|
135
|
+
sources_failed[name] = result
|
|
136
|
+
else:
|
|
137
|
+
all_offers.extend(result)
|
|
138
|
+
|
|
139
|
+
# Sort by price
|
|
140
|
+
all_offers.sort(key=lambda o: o.price)
|
|
141
|
+
|
|
142
|
+
result = SearchResult(
|
|
143
|
+
request=request,
|
|
144
|
+
offers=all_offers,
|
|
145
|
+
sources_queried=sources_to_query,
|
|
146
|
+
sources_failed=sources_failed,
|
|
147
|
+
searched_at=now_iso(),
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
# Cache the unfiltered results
|
|
151
|
+
if use_cache:
|
|
152
|
+
put_cache(result, sources_to_query, cache_dir=cache_dir)
|
|
153
|
+
|
|
154
|
+
# Apply time filters to the result
|
|
155
|
+
result_dict = asdict(result)
|
|
156
|
+
result_dict["offers"] = _apply_time_filters(result_dict["offers"], request)
|
|
157
|
+
|
|
158
|
+
return result_dict
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _apply_time_filters(offers: list[dict], request: SearchRequest) -> list[dict]:
|
|
162
|
+
"""Filter offers by time ranges (post-query)."""
|
|
163
|
+
has_outbound_filter = any(
|
|
164
|
+
[
|
|
165
|
+
request.outbound_departure_after,
|
|
166
|
+
request.outbound_departure_before,
|
|
167
|
+
request.outbound_arrival_after,
|
|
168
|
+
request.outbound_arrival_before,
|
|
169
|
+
]
|
|
170
|
+
)
|
|
171
|
+
has_inbound_filter = any(
|
|
172
|
+
[
|
|
173
|
+
request.inbound_departure_after,
|
|
174
|
+
request.inbound_departure_before,
|
|
175
|
+
request.inbound_arrival_after,
|
|
176
|
+
request.inbound_arrival_before,
|
|
177
|
+
]
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
if not has_outbound_filter and not has_inbound_filter:
|
|
181
|
+
return offers
|
|
182
|
+
|
|
183
|
+
filtered = []
|
|
184
|
+
for offer in offers:
|
|
185
|
+
outbound = offer.get("outbound", {})
|
|
186
|
+
segments = outbound.get("segments", [])
|
|
187
|
+
|
|
188
|
+
if has_outbound_filter and segments:
|
|
189
|
+
dep_time = extract_time(segments[0].get("departure_time", ""))
|
|
190
|
+
arr_time = extract_time(segments[-1].get("arrival_time", ""))
|
|
191
|
+
|
|
192
|
+
if not time_in_range(
|
|
193
|
+
dep_time,
|
|
194
|
+
after=request.outbound_departure_after,
|
|
195
|
+
before=request.outbound_departure_before,
|
|
196
|
+
):
|
|
197
|
+
continue
|
|
198
|
+
if not time_in_range(
|
|
199
|
+
arr_time,
|
|
200
|
+
after=request.outbound_arrival_after,
|
|
201
|
+
before=request.outbound_arrival_before,
|
|
202
|
+
):
|
|
203
|
+
continue
|
|
204
|
+
|
|
205
|
+
inbound = offer.get("inbound")
|
|
206
|
+
if has_inbound_filter and inbound:
|
|
207
|
+
in_segments = inbound.get("segments", [])
|
|
208
|
+
if in_segments:
|
|
209
|
+
dep_time = extract_time(in_segments[0].get("departure_time", ""))
|
|
210
|
+
arr_time = extract_time(in_segments[-1].get("arrival_time", ""))
|
|
211
|
+
|
|
212
|
+
if not time_in_range(
|
|
213
|
+
dep_time,
|
|
214
|
+
after=request.inbound_departure_after,
|
|
215
|
+
before=request.inbound_departure_before,
|
|
216
|
+
):
|
|
217
|
+
continue
|
|
218
|
+
if not time_in_range(
|
|
219
|
+
arr_time,
|
|
220
|
+
after=request.inbound_arrival_after,
|
|
221
|
+
before=request.inbound_arrival_before,
|
|
222
|
+
):
|
|
223
|
+
continue
|
|
224
|
+
|
|
225
|
+
filtered.append(offer)
|
|
226
|
+
|
|
227
|
+
return filtered
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Flight data source registry."""
|
|
2
|
+
|
|
3
|
+
from farepy.sources.google_flights_source import GoogleFlightsSource
|
|
4
|
+
from farepy.sources.ryanair_source import RyanairSource
|
|
5
|
+
from farepy.sources.kayak_source import KayakSource
|
|
6
|
+
|
|
7
|
+
ALL_SOURCES = {
|
|
8
|
+
"google_flights": GoogleFlightsSource,
|
|
9
|
+
"ryanair": RyanairSource,
|
|
10
|
+
"kayak": KayakSource,
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def make_source(name: str, **kwargs):
|
|
15
|
+
"""Create a source instance by name, passing config kwargs."""
|
|
16
|
+
cls = ALL_SOURCES.get(name)
|
|
17
|
+
if cls is None:
|
|
18
|
+
raise ValueError(f"Unknown source: {name!r}. Available: {list(ALL_SOURCES)}")
|
|
19
|
+
return cls(**kwargs)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def available_sources(**kwargs) -> list[dict]:
|
|
23
|
+
"""Return status of all sources.
|
|
24
|
+
|
|
25
|
+
Each dict has: name, available (bool), message (str).
|
|
26
|
+
"""
|
|
27
|
+
result = []
|
|
28
|
+
for name, cls in ALL_SOURCES.items():
|
|
29
|
+
source = cls(**kwargs)
|
|
30
|
+
avail, msg = source.is_available()
|
|
31
|
+
result.append({"name": name, "available": avail, "message": msg})
|
|
32
|
+
return result
|