pkdb 0.11.0__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.
pkdb/__init__.py ADDED
@@ -0,0 +1,22 @@
1
+ """PK-DB Python client and shared scientific preparation engine."""
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ __version__ = "0.11.0"
6
+
7
+ from pkdb.domain.vocabulary import Vocabulary
8
+ from pkdb.preparation import PreparedBundle, prepare
9
+
10
+ if TYPE_CHECKING:
11
+ from pkdb.client import Client
12
+
13
+
14
+ def __getattr__(name):
15
+ if name == "Client":
16
+ from pkdb.client import Client
17
+
18
+ return Client
19
+ raise AttributeError(name)
20
+
21
+
22
+ __all__ = ["Client", "PreparedBundle", "Vocabulary", "__version__", "prepare"]
pkdb/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from pkdb.cli import main
2
+
3
+ raise SystemExit(main())
pkdb/cache.py ADDED
@@ -0,0 +1,132 @@
1
+ """Explicit, content-addressed offline vocabulary snapshots."""
2
+
3
+ import hashlib
4
+ import json
5
+ import os
6
+ import sys
7
+ from importlib.resources import files
8
+ from pathlib import Path
9
+ from tempfile import NamedTemporaryFile
10
+ from urllib.parse import urlsplit, urlunsplit
11
+
12
+ from pkdb.domain.vocabulary import Vocabulary, vocabulary_hash
13
+
14
+
15
+ def endpoint_root(value: str) -> str:
16
+ url = urlsplit(value)
17
+ if (
18
+ url.scheme not in {"http", "https"}
19
+ or not url.hostname
20
+ or url.username
21
+ or url.password
22
+ or url.query
23
+ or url.fragment
24
+ ):
25
+ raise ValueError(
26
+ "Endpoint must be an HTTP(S) URL without credentials, query, or fragment"
27
+ )
28
+ # Reject invalid ports here, before any request is attempted.
29
+ _ = url.port
30
+ path = url.path.rstrip("/")
31
+ if path.endswith(("/api/v1", "/api/v2")):
32
+ path = path[:-7]
33
+ return urlunsplit((url.scheme.lower(), url.netloc.lower(), path, "", ""))
34
+
35
+
36
+ def vocabulary_envelope(vocabulary: Vocabulary) -> dict:
37
+ return {
38
+ "schema_version": 1,
39
+ "vocabulary": vocabulary.model_dump(mode="json"),
40
+ "vocabulary_hash": vocabulary_hash(vocabulary),
41
+ }
42
+
43
+
44
+ def parse_vocabulary(value: dict) -> Vocabulary:
45
+ if value.get("schema_version") != 1:
46
+ raise ValueError("Unsupported vocabulary snapshot schema")
47
+ vocabulary = Vocabulary.model_validate(value.get("vocabulary"))
48
+ if value.get("vocabulary_hash") != vocabulary_hash(vocabulary):
49
+ raise ValueError("Vocabulary snapshot hash does not match its contents")
50
+ return vocabulary
51
+
52
+
53
+ def atomic_json(path: Path, value: dict) -> None:
54
+ path.parent.mkdir(parents=True, exist_ok=True)
55
+ temporary = None
56
+ try:
57
+ with NamedTemporaryFile(
58
+ mode="w", encoding="utf-8", dir=path.parent, delete=False
59
+ ) as handle:
60
+ temporary = Path(handle.name)
61
+ json.dump(value, handle, ensure_ascii=False, allow_nan=False, indent=2)
62
+ handle.write("\n")
63
+ temporary.replace(path)
64
+ finally:
65
+ if temporary is not None:
66
+ temporary.unlink(missing_ok=True)
67
+
68
+
69
+ def load_vocabulary(path: str | Path) -> Vocabulary:
70
+ value = json.loads(Path(path).read_text(encoding="utf-8"))
71
+ if not isinstance(value, dict):
72
+ raise ValueError("Expected a vocabulary snapshot object")
73
+ return parse_vocabulary(value)
74
+
75
+
76
+ def bundled_vocabulary() -> Vocabulary:
77
+ return parse_vocabulary(
78
+ json.loads(
79
+ files("pkdb").joinpath("data/vocabulary.json").read_text(encoding="utf-8")
80
+ )
81
+ )
82
+
83
+
84
+ def cache_directory() -> Path:
85
+ if value := os.environ.get("PKDB_CACHE_DIR"):
86
+ return Path(value)
87
+ if sys.platform == "win32":
88
+ root = Path(os.environ.get("LOCALAPPDATA", str(Path.home() / "AppData/Local")))
89
+ elif sys.platform == "darwin":
90
+ root = Path.home() / "Library/Caches"
91
+ else:
92
+ root = Path(os.environ.get("XDG_CACHE_HOME", str(Path.home() / ".cache")))
93
+ return root / "pkdb"
94
+
95
+
96
+ class VocabularyCache:
97
+ def __init__(self, directory: str | Path | None = None):
98
+ self.directory = Path(directory) if directory is not None else cache_directory()
99
+
100
+ def _pointer(self, endpoint: str) -> Path:
101
+ key = hashlib.sha256(endpoint_root(endpoint).encode()).hexdigest()
102
+ return self.directory / "endpoints" / f"{key}.json"
103
+
104
+ def store(self, endpoint: str, vocabulary: Vocabulary) -> Path:
105
+ digest = vocabulary_hash(vocabulary)
106
+ path = self.directory / "snapshots" / f"{digest}.json"
107
+ atomic_json(path, vocabulary_envelope(vocabulary))
108
+ atomic_json(self._pointer(endpoint), {"vocabulary_hash": digest})
109
+ return path
110
+
111
+ def load(self, endpoint: str) -> Vocabulary:
112
+ value = json.loads(self._pointer(endpoint).read_text(encoding="utf-8"))
113
+ if not isinstance(value, dict):
114
+ raise ValueError("Invalid vocabulary cache index")
115
+ digest = value.get("vocabulary_hash")
116
+ if (
117
+ not isinstance(digest, str)
118
+ or len(digest) != 64
119
+ or any(c not in "0123456789abcdef" for c in digest)
120
+ ):
121
+ raise ValueError("Invalid vocabulary cache index")
122
+ try:
123
+ vocabulary = load_vocabulary(
124
+ self.directory / "snapshots" / f"{digest}.json"
125
+ )
126
+ except FileNotFoundError:
127
+ raise ValueError(
128
+ "Cached vocabulary snapshot is missing; synchronize again"
129
+ ) from None
130
+ if vocabulary_hash(vocabulary) != digest:
131
+ raise ValueError("Vocabulary cache index does not match snapshot")
132
+ return vocabulary
pkdb/cli.py ADDED
@@ -0,0 +1,158 @@
1
+ """Prepare, validate, and upload existing PK-DB study folders."""
2
+
3
+ import argparse
4
+ import json
5
+ import os
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from pkdb import __version__
10
+ from pkdb.cache import VocabularyCache, atomic_json, bundled_vocabulary
11
+ from pkdb.client import Client
12
+ from pkdb.domain.vocabulary import Vocabulary
13
+ from pkdb.errors import ClientError
14
+ from pkdb.preparation import prepare, study_folders
15
+ from pkdb.schemas.validation import StudyValidationError
16
+
17
+
18
+ def _redact(value, token):
19
+ if isinstance(value, str) and token:
20
+ return value.replace(token, "[redacted]")
21
+ if isinstance(value, dict):
22
+ return {key: _redact(item, token) for key, item in value.items()}
23
+ if isinstance(value, list):
24
+ return [_redact(item, token) for item in value]
25
+ return value
26
+
27
+
28
+ def main(argv=None, *, client=None) -> int:
29
+ parser = argparse.ArgumentParser(prog="pkdb", description=__doc__)
30
+ parser.add_argument("--version", action="version", version=f"pkdb {__version__}")
31
+ commands = parser.add_subparsers(dest="command", required=True)
32
+ for name in ("prepare", "validate", "upload"):
33
+ command = commands.add_parser(name)
34
+ command.add_argument(
35
+ "folder", type=Path, help="Existing study folder or parent directory"
36
+ )
37
+ command.add_argument(
38
+ "--endpoint", "--api-url", default=os.environ.get("PKDB_ENDPOINT")
39
+ )
40
+ command.add_argument(
41
+ "--vocabulary", type=Path, help="Pinned vocabulary snapshot JSON"
42
+ )
43
+ command.add_argument("--cache-dir", type=Path)
44
+ command.add_argument(
45
+ "--offline",
46
+ action="store_true",
47
+ help="Never contact the server (prepare/validate only)",
48
+ )
49
+ command.add_argument(
50
+ "--output",
51
+ type=Path,
52
+ help="JSON output file outside the study folder (one study only)",
53
+ )
54
+ vocabulary = commands.add_parser("vocabulary")
55
+ actions = vocabulary.add_subparsers(dest="action", required=True)
56
+ sync = actions.add_parser(
57
+ "sync", help="Explicitly retrieve and cache the endpoint's validation rules"
58
+ )
59
+ sync.add_argument("--endpoint", default=os.environ.get("PKDB_ENDPOINT"))
60
+ sync.add_argument("--cache-dir", type=Path)
61
+ sync.add_argument(
62
+ "--output", type=Path, help="Portable project vocabulary lock file"
63
+ )
64
+ args = parser.parse_args(argv)
65
+ if args.command == "upload" and args.offline:
66
+ parser.error("upload requires network access; use validate --offline")
67
+ token = os.environ.get("PKDB_API_KEY")
68
+ cache = VocabularyCache(args.cache_dir)
69
+ try:
70
+ if args.command == "vocabulary":
71
+ with Client(
72
+ args.endpoint, api_key=token, transport=client, cache=cache
73
+ ) as api:
74
+ snapshot = api.vocabulary()
75
+ if args.output:
76
+ snapshot.save(args.output)
77
+ from pkdb.domain.vocabulary import vocabulary_hash
78
+
79
+ print(
80
+ json.dumps(
81
+ {
82
+ "ok": True,
83
+ "vocabulary_version": snapshot.version,
84
+ "vocabulary_hash": vocabulary_hash(snapshot),
85
+ }
86
+ )
87
+ )
88
+ return 0
89
+ folders = study_folders(args.folder)
90
+ if args.output:
91
+ if len(folders) != 1:
92
+ raise ValueError("--output requires a single study folder")
93
+ if args.output.resolve().is_relative_to(folders[0].resolve()):
94
+ raise ValueError(
95
+ "Write --output outside the study folder to keep source files unchanged"
96
+ )
97
+ if args.vocabulary:
98
+ snapshot = Vocabulary.load(args.vocabulary)
99
+ elif args.endpoint:
100
+ try:
101
+ snapshot = cache.load(args.endpoint)
102
+ except FileNotFoundError:
103
+ snapshot = bundled_vocabulary()
104
+ else:
105
+ snapshot = bundled_vocabulary()
106
+ if args.command == "upload" and (not args.endpoint or not token):
107
+ raise ValueError("Upload requires an endpoint and PKDB_API_KEY")
108
+ except (ValueError, OSError, ClientError) as error:
109
+ print(
110
+ json.dumps(_redact({"ok": False, "error": str(error)}, token)),
111
+ file=sys.stderr,
112
+ )
113
+ return 1
114
+ failed = False
115
+ for folder in folders:
116
+ result = {"path": str(folder), "ok": False}
117
+ try:
118
+ prepared = prepare(folder, vocabulary=snapshot)
119
+ result.update(sid=prepared.study.sid)
120
+ if args.command == "upload":
121
+ with Client(
122
+ args.endpoint, api_key=token, transport=client, cache=cache
123
+ ) as api:
124
+ result.update(api.upload(prepared).model_dump(mode="json"))
125
+ elif args.command == "prepare":
126
+ result.update(prepared.model_dump())
127
+ else:
128
+ result.update(
129
+ report=prepared.report.model_dump(mode="json"),
130
+ vocabulary_version=prepared.prepared.vocabulary_version,
131
+ vocabulary_hash=prepared.vocabulary_hash,
132
+ processing_version=prepared.prepared.processing_version,
133
+ )
134
+ result["ok"] = True
135
+ except StudyValidationError as error:
136
+ result.update(
137
+ error="Study validation failed",
138
+ report=error.report.model_dump(mode="json"),
139
+ )
140
+ except ClientError as error:
141
+ result.update(error=str(error), status_code=error.status_code)
142
+ if error.report:
143
+ result["report"] = error.report.model_dump(mode="json")
144
+ except (ValueError, OSError) as error:
145
+ result["error"] = str(error)
146
+ result = _redact(result, token)
147
+ if args.output:
148
+ try:
149
+ atomic_json(args.output, result)
150
+ except OSError as error:
151
+ result.update(ok=False, error=str(error))
152
+ print(json.dumps(result, ensure_ascii=False, allow_nan=False), flush=True)
153
+ failed |= not result["ok"]
154
+ return int(failed)
155
+
156
+
157
+ if __name__ == "__main__":
158
+ raise SystemExit(main())
pkdb/client.py ADDED
@@ -0,0 +1,369 @@
1
+ """Synchronous, typed HTTP API with explicit offline preparation."""
2
+
3
+ import json
4
+ import os
5
+ from contextlib import ExitStack
6
+ from pathlib import Path
7
+ from tempfile import NamedTemporaryFile
8
+ from typing import BinaryIO
9
+ from urllib.parse import quote
10
+
11
+ import httpx2
12
+ from pydantic import BaseModel, ConfigDict, Field, ValidationError
13
+
14
+ from pkdb import __version__
15
+ from pkdb.cache import (
16
+ VocabularyCache,
17
+ bundled_vocabulary,
18
+ endpoint_root,
19
+ parse_vocabulary,
20
+ )
21
+ from pkdb.domain.validation import PROCESSING_VERSION, prepare_study
22
+ from pkdb.domain.vocabulary import Vocabulary
23
+ from pkdb.errors import ClientError, CompatibilityError
24
+ from pkdb.importers.folder import parse_bundle
25
+ from pkdb.preparation import PreparedBundle, prepare
26
+ from pkdb.schemas.replacement import ReplacementResult
27
+ from pkdb.schemas.responses import (
28
+ GroupResponse,
29
+ IndividualResponse,
30
+ InterventionResponse,
31
+ OutputResponse,
32
+ ReferenceResponse,
33
+ StudyResponse,
34
+ )
35
+ from pkdb.schemas.study import CanonicalStudy
36
+ from pkdb.schemas.validation import ValidationReport, fail
37
+
38
+
39
+ class ResultPage[T: BaseModel](BaseModel):
40
+ items: list[T]
41
+ count: int = Field(ge=0)
42
+ page: int = Field(ge=1)
43
+ pages: int = Field(ge=1)
44
+
45
+
46
+ class UploadLimits(BaseModel):
47
+ max_rows: int = Field(gt=0)
48
+ max_files: int = Field(gt=0)
49
+ max_upload_bytes: int = Field(gt=0)
50
+ max_attachment_bytes: int = Field(gt=0)
51
+
52
+
53
+ class Capabilities(BaseModel):
54
+ model_config = ConfigDict(extra="ignore")
55
+ schema_version: int
56
+ server_version: str
57
+ processing_version: str
58
+ vocabulary_version: str
59
+ vocabulary_hash: str
60
+ upload_limits: UploadLimits
61
+
62
+
63
+ class Studies:
64
+ def __init__(self, client: Client):
65
+ self.client = client
66
+
67
+ def get(self, sid: str) -> CanonicalStudy:
68
+ return self.client._model(
69
+ CanonicalStudy,
70
+ self.client._request("GET", f"/api/v2/studies/{quote(str(sid), safe='')}"),
71
+ )
72
+
73
+ def list(self, **filters) -> ResultPage[StudyResponse]:
74
+ return self.client._page("studies", StudyResponse, filters)
75
+
76
+
77
+ class Client:
78
+ """An endpoint-scoped client. Construction performs no network requests.
79
+
80
+ A caller-supplied HTTP transport remains owned by the caller. Requests never
81
+ follow redirects and writes are never retried automatically.
82
+ """
83
+
84
+ def __init__(
85
+ self,
86
+ endpoint: str | None = None,
87
+ api_key: str | None = None,
88
+ *,
89
+ transport=None,
90
+ cache: VocabularyCache | None = None,
91
+ ):
92
+ endpoint = endpoint or os.environ.get("PKDB_ENDPOINT")
93
+ if not endpoint:
94
+ raise ValueError("Set PKDB_ENDPOINT or pass endpoint")
95
+ self.endpoint = endpoint_root(endpoint)
96
+ self._api_key = (
97
+ api_key if api_key is not None else os.environ.get("PKDB_API_KEY")
98
+ )
99
+ self._owns_transport = transport is None
100
+ self._transport = (
101
+ transport
102
+ if transport is not None
103
+ else httpx2.Client(
104
+ timeout=httpx2.Timeout(600, connect=10), follow_redirects=False
105
+ )
106
+ )
107
+ self.cache = cache if cache is not None else VocabularyCache()
108
+ self._vocabulary: Vocabulary | None = None
109
+ self.studies = Studies(self)
110
+
111
+ def __enter__(self):
112
+ return self
113
+
114
+ def __exit__(self, *args):
115
+ self.close()
116
+
117
+ def close(self):
118
+ if self._owns_transport:
119
+ self._transport.close()
120
+
121
+ def _headers(self, *, required=False) -> dict[str, str]:
122
+ if required and not self._api_key:
123
+ raise ClientError(
124
+ "Set PKDB_API_KEY or pass api_key for uploads and downloads"
125
+ )
126
+ headers = {"User-Agent": f"pkdb/{__version__}"}
127
+ if self._api_key:
128
+ kind = "Bearer" if self._api_key.startswith("pkdb_live_") else "Token"
129
+ headers["Authorization"] = f"{kind} {self._api_key}"
130
+ return headers
131
+
132
+ def _check(self, response):
133
+ if 200 <= response.status_code < 300:
134
+ return
135
+ report = None
136
+ body = None
137
+ try:
138
+ body = response.json()
139
+ if isinstance(body, dict) and isinstance(body.get("issues"), list):
140
+ report = ValidationReport.model_validate(body)
141
+ except ValueError, ValidationError:
142
+ pass
143
+ compatibility_messages = {
144
+ "vocabulary_mismatch": "Server vocabulary changed; synchronize vocabulary and prepare again",
145
+ "vocabulary_changed": "Server vocabulary changed during upload; synchronize vocabulary and prepare again",
146
+ "processing_version_mismatch": "Server processing rules differ; install the matching pkdb release and prepare again",
147
+ }
148
+ detail = body.get("detail") if isinstance(body, dict) else None
149
+ message = f"PK-DB request rejected (HTTP {response.status_code})"
150
+ error = ClientError
151
+ if (
152
+ response.status_code == 409
153
+ and isinstance(detail, str)
154
+ and detail in compatibility_messages
155
+ ):
156
+ error = CompatibilityError
157
+ message = compatibility_messages[detail]
158
+ raise error(
159
+ message,
160
+ status_code=response.status_code,
161
+ report=report,
162
+ )
163
+
164
+ def _request(self, method, path, *, headers=None, **kwargs):
165
+ request_headers = {**self._headers(), **(headers or {})}
166
+ try:
167
+ response = self._transport.request(
168
+ method,
169
+ self.endpoint + path,
170
+ headers=request_headers,
171
+ follow_redirects=False,
172
+ **kwargs,
173
+ )
174
+ except httpx2.RequestError:
175
+ message = "PK-DB request failed"
176
+ if method != "GET":
177
+ message += (
178
+ "; upload outcome may be unknown. Inspect the study before retrying"
179
+ )
180
+ raise ClientError(message) from None
181
+ self._check(response)
182
+ return response
183
+
184
+ @staticmethod
185
+ def _model[T: BaseModel](model: type[T], response: httpx2.Response) -> T:
186
+ try:
187
+ return model.model_validate(response.json())
188
+ except ValueError, ValidationError:
189
+ raise ClientError("Server returned an invalid response contract") from None
190
+
191
+ def capabilities(self) -> Capabilities:
192
+ value = self._model(Capabilities, self._request("GET", "/api/v2/capabilities"))
193
+ if value.schema_version != 1:
194
+ raise CompatibilityError(
195
+ "Unsupported server client-protocol version; update pkdb"
196
+ )
197
+ return value
198
+
199
+ def vocabulary(self, *, refresh: bool = True) -> Vocabulary:
200
+ if refresh:
201
+ try:
202
+ value = parse_vocabulary(
203
+ self._request("GET", "/api/v2/vocabulary").json()
204
+ )
205
+ except ValueError, ValidationError, AttributeError:
206
+ raise ClientError(
207
+ "Server returned an invalid vocabulary snapshot"
208
+ ) from None
209
+ self.cache.store(self.endpoint, value)
210
+ else:
211
+ value = self.cache.load(self.endpoint)
212
+ self._vocabulary = value
213
+ return value
214
+
215
+ def _local_vocabulary(self) -> Vocabulary:
216
+ if self._vocabulary is not None:
217
+ return self._vocabulary
218
+ try:
219
+ return self.cache.load(self.endpoint)
220
+ except FileNotFoundError:
221
+ return bundled_vocabulary()
222
+
223
+ def upload(self, study: PreparedBundle | str | Path) -> ReplacementResult:
224
+ prepared = (
225
+ study
226
+ if isinstance(study, PreparedBundle)
227
+ else prepare(study, vocabulary=self._local_vocabulary())
228
+ )
229
+ headers = self._headers(required=True)
230
+ # Detect file changes and validate again against the captured vocabulary
231
+ # before contacting the server. Never trust mutated Pydantic objects.
232
+ with prepared.source() as source:
233
+ canonical = parse_bundle(source, max_rows=prepared.max_rows)
234
+ checked = prepare_study(canonical, prepared.vocabulary)
235
+ capabilities = self.capabilities()
236
+ if capabilities.processing_version != PROCESSING_VERSION:
237
+ raise CompatibilityError(
238
+ "Server processing rules differ; install the matching pkdb release and prepare again"
239
+ )
240
+ if capabilities.vocabulary_hash != prepared.vocabulary_hash:
241
+ raise CompatibilityError(
242
+ "Server vocabulary differs; run pkdb vocabulary sync and prepare again"
243
+ )
244
+ limits = capabilities.upload_limits
245
+ if len(source.files) > limits.max_files:
246
+ fail("file_limit", "Too many source files for this server")
247
+ if limits.max_rows < prepared.max_rows:
248
+ parse_bundle(source, max_rows=limits.max_rows)
249
+ sizes = [path.stat().st_size for path in source.files.values()]
250
+ if any(size > limits.max_attachment_bytes for size in sizes):
251
+ fail("file_limit", "Attachment exceeds the server's byte limit")
252
+ payloads = {
253
+ "study": json.dumps(source.study),
254
+ "reference": json.dumps(source.reference),
255
+ }
256
+ if (
257
+ sum(sizes) + sum(len(value.encode()) for value in payloads.values())
258
+ > limits.max_upload_bytes
259
+ ):
260
+ fail(
261
+ "file_limit", "Study bundle exceeds the server's upload byte limit"
262
+ )
263
+ headers.update(
264
+ {
265
+ "X-PKDB-Vocabulary-Hash": prepared.vocabulary_hash,
266
+ "X-PKDB-Processing-Version": PROCESSING_VERSION,
267
+ }
268
+ )
269
+ with ExitStack() as stack:
270
+ parts: list[
271
+ tuple[str, tuple[None, str] | tuple[str, BinaryIO, str]]
272
+ ] = [(name, (None, value)) for name, value in payloads.items()]
273
+ parts.extend(
274
+ (
275
+ "files",
276
+ (
277
+ name,
278
+ stack.enter_context(path.open("rb")),
279
+ "application/octet-stream",
280
+ ),
281
+ )
282
+ for name, path in source.files.items()
283
+ )
284
+ response = self._request(
285
+ "PUT",
286
+ f"/api/v2/studies/{quote(checked.study.sid, safe='')}",
287
+ headers=headers,
288
+ files=parts,
289
+ )
290
+ result = self._model(ReplacementResult, response)
291
+ if result.sid != checked.study.sid:
292
+ raise ClientError(
293
+ "Server did not confirm the uploaded study identifier"
294
+ )
295
+ return result
296
+
297
+ @staticmethod
298
+ def _params(filters: dict) -> dict:
299
+ params = {}
300
+ for key, value in filters.items():
301
+ if isinstance(value, (list, tuple)):
302
+ value = ",".join(str(item) for item in value)
303
+ elif isinstance(value, bool):
304
+ value = str(value).lower()
305
+ if value is not None:
306
+ params[key] = value
307
+ return params
308
+
309
+ def _page[T: BaseModel](
310
+ self, entity: str, model: type[T], filters: dict
311
+ ) -> ResultPage[T]:
312
+ params = self._params(filters)
313
+ response = self._request("GET", f"/api/v1/{entity}/", params=params)
314
+ try:
315
+ body = response.json()
316
+ return ResultPage(
317
+ items=[model.model_validate(item) for item in body["data"]["data"]],
318
+ count=body["data"]["count"],
319
+ page=body["current_page"],
320
+ pages=body["last_page"],
321
+ )
322
+ except ValueError, KeyError, TypeError, ValidationError:
323
+ raise ClientError("Server returned an invalid result page") from None
324
+
325
+ def query(self, entity: str, **filters) -> ResultPage:
326
+ models = {
327
+ "studies": StudyResponse,
328
+ "outputs": OutputResponse,
329
+ "groups": GroupResponse,
330
+ "individuals": IndividualResponse,
331
+ "interventions": InterventionResponse,
332
+ "references": ReferenceResponse,
333
+ }
334
+ if entity not in models:
335
+ raise ValueError(f"Unsupported query entity: {entity}")
336
+ return self._page(entity, models[entity], filters)
337
+
338
+ def download(self, destination: str | Path, **filters) -> Path:
339
+ """Stream an authenticated dataset ZIP to an atomic destination file."""
340
+ headers = self._headers(required=True)
341
+ path = Path(destination)
342
+ temporary = None
343
+ try:
344
+ with self._transport.stream(
345
+ "GET",
346
+ self.endpoint + "/api/v1/filter/",
347
+ params={**self._params(filters), "download": "true"},
348
+ headers=headers,
349
+ follow_redirects=False,
350
+ ) as response:
351
+ if not 200 <= response.status_code < 300:
352
+ response.read()
353
+ self._check(response)
354
+ if "zip" not in response.headers.get("content-type", ""):
355
+ raise ClientError("Server did not return a dataset ZIP")
356
+ path.parent.mkdir(parents=True, exist_ok=True)
357
+ with NamedTemporaryFile(dir=path.parent, delete=False) as handle:
358
+ temporary = Path(handle.name)
359
+ for chunk in response.iter_bytes():
360
+ handle.write(chunk)
361
+ temporary.replace(path)
362
+ except httpx2.RequestError:
363
+ raise ClientError(
364
+ "Dataset download failed; destination was not replaced"
365
+ ) from None
366
+ finally:
367
+ if temporary is not None:
368
+ temporary.unlink(missing_ok=True)
369
+ return path