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 +22 -0
- pkdb/__main__.py +3 -0
- pkdb/cache.py +132 -0
- pkdb/cli.py +158 -0
- pkdb/client.py +369 -0
- pkdb/data/vocabulary.json +11657 -0
- pkdb/domain/datasets.py +161 -0
- pkdb/domain/normalization.py +76 -0
- pkdb/domain/pharmacokinetics.py +238 -0
- pkdb/domain/provenance.py +32 -0
- pkdb/domain/statistics.py +90 -0
- pkdb/domain/units.py +19 -0
- pkdb/domain/validation.py +410 -0
- pkdb/domain/vocabulary.py +87 -0
- pkdb/errors.py +24 -0
- pkdb/importers/expressions.py +120 -0
- pkdb/importers/folder.py +495 -0
- pkdb/importers/structure.py +58 -0
- pkdb/importers/workbook.py +79 -0
- pkdb/preparation.py +130 -0
- pkdb/py.typed +0 -0
- pkdb/schemas/accounts.py +44 -0
- pkdb/schemas/admin_users.py +27 -0
- pkdb/schemas/analysis.py +193 -0
- pkdb/schemas/bundle.py +10 -0
- pkdb/schemas/filters.py +23 -0
- pkdb/schemas/legacy.py +11 -0
- pkdb/schemas/prepared.py +14 -0
- pkdb/schemas/profiles.py +50 -0
- pkdb/schemas/queries.py +42 -0
- pkdb/schemas/replacement.py +20 -0
- pkdb/schemas/responses.py +264 -0
- pkdb/schemas/security.py +29 -0
- pkdb/schemas/source.py +21 -0
- pkdb/schemas/study.py +182 -0
- pkdb/schemas/validation.py +49 -0
- pkdb-0.11.0.dist-info/METADATA +53 -0
- pkdb-0.11.0.dist-info/RECORD +41 -0
- pkdb-0.11.0.dist-info/WHEEL +4 -0
- pkdb-0.11.0.dist-info/entry_points.txt +2 -0
- pkdb-0.11.0.dist-info/licenses/LICENSE +7 -0
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
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
|