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/oracles/base.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""The oracle contract (ORACLES §1).
|
|
2
|
+
|
|
3
|
+
Every source — an API, a scraped site, a punctuality lookup — implements this and
|
|
4
|
+
publishes a capability descriptor. The solver plans against *capabilities*, never
|
|
5
|
+
against site names, so that losing an oracle degrades a search rather than breaking
|
|
6
|
+
it.
|
|
7
|
+
|
|
8
|
+
Failures are typed, because "no offers" is ambiguous and the ambiguity is dangerous:
|
|
9
|
+
an empty market and a silently broken parser look identical from the outside, and
|
|
10
|
+
only one of them means "stop querying".
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import hashlib
|
|
16
|
+
import json
|
|
17
|
+
from abc import ABC, abstractmethod
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from datetime import date
|
|
20
|
+
from enum import StrEnum
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from layover.models import Cabin, Offer, POSContext, TripShape
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class FailureKind(StrEnum):
|
|
27
|
+
BLOCKED = "blocked"
|
|
28
|
+
"""403/429, a bot wall, a login redirect. Hard signal: back off."""
|
|
29
|
+
CAPTCHA = "captcha"
|
|
30
|
+
"""Never auto-solved. Screenshot, cool down, route around (ORACLES §2)."""
|
|
31
|
+
PARSE_ERROR = "parse_error"
|
|
32
|
+
"""The response arrived and made no sense — usually a layout change."""
|
|
33
|
+
EMPTY_ANOMALY = "empty_anomaly"
|
|
34
|
+
"""Zero offers where offers were expected. Soft signal: possible soft block."""
|
|
35
|
+
EMPTY_MARKET = "empty_market"
|
|
36
|
+
"""Zero offers, believed genuine. Not a failure of the adapter."""
|
|
37
|
+
TIMEOUT = "timeout"
|
|
38
|
+
RATE_LIMITED = "rate_limited"
|
|
39
|
+
"""Our own token bucket refused — never the site's problem."""
|
|
40
|
+
UNSUPPORTED = "unsupported"
|
|
41
|
+
"""The oracle cannot express this query at all."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True, slots=True)
|
|
45
|
+
class OracleCapabilities:
|
|
46
|
+
"""What an oracle can be asked, and what asking costs."""
|
|
47
|
+
|
|
48
|
+
trip_shapes: frozenset[TripShape] = frozenset({TripShape.ONE_WAY, TripShape.RETURN})
|
|
49
|
+
date_range_query: bool = False
|
|
50
|
+
"""Native calendar/±n pricing — the cheap source of bounds (DESIGN §6)."""
|
|
51
|
+
nearby_airports: bool = False
|
|
52
|
+
virtual_interline: bool = False
|
|
53
|
+
composes_one_ways: bool = False
|
|
54
|
+
"""Whether a "return" from this source is already two one-way tickets.
|
|
55
|
+
|
|
56
|
+
AZair's whole business is composing one-ways that no single airline sells
|
|
57
|
+
together, so asking it the return-versus-one-ways question spends two queries to
|
|
58
|
+
be told what its baseline answer already contained. A probe that cannot learn
|
|
59
|
+
anything is not a cheap probe, it is a wasted one (DESIGN §6)."""
|
|
60
|
+
hidden_city: bool = False
|
|
61
|
+
multimodal: bool = False
|
|
62
|
+
baggage_filter: bool = False
|
|
63
|
+
pos_controls: frozenset[str] = frozenset()
|
|
64
|
+
"""Any of: market_param, currency_param, locale, ip_sensitive."""
|
|
65
|
+
latency_est_s: float = 3.0
|
|
66
|
+
block_risk: str = "low"
|
|
67
|
+
money_cost_per_query: float = 0.0
|
|
68
|
+
bookable: str = "deep_link"
|
|
69
|
+
coverage: str = "unknown"
|
|
70
|
+
"""What this source actually knows about, in plain words.
|
|
71
|
+
|
|
72
|
+
A capability descriptor that only lists *features* will happily send a Bangkok
|
|
73
|
+
query to a European low-cost combinator and read the empty answer as a finding.
|
|
74
|
+
Coverage is a capability too."""
|
|
75
|
+
|
|
76
|
+
def can_answer(self, query: OracleQuery) -> bool:
|
|
77
|
+
return query.trip_shape in self.trip_shapes
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(frozen=True, slots=True)
|
|
81
|
+
class OracleQuery:
|
|
82
|
+
"""One request, in oracle-neutral terms.
|
|
83
|
+
|
|
84
|
+
The `dedup_key` is what makes the politeness budget real: two requests with the
|
|
85
|
+
same key are the same question, and the second one is answered from the session
|
|
86
|
+
store instead of the network (DESIGN §6).
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
origin: str
|
|
90
|
+
destination: str
|
|
91
|
+
depart: date
|
|
92
|
+
trip_shape: TripShape = TripShape.ONE_WAY
|
|
93
|
+
return_on: date | None = None
|
|
94
|
+
adults: int = 1
|
|
95
|
+
cabin: Cabin = Cabin.ECONOMY
|
|
96
|
+
pos: POSContext = field(default_factory=POSContext)
|
|
97
|
+
max_stops: int | None = None
|
|
98
|
+
baggage_filter: bool = False
|
|
99
|
+
depart_until: date | None = None
|
|
100
|
+
"""End of the outbound date window, for oracles with `date_range_query`.
|
|
101
|
+
|
|
102
|
+
One query covering a range is the cheapest bound the solver ever gets (DESIGN §6):
|
|
103
|
+
it prices many dates for the cost of one request. Oracles without the capability
|
|
104
|
+
ignore it and are asked per date."""
|
|
105
|
+
return_until: date | None = None
|
|
106
|
+
min_nights: int | None = None
|
|
107
|
+
max_nights: int | None = None
|
|
108
|
+
|
|
109
|
+
def __post_init__(self) -> None:
|
|
110
|
+
if self.trip_shape is TripShape.RETURN and self.return_on is None:
|
|
111
|
+
raise ValueError("a return query needs a return date")
|
|
112
|
+
if self.return_on is not None and self.return_on <= self.depart:
|
|
113
|
+
raise ValueError("the return date must be after the outbound date")
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def dedup_key(self) -> str:
|
|
117
|
+
payload = {
|
|
118
|
+
"origin": self.origin.upper(),
|
|
119
|
+
"destination": self.destination.upper(),
|
|
120
|
+
"depart": self.depart.isoformat(),
|
|
121
|
+
"return_on": self.return_on.isoformat() if self.return_on else None,
|
|
122
|
+
"shape": str(self.trip_shape),
|
|
123
|
+
"adults": self.adults,
|
|
124
|
+
"cabin": str(self.cabin),
|
|
125
|
+
"pos": self.pos.model_dump(),
|
|
126
|
+
"max_stops": self.max_stops,
|
|
127
|
+
"baggage_filter": self.baggage_filter,
|
|
128
|
+
"depart_until": self.depart_until.isoformat() if self.depart_until else None,
|
|
129
|
+
"return_until": self.return_until.isoformat() if self.return_until else None,
|
|
130
|
+
"min_nights": self.min_nights,
|
|
131
|
+
"max_nights": self.max_nights,
|
|
132
|
+
}
|
|
133
|
+
blob = json.dumps(payload, sort_keys=True, default=str)
|
|
134
|
+
return hashlib.sha256(blob.encode()).hexdigest()[:24]
|
|
135
|
+
|
|
136
|
+
def describe(self) -> str:
|
|
137
|
+
leg = f"{self.origin}-{self.destination} {self.depart}"
|
|
138
|
+
if self.depart_until and self.depart_until != self.depart:
|
|
139
|
+
leg += f"..{self.depart_until}"
|
|
140
|
+
if self.return_on:
|
|
141
|
+
leg += f" / {self.return_on}"
|
|
142
|
+
if self.return_until and self.return_until != self.return_on:
|
|
143
|
+
leg += f"..{self.return_until}"
|
|
144
|
+
if self.min_nights is not None:
|
|
145
|
+
leg += f" [{self.min_nights}-{self.max_nights}n]"
|
|
146
|
+
return f"{leg} x{self.adults} {self.cabin} @{self.pos.name}"
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@dataclass(frozen=True, slots=True)
|
|
150
|
+
class QueryResult:
|
|
151
|
+
"""Offers, or a typed reason there are none."""
|
|
152
|
+
|
|
153
|
+
query: OracleQuery
|
|
154
|
+
oracle_id: str
|
|
155
|
+
offers: list[Offer] = field(default_factory=list)
|
|
156
|
+
failure: FailureKind | None = None
|
|
157
|
+
detail: str = ""
|
|
158
|
+
latency_ms: float | None = None
|
|
159
|
+
raw: Any = None
|
|
160
|
+
"""Kept only in memory, for fixture recording and debugging."""
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def ok(self) -> bool:
|
|
164
|
+
return self.failure is None
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def status(self) -> str:
|
|
168
|
+
return "ok" if self.ok else str(self.failure)
|
|
169
|
+
|
|
170
|
+
@classmethod
|
|
171
|
+
def failed(
|
|
172
|
+
cls,
|
|
173
|
+
query: OracleQuery,
|
|
174
|
+
oracle_id: str,
|
|
175
|
+
kind: FailureKind,
|
|
176
|
+
detail: str = "",
|
|
177
|
+
latency_ms: float | None = None,
|
|
178
|
+
) -> QueryResult:
|
|
179
|
+
return cls(
|
|
180
|
+
query=query,
|
|
181
|
+
oracle_id=oracle_id,
|
|
182
|
+
failure=kind,
|
|
183
|
+
detail=detail,
|
|
184
|
+
latency_ms=latency_ms,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class Oracle(ABC):
|
|
189
|
+
"""What every source implements."""
|
|
190
|
+
|
|
191
|
+
id: str
|
|
192
|
+
capabilities: OracleCapabilities
|
|
193
|
+
|
|
194
|
+
@abstractmethod
|
|
195
|
+
def search(self, query: OracleQuery) -> QueryResult:
|
|
196
|
+
"""Answer one query, or fail in a way the health monitor can classify."""
|
|
197
|
+
|
|
198
|
+
def canary(self) -> QueryResult:
|
|
199
|
+
"""A cheap self-test on a known-liquid route (ORACLES §5).
|
|
200
|
+
|
|
201
|
+
Used to decide recovery after a cooldown: if this comes back empty, the
|
|
202
|
+
adapter is broken rather than the market.
|
|
203
|
+
"""
|
|
204
|
+
return self.search(self.canary_query())
|
|
205
|
+
|
|
206
|
+
def canary_query(self) -> OracleQuery:
|
|
207
|
+
from datetime import timedelta
|
|
208
|
+
|
|
209
|
+
return OracleQuery(
|
|
210
|
+
origin="BUD",
|
|
211
|
+
destination="LON",
|
|
212
|
+
depart=date.today() + timedelta(days=30),
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def classify_empty(offers: list[Offer], *, expected_liquid: bool) -> FailureKind | None:
|
|
217
|
+
"""Zero offers: genuinely empty market, or a silent breakage? (ORACLES §3)
|
|
218
|
+
|
|
219
|
+
The heuristic is deliberately conservative — on a route that should always have
|
|
220
|
+
something, "no results" means the adapter, not the world.
|
|
221
|
+
"""
|
|
222
|
+
if offers:
|
|
223
|
+
return None
|
|
224
|
+
return FailureKind.EMPTY_ANOMALY if expected_liquid else FailureKind.EMPTY_MARKET
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Reusing a consent choice you already made (ORACLES §2, §7).
|
|
2
|
+
|
|
3
|
+
From an EU address Google answers with a consent interstitial, and an HTTP client
|
|
4
|
+
cannot answer it. The design's answer is a browser profile that has *actually* clicked
|
|
5
|
+
the dialog — this module is the small version of that: you make the choice in your own
|
|
6
|
+
browser, once, and layover reuses the resulting cookie the way your browser would.
|
|
7
|
+
|
|
8
|
+
**What this deliberately is not.** It does not fabricate a consent value, guess one,
|
|
9
|
+
or synthesise "accept all" on your behalf. The value comes from a browser where a
|
|
10
|
+
human answered the dialog; if you have not answered it, there is nothing to import and
|
|
11
|
+
the wall stays up. That distinction is the whole point: the EU obstacle is a consent
|
|
12
|
+
question, and the honest way past a question is to answer it.
|
|
13
|
+
|
|
14
|
+
It also does not need to be "accept all". Google records a rejection in the same
|
|
15
|
+
cookie, and a rejected-consent cookie clears the interstitial just as well.
|
|
16
|
+
|
|
17
|
+
Capture it from a **logged-out private window**, not from your everyday browser. The
|
|
18
|
+
value is a stable token, so every request carries the same one; taken from a session
|
|
19
|
+
where you are signed in, it is a token your signed-in browser also holds, and the two
|
|
20
|
+
become correlatable. An incognito window that has never seen your account gives a
|
|
21
|
+
cookie that is merely stable, not linked to you. (A *new Google account* is the wrong
|
|
22
|
+
fix — an account is a far stronger identifier than a cookie, and creating one to
|
|
23
|
+
support automated querying turns a grey area into a plain violation.)
|
|
24
|
+
|
|
25
|
+
"Reject all" works as well as "accept all": both record a decision, and the decision
|
|
26
|
+
is what clears the interstitial.
|
|
27
|
+
|
|
28
|
+
open a private window → google.com → answer the dialog → DevTools →
|
|
29
|
+
Application → Cookies → copy **SOCS**
|
|
30
|
+
|
|
31
|
+
layover consent --import "SOCS=<value>"
|
|
32
|
+
layover consent --check
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import tomllib
|
|
38
|
+
from dataclasses import dataclass, field
|
|
39
|
+
from datetime import UTC, datetime
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
|
|
42
|
+
CONSENT_COOKIES = ("SOCS", "CONSENT")
|
|
43
|
+
"""The cookies Google uses to record the answer. Nothing else is stored."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def default_cookie_path() -> Path:
|
|
47
|
+
return Path.home() / ".config" / "layover" / "cookies.toml"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(slots=True)
|
|
51
|
+
class CookieJar:
|
|
52
|
+
"""Per-domain cookies the owner has chosen to share with layover.
|
|
53
|
+
|
|
54
|
+
Kept in its own file, gitignored, and holding only consent cookies — never
|
|
55
|
+
session or authentication cookies. layover logs into nothing (ORACLES §7), so a
|
|
56
|
+
cookie that would authenticate anything has no business here.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
path: Path = field(default_factory=default_cookie_path)
|
|
60
|
+
domains: dict[str, dict[str, str]] = field(default_factory=dict)
|
|
61
|
+
meta: dict[str, dict[str, str]] = field(default_factory=dict)
|
|
62
|
+
"""Per domain: when it was imported, and which exit it was captured from.
|
|
63
|
+
|
|
64
|
+
The exit matters more than it looks. A cookie captured at home and then sent
|
|
65
|
+
through a Singapore exit is a *more* distinctive combination than either alone —
|
|
66
|
+
it says "the person whose consent record this is, arriving from an address they
|
|
67
|
+
have never used". That is the identity-consistency rule in DESIGN §9, and here it
|
|
68
|
+
is enforced rather than advised."""
|
|
69
|
+
|
|
70
|
+
@classmethod
|
|
71
|
+
def load(cls, path: Path | None = None) -> CookieJar:
|
|
72
|
+
path = path or default_cookie_path()
|
|
73
|
+
jar = cls(path=path)
|
|
74
|
+
if not path.is_file():
|
|
75
|
+
return jar
|
|
76
|
+
raw = tomllib.loads(path.read_text())
|
|
77
|
+
for domain, cookies in (raw.get("domain") or {}).items():
|
|
78
|
+
jar.domains[domain] = {str(k): str(v) for k, v in cookies.items()}
|
|
79
|
+
for domain, meta in (raw.get("meta") or {}).items():
|
|
80
|
+
jar.meta[domain] = {str(k): str(v) for k, v in meta.items()}
|
|
81
|
+
return jar
|
|
82
|
+
|
|
83
|
+
def save(self) -> None:
|
|
84
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
85
|
+
lines = [
|
|
86
|
+
"# Cookies you have chosen to share with layover (docs/ORACLES.md §2).",
|
|
87
|
+
"#",
|
|
88
|
+
"# Consent choices only — never anything that authenticates you. layover",
|
|
89
|
+
"# logs into nothing, so a session cookie here would be pure risk.",
|
|
90
|
+
"# This file is gitignored. Delete it any time; the only cost is that",
|
|
91
|
+
"# Google goes back to asking.",
|
|
92
|
+
"",
|
|
93
|
+
]
|
|
94
|
+
for domain, cookies in sorted(self.domains.items()):
|
|
95
|
+
lines.append(f'[domain."{domain}"]')
|
|
96
|
+
for name, value in sorted(cookies.items()):
|
|
97
|
+
lines.append(f'{name} = "{value}"')
|
|
98
|
+
lines.append("")
|
|
99
|
+
for domain, meta in sorted(self.meta.items()):
|
|
100
|
+
lines.append(f'[meta."{domain}"]')
|
|
101
|
+
for name, value in sorted(meta.items()):
|
|
102
|
+
lines.append(f'{name} = "{value}"')
|
|
103
|
+
lines.append("")
|
|
104
|
+
self.path.write_text("\n".join(lines))
|
|
105
|
+
self.path.chmod(0o600)
|
|
106
|
+
|
|
107
|
+
def for_domain(self, domain: str) -> dict[str, str]:
|
|
108
|
+
"""Cookies for a host, including any stored against a parent domain."""
|
|
109
|
+
out: dict[str, str] = {}
|
|
110
|
+
for stored, cookies in self.domains.items():
|
|
111
|
+
if domain == stored or domain.endswith(f".{stored}"):
|
|
112
|
+
out.update(cookies)
|
|
113
|
+
return out
|
|
114
|
+
|
|
115
|
+
def set(
|
|
116
|
+
self,
|
|
117
|
+
domain: str,
|
|
118
|
+
name: str,
|
|
119
|
+
value: str,
|
|
120
|
+
*,
|
|
121
|
+
captured_egress: str | None = None,
|
|
122
|
+
) -> None:
|
|
123
|
+
self.domains.setdefault(domain, {})[name] = value
|
|
124
|
+
meta = self.meta.setdefault(domain, {})
|
|
125
|
+
meta["imported_at"] = datetime.now(UTC).isoformat(timespec="seconds")
|
|
126
|
+
if captured_egress:
|
|
127
|
+
meta["captured_egress"] = captured_egress
|
|
128
|
+
|
|
129
|
+
def captured_egress(self, domain: str) -> str | None:
|
|
130
|
+
for stored, meta in self.meta.items():
|
|
131
|
+
if domain == stored or domain.endswith(f".{stored}"):
|
|
132
|
+
return meta.get("captured_egress")
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
def age_days(self, domain: str) -> int | None:
|
|
136
|
+
"""How old the stored answer is. Consent records do not live forever."""
|
|
137
|
+
for stored, meta in self.meta.items():
|
|
138
|
+
if domain == stored or domain.endswith(f".{stored}"):
|
|
139
|
+
stamp = meta.get("imported_at")
|
|
140
|
+
if not stamp:
|
|
141
|
+
return None
|
|
142
|
+
return (datetime.now(UTC) - datetime.fromisoformat(stamp)).days
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
def usable_for(
|
|
146
|
+
self, domain: str, *, egress: str | None, max_age_days: int = 365
|
|
147
|
+
) -> tuple[dict[str, str], list[str]]:
|
|
148
|
+
"""Cookies to send, plus every reason not to trust them.
|
|
149
|
+
|
|
150
|
+
Returns no cookies at all on an egress mismatch. Sending them anyway would
|
|
151
|
+
trade a consent wall — a visible, recoverable failure — for a distinctive
|
|
152
|
+
fingerprint, which is neither.
|
|
153
|
+
"""
|
|
154
|
+
cookies = self.for_domain(domain)
|
|
155
|
+
if not cookies:
|
|
156
|
+
return {}, []
|
|
157
|
+
|
|
158
|
+
problems: list[str] = []
|
|
159
|
+
captured = self.captured_egress(domain)
|
|
160
|
+
if captured and egress and captured != egress:
|
|
161
|
+
problems.append(
|
|
162
|
+
f"captured from {captured} but this request leaves from {egress} — "
|
|
163
|
+
f"not sending it: that pairing is more identifying than either alone"
|
|
164
|
+
)
|
|
165
|
+
return {}, problems
|
|
166
|
+
|
|
167
|
+
age = self.age_days(domain)
|
|
168
|
+
if age is not None and age > max_age_days:
|
|
169
|
+
problems.append(
|
|
170
|
+
f"imported {age} days ago; consent records typically lapse around a "
|
|
171
|
+
f"year, so re-import if the wall comes back"
|
|
172
|
+
)
|
|
173
|
+
return cookies, problems
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class NotAConsentCookie(ValueError):
|
|
177
|
+
"""Refuses anything that is not a consent cookie."""
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def parse_cookie(text: str) -> tuple[str, str]:
|
|
181
|
+
"""Read a `NAME=value` pair, and accept only consent cookies.
|
|
182
|
+
|
|
183
|
+
The refusal matters more than the parsing: pasting a whole `Cookie:` header from a
|
|
184
|
+
logged-in browser would hand over session credentials, and this tool has no use
|
|
185
|
+
for them.
|
|
186
|
+
"""
|
|
187
|
+
if "=" not in text:
|
|
188
|
+
raise NotAConsentCookie(f"expected NAME=value, got {text[:40]!r}")
|
|
189
|
+
name, _, value = text.strip().partition("=")
|
|
190
|
+
name, value = name.strip(), value.strip().strip('"')
|
|
191
|
+
if name.upper() not in CONSENT_COOKIES:
|
|
192
|
+
raise NotAConsentCookie(
|
|
193
|
+
f"{name} is not a consent cookie. Only {', '.join(CONSENT_COOKIES)} are "
|
|
194
|
+
f"accepted — layover logs into nothing and must not hold anything that "
|
|
195
|
+
f"authenticates you."
|
|
196
|
+
)
|
|
197
|
+
if not value:
|
|
198
|
+
raise NotAConsentCookie(f"{name} has no value")
|
|
199
|
+
return name.upper(), value
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""What we have learned about oracles, kept across sessions (DESIGN §5).
|
|
2
|
+
|
|
3
|
+
Session stores age out after a fortnight, which is right for prices and wrong for
|
|
4
|
+
everything else. Two kinds of fact outlive them:
|
|
5
|
+
|
|
6
|
+
* **Coverage.** AZair does not fly to Bangkok. That is a property of the oracle, not
|
|
7
|
+
of a search, and re-learning it costs a query every time a session starts fresh.
|
|
8
|
+
* **Health.** Backoff is meaningless if it forgets: an oracle that blocked us an hour
|
|
9
|
+
ago should still be cooling now, whatever process is asking (ORACLES §5).
|
|
10
|
+
|
|
11
|
+
Both are deliberately *small* and *non-price*. Nothing here records a fare, so the
|
|
12
|
+
no-hoarding principle is untouched — this is a notebook about the sources, not about
|
|
13
|
+
the market.
|
|
14
|
+
|
|
15
|
+
Coverage facts expire. Airlines add routes and sites extend their networks, so a
|
|
16
|
+
"not covered" from six months ago is a stale opinion rather than a fact; the default
|
|
17
|
+
shelf life is 90 days, after which the question is worth one query again.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from datetime import UTC, datetime, timedelta
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
DEFAULT_COVERAGE_TTL_DAYS = 90
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def default_path() -> Path:
|
|
32
|
+
return Path.home() / ".local" / "state" / "layover" / "oracle-knowledge.json"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True, slots=True)
|
|
36
|
+
class CoverageFact:
|
|
37
|
+
oracle_id: str
|
|
38
|
+
route: str
|
|
39
|
+
covered: bool
|
|
40
|
+
learned_at: datetime
|
|
41
|
+
detail: str = ""
|
|
42
|
+
|
|
43
|
+
def age_days(self, now: datetime | None = None) -> int:
|
|
44
|
+
return ((now or datetime.now(UTC)) - self.learned_at).days
|
|
45
|
+
|
|
46
|
+
def is_stale(
|
|
47
|
+
self, ttl_days: int = DEFAULT_COVERAGE_TTL_DAYS, now: datetime | None = None
|
|
48
|
+
) -> bool:
|
|
49
|
+
return self.age_days(now) >= ttl_days
|
|
50
|
+
|
|
51
|
+
def describe(self) -> str:
|
|
52
|
+
verdict = "covers" if self.covered else "does not cover"
|
|
53
|
+
return f"{self.oracle_id} {verdict} {self.route} (learned {self.age_days()}d ago)"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class OracleKnowledge:
|
|
57
|
+
"""A small, durable notebook about the sources."""
|
|
58
|
+
|
|
59
|
+
def __init__(
|
|
60
|
+
self, path: Path | None = None, *, ttl_days: int = DEFAULT_COVERAGE_TTL_DAYS
|
|
61
|
+
) -> None:
|
|
62
|
+
self.path = Path(path or default_path())
|
|
63
|
+
self.ttl_days = ttl_days
|
|
64
|
+
self._data: dict[str, Any] = self._load()
|
|
65
|
+
|
|
66
|
+
def _load(self) -> dict[str, Any]:
|
|
67
|
+
if not self.path.is_file():
|
|
68
|
+
return {"coverage": {}, "health": {}}
|
|
69
|
+
try:
|
|
70
|
+
raw = json.loads(self.path.read_text())
|
|
71
|
+
except json.JSONDecodeError:
|
|
72
|
+
return {"coverage": {}, "health": {}} # corrupt notebook: start a new one
|
|
73
|
+
raw.setdefault("coverage", {})
|
|
74
|
+
raw.setdefault("health", {})
|
|
75
|
+
return raw
|
|
76
|
+
|
|
77
|
+
def save(self) -> None:
|
|
78
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
79
|
+
self.path.write_text(json.dumps(self._data, indent=2, sort_keys=True))
|
|
80
|
+
|
|
81
|
+
# ── coverage ─────────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
@staticmethod
|
|
84
|
+
def _key(oracle_id: str, route: str) -> str:
|
|
85
|
+
return f"{oracle_id}|{route.upper()}"
|
|
86
|
+
|
|
87
|
+
def record_coverage(
|
|
88
|
+
self,
|
|
89
|
+
oracle_id: str,
|
|
90
|
+
route: str,
|
|
91
|
+
*,
|
|
92
|
+
covered: bool,
|
|
93
|
+
detail: str = "",
|
|
94
|
+
when: datetime | None = None,
|
|
95
|
+
) -> None:
|
|
96
|
+
"""Remember whether a source can answer about a route at all."""
|
|
97
|
+
self._data["coverage"][self._key(oracle_id, route)] = {
|
|
98
|
+
"oracle_id": oracle_id,
|
|
99
|
+
"route": route.upper(),
|
|
100
|
+
"covered": covered,
|
|
101
|
+
"learned_at": (when or datetime.now(UTC)).isoformat(timespec="seconds"),
|
|
102
|
+
"detail": detail[:200],
|
|
103
|
+
}
|
|
104
|
+
self.save()
|
|
105
|
+
|
|
106
|
+
def coverage(self, oracle_id: str, route: str) -> CoverageFact | None:
|
|
107
|
+
entry = self._data["coverage"].get(self._key(oracle_id, route))
|
|
108
|
+
if not entry:
|
|
109
|
+
return None
|
|
110
|
+
try:
|
|
111
|
+
learned = datetime.fromisoformat(entry["learned_at"])
|
|
112
|
+
except (KeyError, ValueError):
|
|
113
|
+
return None
|
|
114
|
+
return CoverageFact(
|
|
115
|
+
oracle_id=entry["oracle_id"],
|
|
116
|
+
route=entry["route"],
|
|
117
|
+
covered=bool(entry["covered"]),
|
|
118
|
+
learned_at=learned,
|
|
119
|
+
detail=entry.get("detail", ""),
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
def excluded_for(self, route: str, *, now: datetime | None = None) -> set[str]:
|
|
123
|
+
"""Oracles known not to cover this route, ignoring stale opinions."""
|
|
124
|
+
out: set[str] = set()
|
|
125
|
+
for entry in self._data["coverage"].values():
|
|
126
|
+
if entry.get("route") != route.upper() or entry.get("covered"):
|
|
127
|
+
continue
|
|
128
|
+
fact = self.coverage(entry["oracle_id"], entry["route"])
|
|
129
|
+
if fact and not fact.is_stale(self.ttl_days, now):
|
|
130
|
+
out.add(fact.oracle_id)
|
|
131
|
+
return out
|
|
132
|
+
|
|
133
|
+
def known_gaps(self) -> list[CoverageFact]:
|
|
134
|
+
facts = []
|
|
135
|
+
for entry in self._data["coverage"].values():
|
|
136
|
+
fact = self.coverage(entry.get("oracle_id", ""), entry.get("route", ""))
|
|
137
|
+
if fact and not fact.covered and not fact.is_stale(self.ttl_days):
|
|
138
|
+
facts.append(fact)
|
|
139
|
+
return sorted(facts, key=lambda f: (f.oracle_id, f.route))
|
|
140
|
+
|
|
141
|
+
# ── health ───────────────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
def record_failure(
|
|
144
|
+
self,
|
|
145
|
+
oracle_id: str,
|
|
146
|
+
kind: str,
|
|
147
|
+
*,
|
|
148
|
+
cooldown_minutes: float = 0.0,
|
|
149
|
+
when: datetime | None = None,
|
|
150
|
+
) -> None:
|
|
151
|
+
"""Note a failure, and when this oracle may be asked again.
|
|
152
|
+
|
|
153
|
+
Backoff that forgets is not backoff: an oracle blocked ten minutes ago is
|
|
154
|
+
still blocked now, whichever process is asking.
|
|
155
|
+
"""
|
|
156
|
+
now = when or datetime.now(UTC)
|
|
157
|
+
entry = self._data["health"].get(oracle_id, {})
|
|
158
|
+
entry["last_failure"] = kind
|
|
159
|
+
entry["last_failure_at"] = now.isoformat(timespec="seconds")
|
|
160
|
+
entry["consecutive_failures"] = int(entry.get("consecutive_failures", 0)) + 1
|
|
161
|
+
if cooldown_minutes > 0:
|
|
162
|
+
entry["cooling_until"] = (now + timedelta(minutes=cooldown_minutes)).isoformat(
|
|
163
|
+
timespec="seconds"
|
|
164
|
+
)
|
|
165
|
+
self._data["health"][oracle_id] = entry
|
|
166
|
+
self.save()
|
|
167
|
+
|
|
168
|
+
def record_success(self, oracle_id: str, when: datetime | None = None) -> None:
|
|
169
|
+
entry = self._data["health"].get(oracle_id, {})
|
|
170
|
+
entry["consecutive_failures"] = 0
|
|
171
|
+
entry["cooling_until"] = None
|
|
172
|
+
entry["last_success_at"] = (when or datetime.now(UTC)).isoformat(timespec="seconds")
|
|
173
|
+
self._data["health"][oracle_id] = entry
|
|
174
|
+
self.save()
|
|
175
|
+
|
|
176
|
+
def cooling(self, oracle_id: str, *, now: datetime | None = None) -> datetime | None:
|
|
177
|
+
"""When this oracle may be asked again, or `None` if it is free to use."""
|
|
178
|
+
entry = self._data["health"].get(oracle_id) or {}
|
|
179
|
+
stamp = entry.get("cooling_until")
|
|
180
|
+
if not stamp:
|
|
181
|
+
return None
|
|
182
|
+
try:
|
|
183
|
+
until = datetime.fromisoformat(stamp)
|
|
184
|
+
except ValueError:
|
|
185
|
+
return None
|
|
186
|
+
return until if until > (now or datetime.now(UTC)) else None
|
|
187
|
+
|
|
188
|
+
def health(self, oracle_id: str) -> dict[str, Any]:
|
|
189
|
+
return dict(self._data["health"].get(oracle_id) or {})
|
|
190
|
+
|
|
191
|
+
# ── maintenance ──────────────────────────────────────────────────────────
|
|
192
|
+
|
|
193
|
+
def forget(self, oracle_id: str) -> int:
|
|
194
|
+
"""Drop everything remembered about one source; returns how much was dropped.
|
|
195
|
+
|
|
196
|
+
The escape hatch for when the notebook is wrong — a fixed adapter, a site that
|
|
197
|
+
extended its network, a cooldown that has clearly outlived its cause.
|
|
198
|
+
"""
|
|
199
|
+
keys = [k for k, v in self._data["coverage"].items() if v.get("oracle_id") == oracle_id]
|
|
200
|
+
for key in keys:
|
|
201
|
+
del self._data["coverage"][key]
|
|
202
|
+
dropped = len(keys) + (1 if self._data["health"].pop(oracle_id, None) else 0)
|
|
203
|
+
self.save()
|
|
204
|
+
return dropped
|
|
205
|
+
|
|
206
|
+
def oracles(self) -> list[str]:
|
|
207
|
+
names = {entry.get("oracle_id", "") for entry in self._data["coverage"].values()}
|
|
208
|
+
return sorted(n for n in names | set(self._data["health"]) if n)
|