grz-cli 0.3.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 (41) hide show
  1. grz_cli-0.5.0/MANIFEST.in +2 -0
  2. {grz_cli-0.3.0/src/grz_cli.egg-info → grz_cli-0.5.0}/PKG-INFO +10 -5
  3. {grz_cli-0.3.0 → grz_cli-0.5.0}/README.md +2 -0
  4. {grz_cli-0.3.0 → grz_cli-0.5.0}/pyproject.toml +6 -8
  5. grz_cli-0.5.0/src/grz_cli/__init__.py +1 -0
  6. grz_cli-0.5.0/src/grz_cli/bam_validation.py +39 -0
  7. {grz_cli-0.3.0 → grz_cli-0.5.0}/src/grz_cli/cli.py +178 -88
  8. {grz_cli-0.3.0 → grz_cli-0.5.0}/src/grz_cli/constants.py +2 -1
  9. {grz_cli-0.3.0 → grz_cli-0.5.0}/src/grz_cli/download.py +48 -31
  10. {grz_cli-0.3.0 → grz_cli-0.5.0}/src/grz_cli/fastq_validation.py +43 -24
  11. {grz_cli-0.3.0 → grz_cli-0.5.0}/src/grz_cli/file_operations.py +6 -1
  12. {grz_cli-0.3.0 → grz_cli-0.5.0}/src/grz_cli/models/config.py +27 -2
  13. {grz_cli-0.3.0 → grz_cli-0.5.0}/src/grz_cli/parser.py +61 -11
  14. {grz_cli-0.3.0 → grz_cli-0.5.0}/src/grz_cli/progress_logging.py +3 -2
  15. grz_cli-0.5.0/src/grz_cli/transfer.py +42 -0
  16. {grz_cli-0.3.0 → grz_cli-0.5.0}/src/grz_cli/upload.py +35 -35
  17. {grz_cli-0.3.0 → grz_cli-0.5.0/src/grz_cli.egg-info}/PKG-INFO +10 -5
  18. {grz_cli-0.3.0 → grz_cli-0.5.0}/src/grz_cli.egg-info/SOURCES.txt +3 -1
  19. grz_cli-0.5.0/src/grz_cli.egg-info/entry_points.txt +2 -0
  20. {grz_cli-0.3.0 → grz_cli-0.5.0}/src/grz_cli.egg-info/requires.txt +4 -2
  21. grz_cli-0.5.0/tests/test_bam_validation.py +15 -0
  22. {grz_cli-0.3.0 → grz_cli-0.5.0}/tests/test_cli.py +17 -11
  23. {grz_cli-0.3.0 → grz_cli-0.5.0}/tests/test_fastq_validation.py +1 -1
  24. {grz_cli-0.3.0 → grz_cli-0.5.0}/tests/test_file_progress_logging.py +1 -1
  25. {grz_cli-0.3.0 → grz_cli-0.5.0}/tests/test_upload.py +12 -11
  26. grz_cli-0.3.0/MANIFEST.in +0 -3
  27. grz_cli-0.3.0/src/grz_cli/__init__.py +0 -1
  28. grz_cli-0.3.0/src/grz_cli/resources/thresholds.json +0 -299
  29. grz_cli-0.3.0/src/grz_cli.egg-info/entry_points.txt +0 -2
  30. {grz_cli-0.3.0 → grz_cli-0.5.0}/LICENSE +0 -0
  31. {grz_cli-0.3.0 → grz_cli-0.5.0}/setup.cfg +0 -0
  32. {grz_cli-0.3.0 → grz_cli-0.5.0}/src/__init__.py +0 -0
  33. {grz_cli-0.3.0 → grz_cli-0.5.0}/src/grz_cli/logging_setup.py +0 -0
  34. {grz_cli-0.3.0 → grz_cli-0.5.0}/src/grz_cli/models/__init__.py +0 -0
  35. {grz_cli-0.3.0 → grz_cli-0.5.0}/src/grz_cli/states.py +0 -0
  36. {grz_cli-0.3.0 → grz_cli-0.5.0}/src/grz_cli.egg-info/dependency_links.txt +0 -0
  37. {grz_cli-0.3.0 → grz_cli-0.5.0}/src/grz_cli.egg-info/top_level.txt +0 -0
  38. {grz_cli-0.3.0 → grz_cli-0.5.0}/tests/test_download.py +0 -0
  39. {grz_cli-0.3.0 → grz_cli-0.5.0}/tests/test_file_operations.py +0 -0
  40. {grz_cli-0.3.0 → grz_cli-0.5.0}/tests/test_parser.py +0 -0
  41. {grz_cli-0.3.0 → grz_cli-0.5.0}/tests/test_read_multiple_json.py +0 -0
@@ -0,0 +1,2 @@
1
+ graft src
2
+ include LICENSE
@@ -1,17 +1,17 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: grz-cli
3
- Version: 0.3.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
14
- Requires-Dist: boto3<2,>=1.35.39
13
+ License-File: LICENSE
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,7 +19,9 @@ 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.0
22
+ Requires-Dist: grz-pydantic-models>=1.2.1
23
+ Requires-Dist: pysam==0.23.*
24
+ Requires-Dist: rich==13.*
23
25
  Provides-Extra: test
24
26
  Requires-Dist: pytest<9,>=8.3.3; extra == "test"
25
27
  Requires-Dist: pytest-cov<6,>=5.0.0; extra == "test"
@@ -28,6 +30,7 @@ Requires-Dist: moto[s3]<6,>=5.0.16; extra == "test"
28
30
  Requires-Dist: pytest-sugar<2,>=1.0.0; extra == "test"
29
31
  Requires-Dist: ruff<1,>=0.9.6; extra == "test"
30
32
  Requires-Dist: numpy<3,>=2.1.2; extra == "test"
33
+ Dynamic: license-file
31
34
 
32
35
  # GRZ CLI
33
36
 
@@ -254,6 +257,8 @@ Option is for the usage at a GDC/GRZ.
254
257
 
255
258
  ## Testing
256
259
 
260
+ 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.
261
+
257
262
  To run the tests, navigate to the root directory of your project and invoke `pytest`.
258
263
  Alternatively, install `uv` and `tox` and run `uv run tox`.
259
264
 
@@ -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
 
@@ -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" },
@@ -16,7 +16,7 @@ authors = [
16
16
  ]
17
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,11 +24,12 @@ 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.0"
27
+ "grz-pydantic-models >=1.2.1",
28
+ "pysam ==0.23.*",
29
+ "rich ==13.*"
28
30
  ]
29
31
  classifiers = [
30
32
  "Programming Language :: Python :: 3",
31
- "License :: OSI Approved :: MIT License",
32
33
  "Operating System :: OS Independent"
33
34
  ]
34
35
  requires-python = ">=3.12"
@@ -42,7 +43,7 @@ version = { attr = "grz_cli.__version__" }
42
43
  "Homepage" = "https://codebase.helmholtz.cloud/grz-mv-genomseq/grz-cli"
43
44
 
44
45
  [project.scripts]
45
- grz-cli = "grz_cli.cli:cli"
46
+ grz-cli = "grz_cli.cli:main"
46
47
 
47
48
  [project.optional-dependencies]
48
49
  test = [
@@ -206,6 +207,3 @@ follow_untyped_imports = true
206
207
  addopts = [
207
208
  "--import-mode=importlib",
208
209
  ]
209
-
210
- [tool.setuptools]
211
- license-files = []
@@ -0,0 +1 @@
1
+ __version__ = "0.5.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
@@ -3,19 +3,25 @@ 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
11
13
  from tempfile import NamedTemporaryFile
12
14
 
13
15
  import click
14
- import grz_pydantic_models
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
@@ -82,7 +88,7 @@ submission_id = click.option(
82
88
  required=True,
83
89
  type=str,
84
90
  metavar="STRING",
85
- help="S3 submission prefix (corresponds to the tanG of a submission)",
91
+ help="S3 submission ID",
86
92
  )
87
93
 
88
94
  output_dir = click.option(
@@ -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.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
@@ -275,12 +216,34 @@ def upload(
275
216
  encrypted_files_dir=submission_dir / "encrypted_files",
276
217
  threads=threads,
277
218
  )
278
- worker_inst.upload(config)
219
+ # output the generated submission ID
220
+ print(worker_inst.upload(config))
279
221
 
280
222
  log.info("Upload finished!")
281
223
 
282
224
 
283
- @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()
284
247
  @submission_id
285
248
  @output_dir
286
249
  @config_file
@@ -318,13 +281,140 @@ def download(
318
281
  log.info("Download finished!")
319
282
 
320
283
 
321
- def read_config(config_path: str | PathLike) -> ConfigModel:
322
- """Reads the configuration file and validates it against the schema."""
323
- with open(config_path, encoding="utf-8") as f:
324
- 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)
325
293
 
326
- 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)
327
315
 
328
316
 
329
- 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)
330
416
  cli()
417
+
418
+
419
+ if __name__ == "__main__":
420
+ main()
@@ -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": {
@@ -35,3 +34,5 @@ LOGGING_CONFIG = {
35
34
  },
36
35
  },
37
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,32 +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 = 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
78
-
79
- # Initialize S3 client for uploading
80
- self._s3_client: boto3.session.Session.client = boto3_client(
81
- service_name="s3",
82
- region_name=empty_str_to_none(self._config.s3_options.region_name),
83
- api_version=empty_str_to_none(self._config.s3_options.api_version),
84
- use_ssl=self._config.s3_options.use_ssl,
85
- endpoint_url=empty_str_to_none(str(self._config.s3_options.endpoint_url)),
86
- aws_access_key_id=empty_str_to_none(self._config.s3_options.access_key),
87
- aws_secret_access_key=empty_str_to_none(self._config.s3_options.secret),
88
- aws_session_token=empty_str_to_none(self._config.s3_options.session_token),
89
- config=config,
90
- )
68
+ self._s3_client = init_s3_client(config)
91
69
 
92
70
  def prepare_download(
93
71
  self,
@@ -155,7 +133,7 @@ class S3BotoDownloadWorker:
155
133
  )
156
134
 
157
135
  transfer = S3Transfer(self._s3_client, config)
158
- 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)
159
137
  transfer.download_file(
160
138
  self._config.s3_options.bucket,
161
139
  s3_object_id,
@@ -222,3 +200,42 @@ class S3BotoDownloadWorker:
222
200
  file_key,
223
201
  str(full_path),
224
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"))