Coverage for src/data_api/checks/common.py: 100%
41 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-13 19:28 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-13 19:28 +0000
1"""Safe diagnostics and HTTP document checks for external contract checks."""
3import hashlib
4import json
5from dataclasses import dataclass
7import httpx
8from pydantic import ValidationError
10from data_api.data.nthudata import JsonData
13@dataclass
14class CheckFailure(Exception):
15 target: str
16 stage: str
17 reason: str
19 def __str__(self) -> str:
20 return f"{self.target}: stage={self.stage} reason={self.reason}"
23async def fetch_json(
24 client: httpx.AsyncClient, url: str, target: str, checksum: str | None = None
25) -> tuple[JsonData, httpx.Response]:
26 try:
27 response = await client.get(url)
28 except httpx.TimeoutException as exc:
29 raise CheckFailure(target, "http", "timeout") from exc
30 except httpx.RequestError as exc:
31 raise CheckFailure(target, "http", "connection/request failure") from exc
32 if response.status_code != 200:
33 raise CheckFailure(target, "http", f"status {response.status_code}")
34 content_type = response.headers.get("content-type", "").split(";")[0].strip().lower()
35 if content_type != "application/json" and not (
36 content_type.startswith("application/") and content_type.endswith("+json")
37 ):
38 raise CheckFailure(target, "content-type", "expected application/json")
39 if checksum is not None and hashlib.sha256(response.content).hexdigest() != checksum:
40 raise CheckFailure(target, "checksum", "SHA-256 mismatch")
41 try:
42 payload = response.json()
43 except (json.JSONDecodeError, UnicodeDecodeError) as exc:
44 raise CheckFailure(target, "json", "invalid JSON") from exc
45 if not isinstance(payload, (dict, list)):
46 raise CheckFailure(target, "json", "expected an object or array")
47 return payload, response
50def validation_reason(exc: ValidationError) -> str:
51 """Report field locations and categories without copying upstream values."""
52 return "; ".join(
53 f"{'.'.join(map(str, error['loc'])) or '<root>'}: {error['type']}"
54 for error in exc.errors(include_url=False, include_context=False, include_input=False)[:5]
55 )
58def report(failures: list[CheckFailure], label: str) -> int:
59 if failures:
60 for failure in failures:
61 print(f"FAIL {label} {failure}")
62 print(f"FAIL {label}: {len(failures)} external contract check(s) failed")
63 return 1
64 print(f"PASS {label}")
65 return 0