pkdb 0.11.0__tar.gz

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.
Files changed (47) hide show
  1. pkdb-0.11.0/.gitignore +32 -0
  2. pkdb-0.11.0/LICENSE +7 -0
  3. pkdb-0.11.0/PKG-INFO +53 -0
  4. pkdb-0.11.0/README.md +24 -0
  5. pkdb-0.11.0/pyproject.toml +60 -0
  6. pkdb-0.11.0/src/pkdb/__init__.py +22 -0
  7. pkdb-0.11.0/src/pkdb/__main__.py +3 -0
  8. pkdb-0.11.0/src/pkdb/cache.py +132 -0
  9. pkdb-0.11.0/src/pkdb/cli.py +158 -0
  10. pkdb-0.11.0/src/pkdb/client.py +369 -0
  11. pkdb-0.11.0/src/pkdb/data/vocabulary.json +11657 -0
  12. pkdb-0.11.0/src/pkdb/domain/datasets.py +161 -0
  13. pkdb-0.11.0/src/pkdb/domain/normalization.py +76 -0
  14. pkdb-0.11.0/src/pkdb/domain/pharmacokinetics.py +238 -0
  15. pkdb-0.11.0/src/pkdb/domain/provenance.py +32 -0
  16. pkdb-0.11.0/src/pkdb/domain/statistics.py +90 -0
  17. pkdb-0.11.0/src/pkdb/domain/units.py +19 -0
  18. pkdb-0.11.0/src/pkdb/domain/validation.py +410 -0
  19. pkdb-0.11.0/src/pkdb/domain/vocabulary.py +87 -0
  20. pkdb-0.11.0/src/pkdb/errors.py +24 -0
  21. pkdb-0.11.0/src/pkdb/importers/expressions.py +120 -0
  22. pkdb-0.11.0/src/pkdb/importers/folder.py +495 -0
  23. pkdb-0.11.0/src/pkdb/importers/structure.py +58 -0
  24. pkdb-0.11.0/src/pkdb/importers/workbook.py +79 -0
  25. pkdb-0.11.0/src/pkdb/preparation.py +130 -0
  26. pkdb-0.11.0/src/pkdb/py.typed +0 -0
  27. pkdb-0.11.0/src/pkdb/schemas/accounts.py +44 -0
  28. pkdb-0.11.0/src/pkdb/schemas/admin_users.py +27 -0
  29. pkdb-0.11.0/src/pkdb/schemas/analysis.py +193 -0
  30. pkdb-0.11.0/src/pkdb/schemas/bundle.py +10 -0
  31. pkdb-0.11.0/src/pkdb/schemas/filters.py +23 -0
  32. pkdb-0.11.0/src/pkdb/schemas/legacy.py +11 -0
  33. pkdb-0.11.0/src/pkdb/schemas/prepared.py +14 -0
  34. pkdb-0.11.0/src/pkdb/schemas/profiles.py +50 -0
  35. pkdb-0.11.0/src/pkdb/schemas/queries.py +42 -0
  36. pkdb-0.11.0/src/pkdb/schemas/replacement.py +20 -0
  37. pkdb-0.11.0/src/pkdb/schemas/responses.py +264 -0
  38. pkdb-0.11.0/src/pkdb/schemas/security.py +29 -0
  39. pkdb-0.11.0/src/pkdb/schemas/source.py +21 -0
  40. pkdb-0.11.0/src/pkdb/schemas/study.py +182 -0
  41. pkdb-0.11.0/src/pkdb/schemas/validation.py +49 -0
  42. pkdb-0.11.0/tests/conftest.py +106 -0
  43. pkdb-0.11.0/tests/test_cache.py +54 -0
  44. pkdb-0.11.0/tests/test_cli.py +140 -0
  45. pkdb-0.11.0/tests/test_client.py +303 -0
  46. pkdb-0.11.0/tests/test_preparation.py +69 -0
  47. pkdb-0.11.0/uv.lock +759 -0
pkdb-0.11.0/.gitignore ADDED
@@ -0,0 +1,32 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.log
4
+ *.ods#
5
+ *.xlsx#
6
+ .env
7
+ .env.*
8
+ !.env.example
9
+ .venv*/
10
+ .cache/
11
+ .pytest_cache/
12
+ .ruff_cache/
13
+ .coverage*
14
+ coverage.xml
15
+ htmlcov/
16
+ dist/
17
+ *.egg-info/
18
+ .ipynb_checkpoints/
19
+ .idea/
20
+ /site/
21
+ node_modules/
22
+ .git-credentials
23
+ *.dump
24
+ *.sql.gz
25
+
26
+ # Preserve ignored local data and caches from existing checkouts.
27
+ /volumes/
28
+ /static/
29
+ media/
30
+ .static_storage/
31
+ .tox/
32
+ .hypothesis/
pkdb-0.11.0/LICENSE ADDED
@@ -0,0 +1,7 @@
1
+ Copyright (c) 2017-2026 Matthias König
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
+
5
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
+
7
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
pkdb-0.11.0/PKG-INFO ADDED
@@ -0,0 +1,53 @@
1
+ Metadata-Version: 2.5
2
+ Name: pkdb
3
+ Version: 0.11.0
4
+ Summary: Prepare, validate, query, and upload PK-DB studies
5
+ Project-URL: Homepage, https://pk-db.com
6
+ Project-URL: Documentation, https://matthiaskoenig.github.io/pkdb/
7
+ Project-URL: Repository, https://github.com/matthiaskoenig/pkdb
8
+ Project-URL: Issues, https://github.com/matthiaskoenig/pkdb/issues
9
+ Author: Matthias König
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: pharmacokinetics,pk-db,validation
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Science/Research
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Programming Language :: Python :: 3.14
18
+ Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
19
+ Requires-Python: <3.15,>=3.14
20
+ Requires-Dist: httpx2<3,>=2.13.0
21
+ Requires-Dist: numpy<3,>=2.5.3
22
+ Requires-Dist: openpyxl<4,>=3.1.5
23
+ Requires-Dist: pandas<4,>=3.0.6
24
+ Requires-Dist: pint<1,>=0.26.1
25
+ Requires-Dist: pkpdutils<2,>=1.2.0
26
+ Requires-Dist: pydantic<3,>=2.13.5
27
+ Requires-Dist: scipy<2,>=1.18.1
28
+ Description-Content-Type: text/markdown
29
+
30
+ # PK-DB Python client
31
+
32
+ Prepare, validate, query, and upload pharmacokinetic studies with the same scientific validation engine used by the PK-DB server. Python 3.14 is supported on Linux, macOS, and Windows.
33
+
34
+ Install a published release with `pip install pkdb`, or install this checkout with `pip install ./python` from the repository root.
35
+
36
+ Existing `pkdb_data` study folders work directly:
37
+
38
+ ```bash
39
+ pkdb prepare studies/ExampleStudy
40
+ pkdb validate studies/ExampleStudy --offline
41
+ pkdb upload studies/ExampleStudy --endpoint https://pk-db.com
42
+ ```
43
+
44
+ Preparation and validation work offline using the bundled vocabulary. Upload validates locally first and then the server validates the original files again. Set `PKDB_API_KEY` for authenticated operations and optionally `PKDB_ENDPOINT` for the default endpoint.
45
+
46
+ ```python
47
+ from pkdb import prepare
48
+
49
+ prepared = prepare("studies/ExampleStudy")
50
+ print(prepared.study.sid)
51
+ ```
52
+
53
+ See the [client documentation](https://matthiaskoenig.github.io/pkdb/python-client/) for vocabulary snapshots, structured reports, authentication, queries, and downloads. The source repository contains the separate `pkdb-server` distribution, which depends on the exact matching public client release.
pkdb-0.11.0/README.md ADDED
@@ -0,0 +1,24 @@
1
+ # PK-DB Python client
2
+
3
+ Prepare, validate, query, and upload pharmacokinetic studies with the same scientific validation engine used by the PK-DB server. Python 3.14 is supported on Linux, macOS, and Windows.
4
+
5
+ Install a published release with `pip install pkdb`, or install this checkout with `pip install ./python` from the repository root.
6
+
7
+ Existing `pkdb_data` study folders work directly:
8
+
9
+ ```bash
10
+ pkdb prepare studies/ExampleStudy
11
+ pkdb validate studies/ExampleStudy --offline
12
+ pkdb upload studies/ExampleStudy --endpoint https://pk-db.com
13
+ ```
14
+
15
+ Preparation and validation work offline using the bundled vocabulary. Upload validates locally first and then the server validates the original files again. Set `PKDB_API_KEY` for authenticated operations and optionally `PKDB_ENDPOINT` for the default endpoint.
16
+
17
+ ```python
18
+ from pkdb import prepare
19
+
20
+ prepared = prepare("studies/ExampleStudy")
21
+ print(prepared.study.sid)
22
+ ```
23
+
24
+ See the [client documentation](https://matthiaskoenig.github.io/pkdb/python-client/) for vocabulary snapshots, structured reports, authentication, queries, and downloads. The source repository contains the separate `pkdb-server` distribution, which depends on the exact matching public client release.
@@ -0,0 +1,60 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.32.4"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "pkdb"
7
+ version = "0.11.0"
8
+ description = "Prepare, validate, query, and upload PK-DB studies"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ authors = [{ name = "Matthias König" }]
13
+ keywords = ["pharmacokinetics", "validation", "pk-db"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Science/Research",
17
+ "Operating System :: OS Independent",
18
+ "Programming Language :: Python :: 3 :: Only",
19
+ "Programming Language :: Python :: 3.14",
20
+ "Topic :: Scientific/Engineering :: Bio-Informatics",
21
+ ]
22
+ requires-python = ">=3.14,<3.15"
23
+ dependencies = [
24
+ "pydantic>=2.13.5,<3",
25
+ "pandas>=3.0.6,<4",
26
+ "numpy>=2.5.3,<3",
27
+ "scipy>=1.18.1,<2",
28
+ "pint>=0.26.1,<1",
29
+ "openpyxl>=3.1.5,<4",
30
+ "pkpdutils>=1.2.0,<2",
31
+ "httpx2>=2.13.0,<3",
32
+ ]
33
+
34
+ [project.urls]
35
+ Homepage = "https://pk-db.com"
36
+ Documentation = "https://matthiaskoenig.github.io/pkdb/"
37
+ Repository = "https://github.com/matthiaskoenig/pkdb"
38
+ Issues = "https://github.com/matthiaskoenig/pkdb/issues"
39
+
40
+ [project.scripts]
41
+ pkdb = "pkdb.cli:main"
42
+
43
+ [dependency-groups]
44
+ dev = ["pytest>=9.1.1", "ruff>=0.16.8", "ty>=0.0.83"]
45
+
46
+ [tool.hatch.build.targets.wheel]
47
+ packages = ["src/pkdb"]
48
+
49
+ [tool.ruff]
50
+ target-version = "py314"
51
+
52
+ [tool.ruff.lint]
53
+ select = ["E4", "E7", "E9", "F", "I", "UP"]
54
+
55
+ [tool.ty.environment]
56
+ python-version = "3.14"
57
+
58
+ [tool.pytest.ini_options]
59
+ testpaths = ["tests"]
60
+ addopts = "--strict-markers"
@@ -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"]
@@ -0,0 +1,3 @@
1
+ from pkdb.cli import main
2
+
3
+ raise SystemExit(main())
@@ -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
@@ -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())