lightning-sdk 2025.12.16__py3-none-any.whl → 2026.1.22__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/__version__.py +1 -1
- lightning_sdk/api/k8s_api.py +75 -29
- lightning_sdk/api/org_api.py +7 -0
- lightning_sdk/api/studio_api.py +223 -36
- lightning_sdk/api/teamspace_api.py +28 -9
- lightning_sdk/api/user_api.py +5 -0
- lightning_sdk/cli/cp/__init__.py +64 -0
- lightning_sdk/cli/entrypoint.py +2 -0
- lightning_sdk/cli/groups.py +22 -0
- lightning_sdk/cli/legacy/clusters_menu.py +2 -2
- lightning_sdk/cli/legacy/deploy/_auth.py +7 -6
- lightning_sdk/cli/legacy/download.py +2 -1
- lightning_sdk/cli/legacy/run.py +13 -2
- lightning_sdk/cli/legacy/studios_menu.py +8 -1
- lightning_sdk/cli/studio/__init__.py +4 -0
- lightning_sdk/cli/studio/cp.py +20 -64
- lightning_sdk/cli/studio/ls.py +57 -0
- lightning_sdk/cli/studio/rm.py +71 -0
- lightning_sdk/cli/utils/logging.py +2 -1
- lightning_sdk/cli/utils/studio_filesystem.py +65 -0
- lightning_sdk/cli/utils/teamspace_selection.py +5 -0
- lightning_sdk/exceptions.py +4 -0
- lightning_sdk/job/base.py +24 -5
- lightning_sdk/job/job.py +12 -5
- lightning_sdk/job/v1.py +5 -2
- lightning_sdk/job/v2.py +9 -1
- lightning_sdk/k8s_cluster.py +9 -10
- lightning_sdk/lightning_cloud/__version__.py +1 -1
- lightning_sdk/lightning_cloud/openapi/__init__.py +46 -3
- lightning_sdk/lightning_cloud/openapi/api/__init__.py +1 -0
- lightning_sdk/lightning_cloud/openapi/api/cloud_space_service_api.py +118 -1
- lightning_sdk/lightning_cloud/openapi/api/cluster_service_api.py +787 -125
- lightning_sdk/lightning_cloud/openapi/api/data_connection_service_api.py +5 -1
- lightning_sdk/lightning_cloud/openapi/api/file_system_service_api.py +11 -11
- lightning_sdk/lightning_cloud/openapi/api/k8_s_cluster_service_api.py +246 -19
- lightning_sdk/lightning_cloud/openapi/api/lightningwork_service_api.py +116 -11
- lightning_sdk/lightning_cloud/openapi/api/lit_logger_service_api.py +588 -2
- lightning_sdk/lightning_cloud/openapi/api/models_store_api.py +9 -1
- lightning_sdk/lightning_cloud/openapi/api/storage_service_api.py +5 -1
- lightning_sdk/lightning_cloud/openapi/api/virtual_machine_service_api.py +557 -0
- lightning_sdk/lightning_cloud/openapi/models/__init__.py +45 -3
- lightning_sdk/lightning_cloud/openapi/models/cloud_space_environment_template_service_update_cloud_space_environment_template_body.py +27 -1
- lightning_sdk/lightning_cloud/openapi/models/cluster_service_add_container_registry_body.py +123 -0
- lightning_sdk/lightning_cloud/openapi/models/cluster_service_create_cluster_capacity_reservation_body.py +53 -1
- lightning_sdk/lightning_cloud/openapi/models/cluster_service_create_machine_body.py +79 -1
- lightning_sdk/lightning_cloud/openapi/models/cluster_service_create_org_cluster_capacity_reservation_body.py +409 -0
- lightning_sdk/lightning_cloud/openapi/models/cluster_service_refresh_container_registry_credentials_body.py +97 -0
- lightning_sdk/lightning_cloud/openapi/models/cluster_service_report_machine_system_metrics_body.py +123 -0
- lightning_sdk/lightning_cloud/openapi/models/cluster_service_validate_container_registry_body.py +97 -0
- lightning_sdk/lightning_cloud/openapi/models/externalv1_cloud_space_instance_status.py +27 -1
- lightning_sdk/lightning_cloud/openapi/models/lit_logger_service_create_lit_logger_media_body.py +305 -0
- lightning_sdk/lightning_cloud/openapi/models/lit_logger_service_update_lit_logger_media_body.py +149 -0
- lightning_sdk/lightning_cloud/openapi/models/lit_logger_service_update_metrics_stream_body.py +53 -1
- lightning_sdk/lightning_cloud/openapi/models/v1_add_container_registry_response.py +123 -0
- lightning_sdk/lightning_cloud/openapi/models/v1_capacity_reservation_used_by.py +227 -0
- lightning_sdk/lightning_cloud/openapi/models/v1_cloud_provider.py +1 -1
- lightning_sdk/lightning_cloud/openapi/models/v1_cloud_space_environment_template_config.py +27 -1
- lightning_sdk/lightning_cloud/openapi/models/v1_cloud_space_environment_type.py +1 -0
- lightning_sdk/lightning_cloud/openapi/models/v1_cloud_space_specialized_view.py +1 -0
- lightning_sdk/lightning_cloud/openapi/models/v1_cluster_accelerator.py +27 -1
- lightning_sdk/lightning_cloud/openapi/models/v1_cluster_capacity_reservation.py +27 -1
- lightning_sdk/lightning_cloud/openapi/models/v1_cluster_spec.py +53 -27
- lightning_sdk/lightning_cloud/openapi/models/v1_cluster_type.py +0 -1
- lightning_sdk/lightning_cloud/openapi/models/v1_container_registry.py +253 -0
- lightning_sdk/lightning_cloud/openapi/models/v1_container_registry_info.py +281 -0
- lightning_sdk/lightning_cloud/openapi/models/v1_container_registry_integration.py +123 -0
- lightning_sdk/lightning_cloud/openapi/models/v1_container_registry_status.py +105 -0
- lightning_sdk/lightning_cloud/openapi/models/v1_create_cloud_space_environment_template_request.py +27 -1
- lightning_sdk/lightning_cloud/openapi/models/v1_create_lit_logger_media_response.py +149 -0
- lightning_sdk/lightning_cloud/openapi/models/v1_create_org_cluster_capacity_reservation_response.py +201 -0
- lightning_sdk/lightning_cloud/openapi/models/v1_create_sdk_command_history_request.py +29 -3
- lightning_sdk/lightning_cloud/openapi/models/v1_cudo_direct_v1.py +175 -0
- lightning_sdk/lightning_cloud/openapi/models/v1_delete_container_registry_response.py +97 -0
- lightning_sdk/lightning_cloud/openapi/models/v1_delete_lit_logger_media_response.py +97 -0
- lightning_sdk/lightning_cloud/openapi/models/v1_delete_virtual_machine_response.py +97 -0
- lightning_sdk/lightning_cloud/openapi/models/{v1_ai_pod_v1.py → v1_ecr_registry_config.py} +49 -49
- lightning_sdk/lightning_cloud/openapi/models/v1_ecr_registry_config_input.py +123 -0
- lightning_sdk/lightning_cloud/openapi/models/v1_ecr_registry_details.py +201 -0
- lightning_sdk/lightning_cloud/openapi/models/v1_external_cluster_spec.py +27 -27
- lightning_sdk/lightning_cloud/openapi/models/v1_external_search_user.py +27 -1
- lightning_sdk/lightning_cloud/openapi/models/v1_get_artifacts_page_response.py +29 -3
- lightning_sdk/lightning_cloud/openapi/models/v1_get_kubernetes_pod_logs_response.py +149 -0
- lightning_sdk/lightning_cloud/openapi/models/{v1_get_machine_response.py → v1_get_kubernetes_pod_response.py} +23 -23
- lightning_sdk/lightning_cloud/openapi/models/v1_get_user_response.py +27 -1
- lightning_sdk/lightning_cloud/openapi/models/v1_job_spec.py +27 -1
- lightning_sdk/lightning_cloud/openapi/models/v1_joinable_organization.py +27 -1
- lightning_sdk/lightning_cloud/openapi/models/v1_k8s_incident_setting.py +149 -0
- lightning_sdk/lightning_cloud/openapi/models/v1_k8s_incident_type.py +108 -0
- lightning_sdk/lightning_cloud/openapi/models/v1_kubernetes_direct_settings_v1.py +53 -1
- lightning_sdk/lightning_cloud/openapi/models/v1_kubernetes_direct_v1.py +105 -1
- lightning_sdk/lightning_cloud/openapi/models/v1_kubernetes_pod.py +27 -1
- lightning_sdk/lightning_cloud/openapi/models/v1_kubernetes_pod_logs_page.py +227 -0
- lightning_sdk/lightning_cloud/openapi/models/v1_kubevirt_config.py +357 -0
- lightning_sdk/lightning_cloud/openapi/models/v1_list_container_registries_response.py +123 -0
- lightning_sdk/lightning_cloud/openapi/models/v1_list_kubernetes_pods_response.py +43 -17
- lightning_sdk/lightning_cloud/openapi/models/v1_list_kubernetes_pods_sort_order.py +104 -0
- lightning_sdk/lightning_cloud/openapi/models/v1_list_lit_logger_media_response.py +149 -0
- lightning_sdk/lightning_cloud/openapi/models/v1_list_models_response.py +55 -3
- lightning_sdk/lightning_cloud/openapi/models/v1_list_virtual_machines_response.py +123 -0
- lightning_sdk/lightning_cloud/openapi/models/v1_lit_logger_media.py +513 -0
- lightning_sdk/lightning_cloud/openapi/models/v1_machine.py +53 -53
- lightning_sdk/lightning_cloud/openapi/models/v1_machine_direct_v1.py +107 -3
- lightning_sdk/lightning_cloud/openapi/models/v1_media_type.py +104 -0
- lightning_sdk/lightning_cloud/openapi/models/v1_nebius_direct_v1.py +29 -3
- lightning_sdk/lightning_cloud/openapi/models/v1_node_metrics.py +27 -1
- lightning_sdk/lightning_cloud/openapi/models/v1_organization.py +27 -1
- lightning_sdk/lightning_cloud/openapi/models/v1_refresh_container_registry_credentials_response.py +123 -0
- lightning_sdk/lightning_cloud/openapi/models/v1_report_cloud_space_instance_idle_state_response.py +97 -0
- lightning_sdk/lightning_cloud/openapi/models/v1_report_machine_system_metrics_response.py +97 -0
- lightning_sdk/lightning_cloud/openapi/models/v1_search_user.py +27 -1
- lightning_sdk/lightning_cloud/openapi/models/v1_tenant_credentials.py +201 -0
- lightning_sdk/lightning_cloud/openapi/models/v1_user_features.py +217 -113
- lightning_sdk/lightning_cloud/openapi/models/v1_validate_container_registry_response.py +149 -0
- lightning_sdk/lightning_cloud/openapi/models/v1_virtual_machine.py +409 -0
- lightning_sdk/lightning_cloud/openapi/models/v1_vm_configuration.py +149 -0
- lightning_sdk/lightning_cloud/openapi/models/v1_vm_provider_configuration.py +227 -0
- lightning_sdk/lightning_cloud/openapi/models/virtual_machine_service_create_virtual_machine_body.py +565 -0
- lightning_sdk/lightning_cloud/openapi/models/virtual_machine_service_update_virtual_machine_body.py +97 -0
- lightning_sdk/lightning_cloud/openapi/rest.py +2 -2
- lightning_sdk/lightning_cloud/rest_client.py +0 -2
- lightning_sdk/machine.py +3 -3
- lightning_sdk/mmt/base.py +26 -7
- lightning_sdk/mmt/mmt.py +11 -6
- lightning_sdk/mmt/v1.py +5 -2
- lightning_sdk/mmt/v2.py +5 -2
- lightning_sdk/organization.py +10 -1
- lightning_sdk/owner.py +4 -0
- lightning_sdk/plugin.py +2 -2
- lightning_sdk/studio.py +47 -6
- lightning_sdk/user.py +22 -2
- lightning_sdk/utils/logging.py +2 -1
- lightning_sdk/utils/resolve.py +9 -7
- {lightning_sdk-2025.12.16.dist-info → lightning_sdk-2026.1.22.dist-info}/METADATA +1 -5
- {lightning_sdk-2025.12.16.dist-info → lightning_sdk-2026.1.22.dist-info}/RECORD +139 -94
- {lightning_sdk-2025.12.16.dist-info → lightning_sdk-2026.1.22.dist-info}/WHEEL +1 -1
- lightning_sdk/lightning_cloud/cli/__main__.py +0 -29
- lightning_sdk/lightning_cloud/source_code/logs_socket_api.py +0 -103
- /lightning_sdk/lightning_cloud/openapi/models/{v1_list_filesystem_mm_ts_response.py → v1_list_filesystem_mmts_response.py} +0 -0
- {lightning_sdk-2025.12.16.dist-info → lightning_sdk-2026.1.22.dist-info}/LICENSE +0 -0
- {lightning_sdk-2025.12.16.dist-info → lightning_sdk-2026.1.22.dist-info}/entry_points.txt +0 -0
- {lightning_sdk-2025.12.16.dist-info → lightning_sdk-2026.1.22.dist-info}/top_level.txt +0 -0
lightning_sdk/__version__.py
CHANGED
lightning_sdk/api/k8s_api.py
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import json
|
|
2
2
|
import logging
|
|
3
|
-
from
|
|
4
|
-
|
|
5
|
-
import
|
|
3
|
+
from collections import defaultdict
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import Any, Dict, List, TypedDict
|
|
6
6
|
|
|
7
7
|
from lightning_sdk.api.utils import ApiException
|
|
8
8
|
from lightning_sdk.lightning_cloud.rest_client import LightningClient
|
|
@@ -63,42 +63,88 @@ class K8sClusterApi:
|
|
|
63
63
|
except Exception:
|
|
64
64
|
return str(e.reason)
|
|
65
65
|
|
|
66
|
-
def get_billing_usage(self, print_data: bool = False, **kwargs: Dict[str, Any]) ->
|
|
66
|
+
def get_billing_usage(self, print_data: bool = False, **kwargs: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
67
67
|
"""Gets the k8s usage metrics.
|
|
68
68
|
|
|
69
69
|
Returns:
|
|
70
|
-
The k8s usage metrics as a
|
|
70
|
+
The k8s usage metrics as a list of dictionaries.
|
|
71
71
|
"""
|
|
72
72
|
try:
|
|
73
73
|
response = self._client.k8_s_cluster_service_list_cluster_metrics(self.cloud_account, **kwargs)
|
|
74
74
|
cluster_metrics = [entry.to_dict() for entry in response.cluster_metrics]
|
|
75
75
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
76
|
+
if not cluster_metrics:
|
|
77
|
+
return []
|
|
78
|
+
|
|
79
|
+
# Parse timestamps and floor to hour, then group by hour
|
|
80
|
+
hourly_data = defaultdict(lambda: {"allocated_gpus": [], "first_entry": None})
|
|
81
|
+
|
|
82
|
+
for entry in cluster_metrics:
|
|
83
|
+
# Parse timestamp and floor to hour
|
|
84
|
+
timestamp = entry["timestamp"]
|
|
85
|
+
if isinstance(timestamp, str):
|
|
86
|
+
timestamp = datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
|
|
87
|
+
hour = timestamp.replace(minute=0, second=0, microsecond=0)
|
|
88
|
+
|
|
89
|
+
# Store allocated GPUs for averaging
|
|
90
|
+
hourly_data[hour]["allocated_gpus"].append(entry["num_allocated_gpus"])
|
|
91
|
+
|
|
92
|
+
# Keep first entry for each hour (for other fields)
|
|
93
|
+
if hourly_data[hour]["first_entry"] is None:
|
|
94
|
+
hourly_data[hour]["first_entry"] = entry
|
|
95
|
+
|
|
96
|
+
# Build result list with aggregated data
|
|
97
|
+
result = []
|
|
98
|
+
for hour, data in sorted(hourly_data.items()):
|
|
99
|
+
entry = data["first_entry"]
|
|
100
|
+
|
|
101
|
+
# Calculate mean of allocated GPUs for this hour
|
|
102
|
+
mean_allocated_gpus = sum(data["allocated_gpus"]) / len(data["allocated_gpus"])
|
|
103
|
+
|
|
104
|
+
# Create row with mean allocated GPUs
|
|
105
|
+
row = {
|
|
106
|
+
"hour": hour,
|
|
107
|
+
"num_gpus": entry["num_gpus"],
|
|
108
|
+
"num_requested_gpus": entry["num_requested_gpus"],
|
|
109
|
+
"num_allocated_gpus": mean_allocated_gpus,
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
# Calculate billed GPUs
|
|
113
|
+
row["billed_gpus"] = _calculate_billed_k8s_gpus(
|
|
114
|
+
{
|
|
115
|
+
"num_allocated_gpus": mean_allocated_gpus,
|
|
116
|
+
"num_requested_gpus": row["num_requested_gpus"],
|
|
117
|
+
"num_gpus": row["num_gpus"],
|
|
118
|
+
}
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
result.append(row)
|
|
95
122
|
|
|
96
|
-
# Keep only the required columns
|
|
97
|
-
df = df[["hour", "num_gpus", "num_requested_gpus", "num_allocated_gpus", "billed_gpus"]]
|
|
98
123
|
if print_data:
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
124
|
+
# Print using rich table (local import)
|
|
125
|
+
from rich.console import Console
|
|
126
|
+
from rich.table import Table
|
|
127
|
+
|
|
128
|
+
table = Table(title="K8s Billing Usage")
|
|
129
|
+
table.add_column("Hour", style="cyan")
|
|
130
|
+
table.add_column("Available GPUs", justify="right", style="green")
|
|
131
|
+
table.add_column("Requested GPUs", justify="right", style="yellow")
|
|
132
|
+
table.add_column("Allocated GPUs (Avg)", justify="right", style="magenta")
|
|
133
|
+
table.add_column("Billed GPUs", justify="right", style="red")
|
|
134
|
+
|
|
135
|
+
for row in result:
|
|
136
|
+
table.add_row(
|
|
137
|
+
str(row["hour"]),
|
|
138
|
+
str(row["num_gpus"]),
|
|
139
|
+
str(row["num_requested_gpus"]),
|
|
140
|
+
f"{row['num_allocated_gpus']:.2f}",
|
|
141
|
+
str(row["billed_gpus"]),
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
console = Console()
|
|
145
|
+
console.print(table)
|
|
146
|
+
|
|
147
|
+
return result
|
|
102
148
|
except ApiException as e:
|
|
103
149
|
msg = self._parse_request_failure_body(e)
|
|
104
150
|
logger.error(f"Failed to retrieve Kubernetes usage data: {msg}")
|
lightning_sdk/api/org_api.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
from lightning_sdk.lightning_cloud.openapi import (
|
|
2
|
+
V1CreateProjectRequest,
|
|
2
3
|
V1Organization,
|
|
3
4
|
)
|
|
4
5
|
from lightning_sdk.lightning_cloud.rest_client import LightningClient
|
|
@@ -20,3 +21,9 @@ class OrgApi:
|
|
|
20
21
|
def _get_org_by_id(self, org_id: str) -> V1Organization:
|
|
21
22
|
"""Gets the organization from the given ID."""
|
|
22
23
|
return self._client.organizations_service_get_organization(id=org_id)
|
|
24
|
+
|
|
25
|
+
def create_teamspace(self, name: str, organization_id: str) -> None:
|
|
26
|
+
"""Creates a new teamspace."""
|
|
27
|
+
self._client.projects_service_create_project(
|
|
28
|
+
body=V1CreateProjectRequest(name=name, organization_id=organization_id, display_name=name)
|
|
29
|
+
)
|
lightning_sdk/api/studio_api.py
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import concurrent
|
|
1
2
|
import json
|
|
2
3
|
import os
|
|
3
4
|
import time
|
|
4
5
|
import warnings
|
|
6
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
5
7
|
from pathlib import Path
|
|
6
8
|
from threading import Event, Thread
|
|
7
9
|
from typing import Any, Dict, Generator, List, Mapping, Optional, Tuple, Union
|
|
@@ -12,10 +14,8 @@ from tqdm import tqdm
|
|
|
12
14
|
|
|
13
15
|
from lightning_sdk.api.utils import (
|
|
14
16
|
_create_app,
|
|
15
|
-
_download_teamspace_files,
|
|
16
17
|
_DummyBody,
|
|
17
18
|
_DummyResponse,
|
|
18
|
-
_FileUploader,
|
|
19
19
|
_machine_to_compute_name,
|
|
20
20
|
_sanitize_studio_remote_path,
|
|
21
21
|
)
|
|
@@ -410,6 +410,21 @@ class StudioApi:
|
|
|
410
410
|
|
|
411
411
|
progress.complete("Machine switch completed successfully")
|
|
412
412
|
|
|
413
|
+
def machine_is_supported(self, machine: Machine, teamspace_id: str, cloud_account_id: str, org_id: str) -> bool:
|
|
414
|
+
"""Check if the machine is available in provided cloud_account."""
|
|
415
|
+
accelerators = self._get_machines_for_cloud_account(
|
|
416
|
+
teamspace_id=teamspace_id, cloud_account_id=cloud_account_id, org_id=org_id
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
for accelerator in accelerators:
|
|
420
|
+
if accelerator.accelerator_type == "GPU":
|
|
421
|
+
accelerator_resources_count = accelerator.resources.gpu
|
|
422
|
+
else:
|
|
423
|
+
accelerator_resources_count = accelerator.resources.cpu
|
|
424
|
+
if machine.accelerator_count == accelerator_resources_count and machine.family == accelerator.family:
|
|
425
|
+
return True
|
|
426
|
+
return False
|
|
427
|
+
|
|
413
428
|
def machine_has_capacity(self, machine: Machine, teamspace_id: str, cloud_account_id: str, org_id: str) -> bool:
|
|
414
429
|
"""Check capacity of the requested machine."""
|
|
415
430
|
accelerators = self._get_machines_for_cloud_account(
|
|
@@ -646,14 +661,21 @@ class StudioApi:
|
|
|
646
661
|
self.stop_keeping_alive(teamspace_id=teamspace_id, studio_id=studio_id)
|
|
647
662
|
self._client.cloud_space_service_delete_cloud_space(project_id=teamspace_id, id=studio_id)
|
|
648
663
|
|
|
649
|
-
def
|
|
664
|
+
def _authenticate_and_get_token(self) -> str:
|
|
665
|
+
"""Authenticate and return a token for API requests."""
|
|
650
666
|
auth = Auth()
|
|
651
667
|
auth.authenticate()
|
|
652
|
-
|
|
668
|
+
return self._client.auth_service_login(V1LoginRequest(auth.api_key)).token
|
|
653
669
|
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
670
|
+
def get_tree(self, studio_id: str, teamspace_id: str, path: str, query_params: Optional[dict] = None) -> None:
|
|
671
|
+
token = self._authenticate_and_get_token()
|
|
672
|
+
|
|
673
|
+
if query_params is None:
|
|
674
|
+
query_params = {
|
|
675
|
+
"token": token,
|
|
676
|
+
}
|
|
677
|
+
else:
|
|
678
|
+
query_params["token"] = token
|
|
657
679
|
r = requests.get(
|
|
658
680
|
f"{self._client.api_client.configuration.host}/v1/projects/{teamspace_id}/artifacts/cloudspaces/{studio_id}/trees/{path}",
|
|
659
681
|
params=query_params,
|
|
@@ -688,6 +710,16 @@ class StudioApi:
|
|
|
688
710
|
warnings.warn(f"If '{path}' is a directory, it may be empty and thus not detected.")
|
|
689
711
|
return {"exists": False, "type": None, "size": None}
|
|
690
712
|
|
|
713
|
+
def list_files(
|
|
714
|
+
self,
|
|
715
|
+
studio_id: str,
|
|
716
|
+
teamspace_id: str,
|
|
717
|
+
path: str = "",
|
|
718
|
+
) -> List[Dict]:
|
|
719
|
+
"""Recursively list all files in a directory tree."""
|
|
720
|
+
path = path.strip("/")
|
|
721
|
+
return self.get_tree(studio_id, teamspace_id, path, query_params={"recursive": "true"}).get("tree", [])
|
|
722
|
+
|
|
691
723
|
def upload_file(
|
|
692
724
|
self,
|
|
693
725
|
studio_id: str,
|
|
@@ -698,14 +730,32 @@ class StudioApi:
|
|
|
698
730
|
progress_bar: bool,
|
|
699
731
|
) -> None:
|
|
700
732
|
"""Uploads file to given remote path on the studio."""
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
733
|
+
token = self._authenticate_and_get_token()
|
|
734
|
+
|
|
735
|
+
query_params = {"token": token}
|
|
736
|
+
client_host = self._client.api_client.configuration.host
|
|
737
|
+
url = f"{client_host}/v1/projects/{teamspace_id}/artifacts/cloudspaces/{studio_id}/blobs/{remote_path}"
|
|
738
|
+
|
|
739
|
+
filesize = os.path.getsize(file_path)
|
|
740
|
+
with open(file_path, "rb") as f:
|
|
741
|
+
if progress_bar:
|
|
742
|
+
filesize = os.path.getsize(file_path)
|
|
743
|
+
with tqdm.wrapattr(
|
|
744
|
+
f,
|
|
745
|
+
"read",
|
|
746
|
+
desc=f"Uploading {os.path.split(file_path)[1]}",
|
|
747
|
+
total=filesize,
|
|
748
|
+
unit="B",
|
|
749
|
+
unit_scale=True,
|
|
750
|
+
unit_divisor=1000,
|
|
751
|
+
) as wrapped_file:
|
|
752
|
+
r = requests.put(url, data=wrapped_file, params=query_params, timeout=30)
|
|
753
|
+
else:
|
|
754
|
+
r = requests.put(url, data=f, params=query_params, timeout=30)
|
|
755
|
+
|
|
756
|
+
if r.status_code == 200:
|
|
757
|
+
return
|
|
758
|
+
raise RuntimeError(f"Failed to upload file '{file_path}' to the Studio. Status code: {r.status_code}")
|
|
709
759
|
|
|
710
760
|
def download_file(
|
|
711
761
|
self,
|
|
@@ -718,9 +768,7 @@ class StudioApi:
|
|
|
718
768
|
) -> None:
|
|
719
769
|
"""Downloads a given file from a Studio to a target location."""
|
|
720
770
|
# TODO: Update this endpoint to permit basic auth
|
|
721
|
-
|
|
722
|
-
auth.authenticate()
|
|
723
|
-
token = self._client.auth_service_login(V1LoginRequest(auth.api_key)).token
|
|
771
|
+
token = self._authenticate_and_get_token()
|
|
724
772
|
|
|
725
773
|
query_params = {
|
|
726
774
|
"clusterId": cloud_account,
|
|
@@ -729,7 +777,7 @@ class StudioApi:
|
|
|
729
777
|
}
|
|
730
778
|
|
|
731
779
|
r = requests.get(
|
|
732
|
-
f"{self._client.api_client.configuration.host}/v1/projects/{teamspace_id}/artifacts/
|
|
780
|
+
f"{self._client.api_client.configuration.host}/v1/projects/{teamspace_id}/artifacts/cloudspaces/{studio_id}/blobs/{path}",
|
|
733
781
|
params=query_params,
|
|
734
782
|
stream=True,
|
|
735
783
|
)
|
|
@@ -756,6 +804,39 @@ class StudioApi:
|
|
|
756
804
|
f.write(chunk)
|
|
757
805
|
pbar_update(len(chunk))
|
|
758
806
|
|
|
807
|
+
def _download_single_studio_file(
|
|
808
|
+
self,
|
|
809
|
+
file_info: Dict,
|
|
810
|
+
base_path: str,
|
|
811
|
+
download_dir: Path,
|
|
812
|
+
studio_id: str,
|
|
813
|
+
teamspace_id: str,
|
|
814
|
+
token: str,
|
|
815
|
+
pbar: Optional[tqdm],
|
|
816
|
+
) -> None:
|
|
817
|
+
"""Download a single file from Studio with progress tracking."""
|
|
818
|
+
relative_path = file_info["path"].lstrip("/")
|
|
819
|
+
local_file = download_dir / relative_path
|
|
820
|
+
local_file.parent.mkdir(parents=True, exist_ok=True)
|
|
821
|
+
|
|
822
|
+
file_path = os.path.join(base_path, relative_path) if base_path else relative_path
|
|
823
|
+
|
|
824
|
+
query_params = {
|
|
825
|
+
"token": token,
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
r = requests.get(
|
|
829
|
+
f"{self._client.api_client.configuration.host}/v1/projects/{teamspace_id}/artifacts/cloudspaces/{studio_id}/blobs/{file_path}",
|
|
830
|
+
params=query_params,
|
|
831
|
+
stream=True,
|
|
832
|
+
)
|
|
833
|
+
|
|
834
|
+
with open(str(local_file), "wb") as f:
|
|
835
|
+
for chunk in r.iter_content(chunk_size=4096 * 8):
|
|
836
|
+
f.write(chunk)
|
|
837
|
+
if pbar:
|
|
838
|
+
pbar.update(len(chunk))
|
|
839
|
+
|
|
759
840
|
def download_folder(
|
|
760
841
|
self,
|
|
761
842
|
path: str,
|
|
@@ -764,25 +845,106 @@ class StudioApi:
|
|
|
764
845
|
teamspace_id: str,
|
|
765
846
|
cloud_account: str,
|
|
766
847
|
progress_bar: bool = True,
|
|
848
|
+
num_workers: Optional[int] = None,
|
|
767
849
|
) -> None:
|
|
768
850
|
"""Downloads a given folder from a Studio to a target location."""
|
|
769
851
|
# TODO: implement resumable downloads
|
|
770
|
-
auth = Auth()
|
|
771
|
-
auth.authenticate()
|
|
772
852
|
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
if prefix.endswith("/") is False:
|
|
776
|
-
prefix = prefix + "/"
|
|
853
|
+
if num_workers is None:
|
|
854
|
+
num_workers = os.cpu_count() * 4
|
|
777
855
|
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
856
|
+
# Normalize the path
|
|
857
|
+
path = path.strip("/")
|
|
858
|
+
download_dir = Path(target_path)
|
|
859
|
+
download_dir.mkdir(parents=True, exist_ok=True)
|
|
860
|
+
|
|
861
|
+
files = self.list_files(studio_id, teamspace_id, path)
|
|
862
|
+
|
|
863
|
+
if not files:
|
|
864
|
+
print(f"No files found in {path}")
|
|
865
|
+
return
|
|
866
|
+
|
|
867
|
+
token = self._authenticate_and_get_token()
|
|
868
|
+
|
|
869
|
+
total_size = sum(f.get("size", 0) for f in files)
|
|
870
|
+
|
|
871
|
+
pbar = None
|
|
872
|
+
if progress_bar:
|
|
873
|
+
pbar = tqdm(
|
|
874
|
+
desc="Downloading files",
|
|
875
|
+
total=total_size,
|
|
876
|
+
unit="B",
|
|
877
|
+
unit_scale=True,
|
|
878
|
+
unit_divisor=1000,
|
|
879
|
+
mininterval=1,
|
|
880
|
+
)
|
|
881
|
+
|
|
882
|
+
with ThreadPoolExecutor(max_workers=num_workers) as executor:
|
|
883
|
+
futures = [
|
|
884
|
+
executor.submit(
|
|
885
|
+
self._download_single_studio_file,
|
|
886
|
+
file_info,
|
|
887
|
+
path,
|
|
888
|
+
download_dir,
|
|
889
|
+
studio_id,
|
|
890
|
+
teamspace_id,
|
|
891
|
+
token,
|
|
892
|
+
pbar,
|
|
893
|
+
)
|
|
894
|
+
for file_info in files
|
|
895
|
+
]
|
|
896
|
+
concurrent.futures.wait(futures)
|
|
897
|
+
|
|
898
|
+
if pbar:
|
|
899
|
+
pbar.set_description("Download complete")
|
|
900
|
+
pbar.refresh()
|
|
901
|
+
pbar.close()
|
|
902
|
+
|
|
903
|
+
def remove_file(self, studio_id: str, teamspace_id: str, path: str) -> None:
|
|
904
|
+
"""Removes a file from a Studio."""
|
|
905
|
+
info = self.get_path_info(studio_id, teamspace_id, path=path)
|
|
906
|
+
|
|
907
|
+
if not info["exists"]:
|
|
908
|
+
raise FileNotFoundError(f"The path '{path}' does not exist in the Studio.")
|
|
909
|
+
|
|
910
|
+
if info["type"] != "file":
|
|
911
|
+
raise IsADirectoryError(f"The path '{path}' is a directory. Use 'remove_folder()' to remove directories.")
|
|
912
|
+
|
|
913
|
+
token = self._authenticate_and_get_token()
|
|
914
|
+
|
|
915
|
+
query_params = {"token": token}
|
|
916
|
+
client_host = self._client.api_client.configuration.host
|
|
917
|
+
url = f"{client_host}/v1/projects/{teamspace_id}/artifacts/cloudspaces/{studio_id}/blobs/{path}"
|
|
918
|
+
|
|
919
|
+
r = requests.delete(url, params=query_params, timeout=30)
|
|
920
|
+
|
|
921
|
+
if r.status_code == 204:
|
|
922
|
+
return
|
|
923
|
+
|
|
924
|
+
raise RuntimeError(f"Failed to remove file '{path}' from the Studio. Status code: {r.status_code}")
|
|
925
|
+
|
|
926
|
+
def remove_folder(self, studio_id: str, teamspace_id: str, path: str) -> None:
|
|
927
|
+
"""Removes a folder (directory) from a Studio."""
|
|
928
|
+
info = self.get_path_info(studio_id, teamspace_id, path=path)
|
|
929
|
+
|
|
930
|
+
if not info["exists"]:
|
|
931
|
+
raise FileNotFoundError(f"The path '{path}' does not exist in the Studio.")
|
|
932
|
+
|
|
933
|
+
if info["type"] == "file":
|
|
934
|
+
raise ValueError(f"The path '{path}' is a file. Use 'remove_file()' to remove files.")
|
|
935
|
+
|
|
936
|
+
token = self._authenticate_and_get_token()
|
|
937
|
+
|
|
938
|
+
query_params = {"token": token}
|
|
939
|
+
client_host = self._client.api_client.configuration.host
|
|
940
|
+
url = f"{client_host}/v1/projects/{teamspace_id}/artifacts/cloudspaces/{studio_id}/trees/{path}"
|
|
941
|
+
|
|
942
|
+
r = requests.delete(url, params=query_params, timeout=30)
|
|
943
|
+
|
|
944
|
+
if r.status_code == 204:
|
|
945
|
+
return
|
|
946
|
+
|
|
947
|
+
raise RuntimeError(f"Failed to remove folder '{path}' from the Studio. Status code: {r.status_code}")
|
|
786
948
|
|
|
787
949
|
def install_plugin(self, studio_id: str, teamspace_id: str, plugin_name: str) -> str:
|
|
788
950
|
"""Installs the given plugin."""
|
|
@@ -962,9 +1124,9 @@ class StudioApi:
|
|
|
962
1124
|
interruptible=interruptible,
|
|
963
1125
|
)
|
|
964
1126
|
|
|
965
|
-
def
|
|
1127
|
+
def add_port(self, teamspace_id: str, studio_id: str, name: str, port: int, auto_start: bool = False) -> V1Endpoint:
|
|
966
1128
|
"""Starts a new port to the given Studio."""
|
|
967
|
-
|
|
1129
|
+
return self._client.endpoint_service_create_endpoint(
|
|
968
1130
|
project_id=teamspace_id,
|
|
969
1131
|
body=EndpointServiceCreateEndpointBody(
|
|
970
1132
|
name=name,
|
|
@@ -974,7 +1136,32 @@ class StudioApi:
|
|
|
974
1136
|
),
|
|
975
1137
|
),
|
|
976
1138
|
)
|
|
977
|
-
|
|
1139
|
+
|
|
1140
|
+
def get_port_url(
|
|
1141
|
+
self, teamspace_id: str, studio_id: str, port: Optional[int] = None, name: Optional[str] = None
|
|
1142
|
+
) -> str:
|
|
1143
|
+
if port is None and name is None:
|
|
1144
|
+
raise ValueError("Either 'port' or 'name' must be provided")
|
|
1145
|
+
|
|
1146
|
+
endpoints = self.list_ports(teamspace_id=teamspace_id, studio_id=studio_id)
|
|
1147
|
+
|
|
1148
|
+
for endpoint in endpoints:
|
|
1149
|
+
if port is not None and port in endpoint.ports:
|
|
1150
|
+
idx = endpoint.ports.index(port)
|
|
1151
|
+
return endpoint.urls[idx]
|
|
1152
|
+
|
|
1153
|
+
if name is not None and endpoint.name == name:
|
|
1154
|
+
return endpoint.urls[0] if endpoint.urls else None
|
|
1155
|
+
|
|
1156
|
+
identifier = f"port {port}" if port else f"name '{name}'"
|
|
1157
|
+
raise ValueError(f"Endpoint with {identifier} not found")
|
|
1158
|
+
|
|
1159
|
+
def list_ports(self, teamspace_id: str, studio_id: str) -> List[V1Endpoint]:
|
|
1160
|
+
"""List ports that are exposed in the Studio."""
|
|
1161
|
+
return self._client.endpoint_service_list_endpoints(
|
|
1162
|
+
project_id=teamspace_id,
|
|
1163
|
+
cloudspace_id=studio_id,
|
|
1164
|
+
).endpoints
|
|
978
1165
|
|
|
979
1166
|
def create_assistant(self, studio_id: str, teamspace_id: str, port: int, assistant_name: str) -> V1Assistant:
|
|
980
1167
|
target_teamspace = self._client.projects_service_get_project(teamspace_id)
|
|
@@ -10,7 +10,6 @@ from lightning_sdk.api.utils import (
|
|
|
10
10
|
_download_model_files,
|
|
11
11
|
_download_teamspace_files,
|
|
12
12
|
_DummyBody,
|
|
13
|
-
_FileUploader,
|
|
14
13
|
_get_model_version,
|
|
15
14
|
_ModelFileUploader,
|
|
16
15
|
_resolve_teamspace_remote_path,
|
|
@@ -401,14 +400,34 @@ class TeamspaceApi:
|
|
|
401
400
|
progress_bar: bool,
|
|
402
401
|
) -> None:
|
|
403
402
|
"""Uploads file to given remote path in the Teamspace drive."""
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
403
|
+
auth = Auth()
|
|
404
|
+
auth.authenticate()
|
|
405
|
+
token = self._client.auth_service_login(V1LoginRequest(auth.api_key)).token
|
|
406
|
+
|
|
407
|
+
query_params = {"token": token, "clusterId": cloud_account}
|
|
408
|
+
client_host = self._client.api_client.configuration.host
|
|
409
|
+
url = f"{client_host}/v1/projects/{teamspace_id}/artifacts/uploads/blobs/{remote_path}"
|
|
410
|
+
|
|
411
|
+
filesize = os.path.getsize(file_path)
|
|
412
|
+
with open(file_path, "rb") as f:
|
|
413
|
+
if progress_bar:
|
|
414
|
+
filesize = os.path.getsize(file_path)
|
|
415
|
+
with tqdm.wrapattr(
|
|
416
|
+
f,
|
|
417
|
+
"read",
|
|
418
|
+
desc=f"Uploading {os.path.split(file_path)[1]}",
|
|
419
|
+
total=filesize,
|
|
420
|
+
unit="B",
|
|
421
|
+
unit_scale=True,
|
|
422
|
+
unit_divisor=1000,
|
|
423
|
+
) as wrapped_file:
|
|
424
|
+
r = requests.put(url, data=wrapped_file, params=query_params, timeout=30)
|
|
425
|
+
else:
|
|
426
|
+
r = requests.put(url, data=f, params=query_params, timeout=30)
|
|
427
|
+
|
|
428
|
+
if r.status_code == 200:
|
|
429
|
+
return
|
|
430
|
+
raise RuntimeError(f"Failed to upload file '{file_path}' to the Teamspace drive. Status code: {r.status_code}")
|
|
412
431
|
|
|
413
432
|
def download_file(
|
|
414
433
|
self,
|
lightning_sdk/api/user_api.py
CHANGED
|
@@ -5,6 +5,7 @@ from lightning_sdk.lightning_cloud.login import Auth
|
|
|
5
5
|
from lightning_sdk.lightning_cloud.openapi import (
|
|
6
6
|
SecretServiceUpdateUserSecretBody,
|
|
7
7
|
V1CloudSpace,
|
|
8
|
+
V1CreateProjectRequest,
|
|
8
9
|
V1CreateUserSecretRequest,
|
|
9
10
|
V1GetUserResponse,
|
|
10
11
|
V1ListCloudSpacesResponse,
|
|
@@ -123,3 +124,7 @@ class UserApi:
|
|
|
123
124
|
"""
|
|
124
125
|
pattern = r"^[A-Za-z_][A-Za-z0-9_]*$"
|
|
125
126
|
return re.match(pattern, name) is not None
|
|
127
|
+
|
|
128
|
+
def create_teamspace(self, name: str) -> None:
|
|
129
|
+
"""Creates a new teamspace."""
|
|
130
|
+
self._client.projects_service_create_project(body=V1CreateProjectRequest(name=name, display_name=name))
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""CP CLI commands."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Literal, Optional
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from lightning_sdk.cli.studio.cp import cp_download as studio_cp_download
|
|
8
|
+
from lightning_sdk.cli.studio.cp import cp_upload as studio_cp_upload
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def parse_lit_url(url: str) -> tuple[str, list[str], Literal["studios", "uploads"]]:
|
|
12
|
+
"""Parse lit:// URL and extract resource type."""
|
|
13
|
+
if "://" not in url:
|
|
14
|
+
raise ValueError("URL must contain '://'")
|
|
15
|
+
|
|
16
|
+
path = url.split("://")[-1].split("/")
|
|
17
|
+
|
|
18
|
+
if path[2] == "studios":
|
|
19
|
+
resource_type = "studios"
|
|
20
|
+
elif "uploads" in path[3]:
|
|
21
|
+
resource_type = "uploads"
|
|
22
|
+
else:
|
|
23
|
+
raise ValueError("URL must contain either 'studios' or 'uploads'")
|
|
24
|
+
|
|
25
|
+
return resource_type
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def route_cp_operation(source: str, destination: str, **options: Any) -> None:
|
|
29
|
+
"""Route copy operation based on URL structure."""
|
|
30
|
+
source_is_lit = source.startswith("lit://")
|
|
31
|
+
dest_is_lit = destination.startswith("lit://")
|
|
32
|
+
|
|
33
|
+
if source_is_lit and dest_is_lit:
|
|
34
|
+
raise ValueError("Cannot copy between two remote URLs. One path must be local.")
|
|
35
|
+
|
|
36
|
+
if not source_is_lit and not dest_is_lit:
|
|
37
|
+
raise ValueError("At least one path must be a lit://")
|
|
38
|
+
|
|
39
|
+
if source_is_lit:
|
|
40
|
+
resource_type = parse_lit_url(source)
|
|
41
|
+
if resource_type == "studios":
|
|
42
|
+
return studio_cp_download(source, destination, options)
|
|
43
|
+
raise ValueError(f"Resource type: {resource_type} is not supported")
|
|
44
|
+
else:
|
|
45
|
+
resource_type = parse_lit_url(destination)
|
|
46
|
+
if resource_type == "studios":
|
|
47
|
+
return studio_cp_upload(source, destination, options)
|
|
48
|
+
raise ValueError(f"Resource type: {resource_type} is not supported")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def register_commands(command: click.Command) -> None:
|
|
52
|
+
"""Register cp command callback."""
|
|
53
|
+
|
|
54
|
+
def new_callback(source: str, destination: Optional[str], recursive: bool, **kwargs: Any) -> None:
|
|
55
|
+
try:
|
|
56
|
+
route_cp_operation(
|
|
57
|
+
source=source,
|
|
58
|
+
destination=destination,
|
|
59
|
+
recursive=recursive,
|
|
60
|
+
)
|
|
61
|
+
except Exception:
|
|
62
|
+
raise
|
|
63
|
+
|
|
64
|
+
command.callback = new_callback
|
lightning_sdk/cli/entrypoint.py
CHANGED
|
@@ -12,6 +12,7 @@ from lightning_sdk.api.studio_api import _cloud_url
|
|
|
12
12
|
from lightning_sdk.cli.groups import (
|
|
13
13
|
base_studio,
|
|
14
14
|
config,
|
|
15
|
+
cp,
|
|
15
16
|
# job,
|
|
16
17
|
license,
|
|
17
18
|
# mmt,
|
|
@@ -77,6 +78,7 @@ main_cli.add_command(studio)
|
|
|
77
78
|
main_cli.add_command(vm)
|
|
78
79
|
main_cli.add_command(base_studio)
|
|
79
80
|
main_cli.add_command(license)
|
|
81
|
+
main_cli.add_command(cp)
|
|
80
82
|
|
|
81
83
|
if os.environ.get("LIGHTNING_EXPERIMENTAL_CLI_ONLY", "0") != "1":
|
|
82
84
|
#### LEGACY COMMANDS ####
|