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/prefs/profile.py
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
"""Preference profile: the money-denominated valuations (DESIGN §8).
|
|
2
|
+
|
|
3
|
+
Every field here is a **price** — per hour, per night, per event — never a weight.
|
|
4
|
+
That is what keeps the objective a single comparable scalar and lets a report explain
|
|
5
|
+
a ranking in euros.
|
|
6
|
+
|
|
7
|
+
These are the *soft* half. Hard requirements ("be in Bogotá before the conference
|
|
8
|
+
opens") live in `cost/constraints.py`, are checked rather than priced, and carry the
|
|
9
|
+
stakes of missing them. There is deliberately **no trip type**: "business" turned out
|
|
10
|
+
to be a bundle of things that either belong to a constraint (a deadline, and how much
|
|
11
|
+
cushion its stakes deserve) or are plain numbers that may differ per leg.
|
|
12
|
+
|
|
13
|
+
Three families, kept apart because they answer different questions:
|
|
14
|
+
|
|
15
|
+
1. **Time** (`[time]`) — what an hour is worth, times how much of the hour you get
|
|
16
|
+
back. The factors here are *productivity* only: in-flight time is discounted
|
|
17
|
+
because you can sleep or work, lounge time because you can work.
|
|
18
|
+
2. **Discomfort** (`[discomfort]`) — what unpleasantness is worth, as fixed amounts
|
|
19
|
+
scaled by one `scale` knob. Deliberately **not** multiplied by the wage rate: how
|
|
20
|
+
much you mind a broken night or dragging bags across a city does not depend on what
|
|
21
|
+
your hour is worth. This is the comfort axis, and it is separate on purpose.
|
|
22
|
+
3. **Risk** — no longer configured as premiums. It is computed: the probability of
|
|
23
|
+
missing a connection times what missing it costs, including the stakes of any hard
|
|
24
|
+
constraint it would break.
|
|
25
|
+
|
|
26
|
+
Loading order (later wins, per key):
|
|
27
|
+
1. repo `config/preferences.toml` — shipped defaults
|
|
28
|
+
2. `~/.config/layover/preferences.toml` — the owner's values
|
|
29
|
+
3. per-intent or per-leg overrides — plain nested dicts
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import tomllib
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
from typing import Any
|
|
37
|
+
|
|
38
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Strict(BaseModel):
|
|
42
|
+
"""Base for every preference model: unknown keys are errors, not noise.
|
|
43
|
+
|
|
44
|
+
Pydantic ignores extra fields by default, which meant a mistyped or
|
|
45
|
+
wrongly-nested config key was silently dropped and the shipped default used
|
|
46
|
+
instead — a wrong ranking that looks right. Forbidding extras turns that into a
|
|
47
|
+
startup error naming the key.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
model_config = ConfigDict(extra="forbid")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class TimePrefs(Strict):
|
|
54
|
+
"""The price of time, and how much of each hour you actually get back."""
|
|
55
|
+
|
|
56
|
+
wage_rate_per_hour: float = 25.0
|
|
57
|
+
in_flight_factor: float = 0.6
|
|
58
|
+
"""You can sleep, read, work. Productivity, not comfort."""
|
|
59
|
+
layover_factor: float = 1.0
|
|
60
|
+
"""The reference unit: an hour you get nothing back from."""
|
|
61
|
+
ground_transfer_factor: float = 1.0
|
|
62
|
+
"""A train between airports is about as usable as a gate area."""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class DiscomfortPrefs(Strict):
|
|
66
|
+
"""What unpleasantness costs, independent of what an hour is worth.
|
|
67
|
+
|
|
68
|
+
Fixed amounts rather than time multipliers, because most of these are not
|
|
69
|
+
proportional to duration: a broken night is a broken night whether the flight is
|
|
70
|
+
two hours or twelve, and the stress of a 45-minute connection is not 45 minutes of
|
|
71
|
+
anything. Each is multiplied by `scale` — the single comfort knob: 0.5 if airports
|
|
72
|
+
genuinely don't bother you, 2.0 for an easy trip at almost any price.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
scale: float = 1.0
|
|
76
|
+
|
|
77
|
+
red_eye_per_flight: float = 25.0
|
|
78
|
+
"""Departing 23:00–05:00: a night you don't get back."""
|
|
79
|
+
red_eye_from_hour: int = 23
|
|
80
|
+
red_eye_to_hour: int = 5
|
|
81
|
+
|
|
82
|
+
tight_connection: float = 12.0
|
|
83
|
+
"""Running for a gate. Fixed, not per minute."""
|
|
84
|
+
tight_connection_minutes: int = 90
|
|
85
|
+
|
|
86
|
+
night_in_terminal: float = 90.0
|
|
87
|
+
"""Sleeping in an airport, per night."""
|
|
88
|
+
|
|
89
|
+
bag_recheck: float = 10.0
|
|
90
|
+
"""Claim, queue, re-drop."""
|
|
91
|
+
|
|
92
|
+
self_transfer: float = 25.0
|
|
93
|
+
"""Managing your own connection: watching the clock, nobody helping if it slips."""
|
|
94
|
+
separate_ticket: float = 30.0
|
|
95
|
+
"""Two contracts instead of one — rebooking is yours to do. This is the *hassle*
|
|
96
|
+
of it; the money risk is computed from probabilities, not set here."""
|
|
97
|
+
third_party_guarantee: float = 10.0
|
|
98
|
+
"""Aggregator-guaranteed self-transfer: covered, but you handle the claim."""
|
|
99
|
+
airport_change: float = 45.0
|
|
100
|
+
"""Crossing a city with luggage between two flights."""
|
|
101
|
+
border_entry_exit: float = 40.0
|
|
102
|
+
"""Immigration, customs and re-screening in the middle of a journey."""
|
|
103
|
+
lcc_bag_gauge: float = 10.0
|
|
104
|
+
"""The gate-side sizer lottery."""
|
|
105
|
+
|
|
106
|
+
def cost(self, amount: float) -> float:
|
|
107
|
+
"""Apply the comfort scale to one discomfort amount."""
|
|
108
|
+
return amount * self.scale
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class OvernightPrefs(Strict):
|
|
112
|
+
"""Where an overnight gap is spent: the terminal's misery is discomfort, the hotel
|
|
113
|
+
is real money."""
|
|
114
|
+
|
|
115
|
+
airport_hotel_cost: float = 90.0
|
|
116
|
+
hotel_transfer_hours: float = 1.5
|
|
117
|
+
window_from_hour: int = 22
|
|
118
|
+
window_to_hour: int = 7
|
|
119
|
+
min_night_hours: float = 5.0
|
|
120
|
+
"""Shorter than this is a late wait, not a lost night."""
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class LoungePrefs(Strict):
|
|
124
|
+
wait_factor: float = 0.35
|
|
125
|
+
"""A lounge gives most of the hour back — productivity, so it belongs with time."""
|
|
126
|
+
min_wait_to_use_h: float = 1.5
|
|
127
|
+
overage_price: float = 30.0
|
|
128
|
+
trips_per_year: int = 8
|
|
129
|
+
use: str = "if_worth_it"
|
|
130
|
+
"""if_worth_it | never. There is no "always": a lounge is taken when it pays for
|
|
131
|
+
itself, and its absence makes a plan slightly worse — never infeasible."""
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class StopoverPrefs(Strict):
|
|
135
|
+
"""What a stop is worth, as one signed number per place (§8.3).
|
|
136
|
+
|
|
137
|
+
Net, always: hotel minus whatever being there is worth. A hotel rate is guessable
|
|
138
|
+
and what a place is worth to *you* is not, and only the difference ever ranks
|
|
139
|
+
anything — so the model asks for the difference and nothing else.
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
candidate_min_hours: float = 10.0
|
|
143
|
+
"""Gaps at least this long are offered as stopovers (DESIGN §8.3). Below it the
|
|
144
|
+
question is noise: nobody takes a hotel for a six-hour connection."""
|
|
145
|
+
offer: bool = True
|
|
146
|
+
"""Whether unexpected long gaps are offered at all. Slack decides whether there is
|
|
147
|
+
anything to offer; this only silences the question for a run."""
|
|
148
|
+
net: dict[str, float] = Field(default_factory=dict)
|
|
149
|
+
"""Standing wishlist, place -> net cost of a stay there. **Negative means wanted.**
|
|
150
|
+
|
|
151
|
+
A routing through one of these is a wanted stop without asking — the owner
|
|
152
|
+
answered in advance. Zero is the honest default for a place never mentioned: a
|
|
153
|
+
wash, neither hunted for nor penalized."""
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class FlexibilityPrefs(Strict):
|
|
157
|
+
date_cost_at_tolerance: float = 60.0
|
|
158
|
+
"""What the edge of a date window costs when the intent does not price it itself.
|
|
159
|
+
|
|
160
|
+
The scale of the quadratic in §8.4: `cost = this × ((when − ideal) / tolerance)²`,
|
|
161
|
+
with the tolerance defaulting to the distance from the ideal to each bound. A trip
|
|
162
|
+
that cares unusually much (or little) about its dates overrides it per window;
|
|
163
|
+
this is the standing answer to "what is a day of slippage worth to me"."""
|
|
164
|
+
length_cost_at_tolerance: float = 40.0
|
|
165
|
+
"""The same, for windows counted in nights: trip length and stopover length.
|
|
166
|
+
|
|
167
|
+
Its own number because "a day later than I wanted to leave" and "a night longer
|
|
168
|
+
than I wanted to stay" are not the same regret — the second one you are at least
|
|
169
|
+
somewhere, which is why this is the smaller of the two."""
|
|
170
|
+
date_deviation_cost_per_day: float = 12.0
|
|
171
|
+
"""Only for windows written with the older flat per-day sugar."""
|
|
172
|
+
duration_deviation_cost_per_night: float = 10.0
|
|
173
|
+
"""Symmetric fallback, used when neither direction below is set."""
|
|
174
|
+
extra_day_away_cost: float | None = None
|
|
175
|
+
"""Each night longer than preferred: leave days, a second hotel, the dog.
|
|
176
|
+
|
|
177
|
+
This is the number a discovered stopover has to beat — leaving three days early
|
|
178
|
+
for Punta Cana costs three days away from home, and the value of being there must
|
|
179
|
+
cover them."""
|
|
180
|
+
missing_day_cost: float | None = None
|
|
181
|
+
"""Each night shorter than preferred: less trip than you wanted."""
|
|
182
|
+
time_of_day: dict[str, dict[str, float]] = Field(default_factory=dict)
|
|
183
|
+
|
|
184
|
+
def duration_cost(self, nights: int, preferred: int) -> float:
|
|
185
|
+
"""Price the gap between a trip's length and the length that was wanted.
|
|
186
|
+
|
|
187
|
+
Asymmetric on purpose: an extra day away and a day short of the trip you
|
|
188
|
+
planned are different kinds of loss.
|
|
189
|
+
"""
|
|
190
|
+
if nights == preferred:
|
|
191
|
+
return 0.0
|
|
192
|
+
if nights > preferred:
|
|
193
|
+
rate = (
|
|
194
|
+
self.extra_day_away_cost
|
|
195
|
+
if self.extra_day_away_cost is not None
|
|
196
|
+
else self.duration_deviation_cost_per_night
|
|
197
|
+
)
|
|
198
|
+
return (nights - preferred) * rate
|
|
199
|
+
rate = (
|
|
200
|
+
self.missing_day_cost
|
|
201
|
+
if self.missing_day_cost is not None
|
|
202
|
+
else self.duration_deviation_cost_per_night
|
|
203
|
+
)
|
|
204
|
+
return (preferred - nights) * rate
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class DelayPrefs(Strict):
|
|
208
|
+
misconnect_replacement_cost: float = 180.0
|
|
209
|
+
"""Fallback price of a replacement ticket when the onward leg's real price is not
|
|
210
|
+
known. This is the *money* consequence of a miss."""
|
|
211
|
+
prior_pseudo_observations: int = 10
|
|
212
|
+
"""Weight of the static prior when blending observed punctuality."""
|
|
213
|
+
recovery_hours: float = 6.0
|
|
214
|
+
"""How much later you realistically arrive after missing a connection."""
|
|
215
|
+
recovery_hours_overnight: float = 20.0
|
|
216
|
+
"""...and when the missed flight was the last departure of the day."""
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
class ConstraintPrefs(Strict):
|
|
220
|
+
"""What missing a hard requirement costs (DESIGN §8.1).
|
|
221
|
+
|
|
222
|
+
This is what "definitely arrive by 20:00" actually means. A schedule check alone
|
|
223
|
+
would pass any plan that *plans* to arrive at 14:00, however likely it is to slip
|
|
224
|
+
to tomorrow. Stakes make risky plans lose on cost, and make the composer demand
|
|
225
|
+
more cushion — which is the job the old business/holiday switch was doing badly.
|
|
226
|
+
"""
|
|
227
|
+
|
|
228
|
+
inconvenient: float = 150.0
|
|
229
|
+
serious: float = 800.0
|
|
230
|
+
unmissable: float = 3000.0
|
|
231
|
+
stake_buffer_max_multiplier: float = 1.5
|
|
232
|
+
"""Required buffers inflate up to this much as stakes rise (§8.9). At
|
|
233
|
+
`unmissable` this reproduces the old business-mode 1.5x — for the right reason."""
|
|
234
|
+
|
|
235
|
+
def cost_of(self, criticality: str) -> float:
|
|
236
|
+
value = getattr(self, criticality, None)
|
|
237
|
+
if value is None:
|
|
238
|
+
raise KeyError(f"unknown criticality {criticality!r}")
|
|
239
|
+
return float(value)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
class FeasibilityPrefs(Strict):
|
|
243
|
+
"""Physical minimums: whether a connection is *makeable*.
|
|
244
|
+
|
|
245
|
+
Whether it is *wise* is the objective's judgement, via expected cost — which is
|
|
246
|
+
why the old `allow_self_transfer` / `buffer_multiplier` knobs are gone.
|
|
247
|
+
"""
|
|
248
|
+
|
|
249
|
+
absolute_floor_h: float = 2.0
|
|
250
|
+
self_transfer_same_airport_h: float = 3.0
|
|
251
|
+
self_transfer_bag_recheck_h: float = 4.0
|
|
252
|
+
self_transfer_entry_required_h: float = 5.0
|
|
253
|
+
airport_change_h: float = 5.0
|
|
254
|
+
require_protected: bool = False
|
|
255
|
+
"""For a run where only single-ticket itineraries are acceptable at any price. A
|
|
256
|
+
blunt instrument, rarely needed now that risk is priced against stakes."""
|
|
257
|
+
protected_connection: str = "carrier_mct"
|
|
258
|
+
carrier_mct_default_minutes: float = 45.0
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
class GroundPrefs(Strict):
|
|
262
|
+
default_metro_transfer_cost: float = 25.0
|
|
263
|
+
default_metro_transfer_hours: float = 1.25
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
class VerificationPrefs(Strict):
|
|
267
|
+
shortlist_size: int = 5
|
|
268
|
+
changed_threshold_fraction: float = 0.01
|
|
269
|
+
changed_threshold_absolute: float = 5.0
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
class PreferenceProfile(Strict):
|
|
273
|
+
normalize_currency: str = "EUR"
|
|
274
|
+
label: str | None = None
|
|
275
|
+
"""Optional name for a saved set of overrides, for the report only. It carries no
|
|
276
|
+
meaning in the model — there is no trip type."""
|
|
277
|
+
time: TimePrefs = Field(default_factory=TimePrefs)
|
|
278
|
+
discomfort: DiscomfortPrefs = Field(default_factory=DiscomfortPrefs)
|
|
279
|
+
overnight: OvernightPrefs = Field(default_factory=OvernightPrefs)
|
|
280
|
+
lounge: LoungePrefs = Field(default_factory=LoungePrefs)
|
|
281
|
+
stopover: StopoverPrefs = Field(default_factory=StopoverPrefs)
|
|
282
|
+
flexibility: FlexibilityPrefs = Field(default_factory=FlexibilityPrefs)
|
|
283
|
+
delay: DelayPrefs = Field(default_factory=DelayPrefs)
|
|
284
|
+
constraints: ConstraintPrefs = Field(default_factory=ConstraintPrefs)
|
|
285
|
+
feasibility: FeasibilityPrefs = Field(default_factory=FeasibilityPrefs)
|
|
286
|
+
ground: GroundPrefs = Field(default_factory=GroundPrefs)
|
|
287
|
+
verification: VerificationPrefs = Field(default_factory=VerificationPrefs)
|
|
288
|
+
|
|
289
|
+
def with_overrides(self, overrides: dict[str, Any]) -> PreferenceProfile:
|
|
290
|
+
"""Return a copy with a nested dict merged in (per key, not per section).
|
|
291
|
+
|
|
292
|
+
This is also how a per-leg difference is expressed: a leg is not a "mode", it
|
|
293
|
+
is these numbers with a few of them changed.
|
|
294
|
+
"""
|
|
295
|
+
return PreferenceProfile.model_validate(_deep_merge(self.model_dump(), overrides))
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _deep_merge(base: dict[str, Any], overlay: dict[str, Any]) -> dict[str, Any]:
|
|
299
|
+
out = dict(base)
|
|
300
|
+
for key, value in overlay.items():
|
|
301
|
+
if isinstance(value, dict) and isinstance(out.get(key), dict):
|
|
302
|
+
out[key] = _deep_merge(out[key], value)
|
|
303
|
+
else:
|
|
304
|
+
out[key] = value
|
|
305
|
+
return out
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def load_preferences(
|
|
309
|
+
paths: list[Path],
|
|
310
|
+
*,
|
|
311
|
+
overrides: dict[str, Any] | None = None,
|
|
312
|
+
) -> PreferenceProfile:
|
|
313
|
+
"""Compose a profile from config files, then overrides.
|
|
314
|
+
|
|
315
|
+
Missing files are skipped: the shipped defaults are a complete configuration, so a
|
|
316
|
+
first run works before anything is customized.
|
|
317
|
+
"""
|
|
318
|
+
merged: dict[str, Any] = {}
|
|
319
|
+
for path in paths:
|
|
320
|
+
if path.is_file():
|
|
321
|
+
merged = _deep_merge(merged, tomllib.loads(path.read_text()))
|
|
322
|
+
if overrides:
|
|
323
|
+
merged = _deep_merge(merged, overrides)
|
|
324
|
+
return PreferenceProfile.model_validate(merged)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def default_config_paths(repo_config: Path | None = None) -> list[Path]:
|
|
328
|
+
"""Shipped defaults first, then the owner's overrides."""
|
|
329
|
+
paths: list[Path] = []
|
|
330
|
+
if repo_config is not None:
|
|
331
|
+
paths.append(repo_config / "preferences.toml")
|
|
332
|
+
paths.append(Path.home() / ".config" / "layover" / "preferences.toml")
|
|
333
|
+
return paths
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Travellers and parties (DESIGN §8.6, §13.9).
|
|
2
|
+
|
|
3
|
+
Identity and entitlements only, loaded from its own file — never the repo, never
|
|
4
|
+
mixed with valuations. No document numbers: nationality, expiry and authorization
|
|
5
|
+
validity are everything the cost model needs, and nothing here books anything.
|
|
6
|
+
|
|
7
|
+
A party is 1–2 adults on one itinerary. The combining rules matter more than they
|
|
8
|
+
look: time takes the party's *maximum* (a couple clears immigration together, at
|
|
9
|
+
the slower queue), money and applications are *per person*, and one traveller who
|
|
10
|
+
cannot be admitted makes the routing infeasible for everyone.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import tomllib
|
|
16
|
+
from datetime import date
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from pydantic import BaseModel, Field
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Passport(BaseModel):
|
|
23
|
+
nationality: str = Field(min_length=2, max_length=2)
|
|
24
|
+
expires: date | None = None
|
|
25
|
+
|
|
26
|
+
def valid_on(self, day: date) -> bool:
|
|
27
|
+
return self.expires is None or self.expires > day
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Authorization(BaseModel):
|
|
31
|
+
"""A travel authorization (ESTA, UK ETA, eTA, ETIAS, or a full visa).
|
|
32
|
+
|
|
33
|
+
Bound to the passport it was issued against, because that is how it works —
|
|
34
|
+
and because a spouse holding IT+BR can only ever hang a US authorization off
|
|
35
|
+
the IT passport.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
kind: str
|
|
39
|
+
passport: str = Field(min_length=2, max_length=2)
|
|
40
|
+
expires: date | None = None
|
|
41
|
+
|
|
42
|
+
def covers(self, nationality: str, day: date) -> bool:
|
|
43
|
+
return self.passport == nationality and (self.expires is None or self.expires > day)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class LoungeMembership(BaseModel):
|
|
47
|
+
program: str = "none"
|
|
48
|
+
annual_quota: int = 0
|
|
49
|
+
quota_used: int = 0
|
|
50
|
+
year_resets_on: date | None = None
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def quota_remaining(self) -> int:
|
|
54
|
+
return max(0, self.annual_quota - self.quota_used)
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def has_program(self) -> bool:
|
|
58
|
+
return self.program != "none" and self.annual_quota > 0
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class Traveler(BaseModel):
|
|
62
|
+
id: str
|
|
63
|
+
passports: list[Passport] = Field(default_factory=list)
|
|
64
|
+
held_authorizations: list[Authorization] = Field(default_factory=list)
|
|
65
|
+
lounge: LoungeMembership = Field(default_factory=LoungeMembership)
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def nationalities(self) -> list[str]:
|
|
69
|
+
return [p.nationality for p in self.passports]
|
|
70
|
+
|
|
71
|
+
def passport_valid_on(self, nationality: str, day: date) -> bool:
|
|
72
|
+
return any(p.nationality == nationality and p.valid_on(day) for p in self.passports)
|
|
73
|
+
|
|
74
|
+
def holds(self, kind: str, nationality: str, day: date) -> bool:
|
|
75
|
+
return any(
|
|
76
|
+
a.kind == kind and a.covers(nationality, day) for a in self.held_authorizations
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class Party(BaseModel):
|
|
81
|
+
"""The travellers on one search."""
|
|
82
|
+
|
|
83
|
+
travelers: list[Traveler] = Field(min_length=1)
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def size(self) -> int:
|
|
87
|
+
return len(self.travelers)
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def ids(self) -> list[str]:
|
|
91
|
+
return [t.id for t in self.travelers]
|
|
92
|
+
|
|
93
|
+
def get(self, traveler_id: str) -> Traveler:
|
|
94
|
+
for t in self.travelers:
|
|
95
|
+
if t.id == traveler_id:
|
|
96
|
+
return t
|
|
97
|
+
raise KeyError(f"no traveller {traveler_id!r} in party {self.ids}")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def load_travelers(path: Path) -> dict[str, Traveler]:
|
|
101
|
+
"""Load `travelers.toml`. Absent file = no travellers configured."""
|
|
102
|
+
if not path.is_file():
|
|
103
|
+
return {}
|
|
104
|
+
raw = tomllib.loads(path.read_text())
|
|
105
|
+
out: dict[str, Traveler] = {}
|
|
106
|
+
for traveler_id, body in (raw.get("traveler") or {}).items():
|
|
107
|
+
data = dict(body)
|
|
108
|
+
lounge = data.pop("lounge", None)
|
|
109
|
+
out[traveler_id] = Traveler.model_validate(
|
|
110
|
+
{
|
|
111
|
+
"id": traveler_id,
|
|
112
|
+
**data,
|
|
113
|
+
**({"lounge": lounge} if lounge else {}),
|
|
114
|
+
}
|
|
115
|
+
)
|
|
116
|
+
return out
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def default_travelers_path() -> Path:
|
|
120
|
+
return Path.home() / ".config" / "layover" / "travelers.toml"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def solo(traveler_id: str = "owner", **kwargs) -> Party:
|
|
124
|
+
"""A one-person party — the common case, and handy in tests."""
|
|
125
|
+
return Party(travelers=[Traveler(id=traveler_id, **kwargs)])
|