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
|
@@ -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."""
|