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.
Files changed (64) hide show
  1. {grz_cli-0.5.0/src/grz_cli.egg-info → grz_cli-0.6.0}/PKG-INFO +9 -2
  2. {grz_cli-0.5.0 → grz_cli-0.6.0}/README.md +4 -0
  3. {grz_cli-0.5.0 → grz_cli-0.6.0}/pyproject.toml +16 -16
  4. grz_cli-0.6.0/src/grz_cli/__init__.py +1 -0
  5. grz_cli-0.6.0/src/grz_cli/cli.py +113 -0
  6. grz_cli-0.6.0/src/grz_cli/commands/__init__.py +1 -0
  7. grz_cli-0.6.0/src/grz_cli/commands/archive.py +43 -0
  8. grz_cli-0.6.0/src/grz_cli/commands/clean.py +77 -0
  9. grz_cli-0.6.0/src/grz_cli/commands/common.py +79 -0
  10. grz_cli-0.6.0/src/grz_cli/commands/consent.py +115 -0
  11. grz_cli-0.6.0/src/grz_cli/commands/decrypt.py +45 -0
  12. grz_cli-0.6.0/src/grz_cli/commands/download.py +46 -0
  13. grz_cli-0.6.0/src/grz_cli/commands/encrypt.py +55 -0
  14. grz_cli-0.6.0/src/grz_cli/commands/list_submissions.py +50 -0
  15. grz_cli-0.6.0/src/grz_cli/commands/pruefbericht.py +181 -0
  16. grz_cli-0.6.0/src/grz_cli/commands/submit.py +35 -0
  17. grz_cli-0.6.0/src/grz_cli/commands/upload.py +44 -0
  18. grz_cli-0.6.0/src/grz_cli/commands/validate.py +36 -0
  19. {grz_cli-0.5.0 → grz_cli-0.6.0}/src/grz_cli/models/config.py +37 -2
  20. grz_cli-0.6.0/src/grz_cli/progress/__init__.py +21 -0
  21. {grz_cli-0.5.0/src/grz_cli → grz_cli-0.6.0/src/grz_cli/progress}/progress_logging.py +2 -1
  22. grz_cli-0.6.0/src/grz_cli/py.typed +0 -0
  23. {grz_cli-0.5.0 → grz_cli-0.6.0}/src/grz_cli/transfer.py +24 -2
  24. grz_cli-0.6.0/src/grz_cli/utils/__init__.py +1 -0
  25. grz_cli-0.6.0/src/grz_cli/utils/checksums.py +43 -0
  26. grz_cli-0.6.0/src/grz_cli/utils/config.py +18 -0
  27. grz_cli-0.6.0/src/grz_cli/utils/crypt.py +143 -0
  28. grz_cli-0.6.0/src/grz_cli/utils/io.py +111 -0
  29. grz_cli-0.6.0/src/grz_cli/utils/paths.py +26 -0
  30. grz_cli-0.6.0/src/grz_cli/validation/__init__.py +1 -0
  31. grz_cli-0.5.0/src/grz_cli/fastq_validation.py → grz_cli-0.6.0/src/grz_cli/validation/fastq.py +2 -2
  32. grz_cli-0.6.0/src/grz_cli/workers/__init__.py +1 -0
  33. {grz_cli-0.5.0/src/grz_cli → grz_cli-0.6.0/src/grz_cli/workers}/download.py +11 -12
  34. grz_cli-0.5.0/src/grz_cli/parser.py → grz_cli-0.6.0/src/grz_cli/workers/submission.py +44 -215
  35. {grz_cli-0.5.0/src/grz_cli → grz_cli-0.6.0/src/grz_cli/workers}/upload.py +27 -9
  36. grz_cli-0.6.0/src/grz_cli/workers/worker.py +225 -0
  37. {grz_cli-0.5.0 → grz_cli-0.6.0/src/grz_cli.egg-info}/PKG-INFO +9 -2
  38. grz_cli-0.6.0/src/grz_cli.egg-info/SOURCES.txt +58 -0
  39. {grz_cli-0.5.0 → grz_cli-0.6.0}/src/grz_cli.egg-info/requires.txt +4 -1
  40. {grz_cli-0.5.0 → grz_cli-0.6.0}/tests/test_bam_validation.py +1 -1
  41. {grz_cli-0.5.0 → grz_cli-0.6.0}/tests/test_cli.py +27 -1
  42. {grz_cli-0.5.0 → grz_cli-0.6.0}/tests/test_download.py +2 -2
  43. {grz_cli-0.5.0 → grz_cli-0.6.0}/tests/test_fastq_validation.py +1 -1
  44. {grz_cli-0.5.0 → grz_cli-0.6.0}/tests/test_file_operations.py +3 -13
  45. {grz_cli-0.5.0 → grz_cli-0.6.0}/tests/test_file_progress_logging.py +2 -2
  46. {grz_cli-0.5.0 → grz_cli-0.6.0}/tests/test_parser.py +1 -1
  47. {grz_cli-0.5.0 → grz_cli-0.6.0}/tests/test_read_multiple_json.py +1 -1
  48. {grz_cli-0.5.0 → grz_cli-0.6.0}/tests/test_upload.py +2 -2
  49. grz_cli-0.5.0/src/grz_cli/__init__.py +0 -1
  50. grz_cli-0.5.0/src/grz_cli/cli.py +0 -420
  51. grz_cli-0.5.0/src/grz_cli/file_operations.py +0 -361
  52. grz_cli-0.5.0/src/grz_cli.egg-info/SOURCES.txt +0 -35
  53. {grz_cli-0.5.0 → grz_cli-0.6.0}/LICENSE +0 -0
  54. {grz_cli-0.5.0 → grz_cli-0.6.0}/MANIFEST.in +0 -0
  55. {grz_cli-0.5.0 → grz_cli-0.6.0}/setup.cfg +0 -0
  56. {grz_cli-0.5.0 → grz_cli-0.6.0}/src/__init__.py +0 -0
  57. {grz_cli-0.5.0 → grz_cli-0.6.0}/src/grz_cli/constants.py +0 -0
  58. {grz_cli-0.5.0 → grz_cli-0.6.0}/src/grz_cli/logging_setup.py +0 -0
  59. {grz_cli-0.5.0 → grz_cli-0.6.0}/src/grz_cli/models/__init__.py +0 -0
  60. {grz_cli-0.5.0/src/grz_cli → grz_cli-0.6.0/src/grz_cli/progress}/states.py +0 -0
  61. /grz_cli-0.5.0/src/grz_cli/bam_validation.py → /grz_cli-0.6.0/src/grz_cli/validation/bam.py +0 -0
  62. {grz_cli-0.5.0 → grz_cli-0.6.0}/src/grz_cli.egg-info/dependency_links.txt +0 -0
  63. {grz_cli-0.5.0 → grz_cli-0.6.0}/src/grz_cli.egg-info/entry_points.txt +0 -0
  64. {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.5.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.2.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.2.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", "mypy", "3.12", "3.13"]
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."mypy"]
183
+ [tool.tox.env."pyrefly"]
181
184
  runner = "uv-venv-lock-runner"
182
185
  skip_install = false
183
186
  dependency_groups = ["lint"]
184
- commands = [["mypy"]]
187
+ commands = [["pyrefly", "check"]]
185
188
 
186
189
  [tool.uv]
187
190
  dev-dependencies = [
188
191
  "datamodel-code-generator>=0.26.2",
189
- "mypy>=1.13.0",
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.mypy]
198
- python_version = "3.12"
199
- plugins = "pydantic.mypy"
200
- packages = "src"
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!")