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
grz_cli/parser.py
ADDED
|
@@ -0,0 +1,771 @@
|
|
|
1
|
+
"""Classes for parsing and validating submission metadata and files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
from collections.abc import Generator
|
|
8
|
+
from itertools import groupby
|
|
9
|
+
from os import PathLike
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from .download import S3BotoDownloadWorker
|
|
13
|
+
from .fastq_validation import validate_paired_end_reads, validate_single_end_reads
|
|
14
|
+
from .file_operations import Crypt4GH, calculate_sha256
|
|
15
|
+
from .models.config import ConfigModel
|
|
16
|
+
from .models.v1_0_0.metadata import File as SubmissionFileMetadata
|
|
17
|
+
from .models.v1_0_0.metadata import (
|
|
18
|
+
FileType,
|
|
19
|
+
GrzSubmissionMetadata,
|
|
20
|
+
ReadOrder,
|
|
21
|
+
SequenceData,
|
|
22
|
+
SequenceType,
|
|
23
|
+
SequencingLayout,
|
|
24
|
+
)
|
|
25
|
+
from .upload import S3BotoUploadWorker
|
|
26
|
+
|
|
27
|
+
log = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class SubmissionMetadata:
|
|
31
|
+
"""Class for reading and validating submission metadata"""
|
|
32
|
+
|
|
33
|
+
__log = log.getChild("SubmissionMetadata")
|
|
34
|
+
|
|
35
|
+
def __init__(self, metadata_file):
|
|
36
|
+
"""
|
|
37
|
+
Load, parse and validate the metadata file.
|
|
38
|
+
|
|
39
|
+
:param metadata_file: path to the metadata.json file
|
|
40
|
+
:raises json.JSONDecodeError: if failed to read the metadata.json file
|
|
41
|
+
:raises jsonschema.exceptions.ValidationError: if metadata does not match expected schema
|
|
42
|
+
"""
|
|
43
|
+
self.file_path = metadata_file
|
|
44
|
+
self.content = self._read_metadata(self.file_path)
|
|
45
|
+
self._checksum = calculate_sha256(self.file_path, progress=False)
|
|
46
|
+
|
|
47
|
+
self._files = None
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def _read_metadata(cls, file_path: Path) -> GrzSubmissionMetadata:
|
|
51
|
+
"""
|
|
52
|
+
Load and parse the metadata file in JSON format.
|
|
53
|
+
|
|
54
|
+
:param file_path: Path to the metadata JSON file
|
|
55
|
+
:return: Parsed metadata as a dictionary
|
|
56
|
+
:raises json.JSONDecodeError: if failed to read the metadata.json file
|
|
57
|
+
"""
|
|
58
|
+
try:
|
|
59
|
+
with open(file_path, encoding="utf-8") as jsonfile:
|
|
60
|
+
metadata = json.load(jsonfile)
|
|
61
|
+
metadata_model = GrzSubmissionMetadata(**metadata)
|
|
62
|
+
return metadata_model
|
|
63
|
+
except json.JSONDecodeError as e:
|
|
64
|
+
cls.__log.error("Invalid JSON format in metadata file: %s", file_path)
|
|
65
|
+
raise e
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def index_case_id(self) -> str:
|
|
69
|
+
"""
|
|
70
|
+
The index case ID of this submission
|
|
71
|
+
"""
|
|
72
|
+
return self.content.submission.tan_g
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def files(self) -> dict[Path, SubmissionFileMetadata]:
|
|
76
|
+
"""
|
|
77
|
+
The files linked in the metadata.
|
|
78
|
+
|
|
79
|
+
:return: Dictionary of `file_path` -> `SubmissionFileMetadata` pairs.
|
|
80
|
+
Each `file_path` refers to the relative file path from the metadata.
|
|
81
|
+
"""
|
|
82
|
+
if self._files is not None:
|
|
83
|
+
return self._files
|
|
84
|
+
|
|
85
|
+
submission_files = {}
|
|
86
|
+
for donor in self.content.donors:
|
|
87
|
+
for lab_data in donor.lab_data:
|
|
88
|
+
for file_data in lab_data.sequence_data.files:
|
|
89
|
+
file_path = Path(file_data.file_path)
|
|
90
|
+
if file_path.is_symlink():
|
|
91
|
+
raise ValueError(
|
|
92
|
+
f"Provided path is a symlink which is not accepted: {file_path}"
|
|
93
|
+
)
|
|
94
|
+
else:
|
|
95
|
+
submission_files[file_path] = file_data
|
|
96
|
+
|
|
97
|
+
self._files = submission_files
|
|
98
|
+
return self._files
|
|
99
|
+
|
|
100
|
+
def validate(self) -> Generator[str]:
|
|
101
|
+
"""
|
|
102
|
+
Validates this submission's metadata (content).
|
|
103
|
+
|
|
104
|
+
:return: Generator of errors
|
|
105
|
+
"""
|
|
106
|
+
submission_files: dict[str | PathLike, SubmissionFileMetadata] = {}
|
|
107
|
+
for donor in self.content.donors:
|
|
108
|
+
for lab_data in donor.lab_data:
|
|
109
|
+
for file_data in lab_data.sequence_data.files:
|
|
110
|
+
# check if file is already registered
|
|
111
|
+
file_path = Path(file_data.file_path)
|
|
112
|
+
if other_metadata := submission_files.get(file_path):
|
|
113
|
+
# check if metadata matches
|
|
114
|
+
if file_data != other_metadata:
|
|
115
|
+
yield f"{file_data.file_path}: Different metadata for the same path observed!"
|
|
116
|
+
|
|
117
|
+
# check if FASTQ data was already linked in another submission
|
|
118
|
+
if file_data.file_type == "fastq":
|
|
119
|
+
yield f"{file_data.file_path}: FASTQ file already linked in another submission!"
|
|
120
|
+
if file_data.file_type == "bam":
|
|
121
|
+
yield f"{file_data.file_path}: BAM file already linked in another submission!"
|
|
122
|
+
else:
|
|
123
|
+
submission_files[file_path] = file_data
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def checksum(self) -> str:
|
|
127
|
+
"""
|
|
128
|
+
Checksum of the metadata file
|
|
129
|
+
"""
|
|
130
|
+
return self._checksum
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class Submission:
|
|
134
|
+
"""Class for handling submission data"""
|
|
135
|
+
|
|
136
|
+
__log = log.getChild("Submission")
|
|
137
|
+
|
|
138
|
+
def __init__(self, metadata_dir: str | PathLike, files_dir: str | PathLike):
|
|
139
|
+
"""
|
|
140
|
+
Initialize the submission object.
|
|
141
|
+
|
|
142
|
+
:param metadata_dir: Path to the metadata directory
|
|
143
|
+
:param files_dir: Path to the files directory
|
|
144
|
+
"""
|
|
145
|
+
self.metadata_dir = Path(metadata_dir)
|
|
146
|
+
self.files_dir = Path(files_dir)
|
|
147
|
+
|
|
148
|
+
self.metadata = SubmissionMetadata(self.metadata_dir / "metadata.json")
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def files(self) -> dict[Path, SubmissionFileMetadata]:
|
|
152
|
+
"""
|
|
153
|
+
The files liked in the metadata.
|
|
154
|
+
|
|
155
|
+
:return: Dictionary of `local_file_path` -> `SubmissionFileMetadata` pairs.
|
|
156
|
+
"""
|
|
157
|
+
retval = {}
|
|
158
|
+
for file_path, file_metadata in self.metadata.files.items():
|
|
159
|
+
local_file_path = self.files_dir / file_path
|
|
160
|
+
|
|
161
|
+
retval[local_file_path] = file_metadata
|
|
162
|
+
|
|
163
|
+
return retval
|
|
164
|
+
|
|
165
|
+
def validate_checksums(self, progress_log_file: str | PathLike) -> Generator[str]:
|
|
166
|
+
"""
|
|
167
|
+
Validates the checksum of the files against the metadata and prints the errors.
|
|
168
|
+
|
|
169
|
+
:return: Generator of errors
|
|
170
|
+
"""
|
|
171
|
+
from .progress_logging import FileProgressLogger
|
|
172
|
+
|
|
173
|
+
progress_logger = FileProgressLogger(log_file_path=progress_log_file)
|
|
174
|
+
# cleanup log file and keep only files listed here
|
|
175
|
+
progress_logger.cleanup(
|
|
176
|
+
keep=[
|
|
177
|
+
(file_path, file_metadata)
|
|
178
|
+
for file_path, file_metadata in self.files.items()
|
|
179
|
+
]
|
|
180
|
+
)
|
|
181
|
+
# fields:
|
|
182
|
+
# - "errors": List[str]
|
|
183
|
+
# - "validation_passed": bool
|
|
184
|
+
|
|
185
|
+
for local_file_path, file_metadata in self.files.items():
|
|
186
|
+
logged_state = progress_logger.get_state(local_file_path, file_metadata)
|
|
187
|
+
|
|
188
|
+
# determine if we can skip the verification
|
|
189
|
+
if logged_state is None:
|
|
190
|
+
self.__log.debug("State for %s not calculated yet", local_file_path)
|
|
191
|
+
elif not logged_state.get("validation_passed", False):
|
|
192
|
+
errors = logged_state.get("errors", [])
|
|
193
|
+
yield from errors
|
|
194
|
+
|
|
195
|
+
# skip re-verification
|
|
196
|
+
continue
|
|
197
|
+
else:
|
|
198
|
+
self.__log.debug(
|
|
199
|
+
"Validation for %s already passed, skipping...",
|
|
200
|
+
str(local_file_path),
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
# skip re-verification
|
|
204
|
+
continue
|
|
205
|
+
|
|
206
|
+
self.__log.debug("Validating '%s'...", str(local_file_path))
|
|
207
|
+
# validate the file
|
|
208
|
+
errors = list(file_metadata.validate_data(local_file_path))
|
|
209
|
+
validation_passed = len(errors) == 0
|
|
210
|
+
|
|
211
|
+
# log state
|
|
212
|
+
progress_logger.set_state(
|
|
213
|
+
local_file_path,
|
|
214
|
+
file_metadata,
|
|
215
|
+
state={
|
|
216
|
+
"errors": errors,
|
|
217
|
+
"validation_passed": validation_passed,
|
|
218
|
+
},
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
yield from errors
|
|
222
|
+
|
|
223
|
+
def validate_sequencing_data(
|
|
224
|
+
self, progress_log_file: str | PathLike
|
|
225
|
+
) -> Generator[str]:
|
|
226
|
+
"""
|
|
227
|
+
Quick-validates sequencing data linked in this submission.
|
|
228
|
+
|
|
229
|
+
:return: Generator of errors
|
|
230
|
+
"""
|
|
231
|
+
|
|
232
|
+
def find_fastq_files(sequence_data: SequenceData):
|
|
233
|
+
return [f for f in sequence_data.files if f.file_type == FileType.fastq]
|
|
234
|
+
|
|
235
|
+
for donor in self.metadata.content.donors:
|
|
236
|
+
for lab_data in donor.lab_data:
|
|
237
|
+
sequence_type = lab_data.sequence_type
|
|
238
|
+
sequencing_layout = lab_data.sequencing_layout
|
|
239
|
+
sequence_data = lab_data.sequence_data
|
|
240
|
+
if sequence_type == SequenceType.dna:
|
|
241
|
+
# find all FASTQ files
|
|
242
|
+
fastq_files = find_fastq_files(sequence_data)
|
|
243
|
+
|
|
244
|
+
if sequencing_layout == SequencingLayout.single_end:
|
|
245
|
+
yield from validate_single_end_reads(
|
|
246
|
+
self.files_dir / fastq_files[0].file_path
|
|
247
|
+
)
|
|
248
|
+
else:
|
|
249
|
+
# group reads by flowcell id and lane id
|
|
250
|
+
key = lambda f: (f.flowcell_id, f.lane_id)
|
|
251
|
+
fastq_files.sort(key=key)
|
|
252
|
+
for _key, group in groupby(fastq_files, key):
|
|
253
|
+
files = list(group)
|
|
254
|
+
|
|
255
|
+
# separate R1 and R2 files
|
|
256
|
+
fastq_r1_files = [
|
|
257
|
+
f for f in files if f.read_order == ReadOrder.r1
|
|
258
|
+
]
|
|
259
|
+
fastq_r2_files = [
|
|
260
|
+
f for f in files if f.read_order == ReadOrder.r2
|
|
261
|
+
]
|
|
262
|
+
|
|
263
|
+
for fastq_r1, fastq_r2 in zip(
|
|
264
|
+
fastq_r1_files, fastq_r2_files, strict=True
|
|
265
|
+
):
|
|
266
|
+
yield from validate_paired_end_reads(
|
|
267
|
+
# fastq R1
|
|
268
|
+
self.files_dir / fastq_r1.file_path,
|
|
269
|
+
# fastq R2
|
|
270
|
+
self.files_dir / fastq_r2.file_path,
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
elif sequence_type == SequenceType.rna:
|
|
274
|
+
# TODO: What to check here?
|
|
275
|
+
pass
|
|
276
|
+
|
|
277
|
+
def encrypt(
|
|
278
|
+
self,
|
|
279
|
+
encrypted_files_dir: str | PathLike,
|
|
280
|
+
progress_log_file: str | PathLike,
|
|
281
|
+
recipient_public_key_path: str | PathLike,
|
|
282
|
+
submitter_private_key_path: str | PathLike | None = None,
|
|
283
|
+
) -> EncryptedSubmission:
|
|
284
|
+
"""
|
|
285
|
+
Encrypt this submission with a public key using Crypt4Gh
|
|
286
|
+
|
|
287
|
+
:param encrypted_files_dir: Output directory of the encrypted files
|
|
288
|
+
:param progress_log_file: Path to a log file to store the progress of the encryption process
|
|
289
|
+
:param recipient_public_key_path: Path to the public key file which will be used for encryption
|
|
290
|
+
:param submitter_private_key_path: Path to the private key file which will be used to sign the encryption
|
|
291
|
+
:return: EncryptedSubmission instance
|
|
292
|
+
"""
|
|
293
|
+
encrypted_files_dir = Path(encrypted_files_dir)
|
|
294
|
+
|
|
295
|
+
if not Path(recipient_public_key_path).expanduser().is_file():
|
|
296
|
+
msg = f"Public key file does not exist: {recipient_public_key_path}"
|
|
297
|
+
self.__log.error(msg)
|
|
298
|
+
raise FileNotFoundError(msg)
|
|
299
|
+
if not submitter_private_key_path:
|
|
300
|
+
self.__log.warning("No submitter private key provided, skipping signing.")
|
|
301
|
+
elif not Path(submitter_private_key_path).expanduser().is_file():
|
|
302
|
+
msg = f"Private key file does not exist: {submitter_private_key_path}"
|
|
303
|
+
self.__log.error(msg)
|
|
304
|
+
raise FileNotFoundError(msg)
|
|
305
|
+
|
|
306
|
+
if not encrypted_files_dir.is_dir():
|
|
307
|
+
self.__log.debug(
|
|
308
|
+
"Creating encrypted submission files directory: %s...",
|
|
309
|
+
encrypted_files_dir,
|
|
310
|
+
)
|
|
311
|
+
encrypted_files_dir.mkdir(mode=0o770, parents=False, exist_ok=False)
|
|
312
|
+
|
|
313
|
+
from .progress_logging import FileProgressLogger
|
|
314
|
+
|
|
315
|
+
progress_logger = FileProgressLogger(log_file_path=progress_log_file)
|
|
316
|
+
|
|
317
|
+
try:
|
|
318
|
+
public_keys = Crypt4GH.prepare_c4gh_keys(recipient_public_key_path)
|
|
319
|
+
except Exception as e:
|
|
320
|
+
self.__log.error(f"Error preparing public keys: {e}")
|
|
321
|
+
raise e
|
|
322
|
+
|
|
323
|
+
for file_path, file_metadata in self.files.items():
|
|
324
|
+
# encryption_successful = True
|
|
325
|
+
logged_state = progress_logger.get_state(file_path, file_metadata)
|
|
326
|
+
self.__log.debug("state for %s: %s", file_path, logged_state)
|
|
327
|
+
|
|
328
|
+
encrypted_file_path = (
|
|
329
|
+
encrypted_files_dir
|
|
330
|
+
/ EncryptedSubmission.get_encrypted_file_path(file_metadata.file_path)
|
|
331
|
+
)
|
|
332
|
+
encrypted_file_path.parent.mkdir(mode=0o770, parents=True, exist_ok=True)
|
|
333
|
+
|
|
334
|
+
if (
|
|
335
|
+
(logged_state is None)
|
|
336
|
+
or not logged_state.get("encryption_successful", False)
|
|
337
|
+
or not encrypted_file_path.is_file()
|
|
338
|
+
):
|
|
339
|
+
self.__log.info(
|
|
340
|
+
"Encrypting file: '%s' -> '%s'",
|
|
341
|
+
str(file_path),
|
|
342
|
+
str(encrypted_file_path),
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
try:
|
|
346
|
+
Crypt4GH.encrypt_file(file_path, encrypted_file_path, public_keys)
|
|
347
|
+
|
|
348
|
+
self.__log.info(f"Encryption complete for {str(file_path)}. ")
|
|
349
|
+
progress_logger.set_state(
|
|
350
|
+
file_path, file_metadata, state={"encryption_successful": True}
|
|
351
|
+
)
|
|
352
|
+
except Exception as e:
|
|
353
|
+
self.__log.error("Encryption failed for '%s'", str(file_path))
|
|
354
|
+
|
|
355
|
+
progress_logger.set_state(
|
|
356
|
+
file_path,
|
|
357
|
+
file_metadata,
|
|
358
|
+
state={"encryption_successful": False, "error": str(e)},
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
raise e
|
|
362
|
+
else:
|
|
363
|
+
self.__log.info(
|
|
364
|
+
"File '%s' already encrypted in '%s'",
|
|
365
|
+
str(file_path),
|
|
366
|
+
str(encrypted_file_path),
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
self.__log.info("File encryption completed.")
|
|
370
|
+
|
|
371
|
+
return EncryptedSubmission(
|
|
372
|
+
metadata_dir=self.metadata_dir,
|
|
373
|
+
encrypted_files_dir=encrypted_files_dir,
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
class EncryptedSubmission:
|
|
378
|
+
"""The encrypted counterpart to `Submission`. Handles encrypted submission data."""
|
|
379
|
+
|
|
380
|
+
__log = log.getChild("EncryptedSubmission")
|
|
381
|
+
|
|
382
|
+
def __init__(
|
|
383
|
+
self, metadata_dir: str | PathLike, encrypted_files_dir: str | PathLike
|
|
384
|
+
):
|
|
385
|
+
"""
|
|
386
|
+
Initialize the encrypted submission object.
|
|
387
|
+
|
|
388
|
+
:param metadata_dir: Path to the metadata directory
|
|
389
|
+
:param encrypted_files_dir: Path to the encrypted files directory
|
|
390
|
+
"""
|
|
391
|
+
self.metadata_dir = Path(metadata_dir)
|
|
392
|
+
self.encrypted_files_dir = Path(encrypted_files_dir)
|
|
393
|
+
|
|
394
|
+
self.metadata = SubmissionMetadata(self.metadata_dir / "metadata.json")
|
|
395
|
+
|
|
396
|
+
@property
|
|
397
|
+
def encrypted_files(self):
|
|
398
|
+
"""
|
|
399
|
+
The encrypted files liked in the metadata.
|
|
400
|
+
|
|
401
|
+
:return: Dictionary of `local_file_path` -> `SubmissionFileMetadata` pairs.
|
|
402
|
+
"""
|
|
403
|
+
retval = {}
|
|
404
|
+
for file_path, file_metadata in self.metadata.files.items():
|
|
405
|
+
encrypted_file_path = self.get_encrypted_file_path(
|
|
406
|
+
self.encrypted_files_dir / file_path
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
retval[encrypted_file_path] = file_metadata
|
|
410
|
+
|
|
411
|
+
return retval
|
|
412
|
+
|
|
413
|
+
def get_metadata_file_path_and_object_id(self) -> tuple[Path, str]:
|
|
414
|
+
"""
|
|
415
|
+
:return: tuple with the `local_file_path` and s3_object_id of the metadata file
|
|
416
|
+
"""
|
|
417
|
+
return Path(self.metadata.file_path), str(
|
|
418
|
+
Path(self.metadata.index_case_id)
|
|
419
|
+
/ "metadata"
|
|
420
|
+
/ self.metadata.file_path.name
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
def get_encrypted_files_and_object_id(self) -> dict[Path, str]:
|
|
424
|
+
"""
|
|
425
|
+
:return Dictionary of `local_file_path` -> s3_object_id
|
|
426
|
+
"""
|
|
427
|
+
retval = {}
|
|
428
|
+
for local_file_path, file_metadata in self.encrypted_files.items():
|
|
429
|
+
retval[local_file_path] = str(
|
|
430
|
+
Path(self.metadata.index_case_id)
|
|
431
|
+
/ "files"
|
|
432
|
+
/ self.get_encrypted_file_path(file_metadata.file_path)
|
|
433
|
+
)
|
|
434
|
+
return retval
|
|
435
|
+
|
|
436
|
+
@staticmethod
|
|
437
|
+
def get_encrypted_file_path(file_path: str | PathLike) -> Path:
|
|
438
|
+
"""
|
|
439
|
+
Return the path to the encrypted file based on the original file path,
|
|
440
|
+
with additional extension'.c4gh'.
|
|
441
|
+
"""
|
|
442
|
+
p = Path(file_path)
|
|
443
|
+
return p.with_suffix(p.suffix + ".c4gh")
|
|
444
|
+
|
|
445
|
+
@staticmethod
|
|
446
|
+
def get_encryption_header_path(file_path: str | PathLike) -> Path:
|
|
447
|
+
"""
|
|
448
|
+
Return the path to the encryption header file based on the original file path,
|
|
449
|
+
with additional extension'.c4gh_header'.
|
|
450
|
+
"""
|
|
451
|
+
p = Path(file_path)
|
|
452
|
+
return p.with_suffix(p.suffix + ".c4gh_header")
|
|
453
|
+
|
|
454
|
+
def decrypt(
|
|
455
|
+
self,
|
|
456
|
+
files_dir: str | PathLike,
|
|
457
|
+
progress_log_file: str | PathLike,
|
|
458
|
+
recipient_private_key_path: str | PathLike,
|
|
459
|
+
) -> Submission:
|
|
460
|
+
"""
|
|
461
|
+
Decrypt this encrypted submission with a private key using Crypt4Gh
|
|
462
|
+
|
|
463
|
+
:param files_dir: Output directory of the decrypted files
|
|
464
|
+
:param progress_log_file: Path to a log file to store the progress of the decryption process
|
|
465
|
+
:param recipient_private_key_path: Path to the private key file which will be used for decryption
|
|
466
|
+
:return: Submission instance
|
|
467
|
+
"""
|
|
468
|
+
files_dir = Path(files_dir)
|
|
469
|
+
|
|
470
|
+
if not files_dir.is_dir():
|
|
471
|
+
self.__log.debug(
|
|
472
|
+
"Creating decrypted submission files directory: %s...",
|
|
473
|
+
files_dir,
|
|
474
|
+
)
|
|
475
|
+
files_dir.mkdir(mode=0o770, parents=False, exist_ok=False)
|
|
476
|
+
|
|
477
|
+
from .progress_logging import FileProgressLogger
|
|
478
|
+
|
|
479
|
+
progress_logger = FileProgressLogger(log_file_path=progress_log_file)
|
|
480
|
+
|
|
481
|
+
try:
|
|
482
|
+
private_key = Crypt4GH.retrieve_private_key(recipient_private_key_path)
|
|
483
|
+
except Exception as e:
|
|
484
|
+
self.__log.error(f"Error preparing private key: {e}")
|
|
485
|
+
raise e
|
|
486
|
+
|
|
487
|
+
for encrypted_file_path, file_metadata in self.encrypted_files.items():
|
|
488
|
+
logged_state = progress_logger.get_state(encrypted_file_path, file_metadata)
|
|
489
|
+
self.__log.debug("state for %s: %s", encrypted_file_path, logged_state)
|
|
490
|
+
|
|
491
|
+
decrypted_file_path = files_dir / file_metadata.file_path
|
|
492
|
+
if not decrypted_file_path.parent.is_dir():
|
|
493
|
+
decrypted_file_path.parent.mkdir(
|
|
494
|
+
mode=0o770, parents=True, exist_ok=False
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
if (
|
|
498
|
+
(logged_state is None)
|
|
499
|
+
or not logged_state.get("decryption_successful", False)
|
|
500
|
+
or not decrypted_file_path.is_file()
|
|
501
|
+
):
|
|
502
|
+
self.__log.info(
|
|
503
|
+
"Decrypting file: '%s' -> '%s'",
|
|
504
|
+
str(encrypted_file_path),
|
|
505
|
+
str(decrypted_file_path),
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
try:
|
|
509
|
+
Crypt4GH.decrypt_file(
|
|
510
|
+
encrypted_file_path, decrypted_file_path, private_key
|
|
511
|
+
)
|
|
512
|
+
|
|
513
|
+
self.__log.info(
|
|
514
|
+
f"Decryption complete for {str(encrypted_file_path)}. "
|
|
515
|
+
)
|
|
516
|
+
progress_logger.set_state(
|
|
517
|
+
encrypted_file_path,
|
|
518
|
+
file_metadata,
|
|
519
|
+
state={"decryption_successful": True},
|
|
520
|
+
)
|
|
521
|
+
except Exception as e:
|
|
522
|
+
self.__log.error(
|
|
523
|
+
"Decryption failed for '%s'", str(encrypted_file_path)
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
progress_logger.set_state(
|
|
527
|
+
encrypted_file_path,
|
|
528
|
+
file_metadata,
|
|
529
|
+
state={"decryption_successful": False, "error": str(e)},
|
|
530
|
+
)
|
|
531
|
+
|
|
532
|
+
raise e
|
|
533
|
+
else:
|
|
534
|
+
self.__log.info(
|
|
535
|
+
"File '%s' already decrypted in '%s'",
|
|
536
|
+
str(encrypted_file_path),
|
|
537
|
+
str(decrypted_file_path),
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
self.__log.info("File decryption completed.")
|
|
541
|
+
|
|
542
|
+
return Submission(
|
|
543
|
+
metadata_dir=self.metadata_dir,
|
|
544
|
+
files_dir=files_dir,
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
class SubmissionValidationError(Exception):
|
|
549
|
+
"""Exception raised when validation of a submission fails"""
|
|
550
|
+
|
|
551
|
+
pass
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
class Worker:
|
|
555
|
+
"""Worker class for handling submission processing"""
|
|
556
|
+
|
|
557
|
+
__log = log.getChild("Worker")
|
|
558
|
+
|
|
559
|
+
def __init__(
|
|
560
|
+
self,
|
|
561
|
+
metadata_dir: str | PathLike,
|
|
562
|
+
files_dir: str | PathLike,
|
|
563
|
+
log_dir: str | PathLike,
|
|
564
|
+
encrypted_files_dir: str | PathLike,
|
|
565
|
+
threads: int = 1,
|
|
566
|
+
):
|
|
567
|
+
"""
|
|
568
|
+
Initialize the worker object.
|
|
569
|
+
|
|
570
|
+
:param metadata_dir: Path to the metadata directory
|
|
571
|
+
:param files_dir: Path to the files directory
|
|
572
|
+
:param log_dir: Path to the log directory
|
|
573
|
+
:param encrypted_files_dir: Path to the encrypted files directory
|
|
574
|
+
:param threads: Number of threads to use
|
|
575
|
+
"""
|
|
576
|
+
self._threads = threads
|
|
577
|
+
self.__log.debug("Threads: %s", self._threads)
|
|
578
|
+
|
|
579
|
+
# metadata dir
|
|
580
|
+
self.metadata_dir = Path(metadata_dir)
|
|
581
|
+
self.__log.debug("Metadata directory: %s", self.metadata_dir)
|
|
582
|
+
|
|
583
|
+
# files dir
|
|
584
|
+
self.files_dir = Path(files_dir)
|
|
585
|
+
self.__log.debug("Files directory: %s", self.files_dir)
|
|
586
|
+
|
|
587
|
+
# encrypted files dir
|
|
588
|
+
self.encrypted_files_dir = (
|
|
589
|
+
Path(encrypted_files_dir) if encrypted_files_dir is not None else Path()
|
|
590
|
+
)
|
|
591
|
+
|
|
592
|
+
self.__log.info("Encrypted files directory: %s", self.encrypted_files_dir)
|
|
593
|
+
|
|
594
|
+
# log dir
|
|
595
|
+
self.log_dir = Path(log_dir)
|
|
596
|
+
self.__log.info("Log directory: %s", self.log_dir)
|
|
597
|
+
|
|
598
|
+
# create log dir if non-existent
|
|
599
|
+
if not self.log_dir.is_dir():
|
|
600
|
+
self.__log.debug("Creating log directory...")
|
|
601
|
+
self.log_dir.mkdir(mode=0o770, parents=False, exist_ok=False)
|
|
602
|
+
|
|
603
|
+
self.progress_file_checksum_validation = (
|
|
604
|
+
self.log_dir / "progress_checksum_validation.cjson"
|
|
605
|
+
)
|
|
606
|
+
self.progress_file_sequencing_data_validation = (
|
|
607
|
+
self.log_dir / "progress_sequencing_data_validation.cjson"
|
|
608
|
+
)
|
|
609
|
+
self.progress_file_encrypt = self.log_dir / "progress_encrypt.cjson"
|
|
610
|
+
self.progress_file_decrypt = self.log_dir / "progress_decrypt.cjson"
|
|
611
|
+
self.progress_file_upload = self.log_dir / "progress_upload.cjson"
|
|
612
|
+
self.progress_file_download = self.log_dir / "progress_download.cjson"
|
|
613
|
+
|
|
614
|
+
def parse_submission(self) -> Submission:
|
|
615
|
+
"""
|
|
616
|
+
Reads the submission metadata and returns a Submission instance
|
|
617
|
+
"""
|
|
618
|
+
submission = Submission(
|
|
619
|
+
metadata_dir=self.metadata_dir,
|
|
620
|
+
files_dir=self.files_dir,
|
|
621
|
+
)
|
|
622
|
+
return submission
|
|
623
|
+
|
|
624
|
+
def parse_encrypted_submission(self) -> EncryptedSubmission:
|
|
625
|
+
"""
|
|
626
|
+
Reads the submission metadata and returns an EncryptedSubmission instance
|
|
627
|
+
"""
|
|
628
|
+
encrypted_submission = EncryptedSubmission(
|
|
629
|
+
metadata_dir=self.metadata_dir,
|
|
630
|
+
encrypted_files_dir=str(self.encrypted_files_dir),
|
|
631
|
+
)
|
|
632
|
+
return encrypted_submission
|
|
633
|
+
|
|
634
|
+
def validate(self, force=False):
|
|
635
|
+
"""
|
|
636
|
+
Validate this submission
|
|
637
|
+
|
|
638
|
+
:param force: Force validation of already validated files
|
|
639
|
+
:raises SubmissionValidationError: if the validation fails
|
|
640
|
+
"""
|
|
641
|
+
submission = self.parse_submission()
|
|
642
|
+
|
|
643
|
+
self.__log.info("Starting metadata validation...")
|
|
644
|
+
if errors := list(submission.metadata.validate()):
|
|
645
|
+
error_msg = "\n".join(["Metadata validation failed! Errors:", *errors])
|
|
646
|
+
self.__log.error(error_msg)
|
|
647
|
+
|
|
648
|
+
raise SubmissionValidationError(error_msg)
|
|
649
|
+
else:
|
|
650
|
+
self.__log.info("Metadata validation successful!")
|
|
651
|
+
|
|
652
|
+
if force:
|
|
653
|
+
# delete the log file
|
|
654
|
+
self.progress_file_checksum_validation.unlink()
|
|
655
|
+
|
|
656
|
+
self.__log.info("Starting checksum validation...")
|
|
657
|
+
if errors := list(
|
|
658
|
+
submission.validate_checksums(
|
|
659
|
+
progress_log_file=self.progress_file_checksum_validation
|
|
660
|
+
)
|
|
661
|
+
):
|
|
662
|
+
error_msg = "\n".join(["Checksum validation failed! Errors:", *errors])
|
|
663
|
+
self.__log.error(error_msg)
|
|
664
|
+
|
|
665
|
+
raise SubmissionValidationError(error_msg)
|
|
666
|
+
else:
|
|
667
|
+
self.__log.info("Checksum validation successful!")
|
|
668
|
+
self.__log.info("Starting checksum validation...")
|
|
669
|
+
|
|
670
|
+
if errors := list(
|
|
671
|
+
submission.validate_sequencing_data(
|
|
672
|
+
progress_log_file=self.progress_file_sequencing_data_validation
|
|
673
|
+
)
|
|
674
|
+
):
|
|
675
|
+
error_msg = "\n".join(["Checksum validation failed! Errors:", *errors])
|
|
676
|
+
self.__log.error(error_msg)
|
|
677
|
+
|
|
678
|
+
raise SubmissionValidationError(error_msg)
|
|
679
|
+
else:
|
|
680
|
+
self.__log.info("Checksum validation successful!")
|
|
681
|
+
|
|
682
|
+
# TODO: validate FASTQ
|
|
683
|
+
|
|
684
|
+
def encrypt(
|
|
685
|
+
self,
|
|
686
|
+
recipient_public_key_path: str | PathLike,
|
|
687
|
+
submitter_private_key_path: str | PathLike | None = None,
|
|
688
|
+
force=False,
|
|
689
|
+
) -> EncryptedSubmission:
|
|
690
|
+
"""
|
|
691
|
+
Encrypt this submission with a public key using Crypt4Gh.
|
|
692
|
+
:param recipient_public_key_path: Path to the public key file of the recipient.
|
|
693
|
+
:param submitter_private_key_path: Path to the private key file of the submitter.
|
|
694
|
+
:param force: Force encryption of already encrypted files
|
|
695
|
+
:return: EncryptedSubmission instance
|
|
696
|
+
"""
|
|
697
|
+
submission = self.parse_submission()
|
|
698
|
+
|
|
699
|
+
if force:
|
|
700
|
+
# delete the log file
|
|
701
|
+
self.progress_file_encrypt.unlink()
|
|
702
|
+
|
|
703
|
+
encrypted_submission = submission.encrypt(
|
|
704
|
+
encrypted_files_dir=str(self.encrypted_files_dir),
|
|
705
|
+
progress_log_file=self.progress_file_encrypt,
|
|
706
|
+
recipient_public_key_path=recipient_public_key_path,
|
|
707
|
+
submitter_private_key_path=submitter_private_key_path,
|
|
708
|
+
)
|
|
709
|
+
|
|
710
|
+
return encrypted_submission
|
|
711
|
+
|
|
712
|
+
def decrypt(
|
|
713
|
+
self, recipient_private_key_path: str | PathLike, force=False
|
|
714
|
+
) -> Submission:
|
|
715
|
+
"""
|
|
716
|
+
Encrypt this submission with a public key using Crypt4Gh.
|
|
717
|
+
:param recipient_public_key_path: Path to the public key file of the recipient.
|
|
718
|
+
:param submitter_private_key_path: Path to the private key file of the submitter.
|
|
719
|
+
:param force: Force decryption of already decrypted files
|
|
720
|
+
:return: EncryptedSubmission instance
|
|
721
|
+
"""
|
|
722
|
+
encrypted_submission = self.parse_encrypted_submission()
|
|
723
|
+
|
|
724
|
+
if force:
|
|
725
|
+
# delete the log file
|
|
726
|
+
self.progress_file_decrypt.unlink()
|
|
727
|
+
|
|
728
|
+
submission = encrypted_submission.decrypt(
|
|
729
|
+
files_dir=self.files_dir,
|
|
730
|
+
progress_log_file=self.progress_file_decrypt,
|
|
731
|
+
recipient_private_key_path=recipient_private_key_path,
|
|
732
|
+
)
|
|
733
|
+
|
|
734
|
+
return submission
|
|
735
|
+
|
|
736
|
+
def upload(self, config: ConfigModel):
|
|
737
|
+
"""
|
|
738
|
+
Upload an encrypted submission
|
|
739
|
+
|
|
740
|
+
"""
|
|
741
|
+
upload_worker = S3BotoUploadWorker(
|
|
742
|
+
config, status_file_path=self.progress_file_upload
|
|
743
|
+
)
|
|
744
|
+
|
|
745
|
+
encrypted_submission = self.parse_encrypted_submission()
|
|
746
|
+
|
|
747
|
+
upload_worker.upload(encrypted_submission)
|
|
748
|
+
|
|
749
|
+
def download(self, config: ConfigModel, submission_id: str):
|
|
750
|
+
"""
|
|
751
|
+
Download an encrypted submission
|
|
752
|
+
"""
|
|
753
|
+
download_worker = S3BotoDownloadWorker(
|
|
754
|
+
config, status_file_path=self.progress_file_upload
|
|
755
|
+
)
|
|
756
|
+
|
|
757
|
+
self.__log.info("Preparing output directories...")
|
|
758
|
+
download_worker.prepare_download(
|
|
759
|
+
self.metadata_dir, self.encrypted_files_dir, self.log_dir
|
|
760
|
+
)
|
|
761
|
+
|
|
762
|
+
self.__log.info("Downloading metadata...")
|
|
763
|
+
download_worker.download_metadata(
|
|
764
|
+
submission_id, self.metadata_dir, metadata_file_name="metadata.json"
|
|
765
|
+
)
|
|
766
|
+
|
|
767
|
+
self.__log.info("Downloading encrypted files...")
|
|
768
|
+
download_worker.download(
|
|
769
|
+
submission_id,
|
|
770
|
+
EncryptedSubmission(self.metadata_dir, self.encrypted_files_dir),
|
|
771
|
+
)
|