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.
Files changed (99) hide show
  1. lightning_sdk/__version__.py +1 -1
  2. lightning_sdk/api/k8s_api.py +75 -29
  3. lightning_sdk/api/studio_api.py +195 -33
  4. lightning_sdk/api/teamspace_api.py +28 -9
  5. lightning_sdk/cli/cp/__init__.py +64 -0
  6. lightning_sdk/cli/entrypoint.py +2 -0
  7. lightning_sdk/cli/groups.py +22 -0
  8. lightning_sdk/cli/legacy/clusters_menu.py +2 -2
  9. lightning_sdk/cli/legacy/deploy/_auth.py +7 -6
  10. lightning_sdk/cli/legacy/run.py +13 -2
  11. lightning_sdk/cli/studio/__init__.py +4 -0
  12. lightning_sdk/cli/studio/cp.py +20 -64
  13. lightning_sdk/cli/studio/ls.py +57 -0
  14. lightning_sdk/cli/studio/rm.py +71 -0
  15. lightning_sdk/cli/utils/logging.py +2 -1
  16. lightning_sdk/cli/utils/studio_filesystem.py +65 -0
  17. lightning_sdk/cli/utils/teamspace_selection.py +5 -0
  18. lightning_sdk/exceptions.py +4 -0
  19. lightning_sdk/job/base.py +1 -1
  20. lightning_sdk/k8s_cluster.py +9 -10
  21. lightning_sdk/lightning_cloud/__version__.py +1 -1
  22. lightning_sdk/lightning_cloud/openapi/__init__.py +29 -11
  23. lightning_sdk/lightning_cloud/openapi/api/__init__.py +1 -1
  24. lightning_sdk/lightning_cloud/openapi/api/cloud_space_service_api.py +113 -0
  25. lightning_sdk/lightning_cloud/openapi/api/cluster_service_api.py +268 -123
  26. lightning_sdk/lightning_cloud/openapi/api/k8_s_cluster_service_api.py +246 -19
  27. lightning_sdk/lightning_cloud/openapi/api/lightningwork_service_api.py +116 -11
  28. lightning_sdk/lightning_cloud/openapi/api/lit_logger_service_api.py +588 -2
  29. lightning_sdk/lightning_cloud/openapi/api/models_store_api.py +9 -1
  30. lightning_sdk/lightning_cloud/openapi/api/{kubernetes_virtual_machine_service_api.py → virtual_machine_service_api.py} +82 -82
  31. lightning_sdk/lightning_cloud/openapi/models/__init__.py +28 -10
  32. lightning_sdk/lightning_cloud/openapi/models/cluster_service_create_cluster_capacity_reservation_body.py +53 -1
  33. lightning_sdk/lightning_cloud/openapi/models/cluster_service_create_machine_body.py +53 -1
  34. lightning_sdk/lightning_cloud/openapi/models/cluster_service_create_org_cluster_capacity_reservation_body.py +409 -0
  35. lightning_sdk/lightning_cloud/openapi/models/cluster_service_report_machine_system_metrics_body.py +123 -0
  36. lightning_sdk/lightning_cloud/openapi/models/externalv1_cloud_space_instance_status.py +27 -1
  37. lightning_sdk/lightning_cloud/openapi/models/lit_logger_service_create_lit_logger_media_body.py +305 -0
  38. lightning_sdk/lightning_cloud/openapi/models/lit_logger_service_update_lit_logger_media_body.py +149 -0
  39. lightning_sdk/lightning_cloud/openapi/models/lit_logger_service_update_metrics_stream_body.py +53 -1
  40. lightning_sdk/lightning_cloud/openapi/models/v1_capacity_reservation_used_by.py +227 -0
  41. lightning_sdk/lightning_cloud/openapi/models/v1_cloud_provider.py +1 -1
  42. lightning_sdk/lightning_cloud/openapi/models/v1_cloud_space_environment_type.py +1 -0
  43. lightning_sdk/lightning_cloud/openapi/models/v1_cluster_accelerator.py +27 -1
  44. lightning_sdk/lightning_cloud/openapi/models/v1_cluster_capacity_reservation.py +27 -1
  45. lightning_sdk/lightning_cloud/openapi/models/v1_cluster_spec.py +53 -27
  46. lightning_sdk/lightning_cloud/openapi/models/v1_cluster_type.py +0 -1
  47. lightning_sdk/lightning_cloud/openapi/models/v1_create_lit_logger_media_response.py +149 -0
  48. lightning_sdk/lightning_cloud/openapi/models/v1_create_org_cluster_capacity_reservation_response.py +201 -0
  49. lightning_sdk/lightning_cloud/openapi/models/v1_create_sdk_command_history_request.py +29 -3
  50. lightning_sdk/lightning_cloud/openapi/models/{v1_ai_pod_v1.py → v1_cudo_direct_v1.py} +51 -51
  51. lightning_sdk/lightning_cloud/openapi/models/{v1_delete_kubernetes_virtual_machine_response.py → v1_delete_lit_logger_media_response.py} +6 -6
  52. lightning_sdk/lightning_cloud/openapi/models/{kubernetes_virtual_machine_service_update_kubernetes_virtual_machine_body.py → v1_delete_virtual_machine_response.py} +6 -6
  53. lightning_sdk/lightning_cloud/openapi/models/v1_external_cluster_spec.py +27 -27
  54. lightning_sdk/lightning_cloud/openapi/models/v1_external_search_user.py +27 -1
  55. lightning_sdk/lightning_cloud/openapi/models/v1_get_kubernetes_pod_logs_response.py +149 -0
  56. lightning_sdk/lightning_cloud/openapi/models/{v1_get_machine_response.py → v1_get_kubernetes_pod_response.py} +23 -23
  57. lightning_sdk/lightning_cloud/openapi/models/v1_job_spec.py +27 -1
  58. lightning_sdk/lightning_cloud/openapi/models/v1_joinable_organization.py +27 -1
  59. lightning_sdk/lightning_cloud/openapi/models/v1_k8s_incident_setting.py +149 -0
  60. lightning_sdk/lightning_cloud/openapi/models/v1_k8s_incident_type.py +108 -0
  61. lightning_sdk/lightning_cloud/openapi/models/v1_kubernetes_direct_settings_v1.py +53 -1
  62. lightning_sdk/lightning_cloud/openapi/models/v1_kubernetes_direct_v1.py +53 -1
  63. lightning_sdk/lightning_cloud/openapi/models/v1_kubernetes_pod.py +27 -1
  64. lightning_sdk/lightning_cloud/openapi/models/v1_kubernetes_pod_logs_page.py +227 -0
  65. lightning_sdk/lightning_cloud/openapi/models/v1_kubevirt_config.py +53 -1
  66. lightning_sdk/lightning_cloud/openapi/models/v1_list_kubernetes_pods_response.py +43 -17
  67. lightning_sdk/lightning_cloud/openapi/models/v1_list_kubernetes_pods_sort_order.py +104 -0
  68. lightning_sdk/lightning_cloud/openapi/models/v1_list_lit_logger_media_response.py +149 -0
  69. lightning_sdk/lightning_cloud/openapi/models/v1_list_models_response.py +55 -3
  70. lightning_sdk/lightning_cloud/openapi/models/{v1_list_kubernetes_virtual_machines_response.py → v1_list_virtual_machines_response.py} +16 -16
  71. lightning_sdk/lightning_cloud/openapi/models/v1_lit_logger_media.py +513 -0
  72. lightning_sdk/lightning_cloud/openapi/models/v1_machine.py +27 -53
  73. lightning_sdk/lightning_cloud/openapi/models/v1_machine_direct_v1.py +107 -3
  74. lightning_sdk/lightning_cloud/openapi/models/v1_media_type.py +104 -0
  75. lightning_sdk/lightning_cloud/openapi/models/v1_nebius_direct_v1.py +29 -3
  76. lightning_sdk/lightning_cloud/openapi/models/v1_organization.py +27 -1
  77. lightning_sdk/lightning_cloud/openapi/models/v1_report_cloud_space_instance_idle_state_response.py +97 -0
  78. lightning_sdk/lightning_cloud/openapi/models/v1_report_machine_system_metrics_response.py +97 -0
  79. lightning_sdk/lightning_cloud/openapi/models/v1_tenant_credentials.py +201 -0
  80. lightning_sdk/lightning_cloud/openapi/models/v1_user_features.py +157 -131
  81. lightning_sdk/lightning_cloud/openapi/models/{v1_kubernetes_virtual_machine.py → v1_virtual_machine.py} +94 -68
  82. lightning_sdk/lightning_cloud/openapi/models/{v1_kubevirt_vm_configuration.py → v1_vm_configuration.py} +20 -20
  83. lightning_sdk/lightning_cloud/openapi/models/{v1_kubevirt_provider_configuration.py → v1_vm_provider_configuration.py} +32 -32
  84. lightning_sdk/lightning_cloud/openapi/models/virtual_machine_service_create_virtual_machine_body.py +565 -0
  85. lightning_sdk/lightning_cloud/openapi/models/virtual_machine_service_update_virtual_machine_body.py +97 -0
  86. lightning_sdk/lightning_cloud/rest_client.py +0 -2
  87. lightning_sdk/machine.py +3 -3
  88. lightning_sdk/studio.py +14 -4
  89. lightning_sdk/utils/logging.py +2 -1
  90. {lightning_sdk-2025.12.17.dist-info → lightning_sdk-2026.1.22.dist-info}/METADATA +1 -5
  91. {lightning_sdk-2025.12.17.dist-info → lightning_sdk-2026.1.22.dist-info}/RECORD +95 -75
  92. {lightning_sdk-2025.12.17.dist-info → lightning_sdk-2026.1.22.dist-info}/WHEEL +1 -1
  93. lightning_sdk/lightning_cloud/cli/__main__.py +0 -29
  94. lightning_sdk/lightning_cloud/openapi/models/kubernetes_virtual_machine_service_create_kubernetes_virtual_machine_body.py +0 -513
  95. lightning_sdk/lightning_cloud/openapi/models/v1_kubevirt_vm_resources.py +0 -201
  96. lightning_sdk/lightning_cloud/source_code/logs_socket_api.py +0 -103
  97. {lightning_sdk-2025.12.17.dist-info → lightning_sdk-2026.1.22.dist-info}/LICENSE +0 -0
  98. {lightning_sdk-2025.12.17.dist-info → lightning_sdk-2026.1.22.dist-info}/entry_points.txt +0 -0
  99. {lightning_sdk-2025.12.17.dist-info → lightning_sdk-2026.1.22.dist-info}/top_level.txt +0 -0
@@ -1,3 +1,3 @@
1
1
  """Version information for lightning_sdk."""
2
2
 
3
- __version__ = "2025.12.17"
3
+ __version__ = "2026.01.22"
@@ -1,8 +1,8 @@
1
1
  import json
2
2
  import logging
3
- from typing import Any, Dict, TypedDict, Union
4
-
5
- import pandas as pd
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]) -> Union[pd.DataFrame, pd.Series]:
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 DataFrame or Series.
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
- df = pd.DataFrame.from_records(cluster_metrics)
77
- if df.empty:
78
- return df
79
-
80
- df["hour"] = pd.to_datetime(df["timestamp"]).dt.floor("h")
81
-
82
- # Calculate the mean of num_allocated_gpus for each hour
83
- aggregated = df.groupby("hour", as_index=False)["num_allocated_gpus"].mean()
84
- # Merge the aggregated values back into the original DataFrame
85
- df = df.merge(aggregated, on="hour", suffixes=("", "_mean"))
86
-
87
- # Replace the original num_allocated_gpus with the mean values
88
- df["num_allocated_gpus"] = df["num_allocated_gpus_mean"]
89
-
90
- # We group the data by hour and take the first occurrence to avoid duplicates
91
- df = df.drop_duplicates(subset="hour", keep="first")
92
-
93
- # Convert timestamp to hourly floor and rename columns
94
- df["billed_gpus"] = df.apply(_calculate_billed_k8s_gpus, axis=1)
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
- with pd.option_context("display.max_rows", None, "display.max_columns", None):
100
- print(df)
101
- return df
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}")
@@ -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 get_tree(self, studio_id: str, teamspace_id: str, path: str) -> None:
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
- token = self._client.auth_service_login(V1LoginRequest(auth.api_key)).token
668
+ return self._client.auth_service_login(V1LoginRequest(auth.api_key)).token
653
669
 
654
- query_params = {
655
- "token": token,
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
- _FileUploader(
702
- client=self._client,
703
- teamspace_id=teamspace_id,
704
- cloud_account=cloud_account,
705
- file_path=file_path,
706
- remote_path=_sanitize_studio_remote_path(remote_path, studio_id),
707
- progress_bar=progress_bar,
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
- auth = Auth()
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/download",
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
- prefix = _sanitize_studio_remote_path(path, studio_id)
774
- # ensure we only download as a directory and not the entire prefix
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
- _download_teamspace_files(
779
- client=self._client,
780
- teamspace_id=teamspace_id,
781
- cluster_id=cloud_account,
782
- prefix=prefix,
783
- download_dir=Path(target_path),
784
- progress_bar=progress_bar,
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
- _FileUploader(
405
- client=self._client,
406
- teamspace_id=teamspace_id,
407
- cloud_account=cloud_account,
408
- file_path=file_path,
409
- remote_path=_resolve_teamspace_remote_path(remote_path),
410
- progress_bar=progress_bar,
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
@@ -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 ####
@@ -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.get_cloud_account(
36
- cloud_account_id=selected_cluster_id, org_id=teamspace.owner.id, teamspace_id=teamspace.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
- possible_teamspaces = menu._get_possible_teamspaces(_get_authed_user())
77
+ auth_user = _get_authed_user()
78
+ possible_teamspaces = menu._get_possible_teamspaces(auth_user)
78
79
  if len(possible_teamspaces) == 1:
79
- name = next(iter(possible_teamspaces.values()))["name"]
80
- return Teamspace(name=name, org=org, user=user)
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