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.
@@ -0,0 +1 @@
1
+ """Interaction layer: questions as data (DESIGN 10)."""
@@ -0,0 +1,120 @@
1
+ """Assumptions as data, not prompts (DESIGN §10).
2
+
3
+ Some inputs cannot be known in advance and should not be silently defaulted: is
4
+ this ESTA still valid, is a 4-hour self-transfer acceptable today, is a night in
5
+ Santorini worth having. The solver emits `Assumption` objects; a front end decides
6
+ how to ask them — a CLI prompt, an MCP round-trip through Claude, or nothing at all
7
+ in an unattended run, where the default is used and *recorded as assumed*.
8
+
9
+ Every option carries its pros and cons in plain language, because that is what
10
+ makes a question answerable by a human at the moment of deciding — and the same
11
+ text is what the report prints next to the plan the answer produced.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass, field
17
+ from enum import StrEnum
18
+
19
+
20
+ class Source(StrEnum):
21
+ CONFIG = "config"
22
+ """Came from the owner's config: a settled preference."""
23
+
24
+ ASKED = "asked"
25
+ """A human answered this, in this session."""
26
+
27
+ ASSUMED = "assumed"
28
+ """Nobody was there to ask; the default was used and is reported as such."""
29
+
30
+
31
+ @dataclass(frozen=True, slots=True)
32
+ class Option:
33
+ label: str
34
+ value: object
35
+ pros: str = ""
36
+ cons: str = ""
37
+
38
+ def describe(self) -> str:
39
+ bits = [self.label]
40
+ if self.pros:
41
+ bits.append(f"+ {self.pros}")
42
+ if self.cons:
43
+ bits.append(f"- {self.cons}")
44
+ return " ".join(bits)
45
+
46
+
47
+ @dataclass(slots=True)
48
+ class Assumption:
49
+ key: str
50
+ question: str
51
+ options: list[Option]
52
+ default: object
53
+ source: Source = Source.ASSUMED
54
+ blocking: bool = False
55
+ """Blocking questions can make a plan infeasible or unbookable — held
56
+ authorizations, unknown transit regimes, trip mode, airport-change consent.
57
+ Non-blocking ones are comfort and money trade-offs the default handles."""
58
+ affects: list[str] = field(default_factory=list)
59
+ """Plan ids whose cost depends on the answer."""
60
+ answer: object | None = None
61
+ remember: bool = False
62
+ """Whether the answer should be promoted into config (`--remember`)."""
63
+
64
+ @property
65
+ def value(self) -> object:
66
+ return self.default if self.answer is None else self.answer
67
+
68
+ @property
69
+ def answered(self) -> bool:
70
+ return self.answer is not None
71
+
72
+ def describe(self) -> str:
73
+ head = f"[{self.source}] {self.question}"
74
+ body = "\n".join(f" {o.describe()}" for o in self.options)
75
+ return f"{head}\n{body}" if body else head
76
+
77
+
78
+ @dataclass(slots=True)
79
+ class AssumptionLog:
80
+ """Everything the system had to decide without being told, in one place.
81
+
82
+ The report prints this verbatim: a plan whose ranking rests on an assumed
83
+ answer must say so, or the number looks more solid than it is.
84
+ """
85
+
86
+ entries: list[Assumption] = field(default_factory=list)
87
+
88
+ def add(self, assumption: Assumption) -> Assumption:
89
+ existing = self.get(assumption.key)
90
+ if existing is not None:
91
+ return existing
92
+ self.entries.append(assumption)
93
+ return assumption
94
+
95
+ def get(self, key: str) -> Assumption | None:
96
+ for entry in self.entries:
97
+ if entry.key == key:
98
+ return entry
99
+ return None
100
+
101
+ def answer(self, key: str, value: object, *, remember: bool = False) -> None:
102
+ entry = self.get(key)
103
+ if entry is None:
104
+ raise KeyError(f"no assumption {key!r} to answer")
105
+ entry.answer = value
106
+ entry.source = Source.ASKED
107
+ entry.remember = remember
108
+
109
+ def value_of(self, key: str, fallback: object = None) -> object:
110
+ entry = self.get(key)
111
+ return fallback if entry is None else entry.value
112
+
113
+ @property
114
+ def blocking_unanswered(self) -> list[Assumption]:
115
+ return [e for e in self.entries if e.blocking and not e.answered]
116
+
117
+ def describe(self) -> str:
118
+ if not self.entries:
119
+ return "no assumptions — everything came from config or the itinerary"
120
+ return "\n".join(e.describe() for e in self.entries)
layover/models.py ADDED
@@ -0,0 +1,363 @@
1
+ """Canonical models (DESIGN §5).
2
+
3
+ Everything an oracle returns is normalized into these before the solver or the
4
+ cost model sees it. The models deliberately carry *protection structure* — how
5
+ many tickets, who owns the connection risk, whether bags are through-checked —
6
+ because none of that can be reconstructed later from a price and a duration.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import itertools
12
+ from datetime import datetime, timedelta
13
+ from enum import StrEnum
14
+
15
+ from pydantic import BaseModel, Field, model_validator
16
+
17
+ from layover.money import Money
18
+
19
+
20
+ class TransportMode(StrEnum):
21
+ AIR = "air"
22
+ RAIL = "rail"
23
+ BUS = "bus"
24
+ FERRY = "ferry"
25
+
26
+
27
+ class Cabin(StrEnum):
28
+ ECONOMY = "economy"
29
+ PREMIUM = "premium"
30
+ BUSINESS = "business"
31
+
32
+
33
+ class BaggageProfile(StrEnum):
34
+ PERSONAL_ITEM_ONLY = "personal_item_only"
35
+ CABIN_BAG = "cabin_bag"
36
+ CABIN_PLUS_CHECKED = "cabin_plus_checked"
37
+
38
+
39
+ class Protection(StrEnum):
40
+ """Who carries the risk if a connection inside one offer fails."""
41
+
42
+ SINGLE_TICKET = "single_ticket"
43
+ """One PNR: the airline rebooks you. Its own MCT applies."""
44
+
45
+ GUARANTEED_SELF_TRANSFER = "guaranteed_self_transfer"
46
+ """Separate tickets with an aggregator guarantee (Kiwi). Covered, but by the
47
+ aggregator rather than the airline — the middle risk rate applies."""
48
+
49
+ SELF_TRANSFER = "self_transfer"
50
+ """Separate tickets, nobody's problem but yours."""
51
+
52
+
53
+ class TripShape(StrEnum):
54
+ ONE_WAY = "one_way"
55
+ RETURN = "return"
56
+ OPEN_JAW = "open_jaw"
57
+ MULTI_CITY = "multi_city"
58
+
59
+
60
+ class TriState(StrEnum):
61
+ YES = "yes"
62
+ NO = "no"
63
+ UNKNOWN = "unknown"
64
+
65
+
66
+ class Segment(BaseModel):
67
+ """One flight (or ground leg, later)."""
68
+
69
+ carrier: str
70
+ flight_number: str
71
+ origin: str = Field(min_length=3, max_length=4)
72
+ destination: str = Field(min_length=3, max_length=4)
73
+ depart: datetime
74
+ arrive: datetime
75
+ mode: TransportMode = TransportMode.AIR
76
+ cabin: Cabin = Cabin.ECONOMY
77
+ equipment: str | None = None
78
+ last_departure_of_day: bool | None = None
79
+ """Whether this is the day's final departure on the route. Missing it means an
80
+ overnight and a lost day, not a rebooking — so adapters must report it when
81
+ they can (ORACLES §3)."""
82
+
83
+ @model_validator(mode="after")
84
+ def _check_times(self) -> Segment:
85
+ if self.depart.tzinfo is None or self.arrive.tzinfo is None:
86
+ raise ValueError("segment times must be timezone-aware")
87
+ if self.arrive <= self.depart:
88
+ raise ValueError(f"{self.flight_number}: arrive {self.arrive} not after depart")
89
+ return self
90
+
91
+ @property
92
+ def duration(self) -> timedelta:
93
+ return self.arrive - self.depart
94
+
95
+ @property
96
+ def local_departure_hour(self) -> int:
97
+ return self.depart.hour
98
+
99
+ def __str__(self) -> str:
100
+ return (
101
+ f"{self.carrier}{self.flight_number} {self.origin}-{self.destination} "
102
+ f"{self.depart:%Y-%m-%d %H:%M}"
103
+ )
104
+
105
+
106
+ class Leg(BaseModel):
107
+ """Ordered segments sold as one bound (e.g. the outbound of a return)."""
108
+
109
+ segments: list[Segment] = Field(min_length=1)
110
+
111
+ @property
112
+ def origin(self) -> str:
113
+ return self.segments[0].origin
114
+
115
+ @property
116
+ def destination(self) -> str:
117
+ return self.segments[-1].destination
118
+
119
+ @property
120
+ def depart(self) -> datetime:
121
+ return self.segments[0].depart
122
+
123
+ @property
124
+ def arrive(self) -> datetime:
125
+ return self.segments[-1].arrive
126
+
127
+ @property
128
+ def elapsed(self) -> timedelta:
129
+ return self.arrive - self.depart
130
+
131
+
132
+ class POSContext(BaseModel):
133
+ """Point of sale a price was observed under (DESIGN §9)."""
134
+
135
+ site_market: str | None = None
136
+ currency: str = "EUR"
137
+ locale: str | None = None
138
+ ip_country: str | None = None
139
+ """None = native IP."""
140
+
141
+ @property
142
+ def name(self) -> str:
143
+ return self.site_market or self.ip_country or "native"
144
+
145
+
146
+ class BaggageIncluded(BaseModel):
147
+ personal_item: TriState = TriState.UNKNOWN
148
+ cabin_bag: TriState = TriState.UNKNOWN
149
+ checked: TriState = TriState.UNKNOWN
150
+
151
+
152
+ class OfferKind(StrEnum):
153
+ THROUGH = "through"
154
+ LEG_PROBE = "leg_probe"
155
+ OW_PROBE = "ow_probe"
156
+ POS_PROBE = "pos_probe"
157
+
158
+
159
+ class Offer(BaseModel):
160
+ """One purchasable ticket."""
161
+
162
+ legs: list[Leg] = Field(min_length=1)
163
+ price: Money
164
+ price_normalized: float | None = None
165
+ """None means no reference rate was available — the offer is uncomparable and
166
+ excluded from ranking rather than converted at a guessed rate (DESIGN §9)."""
167
+ protection: Protection = Protection.SINGLE_TICKET
168
+ baggage_included: BaggageIncluded = Field(default_factory=BaggageIncluded)
169
+ pos: POSContext = Field(default_factory=POSContext)
170
+ oracle_id: str = "unknown"
171
+ observed_at: datetime | None = None
172
+ deep_link: str | None = None
173
+ fare_notes: list[str] = Field(default_factory=list)
174
+ offer_kind: OfferKind = OfferKind.THROUGH
175
+
176
+ @property
177
+ def segments(self) -> list[Segment]:
178
+ return [s for leg in self.legs for s in leg.segments]
179
+
180
+ @property
181
+ def identity_key(self) -> tuple:
182
+ """Same key from two oracles means the same product (DESIGN §5)."""
183
+ return (
184
+ tuple(
185
+ (s.carrier, s.flight_number, s.depart.date().isoformat()) for s in self.segments
186
+ ),
187
+ self.segments[0].cabin,
188
+ self.pos.name,
189
+ )
190
+
191
+
192
+ class Connection(BaseModel):
193
+ """A gap between two consecutive segments of a plan.
194
+
195
+ Whether this is a 50-minute non-event or the thing that wrecks the trip
196
+ depends on who owns it, which is why the flags live here rather than being
197
+ inferred at pricing time.
198
+ """
199
+
200
+ arrive_segment: Segment
201
+ depart_segment: Segment
202
+ protection: Protection
203
+ within_offer: bool
204
+ """True when both segments are on the same ticket."""
205
+
206
+ @property
207
+ def gap(self) -> timedelta:
208
+ return self.depart_segment.depart - self.arrive_segment.arrive
209
+
210
+ @property
211
+ def gap_minutes(self) -> float:
212
+ return self.gap.total_seconds() / 60
213
+
214
+ @property
215
+ def at_airport(self) -> str:
216
+ return self.arrive_segment.destination
217
+
218
+ @property
219
+ def airport_change(self) -> bool:
220
+ return self.arrive_segment.destination != self.depart_segment.origin
221
+
222
+ @property
223
+ def is_protected(self) -> bool:
224
+ return self.protection is Protection.SINGLE_TICKET
225
+
226
+ @property
227
+ def onward_is_last_of_day(self) -> bool:
228
+ return bool(self.depart_segment.last_departure_of_day)
229
+
230
+ def __str__(self) -> str:
231
+ where = (
232
+ f"{self.arrive_segment.destination}->{self.depart_segment.origin}"
233
+ if self.airport_change
234
+ else self.at_airport
235
+ )
236
+ return f"{where} {self.gap_minutes / 60:.1f}h ({self.protection})"
237
+
238
+
239
+ class TripPlan(BaseModel):
240
+ """An ordered composition of 1..n offers covering the intent.
241
+
242
+ Cost fields are not stored on the model: pricing is a pure function of a plan
243
+ plus a context (preferences, party, reference data), so the same plan can be
244
+ priced under two trip modes without mutating anything.
245
+ """
246
+
247
+ offers: list[Offer] = Field(min_length=1)
248
+ plan_id: str = "plan"
249
+ label: str = ""
250
+ """How this plan was arrived at — "2 one-ways" and so on.
251
+
252
+ A probe's answer is only useful if the report can say which shape of purchase it
253
+ is: two itineraries with the same fare are not the same offer when one of them is
254
+ two bookings (DESIGN §6)."""
255
+
256
+ @property
257
+ def segments(self) -> list[Segment]:
258
+ return [s for offer in self.offers for s in offer.segments]
259
+
260
+ @property
261
+ def money_out_of_pocket(self) -> float | None:
262
+ """Sum of normalized fares, or None if any offer could not be normalized."""
263
+ total = 0.0
264
+ for offer in self.offers:
265
+ if offer.price_normalized is None:
266
+ return None
267
+ total += offer.price_normalized
268
+ return total
269
+
270
+ @property
271
+ def depart(self) -> datetime:
272
+ return self.segments[0].depart
273
+
274
+ @property
275
+ def arrive(self) -> datetime:
276
+ return self.segments[-1].arrive
277
+
278
+ def connections(self) -> list[Connection]:
279
+ """All gaps in the plan, tagged with who owns the risk.
280
+
281
+ Connections inside one offer inherit that offer's protection. Connections
282
+ *between* offers are bare self-transfers by construction: two tickets
283
+ bought separately have no relationship at all.
284
+ """
285
+ out: list[Connection] = []
286
+ for offer in self.offers:
287
+ segs = offer.segments
288
+ for a, b in itertools.pairwise(segs):
289
+ out.append(
290
+ Connection(
291
+ arrive_segment=a,
292
+ depart_segment=b,
293
+ protection=offer.protection,
294
+ within_offer=True,
295
+ )
296
+ )
297
+ for prev, nxt in itertools.pairwise(self.offers):
298
+ out.append(
299
+ Connection(
300
+ arrive_segment=prev.segments[-1],
301
+ depart_segment=nxt.segments[0],
302
+ protection=Protection.SELF_TRANSFER,
303
+ within_offer=False,
304
+ )
305
+ )
306
+ out.sort(key=lambda c: c.arrive_segment.arrive)
307
+ return out
308
+
309
+ def stays(self) -> list[Stay]:
310
+ """Gaps long enough to be a stay somewhere rather than a connection.
311
+
312
+ Whether a stay is a wanted stopover or an involuntary layover is not a
313
+ property of the itinerary — it depends on what the intent asked for, so
314
+ that judgement belongs to the cost model (DESIGN §8.3), not here.
315
+ """
316
+ return [
317
+ Stay(
318
+ place=c.at_airport,
319
+ arrive=c.arrive_segment.arrive,
320
+ depart=c.depart_segment.depart,
321
+ )
322
+ for c in self.connections()
323
+ ]
324
+
325
+
326
+ class Stay(BaseModel):
327
+ """Time on the ground between two flights, at one place."""
328
+
329
+ place: str
330
+ arrive: datetime
331
+ depart: datetime
332
+
333
+ @property
334
+ def hours(self) -> float:
335
+ return (self.depart - self.arrive).total_seconds() / 3600
336
+
337
+ @property
338
+ def nights(self) -> int:
339
+ """Calendar nights, which is what hotels and stopover rules count."""
340
+ return max(0, (self.depart.date() - self.arrive.date()).days)
341
+
342
+
343
+ class RiskKind(StrEnum):
344
+ SELF_TRANSFER = "self_transfer"
345
+ SEPARATE_PNR = "separate_pnr"
346
+ THIRD_PARTY_GUARANTEE = "third_party_guarantee"
347
+ AIRPORT_CHANGE = "airport_change"
348
+ OVERNIGHT_SELF_TRANSFER = "overnight_self_transfer"
349
+ ENTRY_EXIT_TRANSIT = "entry_exit_transit"
350
+ LCC_BAG_GAUGE = "lcc_bag_gauge"
351
+ SKIPLAG = "skiplag"
352
+ AUTHORIZATION_PENDING = "authorization_pending"
353
+ UNKNOWN_TRANSIT_REGIME = "unknown_transit_regime"
354
+ VISA_CHECK = "visa_check"
355
+
356
+
357
+ class RiskAnnotation(BaseModel):
358
+ kind: RiskKind
359
+ detail: str
360
+ traveler_id: str | None = None
361
+
362
+
363
+ TripPlan.model_rebuild()
layover/money.py ADDED
@@ -0,0 +1,112 @@
1
+ """Money and cost bands.
2
+
3
+ Every computed cost in layover is a *band*, not a number: ancillary fees are
4
+ ranges, preference terms the user never set are ranges, and a delay prior is an
5
+ expectation. Ranking uses the midpoint; the report shows the interval; two plans
6
+ whose intervals overlap are "too close to call" rather than ordered (DESIGN §8.8).
7
+
8
+ Values are floats in the session's normalization currency. Cents are not the
9
+ point — the fee tables and priors feeding this are accurate to a few euros at
10
+ best, and pretending otherwise with Decimal would be false precision. Actual
11
+ observed fares keep their own currency in `Money` until normalized.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass
17
+
18
+ from pydantic import BaseModel, Field
19
+
20
+
21
+ class Money(BaseModel):
22
+ """An observed price in its original currency, exactly as the oracle said it."""
23
+
24
+ amount: float
25
+ currency: str = Field(min_length=3, max_length=3)
26
+
27
+ def __str__(self) -> str:
28
+ return f"{self.amount:,.2f} {self.currency}"
29
+
30
+
31
+ @dataclass(frozen=True, slots=True)
32
+ class Band:
33
+ """A cost interval in the normalization currency: low <= mid <= high."""
34
+
35
+ low: float
36
+ mid: float
37
+ high: float
38
+
39
+ def __post_init__(self) -> None:
40
+ if not (self.low <= self.mid <= self.high):
41
+ raise ValueError(f"band must satisfy low <= mid <= high, got {self!r}")
42
+
43
+ # ── constructors ──────────────────────────────────────────────────────────
44
+
45
+ @classmethod
46
+ def exact(cls, value: float) -> Band:
47
+ """A cost we know."""
48
+ return cls(value, value, value)
49
+
50
+ @classmethod
51
+ def between(cls, low: float, high: float, *, mid: float | None = None) -> Band:
52
+ """A cost we only know within a range — e.g. an unpublished bag fee."""
53
+ if high < low:
54
+ raise ValueError(f"high {high} below low {low}")
55
+ return cls(low, mid if mid is not None else (low + high) / 2, high)
56
+
57
+ @classmethod
58
+ def zero(cls) -> Band:
59
+ return cls(0.0, 0.0, 0.0)
60
+
61
+ # ── interval arithmetic ───────────────────────────────────────────────────
62
+
63
+ def __add__(self, other: Band | float) -> Band:
64
+ if isinstance(other, int | float):
65
+ return Band(self.low + other, self.mid + other, self.high + other)
66
+ return Band(self.low + other.low, self.mid + other.mid, self.high + other.high)
67
+
68
+ __radd__ = __add__
69
+
70
+ def __mul__(self, factor: float) -> Band:
71
+ if factor < 0:
72
+ # Negative scaling flips the interval; nothing in the cost model needs
73
+ # it, and silently returning an inverted band would corrupt comparisons.
74
+ raise ValueError("bands scale by non-negative factors only")
75
+ return Band(self.low * factor, self.mid * factor, self.high * factor)
76
+
77
+ __rmul__ = __mul__
78
+
79
+ def __neg__(self) -> Band:
80
+ """Negation for credits (stopover value offsetting lodging)."""
81
+ return Band(-self.high, -self.mid, -self.low)
82
+
83
+ def __sub__(self, other: Band | float) -> Band:
84
+ if isinstance(other, int | float):
85
+ return self + (-other)
86
+ return self + (-other)
87
+
88
+ # ── comparison ────────────────────────────────────────────────────────────
89
+
90
+ @property
91
+ def is_exact(self) -> bool:
92
+ return self.low == self.high
93
+
94
+ @property
95
+ def width(self) -> float:
96
+ return self.high - self.low
97
+
98
+ def overlaps(self, other: Band) -> bool:
99
+ """True when the two costs cannot be honestly ordered."""
100
+ return self.low <= other.high and other.low <= self.high
101
+
102
+ def __str__(self) -> str:
103
+ if self.is_exact:
104
+ return f"{self.mid:,.0f}"
105
+ return f"{self.low:,.0f}..{self.high:,.0f}"
106
+
107
+
108
+ def band_sum(bands: list[Band]) -> Band:
109
+ total = Band.zero()
110
+ for b in bands:
111
+ total = total + b
112
+ return total
@@ -0,0 +1 @@
1
+ """Oracle abstraction: capability descriptors, typed failures, scheduling."""
@@ -0,0 +1 @@
1
+ """API oracles: HTTP sources that need no browser."""