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.
Files changed (41) hide show
  1. grz_cli-0.4.0/LICENSE +21 -0
  2. grz_cli-0.4.0/MANIFEST.in +2 -0
  3. {grz_cli-0.2.0/src/grz_cli.egg-info → grz_cli-0.4.0}/PKG-INFO +7 -3
  4. {grz_cli-0.2.0 → grz_cli-0.4.0}/README.md +2 -0
  5. {grz_cli-0.2.0 → grz_cli-0.4.0}/pyproject.toml +12 -2
  6. grz_cli-0.4.0/src/grz_cli/__init__.py +1 -0
  7. grz_cli-0.4.0/src/grz_cli/bam_validation.py +39 -0
  8. {grz_cli-0.2.0 → grz_cli-0.4.0}/src/grz_cli/cli.py +9 -3
  9. {grz_cli-0.2.0 → grz_cli-0.4.0}/src/grz_cli/constants.py +0 -1
  10. {grz_cli-0.2.0 → grz_cli-0.4.0}/src/grz_cli/download.py +5 -2
  11. {grz_cli-0.2.0 → grz_cli-0.4.0}/src/grz_cli/fastq_validation.py +41 -24
  12. {grz_cli-0.2.0 → grz_cli-0.4.0}/src/grz_cli/models/config.py +18 -0
  13. {grz_cli-0.2.0 → grz_cli-0.4.0}/src/grz_cli/parser.py +114 -18
  14. {grz_cli-0.2.0 → grz_cli-0.4.0}/src/grz_cli/progress_logging.py +4 -2
  15. {grz_cli-0.2.0 → grz_cli-0.4.0}/src/grz_cli/upload.py +8 -3
  16. {grz_cli-0.2.0 → grz_cli-0.4.0/src/grz_cli.egg-info}/PKG-INFO +7 -3
  17. {grz_cli-0.2.0 → grz_cli-0.4.0}/src/grz_cli.egg-info/SOURCES.txt +3 -3
  18. {grz_cli-0.2.0 → grz_cli-0.4.0}/src/grz_cli.egg-info/requires.txt +3 -1
  19. grz_cli-0.4.0/tests/test_bam_validation.py +15 -0
  20. {grz_cli-0.2.0 → grz_cli-0.4.0}/tests/test_cli.py +5 -3
  21. {grz_cli-0.2.0 → grz_cli-0.4.0}/tests/test_fastq_validation.py +1 -1
  22. {grz_cli-0.2.0 → grz_cli-0.4.0}/tests/test_file_progress_logging.py +1 -1
  23. {grz_cli-0.2.0 → grz_cli-0.4.0}/tests/test_upload.py +12 -11
  24. grz_cli-0.2.0/MANIFEST.in +0 -2
  25. grz_cli-0.2.0/src/grz_cli/models/__init__.py +0 -0
  26. grz_cli-0.2.0/src/grz_cli/models/v1_1_1/__init__.py +0 -0
  27. grz_cli-0.2.0/src/grz_cli/models/v1_1_1/metadata.py +0 -1071
  28. grz_cli-0.2.0/src/grz_cli/resources/thresholds.json +0 -299
  29. {grz_cli-0.2.0 → grz_cli-0.4.0}/setup.cfg +0 -0
  30. {grz_cli-0.2.0 → grz_cli-0.4.0}/src/__init__.py +0 -0
  31. {grz_cli-0.2.0 → grz_cli-0.4.0}/src/grz_cli/file_operations.py +0 -0
  32. {grz_cli-0.2.0 → grz_cli-0.4.0}/src/grz_cli/logging_setup.py +0 -0
  33. {grz_cli-0.2.0/src/grz_cli → grz_cli-0.4.0/src/grz_cli/models}/__init__.py +0 -0
  34. {grz_cli-0.2.0 → grz_cli-0.4.0}/src/grz_cli/states.py +0 -0
  35. {grz_cli-0.2.0 → grz_cli-0.4.0}/src/grz_cli.egg-info/dependency_links.txt +0 -0
  36. {grz_cli-0.2.0 → grz_cli-0.4.0}/src/grz_cli.egg-info/entry_points.txt +0 -0
  37. {grz_cli-0.2.0 → grz_cli-0.4.0}/src/grz_cli.egg-info/top_level.txt +0 -0
  38. {grz_cli-0.2.0 → grz_cli-0.4.0}/tests/test_download.py +0 -0
  39. {grz_cli-0.2.0 → grz_cli-0.4.0}/tests/test_file_operations.py +0 -0
  40. {grz_cli-0.2.0 → grz_cli-0.4.0}/tests/test_parser.py +0 -0
  41. {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.
@@ -0,0 +1,2 @@
1
+ graft src
2
+ include LICENSE
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.2
1
+ Metadata-Version: 2.4
2
2
  Name: grz-cli
3
- Version: 0.2.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.35.39
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.35.39,<2",
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 prefix (corresponds to the tanG of a 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(version=importlib.metadata.version("grz-cli"), prog_name="grz-cli")
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
- worker_inst.upload(config)
278
+ # output the generated submission ID
279
+ print(worker_inst.upload(config))
274
280
 
275
281
  log.info("Upload finished!")
276
282
 
@@ -18,7 +18,6 @@ LOGGING_CONFIG = {
18
18
  "level": "INFO",
19
19
  "formatter": "standard",
20
20
  "class": "logging.StreamHandler",
21
- "stream": "ext://sys.stdout", # Default is stderr
22
21
  },
23
22
  },
24
23
  "loggers": {
@@ -73,8 +73,11 @@ class S3BotoDownloadWorker:
73
73
  return string
74
74
 
75
75
  # configure proxies if proxy_url is defined
76
- proxy_url = empty_str_to_none(self._config.s3_options.proxy_url)
77
- config = Boto3Config(proxies={"http": proxy_url, "https": proxy_url}) if proxy_url is not None else None
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, set[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
- - number of lines in the file
73
- - Set of observed read lengths
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
- read_lengths.add(len(line.strip()))
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
- read_lengths,
92
+ expected_read_length,
86
93
  )
87
94
 
88
95
 
89
- def validate_fastq_file(fastq_file: str | PathLike) -> tuple[int, set[int], list[str]]:
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
- # Check if all reads in the FASTQ file have the same length.
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(fastq_file1: str | PathLike, fastq_file2: str | PathLike) -> Generator[str]:
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(fastq_file1)
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(fastq_file2)
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 pydantic import ValidationError
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 .models.v1_1_1.metadata import File as SubmissionFileMetadata
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(file_metadata.validate_data(local_file_path))
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, _file_metadata) -> ValidationState:
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.metadata.transaction_id) / "files" / self.get_encrypted_file_path(file_metadata.file_path)
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 = empty_str_to_none(self._config.s3_options.proxy_url)
102
- config = Boto3Config(proxies={"http": proxy_url, "https": proxy_url}) if proxy_url is not None else None
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(f"Using a chunksize of: {chunksize / 1024**2}MiB, results in {filesize / chunksize} chunks")
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,