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,98 @@
1
+ """Record a live response as a test fixture (ORACLES §3).
2
+
3
+ Parsers are the part of this system that breaks, and the only defence that works is a
4
+ recorded response plus a golden parse: CI then re-runs the parser against real bytes
5
+ without touching the network. This is the tool that captures those bytes.
6
+
7
+ Sanitising is deliberate rather than thorough-looking. The page's *data* is exactly
8
+ what the parser needs, so it stays; what goes is anything that identifies the machine
9
+ or the session that fetched it. The repo is private (DESIGN §13), which is what makes
10
+ committing a site's markup acceptable at all — that is a decision, not an oversight.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import re
17
+ from datetime import UTC, datetime
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ from layover.oracles.base import OracleQuery
22
+ from layover.oracles.transport import HttpTransport, is_eu_egress
23
+
24
+ # Session-ish identifiers that can appear in an embedded state blob. Values are
25
+ # replaced, keys kept, so the shape the parser sees is unchanged.
26
+ _SCRUB_PATTERNS: tuple[tuple[str, re.Pattern[str], str], ...] = (
27
+ ("email", re.compile(r"[\w.+-]+@[\w-]+\.[\w.]+"), "someone@example.invalid"),
28
+ (
29
+ "sapisid",
30
+ re.compile(r'"(SAPISID|APISID|SID|HSID|SSID)"\s*:\s*"[^"]*"'),
31
+ r'"\1":"REDACTED"',
32
+ ),
33
+ ("bearer", re.compile(r"(ya29|1//)[\w.\-]{10,}"), "REDACTED-TOKEN"),
34
+ ("cookie", re.compile(r"(?i)(set-cookie:)[^\n]*"), r"\1 REDACTED"),
35
+ )
36
+
37
+
38
+ def sanitize(text: str) -> tuple[str, list[str]]:
39
+ """Strip machine/session identifiers. Returns the text and what was touched."""
40
+ touched: list[str] = []
41
+ for name, pattern, replacement in _SCRUB_PATTERNS:
42
+ text, count = pattern.subn(replacement, text)
43
+ if count:
44
+ touched.append(f"{name}x{count}")
45
+ return text, touched
46
+
47
+
48
+ def record(
49
+ query: OracleQuery,
50
+ *,
51
+ url: str,
52
+ directory: Path,
53
+ name: str,
54
+ transport: HttpTransport | None = None,
55
+ ) -> dict[str, Any]:
56
+ """Fetch one URL and write `<name>.html` plus `<name>.meta.json` beside it.
57
+
58
+ The metadata is the part people forget: a fixture without its egress country,
59
+ point of sale and capture date is a mystery in six months, and a parser tuned to
60
+ a mystery is worse than no fixture at all.
61
+ """
62
+ transport = transport or HttpTransport(pos=query.pos)
63
+ egress = transport.detect_egress_country()
64
+ response = transport.get(url)
65
+ body, scrubbed = sanitize(response.text)
66
+
67
+ directory = Path(directory)
68
+ directory.mkdir(parents=True, exist_ok=True)
69
+ (directory / f"{name}.html").write_text(body)
70
+
71
+ meta = {
72
+ "captured_at": datetime.now(UTC).isoformat(timespec="seconds"),
73
+ "query": query.describe(),
74
+ "dedup_key": query.dedup_key,
75
+ "request_url": url,
76
+ "final_url": response.url,
77
+ "status_code": response.status_code,
78
+ "bytes": len(body),
79
+ "egress_country": egress,
80
+ "eu_egress": is_eu_egress(egress),
81
+ "pos": transport.pos_with_egress().model_dump(),
82
+ "scrubbed": scrubbed,
83
+ "note": "Recorded for offline parser tests. Private repo (DESIGN §13).",
84
+ }
85
+ (directory / f"{name}.meta.json").write_text(json.dumps(meta, indent=2, sort_keys=True))
86
+ return meta
87
+
88
+
89
+ def looks_like_results(html: str) -> bool:
90
+ """A cheap "did we actually get a results page?" check for the recorder.
91
+
92
+ Not a parser — just enough signal to tell a captured results page from a captured
93
+ consent wall, so a useless fixture is never committed by accident.
94
+ """
95
+ if "consent.google.com" in html[:5000] or "Before you continue" in html[:5000]:
96
+ return False
97
+ markers = ("AF_initDataCallback", "aria-label", "flt-", "Nonstop", "nonstop")
98
+ return sum(marker in html for marker in markers) >= 2
@@ -0,0 +1,429 @@
1
+ """HTTP transport for API oracles, with the point of sale made explicit (§9).
2
+
3
+ The reason this exists rather than calling `httpx.get` inline: **which exit a price
4
+ came from is part of the price**. A fare seen from a US exit and one seen from a
5
+ Hungarian exit are different observations, and mixing them silently would produce a
6
+ ranking that cannot be reproduced or booked. So the transport carries a `POSContext`,
7
+ stamps it on what it returns, and refuses to guess.
8
+
9
+ The exit itself is whatever the owner has running — a VPN on the machine, or a
10
+ per-request proxy from `config/pos.toml`. Both are the same idea; only the second is
11
+ per-oracle, which is why `pos.toml` exists at all.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import time
17
+ from dataclasses import dataclass
18
+ from typing import Any
19
+
20
+ from layover.models import POSContext
21
+
22
+ USER_AGENT = (
23
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
24
+ "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
25
+ )
26
+
27
+ EGRESS_LOOKUPS: tuple[tuple[str, str, str], ...] = (
28
+ ("https://ipapi.co/json", "country_name", "ip"),
29
+ ("https://ifconfig.co/json", "country", "ip"),
30
+ ("https://ipinfo.io/json", "country", "ip"),
31
+ )
32
+ """Independent places to ask where we are leaving from: (url, country key, ip key).
33
+
34
+ Vendor-neutral on purpose. These are plain geolocation echoes that answer the same
35
+ way whatever the tunnel is — or whether there is one — and none of them belongs to a
36
+ VPN provider: asking one provider's server where you are, while using a different
37
+ provider, is both confusing to read and a needless disclosure.
38
+
39
+ **Two of them, deliberately.** A country label from a single service was treated as
40
+ ground truth once in this project and turned out to be load-bearing for a comparison;
41
+ geolocation databases mislocate exits, especially datacentre ranges. So at least two
42
+ are asked, their answers are recorded verbatim, and disagreement is surfaced rather
43
+ than resolved by picking a favourite."""
44
+
45
+
46
+ @dataclass(frozen=True, slots=True)
47
+ class Egress:
48
+ """Where requests actually leave from, and how confident we are about it."""
49
+
50
+ country: str | None
51
+ ip: str | None = None
52
+ family: str = "ipv4"
53
+ """Which address family this was measured over. A dual-stack machine can leave
54
+ via different countries per family, so the label is meaningless without it."""
55
+ sources: tuple[tuple[str, str | None, str | None], ...] = ()
56
+ """(url, country, ip) as each service answered — kept verbatim for the record."""
57
+
58
+ @property
59
+ def answered(self) -> int:
60
+ return sum(1 for _url, country, _ip in self.sources if country)
61
+
62
+ @property
63
+ def dissenting(self) -> tuple[str, ...]:
64
+ """Countries some service reported that the majority did not."""
65
+ countries = [canonical_country(c) for _u, c, _i in self.sources if c]
66
+ if not countries:
67
+ return ()
68
+ winner = max(set(countries), key=countries.count)
69
+ return tuple(sorted({c for c in countries if c != winner}))
70
+
71
+ @property
72
+ def agreed(self) -> bool:
73
+ """Do the services that answered agree on the country *and* the IP?
74
+
75
+ Compared canonically — "HU" and "Hungary" are the same answer in different
76
+ clothes, and treating them as a conflict would drown the real conflicts.
77
+ """
78
+ countries = {canonical_country(c) for _u, c, _i in self.sources if c}
79
+ ips = {i for _u, _c, i in self.sources if i}
80
+ return len(countries) <= 1 and len(ips) <= 1
81
+
82
+ @property
83
+ def verified(self) -> bool:
84
+ """Two or more independent services, in agreement."""
85
+ return self.answered >= 2 and self.agreed
86
+
87
+ def describe(self) -> str:
88
+ if self.country is None:
89
+ return "unknown"
90
+ if self.verified:
91
+ return f"{self.country} ({self.ip} {self.family}, {self.answered} agree)"
92
+ if self.answered >= 2:
93
+ detail = "; ".join(f"{u.split('/')[2]}={c}" for u, c, _i in self.sources if c)
94
+ return f"{self.country} by majority ({self.ip}) — sources disagree: {detail}"
95
+ return f"{self.country} (single source, unverified)"
96
+
97
+
98
+ @dataclass(frozen=True, slots=True)
99
+ class Response:
100
+ status_code: int
101
+ url: str
102
+ text: str
103
+ latency_ms: float
104
+ egress_country: str | None = None
105
+ """Where the request actually left from, when it could be determined."""
106
+
107
+
108
+ class HttpTransport:
109
+ """A thin, POS-aware wrapper. One in-flight request at a time, by construction."""
110
+
111
+ def __init__(
112
+ self,
113
+ *,
114
+ pos: POSContext | None = None,
115
+ proxy: str | None = None,
116
+ timeout: float = 20.0,
117
+ headers: dict[str, str] | None = None,
118
+ egress_country: str | None = None,
119
+ cookies: dict[str, str] | None = None,
120
+ force_ipv4: bool = True,
121
+ ) -> None:
122
+ self.pos = pos or POSContext()
123
+ self.proxy = proxy
124
+ self.timeout = timeout
125
+ self.cookies = cookies or {}
126
+ """Consent cookies the owner chose to share (ORACLES §2). Never credentials."""
127
+ self.force_ipv4 = force_ipv4
128
+ """Pin requests to IPv4.
129
+
130
+ A dual-stack machine behind a VPN can leave via **two different countries at
131
+ once**: measured 2026-08-05, a UAE tunnel carried IPv4 while IPv6 went out
132
+ through France — so Google was fetched from an EU address and answered with a
133
+ consent wall, on a run labelled "United Arab Emirates". Whichever family the
134
+ request happens to use is the exit that counts, so both the lookups and the
135
+ oracle traffic are pinned to one family and the label means something."""
136
+ self.headers = {
137
+ "User-Agent": USER_AGENT,
138
+ "Accept-Language": self._accept_language(),
139
+ **(headers or {}),
140
+ }
141
+ self._egress_country = egress_country
142
+ self._egress: Egress | None = Egress(country=egress_country) if egress_country else None
143
+
144
+ def _accept_language(self) -> str:
145
+ locale = (self.pos.locale or "en-US").replace("_", "-")
146
+ primary = locale.split("-")[0]
147
+ return f"{locale},{primary};q=0.9"
148
+
149
+ def _client_kwargs(self) -> dict[str, Any]:
150
+ import httpx
151
+
152
+ kwargs: dict[str, Any] = {"proxy": self.proxy, "timeout": self.timeout}
153
+ if self.force_ipv4 and self.proxy is None:
154
+ # local_address="0.0.0.0" binds the socket to an IPv4 source, which makes
155
+ # the connection use IPv4 whatever the DNS answer offers.
156
+ kwargs["transport"] = httpx.HTTPTransport(local_address="0.0.0.0")
157
+ return kwargs
158
+
159
+ def get(self, url: str, headers: dict[str, str] | None = None, **kwargs: Any) -> Response:
160
+ """One GET, with this transport's headers plus any the caller adds."""
161
+ import httpx
162
+
163
+ started = time.monotonic()
164
+ with httpx.Client(
165
+ **self._client_kwargs(),
166
+ follow_redirects=True,
167
+ cookies=self.cookies or None,
168
+ ) as client:
169
+ response = client.get(url, headers={**self.headers, **(headers or {})}, **kwargs)
170
+ return Response(
171
+ status_code=response.status_code,
172
+ url=str(response.url),
173
+ text=response.text,
174
+ latency_ms=(time.monotonic() - started) * 1000,
175
+ egress_country=self._egress_country,
176
+ )
177
+
178
+ # ── egress ───────────────────────────────────────────────────────────────
179
+
180
+ def detect_egress(self, *, timeout: float = 8.0) -> Egress:
181
+ """Ask several services where we are, and keep every answer.
182
+
183
+ Worth a couple of requests per session: a measurement whose result depends on
184
+ the exit should be able to say which exit, and how it knows.
185
+ """
186
+ import httpx
187
+
188
+ sources: list[tuple[str, str | None, str | None]] = []
189
+ for url, country_key, ip_key in EGRESS_LOOKUPS:
190
+ try:
191
+ kwargs = self._client_kwargs()
192
+ kwargs["timeout"] = timeout
193
+ with httpx.Client(**kwargs) as client:
194
+ response = client.get(url, headers={"Accept": "application/json"})
195
+ if response.status_code != 200:
196
+ continue
197
+ body = response.json()
198
+ sources.append((url, body.get(country_key), body.get(ip_key)))
199
+ except Exception:
200
+ continue
201
+ if len(sources) >= 2:
202
+ break # two agreeing sources is the bar; a third is only for ties
203
+
204
+ countries = [c for _u, c, _i in sources if c]
205
+ ips = [i for _u, _c, i in sources if i]
206
+ egress = Egress(
207
+ country=countries[0] if countries else None,
208
+ ip=ips[0] if ips else None,
209
+ sources=tuple(sources),
210
+ )
211
+ self._egress = egress
212
+ self._egress_country = egress.country
213
+ return egress
214
+
215
+ def detect_egress_country(self, *, timeout: float = 8.0) -> str | None:
216
+ """Ask where we are actually coming from, and remember it.
217
+
218
+ Worth one request per session: a search whose results depend on the exit
219
+ should be able to state which exit it used, and "I assumed the VPN was on" is
220
+ not a statement. `None` means unknown — never assumed, and never inferred
221
+ from which provider the config says is configured.
222
+ """
223
+ import httpx
224
+
225
+ self._egress_country = None
226
+ for url in EGRESS_LOOKUPS:
227
+ try:
228
+ with httpx.Client(proxy=self.proxy, timeout=timeout) as client:
229
+ response = client.get(url)
230
+ if response.status_code == 200 and response.text.strip():
231
+ self._egress_country = response.text.strip().splitlines()[0][:40]
232
+ break
233
+ except Exception:
234
+ continue
235
+ return self._egress_country
236
+
237
+ @property
238
+ def egress_country(self) -> str | None:
239
+ return self._egress_country
240
+
241
+ @property
242
+ def egress(self) -> Egress | None:
243
+ return self._egress
244
+
245
+ def pos_with_egress(self) -> POSContext:
246
+ """The POS as it should be recorded on an offer: what was *observed*.
247
+
248
+ If the exit country is known and differs from what the intent asked for, the
249
+ observed value wins — the offer says where it really came from, and the
250
+ mismatch is the report's problem to raise, not something to quietly overwrite.
251
+ """
252
+ if self._egress_country is None:
253
+ return self.pos
254
+ return self.pos.model_copy(update={"ip_country": self._egress_country})
255
+
256
+
257
+ COUNTRY_NAMES: dict[str, str] = {
258
+ "AE": "united arab emirates",
259
+ "AT": "austria",
260
+ "AU": "australia",
261
+ "BE": "belgium",
262
+ "BG": "bulgaria",
263
+ "BR": "brazil",
264
+ "CA": "canada",
265
+ "CH": "switzerland",
266
+ "CY": "cyprus",
267
+ "CZ": "czechia",
268
+ "DE": "germany",
269
+ "DK": "denmark",
270
+ "EE": "estonia",
271
+ "ES": "spain",
272
+ "FI": "finland",
273
+ "FR": "france",
274
+ "GB": "united kingdom",
275
+ "GR": "greece",
276
+ "HR": "croatia",
277
+ "HU": "hungary",
278
+ "IE": "ireland",
279
+ "IN": "india",
280
+ "IS": "iceland",
281
+ "IT": "italy",
282
+ "JP": "japan",
283
+ "LI": "liechtenstein",
284
+ "LT": "lithuania",
285
+ "LU": "luxembourg",
286
+ "LV": "latvia",
287
+ "MT": "malta",
288
+ "NL": "netherlands",
289
+ "NO": "norway",
290
+ "PL": "poland",
291
+ "PT": "portugal",
292
+ "RO": "romania",
293
+ "SE": "sweden",
294
+ "SG": "singapore",
295
+ "SI": "slovenia",
296
+ "SK": "slovakia",
297
+ "TR": "turkey",
298
+ "US": "united states",
299
+ }
300
+ """Enough ISO alpha-2 to compare answers, because services differ in *format*.
301
+
302
+ One returns "Hungary", another "HU". Treating that as a disagreement would cry wolf
303
+ on every measurement and hide the disagreements that matter — like a US exit one
304
+ service calls Czechia."""
305
+
306
+
307
+ def canonical_country(value: str | None) -> str | None:
308
+ """A comparable form: lower-cased name, with alpha-2 codes expanded."""
309
+ if not value:
310
+ return None
311
+ text = value.strip()
312
+ if len(text) == 2 and text.isalpha():
313
+ return COUNTRY_NAMES.get(text.upper(), text.lower())
314
+ return text.lower()
315
+
316
+
317
+ EU_EEA_CODES = frozenset(
318
+ [
319
+ "AT",
320
+ "BE",
321
+ "BG",
322
+ "HR",
323
+ "CY",
324
+ "CZ",
325
+ "DK",
326
+ "EE",
327
+ "FI",
328
+ "FR",
329
+ "DE",
330
+ "GR",
331
+ "HU",
332
+ "IE",
333
+ "IT",
334
+ "LV",
335
+ "LT",
336
+ "LU",
337
+ "MT",
338
+ "NL",
339
+ "PL",
340
+ "PT",
341
+ "RO",
342
+ "SK",
343
+ "SI",
344
+ "ES",
345
+ "SE",
346
+ "IS",
347
+ "LI",
348
+ "NO",
349
+ ]
350
+ )
351
+
352
+ EU_EEA_NAMES = frozenset(
353
+ [
354
+ "austria",
355
+ "belgium",
356
+ "bulgaria",
357
+ "croatia",
358
+ "cyprus",
359
+ "czechia",
360
+ "czech republic",
361
+ "denmark",
362
+ "estonia",
363
+ "finland",
364
+ "france",
365
+ "germany",
366
+ "greece",
367
+ "hungary",
368
+ "ireland",
369
+ "italy",
370
+ "latvia",
371
+ "lithuania",
372
+ "luxembourg",
373
+ "malta",
374
+ "netherlands",
375
+ "poland",
376
+ "portugal",
377
+ "romania",
378
+ "slovakia",
379
+ "slovenia",
380
+ "spain",
381
+ "sweden",
382
+ "iceland",
383
+ "liechtenstein",
384
+ "norway",
385
+ ]
386
+ )
387
+ """Lookups answer with a country *name* ("Hungary"), not always a code — so both forms
388
+ are recognised. Getting this wrong would report an EU exit as non-EU and make an
389
+ entirely expected consent wall look like a site change."""
390
+
391
+
392
+ class UnknownEgress(RuntimeError):
393
+ """The exit could not be determined, and the caller needs it to be."""
394
+
395
+
396
+ def check_egress(
397
+ egress: str | None, *, required: bool = True, override: bool = False
398
+ ) -> str | None:
399
+ """Refusal message when an unknown exit must not be used, else `None`.
400
+
401
+ The fail-closed rule in `pos.toml` was written for a *configured proxy* that is
402
+ down. This is the other way to end up somewhere unknown: the geolocation lookups
403
+ themselves fail, and the search carries on and records prices with no idea which
404
+ market produced them. Those observations look identical to good ones in the
405
+ session store, and a later comparison reads them as peers.
406
+
407
+ So an unknown exit is a refusal by default, overridable per run — because for a
408
+ home-market oracle it genuinely does not matter, and the owner is entitled to say
409
+ so out loud rather than have it assumed.
410
+ """
411
+ if egress is not None or not required or override:
412
+ return None
413
+ return (
414
+ "cannot determine which exit this request would leave from — every "
415
+ "geolocation lookup failed. Prices recorded now could not be attributed to a "
416
+ "market, and would sit in the session store looking like any other "
417
+ "observation. Re-run when the network settles, or pass --allow-unknown-exit "
418
+ "if the market does not matter for this oracle."
419
+ )
420
+
421
+
422
+ def is_eu_egress(country: str | None) -> bool | None:
423
+ """Whether a consent wall is expected. `None` when the egress is unknown."""
424
+ if country is None:
425
+ return None
426
+ return (
427
+ country.strip().upper() in EU_EEA_CODES
428
+ or (canonical_country(country) or "") in EU_EEA_NAMES
429
+ )
@@ -0,0 +1 @@
1
+ """Preferences: valuations, travellers, parties."""