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.
Files changed (30) hide show
  1. {divbase_cli-0.1.0a2 → divbase_cli-0.1.0a5}/.gitignore +8 -0
  2. {divbase_cli-0.1.0a2 → divbase_cli-0.1.0a5}/PKG-INFO +2 -2
  3. {divbase_cli-0.1.0a2 → divbase_cli-0.1.0a5}/pyproject.toml +1 -1
  4. divbase_cli-0.1.0a5/src/divbase_cli/__init__.py +1 -0
  5. divbase_cli-0.1.0a5/src/divbase_cli/cli_commands/auth_cli.py +184 -0
  6. {divbase_cli-0.1.0a2 → divbase_cli-0.1.0a5}/src/divbase_cli/cli_commands/dimensions_cli.py +2 -2
  7. {divbase_cli-0.1.0a2 → divbase_cli-0.1.0a5}/src/divbase_cli/cli_commands/file_cli.py +146 -38
  8. {divbase_cli-0.1.0a2 → divbase_cli-0.1.0a5}/src/divbase_cli/cli_commands/query_cli.py +123 -7
  9. {divbase_cli-0.1.0a2 → divbase_cli-0.1.0a5}/src/divbase_cli/cli_commands/shared_args_options.py +10 -0
  10. {divbase_cli-0.1.0a2 → divbase_cli-0.1.0a5}/src/divbase_cli/cli_commands/user_config_cli.py +14 -7
  11. {divbase_cli-0.1.0a2 → divbase_cli-0.1.0a5}/src/divbase_cli/cli_config.py +9 -6
  12. {divbase_cli-0.1.0a2 → divbase_cli-0.1.0a5}/src/divbase_cli/cli_exceptions.py +6 -49
  13. {divbase_cli-0.1.0a2 → divbase_cli-0.1.0a5}/src/divbase_cli/config_resolver.py +3 -3
  14. {divbase_cli-0.1.0a2 → divbase_cli-0.1.0a5}/src/divbase_cli/retries.py +11 -1
  15. {divbase_cli-0.1.0a2 → divbase_cli-0.1.0a5}/src/divbase_cli/services/pre_signed_urls.py +17 -4
  16. {divbase_cli-0.1.0a2 → divbase_cli-0.1.0a5}/src/divbase_cli/services/s3_files.py +111 -39
  17. {divbase_cli-0.1.0a2 → divbase_cli-0.1.0a5}/src/divbase_cli/user_auth.py +176 -31
  18. divbase_cli-0.1.0a2/src/divbase_cli/__init__.py +0 -1
  19. divbase_cli-0.1.0a2/src/divbase_cli/cli_commands/auth_cli.py +0 -95
  20. {divbase_cli-0.1.0a2 → divbase_cli-0.1.0a5}/README.md +0 -0
  21. {divbase_cli-0.1.0a2 → divbase_cli-0.1.0a5}/src/divbase_cli/cli_commands/__init__.py +0 -0
  22. {divbase_cli-0.1.0a2 → divbase_cli-0.1.0a5}/src/divbase_cli/cli_commands/task_history_cli.py +0 -0
  23. {divbase_cli-0.1.0a2 → divbase_cli-0.1.0a5}/src/divbase_cli/cli_commands/version_cli.py +0 -0
  24. {divbase_cli-0.1.0a2 → divbase_cli-0.1.0a5}/src/divbase_cli/display_task_history.py +0 -0
  25. {divbase_cli-0.1.0a2 → divbase_cli-0.1.0a5}/src/divbase_cli/divbase_cli.py +0 -0
  26. {divbase_cli-0.1.0a2 → divbase_cli-0.1.0a5}/src/divbase_cli/services/__init__.py +0 -0
  27. {divbase_cli-0.1.0a2 → divbase_cli-0.1.0a5}/src/divbase_cli/services/announcements.py +0 -0
  28. {divbase_cli-0.1.0a2 → divbase_cli-0.1.0a5}/src/divbase_cli/services/project_versions.py +0 -0
  29. {divbase_cli-0.1.0a2 → divbase_cli-0.1.0a5}/src/divbase_cli/user_config.py +0 -0
  30. {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.0a2
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.0a2
21
+ Requires-Dist: divbase-lib==0.1.0a5
22
22
  Requires-Dist: keyring>=25.7.0
23
23
  Requires-Dist: typer==0.24.1
24
24
  Description-Content-Type: text/markdown
@@ -10,7 +10,7 @@ requires-python = ">=3.12"
10
10
  dependencies = [
11
11
  "typer==0.24.1",
12
12
  "keyring>=25.7.0",
13
- "divbase-lib==0.1.0a2",
13
+ "divbase-lib==0.1.0a5",
14
14
  ]
15
15
  dynamic = ["version"]
16
16
 
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0a5"
@@ -0,0 +1,184 @@
1
+ """
2
+ CLI subcommand for managing user auth with DivBase server.
3
+ """
4
+
5
+ import logging
6
+ import time
7
+ from datetime import datetime
8
+
9
+ import typer
10
+ from pydantic import SecretStr
11
+ from rich import print
12
+
13
+ from divbase_cli.cli_config import cli_settings
14
+ from divbase_cli.cli_exceptions import DivBaseAPIConnectionError, DivBaseAPIError
15
+ from divbase_cli.config_resolver import resolve_url_for_non_project_specific_commands
16
+ from divbase_cli.services.announcements import get_and_display_announcements
17
+ from divbase_cli.user_auth import (
18
+ PERSONAL_ACCESS_TOKEN_EXPIRED_MESSAGE,
19
+ PATData,
20
+ check_existing_session,
21
+ delete_stored_pat,
22
+ load_stored_user_pat,
23
+ login_to_divbase,
24
+ logout_of_divbase,
25
+ make_authenticated_request,
26
+ )
27
+ from divbase_cli.user_config import load_user_config
28
+ from divbase_lib.divbase_constants import PAT_TOKEN_PREFIX
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+ divbase_home_url = cli_settings.DIVBASE_API_URL.replace("/api", "")
33
+
34
+ auth_app = typer.Typer(
35
+ no_args_is_help=True,
36
+ help=f"Login/logout of DivBase server. To register, visit {divbase_home_url}.",
37
+ )
38
+
39
+
40
+ @auth_app.command("login")
41
+ def login(
42
+ email: str,
43
+ divbase_url: str = typer.Option(cli_settings.DIVBASE_API_URL, help="DivBase server URL to connect to."),
44
+ force: bool = typer.Option(False, "--force", "-f", help="Force login again even if already logged in"),
45
+ ):
46
+ """
47
+ Log in to the DivBase server.
48
+
49
+ You'll be prompted for your password after running the command.
50
+ """
51
+ password: str = typer.prompt("please enter your password", hide_input=True, confirmation_prompt=False)
52
+ secret_password = SecretStr(password)
53
+ del password # avoid user passwords showing up in error messages etc...
54
+
55
+ config = load_user_config()
56
+
57
+ if not force:
58
+ session_expires_at = check_existing_session(divbase_url=divbase_url, config=config)
59
+ if session_expires_at:
60
+ print(f"Already logged in to {divbase_url} with email: {config.logged_in_email}.")
61
+ print(f"Session expires: {datetime.fromtimestamp(session_expires_at)}")
62
+
63
+ if not typer.confirm("Do you want to login again? This will replace your current session."):
64
+ print("Login cancelled.")
65
+ return
66
+
67
+ login_to_divbase(email=email, password=secret_password, divbase_url=divbase_url)
68
+
69
+ try:
70
+ get_and_display_announcements(divbase_base_url=divbase_url)
71
+ except (DivBaseAPIError, DivBaseAPIConnectionError):
72
+ # lets not fail the login process if announcements are not working.
73
+ logger.info("Failed to get announcements after login. Error was: ", exc_info=True)
74
+
75
+ print(f"Logged in successfully as: {email}")
76
+
77
+
78
+ @auth_app.command("logout")
79
+ def logout():
80
+ """
81
+ Log out of the DivBase server.
82
+
83
+ Removes your locally stored credentials and invalidates the server-side refresh token.
84
+ """
85
+ logout_of_divbase()
86
+ print("Logged out successfully.")
87
+
88
+
89
+ @auth_app.command("add-pat")
90
+ def add_pat(
91
+ name: str = typer.Argument(..., help="Name of the personal access token (PAT). E.g. 'work-laptop-pat'"),
92
+ expires_unix_timestamp: int | None = typer.Option(
93
+ None,
94
+ "--expires",
95
+ "-e",
96
+ help="When the personal access token (PAT) expires (if it does), as a unix timestamp.",
97
+ min=1,
98
+ ),
99
+ overwrite_existing: bool = typer.Option(
100
+ False, "--overwrite-existing", "-o", help="Overwrite the existing stored PAT if one already exists."
101
+ ),
102
+ ):
103
+ """
104
+ Add a personal access token (PAT) to your device for authentication with DivBase.
105
+
106
+ PATs are used for authenticating with the DivBase API instead of using your password, and are recommended for use in scripts and pipelines.
107
+ See https://scilifelabdatacentre.github.io/divbase/user-guides/using-divbase-programmatically/ for more details on how to create a personal access token.
108
+ You can only store one personal access token at a time.
109
+ """
110
+ existing = load_stored_user_pat()
111
+ if existing and not overwrite_existing:
112
+ print(
113
+ f"A personal access token named '{existing.name}' is already stored on your device \n"
114
+ f"It is due to expire on: {existing.pat_expiry_formatted()} \n"
115
+ "Append the flag --overwrite-existing (-o) to this command if you want to replace it. \n"
116
+ "You can only store one PAT at a time."
117
+ )
118
+ raise typer.Exit(code=1)
119
+
120
+ if expires_unix_timestamp and expires_unix_timestamp < time.time():
121
+ print("The expiry time you entered is in the past. Please enter a valid expiry time for the PAT.")
122
+ raise typer.Exit(code=1)
123
+
124
+ pat = SecretStr(
125
+ typer.prompt("please paste your personal access token now", hide_input=True, confirmation_prompt=False)
126
+ )
127
+ if not pat.get_secret_value().startswith(PAT_TOKEN_PREFIX):
128
+ print(
129
+ "It looks like the token you entered is not a valid personal access token. "
130
+ f"Please make sure you copied the entire token, including the '{PAT_TOKEN_PREFIX}' prefix."
131
+ )
132
+ raise typer.Exit(code=1)
133
+
134
+ pat_data = PATData(name=name, pat=pat, pat_expires_at=expires_unix_timestamp)
135
+ pat_data.dump_pat_data()
136
+ print(f"Personal access token '{name}' stored successfully.")
137
+
138
+
139
+ @auth_app.command("rm-pat")
140
+ def rm_pat():
141
+ """
142
+ Remove the stored personal access token from your device.
143
+
144
+ If no such token exists the command will still succeed.
145
+ """
146
+ delete_stored_pat()
147
+ print("If a personal access token was stored, it has now been removed.")
148
+
149
+
150
+ @auth_app.command("pat-info")
151
+ def pat_info():
152
+ """
153
+ Display information about the personal access token stored on this device, if any.
154
+
155
+ The token will not be displayed for security reasons, but the name and expiry time will be shown.
156
+ """
157
+ pat_data = load_stored_user_pat()
158
+ if not pat_data:
159
+ print("No personal access token stored on this device.")
160
+ return
161
+
162
+ print(f"Name: {pat_data.name}")
163
+ print(f"Expires: {pat_data.pat_expiry_formatted()}")
164
+
165
+ if pat_data.is_pat_expired():
166
+ print("[red bold]Warning [/red bold]")
167
+ print(PERSONAL_ACCESS_TOKEN_EXPIRED_MESSAGE)
168
+
169
+
170
+ @auth_app.command("whoami")
171
+ def whoami():
172
+ """
173
+ Return information about the currently logged-in user.
174
+ """
175
+ divbase_url = resolve_url_for_non_project_specific_commands()
176
+
177
+ request = make_authenticated_request(
178
+ method="GET",
179
+ divbase_base_url=divbase_url,
180
+ api_route="v1/auth/whoami",
181
+ )
182
+
183
+ current_user = request.json()
184
+ print(f"Currently logged in as: {current_user['email']} (Name: {current_user['name']})")
@@ -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 a VCF file to the dimensions index file in the project."""
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 areup-to-date.
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
- list all currently available files in the project's DivBase store.
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 upload."),
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 file info [FILE_NAME]' command.
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 file info [FILE_NAME]' command.",
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[Path] | None = typer.Argument(None, help="Space separated list of files to upload."),
416
- upload_dir: Path | None = typer.Option(None, "--upload-dir", help="Directory to upload all files from."),
417
- file_list: Path | None = typer.Option(None, "--file-list", help="Text file with list of files to upload."),
407
+ files: list[str] | None = typer.Argument(None, help="Space separated list of files or glob patterns to upload."),
408
+ file_list: Path | None = typer.Option(None, "--file-list", "-l", help="Text file with list of files to upload."),
409
+ skip_existing: bool = typer.Option(
410
+ False,
411
+ "--skip-existing",
412
+ "-s",
413
+ help="If set, will skip already uploaded files, from the files you provided in the command. "
414
+ "Already uploaded files are determined by checking if a file with the same name and MD5 checksum already exists in the project's store on DivBase. ",
415
+ ),
416
+ recursive: bool = typer.Option(
417
+ False,
418
+ "--recursive",
419
+ "-r",
420
+ "-R",
421
+ help="If set, recursively include subdirectories contents when uploading (i.e. '**' is expanded). "
422
+ "Without this flag, patterns only match files in the specified directory.",
423
+ ),
424
+ dry_run: bool = typer.Option(
425
+ False, "--dry-run", "-n", help="If set, will show what files would be uploaded, but not actually upload them."
426
+ ),
418
427
  disable_safe_mode: Annotated[
419
428
  bool,
420
429
  typer.Option(
@@ -428,56 +437,116 @@ def upload_files(
428
437
  project: str | None = PROJECT_NAME_OPTION,
429
438
  ):
430
439
  """
431
- Upload files to your project's store on DivBase:
440
+ Upload files to your project's store on DivBase.
441
+ NOTE that directory structure is not preserved on upload, only the file name is used as the object name.
442
+
443
+ You can specify files directly as arguments including glob patterns (aka * or **) or use the --file-list option.
444
+ Use the flag --recursive / -R to expand glob patterns into subdirectories.
445
+ We recommend wrapping the glob pattern in quotes when using --recursive to ensure the correct behavior across different shells.
432
446
 
433
- To provide files to upload you can either:
434
- 1. provide a list of files paths directly in the command line
435
- 2. provide a directory to upload
436
- 3. provide a text file with or a file list.
447
+ Examples:
448
+ # Upload multiple files by specifying them one after another
449
+ divbase-cli files upload file1.vcf.gz file2.tsv
450
+
451
+ # Upload all .vcf.gz files in the current directory
452
+ divbase-cli files upload "*.vcf.gz"
453
+
454
+ # Upload all files in a directory (non-recursive)
455
+ divbase-cli files upload "/path/to/data/*"
456
+
457
+ # Upload all files in a directory and its subdirectories
458
+ divbase-cli files upload --recursive "/path/to/data/**"
459
+
460
+ # Upload from a text file list (one file path per line)
461
+ divbase-cli files upload --file-list files_to_upload.txt
437
462
  """
438
463
  project_config = resolve_project(project_name=project)
439
464
  logged_in_url = ensure_logged_in(desired_url=project_config.divbase_url)
440
465
 
441
- if bool(files) + bool(upload_dir) + bool(file_list) > 1:
442
- print("Please specify only one of --files, --upload_dir, or --file-list.")
466
+ if bool(files) + bool(file_list) > 1:
467
+ print("Please specify only space separated files or provide a --file-list.")
468
+ raise typer.Exit(1)
469
+
470
+ if skip_existing and disable_safe_mode:
471
+ print(
472
+ "The --skip-existing and --disable-safe-mode options cannot be used together.\n"
473
+ "Safe mode calculates file checksums, "
474
+ "and these checksums are needed for --skip-existing to know what files to skip uploading."
475
+ )
443
476
  raise typer.Exit(1)
444
477
 
445
- all_files: set[Path] = set()
478
+ # (to preserve order of files provided by user, but ensure no duplicates)
479
+ all_files: list[Path] = []
480
+ missing_files_or_patterns: list[str] = []
481
+ _seen: set[Path] = set()
446
482
  if files:
447
- all_files.update(files)
448
- if upload_dir:
449
- all_files.update([p for p in upload_dir.iterdir() if p.is_file()])
483
+ for pattern in files:
484
+ matched = [Path(p) for p in glob(pattern, recursive=recursive) if Path(p).is_file()]
485
+ if not matched:
486
+ missing_files_or_patterns.append(pattern)
487
+ for file in matched:
488
+ if file not in _seen:
489
+ _seen.add(file)
490
+ all_files.append(file)
491
+ if missing_files_or_patterns:
492
+ print(f"[red bold]{NO_UPLOAD_MATCHES_MSG}[/red bold]")
493
+ for path in missing_files_or_patterns:
494
+ print(f"[red]- '{path}'[/red]")
495
+ raise typer.Exit(1)
450
496
  if file_list:
497
+ missing_files = []
451
498
  with open(file_list) as f:
452
499
  for line in f:
453
500
  path = Path(line.strip())
454
501
  if path.is_file():
455
- all_files.add(path)
502
+ if path not in _seen:
503
+ _seen.add(path)
504
+ all_files.append(path)
505
+ else:
506
+ missing_files.append(path)
507
+ if missing_files:
508
+ print(
509
+ "[red bold]Error: The following file paths provided in the --file-list do not exist or are not files:[/red bold]"
510
+ )
511
+ for path in missing_files:
512
+ print(f"[red]- '{path}'[/red]")
513
+ raise typer.Exit(1)
456
514
 
457
515
  if not all_files:
458
516
  print(NO_FILES_SPECIFIED_MSG)
459
517
  raise typer.Exit(1)
460
518
 
519
+ _check_for_duplicate_file_names(all_files)
461
520
  _check_for_unsupported_files(all_files)
462
521
 
463
522
  uploaded_results = upload_files_command(
464
523
  project_name=project_config.name,
465
524
  divbase_base_url=logged_in_url,
466
- all_files=list(all_files),
525
+ all_files=all_files,
467
526
  safe_mode=not disable_safe_mode,
527
+ skip_existing=skip_existing,
528
+ dry_run=dry_run,
468
529
  )
469
530
 
531
+ if uploaded_results.skipped:
532
+ print("[yellow bold]\nThe following files were skipped: [/yellow bold]")
533
+ for file in uploaded_results.skipped:
534
+ print(f"[yellow]- '{file.object_name}' reason: {file.reason}[/yellow]")
535
+
470
536
  if uploaded_results.successful:
471
537
  print("[green bold]\nThe following files were successfully uploaded: [/green bold]")
472
- for object in uploaded_results.successful:
473
- print(f"- '{object.object_name}' created from file at: '{object.file_path.resolve()}'")
538
+ for file in uploaded_results.successful:
539
+ print(f"[green]- '{file.object_name}' created from file at: '{file.file_path.resolve()}'[/green]")
474
540
 
475
541
  if uploaded_results.failed:
476
542
  print("[red bold]\nERROR: Failed to upload the following files:[/red bold]")
477
543
  for failed in uploaded_results.failed:
478
544
  print(f"[red]- '{failed.object_name}': Exception: '{failed.exception}'[/red]")
479
-
480
545
  raise typer.Exit(1)
546
+ elif not uploaded_results.successful:
547
+ print("\nNo files were uploaded.")
548
+ else:
549
+ print("\n[green bold]All files uploaded successfully![/green bold]")
481
550
 
482
551
 
483
552
  @file_app.command("rm")
@@ -591,7 +660,32 @@ def _resolve_file_inputs(files: list[str] | None, file_list: Path | None) -> lis
591
660
  return list(all_files)
592
661
 
593
662
 
594
- def _check_for_unsupported_files(all_files: set[Path]) -> None:
663
+ def _check_for_duplicate_file_names(all_files: list[Path]) -> None:
664
+ """
665
+ Helper fn to check if any two files in the upload set share the same file name.
666
+ Since directory structure is not preserved on upload, duplicate names would overwrite each other.
667
+ """
668
+ name_to_paths: dict[str, list[Path]] = {}
669
+ for f in all_files:
670
+ if f.name not in name_to_paths:
671
+ name_to_paths[f.name] = []
672
+ name_to_paths[f.name].append(f)
673
+
674
+ duplicates = {name: paths for name, paths in name_to_paths.items() if len(paths) > 1}
675
+ if duplicates:
676
+ print(
677
+ "[red bold]Error: The following file names appear more than once in the upload list.[/red bold]\n"
678
+ "[red]Since directory structure is not preserved on upload, these files would collide:[/red]\n"
679
+ )
680
+ for name, paths in duplicates.items():
681
+ print(f"[red bold]'{name}':[/red bold]")
682
+ for p in paths:
683
+ print(f"[red]- {p.resolve()}[/red]")
684
+ print("\nPlease rename the files so each has a unique name before uploading.")
685
+ raise typer.Exit(1)
686
+
687
+
688
+ def _check_for_unsupported_files(all_files: list[Path]) -> None:
595
689
  """
596
690
  Helper fn to check if any of the files to be uploaded are not supported by DivBase.
597
691
  Raises error if so.
@@ -612,10 +706,24 @@ def _check_for_unsupported_files(all_files: set[Path]) -> None:
612
706
  unsupported_chars.append(file_path)
613
707
 
614
708
  if unsupported_file_types:
615
- raise UnsupportedFileTypeError(unsupported_files=unsupported_file_types)
709
+ print(
710
+ f"[red bold]Error: The following files' types are not supported by DivBase and therefore cannot be uploaded: [/red bold] \n"
711
+ f"[red]{'\n'.join(str(file) for file in unsupported_file_types)}\n\n[/red]"
712
+ f"DivBase currently supports the following file types: '{', '.join(SUPPORTED_DIVBASE_FILE_TYPES)}'\n"
713
+ "Note that while you can upload '.tbi' and '.csi' files they are not used by DivBase in queries, we create our own index files instead. \n"
714
+ "If you want us to support another file type, please let us know.",
715
+ )
616
716
 
617
717
  if unsupported_chars:
618
- raise UnsupportedFileNameError(unsupported_files=unsupported_chars)
718
+ print(
719
+ f"[red bold]Error: The following file(s) have unsupported characters in their filenames and therefore cannot be uploaded: [/red bold]\n"
720
+ f"[red]{'\n'.join(str(file) for file in unsupported_chars)}\n\n[/red]"
721
+ f"Filenames cannot contain any of the following characters: [green]{' '.join(UNSUPPORTED_CHARACTERS_IN_FILENAMES)} [/green]\n"
722
+ "Please rename the files and try again.",
723
+ )
724
+
725
+ if unsupported_file_types or unsupported_chars:
726
+ raise typer.Exit(1)
619
727
 
620
728
 
621
729
  def _pretty_print_download_results(download_results):