92 lines
5.1 KiB
Python
92 lines
5.1 KiB
Python
"""Bounded exact-ID summaries for v3 capture; no native ARR display claim."""
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
import urllib.error
|
|
|
|
from . import collect_arr_source as source, profile_summary as probe
|
|
|
|
|
|
class ProfileSummaryReader:
|
|
def __init__(self, archive, hotel_id, transport, *, max_profiles: int, key="", sleep=time.sleep):
|
|
source.Options("2000-01-01", hotel_id).validate()
|
|
source.require(type(max_profiles) is int and 1 <= max_profiles <= 10000, "invalid_profile_limit")
|
|
self.archive, self.hotel_id, self.transport = archive, hotel_id, transport
|
|
self.max_profiles, self._key, self.sleep = max_profiles, key, sleep
|
|
self.request_count = 0
|
|
self._cache: dict[str, tuple[bytes, str]] = {}
|
|
|
|
@property
|
|
def unique_profiles(self):
|
|
return len(self._cache)
|
|
|
|
def read(self, profile_id: str) -> tuple[bytes, str]:
|
|
method, path, payload = probe.request(profile_id)
|
|
if profile_id in self._cache:
|
|
return self._cache[profile_id]
|
|
source.require(self.unique_profiles < self.max_profiles, "profile_limit_exceeded")
|
|
for attempt in range(1, 4):
|
|
self.request_count += 1
|
|
prefix = f"profile-{self.request_count:06d}"
|
|
request_file = prefix + ".json"
|
|
self.archive.write(request_file, {"operation_id": probe.OPERATION, "method": method,
|
|
"path": path, "body": source.strict_json(payload), "attempt": attempt, "started_at": source.utc_now()})
|
|
try:
|
|
status, headers, raw = self.transport(method, path, payload)
|
|
except (urllib.error.URLError, TimeoutError, ConnectionError):
|
|
self.archive.write(prefix + ".meta.json", {"error": "transport_failure", "ended_at": source.utc_now()})
|
|
if attempt == 3:
|
|
raise source.CollectionError("profile_transport_retry_exhausted") from None
|
|
self.sleep(float(2 ** (attempt - 1)))
|
|
continue
|
|
source.require(not self._key or self._key.encode() not in raw, "secret_in_response")
|
|
headers = {name.lower(): value for name, value in headers.items()}
|
|
self.archive.write(prefix + ".response.bin", raw)
|
|
self.archive.write(prefix + ".meta.json", {"http_status": status, "ended_at": source.utc_now(),
|
|
"oversized": len(raw) > source.MAX_RESPONSE_BYTES,
|
|
"edge_request_id": headers.get("x-request-id"), "retry_after": headers.get("retry-after")})
|
|
source.require(len(raw) <= source.MAX_RESPONSE_BYTES, "profile_response_too_large")
|
|
if status in source.RETRY_STATUSES and attempt < 3:
|
|
self.sleep(source.retry_delay(headers, attempt))
|
|
continue
|
|
source.require(status == 200, "profile_http_failure")
|
|
# Wrong identity, hotel, operation, warnings or pagination are fatal.
|
|
probe.summary_row(raw, profile_id, self.hotel_id)
|
|
self._cache[profile_id] = (raw, request_file)
|
|
return self._cache[profile_id]
|
|
raise source.CollectionError("profile_retry_exhausted")
|
|
|
|
|
|
def assess(detail: dict, reader: ProfileSummaryReader) -> dict:
|
|
"""Return private per-reservation evidence; intrinsic name gaps stay explicit."""
|
|
try:
|
|
guest = probe.primary_guest(detail, include_prefix=True)
|
|
except source.CollectionError as error:
|
|
if str(error) not in {"profile_missing_guest_list", "profile_ambiguous_primary_guest",
|
|
"profile_missing_primary_info", "profile_missing_person_names",
|
|
"profile_ambiguous_primary_name"}:
|
|
raise
|
|
return {"error": str(error)}
|
|
raw, request_file = reader.read(guest.profile_id)
|
|
evidence = {"profile_id": guest.profile_id, "profile_request": request_file}
|
|
try:
|
|
inspected = probe.inspect_summary(raw, guest, reader.hotel_id, include_prefix=True)
|
|
evidence.update(name_type=inspected["name_type"],
|
|
component_comparisons=inspected["component_comparisons"],
|
|
full_name_state=inspected["full_name_state"])
|
|
source.require(inspected["name_type"] in (None, "Primary"), "profile_nonprimary_summary_name")
|
|
source.require(all(value in {"equal", "both_absent"}
|
|
for value in inspected["component_comparisons"].values()), "profile_name_components_mismatch")
|
|
source.require(any(guest.name.get(key, "").strip() for key in ("surname", "givenName", "middleName")),
|
|
"profile_primary_name_unverifiable")
|
|
source.require(inspected["full_name_state"] == "present", "profile_full_name_unavailable")
|
|
except source.CollectionError as error:
|
|
if str(error) not in {"profile_missing_summary_profile", "profile_missing_former_name",
|
|
"profile_nonprimary_summary_name", "profile_name_components_mismatch",
|
|
"profile_primary_name_unverifiable", "profile_full_name_unavailable"}:
|
|
raise
|
|
evidence["error"] = str(error)
|
|
else:
|
|
evidence["full_name"] = inspected["full_name"]
|
|
return evidence
|