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
divbase_cli/user_auth.py
ADDED
|
@@ -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,,
|