grz-cli 0.1.2__tar.gz → 0.1.3__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.1.2 → grz_cli-0.1.3}/MANIFEST.in +1 -0
- {grz_cli-0.1.2/src/grz_cli.egg-info → grz_cli-0.1.3}/PKG-INFO +1 -1
- {grz_cli-0.1.2 → grz_cli-0.1.3}/pyproject.toml +7 -3
- {grz_cli-0.1.2 → grz_cli-0.1.3}/src/grz_cli/cli.py +1 -3
- {grz_cli-0.1.2 → grz_cli-0.1.3}/src/grz_cli/download.py +7 -22
- {grz_cli-0.1.2 → grz_cli-0.1.3}/src/grz_cli/fastq_validation.py +3 -9
- {grz_cli-0.1.2 → grz_cli-0.1.3}/src/grz_cli/file_operations.py +4 -12
- {grz_cli-0.1.2 → grz_cli-0.1.3}/src/grz_cli/models/config.py +3 -11
- {grz_cli-0.1.2 → grz_cli-0.1.3}/src/grz_cli/models/v1_0_0/metadata.py +112 -61
- {grz_cli-0.1.2 → grz_cli-0.1.3}/src/grz_cli/parser.py +36 -115
- {grz_cli-0.1.2 → grz_cli-0.1.3}/src/grz_cli/resources/grz-schema.json +25 -11
- grz_cli-0.1.3/src/grz_cli/resources/thresholds.json +299 -0
- {grz_cli-0.1.2 → grz_cli-0.1.3}/src/grz_cli/upload.py +7 -22
- {grz_cli-0.1.2 → grz_cli-0.1.3/src/grz_cli.egg-info}/PKG-INFO +1 -1
- {grz_cli-0.1.2 → grz_cli-0.1.3}/src/grz_cli.egg-info/SOURCES.txt +1 -0
- {grz_cli-0.1.2 → grz_cli-0.1.3}/tests/test_cli.py +8 -26
- {grz_cli-0.1.2 → grz_cli-0.1.3}/tests/test_download.py +5 -16
- {grz_cli-0.1.2 → grz_cli-0.1.3}/tests/test_fastq_validation.py +1 -5
- {grz_cli-0.1.2 → grz_cli-0.1.3}/tests/test_file_operations.py +2 -6
- {grz_cli-0.1.2 → grz_cli-0.1.3}/tests/test_file_progress_logging.py +1 -3
- {grz_cli-0.1.2 → grz_cli-0.1.3}/tests/test_parser.py +2 -6
- {grz_cli-0.1.2 → grz_cli-0.1.3}/tests/test_upload.py +5 -17
- {grz_cli-0.1.2 → grz_cli-0.1.3}/LICENSE +0 -0
- {grz_cli-0.1.2 → grz_cli-0.1.3}/README.md +0 -0
- {grz_cli-0.1.2 → grz_cli-0.1.3}/setup.cfg +0 -0
- {grz_cli-0.1.2 → grz_cli-0.1.3}/src/__init__.py +0 -0
- {grz_cli-0.1.2 → grz_cli-0.1.3}/src/grz_cli/__init__.py +0 -0
- {grz_cli-0.1.2 → grz_cli-0.1.3}/src/grz_cli/constants.py +0 -0
- {grz_cli-0.1.2 → grz_cli-0.1.3}/src/grz_cli/logging_setup.py +0 -0
- {grz_cli-0.1.2 → grz_cli-0.1.3}/src/grz_cli/models/__init__.py +0 -0
- {grz_cli-0.1.2 → grz_cli-0.1.3}/src/grz_cli/models/v1_0_0/__init__.py +0 -0
- {grz_cli-0.1.2 → grz_cli-0.1.3}/src/grz_cli/progress_logging.py +0 -0
- {grz_cli-0.1.2 → grz_cli-0.1.3}/src/grz_cli/states.py +0 -0
- {grz_cli-0.1.2 → grz_cli-0.1.3}/src/grz_cli.egg-info/dependency_links.txt +0 -0
- {grz_cli-0.1.2 → grz_cli-0.1.3}/src/grz_cli.egg-info/entry_points.txt +0 -0
- {grz_cli-0.1.2 → grz_cli-0.1.3}/src/grz_cli.egg-info/requires.txt +0 -0
- {grz_cli-0.1.2 → grz_cli-0.1.3}/src/grz_cli.egg-info/top_level.txt +0 -0
- {grz_cli-0.1.2 → grz_cli-0.1.3}/tests/test_read_multiple_json.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: grz-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.3
|
|
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: MIT
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "grz-cli"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.3"
|
|
8
8
|
description = "Tool for validation, encryption and upload of MV submissions to GDCs."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT" }
|
|
@@ -76,7 +76,7 @@ exclude = [
|
|
|
76
76
|
"dist",
|
|
77
77
|
"out"
|
|
78
78
|
]
|
|
79
|
-
line-length =
|
|
79
|
+
line-length = 120
|
|
80
80
|
src = [
|
|
81
81
|
"src",
|
|
82
82
|
"tests"
|
|
@@ -149,7 +149,7 @@ convention = "pep257"
|
|
|
149
149
|
|
|
150
150
|
[tool.tox]
|
|
151
151
|
requires = ["tox>=4.23"]
|
|
152
|
-
env_list = ["format-check", "lints", "mypy", "3.12"]
|
|
152
|
+
env_list = ["format-check", "lints", "mypy", "3.12", "3.13"]
|
|
153
153
|
allowlist_externals = ["pytest", "ruff"]
|
|
154
154
|
isolated_build = true
|
|
155
155
|
|
|
@@ -188,3 +188,7 @@ python_version = "3.12"
|
|
|
188
188
|
plugins = "pydantic.mypy"
|
|
189
189
|
packages = "src"
|
|
190
190
|
|
|
191
|
+
[tool.pytest.ini_options]
|
|
192
|
+
addopts = [
|
|
193
|
+
"--import-mode=importlib",
|
|
194
|
+
]
|
|
@@ -48,9 +48,7 @@ DIR_RW_C = click.Path(
|
|
|
48
48
|
writable=True,
|
|
49
49
|
resolve_path=True,
|
|
50
50
|
)
|
|
51
|
-
FILE_R_E = click.Path(
|
|
52
|
-
exists=True, file_okay=True, dir_okay=False, readable=True, resolve_path=True
|
|
53
|
-
)
|
|
51
|
+
FILE_R_E = click.Path(exists=True, file_okay=True, dir_okay=False, readable=True, resolve_path=True)
|
|
54
52
|
|
|
55
53
|
submission_dir = click.option(
|
|
56
54
|
"--submission-dir",
|
|
@@ -74,10 +74,7 @@ class S3BotoDownloadWorker:
|
|
|
74
74
|
|
|
75
75
|
# configure proxies if proxy_url is defined
|
|
76
76
|
proxy_url = empty_str_to_none(self._config.s3_options.proxy_url)
|
|
77
|
-
if proxy_url is not None
|
|
78
|
-
config = Boto3Config(proxies={"http": proxy_url, "https": proxy_url})
|
|
79
|
-
else:
|
|
80
|
-
config = None
|
|
77
|
+
config = Boto3Config(proxies={"http": proxy_url, "https": proxy_url}) if proxy_url is not None else None
|
|
81
78
|
|
|
82
79
|
# Initialize S3 client for uploading
|
|
83
80
|
self._s3_client: boto3.session.Session.client = boto3_client(
|
|
@@ -149,9 +146,7 @@ class S3BotoDownloadWorker:
|
|
|
149
146
|
if filesize / MULTIPART_CHUNKSIZE > MULTIPART_MAX_CHUNKS
|
|
150
147
|
else MULTIPART_CHUNKSIZE
|
|
151
148
|
)
|
|
152
|
-
self.__log.debug(
|
|
153
|
-
f"Using a chunksize of: {chunksize / 1024**2}MiB, results in {filesize/chunksize} chunks"
|
|
154
|
-
)
|
|
149
|
+
self.__log.debug(f"Using a chunksize of: {chunksize / 1024**2}MiB, results in {filesize/chunksize} chunks")
|
|
155
150
|
|
|
156
151
|
config = TransferConfig(
|
|
157
152
|
multipart_threshold=MULTIPART_THRESHOLD,
|
|
@@ -160,9 +155,7 @@ class S3BotoDownloadWorker:
|
|
|
160
155
|
)
|
|
161
156
|
|
|
162
157
|
transfer = S3Transfer(self._s3_client, config)
|
|
163
|
-
progress_bar = tqdm(
|
|
164
|
-
total=filesize, unit="B", unit_scale=True, unit_divisor=1024
|
|
165
|
-
)
|
|
158
|
+
progress_bar = tqdm(total=filesize, unit="B", unit_scale=True, unit_divisor=1024)
|
|
166
159
|
transfer.download_file(
|
|
167
160
|
self._config.s3_options.bucket,
|
|
168
161
|
s3_object_id,
|
|
@@ -192,15 +185,11 @@ class S3BotoDownloadWorker:
|
|
|
192
185
|
file_path = Path(file_key).relative_to(encrypted_files_key_prefix)
|
|
193
186
|
full_path = encrypted_submission.encrypted_files_dir / file_path
|
|
194
187
|
if full_path not in encrypted_submission.encrypted_files:
|
|
195
|
-
raise DownloadError(
|
|
196
|
-
f"File {file_path} not listed in metadata.json"
|
|
197
|
-
)
|
|
188
|
+
raise DownloadError(f"File {file_path} not listed in metadata.json")
|
|
198
189
|
file_metadata = encrypted_submission.encrypted_files[full_path]
|
|
199
190
|
logged_state = progress_logger.get_state(full_path, file_metadata)
|
|
200
191
|
|
|
201
|
-
if (logged_state is None) or not logged_state.get(
|
|
202
|
-
"download_successful", False
|
|
203
|
-
):
|
|
192
|
+
if (logged_state is None) or not logged_state.get("download_successful", False):
|
|
204
193
|
self.__log.info(
|
|
205
194
|
"Download file: '%s' -> '%s'",
|
|
206
195
|
file_key,
|
|
@@ -208,9 +197,7 @@ class S3BotoDownloadWorker:
|
|
|
208
197
|
)
|
|
209
198
|
|
|
210
199
|
try:
|
|
211
|
-
full_path.parent.mkdir(
|
|
212
|
-
mode=0o770, parents=True, exist_ok=True
|
|
213
|
-
)
|
|
200
|
+
full_path.parent.mkdir(mode=0o770, parents=True, exist_ok=True)
|
|
214
201
|
self.download_file(str(full_path), file_key, file["Size"])
|
|
215
202
|
|
|
216
203
|
self.__log.info(f"Download complete for {str(full_path)}. ")
|
|
@@ -225,9 +212,7 @@ class S3BotoDownloadWorker:
|
|
|
225
212
|
progress_logger.set_state(
|
|
226
213
|
full_path,
|
|
227
214
|
file_metadata,
|
|
228
|
-
state=DownloadState(
|
|
229
|
-
download_successful=False, errors=[str(e)]
|
|
230
|
-
),
|
|
215
|
+
state=DownloadState(download_successful=False, errors=[str(e)]),
|
|
231
216
|
)
|
|
232
217
|
|
|
233
218
|
raise e
|
|
@@ -29,9 +29,7 @@ def is_gzipped(file_path: str | PathLike) -> bool:
|
|
|
29
29
|
|
|
30
30
|
|
|
31
31
|
@contextmanager
|
|
32
|
-
def open_fastq(
|
|
33
|
-
file_path: str | PathLike, progress=True
|
|
34
|
-
) -> Generator[TextIO, None, None]:
|
|
32
|
+
def open_fastq(file_path: str | PathLike, progress=True) -> Generator[TextIO, None, None]:
|
|
35
33
|
"""
|
|
36
34
|
Open a FASTQ file, handling both regular and gzipped formats.
|
|
37
35
|
|
|
@@ -103,9 +101,7 @@ def validate_fastq_file(fastq_file: str | PathLike) -> tuple[int, set[int], list
|
|
|
103
101
|
|
|
104
102
|
# Check if the number of lines in a FASTQ file is a multiple of 4.
|
|
105
103
|
if num_lines % 4 != 0:
|
|
106
|
-
errors.append(
|
|
107
|
-
f"{fastq_file}: Number of lines is not a multiple of 4! Found {num_lines} lines."
|
|
108
|
-
)
|
|
104
|
+
errors.append(f"{fastq_file}: Number of lines is not a multiple of 4! Found {num_lines} lines.")
|
|
109
105
|
else:
|
|
110
106
|
log.debug("%s: %s lines", fastq_file, num_lines)
|
|
111
107
|
|
|
@@ -132,9 +128,7 @@ def validate_single_end_reads(fastq_file: str | PathLike) -> Generator[str]:
|
|
|
132
128
|
yield from errors
|
|
133
129
|
|
|
134
130
|
|
|
135
|
-
def validate_paired_end_reads(
|
|
136
|
-
fastq_file1: str | PathLike, fastq_file2: str | PathLike
|
|
137
|
-
) -> Generator[str]:
|
|
131
|
+
def validate_paired_end_reads(fastq_file1: str | PathLike, fastq_file2: str | PathLike) -> Generator[str]:
|
|
138
132
|
"""
|
|
139
133
|
Validate two paired-end FASTQ files.
|
|
140
134
|
|
|
@@ -69,9 +69,7 @@ def calculate_md5(file_path, chunk_size=2**16, progress=True) -> str:
|
|
|
69
69
|
md5_hash = hashlib.md5() # noqa: S324
|
|
70
70
|
with open(file_path, "rb") as f:
|
|
71
71
|
if progress and (total_size > chunk_size):
|
|
72
|
-
with tqdm(
|
|
73
|
-
total=total_size, unit="B", unit_scale=True, desc="Calculating MD5"
|
|
74
|
-
) as pbar:
|
|
72
|
+
with tqdm(total=total_size, unit="B", unit_scale=True, desc="Calculating MD5") as pbar:
|
|
75
73
|
while chunk := f.read(chunk_size):
|
|
76
74
|
md5_hash.update(chunk)
|
|
77
75
|
pbar.update(len(chunk))
|
|
@@ -117,9 +115,7 @@ def read_multiple_json(input: TextIO, buffer_size=65536, max_buffer_size=1342177
|
|
|
117
115
|
raise ValueError("Remaining data is not empty. Is there invalid JSON?")
|
|
118
116
|
|
|
119
117
|
|
|
120
|
-
def is_relative_subdirectory(
|
|
121
|
-
relative_path: str | PathLike, root_directory: str | PathLike
|
|
122
|
-
) -> bool:
|
|
118
|
+
def is_relative_subdirectory(relative_path: str | PathLike, root_directory: str | PathLike) -> bool:
|
|
123
119
|
"""
|
|
124
120
|
Check if the target path is a subdirectory of the root path
|
|
125
121
|
using os.path.commonpath() without checking the file system.
|
|
@@ -223,9 +219,7 @@ class Crypt4GH:
|
|
|
223
219
|
if passphrase:
|
|
224
220
|
passphrase_callback = lambda: passphrase
|
|
225
221
|
else:
|
|
226
|
-
passphrase_callback = partial(
|
|
227
|
-
getpass, prompt=f"Passphrase for {seckey_path}: "
|
|
228
|
-
)
|
|
222
|
+
passphrase_callback = partial(getpass, prompt=f"Passphrase for {seckey_path}: ")
|
|
229
223
|
|
|
230
224
|
return crypt4gh.keys.get_private_key(seckeypath, passphrase_callback)
|
|
231
225
|
|
|
@@ -254,9 +248,7 @@ class Crypt4GH:
|
|
|
254
248
|
) as pbar_in_fd,
|
|
255
249
|
):
|
|
256
250
|
crypt4gh.lib.decrypt(
|
|
257
|
-
keys=[
|
|
258
|
-
(0, private_key, None)
|
|
259
|
-
], # list of (method, privkey, recipient_pubkey=None),
|
|
251
|
+
keys=[(0, private_key, None)], # list of (method, privkey, recipient_pubkey=None),
|
|
260
252
|
infile=pbar_in_fd,
|
|
261
253
|
outfile=out_fd,
|
|
262
254
|
)
|
|
@@ -97,22 +97,14 @@ class ConfigModel(StrictBaseModel):
|
|
|
97
97
|
@field_validator("grz_public_key")
|
|
98
98
|
@classmethod
|
|
99
99
|
def check_grz_public_key(cls, v):
|
|
100
|
-
if
|
|
101
|
-
v is not None
|
|
102
|
-
and "BEGIN CRYPT4GH PUBLIC KEY" not in v
|
|
103
|
-
and "END CRYPT4GH PUBLIC KEY" not in v
|
|
104
|
-
):
|
|
100
|
+
if v is not None and "BEGIN CRYPT4GH PUBLIC KEY" not in v and "END CRYPT4GH PUBLIC KEY" not in v:
|
|
105
101
|
raise ValueError("Invalid public key format")
|
|
106
102
|
return v
|
|
107
103
|
|
|
108
104
|
@model_validator(mode="after")
|
|
109
105
|
def validate_grz_public_key(self) -> Self:
|
|
110
106
|
if self.grz_public_key is None and self.grz_public_key_path is None:
|
|
111
|
-
raise ValueError(
|
|
112
|
-
"Either grz_public_key or grz_public_key_path must be set."
|
|
113
|
-
)
|
|
107
|
+
raise ValueError("Either grz_public_key or grz_public_key_path must be set.")
|
|
114
108
|
if self.grz_public_key is not None and self.grz_public_key_path is not None:
|
|
115
|
-
raise ValueError(
|
|
116
|
-
"Only one of grz_public_key or grz_public_key_path must be set."
|
|
117
|
-
)
|
|
109
|
+
raise ValueError("Only one of grz_public_key or grz_public_key_path must be set.")
|
|
118
110
|
return self
|
|
@@ -16,12 +16,14 @@
|
|
|
16
16
|
|
|
17
17
|
from __future__ import annotations
|
|
18
18
|
|
|
19
|
+
import json
|
|
19
20
|
import logging
|
|
20
21
|
from collections.abc import Generator
|
|
21
22
|
from datetime import date
|
|
22
23
|
from enum import StrEnum
|
|
24
|
+
from importlib.resources import files
|
|
23
25
|
from pathlib import Path
|
|
24
|
-
from typing import Annotated, Any
|
|
26
|
+
from typing import Annotated, Any, Self
|
|
25
27
|
|
|
26
28
|
from pydantic import (
|
|
27
29
|
BaseModel,
|
|
@@ -105,17 +107,15 @@ class Submission(StrictBaseModel):
|
|
|
105
107
|
|
|
106
108
|
genomic_data_center_id: Annotated[
|
|
107
109
|
str,
|
|
108
|
-
StringConstraints(pattern=r"^(GRZ
|
|
110
|
+
StringConstraints(pattern=r"^(GRZ)[A-Z0-9]{3}[0-9]{3}$"),
|
|
109
111
|
]
|
|
110
112
|
"""
|
|
111
113
|
ID of the genomic data center in the format GRZXXXnnn.
|
|
112
114
|
"""
|
|
113
115
|
|
|
114
|
-
clinical_data_node_id: Annotated[
|
|
115
|
-
str, StringConstraints(pattern=r"^(GRZ|KDK)[A-Z0-9]{3}[0-9]{3}$")
|
|
116
|
-
]
|
|
116
|
+
clinical_data_node_id: Annotated[str, StringConstraints(pattern=r"^(KDK)[A-Z0-9]{3}[0-9]{3}$")]
|
|
117
117
|
"""
|
|
118
|
-
ID of the
|
|
118
|
+
ID of the clinical data node in the format KDKXXXnnn.
|
|
119
119
|
"""
|
|
120
120
|
|
|
121
121
|
genomic_study_type: GenomicStudyType
|
|
@@ -262,10 +262,13 @@ class LibraryType(StrEnum):
|
|
|
262
262
|
"""
|
|
263
263
|
|
|
264
264
|
panel = "panel"
|
|
265
|
+
panel_lr = "panel_lr"
|
|
265
266
|
wes = "wes"
|
|
267
|
+
wes_lr = "wes_lr"
|
|
266
268
|
wgs = "wgs"
|
|
267
269
|
wgs_lr = "wgs_lr"
|
|
268
270
|
wxs = "wxs"
|
|
271
|
+
wxs_lr = "wxs_lr"
|
|
269
272
|
other = "other"
|
|
270
273
|
unknown = "unknown"
|
|
271
274
|
|
|
@@ -433,6 +436,16 @@ class File(StrictBaseModel):
|
|
|
433
436
|
return self.file_path + ".c4gh"
|
|
434
437
|
|
|
435
438
|
|
|
439
|
+
class PercentBasesAboveQualityThreshold(StrictBaseModel):
|
|
440
|
+
"""Percentage of bases with a specified minimum quality threshold"""
|
|
441
|
+
|
|
442
|
+
minimum_quality: Annotated[float, Field(strict=True, ge=0.0)]
|
|
443
|
+
"""The minimum quality score threshold"""
|
|
444
|
+
|
|
445
|
+
percent: Annotated[float, Field(strict=True, ge=0.0, le=100.0)]
|
|
446
|
+
"""Percentage of bases that meet or exceed the minimum quality score"""
|
|
447
|
+
|
|
448
|
+
|
|
436
449
|
class SequenceData(StrictBaseModel):
|
|
437
450
|
bioinformatics_pipeline_name: str
|
|
438
451
|
"""
|
|
@@ -449,9 +462,9 @@ class SequenceData(StrictBaseModel):
|
|
|
449
462
|
Reference genome used
|
|
450
463
|
"""
|
|
451
464
|
|
|
452
|
-
|
|
465
|
+
percent_bases_above_quality_threshold: PercentBasesAboveQualityThreshold
|
|
453
466
|
"""
|
|
454
|
-
Percentage of bases with
|
|
467
|
+
Percentage of bases with a specified minimum quality threshold
|
|
455
468
|
"""
|
|
456
469
|
|
|
457
470
|
mean_depth_of_coverage: Annotated[float, Field(strict=True, ge=0.0)]
|
|
@@ -464,9 +477,7 @@ class SequenceData(StrictBaseModel):
|
|
|
464
477
|
Minimum coverage
|
|
465
478
|
"""
|
|
466
479
|
|
|
467
|
-
targeted_regions_above_min_coverage: Annotated[
|
|
468
|
-
float, Field(strict=True, ge=0.0, le=1.0)
|
|
469
|
-
]
|
|
480
|
+
targeted_regions_above_min_coverage: Annotated[float, Field(strict=True, ge=0.0, le=1.0)]
|
|
470
481
|
"""
|
|
471
482
|
Fraction of targeted regions that are above minimum coverage
|
|
472
483
|
"""
|
|
@@ -590,9 +601,7 @@ class LabDatum(StrictBaseModel):
|
|
|
590
601
|
The sequencing layout, aka the end type of sequencing.
|
|
591
602
|
"""
|
|
592
603
|
|
|
593
|
-
tumor_cell_count: Annotated[
|
|
594
|
-
float | None, Field(alias="tumorCellCount", ge=0.0, le=100.0)
|
|
595
|
-
] = None
|
|
604
|
+
tumor_cell_count: Annotated[float | None, Field(alias="tumorCellCount", ge=0.0, le=100.0)] = None
|
|
596
605
|
"""
|
|
597
606
|
Tumor cell count in %
|
|
598
607
|
"""
|
|
@@ -607,6 +616,15 @@ class LabDatum(StrictBaseModel):
|
|
|
607
616
|
Sequence data generated from the wet lab experiment.
|
|
608
617
|
"""
|
|
609
618
|
|
|
619
|
+
@model_validator(mode="after")
|
|
620
|
+
def validate_sequencing_setup(self) -> Self:
|
|
621
|
+
if self.library_type in {LibraryType.wxs, LibraryType.wxs_lr} and self.sequence_type != SequenceType.rna:
|
|
622
|
+
raise ValueError(
|
|
623
|
+
f"Error in lab datum '{self.lab_data_name}': "
|
|
624
|
+
f"WXS requires RNA sequencing, but got '{self.sequence_type}'."
|
|
625
|
+
)
|
|
626
|
+
return self
|
|
627
|
+
|
|
610
628
|
|
|
611
629
|
class Donor(StrictBaseModel):
|
|
612
630
|
case_id: Annotated[str, StringConstraints(pattern=r"^[a-fA-F0-9]{32}$")]
|
|
@@ -698,12 +716,17 @@ class Donor(StrictBaseModel):
|
|
|
698
716
|
def contains_bed_files(sequence_data: SequenceData) -> bool:
|
|
699
717
|
return any(f.file_type == FileType.bed for f in sequence_data.files)
|
|
700
718
|
|
|
701
|
-
|
|
719
|
+
lib_types = {
|
|
720
|
+
LibraryType.panel,
|
|
721
|
+
LibraryType.wes,
|
|
722
|
+
LibraryType.wxs,
|
|
723
|
+
LibraryType.panel_lr,
|
|
724
|
+
LibraryType.wes_lr,
|
|
725
|
+
LibraryType.wxs_lr,
|
|
726
|
+
}
|
|
702
727
|
|
|
703
728
|
for lab_datum in self.lab_data:
|
|
704
|
-
if lab_datum.library_type in
|
|
705
|
-
lab_datum.sequence_data
|
|
706
|
-
):
|
|
729
|
+
if lab_datum.library_type in lib_types and not contains_bed_files(lab_datum.sequence_data):
|
|
707
730
|
raise ValueError(
|
|
708
731
|
f"BED file missing for lab datum '{lab_datum.lab_data_name}' in donor '{self.case_id}'."
|
|
709
732
|
)
|
|
@@ -751,12 +774,8 @@ class Donor(StrictBaseModel):
|
|
|
751
774
|
)
|
|
752
775
|
|
|
753
776
|
# check if there is an equal number of R1 and R2 files
|
|
754
|
-
r1_fastq_files = [
|
|
755
|
-
|
|
756
|
-
]
|
|
757
|
-
r2_fastq_files = [
|
|
758
|
-
i for i in fastq_files if i.read_order == ReadOrder.r2
|
|
759
|
-
]
|
|
777
|
+
r1_fastq_files = [i for i in fastq_files if i.read_order == ReadOrder.r1]
|
|
778
|
+
r2_fastq_files = [i for i in fastq_files if i.read_order == ReadOrder.r2]
|
|
760
779
|
|
|
761
780
|
if len(r1_fastq_files) != len(r2_fastq_files):
|
|
762
781
|
raise ValueError(
|
|
@@ -790,60 +809,92 @@ class GrzSubmissionMetadata(StrictBaseModel):
|
|
|
790
809
|
case GenomicStudyType.single:
|
|
791
810
|
# Check if the submission has at least one donor
|
|
792
811
|
if not self.donors:
|
|
793
|
-
raise ValueError(
|
|
794
|
-
"At least one donor is required for a single study."
|
|
795
|
-
)
|
|
812
|
+
raise ValueError("At least one donor is required for a single study.")
|
|
796
813
|
case GenomicStudyType.duo:
|
|
797
814
|
# Check if the submission has at least two donors
|
|
798
815
|
if len(self.donors) < 2:
|
|
799
|
-
raise ValueError(
|
|
800
|
-
"At least two donors are required for a duo study."
|
|
801
|
-
)
|
|
816
|
+
raise ValueError("At least two donors are required for a duo study.")
|
|
802
817
|
case GenomicStudyType.trio:
|
|
803
818
|
# Check if the submission has at least three donors
|
|
804
819
|
if len(self.donors) < 3:
|
|
805
|
-
raise ValueError(
|
|
806
|
-
"At least three donors are required for a trio study."
|
|
807
|
-
)
|
|
820
|
+
raise ValueError("At least three donors are required for a trio study.")
|
|
808
821
|
|
|
809
822
|
return self
|
|
810
823
|
|
|
811
824
|
@model_validator(mode="after")
|
|
812
|
-
def
|
|
825
|
+
def validate_thresholds(self):
|
|
813
826
|
"""
|
|
814
827
|
Check if the submission meets the minimum mean coverage requirements.
|
|
815
828
|
"""
|
|
816
|
-
|
|
817
|
-
(LibraryType.panel, SequenceSubtype.somatic): 500,
|
|
818
|
-
(LibraryType.wes, SequenceSubtype.somatic): 250,
|
|
819
|
-
(LibraryType.wes, SequenceSubtype.germline): 100,
|
|
820
|
-
(LibraryType.wgs, SequenceSubtype.somatic): 80,
|
|
821
|
-
(LibraryType.wgs, SequenceSubtype.germline): 40,
|
|
822
|
-
}
|
|
823
|
-
thresholds_rare_disease = {(LibraryType.wgs, SequenceSubtype.germline): 30}
|
|
824
|
-
|
|
825
|
-
study_subtype = self.submission.genomic_study_subtype
|
|
826
|
-
thresholds = (
|
|
827
|
-
thresholds_rare_disease
|
|
828
|
-
if study_subtype == GenomicStudySubtype.germline_only
|
|
829
|
-
else thresholds_somatic
|
|
830
|
-
)
|
|
829
|
+
threshold_definitions = _load_thresholds()
|
|
831
830
|
|
|
832
831
|
for donor in self.donors:
|
|
833
832
|
for lab_datum in donor.lab_data:
|
|
834
|
-
|
|
835
|
-
|
|
833
|
+
key = (
|
|
834
|
+
self.submission.genomic_study_subtype,
|
|
835
|
+
lab_datum.library_type,
|
|
836
|
+
lab_datum.sequence_subtype,
|
|
836
837
|
)
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
838
|
+
thresholds = threshold_definitions.get(key)
|
|
839
|
+
if thresholds is None:
|
|
840
|
+
log.warning(f"Thresholds for {key} not found! Skipping.")
|
|
841
|
+
continue
|
|
841
842
|
|
|
842
|
-
|
|
843
|
-
if sequence_data.mean_depth_of_coverage < threshold:
|
|
844
|
-
raise ValueError(
|
|
845
|
-
f"Mean depth of coverage for donor '{donor.case_id}', lab datum '{lab_datum.lab_data_name}' "
|
|
846
|
-
f"below threshold ({threshold}): {sequence_data.mean_depth_of_coverage}"
|
|
847
|
-
)
|
|
843
|
+
_check_thresholds(donor, lab_datum, thresholds)
|
|
848
844
|
|
|
849
845
|
return self
|
|
846
|
+
|
|
847
|
+
|
|
848
|
+
def _check_thresholds(donor: Donor, lab_datum: LabDatum, thresholds: dict[str, Any]):
|
|
849
|
+
sequence_data = lab_datum.sequence_data
|
|
850
|
+
case_id = donor.case_id
|
|
851
|
+
lab_data_name = lab_datum.lab_data_name
|
|
852
|
+
|
|
853
|
+
mean_depth_of_coverage_t = thresholds.get("meanDepthOfCoverage")
|
|
854
|
+
mean_depth_of_coverage_v = sequence_data.mean_depth_of_coverage
|
|
855
|
+
if mean_depth_of_coverage_t and mean_depth_of_coverage_v < mean_depth_of_coverage_t:
|
|
856
|
+
raise ValueError(
|
|
857
|
+
f"Mean depth of coverage for donor '{case_id}', lab datum '{lab_data_name}' "
|
|
858
|
+
f"below threshold: {mean_depth_of_coverage_v} < {mean_depth_of_coverage_t}"
|
|
859
|
+
)
|
|
860
|
+
|
|
861
|
+
read_length_t = thresholds.get("readLength")
|
|
862
|
+
read_length_v = sequence_data.read_length
|
|
863
|
+
if read_length_t and read_length_v < read_length_t:
|
|
864
|
+
raise ValueError(
|
|
865
|
+
f"Read length for donor '{case_id}', lab datum '{lab_data_name}' "
|
|
866
|
+
f"below threshold: {read_length_v} < {read_length_t}"
|
|
867
|
+
)
|
|
868
|
+
|
|
869
|
+
if t := thresholds.get("targetedRegionsAboveMinCoverage"):
|
|
870
|
+
min_coverage_t = t.get("minCoverage")
|
|
871
|
+
min_coverage_v = sequence_data.min_coverage
|
|
872
|
+
|
|
873
|
+
fraction_above_t = t.get("fractionAbove")
|
|
874
|
+
fraction_above_v = sequence_data.targeted_regions_above_min_coverage
|
|
875
|
+
|
|
876
|
+
if min_coverage_t and min_coverage_v < min_coverage_t:
|
|
877
|
+
raise ValueError(
|
|
878
|
+
f"Minimum coverage for donor '{case_id}', lab datum '{lab_data_name}' "
|
|
879
|
+
f"below threshold: {min_coverage_v} < {min_coverage_t}"
|
|
880
|
+
)
|
|
881
|
+
if fraction_above_t and fraction_above_v < fraction_above_t:
|
|
882
|
+
raise ValueError(
|
|
883
|
+
f"Fraction of targeted regions above minimum coverage for donor '{case_id}', "
|
|
884
|
+
f"lab datum '{lab_data_name}' below threshold: "
|
|
885
|
+
f"{fraction_above_v} < {fraction_above_t}"
|
|
886
|
+
)
|
|
887
|
+
|
|
888
|
+
|
|
889
|
+
type Thresholds = dict[tuple[str, str, str], dict[str, Any]]
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
def _load_thresholds() -> Thresholds:
|
|
893
|
+
threshold_definitions = json.load(
|
|
894
|
+
files("grz_cli").joinpath("resources", "thresholds.json").open("r", encoding="utf-8")
|
|
895
|
+
)
|
|
896
|
+
threshold_definitions = {
|
|
897
|
+
(d["genomicStudySubtype"], d["libraryType"], d["sequenceSubtype"]): d["thresholds"]
|
|
898
|
+
for d in threshold_definitions
|
|
899
|
+
}
|
|
900
|
+
return threshold_definitions
|