grz-cli 0.1.0__py3-none-any.whl
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.
- __init__.py +0 -0
- grz_cli/__init__.py +0 -0
- grz_cli/cli.py +326 -0
- grz_cli/constants.py +37 -0
- grz_cli/download.py +236 -0
- grz_cli/fastq_validation.py +122 -0
- grz_cli/file_operations.py +364 -0
- grz_cli/logging_setup.py +59 -0
- grz_cli/models/__init__.py +0 -0
- grz_cli/models/config.py +118 -0
- grz_cli/models/v1_0_0/__init__.py +0 -0
- grz_cli/models/v1_0_0/metadata.py +845 -0
- grz_cli/parser.py +771 -0
- grz_cli/progress_logging.py +170 -0
- grz_cli/resources/grz-schema.json +560 -0
- grz_cli/upload.py +243 -0
- grz_cli-0.1.0.dist-info/LICENSE +21 -0
- grz_cli-0.1.0.dist-info/METADATA +240 -0
- grz_cli-0.1.0.dist-info/RECORD +22 -0
- grz_cli-0.1.0.dist-info/WHEEL +5 -0
- grz_cli-0.1.0.dist-info/entry_points.txt +2 -0
- grz_cli-0.1.0.dist-info/top_level.txt +2 -0
__init__.py
ADDED
|
File without changes
|
grz_cli/__init__.py
ADDED
|
File without changes
|
grz_cli/cli.py
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CLI module for handling command-line interface operations.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import logging.config
|
|
7
|
+
import sys
|
|
8
|
+
from os import PathLike, sched_getaffinity
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from tempfile import NamedTemporaryFile
|
|
11
|
+
|
|
12
|
+
import click
|
|
13
|
+
import platformdirs
|
|
14
|
+
import yaml
|
|
15
|
+
|
|
16
|
+
from .constants import PACKAGE_ROOT
|
|
17
|
+
from .logging_setup import add_filelogger
|
|
18
|
+
from .models.config import ConfigModel
|
|
19
|
+
from .parser import Worker
|
|
20
|
+
|
|
21
|
+
log = logging.getLogger(PACKAGE_ROOT + ".cli")
|
|
22
|
+
|
|
23
|
+
DEFAULT_CONFIG_PATH = Path(platformdirs.user_config_dir("grz-cli")) / "config.yaml"
|
|
24
|
+
|
|
25
|
+
# Aliases for path types for click options
|
|
26
|
+
# Naming convention: {DIR,FILE}_{Read,Write}_{Exists,Create}
|
|
27
|
+
DIR_R_E = click.Path(
|
|
28
|
+
exists=True,
|
|
29
|
+
file_okay=False,
|
|
30
|
+
dir_okay=True,
|
|
31
|
+
readable=True,
|
|
32
|
+
writable=False,
|
|
33
|
+
resolve_path=True,
|
|
34
|
+
)
|
|
35
|
+
DIR_RW_E = click.Path(
|
|
36
|
+
exists=True,
|
|
37
|
+
file_okay=False,
|
|
38
|
+
dir_okay=True,
|
|
39
|
+
readable=True,
|
|
40
|
+
writable=True,
|
|
41
|
+
resolve_path=True,
|
|
42
|
+
)
|
|
43
|
+
DIR_RW_C = click.Path(
|
|
44
|
+
exists=False,
|
|
45
|
+
file_okay=False,
|
|
46
|
+
dir_okay=True,
|
|
47
|
+
readable=True,
|
|
48
|
+
writable=True,
|
|
49
|
+
resolve_path=True,
|
|
50
|
+
)
|
|
51
|
+
FILE_R_E = click.Path(
|
|
52
|
+
exists=True, file_okay=True, dir_okay=False, readable=True, resolve_path=True
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
submission_dir = click.option(
|
|
56
|
+
"--submission-dir",
|
|
57
|
+
metavar="PATH",
|
|
58
|
+
type=DIR_R_E,
|
|
59
|
+
required=True,
|
|
60
|
+
help="Path to the submission directory containing 'metadata/', 'files/', 'encrypted_files/' and 'logs/' directories",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
config_file = click.option(
|
|
64
|
+
"--config-file",
|
|
65
|
+
metavar="STRING",
|
|
66
|
+
type=FILE_R_E,
|
|
67
|
+
required=False,
|
|
68
|
+
default=DEFAULT_CONFIG_PATH,
|
|
69
|
+
help="Path to config file",
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
threads = click.option(
|
|
73
|
+
"--threads",
|
|
74
|
+
default=min(len(sched_getaffinity(0)), 4),
|
|
75
|
+
type=int,
|
|
76
|
+
show_default=True,
|
|
77
|
+
help="Number of threads to use for parallel operations",
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
submission_id = click.option(
|
|
81
|
+
"--submission-id",
|
|
82
|
+
required=True,
|
|
83
|
+
type=str,
|
|
84
|
+
metavar="STRING",
|
|
85
|
+
help="S3 submission prefix (corresponds to the tanG of a submission)",
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
output_dir = click.option(
|
|
89
|
+
"--output-dir",
|
|
90
|
+
metavar="PATH",
|
|
91
|
+
type=DIR_RW_E,
|
|
92
|
+
required=True,
|
|
93
|
+
default=None,
|
|
94
|
+
help="Path to the target submission output directory",
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class OrderedGroup(click.Group):
|
|
99
|
+
"""
|
|
100
|
+
A click Group that keeps track of the order in which commands are added.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
def list_commands(self, ctx):
|
|
104
|
+
"""Return the list of commands in the order they were added."""
|
|
105
|
+
return list(self.commands.keys())
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@click.group(
|
|
109
|
+
cls=OrderedGroup,
|
|
110
|
+
help="Validate, encrypt, decrypt and upload submissions to a GRZ/GDC.",
|
|
111
|
+
)
|
|
112
|
+
@click.version_option(version="0.1", prog_name="grz-cli")
|
|
113
|
+
@click.option("--log-file", metavar="FILE", type=str, help="Path to log file")
|
|
114
|
+
@click.option(
|
|
115
|
+
"--log-level",
|
|
116
|
+
default="INFO",
|
|
117
|
+
type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]),
|
|
118
|
+
help="Set the log level (default: INFO)",
|
|
119
|
+
)
|
|
120
|
+
def cli(log_file: str | None = None, log_level: str = "INFO"):
|
|
121
|
+
"""
|
|
122
|
+
Command-line interface function for setting up logging.
|
|
123
|
+
|
|
124
|
+
:param log_file: Path to the log file. If provided, a file logger will be added.
|
|
125
|
+
:param log_level: Log level for the logger. It should be one of the following:
|
|
126
|
+
DEBUG, INFO, WARNING, ERROR, CRITICAL.
|
|
127
|
+
"""
|
|
128
|
+
if log_file:
|
|
129
|
+
add_filelogger(
|
|
130
|
+
log_file,
|
|
131
|
+
log_level.upper(),
|
|
132
|
+
) # Add file logger
|
|
133
|
+
|
|
134
|
+
# show only time and log level in STDOUT
|
|
135
|
+
logging.basicConfig(
|
|
136
|
+
format="%(asctime)s - %(levelname)s - %(message)s",
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
# set the log level for this package
|
|
140
|
+
logging.getLogger(PACKAGE_ROOT).setLevel(log_level.upper())
|
|
141
|
+
|
|
142
|
+
log.debug("Logging setup complete.")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@cli.command()
|
|
146
|
+
@submission_dir
|
|
147
|
+
def validate(submission_dir):
|
|
148
|
+
"""
|
|
149
|
+
Validate the submission.
|
|
150
|
+
|
|
151
|
+
This validates the submission by checking its checksums, as well as performing basic sanity checks on the supplied metadata.
|
|
152
|
+
Must be executed before calling `encrypt` and `upload`.
|
|
153
|
+
"""
|
|
154
|
+
log.info("Starting validation...")
|
|
155
|
+
|
|
156
|
+
submission_dir = Path(submission_dir)
|
|
157
|
+
|
|
158
|
+
worker_inst = Worker(
|
|
159
|
+
metadata_dir=submission_dir / "metadata",
|
|
160
|
+
files_dir=submission_dir / "files",
|
|
161
|
+
log_dir=submission_dir / "logs",
|
|
162
|
+
encrypted_files_dir=submission_dir / "encrypted_files",
|
|
163
|
+
)
|
|
164
|
+
worker_inst.validate()
|
|
165
|
+
|
|
166
|
+
log.info("Validation finished!")
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@cli.command()
|
|
170
|
+
@submission_dir
|
|
171
|
+
@config_file
|
|
172
|
+
def encrypt(
|
|
173
|
+
submission_dir,
|
|
174
|
+
config_file,
|
|
175
|
+
):
|
|
176
|
+
"""
|
|
177
|
+
Encrypt a submission.
|
|
178
|
+
|
|
179
|
+
Encryption is done with the recipient's public key.
|
|
180
|
+
Sub-folders 'encrypted_files' and 'logs' are created within the submission directory.
|
|
181
|
+
"""
|
|
182
|
+
config = read_config(config_file)
|
|
183
|
+
|
|
184
|
+
submitter_privkey_path = config.submitter_private_key_path
|
|
185
|
+
if submitter_privkey_path == "":
|
|
186
|
+
submitter_privkey_path = None
|
|
187
|
+
|
|
188
|
+
log.info("Starting encryption...")
|
|
189
|
+
|
|
190
|
+
submission_dir = Path(submission_dir)
|
|
191
|
+
|
|
192
|
+
worker_inst = Worker(
|
|
193
|
+
metadata_dir=submission_dir / "metadata",
|
|
194
|
+
files_dir=submission_dir / "files",
|
|
195
|
+
log_dir=submission_dir / "logs",
|
|
196
|
+
encrypted_files_dir=submission_dir / "encrypted_files",
|
|
197
|
+
)
|
|
198
|
+
if config.grz_public_key:
|
|
199
|
+
with NamedTemporaryFile("w") as f:
|
|
200
|
+
f.write(config.grz_public_key)
|
|
201
|
+
f.flush()
|
|
202
|
+
worker_inst.encrypt(
|
|
203
|
+
f.name,
|
|
204
|
+
submitter_private_key_path=submitter_privkey_path,
|
|
205
|
+
)
|
|
206
|
+
else:
|
|
207
|
+
worker_inst.encrypt(
|
|
208
|
+
config.grz_public_key_path,
|
|
209
|
+
submitter_private_key_path=submitter_privkey_path,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
log.info("Encryption successful!")
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
@cli.command()
|
|
216
|
+
@submission_dir
|
|
217
|
+
@config_file
|
|
218
|
+
def decrypt(
|
|
219
|
+
submission_dir,
|
|
220
|
+
config_file,
|
|
221
|
+
):
|
|
222
|
+
"""
|
|
223
|
+
Decrypt a submission.
|
|
224
|
+
|
|
225
|
+
Decrypting a submission requires the _private_ key of the original recipient.
|
|
226
|
+
"""
|
|
227
|
+
config = read_config(config_file)
|
|
228
|
+
|
|
229
|
+
grz_privkey_path = config.grz_private_key_path
|
|
230
|
+
if not grz_privkey_path:
|
|
231
|
+
log.error("GRZ private key path is required for decryption.")
|
|
232
|
+
sys.exit(1)
|
|
233
|
+
|
|
234
|
+
log.info("Starting encryption...")
|
|
235
|
+
|
|
236
|
+
submission_dir = Path(submission_dir)
|
|
237
|
+
|
|
238
|
+
worker_inst = Worker(
|
|
239
|
+
metadata_dir=submission_dir / "metadata",
|
|
240
|
+
files_dir=submission_dir / "files",
|
|
241
|
+
log_dir=submission_dir / "logs",
|
|
242
|
+
encrypted_files_dir=submission_dir / "encrypted_files",
|
|
243
|
+
)
|
|
244
|
+
worker_inst.decrypt(grz_privkey_path)
|
|
245
|
+
|
|
246
|
+
log.info("Encryption successful!")
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
@cli.command()
|
|
250
|
+
@submission_dir
|
|
251
|
+
@config_file
|
|
252
|
+
@threads
|
|
253
|
+
def upload(
|
|
254
|
+
submission_dir,
|
|
255
|
+
config_file,
|
|
256
|
+
threads,
|
|
257
|
+
):
|
|
258
|
+
"""
|
|
259
|
+
Upload a submission to a GRZ/GDC.
|
|
260
|
+
"""
|
|
261
|
+
config = read_config(config_file)
|
|
262
|
+
|
|
263
|
+
log.info("Starting upload...")
|
|
264
|
+
|
|
265
|
+
submission_dir = Path(submission_dir)
|
|
266
|
+
|
|
267
|
+
worker_inst = Worker(
|
|
268
|
+
metadata_dir=submission_dir / "metadata",
|
|
269
|
+
files_dir=submission_dir / "files",
|
|
270
|
+
log_dir=submission_dir / "logs",
|
|
271
|
+
encrypted_files_dir=submission_dir / "encrypted_files",
|
|
272
|
+
threads=threads,
|
|
273
|
+
)
|
|
274
|
+
worker_inst.upload(config)
|
|
275
|
+
|
|
276
|
+
log.info("Upload finished!")
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
@cli.command()
|
|
280
|
+
@submission_id
|
|
281
|
+
@output_dir
|
|
282
|
+
@config_file
|
|
283
|
+
@threads
|
|
284
|
+
def download(
|
|
285
|
+
submission_id,
|
|
286
|
+
output_dir,
|
|
287
|
+
config_file,
|
|
288
|
+
threads,
|
|
289
|
+
):
|
|
290
|
+
"""
|
|
291
|
+
Download a submission from a GRZ.
|
|
292
|
+
|
|
293
|
+
Downloaded metadata is stored within the `metadata` sub-folder of the submission output directory.
|
|
294
|
+
Downloaded files are stored within the `encrypted_files` sub-folder of the submission output directory.
|
|
295
|
+
"""
|
|
296
|
+
config = read_config(config_file)
|
|
297
|
+
|
|
298
|
+
log.info("Starting download...")
|
|
299
|
+
|
|
300
|
+
submission_dir_path = Path(output_dir)
|
|
301
|
+
if not submission_dir_path.is_dir():
|
|
302
|
+
log.debug("Creating submission directory %s", submission_dir_path)
|
|
303
|
+
submission_dir_path.mkdir(mode=0o770, parents=False, exist_ok=False)
|
|
304
|
+
|
|
305
|
+
worker_inst = Worker(
|
|
306
|
+
metadata_dir=submission_dir_path / "metadata",
|
|
307
|
+
files_dir=submission_dir_path / "files",
|
|
308
|
+
log_dir=submission_dir_path / "logs",
|
|
309
|
+
encrypted_files_dir=submission_dir_path / "encrypted_files",
|
|
310
|
+
threads=threads,
|
|
311
|
+
)
|
|
312
|
+
worker_inst.download(config, submission_id)
|
|
313
|
+
|
|
314
|
+
log.info("Download finished!")
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def read_config(config_path: str | PathLike) -> ConfigModel:
|
|
318
|
+
"""Reads the configuration file and validates it against the schema."""
|
|
319
|
+
with open(config_path, encoding="utf-8") as f:
|
|
320
|
+
config = ConfigModel(**yaml.safe_load(f))
|
|
321
|
+
|
|
322
|
+
return config
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
if __name__ == "__main__":
|
|
326
|
+
cli()
|
grz_cli/constants.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Constants for logging configuration, JSON schema validation, and other settings."""
|
|
2
|
+
|
|
3
|
+
PACKAGE_ROOT = "grz_cli"
|
|
4
|
+
|
|
5
|
+
LOGGING_FORMAT = "%(asctime)s [%(levelname)s] %(name)s: %(message)s"
|
|
6
|
+
LOGGING_DATEFMT = "%Y-%m-%d %I:%M %p"
|
|
7
|
+
LOGGING_CONFIG = {
|
|
8
|
+
"version": 1,
|
|
9
|
+
"disable_existing_loggers": True,
|
|
10
|
+
"formatters": {
|
|
11
|
+
"standard": {
|
|
12
|
+
"format": LOGGING_FORMAT,
|
|
13
|
+
"datefmt": LOGGING_DATEFMT,
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
"handlers": {
|
|
17
|
+
"default": {
|
|
18
|
+
"level": "INFO",
|
|
19
|
+
"formatter": "standard",
|
|
20
|
+
"class": "logging.StreamHandler",
|
|
21
|
+
"stream": "ext://sys.stdout", # Default is stderr
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
"loggers": {
|
|
25
|
+
"": { # root logger
|
|
26
|
+
"handlers": ["default"],
|
|
27
|
+
"level": "WARNING",
|
|
28
|
+
"propagate": False,
|
|
29
|
+
},
|
|
30
|
+
PACKAGE_ROOT: {"handlers": ["default"], "level": "INFO", "propagate": False},
|
|
31
|
+
"__main__": { # if __name__ == '__main__'
|
|
32
|
+
"handlers": ["default"],
|
|
33
|
+
"level": "DEBUG",
|
|
34
|
+
"propagate": False,
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
}
|
grz_cli/download.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""Module for uploading encrypted submissions to a remote storage"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import math
|
|
7
|
+
import re
|
|
8
|
+
from os import PathLike
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
import boto3 # type: ignore[import-untyped]
|
|
13
|
+
import botocore.handlers # type: ignore[import-untyped]
|
|
14
|
+
from boto3 import client as boto3_client # type: ignore[import-untyped]
|
|
15
|
+
from boto3.s3.transfer import S3Transfer, TransferConfig # type: ignore[import-untyped]
|
|
16
|
+
from botocore.config import Config as Boto3Config # type: ignore[import-untyped]
|
|
17
|
+
from tqdm.auto import tqdm
|
|
18
|
+
|
|
19
|
+
from .models.config import ConfigModel
|
|
20
|
+
from .progress_logging import FileProgressLogger
|
|
21
|
+
|
|
22
|
+
MULTIPART_THRESHOLD = 8 * 1024 * 1024 # 8MiB, boto3 default
|
|
23
|
+
MULTIPART_CHUNKSIZE = 8 * 1024 * 1024 # 8MiB, boto3 default
|
|
24
|
+
MULTIPART_MAX_CHUNKS = 1000 # CEPH S3 limit, AWS limit is 10000
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from .parser import EncryptedSubmission
|
|
28
|
+
|
|
29
|
+
log = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
# see discussion: https://github.com/boto/boto3/discussions/4251 to accept bucket names with ":" in the name
|
|
32
|
+
botocore.handlers.VALID_BUCKET = re.compile(r"^[:a-zA-Z0-9.\-_]{1,255}$") # type: ignore[import-untyped]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class DownloadError(Exception):
|
|
36
|
+
"""Exception raised when an upload fails"""
|
|
37
|
+
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class S3BotoDownloadWorker:
|
|
42
|
+
"""Implementation of an upload worker using boto3 for S3"""
|
|
43
|
+
|
|
44
|
+
__log = log.getChild("S3BotoDownloadWorker")
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
config: ConfigModel,
|
|
49
|
+
status_file_path: str | PathLike,
|
|
50
|
+
threads: int = 1,
|
|
51
|
+
):
|
|
52
|
+
"""
|
|
53
|
+
An download manager for S3 storage
|
|
54
|
+
|
|
55
|
+
:param config: The configuration model
|
|
56
|
+
:param status_file_path: The path to the status file
|
|
57
|
+
"""
|
|
58
|
+
super().__init__()
|
|
59
|
+
|
|
60
|
+
self._status_file_path = Path(status_file_path)
|
|
61
|
+
self._config = config
|
|
62
|
+
self._threads = threads
|
|
63
|
+
|
|
64
|
+
self._init_s3_client()
|
|
65
|
+
|
|
66
|
+
def _init_s3_client(self):
|
|
67
|
+
# if user specifies empty strings, this might be an issue
|
|
68
|
+
def empty_str_to_none(string: str | None) -> str | None:
|
|
69
|
+
if string == "" or string is None:
|
|
70
|
+
return None
|
|
71
|
+
else:
|
|
72
|
+
return string
|
|
73
|
+
|
|
74
|
+
# configure proxies if proxy_url is defined
|
|
75
|
+
proxy_url = empty_str_to_none(self._config.s3_options.proxy_url)
|
|
76
|
+
if proxy_url is not None:
|
|
77
|
+
config = Boto3Config(proxies={"http": proxy_url, "https": proxy_url})
|
|
78
|
+
else:
|
|
79
|
+
config = None
|
|
80
|
+
|
|
81
|
+
# Initialize S3 client for uploading
|
|
82
|
+
self._s3_client: boto3.session.Session.client = boto3_client(
|
|
83
|
+
service_name="s3",
|
|
84
|
+
region_name=empty_str_to_none(self._config.s3_options.region_name),
|
|
85
|
+
api_version=empty_str_to_none(self._config.s3_options.api_version),
|
|
86
|
+
use_ssl=self._config.s3_options.use_ssl,
|
|
87
|
+
endpoint_url=empty_str_to_none(str(self._config.s3_options.endpoint_url)),
|
|
88
|
+
aws_access_key_id=empty_str_to_none(self._config.s3_options.access_key),
|
|
89
|
+
aws_secret_access_key=empty_str_to_none(self._config.s3_options.secret),
|
|
90
|
+
aws_session_token=empty_str_to_none(self._config.s3_options.session_token),
|
|
91
|
+
config=config,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
def prepare_download(
|
|
95
|
+
self,
|
|
96
|
+
metadata_dir: Path,
|
|
97
|
+
encrypted_files_dir: Path,
|
|
98
|
+
log_dir: Path,
|
|
99
|
+
):
|
|
100
|
+
"""
|
|
101
|
+
Prepare the download of an encrypted submission
|
|
102
|
+
|
|
103
|
+
:param metadata_dir: Path to the metadata directory
|
|
104
|
+
:param encrypted_files_dir: Path to the encrypted_files directory
|
|
105
|
+
:param log_dir: Path to the logs directory
|
|
106
|
+
"""
|
|
107
|
+
for dir_path in [metadata_dir, encrypted_files_dir, log_dir]:
|
|
108
|
+
if not dir_path.exists():
|
|
109
|
+
self.__log.debug("Creating directory: %s", dir_path)
|
|
110
|
+
dir_path.mkdir(parents=False, exist_ok=False) # Create the directories
|
|
111
|
+
else:
|
|
112
|
+
self.__log.debug("Directory exists: %s", dir_path)
|
|
113
|
+
|
|
114
|
+
def download_metadata(
|
|
115
|
+
self,
|
|
116
|
+
submission_id: str,
|
|
117
|
+
metadata_dir: Path,
|
|
118
|
+
metadata_file_name: str = "metadata.json",
|
|
119
|
+
):
|
|
120
|
+
"""
|
|
121
|
+
Download the metadata.json
|
|
122
|
+
|
|
123
|
+
:param submission_id: submission folder on S3 structure
|
|
124
|
+
:param metadata_dir: Path of the metadir folder
|
|
125
|
+
:param metadata_file_name: name of the metadata.json
|
|
126
|
+
"""
|
|
127
|
+
metadata_key = str(Path(submission_id) / metadata_dir.name / metadata_file_name)
|
|
128
|
+
metadata_file_path = metadata_dir / metadata_file_name
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
self.download_file(str(metadata_file_path), metadata_key, 10000)
|
|
132
|
+
except Exception as e:
|
|
133
|
+
self.__log.error("Download failed for '%s'", str(metadata_key))
|
|
134
|
+
|
|
135
|
+
raise e
|
|
136
|
+
|
|
137
|
+
def download_file(self, local_file_path, s3_object_id, filesize):
|
|
138
|
+
"""
|
|
139
|
+
Upload a single file to the specified object ID
|
|
140
|
+
:param local_file_path: Path to the file to upload
|
|
141
|
+
:param s3_object_id: Remote S3 object ID under which the file should be stored
|
|
142
|
+
:param filesize: size of the file
|
|
143
|
+
"""
|
|
144
|
+
# self.__log.info(f"Download {s3_object_id} to {local_file_path}...")
|
|
145
|
+
|
|
146
|
+
chunksize = (
|
|
147
|
+
math.ceil(filesize / MULTIPART_MAX_CHUNKS)
|
|
148
|
+
if filesize / MULTIPART_CHUNKSIZE > MULTIPART_MAX_CHUNKS
|
|
149
|
+
else MULTIPART_CHUNKSIZE
|
|
150
|
+
)
|
|
151
|
+
self.__log.debug(
|
|
152
|
+
f"Using a chunksize of: {chunksize / 1024**2}MiB, results in {filesize/chunksize} chunks"
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
config = TransferConfig(
|
|
156
|
+
multipart_threshold=MULTIPART_THRESHOLD,
|
|
157
|
+
multipart_chunksize=chunksize,
|
|
158
|
+
max_concurrency=self._threads,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
transfer = S3Transfer(self._s3_client, config)
|
|
162
|
+
progress_bar = tqdm(
|
|
163
|
+
total=filesize, unit="B", unit_scale=True, unit_divisor=1024
|
|
164
|
+
)
|
|
165
|
+
transfer.download_file(
|
|
166
|
+
self._config.s3_options.bucket,
|
|
167
|
+
s3_object_id,
|
|
168
|
+
local_file_path,
|
|
169
|
+
callback=lambda bytes_transferred: progress_bar.update(bytes_transferred),
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
def download(self, submission_id: str, encrypted_submission: EncryptedSubmission):
|
|
173
|
+
"""
|
|
174
|
+
Upload an encrypted submission
|
|
175
|
+
:param encrypted_submission: The encrypted submission to upload
|
|
176
|
+
"""
|
|
177
|
+
progress_logger = FileProgressLogger(self._status_file_path)
|
|
178
|
+
|
|
179
|
+
encrypted_files_key_prefix = f"{submission_id}/files/"
|
|
180
|
+
|
|
181
|
+
response = self._s3_client.list_objects_v2(
|
|
182
|
+
Bucket=self._config.s3_options.bucket, Prefix=encrypted_files_key_prefix
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
if "Contents" in response:
|
|
186
|
+
for file in response["Contents"]:
|
|
187
|
+
if file["Size"] == 0:
|
|
188
|
+
continue
|
|
189
|
+
else:
|
|
190
|
+
file_key = file["Key"]
|
|
191
|
+
file_path = Path(file_key).relative_to(encrypted_files_key_prefix)
|
|
192
|
+
full_path = encrypted_submission.encrypted_files_dir / file_path
|
|
193
|
+
if full_path not in encrypted_submission.encrypted_files:
|
|
194
|
+
raise DownloadError(
|
|
195
|
+
f"File {file_path} not listed in metadata.json"
|
|
196
|
+
)
|
|
197
|
+
file_metadata = encrypted_submission.encrypted_files[full_path]
|
|
198
|
+
logged_state = progress_logger.get_state(full_path, file_metadata)
|
|
199
|
+
|
|
200
|
+
if (logged_state is None) or not logged_state.get(
|
|
201
|
+
"download_successful", False
|
|
202
|
+
):
|
|
203
|
+
self.__log.info(
|
|
204
|
+
"Download file: '%s' -> '%s'",
|
|
205
|
+
file_key,
|
|
206
|
+
str(full_path),
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
try:
|
|
210
|
+
full_path.parent.mkdir(
|
|
211
|
+
mode=0o770, parents=True, exist_ok=True
|
|
212
|
+
)
|
|
213
|
+
self.download_file(str(full_path), file_key, file["Size"])
|
|
214
|
+
|
|
215
|
+
self.__log.info(f"Download complete for {str(full_path)}. ")
|
|
216
|
+
progress_logger.set_state(
|
|
217
|
+
full_path,
|
|
218
|
+
file_metadata,
|
|
219
|
+
state={"download_successful": True},
|
|
220
|
+
)
|
|
221
|
+
except Exception as e:
|
|
222
|
+
self.__log.error("Download failed for '%s'", str(full_path))
|
|
223
|
+
|
|
224
|
+
progress_logger.set_state(
|
|
225
|
+
full_path,
|
|
226
|
+
file_metadata,
|
|
227
|
+
state={"download_successful": False, "error": str(e)},
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
raise e
|
|
231
|
+
else:
|
|
232
|
+
self.__log.info(
|
|
233
|
+
"File '%s' already downloaded (at '%s')",
|
|
234
|
+
file_key,
|
|
235
|
+
str(full_path),
|
|
236
|
+
)
|