lightning-sdk 2025.8.19.post0__py3-none-any.whl → 2025.8.26__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.
- lightning_sdk/__init__.py +1 -1
- lightning_sdk/api/llm_api.py +6 -2
- lightning_sdk/api/studio_api.py +168 -2
- lightning_sdk/api/teamspace_api.py +60 -30
- lightning_sdk/api/user_api.py +49 -1
- lightning_sdk/api/utils.py +1 -1
- lightning_sdk/cli/config/set.py +6 -18
- lightning_sdk/cli/legacy/create.py +12 -14
- lightning_sdk/cli/legacy/delete.py +3 -3
- lightning_sdk/cli/legacy/deploy/_auth.py +4 -4
- lightning_sdk/cli/legacy/download.py +7 -7
- lightning_sdk/cli/legacy/job_and_mmt_action.py +4 -4
- lightning_sdk/cli/legacy/list.py +9 -9
- lightning_sdk/cli/legacy/open.py +3 -3
- lightning_sdk/cli/legacy/start.py +1 -0
- lightning_sdk/cli/legacy/switch.py +1 -0
- lightning_sdk/cli/legacy/upload.py +3 -3
- lightning_sdk/cli/studio/create.py +14 -23
- lightning_sdk/cli/studio/delete.py +28 -27
- lightning_sdk/cli/studio/list.py +5 -6
- lightning_sdk/cli/studio/ssh.py +19 -22
- lightning_sdk/cli/studio/start.py +23 -23
- lightning_sdk/cli/studio/stop.py +22 -26
- lightning_sdk/cli/studio/switch.py +20 -23
- lightning_sdk/cli/utils/resolve.py +1 -1
- lightning_sdk/cli/utils/save_to_config.py +27 -0
- lightning_sdk/cli/utils/studio_selection.py +106 -0
- lightning_sdk/cli/utils/teamspace_selection.py +125 -0
- lightning_sdk/lightning_cloud/openapi/__init__.py +3 -0
- lightning_sdk/lightning_cloud/openapi/api/billing_service_api.py +170 -0
- lightning_sdk/lightning_cloud/openapi/api/k8_s_cluster_service_api.py +101 -0
- lightning_sdk/lightning_cloud/openapi/models/__init__.py +3 -0
- lightning_sdk/lightning_cloud/openapi/models/assistant_id_conversations_body.py +15 -15
- lightning_sdk/lightning_cloud/openapi/models/externalv1_user_status.py +27 -1
- lightning_sdk/lightning_cloud/openapi/models/v1_cluster_metrics.py +270 -36
- lightning_sdk/lightning_cloud/openapi/models/v1_container_metrics.py +21 -21
- lightning_sdk/lightning_cloud/openapi/models/v1_list_cluster_metric_timestamps_response.py +123 -0
- lightning_sdk/lightning_cloud/openapi/models/v1_namespace_metrics.py +11 -11
- lightning_sdk/lightning_cloud/openapi/models/v1_namespace_user_metrics.py +16 -16
- lightning_sdk/lightning_cloud/openapi/models/v1_node_metrics.py +156 -26
- lightning_sdk/lightning_cloud/openapi/models/v1_pod_metrics.py +281 -21
- lightning_sdk/lightning_cloud/openapi/models/v1_project_cluster_binding.py +27 -1
- lightning_sdk/lightning_cloud/openapi/models/v1_purchase_annual_upsell_response.py +123 -0
- lightning_sdk/lightning_cloud/openapi/models/v1_quote_annual_upsell_response.py +201 -0
- lightning_sdk/lightning_cloud/openapi/models/v1_storage_asset.py +107 -3
- lightning_sdk/lightning_cloud/openapi/models/v1_user_features.py +1 -27
- lightning_sdk/llm/llm.py +2 -2
- lightning_sdk/llm/public_assistants.py +4 -0
- lightning_sdk/studio.py +92 -28
- lightning_sdk/teamspace.py +25 -2
- lightning_sdk/user.py +19 -1
- lightning_sdk/utils/config.py +6 -0
- lightning_sdk/utils/names.py +1179 -0
- lightning_sdk/utils/progress.py +284 -0
- lightning_sdk/utils/resolve.py +6 -6
- {lightning_sdk-2025.8.19.post0.dist-info → lightning_sdk-2025.8.26.dist-info}/METADATA +1 -1
- {lightning_sdk-2025.8.19.post0.dist-info → lightning_sdk-2025.8.26.dist-info}/RECORD +61 -53
- {lightning_sdk-2025.8.19.post0.dist-info → lightning_sdk-2025.8.26.dist-info}/LICENSE +0 -0
- {lightning_sdk-2025.8.19.post0.dist-info → lightning_sdk-2025.8.26.dist-info}/WHEEL +0 -0
- {lightning_sdk-2025.8.19.post0.dist-info → lightning_sdk-2025.8.26.dist-info}/entry_points.txt +0 -0
- {lightning_sdk-2025.8.19.post0.dist-info → lightning_sdk-2025.8.26.dist-info}/top_level.txt +0 -0
lightning_sdk/__init__.py
CHANGED
lightning_sdk/api/llm_api.py
CHANGED
|
@@ -146,7 +146,6 @@ class LLMApi:
|
|
|
146
146
|
{"contentType": "text", "parts": [prompt]},
|
|
147
147
|
],
|
|
148
148
|
},
|
|
149
|
-
"max_tokens": max_completion_tokens,
|
|
150
149
|
"conversation_id": conversation_id,
|
|
151
150
|
"billing_project_id": billing_project_id,
|
|
152
151
|
"name": name,
|
|
@@ -159,6 +158,9 @@ class LLMApi:
|
|
|
159
158
|
"parent_message_id": kwargs.get("parent_message_id", ""),
|
|
160
159
|
"tools": tools,
|
|
161
160
|
}
|
|
161
|
+
if max_completion_tokens is not None:
|
|
162
|
+
body["max_completion_tokens"] = max_completion_tokens
|
|
163
|
+
|
|
162
164
|
if images:
|
|
163
165
|
for image in images:
|
|
164
166
|
url = image
|
|
@@ -203,7 +205,6 @@ class LLMApi:
|
|
|
203
205
|
{"contentType": "text", "parts": [prompt]},
|
|
204
206
|
],
|
|
205
207
|
},
|
|
206
|
-
"max_completion_tokens": max_completion_tokens,
|
|
207
208
|
"conversation_id": conversation_id,
|
|
208
209
|
"billing_project_id": billing_project_id,
|
|
209
210
|
"name": name,
|
|
@@ -216,6 +217,9 @@ class LLMApi:
|
|
|
216
217
|
"parent_message_id": kwargs.get("parent_message_id", ""),
|
|
217
218
|
"sent_at": datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="microseconds"),
|
|
218
219
|
}
|
|
220
|
+
if max_completion_tokens is not None:
|
|
221
|
+
body["max_completion_tokens"] = max_completion_tokens
|
|
222
|
+
|
|
219
223
|
if images:
|
|
220
224
|
for image in images:
|
|
221
225
|
url = image
|
lightning_sdk/api/studio_api.py
CHANGED
|
@@ -11,7 +11,7 @@ from tqdm import tqdm
|
|
|
11
11
|
|
|
12
12
|
from lightning_sdk.api.utils import (
|
|
13
13
|
_create_app,
|
|
14
|
-
|
|
14
|
+
_download_teamspace_files,
|
|
15
15
|
_DummyBody,
|
|
16
16
|
_DummyResponse,
|
|
17
17
|
_FileUploader,
|
|
@@ -25,6 +25,7 @@ from lightning_sdk.constants import _LIGHTNING_DEBUG
|
|
|
25
25
|
from lightning_sdk.lightning_cloud.login import Auth
|
|
26
26
|
from lightning_sdk.lightning_cloud.openapi import (
|
|
27
27
|
CloudspaceIdRunsBody,
|
|
28
|
+
CloudspacesIdBody,
|
|
28
29
|
Externalv1LightningappInstance,
|
|
29
30
|
IdCodeconfigBody,
|
|
30
31
|
IdExecuteBody1,
|
|
@@ -40,6 +41,7 @@ from lightning_sdk.lightning_cloud.openapi import (
|
|
|
40
41
|
V1CloudSpaceState,
|
|
41
42
|
V1ClusterAccelerator,
|
|
42
43
|
V1EndpointType,
|
|
44
|
+
V1EnvVar,
|
|
43
45
|
V1GetCloudSpaceInstanceStatusResponse,
|
|
44
46
|
V1GetLongRunningCommandInCloudSpaceResponse,
|
|
45
47
|
V1LoginRequest,
|
|
@@ -205,6 +207,32 @@ class StudioApi:
|
|
|
205
207
|
instance_id = code_status.in_use.cloud_space_instance_id
|
|
206
208
|
print(f"Studio started | {teamspace_id=} {studio_id=} {instance_id=}")
|
|
207
209
|
|
|
210
|
+
def start_studio_async(
|
|
211
|
+
self,
|
|
212
|
+
studio_id: str,
|
|
213
|
+
teamspace_id: str,
|
|
214
|
+
machine: Union[Machine, str],
|
|
215
|
+
interruptible: bool = False,
|
|
216
|
+
max_runtime: Optional[int] = None,
|
|
217
|
+
) -> None:
|
|
218
|
+
"""Start an existing Studio without blocking."""
|
|
219
|
+
# need to go via kwargs for typing compatibility since autogenerated apis accept None but aren't typed with None
|
|
220
|
+
optional_kwargs_compute_body = {}
|
|
221
|
+
|
|
222
|
+
if max_runtime is not None:
|
|
223
|
+
optional_kwargs_compute_body["requested_run_duration_seconds"] = str(max_runtime)
|
|
224
|
+
self._client.cloud_space_service_start_cloud_space_instance(
|
|
225
|
+
IdStartBody(
|
|
226
|
+
compute_config=V1UserRequestedComputeConfig(
|
|
227
|
+
name=_machine_to_compute_name(machine),
|
|
228
|
+
spot=interruptible,
|
|
229
|
+
**optional_kwargs_compute_body,
|
|
230
|
+
)
|
|
231
|
+
),
|
|
232
|
+
teamspace_id,
|
|
233
|
+
studio_id,
|
|
234
|
+
)
|
|
235
|
+
|
|
208
236
|
def stop_studio(self, studio_id: str, teamspace_id: str) -> None:
|
|
209
237
|
"""Stop an existing Studio."""
|
|
210
238
|
self.stop_keeping_alive(teamspace_id=teamspace_id, studio_id=studio_id)
|
|
@@ -289,6 +317,79 @@ class StudioApi:
|
|
|
289
317
|
break
|
|
290
318
|
time.sleep(1)
|
|
291
319
|
|
|
320
|
+
def switch_studio_machine_with_progress(
|
|
321
|
+
self,
|
|
322
|
+
studio_id: str,
|
|
323
|
+
teamspace_id: str,
|
|
324
|
+
machine: Union[Machine, str],
|
|
325
|
+
interruptible: bool,
|
|
326
|
+
progress: Any, # StudioProgressTracker - avoid circular import
|
|
327
|
+
) -> None:
|
|
328
|
+
"""Switches given Studio to a new machine type with progress tracking."""
|
|
329
|
+
progress.update_progress(10, "Requesting machine switch...")
|
|
330
|
+
|
|
331
|
+
self._request_switch(
|
|
332
|
+
studio_id=studio_id, teamspace_id=teamspace_id, machine=machine, interruptible=interruptible
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
progress.update_progress(20, "Waiting for machine allocation...")
|
|
336
|
+
|
|
337
|
+
# Wait until it's time to switch
|
|
338
|
+
requested_was_found = False
|
|
339
|
+
startup_status = None
|
|
340
|
+
base_progress = 20
|
|
341
|
+
max_wait_progress = 60
|
|
342
|
+
wait_counter = 0
|
|
343
|
+
|
|
344
|
+
while True:
|
|
345
|
+
status = self.get_studio_status(studio_id, teamspace_id)
|
|
346
|
+
requested_machine = status.requested
|
|
347
|
+
|
|
348
|
+
if requested_machine is not None:
|
|
349
|
+
requested_was_found = True
|
|
350
|
+
startup_status = requested_machine.startup_status
|
|
351
|
+
|
|
352
|
+
# if the requested machine was found in the past, use the in_use status instead.
|
|
353
|
+
# it might be that it either was cancelled or it actually is ready.
|
|
354
|
+
# Either way, since we're actually blocking below for the in use startup status
|
|
355
|
+
# it's safe to switch at this point
|
|
356
|
+
elif requested_was_found:
|
|
357
|
+
in_use_machine = status.in_use
|
|
358
|
+
if in_use_machine is not None:
|
|
359
|
+
startup_status = in_use_machine.startup_status
|
|
360
|
+
|
|
361
|
+
if startup_status and startup_status.initial_restore_finished:
|
|
362
|
+
break
|
|
363
|
+
|
|
364
|
+
# Update progress gradually while waiting
|
|
365
|
+
wait_counter += 1
|
|
366
|
+
current_progress = min(base_progress + (wait_counter * 2), max_wait_progress)
|
|
367
|
+
progress.update_progress(current_progress, "Allocating new machine...")
|
|
368
|
+
time.sleep(1)
|
|
369
|
+
|
|
370
|
+
progress.update_progress(70, "Starting machine switch...")
|
|
371
|
+
self._client.cloud_space_service_switch_cloud_space_instance(teamspace_id, studio_id)
|
|
372
|
+
|
|
373
|
+
progress.update_progress(80, "Configuring new machine...")
|
|
374
|
+
|
|
375
|
+
# Wait until the new machine is ready to use
|
|
376
|
+
switch_counter = 0
|
|
377
|
+
while True:
|
|
378
|
+
in_use = self.get_studio_status(studio_id, teamspace_id).in_use
|
|
379
|
+
if in_use is None:
|
|
380
|
+
continue
|
|
381
|
+
startup_status = in_use.startup_status
|
|
382
|
+
if startup_status and startup_status.top_up_restore_finished:
|
|
383
|
+
break
|
|
384
|
+
|
|
385
|
+
# Update progress while waiting for machine to be ready
|
|
386
|
+
switch_counter += 1
|
|
387
|
+
current_progress = min(80 + switch_counter, 95)
|
|
388
|
+
progress.update_progress(current_progress, "Finalizing machine setup...")
|
|
389
|
+
time.sleep(1)
|
|
390
|
+
|
|
391
|
+
progress.complete("Machine switch completed successfully")
|
|
392
|
+
|
|
292
393
|
def get_machine(self, studio_id: str, teamspace_id: str, cloud_account_id: str, org_id: str) -> Machine:
|
|
293
394
|
"""Get the current machine type the given Studio is running on."""
|
|
294
395
|
response: V1CloudSpaceInstanceConfig = self._client.cloud_space_service_get_cloud_space_instance_config(
|
|
@@ -578,7 +679,7 @@ class StudioApi:
|
|
|
578
679
|
if prefix.endswith("/") is False:
|
|
579
680
|
prefix = prefix + "/"
|
|
580
681
|
|
|
581
|
-
|
|
682
|
+
_download_teamspace_files(
|
|
582
683
|
client=self._client,
|
|
583
684
|
teamspace_id=teamspace_id,
|
|
584
685
|
cluster_id=cloud_account,
|
|
@@ -841,3 +942,68 @@ class StudioApi:
|
|
|
841
942
|
plugin_type=plugin_type,
|
|
842
943
|
**other_arguments,
|
|
843
944
|
)
|
|
945
|
+
|
|
946
|
+
def _update_cloudspace(self, studio: V1CloudSpace, teamspace_id: str, key: str, value: Any) -> None:
|
|
947
|
+
body = CloudspacesIdBody(
|
|
948
|
+
code_url=studio.code_url,
|
|
949
|
+
data_connection_mounts=studio.data_connection_mounts,
|
|
950
|
+
description=studio.description,
|
|
951
|
+
display_name=studio.display_name,
|
|
952
|
+
env=studio.env,
|
|
953
|
+
featured=studio.featured,
|
|
954
|
+
hide_files=studio.hide_files,
|
|
955
|
+
is_cloudspace_private=studio.is_cloudspace_private,
|
|
956
|
+
is_code_private=studio.is_code_private,
|
|
957
|
+
is_favorite=studio.is_favorite,
|
|
958
|
+
is_published=studio.is_published,
|
|
959
|
+
license=studio.license,
|
|
960
|
+
license_url=studio.license_url,
|
|
961
|
+
message=studio.message,
|
|
962
|
+
multi_user_edit=studio.multi_user_edit,
|
|
963
|
+
operating_cost=studio.operating_cost,
|
|
964
|
+
paper_authors=studio.paper_authors,
|
|
965
|
+
paper_org=studio.paper_org,
|
|
966
|
+
paper_org_avatar_url=studio.paper_org_avatar_url,
|
|
967
|
+
paper_url=studio.paper_url,
|
|
968
|
+
switch_to_default_machine_on_idle=studio.switch_to_default_machine_on_idle,
|
|
969
|
+
tags=studio.tags,
|
|
970
|
+
thumbnail_file_type=studio.thumbnail_file_type,
|
|
971
|
+
user_metadata=studio.user_metadata,
|
|
972
|
+
)
|
|
973
|
+
|
|
974
|
+
setattr(body, key, value)
|
|
975
|
+
|
|
976
|
+
self._client.cloud_space_service_update_cloud_space(
|
|
977
|
+
id=studio.id,
|
|
978
|
+
project_id=teamspace_id,
|
|
979
|
+
body=body,
|
|
980
|
+
)
|
|
981
|
+
|
|
982
|
+
def set_env(
|
|
983
|
+
self,
|
|
984
|
+
studio: V1CloudSpace,
|
|
985
|
+
teamspace_id: str,
|
|
986
|
+
new_env: Dict[str, str],
|
|
987
|
+
partial: bool = True,
|
|
988
|
+
) -> None:
|
|
989
|
+
"""Set the environment variables for the Studio.
|
|
990
|
+
|
|
991
|
+
Args:
|
|
992
|
+
new_env: The new environment variables to set.
|
|
993
|
+
partial: Whether to only set the environment variables that are provided.
|
|
994
|
+
If False, existing environment variables that are not in new_env will be removed.
|
|
995
|
+
If True, existing environment variables that are not in new_env will be kept.
|
|
996
|
+
"""
|
|
997
|
+
updated_env_dict = {}
|
|
998
|
+
if partial:
|
|
999
|
+
updated_env_dict = {env.name: env.value for env in studio.env}
|
|
1000
|
+
updated_env_dict.update(new_env)
|
|
1001
|
+
else:
|
|
1002
|
+
updated_env_dict = new_env
|
|
1003
|
+
|
|
1004
|
+
updated_env = [V1EnvVar(name=key, value=value) for key, value in updated_env_dict.items()]
|
|
1005
|
+
|
|
1006
|
+
self._update_cloudspace(studio, teamspace_id, "env", updated_env)
|
|
1007
|
+
|
|
1008
|
+
def get_env(self, studio: V1CloudSpace) -> Dict[str, str]:
|
|
1009
|
+
return {env.name: env.value for env in studio.env}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import os
|
|
2
|
-
import
|
|
3
|
-
import zipfile
|
|
2
|
+
import re
|
|
4
3
|
from pathlib import Path
|
|
5
4
|
from typing import Dict, List, Optional, Tuple
|
|
6
5
|
|
|
@@ -9,6 +8,7 @@ from tqdm.auto import tqdm
|
|
|
9
8
|
|
|
10
9
|
from lightning_sdk.api.utils import (
|
|
11
10
|
_download_model_files,
|
|
11
|
+
_download_teamspace_files,
|
|
12
12
|
_DummyBody,
|
|
13
13
|
_FileUploader,
|
|
14
14
|
_get_model_version,
|
|
@@ -22,6 +22,8 @@ from lightning_sdk.lightning_cloud.openapi import (
|
|
|
22
22
|
ModelsStoreApi,
|
|
23
23
|
ProjectIdAgentsBody,
|
|
24
24
|
ProjectIdModelsBody,
|
|
25
|
+
ProjectIdSecretsBody,
|
|
26
|
+
SecretsIdBody,
|
|
25
27
|
V1Assistant,
|
|
26
28
|
V1CloudSpace,
|
|
27
29
|
V1ClusterAccelerator,
|
|
@@ -34,6 +36,8 @@ from lightning_sdk.lightning_cloud.openapi import (
|
|
|
34
36
|
V1Project,
|
|
35
37
|
V1ProjectClusterBinding,
|
|
36
38
|
V1PromptSuggestion,
|
|
39
|
+
V1Secret,
|
|
40
|
+
V1SecretType,
|
|
37
41
|
V1UpstreamOpenAI,
|
|
38
42
|
)
|
|
39
43
|
from lightning_sdk.lightning_cloud.rest_client import LightningClient
|
|
@@ -421,39 +425,65 @@ class TeamspaceApi:
|
|
|
421
425
|
# TODO: Update this endpoint to permit basic auth
|
|
422
426
|
auth = Auth()
|
|
423
427
|
auth.authenticate()
|
|
424
|
-
token = self._client.auth_service_login(V1LoginRequest(auth.api_key)).token
|
|
425
428
|
|
|
426
|
-
|
|
427
|
-
"clusterId": cloud_account,
|
|
428
|
-
"prefix": _resolve_teamspace_remote_path(path),
|
|
429
|
-
"token": token,
|
|
430
|
-
}
|
|
429
|
+
prefix = _resolve_teamspace_remote_path(path)
|
|
431
430
|
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
431
|
+
# ensure we only download as a directory and not the entire prefix
|
|
432
|
+
if prefix.endswith("/") is False:
|
|
433
|
+
prefix = prefix + "/"
|
|
434
|
+
|
|
435
|
+
_download_teamspace_files(
|
|
436
|
+
client=self._client,
|
|
437
|
+
teamspace_id=teamspace_id,
|
|
438
|
+
cluster_id=cloud_account,
|
|
439
|
+
prefix=prefix,
|
|
440
|
+
download_dir=Path(target_path),
|
|
441
|
+
progress_bar=progress_bar,
|
|
436
442
|
)
|
|
437
443
|
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
444
|
+
def get_secrets(self, teamspace_id: str) -> Dict[str, str]:
|
|
445
|
+
"""Get all secrets for a teamspace."""
|
|
446
|
+
secrets = self._get_secrets(teamspace_id)
|
|
447
|
+
# this returns encrypted values for security. It doesn't make sense to show them,
|
|
448
|
+
# so we just return a placeholder
|
|
449
|
+
# not a security issue to replace in the client as we get the encrypted values from the server.
|
|
450
|
+
return {secret.name: "***REDACTED***" for secret in secrets if secret.type == V1SecretType.UNSPECIFIED}
|
|
445
451
|
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
pbar_update = lambda x: None
|
|
452
|
+
def set_secret(self, teamspace_id: str, key: str, value: str) -> None:
|
|
453
|
+
"""Set a secret for a teamspace.
|
|
449
454
|
|
|
450
|
-
if
|
|
451
|
-
|
|
455
|
+
This will replace the existing secret if it exists and create a new one if it doesn't.
|
|
456
|
+
"""
|
|
457
|
+
secrets = self._get_secrets(teamspace_id)
|
|
458
|
+
for secret in secrets:
|
|
459
|
+
if secret.name == key:
|
|
460
|
+
return self._update_secret(teamspace_id, secret.id, value)
|
|
461
|
+
return self._create_secret(teamspace_id, key, value)
|
|
462
|
+
|
|
463
|
+
def _get_secrets(self, teamspace_id: str) -> List[V1Secret]:
|
|
464
|
+
return self._client.secret_service_list_secrets(project_id=teamspace_id).secrets
|
|
465
|
+
|
|
466
|
+
def _update_secret(self, teamspace_id: str, secret_id: str, value: str) -> None:
|
|
467
|
+
self._client.secret_service_update_secret(
|
|
468
|
+
body=SecretsIdBody(value=value),
|
|
469
|
+
project_id=teamspace_id,
|
|
470
|
+
id=secret_id,
|
|
471
|
+
)
|
|
452
472
|
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
473
|
+
def _create_secret(
|
|
474
|
+
self,
|
|
475
|
+
teamspace_id: str,
|
|
476
|
+
key: str,
|
|
477
|
+
value: str,
|
|
478
|
+
) -> None:
|
|
479
|
+
self._client.secret_service_create_secret(
|
|
480
|
+
body=ProjectIdSecretsBody(name=key, value=value, type=V1SecretType.UNSPECIFIED), project_id=teamspace_id
|
|
481
|
+
)
|
|
457
482
|
|
|
458
|
-
|
|
459
|
-
|
|
483
|
+
def verify_secret_name(self, name: str) -> bool:
|
|
484
|
+
"""Verify if a secret name is valid.
|
|
485
|
+
|
|
486
|
+
A valid secret name starts with a letter or underscore, followed by letters, digits, or underscores.
|
|
487
|
+
"""
|
|
488
|
+
pattern = r"^[A-Za-z_][A-Za-z0-9_]*$"
|
|
489
|
+
return re.match(pattern, name) is not None
|
lightning_sdk/api/user_api.py
CHANGED
|
@@ -1,15 +1,20 @@
|
|
|
1
|
-
|
|
1
|
+
import re
|
|
2
|
+
from typing import Dict, List, Union
|
|
2
3
|
|
|
3
4
|
from lightning_sdk.lightning_cloud.login import Auth
|
|
4
5
|
from lightning_sdk.lightning_cloud.openapi import (
|
|
6
|
+
SecretsIdBody1,
|
|
5
7
|
V1CloudSpace,
|
|
8
|
+
V1CreateUserSecretRequest,
|
|
6
9
|
V1GetUserResponse,
|
|
7
10
|
V1ListCloudSpacesResponse,
|
|
8
11
|
V1Membership,
|
|
9
12
|
V1Organization,
|
|
10
13
|
V1SearchUser,
|
|
14
|
+
V1Secret,
|
|
11
15
|
V1UserFeatures,
|
|
12
16
|
)
|
|
17
|
+
from lightning_sdk.lightning_cloud.openapi.models.v1_secret_type import V1SecretType
|
|
13
18
|
from lightning_sdk.lightning_cloud.rest_client import LightningClient
|
|
14
19
|
|
|
15
20
|
|
|
@@ -69,3 +74,46 @@ class UserApi:
|
|
|
69
74
|
def _get_feature_flags(self) -> V1UserFeatures:
|
|
70
75
|
resp: V1GetUserResponse = self._client.auth_service_get_user()
|
|
71
76
|
return resp.features
|
|
77
|
+
|
|
78
|
+
def get_secrets(self) -> Dict[str, str]:
|
|
79
|
+
"""Get all secrets for the current user."""
|
|
80
|
+
secrets = self._get_secrets()
|
|
81
|
+
# this returns encrypted values for security. It doesn't make sense to show them,
|
|
82
|
+
# so we just return a placeholder
|
|
83
|
+
# not a security issue to replace in the client as we get the encrypted values from the server.
|
|
84
|
+
return {secret.name: "***REDACTED***" for secret in secrets if secret.type == V1SecretType.UNSPECIFIED}
|
|
85
|
+
|
|
86
|
+
def set_secret(self, key: str, value: str) -> None:
|
|
87
|
+
"""Set a secret for the current user.
|
|
88
|
+
|
|
89
|
+
This will replace the existing secret if it exists and create a new one if it doesn't.
|
|
90
|
+
"""
|
|
91
|
+
secrets = self._get_secrets()
|
|
92
|
+
for secret in secrets:
|
|
93
|
+
if secret.name == key:
|
|
94
|
+
return self._update_secret(secret.id, value)
|
|
95
|
+
return self._create_secret(key, value)
|
|
96
|
+
|
|
97
|
+
def _get_secrets(self) -> List[V1Secret]:
|
|
98
|
+
return self._client.secret_service_list_user_secrets().secrets
|
|
99
|
+
|
|
100
|
+
def _update_secret(self, secret_id: str, value: str) -> None:
|
|
101
|
+
self._client.secret_service_update_user_secret(
|
|
102
|
+
body=SecretsIdBody1(value=value),
|
|
103
|
+
id=secret_id,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
def _create_secret(
|
|
107
|
+
self,
|
|
108
|
+
key: str,
|
|
109
|
+
value: str,
|
|
110
|
+
) -> None:
|
|
111
|
+
self._client.secret_service_create_user_secret(body=V1CreateUserSecretRequest(name=key, value=value))
|
|
112
|
+
|
|
113
|
+
def verify_secret_name(self, name: str) -> bool:
|
|
114
|
+
"""Verify if a secret name is valid.
|
|
115
|
+
|
|
116
|
+
A valid secret name starts with a letter or underscore, followed by letters, digits, or underscores.
|
|
117
|
+
"""
|
|
118
|
+
pattern = r"^[A-Za-z_][A-Za-z0-9_]*$"
|
|
119
|
+
return re.match(pattern, name) is not None
|
lightning_sdk/api/utils.py
CHANGED
lightning_sdk/cli/config/set.py
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import click
|
|
2
2
|
|
|
3
|
-
from lightning_sdk.cli.utils.
|
|
3
|
+
from lightning_sdk.cli.utils.save_to_config import save_teamspace_to_config
|
|
4
|
+
from lightning_sdk.cli.utils.teamspace_selection import TeamspacesMenu
|
|
4
5
|
from lightning_sdk.machine import CloudProvider
|
|
5
|
-
from lightning_sdk.organization import Organization
|
|
6
6
|
from lightning_sdk.studio import Studio
|
|
7
7
|
from lightning_sdk.utils.config import Config, DefaultConfigKeys
|
|
8
8
|
from lightning_sdk.utils.resolve import _resolve_org, _resolve_user
|
|
@@ -59,23 +59,11 @@ def set_studio(studio_name: str) -> None:
|
|
|
59
59
|
@click.argument("teamspace_name")
|
|
60
60
|
def set_teamspace(teamspace_name: str) -> None:
|
|
61
61
|
"""Set the default teamspace name in the config."""
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
teamspace_resolved = resolve_teamspace_owner_name_format(teamspace_name)
|
|
62
|
+
menu = TeamspacesMenu()
|
|
63
|
+
teamspace_resolved = menu(teamspace=teamspace_name)
|
|
65
64
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
raise ValueError(
|
|
69
|
-
f"Could not resolve teamspace: '{teamspace_name}'. "
|
|
70
|
-
"Teamspace should be specified as 'owner/name'. Does the teamspace exist?"
|
|
71
|
-
)
|
|
72
|
-
|
|
73
|
-
setattr(config, DefaultConfigKeys.teamspace_name, teamspace_resolved.name)
|
|
74
|
-
setattr(config, DefaultConfigKeys.teamspace_owner, teamspace_resolved.owner.name)
|
|
75
|
-
if isinstance(teamspace_resolved.owner, Organization):
|
|
76
|
-
setattr(config, DefaultConfigKeys.teamspace_owner_type, "organization")
|
|
77
|
-
else:
|
|
78
|
-
setattr(config, DefaultConfigKeys.teamspace_owner_type, "user")
|
|
65
|
+
# explicit user action, so overwrite the config
|
|
66
|
+
save_teamspace_to_config(teamspace_resolved, overwrite=True)
|
|
79
67
|
|
|
80
68
|
|
|
81
69
|
@set_value.command("cloud-account")
|
|
@@ -7,10 +7,8 @@ import click
|
|
|
7
7
|
from rich.console import Console
|
|
8
8
|
|
|
9
9
|
from lightning_sdk import Machine, Studio
|
|
10
|
-
from lightning_sdk.
|
|
11
|
-
from lightning_sdk.cli.legacy.teamspace_menu import _TeamspacesMenu
|
|
10
|
+
from lightning_sdk.cli.utils.teamspace_selection import TeamspacesMenu
|
|
12
11
|
from lightning_sdk.machine import CloudProvider
|
|
13
|
-
from lightning_sdk.utils.resolve import _resolve_deprecated_provider
|
|
14
12
|
|
|
15
13
|
_MACHINE_VALUES = tuple(
|
|
16
14
|
[machine.name for machine in Machine.__dict__.values() if isinstance(machine, Machine) and machine._include_in_cli]
|
|
@@ -80,16 +78,8 @@ def studio(
|
|
|
80
78
|
|
|
81
79
|
NAME: the name of the studio to create. If already present within teamspace, will add a random suffix.
|
|
82
80
|
"""
|
|
83
|
-
menu =
|
|
84
|
-
teamspace_resolved = menu
|
|
85
|
-
|
|
86
|
-
cloud_provider = str(_resolve_deprecated_provider(cloud_provider, provider))
|
|
87
|
-
|
|
88
|
-
if cloud_provider is not None:
|
|
89
|
-
cloud_account_api = CloudAccountApi()
|
|
90
|
-
cloud_account = cloud_account_api.resolve_cloud_account(
|
|
91
|
-
teamspace_resolved.id, cloud_account, cloud_provider, teamspace_resolved.default_cloud_account
|
|
92
|
-
)
|
|
81
|
+
menu = TeamspacesMenu()
|
|
82
|
+
teamspace_resolved = menu(teamspace)
|
|
93
83
|
|
|
94
84
|
# default cloud account to current studios cloud account if run from studio
|
|
95
85
|
# else it will fall back to teamspace default in the backend
|
|
@@ -107,11 +97,19 @@ def studio(
|
|
|
107
97
|
console.print(f"Studio with name {name} already exists. Using {new_name} instead.")
|
|
108
98
|
name = new_name
|
|
109
99
|
|
|
110
|
-
studio = Studio(
|
|
100
|
+
studio = Studio(
|
|
101
|
+
name=name,
|
|
102
|
+
teamspace=teamspace_resolved,
|
|
103
|
+
cloud_account=cloud_account,
|
|
104
|
+
create_ok=True,
|
|
105
|
+
cloud_provider=cloud_provider,
|
|
106
|
+
provider=provider,
|
|
107
|
+
)
|
|
111
108
|
|
|
112
109
|
console.print(f"Created Studio {studio.name}.")
|
|
113
110
|
|
|
114
111
|
if start is not None:
|
|
115
112
|
start_machine = getattr(Machine, start, start)
|
|
113
|
+
Studio.show_progress = True
|
|
116
114
|
studio.start(start_machine)
|
|
117
115
|
console.print(f"Started Studio {studio.name} on machine {start}")
|
|
@@ -5,7 +5,7 @@ from rich.console import Console
|
|
|
5
5
|
|
|
6
6
|
from lightning_sdk.cli.legacy.exceptions import StudioCliError
|
|
7
7
|
from lightning_sdk.cli.legacy.job_and_mmt_action import _JobAndMMTAction
|
|
8
|
-
from lightning_sdk.cli.
|
|
8
|
+
from lightning_sdk.cli.utils.teamspace_selection import TeamspacesMenu
|
|
9
9
|
from lightning_sdk.lightning_cloud.openapi.rest import ApiException
|
|
10
10
|
from lightning_sdk.lit_container import LitContainer
|
|
11
11
|
from lightning_sdk.studio import Studio
|
|
@@ -30,8 +30,8 @@ def delete() -> None:
|
|
|
30
30
|
def container(name: str, teamspace: Optional[str] = None) -> None:
|
|
31
31
|
"""Delete the docker container NAME."""
|
|
32
32
|
api = LitContainer()
|
|
33
|
-
menu =
|
|
34
|
-
resolved_teamspace = menu
|
|
33
|
+
menu = TeamspacesMenu()
|
|
34
|
+
resolved_teamspace = menu(teamspace=teamspace)
|
|
35
35
|
try:
|
|
36
36
|
api.delete_container(name, resolved_teamspace.name, resolved_teamspace.owner.name)
|
|
37
37
|
Console().print(f"Container {name} deleted successfully.")
|
|
@@ -10,7 +10,7 @@ from rich.prompt import Confirm
|
|
|
10
10
|
|
|
11
11
|
from lightning_sdk import Teamspace
|
|
12
12
|
from lightning_sdk.api import UserApi
|
|
13
|
-
from lightning_sdk.cli.
|
|
13
|
+
from lightning_sdk.cli.utils.teamspace_selection import TeamspacesMenu
|
|
14
14
|
from lightning_sdk.lightning_cloud import env
|
|
15
15
|
from lightning_sdk.lightning_cloud.login import Auth, AuthServer
|
|
16
16
|
from lightning_sdk.lightning_cloud.openapi import V1CloudSpace
|
|
@@ -74,13 +74,13 @@ def authenticate(mode: _AuthMode, shall_confirm: bool = True) -> None:
|
|
|
74
74
|
def select_teamspace(teamspace: Optional[str], org: Optional[str], user: Optional[str]) -> Teamspace:
|
|
75
75
|
if teamspace is None:
|
|
76
76
|
user = _get_authed_user()
|
|
77
|
-
menu =
|
|
77
|
+
menu = TeamspacesMenu()
|
|
78
78
|
possible_teamspaces = menu._get_possible_teamspaces(user)
|
|
79
79
|
if len(possible_teamspaces) == 1:
|
|
80
80
|
name = next(iter(possible_teamspaces.values()))["name"]
|
|
81
81
|
return Teamspace(name=name, org=org, user=user)
|
|
82
82
|
|
|
83
|
-
return menu
|
|
83
|
+
return menu(teamspace)
|
|
84
84
|
|
|
85
85
|
return _resolve_teamspace(teamspace=teamspace, org=org, user=user)
|
|
86
86
|
|
|
@@ -180,7 +180,7 @@ class _Onboarding:
|
|
|
180
180
|
return select_teamspace(teamspace, org, user)
|
|
181
181
|
|
|
182
182
|
# Run only when user hasn't completed onboarding yet.
|
|
183
|
-
menu =
|
|
183
|
+
menu = TeamspacesMenu()
|
|
184
184
|
self._wait_user_onboarding()
|
|
185
185
|
# Onboarding has been completed - user already selected organization if they could
|
|
186
186
|
possible_teamspaces = menu._get_possible_teamspaces(self.user)
|
|
@@ -11,7 +11,7 @@ from lightning_sdk.api.license_api import LicenseApi
|
|
|
11
11
|
from lightning_sdk.api.lit_container_api import LitContainerApi
|
|
12
12
|
from lightning_sdk.cli.legacy.exceptions import StudioCliError
|
|
13
13
|
from lightning_sdk.cli.legacy.studios_menu import _StudiosMenu
|
|
14
|
-
from lightning_sdk.cli.
|
|
14
|
+
from lightning_sdk.cli.utils.teamspace_selection import TeamspacesMenu
|
|
15
15
|
from lightning_sdk.models import download_model
|
|
16
16
|
from lightning_sdk.studio import Studio
|
|
17
17
|
from lightning_sdk.utils.resolve import _get_authed_user, skip_studio_init
|
|
@@ -110,8 +110,8 @@ def folder(
|
|
|
110
110
|
path = _expand_remote_path(path)
|
|
111
111
|
resolved_downloader = _resolve_studio(studio)
|
|
112
112
|
elif teamspace:
|
|
113
|
-
menu =
|
|
114
|
-
resolved_downloader = menu
|
|
113
|
+
menu = TeamspacesMenu()
|
|
114
|
+
resolved_downloader = menu(teamspace)
|
|
115
115
|
else:
|
|
116
116
|
raise ValueError("Either --studio or --teamspace must be provided")
|
|
117
117
|
|
|
@@ -173,8 +173,8 @@ def file(path: str = "", studio: Optional[str] = None, teamspace: Optional[str]
|
|
|
173
173
|
if studio:
|
|
174
174
|
resolved_downloader = _resolve_studio(studio)
|
|
175
175
|
elif teamspace:
|
|
176
|
-
menu =
|
|
177
|
-
resolved_downloader = menu
|
|
176
|
+
menu = TeamspacesMenu()
|
|
177
|
+
resolved_downloader = menu(teamspace)
|
|
178
178
|
else:
|
|
179
179
|
raise ValueError("Either --studio or --teamspace must be provided")
|
|
180
180
|
|
|
@@ -214,8 +214,8 @@ def download_container(
|
|
|
214
214
|
CONTAINER: The name of the container to download.
|
|
215
215
|
"""
|
|
216
216
|
console = Console()
|
|
217
|
-
menu =
|
|
218
|
-
resolved_teamspace = menu
|
|
217
|
+
menu = TeamspacesMenu()
|
|
218
|
+
resolved_teamspace = menu(teamspace)
|
|
219
219
|
with console.status("Downloading container..."):
|
|
220
220
|
api = LitContainerApi()
|
|
221
221
|
api.download_container(container, resolved_teamspace, tag, cloud_account)
|