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 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