divbase-cli 0.1.0a1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- divbase_cli/__init__.py +1 -0
- divbase_cli/cli_commands/__init__.py +4 -0
- divbase_cli/cli_commands/auth_cli.py +95 -0
- divbase_cli/cli_commands/dimensions_cli.py +472 -0
- divbase_cli/cli_commands/file_cli.py +632 -0
- divbase_cli/cli_commands/query_cli.py +300 -0
- divbase_cli/cli_commands/shared_args_options.py +23 -0
- divbase_cli/cli_commands/task_history_cli.py +125 -0
- divbase_cli/cli_commands/user_config_cli.py +145 -0
- divbase_cli/cli_commands/version_cli.py +214 -0
- divbase_cli/cli_config.py +71 -0
- divbase_cli/cli_exceptions.py +166 -0
- divbase_cli/config_resolver.py +86 -0
- divbase_cli/display_task_history.py +197 -0
- divbase_cli/divbase_cli.py +76 -0
- divbase_cli/retries.py +34 -0
- divbase_cli/services/__init__.py +0 -0
- divbase_cli/services/announcements.py +46 -0
- divbase_cli/services/pre_signed_urls.py +460 -0
- divbase_cli/services/project_versions.py +103 -0
- divbase_cli/services/s3_files.py +444 -0
- divbase_cli/user_auth.py +380 -0
- divbase_cli/user_config.py +177 -0
- divbase_cli/utils.py +28 -0
- divbase_cli-0.1.0a1.dist-info/METADATA +44 -0
- divbase_cli-0.1.0a1.dist-info/RECORD +28 -0
- divbase_cli-0.1.0a1.dist-info/WHEEL +4 -0
- divbase_cli-0.1.0a1.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""CLI commands for managing project versions in DivBase."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from zoneinfo import ZoneInfo
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
from rich import print
|
|
8
|
+
from rich.table import Table
|
|
9
|
+
|
|
10
|
+
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
|
|
12
|
+
from divbase_cli.services.project_versions import (
|
|
13
|
+
add_version_command,
|
|
14
|
+
delete_version_command,
|
|
15
|
+
get_version_details_command,
|
|
16
|
+
list_versions_command,
|
|
17
|
+
update_version_command,
|
|
18
|
+
)
|
|
19
|
+
from divbase_cli.utils import print_rich_table_as_tsv
|
|
20
|
+
from divbase_lib.utils import format_file_size
|
|
21
|
+
|
|
22
|
+
version_app = typer.Typer(
|
|
23
|
+
no_args_is_help=True,
|
|
24
|
+
help="Add, view and remove versions representing the state of all files in the entire project at the current timestamp.",
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
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
|
+
@version_app.command("add")
|
|
36
|
+
def add_version(
|
|
37
|
+
name: str = typer.Argument(help="Name of the version (e.g., semantic version).", show_default=False),
|
|
38
|
+
description: str = typer.Option("", help="Optional description of the version."),
|
|
39
|
+
project: str | None = PROJECT_NAME_OPTION,
|
|
40
|
+
):
|
|
41
|
+
"""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
|
+
|
|
45
|
+
add_version_response = add_version_command(
|
|
46
|
+
name=name,
|
|
47
|
+
description=description,
|
|
48
|
+
project_name=project_config.name,
|
|
49
|
+
divbase_base_url=logged_in_url,
|
|
50
|
+
)
|
|
51
|
+
print(f"New version: '{add_version_response.name}' added to the project: '{project_config.name}'")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@version_app.command("update")
|
|
55
|
+
def update_version(
|
|
56
|
+
version_name: str = typer.Argument(help="Name of the existing version you want to modify", show_default=False),
|
|
57
|
+
new_name: str | None = typer.Option(
|
|
58
|
+
None, "--new-name", "-n", help="New name for the version. If not specified, the name will remain unchanged."
|
|
59
|
+
),
|
|
60
|
+
new_description: str | None = typer.Option(
|
|
61
|
+
None,
|
|
62
|
+
"--new-description",
|
|
63
|
+
"-d",
|
|
64
|
+
help="Optional new description of the version. If not specified, the description will remain unchanged.",
|
|
65
|
+
),
|
|
66
|
+
project: str | None = PROJECT_NAME_OPTION,
|
|
67
|
+
):
|
|
68
|
+
"""
|
|
69
|
+
Update an existing project version entry's name and/or description.
|
|
70
|
+
|
|
71
|
+
The files and timestamp associated with a version entry are immutable and cannot be changed.
|
|
72
|
+
This is by design to ensure version entries are representations of the project's state at the timepoint they were created at.
|
|
73
|
+
You can create a new version entry to capture the current state of the project instead.
|
|
74
|
+
|
|
75
|
+
To see your current version entries run: `divbase-cli version ls`
|
|
76
|
+
"""
|
|
77
|
+
if not new_name and not new_description:
|
|
78
|
+
print(
|
|
79
|
+
"No updates specified. Please provide a new name (--new-name) and/or description (--new-description) to update for this entry."
|
|
80
|
+
)
|
|
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
|
+
|
|
86
|
+
updated_version = update_version_command(
|
|
87
|
+
project_name=project_config.name,
|
|
88
|
+
divbase_base_url=logged_in_url,
|
|
89
|
+
version_name=version_name,
|
|
90
|
+
new_name=new_name,
|
|
91
|
+
new_description=new_description,
|
|
92
|
+
)
|
|
93
|
+
if new_name and new_name != version_name:
|
|
94
|
+
print(f"Version '{version_name}' renamed to '{updated_version.name}' in project: '{project_config.name}'")
|
|
95
|
+
else:
|
|
96
|
+
print(f"Version '{updated_version.name}' updated in project: '{project_config.name}'")
|
|
97
|
+
if new_description:
|
|
98
|
+
print(f"New description: '{updated_version.description}'")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@version_app.command("ls")
|
|
102
|
+
def list_versions(
|
|
103
|
+
project: str | None = PROJECT_NAME_OPTION,
|
|
104
|
+
include_deleted: bool = typer.Option(False, help="Include soft-deleted versions in the listing."),
|
|
105
|
+
format_output_as_tsv: bool = FORMAT_AS_TSV_OPTION,
|
|
106
|
+
):
|
|
107
|
+
"""
|
|
108
|
+
List all entries in the project versioning file.
|
|
109
|
+
|
|
110
|
+
Displays version name, creation timestamp, and description for each project version.
|
|
111
|
+
If you specify --include-deleted, soft-deleted versions will also be shown.
|
|
112
|
+
Soft-deleted versions can be restored by a DivBase admin within 30 days of deletion.
|
|
113
|
+
"""
|
|
114
|
+
project_config = resolve_project(project_name=project)
|
|
115
|
+
logged_in_url = ensure_logged_in(desired_url=project_config.divbase_url)
|
|
116
|
+
|
|
117
|
+
versions_info = list_versions_command(
|
|
118
|
+
project_name=project_config.name, include_deleted=include_deleted, divbase_base_url=logged_in_url
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
if not versions_info:
|
|
122
|
+
print(f"No versions found for project: {project_config.name}.")
|
|
123
|
+
return
|
|
124
|
+
|
|
125
|
+
table = Table(title=f"Versions for {project_config.name}")
|
|
126
|
+
table.add_column("Version", style="cyan", no_wrap=True)
|
|
127
|
+
table.add_column("Created ", style="magenta")
|
|
128
|
+
table.add_column("Description", style="green")
|
|
129
|
+
if include_deleted:
|
|
130
|
+
table.add_column("Soft Deleted", style="red")
|
|
131
|
+
|
|
132
|
+
for version in versions_info:
|
|
133
|
+
name = version.name
|
|
134
|
+
desc = version.description or "No description provided"
|
|
135
|
+
created_at = format_timestamp(version.created_at)
|
|
136
|
+
if include_deleted:
|
|
137
|
+
soft_deleted = "Yes" if version.is_deleted else "No"
|
|
138
|
+
table.add_row(name, created_at, desc, soft_deleted)
|
|
139
|
+
else:
|
|
140
|
+
table.add_row(name, created_at, desc)
|
|
141
|
+
|
|
142
|
+
if not format_output_as_tsv:
|
|
143
|
+
print(table)
|
|
144
|
+
else:
|
|
145
|
+
print_rich_table_as_tsv(table=table)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@version_app.command("info")
|
|
149
|
+
def get_version_info(
|
|
150
|
+
version: str = typer.Argument(help="Specific version to retrieve information for"),
|
|
151
|
+
project: str | None = PROJECT_NAME_OPTION,
|
|
152
|
+
format_output_as_tsv: bool = FORMAT_AS_TSV_OPTION,
|
|
153
|
+
):
|
|
154
|
+
"""
|
|
155
|
+
Provide detailed information about a user specified project version, including all files present and their unique hashes.
|
|
156
|
+
"""
|
|
157
|
+
project_config = resolve_project(project_name=project)
|
|
158
|
+
logged_in_url = ensure_logged_in(desired_url=project_config.divbase_url)
|
|
159
|
+
|
|
160
|
+
version_details = get_version_details_command(
|
|
161
|
+
project_name=project_config.name, divbase_base_url=logged_in_url, version_name=version
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
table = Table(title=f"Project version files for {project_config.name}")
|
|
165
|
+
table.add_column("Name", style="cyan", no_wrap=True)
|
|
166
|
+
table.add_column("Version ID", style="magenta")
|
|
167
|
+
table.add_column("MD5 Checksum", style="green")
|
|
168
|
+
table.add_column("Size", style="yellow")
|
|
169
|
+
for object_name, file_details in version_details.files.items():
|
|
170
|
+
file_size = format_file_size(file_details["size"])
|
|
171
|
+
|
|
172
|
+
table.add_row(
|
|
173
|
+
object_name,
|
|
174
|
+
file_details["version_id"],
|
|
175
|
+
file_details["etag"],
|
|
176
|
+
file_size,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
if not format_output_as_tsv:
|
|
180
|
+
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)}")
|
|
182
|
+
if version_details.description:
|
|
183
|
+
print(f"Description: {version_details.description}")
|
|
184
|
+
if version_details.is_deleted:
|
|
185
|
+
print(
|
|
186
|
+
"[red]WARNING: This version has been soft-deleted and will soon be permanently deleted unless restored - Contact a DivBase admin to prevent this.[/red]"
|
|
187
|
+
)
|
|
188
|
+
print(table)
|
|
189
|
+
else:
|
|
190
|
+
print_rich_table_as_tsv(table=table)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@version_app.command("rm")
|
|
194
|
+
def delete_version(
|
|
195
|
+
name: str = typer.Argument(help="Name of the version (e.g., semantic version).", show_default=False),
|
|
196
|
+
project: str | None = PROJECT_NAME_OPTION,
|
|
197
|
+
):
|
|
198
|
+
"""
|
|
199
|
+
Delete a version entry in the project versioning table. This does not delete the files themselves.
|
|
200
|
+
|
|
201
|
+
Deleted version entries older than 30 days will be permanently deleted.
|
|
202
|
+
You can ask a DivBase admin to restore a deleted version within that time period.
|
|
203
|
+
"""
|
|
204
|
+
project_config = resolve_project(project_name=project)
|
|
205
|
+
logged_in_url = ensure_logged_in(desired_url=project_config.divbase_url)
|
|
206
|
+
|
|
207
|
+
deleted_version = delete_version_command(
|
|
208
|
+
project_name=project_config.name, divbase_base_url=logged_in_url, version_name=name
|
|
209
|
+
)
|
|
210
|
+
if deleted_version.already_deleted:
|
|
211
|
+
date_deleted = format_timestamp(deleted_version.date_deleted)
|
|
212
|
+
print(f"The version: '{deleted_version.name}' has already been soft-deleted on {date_deleted}.")
|
|
213
|
+
else:
|
|
214
|
+
print(f"The version: '{deleted_version.name}' was deleted from the project: '{project_config.name}'")
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Settings for DivBase CLI.
|
|
3
|
+
|
|
4
|
+
This class creates a single 'settings' object at module load time that can be imported and used throughout the entire package.
|
|
5
|
+
|
|
6
|
+
The user config and tokens are stored in the users local app dir:
|
|
7
|
+
https://typer.tiangolo.com/tutorial/app-dir/
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import typer
|
|
15
|
+
from pydantic import SecretStr
|
|
16
|
+
|
|
17
|
+
APP_NAME = "divbase-cli"
|
|
18
|
+
APP_DIR = Path(typer.get_app_dir(APP_NAME))
|
|
19
|
+
DEFAULT_METADATA_TSV_NAME = "sample_metadata.tsv"
|
|
20
|
+
DEFAULT_LOG_LEVEL = "INFO"
|
|
21
|
+
DEV_MODE = os.getenv("DIVBASE_DEV", "0") == "1"
|
|
22
|
+
CONFIG_PATH = APP_DIR / "config.yaml"
|
|
23
|
+
TOKENS_PATH = APP_DIR / ".secrets"
|
|
24
|
+
|
|
25
|
+
if DEV_MODE:
|
|
26
|
+
DEFAULT_DIVBASE_API_URL = "http://localhost:8000/api"
|
|
27
|
+
DEFAULT_LOGGING_ON = "1"
|
|
28
|
+
else:
|
|
29
|
+
DEFAULT_DIVBASE_API_URL = "https://divbase.scilifelab-2-prod.sys.kth.se/api"
|
|
30
|
+
DEFAULT_LOGGING_ON = "0"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class DivBaseCLISettings:
|
|
35
|
+
"""
|
|
36
|
+
Settings for DivBase CLI.
|
|
37
|
+
|
|
38
|
+
You do not need to create an instance of this class yourself,
|
|
39
|
+
instead, import the 'cli_settings' instance created at this module's load time.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
CONFIG_PATH: Path = Path(os.getenv("DIVBASE_CLI_CONFIG_PATH", CONFIG_PATH))
|
|
43
|
+
|
|
44
|
+
# for tokens stored via OS keyring, we use these to define a unique lookup key.
|
|
45
|
+
KEYRING_SERVICE: str = os.getenv("DIVBASE_KEYRING_SERVICE", "divbase-cli")
|
|
46
|
+
KEYRING_USERNAME: str = "tokens"
|
|
47
|
+
# Fallback path for tokens storage if keyring cannot be used on device.
|
|
48
|
+
TOKENS_PATH: Path = Path(os.getenv("DIVBASE_CLI_TOKENS_PATH", TOKENS_PATH))
|
|
49
|
+
|
|
50
|
+
DIVBASE_API_URL: str = os.getenv("DIVBASE_API_URL", DEFAULT_DIVBASE_API_URL)
|
|
51
|
+
METADATA_TSV_NAME: str = os.getenv("DIVBASE_METADATA_TSV_NAME", DEFAULT_METADATA_TSV_NAME)
|
|
52
|
+
LOGGING_ON: bool = os.getenv("DIVBASE_LOGGING_ON", DEFAULT_LOGGING_ON) == "1"
|
|
53
|
+
LOG_LEVEL: str = os.getenv("DIVBASE_LOG_LEVEL", DEFAULT_LOG_LEVEL).upper()
|
|
54
|
+
|
|
55
|
+
DIVBASE_API_PAT: SecretStr | None = field(init=False)
|
|
56
|
+
|
|
57
|
+
def __post_init__(self):
|
|
58
|
+
if os.getenv("DIVBASE_API_PAT"):
|
|
59
|
+
self.DIVBASE_API_PAT = SecretStr(os.environ["DIVBASE_API_PAT"])
|
|
60
|
+
else:
|
|
61
|
+
self.DIVBASE_API_PAT = None
|
|
62
|
+
|
|
63
|
+
valid_levels = ["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG", "NOTSET"]
|
|
64
|
+
if self.LOG_LEVEL not in valid_levels:
|
|
65
|
+
raise ValueError(f"Invalid LOG_LEVEL: {self.LOG_LEVEL}. Must be one of {valid_levels}.")
|
|
66
|
+
|
|
67
|
+
if self.DIVBASE_API_URL.endswith("/"):
|
|
68
|
+
self.DIVBASE_API_URL = self.DIVBASE_API_URL[:-1]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
cli_settings = DivBaseCLISettings()
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Custom exceptions for the divbase CLI.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from divbase_lib.divbase_constants import SUPPORTED_DIVBASE_FILE_TYPES, UNSUPPORTED_CHARACTERS_IN_FILENAMES
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class DivBaseCLIError(Exception):
|
|
11
|
+
"""Base exception for all divbase CLI errors."""
|
|
12
|
+
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AuthenticationError(DivBaseCLIError):
|
|
17
|
+
"""Raised for user authentication errors when using CLI tool."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, error_message: str = "Authentication required, make sure you're logged in."):
|
|
20
|
+
super().__init__(error_message)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class DivBaseAPIConnectionError(DivBaseCLIError):
|
|
24
|
+
"""Raised when CLI tool can't connect to the provided DivBase API URL."""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
error_message: str = "Unable to connect to the DivBase API. Check the API URL and your network connection. Perhaps the server is down?",
|
|
29
|
+
):
|
|
30
|
+
super().__init__(error_message)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class DivBaseAPIError(DivBaseCLIError):
|
|
34
|
+
"""
|
|
35
|
+
Used by CLI tool when making requests to DivBase API.
|
|
36
|
+
Raised when the DivBase API/server responds with an error status code.
|
|
37
|
+
Provides a helpful and easy-to-read error message for the user.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
error_details: str = "Not Provided",
|
|
43
|
+
error_type: str = "unknown",
|
|
44
|
+
status_code: int = 500,
|
|
45
|
+
http_method: str = "unknown",
|
|
46
|
+
url: str = "unknown",
|
|
47
|
+
):
|
|
48
|
+
self.status_code = status_code
|
|
49
|
+
self.error_type = error_type
|
|
50
|
+
self.error_details = error_details
|
|
51
|
+
self.http_method = http_method
|
|
52
|
+
self.url = url
|
|
53
|
+
error_message = (
|
|
54
|
+
f"DivBase Server returned an error response:\n"
|
|
55
|
+
f"HTTP Status code: {status_code}\n"
|
|
56
|
+
f"HTTP method: {http_method}\n"
|
|
57
|
+
f"URL: {url}\n"
|
|
58
|
+
f"Error type: {error_type}\n"
|
|
59
|
+
f"Details: {error_details}\n"
|
|
60
|
+
)
|
|
61
|
+
self.error_message = error_message
|
|
62
|
+
super().__init__(error_message)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class FileDoesNotExistInSpecifiedVersionError(DivBaseCLIError):
|
|
66
|
+
"""Raised when a file does not exist in the project at the specified project version"""
|
|
67
|
+
|
|
68
|
+
def __init__(self, project_name: str, project_version: str, missing_files: list[str]):
|
|
69
|
+
missing_files_str = "\n".join(f"- '{name}'" for name in missing_files)
|
|
70
|
+
self.project_name = project_name
|
|
71
|
+
self.project_version = project_version
|
|
72
|
+
self.missing_files = missing_files
|
|
73
|
+
|
|
74
|
+
error_message = (
|
|
75
|
+
f"For the project: '{project_name}'\n"
|
|
76
|
+
f"And project version you specified: '{project_version}':\n"
|
|
77
|
+
"The following file(s) could not be found:\n"
|
|
78
|
+
f"{missing_files_str}"
|
|
79
|
+
"\n Maybe they only existed in a later version of the project?"
|
|
80
|
+
)
|
|
81
|
+
super().__init__(error_message)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class FilesAlreadyInProjectError(DivBaseCLIError):
|
|
85
|
+
"""
|
|
86
|
+
Raised when trying to upload file(s) that already exists in the project
|
|
87
|
+
and the user does not want to accidently create a new version of any file.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
def __init__(self, existing_files: dict[Path, str], project_name: str):
|
|
91
|
+
files_list = "\n".join(
|
|
92
|
+
f"'{file_path}' (Checksum: {checksum})" for file_path, checksum in existing_files.items()
|
|
93
|
+
)
|
|
94
|
+
self.existing_files = existing_files
|
|
95
|
+
self.project_name = project_name
|
|
96
|
+
|
|
97
|
+
error_message = (
|
|
98
|
+
f"For the project: '{project_name}'\n"
|
|
99
|
+
"The exact version of the following file(s) that you're trying to upload already exist inside the project:\n"
|
|
100
|
+
f"{files_list}."
|
|
101
|
+
)
|
|
102
|
+
super().__init__(error_message)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class ProjectNameNotSpecifiedError(DivBaseCLIError):
|
|
106
|
+
"""
|
|
107
|
+
Raised when the project name is not specified in the command line arguments, and
|
|
108
|
+
no default project is set in the user config file.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
def __init__(self):
|
|
112
|
+
error_message = (
|
|
113
|
+
"No project name provided.\n"
|
|
114
|
+
"Please either set a default project in your user configuration file.\n"
|
|
115
|
+
"or pass the flag '--project <project_name>' to this command.\n"
|
|
116
|
+
"To set a default project, you can run 'divbase-cli config set-default <project_name>'.\n"
|
|
117
|
+
)
|
|
118
|
+
super().__init__(error_message)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class ProjectNotInConfigError(DivBaseCLIError):
|
|
122
|
+
"""
|
|
123
|
+
Raised when the project name was
|
|
124
|
+
1. specified in the command line arguments OR
|
|
125
|
+
2. set as the default project in the user config file.
|
|
126
|
+
But info about the project could not be obtained from the user config file.
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
def __init__(self, config_path: Path, project_name: str):
|
|
130
|
+
self.config_path = config_path
|
|
131
|
+
self.project_name = project_name
|
|
132
|
+
error_message = (
|
|
133
|
+
f"Couldn't get information about the project named: '{project_name}' \n"
|
|
134
|
+
f"Please check the project is included in '{config_path.resolve()}'.\n"
|
|
135
|
+
f"you can run 'divbase-cli config show' to view the contents of your config file.\n"
|
|
136
|
+
)
|
|
137
|
+
super().__init__(error_message)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class UnsupportedFileTypeError(DivBaseCLIError):
|
|
141
|
+
"""Raised when one or more files to be uploaded are not supported by DivBase (based on file extension)."""
|
|
142
|
+
|
|
143
|
+
def __init__(self, unsupported_files: list[Path], supported_types: tuple[str, ...] = SUPPORTED_DIVBASE_FILE_TYPES):
|
|
144
|
+
self.unsupported_files = unsupported_files
|
|
145
|
+
self.supported_types = supported_types
|
|
146
|
+
message = (
|
|
147
|
+
f"The following file(s) have types that are not supported by DivBase and therefore cannot be uploaded: \n"
|
|
148
|
+
f"{'\n'.join(str(file) for file in unsupported_files)}\n"
|
|
149
|
+
f"DivBase currently supports the following file types: {', '.join(SUPPORTED_DIVBASE_FILE_TYPES)}\n"
|
|
150
|
+
"If you want us to support another file type, please let us know."
|
|
151
|
+
)
|
|
152
|
+
super().__init__(message)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class UnsupportedFileNameError(DivBaseCLIError):
|
|
156
|
+
"""Raised when one or more files to be uploaded have unsupported characters in their filenames."""
|
|
157
|
+
|
|
158
|
+
def __init__(self, unsupported_files: list[Path]):
|
|
159
|
+
self.unsupported_files = unsupported_files
|
|
160
|
+
message = (
|
|
161
|
+
f"The following file(s) have unsupported characters in their filenames and therefore cannot be uploaded: \n"
|
|
162
|
+
f"{'\n'.join(str(file) for file in unsupported_files)}\n"
|
|
163
|
+
f"Filenames cannot contain any of the following characters: {', '.join(UNSUPPORTED_CHARACTERS_IN_FILENAMES)}\n"
|
|
164
|
+
"Please rename the files and try again."
|
|
165
|
+
)
|
|
166
|
+
super().__init__(message)
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Functions that resolve for the CLI commands things like:
|
|
3
|
+
- which project to use
|
|
4
|
+
- which download directory to use
|
|
5
|
+
- which DivBase API URL to use
|
|
6
|
+
Based on provided user input and their config file.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from divbase_cli.cli_config import cli_settings
|
|
12
|
+
from divbase_cli.cli_exceptions import AuthenticationError, ProjectNameNotSpecifiedError
|
|
13
|
+
from divbase_cli.user_config import ProjectConfig, load_user_config
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def ensure_logged_in(desired_url: str | None = None) -> str:
|
|
17
|
+
"""
|
|
18
|
+
Ensure the user is logged in by checking the logged_in_url value in the user config.
|
|
19
|
+
|
|
20
|
+
Optionally checks the logged_in_url matches the desired_url (e.g. for project-specific commands) if provided.
|
|
21
|
+
"""
|
|
22
|
+
config = load_user_config()
|
|
23
|
+
if config.logged_in_url:
|
|
24
|
+
if desired_url and config.logged_in_url != desired_url:
|
|
25
|
+
raise AuthenticationError(
|
|
26
|
+
f"You are not logged in to the correct DivBase URL: {desired_url}. Please log in again."
|
|
27
|
+
)
|
|
28
|
+
return config.logged_in_url
|
|
29
|
+
|
|
30
|
+
if cli_settings.DIVBASE_API_PAT:
|
|
31
|
+
return desired_url or cli_settings.DIVBASE_API_URL
|
|
32
|
+
|
|
33
|
+
raise AuthenticationError("You are not logged in. Please log in with 'divbase-cli auth login [EMAIL]'.")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def resolve_url_for_non_project_specific_commands() -> str:
|
|
37
|
+
"""
|
|
38
|
+
Resolve the DivBase API URL to use for CLI commands that are not project-specific.
|
|
39
|
+
|
|
40
|
+
Returns the url the user is either logged into or in the case of a user using a personal access token (PAT),
|
|
41
|
+
the default API URL, since PATs don't require login to be used.
|
|
42
|
+
Current examples: auth whoami and some task-history commands that are not project-specific.
|
|
43
|
+
|
|
44
|
+
Priority mirrors make_authenticated_request: active session first, PAT as fallback.
|
|
45
|
+
"""
|
|
46
|
+
config = load_user_config()
|
|
47
|
+
if config.logged_in_url:
|
|
48
|
+
return config.logged_in_url
|
|
49
|
+
|
|
50
|
+
# No active session — fall back to PAT if available.
|
|
51
|
+
if cli_settings.DIVBASE_API_PAT:
|
|
52
|
+
return cli_settings.DIVBASE_API_URL
|
|
53
|
+
|
|
54
|
+
raise AuthenticationError("You are not logged in. Please log in with 'divbase-cli auth login [EMAIL]'.")
|
|
55
|
+
|
|
56
|
+
|
|
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
|
+
def resolve_download_dir(download_dir: str | None) -> Path:
|
|
74
|
+
"""
|
|
75
|
+
Helper function to resolve the download directory to use for a CLI command involving downloading files.
|
|
76
|
+
|
|
77
|
+
Priority given to `download_dir` argument, then if a default is set in the user config.
|
|
78
|
+
Note: "." or None should default to the current working directory.
|
|
79
|
+
"""
|
|
80
|
+
if not download_dir:
|
|
81
|
+
config = load_user_config()
|
|
82
|
+
download_dir = config.default_download_dir
|
|
83
|
+
|
|
84
|
+
if download_dir and download_dir != ".":
|
|
85
|
+
return Path(download_dir)
|
|
86
|
+
return Path.cwd()
|