grz-cli 0.4.0__tar.gz → 0.5.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 (39) hide show
  1. {grz_cli-0.4.0/src/grz_cli.egg-info → grz_cli-0.5.0}/PKG-INFO +5 -3
  2. {grz_cli-0.4.0 → grz_cli-0.5.0}/pyproject.toml +4 -7
  3. grz_cli-0.5.0/src/grz_cli/__init__.py +1 -0
  4. {grz_cli-0.4.0 → grz_cli-0.5.0}/src/grz_cli/cli.py +174 -85
  5. {grz_cli-0.4.0 → grz_cli-0.5.0}/src/grz_cli/constants.py +2 -0
  6. {grz_cli-0.4.0 → grz_cli-0.5.0}/src/grz_cli/download.py +48 -34
  7. {grz_cli-0.4.0 → grz_cli-0.5.0}/src/grz_cli/fastq_validation.py +2 -0
  8. {grz_cli-0.4.0 → grz_cli-0.5.0}/src/grz_cli/file_operations.py +6 -1
  9. {grz_cli-0.4.0 → grz_cli-0.5.0}/src/grz_cli/models/config.py +9 -2
  10. grz_cli-0.5.0/src/grz_cli/transfer.py +42 -0
  11. {grz_cli-0.4.0 → grz_cli-0.5.0}/src/grz_cli/upload.py +32 -37
  12. {grz_cli-0.4.0 → grz_cli-0.5.0/src/grz_cli.egg-info}/PKG-INFO +5 -3
  13. {grz_cli-0.4.0 → grz_cli-0.5.0}/src/grz_cli.egg-info/SOURCES.txt +1 -0
  14. grz_cli-0.5.0/src/grz_cli.egg-info/entry_points.txt +2 -0
  15. {grz_cli-0.4.0 → grz_cli-0.5.0}/src/grz_cli.egg-info/requires.txt +1 -0
  16. {grz_cli-0.4.0 → grz_cli-0.5.0}/tests/test_cli.py +12 -8
  17. grz_cli-0.4.0/src/grz_cli/__init__.py +0 -1
  18. grz_cli-0.4.0/src/grz_cli.egg-info/entry_points.txt +0 -2
  19. {grz_cli-0.4.0 → grz_cli-0.5.0}/LICENSE +0 -0
  20. {grz_cli-0.4.0 → grz_cli-0.5.0}/MANIFEST.in +0 -0
  21. {grz_cli-0.4.0 → grz_cli-0.5.0}/README.md +0 -0
  22. {grz_cli-0.4.0 → grz_cli-0.5.0}/setup.cfg +0 -0
  23. {grz_cli-0.4.0 → grz_cli-0.5.0}/src/__init__.py +0 -0
  24. {grz_cli-0.4.0 → grz_cli-0.5.0}/src/grz_cli/bam_validation.py +0 -0
  25. {grz_cli-0.4.0 → grz_cli-0.5.0}/src/grz_cli/logging_setup.py +0 -0
  26. {grz_cli-0.4.0 → grz_cli-0.5.0}/src/grz_cli/models/__init__.py +0 -0
  27. {grz_cli-0.4.0 → grz_cli-0.5.0}/src/grz_cli/parser.py +0 -0
  28. {grz_cli-0.4.0 → grz_cli-0.5.0}/src/grz_cli/progress_logging.py +0 -0
  29. {grz_cli-0.4.0 → grz_cli-0.5.0}/src/grz_cli/states.py +0 -0
  30. {grz_cli-0.4.0 → grz_cli-0.5.0}/src/grz_cli.egg-info/dependency_links.txt +0 -0
  31. {grz_cli-0.4.0 → grz_cli-0.5.0}/src/grz_cli.egg-info/top_level.txt +0 -0
  32. {grz_cli-0.4.0 → grz_cli-0.5.0}/tests/test_bam_validation.py +0 -0
  33. {grz_cli-0.4.0 → grz_cli-0.5.0}/tests/test_download.py +0 -0
  34. {grz_cli-0.4.0 → grz_cli-0.5.0}/tests/test_fastq_validation.py +0 -0
  35. {grz_cli-0.4.0 → grz_cli-0.5.0}/tests/test_file_operations.py +0 -0
  36. {grz_cli-0.4.0 → grz_cli-0.5.0}/tests/test_file_progress_logging.py +0 -0
  37. {grz_cli-0.4.0 → grz_cli-0.5.0}/tests/test_parser.py +0 -0
  38. {grz_cli-0.4.0 → grz_cli-0.5.0}/tests/test_read_multiple_json.py +0 -0
  39. {grz_cli-0.4.0 → grz_cli-0.5.0}/tests/test_upload.py +0 -0
@@ -1,16 +1,16 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: grz-cli
3
- Version: 0.4.0
3
+ Version: 0.5.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
- License: MIT
6
+ License-Expression: MIT
7
7
  Project-URL: Homepage, https://codebase.helmholtz.cloud/grz-mv-genomseq/grz-cli
8
8
  Keywords: GRZ,GDC,S3
9
9
  Classifier: Programming Language :: Python :: 3
10
- Classifier: License :: OSI Approved :: MIT License
11
10
  Classifier: Operating System :: OS Independent
12
11
  Requires-Python: >=3.12
13
12
  Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
14
  Requires-Dist: boto3<2,>=1.36
15
15
  Requires-Dist: click<9,>=8.1.7
16
16
  Requires-Dist: crypt4gh<2,>=1.7
@@ -21,6 +21,7 @@ Requires-Dist: pydantic<2.10,>=2.9.2
21
21
  Requires-Dist: platformdirs<5,>=4.3.6
22
22
  Requires-Dist: grz-pydantic-models>=1.2.1
23
23
  Requires-Dist: pysam==0.23.*
24
+ Requires-Dist: rich==13.*
24
25
  Provides-Extra: test
25
26
  Requires-Dist: pytest<9,>=8.3.3; extra == "test"
26
27
  Requires-Dist: pytest-cov<6,>=5.0.0; extra == "test"
@@ -29,6 +30,7 @@ Requires-Dist: moto[s3]<6,>=5.0.16; extra == "test"
29
30
  Requires-Dist: pytest-sugar<2,>=1.0.0; extra == "test"
30
31
  Requires-Dist: ruff<1,>=0.9.6; extra == "test"
31
32
  Requires-Dist: numpy<3,>=2.1.2; extra == "test"
33
+ Dynamic: license-file
32
34
 
33
35
  # GRZ CLI
34
36
 
@@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta"
6
6
  name = "grz-cli"
7
7
  description = "Tool for validation, encryption and upload of MV submissions to GDCs."
8
8
  readme = "README.md"
9
- license = { text = "MIT" }
9
+ license = "MIT"
10
10
  authors = [
11
11
  { name = "Koray Kirli", email = "koraykirli@gmail.com" },
12
12
  { name = "Mathias Lesche", email = "mathias.lesche@tu-dresden.de" },
@@ -25,11 +25,11 @@ dependencies = [
25
25
  "pydantic >=2.9.2,<2.10",
26
26
  "platformdirs >=4.3.6,<5",
27
27
  "grz-pydantic-models >=1.2.1",
28
- "pysam ==0.23.*"
28
+ "pysam ==0.23.*",
29
+ "rich ==13.*"
29
30
  ]
30
31
  classifiers = [
31
32
  "Programming Language :: Python :: 3",
32
- "License :: OSI Approved :: MIT License",
33
33
  "Operating System :: OS Independent"
34
34
  ]
35
35
  requires-python = ">=3.12"
@@ -43,7 +43,7 @@ version = { attr = "grz_cli.__version__" }
43
43
  "Homepage" = "https://codebase.helmholtz.cloud/grz-mv-genomseq/grz-cli"
44
44
 
45
45
  [project.scripts]
46
- grz-cli = "grz_cli.cli:cli"
46
+ grz-cli = "grz_cli.cli:main"
47
47
 
48
48
  [project.optional-dependencies]
49
49
  test = [
@@ -207,6 +207,3 @@ follow_untyped_imports = true
207
207
  addopts = [
208
208
  "--import-mode=importlib",
209
209
  ]
210
-
211
- [tool.setuptools]
212
- license-files = []
@@ -0,0 +1 @@
1
+ __version__ = "0.5.0"
@@ -3,8 +3,10 @@ CLI module for handling command-line interface operations.
3
3
  """
4
4
 
5
5
  import importlib
6
+ import json
6
7
  import logging
7
8
  import logging.config
9
+ import os
8
10
  import sys
9
11
  from os import PathLike, sched_getaffinity
10
12
  from pathlib import Path
@@ -13,9 +15,13 @@ from tempfile import NamedTemporaryFile
13
15
  import click
14
16
  import grz_pydantic_models.submission.metadata
15
17
  import platformdirs
18
+ import rich.console
19
+ import rich.table
16
20
  import yaml
21
+ from pydantic_core import to_jsonable_python
17
22
 
18
23
  from .constants import PACKAGE_ROOT
24
+ from .download import query_submissions
19
25
  from .logging_setup import add_filelogger
20
26
  from .models.config import ConfigModel
21
27
  from .parser import Worker
@@ -94,6 +100,16 @@ output_dir = click.option(
94
100
  help="Path to the target submission output directory",
95
101
  )
96
102
 
103
+ output_json = click.option("--json", "output_json", is_flag=True, help="Output JSON for machine-readability.")
104
+
105
+
106
+ def read_config(config_path: str | PathLike) -> ConfigModel:
107
+ """Reads the configuration file and validates it against the schema."""
108
+ with open(config_path, encoding="utf-8") as f:
109
+ config = ConfigModel(**yaml.safe_load(f))
110
+
111
+ return config
112
+
97
113
 
98
114
  class OrderedGroup(click.Group):
99
115
  """
@@ -105,48 +121,7 @@ class OrderedGroup(click.Group):
105
121
  return list(self.commands.keys())
106
122
 
107
123
 
108
- @click.group(
109
- cls=OrderedGroup,
110
- help="Validate, encrypt, decrypt and upload submissions to a GRZ/GDC.",
111
- )
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
- )
117
- @click.option("--log-file", metavar="FILE", type=str, help="Path to log file")
118
- @click.option(
119
- "--log-level",
120
- default="INFO",
121
- type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]),
122
- help="Set the log level (default: INFO)",
123
- )
124
- def cli(log_file: str | None = None, log_level: str = "INFO"):
125
- """
126
- Command-line interface function for setting up logging.
127
-
128
- :param log_file: Path to the log file. If provided, a file logger will be added.
129
- :param log_level: Log level for the logger. It should be one of the following:
130
- DEBUG, INFO, WARNING, ERROR, CRITICAL.
131
- """
132
- if log_file:
133
- add_filelogger(
134
- log_file,
135
- log_level.upper(),
136
- ) # Add file logger
137
-
138
- # show only time and log level in STDOUT
139
- logging.basicConfig(
140
- format="%(asctime)s - %(levelname)s - %(message)s",
141
- )
142
-
143
- # set the log level for this package
144
- logging.getLogger(PACKAGE_ROOT).setLevel(log_level.upper())
145
-
146
- log.debug("Logging setup complete.")
147
-
148
-
149
- @cli.command()
124
+ @click.command()
150
125
  @submission_dir
151
126
  def validate(submission_dir):
152
127
  """
@@ -170,7 +145,7 @@ def validate(submission_dir):
170
145
  log.info("Validation finished!")
171
146
 
172
147
 
173
- @cli.command()
148
+ @click.command()
174
149
  @submission_dir
175
150
  @config_file
176
151
  def encrypt(
@@ -216,41 +191,7 @@ def encrypt(
216
191
  log.info("Encryption successful!")
217
192
 
218
193
 
219
- @cli.command()
220
- @submission_dir
221
- @config_file
222
- def decrypt(
223
- submission_dir,
224
- config_file,
225
- ):
226
- """
227
- Decrypt a submission.
228
-
229
- Decrypting a submission requires the _private_ key of the original recipient.
230
- """
231
- config = read_config(config_file)
232
-
233
- grz_privkey_path = config.grz_private_key_path
234
- if not grz_privkey_path:
235
- log.error("GRZ private key path is required for decryption.")
236
- sys.exit(1)
237
-
238
- log.info("Starting decryption...")
239
-
240
- submission_dir = Path(submission_dir)
241
-
242
- worker_inst = Worker(
243
- metadata_dir=submission_dir / "metadata",
244
- files_dir=submission_dir / "files",
245
- log_dir=submission_dir / "logs",
246
- encrypted_files_dir=submission_dir / "encrypted_files",
247
- )
248
- worker_inst.decrypt(grz_privkey_path)
249
-
250
- log.info("Encryption successful!")
251
-
252
-
253
- @cli.command()
194
+ @click.command()
254
195
  @submission_dir
255
196
  @config_file
256
197
  @threads
@@ -281,7 +222,28 @@ def upload(
281
222
  log.info("Upload finished!")
282
223
 
283
224
 
284
- @cli.command()
225
+ @click.command("submit")
226
+ @submission_dir
227
+ @config_file
228
+ @threads
229
+ @click.pass_context
230
+ def submit(ctx, submission_dir, config_file, threads):
231
+ """
232
+ Validate, encrypt, and then upload.
233
+
234
+ This is a convenience command that performs the following steps in order:
235
+ 1. Validate the submission
236
+ 2. Encrypt the submission
237
+ 3. Upload the encrypted submission
238
+ """
239
+ click.echo("Starting submission process...")
240
+ ctx.invoke(validate, submission_dir=submission_dir)
241
+ ctx.invoke(encrypt, submission_dir=submission_dir, config_file=config_file)
242
+ ctx.invoke(upload, submission_dir=submission_dir, config_file=config_file, threads=threads)
243
+ click.echo("Submission finished!")
244
+
245
+
246
+ @click.command()
285
247
  @submission_id
286
248
  @output_dir
287
249
  @config_file
@@ -319,13 +281,140 @@ def download(
319
281
  log.info("Download finished!")
320
282
 
321
283
 
322
- def read_config(config_path: str | PathLike) -> ConfigModel:
323
- """Reads the configuration file and validates it against the schema."""
324
- with open(config_path, encoding="utf-8") as f:
325
- config = ConfigModel(**yaml.safe_load(f))
284
+ @click.command()
285
+ @config_file
286
+ @output_json
287
+ def list_submissions(config_file, output_json):
288
+ """
289
+ List submissions within an inbox from oldest to newest.
290
+ """
291
+ config = read_config(config_file)
292
+ submissions = query_submissions(config)
326
293
 
327
- return config
294
+ if output_json:
295
+ json.dump(to_jsonable_python(submissions), sys.stdout)
296
+ else:
297
+ console = rich.console.Console()
298
+ table = rich.table.Table()
299
+ table.add_column("ID", no_wrap=True)
300
+ table.add_column("Status", no_wrap=True)
301
+ table.add_column("Oldest Upload", overflow="fold")
302
+ table.add_column("Newest Upload", overflow="fold")
303
+ for submission in submissions:
304
+ status_text = rich.text.Text(
305
+ "Complete" if submission.complete else "Incomplete",
306
+ style="green" if submission.complete else "yellow",
307
+ )
308
+ table.add_row(
309
+ submission.submission_id,
310
+ status_text,
311
+ submission.oldest_upload.astimezone().strftime("%Y-%m-%d %H:%M:%S"),
312
+ submission.newest_upload.astimezone().strftime("%Y-%m-%d %H:%M:%S"),
313
+ )
314
+ console.print(table)
328
315
 
329
316
 
330
- if __name__ == "__main__":
317
+ @click.command()
318
+ @submission_dir
319
+ @config_file
320
+ def decrypt(
321
+ submission_dir,
322
+ config_file,
323
+ ):
324
+ """
325
+ Decrypt a submission.
326
+
327
+ Decrypting a submission requires the _private_ key of the original recipient.
328
+ """
329
+ config = read_config(config_file)
330
+
331
+ grz_privkey_path = config.grz_private_key_path
332
+ if not grz_privkey_path:
333
+ log.error("GRZ private key path is required for decryption.")
334
+ sys.exit(1)
335
+
336
+ log.info("Starting decryption...")
337
+
338
+ submission_dir = Path(submission_dir)
339
+
340
+ worker_inst = Worker(
341
+ metadata_dir=submission_dir / "metadata",
342
+ files_dir=submission_dir / "files",
343
+ log_dir=submission_dir / "logs",
344
+ encrypted_files_dir=submission_dir / "encrypted_files",
345
+ )
346
+ worker_inst.decrypt(grz_privkey_path)
347
+
348
+ log.info("Encryption successful!")
349
+
350
+
351
+ def build_cli(grz_mode=False):
352
+ """
353
+ Factory for building the CLI application.
354
+ """
355
+
356
+ @click.group(
357
+ cls=OrderedGroup,
358
+ help="Validate, encrypt, decrypt and upload submissions to a GRZ/GDC.",
359
+ )
360
+ @click.version_option(
361
+ version=importlib.metadata.version("grz-cli"),
362
+ prog_name="grz-cli",
363
+ message=f"%(prog)s v%(version)s (metadata schema versions: {', '.join(grz_pydantic_models.submission.metadata.get_supported_versions())})",
364
+ )
365
+ @click.option("--log-file", metavar="FILE", type=str, help="Path to log file")
366
+ @click.option(
367
+ "--log-level",
368
+ default="INFO",
369
+ type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]),
370
+ help="Set the log level (default: INFO)",
371
+ )
372
+ def cli(log_file: str | None = None, log_level: str = "INFO"):
373
+ """
374
+ Command-line interface function for setting up logging.
375
+
376
+ :param log_file: Path to the log file. If provided, a file logger will be added.
377
+ :param log_level: Log level for the logger. It should be one of the following:
378
+ DEBUG, INFO, WARNING, ERROR, CRITICAL.
379
+ """
380
+ if log_file:
381
+ add_filelogger(
382
+ log_file,
383
+ log_level.upper(),
384
+ ) # Add file logger
385
+
386
+ # show only time and log level in STDOUT
387
+ logging.basicConfig(
388
+ format="%(asctime)s - %(levelname)s - %(message)s",
389
+ )
390
+
391
+ # set the log level for this package
392
+ logging.getLogger(PACKAGE_ROOT).setLevel(log_level.upper())
393
+
394
+ log.debug("Logging setup complete.")
395
+
396
+ cli.add_command(validate)
397
+ cli.add_command(encrypt)
398
+ cli.add_command(upload)
399
+ cli.add_command(submit)
400
+
401
+ if grz_mode:
402
+ cli.add_command(list_submissions, name="list")
403
+ cli.add_command(download)
404
+ cli.add_command(decrypt)
405
+
406
+ return cli
407
+
408
+
409
+ def main():
410
+ """
411
+ Main entry point for the CLI application.
412
+ """
413
+ grz_mode = os.environ.get("GRZ_MODE", "false").lower() in {"true", "yes", "1"}
414
+
415
+ cli = build_cli(grz_mode=grz_mode)
331
416
  cli()
417
+
418
+
419
+ if __name__ == "__main__":
420
+ main()
@@ -34,3 +34,5 @@ LOGGING_CONFIG = {
34
34
  },
35
35
  },
36
36
  }
37
+
38
+ TQDM_SMOOTHING: float = 0.0001
@@ -1,24 +1,27 @@
1
- """Module for uploading encrypted submissions to a remote storage"""
1
+ """Module for downloading encrypted submissions to local storage"""
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import datetime
6
+ import itertools
5
7
  import logging
6
8
  import math
7
9
  import re
10
+ from operator import attrgetter, itemgetter
8
11
  from os import PathLike
9
12
  from pathlib import Path
10
13
  from typing import TYPE_CHECKING
11
14
 
12
- import boto3 # type: ignore[import-untyped]
13
15
  import botocore.handlers # type: ignore[import-untyped]
14
- from boto3 import client as boto3_client # type: ignore[import-untyped]
15
16
  from boto3.s3.transfer import S3Transfer, TransferConfig # type: ignore[import-untyped]
16
- from botocore.config import Config as Boto3Config # type: ignore[import-untyped]
17
+ from pydantic import BaseModel
17
18
  from tqdm.auto import tqdm
18
19
 
20
+ from .constants import TQDM_SMOOTHING
19
21
  from .models.config import ConfigModel
20
22
  from .progress_logging import FileProgressLogger
21
23
  from .states import DownloadState
24
+ from .transfer import init_s3_client
22
25
 
23
26
  MULTIPART_THRESHOLD = 8 * 1024 * 1024 # 8MiB, boto3 default
24
27
  MULTIPART_CHUNKSIZE = 8 * 1024 * 1024 # 8MiB, boto3 default
@@ -62,35 +65,7 @@ class S3BotoDownloadWorker:
62
65
  self._config = config
63
66
  self._threads = threads
64
67
 
65
- self._init_s3_client()
66
-
67
- def _init_s3_client(self):
68
- # if user specifies empty strings, this might be an issue
69
- def empty_str_to_none(string: str | None) -> str | None:
70
- if string == "" or string is None:
71
- return None
72
- else:
73
- return string
74
-
75
- # configure proxies if proxy_url is defined
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
- )
81
-
82
- # Initialize S3 client for uploading
83
- self._s3_client: boto3.session.Session.client = boto3_client(
84
- service_name="s3",
85
- region_name=empty_str_to_none(self._config.s3_options.region_name),
86
- api_version=empty_str_to_none(self._config.s3_options.api_version),
87
- use_ssl=self._config.s3_options.use_ssl,
88
- endpoint_url=empty_str_to_none(str(self._config.s3_options.endpoint_url)),
89
- aws_access_key_id=empty_str_to_none(self._config.s3_options.access_key),
90
- aws_secret_access_key=empty_str_to_none(self._config.s3_options.secret),
91
- aws_session_token=empty_str_to_none(self._config.s3_options.session_token),
92
- config=config,
93
- )
68
+ self._s3_client = init_s3_client(config)
94
69
 
95
70
  def prepare_download(
96
71
  self,
@@ -158,7 +133,7 @@ class S3BotoDownloadWorker:
158
133
  )
159
134
 
160
135
  transfer = S3Transfer(self._s3_client, config)
161
- progress_bar = tqdm(total=filesize, unit="B", unit_scale=True, unit_divisor=1024)
136
+ progress_bar = tqdm(total=filesize, unit="B", unit_scale=True, unit_divisor=1024, smoothing=TQDM_SMOOTHING)
162
137
  transfer.download_file(
163
138
  self._config.s3_options.bucket,
164
139
  s3_object_id,
@@ -225,3 +200,42 @@ class S3BotoDownloadWorker:
225
200
  file_key,
226
201
  str(full_path),
227
202
  )
203
+
204
+
205
+ class SubmissionInboxState(BaseModel):
206
+ """A summary of the state of a submission in an inbox"""
207
+
208
+ submission_id: str
209
+ complete: bool
210
+ oldest_upload: datetime.datetime
211
+ newest_upload: datetime.datetime
212
+
213
+
214
+ def query_submissions(config: ConfigModel) -> list[SubmissionInboxState]:
215
+ """Queries the state of all submissions in the configured bucket."""
216
+ s3_client = init_s3_client(config)
217
+ paginator = s3_client.get_paginator("list_objects_v2")
218
+
219
+ objects = itertools.chain.from_iterable(
220
+ page["Contents"] for page in paginator.paginate(Bucket=config.s3_options.bucket)
221
+ )
222
+ objects_sorted = sorted(objects, key=itemgetter("Key"))
223
+ submission2objects = {
224
+ key: tuple(group) for key, group in itertools.groupby(objects_sorted, key=lambda o: o["Key"].split("/")[0])
225
+ }
226
+
227
+ submissions = []
228
+ for submission_id, submission_objects in submission2objects.items():
229
+ submission_objects_sorted = sorted(submission_objects, key=itemgetter("LastModified"))
230
+ oldest_object = submission_objects_sorted[0]
231
+ newest_object = submission_objects_sorted[-1]
232
+ is_complete = len(list(filter(lambda o: o["Key"].endswith("/metadata.json"), submission_objects))) == 1
233
+ submission = SubmissionInboxState(
234
+ submission_id=submission_id,
235
+ complete=is_complete,
236
+ oldest_upload=oldest_object["LastModified"],
237
+ newest_upload=newest_object["LastModified"],
238
+ )
239
+ submissions.append(submission)
240
+
241
+ return sorted(submissions, key=attrgetter("oldest_upload"))
@@ -13,6 +13,7 @@ from typing import TextIO
13
13
 
14
14
  from tqdm.auto import tqdm
15
15
 
16
+ from .constants import TQDM_SMOOTHING
16
17
  from .file_operations import TqdmIOWrapper
17
18
 
18
19
  log = logging.getLogger(__name__)
@@ -51,6 +52,7 @@ def open_fastq(file_path: str | PathLike, progress=True) -> Generator[TextIO, No
51
52
  unit_scale=True,
52
53
  # unit_divisor=1024, # make use of standard units e.g. KB, MB, etc.
53
54
  miniters=1,
55
+ smoothing=TQDM_SMOOTHING,
54
56
  ),
55
57
  )
56
58
  else:
@@ -24,6 +24,8 @@ import crypt4gh.lib # type: ignore[import-untyped]
24
24
  from nacl.public import PrivateKey
25
25
  from tqdm.auto import tqdm
26
26
 
27
+ from .constants import TQDM_SMOOTHING
28
+
27
29
  log = logging.getLogger(__name__)
28
30
 
29
31
 
@@ -69,7 +71,9 @@ def calculate_md5(file_path, chunk_size=2**16, progress=True) -> str:
69
71
  md5_hash = hashlib.md5() # noqa: S324
70
72
  with open(file_path, "rb") as f:
71
73
  if progress and (total_size > chunk_size):
72
- with tqdm(total=total_size, unit="B", unit_scale=True, desc="Calculating MD5") as pbar:
74
+ with tqdm(
75
+ total=total_size, unit="B", unit_scale=True, desc="Calculating MD5", smoothing=TQDM_SMOOTHING
76
+ ) as pbar:
73
77
  while chunk := f.read(chunk_size):
74
78
  md5_hash.update(chunk)
75
79
  pbar.update(len(chunk))
@@ -195,6 +199,7 @@ class Crypt4GH:
195
199
  unit_scale=True,
196
200
  # unit_divisor=1024, # make use of standard units e.g. KB, MB, etc.
197
201
  miniters=1,
202
+ smoothing=TQDM_SMOOTHING,
198
203
  ),
199
204
  ) as pbar_in_fd,
200
205
  ):
@@ -32,14 +32,16 @@ class S3Options(StrictBaseModel):
32
32
  The name of the S3 bucket.
33
33
  """
34
34
 
35
- access_key: str
35
+ access_key: str | None = None
36
36
  """
37
37
  The access key for the S3 bucket.
38
+ If undefined, it is read from the AWS_ACCESS_KEY_ID environment variable.
38
39
  """
39
40
 
40
- secret: str
41
+ secret: str | None = None
41
42
  """
42
43
  The secret key for the S3 bucket.
44
+ If undefined, it is read from the AWS_SECRET_ACCESS_KEY environment variable.
43
45
  """
44
46
 
45
47
  session_token: str | None = None
@@ -85,6 +87,11 @@ class S3Options(StrictBaseModel):
85
87
  Defaults to None.
86
88
  """
87
89
 
90
+ multipart_chunksize: int = 256 * 1024**2
91
+ """
92
+ The size of the chunks to use for multipart uploads in bytes.
93
+ """
94
+
88
95
 
89
96
  FilePath = Annotated[Path, AfterValidator(lambda v: v.expanduser()), PathType("file")]
90
97
 
@@ -0,0 +1,42 @@
1
+ """
2
+ Common methods for transfering data to and from GRZ buckets.
3
+ """
4
+
5
+ import boto3 # type: ignore[import-untyped]
6
+ from boto3 import client as boto3_client # type: ignore[import-untyped]
7
+ from botocore.config import Config as Boto3Config # type: ignore[import-untyped]
8
+
9
+ from .models.config import ConfigModel
10
+
11
+
12
+ def _empty_str_to_none(string: str | None) -> str | None:
13
+ # if user specifies empty strings, this might be an issue
14
+ if string == "" or string is None:
15
+ return None
16
+ else:
17
+ return string
18
+
19
+
20
+ def init_s3_client(config: ConfigModel) -> boto3.session.Session.client:
21
+ """Create a boto3 Client from a grz-cli configuration."""
22
+ # configure proxies if proxy_url is defined
23
+ proxy_url = config.s3_options.proxy_url
24
+ s3_config = Boto3Config(
25
+ proxies={"http": str(proxy_url), "https": str(proxy_url)} if proxy_url is not None else None,
26
+ request_checksum_calculation=config.s3_options.request_checksum_calculation,
27
+ )
28
+
29
+ # Initialize S3 client for uploading
30
+ s3_client: boto3.session.Session.client = boto3_client(
31
+ service_name="s3",
32
+ region_name=_empty_str_to_none(config.s3_options.region_name),
33
+ api_version=_empty_str_to_none(config.s3_options.api_version),
34
+ use_ssl=config.s3_options.use_ssl,
35
+ endpoint_url=_empty_str_to_none(str(config.s3_options.endpoint_url)),
36
+ aws_access_key_id=_empty_str_to_none(config.s3_options.access_key),
37
+ aws_secret_access_key=_empty_str_to_none(config.s3_options.secret),
38
+ aws_session_token=_empty_str_to_none(config.s3_options.session_token),
39
+ config=s3_config,
40
+ )
41
+
42
+ return s3_client
@@ -11,19 +11,17 @@ from os.path import getsize
11
11
  from pathlib import Path
12
12
  from typing import TYPE_CHECKING, override
13
13
 
14
- import boto3 # type: ignore[import-untyped]
15
14
  import botocore.handlers # type: ignore[import-untyped]
16
- from boto3 import client as boto3_client # type: ignore[import-untyped]
17
15
  from boto3.s3.transfer import S3Transfer, TransferConfig # type: ignore[import-untyped]
18
- from botocore.config import Config as Boto3Config # type: ignore[import-untyped]
19
16
  from tqdm.auto import tqdm
20
17
 
18
+ from .constants import TQDM_SMOOTHING
21
19
  from .models.config import ConfigModel
22
20
  from .progress_logging import FileProgressLogger
23
21
  from .states import UploadState
22
+ from .transfer import init_s3_client
24
23
 
25
- MULTIPART_THRESHOLD = 8 * 1024 * 1024 # 8MiB, boto3 default
26
- MULTIPART_CHUNKSIZE = 8 * 1024 * 1024 # 8MiB, boto3 default
24
+ MULTIPART_THRESHOLD = 8 * 1024**2 # 8MiB, boto3 default, largely irrelevant
27
25
  MULTIPART_MAX_CHUNKS = 1000 # CEPH S3 limit, AWS limit is 10000
28
26
 
29
27
  if TYPE_CHECKING:
@@ -87,35 +85,7 @@ class S3BotoUploadWorker(UploadWorker):
87
85
  self._config = config
88
86
  self._threads = threads
89
87
 
90
- self._init_s3_client()
91
-
92
- def _init_s3_client(self):
93
- # if user specifies empty strings, this might be an issue
94
- def empty_str_to_none(string: str | None) -> str | None:
95
- if string == "" or string is None:
96
- return None
97
- else:
98
- return string
99
-
100
- # configure proxies if proxy_url is defined
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
- )
106
-
107
- # Initialize S3 client for uploading
108
- self._s3_client: boto3.session.Session.client = boto3_client(
109
- service_name="s3",
110
- region_name=empty_str_to_none(self._config.s3_options.region_name),
111
- api_version=empty_str_to_none(self._config.s3_options.api_version),
112
- use_ssl=self._config.s3_options.use_ssl,
113
- endpoint_url=empty_str_to_none(str(self._config.s3_options.endpoint_url)),
114
- aws_access_key_id=empty_str_to_none(self._config.s3_options.access_key),
115
- aws_secret_access_key=empty_str_to_none(self._config.s3_options.secret),
116
- aws_session_token=empty_str_to_none(self._config.s3_options.session_token),
117
- config=config,
118
- )
88
+ self._s3_client = init_s3_client(config)
119
89
 
120
90
  @override
121
91
  def upload_file(self, local_file_path, s3_object_id):
@@ -127,11 +97,12 @@ class S3BotoUploadWorker(UploadWorker):
127
97
  self.__log.info(f"Uploading {local_file_path} to {s3_object_id}...")
128
98
 
129
99
  filesize = getsize(local_file_path)
100
+ multipart_chunksize = self._config.s3_options.multipart_chunksize
130
101
 
131
102
  chunksize = (
132
103
  math.ceil(filesize / MULTIPART_MAX_CHUNKS)
133
- if filesize / MULTIPART_CHUNKSIZE > MULTIPART_MAX_CHUNKS
134
- else MULTIPART_CHUNKSIZE
104
+ if filesize / multipart_chunksize > MULTIPART_MAX_CHUNKS
105
+ else multipart_chunksize
135
106
  )
136
107
  self.__log.debug(
137
108
  f"Using a chunksize of: {chunksize / 1024**2}MiB, results in {math.ceil(filesize / chunksize)} chunk(s)"
@@ -141,10 +112,11 @@ class S3BotoUploadWorker(UploadWorker):
141
112
  multipart_threshold=MULTIPART_THRESHOLD,
142
113
  multipart_chunksize=chunksize,
143
114
  max_concurrency=self._threads,
115
+ use_threads=self._threads > 1,
144
116
  )
145
117
 
146
118
  transfer = S3Transfer(self._s3_client, config)
147
- progress_bar = tqdm(total=filesize, unit="B", unit_scale=True, unit_divisor=1024)
119
+ progress_bar = tqdm(total=filesize, unit="B", unit_scale=True, unit_divisor=1024, smoothing=TQDM_SMOOTHING)
148
120
  transfer.upload_file(
149
121
  local_file_path,
150
122
  self._config.s3_options.bucket,
@@ -152,6 +124,25 @@ class S3BotoUploadWorker(UploadWorker):
152
124
  callback=lambda bytes_transferred: progress_bar.update(bytes_transferred),
153
125
  )
154
126
 
127
+ def _remote_id_exists(self, s3_object_id: str) -> bool:
128
+ """
129
+ Determine if a remote ID already exists
130
+ :param s3_object_id: Remote S3 object ID under which the file should be stored
131
+ """
132
+ exists = True
133
+ try:
134
+ self._s3_client.head_object(Bucket=self._config.s3_options.bucket, Key=s3_object_id)
135
+ except self._s3_client.exceptions.NoSuchKey:
136
+ exists = False
137
+ except botocore.exceptions.ClientError as error:
138
+ if error.response["Error"]["Code"] in {"403", "404"}:
139
+ # backend can return forbidden instead if user has no ListBucket permission
140
+ exists = False
141
+ else:
142
+ raise error
143
+
144
+ return exists
145
+
155
146
  @override
156
147
  def upload(self, encrypted_submission: EncryptedSubmission):
157
148
  """
@@ -160,6 +151,10 @@ class S3BotoUploadWorker(UploadWorker):
160
151
  """
161
152
  progress_logger = FileProgressLogger[UploadState](self._status_file_path)
162
153
  metadata_file_path, metadata_s3_object_id = encrypted_submission.get_metadata_file_path_and_object_id()
154
+
155
+ if self._remote_id_exists(metadata_s3_object_id):
156
+ raise UploadError("Submission already uploaded. Corrections, additions, and followups require a new tanG.")
157
+
163
158
  files_to_upload = encrypted_submission.get_encrypted_files_and_object_id()
164
159
  files_to_upload[metadata_file_path] = metadata_s3_object_id
165
160
 
@@ -1,16 +1,16 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: grz-cli
3
- Version: 0.4.0
3
+ Version: 0.5.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
- License: MIT
6
+ License-Expression: MIT
7
7
  Project-URL: Homepage, https://codebase.helmholtz.cloud/grz-mv-genomseq/grz-cli
8
8
  Keywords: GRZ,GDC,S3
9
9
  Classifier: Programming Language :: Python :: 3
10
- Classifier: License :: OSI Approved :: MIT License
11
10
  Classifier: Operating System :: OS Independent
12
11
  Requires-Python: >=3.12
13
12
  Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
14
  Requires-Dist: boto3<2,>=1.36
15
15
  Requires-Dist: click<9,>=8.1.7
16
16
  Requires-Dist: crypt4gh<2,>=1.7
@@ -21,6 +21,7 @@ Requires-Dist: pydantic<2.10,>=2.9.2
21
21
  Requires-Dist: platformdirs<5,>=4.3.6
22
22
  Requires-Dist: grz-pydantic-models>=1.2.1
23
23
  Requires-Dist: pysam==0.23.*
24
+ Requires-Dist: rich==13.*
24
25
  Provides-Extra: test
25
26
  Requires-Dist: pytest<9,>=8.3.3; extra == "test"
26
27
  Requires-Dist: pytest-cov<6,>=5.0.0; extra == "test"
@@ -29,6 +30,7 @@ Requires-Dist: moto[s3]<6,>=5.0.16; extra == "test"
29
30
  Requires-Dist: pytest-sugar<2,>=1.0.0; extra == "test"
30
31
  Requires-Dist: ruff<1,>=0.9.6; extra == "test"
31
32
  Requires-Dist: numpy<3,>=2.1.2; extra == "test"
33
+ Dynamic: license-file
32
34
 
33
35
  # GRZ CLI
34
36
 
@@ -14,6 +14,7 @@ src/grz_cli/logging_setup.py
14
14
  src/grz_cli/parser.py
15
15
  src/grz_cli/progress_logging.py
16
16
  src/grz_cli/states.py
17
+ src/grz_cli/transfer.py
17
18
  src/grz_cli/upload.py
18
19
  src/grz_cli.egg-info/PKG-INFO
19
20
  src/grz_cli.egg-info/SOURCES.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ grz-cli = grz_cli.cli:main
@@ -8,6 +8,7 @@ pydantic<2.10,>=2.9.2
8
8
  platformdirs<5,>=4.3.6
9
9
  grz-pydantic-models>=1.2.1
10
10
  pysam==0.23.*
11
+ rich==13.*
11
12
 
12
13
  [test]
13
14
  pytest<9,>=8.3.3
@@ -42,10 +42,11 @@ def test_validate_submission(
42
42
  ]
43
43
 
44
44
  runner = CliRunner()
45
- result = runner.invoke(grz_cli.cli.cli, testargs, catch_exceptions=False)
45
+ cli = grz_cli.cli.build_cli()
46
+ result = runner.invoke(cli, testargs, catch_exceptions=False)
46
47
 
47
48
  # check if re-validation is skipped
48
- result = runner.invoke(grz_cli.cli.cli, testargs, catch_exceptions=False)
49
+ result = runner.invoke(cli, testargs, catch_exceptions=False)
49
50
 
50
51
  # test if command has correctly checked for:
51
52
  # - mismatched md5sums
@@ -75,7 +76,8 @@ def test_encrypt_decrypt_submission(
75
76
  ]
76
77
 
77
78
  runner = CliRunner()
78
- result = runner.invoke(grz_cli.cli.cli, testargs, catch_exceptions=False)
79
+ cli = grz_cli.cli.build_cli(grz_mode=True)
80
+ result = runner.invoke(cli, testargs, catch_exceptions=False)
79
81
 
80
82
  assert result.exit_code == 0, result.output
81
83
 
@@ -89,7 +91,7 @@ def test_encrypt_decrypt_submission(
89
91
  ]
90
92
 
91
93
  runner = CliRunner()
92
- result = runner.invoke(grz_cli.cli.cli, testargs, catch_exceptions=False)
94
+ result = runner.invoke(cli, testargs, catch_exceptions=False)
93
95
 
94
96
  assert result.exit_code == 0, result.output
95
97
 
@@ -126,7 +128,8 @@ def test_decrypt_submission(working_dir_path, temp_config_file_path):
126
128
  temp_config_file_path,
127
129
  ]
128
130
  runner = CliRunner()
129
- result = runner.invoke(grz_cli.cli.cli, testargs, catch_exceptions=False)
131
+ cli = grz_cli.cli.build_cli(grz_mode=True)
132
+ result = runner.invoke(cli, testargs, catch_exceptions=False)
130
133
 
131
134
  assert result.exit_code == 0, result.output
132
135
 
@@ -204,7 +207,8 @@ def test_upload_download_submission(
204
207
  "grz_cli.models.config.S3Options.__getattr__",
205
208
  lambda self, name: None if name == "endpoint_url" else AttributeError,
206
209
  ):
207
- result = runner.invoke(grz_cli.cli.cli, testargs, catch_exceptions=False)
210
+ cli = grz_cli.cli.build_cli(grz_mode=True)
211
+ result = runner.invoke(cli, testargs, catch_exceptions=False)
208
212
 
209
213
  assert result.exit_code == 0, result.output
210
214
  assert len(result.output) != 0, result.stderr
@@ -226,8 +230,8 @@ def test_upload_download_submission(
226
230
  temp_config_file_path,
227
231
  ]
228
232
  runner = CliRunner()
229
-
230
- result = runner.invoke(grz_cli.cli.cli, testargs, catch_exceptions=False)
233
+ cli = grz_cli.cli.build_cli(grz_mode=True)
234
+ result = runner.invoke(cli, testargs, catch_exceptions=False)
231
235
 
232
236
  assert result.exit_code == 0, result.output
233
237
 
@@ -1 +0,0 @@
1
- __version__ = "0.4.0"
@@ -1,2 +0,0 @@
1
- [console_scripts]
2
- grz-cli = grz_cli.cli:cli
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes