divbase-cli 0.1.0a9__tar.gz → 0.1.0a11__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. {divbase_cli-0.1.0a9 → divbase_cli-0.1.0a11}/PKG-INFO +2 -2
  2. {divbase_cli-0.1.0a9 → divbase_cli-0.1.0a11}/pyproject.toml +1 -1
  3. divbase_cli-0.1.0a11/src/divbase_cli/__init__.py +1 -0
  4. {divbase_cli-0.1.0a9 → divbase_cli-0.1.0a11}/src/divbase_cli/cli_commands/auth_cli.py +11 -7
  5. {divbase_cli-0.1.0a9 → divbase_cli-0.1.0a11}/src/divbase_cli/cli_commands/dimensions_cli.py +1 -4
  6. {divbase_cli-0.1.0a9 → divbase_cli-0.1.0a11}/src/divbase_cli/cli_commands/file_cli.py +65 -65
  7. {divbase_cli-0.1.0a9 → divbase_cli-0.1.0a11}/src/divbase_cli/cli_commands/query_cli.py +3 -4
  8. {divbase_cli-0.1.0a9 → divbase_cli-0.1.0a11}/src/divbase_cli/cli_config.py +6 -0
  9. {divbase_cli-0.1.0a9 → divbase_cli-0.1.0a11}/src/divbase_cli/cli_exceptions.py +78 -3
  10. {divbase_cli-0.1.0a9 → divbase_cli-0.1.0a11}/src/divbase_cli/divbase_cli.py +14 -2
  11. {divbase_cli-0.1.0a9 → divbase_cli-0.1.0a11}/src/divbase_cli/services/s3_files.py +18 -21
  12. divbase_cli-0.1.0a9/src/divbase_cli/__init__.py +0 -1
  13. {divbase_cli-0.1.0a9 → divbase_cli-0.1.0a11}/.gitignore +0 -0
  14. {divbase_cli-0.1.0a9 → divbase_cli-0.1.0a11}/README.md +0 -0
  15. {divbase_cli-0.1.0a9 → divbase_cli-0.1.0a11}/src/divbase_cli/cli_commands/__init__.py +0 -0
  16. {divbase_cli-0.1.0a9 → divbase_cli-0.1.0a11}/src/divbase_cli/cli_commands/shared_args_options.py +0 -0
  17. {divbase_cli-0.1.0a9 → divbase_cli-0.1.0a11}/src/divbase_cli/cli_commands/task_history_cli.py +0 -0
  18. {divbase_cli-0.1.0a9 → divbase_cli-0.1.0a11}/src/divbase_cli/cli_commands/user_config_cli.py +0 -0
  19. {divbase_cli-0.1.0a9 → divbase_cli-0.1.0a11}/src/divbase_cli/cli_commands/version_cli.py +0 -0
  20. {divbase_cli-0.1.0a9 → divbase_cli-0.1.0a11}/src/divbase_cli/config_resolver.py +0 -0
  21. {divbase_cli-0.1.0a9 → divbase_cli-0.1.0a11}/src/divbase_cli/display_task_history.py +0 -0
  22. {divbase_cli-0.1.0a9 → divbase_cli-0.1.0a11}/src/divbase_cli/retries.py +0 -0
  23. {divbase_cli-0.1.0a9 → divbase_cli-0.1.0a11}/src/divbase_cli/services/__init__.py +0 -0
  24. {divbase_cli-0.1.0a9 → divbase_cli-0.1.0a11}/src/divbase_cli/services/announcements.py +0 -0
  25. {divbase_cli-0.1.0a9 → divbase_cli-0.1.0a11}/src/divbase_cli/services/pre_signed_urls.py +0 -0
  26. {divbase_cli-0.1.0a9 → divbase_cli-0.1.0a11}/src/divbase_cli/services/project_versions.py +0 -0
  27. {divbase_cli-0.1.0a9 → divbase_cli-0.1.0a11}/src/divbase_cli/user_auth.py +0 -0
  28. {divbase_cli-0.1.0a9 → divbase_cli-0.1.0a11}/src/divbase_cli/user_config.py +0 -0
  29. {divbase_cli-0.1.0a9 → divbase_cli-0.1.0a11}/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.0a9
3
+ Version: 0.1.0a11
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.0a9
21
+ Requires-Dist: divbase-lib==0.1.0a11
22
22
  Requires-Dist: keyring>=25.7.0
23
23
  Requires-Dist: typer==0.26.7
24
24
  Description-Content-Type: text/markdown
@@ -10,7 +10,7 @@ requires-python = ">=3.12"
10
10
  dependencies = [
11
11
  "typer==0.26.7",
12
12
  "keyring>=25.7.0",
13
- "divbase-lib==0.1.0a9",
13
+ "divbase-lib==0.1.0a11",
14
14
  ]
15
15
  dynamic = ["version"]
16
16
 
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0a11"
@@ -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 DivBaseAPIConnectionError, DivBaseAPIError
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
- print(
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
- 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)
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
- print(
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.echo("Use only one of --sample-names-output or --sample-names-stdout.", err=True)
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 = "Error: The following file paths or glob patterns did not match any existing files:"
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 option cannot be used with the flag --include-results-files. Please use these options separately."
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
- print("You cannot specify files as arguments and provide a --file-list.")
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
- print(
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
- print(
745
- f"[red bold]Error: The directory '{dir}' is not empty.\n"
746
- f"You must first remove all files and/or subdirectories inside the directory using:\n"
747
- "'divbase-cli files rm' and 'divbase-cli files rmdir' [/red bold]"
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]WARNING: Some files could not be restored:[/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) > 1:
931
- print("Please specify only one of --files or --file-list.")
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
- print(NO_FILES_SPECIFIED_MSG)
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
- print(
969
- f"[red bold]Error: The --to destination path contains unsupported characters.[/red bold]\n"
970
- f"[red]Unsupported characters: {UNSUPPORTED_CHARACTERS_DISPLAY}[/red]\n"
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
- print(
976
- "[red bold]Error: --recursive was passed but all arguments provided are file paths.[/red bold]\n"
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
- " [green]divbase-cli files upload 'my_data/**/*.vcf.gz' --recursive[/green] AND NOT \n"
980
- " [red]divbase-cli files upload my_data/**/*.vcf.gz --recursive[/red]"
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
- print("[red bold]Error: The following file paths provided in your --file-list were not found:[/red bold]")
1030
- for path in missing_files:
1031
- print(f"[red]- '{path}'[/red]")
1032
- raise typer.Exit(1)
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
- print(f"[red bold]{NO_UPLOAD_MATCHES_MSG}[/red bold]")
1036
- for pattern in missing_patterns:
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
- print(NO_FILES_SPECIFIED_MSG)
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
- print(
1056
- "[red bold]Error: The following S3 destination keys appear more than once in the upload list.[/red bold]\n"
1057
- "[red]Multiple local files would overwrite each other at the same key:[/red]\n"
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 Exit(1) if a prefix has no files.
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
- print(f"[red bold]Error: No files found inside the folder: '{arg}' that you provided.[/red bold]")
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
- print(
1124
- "[red bold]Error: The following files would overwrite each other when downloaded with --flatten option.[/red bold]\n"
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
- for name, paths in duplicates.items():
1128
- print(f"[red bold]'{name}':[/red bold]")
1129
- for p in paths:
1130
- print(f"[red]- {p}[/red]")
1131
- print(
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
- print(
1183
- f"[red bold]Error: The directory name '{directory}' contains unsupported characters.[/red bold]\n"
1184
- f"[red]Unsupported characters: {UNSUPPORTED_CHARACTERS_DISPLAY}[/red]\n"
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
- print(f"Task {task_id} failed. Run 'divbase-cli task-history id {task_id}' for more details on the failure.")
288
- print(
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(Exception):
121
- """Raised when a polled celerytask is not in a final state."""
187
+ class PolledTaskNotFinalError(DivBaseCLIError):
188
+ """Raised when a polled DivBase task is not in a final state."""
122
189
 
123
- pass
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
- app()
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 is not None:
240
+ if project_version:
239
241
  offending_files = [file for file in raw_files_input if ":" in file]
240
242
  if offending_files:
241
- print(
242
- "[red] ERROR: bad Input: If you provide a global project version (using --project-version) "
243
- "you cannot also specify specific versions of individual files to download. \n"
244
- "offending files in your input: \n"
245
- f"{'\n'.join(offending_files)} \n"
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
- if dry_run:
374
- print(
375
- "[red bold]Error: The following upload attempt would have failed due to the below error:\n[/red bold]"
376
- )
377
-
378
- files_str = "\n".join(f"'{f.file_path}' (Checksum: {f.checksum_local})" for f in already_uploaded)
379
- print(
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
- raise typer.Exit(1)
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