Coverage for src/data_api/checks/published.py: 85%
125 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"""Opt-in publisher checks: python -m data_api.checks.published."""
3import argparse
4import asyncio
5import json
6import math
7from typing import Any
9import httpx
10from pydantic import TypeAdapter, ValidationError
12from data_api.api.api import create_app
13from data_api.api.schemas.buses import BusCanonicalDetailedSchedule, BusCanonicalSchedule
14from data_api.api.schemas.courses import CourseData
15from data_api.checks.common import CheckFailure, fetch_json, report, validation_reason
16from data_api.checks.smoke import ENDPOINTS, EndpointCheck, check_endpoints
17from data_api.core.config import PREFETCH_ENDPOINTS
18from data_api.data.nthudata import (
19 FetchFailure,
20 JsonData,
21 ManifestEntry,
22 NTHUDataManager,
23 parse_manifest,
24)
25from data_api.domain.buses.services import PreparedBusData
26from data_api.domain.courses.services import prepare_catalog, prepare_courses
27from data_api.runtime import Runtime
29MANIFEST_PATH = "/file_details.json"
32def _check_serialization(path: str, candidate: Any) -> None:
33 try:
34 if path == "/courses/latest.json" or path.startswith("/courses/semesters/"):
35 adapter = TypeAdapter(list[CourseData])
36 parsed = adapter.validate_python(candidate, from_attributes=True)
37 adapter.dump_json(parsed, warnings="error")
38 elif isinstance(candidate, PreparedBusData):
39 for model, store in [
40 (BusCanonicalSchedule, candidate.raw_schedule_data),
41 (BusCanonicalDetailedSchedule, candidate.detailed_schedule_data),
42 ]:
43 adapter = TypeAdapter(list[model])
44 for rows in store.values():
45 adapter.dump_json(adapter.validate_python(rows), warnings="error")
46 else:
47 TypeAdapter(JsonData).dump_json(candidate, warnings="error")
48 except (ValidationError, ValueError) as exc:
49 reason = validation_reason(exc) if isinstance(exc, ValidationError) else type(exc).__name__
50 raise CheckFailure(path, "serialization", reason) from exc
53async def _check_download(
54 client: httpx.AsyncClient, data_url: str, path: str, checksum: str | None, runtime: Runtime
55) -> bytes:
56 payload, response = await fetch_json(client, f"{data_url}{path}", path, checksum)
57 try:
58 if path.startswith("/courses/semesters/"):
59 semester = path.rsplit("/", 1)[1].removesuffix(".json")
60 candidate = prepare_courses(payload, semester)
61 else:
62 candidate = runtime.datasets.state_for(path).prepare(payload)
63 except ValidationError as exc:
64 raise CheckFailure(path, "candidate", validation_reason(exc)) from exc
65 except FetchFailure as exc:
66 raise CheckFailure(path, "candidate", exc.category) from exc
67 except ValueError as exc:
68 raise CheckFailure(path, "transformation", type(exc).__name__) from exc
69 _check_serialization(path, candidate)
70 return response.content
73async def _check_local_responses(
74 documents: dict[str, bytes], entries: dict[str, ManifestEntry], runtime: Runtime
75) -> list[CheckFailure]:
76 def serve_downloaded(request: httpx.Request) -> httpx.Response:
77 content = documents.get(request.url.path)
78 if content is None:
79 return httpx.Response(404)
80 return httpx.Response(200, content=content, headers={"content-type": "application/json"})
82 # Serialization uses exactly the checked bytes, never a second publisher download or live API.
83 failures = []
84 nthudata = runtime.datasets
85 async with nthudata.lifespan(
86 httpx.AsyncClient(transport=httpx.MockTransport(serve_downloaded))
87 ):
88 results = await nthudata.prefetch(PREFETCH_ENDPOINTS)
89 for endpoint, usable in results.items():
90 if not usable:
91 state = nthudata.state_for(endpoint)
92 failures.append(
93 CheckFailure(
94 f"/{endpoint}",
95 "installation",
96 state.last_error.category if state.last_error else "unavailable",
97 )
98 )
99 if failures:
100 return failures
101 await runtime.courses.get_snapshot()
102 await runtime.buses.update_data()
103 expected_versions = {
104 check.path: entries[check.dataset].active_version for check in ENDPOINTS
105 }
106 expected_versions["/calendars"], _ = await runtime.calendars.get_all_calendars()
107 checks = list(ENDPOINTS)
108 for semester in prepare_catalog(json.loads(documents["/courses/sources.json"])):
109 path = f"/courses/{semester}"
110 dataset = f"/courses/semesters/{semester}.json"
111 checks.append(EndpointCheck(path, TypeAdapter(list[CourseData]), dataset, counted=True))
112 expected_versions[path] = entries[dataset].active_version
113 async with httpx.AsyncClient(
114 transport=httpx.ASGITransport(app=create_app(runtime)),
115 base_url="http://contract.test",
116 ) as local:
117 failures = await check_endpoints(
118 local, "http://contract.test", expected_versions, checks=checks
119 )
120 return [
121 CheckFailure(failure.target, f"serialization/{failure.stage}", failure.reason)
122 for failure in failures
123 ]
126async def check_published(client: httpx.AsyncClient, data_url: str) -> list[CheckFailure]:
127 data_url = data_url.rstrip("/")
128 try:
129 manifest, manifest_response = await fetch_json(
130 client, f"{data_url}{MANIFEST_PATH}", MANIFEST_PATH
131 )
132 try:
133 entries = parse_manifest(manifest)
134 except FetchFailure as exc:
135 raise CheckFailure(MANIFEST_PATH, "manifest", exc.category) from exc
136 except CheckFailure as exc:
137 return [exc]
139 failures = []
140 runtime = Runtime.create(NTHUDataManager(base_url=data_url))
141 documents = {MANIFEST_PATH: manifest_response.content}
142 for endpoint in PREFETCH_ENDPOINTS:
143 path = f"/{endpoint}"
144 try:
145 if path not in entries:
146 raise CheckFailure(path, "manifest", "required dataset is missing")
147 documents[path] = await _check_download(
148 client, data_url, path, entries[path].sha256, runtime
149 )
150 except CheckFailure as exc:
151 failures.append(exc)
152 if failures:
153 return failures
154 semesters = prepare_catalog(json.loads(documents["/courses/sources.json"]))
155 for semester in semesters:
156 path = f"/courses/semesters/{semester}.json"
157 try:
158 if path not in entries:
159 raise CheckFailure(path, "manifest", "published semester dataset is missing")
160 documents[path] = await _check_download(
161 client, data_url, path, entries[path].sha256, runtime
162 )
163 except CheckFailure as exc:
164 failures.append(exc)
165 if failures:
166 return failures
167 return await _check_local_responses(documents, entries, runtime)
170async def run(data_url: str, request_timeout: float) -> int:
171 async with httpx.AsyncClient(timeout=request_timeout, follow_redirects=True) as client:
172 return report(await check_published(client, data_url), "published-data")
175def main() -> int:
176 parser = argparse.ArgumentParser(description=__doc__)
177 parser.add_argument("--data-url", default="https://data.nthusa.tw")
178 parser.add_argument("--timeout", type=float, default=15)
179 args = parser.parse_args()
180 if not math.isfinite(args.timeout) or args.timeout <= 0:
181 parser.error("--timeout must be finite and positive")
182 return asyncio.run(run(args.data_url, args.timeout))
185if __name__ == "__main__":
186 raise SystemExit(main())