divbase-cli 0.1.0a2__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.0a2 → divbase_cli-0.1.0a5}/.gitignore +8 -0
- {divbase_cli-0.1.0a2 → divbase_cli-0.1.0a5}/PKG-INFO +2 -2
- {divbase_cli-0.1.0a2 → 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.0a2 → divbase_cli-0.1.0a5}/src/divbase_cli/cli_commands/dimensions_cli.py +2 -2
- {divbase_cli-0.1.0a2 → divbase_cli-0.1.0a5}/src/divbase_cli/cli_commands/file_cli.py +146 -38
- {divbase_cli-0.1.0a2 → divbase_cli-0.1.0a5}/src/divbase_cli/cli_commands/query_cli.py +123 -7
- {divbase_cli-0.1.0a2 → divbase_cli-0.1.0a5}/src/divbase_cli/cli_commands/shared_args_options.py +10 -0
- {divbase_cli-0.1.0a2 → divbase_cli-0.1.0a5}/src/divbase_cli/cli_commands/user_config_cli.py +14 -7
- {divbase_cli-0.1.0a2 → divbase_cli-0.1.0a5}/src/divbase_cli/cli_config.py +9 -6
- {divbase_cli-0.1.0a2 → divbase_cli-0.1.0a5}/src/divbase_cli/cli_exceptions.py +6 -49
- {divbase_cli-0.1.0a2 → divbase_cli-0.1.0a5}/src/divbase_cli/config_resolver.py +3 -3
- {divbase_cli-0.1.0a2 → divbase_cli-0.1.0a5}/src/divbase_cli/retries.py +11 -1
- {divbase_cli-0.1.0a2 → divbase_cli-0.1.0a5}/src/divbase_cli/services/pre_signed_urls.py +17 -4
- {divbase_cli-0.1.0a2 → divbase_cli-0.1.0a5}/src/divbase_cli/services/s3_files.py +111 -39
- {divbase_cli-0.1.0a2 → divbase_cli-0.1.0a5}/src/divbase_cli/user_auth.py +176 -31
- divbase_cli-0.1.0a2/src/divbase_cli/__init__.py +0 -1
- divbase_cli-0.1.0a2/src/divbase_cli/cli_commands/auth_cli.py +0 -95
- {divbase_cli-0.1.0a2 → divbase_cli-0.1.0a5}/README.md +0 -0
- {divbase_cli-0.1.0a2 → divbase_cli-0.1.0a5}/src/divbase_cli/cli_commands/__init__.py +0 -0
- {divbase_cli-0.1.0a2 → divbase_cli-0.1.0a5}/src/divbase_cli/cli_commands/task_history_cli.py +0 -0
- {divbase_cli-0.1.0a2 → divbase_cli-0.1.0a5}/src/divbase_cli/cli_commands/version_cli.py +0 -0
- {divbase_cli-0.1.0a2 → divbase_cli-0.1.0a5}/src/divbase_cli/display_task_history.py +0 -0
- {divbase_cli-0.1.0a2 → divbase_cli-0.1.0a5}/src/divbase_cli/divbase_cli.py +0 -0
- {divbase_cli-0.1.0a2 → divbase_cli-0.1.0a5}/src/divbase_cli/services/__init__.py +0 -0
- {divbase_cli-0.1.0a2 → divbase_cli-0.1.0a5}/src/divbase_cli/services/announcements.py +0 -0
- {divbase_cli-0.1.0a2 → divbase_cli-0.1.0a5}/src/divbase_cli/services/project_versions.py +0 -0
- {divbase_cli-0.1.0a2 → divbase_cli-0.1.0a5}/src/divbase_cli/user_config.py +0 -0
- {divbase_cli-0.1.0a2 → 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
|
|
|
@@ -47,6 +54,7 @@ docker/coverage-data/
|
|
|
47
54
|
.cache/
|
|
48
55
|
# mkdocs auto generated files
|
|
49
56
|
docs/cli/_auto_generated/*.md
|
|
57
|
+
docs/releases/index.md
|
|
50
58
|
|
|
51
59
|
# pypi
|
|
52
60
|
dist/
|
|
@@ -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']})")
|
|
@@ -32,7 +32,7 @@ dimensions_app = typer.Typer(
|
|
|
32
32
|
def update_dimensions_index(
|
|
33
33
|
project: str | None = PROJECT_NAME_OPTION,
|
|
34
34
|
) -> None:
|
|
35
|
-
"""Calculate and add the dimensions of
|
|
35
|
+
"""Calculate and add the dimensions of all VCF files in the project to the dimensions index in the project."""
|
|
36
36
|
|
|
37
37
|
project_config = resolve_project(project_name=project)
|
|
38
38
|
|
|
@@ -279,7 +279,7 @@ def create_metadata_template_with_project_samples_names(
|
|
|
279
279
|
project: str | None = PROJECT_NAME_OPTION,
|
|
280
280
|
) -> None:
|
|
281
281
|
"""
|
|
282
|
-
Create a template sample metadata file (TSV format) pre-filled with the sample names from the project's VCF files based on the information stored in the project's VCF dimensions cache. Tip: run 'divbase-cli dimensions update' first to ensure that the VCF dimensions
|
|
282
|
+
Create a template sample metadata file (TSV format) pre-filled with the sample names from the project's VCF files based on the information stored in the project's VCF dimensions cache. Tip: run 'divbase-cli dimensions update' first to ensure that the VCF dimensions are up-to-date.
|
|
283
283
|
"""
|
|
284
284
|
project_config = resolve_project(project_name=project)
|
|
285
285
|
logged_in_url = ensure_logged_in(desired_url=project_config.divbase_url)
|
|
@@ -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,22 +33,14 @@ 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",
|
|
49
41
|
"-nc",
|
|
50
42
|
help="Turn off checksum verification which is on by default. "
|
|
51
|
-
"Checksum verification means all downloaded files are verified against their MD5 checksums."
|
|
43
|
+
"Checksum verification means all downloaded files are verified against their MD5 checksums. "
|
|
52
44
|
"It is recommended to leave checksum verification enabled unless you have a specific reason to disable it.",
|
|
53
45
|
)
|
|
54
46
|
PROJECT_VERSION_OPTION = typer.Option(
|
|
@@ -89,7 +81,7 @@ def list_files(
|
|
|
89
81
|
project: str | None = PROJECT_NAME_OPTION,
|
|
90
82
|
):
|
|
91
83
|
"""
|
|
92
|
-
|
|
84
|
+
List all currently available files in the project's DivBase store.
|
|
93
85
|
|
|
94
86
|
You can optionally filter the listed files by providing a prefix.
|
|
95
87
|
By default, DivBase query results files are hidden from the listing. Use the --include-results-files option to include them.
|
|
@@ -219,7 +211,7 @@ def download_files(
|
|
|
219
211
|
files: list[str] = typer.Argument(
|
|
220
212
|
None, help="Space separated list of files/objects to download from the project's store on DivBase."
|
|
221
213
|
),
|
|
222
|
-
file_list: Path | None = typer.Option(None, "--file-list", help="Text file with list of files to
|
|
214
|
+
file_list: Path | None = typer.Option(None, "--file-list", help="Text file with list of files to download."),
|
|
223
215
|
download_dir: str = DOWNLOAD_DIR_OPTION,
|
|
224
216
|
dry_run: bool = DRY_RUN_OPTION,
|
|
225
217
|
disable_verify_checksums: bool = DISABLE_VERIFY_CHECKSUMS_OPTION,
|
|
@@ -235,7 +227,7 @@ def download_files(
|
|
|
235
227
|
|
|
236
228
|
To download the latest version of a file, just provide its name. "file1" "file2" etc.
|
|
237
229
|
To download a specific/older version of a file, use the format: "file_name:version_id"
|
|
238
|
-
You can get a file's version id using the 'divbase-cli
|
|
230
|
+
You can get a file's version id using the 'divbase-cli files info [FILE_NAME]' command.
|
|
239
231
|
You can mix and match latest and specific versions in the same command.
|
|
240
232
|
E.g. to download the latest version of file1 and version "3xcdsdsdiw829x"
|
|
241
233
|
of file2: 'divbase-cli files download file1 file2:3xcdsdsdiw829x'
|
|
@@ -385,7 +377,7 @@ def stream_file(
|
|
|
385
377
|
default=None,
|
|
386
378
|
help="Specify this if you want to look at an older/specific version of the file. "
|
|
387
379
|
"If not provided, the latest version of the file is used. "
|
|
388
|
-
"To get a file's version ids, use the 'divbase-cli
|
|
380
|
+
"To get a file's version ids, use the 'divbase-cli files info [FILE_NAME]' command.",
|
|
389
381
|
),
|
|
390
382
|
project: str | None = PROJECT_NAME_OPTION,
|
|
391
383
|
):
|
|
@@ -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):
|