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.
- {divbase_cli-0.1.0a3 → divbase_cli-0.1.0a5}/.gitignore +7 -0
- {divbase_cli-0.1.0a3 → divbase_cli-0.1.0a5}/PKG-INFO +2 -2
- {divbase_cli-0.1.0a3 → divbase_cli-0.1.0a5}/pyproject.toml +1 -1
- divbase_cli-0.1.0a5/src/divbase_cli/__init__.py +1 -0
- divbase_cli-0.1.0a5/src/divbase_cli/cli_commands/auth_cli.py +184 -0
- {divbase_cli-0.1.0a3 → divbase_cli-0.1.0a5}/src/divbase_cli/cli_commands/file_cli.py +141 -33
- {divbase_cli-0.1.0a3 → divbase_cli-0.1.0a5}/src/divbase_cli/cli_commands/query_cli.py +123 -7
- {divbase_cli-0.1.0a3 → divbase_cli-0.1.0a5}/src/divbase_cli/cli_commands/shared_args_options.py +10 -0
- {divbase_cli-0.1.0a3 → divbase_cli-0.1.0a5}/src/divbase_cli/cli_config.py +9 -6
- {divbase_cli-0.1.0a3 → divbase_cli-0.1.0a5}/src/divbase_cli/cli_exceptions.py +6 -49
- {divbase_cli-0.1.0a3 → divbase_cli-0.1.0a5}/src/divbase_cli/config_resolver.py +3 -3
- {divbase_cli-0.1.0a3 → divbase_cli-0.1.0a5}/src/divbase_cli/retries.py +11 -1
- {divbase_cli-0.1.0a3 → divbase_cli-0.1.0a5}/src/divbase_cli/services/pre_signed_urls.py +17 -4
- {divbase_cli-0.1.0a3 → divbase_cli-0.1.0a5}/src/divbase_cli/services/s3_files.py +111 -39
- {divbase_cli-0.1.0a3 → divbase_cli-0.1.0a5}/src/divbase_cli/user_auth.py +176 -31
- divbase_cli-0.1.0a3/src/divbase_cli/__init__.py +0 -1
- divbase_cli-0.1.0a3/src/divbase_cli/cli_commands/auth_cli.py +0 -97
- {divbase_cli-0.1.0a3 → divbase_cli-0.1.0a5}/README.md +0 -0
- {divbase_cli-0.1.0a3 → divbase_cli-0.1.0a5}/src/divbase_cli/cli_commands/__init__.py +0 -0
- {divbase_cli-0.1.0a3 → divbase_cli-0.1.0a5}/src/divbase_cli/cli_commands/dimensions_cli.py +0 -0
- {divbase_cli-0.1.0a3 → divbase_cli-0.1.0a5}/src/divbase_cli/cli_commands/task_history_cli.py +0 -0
- {divbase_cli-0.1.0a3 → divbase_cli-0.1.0a5}/src/divbase_cli/cli_commands/user_config_cli.py +0 -0
- {divbase_cli-0.1.0a3 → divbase_cli-0.1.0a5}/src/divbase_cli/cli_commands/version_cli.py +0 -0
- {divbase_cli-0.1.0a3 → divbase_cli-0.1.0a5}/src/divbase_cli/display_task_history.py +0 -0
- {divbase_cli-0.1.0a3 → divbase_cli-0.1.0a5}/src/divbase_cli/divbase_cli.py +0 -0
- {divbase_cli-0.1.0a3 → divbase_cli-0.1.0a5}/src/divbase_cli/services/__init__.py +0 -0
- {divbase_cli-0.1.0a3 → divbase_cli-0.1.0a5}/src/divbase_cli/services/announcements.py +0 -0
- {divbase_cli-0.1.0a3 → divbase_cli-0.1.0a5}/src/divbase_cli/services/project_versions.py +0 -0
- {divbase_cli-0.1.0a3 → divbase_cli-0.1.0a5}/src/divbase_cli/user_config.py +0 -0
- {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.
|
|
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.
|
|
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
|
|
@@ -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[
|
|
416
|
-
|
|
417
|
-
|
|
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
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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(
|
|
442
|
-
print("Please specify only
|
|
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
|
-
|
|
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
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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
|
-
|
|
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=
|
|
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
|
|
473
|
-
print(f"- '{
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
{divbase_cli-0.1.0a3 → divbase_cli-0.1.0a5}/src/divbase_cli/cli_commands/shared_args_options.py
RENAMED
|
@@ -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",
|