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.
@@ -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()