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,380 @@
1
+ """
2
+ Manage user authentication with the DivBase server.
3
+
4
+ This includes login/logout and the getting, storing, using, and refreshing of access + refresh JWTs and Personal Access Tokens (PATs).
5
+
6
+ User JWTs are stored in the device's OS keyring. They fallback to a local file with 0600 permissions if not possible.
7
+ PATs provided via environment variable
8
+ """
9
+
10
+ import contextlib
11
+ import json
12
+ import logging
13
+ import os
14
+ import time
15
+ import warnings
16
+ from dataclasses import dataclass
17
+ from json import JSONDecodeError
18
+ from pathlib import Path
19
+
20
+ import httpx
21
+ import keyring
22
+ import stamina
23
+ import yaml
24
+ from keyring.errors import KeyringError, NoKeyringError, PasswordDeleteError
25
+ from pydantic import SecretStr
26
+
27
+ from divbase_cli import __version__ as cli_version
28
+ from divbase_cli.cli_config import cli_settings
29
+ from divbase_cli.cli_exceptions import AuthenticationError, DivBaseAPIConnectionError, DivBaseAPIError
30
+ from divbase_cli.retries import retry_only_on_retryable_divbase_api_errors
31
+ from divbase_cli.user_config import load_user_config
32
+ from divbase_lib.api_schemas.auth import LogoutRequest
33
+ from divbase_lib.divbase_constants import CLI_VERSION_HEADER_KEY
34
+
35
+ LOGIN_AGAIN_MESSAGE = "Your session has expired. Please log in again with 'divbase-cli auth login [EMAIL]'."
36
+
37
+ logger = logging.getLogger(__name__)
38
+
39
+
40
+ @dataclass
41
+ class TokenData:
42
+ """Class to hold user JWT data"""
43
+
44
+ access_token: SecretStr
45
+ refresh_token: SecretStr
46
+ access_token_expires_at: int
47
+ refresh_token_expires_at: int
48
+
49
+ def dump_tokens(self, output_path: Path = cli_settings.TOKENS_PATH) -> None:
50
+ """Dump the user token data to the OS keyring. If fails, fall back to a local file."""
51
+ token_dict = {
52
+ "access_token": self.access_token.get_secret_value(),
53
+ "refresh_token": self.refresh_token.get_secret_value(),
54
+ "access_token_expires_at": self.access_token_expires_at,
55
+ "refresh_token_expires_at": self.refresh_token_expires_at,
56
+ }
57
+ try:
58
+ keyring.set_password(
59
+ service_name=cli_settings.KEYRING_SERVICE,
60
+ username=cli_settings.KEYRING_USERNAME,
61
+ password=json.dumps(token_dict),
62
+ )
63
+ output_path.unlink(missing_ok=True)
64
+ logger.debug("JWTs stored in device keyring successfully.")
65
+ return
66
+ except KeyringError as e:
67
+ logger.debug(f"Keyring JWT storage failed with error: {e}\n falling back to file storage.")
68
+
69
+ output_path.parent.mkdir(parents=True, exist_ok=True)
70
+ # Create file with 0600 permissions (user read/write only).
71
+ # Windows doesn't support 0600 permissions, but Windows can use keyring, so should never reach this fallback.
72
+ # Even if a Windows OS can't use keyring the fallback will still work, the 0600 mode will just be ignored.
73
+ fd = os.open(path=output_path, flags=os.O_WRONLY | os.O_CREAT | os.O_TRUNC, mode=0o600)
74
+ with os.fdopen(fd, "w") as file:
75
+ yaml.safe_dump(token_dict, file, sort_keys=False)
76
+
77
+ def is_access_token_expired(self) -> bool:
78
+ """Check if the access token is expired"""
79
+ return time.time() >= (self.access_token_expires_at - 5) # 5 second buffer
80
+
81
+ def is_refresh_token_expired(self) -> bool:
82
+ """Check if the refresh token is expired"""
83
+ return time.time() >= (self.refresh_token_expires_at - 300) # 5 minute buffer
84
+
85
+
86
+ def _delete_stored_jwts(token_path: Path) -> None:
87
+ """Attempt to delete user JWTs from both the keyring and the fallback file."""
88
+ with contextlib.suppress(NoKeyringError, PasswordDeleteError):
89
+ keyring.delete_password(service_name=cli_settings.KEYRING_SERVICE, username=cli_settings.KEYRING_USERNAME)
90
+ token_path.unlink(missing_ok=True)
91
+
92
+
93
+ def check_existing_session(divbase_url: str, config) -> int | None:
94
+ """
95
+ Check if a user is already logged in to DivBase.
96
+ Used to prevent unnecessary multiple logins.
97
+
98
+ Returns the refresh token expiry timestamp if logged in (POSIX time), else None.
99
+ """
100
+ if not config.logged_in_url or config.logged_in_url != divbase_url:
101
+ return None
102
+
103
+ token_data = load_user_tokens()
104
+ if not token_data or token_data.is_refresh_token_expired():
105
+ return None
106
+
107
+ return token_data.refresh_token_expires_at
108
+
109
+
110
+ def _handle_divbase_api_error(response: httpx.Response, http_method: str, url: str) -> None:
111
+ """Handles custom display of a HTTP error response returned by DivBase API."""
112
+ try:
113
+ response_body = response.json()
114
+ error_details = response_body.get("detail", "No error message provided.")
115
+ error_type = response_body.get("type", "unknown")
116
+ except (JSONDecodeError, ValueError):
117
+ # most likely situation for this would be if the reverse proxy returns an error, not the server itself.
118
+ error_details = response.text
119
+ error_type = "unexpected_server_error"
120
+
121
+ raise DivBaseAPIError(
122
+ error_details=error_details,
123
+ status_code=response.status_code,
124
+ error_type=error_type,
125
+ http_method=http_method,
126
+ url=url,
127
+ ) from None
128
+
129
+
130
+ @stamina.retry(on=retry_only_on_retryable_divbase_api_errors, attempts=3)
131
+ def login_to_divbase(email: str, password: SecretStr, divbase_url: str) -> None:
132
+ """Log in to the DivBase server and return user tokens."""
133
+ login_url = f"{divbase_url}/v1/auth/login"
134
+ try:
135
+ response = httpx.post(
136
+ url=login_url,
137
+ data={
138
+ "grant_type": "password",
139
+ "username": email, # OAuth2 uses 'username', not 'email'
140
+ "password": password.get_secret_value(),
141
+ },
142
+ headers={
143
+ "Content-Type": "application/x-www-form-urlencoded",
144
+ CLI_VERSION_HEADER_KEY: cli_version,
145
+ },
146
+ )
147
+ except httpx.ConnectError:
148
+ # We don't raise the full error as it contains the password in the stack trace.
149
+ # a user could unknowingly dump this into e.g. a bug report/GitHub issue.
150
+ raise DivBaseAPIConnectionError() from None
151
+
152
+ try:
153
+ response.raise_for_status()
154
+ except httpx.HTTPStatusError:
155
+ if response.status_code == 401:
156
+ error_message = response.json().get("detail", "Invalid email or password.")
157
+ raise AuthenticationError(error_message) from None
158
+ _handle_divbase_api_error(response=response, http_method="POST", url=login_url)
159
+
160
+ data = response.json()
161
+ token_data = TokenData(
162
+ access_token=SecretStr(data["access_token"]),
163
+ refresh_token=SecretStr(data["refresh_token"]),
164
+ access_token_expires_at=data["access_token_expires_at"],
165
+ refresh_token_expires_at=data["refresh_token_expires_at"],
166
+ )
167
+ token_data.dump_tokens()
168
+
169
+ config = load_user_config()
170
+ config.set_login_status(url=divbase_url, email=email)
171
+
172
+
173
+ def logout_of_divbase(token_path: Path = cli_settings.TOKENS_PATH) -> None:
174
+ """
175
+ Log out of the DivBase server.
176
+ We send the refresh token to DivBase to be revoked server-side.
177
+ """
178
+ config = load_user_config()
179
+
180
+ # the "if" avoids raising an error on a non logged in user trying to logout
181
+ if config.logged_in_url:
182
+ token_data = load_user_tokens(token_path=token_path)
183
+ if not token_data:
184
+ _delete_stored_jwts(token_path)
185
+ config.set_login_status(url=None, email=None)
186
+ return None
187
+
188
+ request_data = LogoutRequest(refresh_token=token_data.refresh_token.get_secret_value())
189
+ # We don't want logout to fail if server is unreachable or gives an error
190
+ # JWTs are stateless so local logout is sufficient.
191
+ try:
192
+ make_unauthenticated_request(
193
+ method="POST",
194
+ divbase_base_url=config.logged_in_url,
195
+ api_route="v1/auth/logout",
196
+ json=request_data.model_dump(),
197
+ )
198
+ except DivBaseAPIConnectionError as e:
199
+ warnings.warn(
200
+ f"Could not connect to DivBase server to log out fully: '{e}'.\n\n"
201
+ "Continuing local logout.\n"
202
+ "You do not need to do anything, but if you see this message often, please let us know.",
203
+ stacklevel=2,
204
+ category=UserWarning,
205
+ )
206
+ except DivBaseAPIError as e:
207
+ warnings.warn(
208
+ f"Received an error message from DivBase server when attempting to logout:"
209
+ f"'{e.error_message=}'. \n\n"
210
+ "Continuing local logout.\n"
211
+ "You do not need to do anything. If you see this message a lot, please let us know.",
212
+ stacklevel=2,
213
+ category=UserWarning,
214
+ )
215
+
216
+ _delete_stored_jwts(token_path)
217
+ config.set_login_status(url=None, email=None)
218
+
219
+
220
+ def load_user_tokens(token_path: Path = cli_settings.TOKENS_PATH) -> TokenData | None:
221
+ """
222
+ Load user tokens from the OS keyring. Fallback for this is a local file with 0600 permissions.
223
+ Return None if no tokens found or if tokens are malformed/invalid - user logged out.
224
+ """
225
+ try:
226
+ raw = keyring.get_password(service_name=cli_settings.KEYRING_SERVICE, username=cli_settings.KEYRING_USERNAME)
227
+ except KeyringError as e:
228
+ raw = None
229
+ logger.debug(f"Keyring read failed with error: {e}, falling back to file storage.")
230
+
231
+ if raw is not None:
232
+ try:
233
+ token_dict = json.loads(raw)
234
+ return TokenData(
235
+ access_token=SecretStr(token_dict["access_token"]),
236
+ refresh_token=SecretStr(token_dict["refresh_token"]),
237
+ access_token_expires_at=token_dict["access_token_expires_at"],
238
+ refresh_token_expires_at=token_dict["refresh_token_expires_at"],
239
+ )
240
+ except (KeyError, json.JSONDecodeError) as e:
241
+ logger.debug(f"Keyring token data malformed: {e}")
242
+
243
+ if not token_path.exists():
244
+ return None
245
+
246
+ with open(token_path, "r") as file:
247
+ token_dict = yaml.safe_load(file)
248
+
249
+ try:
250
+ token_data = TokenData(
251
+ access_token=SecretStr(token_dict["access_token"]),
252
+ refresh_token=SecretStr(token_dict["refresh_token"]),
253
+ access_token_expires_at=token_dict["access_token_expires_at"],
254
+ refresh_token_expires_at=token_dict["refresh_token_expires_at"],
255
+ )
256
+ except KeyError as e:
257
+ logger.debug(f"User tokens file at {token_path} appears to be malformed: {e}")
258
+ return None
259
+
260
+ return token_data
261
+
262
+
263
+ @stamina.retry(on=retry_only_on_retryable_divbase_api_errors, attempts=3)
264
+ def make_authenticated_request(
265
+ method: str,
266
+ divbase_base_url: str,
267
+ api_route: str,
268
+ token_path: Path = cli_settings.TOKENS_PATH,
269
+ **kwargs,
270
+ ) -> httpx.Response:
271
+ """Make an authenticated request to the DivBase server, handles refreshing tokens if needed."""
272
+ headers = kwargs.get("headers", {})
273
+ headers[CLI_VERSION_HEADER_KEY] = cli_version
274
+
275
+ token_data = load_user_tokens(token_path=token_path)
276
+ if not token_data:
277
+ if not cli_settings.DIVBASE_API_PAT:
278
+ raise AuthenticationError(LOGIN_AGAIN_MESSAGE)
279
+ logger.info("Using personal access token in request to DivBase API.")
280
+ headers["Authorization"] = f"Bearer {cli_settings.DIVBASE_API_PAT.get_secret_value()}"
281
+ else:
282
+ if token_data.is_access_token_expired():
283
+ if token_data.is_refresh_token_expired():
284
+ # Prevents user getting warning about being already logged in when they try to log in again
285
+ config = load_user_config()
286
+ config.set_login_status(url=None, email=None)
287
+ _delete_stored_jwts(token_path)
288
+ raise AuthenticationError(LOGIN_AGAIN_MESSAGE)
289
+ else:
290
+ token_data = _refresh_access_token(token_data=token_data, divbase_base_url=divbase_base_url)
291
+ headers["Authorization"] = f"Bearer {token_data.access_token.get_secret_value()}"
292
+
293
+ kwargs["headers"] = headers
294
+ url = f"{divbase_base_url}/{api_route.lstrip('/')}"
295
+
296
+ try:
297
+ response = httpx.request(method=method, url=url, **kwargs)
298
+ except httpx.HTTPError as e:
299
+ raise DivBaseAPIConnectionError() from e
300
+
301
+ try:
302
+ response.raise_for_status()
303
+ except httpx.HTTPStatusError:
304
+ _handle_divbase_api_error(response=response, http_method=method, url=url)
305
+
306
+ return response
307
+
308
+
309
+ @stamina.retry(on=retry_only_on_retryable_divbase_api_errors, attempts=3)
310
+ def make_unauthenticated_request(
311
+ method: str,
312
+ divbase_base_url: str,
313
+ api_route: str,
314
+ **kwargs,
315
+ ) -> httpx.Response:
316
+ """
317
+ Make an unauthenticated request to the DivBase server.
318
+ Used for those few endpoints that require no authentication, even if the user is logged in.
319
+ E.g. the announcements endpoint.
320
+ """
321
+ url = f"{divbase_base_url}/{api_route.lstrip('/')}"
322
+
323
+ headers = kwargs.get("headers", {})
324
+ headers[CLI_VERSION_HEADER_KEY] = cli_version
325
+ kwargs["headers"] = headers
326
+
327
+ try:
328
+ response = httpx.request(method=method, url=url, **kwargs)
329
+ except httpx.HTTPError as e:
330
+ raise DivBaseAPIConnectionError() from e
331
+
332
+ try:
333
+ response.raise_for_status()
334
+ except httpx.HTTPStatusError:
335
+ _handle_divbase_api_error(response=response, http_method=method, url=url)
336
+
337
+ return response
338
+
339
+
340
+ def _refresh_access_token(token_data: TokenData, divbase_base_url: str) -> TokenData:
341
+ """
342
+ Use the refresh token to get a new access token and update the token file.
343
+
344
+ Returns the new TokenData object which can be used immediately in a new request.
345
+ NOTE: We do not need retry logic inside this function as the calling function has it.
346
+ """
347
+ refresh_url = f"{divbase_base_url}/v1/auth/refresh"
348
+ try:
349
+ response = httpx.post(
350
+ url=refresh_url,
351
+ json={"refresh_token": token_data.refresh_token.get_secret_value()},
352
+ headers={CLI_VERSION_HEADER_KEY: cli_version},
353
+ )
354
+ except httpx.HTTPError as e:
355
+ raise DivBaseAPIConnectionError() from e
356
+
357
+ try:
358
+ response.raise_for_status()
359
+ except httpx.HTTPStatusError:
360
+ # Possible if e.g. token revoked on server side.
361
+ if response.status_code == 401:
362
+ # Clear logged in status in user config as tokens no longer valid.
363
+ # Prevents user getting warning about being already logged in when they try to log in again.
364
+ config = load_user_config()
365
+ config.set_login_status(url=None, email=None)
366
+ _delete_stored_jwts(token_path=cli_settings.TOKENS_PATH)
367
+ raise AuthenticationError(LOGIN_AGAIN_MESSAGE) from None
368
+
369
+ _handle_divbase_api_error(response=response, http_method="POST", url=refresh_url)
370
+
371
+ data = response.json()
372
+
373
+ new_token_data = TokenData(
374
+ access_token=SecretStr(data["access_token"]),
375
+ refresh_token=token_data.refresh_token, # (refresh_token is still a SecretStr)
376
+ access_token_expires_at=data["expires_at"],
377
+ refresh_token_expires_at=token_data.refresh_token_expires_at,
378
+ )
379
+ new_token_data.dump_tokens()
380
+ return new_token_data
@@ -0,0 +1,177 @@
1
+ """
2
+ Handles the user's configuration file for the divbase-cli package.
3
+ The user configuration is stored in a local file.
4
+
5
+ The path to the config file is determined by `cli_settings.CONFIG_PATH` and
6
+ is stored in a platform-specific user configuration directory (for example,
7
+ under the user application data directory on Linux, macOS, or Windows).
8
+ """
9
+
10
+ import logging
11
+ import warnings
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+
15
+ import yaml
16
+
17
+ from divbase_cli.cli_config import cli_settings
18
+ from divbase_cli.cli_exceptions import ProjectNotInConfigError
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ @dataclass
24
+ class ProjectConfig:
25
+ """Config of a single project."""
26
+
27
+ name: str
28
+ divbase_url: str
29
+
30
+
31
+ @dataclass
32
+ class UserConfig:
33
+ """Overall user configuration"""
34
+
35
+ config_path: Path
36
+ logged_in_url: str | None = None # URL of the divbase server the user is currently logged in to, if any.
37
+ logged_in_email: str | None = None # Email the user is currently logged in with, if any.
38
+ projects: list[ProjectConfig] = field(default_factory=list)
39
+ default_project: str | None = None
40
+ # default_download_dir is a string (rather than Path) for easier loading/saving.
41
+ default_download_dir: str | None = None
42
+
43
+ @property
44
+ def all_project_names(self) -> list[str]:
45
+ """List of all project names in the user's config"""
46
+ return [project.name for project in self.projects]
47
+
48
+ def dump_config(self) -> None:
49
+ """
50
+ Dump the user configuration to the specified output path
51
+ Don't include the config_path in the dumped file.
52
+ """
53
+ config_dict = {
54
+ "logged_in_url": self.logged_in_url,
55
+ "logged_in_email": self.logged_in_email,
56
+ "projects": [project.__dict__ for project in self.projects],
57
+ "default_project": self.default_project,
58
+ "default_download_dir": self.default_download_dir,
59
+ }
60
+
61
+ with open(self.config_path, "w") as file:
62
+ yaml.safe_dump(config_dict, file, sort_keys=False)
63
+
64
+ def add_project(self, name: str, divbase_url: str, is_default: bool) -> ProjectConfig:
65
+ """
66
+ Add a new project to the user configuration file.
67
+ If the configuration file does not exist, it will be created.
68
+ """
69
+ new_project = ProjectConfig(name=name, divbase_url=divbase_url)
70
+
71
+ if new_project.name in self.all_project_names:
72
+ warnings.warn(
73
+ f"""The project: '{new_project.name}' already existed in your configuration file. It will be replaced with your new params""",
74
+ stacklevel=2,
75
+ )
76
+
77
+ self.projects = [project for project in self.projects if project.name != new_project.name]
78
+
79
+ self.projects.append(new_project)
80
+
81
+ if is_default:
82
+ self.default_project = new_project.name
83
+
84
+ self.dump_config()
85
+ return new_project
86
+
87
+ def set_default_project(self, name: str) -> str:
88
+ """
89
+ Set the default project in the user configuration file.
90
+ """
91
+ if name not in self.all_project_names:
92
+ raise ValueError(
93
+ f"The project name specified: '{name}', was not found in your config file, please add it first."
94
+ )
95
+ self.default_project = name
96
+ self.dump_config()
97
+ return self.default_project
98
+
99
+ def remove_project(self, name: str) -> str | None:
100
+ """
101
+ Remove a project from the user configuration file.
102
+ Returns the project name if it was removed successfully, otherwise None.
103
+ """
104
+ if name not in self.all_project_names:
105
+ return
106
+
107
+ self.projects = [project for project in self.projects if project.name != name]
108
+
109
+ if self.default_project == name:
110
+ self.default_project = None
111
+
112
+ self.dump_config()
113
+
114
+ return name
115
+
116
+ def set_default_download_dir(self, download_dir: str) -> str:
117
+ """
118
+ Set the default download directory in the user configuration file.
119
+ """
120
+ self.default_download_dir = download_dir
121
+ self.dump_config()
122
+ return self.default_download_dir
123
+
124
+ def project_info(self, name: str) -> ProjectConfig:
125
+ """
126
+ Get the project configuration given the project name.
127
+ """
128
+ for project in self.projects:
129
+ if project.name == name:
130
+ return project
131
+ raise ProjectNotInConfigError(config_path=self.config_path, project_name=name)
132
+
133
+ def set_login_status(self, url: str | None, email: str | None) -> None:
134
+ """
135
+ Used during login/logout to track state (Dumps config after updating)
136
+ Sets the following fields:
137
+ 1. URL of the DivBase server the user is currently logged in to.
138
+ 2. Email of the user currently logged in.
139
+ """
140
+ self.logged_in_url = url
141
+ self.logged_in_email = email
142
+ self.dump_config()
143
+
144
+
145
+ def load_user_config() -> UserConfig:
146
+ """Helper function to load the user config file"""
147
+ try:
148
+ with open(cli_settings.CONFIG_PATH, "r") as file:
149
+ config_contents = yaml.safe_load(file)
150
+ except FileNotFoundError:
151
+ logger.debug(f"No existing config file found at {cli_settings.CONFIG_PATH}, creating a new one.")
152
+ create_user_config(config_path=cli_settings.CONFIG_PATH)
153
+ with open(cli_settings.CONFIG_PATH, "r") as file:
154
+ config_contents = yaml.safe_load(file)
155
+
156
+ projects = [ProjectConfig(**project) for project in config_contents.get("projects", [])]
157
+
158
+ return UserConfig(
159
+ config_path=cli_settings.CONFIG_PATH,
160
+ logged_in_url=config_contents.get("logged_in_url"),
161
+ logged_in_email=config_contents.get("logged_in_email"),
162
+ projects=projects,
163
+ default_project=config_contents.get("default_project"),
164
+ default_download_dir=config_contents.get("default_download_dir"),
165
+ )
166
+
167
+
168
+ def create_user_config(config_path: Path) -> None:
169
+ """Create a user configuration file at the specified path."""
170
+ if config_path.exists():
171
+ raise FileExistsError(
172
+ f"Config file already exists at {config_path}. Stopping to avoid overwriting existing config."
173
+ )
174
+ config_path.parent.mkdir(parents=True, exist_ok=True)
175
+
176
+ user_config = UserConfig(config_path=config_path, projects=[])
177
+ user_config.dump_config()
divbase_cli/utils.py ADDED
@@ -0,0 +1,28 @@
1
+ """Collection of utility functions for divbase-cli package that haven't found a better home"""
2
+
3
+ import csv
4
+ import sys
5
+
6
+ from rich.table import Table
7
+
8
+
9
+ def print_rich_table_as_tsv(table: Table) -> None:
10
+ """
11
+ Helper function to print a rich Table as a TSV file to standard output.
12
+
13
+ This is useful for CLI commands that want to offer both rich table output
14
+ for human users as well as TSV output for programmatic parsing.
15
+
16
+ NOTE: This function expects all table rows to be of same length (you can have None values in cells).
17
+ """
18
+ writer = csv.writer(sys.stdout, delimiter="\t")
19
+
20
+ headers = [str(col.header) for col in table.columns]
21
+ writer.writerow(headers)
22
+
23
+ columns_data = [col._cells for col in table.columns]
24
+
25
+ num_rows = min(len(col) for col in columns_data)
26
+ for row_index in range(num_rows):
27
+ row = [str(columns_data[col_index][row_index]) for col_index in range(len(columns_data))]
28
+ writer.writerow(row)
@@ -0,0 +1,44 @@
1
+ Metadata-Version: 2.4
2
+ Name: divbase-cli
3
+ Version: 0.1.0a1
4
+ Summary: Command Line Interface for Divbase
5
+ Project-URL: Homepage, https://divbase.scilifelab-2-prod.sys.kth.se
6
+ Project-URL: Documentation, https://scilifelabdatacentre.github.io/divbase
7
+ Project-URL: Repository, https://github.com/ScilifelabDataCentre/divbase
8
+ Project-URL: Issues, https://github.com/ScilifelabDataCentre/divbase/issues
9
+ Author-email: SciLifeLab Data Centre <datacentre@scilifelab.se>
10
+ License-Expression: MIT
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Science/Research
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Programming Language :: Python :: 3.14
20
+ Requires-Python: >=3.12
21
+ Requires-Dist: divbase-lib==0.1.0a1
22
+ Requires-Dist: keyring>=25.7.0
23
+ Requires-Dist: typer==0.24.1
24
+ Description-Content-Type: text/markdown
25
+
26
+ # divbase-cli
27
+
28
+ Command Line Interface for Divbase.
29
+
30
+ ## Installation
31
+
32
+ ```bash
33
+ pipx install divbase-cli
34
+ ```
35
+
36
+ ## Usage
37
+
38
+ ```bash
39
+ divbase --help # can also run divbase-cli --help
40
+ ```
41
+
42
+ ## Development
43
+
44
+ This package is developed in the [DivBase repository](https://github.com/ScilifelabDataCentre/divbase) by Scilifelab Data Centre.
@@ -0,0 +1,28 @@
1
+ divbase_cli/__init__.py,sha256=lmvUfjeqbjbsnD2PLlUjZbqEGZrq6yUD6t2LZsM61ak,24
2
+ divbase_cli/cli_config.py,sha256=-B6sOFq2fCQzGG9FGkOntEpeacy5XzG-9LrBRyzXEtQ,2532
3
+ divbase_cli/cli_exceptions.py,sha256=MeSignmkzTidzlhRDdbUBHkbxD6qOWUYtTRUOfDVz0w,6501
4
+ divbase_cli/config_resolver.py,sha256=2P-1OyyRUg3jWxi2rl_sRnW0DXU1WFXofNRFCU01G_w,3286
5
+ divbase_cli/display_task_history.py,sha256=_DlxOwVRU0hSn1w8yz4xMeFYzSVeATJ6me7_DLwBYe4,8254
6
+ divbase_cli/divbase_cli.py,sha256=JrUvPB925gJHroNXez4uSNehKHKfDCGMLzpyipHkGKY,2358
7
+ divbase_cli/retries.py,sha256=UFv4CHudC1Sb0kRe2HuuCidoCNA9TSXB-mOdrt4uDew,1298
8
+ divbase_cli/user_auth.py,sha256=jdz8ZKrmEVBx-7fJdE9DFIolCRI5WZobsXJ_qYpgXAY,15149
9
+ divbase_cli/user_config.py,sha256=oROFuNZN5MoC01070ufZbVh8lwavFN2bTj6u4oy7MYE,6350
10
+ divbase_cli/utils.py,sha256=-D2x3uIiVDcO3YABrrHhWUszEFzc1kQIeXN69EUWS78,962
11
+ divbase_cli/cli_commands/__init__.py,sha256=K_2r8V1QGpEmxDcD2QOyWlXR4HPoc16yytmZwGkIyLw,166
12
+ divbase_cli/cli_commands/auth_cli.py,sha256=XDp5sRFovrzTLMpFVnsfmtCgZghZrchr_GhY08W6Rd8,3114
13
+ divbase_cli/cli_commands/dimensions_cli.py,sha256=rYhxiviIdU-tfw_Gr8Ly_7pCPbBFaGZ9F8E02Tc_mF4,18913
14
+ divbase_cli/cli_commands/file_cli.py,sha256=gGjeHfa6G9h1T9AwOWgasVdhflZXOeuCOte76HujUAo,25755
15
+ divbase_cli/cli_commands/query_cli.py,sha256=1r3m9g450x1nhR7B9EiBVcmzyi_vJywZ5cDo7qX3U2E,11910
16
+ divbase_cli/cli_commands/shared_args_options.py,sha256=BGOo1tPjrCM3XOB5v3wAeRZJ8d8NvCPruqa_ZVReLPE,585
17
+ divbase_cli/cli_commands/task_history_cli.py,sha256=ePbgRZ3fmQLycyMjAe6X-ZuqsQDsn9y1EgOKdiT1Uj0,4558
18
+ divbase_cli/cli_commands/user_config_cli.py,sha256=WB1iPPuecEdYmUBxnMQYTT1E3LC5FzU62LF4WUp-lJE,5396
19
+ divbase_cli/cli_commands/version_cli.py,sha256=oC3NWjTrkq_axwXKrwlnmfXL0fkMPDGWCZ0ryaJ9Dy0,8818
20
+ divbase_cli/services/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
21
+ divbase_cli/services/announcements.py,sha256=EYXoHaAcZmzNm3ezgLVi81DQFn3qecZVlIuNHvpQq_c,1531
22
+ divbase_cli/services/pre_signed_urls.py,sha256=Tg6d1z01Sk5Id03_AL6e3k-u4gYWdd-ipQwyzaU65Ro,17437
23
+ divbase_cli/services/project_versions.py,sha256=LwDjJCKPGJl1fAv5GSQ7aTOrEfrpzn3IVLwR-b10C9k,3628
24
+ divbase_cli/services/s3_files.py,sha256=0t76BfBX0VKGzn7pQNJAu-S55MWqykJp1X7RSw9phmM,17921
25
+ divbase_cli-0.1.0a1.dist-info/METADATA,sha256=ULzFB-uWahbhyvcb9AgrHgVnrRc2HtA2H3cryDqnze0,1395
26
+ divbase_cli-0.1.0a1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
27
+ divbase_cli-0.1.0a1.dist-info/entry_points.txt,sha256=U4l9q6SebrFeGx3m8qA5bM_-Y-5KpTPUdFHhFoVUSU8,61
28
+ divbase_cli-0.1.0a1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ divbase-cli = divbase_cli.divbase_cli:main