grz-cli 0.2.0__tar.gz → 0.4.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.4.0/LICENSE +21 -0
- grz_cli-0.4.0/MANIFEST.in +2 -0
- {grz_cli-0.2.0/src/grz_cli.egg-info → grz_cli-0.4.0}/PKG-INFO +7 -3
- {grz_cli-0.2.0 → grz_cli-0.4.0}/README.md +2 -0
- {grz_cli-0.2.0 → grz_cli-0.4.0}/pyproject.toml +12 -2
- grz_cli-0.4.0/src/grz_cli/__init__.py +1 -0
- grz_cli-0.4.0/src/grz_cli/bam_validation.py +39 -0
- {grz_cli-0.2.0 → grz_cli-0.4.0}/src/grz_cli/cli.py +9 -3
- {grz_cli-0.2.0 → grz_cli-0.4.0}/src/grz_cli/constants.py +0 -1
- {grz_cli-0.2.0 → grz_cli-0.4.0}/src/grz_cli/download.py +5 -2
- {grz_cli-0.2.0 → grz_cli-0.4.0}/src/grz_cli/fastq_validation.py +41 -24
- {grz_cli-0.2.0 → grz_cli-0.4.0}/src/grz_cli/models/config.py +18 -0
- {grz_cli-0.2.0 → grz_cli-0.4.0}/src/grz_cli/parser.py +114 -18
- {grz_cli-0.2.0 → grz_cli-0.4.0}/src/grz_cli/progress_logging.py +4 -2
- {grz_cli-0.2.0 → grz_cli-0.4.0}/src/grz_cli/upload.py +8 -3
- {grz_cli-0.2.0 → grz_cli-0.4.0/src/grz_cli.egg-info}/PKG-INFO +7 -3
- {grz_cli-0.2.0 → grz_cli-0.4.0}/src/grz_cli.egg-info/SOURCES.txt +3 -3
- {grz_cli-0.2.0 → grz_cli-0.4.0}/src/grz_cli.egg-info/requires.txt +3 -1
- grz_cli-0.4.0/tests/test_bam_validation.py +15 -0
- {grz_cli-0.2.0 → grz_cli-0.4.0}/tests/test_cli.py +5 -3
- {grz_cli-0.2.0 → grz_cli-0.4.0}/tests/test_fastq_validation.py +1 -1
- {grz_cli-0.2.0 → grz_cli-0.4.0}/tests/test_file_progress_logging.py +1 -1
- {grz_cli-0.2.0 → grz_cli-0.4.0}/tests/test_upload.py +12 -11
- grz_cli-0.2.0/MANIFEST.in +0 -2
- grz_cli-0.2.0/src/grz_cli/models/__init__.py +0 -0
- grz_cli-0.2.0/src/grz_cli/models/v1_1_1/__init__.py +0 -0
- grz_cli-0.2.0/src/grz_cli/models/v1_1_1/metadata.py +0 -1071
- grz_cli-0.2.0/src/grz_cli/resources/thresholds.json +0 -299
- {grz_cli-0.2.0 → grz_cli-0.4.0}/setup.cfg +0 -0
- {grz_cli-0.2.0 → grz_cli-0.4.0}/src/__init__.py +0 -0
- {grz_cli-0.2.0 → grz_cli-0.4.0}/src/grz_cli/file_operations.py +0 -0
- {grz_cli-0.2.0 → grz_cli-0.4.0}/src/grz_cli/logging_setup.py +0 -0
- {grz_cli-0.2.0/src/grz_cli → grz_cli-0.4.0/src/grz_cli/models}/__init__.py +0 -0
- {grz_cli-0.2.0 → grz_cli-0.4.0}/src/grz_cli/states.py +0 -0
- {grz_cli-0.2.0 → grz_cli-0.4.0}/src/grz_cli.egg-info/dependency_links.txt +0 -0
- {grz_cli-0.2.0 → grz_cli-0.4.0}/src/grz_cli.egg-info/entry_points.txt +0 -0
- {grz_cli-0.2.0 → grz_cli-0.4.0}/src/grz_cli.egg-info/top_level.txt +0 -0
- {grz_cli-0.2.0 → grz_cli-0.4.0}/tests/test_download.py +0 -0
- {grz_cli-0.2.0 → grz_cli-0.4.0}/tests/test_file_operations.py +0 -0
- {grz_cli-0.2.0 → grz_cli-0.4.0}/tests/test_parser.py +0 -0
- {grz_cli-0.2.0 → grz_cli-0.4.0}/tests/test_read_multiple_json.py +0 -0
grz_cli-0.4.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 GRZ/GHGA
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: grz-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Tool for validation, encryption and upload of MV submissions to GDCs.
|
|
5
5
|
Author-email: Koray Kirli <koraykirli@gmail.com>, Mathias Lesche <mathias.lesche@tu-dresden.de>, "Florian R. Hölzlwimmer" <git.ich@frhoelzlwimmer.de>, Till Hartmann <till.hartmann@bih-charite.de>, Thomas Sell <thomas.sell@bih-charite.de>
|
|
6
6
|
License: MIT
|
|
@@ -11,7 +11,7 @@ Classifier: License :: OSI Approved :: MIT License
|
|
|
11
11
|
Classifier: Operating System :: OS Independent
|
|
12
12
|
Requires-Python: >=3.12
|
|
13
13
|
Description-Content-Type: text/markdown
|
|
14
|
-
Requires-Dist: boto3<2,>=1.
|
|
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,6 +19,8 @@ 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.1
|
|
23
|
+
Requires-Dist: pysam==0.23.*
|
|
22
24
|
Provides-Extra: test
|
|
23
25
|
Requires-Dist: pytest<9,>=8.3.3; extra == "test"
|
|
24
26
|
Requires-Dist: pytest-cov<6,>=5.0.0; extra == "test"
|
|
@@ -253,6 +255,8 @@ Option is for the usage at a GDC/GRZ.
|
|
|
253
255
|
|
|
254
256
|
## Testing
|
|
255
257
|
|
|
258
|
+
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.
|
|
259
|
+
|
|
256
260
|
To run the tests, navigate to the root directory of your project and invoke `pytest`.
|
|
257
261
|
Alternatively, install `uv` and `tox` and run `uv run tox`.
|
|
258
262
|
|
|
@@ -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
|
|
|
@@ -4,7 +4,6 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "grz-cli"
|
|
7
|
-
version = "0.2.0"
|
|
8
7
|
description = "Tool for validation, encryption and upload of MV submissions to GDCs."
|
|
9
8
|
readme = "README.md"
|
|
10
9
|
license = { text = "MIT" }
|
|
@@ -15,8 +14,9 @@ authors = [
|
|
|
15
14
|
{ name = "Till Hartmann", email = "till.hartmann@bih-charite.de" },
|
|
16
15
|
{ name = "Thomas Sell", email = "thomas.sell@bih-charite.de" },
|
|
17
16
|
]
|
|
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,6 +24,8 @@ 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.1",
|
|
28
|
+
"pysam ==0.23.*"
|
|
27
29
|
]
|
|
28
30
|
classifiers = [
|
|
29
31
|
"Programming Language :: Python :: 3",
|
|
@@ -33,6 +35,10 @@ classifiers = [
|
|
|
33
35
|
requires-python = ">=3.12"
|
|
34
36
|
keywords = ["GRZ", "GDC", "S3"]
|
|
35
37
|
|
|
38
|
+
|
|
39
|
+
[tool.setuptools.dynamic]
|
|
40
|
+
version = { attr = "grz_cli.__version__" }
|
|
41
|
+
|
|
36
42
|
[project.urls]
|
|
37
43
|
"Homepage" = "https://codebase.helmholtz.cloud/grz-mv-genomseq/grz-cli"
|
|
38
44
|
|
|
@@ -193,6 +199,10 @@ python_version = "3.12"
|
|
|
193
199
|
plugins = "pydantic.mypy"
|
|
194
200
|
packages = "src"
|
|
195
201
|
|
|
202
|
+
[[tool.mypy.overrides]]
|
|
203
|
+
module = ["grz_pydantic_models.*"]
|
|
204
|
+
follow_untyped_imports = true
|
|
205
|
+
|
|
196
206
|
[tool.pytest.ini_options]
|
|
197
207
|
addopts = [
|
|
198
208
|
"--import-mode=importlib",
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.4.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
|
|
@@ -11,6 +11,7 @@ from pathlib import Path
|
|
|
11
11
|
from tempfile import NamedTemporaryFile
|
|
12
12
|
|
|
13
13
|
import click
|
|
14
|
+
import grz_pydantic_models.submission.metadata
|
|
14
15
|
import platformdirs
|
|
15
16
|
import yaml
|
|
16
17
|
|
|
@@ -81,7 +82,7 @@ submission_id = click.option(
|
|
|
81
82
|
required=True,
|
|
82
83
|
type=str,
|
|
83
84
|
metavar="STRING",
|
|
84
|
-
help="S3 submission
|
|
85
|
+
help="S3 submission ID",
|
|
85
86
|
)
|
|
86
87
|
|
|
87
88
|
output_dir = click.option(
|
|
@@ -108,7 +109,11 @@ class OrderedGroup(click.Group):
|
|
|
108
109
|
cls=OrderedGroup,
|
|
109
110
|
help="Validate, encrypt, decrypt and upload submissions to a GRZ/GDC.",
|
|
110
111
|
)
|
|
111
|
-
@click.version_option(
|
|
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.submission.metadata.get_supported_versions())})",
|
|
116
|
+
)
|
|
112
117
|
@click.option("--log-file", metavar="FILE", type=str, help="Path to log file")
|
|
113
118
|
@click.option(
|
|
114
119
|
"--log-level",
|
|
@@ -270,7 +275,8 @@ def upload(
|
|
|
270
275
|
encrypted_files_dir=submission_dir / "encrypted_files",
|
|
271
276
|
threads=threads,
|
|
272
277
|
)
|
|
273
|
-
|
|
278
|
+
# output the generated submission ID
|
|
279
|
+
print(worker_inst.upload(config))
|
|
274
280
|
|
|
275
281
|
log.info("Upload finished!")
|
|
276
282
|
|
|
@@ -73,8 +73,11 @@ class S3BotoDownloadWorker:
|
|
|
73
73
|
return string
|
|
74
74
|
|
|
75
75
|
# configure proxies if proxy_url is defined
|
|
76
|
-
proxy_url =
|
|
77
|
-
config = Boto3Config(
|
|
76
|
+
proxy_url = self._config.s3_options.proxy_url
|
|
77
|
+
config = Boto3Config(
|
|
78
|
+
proxies={"http": str(proxy_url), "https": str(proxy_url)} if proxy_url is not None else None,
|
|
79
|
+
request_checksum_calculation=self._config.s3_options.request_checksum_calculation,
|
|
80
|
+
)
|
|
78
81
|
|
|
79
82
|
# Initialize S3 client for uploading
|
|
80
83
|
self._s3_client: boto3.session.Session.client = boto3_client(
|
|
@@ -64,40 +64,58 @@ def open_fastq(file_path: str | PathLike, progress=True) -> Generator[TextIO, No
|
|
|
64
64
|
yield typing.cast(TextIO, handle)
|
|
65
65
|
|
|
66
66
|
|
|
67
|
-
def calculate_fastq_stats(file_path) -> tuple[int,
|
|
67
|
+
def calculate_fastq_stats(file_path, expected_read_length: int | None = None) -> tuple[int, int | None]:
|
|
68
68
|
"""
|
|
69
69
|
Calculate line number and read lengths in FASTQ file.
|
|
70
70
|
:param file_path: Path to the FASTQ file
|
|
71
|
+
:param expected_read_length: Expected read length (None if not known)
|
|
71
72
|
:return: tuple with the following values:
|
|
72
|
-
-
|
|
73
|
-
-
|
|
73
|
+
- Number of lines in the file
|
|
74
|
+
- Observed read length
|
|
74
75
|
"""
|
|
75
|
-
read_lengths = set()
|
|
76
|
-
|
|
77
76
|
with open_fastq(file_path) as f:
|
|
78
77
|
for line_number, line in enumerate(f):
|
|
79
78
|
if (line_number % 4) == 1:
|
|
80
79
|
# Sequence lines are every 4th line starting from the 2nd
|
|
81
|
-
|
|
80
|
+
# Check if the read length is consistent
|
|
81
|
+
read_length = len(line.strip())
|
|
82
|
+
if expected_read_length is None:
|
|
83
|
+
expected_read_length = read_length
|
|
84
|
+
elif read_length != expected_read_length:
|
|
85
|
+
raise ValueError(
|
|
86
|
+
f"Read length mismatch at line {line_number + 1}: "
|
|
87
|
+
f"expected {expected_read_length}, found {read_length}"
|
|
88
|
+
)
|
|
82
89
|
|
|
83
90
|
return (
|
|
84
91
|
line_number + 1, # enumerate starts indexing at 0
|
|
85
|
-
|
|
92
|
+
expected_read_length,
|
|
86
93
|
)
|
|
87
94
|
|
|
88
95
|
|
|
89
|
-
def validate_fastq_file(
|
|
96
|
+
def validate_fastq_file(
|
|
97
|
+
fastq_file: str | PathLike, expected_read_length: int | None = None
|
|
98
|
+
) -> tuple[int, int, list[str]] | tuple[int, int | None, list[str]]:
|
|
90
99
|
"""
|
|
91
100
|
Validates a FASTQ file.
|
|
92
101
|
|
|
93
102
|
:param fastq_file: Path to the fastq file
|
|
103
|
+
:param expected_read_length: Expected read length (None if not known)
|
|
94
104
|
:return: Tuple with the following fields:
|
|
95
105
|
- number of lines
|
|
96
106
|
- set of observed read lengths
|
|
97
107
|
- list of errors found
|
|
98
108
|
"""
|
|
99
|
-
num_lines, read_lengths = calculate_fastq_stats(fastq_file)
|
|
100
109
|
errors = []
|
|
110
|
+
try:
|
|
111
|
+
if expected_read_length is not None:
|
|
112
|
+
log.debug("%s: expecting read length of %s", fastq_file, expected_read_length)
|
|
113
|
+
|
|
114
|
+
# Calculate the number of lines and read lengths
|
|
115
|
+
num_lines, read_length = calculate_fastq_stats(fastq_file, expected_read_length=expected_read_length)
|
|
116
|
+
except ValueError as e:
|
|
117
|
+
errors.append(f"{fastq_file}: {e}")
|
|
118
|
+
return -1, -1, errors
|
|
101
119
|
|
|
102
120
|
# Check if the number of lines in a FASTQ file is a multiple of 4.
|
|
103
121
|
if num_lines % 4 != 0:
|
|
@@ -105,40 +123,39 @@ def validate_fastq_file(fastq_file: str | PathLike) -> tuple[int, set[int], list
|
|
|
105
123
|
else:
|
|
106
124
|
log.debug("%s: %s lines", fastq_file, num_lines)
|
|
107
125
|
|
|
108
|
-
|
|
109
|
-
if len(read_lengths) != 1:
|
|
110
|
-
errors.append(
|
|
111
|
-
f"{fastq_file}: Inconsistent read lengths! "
|
|
112
|
-
f"Found reads with following lengths: {', '.join([str(c) for c in read_lengths])}"
|
|
113
|
-
)
|
|
114
|
-
else:
|
|
115
|
-
log.debug("%s: read length: %s", fastq_file, next(iter(read_lengths)))
|
|
116
|
-
|
|
117
|
-
return num_lines, read_lengths, errors
|
|
126
|
+
return num_lines, read_length, errors
|
|
118
127
|
|
|
119
128
|
|
|
120
|
-
def validate_single_end_reads(fastq_file: str | PathLike) -> Generator[str]:
|
|
129
|
+
def validate_single_end_reads(fastq_file: str | PathLike, expected_read_length: int | None = None) -> Generator[str]:
|
|
121
130
|
"""
|
|
122
131
|
Validate a single-end FASTQ file.
|
|
123
132
|
|
|
124
133
|
:param fastq_file: Path to the FASTQ file
|
|
134
|
+
:param expected_read_length: Expected read length (None if not known)
|
|
125
135
|
:return: Generator of errors, if any.
|
|
126
136
|
"""
|
|
127
|
-
num_lines, read_lengths, errors = validate_fastq_file(fastq_file)
|
|
137
|
+
num_lines, read_lengths, errors = validate_fastq_file(fastq_file, expected_read_length=expected_read_length)
|
|
128
138
|
yield from errors
|
|
129
139
|
|
|
130
140
|
|
|
131
|
-
def validate_paired_end_reads(
|
|
141
|
+
def validate_paired_end_reads(
|
|
142
|
+
fastq_file1: str | PathLike, fastq_file2: str | PathLike, expected_read_length: int | None = None
|
|
143
|
+
) -> Generator[str]:
|
|
132
144
|
"""
|
|
133
145
|
Validate two paired-end FASTQ files.
|
|
134
146
|
|
|
135
147
|
:param fastq_file1: Path to the first FASTQ file (Read 1)
|
|
136
148
|
:param fastq_file2: Path to the second FASTQ file (Read 2)
|
|
149
|
+
:param expected_read_length: Expected read length (None if not known)
|
|
137
150
|
:return: Generator of errors, if any.
|
|
138
151
|
"""
|
|
139
|
-
num_lines_file1, read_lengths_file1, errors_file1 = validate_fastq_file(
|
|
152
|
+
num_lines_file1, read_lengths_file1, errors_file1 = validate_fastq_file(
|
|
153
|
+
fastq_file1, expected_read_length=expected_read_length
|
|
154
|
+
)
|
|
140
155
|
yield from errors_file1
|
|
141
|
-
num_lines_file2, read_lengths_file2, errors_file2 = validate_fastq_file(
|
|
156
|
+
num_lines_file2, read_lengths_file2, errors_file2 = validate_fastq_file(
|
|
157
|
+
fastq_file2, expected_read_length=expected_read_length
|
|
158
|
+
)
|
|
142
159
|
yield from errors_file2
|
|
143
160
|
|
|
144
161
|
if num_lines_file1 != num_lines_file2:
|
|
@@ -67,6 +67,24 @@ class S3Options(StrictBaseModel):
|
|
|
67
67
|
The proxy URL for S3 operations (optional).
|
|
68
68
|
"""
|
|
69
69
|
|
|
70
|
+
request_checksum_calculation: str | None = None
|
|
71
|
+
"""
|
|
72
|
+
Whether to calculate checksums for S3 request payloads (optional).
|
|
73
|
+
Valid values are:
|
|
74
|
+
|
|
75
|
+
* ``when_supported`` -- When set, a checksum will be calculated for
|
|
76
|
+
all request payloads of operations modeled with the ``httpChecksum``
|
|
77
|
+
trait where ``requestChecksumRequired`` is ``true`` or a
|
|
78
|
+
``requestAlgorithmMember`` is modeled.
|
|
79
|
+
|
|
80
|
+
* ``when_required`` -- When set, a checksum will only be calculated
|
|
81
|
+
for request payloads of operations modeled with the ``httpChecksum``
|
|
82
|
+
trait where ``requestChecksumRequired`` is ``true`` or where a
|
|
83
|
+
``requestAlgorithmMember`` is modeled and supplied.
|
|
84
|
+
|
|
85
|
+
Defaults to None.
|
|
86
|
+
"""
|
|
87
|
+
|
|
70
88
|
|
|
71
89
|
FilePath = Annotated[Path, AfterValidator(lambda v: v.expanduser()), PathType("file")]
|
|
72
90
|
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import hashlib
|
|
5
6
|
import json
|
|
6
7
|
import logging
|
|
7
8
|
from collections.abc import Generator
|
|
@@ -9,13 +10,8 @@ from itertools import groupby
|
|
|
9
10
|
from os import PathLike
|
|
10
11
|
from pathlib import Path
|
|
11
12
|
|
|
12
|
-
from
|
|
13
|
-
|
|
14
|
-
from .download import S3BotoDownloadWorker
|
|
15
|
-
from .fastq_validation import validate_paired_end_reads, validate_single_end_reads
|
|
16
|
-
from .file_operations import Crypt4GH, calculate_sha256
|
|
17
|
-
from .models.config import ConfigModel
|
|
18
|
-
from .models.v1_1_1.metadata import (
|
|
13
|
+
from grz_pydantic_models.submission.metadata.v1 import (
|
|
14
|
+
ChecksumType,
|
|
19
15
|
File,
|
|
20
16
|
FileType,
|
|
21
17
|
GrzSubmissionMetadata,
|
|
@@ -23,7 +19,14 @@ from .models.v1_1_1.metadata import (
|
|
|
23
19
|
SequenceData,
|
|
24
20
|
SequencingLayout,
|
|
25
21
|
)
|
|
26
|
-
from .
|
|
22
|
+
from grz_pydantic_models.submission.metadata.v1 import File as SubmissionFileMetadata
|
|
23
|
+
from pydantic import ValidationError
|
|
24
|
+
|
|
25
|
+
from .bam_validation import validate_bam
|
|
26
|
+
from .download import S3BotoDownloadWorker
|
|
27
|
+
from .fastq_validation import validate_paired_end_reads, validate_single_end_reads
|
|
28
|
+
from .file_operations import Crypt4GH, calculate_sha256
|
|
29
|
+
from .models.config import ConfigModel
|
|
27
30
|
from .progress_logging import FileProgressLogger
|
|
28
31
|
from .states import DecryptionState, EncryptionState, ValidationState
|
|
29
32
|
from .upload import S3BotoUploadWorker
|
|
@@ -168,6 +171,51 @@ class Submission:
|
|
|
168
171
|
|
|
169
172
|
return retval
|
|
170
173
|
|
|
174
|
+
@staticmethod
|
|
175
|
+
def validate_file_data(metadata: File, local_file_path: Path) -> Generator[str]:
|
|
176
|
+
"""
|
|
177
|
+
Validates whether the provided file matches this metadata.
|
|
178
|
+
|
|
179
|
+
:param metadata: Metadata model object
|
|
180
|
+
:param local_file_path: Path to the actual file (resolved if symlinked)
|
|
181
|
+
:return: Generator of errors
|
|
182
|
+
"""
|
|
183
|
+
# Resolve file path
|
|
184
|
+
local_file_path = local_file_path.resolve()
|
|
185
|
+
|
|
186
|
+
# Check if path exists
|
|
187
|
+
if not local_file_path.exists():
|
|
188
|
+
yield f"{str(metadata.file_path)} does not exist!"
|
|
189
|
+
# Return here as following tests cannot work
|
|
190
|
+
return
|
|
191
|
+
|
|
192
|
+
# Check if path is a file
|
|
193
|
+
if not local_file_path.is_file():
|
|
194
|
+
yield f"{str(metadata.file_path)} is not a file!"
|
|
195
|
+
# Return here as following tests cannot work
|
|
196
|
+
return
|
|
197
|
+
|
|
198
|
+
# Check if the checksum is correct
|
|
199
|
+
if metadata.checksum_type == "sha256":
|
|
200
|
+
calculated_checksum = calculate_sha256(local_file_path)
|
|
201
|
+
if metadata.file_checksum != calculated_checksum:
|
|
202
|
+
yield (
|
|
203
|
+
f"{str(metadata.file_path)}: Checksum mismatch! "
|
|
204
|
+
f"Expected: '{metadata.file_checksum}', calculated: '{calculated_checksum}'."
|
|
205
|
+
)
|
|
206
|
+
else:
|
|
207
|
+
yield (
|
|
208
|
+
f"{str(metadata.file_path)}: Unsupported checksum type: {metadata.checksum_type}. "
|
|
209
|
+
f"Supported types: {[e.value for e in ChecksumType]}"
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
# Check file size
|
|
213
|
+
if metadata.file_size_in_bytes != local_file_path.stat().st_size:
|
|
214
|
+
yield (
|
|
215
|
+
f"{str(metadata.file_path)}: File size mismatch! "
|
|
216
|
+
f"Expected: '{metadata.file_size_in_bytes}', observed: '{local_file_path.stat().st_size}'."
|
|
217
|
+
)
|
|
218
|
+
|
|
171
219
|
def validate_checksums(self, progress_log_file: str | PathLike) -> Generator[str]:
|
|
172
220
|
"""
|
|
173
221
|
Validates the checksum of the files against the metadata and prints the errors.
|
|
@@ -185,7 +233,7 @@ class Submission:
|
|
|
185
233
|
self.__log.debug("Validating '%s'...", str(local_file_path))
|
|
186
234
|
|
|
187
235
|
# validate the file
|
|
188
|
-
errors = list(
|
|
236
|
+
errors = list(self.validate_file_data(file_metadata, local_file_path))
|
|
189
237
|
validation_passed = len(errors) == 0
|
|
190
238
|
|
|
191
239
|
# return log state
|
|
@@ -219,12 +267,16 @@ class Submission:
|
|
|
219
267
|
def find_fastq_files(sequence_data: SequenceData) -> list[File]:
|
|
220
268
|
return [f for f in sequence_data.files if f.file_type == FileType.fastq]
|
|
221
269
|
|
|
270
|
+
def find_bam_files(sequence_data: SequenceData) -> list[File]:
|
|
271
|
+
return [f for f in sequence_data.files if f.file_type == FileType.bam]
|
|
272
|
+
|
|
222
273
|
for donor in self.metadata.content.donors:
|
|
223
274
|
for lab_data in donor.lab_data:
|
|
224
275
|
sequencing_layout = lab_data.sequencing_layout
|
|
225
276
|
sequence_data = lab_data.sequence_data
|
|
226
277
|
# find all FASTQ files
|
|
227
278
|
fastq_files = find_fastq_files(sequence_data)
|
|
279
|
+
bam_files = find_bam_files(sequence_data)
|
|
228
280
|
|
|
229
281
|
match sequencing_layout:
|
|
230
282
|
case SequencingLayout.single_end | SequencingLayout.reverse | SequencingLayout.other:
|
|
@@ -233,16 +285,45 @@ class Submission:
|
|
|
233
285
|
case SequencingLayout.paired_end:
|
|
234
286
|
yield from self._validate_paired_end(fastq_files, progress_logger)
|
|
235
287
|
|
|
288
|
+
yield from self._validate_bams(bam_files, progress_logger)
|
|
289
|
+
|
|
290
|
+
def _validate_bams(
|
|
291
|
+
self,
|
|
292
|
+
bam_files: list[File],
|
|
293
|
+
progress_logger: FileProgressLogger[ValidationState],
|
|
294
|
+
) -> Generator[str, None, None]:
|
|
295
|
+
def validate_file(local_file_path, _file_metadata) -> ValidationState:
|
|
296
|
+
self.__log.debug("Validating '%s'...", str(local_file_path))
|
|
297
|
+
|
|
298
|
+
# validate the file
|
|
299
|
+
errors = list(validate_bam(local_file_path))
|
|
300
|
+
validation_passed = len(errors) == 0
|
|
301
|
+
|
|
302
|
+
# return log state
|
|
303
|
+
return ValidationState(
|
|
304
|
+
errors=errors,
|
|
305
|
+
validation_passed=validation_passed,
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
for bam_file in bam_files:
|
|
309
|
+
logged_state = progress_logger.get_state(
|
|
310
|
+
self.files_dir / bam_file.file_path,
|
|
311
|
+
bam_file,
|
|
312
|
+
default=validate_file, # validate the file if the state was not calculated yet
|
|
313
|
+
)
|
|
314
|
+
if logged_state:
|
|
315
|
+
yield from logged_state["errors"]
|
|
316
|
+
|
|
236
317
|
def _validate_single_end(
|
|
237
318
|
self,
|
|
238
319
|
fastq_files: list[File],
|
|
239
320
|
progress_logger: FileProgressLogger[ValidationState],
|
|
240
321
|
) -> Generator[str, None, None]:
|
|
241
|
-
def validate_file(local_file_path,
|
|
322
|
+
def validate_file(local_file_path, file_metadata: SubmissionFileMetadata) -> ValidationState:
|
|
242
323
|
self.__log.debug("Validating '%s'...", str(local_file_path))
|
|
243
324
|
|
|
244
325
|
# validate the file
|
|
245
|
-
errors = list(validate_single_end_reads(local_file_path))
|
|
326
|
+
errors = list(validate_single_end_reads(local_file_path, expected_read_length=file_metadata.read_length))
|
|
246
327
|
validation_passed = len(errors) == 0
|
|
247
328
|
|
|
248
329
|
# return log state
|
|
@@ -293,6 +374,7 @@ class Submission:
|
|
|
293
374
|
validate_paired_end_reads(
|
|
294
375
|
local_fastq_r1_path, # fastq R1
|
|
295
376
|
local_fastq_r2_path, # fastq R2
|
|
377
|
+
expected_read_length=fastq_r1.read_length, # fastq R1 and R2 should have the same read length
|
|
296
378
|
)
|
|
297
379
|
)
|
|
298
380
|
validation_passed = len(errors) == 0
|
|
@@ -447,13 +529,26 @@ class EncryptedSubmission:
|
|
|
447
529
|
|
|
448
530
|
return retval
|
|
449
531
|
|
|
532
|
+
_submission_id: str | None = None
|
|
533
|
+
|
|
534
|
+
@property
|
|
535
|
+
def submission_id(self) -> str:
|
|
536
|
+
"""ID to refer to an individual submission to a GRZ"""
|
|
537
|
+
if self._submission_id is None:
|
|
538
|
+
# generate a submission ID once
|
|
539
|
+
submitter_id = self.metadata.content.submission.submitter_id
|
|
540
|
+
submission_date = self.metadata.content.submission.submission_date
|
|
541
|
+
# use first 8 characters of SHA256 hash of transaction ID to virtually prevent collisions
|
|
542
|
+
suffix = hashlib.sha256(self.metadata.transaction_id.encode("utf-8")).hexdigest()[:8]
|
|
543
|
+
self._submission_id = f"{submitter_id}_{submission_date}_{suffix}"
|
|
544
|
+
|
|
545
|
+
return self._submission_id
|
|
546
|
+
|
|
450
547
|
def get_metadata_file_path_and_object_id(self) -> tuple[Path, str]:
|
|
451
548
|
"""
|
|
452
549
|
:return: tuple with the `local_file_path` and s3_object_id of the metadata file
|
|
453
550
|
"""
|
|
454
|
-
return Path(self.metadata.file_path), str(
|
|
455
|
-
Path(self.metadata.transaction_id) / "metadata" / self.metadata.file_path.name
|
|
456
|
-
)
|
|
551
|
+
return Path(self.metadata.file_path), str(Path(self.submission_id) / "metadata" / self.metadata.file_path.name)
|
|
457
552
|
|
|
458
553
|
def get_encrypted_files_and_object_id(self) -> dict[Path, str]:
|
|
459
554
|
"""
|
|
@@ -462,7 +557,7 @@ class EncryptedSubmission:
|
|
|
462
557
|
retval = {}
|
|
463
558
|
for local_file_path, file_metadata in self.encrypted_files.items():
|
|
464
559
|
retval[local_file_path] = str(
|
|
465
|
-
Path(self.
|
|
560
|
+
Path(self.submission_id) / "files" / self.get_encrypted_file_path(file_metadata.file_path)
|
|
466
561
|
)
|
|
467
562
|
return retval
|
|
468
563
|
|
|
@@ -742,10 +837,9 @@ class Worker:
|
|
|
742
837
|
|
|
743
838
|
return submission
|
|
744
839
|
|
|
745
|
-
def upload(self, config: ConfigModel):
|
|
840
|
+
def upload(self, config: ConfigModel) -> str:
|
|
746
841
|
"""
|
|
747
|
-
Upload an encrypted submission
|
|
748
|
-
|
|
842
|
+
Upload an encrypted submission and return the generated submission ID
|
|
749
843
|
"""
|
|
750
844
|
upload_worker = S3BotoUploadWorker(config, status_file_path=self.progress_file_upload)
|
|
751
845
|
|
|
@@ -753,6 +847,8 @@ class Worker:
|
|
|
753
847
|
|
|
754
848
|
upload_worker.upload(encrypted_submission)
|
|
755
849
|
|
|
850
|
+
return encrypted_submission.submission_id
|
|
851
|
+
|
|
756
852
|
def download(self, config: ConfigModel, submission_id: str):
|
|
757
853
|
"""
|
|
758
854
|
Download an encrypted submission
|
|
@@ -9,8 +9,9 @@ from collections.abc import Callable
|
|
|
9
9
|
from os import PathLike
|
|
10
10
|
from pathlib import Path
|
|
11
11
|
|
|
12
|
+
from grz_pydantic_models.submission.metadata.v1 import File as SubmissionFileMetadata
|
|
13
|
+
|
|
12
14
|
from .file_operations import read_multiple_json
|
|
13
|
-
from .models.v1_1_1.metadata import File as SubmissionFileMetadata
|
|
14
15
|
from .states import State
|
|
15
16
|
|
|
16
17
|
Index = tuple[str, float, int]
|
|
@@ -104,7 +105,8 @@ class FileProgressLogger[T: State]:
|
|
|
104
105
|
:param file_path: The file path to query for the state.
|
|
105
106
|
:param file_metadata: The metadata of the file to query for the state.
|
|
106
107
|
:param default: Default state to use if the file does not exist.
|
|
107
|
-
Can be a Callable that takes the file path and the file metadata as input and returns some state
|
|
108
|
+
Can be a Callable that takes the file path and the file metadata as input and returns some state:
|
|
109
|
+
`Callable[[Path, SubmissionFileMetadata], T]`.
|
|
108
110
|
|
|
109
111
|
The default state gets automatically saved as the state for this file in case there is no stored state.
|
|
110
112
|
:return: A dictionary representing the file's state, or None if the file's state isn't logged.
|
|
@@ -98,8 +98,11 @@ class S3BotoUploadWorker(UploadWorker):
|
|
|
98
98
|
return string
|
|
99
99
|
|
|
100
100
|
# configure proxies if proxy_url is defined
|
|
101
|
-
proxy_url =
|
|
102
|
-
config = Boto3Config(
|
|
101
|
+
proxy_url = self._config.s3_options.proxy_url
|
|
102
|
+
config = Boto3Config(
|
|
103
|
+
proxies={"http": str(proxy_url), "https": str(proxy_url)} if proxy_url is not None else None,
|
|
104
|
+
request_checksum_calculation=self._config.s3_options.request_checksum_calculation,
|
|
105
|
+
)
|
|
103
106
|
|
|
104
107
|
# Initialize S3 client for uploading
|
|
105
108
|
self._s3_client: boto3.session.Session.client = boto3_client(
|
|
@@ -130,7 +133,9 @@ class S3BotoUploadWorker(UploadWorker):
|
|
|
130
133
|
if filesize / MULTIPART_CHUNKSIZE > MULTIPART_MAX_CHUNKS
|
|
131
134
|
else MULTIPART_CHUNKSIZE
|
|
132
135
|
)
|
|
133
|
-
self.__log.debug(
|
|
136
|
+
self.__log.debug(
|
|
137
|
+
f"Using a chunksize of: {chunksize / 1024**2}MiB, results in {math.ceil(filesize / chunksize)} chunk(s)"
|
|
138
|
+
)
|
|
134
139
|
|
|
135
140
|
config = TransferConfig(
|
|
136
141
|
multipart_threshold=MULTIPART_THRESHOLD,
|