lightning-sdk 2025.12.17__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/studio_api.py +195 -33
- lightning_sdk/api/teamspace_api.py +28 -9
- 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/run.py +13 -2
- 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 +1 -1
- lightning_sdk/k8s_cluster.py +9 -10
- lightning_sdk/lightning_cloud/__version__.py +1 -1
- lightning_sdk/lightning_cloud/openapi/__init__.py +29 -11
- lightning_sdk/lightning_cloud/openapi/api/__init__.py +1 -1
- lightning_sdk/lightning_cloud/openapi/api/cloud_space_service_api.py +113 -0
- lightning_sdk/lightning_cloud/openapi/api/cluster_service_api.py +268 -123
- 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/{kubernetes_virtual_machine_service_api.py → virtual_machine_service_api.py} +82 -82
- lightning_sdk/lightning_cloud/openapi/models/__init__.py +28 -10
- 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 +53 -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_report_machine_system_metrics_body.py +123 -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_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_type.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_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_ai_pod_v1.py → v1_cudo_direct_v1.py} +51 -51
- lightning_sdk/lightning_cloud/openapi/models/{v1_delete_kubernetes_virtual_machine_response.py → v1_delete_lit_logger_media_response.py} +6 -6
- lightning_sdk/lightning_cloud/openapi/models/{kubernetes_virtual_machine_service_update_kubernetes_virtual_machine_body.py → v1_delete_virtual_machine_response.py} +6 -6
- 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_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_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 +53 -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 +53 -1
- 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_kubernetes_virtual_machines_response.py → v1_list_virtual_machines_response.py} +16 -16
- lightning_sdk/lightning_cloud/openapi/models/v1_lit_logger_media.py +513 -0
- lightning_sdk/lightning_cloud/openapi/models/v1_machine.py +27 -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_organization.py +27 -1
- 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_tenant_credentials.py +201 -0
- lightning_sdk/lightning_cloud/openapi/models/v1_user_features.py +157 -131
- lightning_sdk/lightning_cloud/openapi/models/{v1_kubernetes_virtual_machine.py → v1_virtual_machine.py} +94 -68
- lightning_sdk/lightning_cloud/openapi/models/{v1_kubevirt_vm_configuration.py → v1_vm_configuration.py} +20 -20
- lightning_sdk/lightning_cloud/openapi/models/{v1_kubevirt_provider_configuration.py → v1_vm_provider_configuration.py} +32 -32
- 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/rest_client.py +0 -2
- lightning_sdk/machine.py +3 -3
- lightning_sdk/studio.py +14 -4
- lightning_sdk/utils/logging.py +2 -1
- {lightning_sdk-2025.12.17.dist-info → lightning_sdk-2026.1.22.dist-info}/METADATA +1 -5
- {lightning_sdk-2025.12.17.dist-info → lightning_sdk-2026.1.22.dist-info}/RECORD +95 -75
- {lightning_sdk-2025.12.17.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/openapi/models/kubernetes_virtual_machine_service_create_kubernetes_virtual_machine_body.py +0 -513
- lightning_sdk/lightning_cloud/openapi/models/v1_kubevirt_vm_resources.py +0 -201
- lightning_sdk/lightning_cloud/source_code/logs_socket_api.py +0 -103
- {lightning_sdk-2025.12.17.dist-info → lightning_sdk-2026.1.22.dist-info}/LICENSE +0 -0
- {lightning_sdk-2025.12.17.dist-info → lightning_sdk-2026.1.22.dist-info}/entry_points.txt +0 -0
- {lightning_sdk-2025.12.17.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/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."""
|
|
@@ -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,
|
|
@@ -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 ####
|
lightning_sdk/cli/groups.py
CHANGED
|
@@ -4,6 +4,7 @@ import click
|
|
|
4
4
|
|
|
5
5
|
from lightning_sdk.cli.base_studio import register_commands as register_base_studio_commands
|
|
6
6
|
from lightning_sdk.cli.config import register_commands as register_config_commands
|
|
7
|
+
from lightning_sdk.cli.cp import register_commands as register_cp_commands
|
|
7
8
|
from lightning_sdk.cli.job import register_commands as register_job_commands
|
|
8
9
|
from lightning_sdk.cli.license import register_commands as register_license_commands
|
|
9
10
|
from lightning_sdk.cli.mmt import register_commands as register_mmt_commands
|
|
@@ -46,6 +47,26 @@ def license() -> None: # noqa: A001
|
|
|
46
47
|
"""Manage Lightning AI Product Licenses."""
|
|
47
48
|
|
|
48
49
|
|
|
50
|
+
@click.command(name="cp")
|
|
51
|
+
@click.argument("source")
|
|
52
|
+
@click.argument("destination", required=False)
|
|
53
|
+
@click.option("--recursive", "-r", is_flag=True, help="Copy directories recursively")
|
|
54
|
+
@click.pass_context
|
|
55
|
+
def cp() -> None:
|
|
56
|
+
"""Copy files between local filesystem, Studios, and teamspace drives.
|
|
57
|
+
|
|
58
|
+
\b
|
|
59
|
+
URL formats:
|
|
60
|
+
Studios: lit://<owner>/<teamspace>/studios/<studio-name>/<path>
|
|
61
|
+
Teamspace drives: lit://<owner>/<teamspace>/uploads/<path>
|
|
62
|
+
|
|
63
|
+
\b
|
|
64
|
+
Examples:
|
|
65
|
+
lightning studio cp source.txt lit://<owner>/<my-teamspace>/studios/<my-studio>/destination.txt
|
|
66
|
+
lightning studio cp -r source_folder/ lit://<owner>/<my-teamspace>/studios/<my-studio>/destination_folder/
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
|
|
49
70
|
# Register config commands with the main config group
|
|
50
71
|
register_job_commands(job)
|
|
51
72
|
register_mmt_commands(mmt)
|
|
@@ -54,3 +75,4 @@ register_config_commands(config)
|
|
|
54
75
|
register_vm_commands(vm)
|
|
55
76
|
register_base_studio_commands(base_studio)
|
|
56
77
|
register_license_commands(license)
|
|
78
|
+
register_cp_commands(cp)
|
|
@@ -32,8 +32,8 @@ class _ClustersMenu:
|
|
|
32
32
|
|
|
33
33
|
cloud_account_api = CloudAccountApi()
|
|
34
34
|
|
|
35
|
-
resolved_cluster_obj = cloud_account_api.
|
|
36
|
-
cloud_account_id=selected_cluster_id,
|
|
35
|
+
resolved_cluster_obj = cloud_account_api.get_cloud_account_non_org(
|
|
36
|
+
cloud_account_id=selected_cluster_id, teamspace_id=teamspace.id
|
|
37
37
|
)
|
|
38
38
|
|
|
39
39
|
return None if resolved_cluster_obj.spec.cluster_type == V1ClusterType.GLOBAL else resolved_cluster_obj.id
|
|
@@ -8,7 +8,7 @@ from urllib.parse import urlencode
|
|
|
8
8
|
from rich.console import Console
|
|
9
9
|
from rich.prompt import Confirm
|
|
10
10
|
|
|
11
|
-
from lightning_sdk import Teamspace
|
|
11
|
+
from lightning_sdk import Organization, Teamspace
|
|
12
12
|
from lightning_sdk.api import UserApi
|
|
13
13
|
from lightning_sdk.cli.utils.teamspace_selection import TeamspacesMenu
|
|
14
14
|
from lightning_sdk.lightning_cloud import env
|
|
@@ -74,13 +74,14 @@ 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
|
menu = TeamspacesMenu()
|
|
77
|
-
|
|
77
|
+
auth_user = _get_authed_user()
|
|
78
|
+
possible_teamspaces = menu._get_possible_teamspaces(auth_user)
|
|
78
79
|
if len(possible_teamspaces) == 1:
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
80
|
+
teamspace_name = next(iter(possible_teamspaces.values()))
|
|
81
|
+
if isinstance(menu._owner, Organization):
|
|
82
|
+
return Teamspace(name=teamspace_name, org=menu._owner, user=None)
|
|
83
|
+
return Teamspace(name=teamspace_name, org=None, user=menu._owner)
|
|
82
84
|
return menu(teamspace)
|
|
83
|
-
|
|
84
85
|
return _resolve_teamspace(teamspace=teamspace, org=org, user=user)
|
|
85
86
|
|
|
86
87
|
|