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