grz-cli 0.5.0__tar.gz → 0.6.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.
- {grz_cli-0.5.0/src/grz_cli.egg-info → grz_cli-0.6.0}/PKG-INFO +9 -2
- {grz_cli-0.5.0 → grz_cli-0.6.0}/README.md +4 -0
- {grz_cli-0.5.0 → grz_cli-0.6.0}/pyproject.toml +16 -16
- grz_cli-0.6.0/src/grz_cli/__init__.py +1 -0
- grz_cli-0.6.0/src/grz_cli/cli.py +113 -0
- grz_cli-0.6.0/src/grz_cli/commands/__init__.py +1 -0
- grz_cli-0.6.0/src/grz_cli/commands/archive.py +43 -0
- grz_cli-0.6.0/src/grz_cli/commands/clean.py +77 -0
- grz_cli-0.6.0/src/grz_cli/commands/common.py +79 -0
- grz_cli-0.6.0/src/grz_cli/commands/consent.py +115 -0
- grz_cli-0.6.0/src/grz_cli/commands/decrypt.py +45 -0
- grz_cli-0.6.0/src/grz_cli/commands/download.py +46 -0
- grz_cli-0.6.0/src/grz_cli/commands/encrypt.py +55 -0
- grz_cli-0.6.0/src/grz_cli/commands/list_submissions.py +50 -0
- grz_cli-0.6.0/src/grz_cli/commands/pruefbericht.py +181 -0
- grz_cli-0.6.0/src/grz_cli/commands/submit.py +35 -0
- grz_cli-0.6.0/src/grz_cli/commands/upload.py +44 -0
- grz_cli-0.6.0/src/grz_cli/commands/validate.py +36 -0
- {grz_cli-0.5.0 → grz_cli-0.6.0}/src/grz_cli/models/config.py +37 -2
- grz_cli-0.6.0/src/grz_cli/progress/__init__.py +21 -0
- {grz_cli-0.5.0/src/grz_cli → grz_cli-0.6.0/src/grz_cli/progress}/progress_logging.py +2 -1
- grz_cli-0.6.0/src/grz_cli/py.typed +0 -0
- {grz_cli-0.5.0 → grz_cli-0.6.0}/src/grz_cli/transfer.py +24 -2
- grz_cli-0.6.0/src/grz_cli/utils/__init__.py +1 -0
- grz_cli-0.6.0/src/grz_cli/utils/checksums.py +43 -0
- grz_cli-0.6.0/src/grz_cli/utils/config.py +18 -0
- grz_cli-0.6.0/src/grz_cli/utils/crypt.py +143 -0
- grz_cli-0.6.0/src/grz_cli/utils/io.py +111 -0
- grz_cli-0.6.0/src/grz_cli/utils/paths.py +26 -0
- grz_cli-0.6.0/src/grz_cli/validation/__init__.py +1 -0
- grz_cli-0.5.0/src/grz_cli/fastq_validation.py → grz_cli-0.6.0/src/grz_cli/validation/fastq.py +2 -2
- grz_cli-0.6.0/src/grz_cli/workers/__init__.py +1 -0
- {grz_cli-0.5.0/src/grz_cli → grz_cli-0.6.0/src/grz_cli/workers}/download.py +11 -12
- grz_cli-0.5.0/src/grz_cli/parser.py → grz_cli-0.6.0/src/grz_cli/workers/submission.py +44 -215
- {grz_cli-0.5.0/src/grz_cli → grz_cli-0.6.0/src/grz_cli/workers}/upload.py +27 -9
- grz_cli-0.6.0/src/grz_cli/workers/worker.py +225 -0
- {grz_cli-0.5.0 → grz_cli-0.6.0/src/grz_cli.egg-info}/PKG-INFO +9 -2
- grz_cli-0.6.0/src/grz_cli.egg-info/SOURCES.txt +58 -0
- {grz_cli-0.5.0 → grz_cli-0.6.0}/src/grz_cli.egg-info/requires.txt +4 -1
- {grz_cli-0.5.0 → grz_cli-0.6.0}/tests/test_bam_validation.py +1 -1
- {grz_cli-0.5.0 → grz_cli-0.6.0}/tests/test_cli.py +27 -1
- {grz_cli-0.5.0 → grz_cli-0.6.0}/tests/test_download.py +2 -2
- {grz_cli-0.5.0 → grz_cli-0.6.0}/tests/test_fastq_validation.py +1 -1
- {grz_cli-0.5.0 → grz_cli-0.6.0}/tests/test_file_operations.py +3 -13
- {grz_cli-0.5.0 → grz_cli-0.6.0}/tests/test_file_progress_logging.py +2 -2
- {grz_cli-0.5.0 → grz_cli-0.6.0}/tests/test_parser.py +1 -1
- {grz_cli-0.5.0 → grz_cli-0.6.0}/tests/test_read_multiple_json.py +1 -1
- {grz_cli-0.5.0 → grz_cli-0.6.0}/tests/test_upload.py +2 -2
- grz_cli-0.5.0/src/grz_cli/__init__.py +0 -1
- grz_cli-0.5.0/src/grz_cli/cli.py +0 -420
- grz_cli-0.5.0/src/grz_cli/file_operations.py +0 -361
- grz_cli-0.5.0/src/grz_cli.egg-info/SOURCES.txt +0 -35
- {grz_cli-0.5.0 → grz_cli-0.6.0}/LICENSE +0 -0
- {grz_cli-0.5.0 → grz_cli-0.6.0}/MANIFEST.in +0 -0
- {grz_cli-0.5.0 → grz_cli-0.6.0}/setup.cfg +0 -0
- {grz_cli-0.5.0 → grz_cli-0.6.0}/src/__init__.py +0 -0
- {grz_cli-0.5.0 → grz_cli-0.6.0}/src/grz_cli/constants.py +0 -0
- {grz_cli-0.5.0 → grz_cli-0.6.0}/src/grz_cli/logging_setup.py +0 -0
- {grz_cli-0.5.0 → grz_cli-0.6.0}/src/grz_cli/models/__init__.py +0 -0
- {grz_cli-0.5.0/src/grz_cli → grz_cli-0.6.0/src/grz_cli/progress}/states.py +0 -0
- /grz_cli-0.5.0/src/grz_cli/bam_validation.py → /grz_cli-0.6.0/src/grz_cli/validation/bam.py +0 -0
- {grz_cli-0.5.0 → grz_cli-0.6.0}/src/grz_cli.egg-info/dependency_links.txt +0 -0
- {grz_cli-0.5.0 → grz_cli-0.6.0}/src/grz_cli.egg-info/entry_points.txt +0 -0
- {grz_cli-0.5.0 → grz_cli-0.6.0}/src/grz_cli.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: grz-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.0
|
|
4
4
|
Summary: Tool for validation, encryption and upload of MV submissions to GDCs.
|
|
5
5
|
Author-email: Koray Kirli <koraykirli@gmail.com>, Mathias Lesche <mathias.lesche@tu-dresden.de>, "Florian R. Hölzlwimmer" <git.ich@frhoelzlwimmer.de>, Till Hartmann <till.hartmann@bih-charite.de>, Thomas Sell <thomas.sell@bih-charite.de>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -18,10 +18,12 @@ Requires-Dist: jsonschema<5,>=4.23.0
|
|
|
18
18
|
Requires-Dist: pyyaml<7,>=6.0.2
|
|
19
19
|
Requires-Dist: tqdm<5,>=4.66.5
|
|
20
20
|
Requires-Dist: pydantic<2.10,>=2.9.2
|
|
21
|
+
Requires-Dist: pydantic-settings<2.10,>=2.9.0
|
|
21
22
|
Requires-Dist: platformdirs<5,>=4.3.6
|
|
22
|
-
Requires-Dist: grz-pydantic-models>=1.
|
|
23
|
+
Requires-Dist: grz-pydantic-models>=1.3.0
|
|
23
24
|
Requires-Dist: pysam==0.23.*
|
|
24
25
|
Requires-Dist: rich==13.*
|
|
26
|
+
Requires-Dist: requests<3,>=2.32.3
|
|
25
27
|
Provides-Extra: test
|
|
26
28
|
Requires-Dist: pytest<9,>=8.3.3; extra == "test"
|
|
27
29
|
Requires-Dist: pytest-cov<6,>=5.0.0; extra == "test"
|
|
@@ -30,6 +32,7 @@ Requires-Dist: moto[s3]<6,>=5.0.16; extra == "test"
|
|
|
30
32
|
Requires-Dist: pytest-sugar<2,>=1.0.0; extra == "test"
|
|
31
33
|
Requires-Dist: ruff<1,>=0.9.6; extra == "test"
|
|
32
34
|
Requires-Dist: numpy<3,>=2.1.2; extra == "test"
|
|
35
|
+
Requires-Dist: responses==0.25.*; extra == "test"
|
|
33
36
|
Dynamic: license-file
|
|
34
37
|
|
|
35
38
|
# GRZ CLI
|
|
@@ -140,6 +143,10 @@ pip upgrade grz-cli
|
|
|
140
143
|
```
|
|
141
144
|
|
|
142
145
|
|
|
146
|
+
#### Docker
|
|
147
|
+
Docker images are available via biocontainers at [https://biocontainers.pro/tools/grz-cli](https://biocontainers.pro/tools/grz-cli).
|
|
148
|
+
|
|
149
|
+
|
|
143
150
|
### Development setup
|
|
144
151
|
For development purposes, you can clone the repository and install the package in editable mode:
|
|
145
152
|
|
|
@@ -106,6 +106,10 @@ pip upgrade grz-cli
|
|
|
106
106
|
```
|
|
107
107
|
|
|
108
108
|
|
|
109
|
+
#### Docker
|
|
110
|
+
Docker images are available via biocontainers at [https://biocontainers.pro/tools/grz-cli](https://biocontainers.pro/tools/grz-cli).
|
|
111
|
+
|
|
112
|
+
|
|
109
113
|
### Development setup
|
|
110
114
|
For development purposes, you can clone the repository and install the package in editable mode:
|
|
111
115
|
|
|
@@ -23,10 +23,12 @@ dependencies = [
|
|
|
23
23
|
"pyyaml >=6.0.2,<7",
|
|
24
24
|
"tqdm >=4.66.5,<5",
|
|
25
25
|
"pydantic >=2.9.2,<2.10",
|
|
26
|
+
"pydantic-settings >=2.9.0,<2.10",
|
|
26
27
|
"platformdirs >=4.3.6,<5",
|
|
27
|
-
"grz-pydantic-models >=1.
|
|
28
|
+
"grz-pydantic-models >=1.3.0",
|
|
28
29
|
"pysam ==0.23.*",
|
|
29
|
-
"rich ==13.*"
|
|
30
|
+
"rich ==13.*",
|
|
31
|
+
"requests >=2.32.3,<3"
|
|
30
32
|
]
|
|
31
33
|
classifiers = [
|
|
32
34
|
"Programming Language :: Python :: 3",
|
|
@@ -53,7 +55,8 @@ test = [
|
|
|
53
55
|
"moto[s3] >=5.0.16,<6",
|
|
54
56
|
"pytest-sugar >=1.0.0,<2",
|
|
55
57
|
"ruff >=0.9.6,<1",
|
|
56
|
-
"numpy >=2.1.2,<3"
|
|
58
|
+
"numpy >=2.1.2,<3",
|
|
59
|
+
"responses ==0.25.*"
|
|
57
60
|
]
|
|
58
61
|
|
|
59
62
|
[dependency-groups]
|
|
@@ -67,10 +70,10 @@ test = [
|
|
|
67
70
|
]
|
|
68
71
|
lint = [
|
|
69
72
|
"ruff",
|
|
70
|
-
"mypy",
|
|
71
73
|
"types-pyyaml",
|
|
72
74
|
"types-tqdm",
|
|
73
75
|
"pydantic",
|
|
76
|
+
"pyrefly",
|
|
74
77
|
]
|
|
75
78
|
|
|
76
79
|
[tool.ruff]
|
|
@@ -155,7 +158,7 @@ convention = "pep257"
|
|
|
155
158
|
|
|
156
159
|
[tool.tox]
|
|
157
160
|
requires = ["tox>=4.23"]
|
|
158
|
-
env_list = ["format-check", "lints", "
|
|
161
|
+
env_list = ["format-check", "lints", "pyrefly", "3.12", "3.13"]
|
|
159
162
|
allowlist_externals = ["pytest", "ruff"]
|
|
160
163
|
isolated_build = true
|
|
161
164
|
|
|
@@ -177,16 +180,16 @@ skip_install = true
|
|
|
177
180
|
dependency_groups = ["lint"]
|
|
178
181
|
commands = [["ruff", "check"]]
|
|
179
182
|
|
|
180
|
-
[tool.tox.env."
|
|
183
|
+
[tool.tox.env."pyrefly"]
|
|
181
184
|
runner = "uv-venv-lock-runner"
|
|
182
185
|
skip_install = false
|
|
183
186
|
dependency_groups = ["lint"]
|
|
184
|
-
commands = [["
|
|
187
|
+
commands = [["pyrefly", "check"]]
|
|
185
188
|
|
|
186
189
|
[tool.uv]
|
|
187
190
|
dev-dependencies = [
|
|
188
191
|
"datamodel-code-generator>=0.26.2",
|
|
189
|
-
"
|
|
192
|
+
"pyrefly>=0.16.0,<0.17.0",
|
|
190
193
|
"ruff>=0.9.6",
|
|
191
194
|
"tox>=4.23.2",
|
|
192
195
|
"tox-uv>=1.20.1",
|
|
@@ -194,14 +197,11 @@ dev-dependencies = [
|
|
|
194
197
|
"types-tqdm>=4.66.0.20240417",
|
|
195
198
|
]
|
|
196
199
|
|
|
197
|
-
[tool.
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
[[tool.mypy.overrides]]
|
|
203
|
-
module = ["grz_pydantic_models.*"]
|
|
204
|
-
follow_untyped_imports = true
|
|
200
|
+
[tool.pyrefly]
|
|
201
|
+
project_includes = ["src"]
|
|
202
|
+
python_version = "3.12.0"
|
|
203
|
+
use_untyped_imports = false
|
|
204
|
+
ignore_missing_source = false
|
|
205
205
|
|
|
206
206
|
[tool.pytest.ini_options]
|
|
207
207
|
addopts = [
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.6.0"
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CLI module for handling command-line interface operations.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import logging.config
|
|
7
|
+
import os
|
|
8
|
+
from importlib.metadata import version
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
import grz_pydantic_models.submission.metadata
|
|
12
|
+
|
|
13
|
+
from .commands.archive import archive
|
|
14
|
+
from .commands.clean import clean
|
|
15
|
+
from .commands.consent import consent
|
|
16
|
+
from .commands.decrypt import decrypt
|
|
17
|
+
from .commands.download import download
|
|
18
|
+
from .commands.encrypt import encrypt
|
|
19
|
+
from .commands.list_submissions import list_submissions
|
|
20
|
+
from .commands.pruefbericht import pruefbericht
|
|
21
|
+
from .commands.submit import submit
|
|
22
|
+
from .commands.upload import upload
|
|
23
|
+
from .commands.validate import validate
|
|
24
|
+
from .constants import PACKAGE_ROOT
|
|
25
|
+
from .logging_setup import add_filelogger
|
|
26
|
+
|
|
27
|
+
log = logging.getLogger(PACKAGE_ROOT + ".cli")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class OrderedGroup(click.Group):
|
|
31
|
+
"""
|
|
32
|
+
A click Group that keeps track of the order in which commands are added.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def list_commands(self, ctx):
|
|
36
|
+
"""Return the list of commands in the order they were added."""
|
|
37
|
+
return list(self.commands.keys())
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def build_cli(grz_mode=False):
|
|
41
|
+
"""
|
|
42
|
+
Factory for building the CLI application.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
@click.group(
|
|
46
|
+
cls=OrderedGroup,
|
|
47
|
+
help="Validate, encrypt, decrypt and upload submissions to a GRZ/GDC.",
|
|
48
|
+
)
|
|
49
|
+
@click.version_option(
|
|
50
|
+
version=version("grz-cli"),
|
|
51
|
+
prog_name="grz-cli",
|
|
52
|
+
message=f"%(prog)s v%(version)s (metadata schema versions: {', '.join(grz_pydantic_models.submission.metadata.get_supported_versions())})",
|
|
53
|
+
)
|
|
54
|
+
@click.option("--log-file", metavar="FILE", type=str, help="Path to log file")
|
|
55
|
+
@click.option(
|
|
56
|
+
"--log-level",
|
|
57
|
+
default="INFO",
|
|
58
|
+
type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]),
|
|
59
|
+
help="Set the log level (default: INFO)",
|
|
60
|
+
)
|
|
61
|
+
def cli(log_file: str | None = None, log_level: str = "INFO"):
|
|
62
|
+
"""
|
|
63
|
+
Command-line interface function for setting up logging.
|
|
64
|
+
|
|
65
|
+
:param log_file: Path to the log file. If provided, a file logger will be added.
|
|
66
|
+
:param log_level: Log level for the logger. It should be one of the following:
|
|
67
|
+
DEBUG, INFO, WARNING, ERROR, CRITICAL.
|
|
68
|
+
"""
|
|
69
|
+
if log_file:
|
|
70
|
+
add_filelogger(
|
|
71
|
+
log_file,
|
|
72
|
+
log_level.upper(),
|
|
73
|
+
) # Add file logger
|
|
74
|
+
|
|
75
|
+
# show only time and log level in STDOUT
|
|
76
|
+
logging.basicConfig(
|
|
77
|
+
format="%(asctime)s - %(levelname)s - %(message)s",
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
# set the log level for this package
|
|
81
|
+
logging.getLogger(PACKAGE_ROOT).setLevel(log_level.upper())
|
|
82
|
+
|
|
83
|
+
log.debug("Logging setup complete.")
|
|
84
|
+
|
|
85
|
+
cli.add_command(validate)
|
|
86
|
+
cli.add_command(encrypt)
|
|
87
|
+
cli.add_command(upload)
|
|
88
|
+
cli.add_command(submit)
|
|
89
|
+
|
|
90
|
+
if grz_mode:
|
|
91
|
+
cli.add_command(list_submissions, name="list")
|
|
92
|
+
cli.add_command(download)
|
|
93
|
+
cli.add_command(decrypt)
|
|
94
|
+
cli.add_command(archive)
|
|
95
|
+
cli.add_command(clean)
|
|
96
|
+
cli.add_command(consent)
|
|
97
|
+
cli.add_command(pruefbericht)
|
|
98
|
+
|
|
99
|
+
return cli
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def main():
|
|
103
|
+
"""
|
|
104
|
+
Main entry point for the CLI application.
|
|
105
|
+
"""
|
|
106
|
+
grz_mode = os.environ.get("GRZ_MODE", "false").lower() in {"true", "yes", "1"}
|
|
107
|
+
|
|
108
|
+
cli = build_cli(grz_mode=grz_mode)
|
|
109
|
+
cli()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
if __name__ == "__main__":
|
|
113
|
+
main()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Commands package for grz-cli."""
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Command for archiving a submission."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
|
|
8
|
+
from ..workers.worker import Worker
|
|
9
|
+
|
|
10
|
+
log = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
from ..utils.config import read_config
|
|
13
|
+
from .common import config_file, submission_dir, threads
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@click.command()
|
|
17
|
+
@submission_dir
|
|
18
|
+
@config_file
|
|
19
|
+
@threads
|
|
20
|
+
def archive(
|
|
21
|
+
submission_dir,
|
|
22
|
+
config_file,
|
|
23
|
+
threads,
|
|
24
|
+
):
|
|
25
|
+
"""
|
|
26
|
+
Archive a submission within a GRZ/GDC.
|
|
27
|
+
"""
|
|
28
|
+
config = read_config(config_file)
|
|
29
|
+
|
|
30
|
+
log.info("Starting archival...")
|
|
31
|
+
|
|
32
|
+
submission_dir = Path(submission_dir)
|
|
33
|
+
|
|
34
|
+
worker_inst = Worker(
|
|
35
|
+
metadata_dir=submission_dir / "metadata",
|
|
36
|
+
files_dir=submission_dir / "files",
|
|
37
|
+
log_dir=submission_dir / "logs",
|
|
38
|
+
encrypted_files_dir=submission_dir / "encrypted_files",
|
|
39
|
+
threads=threads,
|
|
40
|
+
)
|
|
41
|
+
worker_inst.archive(config)
|
|
42
|
+
|
|
43
|
+
log.info("Archival finished!")
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Command for cleaning a submission from the S3 inbox."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
|
|
8
|
+
from ..transfer import init_s3_resource
|
|
9
|
+
from ..utils.config import read_config
|
|
10
|
+
from .common import config_file, submission_id
|
|
11
|
+
|
|
12
|
+
log = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@click.command()
|
|
16
|
+
@submission_id
|
|
17
|
+
@config_file
|
|
18
|
+
@click.option("--yes-i-really-mean-it", is_flag=True)
|
|
19
|
+
def clean(submission_id, config_file, yes_i_really_mean_it: bool):
|
|
20
|
+
"""
|
|
21
|
+
Remove all files of a submission from the S3 inbox.
|
|
22
|
+
"""
|
|
23
|
+
config = read_config(config_file)
|
|
24
|
+
bucket_name = config.s3_options.bucket
|
|
25
|
+
|
|
26
|
+
if yes_i_really_mean_it or click.confirm(
|
|
27
|
+
f"Are you SURE you want to delete the submission from the inbox bucket ({bucket_name})?",
|
|
28
|
+
default=False,
|
|
29
|
+
show_default=True,
|
|
30
|
+
):
|
|
31
|
+
prefix = submission_id
|
|
32
|
+
prefix = prefix + "/" if not prefix.endswith("/") else prefix
|
|
33
|
+
|
|
34
|
+
resource = init_s3_resource(config)
|
|
35
|
+
bucket = resource.Bucket(bucket_name)
|
|
36
|
+
log.info(f"Deleting {prefix} from {bucket_name} …")
|
|
37
|
+
|
|
38
|
+
responses = bucket.objects.filter(Prefix=prefix).delete()
|
|
39
|
+
if not responses:
|
|
40
|
+
sys.exit(f"No objects with prefix '{prefix}' in bucket '{bucket_name}' found for deletion.")
|
|
41
|
+
|
|
42
|
+
successfully_deleted_keys = []
|
|
43
|
+
errors_encountered = []
|
|
44
|
+
objects_found = 0
|
|
45
|
+
|
|
46
|
+
# responses is a list of dicts reporting the result of the deletion API call
|
|
47
|
+
# because the API does things in batches
|
|
48
|
+
for response in responses:
|
|
49
|
+
deleted_batch = response.get("Deleted", [])
|
|
50
|
+
for deleted_obj in deleted_batch:
|
|
51
|
+
key = deleted_obj.get("Key")
|
|
52
|
+
if key:
|
|
53
|
+
successfully_deleted_keys.append(key)
|
|
54
|
+
|
|
55
|
+
errors_batch = response.get("Errors", [])
|
|
56
|
+
for error_obj in errors_batch:
|
|
57
|
+
errors_encountered.append(
|
|
58
|
+
{
|
|
59
|
+
"Key": error_obj.get("Key", "N/A"),
|
|
60
|
+
"Code": error_obj.get("Code", "N/A"),
|
|
61
|
+
"Message": error_obj.get("Message", "N/A"),
|
|
62
|
+
}
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
objects_found += len(deleted_batch) + len(errors_batch)
|
|
66
|
+
|
|
67
|
+
log.info(f"Total objects attempted to delete: {objects_found}")
|
|
68
|
+
log.info(f"Successfully deleted: {len(successfully_deleted_keys)} objects.")
|
|
69
|
+
log.info(f"Failed to delete: {len(errors_encountered)} objects.")
|
|
70
|
+
|
|
71
|
+
for error in errors_encountered:
|
|
72
|
+
log.error(f" - Key: {error['Key']}, Code: {error['Code']}, Message: {error['Message']}")
|
|
73
|
+
|
|
74
|
+
if errors_encountered:
|
|
75
|
+
sys.exit(f"Errors encountered while deleting objects from bucket '{bucket_name}'. See log for details.")
|
|
76
|
+
|
|
77
|
+
log.info(f"Deleted '{prefix}' from '{bucket_name}'.")
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Common click options for the CLI commands.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from os import sched_getaffinity
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
import platformdirs
|
|
10
|
+
|
|
11
|
+
DEFAULT_CONFIG_PATH = Path(platformdirs.user_config_dir("grz-cli")) / "config.yaml"
|
|
12
|
+
|
|
13
|
+
# Aliases for path types for click options
|
|
14
|
+
# Naming convention: {DIR,FILE}_{Read,Write}_{Exists,Create}
|
|
15
|
+
DIR_R_E = click.Path(
|
|
16
|
+
exists=True,
|
|
17
|
+
file_okay=False,
|
|
18
|
+
dir_okay=True,
|
|
19
|
+
readable=True,
|
|
20
|
+
writable=False,
|
|
21
|
+
resolve_path=True,
|
|
22
|
+
)
|
|
23
|
+
DIR_RW_C = click.Path(
|
|
24
|
+
exists=False,
|
|
25
|
+
file_okay=False,
|
|
26
|
+
dir_okay=True,
|
|
27
|
+
readable=True,
|
|
28
|
+
writable=True,
|
|
29
|
+
resolve_path=True,
|
|
30
|
+
)
|
|
31
|
+
FILE_R_E = click.Path(exists=True, file_okay=True, dir_okay=False, readable=True, resolve_path=True)
|
|
32
|
+
|
|
33
|
+
submission_dir = click.option(
|
|
34
|
+
"--submission-dir",
|
|
35
|
+
metavar="PATH",
|
|
36
|
+
type=DIR_R_E,
|
|
37
|
+
required=True,
|
|
38
|
+
help="Path to the submission directory containing 'metadata/', 'files/', 'encrypted_files/' and 'logs/' directories",
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
config_file = click.option(
|
|
42
|
+
"--config-file",
|
|
43
|
+
metavar="STRING",
|
|
44
|
+
type=FILE_R_E,
|
|
45
|
+
required=False,
|
|
46
|
+
default=DEFAULT_CONFIG_PATH,
|
|
47
|
+
help="Path to config file",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
threads = click.option(
|
|
51
|
+
"--threads",
|
|
52
|
+
default=min(len(sched_getaffinity(0)), 4),
|
|
53
|
+
type=int,
|
|
54
|
+
show_default=True,
|
|
55
|
+
help="Number of threads to use for parallel operations",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
submission_id = click.option(
|
|
59
|
+
"--submission-id",
|
|
60
|
+
required=True,
|
|
61
|
+
type=str,
|
|
62
|
+
metavar="STRING",
|
|
63
|
+
help="S3 submission ID",
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
output_dir = click.option(
|
|
67
|
+
"--output-dir",
|
|
68
|
+
metavar="PATH",
|
|
69
|
+
type=DIR_RW_C,
|
|
70
|
+
required=True,
|
|
71
|
+
default=None,
|
|
72
|
+
help="Path to the target submission output directory",
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
output_json = click.option("--json", "output_json", is_flag=True, help="Output JSON for machine-readability.")
|
|
76
|
+
|
|
77
|
+
show_details = click.option("--details", "show_details", is_flag=True, help="Show more detailed output.")
|
|
78
|
+
|
|
79
|
+
force = click.option("--force/--no-force", help="Overwrite files and ignore cached results (dangerous!)")
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Command for determining whether a submission is consented for research."""
|
|
2
|
+
|
|
3
|
+
import enum
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
import rich.console
|
|
12
|
+
import rich.table
|
|
13
|
+
import rich.text
|
|
14
|
+
|
|
15
|
+
from ..workers.submission import GrzSubmissionMetadata, SubmissionMetadata
|
|
16
|
+
from .common import output_json, show_details, submission_dir
|
|
17
|
+
|
|
18
|
+
log = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
MDAT_WISSENSCHAFTLICH_NUTZEN_EU_DSGVO_NIVEAU = "2.16.840.1.113883.3.1937.777.24.5.3.8"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class FhirProvision(enum.StrEnum):
|
|
24
|
+
"""Possible FHIR Provision options."""
|
|
25
|
+
|
|
26
|
+
PERMIT = "permit"
|
|
27
|
+
DENY = "deny"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@click.command()
|
|
31
|
+
@submission_dir
|
|
32
|
+
@output_json
|
|
33
|
+
@show_details
|
|
34
|
+
def consent(submission_dir, output_json, show_details):
|
|
35
|
+
"""
|
|
36
|
+
Check if a submission is consented for research.
|
|
37
|
+
|
|
38
|
+
Returns 'true' if consented, 'false' if not.
|
|
39
|
+
A submission is considered consented if all donors have consented for research, that is
|
|
40
|
+
the FHIR MII IG Consent profiles all have a "permit" provision for code 2.16.840.1.113883.3.1937.777.24.5.3.8
|
|
41
|
+
"""
|
|
42
|
+
metadata = SubmissionMetadata(Path(submission_dir) / "metadata" / "metadata.json").content
|
|
43
|
+
|
|
44
|
+
consents = _gather_consent_information(metadata)
|
|
45
|
+
overall_consent = _submission_has_research_consent(consents)
|
|
46
|
+
|
|
47
|
+
match output_json, show_details:
|
|
48
|
+
case True, True:
|
|
49
|
+
json.dump(consents, sys.stdout)
|
|
50
|
+
case True, False:
|
|
51
|
+
json.dump(overall_consent, sys.stdout)
|
|
52
|
+
case False, True:
|
|
53
|
+
_print_rich_table(consents)
|
|
54
|
+
case False, False:
|
|
55
|
+
click.echo(str(overall_consent).lower())
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _submission_has_research_consent(consents):
|
|
59
|
+
return all(consents.values())
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _print_rich_table(consents: dict[str, bool]):
|
|
63
|
+
console = rich.console.Console()
|
|
64
|
+
table = rich.table.Table()
|
|
65
|
+
table.add_column("Donor", no_wrap=True)
|
|
66
|
+
table.add_column("Research Consent", no_wrap=True)
|
|
67
|
+
for donor_pseudonym, consent_value in consents.items():
|
|
68
|
+
research_consent = rich.text.Text(
|
|
69
|
+
"True" if consent_value else "False",
|
|
70
|
+
style="green" if consent_value else "red",
|
|
71
|
+
)
|
|
72
|
+
table.add_row(
|
|
73
|
+
donor_pseudonym,
|
|
74
|
+
research_consent,
|
|
75
|
+
)
|
|
76
|
+
console.print(table)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _gather_consent_information(metadata: GrzSubmissionMetadata) -> dict[str, bool]:
|
|
80
|
+
consents = {donor.donor_pseudonym: False for donor in metadata.donors}
|
|
81
|
+
for donor in metadata.donors:
|
|
82
|
+
for research_consent in donor.research_consents:
|
|
83
|
+
mii_consent = research_consent.scope
|
|
84
|
+
if isinstance(mii_consent, str):
|
|
85
|
+
mii_consent = json.loads(mii_consent)
|
|
86
|
+
|
|
87
|
+
if top_level_provision := mii_consent.get("provision"):
|
|
88
|
+
if top_level_provision.get("type") != FhirProvision.DENY:
|
|
89
|
+
sys.exit(
|
|
90
|
+
f"The root provision type must be deny, not {top_level_provision.get('type')}, "
|
|
91
|
+
f"since the profile follows an opt-in consent scheme. "
|
|
92
|
+
f"Explicit opt-in consents must be made via nested provisions."
|
|
93
|
+
)
|
|
94
|
+
else:
|
|
95
|
+
nested_provisions = top_level_provision.get("provision")
|
|
96
|
+
consents[donor.donor_pseudonym] = _check_nested_provisions(nested_provisions)
|
|
97
|
+
|
|
98
|
+
return consents
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _check_nested_provisions(provisions: list[dict[str, Any]]) -> bool:
|
|
102
|
+
for provision in provisions:
|
|
103
|
+
if provision.get("type") == FhirProvision.PERMIT:
|
|
104
|
+
for codeable_concept in provision.get("code", []):
|
|
105
|
+
for coding in codeable_concept.get("coding", []):
|
|
106
|
+
code = coding.get("code")
|
|
107
|
+
if isinstance(code, str):
|
|
108
|
+
if code == MDAT_WISSENSCHAFTLICH_NUTZEN_EU_DSGVO_NIVEAU:
|
|
109
|
+
return True
|
|
110
|
+
elif isinstance(code, dict):
|
|
111
|
+
if (value := code.get("value")) and value == MDAT_WISSENSCHAFTLICH_NUTZEN_EU_DSGVO_NIVEAU:
|
|
112
|
+
return True
|
|
113
|
+
else:
|
|
114
|
+
raise ValueError(code, f"Expected str or dict, got {type(code)}")
|
|
115
|
+
return False
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Command for decrypting a submission."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from ..utils.config import read_config
|
|
10
|
+
from ..workers.worker import Worker
|
|
11
|
+
from .common import config_file, force, submission_dir
|
|
12
|
+
|
|
13
|
+
log = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@click.command()
|
|
17
|
+
@submission_dir
|
|
18
|
+
@config_file
|
|
19
|
+
@force
|
|
20
|
+
def decrypt(submission_dir, config_file, force):
|
|
21
|
+
"""
|
|
22
|
+
Decrypt a submission.
|
|
23
|
+
|
|
24
|
+
Decrypting a submission requires the _private_ key of the original recipient.
|
|
25
|
+
"""
|
|
26
|
+
config = read_config(config_file)
|
|
27
|
+
|
|
28
|
+
grz_privkey_path = config.grz_private_key_path
|
|
29
|
+
if not grz_privkey_path:
|
|
30
|
+
log.error("GRZ private key path is required for decryption.")
|
|
31
|
+
sys.exit(1)
|
|
32
|
+
|
|
33
|
+
log.info("Starting decryption...")
|
|
34
|
+
|
|
35
|
+
submission_dir = Path(submission_dir)
|
|
36
|
+
|
|
37
|
+
worker_inst = Worker(
|
|
38
|
+
metadata_dir=submission_dir / "metadata",
|
|
39
|
+
files_dir=submission_dir / "files",
|
|
40
|
+
log_dir=submission_dir / "logs",
|
|
41
|
+
encrypted_files_dir=submission_dir / "encrypted_files",
|
|
42
|
+
)
|
|
43
|
+
worker_inst.decrypt(grz_privkey_path, force=force)
|
|
44
|
+
|
|
45
|
+
log.info("Decryption successful!")
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Command for downloading a submission."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
|
|
8
|
+
from ..utils.config import read_config
|
|
9
|
+
from ..workers.worker import Worker
|
|
10
|
+
from .common import config_file, force, output_dir, submission_id, threads
|
|
11
|
+
|
|
12
|
+
log = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@click.command()
|
|
16
|
+
@submission_id
|
|
17
|
+
@output_dir
|
|
18
|
+
@config_file
|
|
19
|
+
@threads
|
|
20
|
+
@force
|
|
21
|
+
def download(submission_id, output_dir, config_file, threads, force):
|
|
22
|
+
"""
|
|
23
|
+
Download a submission from a GRZ.
|
|
24
|
+
|
|
25
|
+
Downloaded metadata is stored within the `metadata` sub-folder of the submission output directory.
|
|
26
|
+
Downloaded files are stored within the `encrypted_files` sub-folder of the submission output directory.
|
|
27
|
+
"""
|
|
28
|
+
config = read_config(config_file)
|
|
29
|
+
|
|
30
|
+
log.info("Starting download...")
|
|
31
|
+
|
|
32
|
+
submission_dir_path = Path(output_dir)
|
|
33
|
+
if not submission_dir_path.is_dir():
|
|
34
|
+
log.debug("Creating submission directory %s", submission_dir_path)
|
|
35
|
+
submission_dir_path.mkdir(mode=0o770, parents=False, exist_ok=False)
|
|
36
|
+
|
|
37
|
+
worker_inst = Worker(
|
|
38
|
+
metadata_dir=submission_dir_path / "metadata",
|
|
39
|
+
files_dir=submission_dir_path / "files",
|
|
40
|
+
log_dir=submission_dir_path / "logs",
|
|
41
|
+
encrypted_files_dir=submission_dir_path / "encrypted_files",
|
|
42
|
+
threads=threads,
|
|
43
|
+
)
|
|
44
|
+
worker_inst.download(config, submission_id, force=force)
|
|
45
|
+
|
|
46
|
+
log.info("Download finished!")
|