divbase-cli 0.1.0a3__tar.gz → 0.1.0a5__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 (30) hide show
  1. {divbase_cli-0.1.0a3 → divbase_cli-0.1.0a5}/.gitignore +7 -0
  2. {divbase_cli-0.1.0a3 → divbase_cli-0.1.0a5}/PKG-INFO +2 -2
  3. {divbase_cli-0.1.0a3 → divbase_cli-0.1.0a5}/pyproject.toml +1 -1
  4. divbase_cli-0.1.0a5/src/divbase_cli/__init__.py +1 -0
  5. divbase_cli-0.1.0a5/src/divbase_cli/cli_commands/auth_cli.py +184 -0
  6. {divbase_cli-0.1.0a3 → divbase_cli-0.1.0a5}/src/divbase_cli/cli_commands/file_cli.py +141 -33
  7. {divbase_cli-0.1.0a3 → divbase_cli-0.1.0a5}/src/divbase_cli/cli_commands/query_cli.py +123 -7
  8. {divbase_cli-0.1.0a3 → divbase_cli-0.1.0a5}/src/divbase_cli/cli_commands/shared_args_options.py +10 -0
  9. {divbase_cli-0.1.0a3 → divbase_cli-0.1.0a5}/src/divbase_cli/cli_config.py +9 -6
  10. {divbase_cli-0.1.0a3 → divbase_cli-0.1.0a5}/src/divbase_cli/cli_exceptions.py +6 -49
  11. {divbase_cli-0.1.0a3 → divbase_cli-0.1.0a5}/src/divbase_cli/config_resolver.py +3 -3
  12. {divbase_cli-0.1.0a3 → divbase_cli-0.1.0a5}/src/divbase_cli/retries.py +11 -1
  13. {divbase_cli-0.1.0a3 → divbase_cli-0.1.0a5}/src/divbase_cli/services/pre_signed_urls.py +17 -4
  14. {divbase_cli-0.1.0a3 → divbase_cli-0.1.0a5}/src/divbase_cli/services/s3_files.py +111 -39
  15. {divbase_cli-0.1.0a3 → divbase_cli-0.1.0a5}/src/divbase_cli/user_auth.py +176 -31
  16. divbase_cli-0.1.0a3/src/divbase_cli/__init__.py +0 -1
  17. divbase_cli-0.1.0a3/src/divbase_cli/cli_commands/auth_cli.py +0 -97
  18. {divbase_cli-0.1.0a3 → divbase_cli-0.1.0a5}/README.md +0 -0
  19. {divbase_cli-0.1.0a3 → divbase_cli-0.1.0a5}/src/divbase_cli/cli_commands/__init__.py +0 -0
  20. {divbase_cli-0.1.0a3 → divbase_cli-0.1.0a5}/src/divbase_cli/cli_commands/dimensions_cli.py +0 -0
  21. {divbase_cli-0.1.0a3 → divbase_cli-0.1.0a5}/src/divbase_cli/cli_commands/task_history_cli.py +0 -0
  22. {divbase_cli-0.1.0a3 → divbase_cli-0.1.0a5}/src/divbase_cli/cli_commands/user_config_cli.py +0 -0
  23. {divbase_cli-0.1.0a3 → divbase_cli-0.1.0a5}/src/divbase_cli/cli_commands/version_cli.py +0 -0
  24. {divbase_cli-0.1.0a3 → divbase_cli-0.1.0a5}/src/divbase_cli/display_task_history.py +0 -0
  25. {divbase_cli-0.1.0a3 → divbase_cli-0.1.0a5}/src/divbase_cli/divbase_cli.py +0 -0
  26. {divbase_cli-0.1.0a3 → divbase_cli-0.1.0a5}/src/divbase_cli/services/__init__.py +0 -0
  27. {divbase_cli-0.1.0a3 → divbase_cli-0.1.0a5}/src/divbase_cli/services/announcements.py +0 -0
  28. {divbase_cli-0.1.0a3 → divbase_cli-0.1.0a5}/src/divbase_cli/services/project_versions.py +0 -0
  29. {divbase_cli-0.1.0a3 → divbase_cli-0.1.0a5}/src/divbase_cli/user_config.py +0 -0
  30. {divbase_cli-0.1.0a3 → divbase_cli-0.1.0a5}/src/divbase_cli/utils.py +0 -0
@@ -22,6 +22,9 @@ __pycache__/
22
22
  tests/fixtures/temp*
23
23
  tests/fixtures/merged*
24
24
  divbase_metadata_template*.tsv
25
+ # query results files
26
+ result_of_job_*.vcf.gz
27
+ result_of_job_*.log
25
28
 
26
29
  # query job config files
27
30
  bcftools_divbase_job_config.json
@@ -40,6 +43,10 @@ scripts/benchmarking/results
40
43
  htmlcov/
41
44
  docker/coverage-data/
42
45
 
46
+ # log files written by docker compose, if env var set to do so (by default no)
47
+ docker/logs/*
48
+ !docker/logs/.gitkeep
49
+
43
50
  #MacOS artifacts
44
51
  .DS_Store
45
52
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: divbase-cli
3
- Version: 0.1.0a3
3
+ Version: 0.1.0a5
4
4
  Summary: Command Line Interface for Divbase
5
5
  Project-URL: Homepage, https://divbase.scilifelab-2-prod.sys.kth.se
6
6
  Project-URL: Documentation, https://scilifelabdatacentre.github.io/divbase
@@ -18,7 +18,7 @@ Classifier: Programming Language :: Python :: 3.12
18
18
  Classifier: Programming Language :: Python :: 3.13
19
19
  Classifier: Programming Language :: Python :: 3.14
20
20
  Requires-Python: >=3.12
21
- Requires-Dist: divbase-lib==0.1.0a3
21
+ Requires-Dist: divbase-lib==0.1.0a5
22
22
  Requires-Dist: keyring>=25.7.0
23
23
  Requires-Dist: typer==0.24.1
24
24
  Description-Content-Type: text/markdown
@@ -10,7 +10,7 @@ requires-python = ">=3.12"
10
10
  dependencies = [
11
11
  "typer==0.24.1",
12
12
  "keyring>=25.7.0",
13
- "divbase-lib==0.1.0a3",
13
+ "divbase-lib==0.1.0a5",
14
14
  ]
15
15
  dynamic = ["version"]
16
16
 
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0a5"
@@ -0,0 +1,184 @@
1
+ """
2
+ CLI subcommand for managing user auth with DivBase server.
3
+ """
4
+
5
+ import logging
6
+ import time
7
+ from datetime import datetime
8
+
9
+ import typer
10
+ from pydantic import SecretStr
11
+ from rich import print
12
+
13
+ from divbase_cli.cli_config import cli_settings
14
+ from divbase_cli.cli_exceptions import DivBaseAPIConnectionError, DivBaseAPIError
15
+ from divbase_cli.config_resolver import resolve_url_for_non_project_specific_commands
16
+ from divbase_cli.services.announcements import get_and_display_announcements
17
+ from divbase_cli.user_auth import (
18
+ PERSONAL_ACCESS_TOKEN_EXPIRED_MESSAGE,
19
+ PATData,
20
+ check_existing_session,
21
+ delete_stored_pat,
22
+ load_stored_user_pat,
23
+ login_to_divbase,
24
+ logout_of_divbase,
25
+ make_authenticated_request,
26
+ )
27
+ from divbase_cli.user_config import load_user_config
28
+ from divbase_lib.divbase_constants import PAT_TOKEN_PREFIX
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+ divbase_home_url = cli_settings.DIVBASE_API_URL.replace("/api", "")
33
+
34
+ auth_app = typer.Typer(
35
+ no_args_is_help=True,
36
+ help=f"Login/logout of DivBase server. To register, visit {divbase_home_url}.",
37
+ )
38
+
39
+
40
+ @auth_app.command("login")
41
+ def login(
42
+ email: str,
43
+ divbase_url: str = typer.Option(cli_settings.DIVBASE_API_URL, help="DivBase server URL to connect to."),
44
+ force: bool = typer.Option(False, "--force", "-f", help="Force login again even if already logged in"),
45
+ ):
46
+ """
47
+ Log in to the DivBase server.
48
+
49
+ You'll be prompted for your password after running the command.
50
+ """
51
+ password: str = typer.prompt("please enter your password", hide_input=True, confirmation_prompt=False)
52
+ secret_password = SecretStr(password)
53
+ del password # avoid user passwords showing up in error messages etc...
54
+
55
+ config = load_user_config()
56
+
57
+ if not force:
58
+ session_expires_at = check_existing_session(divbase_url=divbase_url, config=config)
59
+ if session_expires_at:
60
+ print(f"Already logged in to {divbase_url} with email: {config.logged_in_email}.")
61
+ print(f"Session expires: {datetime.fromtimestamp(session_expires_at)}")
62
+
63
+ if not typer.confirm("Do you want to login again? This will replace your current session."):
64
+ print("Login cancelled.")
65
+ return
66
+
67
+ login_to_divbase(email=email, password=secret_password, divbase_url=divbase_url)
68
+
69
+ try:
70
+ get_and_display_announcements(divbase_base_url=divbase_url)
71
+ except (DivBaseAPIError, DivBaseAPIConnectionError):
72
+ # lets not fail the login process if announcements are not working.
73
+ logger.info("Failed to get announcements after login. Error was: ", exc_info=True)
74
+
75
+ print(f"Logged in successfully as: {email}")
76
+
77
+
78
+ @auth_app.command("logout")
79
+ def logout():
80
+ """
81
+ Log out of the DivBase server.
82
+
83
+ Removes your locally stored credentials and invalidates the server-side refresh token.
84
+ """
85
+ logout_of_divbase()
86
+ print("Logged out successfully.")
87
+
88
+
89
+ @auth_app.command("add-pat")
90
+ def add_pat(
91
+ name: str = typer.Argument(..., help="Name of the personal access token (PAT). E.g. 'work-laptop-pat'"),
92
+ expires_unix_timestamp: int | None = typer.Option(
93
+ None,
94
+ "--expires",
95
+ "-e",
96
+ help="When the personal access token (PAT) expires (if it does), as a unix timestamp.",
97
+ min=1,
98
+ ),
99
+ overwrite_existing: bool = typer.Option(
100
+ False, "--overwrite-existing", "-o", help="Overwrite the existing stored PAT if one already exists."
101
+ ),
102
+ ):
103
+ """
104
+ Add a personal access token (PAT) to your device for authentication with DivBase.
105
+
106
+ PATs are used for authenticating with the DivBase API instead of using your password, and are recommended for use in scripts and pipelines.
107
+ See https://scilifelabdatacentre.github.io/divbase/user-guides/using-divbase-programmatically/ for more details on how to create a personal access token.
108
+ You can only store one personal access token at a time.
109
+ """
110
+ existing = load_stored_user_pat()
111
+ if existing and not overwrite_existing:
112
+ print(
113
+ f"A personal access token named '{existing.name}' is already stored on your device \n"
114
+ f"It is due to expire on: {existing.pat_expiry_formatted()} \n"
115
+ "Append the flag --overwrite-existing (-o) to this command if you want to replace it. \n"
116
+ "You can only store one PAT at a time."
117
+ )
118
+ raise typer.Exit(code=1)
119
+
120
+ if expires_unix_timestamp and expires_unix_timestamp < time.time():
121
+ print("The expiry time you entered is in the past. Please enter a valid expiry time for the PAT.")
122
+ raise typer.Exit(code=1)
123
+
124
+ pat = SecretStr(
125
+ typer.prompt("please paste your personal access token now", hide_input=True, confirmation_prompt=False)
126
+ )
127
+ if not pat.get_secret_value().startswith(PAT_TOKEN_PREFIX):
128
+ print(
129
+ "It looks like the token you entered is not a valid personal access token. "
130
+ f"Please make sure you copied the entire token, including the '{PAT_TOKEN_PREFIX}' prefix."
131
+ )
132
+ raise typer.Exit(code=1)
133
+
134
+ pat_data = PATData(name=name, pat=pat, pat_expires_at=expires_unix_timestamp)
135
+ pat_data.dump_pat_data()
136
+ print(f"Personal access token '{name}' stored successfully.")
137
+
138
+
139
+ @auth_app.command("rm-pat")
140
+ def rm_pat():
141
+ """
142
+ Remove the stored personal access token from your device.
143
+
144
+ If no such token exists the command will still succeed.
145
+ """
146
+ delete_stored_pat()
147
+ print("If a personal access token was stored, it has now been removed.")
148
+
149
+
150
+ @auth_app.command("pat-info")
151
+ def pat_info():
152
+ """
153
+ Display information about the personal access token stored on this device, if any.
154
+
155
+ The token will not be displayed for security reasons, but the name and expiry time will be shown.
156
+ """
157
+ pat_data = load_stored_user_pat()
158
+ if not pat_data:
159
+ print("No personal access token stored on this device.")
160
+ return
161
+
162
+ print(f"Name: {pat_data.name}")
163
+ print(f"Expires: {pat_data.pat_expiry_formatted()}")
164
+
165
+ if pat_data.is_pat_expired():
166
+ print("[red bold]Warning [/red bold]")
167
+ print(PERSONAL_ACCESS_TOKEN_EXPIRED_MESSAGE)
168
+
169
+
170
+ @auth_app.command("whoami")
171
+ def whoami():
172
+ """
173
+ Return information about the currently logged-in user.
174
+ """
175
+ divbase_url = resolve_url_for_non_project_specific_commands()
176
+
177
+ request = make_authenticated_request(
178
+ method="GET",
179
+ divbase_base_url=divbase_url,
180
+ api_route="v1/auth/whoami",
181
+ )
182
+
183
+ current_user = request.json()
184
+ print(f"Currently logged in as: {current_user['email']} (Name: {current_user['name']})")
@@ -2,6 +2,7 @@
2
2
  Command line interface for managing files in a DivBase project's store on DivBase.
3
3
  """
4
4
 
5
+ from glob import glob
5
6
  from pathlib import Path
6
7
  from zoneinfo import ZoneInfo
7
8
 
@@ -10,8 +11,7 @@ from rich import print
10
11
  from rich.table import Table
11
12
  from typing_extensions import Annotated
12
13
 
13
- from divbase_cli.cli_commands.shared_args_options import FORMAT_AS_TSV_OPTION, PROJECT_NAME_OPTION
14
- from divbase_cli.cli_exceptions import UnsupportedFileNameError, UnsupportedFileTypeError
14
+ from divbase_cli.cli_commands.shared_args_options import DOWNLOAD_DIR_OPTION, FORMAT_AS_TSV_OPTION, PROJECT_NAME_OPTION
15
15
  from divbase_cli.config_resolver import ensure_logged_in, resolve_download_dir, resolve_project
16
16
  from divbase_cli.services.project_versions import get_version_details_command
17
17
  from divbase_cli.services.s3_files import (
@@ -33,16 +33,8 @@ from divbase_lib.utils import format_file_size
33
33
  file_app = typer.Typer(no_args_is_help=True, help="Download/upload/list files to/from the project's store on DivBase.")
34
34
 
35
35
  NO_FILES_SPECIFIED_MSG = "No files specified for the command, exiting..."
36
+ NO_UPLOAD_MATCHES_MSG = "Error: The following file paths or glob patterns did not match any existing files:"
36
37
 
37
- DOWNLOAD_DIR_OPTION = typer.Option(
38
- None,
39
- "--download-dir",
40
- "-d",
41
- help="""Directory to download the files to.
42
- If not provided, defaults to what you specified in your user config.
43
- If also not specified in your user config, downloads to the current directory.
44
- You can also specify "." to download to the current directory.""",
45
- )
46
38
  DISABLE_VERIFY_CHECKSUMS_OPTION = typer.Option(
47
39
  False,
48
40
  "--disable-verify-checksums",
@@ -412,9 +404,26 @@ def stream_file(
412
404
 
413
405
  @file_app.command("upload")
414
406
  def upload_files(
415
- files: list[Path] | None = typer.Argument(None, help="Space separated list of files to upload."),
416
- upload_dir: Path | None = typer.Option(None, "--upload-dir", help="Directory to upload all files from."),
417
- file_list: Path | None = typer.Option(None, "--file-list", help="Text file with list of files to upload."),
407
+ files: list[str] | None = typer.Argument(None, help="Space separated list of files or glob patterns to upload."),
408
+ file_list: Path | None = typer.Option(None, "--file-list", "-l", help="Text file with list of files to upload."),
409
+ skip_existing: bool = typer.Option(
410
+ False,
411
+ "--skip-existing",
412
+ "-s",
413
+ help="If set, will skip already uploaded files, from the files you provided in the command. "
414
+ "Already uploaded files are determined by checking if a file with the same name and MD5 checksum already exists in the project's store on DivBase. ",
415
+ ),
416
+ recursive: bool = typer.Option(
417
+ False,
418
+ "--recursive",
419
+ "-r",
420
+ "-R",
421
+ help="If set, recursively include subdirectories contents when uploading (i.e. '**' is expanded). "
422
+ "Without this flag, patterns only match files in the specified directory.",
423
+ ),
424
+ dry_run: bool = typer.Option(
425
+ False, "--dry-run", "-n", help="If set, will show what files would be uploaded, but not actually upload them."
426
+ ),
418
427
  disable_safe_mode: Annotated[
419
428
  bool,
420
429
  typer.Option(
@@ -428,56 +437,116 @@ def upload_files(
428
437
  project: str | None = PROJECT_NAME_OPTION,
429
438
  ):
430
439
  """
431
- Upload files to your project's store on DivBase:
440
+ Upload files to your project's store on DivBase.
441
+ NOTE that directory structure is not preserved on upload, only the file name is used as the object name.
442
+
443
+ You can specify files directly as arguments including glob patterns (aka * or **) or use the --file-list option.
444
+ Use the flag --recursive / -R to expand glob patterns into subdirectories.
445
+ We recommend wrapping the glob pattern in quotes when using --recursive to ensure the correct behavior across different shells.
432
446
 
433
- To provide files to upload you can either:
434
- 1. provide a list of files paths directly in the command line
435
- 2. provide a directory to upload
436
- 3. provide a text file with or a file list.
447
+ Examples:
448
+ # Upload multiple files by specifying them one after another
449
+ divbase-cli files upload file1.vcf.gz file2.tsv
450
+
451
+ # Upload all .vcf.gz files in the current directory
452
+ divbase-cli files upload "*.vcf.gz"
453
+
454
+ # Upload all files in a directory (non-recursive)
455
+ divbase-cli files upload "/path/to/data/*"
456
+
457
+ # Upload all files in a directory and its subdirectories
458
+ divbase-cli files upload --recursive "/path/to/data/**"
459
+
460
+ # Upload from a text file list (one file path per line)
461
+ divbase-cli files upload --file-list files_to_upload.txt
437
462
  """
438
463
  project_config = resolve_project(project_name=project)
439
464
  logged_in_url = ensure_logged_in(desired_url=project_config.divbase_url)
440
465
 
441
- if bool(files) + bool(upload_dir) + bool(file_list) > 1:
442
- print("Please specify only one of --files, --upload_dir, or --file-list.")
466
+ if bool(files) + bool(file_list) > 1:
467
+ print("Please specify only space separated files or provide a --file-list.")
468
+ raise typer.Exit(1)
469
+
470
+ if skip_existing and disable_safe_mode:
471
+ print(
472
+ "The --skip-existing and --disable-safe-mode options cannot be used together.\n"
473
+ "Safe mode calculates file checksums, "
474
+ "and these checksums are needed for --skip-existing to know what files to skip uploading."
475
+ )
443
476
  raise typer.Exit(1)
444
477
 
445
- all_files: set[Path] = set()
478
+ # (to preserve order of files provided by user, but ensure no duplicates)
479
+ all_files: list[Path] = []
480
+ missing_files_or_patterns: list[str] = []
481
+ _seen: set[Path] = set()
446
482
  if files:
447
- all_files.update(files)
448
- if upload_dir:
449
- all_files.update([p for p in upload_dir.iterdir() if p.is_file()])
483
+ for pattern in files:
484
+ matched = [Path(p) for p in glob(pattern, recursive=recursive) if Path(p).is_file()]
485
+ if not matched:
486
+ missing_files_or_patterns.append(pattern)
487
+ for file in matched:
488
+ if file not in _seen:
489
+ _seen.add(file)
490
+ all_files.append(file)
491
+ if missing_files_or_patterns:
492
+ print(f"[red bold]{NO_UPLOAD_MATCHES_MSG}[/red bold]")
493
+ for path in missing_files_or_patterns:
494
+ print(f"[red]- '{path}'[/red]")
495
+ raise typer.Exit(1)
450
496
  if file_list:
497
+ missing_files = []
451
498
  with open(file_list) as f:
452
499
  for line in f:
453
500
  path = Path(line.strip())
454
501
  if path.is_file():
455
- all_files.add(path)
502
+ if path not in _seen:
503
+ _seen.add(path)
504
+ all_files.append(path)
505
+ else:
506
+ missing_files.append(path)
507
+ if missing_files:
508
+ print(
509
+ "[red bold]Error: The following file paths provided in the --file-list do not exist or are not files:[/red bold]"
510
+ )
511
+ for path in missing_files:
512
+ print(f"[red]- '{path}'[/red]")
513
+ raise typer.Exit(1)
456
514
 
457
515
  if not all_files:
458
516
  print(NO_FILES_SPECIFIED_MSG)
459
517
  raise typer.Exit(1)
460
518
 
519
+ _check_for_duplicate_file_names(all_files)
461
520
  _check_for_unsupported_files(all_files)
462
521
 
463
522
  uploaded_results = upload_files_command(
464
523
  project_name=project_config.name,
465
524
  divbase_base_url=logged_in_url,
466
- all_files=list(all_files),
525
+ all_files=all_files,
467
526
  safe_mode=not disable_safe_mode,
527
+ skip_existing=skip_existing,
528
+ dry_run=dry_run,
468
529
  )
469
530
 
531
+ if uploaded_results.skipped:
532
+ print("[yellow bold]\nThe following files were skipped: [/yellow bold]")
533
+ for file in uploaded_results.skipped:
534
+ print(f"[yellow]- '{file.object_name}' reason: {file.reason}[/yellow]")
535
+
470
536
  if uploaded_results.successful:
471
537
  print("[green bold]\nThe following files were successfully uploaded: [/green bold]")
472
- for object in uploaded_results.successful:
473
- print(f"- '{object.object_name}' created from file at: '{object.file_path.resolve()}'")
538
+ for file in uploaded_results.successful:
539
+ print(f"[green]- '{file.object_name}' created from file at: '{file.file_path.resolve()}'[/green]")
474
540
 
475
541
  if uploaded_results.failed:
476
542
  print("[red bold]\nERROR: Failed to upload the following files:[/red bold]")
477
543
  for failed in uploaded_results.failed:
478
544
  print(f"[red]- '{failed.object_name}': Exception: '{failed.exception}'[/red]")
479
-
480
545
  raise typer.Exit(1)
546
+ elif not uploaded_results.successful:
547
+ print("\nNo files were uploaded.")
548
+ else:
549
+ print("\n[green bold]All files uploaded successfully![/green bold]")
481
550
 
482
551
 
483
552
  @file_app.command("rm")
@@ -591,7 +660,32 @@ def _resolve_file_inputs(files: list[str] | None, file_list: Path | None) -> lis
591
660
  return list(all_files)
592
661
 
593
662
 
594
- def _check_for_unsupported_files(all_files: set[Path]) -> None:
663
+ def _check_for_duplicate_file_names(all_files: list[Path]) -> None:
664
+ """
665
+ Helper fn to check if any two files in the upload set share the same file name.
666
+ Since directory structure is not preserved on upload, duplicate names would overwrite each other.
667
+ """
668
+ name_to_paths: dict[str, list[Path]] = {}
669
+ for f in all_files:
670
+ if f.name not in name_to_paths:
671
+ name_to_paths[f.name] = []
672
+ name_to_paths[f.name].append(f)
673
+
674
+ duplicates = {name: paths for name, paths in name_to_paths.items() if len(paths) > 1}
675
+ if duplicates:
676
+ print(
677
+ "[red bold]Error: The following file names appear more than once in the upload list.[/red bold]\n"
678
+ "[red]Since directory structure is not preserved on upload, these files would collide:[/red]\n"
679
+ )
680
+ for name, paths in duplicates.items():
681
+ print(f"[red bold]'{name}':[/red bold]")
682
+ for p in paths:
683
+ print(f"[red]- {p.resolve()}[/red]")
684
+ print("\nPlease rename the files so each has a unique name before uploading.")
685
+ raise typer.Exit(1)
686
+
687
+
688
+ def _check_for_unsupported_files(all_files: list[Path]) -> None:
595
689
  """
596
690
  Helper fn to check if any of the files to be uploaded are not supported by DivBase.
597
691
  Raises error if so.
@@ -612,10 +706,24 @@ def _check_for_unsupported_files(all_files: set[Path]) -> None:
612
706
  unsupported_chars.append(file_path)
613
707
 
614
708
  if unsupported_file_types:
615
- raise UnsupportedFileTypeError(unsupported_files=unsupported_file_types)
709
+ print(
710
+ f"[red bold]Error: The following files' types are not supported by DivBase and therefore cannot be uploaded: [/red bold] \n"
711
+ f"[red]{'\n'.join(str(file) for file in unsupported_file_types)}\n\n[/red]"
712
+ f"DivBase currently supports the following file types: '{', '.join(SUPPORTED_DIVBASE_FILE_TYPES)}'\n"
713
+ "Note that while you can upload '.tbi' and '.csi' files they are not used by DivBase in queries, we create our own index files instead. \n"
714
+ "If you want us to support another file type, please let us know.",
715
+ )
616
716
 
617
717
  if unsupported_chars:
618
- raise UnsupportedFileNameError(unsupported_files=unsupported_chars)
718
+ print(
719
+ f"[red bold]Error: The following file(s) have unsupported characters in their filenames and therefore cannot be uploaded: [/red bold]\n"
720
+ f"[red]{'\n'.join(str(file) for file in unsupported_chars)}\n\n[/red]"
721
+ f"Filenames cannot contain any of the following characters: [green]{' '.join(UNSUPPORTED_CHARACTERS_IN_FILENAMES)} [/green]\n"
722
+ "Please rename the files and try again.",
723
+ )
724
+
725
+ if unsupported_file_types or unsupported_chars:
726
+ raise typer.Exit(1)
619
727
 
620
728
 
621
729
  def _pretty_print_download_results(download_results):
@@ -9,28 +9,36 @@ If sample metadata query:
9
9
  If bcftools query:
10
10
  a task id is returned which can be used to check the status of the job.
11
11
  After task completed, a merged VCF file will be added to the project's storage bucket which can be downloaded by the user.
12
-
13
-
14
- TODO:
15
- - Ability to download results file given task id with the file cli?
16
- -
17
12
  """
18
13
 
19
14
  import logging
20
15
  from pathlib import Path
21
16
 
17
+ import stamina
22
18
  import typer
23
19
  from rich import print
24
20
 
25
- from divbase_cli.cli_commands.shared_args_options import PROJECT_NAME_OPTION
21
+ from divbase_cli.cli_commands.file_cli import _pretty_print_download_results
22
+ from divbase_cli.cli_commands.shared_args_options import DOWNLOAD_DIR_OPTION, PROJECT_NAME_OPTION
26
23
  from divbase_cli.cli_config import cli_settings
27
- from divbase_cli.config_resolver import ensure_logged_in, resolve_project
24
+ from divbase_cli.cli_exceptions import PolledTaskNotFinalError
25
+ from divbase_cli.config_resolver import (
26
+ ensure_logged_in,
27
+ resolve_download_dir,
28
+ resolve_project,
29
+ )
30
+ from divbase_cli.retries import (
31
+ retry_polling_until_final_or_retryable_api_errors,
32
+ )
33
+ from divbase_cli.services.s3_files import download_files_command
28
34
  from divbase_cli.user_auth import make_authenticated_request
29
35
  from divbase_lib.api_schemas.queries import (
30
36
  BcftoolsQueryRequest,
31
37
  SampleMetadataQueryRequest,
32
38
  SampleMetadataQueryTaskResult,
33
39
  )
40
+ from divbase_lib.api_schemas.task_history import TaskHistoryResult
41
+ from divbase_lib.divbase_constants import QUERY_RESULTS_FILE_PREFIX
34
42
 
35
43
  logger = logging.getLogger(__name__)
36
44
 
@@ -70,6 +78,15 @@ VCF_QUERY_HELP_TEXT = (
70
78
  "--tsv-filter | --samples | --samples-file | --all-samples."
71
79
  )
72
80
 
81
+ MAX_WAIT_MINS_HARD_LIMIT = 600 # 10 hours
82
+
83
+ GET_RESULTS_HELP_TEXT = (
84
+ "Wait for a 'divbase-cli query vcf' job to complete and download the results file when complete. "
85
+ "Similar to running 'divbase-cli task-history id <TASK_ID>' but with the added benefit of polling for the "
86
+ "terminal state of the job (SUCCESS/FAILED). Designed to be of particular use in scripts and other automated workflows. "
87
+ "Error codes (e.g. for scripts): 0 — task succeeded and file downloaded; 1 — task failed; 2 — unsupported task type"
88
+ )
89
+
73
90
  query_app = typer.Typer(
74
91
  help="Run queries on the VCF files stored in the project's data store on DivBase. Queries are run on the DivBase API",
75
92
  no_args_is_help=True,
@@ -218,6 +235,105 @@ def vcf_query(
218
235
  )
219
236
 
220
237
 
238
+ @query_app.command("get-vcf-results", help=GET_RESULTS_HELP_TEXT)
239
+ def get_results_from_query_job_by_task_id(
240
+ task_id: int = typer.Argument(..., help="Task ID of the query job to poll for results from."),
241
+ download_dir: str = DOWNLOAD_DIR_OPTION,
242
+ project: str | None = PROJECT_NAME_OPTION,
243
+ max_wait_mins: int = typer.Option(
244
+ 120,
245
+ "--max-wait-mins",
246
+ help=f"Maximum number of minutes to poll for task completion. Must be between 1 and {MAX_WAIT_MINS_HARD_LIMIT}.",
247
+ ),
248
+ ) -> None:
249
+ """
250
+ Get results from a VCF query job by its task ID by first polling for the final state of the task and then downloading the results file.
251
+
252
+ Exit codes (for programmatic use in scripts and other automated workflows):
253
+ 0 — task succeeded and file downloaded (default, no explicit call needed)
254
+ 1 — task failed (Celery job final status was FAILURE)
255
+ 2 — unsupported task type
256
+ """
257
+
258
+ if not 1 <= max_wait_mins <= MAX_WAIT_MINS_HARD_LIMIT:
259
+ raise typer.BadParameter(
260
+ f"--max-wait-mins must be between 1 and {MAX_WAIT_MINS_HARD_LIMIT} minutes, got {max_wait_mins}."
261
+ )
262
+
263
+ project_config = resolve_project(project_name=project)
264
+ logged_in_url = ensure_logged_in(desired_url=project_config.divbase_url)
265
+
266
+ task_status = poll_task_until_final_state_reached(
267
+ divbase_url=logged_in_url, task_id=task_id, timeout_mins=max_wait_mins
268
+ )
269
+
270
+ resolved_download_dir = resolve_download_dir(download_dir=download_dir)
271
+ log_filename = f"{QUERY_RESULTS_FILE_PREFIX}{task_id}.log"
272
+
273
+ if task_status == "SUCCESS":
274
+ result_filename = f"{QUERY_RESULTS_FILE_PREFIX}{task_id}.vcf.gz"
275
+ download_results = download_files_command(
276
+ divbase_base_url=logged_in_url,
277
+ project_name=project_config.name,
278
+ raw_files_input=[result_filename],
279
+ download_dir=resolved_download_dir,
280
+ verify_checksums=True,
281
+ dry_run=False,
282
+ project_version=None,
283
+ )
284
+ _pretty_print_download_results(download_results=download_results)
285
+ else:
286
+ print(f"Task {task_id} failed. Run 'divbase-cli task-history id {task_id}' for more details on the failure.")
287
+ print(
288
+ f"The task's log file can be downloaded for debugging using the command:\n'divbase-cli files download {log_filename}'\n"
289
+ f"You can view the log file contents directly in your terminal with:\n'divbase-cli files stream {log_filename}'"
290
+ )
291
+ raise typer.Exit(code=1)
292
+
293
+
294
+ def poll_task_until_final_state_reached(divbase_url: str, task_id: int, timeout_mins: int) -> str:
295
+ """
296
+ Poll for the final state (SUCCESS/FAILURE) of a celery task by task ID.
297
+
298
+ Note that this is designed with two layers of retry_only_on_retryable_divbase_api_errors: the outer layer (this function), and the inner layer (make_authenticated_request; 3 attempts).
299
+ This is to make this polling more resilient to temporary API errors for the intent of using the CLI command that calls this in scripts and other automated workflows.
300
+ """
301
+
302
+ FINAL_STATES = {"SUCCESS", "FAILURE"}
303
+ SUPPORTED_TASK_NAMES = {"tasks.bcftools_query"}
304
+
305
+ # inline retry_context instead of using @stamina.retry decorator because timeout_mins is a runtime value input from the CLI, not a constant (which the decorator needs)
306
+ for attempt in stamina.retry_context(
307
+ on=retry_polling_until_final_or_retryable_api_errors,
308
+ attempts=None, # no attempt cap — rely solely on timeout_mins
309
+ wait_initial=1.0, # seconds. Some overhead time is required to init the Celery task even with idle workers, so wait a bit before first poll.
310
+ wait_exp_base=2, # exponential backoff factor
311
+ wait_max=60.0, # cap exponential backoff at once per 60 seconds if it reaches that far.
312
+ wait_jitter=0.0,
313
+ timeout=timeout_mins * 60,
314
+ ):
315
+ with attempt:
316
+ response = make_authenticated_request(
317
+ method="GET",
318
+ divbase_base_url=divbase_url,
319
+ api_route=f"v1/task-history/tasks/{task_id}",
320
+ )
321
+ # Endpoint returns 403 if the task is not found or the user does not have permission to view it. So item should not be empty.
322
+ items = response.json()
323
+
324
+ task_results = TaskHistoryResult(**items[0]) # for task ID lookups, only one entry is returned
325
+
326
+ if task_results.name not in SUPPORTED_TASK_NAMES:
327
+ raise typer.BadParameter(
328
+ f"Task {task_id} has unsupported task type '{task_results.name}'. Only VCF query jobs are supported for this CLI command."
329
+ )
330
+
331
+ if task_results.status in FINAL_STATES:
332
+ return task_results.status
333
+
334
+ raise PolledTaskNotFinalError(f"Task {task_id} state is {task_results.status}")
335
+
336
+
221
337
  def _normalize_samples_input(samples: str | None, samples_file: Path | None) -> tuple[list[str] | None, list[str]]:
222
338
  """
223
339
  Normalize sample selection inputs from CLI options.
@@ -6,6 +6,16 @@ If you have something that is only used in one of the cli subcommands, don't mov
6
6
 
7
7
  import typer
8
8
 
9
+ DOWNLOAD_DIR_OPTION = typer.Option(
10
+ None,
11
+ "--download-dir",
12
+ "-d",
13
+ help="""Directory to download the files to.
14
+ If not provided, defaults to what you specified in your user config.
15
+ If also not specified in your user config, downloads to the current directory.
16
+ You can also specify "." to download to the current directory.""",
17
+ )
18
+
9
19
  PROJECT_NAME_OPTION = typer.Option(
10
20
  None,
11
21
  "--project",