grz-cli 0.3.0__tar.gz → 0.5.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/MANIFEST.in +2 -0
- {grz_cli-0.3.0/src/grz_cli.egg-info → grz_cli-0.5.0}/PKG-INFO +10 -5
- {grz_cli-0.3.0 → grz_cli-0.5.0}/README.md +2 -0
- {grz_cli-0.3.0 → grz_cli-0.5.0}/pyproject.toml +6 -8
- grz_cli-0.5.0/src/grz_cli/__init__.py +1 -0
- grz_cli-0.5.0/src/grz_cli/bam_validation.py +39 -0
- {grz_cli-0.3.0 → grz_cli-0.5.0}/src/grz_cli/cli.py +178 -88
- {grz_cli-0.3.0 → grz_cli-0.5.0}/src/grz_cli/constants.py +2 -1
- {grz_cli-0.3.0 → grz_cli-0.5.0}/src/grz_cli/download.py +48 -31
- {grz_cli-0.3.0 → grz_cli-0.5.0}/src/grz_cli/fastq_validation.py +43 -24
- {grz_cli-0.3.0 → grz_cli-0.5.0}/src/grz_cli/file_operations.py +6 -1
- {grz_cli-0.3.0 → grz_cli-0.5.0}/src/grz_cli/models/config.py +27 -2
- {grz_cli-0.3.0 → grz_cli-0.5.0}/src/grz_cli/parser.py +61 -11
- {grz_cli-0.3.0 → grz_cli-0.5.0}/src/grz_cli/progress_logging.py +3 -2
- grz_cli-0.5.0/src/grz_cli/transfer.py +42 -0
- {grz_cli-0.3.0 → grz_cli-0.5.0}/src/grz_cli/upload.py +35 -35
- {grz_cli-0.3.0 → grz_cli-0.5.0/src/grz_cli.egg-info}/PKG-INFO +10 -5
- {grz_cli-0.3.0 → grz_cli-0.5.0}/src/grz_cli.egg-info/SOURCES.txt +3 -1
- grz_cli-0.5.0/src/grz_cli.egg-info/entry_points.txt +2 -0
- {grz_cli-0.3.0 → grz_cli-0.5.0}/src/grz_cli.egg-info/requires.txt +4 -2
- grz_cli-0.5.0/tests/test_bam_validation.py +15 -0
- {grz_cli-0.3.0 → grz_cli-0.5.0}/tests/test_cli.py +17 -11
- {grz_cli-0.3.0 → grz_cli-0.5.0}/tests/test_fastq_validation.py +1 -1
- {grz_cli-0.3.0 → grz_cli-0.5.0}/tests/test_file_progress_logging.py +1 -1
- {grz_cli-0.3.0 → grz_cli-0.5.0}/tests/test_upload.py +12 -11
- grz_cli-0.3.0/MANIFEST.in +0 -3
- grz_cli-0.3.0/src/grz_cli/__init__.py +0 -1
- grz_cli-0.3.0/src/grz_cli/resources/thresholds.json +0 -299
- grz_cli-0.3.0/src/grz_cli.egg-info/entry_points.txt +0 -2
- {grz_cli-0.3.0 → grz_cli-0.5.0}/LICENSE +0 -0
- {grz_cli-0.3.0 → grz_cli-0.5.0}/setup.cfg +0 -0
- {grz_cli-0.3.0 → grz_cli-0.5.0}/src/__init__.py +0 -0
- {grz_cli-0.3.0 → grz_cli-0.5.0}/src/grz_cli/logging_setup.py +0 -0
- {grz_cli-0.3.0 → grz_cli-0.5.0}/src/grz_cli/models/__init__.py +0 -0
- {grz_cli-0.3.0 → grz_cli-0.5.0}/src/grz_cli/states.py +0 -0
- {grz_cli-0.3.0 → grz_cli-0.5.0}/src/grz_cli.egg-info/dependency_links.txt +0 -0
- {grz_cli-0.3.0 → grz_cli-0.5.0}/src/grz_cli.egg-info/top_level.txt +0 -0
- {grz_cli-0.3.0 → grz_cli-0.5.0}/tests/test_download.py +0 -0
- {grz_cli-0.3.0 → grz_cli-0.5.0}/tests/test_file_operations.py +0 -0
- {grz_cli-0.3.0 → grz_cli-0.5.0}/tests/test_parser.py +0 -0
- {grz_cli-0.3.0 → grz_cli-0.5.0}/tests/test_read_multiple_json.py +0 -0
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: grz-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.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
|
-
License: MIT
|
|
6
|
+
License-Expression: MIT
|
|
7
7
|
Project-URL: Homepage, https://codebase.helmholtz.cloud/grz-mv-genomseq/grz-cli
|
|
8
8
|
Keywords: GRZ,GDC,S3
|
|
9
9
|
Classifier: Programming Language :: Python :: 3
|
|
10
|
-
Classifier: License :: OSI Approved :: MIT License
|
|
11
10
|
Classifier: Operating System :: OS Independent
|
|
12
11
|
Requires-Python: >=3.12
|
|
13
12
|
Description-Content-Type: text/markdown
|
|
14
|
-
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Requires-Dist: boto3<2,>=1.36
|
|
15
15
|
Requires-Dist: click<9,>=8.1.7
|
|
16
16
|
Requires-Dist: crypt4gh<2,>=1.7
|
|
17
17
|
Requires-Dist: jsonschema<5,>=4.23.0
|
|
@@ -19,7 +19,9 @@ 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
21
|
Requires-Dist: platformdirs<5,>=4.3.6
|
|
22
|
-
Requires-Dist: grz-pydantic-models>=1.2.
|
|
22
|
+
Requires-Dist: grz-pydantic-models>=1.2.1
|
|
23
|
+
Requires-Dist: pysam==0.23.*
|
|
24
|
+
Requires-Dist: rich==13.*
|
|
23
25
|
Provides-Extra: test
|
|
24
26
|
Requires-Dist: pytest<9,>=8.3.3; extra == "test"
|
|
25
27
|
Requires-Dist: pytest-cov<6,>=5.0.0; extra == "test"
|
|
@@ -28,6 +30,7 @@ Requires-Dist: moto[s3]<6,>=5.0.16; extra == "test"
|
|
|
28
30
|
Requires-Dist: pytest-sugar<2,>=1.0.0; extra == "test"
|
|
29
31
|
Requires-Dist: ruff<1,>=0.9.6; extra == "test"
|
|
30
32
|
Requires-Dist: numpy<3,>=2.1.2; extra == "test"
|
|
33
|
+
Dynamic: license-file
|
|
31
34
|
|
|
32
35
|
# GRZ CLI
|
|
33
36
|
|
|
@@ -254,6 +257,8 @@ Option is for the usage at a GDC/GRZ.
|
|
|
254
257
|
|
|
255
258
|
## Testing
|
|
256
259
|
|
|
260
|
+
Please note that binary files used for testing are managed with [Git LFS](https://git-lfs.com), which will be needed to clone them locally with the git repository.
|
|
261
|
+
|
|
257
262
|
To run the tests, navigate to the root directory of your project and invoke `pytest`.
|
|
258
263
|
Alternatively, install `uv` and `tox` and run `uv run tox`.
|
|
259
264
|
|
|
@@ -223,6 +223,8 @@ Option is for the usage at a GDC/GRZ.
|
|
|
223
223
|
|
|
224
224
|
## Testing
|
|
225
225
|
|
|
226
|
+
Please note that binary files used for testing are managed with [Git LFS](https://git-lfs.com), which will be needed to clone them locally with the git repository.
|
|
227
|
+
|
|
226
228
|
To run the tests, navigate to the root directory of your project and invoke `pytest`.
|
|
227
229
|
Alternatively, install `uv` and `tox` and run `uv run tox`.
|
|
228
230
|
|
|
@@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta"
|
|
|
6
6
|
name = "grz-cli"
|
|
7
7
|
description = "Tool for validation, encryption and upload of MV submissions to GDCs."
|
|
8
8
|
readme = "README.md"
|
|
9
|
-
license =
|
|
9
|
+
license = "MIT"
|
|
10
10
|
authors = [
|
|
11
11
|
{ name = "Koray Kirli", email = "koraykirli@gmail.com" },
|
|
12
12
|
{ name = "Mathias Lesche", email = "mathias.lesche@tu-dresden.de" },
|
|
@@ -16,7 +16,7 @@ authors = [
|
|
|
16
16
|
]
|
|
17
17
|
dynamic = ["version"]
|
|
18
18
|
dependencies = [
|
|
19
|
-
"boto3 >=1.
|
|
19
|
+
"boto3 >=1.36,<2",
|
|
20
20
|
"click >=8.1.7,<9",
|
|
21
21
|
"crypt4gh >=1.7,<2",
|
|
22
22
|
"jsonschema >=4.23.0,<5",
|
|
@@ -24,11 +24,12 @@ dependencies = [
|
|
|
24
24
|
"tqdm >=4.66.5,<5",
|
|
25
25
|
"pydantic >=2.9.2,<2.10",
|
|
26
26
|
"platformdirs >=4.3.6,<5",
|
|
27
|
-
"grz-pydantic-models >=1.2.
|
|
27
|
+
"grz-pydantic-models >=1.2.1",
|
|
28
|
+
"pysam ==0.23.*",
|
|
29
|
+
"rich ==13.*"
|
|
28
30
|
]
|
|
29
31
|
classifiers = [
|
|
30
32
|
"Programming Language :: Python :: 3",
|
|
31
|
-
"License :: OSI Approved :: MIT License",
|
|
32
33
|
"Operating System :: OS Independent"
|
|
33
34
|
]
|
|
34
35
|
requires-python = ">=3.12"
|
|
@@ -42,7 +43,7 @@ version = { attr = "grz_cli.__version__" }
|
|
|
42
43
|
"Homepage" = "https://codebase.helmholtz.cloud/grz-mv-genomseq/grz-cli"
|
|
43
44
|
|
|
44
45
|
[project.scripts]
|
|
45
|
-
grz-cli = "grz_cli.cli:
|
|
46
|
+
grz-cli = "grz_cli.cli:main"
|
|
46
47
|
|
|
47
48
|
[project.optional-dependencies]
|
|
48
49
|
test = [
|
|
@@ -206,6 +207,3 @@ follow_untyped_imports = true
|
|
|
206
207
|
addopts = [
|
|
207
208
|
"--import-mode=importlib",
|
|
208
209
|
]
|
|
209
|
-
|
|
210
|
-
[tool.setuptools]
|
|
211
|
-
license-files = []
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.5.0"
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Validation methods for BAM files
|
|
3
|
+
|
|
4
|
+
e.g. warn on headers present, which can contain personally identifying
|
|
5
|
+
information
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
from collections.abc import Generator
|
|
10
|
+
from os import PathLike
|
|
11
|
+
|
|
12
|
+
import pysam
|
|
13
|
+
|
|
14
|
+
log = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def validate_bam(bam_path: str | PathLike) -> Generator[str]:
|
|
18
|
+
"""Check if a BAM file is valid for submission"""
|
|
19
|
+
errors: list[str] = []
|
|
20
|
+
|
|
21
|
+
# disable pysam's SQ header presence enforcement (check_sq)
|
|
22
|
+
# these are used to optionally sort alignments by a custom contig order,
|
|
23
|
+
# which is unnecessary in unmapped BAM files
|
|
24
|
+
bam_file = pysam.AlignmentFile(str(bam_path), mode="rb", check_sq=False)
|
|
25
|
+
|
|
26
|
+
header = bam_file.header.to_dict()
|
|
27
|
+
# ignore the HD key as it is almost always present and extremely
|
|
28
|
+
# unlikely to accidentally contain identifying information
|
|
29
|
+
concerning_keys = header.keys() - {"HD"}
|
|
30
|
+
if concerning_keys:
|
|
31
|
+
log.warning(f"Detected a header in BAM file '{bam_path}', ensure it contains no private information:")
|
|
32
|
+
for key in concerning_keys:
|
|
33
|
+
for line in header[key]:
|
|
34
|
+
log.warning(f"@{key}")
|
|
35
|
+
for tag, value in line.items():
|
|
36
|
+
log.warning(f" {tag}: {value}")
|
|
37
|
+
log.warning("--- End of potentially-concerning BAM header information ---")
|
|
38
|
+
|
|
39
|
+
yield from errors
|
|
@@ -3,19 +3,25 @@ CLI module for handling command-line interface operations.
|
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
5
|
import importlib
|
|
6
|
+
import json
|
|
6
7
|
import logging
|
|
7
8
|
import logging.config
|
|
9
|
+
import os
|
|
8
10
|
import sys
|
|
9
11
|
from os import PathLike, sched_getaffinity
|
|
10
12
|
from pathlib import Path
|
|
11
13
|
from tempfile import NamedTemporaryFile
|
|
12
14
|
|
|
13
15
|
import click
|
|
14
|
-
import grz_pydantic_models
|
|
16
|
+
import grz_pydantic_models.submission.metadata
|
|
15
17
|
import platformdirs
|
|
18
|
+
import rich.console
|
|
19
|
+
import rich.table
|
|
16
20
|
import yaml
|
|
21
|
+
from pydantic_core import to_jsonable_python
|
|
17
22
|
|
|
18
23
|
from .constants import PACKAGE_ROOT
|
|
24
|
+
from .download import query_submissions
|
|
19
25
|
from .logging_setup import add_filelogger
|
|
20
26
|
from .models.config import ConfigModel
|
|
21
27
|
from .parser import Worker
|
|
@@ -82,7 +88,7 @@ submission_id = click.option(
|
|
|
82
88
|
required=True,
|
|
83
89
|
type=str,
|
|
84
90
|
metavar="STRING",
|
|
85
|
-
help="S3 submission
|
|
91
|
+
help="S3 submission ID",
|
|
86
92
|
)
|
|
87
93
|
|
|
88
94
|
output_dir = click.option(
|
|
@@ -94,6 +100,16 @@ output_dir = click.option(
|
|
|
94
100
|
help="Path to the target submission output directory",
|
|
95
101
|
)
|
|
96
102
|
|
|
103
|
+
output_json = click.option("--json", "output_json", is_flag=True, help="Output JSON for machine-readability.")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def read_config(config_path: str | PathLike) -> ConfigModel:
|
|
107
|
+
"""Reads the configuration file and validates it against the schema."""
|
|
108
|
+
with open(config_path, encoding="utf-8") as f:
|
|
109
|
+
config = ConfigModel(**yaml.safe_load(f))
|
|
110
|
+
|
|
111
|
+
return config
|
|
112
|
+
|
|
97
113
|
|
|
98
114
|
class OrderedGroup(click.Group):
|
|
99
115
|
"""
|
|
@@ -105,48 +121,7 @@ class OrderedGroup(click.Group):
|
|
|
105
121
|
return list(self.commands.keys())
|
|
106
122
|
|
|
107
123
|
|
|
108
|
-
@click.
|
|
109
|
-
cls=OrderedGroup,
|
|
110
|
-
help="Validate, encrypt, decrypt and upload submissions to a GRZ/GDC.",
|
|
111
|
-
)
|
|
112
|
-
@click.version_option(
|
|
113
|
-
version=importlib.metadata.version("grz-cli"),
|
|
114
|
-
prog_name="grz-cli",
|
|
115
|
-
message=f"%(prog)s v%(version)s (metadata schema versions: {', '.join(grz_pydantic_models.get_supported_versions())})",
|
|
116
|
-
)
|
|
117
|
-
@click.option("--log-file", metavar="FILE", type=str, help="Path to log file")
|
|
118
|
-
@click.option(
|
|
119
|
-
"--log-level",
|
|
120
|
-
default="INFO",
|
|
121
|
-
type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]),
|
|
122
|
-
help="Set the log level (default: INFO)",
|
|
123
|
-
)
|
|
124
|
-
def cli(log_file: str | None = None, log_level: str = "INFO"):
|
|
125
|
-
"""
|
|
126
|
-
Command-line interface function for setting up logging.
|
|
127
|
-
|
|
128
|
-
:param log_file: Path to the log file. If provided, a file logger will be added.
|
|
129
|
-
:param log_level: Log level for the logger. It should be one of the following:
|
|
130
|
-
DEBUG, INFO, WARNING, ERROR, CRITICAL.
|
|
131
|
-
"""
|
|
132
|
-
if log_file:
|
|
133
|
-
add_filelogger(
|
|
134
|
-
log_file,
|
|
135
|
-
log_level.upper(),
|
|
136
|
-
) # Add file logger
|
|
137
|
-
|
|
138
|
-
# show only time and log level in STDOUT
|
|
139
|
-
logging.basicConfig(
|
|
140
|
-
format="%(asctime)s - %(levelname)s - %(message)s",
|
|
141
|
-
)
|
|
142
|
-
|
|
143
|
-
# set the log level for this package
|
|
144
|
-
logging.getLogger(PACKAGE_ROOT).setLevel(log_level.upper())
|
|
145
|
-
|
|
146
|
-
log.debug("Logging setup complete.")
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
@cli.command()
|
|
124
|
+
@click.command()
|
|
150
125
|
@submission_dir
|
|
151
126
|
def validate(submission_dir):
|
|
152
127
|
"""
|
|
@@ -170,7 +145,7 @@ def validate(submission_dir):
|
|
|
170
145
|
log.info("Validation finished!")
|
|
171
146
|
|
|
172
147
|
|
|
173
|
-
@
|
|
148
|
+
@click.command()
|
|
174
149
|
@submission_dir
|
|
175
150
|
@config_file
|
|
176
151
|
def encrypt(
|
|
@@ -216,41 +191,7 @@ def encrypt(
|
|
|
216
191
|
log.info("Encryption successful!")
|
|
217
192
|
|
|
218
193
|
|
|
219
|
-
@
|
|
220
|
-
@submission_dir
|
|
221
|
-
@config_file
|
|
222
|
-
def decrypt(
|
|
223
|
-
submission_dir,
|
|
224
|
-
config_file,
|
|
225
|
-
):
|
|
226
|
-
"""
|
|
227
|
-
Decrypt a submission.
|
|
228
|
-
|
|
229
|
-
Decrypting a submission requires the _private_ key of the original recipient.
|
|
230
|
-
"""
|
|
231
|
-
config = read_config(config_file)
|
|
232
|
-
|
|
233
|
-
grz_privkey_path = config.grz_private_key_path
|
|
234
|
-
if not grz_privkey_path:
|
|
235
|
-
log.error("GRZ private key path is required for decryption.")
|
|
236
|
-
sys.exit(1)
|
|
237
|
-
|
|
238
|
-
log.info("Starting decryption...")
|
|
239
|
-
|
|
240
|
-
submission_dir = Path(submission_dir)
|
|
241
|
-
|
|
242
|
-
worker_inst = Worker(
|
|
243
|
-
metadata_dir=submission_dir / "metadata",
|
|
244
|
-
files_dir=submission_dir / "files",
|
|
245
|
-
log_dir=submission_dir / "logs",
|
|
246
|
-
encrypted_files_dir=submission_dir / "encrypted_files",
|
|
247
|
-
)
|
|
248
|
-
worker_inst.decrypt(grz_privkey_path)
|
|
249
|
-
|
|
250
|
-
log.info("Encryption successful!")
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
@cli.command()
|
|
194
|
+
@click.command()
|
|
254
195
|
@submission_dir
|
|
255
196
|
@config_file
|
|
256
197
|
@threads
|
|
@@ -275,12 +216,34 @@ def upload(
|
|
|
275
216
|
encrypted_files_dir=submission_dir / "encrypted_files",
|
|
276
217
|
threads=threads,
|
|
277
218
|
)
|
|
278
|
-
|
|
219
|
+
# output the generated submission ID
|
|
220
|
+
print(worker_inst.upload(config))
|
|
279
221
|
|
|
280
222
|
log.info("Upload finished!")
|
|
281
223
|
|
|
282
224
|
|
|
283
|
-
@
|
|
225
|
+
@click.command("submit")
|
|
226
|
+
@submission_dir
|
|
227
|
+
@config_file
|
|
228
|
+
@threads
|
|
229
|
+
@click.pass_context
|
|
230
|
+
def submit(ctx, submission_dir, config_file, threads):
|
|
231
|
+
"""
|
|
232
|
+
Validate, encrypt, and then upload.
|
|
233
|
+
|
|
234
|
+
This is a convenience command that performs the following steps in order:
|
|
235
|
+
1. Validate the submission
|
|
236
|
+
2. Encrypt the submission
|
|
237
|
+
3. Upload the encrypted submission
|
|
238
|
+
"""
|
|
239
|
+
click.echo("Starting submission process...")
|
|
240
|
+
ctx.invoke(validate, submission_dir=submission_dir)
|
|
241
|
+
ctx.invoke(encrypt, submission_dir=submission_dir, config_file=config_file)
|
|
242
|
+
ctx.invoke(upload, submission_dir=submission_dir, config_file=config_file, threads=threads)
|
|
243
|
+
click.echo("Submission finished!")
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
@click.command()
|
|
284
247
|
@submission_id
|
|
285
248
|
@output_dir
|
|
286
249
|
@config_file
|
|
@@ -318,13 +281,140 @@ def download(
|
|
|
318
281
|
log.info("Download finished!")
|
|
319
282
|
|
|
320
283
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
284
|
+
@click.command()
|
|
285
|
+
@config_file
|
|
286
|
+
@output_json
|
|
287
|
+
def list_submissions(config_file, output_json):
|
|
288
|
+
"""
|
|
289
|
+
List submissions within an inbox from oldest to newest.
|
|
290
|
+
"""
|
|
291
|
+
config = read_config(config_file)
|
|
292
|
+
submissions = query_submissions(config)
|
|
325
293
|
|
|
326
|
-
|
|
294
|
+
if output_json:
|
|
295
|
+
json.dump(to_jsonable_python(submissions), sys.stdout)
|
|
296
|
+
else:
|
|
297
|
+
console = rich.console.Console()
|
|
298
|
+
table = rich.table.Table()
|
|
299
|
+
table.add_column("ID", no_wrap=True)
|
|
300
|
+
table.add_column("Status", no_wrap=True)
|
|
301
|
+
table.add_column("Oldest Upload", overflow="fold")
|
|
302
|
+
table.add_column("Newest Upload", overflow="fold")
|
|
303
|
+
for submission in submissions:
|
|
304
|
+
status_text = rich.text.Text(
|
|
305
|
+
"Complete" if submission.complete else "Incomplete",
|
|
306
|
+
style="green" if submission.complete else "yellow",
|
|
307
|
+
)
|
|
308
|
+
table.add_row(
|
|
309
|
+
submission.submission_id,
|
|
310
|
+
status_text,
|
|
311
|
+
submission.oldest_upload.astimezone().strftime("%Y-%m-%d %H:%M:%S"),
|
|
312
|
+
submission.newest_upload.astimezone().strftime("%Y-%m-%d %H:%M:%S"),
|
|
313
|
+
)
|
|
314
|
+
console.print(table)
|
|
327
315
|
|
|
328
316
|
|
|
329
|
-
|
|
317
|
+
@click.command()
|
|
318
|
+
@submission_dir
|
|
319
|
+
@config_file
|
|
320
|
+
def decrypt(
|
|
321
|
+
submission_dir,
|
|
322
|
+
config_file,
|
|
323
|
+
):
|
|
324
|
+
"""
|
|
325
|
+
Decrypt a submission.
|
|
326
|
+
|
|
327
|
+
Decrypting a submission requires the _private_ key of the original recipient.
|
|
328
|
+
"""
|
|
329
|
+
config = read_config(config_file)
|
|
330
|
+
|
|
331
|
+
grz_privkey_path = config.grz_private_key_path
|
|
332
|
+
if not grz_privkey_path:
|
|
333
|
+
log.error("GRZ private key path is required for decryption.")
|
|
334
|
+
sys.exit(1)
|
|
335
|
+
|
|
336
|
+
log.info("Starting decryption...")
|
|
337
|
+
|
|
338
|
+
submission_dir = Path(submission_dir)
|
|
339
|
+
|
|
340
|
+
worker_inst = Worker(
|
|
341
|
+
metadata_dir=submission_dir / "metadata",
|
|
342
|
+
files_dir=submission_dir / "files",
|
|
343
|
+
log_dir=submission_dir / "logs",
|
|
344
|
+
encrypted_files_dir=submission_dir / "encrypted_files",
|
|
345
|
+
)
|
|
346
|
+
worker_inst.decrypt(grz_privkey_path)
|
|
347
|
+
|
|
348
|
+
log.info("Encryption successful!")
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def build_cli(grz_mode=False):
|
|
352
|
+
"""
|
|
353
|
+
Factory for building the CLI application.
|
|
354
|
+
"""
|
|
355
|
+
|
|
356
|
+
@click.group(
|
|
357
|
+
cls=OrderedGroup,
|
|
358
|
+
help="Validate, encrypt, decrypt and upload submissions to a GRZ/GDC.",
|
|
359
|
+
)
|
|
360
|
+
@click.version_option(
|
|
361
|
+
version=importlib.metadata.version("grz-cli"),
|
|
362
|
+
prog_name="grz-cli",
|
|
363
|
+
message=f"%(prog)s v%(version)s (metadata schema versions: {', '.join(grz_pydantic_models.submission.metadata.get_supported_versions())})",
|
|
364
|
+
)
|
|
365
|
+
@click.option("--log-file", metavar="FILE", type=str, help="Path to log file")
|
|
366
|
+
@click.option(
|
|
367
|
+
"--log-level",
|
|
368
|
+
default="INFO",
|
|
369
|
+
type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]),
|
|
370
|
+
help="Set the log level (default: INFO)",
|
|
371
|
+
)
|
|
372
|
+
def cli(log_file: str | None = None, log_level: str = "INFO"):
|
|
373
|
+
"""
|
|
374
|
+
Command-line interface function for setting up logging.
|
|
375
|
+
|
|
376
|
+
:param log_file: Path to the log file. If provided, a file logger will be added.
|
|
377
|
+
:param log_level: Log level for the logger. It should be one of the following:
|
|
378
|
+
DEBUG, INFO, WARNING, ERROR, CRITICAL.
|
|
379
|
+
"""
|
|
380
|
+
if log_file:
|
|
381
|
+
add_filelogger(
|
|
382
|
+
log_file,
|
|
383
|
+
log_level.upper(),
|
|
384
|
+
) # Add file logger
|
|
385
|
+
|
|
386
|
+
# show only time and log level in STDOUT
|
|
387
|
+
logging.basicConfig(
|
|
388
|
+
format="%(asctime)s - %(levelname)s - %(message)s",
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
# set the log level for this package
|
|
392
|
+
logging.getLogger(PACKAGE_ROOT).setLevel(log_level.upper())
|
|
393
|
+
|
|
394
|
+
log.debug("Logging setup complete.")
|
|
395
|
+
|
|
396
|
+
cli.add_command(validate)
|
|
397
|
+
cli.add_command(encrypt)
|
|
398
|
+
cli.add_command(upload)
|
|
399
|
+
cli.add_command(submit)
|
|
400
|
+
|
|
401
|
+
if grz_mode:
|
|
402
|
+
cli.add_command(list_submissions, name="list")
|
|
403
|
+
cli.add_command(download)
|
|
404
|
+
cli.add_command(decrypt)
|
|
405
|
+
|
|
406
|
+
return cli
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def main():
|
|
410
|
+
"""
|
|
411
|
+
Main entry point for the CLI application.
|
|
412
|
+
"""
|
|
413
|
+
grz_mode = os.environ.get("GRZ_MODE", "false").lower() in {"true", "yes", "1"}
|
|
414
|
+
|
|
415
|
+
cli = build_cli(grz_mode=grz_mode)
|
|
330
416
|
cli()
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
if __name__ == "__main__":
|
|
420
|
+
main()
|
|
@@ -18,7 +18,6 @@ LOGGING_CONFIG = {
|
|
|
18
18
|
"level": "INFO",
|
|
19
19
|
"formatter": "standard",
|
|
20
20
|
"class": "logging.StreamHandler",
|
|
21
|
-
"stream": "ext://sys.stdout", # Default is stderr
|
|
22
21
|
},
|
|
23
22
|
},
|
|
24
23
|
"loggers": {
|
|
@@ -35,3 +34,5 @@ LOGGING_CONFIG = {
|
|
|
35
34
|
},
|
|
36
35
|
},
|
|
37
36
|
}
|
|
37
|
+
|
|
38
|
+
TQDM_SMOOTHING: float = 0.0001
|
|
@@ -1,24 +1,27 @@
|
|
|
1
|
-
"""Module for
|
|
1
|
+
"""Module for downloading encrypted submissions to local storage"""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import datetime
|
|
6
|
+
import itertools
|
|
5
7
|
import logging
|
|
6
8
|
import math
|
|
7
9
|
import re
|
|
10
|
+
from operator import attrgetter, itemgetter
|
|
8
11
|
from os import PathLike
|
|
9
12
|
from pathlib import Path
|
|
10
13
|
from typing import TYPE_CHECKING
|
|
11
14
|
|
|
12
|
-
import boto3 # type: ignore[import-untyped]
|
|
13
15
|
import botocore.handlers # type: ignore[import-untyped]
|
|
14
|
-
from boto3 import client as boto3_client # type: ignore[import-untyped]
|
|
15
16
|
from boto3.s3.transfer import S3Transfer, TransferConfig # type: ignore[import-untyped]
|
|
16
|
-
from
|
|
17
|
+
from pydantic import BaseModel
|
|
17
18
|
from tqdm.auto import tqdm
|
|
18
19
|
|
|
20
|
+
from .constants import TQDM_SMOOTHING
|
|
19
21
|
from .models.config import ConfigModel
|
|
20
22
|
from .progress_logging import FileProgressLogger
|
|
21
23
|
from .states import DownloadState
|
|
24
|
+
from .transfer import init_s3_client
|
|
22
25
|
|
|
23
26
|
MULTIPART_THRESHOLD = 8 * 1024 * 1024 # 8MiB, boto3 default
|
|
24
27
|
MULTIPART_CHUNKSIZE = 8 * 1024 * 1024 # 8MiB, boto3 default
|
|
@@ -62,32 +65,7 @@ class S3BotoDownloadWorker:
|
|
|
62
65
|
self._config = config
|
|
63
66
|
self._threads = threads
|
|
64
67
|
|
|
65
|
-
self.
|
|
66
|
-
|
|
67
|
-
def _init_s3_client(self):
|
|
68
|
-
# if user specifies empty strings, this might be an issue
|
|
69
|
-
def empty_str_to_none(string: str | None) -> str | None:
|
|
70
|
-
if string == "" or string is None:
|
|
71
|
-
return None
|
|
72
|
-
else:
|
|
73
|
-
return string
|
|
74
|
-
|
|
75
|
-
# configure proxies if proxy_url is defined
|
|
76
|
-
proxy_url = empty_str_to_none(self._config.s3_options.proxy_url)
|
|
77
|
-
config = Boto3Config(proxies={"http": proxy_url, "https": proxy_url}) if proxy_url is not None else None
|
|
78
|
-
|
|
79
|
-
# Initialize S3 client for uploading
|
|
80
|
-
self._s3_client: boto3.session.Session.client = boto3_client(
|
|
81
|
-
service_name="s3",
|
|
82
|
-
region_name=empty_str_to_none(self._config.s3_options.region_name),
|
|
83
|
-
api_version=empty_str_to_none(self._config.s3_options.api_version),
|
|
84
|
-
use_ssl=self._config.s3_options.use_ssl,
|
|
85
|
-
endpoint_url=empty_str_to_none(str(self._config.s3_options.endpoint_url)),
|
|
86
|
-
aws_access_key_id=empty_str_to_none(self._config.s3_options.access_key),
|
|
87
|
-
aws_secret_access_key=empty_str_to_none(self._config.s3_options.secret),
|
|
88
|
-
aws_session_token=empty_str_to_none(self._config.s3_options.session_token),
|
|
89
|
-
config=config,
|
|
90
|
-
)
|
|
68
|
+
self._s3_client = init_s3_client(config)
|
|
91
69
|
|
|
92
70
|
def prepare_download(
|
|
93
71
|
self,
|
|
@@ -155,7 +133,7 @@ class S3BotoDownloadWorker:
|
|
|
155
133
|
)
|
|
156
134
|
|
|
157
135
|
transfer = S3Transfer(self._s3_client, config)
|
|
158
|
-
progress_bar = tqdm(total=filesize, unit="B", unit_scale=True, unit_divisor=1024)
|
|
136
|
+
progress_bar = tqdm(total=filesize, unit="B", unit_scale=True, unit_divisor=1024, smoothing=TQDM_SMOOTHING)
|
|
159
137
|
transfer.download_file(
|
|
160
138
|
self._config.s3_options.bucket,
|
|
161
139
|
s3_object_id,
|
|
@@ -222,3 +200,42 @@ class S3BotoDownloadWorker:
|
|
|
222
200
|
file_key,
|
|
223
201
|
str(full_path),
|
|
224
202
|
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class SubmissionInboxState(BaseModel):
|
|
206
|
+
"""A summary of the state of a submission in an inbox"""
|
|
207
|
+
|
|
208
|
+
submission_id: str
|
|
209
|
+
complete: bool
|
|
210
|
+
oldest_upload: datetime.datetime
|
|
211
|
+
newest_upload: datetime.datetime
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def query_submissions(config: ConfigModel) -> list[SubmissionInboxState]:
|
|
215
|
+
"""Queries the state of all submissions in the configured bucket."""
|
|
216
|
+
s3_client = init_s3_client(config)
|
|
217
|
+
paginator = s3_client.get_paginator("list_objects_v2")
|
|
218
|
+
|
|
219
|
+
objects = itertools.chain.from_iterable(
|
|
220
|
+
page["Contents"] for page in paginator.paginate(Bucket=config.s3_options.bucket)
|
|
221
|
+
)
|
|
222
|
+
objects_sorted = sorted(objects, key=itemgetter("Key"))
|
|
223
|
+
submission2objects = {
|
|
224
|
+
key: tuple(group) for key, group in itertools.groupby(objects_sorted, key=lambda o: o["Key"].split("/")[0])
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
submissions = []
|
|
228
|
+
for submission_id, submission_objects in submission2objects.items():
|
|
229
|
+
submission_objects_sorted = sorted(submission_objects, key=itemgetter("LastModified"))
|
|
230
|
+
oldest_object = submission_objects_sorted[0]
|
|
231
|
+
newest_object = submission_objects_sorted[-1]
|
|
232
|
+
is_complete = len(list(filter(lambda o: o["Key"].endswith("/metadata.json"), submission_objects))) == 1
|
|
233
|
+
submission = SubmissionInboxState(
|
|
234
|
+
submission_id=submission_id,
|
|
235
|
+
complete=is_complete,
|
|
236
|
+
oldest_upload=oldest_object["LastModified"],
|
|
237
|
+
newest_upload=newest_object["LastModified"],
|
|
238
|
+
)
|
|
239
|
+
submissions.append(submission)
|
|
240
|
+
|
|
241
|
+
return sorted(submissions, key=attrgetter("oldest_upload"))
|