layover 0.1.0.dev0__py3-none-any.whl
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.
- layover/__init__.py +3 -0
- layover/cli.py +1137 -0
- layover/cost/__init__.py +1 -0
- layover/cost/borders.py +449 -0
- layover/cost/connection.py +173 -0
- layover/cost/constraints.py +213 -0
- layover/cost/curve.py +145 -0
- layover/cost/delay.py +173 -0
- layover/cost/generalized.py +1207 -0
- layover/cost/lounge.py +110 -0
- layover/currency.py +166 -0
- layover/intent.py +1016 -0
- layover/interact/__init__.py +1 -0
- layover/interact/assumptions.py +120 -0
- layover/models.py +363 -0
- layover/money.py +112 -0
- layover/oracles/__init__.py +1 -0
- layover/oracles/api/__init__.py +1 -0
- layover/oracles/api/azair.py +480 -0
- layover/oracles/api/gf_parse.py +344 -0
- layover/oracles/api/google_flights.py +322 -0
- layover/oracles/api/protobuf.py +110 -0
- layover/oracles/api/serpapi_flights.py +415 -0
- layover/oracles/base.py +224 -0
- layover/oracles/consent.py +199 -0
- layover/oracles/knowledge.py +208 -0
- layover/oracles/record.py +98 -0
- layover/oracles/transport.py +429 -0
- layover/prefs/__init__.py +1 -0
- layover/prefs/profile.py +333 -0
- layover/prefs/travelers.py +125 -0
- layover/session.py +328 -0
- layover/solver/__init__.py +1 -0
- layover/solver/budget.py +208 -0
- layover/solver/probes.py +105 -0
- layover/solver/search.py +166 -0
- layover-0.1.0.dev0.dist-info/METADATA +365 -0
- layover-0.1.0.dev0.dist-info/RECORD +41 -0
- layover-0.1.0.dev0.dist-info/WHEEL +4 -0
- layover-0.1.0.dev0.dist-info/entry_points.txt +2 -0
- layover-0.1.0.dev0.dist-info/licenses/LICENSE +28 -0
layover/cost/lounge.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Lounge access as a scarce budget (DESIGN §8.2).
|
|
2
|
+
|
|
3
|
+
The owner's programme is DragonPass with a free annual quota *per traveller*, so a
|
|
4
|
+
lounge visit is neither free nor priced at the walk-in rate: its marginal cost is
|
|
5
|
+
the chance that this is the visit you would have wanted later in the membership
|
|
6
|
+
year. That is a shadow price, and it is zero for most of the year.
|
|
7
|
+
|
|
8
|
+
Access only counts where the lounge is in the terminal the plan actually uses — a
|
|
9
|
+
DragonPass listing on the other side of an airport is not access.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
|
|
16
|
+
from layover.prefs.profile import LoungePrefs
|
|
17
|
+
from layover.prefs.travelers import Traveler
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True, slots=True)
|
|
21
|
+
class LoungeDecision:
|
|
22
|
+
"""What we assumed about a lounge, in terms a human can argue with."""
|
|
23
|
+
|
|
24
|
+
used: bool
|
|
25
|
+
cost: float
|
|
26
|
+
wait_factor: float
|
|
27
|
+
note: str
|
|
28
|
+
consumes_quota: bool = False
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
SCARCE_ENTRIES = 2
|
|
32
|
+
"""At or below this many free entries left, one is worth the full walk-in price."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def quota_shadow_price(traveler: Traveler, prefs: LoungePrefs) -> float:
|
|
36
|
+
"""What spending one free entry costs, given how many are left.
|
|
37
|
+
|
|
38
|
+
Zero while entries are plentiful, rising linearly to the walk-in price as the
|
|
39
|
+
quota empties. "Plentiful" is the smaller of half the annual quota and half a
|
|
40
|
+
year's trips: with 10 entries against ~8 trips a year, roughly 4 trips are
|
|
41
|
+
still ahead on average, so entries only start to feel scarce below 4.
|
|
42
|
+
|
|
43
|
+
A frequent flyer (high `trips_per_year`) therefore starts economising earlier,
|
|
44
|
+
which is the intended behaviour — the quota is the constraint, not the calendar.
|
|
45
|
+
"""
|
|
46
|
+
lounge = traveler.lounge
|
|
47
|
+
if not lounge.has_program or lounge.quota_remaining <= 0:
|
|
48
|
+
return prefs.overage_price
|
|
49
|
+
|
|
50
|
+
remaining = lounge.quota_remaining
|
|
51
|
+
ramp_start = max(SCARCE_ENTRIES, min(lounge.annual_quota / 2, prefs.trips_per_year / 2))
|
|
52
|
+
|
|
53
|
+
if remaining > ramp_start:
|
|
54
|
+
return 0.0
|
|
55
|
+
if remaining <= SCARCE_ENTRIES:
|
|
56
|
+
return prefs.overage_price
|
|
57
|
+
|
|
58
|
+
span = ramp_start - SCARCE_ENTRIES
|
|
59
|
+
fraction = (ramp_start - remaining) / span
|
|
60
|
+
return round(prefs.overage_price * fraction, 2)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def decide(
|
|
64
|
+
traveler: Traveler,
|
|
65
|
+
*,
|
|
66
|
+
wait_hours: float,
|
|
67
|
+
dead_time_cost: float,
|
|
68
|
+
has_lounge_here: bool,
|
|
69
|
+
prefs: LoungePrefs,
|
|
70
|
+
policy: str = "if_worth_it",
|
|
71
|
+
) -> LoungeDecision:
|
|
72
|
+
"""Decide, per traveller, whether this wait happens in a lounge.
|
|
73
|
+
|
|
74
|
+
`dead_time_cost` is what the wait costs without a lounge, so the comparison is
|
|
75
|
+
always "does paying beat waiting?" rather than an abstract preference.
|
|
76
|
+
"""
|
|
77
|
+
if not has_lounge_here:
|
|
78
|
+
return LoungeDecision(False, 0.0, 1.0, "no lounge access in this terminal")
|
|
79
|
+
if wait_hours < prefs.min_wait_to_use_h:
|
|
80
|
+
return LoungeDecision(
|
|
81
|
+
False, 0.0, 1.0, f"wait under {prefs.min_wait_to_use_h:g}h — not worth the walk"
|
|
82
|
+
)
|
|
83
|
+
if policy == "never":
|
|
84
|
+
return LoungeDecision(False, 0.0, 1.0, "lounge use disabled for this run")
|
|
85
|
+
|
|
86
|
+
discounted = dead_time_cost * prefs.wait_factor
|
|
87
|
+
remaining = traveler.lounge.quota_remaining
|
|
88
|
+
|
|
89
|
+
if remaining > 0:
|
|
90
|
+
price = quota_shadow_price(traveler, prefs)
|
|
91
|
+
if price + discounted < dead_time_cost:
|
|
92
|
+
note = (
|
|
93
|
+
f"free DragonPass entry ({remaining - 1} left after this)"
|
|
94
|
+
if price == 0
|
|
95
|
+
else f"free entry, shadow-priced {price:.0f} ({remaining - 1} left)"
|
|
96
|
+
)
|
|
97
|
+
return LoungeDecision(True, price, prefs.wait_factor, note, consumes_quota=True)
|
|
98
|
+
return LoungeDecision(False, 0.0, 1.0, "free entry not worth spending on this wait")
|
|
99
|
+
|
|
100
|
+
price = prefs.overage_price
|
|
101
|
+
if price + discounted < dead_time_cost:
|
|
102
|
+
return LoungeDecision(
|
|
103
|
+
True, price, prefs.wait_factor, f"paid entry {price:.0f} (annual quota used up)"
|
|
104
|
+
)
|
|
105
|
+
return LoungeDecision(
|
|
106
|
+
False,
|
|
107
|
+
0.0,
|
|
108
|
+
1.0,
|
|
109
|
+
f"paid entry {price:.0f} does not pay for itself on a {wait_hours:.1f}h wait",
|
|
110
|
+
)
|
layover/currency.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Currency normalization (DESIGN §9).
|
|
2
|
+
|
|
3
|
+
Every price is compared in one currency, converted at the ECB's daily reference
|
|
4
|
+
rates. The rule that matters is what happens when a rate is missing: **refuse**. ECB
|
|
5
|
+
does not publish every currency a point-of-sale probe can return, and a guessed
|
|
6
|
+
cross-rate is worse than an absent one — it produces a number that looks comparable
|
|
7
|
+
and is not. Such offers keep their original currency, get no normalized price, and
|
|
8
|
+
are listed separately as uncomparable.
|
|
9
|
+
|
|
10
|
+
Rates are cached on disk for the day. Nothing here is offer data, so the cache is
|
|
11
|
+
allowed to outlive a session.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import xml.etree.ElementTree as ET
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from datetime import UTC, date, datetime
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
ECB_DAILY_URL = "https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml"
|
|
23
|
+
_NS = {
|
|
24
|
+
"gesmes": "http://www.gesmes.org/xml/2002-08-01",
|
|
25
|
+
"ecb": "http://www.ecb.int/vocabulary/2002-08-01/eurofxref",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class MissingRate(LookupError):
|
|
30
|
+
"""No reference rate for this currency. Deliberately not recoverable by guessing."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True, slots=True)
|
|
34
|
+
class Rates:
|
|
35
|
+
"""EUR-based reference rates for one day."""
|
|
36
|
+
|
|
37
|
+
as_of: date
|
|
38
|
+
per_eur: dict[str, float] = field(default_factory=dict)
|
|
39
|
+
source: str = "ecb"
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
def from_xml(cls, text: str, *, source: str = "ecb") -> Rates:
|
|
43
|
+
root = ET.fromstring(text)
|
|
44
|
+
cube = root.find(".//ecb:Cube/ecb:Cube", _NS)
|
|
45
|
+
if cube is None:
|
|
46
|
+
raise ValueError("unrecognised ECB rate document")
|
|
47
|
+
rates = {"EUR": 1.0}
|
|
48
|
+
for entry in cube.findall("ecb:Cube", _NS):
|
|
49
|
+
currency = entry.get("currency")
|
|
50
|
+
rate = entry.get("rate")
|
|
51
|
+
if currency and rate:
|
|
52
|
+
rates[currency.upper()] = float(rate)
|
|
53
|
+
return cls(as_of=date.fromisoformat(cube.get("time")), per_eur=rates, source=source)
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def from_mapping(
|
|
57
|
+
cls, per_eur: dict[str, float], *, as_of: date | None = None, source: str = "manual"
|
|
58
|
+
) -> Rates:
|
|
59
|
+
rates = {"EUR": 1.0, **{k.upper(): float(v) for k, v in per_eur.items()}}
|
|
60
|
+
return cls(as_of=as_of or datetime.now(UTC).date(), per_eur=rates, source=source)
|
|
61
|
+
|
|
62
|
+
# ── conversion ───────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
def covers(self, currency: str) -> bool:
|
|
65
|
+
return currency.upper() in self.per_eur
|
|
66
|
+
|
|
67
|
+
def convert(self, amount: float, source: str, target: str) -> float:
|
|
68
|
+
"""Convert via the EUR pivot. Raises `MissingRate` rather than guessing."""
|
|
69
|
+
src, dst = source.upper(), target.upper()
|
|
70
|
+
if src == dst:
|
|
71
|
+
return amount
|
|
72
|
+
for currency in (src, dst):
|
|
73
|
+
if currency not in self.per_eur:
|
|
74
|
+
raise MissingRate(
|
|
75
|
+
f"no ECB reference rate for {currency} (as of {self.as_of}) — "
|
|
76
|
+
f"the offer stays in its own currency and is excluded from ranking"
|
|
77
|
+
)
|
|
78
|
+
return amount / self.per_eur[src] * self.per_eur[dst]
|
|
79
|
+
|
|
80
|
+
def try_convert(self, amount: float, source: str, target: str) -> float | None:
|
|
81
|
+
"""`None` where `convert` would refuse — the caller marks the offer uncomparable."""
|
|
82
|
+
try:
|
|
83
|
+
return self.convert(amount, source, target)
|
|
84
|
+
except MissingRate:
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def age_days(self) -> int:
|
|
89
|
+
return (datetime.now(UTC).date() - self.as_of).days
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def is_stale(self) -> bool:
|
|
93
|
+
"""ECB publishes on working days; more than a long weekend old is suspect."""
|
|
94
|
+
return self.age_days > 4
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class RateCache:
|
|
98
|
+
"""Daily rates on disk, fetched at most once a day."""
|
|
99
|
+
|
|
100
|
+
def __init__(self, path: Path) -> None:
|
|
101
|
+
self.path = Path(path)
|
|
102
|
+
|
|
103
|
+
def load(self) -> Rates | None:
|
|
104
|
+
if not self.path.is_file():
|
|
105
|
+
return None
|
|
106
|
+
try:
|
|
107
|
+
raw = json.loads(self.path.read_text())
|
|
108
|
+
return Rates(
|
|
109
|
+
as_of=date.fromisoformat(raw["as_of"]),
|
|
110
|
+
per_eur={k: float(v) for k, v in raw["per_eur"].items()},
|
|
111
|
+
source=raw.get("source", "cache"),
|
|
112
|
+
)
|
|
113
|
+
except (json.JSONDecodeError, KeyError, ValueError):
|
|
114
|
+
return None # a corrupt cache is a cache miss, not a crash
|
|
115
|
+
|
|
116
|
+
def save(self, rates: Rates) -> None:
|
|
117
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
118
|
+
self.path.write_text(
|
|
119
|
+
json.dumps(
|
|
120
|
+
{
|
|
121
|
+
"as_of": rates.as_of.isoformat(),
|
|
122
|
+
"per_eur": rates.per_eur,
|
|
123
|
+
"source": rates.source,
|
|
124
|
+
"cached_at": datetime.now(UTC).isoformat(timespec="seconds"),
|
|
125
|
+
},
|
|
126
|
+
indent=2,
|
|
127
|
+
sort_keys=True,
|
|
128
|
+
)
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
def fetch(self, *, timeout: float = 10.0) -> Rates:
|
|
132
|
+
"""Download today's rates. The only network call in this module."""
|
|
133
|
+
import httpx
|
|
134
|
+
|
|
135
|
+
response = httpx.get(ECB_DAILY_URL, timeout=timeout)
|
|
136
|
+
response.raise_for_status()
|
|
137
|
+
rates = Rates.from_xml(response.text)
|
|
138
|
+
self.save(rates)
|
|
139
|
+
return rates
|
|
140
|
+
|
|
141
|
+
def get(self, *, allow_fetch: bool = True) -> Rates | None:
|
|
142
|
+
"""Cached rates if they are current, otherwise fetch — or `None` offline.
|
|
143
|
+
|
|
144
|
+
Returning stale rates beats failing a search: the report says how old they
|
|
145
|
+
are, and a day-old EUR/THB rate does not change a ranking.
|
|
146
|
+
"""
|
|
147
|
+
cached = self.load()
|
|
148
|
+
if cached is not None and cached.age_days <= 1:
|
|
149
|
+
return cached
|
|
150
|
+
if not allow_fetch:
|
|
151
|
+
return cached
|
|
152
|
+
try:
|
|
153
|
+
return self.fetch()
|
|
154
|
+
except Exception:
|
|
155
|
+
return cached
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def default_cache_path() -> Path:
|
|
159
|
+
return Path.home() / ".local" / "state" / "layover" / "ecb-rates.json"
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def normalize(amount: float, currency: str, target: str, rates: Rates | None) -> float | None:
|
|
163
|
+
"""Convenience for the oracle layer: `None` means uncomparable, never guessed."""
|
|
164
|
+
if rates is None:
|
|
165
|
+
return None
|
|
166
|
+
return rates.try_convert(amount, currency, target)
|