divbase-cli 0.1.0a12__tar.gz → 0.1.0a14__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.0a12 → divbase_cli-0.1.0a14}/PKG-INFO +4 -4
  2. {divbase_cli-0.1.0a12 → divbase_cli-0.1.0a14}/pyproject.toml +3 -3
  3. divbase_cli-0.1.0a14/src/divbase_cli/__init__.py +1 -0
  4. {divbase_cli-0.1.0a12 → divbase_cli-0.1.0a14}/src/divbase_cli/cli_commands/auth_cli.py +2 -2
  5. {divbase_cli-0.1.0a12 → divbase_cli-0.1.0a14}/src/divbase_cli/cli_commands/dimensions_cli.py +11 -18
  6. {divbase_cli-0.1.0a12 → divbase_cli-0.1.0a14}/src/divbase_cli/cli_commands/file_cli.py +50 -72
  7. {divbase_cli-0.1.0a12 → divbase_cli-0.1.0a14}/src/divbase_cli/cli_commands/query_cli.py +11 -17
  8. {divbase_cli-0.1.0a12 → divbase_cli-0.1.0a14}/src/divbase_cli/cli_commands/task_history_cli.py +6 -5
  9. {divbase_cli-0.1.0a12 → divbase_cli-0.1.0a14}/src/divbase_cli/cli_commands/version_cli.py +27 -39
  10. {divbase_cli-0.1.0a12 → divbase_cli-0.1.0a14}/src/divbase_cli/cli_exceptions.py +3 -6
  11. {divbase_cli-0.1.0a12 → divbase_cli-0.1.0a14}/src/divbase_cli/config_resolver.py +20 -25
  12. {divbase_cli-0.1.0a12 → divbase_cli-0.1.0a14}/src/divbase_cli/display_task_history.py +8 -22
  13. {divbase_cli-0.1.0a12 → divbase_cli-0.1.0a14}/src/divbase_cli/services/s3_files.py +1 -1
  14. {divbase_cli-0.1.0a12 → divbase_cli-0.1.0a14}/src/divbase_cli/user_auth.py +7 -7
  15. {divbase_cli-0.1.0a12 → divbase_cli-0.1.0a14}/src/divbase_cli/user_config.py +1 -1
  16. divbase_cli-0.1.0a12/src/divbase_cli/__init__.py +0 -1
  17. {divbase_cli-0.1.0a12 → divbase_cli-0.1.0a14}/.gitignore +0 -0
  18. {divbase_cli-0.1.0a12 → divbase_cli-0.1.0a14}/README.md +0 -0
  19. {divbase_cli-0.1.0a12 → divbase_cli-0.1.0a14}/src/divbase_cli/cli_commands/__init__.py +0 -0
  20. {divbase_cli-0.1.0a12 → divbase_cli-0.1.0a14}/src/divbase_cli/cli_commands/shared_args_options.py +0 -0
  21. {divbase_cli-0.1.0a12 → divbase_cli-0.1.0a14}/src/divbase_cli/cli_commands/user_config_cli.py +0 -0
  22. {divbase_cli-0.1.0a12 → divbase_cli-0.1.0a14}/src/divbase_cli/cli_config.py +0 -0
  23. {divbase_cli-0.1.0a12 → divbase_cli-0.1.0a14}/src/divbase_cli/divbase_cli.py +0 -0
  24. {divbase_cli-0.1.0a12 → divbase_cli-0.1.0a14}/src/divbase_cli/retries.py +0 -0
  25. {divbase_cli-0.1.0a12 → divbase_cli-0.1.0a14}/src/divbase_cli/services/__init__.py +0 -0
  26. {divbase_cli-0.1.0a12 → divbase_cli-0.1.0a14}/src/divbase_cli/services/announcements.py +0 -0
  27. {divbase_cli-0.1.0a12 → divbase_cli-0.1.0a14}/src/divbase_cli/services/pre_signed_urls.py +0 -0
  28. {divbase_cli-0.1.0a12 → divbase_cli-0.1.0a14}/src/divbase_cli/services/project_versions.py +0 -0
  29. {divbase_cli-0.1.0a12 → divbase_cli-0.1.0a14}/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.0a12
3
+ Version: 0.1.0a14
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,9 +18,9 @@ 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.0a12
22
- Requires-Dist: keyring>=25.7.0
23
- Requires-Dist: typer==0.26.7
21
+ Requires-Dist: divbase-lib==0.1.0a14
22
+ Requires-Dist: keyring==25.7.0
23
+ Requires-Dist: typer==0.26.8
24
24
  Description-Content-Type: text/markdown
25
25
 
26
26
  # divbase-cli
@@ -8,9 +8,9 @@ authors = [
8
8
  ]
9
9
  requires-python = ">=3.12"
10
10
  dependencies = [
11
- "typer==0.26.7",
12
- "keyring>=25.7.0",
13
- "divbase-lib==0.1.0a12",
11
+ "typer==0.26.8",
12
+ "keyring==25.7.0",
13
+ "divbase-lib==0.1.0a14",
14
14
  ]
15
15
  dynamic = ["version"]
16
16
 
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0a14"
@@ -4,7 +4,6 @@ CLI subcommand for managing user auth with DivBase server.
4
4
 
5
5
  import logging
6
6
  import time
7
- from datetime import datetime
8
7
 
9
8
  import typer
10
9
  from pydantic import SecretStr
@@ -31,6 +30,7 @@ from divbase_cli.user_auth import (
31
30
  )
32
31
  from divbase_cli.user_config import load_user_config
33
32
  from divbase_lib.divbase_constants import PAT_TOKEN_PREFIX
33
+ from divbase_lib.utils import format_unix_timestamp_for_cli
34
34
 
35
35
  logger = logging.getLogger(__name__)
36
36
 
@@ -63,7 +63,7 @@ def login(
63
63
  session_expires_at = check_existing_session(divbase_url=divbase_url, config=config)
64
64
  if session_expires_at:
65
65
  print(f"Already logged in to {divbase_url} with email: {config.logged_in_email}.")
66
- print(f"Session expires: {datetime.fromtimestamp(session_expires_at)}")
66
+ print(f"Session expires: {format_unix_timestamp_for_cli(unix_timestamp=session_expires_at)}")
67
67
 
68
68
  if not typer.confirm("Do you want to login again? This will replace your current session."):
69
69
  print("Login cancelled.")
@@ -1,5 +1,6 @@
1
1
  import csv
2
2
  import logging
3
+ from datetime import datetime
3
4
  from pathlib import Path
4
5
 
5
6
  import typer
@@ -8,7 +9,7 @@ from rich import print
8
9
  from rich.table import Table
9
10
 
10
11
  from divbase_cli.cli_commands.shared_args_options import PROJECT_NAME_OPTION
11
- from divbase_cli.config_resolver import ensure_logged_in, resolve_project
12
+ from divbase_cli.config_resolver import resolve_and_authenticate_project
12
13
  from divbase_cli.user_auth import make_authenticated_request
13
14
  from divbase_cli.utils import print_rich_table_as_tsv
14
15
  from divbase_lib.api_schemas.vcf_dimensions import (
@@ -18,6 +19,7 @@ from divbase_lib.api_schemas.vcf_dimensions import (
18
19
  DimensionsVCFFilesResult,
19
20
  )
20
21
  from divbase_lib.metadata_validator import SharedMetadataValidator
22
+ from divbase_lib.utils import format_datetime_for_cli
21
23
 
22
24
  logger = logging.getLogger(__name__)
23
25
 
@@ -33,9 +35,7 @@ def update_dimensions_index(
33
35
  project: str | None = PROJECT_NAME_OPTION,
34
36
  ) -> None:
35
37
  """Calculate and add the dimensions of all VCF files in the project to the dimensions index in the project."""
36
-
37
- project_config = resolve_project(project_name=project)
38
-
38
+ project_config = resolve_and_authenticate_project(project_name=project)
39
39
  response = make_authenticated_request(
40
40
  method="PUT",
41
41
  divbase_base_url=project_config.divbase_url,
@@ -99,16 +99,14 @@ def show_dimensions_index(
99
99
  Show the dimensions index file for a project.
100
100
  When running --unique-scaffolds, the sorting separates between numeric and non-numeric scaffold names.
101
101
  """
102
- project_config = resolve_project(project_name=project)
103
- logged_in_url = ensure_logged_in(desired_url=project_config.divbase_url)
104
-
105
102
  if sample_names_output and sample_names_stdout:
106
103
  raise typer.BadParameter("Use only one of --sample-names-output or --sample-names-stdout.")
107
104
 
105
+ project_config = resolve_and_authenticate_project(project_name=project)
108
106
  if unique_samples:
109
107
  response = make_authenticated_request(
110
108
  method="GET",
111
- divbase_base_url=logged_in_url,
109
+ divbase_base_url=project_config.divbase_url,
112
110
  api_route=f"v1/vcf-dimensions/projects/{project_config.name}/samples",
113
111
  )
114
112
  unique_sample_names_sorted = DimensionsSamplesResult(**response.json()).unique_samples
@@ -236,7 +234,7 @@ def _format_api_response_for_display_in_terminal(api_response: DimensionsShowRes
236
234
  dimensions_entry = {
237
235
  "filename": entry["vcf_file_s3_key"],
238
236
  "file_version_ID_in_bucket": entry["s3_version_id"],
239
- "last_updated": entry.get("updated_at"),
237
+ "last_updated": format_datetime_for_cli(datetime.fromisoformat(entry["updated_at"])),
240
238
  "dimensions": {
241
239
  "scaffold_count": len(sorted_scaffolds),
242
240
  "scaffolds": sorted_scaffolds,
@@ -278,12 +276,10 @@ def create_metadata_template_with_project_samples_names(
278
276
  """
279
277
  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.
280
278
  """
281
- project_config = resolve_project(project_name=project)
282
- logged_in_url = ensure_logged_in(desired_url=project_config.divbase_url)
283
-
279
+ project_config = resolve_and_authenticate_project(project_name=project)
284
280
  response = make_authenticated_request(
285
281
  method="GET",
286
- divbase_base_url=logged_in_url,
282
+ divbase_base_url=project_config.divbase_url,
287
283
  api_route=f"v1/vcf-dimensions/projects/{project_config.name}/samples",
288
284
  )
289
285
  unique_sample_names_sorted = DimensionsSamplesResult(**response.json()).unique_samples
@@ -342,16 +338,13 @@ def validate_metadata_template_versus_dimensions_and_formatting_constraints(
342
338
  # Client-side validation of a sidecar metadata TSV file, intended to be run before upload to DivBase.
343
339
  # Uses the SharedMetadataValidator (that is also used on the server-side) which checks for formatting errors and also validates that the sample names
344
340
  # in the TSV file match the sample names in the dimensions index for the project
345
-
346
- project_config = resolve_project(project_name=project)
347
- logged_in_url = ensure_logged_in(desired_url=project_config.divbase_url)
348
-
341
+ project_config = resolve_and_authenticate_project(project_name=project)
349
342
  print(f"Validating local metadata file: {input_path}")
350
343
  print(f"Project: {project_config.name}\n")
351
344
 
352
345
  response = make_authenticated_request(
353
346
  method="GET",
354
- divbase_base_url=logged_in_url,
347
+ divbase_base_url=project_config.divbase_url,
355
348
  api_route=f"v1/vcf-dimensions/projects/{project_config.name}/samples",
356
349
  )
357
350
  unique_sample_names = DimensionsSamplesResult(**response.json()).unique_samples
@@ -3,10 +3,8 @@ Command line interface for managing files in a DivBase project's store on DivBas
3
3
  """
4
4
 
5
5
  from collections import defaultdict
6
- from datetime import datetime
7
6
  from glob import glob
8
7
  from pathlib import Path
9
- from zoneinfo import ZoneInfo
10
8
 
11
9
  import typer
12
10
  from rich import print
@@ -24,7 +22,11 @@ from divbase_cli.cli_exceptions import (
24
22
  ShellExpandedGlobError,
25
23
  UnsupportedCharactersError,
26
24
  )
27
- from divbase_cli.config_resolver import ensure_logged_in, resolve_download_dir, resolve_project
25
+ from divbase_cli.config_resolver import (
26
+ resolve_and_authenticate_project,
27
+ resolve_download_dir,
28
+ )
29
+ from divbase_cli.services.pre_signed_urls import DownloadOutcome
28
30
  from divbase_cli.services.project_versions import get_version_details_command
29
31
  from divbase_cli.services.s3_files import (
30
32
  ToDownload,
@@ -48,14 +50,13 @@ from divbase_lib.divbase_constants import (
48
50
  UNSUPPORTED_CHARACTERS_DISPLAY,
49
51
  UNSUPPORTED_CHARACTERS_IN_FILENAMES,
50
52
  )
51
- from divbase_lib.utils import format_file_size
53
+ from divbase_lib.utils import format_datetime_for_cli, format_file_size
52
54
 
53
55
  file_app = typer.Typer(no_args_is_help=True, help="Download/upload/list files to/from the project's store on DivBase.")
54
56
 
55
57
  NO_FILES_SPECIFIED_MSG = "No files specified for the command, exiting..."
56
58
  NO_UPLOAD_MATCHES_MSG = "The following file paths or glob patterns did not match any existing files:"
57
59
 
58
-
59
60
  PREFIX_ARGUMENT = typer.Argument(
60
61
  None,
61
62
  help="Optional prefix to filter on. Use a '/' to specify a folder. e.g. 'vcfs/'",
@@ -143,12 +144,11 @@ def list_files(
143
144
  if detailed and format_output_as_tsv:
144
145
  raise typer.BadParameter(message="The --detailed option and --tsv option cannot be used together.")
145
146
 
146
- project_config = resolve_project(project_name=project)
147
- logged_in_url = ensure_logged_in(desired_url=project_config.divbase_url)
147
+ project_config = resolve_and_authenticate_project(project_name=project)
148
148
 
149
149
  if show_deleted_files:
150
150
  files = list_soft_deleted_files_command(
151
- divbase_base_url=logged_in_url,
151
+ divbase_base_url=project_config.divbase_url,
152
152
  project_name=project_config.name,
153
153
  prefix=prefix,
154
154
  )
@@ -159,17 +159,17 @@ def list_files(
159
159
 
160
160
  print(f"Soft deleted files for the project '{project_config.name}':")
161
161
  for file_details in files:
162
- cet_timestamp = _format_time_stamp(file_details.last_modified)
162
+ deleted_at = format_datetime_for_cli(dt=file_details.last_modified)
163
163
  if file_details.name.endswith("/"):
164
- print(f"- [bold blue]'{file_details.name}'[/bold blue] (deleted at: '{cet_timestamp}')")
164
+ print(f"- [bold blue]'{file_details.name}'[/bold blue] (deleted at: '{deleted_at}')")
165
165
  else:
166
- print(f"- '{file_details.name}' (deleted at: '{cet_timestamp}')")
166
+ print(f"- '{file_details.name}' (deleted at: '{deleted_at}')")
167
167
  print("\nTo restore a soft deleted file, use the 'divbase-cli files restore' command.")
168
168
  print("To get more information about one of the soft deleted files, use the 'divbase-cli files info' command.")
169
169
  return
170
170
 
171
171
  files, folders = list_files_command(
172
- divbase_base_url=logged_in_url,
172
+ divbase_base_url=project_config.divbase_url,
173
173
  project_name=project_config.name,
174
174
  prefix=prefix,
175
175
  include_results_files=include_results_files,
@@ -211,17 +211,14 @@ def tree_files(
211
211
  - Show the tree starting from the 'vcfs/' folder:
212
212
  divbase-cli files tree vcfs/
213
213
  """
214
- project_config = resolve_project(project_name=project)
215
- logged_in_url = ensure_logged_in(desired_url=project_config.divbase_url)
216
-
214
+ project_config = resolve_and_authenticate_project(project_name=project)
217
215
  files, _ = list_files_command(
218
- divbase_base_url=logged_in_url,
216
+ divbase_base_url=project_config.divbase_url,
219
217
  project_name=project_config.name,
220
218
  prefix=prefix,
221
219
  include_results_files=include_results_files,
222
220
  file_system_view=False,
223
221
  )
224
-
225
222
  if not files:
226
223
  print("No files found in the project's store on DivBase.")
227
224
  return
@@ -247,16 +244,14 @@ def file_info(
247
244
  - Get information about a specific file in the project:
248
245
  divbase-cli files info my_file.vcf.gz
249
246
  """
250
- project_config = resolve_project(project_name=project)
251
- logged_in_url = ensure_logged_in(desired_url=project_config.divbase_url)
252
-
253
247
  if file_name.endswith("/"):
254
248
  raise typer.BadParameter(
255
249
  message="The 'info' command is only for files, not folders. Please provide a file name without a trailing '/'."
256
250
  )
257
251
 
252
+ project_config = resolve_and_authenticate_project(project_name=project)
258
253
  file_info = get_file_info_command(
259
- divbase_base_url=logged_in_url,
254
+ divbase_base_url=project_config.divbase_url,
260
255
  project_name=project_config.name,
261
256
  object_name=file_name,
262
257
  )
@@ -278,16 +273,16 @@ def file_info(
278
273
  caption="Versions shown are ordered with the latest/current version first/at the top",
279
274
  )
280
275
  table.add_column("File size", justify="left", style="magenta", no_wrap=True)
281
- table.add_column("Upload date (CET)", justify="left", style="green", no_wrap=True)
276
+ table.add_column("Upload date", justify="left", style="green", no_wrap=True)
282
277
  table.add_column("MD5 checksum", justify="left", style="yellow")
283
278
  table.add_column("Version ID", justify="left", style="cyan")
284
279
 
285
280
  for version in file_info.versions:
286
- cet_timestamp = _format_time_stamp(version.last_modified)
281
+ upload_date = format_datetime_for_cli(dt=version.last_modified)
287
282
  file_size = format_file_size(size_bytes=version.size)
288
283
  table.add_row(
289
284
  file_size,
290
- cet_timestamp,
285
+ upload_date,
291
286
  version.etag,
292
287
  version.version_id,
293
288
  )
@@ -336,27 +331,26 @@ def download_files(
336
331
  - Mix latest and versioned files in one command:
337
332
  divbase-cli files download file1.vcf.gz "file2.tsv:VERSION_ID"
338
333
  """
339
- project_config = resolve_project(project_name=project)
340
- logged_in_url = ensure_logged_in(desired_url=project_config.divbase_url)
341
- download_dir_path = resolve_download_dir(download_dir=download_dir)
342
-
343
334
  if project_version == "":
344
335
  raise typer.BadParameter(
345
336
  "It seems like you provided an empty string for --project-version. \n"
346
337
  "Please provide a valid project version or omit the flag."
347
338
  )
348
339
 
340
+ project_config = resolve_and_authenticate_project(project_name=project)
341
+ download_dir_path = resolve_download_dir(download_dir=download_dir)
342
+
349
343
  raw_files_input = _resolve_file_inputs(files=files, file_list=file_list)
350
344
  raw_files_input = _expand_folder_prefixes(
351
345
  files=raw_files_input,
352
- divbase_base_url=logged_in_url,
346
+ divbase_base_url=project_config.divbase_url,
353
347
  project_name=project_config.name,
354
348
  )
355
349
  if flatten:
356
350
  check_no_overlap_on_flattened_downloads(to_download=raw_files_input)
357
351
 
358
352
  download_results = download_files_command(
359
- divbase_base_url=logged_in_url,
353
+ divbase_base_url=project_config.divbase_url,
360
354
  project_name=project_config.name,
361
355
  raw_files_input=raw_files_input,
362
356
  download_dir=download_dir_path,
@@ -416,14 +410,15 @@ def download_all_files(
416
410
  "Please provide a valid project version or omit the flag."
417
411
  )
418
412
 
419
- project_config = resolve_project(project_name=project)
420
- logged_in_url = ensure_logged_in(desired_url=project_config.divbase_url)
413
+ project_config = resolve_and_authenticate_project(project_name=project)
421
414
  download_dir_path = resolve_download_dir(download_dir=download_dir)
422
415
 
423
416
  all_files: list[ToDownload] = []
424
417
  if project_version:
425
418
  version_details = get_version_details_command(
426
- project_name=project_config.name, divbase_base_url=logged_in_url, version_name=project_version
419
+ project_name=project_config.name,
420
+ divbase_base_url=project_config.divbase_url,
421
+ version_name=project_version,
427
422
  )
428
423
  for version_name, file_details in version_details.files.items():
429
424
  all_files.append(
@@ -437,7 +432,7 @@ def download_all_files(
437
432
 
438
433
  else:
439
434
  files, _ = list_files_command(
440
- divbase_base_url=logged_in_url,
435
+ divbase_base_url=project_config.divbase_url,
441
436
  project_name=project_config.name,
442
437
  prefix=None,
443
438
  include_results_files=False,
@@ -502,7 +497,7 @@ def download_all_files(
502
497
  else:
503
498
  raw_files_input = [file.s3_key for file in files_to_download]
504
499
  download_results = download_files_command(
505
- divbase_base_url=logged_in_url,
500
+ divbase_base_url=project_config.divbase_url,
506
501
  project_name=project_config.name,
507
502
  raw_files_input=raw_files_input,
508
503
  download_dir=download_dir_path,
@@ -542,11 +537,9 @@ def stream_file(
542
537
  - Run a bcftools command (the "-" tells bcftools to read from standard input):
543
538
  divbase-cli files stream my_file.vcf.gz | bcftools view -h -
544
539
  """
545
- project_config = resolve_project(project_name=project)
546
- logged_in_url = ensure_logged_in(desired_url=project_config.divbase_url)
547
-
540
+ project_config = resolve_and_authenticate_project(project_name=project)
548
541
  stream_file_command(
549
- divbase_base_url=logged_in_url,
542
+ divbase_base_url=project_config.divbase_url,
550
543
  project_name=project_config.name,
551
544
  file_name=file_name,
552
545
  version_id=version_id,
@@ -621,19 +614,15 @@ def upload_files(
621
614
  - Upload from a text file list (one file path per line):
622
615
  divbase-cli files upload --file-list files_to_upload.txt --to experiment1/
623
616
  """
624
- project_config = resolve_project(project_name=project)
625
- logged_in_url = ensure_logged_in(desired_url=project_config.divbase_url)
626
-
627
617
  if bool(files) + bool(file_list) > 1:
628
618
  raise typer.BadParameter("You cannot specify files as arguments and provide a --file-list.")
629
-
630
619
  if skip_existing and disable_safe_mode:
631
620
  raise typer.BadParameter(
632
621
  "The --skip-existing and --disable-safe-mode options cannot be used together.\n"
633
622
  "Safe mode calculates file checksums, "
634
623
  "and these checksums are needed for --skip-existing to know what files to skip uploading."
635
624
  )
636
-
625
+ project_config = resolve_and_authenticate_project(project_name=project)
637
626
  to_upload = _resolve_user_upload_inputs(
638
627
  files=files,
639
628
  file_list=file_list,
@@ -645,7 +634,7 @@ def upload_files(
645
634
 
646
635
  uploaded_results = upload_files_command(
647
636
  project_name=project_config.name,
648
- divbase_base_url=logged_in_url,
637
+ divbase_base_url=project_config.divbase_url,
649
638
  all_files=to_upload,
650
639
  safe_mode=not disable_safe_mode,
651
640
  skip_existing=skip_existing,
@@ -697,13 +686,12 @@ def make_directory(
697
686
  - Create multiple directories at once:
698
687
  divbase-cli files mkdir vcfs metadata results
699
688
  """
700
- project_config = resolve_project(project_name=project)
701
- logged_in_url = ensure_logged_in(desired_url=project_config.divbase_url)
702
-
689
+ project_config = resolve_and_authenticate_project(project_name=project)
703
690
  cleaned_dir_names = _sanitize_directory_names(directories)
691
+
704
692
  dirs_created = make_directories_command(
705
693
  directories=cleaned_dir_names,
706
- divbase_base_url=logged_in_url,
694
+ divbase_base_url=project_config.divbase_url,
707
695
  project_name=project_config.name,
708
696
  )
709
697
  if dirs_created.created:
@@ -741,13 +729,12 @@ def remove_directory(
741
729
  - Remove multiple directories at once:
742
730
  divbase-cli files rmdir vcfs/ metadata/
743
731
  """
744
- project_config = resolve_project(project_name=project)
745
- logged_in_url = ensure_logged_in(desired_url=project_config.divbase_url)
746
-
732
+ project_config = resolve_and_authenticate_project(project_name=project)
747
733
  directories = _sanitize_directory_names(directories)
734
+
748
735
  for dir in directories:
749
736
  files, _ = list_files_command(
750
- divbase_base_url=logged_in_url,
737
+ divbase_base_url=project_config.divbase_url,
751
738
  project_name=project_config.name,
752
739
  prefix=dir,
753
740
  include_results_files=True,
@@ -763,7 +750,7 @@ def remove_directory(
763
750
 
764
751
  # NOTE: as dirs are just empty files which end with "/", we can just do a regular soft delete on them.
765
752
  deleted_dirs = soft_delete_objects_command(
766
- divbase_base_url=logged_in_url,
753
+ divbase_base_url=project_config.divbase_url,
767
754
  project_name=project_config.name,
768
755
  all_files=directories,
769
756
  )
@@ -800,9 +787,7 @@ def remove_files(
800
787
  - Run a dry run to see what files would be deleted without actually deleting them:
801
788
  divbase-cli files rm file1.vcf.gz file2.tsv --dry-run
802
789
  """
803
- project_config = resolve_project(project_name=project)
804
- logged_in_url = ensure_logged_in(desired_url=project_config.divbase_url)
805
-
790
+ project_config = resolve_and_authenticate_project(project_name=project)
806
791
  all_files = _resolve_file_inputs(files=files, file_list=file_list)
807
792
 
808
793
  if dry_run:
@@ -812,7 +797,7 @@ def remove_files(
812
797
  return
813
798
 
814
799
  deleted_files = soft_delete_objects_command(
815
- divbase_base_url=logged_in_url,
800
+ divbase_base_url=project_config.divbase_url,
816
801
  project_name=project_config.name,
817
802
  all_files=all_files,
818
803
  )
@@ -845,13 +830,11 @@ def restore_soft_deleted_files(
845
830
  - Restore files from a text file list (one file name per line):
846
831
  divbase-cli files restore --file-list files_to_restore.txt
847
832
  """
848
- project_config = resolve_project(project_name=project)
849
- logged_in_url = ensure_logged_in(desired_url=project_config.divbase_url)
850
-
833
+ project_config = resolve_and_authenticate_project(project_name=project)
851
834
  all_files = _resolve_file_inputs(files=files, file_list=file_list)
852
835
 
853
836
  restored_objects_response = restore_objects_command(
854
- divbase_base_url=logged_in_url,
837
+ divbase_base_url=project_config.divbase_url,
855
838
  project_name=project_config.name,
856
839
  all_files=all_files,
857
840
  )
@@ -923,8 +906,8 @@ def _print_ls_tsv_format(files: list[ObjectDetails], folders: list[str]) -> None
923
906
  for folder in folders:
924
907
  print(f"{markup_escape(folder)}\t-\t-")
925
908
  for f in files:
926
- cet = _format_time_stamp(f.last_modified)
927
- print(f"{markup_escape(f.name)}\t{format_file_size(f.size)}\t{cet}")
909
+ last_modified = format_datetime_for_cli(dt=f.last_modified)
910
+ print(f"{markup_escape(f.name)}\t{format_file_size(f.size)}\t{last_modified}")
928
911
 
929
912
 
930
913
  def _print_ls_detailed(files: list[ObjectDetails], folders: list[str]) -> None:
@@ -934,8 +917,8 @@ def _print_ls_detailed(files: list[ObjectDetails], folders: list[str]) -> None:
934
917
  console.print(f"[bold blue]{markup_escape(folder)}[/bold blue]")
935
918
  for f in files:
936
919
  size_str = format_file_size(f.size).rjust(10)
937
- cet = _format_time_stamp(f.last_modified)
938
- console.print(f"{markup_escape(f.name)} [magenta]{size_str}[/magenta] [green]{cet}[/green]")
920
+ last_modified = format_datetime_for_cli(dt=f.last_modified)
921
+ console.print(f"{markup_escape(f.name)} [magenta]{size_str}[/magenta] [green]{last_modified}[/green]")
939
922
 
940
923
 
941
924
  def _resolve_file_inputs(files: list[str] | None, file_list: Path | None) -> list[str]:
@@ -1191,7 +1174,7 @@ def _sanitize_directory_names(directories: list[str]) -> list[str]:
1191
1174
  return cleaned_dir_names
1192
1175
 
1193
1176
 
1194
- def _pretty_print_download_results(download_results):
1177
+ def _pretty_print_download_results(download_results: DownloadOutcome) -> None:
1195
1178
  """Helper fn used by download and download all commands to print the results of the download in a nice format."""
1196
1179
  if download_results.successful:
1197
1180
  print("\n[green bold]Successfully downloaded the following files:[/green bold]")
@@ -1203,8 +1186,3 @@ def _pretty_print_download_results(download_results):
1203
1186
  for failed in download_results.failed:
1204
1187
  print(f"[red]- '{failed.object_name}': [/red] Exception: '{failed.exception}'")
1205
1188
  raise typer.Exit(1)
1206
-
1207
-
1208
- def _format_time_stamp(time_stamp: datetime) -> str:
1209
- """Helper fn to format a datetime object into a string for printing."""
1210
- return time_stamp.astimezone(ZoneInfo("CET")).strftime("%Y-%m-%d %H:%M %Z")
@@ -23,9 +23,8 @@ from divbase_cli.cli_commands.shared_args_options import DOWNLOAD_DIR_OPTION, PR
23
23
  from divbase_cli.cli_config import cli_settings
24
24
  from divbase_cli.cli_exceptions import PolledTaskNotFinalError, QueryTaskFailedError
25
25
  from divbase_cli.config_resolver import (
26
- ensure_logged_in,
26
+ resolve_and_authenticate_project,
27
27
  resolve_download_dir,
28
- resolve_project,
29
28
  )
30
29
  from divbase_cli.retries import (
31
30
  retry_polling_until_final_or_retryable_api_errors,
@@ -110,19 +109,16 @@ def sample_metadata_query(
110
109
  Query the tsv sidecar metadata file for the VCF files in the project's data store on DivBase.
111
110
  Returns the sample IDs and filenames that match the query.
112
111
  """
113
- project_config = resolve_project(project_name=project)
114
- logged_in_url = ensure_logged_in(desired_url=project_config.divbase_url)
115
-
112
+ project_config = resolve_and_authenticate_project(project_name=project)
116
113
  request_data = SampleMetadataQueryRequest(tsv_filter=tsv_filter, metadata_tsv_name=metadata_tsv_name)
117
114
 
118
115
  response = make_authenticated_request(
119
116
  method="POST",
120
- divbase_base_url=logged_in_url,
117
+ divbase_base_url=project_config.divbase_url,
121
118
  api_route=f"v1/query/sample-metadata/projects/{project_config.name}",
122
119
  json=request_data.model_dump(),
123
120
  timeout=20, # This is longer than default (5), as api call response is query result, not a task-id.
124
121
  )
125
-
126
122
  results = SampleMetadataQueryTaskResult(**response.json())
127
123
 
128
124
  if results.warnings:
@@ -211,9 +207,7 @@ def vcf_query(
211
207
  print(f" • {warning}")
212
208
  print()
213
209
 
214
- project_config = resolve_project(project_name=project)
215
- logged_in_url = ensure_logged_in(desired_url=project_config.divbase_url)
216
-
210
+ project_config = resolve_and_authenticate_project(project_name=project)
217
211
  request_data = BcftoolsQueryRequest(
218
212
  tsv_filter=tsv_filter,
219
213
  command=command,
@@ -221,10 +215,9 @@ def vcf_query(
221
215
  samples=normalized_samples,
222
216
  all_samples=all_samples,
223
217
  )
224
-
225
218
  response = make_authenticated_request(
226
219
  method="POST",
227
- divbase_base_url=logged_in_url,
220
+ divbase_base_url=project_config.divbase_url,
228
221
  api_route=f"v1/query/vcf/projects/{project_config.name}",
229
222
  json=request_data.model_dump(),
230
223
  )
@@ -260,11 +253,12 @@ def get_results_from_query_job_by_task_id(
260
253
  f"--max-wait-mins must be between 1 and {MAX_WAIT_MINS_HARD_LIMIT} minutes, got {max_wait_mins}."
261
254
  )
262
255
 
263
- project_config = resolve_project(project_name=project)
264
- logged_in_url = ensure_logged_in(desired_url=project_config.divbase_url)
256
+ project_config = resolve_and_authenticate_project(project_name=project)
265
257
 
266
258
  task_status = poll_task_until_final_state_reached(
267
- divbase_url=logged_in_url, task_id=task_id, timeout_mins=max_wait_mins
259
+ divbase_url=project_config.divbase_url,
260
+ task_id=task_id,
261
+ timeout_mins=max_wait_mins,
268
262
  )
269
263
 
270
264
  resolved_download_dir = resolve_download_dir(download_dir=download_dir)
@@ -273,7 +267,7 @@ def get_results_from_query_job_by_task_id(
273
267
  if task_status == "SUCCESS":
274
268
  result_filename = f"{QUERY_RESULTS_FILE_PREFIX}{task_id}.vcf.gz"
275
269
  download_results = download_files_command(
276
- divbase_base_url=logged_in_url,
270
+ divbase_base_url=project_config.divbase_url,
277
271
  project_name=project_config.name,
278
272
  raw_files_input=[result_filename],
279
273
  download_dir=resolved_download_dir,
@@ -328,7 +322,7 @@ def poll_task_until_final_state_reached(divbase_url: str, task_id: int, timeout_
328
322
  f"Task {task_id} has unsupported task type '{task_results.name}'. Only VCF query jobs are supported for this CLI command."
329
323
  )
330
324
 
331
- if task_results.status in FINAL_STATES:
325
+ if task_results.status and task_results.status in FINAL_STATES:
332
326
  return task_results.status
333
327
 
334
328
  raise PolledTaskNotFinalError(f"Task {task_id} state is {task_results.status}")
@@ -10,7 +10,10 @@ import logging
10
10
  import typer
11
11
 
12
12
  from divbase_cli.cli_commands.shared_args_options import FORMAT_AS_TSV_OPTION
13
- from divbase_cli.config_resolver import ensure_logged_in, resolve_project, resolve_url_for_non_project_specific_commands
13
+ from divbase_cli.config_resolver import (
14
+ resolve_and_authenticate_project,
15
+ resolve_url_for_non_project_specific_commands,
16
+ )
14
17
  from divbase_cli.display_task_history import TaskHistoryDisplayManager
15
18
  from divbase_cli.user_auth import make_authenticated_request
16
19
  from divbase_cli.user_config import load_user_config
@@ -104,15 +107,13 @@ def list_task_history_for_project(
104
107
  Check status of all tasks submitted for a project. Requires a manager role in the project. Displays the latest 10 tasks by default, unless --limit is specified.
105
108
  """
106
109
  # TODO add option to sort ASC/DESC by task timestamp
107
- project_config = resolve_project(project_name=project)
108
- logged_in_url = ensure_logged_in(desired_url=project_config.divbase_url)
110
+ project_config = resolve_and_authenticate_project(project_name=project)
109
111
 
110
112
  task_history_response = make_authenticated_request(
111
113
  method="GET",
112
- divbase_base_url=logged_in_url,
114
+ divbase_base_url=project_config.divbase_url,
113
115
  api_route=f"v1/task-history/projects/{project_config.name}",
114
116
  )
115
-
116
117
  task_history_data = [TaskHistoryResult(**item) for item in task_history_response.json()]
117
118
 
118
119
  TaskHistoryDisplayManager(
@@ -1,14 +1,11 @@
1
1
  """CLI commands for managing project versions in DivBase."""
2
2
 
3
- from datetime import datetime
4
- from zoneinfo import ZoneInfo
5
-
6
3
  import typer
7
4
  from rich import print
8
5
  from rich.table import Table
9
6
 
10
7
  from divbase_cli.cli_commands.shared_args_options import FORMAT_AS_TSV_OPTION, PROJECT_NAME_OPTION
11
- from divbase_cli.config_resolver import ensure_logged_in, resolve_project
8
+ from divbase_cli.config_resolver import resolve_and_authenticate_project
12
9
  from divbase_cli.services.project_versions import (
13
10
  add_version_command,
14
11
  delete_version_command,
@@ -17,7 +14,7 @@ from divbase_cli.services.project_versions import (
17
14
  update_version_command,
18
15
  )
19
16
  from divbase_cli.utils import print_rich_table_as_tsv
20
- from divbase_lib.utils import format_file_size
17
+ from divbase_lib.utils import format_datetime_for_cli, format_file_size
21
18
 
22
19
  version_app = typer.Typer(
23
20
  no_args_is_help=True,
@@ -25,13 +22,6 @@ version_app = typer.Typer(
25
22
  )
26
23
 
27
24
 
28
- def format_timestamp(timestamp_str: str) -> str:
29
- """Format ISO timestamp to Europe/Stockholm format with timezone"""
30
- dt = datetime.fromisoformat(timestamp_str)
31
- cet_dt = dt.astimezone(ZoneInfo("Europe/Stockholm"))
32
- return cet_dt.strftime("%d/%m/%Y %H:%M:%S %Z")
33
-
34
-
35
25
  @version_app.command("add")
36
26
  def add_version(
37
27
  name: str = typer.Argument(help="Name of the version (e.g., semantic version).", show_default=False),
@@ -39,14 +29,12 @@ def add_version(
39
29
  project: str | None = PROJECT_NAME_OPTION,
40
30
  ):
41
31
  """Add a new project version entry which specifies the current state of all files in the project at the current timestamp."""
42
- project_config = resolve_project(project_name=project)
43
- logged_in_url = ensure_logged_in(desired_url=project_config.divbase_url)
44
-
32
+ project_config = resolve_and_authenticate_project(project_name=project)
45
33
  add_version_response = add_version_command(
46
34
  name=name,
47
35
  description=description,
48
36
  project_name=project_config.name,
49
- divbase_base_url=logged_in_url,
37
+ divbase_base_url=project_config.divbase_url,
50
38
  )
51
39
  print(f"New version: '{add_version_response.name}' added to the project: '{project_config.name}'")
52
40
 
@@ -75,17 +63,14 @@ def update_version(
75
63
  To see your current version entries run: `divbase-cli version ls`
76
64
  """
77
65
  if not new_name and not new_description:
78
- print(
66
+ raise typer.BadParameter(
79
67
  "No updates specified. Please provide a new name (--new-name) and/or description (--new-description) to update for this entry."
80
68
  )
81
- return
82
-
83
- project_config = resolve_project(project_name=project)
84
- logged_in_url = ensure_logged_in(desired_url=project_config.divbase_url)
85
69
 
70
+ project_config = resolve_and_authenticate_project(project_name=project)
86
71
  updated_version = update_version_command(
87
72
  project_name=project_config.name,
88
- divbase_base_url=logged_in_url,
73
+ divbase_base_url=project_config.divbase_url,
89
74
  version_name=version_name,
90
75
  new_name=new_name,
91
76
  new_description=new_description,
@@ -111,11 +96,11 @@ def list_versions(
111
96
  If you specify --include-deleted, soft-deleted versions will also be shown.
112
97
  Soft-deleted versions can be restored by a DivBase admin within 30 days of deletion.
113
98
  """
114
- project_config = resolve_project(project_name=project)
115
- logged_in_url = ensure_logged_in(desired_url=project_config.divbase_url)
116
-
99
+ project_config = resolve_and_authenticate_project(project_name=project)
117
100
  versions_info = list_versions_command(
118
- project_name=project_config.name, include_deleted=include_deleted, divbase_base_url=logged_in_url
101
+ project_name=project_config.name,
102
+ include_deleted=include_deleted,
103
+ divbase_base_url=project_config.divbase_url,
119
104
  )
120
105
 
121
106
  if not versions_info:
@@ -125,6 +110,7 @@ def list_versions(
125
110
  table = Table(title=f"Versions for {project_config.name}")
126
111
  table.add_column("Version", style="cyan", no_wrap=True)
127
112
  table.add_column("Created ", style="magenta")
113
+ table.add_column("Last Updated", style="blue")
128
114
  table.add_column("Description", style="green")
129
115
  if include_deleted:
130
116
  table.add_column("Soft Deleted", style="red")
@@ -132,12 +118,13 @@ def list_versions(
132
118
  for version in versions_info:
133
119
  name = version.name
134
120
  desc = version.description or "No description provided"
135
- created_at = format_timestamp(version.created_at)
121
+ created_at = format_datetime_for_cli(dt=version.created_at)
122
+ updated_at = format_datetime_for_cli(dt=version.updated_at)
136
123
  if include_deleted:
137
124
  soft_deleted = "Yes" if version.is_deleted else "No"
138
- table.add_row(name, created_at, desc, soft_deleted)
125
+ table.add_row(name, created_at, updated_at, desc, soft_deleted)
139
126
  else:
140
- table.add_row(name, created_at, desc)
127
+ table.add_row(name, created_at, updated_at, desc)
141
128
 
142
129
  if not format_output_as_tsv:
143
130
  print(table)
@@ -154,11 +141,11 @@ def get_version_info(
154
141
  """
155
142
  Provide detailed information about a user specified project version, including all files present and their unique hashes.
156
143
  """
157
- project_config = resolve_project(project_name=project)
158
- logged_in_url = ensure_logged_in(desired_url=project_config.divbase_url)
159
-
144
+ project_config = resolve_and_authenticate_project(project_name=project)
160
145
  version_details = get_version_details_command(
161
- project_name=project_config.name, divbase_base_url=logged_in_url, version_name=version
146
+ project_name=project_config.name,
147
+ divbase_base_url=project_config.divbase_url,
148
+ version_name=version,
162
149
  )
163
150
 
164
151
  table = Table(title=f"Project version files for {project_config.name}")
@@ -178,7 +165,8 @@ def get_version_info(
178
165
 
179
166
  if not format_output_as_tsv:
180
167
  print(f"Project version entry for project: '{project_config.name}' with name: '{version_details.name}'")
181
- print(f"Entry created at: {format_timestamp(version_details.created_at)}")
168
+ print(f"Entry created at: {format_datetime_for_cli(dt=version_details.created_at)}")
169
+ print(f"Entry last updated at: {format_datetime_for_cli(dt=version_details.updated_at)}")
182
170
  if version_details.description:
183
171
  print(f"Description: {version_details.description}")
184
172
  if version_details.is_deleted:
@@ -201,14 +189,14 @@ def delete_version(
201
189
  Deleted version entries older than 30 days will be permanently deleted.
202
190
  You can ask a DivBase admin to restore a deleted version within that time period.
203
191
  """
204
- project_config = resolve_project(project_name=project)
205
- logged_in_url = ensure_logged_in(desired_url=project_config.divbase_url)
206
-
192
+ project_config = resolve_and_authenticate_project(project_name=project)
207
193
  deleted_version = delete_version_command(
208
- project_name=project_config.name, divbase_base_url=logged_in_url, version_name=name
194
+ project_name=project_config.name,
195
+ divbase_base_url=project_config.divbase_url,
196
+ version_name=name,
209
197
  )
210
198
  if deleted_version.already_deleted:
211
- date_deleted = format_timestamp(deleted_version.date_deleted)
199
+ date_deleted = format_datetime_for_cli(dt=deleted_version.date_deleted)
212
200
  print(f"The version: '{deleted_version.name}' has already been soft-deleted on {date_deleted}.")
213
201
  else:
214
202
  print(f"The version: '{deleted_version.name}' was deleted from the project: '{project_config.name}'")
@@ -2,8 +2,6 @@
2
2
  Custom exceptions for the divbase CLI.
3
3
  """
4
4
 
5
- from pathlib import Path
6
-
7
5
 
8
6
  class DivBaseCLIError(Exception):
9
7
  """Base exception for all divbase CLI errors."""
@@ -173,13 +171,12 @@ class ProjectNotInConfigError(DivBaseCLIError):
173
171
  But info about the project could not be obtained from the user config file.
174
172
  """
175
173
 
176
- def __init__(self, config_path: Path, project_name: str):
177
- self.config_path = config_path
174
+ def __init__(self, project_name: str):
178
175
  self.project_name = project_name
179
176
  error_message = (
180
177
  f"Couldn't get information about the project named: '{project_name}' \n"
181
- f"Please check the project is included in '{config_path.resolve()}'.\n"
182
- f"you can run 'divbase-cli config show' to view the contents of your config file.\n"
178
+ f"Use the command 'divbase-cli config add <project_name>' to add the project to your config.\n"
179
+ f"You can run 'divbase-cli config show' to view the contents of your config file.\n"
183
180
  )
184
181
  super().__init__(error_message)
185
182
 
@@ -14,22 +14,33 @@ from divbase_cli.user_auth import get_pat_for_authentication
14
14
  from divbase_cli.user_config import ProjectConfig, load_user_config
15
15
 
16
16
 
17
- def ensure_logged_in(desired_url: str | None = None) -> str:
17
+ def resolve_and_authenticate_project(project_name: str | None) -> ProjectConfig:
18
18
  """
19
- Ensure the user is logged in by checking the logged_in_url value in the user config.
19
+ Helper function that resolves the project to use for a CLI command and ensures
20
+ the user is logged in to the correct DivBase API URL for that project.
20
21
 
21
- Optionally checks the logged_in_url matches the desired_url (e.g. for project-specific commands) if provided.
22
+ Returns the ProjectConfig object for the resolved project, will raise errors otherwise.
22
23
  """
23
- config = load_user_config()
24
- if config.logged_in_url:
25
- if desired_url and config.logged_in_url != desired_url:
24
+ user_config = load_user_config()
25
+ if not project_name:
26
+ project_name = user_config.default_project
27
+ if not project_name:
28
+ raise ProjectNameNotSpecifiedError()
29
+
30
+ project_config = user_config.project_info(project_name)
31
+
32
+ if user_config.logged_in_url:
33
+ if project_config.divbase_url != user_config.logged_in_url:
26
34
  raise AuthenticationError(
27
- f"You are not logged in to the correct DivBase URL: {desired_url}. Please log in again."
35
+ f"You are trying to run a command for the project '{project_name}' which has the DivBase URL: {project_config.divbase_url} \n"
36
+ f"You are however logged in to this URL: {user_config.logged_in_url} \n"
37
+ "Please log in again."
28
38
  )
29
- return config.logged_in_url
39
+ return project_config
30
40
 
31
41
  if get_pat_for_authentication():
32
- return desired_url or cli_settings.DIVBASE_API_URL
42
+ # if a user has a PAT, we will assume the PAT is for the correct URL
43
+ return project_config
33
44
 
34
45
  raise AuthenticationError("You are not logged in. Please log in with 'divbase-cli auth login [EMAIL]'.")
35
46
 
@@ -54,22 +65,6 @@ def resolve_url_for_non_project_specific_commands() -> str:
54
65
  raise AuthenticationError("You are not logged in. Please log in with 'divbase-cli auth login [EMAIL]'.")
55
66
 
56
67
 
57
- def resolve_project(project_name: str | None) -> ProjectConfig:
58
- """
59
- Helper function to resolve the project to use for a CLI command.
60
- Falls back to the default project set in the user config if not explicitly provided.
61
-
62
- Once the project is resolved a ProjectConfig object is returned,
63
- which contains the name and API URL of the project.
64
- """
65
- config = load_user_config()
66
- if not project_name:
67
- project_name = config.default_project
68
- if not project_name:
69
- raise ProjectNameNotSpecifiedError()
70
- return config.project_info(project_name)
71
-
72
-
73
68
  def resolve_download_dir(download_dir: str | None) -> Path:
74
69
  """
75
70
  Helper function to resolve the download directory to use for a CLI command involving downloading files.
@@ -1,6 +1,4 @@
1
1
  import logging
2
- from datetime import datetime
3
- from zoneinfo import ZoneInfo
4
2
 
5
3
  from rich.console import Console
6
4
  from rich.table import Table
@@ -12,6 +10,7 @@ from divbase_lib.api_schemas.task_history import (
12
10
  SampleMetadataQueryTaskResult,
13
11
  TaskHistoryResult,
14
12
  )
13
+ from divbase_lib.utils import format_datetime_for_cli
15
14
 
16
15
  logger = logging.getLogger(__name__)
17
16
 
@@ -53,7 +52,7 @@ class TaskHistoryDisplayManager:
53
52
  def print_task_history(self) -> None:
54
53
  """Display the task history fetched from the results backend in a formatted table."""
55
54
 
56
- sorted_tasks = sorted(self.task_items, key=lambda x: x.created_at or "", reverse=True)
55
+ sorted_tasks = sorted(self.task_items, key=lambda x: x.created_at, reverse=True)
57
56
  display_limit = self.display_limit
58
57
  limited_tasks = sorted_tasks[:display_limit]
59
58
 
@@ -72,16 +71,16 @@ class TaskHistoryDisplayManager:
72
71
  colour = self.STATE_COLOURS.get(state, "white")
73
72
  state_with_colour = f"[{colour}]{state}[/{colour}]"
74
73
 
75
- submitter = task.submitter_email or "Unknown"
74
+ submitter = task.submitter_email
76
75
  result = self._format_result(task, state)
77
76
 
78
77
  table.add_row(
79
78
  submitter,
80
79
  str(task.id),
81
80
  state_with_colour,
82
- self._to_cet(task.created_at),
83
- self._to_cet(task.started_at),
84
- str(task.runtime if task.runtime is not None else "N/A"),
81
+ format_datetime_for_cli(task.created_at),
82
+ format_datetime_for_cli(task.started_at) if task.started_at else "N/A",
83
+ f"{task.runtime:.2f}" if task.runtime is not None else "N/A",
85
84
  result,
86
85
  )
87
86
 
@@ -91,7 +90,7 @@ class TaskHistoryDisplayManager:
91
90
  console = Console()
92
91
  console.print(table)
93
92
 
94
- def _create_task_history_table(self):
93
+ def _create_task_history_table(self) -> Table:
95
94
  """
96
95
  Use the Rich library to initiate a table for displaying task history.
97
96
  """
@@ -119,7 +118,7 @@ class TaskHistoryDisplayManager:
119
118
  table.add_column("Result", style="white", width=35, overflow="fold")
120
119
  return table
121
120
 
122
- def _format_result(self, task, state):
121
+ def _format_result(self, task: TaskHistoryResult, state: str) -> str:
123
122
  """
124
123
  Format the result message based on the task state and type.
125
124
  """
@@ -182,16 +181,3 @@ class TaskHistoryDisplayManager:
182
181
  if self.format_output_as_tsv:
183
182
  return text
184
183
  return f"[{colour}]{text}[/{colour}]"
185
-
186
- def _to_cet(self, timestamp_str):
187
- """
188
- Convert a UTC timestamp string in '%Y-%m-%d %H:%M:%S UTC' format to CET.
189
- """
190
- if not timestamp_str or timestamp_str == "N/A":
191
- return "N/A"
192
- try:
193
- dt = datetime.strptime(timestamp_str, "%Y-%m-%d %H:%M:%S %Z")
194
- cet_dt = dt.astimezone(ZoneInfo("Europe/Stockholm"))
195
- return cet_dt.strftime("%Y-%m-%d %H:%M:%S %Z")
196
- except Exception:
197
- return timestamp_str
@@ -267,7 +267,7 @@ def download_files_command(
267
267
  else:
268
268
  # parse raw file inputs to see if any specific version ids were provided using format:
269
269
  # file_name:version_id (not possible when using project_version)
270
- json_data = []
270
+ json_data: list[dict[str, str | None]] = []
271
271
  for file_input in raw_files_input:
272
272
  if ":" in file_input:
273
273
  name, version_id = file_input.split(sep=":", maxsplit=1)
@@ -18,7 +18,6 @@ import os
18
18
  import time
19
19
  import warnings
20
20
  from dataclasses import dataclass
21
- from datetime import datetime
22
21
  from json import JSONDecodeError
23
22
  from pathlib import Path
24
23
  from typing import Any
@@ -34,9 +33,10 @@ from divbase_cli import __version__ as cli_version
34
33
  from divbase_cli.cli_config import cli_settings
35
34
  from divbase_cli.cli_exceptions import AuthenticationError, DivBaseAPIConnectionError, DivBaseAPIError
36
35
  from divbase_cli.retries import retry_only_on_retryable_divbase_api_errors
37
- from divbase_cli.user_config import load_user_config
36
+ from divbase_cli.user_config import UserConfig, load_user_config
38
37
  from divbase_lib.api_schemas.auth import LogoutRequest
39
38
  from divbase_lib.divbase_constants import CLI_VERSION_HEADER_KEY
39
+ from divbase_lib.utils import format_unix_timestamp_for_cli
40
40
 
41
41
  LOGIN_AGAIN_MESSAGE = "Your session has expired. Please log in again with 'divbase-cli auth login [EMAIL]'."
42
42
  PERSONAL_ACCESS_TOKEN_EXPIRED_MESSAGE = (
@@ -113,15 +113,15 @@ class PATData:
113
113
 
114
114
  def is_pat_expired(self) -> bool:
115
115
  """Check if the PAT is expired"""
116
- cutoff = time.time() + 10 # 10 second buffer to account for clock skew or time delay
117
- return self.pat_expires_at is not None and self.pat_expires_at <= cutoff
116
+ if self.pat_expires_at is None:
117
+ return False
118
+ return self.pat_expires_at <= time.time() + 10 # 10 second buffer to account for clock skew or time delay
118
119
 
119
120
  def pat_expiry_formatted(self) -> str:
120
121
  """Returns the token's expiry time in a human readable format in user's current timezone."""
121
122
  if self.pat_expires_at is None:
122
123
  return "Never"
123
- expiry = datetime.fromtimestamp(self.pat_expires_at).astimezone()
124
- return expiry.strftime("%Y-%m-%d %H:%M:%S %Z")
124
+ return format_unix_timestamp_for_cli(unix_timestamp=self.pat_expires_at)
125
125
 
126
126
 
127
127
  def delete_stored_pat(pat_path: Path = cli_settings.PATS_FALLBACK_PATH) -> None:
@@ -131,7 +131,7 @@ def delete_stored_pat(pat_path: Path = cli_settings.PATS_FALLBACK_PATH) -> None:
131
131
  pat_path.unlink(missing_ok=True)
132
132
 
133
133
 
134
- def check_existing_session(divbase_url: str, config) -> int | None:
134
+ def check_existing_session(divbase_url: str, config: UserConfig) -> int | None:
135
135
  """
136
136
  Check if a user is already logged in to DivBase.
137
137
  Used to prevent unnecessary multiple logins.
@@ -128,7 +128,7 @@ class UserConfig:
128
128
  for project in self.projects:
129
129
  if project.name == name:
130
130
  return project
131
- raise ProjectNotInConfigError(config_path=self.config_path, project_name=name)
131
+ raise ProjectNotInConfigError(project_name=name)
132
132
 
133
133
  def set_login_status(self, url: str | None, email: str | None) -> None:
134
134
  """
@@ -1 +0,0 @@
1
- __version__ = "0.1.0a12"
File without changes