divbase-cli 0.1.0a9__tar.gz → 0.1.0a12__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.0a9 → divbase_cli-0.1.0a12}/PKG-INFO +2 -2
- {divbase_cli-0.1.0a9 → divbase_cli-0.1.0a12}/pyproject.toml +1 -1
- divbase_cli-0.1.0a12/src/divbase_cli/__init__.py +1 -0
- {divbase_cli-0.1.0a9 → divbase_cli-0.1.0a12}/src/divbase_cli/cli_commands/auth_cli.py +11 -7
- {divbase_cli-0.1.0a9 → divbase_cli-0.1.0a12}/src/divbase_cli/cli_commands/dimensions_cli.py +1 -4
- {divbase_cli-0.1.0a9 → divbase_cli-0.1.0a12}/src/divbase_cli/cli_commands/file_cli.py +65 -65
- {divbase_cli-0.1.0a9 → divbase_cli-0.1.0a12}/src/divbase_cli/cli_commands/query_cli.py +3 -4
- {divbase_cli-0.1.0a9 → divbase_cli-0.1.0a12}/src/divbase_cli/cli_config.py +6 -0
- {divbase_cli-0.1.0a9 → divbase_cli-0.1.0a12}/src/divbase_cli/cli_exceptions.py +78 -3
- {divbase_cli-0.1.0a9 → divbase_cli-0.1.0a12}/src/divbase_cli/divbase_cli.py +14 -2
- {divbase_cli-0.1.0a9 → divbase_cli-0.1.0a12}/src/divbase_cli/services/s3_files.py +18 -21
- divbase_cli-0.1.0a9/src/divbase_cli/__init__.py +0 -1
- {divbase_cli-0.1.0a9 → divbase_cli-0.1.0a12}/.gitignore +0 -0
- {divbase_cli-0.1.0a9 → divbase_cli-0.1.0a12}/README.md +0 -0
- {divbase_cli-0.1.0a9 → divbase_cli-0.1.0a12}/src/divbase_cli/cli_commands/__init__.py +0 -0
- {divbase_cli-0.1.0a9 → divbase_cli-0.1.0a12}/src/divbase_cli/cli_commands/shared_args_options.py +0 -0
- {divbase_cli-0.1.0a9 → divbase_cli-0.1.0a12}/src/divbase_cli/cli_commands/task_history_cli.py +0 -0
- {divbase_cli-0.1.0a9 → divbase_cli-0.1.0a12}/src/divbase_cli/cli_commands/user_config_cli.py +0 -0
- {divbase_cli-0.1.0a9 → divbase_cli-0.1.0a12}/src/divbase_cli/cli_commands/version_cli.py +0 -0
- {divbase_cli-0.1.0a9 → divbase_cli-0.1.0a12}/src/divbase_cli/config_resolver.py +0 -0
- {divbase_cli-0.1.0a9 → divbase_cli-0.1.0a12}/src/divbase_cli/display_task_history.py +0 -0
- {divbase_cli-0.1.0a9 → divbase_cli-0.1.0a12}/src/divbase_cli/retries.py +0 -0
- {divbase_cli-0.1.0a9 → divbase_cli-0.1.0a12}/src/divbase_cli/services/__init__.py +0 -0
- {divbase_cli-0.1.0a9 → divbase_cli-0.1.0a12}/src/divbase_cli/services/announcements.py +0 -0
- {divbase_cli-0.1.0a9 → divbase_cli-0.1.0a12}/src/divbase_cli/services/pre_signed_urls.py +0 -0
- {divbase_cli-0.1.0a9 → divbase_cli-0.1.0a12}/src/divbase_cli/services/project_versions.py +0 -0
- {divbase_cli-0.1.0a9 → divbase_cli-0.1.0a12}/src/divbase_cli/user_auth.py +0 -0
- {divbase_cli-0.1.0a9 → divbase_cli-0.1.0a12}/src/divbase_cli/user_config.py +0 -0
- {divbase_cli-0.1.0a9 → divbase_cli-0.1.0a12}/src/divbase_cli/utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: divbase-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.0a12
|
|
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.0a12
|
|
22
22
|
Requires-Dist: keyring>=25.7.0
|
|
23
23
|
Requires-Dist: typer==0.26.7
|
|
24
24
|
Description-Content-Type: text/markdown
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0a12"
|
|
@@ -11,7 +11,12 @@ from pydantic import SecretStr
|
|
|
11
11
|
from rich import print
|
|
12
12
|
|
|
13
13
|
from divbase_cli.cli_config import cli_settings
|
|
14
|
-
from divbase_cli.cli_exceptions import
|
|
14
|
+
from divbase_cli.cli_exceptions import (
|
|
15
|
+
DivBaseAPIConnectionError,
|
|
16
|
+
DivBaseAPIError,
|
|
17
|
+
InvalidPersonalAccessTokenError,
|
|
18
|
+
PersonalAccessTokenAlreadyExistsError,
|
|
19
|
+
)
|
|
15
20
|
from divbase_cli.config_resolver import resolve_url_for_non_project_specific_commands
|
|
16
21
|
from divbase_cli.services.announcements import get_and_display_announcements
|
|
17
22
|
from divbase_cli.user_auth import (
|
|
@@ -109,27 +114,26 @@ def add_pat(
|
|
|
109
114
|
"""
|
|
110
115
|
existing = load_stored_user_pat()
|
|
111
116
|
if existing and not overwrite_existing:
|
|
112
|
-
|
|
117
|
+
raise PersonalAccessTokenAlreadyExistsError(
|
|
113
118
|
f"A personal access token named '{existing.name}' is already stored on your device \n"
|
|
114
119
|
f"It is due to expire on: {existing.pat_expiry_formatted()} \n"
|
|
115
120
|
"Append the flag --overwrite-existing (-o) to this command if you want to replace it. \n"
|
|
116
121
|
"You can only store one PAT at a time."
|
|
117
122
|
)
|
|
118
|
-
raise typer.Exit(code=1)
|
|
119
123
|
|
|
120
124
|
if expires_unix_timestamp and expires_unix_timestamp < time.time():
|
|
121
|
-
|
|
122
|
-
|
|
125
|
+
raise InvalidPersonalAccessTokenError(
|
|
126
|
+
"The expiry time you entered is in the past. Please enter a valid expiry time for the PAT."
|
|
127
|
+
)
|
|
123
128
|
|
|
124
129
|
pat = SecretStr(
|
|
125
130
|
typer.prompt("please paste your personal access token now", hide_input=True, confirmation_prompt=False)
|
|
126
131
|
)
|
|
127
132
|
if not pat.get_secret_value().startswith(PAT_TOKEN_PREFIX):
|
|
128
|
-
|
|
133
|
+
raise InvalidPersonalAccessTokenError(
|
|
129
134
|
"It looks like the token you entered is not a valid personal access token. "
|
|
130
135
|
f"Please make sure you copied the entire token, including the '{PAT_TOKEN_PREFIX}' prefix."
|
|
131
136
|
)
|
|
132
|
-
raise typer.Exit(code=1)
|
|
133
137
|
|
|
134
138
|
pat_data = PATData(name=name, pat=pat, pat_expires_at=expires_unix_timestamp)
|
|
135
139
|
pat_data.dump_pat_data()
|
|
@@ -102,11 +102,8 @@ def show_dimensions_index(
|
|
|
102
102
|
project_config = resolve_project(project_name=project)
|
|
103
103
|
logged_in_url = ensure_logged_in(desired_url=project_config.divbase_url)
|
|
104
104
|
|
|
105
|
-
# These two options are mutually exclusive. But due to how typer handles options, this error will only be raised if --sample-names-output has an input value (i.e. path).
|
|
106
|
-
# If the path is missing, it will raise an error about the missing path argument instead...
|
|
107
105
|
if sample_names_output and sample_names_stdout:
|
|
108
|
-
typer.
|
|
109
|
-
raise typer.Exit(code=1)
|
|
106
|
+
raise typer.BadParameter("Use only one of --sample-names-output or --sample-names-stdout.")
|
|
110
107
|
|
|
111
108
|
if unique_samples:
|
|
112
109
|
response = make_authenticated_request(
|
|
@@ -17,6 +17,13 @@ from rich.tree import Tree
|
|
|
17
17
|
from typing_extensions import Annotated
|
|
18
18
|
|
|
19
19
|
from divbase_cli.cli_commands.shared_args_options import DOWNLOAD_DIR_OPTION, FORMAT_AS_TSV_OPTION, PROJECT_NAME_OPTION
|
|
20
|
+
from divbase_cli.cli_exceptions import (
|
|
21
|
+
DuplicateFileNamesError,
|
|
22
|
+
NoFilesSpecifiedError,
|
|
23
|
+
NotEmptyDirectoryError,
|
|
24
|
+
ShellExpandedGlobError,
|
|
25
|
+
UnsupportedCharactersError,
|
|
26
|
+
)
|
|
20
27
|
from divbase_cli.config_resolver import ensure_logged_in, resolve_download_dir, resolve_project
|
|
21
28
|
from divbase_cli.services.project_versions import get_version_details_command
|
|
22
29
|
from divbase_cli.services.s3_files import (
|
|
@@ -46,7 +53,7 @@ from divbase_lib.utils import format_file_size
|
|
|
46
53
|
file_app = typer.Typer(no_args_is_help=True, help="Download/upload/list files to/from the project's store on DivBase.")
|
|
47
54
|
|
|
48
55
|
NO_FILES_SPECIFIED_MSG = "No files specified for the command, exiting..."
|
|
49
|
-
NO_UPLOAD_MATCHES_MSG = "
|
|
56
|
+
NO_UPLOAD_MATCHES_MSG = "The following file paths or glob patterns did not match any existing files:"
|
|
50
57
|
|
|
51
58
|
|
|
52
59
|
PREFIX_ARGUMENT = typer.Argument(
|
|
@@ -131,12 +138,10 @@ def list_files(
|
|
|
131
138
|
"""
|
|
132
139
|
if show_deleted_files and include_results_files:
|
|
133
140
|
raise typer.BadParameter(
|
|
134
|
-
message="The --show-deleted-files
|
|
141
|
+
message="The --show-deleted-files and --include-results-files options cannot be used together."
|
|
135
142
|
)
|
|
136
143
|
if detailed and format_output_as_tsv:
|
|
137
|
-
raise typer.BadParameter(
|
|
138
|
-
message="The --detailed option cannot be used with the flag --format-output-as-tsv. Please use these options separately."
|
|
139
|
-
)
|
|
144
|
+
raise typer.BadParameter(message="The --detailed option and --tsv option cannot be used together.")
|
|
140
145
|
|
|
141
146
|
project_config = resolve_project(project_name=project)
|
|
142
147
|
logged_in_url = ensure_logged_in(desired_url=project_config.divbase_url)
|
|
@@ -335,6 +340,12 @@ def download_files(
|
|
|
335
340
|
logged_in_url = ensure_logged_in(desired_url=project_config.divbase_url)
|
|
336
341
|
download_dir_path = resolve_download_dir(download_dir=download_dir)
|
|
337
342
|
|
|
343
|
+
if project_version == "":
|
|
344
|
+
raise typer.BadParameter(
|
|
345
|
+
"It seems like you provided an empty string for --project-version. \n"
|
|
346
|
+
"Please provide a valid project version or omit the flag."
|
|
347
|
+
)
|
|
348
|
+
|
|
338
349
|
raw_files_input = _resolve_file_inputs(files=files, file_list=file_list)
|
|
339
350
|
raw_files_input = _expand_folder_prefixes(
|
|
340
351
|
files=raw_files_input,
|
|
@@ -399,6 +410,11 @@ def download_all_files(
|
|
|
399
410
|
message="The --resume and --disable-verify-checksums options cannot be used together, "
|
|
400
411
|
"as checksums are used to determine which files don't need to be downloaded."
|
|
401
412
|
)
|
|
413
|
+
if project_version == "":
|
|
414
|
+
raise typer.BadParameter(
|
|
415
|
+
"It seems like you provided an empty string for --project-version. \n"
|
|
416
|
+
"Please provide a valid project version or omit the flag."
|
|
417
|
+
)
|
|
402
418
|
|
|
403
419
|
project_config = resolve_project(project_name=project)
|
|
404
420
|
logged_in_url = ensure_logged_in(desired_url=project_config.divbase_url)
|
|
@@ -609,16 +625,14 @@ def upload_files(
|
|
|
609
625
|
logged_in_url = ensure_logged_in(desired_url=project_config.divbase_url)
|
|
610
626
|
|
|
611
627
|
if bool(files) + bool(file_list) > 1:
|
|
612
|
-
|
|
613
|
-
raise typer.Exit(1)
|
|
628
|
+
raise typer.BadParameter("You cannot specify files as arguments and provide a --file-list.")
|
|
614
629
|
|
|
615
630
|
if skip_existing and disable_safe_mode:
|
|
616
|
-
|
|
631
|
+
raise typer.BadParameter(
|
|
617
632
|
"The --skip-existing and --disable-safe-mode options cannot be used together.\n"
|
|
618
633
|
"Safe mode calculates file checksums, "
|
|
619
634
|
"and these checksums are needed for --skip-existing to know what files to skip uploading."
|
|
620
635
|
)
|
|
621
|
-
raise typer.Exit(1)
|
|
622
636
|
|
|
623
637
|
to_upload = _resolve_user_upload_inputs(
|
|
624
638
|
files=files,
|
|
@@ -741,12 +755,11 @@ def remove_directory(
|
|
|
741
755
|
)
|
|
742
756
|
non_placeholder_files = [f for f in files if f.name != dir]
|
|
743
757
|
if non_placeholder_files:
|
|
744
|
-
|
|
745
|
-
f"
|
|
746
|
-
|
|
747
|
-
"'divbase-cli files rm' and 'divbase-cli files rmdir'
|
|
758
|
+
raise NotEmptyDirectoryError(
|
|
759
|
+
f"The directory '{dir}' is not empty.\n"
|
|
760
|
+
"You must first remove all files and/or subdirectories inside the directory using:\n"
|
|
761
|
+
"'divbase-cli files rm' and 'divbase-cli files rmdir'"
|
|
748
762
|
)
|
|
749
|
-
raise typer.Exit(1)
|
|
750
763
|
|
|
751
764
|
# NOTE: as dirs are just empty files which end with "/", we can just do a regular soft delete on them.
|
|
752
765
|
deleted_dirs = soft_delete_objects_command(
|
|
@@ -849,7 +862,7 @@ def restore_soft_deleted_files(
|
|
|
849
862
|
print(f"- '{file}'")
|
|
850
863
|
|
|
851
864
|
if restored_objects_response.not_restored:
|
|
852
|
-
print("[bold red]
|
|
865
|
+
print("[bold red]Error: Some files could not be restored:[/bold red]")
|
|
853
866
|
for file in restored_objects_response.not_restored:
|
|
854
867
|
print(f"[red]- '{file}'[/red]")
|
|
855
868
|
|
|
@@ -927,9 +940,8 @@ def _print_ls_detailed(files: list[ObjectDetails], folders: list[str]) -> None:
|
|
|
927
940
|
|
|
928
941
|
def _resolve_file_inputs(files: list[str] | None, file_list: Path | None) -> list[str]:
|
|
929
942
|
"""Helper function to resolve file inputs from command line arguments."""
|
|
930
|
-
if bool(files) + bool(file_list)
|
|
931
|
-
|
|
932
|
-
raise typer.Exit(1)
|
|
943
|
+
if bool(files) + bool(file_list) != 1:
|
|
944
|
+
raise typer.BadParameter("Please specify files as arguments or provide a --file-list.")
|
|
933
945
|
|
|
934
946
|
all_files = set()
|
|
935
947
|
if files:
|
|
@@ -941,8 +953,7 @@ def _resolve_file_inputs(files: list[str] | None, file_list: Path | None) -> lis
|
|
|
941
953
|
all_files.add(line.strip())
|
|
942
954
|
|
|
943
955
|
if not all_files:
|
|
944
|
-
|
|
945
|
-
raise typer.Exit(1)
|
|
956
|
+
raise NoFilesSpecifiedError(NO_FILES_SPECIFIED_MSG)
|
|
946
957
|
return list(all_files)
|
|
947
958
|
|
|
948
959
|
|
|
@@ -965,21 +976,19 @@ def _resolve_user_upload_inputs(
|
|
|
965
976
|
remote_dir = remote_dir[1:] if remote_dir.startswith("/") else remote_dir
|
|
966
977
|
remote_dir = remote_dir if remote_dir.endswith("/") else remote_dir + "/"
|
|
967
978
|
if any(char in remote_dir for char in UNSUPPORTED_CHARACTERS_IN_FILENAMES):
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
f"
|
|
979
|
+
raise UnsupportedCharactersError(
|
|
980
|
+
"The --to destination path contains unsupported characters.\n"
|
|
981
|
+
f"Unsupported characters: {UNSUPPORTED_CHARACTERS_DISPLAY}"
|
|
971
982
|
)
|
|
972
|
-
raise typer.Exit(1)
|
|
973
983
|
|
|
974
984
|
if recursive and files and not any("*" in p for p in files) and any(len(Path(p).parts) > 1 for p in files):
|
|
975
|
-
|
|
976
|
-
"
|
|
985
|
+
raise ShellExpandedGlobError(
|
|
986
|
+
"--recursive was passed but all arguments provided are file paths.\n"
|
|
977
987
|
"Your shell likely expanded the glob before passing it to this command.\n"
|
|
978
988
|
"To solve the problem, wrap the pattern(s) in quotes to prevent shell expansion, e.g.:\n"
|
|
979
|
-
"
|
|
980
|
-
"
|
|
989
|
+
" 'divbase-cli files upload 'my_data/**/*.vcf.gz' --recursive' AND NOT \n"
|
|
990
|
+
" 'divbase-cli files upload my_data/**/*.vcf.gz --recursive'"
|
|
981
991
|
)
|
|
982
|
-
raise typer.Exit(1)
|
|
983
992
|
|
|
984
993
|
if files:
|
|
985
994
|
for pattern in files:
|
|
@@ -1026,20 +1035,17 @@ def _resolve_user_upload_inputs(
|
|
|
1026
1035
|
to_upload.append(ToUpload(file_path=path, destination_key=(remote_dir or "") + path.name))
|
|
1027
1036
|
|
|
1028
1037
|
if missing_files:
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1038
|
+
missing_files_str = "\n".join(f"- '{path}'" for path in missing_files)
|
|
1039
|
+
raise NoFilesSpecifiedError(
|
|
1040
|
+
f"The following file paths provided in your --file-list were not found:\n{missing_files_str}"
|
|
1041
|
+
)
|
|
1033
1042
|
|
|
1034
1043
|
if missing_patterns:
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
print(f"[red]- '{pattern}'[/red]")
|
|
1038
|
-
raise typer.Exit(1)
|
|
1044
|
+
missing_patterns_str = "\n".join(f"- '{pattern}'" for pattern in missing_patterns)
|
|
1045
|
+
raise NoFilesSpecifiedError(f"{NO_UPLOAD_MATCHES_MSG}\n{missing_patterns_str}")
|
|
1039
1046
|
|
|
1040
1047
|
if not to_upload:
|
|
1041
|
-
|
|
1042
|
-
raise typer.Exit(1)
|
|
1048
|
+
raise NoFilesSpecifiedError(NO_FILES_SPECIFIED_MSG)
|
|
1043
1049
|
|
|
1044
1050
|
return to_upload
|
|
1045
1051
|
|
|
@@ -1052,16 +1058,15 @@ def _check_for_duplicate_object_keys(to_upload: list[ToUpload]) -> None:
|
|
|
1052
1058
|
|
|
1053
1059
|
duplicates = {key: paths for key, paths in key_to_paths.items() if len(paths) > 1}
|
|
1054
1060
|
if duplicates:
|
|
1055
|
-
|
|
1056
|
-
"
|
|
1057
|
-
|
|
1061
|
+
duplicates_str = "\n".join(
|
|
1062
|
+
f"'{key}':\n" + "\n".join(f"- {p.resolve()}" for p in paths) for key, paths in duplicates.items()
|
|
1063
|
+
)
|
|
1064
|
+
raise DuplicateFileNamesError(
|
|
1065
|
+
"The following destination names appear more than once in the upload list.\n"
|
|
1066
|
+
"Multiple local files would overwrite each other at the same key:\n"
|
|
1067
|
+
f"{duplicates_str}\n"
|
|
1068
|
+
"Ensure each file maps to a unique destination file, or upload them separately and use the '--to' flag to place the files in separate folders."
|
|
1058
1069
|
)
|
|
1059
|
-
for key, paths in duplicates.items():
|
|
1060
|
-
print(f"[red bold]'{key}':[/red bold]")
|
|
1061
|
-
for p in paths:
|
|
1062
|
-
print(f"[red]- {p.resolve()}[/red]")
|
|
1063
|
-
print("\nEnsure each file maps to a unique destination key, or use --to to place them in separate folders.")
|
|
1064
|
-
raise typer.Exit(1)
|
|
1065
1070
|
|
|
1066
1071
|
|
|
1067
1072
|
def _expand_folder_prefixes(
|
|
@@ -1073,7 +1078,7 @@ def _expand_folder_prefixes(
|
|
|
1073
1078
|
Given a list of user provided file args, expand any args that are for folders, i.e. they end with a trailing '/'.
|
|
1074
1079
|
SO updates the list of files to (e.g. download) to include all files inside the folder(s) and returns the updated list.
|
|
1075
1080
|
|
|
1076
|
-
Non-folder arguments are passed through unchanged. Raises
|
|
1081
|
+
Non-folder arguments are passed through unchanged. Raises NoFilesSpecifiedError if a prefix has no files.
|
|
1077
1082
|
"""
|
|
1078
1083
|
all_files: set[str] = set()
|
|
1079
1084
|
for arg in files:
|
|
@@ -1091,8 +1096,7 @@ def _expand_folder_prefixes(
|
|
|
1091
1096
|
# strip off any subfolders
|
|
1092
1097
|
real_files = [f.name for f in full_s3_file_paths if not f.name.endswith("/")]
|
|
1093
1098
|
if not real_files:
|
|
1094
|
-
|
|
1095
|
-
raise typer.Exit(1)
|
|
1099
|
+
raise NoFilesSpecifiedError(f"No files found inside the folder: '{arg}' that you provided.")
|
|
1096
1100
|
|
|
1097
1101
|
all_files.update(real_files)
|
|
1098
1102
|
|
|
@@ -1120,18 +1124,15 @@ def check_no_overlap_on_flattened_downloads(to_download: list[str]) -> None:
|
|
|
1120
1124
|
|
|
1121
1125
|
duplicates = {name: paths for name, paths in name_to_paths.items() if len(paths) > 1}
|
|
1122
1126
|
if duplicates:
|
|
1123
|
-
|
|
1124
|
-
"
|
|
1125
|
-
"[red]Multiple files would resolve to the same local file path:[/red]\n"
|
|
1127
|
+
duplicates_str = "\n".join(
|
|
1128
|
+
f"'{name}':\n" + "\n".join(f"- '{p}'" for p in paths) for name, paths in duplicates.items()
|
|
1126
1129
|
)
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
"\nYou must either download without the --flatten option or only download a subset of the the files to avoid this error."
|
|
1130
|
+
raise DuplicateFileNamesError(
|
|
1131
|
+
"The following files would overwrite each other when downloaded with the --flatten option.\n"
|
|
1132
|
+
"Multiple files would resolve to the same local file path:\n"
|
|
1133
|
+
f"{duplicates_str}\n"
|
|
1134
|
+
"You must either download without the --flatten option or only download a subset of the files to avoid this error."
|
|
1133
1135
|
)
|
|
1134
|
-
raise typer.Exit(1)
|
|
1135
1136
|
|
|
1136
1137
|
|
|
1137
1138
|
def _check_for_unsupported_files(all_files: list[ToUpload]) -> None:
|
|
@@ -1179,11 +1180,10 @@ def _sanitize_directory_names(directories: list[str]) -> list[str]:
|
|
|
1179
1180
|
cleaned_dir_names = []
|
|
1180
1181
|
for directory in directories:
|
|
1181
1182
|
if any(char in directory for char in UNSUPPORTED_CHARACTERS_IN_FILENAMES):
|
|
1182
|
-
|
|
1183
|
-
f"
|
|
1184
|
-
f"
|
|
1183
|
+
raise UnsupportedCharactersError(
|
|
1184
|
+
f"The directory name '{directory}' contains unsupported characters.\n"
|
|
1185
|
+
f"Unsupported characters: {UNSUPPORTED_CHARACTERS_DISPLAY}"
|
|
1185
1186
|
)
|
|
1186
|
-
raise typer.Exit(1)
|
|
1187
1187
|
|
|
1188
1188
|
directory = directory if directory.endswith("/") else directory + "/"
|
|
1189
1189
|
directory = directory[1:] if directory.startswith("/") else directory
|
|
@@ -21,7 +21,7 @@ from rich import print
|
|
|
21
21
|
from divbase_cli.cli_commands.file_cli import _pretty_print_download_results
|
|
22
22
|
from divbase_cli.cli_commands.shared_args_options import DOWNLOAD_DIR_OPTION, PROJECT_NAME_OPTION
|
|
23
23
|
from divbase_cli.cli_config import cli_settings
|
|
24
|
-
from divbase_cli.cli_exceptions import PolledTaskNotFinalError
|
|
24
|
+
from divbase_cli.cli_exceptions import PolledTaskNotFinalError, QueryTaskFailedError
|
|
25
25
|
from divbase_cli.config_resolver import (
|
|
26
26
|
ensure_logged_in,
|
|
27
27
|
resolve_download_dir,
|
|
@@ -284,12 +284,11 @@ def get_results_from_query_job_by_task_id(
|
|
|
284
284
|
)
|
|
285
285
|
_pretty_print_download_results(download_results=download_results)
|
|
286
286
|
else:
|
|
287
|
-
|
|
288
|
-
|
|
287
|
+
raise QueryTaskFailedError(
|
|
288
|
+
f"Task {task_id} failed. Run 'divbase-cli task-history id {task_id}' for more details on the failure.\n"
|
|
289
289
|
f"The task's log file can be downloaded for debugging using the command:\n'divbase-cli files download {log_filename}'\n"
|
|
290
290
|
f"You can view the log file contents directly in your terminal with:\n'divbase-cli files stream {log_filename}'"
|
|
291
291
|
)
|
|
292
|
-
raise typer.Exit(code=1)
|
|
293
292
|
|
|
294
293
|
|
|
295
294
|
def poll_task_until_final_state_reached(divbase_url: str, task_id: int, timeout_mins: int) -> str:
|
|
@@ -26,9 +26,13 @@ DEFAULT_PATS_PATH = APP_DIR / ".pat" # no plural because only 1 stored.
|
|
|
26
26
|
if DEV_MODE:
|
|
27
27
|
DEFAULT_DIVBASE_API_URL = "http://localhost:8000/api"
|
|
28
28
|
DEFAULT_LOGGING_ON = "1"
|
|
29
|
+
DEFAULT_TRACEBACKS_ON = "1"
|
|
30
|
+
DEFAULT_DIVBASE_DOCS_URL = "http://localhost:8008/divbase"
|
|
29
31
|
else:
|
|
30
32
|
DEFAULT_DIVBASE_API_URL = "https://divbase.scilifelab-2-prod.sys.kth.se/api"
|
|
31
33
|
DEFAULT_LOGGING_ON = "0"
|
|
34
|
+
DEFAULT_TRACEBACKS_ON = "0"
|
|
35
|
+
DEFAULT_DIVBASE_DOCS_URL = "https://scilifelabdatacentre.github.io/divbase"
|
|
32
36
|
|
|
33
37
|
|
|
34
38
|
@dataclass
|
|
@@ -50,8 +54,10 @@ class DivBaseCLISettings:
|
|
|
50
54
|
TOKENS_FALLBACK_PATH: Path = Path(os.getenv("DIVBASE_CLI_TOKENS_PATH", DEFAULT_TOKENS_PATH))
|
|
51
55
|
PATS_FALLBACK_PATH: Path = Path(os.getenv("DIVBASE_CLI_PATS_PATH", DEFAULT_PATS_PATH))
|
|
52
56
|
|
|
57
|
+
DIVBASE_DOCS_URL: str = os.getenv("DIVBASE_DOCS_URL", DEFAULT_DIVBASE_DOCS_URL)
|
|
53
58
|
DIVBASE_API_URL: str = os.getenv("DIVBASE_API_URL", DEFAULT_DIVBASE_API_URL)
|
|
54
59
|
METADATA_TSV_NAME: str = os.getenv("DIVBASE_METADATA_TSV_NAME", DEFAULT_METADATA_TSV_NAME)
|
|
60
|
+
TRACEBACKS_ON: bool = os.getenv("DIVBASE_TRACEBACKS_ON", DEFAULT_TRACEBACKS_ON) == "1"
|
|
55
61
|
LOGGING_ON: bool = os.getenv("DIVBASE_LOGGING_ON", DEFAULT_LOGGING_ON) == "1"
|
|
56
62
|
LOG_LEVEL: str = os.getenv("DIVBASE_LOG_LEVEL", DEFAULT_LOG_LEVEL).upper()
|
|
57
63
|
|
|
@@ -18,6 +18,20 @@ class AuthenticationError(DivBaseCLIError):
|
|
|
18
18
|
super().__init__(error_message)
|
|
19
19
|
|
|
20
20
|
|
|
21
|
+
class PersonalAccessTokenAlreadyExistsError(DivBaseCLIError):
|
|
22
|
+
"""Raised when a user tries to store a new personal access token while one is already stored."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, error_message: str = "A personal access token is already stored on this device."):
|
|
25
|
+
super().__init__(error_message)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class InvalidPersonalAccessTokenError(DivBaseCLIError):
|
|
29
|
+
"""Raised when a personal access token provided by the user is invalid."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, error_message: str = "The personal access token provided is invalid."):
|
|
32
|
+
super().__init__(error_message)
|
|
33
|
+
|
|
34
|
+
|
|
21
35
|
class DivBaseAPIConnectionError(DivBaseCLIError):
|
|
22
36
|
"""Raised when CLI tool can't connect to the provided DivBase API URL."""
|
|
23
37
|
|
|
@@ -63,6 +77,17 @@ class DivBaseAPIError(DivBaseCLIError):
|
|
|
63
77
|
super().__init__(error_message)
|
|
64
78
|
|
|
65
79
|
|
|
80
|
+
class InvalidInputError(DivBaseCLIError):
|
|
81
|
+
"""
|
|
82
|
+
Raised when invalid input is provided to a CLI command.
|
|
83
|
+
|
|
84
|
+
Use this when the issue in the input is only discovered in e.g. service layer, and therefore using typer.BadParameter is not recommended.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
def __init__(self, error_message: str):
|
|
88
|
+
super().__init__(error_message)
|
|
89
|
+
|
|
90
|
+
|
|
66
91
|
class FileDoesNotExistInSpecifiedVersionError(DivBaseCLIError):
|
|
67
92
|
"""Raised when a file does not exist in the project at the specified project version"""
|
|
68
93
|
|
|
@@ -82,6 +107,48 @@ class FileDoesNotExistInSpecifiedVersionError(DivBaseCLIError):
|
|
|
82
107
|
super().__init__(error_message)
|
|
83
108
|
|
|
84
109
|
|
|
110
|
+
class FileAlreadyUploadedError(DivBaseCLIError):
|
|
111
|
+
"""Raised when one or more files being uploaded already exist in the project's store on DivBase."""
|
|
112
|
+
|
|
113
|
+
def __init__(self, error_message: str):
|
|
114
|
+
super().__init__(error_message)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class NotEmptyDirectoryError(DivBaseCLIError):
|
|
118
|
+
"""Raised when trying to remove a directory from the project's store that still contains files."""
|
|
119
|
+
|
|
120
|
+
def __init__(self, error_message: str):
|
|
121
|
+
super().__init__(error_message)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class NoFilesSpecifiedError(DivBaseCLIError):
|
|
125
|
+
"""Raised when no files were specified or resolved for a command that requires at least one."""
|
|
126
|
+
|
|
127
|
+
def __init__(self, error_message: str):
|
|
128
|
+
super().__init__(error_message)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class UnsupportedCharactersError(DivBaseCLIError):
|
|
132
|
+
"""Raised when a user-provided file or directory name contains characters that DivBase does not support."""
|
|
133
|
+
|
|
134
|
+
def __init__(self, error_message: str):
|
|
135
|
+
super().__init__(error_message)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class ShellExpandedGlobError(DivBaseCLIError):
|
|
139
|
+
"""Raised when --recursive is passed but the shell has already expanded the glob pattern into file paths."""
|
|
140
|
+
|
|
141
|
+
def __init__(self, error_message: str):
|
|
142
|
+
super().__init__(error_message)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class DuplicateFileNamesError(DivBaseCLIError):
|
|
146
|
+
"""Raised when two or more files would resolve to the same destination name."""
|
|
147
|
+
|
|
148
|
+
def __init__(self, error_message: str):
|
|
149
|
+
super().__init__(error_message)
|
|
150
|
+
|
|
151
|
+
|
|
85
152
|
class ProjectNameNotSpecifiedError(DivBaseCLIError):
|
|
86
153
|
"""
|
|
87
154
|
Raised when the project name is not specified in the command line arguments, and
|
|
@@ -117,7 +184,15 @@ class ProjectNotInConfigError(DivBaseCLIError):
|
|
|
117
184
|
super().__init__(error_message)
|
|
118
185
|
|
|
119
186
|
|
|
120
|
-
class PolledTaskNotFinalError(
|
|
121
|
-
"""Raised when a polled
|
|
187
|
+
class PolledTaskNotFinalError(DivBaseCLIError):
|
|
188
|
+
"""Raised when a polled DivBase task is not in a final state."""
|
|
122
189
|
|
|
123
|
-
|
|
190
|
+
def __init__(self, error_message: str = "The polled task is still running."):
|
|
191
|
+
super().__init__(error_message)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class QueryTaskFailedError(DivBaseCLIError):
|
|
195
|
+
"""Raised when a submitted query task did not complete successfully."""
|
|
196
|
+
|
|
197
|
+
def __init__(self, error_message: str = "The query task failed."):
|
|
198
|
+
super().__init__(error_message)
|
|
@@ -7,6 +7,7 @@ import os
|
|
|
7
7
|
import sys
|
|
8
8
|
|
|
9
9
|
import typer
|
|
10
|
+
from rich import print
|
|
10
11
|
|
|
11
12
|
from divbase_cli import __version__
|
|
12
13
|
from divbase_cli.cli_commands.auth_cli import auth_app
|
|
@@ -17,6 +18,7 @@ from divbase_cli.cli_commands.task_history_cli import task_history_app
|
|
|
17
18
|
from divbase_cli.cli_commands.user_config_cli import config_app
|
|
18
19
|
from divbase_cli.cli_commands.version_cli import version_app
|
|
19
20
|
from divbase_cli.cli_config import cli_settings
|
|
21
|
+
from divbase_cli.cli_exceptions import DivBaseCLIError
|
|
20
22
|
|
|
21
23
|
logger = logging.getLogger(__name__)
|
|
22
24
|
|
|
@@ -40,7 +42,7 @@ def version_callback(show_version: bool) -> None:
|
|
|
40
42
|
"""
|
|
41
43
|
if show_version:
|
|
42
44
|
typer.echo(f"divbase-cli version: {__version__}")
|
|
43
|
-
raise typer.Exit()
|
|
45
|
+
raise typer.Exit(0)
|
|
44
46
|
|
|
45
47
|
|
|
46
48
|
@app.callback()
|
|
@@ -74,7 +76,17 @@ def main():
|
|
|
74
76
|
if cli_settings.LOGGING_ON and not in_auto_complete_mode:
|
|
75
77
|
logging.basicConfig(level=cli_settings.LOG_LEVEL, handlers=[logging.StreamHandler(sys.stderr)])
|
|
76
78
|
logger.info(f"Starting divbase_cli CLI application with logging level: {cli_settings.LOG_LEVEL}")
|
|
77
|
-
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
app()
|
|
82
|
+
except DivBaseCLIError as exc:
|
|
83
|
+
if cli_settings.TRACEBACKS_ON:
|
|
84
|
+
raise
|
|
85
|
+
print(f"[red bold]Error:[/red bold] {str(exc)}")
|
|
86
|
+
print(
|
|
87
|
+
f"[dim]Tip: see our guide if you want to see the full traceback for debugging purposes: {cli_settings.DIVBASE_DOCS_URL}/user-guides/troubleshooting/ [/dim]"
|
|
88
|
+
)
|
|
89
|
+
sys.exit(1)
|
|
78
90
|
|
|
79
91
|
|
|
80
92
|
if __name__ == "__main__":
|
|
@@ -11,7 +11,9 @@ import typer
|
|
|
11
11
|
from rich import print
|
|
12
12
|
|
|
13
13
|
from divbase_cli.cli_exceptions import (
|
|
14
|
+
FileAlreadyUploadedError,
|
|
14
15
|
FileDoesNotExistInSpecifiedVersionError,
|
|
16
|
+
InvalidInputError,
|
|
15
17
|
)
|
|
16
18
|
from divbase_cli.services.pre_signed_urls import (
|
|
17
19
|
DownloadOutcome,
|
|
@@ -235,19 +237,16 @@ def download_files_command(
|
|
|
235
237
|
f"The specified download directory '{download_dir}' is not a directory. Please create it or specify a valid directory before continuing."
|
|
236
238
|
)
|
|
237
239
|
|
|
238
|
-
if project_version
|
|
240
|
+
if project_version:
|
|
239
241
|
offending_files = [file for file in raw_files_input if ":" in file]
|
|
240
242
|
if offending_files:
|
|
241
|
-
|
|
242
|
-
"
|
|
243
|
-
"you cannot also specify specific versions of individual files to download
|
|
244
|
-
"
|
|
245
|
-
f"{'\n'.join(offending_files)}
|
|
246
|
-
"Exiting..."
|
|
243
|
+
raise InvalidInputError(
|
|
244
|
+
"If you provide a global project version (using --project-version) "
|
|
245
|
+
"you cannot also specify specific versions of individual files to download.\n"
|
|
246
|
+
"Offending files in your input:\n"
|
|
247
|
+
f"{'\n'.join(offending_files)}"
|
|
247
248
|
)
|
|
248
|
-
raise typer.Exit(1)
|
|
249
249
|
|
|
250
|
-
if project_version:
|
|
251
250
|
project_version_details = get_version_details_command(
|
|
252
251
|
project_name=project_name, divbase_base_url=divbase_base_url, version_name=project_version
|
|
253
252
|
)
|
|
@@ -370,19 +369,17 @@ def upload_files_command(
|
|
|
370
369
|
if already_uploaded:
|
|
371
370
|
# skip_existing means we just skip uploading those files that are already there
|
|
372
371
|
if not skip_existing:
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
f"\n[red bold]Error: For the project: '{project_name}'\n"
|
|
381
|
-
"The exact version of the following files that you're trying to upload already exist inside the project:\n[/red bold]"
|
|
382
|
-
f"[red]{files_str}[/red]\n"
|
|
383
|
-
"[bold green]Tip: if you want to skip re-uploading these files and continue uploading the other files, re-run this command with the '--skip-existing' flag.[/bold green]"
|
|
372
|
+
files_str = "\n".join(f"'{f.file_path}' (Checksum: '{f.checksum_local}')" for f in already_uploaded)
|
|
373
|
+
message = (
|
|
374
|
+
f"For the project: '{project_name}'\n"
|
|
375
|
+
"The exact version of the following files that you're trying to upload already exist inside the project:\n"
|
|
376
|
+
f"{files_str}\n\n"
|
|
377
|
+
"[bold green]Tip: if you want to skip re-uploading these files and continue uploading the other files, "
|
|
378
|
+
"re-run this command with the '--skip-existing' flag.[/bold green]"
|
|
384
379
|
)
|
|
385
|
-
|
|
380
|
+
if dry_run:
|
|
381
|
+
message = "The following upload attempt would have failed due to the below error:\n" + message
|
|
382
|
+
raise FileAlreadyUploadedError(message)
|
|
386
383
|
else:
|
|
387
384
|
for file in already_uploaded:
|
|
388
385
|
all_skipped_uploads.append(
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.1.0a9"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{divbase_cli-0.1.0a9 → divbase_cli-0.1.0a12}/src/divbase_cli/cli_commands/shared_args_options.py
RENAMED
|
File without changes
|
{divbase_cli-0.1.0a9 → divbase_cli-0.1.0a12}/src/divbase_cli/cli_commands/task_history_cli.py
RENAMED
|
File without changes
|
{divbase_cli-0.1.0a9 → divbase_cli-0.1.0a12}/src/divbase_cli/cli_commands/user_config_cli.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|