lightning-sdk 0.2.11__py3-none-any.whl → 0.2.13__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 (61) hide show
  1. lightning_sdk/__init__.py +1 -1
  2. lightning_sdk/api/deployment_api.py +35 -3
  3. lightning_sdk/api/lit_container_api.py +13 -7
  4. lightning_sdk/api/llm_api.py +46 -0
  5. lightning_sdk/api/studio_api.py +17 -0
  6. lightning_sdk/cli/entrypoint.py +1 -1
  7. lightning_sdk/cli/serve.py +221 -62
  8. lightning_sdk/deployment/deployment.py +53 -7
  9. lightning_sdk/lightning_cloud/openapi/__init__.py +11 -1
  10. lightning_sdk/lightning_cloud/openapi/api/__init__.py +1 -0
  11. lightning_sdk/lightning_cloud/openapi/api/cloud_space_environment_template_service_api.py +4 -4
  12. lightning_sdk/lightning_cloud/openapi/api/cluster_service_api.py +13 -1
  13. lightning_sdk/lightning_cloud/openapi/api/data_connection_service_api.py +4 -4
  14. lightning_sdk/lightning_cloud/openapi/api/git_credentials_service_api.py +497 -0
  15. lightning_sdk/lightning_cloud/openapi/api/jobs_service_api.py +124 -0
  16. lightning_sdk/lightning_cloud/openapi/models/__init__.py +10 -1
  17. lightning_sdk/lightning_cloud/openapi/models/assistant_id_conversations_body.py +27 -1
  18. lightning_sdk/lightning_cloud/openapi/models/create_deployment_request_defines_a_spec_for_the_job_that_allows_for_autoscaling_jobs.py +27 -1
  19. lightning_sdk/lightning_cloud/openapi/models/deployments_id_body.py +27 -1
  20. lightning_sdk/lightning_cloud/openapi/models/orgs_id_body.py +27 -1
  21. lightning_sdk/lightning_cloud/openapi/models/projects_id_body.py +27 -1
  22. lightning_sdk/lightning_cloud/openapi/models/update.py +65 -195
  23. lightning_sdk/lightning_cloud/openapi/models/update1.py +357 -0
  24. lightning_sdk/lightning_cloud/openapi/models/v1_cloud_provider.py +1 -0
  25. lightning_sdk/lightning_cloud/openapi/models/v1_cloud_space_environment_template.py +27 -1
  26. lightning_sdk/lightning_cloud/openapi/models/v1_cluster_accelerator.py +27 -1
  27. lightning_sdk/lightning_cloud/openapi/models/v1_cluster_security_options.py +27 -1
  28. lightning_sdk/lightning_cloud/openapi/models/v1_cluster_spec.py +79 -1
  29. lightning_sdk/lightning_cloud/openapi/models/v1_create_deployment_request.py +27 -1
  30. lightning_sdk/lightning_cloud/openapi/models/v1_create_git_credentials_request.py +175 -0
  31. lightning_sdk/lightning_cloud/openapi/models/v1_delete_cloud_space_environment_template_response.py +1 -53
  32. lightning_sdk/lightning_cloud/openapi/models/v1_delete_git_credentials_response.py +97 -0
  33. lightning_sdk/lightning_cloud/openapi/models/v1_deployment.py +27 -1
  34. lightning_sdk/lightning_cloud/openapi/models/v1_deployment_state.py +2 -0
  35. lightning_sdk/lightning_cloud/openapi/models/v1_get_user_response.py +1 -27
  36. lightning_sdk/lightning_cloud/openapi/models/v1_git_credentials.py +227 -0
  37. lightning_sdk/lightning_cloud/openapi/models/v1_job_resource.py +279 -0
  38. lightning_sdk/lightning_cloud/openapi/models/v1_job_type.py +108 -0
  39. lightning_sdk/lightning_cloud/openapi/models/v1_list_git_credentials_response.py +123 -0
  40. lightning_sdk/lightning_cloud/openapi/models/v1_list_job_resources_response.py +123 -0
  41. lightning_sdk/lightning_cloud/openapi/models/v1_nebius_direct_v1.py +149 -0
  42. lightning_sdk/lightning_cloud/openapi/models/v1_organization.py +55 -1
  43. lightning_sdk/lightning_cloud/openapi/models/v1_project_settings.py +29 -1
  44. lightning_sdk/lightning_cloud/openapi/models/v1_reservation_billing_session.py +279 -0
  45. lightning_sdk/lightning_cloud/openapi/models/v1_resources.py +55 -3
  46. lightning_sdk/lightning_cloud/openapi/models/v1_update_user_request.py +1 -27
  47. lightning_sdk/lightning_cloud/openapi/models/v1_usage.py +27 -1
  48. lightning_sdk/lightning_cloud/openapi/models/v1_user_features.py +157 -1
  49. lightning_sdk/llm/__init__.py +3 -0
  50. lightning_sdk/llm/llm.py +118 -0
  51. lightning_sdk/plugin.py +19 -0
  52. lightning_sdk/serve.py +4 -6
  53. lightning_sdk/studio.py +33 -0
  54. {lightning_sdk-0.2.11.dist-info → lightning_sdk-0.2.13.dist-info}/METADATA +1 -1
  55. {lightning_sdk-0.2.11.dist-info → lightning_sdk-0.2.13.dist-info}/RECORD +60 -47
  56. lightning_sdk/lightning_cloud/openapi/models/environmenttemplates_id_body.py +0 -253
  57. /lightning_sdk/cli/{docker.py → docker_cli.py} +0 -0
  58. {lightning_sdk-0.2.11.dist-info → lightning_sdk-0.2.13.dist-info}/LICENSE +0 -0
  59. {lightning_sdk-0.2.11.dist-info → lightning_sdk-0.2.13.dist-info}/WHEEL +0 -0
  60. {lightning_sdk-0.2.11.dist-info → lightning_sdk-0.2.13.dist-info}/entry_points.txt +0 -0
  61. {lightning_sdk-0.2.11.dist-info → lightning_sdk-0.2.13.dist-info}/top_level.txt +0 -0
lightning_sdk/__init__.py CHANGED
@@ -31,6 +31,6 @@ __all__ = [
31
31
  "User",
32
32
  ]
33
33
 
34
- __version__ = "0.2.11"
34
+ __version__ = "0.2.13"
35
35
  _check_version_and_prompt_upgrade(__version__)
36
36
  _set_tqdm_envvars_noninteractive()
@@ -460,7 +460,7 @@ def to_autoscaling(
460
460
  if target_metrics is None and (threshold < 0 or threshold > 100):
461
461
  raise ValueError("The autoscaling threshold should be defined between 0 and 100.")
462
462
 
463
- if target_metrics is not None and len(target_metrics) == 0:
463
+ if target_metrics is not None and len(target_metrics) == 0 and metric is None:
464
464
  raise ValueError("The target_metrics must be provided.")
465
465
 
466
466
  if target_metrics is not None:
@@ -524,8 +524,14 @@ def to_endpoint(
524
524
  def to_health_check(
525
525
  health_check: Optional[Union[HttpHealthCheck, ExecHealthCheck]] = None
526
526
  ) -> Optional[V1JobHealthCheckConfig]:
527
+ # Use Default health check if none is provided
527
528
  if not health_check:
528
- return None
529
+ return V1JobHealthCheckConfig(
530
+ failure_threshold=600,
531
+ initial_delay_seconds=0,
532
+ interval_seconds=1,
533
+ timeout_seconds=600,
534
+ )
529
535
 
530
536
  health_check_config = V1JobHealthCheckConfig(
531
537
  failure_threshold=health_check.failure_threshold,
@@ -555,6 +561,7 @@ def to_spec(
555
561
  health_check: Optional[Union[HttpHealthCheck, ExecHealthCheck]] = None,
556
562
  quantity: Optional[int] = None,
557
563
  include_credentials: Optional[bool] = None,
564
+ cloudspace_id: Optional[None] = None,
558
565
  ) -> V1JobSpec:
559
566
  if cloud_account is None:
560
567
  raise ValueError("The cloud account should be defined.")
@@ -562,9 +569,15 @@ def to_spec(
562
569
  if machine is None:
563
570
  raise ValueError("The machine should be defined.")
564
571
 
565
- if image is None:
572
+ if image is None and cloudspace_id is None:
566
573
  raise ValueError("The image should be defined.")
567
574
 
575
+ if entrypoint is not None and cloudspace_id is not None:
576
+ raise ValueError("The entrypoint shouldn't be defined when a Studio is provided.")
577
+
578
+ if command is None and cloudspace_id is not None:
579
+ raise ValueError("The command should be defined.")
580
+
568
581
  return V1JobSpec(
569
582
  cluster_id=cloud_account,
570
583
  command=command,
@@ -576,6 +589,7 @@ def to_spec(
576
589
  readiness_probe=to_health_check(health_check),
577
590
  quantity=quantity,
578
591
  include_credentials=include_credentials,
592
+ cloudspace_id=cloudspace_id,
579
593
  )
580
594
 
581
595
 
@@ -600,3 +614,21 @@ def apply_change(spec: Any, key: str, value: Any) -> bool:
600
614
  return True
601
615
 
602
616
  return False
617
+
618
+
619
+ def compose_commands(commands: List[str]) -> str:
620
+ composite_command = []
621
+
622
+ for command in commands:
623
+ command = command.strip()
624
+
625
+ # Check if the command already has '&'
626
+ if command.endswith("&"):
627
+ # It's a background command, add it as a subshell without further adjustment
628
+ composite_command.append(f"( {command} )")
629
+ else:
630
+ # Sequential execution, add as-is and use `&&` to connect if followed by another command
631
+ composite_command.append(command)
632
+
633
+ # Joining commands, using `&&` between sequential parts and respecting subshell backgrounds
634
+ return " && ".join(composite_command)
@@ -125,6 +125,17 @@ class LitContainerApi:
125
125
  except Exception as e:
126
126
  raise ValueError(f"Could not delete container {container} from project {project_id}: {e!s}") from e
127
127
 
128
+ def get_container_url(
129
+ self, repository: str, tag: str, teamspace: Teamspace, cloud_account: Optional[str] = None
130
+ ) -> str:
131
+ """Docker container will be pushed to the URL returned from this function."""
132
+ registry_url = _get_registry_url()
133
+ container_basename = repository.split("/")[-1]
134
+ return (
135
+ f"{registry_url}/lit-container{f'-{cloud_account}' if cloud_account is not None else ''}/"
136
+ f"{teamspace.owner.name}/{teamspace.name}/{container_basename}"
137
+ )
138
+
128
139
  @retry_on_lcr_auth_failure
129
140
  def upload_container(
130
141
  self,
@@ -147,7 +158,6 @@ class LitContainerApi:
147
158
  Named cloud-account in the CLI options.
148
159
  :param platform: If empty will be linux/amd64. This is important because our entire deployment infra runs on
149
160
  linux/amd64. Will show user a warning otherwise.
150
- :return_final_dict: Controls whether we respond with the dictionary containing metadata about container upload
151
161
  :return: Generator[dict, None, dict]
152
162
  """
153
163
  try:
@@ -163,18 +173,14 @@ class LitContainerApi:
163
173
  except Exception as e:
164
174
  raise ValueError(f"Unable to upload {container}:{tag}") from e
165
175
 
166
- registry_url = _get_registry_url()
167
- container_basename = container.split("/")[-1]
168
- repository = (
169
- f"{registry_url}/lit-container{f'-{cloud_account}' if cloud_account is not None else ''}/"
170
- f"{teamspace.owner.name}/{teamspace.name}/{container_basename}"
171
- )
176
+ repository = self.get_container_url(container, tag, teamspace, cloud_account)
172
177
  tagged = self._docker_client.api.tag(f"{container}:{tag}", repository, tag)
173
178
  if not tagged:
174
179
  raise ValueError(f"Could not tag container {container}:{tag} with {repository}:{tag}")
175
180
  yield from self._push_with_retry(repository, tag=tag)
176
181
 
177
182
  if return_final_dict:
183
+ container_basename = repository.split("/")[-1]
178
184
  yield {
179
185
  "finish": True,
180
186
  "url": f"{LIGHTNING_CLOUD_URL}/{teamspace.owner.name}/{teamspace.name}/containers/"
@@ -0,0 +1,46 @@
1
+ from typing import List, Optional
2
+
3
+ from lightning_sdk.lightning_cloud.openapi.models.v1_conversation_response_chunk import V1ConversationResponseChunk
4
+ from lightning_sdk.lightning_cloud.rest_client import LightningClient
5
+
6
+
7
+ class LLMApi:
8
+ def __init__(self) -> None:
9
+ self._client = LightningClient(retry=False, max_tries=0)
10
+
11
+ def get_public_models(self) -> List[str]:
12
+ result = self._client.assistants_service_list_assistants(published=True)
13
+ return result.assistants
14
+
15
+ def get_org_models(self, org_id: str) -> List[str]:
16
+ result = self._client.assistants_service_list_assistants(org_id=org_id)
17
+ return result.assistants
18
+
19
+ def get_user_models(self, user_id: str) -> List[str]:
20
+ result = self._client.assistants_service_list_assistants(user_id=user_id)
21
+ return result.assistants
22
+
23
+ def start_conversation(
24
+ self,
25
+ prompt: str,
26
+ system_prompt: Optional[str],
27
+ max_completion_tokens: Optional[int],
28
+ assistant_id: str,
29
+ conversation_id: Optional[str],
30
+ ) -> V1ConversationResponseChunk:
31
+ body = {
32
+ "message": {
33
+ "author": {"role": "user"},
34
+ "content": [
35
+ {
36
+ "contentType": "text",
37
+ "parts": [prompt],
38
+ }
39
+ ],
40
+ },
41
+ "max_completion_tokens": max_completion_tokens,
42
+ }
43
+ if conversation_id:
44
+ body["conversation_id"] = conversation_id
45
+ result = self._client.assistants_service_start_conversation(body, assistant_id)
46
+ return result.result
@@ -36,13 +36,16 @@ from lightning_sdk.lightning_cloud.openapi import (
36
36
  V1CloudSpaceInstanceConfig,
37
37
  V1CloudSpaceSeedFile,
38
38
  V1CloudSpaceState,
39
+ V1EndpointType,
39
40
  V1GetCloudSpaceInstanceStatusResponse,
40
41
  V1GetLongRunningCommandInCloudSpaceResponse,
41
42
  V1LoginRequest,
42
43
  V1Plugin,
43
44
  V1PluginsListResponse,
45
+ V1UpstreamCloudSpace,
44
46
  V1UserRequestedComputeConfig,
45
47
  )
48
+ from lightning_sdk.lightning_cloud.openapi.models import ProjectIdEndpointsBody
46
49
  from lightning_sdk.lightning_cloud.rest_client import LightningClient
47
50
  from lightning_sdk.machine import Machine
48
51
 
@@ -668,6 +671,20 @@ class StudioApi:
668
671
  interruptible=interruptible,
669
672
  )
670
673
 
674
+ def start_new_port(self, teamspace_id: str, studio_id: str, name: str, port: int, auto_start: bool = False) -> str:
675
+ """Starts a new port to the given Studio."""
676
+ endpoint = self._client.endpoint_service_create_endpoint(
677
+ project_id=teamspace_id,
678
+ body=ProjectIdEndpointsBody(
679
+ name=name,
680
+ ports=[str(port)],
681
+ cloudspace=V1UpstreamCloudSpace(
682
+ cloudspace_id=studio_id, port=str(port), type=V1EndpointType.PLUGIN_PORT, auto_start=auto_start
683
+ ),
684
+ ),
685
+ )
686
+ return endpoint.urls[0]
687
+
671
688
  def _create_app(
672
689
  self, studio_id: str, teamspace_id: str, cloud_account: str, plugin_type: str, **other_arguments: Any
673
690
  ) -> Externalv1LightningappInstance:
@@ -14,7 +14,7 @@ from lightning_sdk.cli.configure import configure
14
14
  from lightning_sdk.cli.connect import connect
15
15
  from lightning_sdk.cli.create import create
16
16
  from lightning_sdk.cli.delete import delete
17
- from lightning_sdk.cli.docker import dockerize
17
+ from lightning_sdk.cli.docker_cli import dockerize
18
18
  from lightning_sdk.cli.download import download
19
19
  from lightning_sdk.cli.generate import generate
20
20
  from lightning_sdk.cli.inspect import inspect
@@ -1,3 +1,4 @@
1
+ import concurrent.futures
1
2
  import os
2
3
  import socket
3
4
  import subprocess
@@ -6,7 +7,8 @@ import webbrowser
6
7
  from datetime import datetime
7
8
  from enum import Enum
8
9
  from pathlib import Path
9
- from typing import List, Optional, TypedDict, Union
10
+ from threading import Thread
11
+ from typing import Dict, List, Optional, TypedDict, Union
10
12
  from urllib.parse import urlencode
11
13
 
12
14
  import click
@@ -17,16 +19,24 @@ from rich.prompt import Confirm
17
19
  from lightning_sdk import Machine, Teamspace
18
20
  from lightning_sdk.api import UserApi
19
21
  from lightning_sdk.api.lit_container_api import LitContainerApi
22
+ from lightning_sdk.api.utils import _get_registry_url
20
23
  from lightning_sdk.cli.teamspace_menu import _TeamspacesMenu
24
+ from lightning_sdk.cli.upload import (
25
+ _dump_current_upload_state,
26
+ _resolve_previous_upload_state,
27
+ _start_parallel_upload,
28
+ )
21
29
  from lightning_sdk.lightning_cloud import env
22
30
  from lightning_sdk.lightning_cloud.login import Auth, AuthServer
23
31
  from lightning_sdk.lightning_cloud.openapi import V1CloudSpace
24
32
  from lightning_sdk.lightning_cloud.rest_client import LightningClient
25
33
  from lightning_sdk.serve import _LitServeDeployer
26
- from lightning_sdk.utils.resolve import _get_authed_user, _resolve_teamspace
34
+ from lightning_sdk.studio import Studio
35
+ from lightning_sdk.utils.resolve import _get_authed_user, _get_studio_url, _resolve_teamspace
27
36
 
28
37
  _MACHINE_VALUES = tuple([machine.name for machine in Machine.__dict__.values() if isinstance(machine, Machine)])
29
38
  _POLL_TIMEOUT = 600
39
+ LITSERVE_CODE = os.environ.get("LITSERVE_CODE", "j39bzk903h")
30
40
 
31
41
 
32
42
  class _ServeGroup(click.Group):
@@ -83,7 +93,14 @@ def deploy() -> None:
83
93
  default="CPU",
84
94
  show_default=True,
85
95
  type=click.Choice(_MACHINE_VALUES),
86
- help="The machine type to deploy the API on.",
96
+ help="Machine type to deploy the API on. Defaults to CPU.",
97
+ )
98
+ @click.option(
99
+ "--devbox",
100
+ default=None,
101
+ show_default=True,
102
+ type=click.Choice(_MACHINE_VALUES),
103
+ help="Machine type to build the API on. Setting this argument will open the server in a Studio.",
87
104
  )
88
105
  @click.option(
89
106
  "--interruptible",
@@ -131,7 +148,8 @@ def api(
131
148
  local: bool,
132
149
  name: Optional[str],
133
150
  non_interactive: bool,
134
- machine: str,
151
+ machine: Optional[str],
152
+ devbox: Optional[str],
135
153
  interruptible: bool,
136
154
  teamspace: Optional[str],
137
155
  org: Optional[str],
@@ -148,9 +166,10 @@ def api(
148
166
  script_path=script_path,
149
167
  easy=easy,
150
168
  local=local,
151
- repository=name,
169
+ name=name,
152
170
  non_interactive=non_interactive,
153
171
  machine=machine,
172
+ devbox=devbox,
154
173
  interruptible=interruptible,
155
174
  teamspace=teamspace,
156
175
  org=org,
@@ -168,10 +187,11 @@ def api_impl(
168
187
  script_path: Union[str, Path],
169
188
  easy: bool = False,
170
189
  local: bool = False,
171
- repository: [str] = None,
190
+ name: Optional[str] = None,
172
191
  tag: Optional[str] = None,
173
192
  non_interactive: bool = False,
174
193
  machine: str = "CPU",
194
+ devbox: Optional[str] = None,
175
195
  interruptible: bool = False,
176
196
  teamspace: Optional[str] = None,
177
197
  org: Optional[str] = None,
@@ -193,47 +213,50 @@ def api_impl(
193
213
 
194
214
  _LitServeDeployer.generate_client() if easy else None
195
215
 
196
- if not repository:
216
+ if not name:
197
217
  timestr = datetime.now().strftime("%b-%d-%H_%M")
198
- repository = f"litserve-{timestr}".lower()
199
-
200
- if not local:
201
- repository = repository or "litserve-model"
202
- machine = Machine.from_str(machine)
203
- return _handle_cloud(
204
- script_path,
205
- console,
206
- repository=repository,
207
- tag=tag,
208
- non_interactive=non_interactive,
209
- machine=machine,
210
- interruptible=interruptible,
211
- teamspace=teamspace,
212
- org=org,
213
- user=user,
214
- cloud_account=cloud_account,
215
- port=port,
216
- min_replica=min_replica,
217
- max_replica=max_replica,
218
- replicas=replicas,
219
- include_credentials=include_credentials,
220
- )
218
+ name = f"litserve-{timestr}".lower()
221
219
 
222
- try:
223
- subprocess.run(
224
- ["python", str(script_path)],
225
- check=True,
226
- text=True,
227
- )
228
- except subprocess.CalledProcessError as e:
229
- error_msg = f"Script execution failed with exit code {e.returncode}\nstdout: {e.stdout}\nstderr: {e.stderr}"
230
- raise RuntimeError(error_msg) from None
220
+ if local:
221
+ try:
222
+ subprocess.run(
223
+ ["python", str(script_path)],
224
+ check=True,
225
+ text=True,
226
+ )
227
+ return None
228
+ except subprocess.CalledProcessError as e:
229
+ error_msg = f"Script execution failed with exit code {e.returncode}\nstdout: {e.stdout}\nstderr: {e.stderr}"
230
+ raise RuntimeError(error_msg) from None
231
+
232
+ if devbox:
233
+ return _handle_devbox(name, script_path, console, non_interactive, devbox, interruptible, teamspace, org, user)
234
+
235
+ machine = Machine.from_str(machine)
236
+ return _handle_cloud(
237
+ script_path,
238
+ console,
239
+ repository=name,
240
+ tag=tag,
241
+ non_interactive=non_interactive,
242
+ machine=machine,
243
+ interruptible=interruptible,
244
+ teamspace=teamspace,
245
+ org=org,
246
+ user=user,
247
+ cloud_account=cloud_account,
248
+ port=port,
249
+ min_replica=min_replica,
250
+ max_replica=max_replica,
251
+ replicas=replicas,
252
+ include_credentials=include_credentials,
253
+ )
231
254
 
232
255
 
233
256
  class _AuthServer(AuthServer):
234
257
  def get_auth_url(self, port: int) -> str:
235
258
  redirect_uri = f"http://localhost:{port}/login-complete"
236
- params = urlencode({"redirectTo": redirect_uri, "inviteCode": "litserve"})
259
+ params = urlencode({"redirectTo": redirect_uri, "okbhrt": LITSERVE_CODE})
237
260
  return f"{env.LIGHTNING_CLOUD_URL}/sign-in?{params}"
238
261
 
239
262
 
@@ -398,6 +421,128 @@ def is_connected(host: str = "8.8.8.8", port: int = 53, timeout: int = 10) -> bo
398
421
  return False
399
422
 
400
423
 
424
+ def _upload_container(
425
+ console: Console,
426
+ ls_deployer: _LitServeDeployer,
427
+ repository: str,
428
+ tag: str,
429
+ resolved_teamspace: Teamspace,
430
+ lit_cr: LitContainerApi,
431
+ cloud_account: Optional[str],
432
+ ) -> bool:
433
+ with Progress(
434
+ SpinnerColumn(),
435
+ TextColumn("[progress.description]{task.description}"),
436
+ TimeElapsedColumn(),
437
+ console=console,
438
+ transient=True,
439
+ ) as progress:
440
+ try:
441
+ push_task = progress.add_task("Uploading container to Lightning registry", total=None)
442
+ for line in ls_deployer.push_container(
443
+ repository, tag, resolved_teamspace, lit_cr, cloud_account=cloud_account
444
+ ):
445
+ progress.update(push_task, advance=1)
446
+ if not ("Pushing" in line["status"] or "Waiting" in line["status"]):
447
+ console.print(line["status"])
448
+ progress.update(push_task, description="[green]Push completed![/green]")
449
+ except Exception as e:
450
+ console.print(f"❌ Deployment failed: {e}", style="red")
451
+ return False
452
+ console.print(f"\n✅ Image pushed to {repository}:{tag}")
453
+ return True
454
+
455
+
456
+ # TODO: Move the rest of the devbox logic here
457
+ class _LitServeDevbox:
458
+ """Build LitServe API in a Studio."""
459
+
460
+ def resolve_previous_upload(self, studio: Studio, folder: str) -> Dict[str, str]:
461
+ remote_path = "."
462
+ pairs = {}
463
+ for root, _, files in os.walk(folder):
464
+ rel_root = os.path.relpath(root, folder)
465
+ for f in files:
466
+ pairs[os.path.join(root, f)] = os.path.join(remote_path, rel_root, f)
467
+ return _resolve_previous_upload_state(studio, remote_path, pairs)
468
+
469
+ def upload_folder(self, studio: Studio, folder: str, upload_state: Dict[str, str]) -> None:
470
+ with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor:
471
+ futures = _start_parallel_upload(executor, studio, upload_state)
472
+ total_files = len(upload_state)
473
+
474
+ with Progress(
475
+ SpinnerColumn(),
476
+ TextColumn("[progress.description]{task.description}"),
477
+ TimeElapsedColumn(),
478
+ console=Console(),
479
+ transient=True,
480
+ ) as progress:
481
+ upload_task = progress.add_task(f"[cyan]Uploading {total_files} files to Studio...", total=total_files)
482
+ for f in concurrent.futures.as_completed(futures):
483
+ upload_state.pop(f.result())
484
+ _dump_current_upload_state(studio, ".", upload_state)
485
+ progress.update(upload_task, advance=1)
486
+
487
+
488
+ def _handle_devbox(
489
+ name: str,
490
+ script_path: Path,
491
+ console: Console,
492
+ non_interactive: bool = False,
493
+ devbox: Machine = "CPU",
494
+ interruptible: bool = False,
495
+ teamspace: Optional[str] = None,
496
+ org: Optional[str] = None,
497
+ user: Optional[str] = None,
498
+ ) -> None:
499
+ if script_path.suffix != ".py":
500
+ console.print("❌ Error: Only Python files (.py) are supported for development servers", style="red")
501
+ return
502
+
503
+ resolved_teamspace = select_teamspace(teamspace, org, user)
504
+ studio = Studio(name=name, teamspace=resolved_teamspace)
505
+ lit_devbox = _LitServeDevbox()
506
+
507
+ studio_url = _get_studio_url(studio, turn_on=True)
508
+ pathlib_path = Path(script_path).resolve()
509
+ ok = False
510
+ studio_path = f"{studio.owner.name}/{studio.teamspace.name}/{studio.name}"
511
+
512
+ console.print("\n=== Lightning Studio Setup ===")
513
+ console.print(f"🔧 [bold]Setting up Studio:[/bold] {studio_path}")
514
+ console.print(f"📁 [bold]Local project:[/bold] {pathlib_path.parent}")
515
+
516
+ upload_state = lit_devbox.resolve_previous_upload(studio, pathlib_path.parent)
517
+ if non_interactive:
518
+ console.print(f"🌐 [bold]Opening Studio:[/bold] [link={studio_url}]{studio_url}[/link]")
519
+ ok = webbrowser.open(studio_url)
520
+ else:
521
+ if Confirm.ask("Would you like to open your Studio in the browser?", default=True):
522
+ console.print(f"🌐 [bold]Opening Studio:[/bold] [link={studio_url}]{studio_url}[/link]")
523
+ ok = webbrowser.open(studio_url)
524
+
525
+ if not ok:
526
+ console.print(f"🔗 [bold]Access Studio:[/bold] [link={studio_url}]{studio_url}[/link]")
527
+
528
+ console.print("\n⚡ Initializing Studio (this typically takes 1-2 minutes)...")
529
+ studio.start(machine=devbox, interruptible=interruptible)
530
+ studio.install_plugin("custom-port")
531
+ console.print("🔌 Configuring server port...")
532
+ studio.run_plugin("custom-port", port=8000) # TODO: Remove hardcoded port and fetch from LitServe
533
+
534
+ console.print("📤 Syncing project files to Studio...")
535
+ lit_devbox.upload_folder(studio, pathlib_path.parent, upload_state)
536
+
537
+ # Add completion message with next steps
538
+ console.print("\n✅ Studio ready!")
539
+ console.print("\n📋 [bold]Next steps:[/bold]")
540
+ console.print(" [bold]1.[/bold] Server code will be available in the Studio")
541
+ console.print(" [bold]2.[/bold] The Studio is now running with the specified configuration")
542
+ console.print(" [bold]3.[/bold] Modify and run your server directly in the Studio")
543
+ # TODO: Once server running is implemented
544
+
545
+
401
546
  def _handle_cloud(
402
547
  script_path: Union[str, Path],
403
548
  console: Console,
@@ -480,29 +625,43 @@ def _handle_cloud(
480
625
  lit_cr = LitContainerApi()
481
626
  lit_cr.list_containers(resolved_teamspace.id, cloud_account=cloud_account)
482
627
 
483
- with Progress(
484
- SpinnerColumn(),
485
- TextColumn("[progress.description]{task.description}"),
486
- TimeElapsedColumn(),
487
- console=console,
488
- transient=True,
489
- ) as progress:
490
- try:
491
- push_task = progress.add_task("Pushing to registry", total=None)
492
- push_status = {}
493
- for line in ls_deployer.push_container(
494
- repository, tag, resolved_teamspace, lit_cr, cloud_account=cloud_account
495
- ):
496
- push_status = line
497
- progress.update(push_task, advance=1)
498
- if not ("Pushing" in line["status"] or "Waiting" in line["status"]):
499
- console.print(line["status"])
500
- progress.update(push_task, description="[green]Push completed![/green]")
501
- except Exception as e:
502
- console.print(f"❌ Deployment failed: {e}", style="red")
628
+ registry_url = _get_registry_url()
629
+ container_basename = repository.split("/")[-1]
630
+ image = (
631
+ f"{registry_url}/lit-container{f'-{cloud_account}' if cloud_account is not None else ''}/"
632
+ f"{resolved_teamspace.owner.name}/{resolved_teamspace.name}/{container_basename}"
633
+ )
634
+
635
+ if from_onboarding:
636
+ thread = Thread(
637
+ target=ls_deployer.run_on_cloud,
638
+ kwargs={
639
+ "deployment_name": deployment_name,
640
+ "image": image,
641
+ "teamspace": resolved_teamspace,
642
+ "metric": None,
643
+ "machine": machine,
644
+ "spot": interruptible,
645
+ "cloud_account": cloud_account,
646
+ "port": port,
647
+ "min_replica": min_replica,
648
+ "max_replica": max_replica,
649
+ "replicas": replicas,
650
+ "include_credentials": include_credentials,
651
+ "cloudspace_id": cloudspace_id,
652
+ "from_onboarding": from_onboarding,
653
+ },
654
+ )
655
+ thread.start()
656
+ console.print("🚀 Deployment started")
657
+ if not _upload_container(console, ls_deployer, repository, tag, resolved_teamspace, lit_cr, cloud_account):
658
+ thread.join()
503
659
  return
504
- console.print(f"\n✅ Image pushed to {repository}:{tag}")
505
- image = push_status.get("image")
660
+ thread.join()
661
+ return
662
+
663
+ if not _upload_container(console, ls_deployer, repository, tag, resolved_teamspace, lit_cr, cloud_account):
664
+ return
506
665
 
507
666
  deployment_status = ls_deployer.run_on_cloud(
508
667
  deployment_name=deployment_name,