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.
- pkdb-0.11.0/.gitignore +32 -0
- pkdb-0.11.0/LICENSE +7 -0
- pkdb-0.11.0/PKG-INFO +53 -0
- pkdb-0.11.0/README.md +24 -0
- pkdb-0.11.0/pyproject.toml +60 -0
- pkdb-0.11.0/src/pkdb/__init__.py +22 -0
- pkdb-0.11.0/src/pkdb/__main__.py +3 -0
- pkdb-0.11.0/src/pkdb/cache.py +132 -0
- pkdb-0.11.0/src/pkdb/cli.py +158 -0
- pkdb-0.11.0/src/pkdb/client.py +369 -0
- pkdb-0.11.0/src/pkdb/data/vocabulary.json +11657 -0
- pkdb-0.11.0/src/pkdb/domain/datasets.py +161 -0
- pkdb-0.11.0/src/pkdb/domain/normalization.py +76 -0
- pkdb-0.11.0/src/pkdb/domain/pharmacokinetics.py +238 -0
- pkdb-0.11.0/src/pkdb/domain/provenance.py +32 -0
- pkdb-0.11.0/src/pkdb/domain/statistics.py +90 -0
- pkdb-0.11.0/src/pkdb/domain/units.py +19 -0
- pkdb-0.11.0/src/pkdb/domain/validation.py +410 -0
- pkdb-0.11.0/src/pkdb/domain/vocabulary.py +87 -0
- pkdb-0.11.0/src/pkdb/errors.py +24 -0
- pkdb-0.11.0/src/pkdb/importers/expressions.py +120 -0
- pkdb-0.11.0/src/pkdb/importers/folder.py +495 -0
- pkdb-0.11.0/src/pkdb/importers/structure.py +58 -0
- pkdb-0.11.0/src/pkdb/importers/workbook.py +79 -0
- pkdb-0.11.0/src/pkdb/preparation.py +130 -0
- pkdb-0.11.0/src/pkdb/py.typed +0 -0
- pkdb-0.11.0/src/pkdb/schemas/accounts.py +44 -0
- pkdb-0.11.0/src/pkdb/schemas/admin_users.py +27 -0
- pkdb-0.11.0/src/pkdb/schemas/analysis.py +193 -0
- pkdb-0.11.0/src/pkdb/schemas/bundle.py +10 -0
- pkdb-0.11.0/src/pkdb/schemas/filters.py +23 -0
- pkdb-0.11.0/src/pkdb/schemas/legacy.py +11 -0
- pkdb-0.11.0/src/pkdb/schemas/prepared.py +14 -0
- pkdb-0.11.0/src/pkdb/schemas/profiles.py +50 -0
- pkdb-0.11.0/src/pkdb/schemas/queries.py +42 -0
- pkdb-0.11.0/src/pkdb/schemas/replacement.py +20 -0
- pkdb-0.11.0/src/pkdb/schemas/responses.py +264 -0
- pkdb-0.11.0/src/pkdb/schemas/security.py +29 -0
- pkdb-0.11.0/src/pkdb/schemas/source.py +21 -0
- pkdb-0.11.0/src/pkdb/schemas/study.py +182 -0
- pkdb-0.11.0/src/pkdb/schemas/validation.py +49 -0
- pkdb-0.11.0/tests/conftest.py +106 -0
- pkdb-0.11.0/tests/test_cache.py +54 -0
- pkdb-0.11.0/tests/test_cli.py +140 -0
- pkdb-0.11.0/tests/test_client.py +303 -0
- pkdb-0.11.0/tests/test_preparation.py +69 -0
- 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,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())
|