lightning-sdk 0.2.13__py3-none-any.whl → 0.2.15__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 (83) hide show
  1. lightning_sdk/__init__.py +1 -1
  2. lightning_sdk/api/base_studio_api.py +73 -0
  3. lightning_sdk/api/license_api.py +48 -0
  4. lightning_sdk/api/llm_api.py +61 -8
  5. lightning_sdk/api/studio_api.py +47 -1
  6. lightning_sdk/base_studio.py +70 -0
  7. lightning_sdk/cli/delete.py +6 -8
  8. lightning_sdk/cli/download.py +25 -0
  9. lightning_sdk/cli/serve.py +82 -30
  10. lightning_sdk/cli/teamspace_menu.py +9 -1
  11. lightning_sdk/cli/upload.py +0 -1
  12. lightning_sdk/lightning_cloud/openapi/__init__.py +11 -0
  13. lightning_sdk/lightning_cloud/openapi/api/__init__.py +1 -0
  14. lightning_sdk/lightning_cloud/openapi/api/billing_service_api.py +9 -1
  15. lightning_sdk/lightning_cloud/openapi/api/cloud_space_service_api.py +121 -0
  16. lightning_sdk/lightning_cloud/openapi/api/file_system_service_api.py +178 -0
  17. lightning_sdk/lightning_cloud/openapi/api/jobs_service_api.py +243 -2
  18. lightning_sdk/lightning_cloud/openapi/api/product_license_service_api.py +525 -0
  19. lightning_sdk/lightning_cloud/openapi/configuration.py +1 -1
  20. lightning_sdk/lightning_cloud/openapi/models/__init__.py +10 -0
  21. lightning_sdk/lightning_cloud/openapi/models/assistant_id_conversations_body.py +53 -1
  22. lightning_sdk/lightning_cloud/openapi/models/endpoints_id_body.py +27 -1
  23. lightning_sdk/lightning_cloud/openapi/models/model_id_versions_body.py +27 -1
  24. lightning_sdk/lightning_cloud/openapi/models/orgs_id_body.py +79 -1
  25. lightning_sdk/lightning_cloud/openapi/models/pipelines_id_body.py +6 -6
  26. lightning_sdk/lightning_cloud/openapi/models/project_id_storage_body.py +27 -1
  27. lightning_sdk/lightning_cloud/openapi/models/projects_id_body.py +79 -1
  28. lightning_sdk/lightning_cloud/openapi/models/storage_complete_body.py +27 -1
  29. lightning_sdk/lightning_cloud/openapi/models/update.py +79 -1
  30. lightning_sdk/lightning_cloud/openapi/models/uploads_upload_id_body1.py +55 -3
  31. lightning_sdk/lightning_cloud/openapi/models/v1_aws_direct_v1.py +53 -1
  32. lightning_sdk/lightning_cloud/openapi/models/v1_cloud_provider.py +3 -0
  33. lightning_sdk/lightning_cloud/openapi/models/v1_cloud_space.py +27 -1
  34. lightning_sdk/lightning_cloud/openapi/models/v1_cloud_space_environment_config.py +123 -0
  35. lightning_sdk/lightning_cloud/openapi/models/v1_cloud_space_environment_template_config.py +79 -1
  36. lightning_sdk/lightning_cloud/openapi/models/v1_cloud_space_environment_type.py +104 -0
  37. lightning_sdk/lightning_cloud/openapi/models/v1_cloudflare_v1.py +66 -66
  38. lightning_sdk/lightning_cloud/openapi/models/v1_cluster_spec.py +27 -1
  39. lightning_sdk/lightning_cloud/openapi/models/v1_cluster_upload.py +149 -0
  40. lightning_sdk/lightning_cloud/openapi/models/v1_complete_upload.py +55 -3
  41. lightning_sdk/lightning_cloud/openapi/models/v1_conversation.py +27 -1
  42. lightning_sdk/lightning_cloud/openapi/models/v1_create_cloud_space_environment_template_request.py +79 -1
  43. lightning_sdk/lightning_cloud/openapi/models/v1_deployment_api.py +27 -1
  44. lightning_sdk/lightning_cloud/openapi/models/v1_deployment_state.py +4 -4
  45. lightning_sdk/lightning_cloud/openapi/models/v1_endpoint.py +27 -1
  46. lightning_sdk/lightning_cloud/openapi/models/v1_external_search_user.py +27 -1
  47. lightning_sdk/lightning_cloud/openapi/models/v1_ge_list_deployment_routing_telemetry_response.py +97 -0
  48. lightning_sdk/lightning_cloud/openapi/models/v1_get_job_stats_response.py +53 -1
  49. lightning_sdk/lightning_cloud/openapi/models/v1_get_project_balance_response.py +1 -27
  50. lightning_sdk/lightning_cloud/openapi/models/v1_get_user_response.py +27 -1
  51. lightning_sdk/lightning_cloud/openapi/models/v1_job_type.py +1 -0
  52. lightning_sdk/lightning_cloud/openapi/models/v1_list_product_licenses_response.py +123 -0
  53. lightning_sdk/lightning_cloud/openapi/models/v1_managed_model.py +27 -1
  54. lightning_sdk/lightning_cloud/openapi/models/v1_membership.py +17 -17
  55. lightning_sdk/lightning_cloud/openapi/models/v1_modify_filesystem_volume_response.py +97 -0
  56. lightning_sdk/lightning_cloud/openapi/models/v1_organization.py +79 -1
  57. lightning_sdk/lightning_cloud/openapi/models/v1_pipeline.py +6 -6
  58. lightning_sdk/lightning_cloud/openapi/models/v1_pipeline_state.py +111 -0
  59. lightning_sdk/lightning_cloud/openapi/models/v1_presigned_url.py +53 -1
  60. lightning_sdk/lightning_cloud/openapi/models/v1_product_license.py +409 -0
  61. lightning_sdk/lightning_cloud/openapi/models/v1_product_license_check_response.py +123 -0
  62. lightning_sdk/lightning_cloud/openapi/models/v1_project_membership.py +17 -17
  63. lightning_sdk/lightning_cloud/openapi/models/v1_project_settings.py +79 -1
  64. lightning_sdk/lightning_cloud/openapi/models/v1_r2_data_connection.py +53 -1
  65. lightning_sdk/lightning_cloud/openapi/models/v1_secret_type.py +1 -0
  66. lightning_sdk/lightning_cloud/openapi/models/v1_server_alert_type.py +1 -0
  67. lightning_sdk/lightning_cloud/openapi/models/v1_trigger_filesystem_upgrade_response.py +123 -0
  68. lightning_sdk/lightning_cloud/openapi/models/v1_update_user_request.py +27 -1
  69. lightning_sdk/lightning_cloud/openapi/models/v1_upload_project_artifact_response.py +27 -1
  70. lightning_sdk/lightning_cloud/openapi/models/v1_usage_report.py +79 -1
  71. lightning_sdk/lightning_cloud/openapi/models/v1_user_features.py +295 -113
  72. lightning_sdk/lightning_cloud/rest_client.py +4 -0
  73. lightning_sdk/llm/llm.py +120 -30
  74. lightning_sdk/services/__init__.py +1 -1
  75. lightning_sdk/services/license.py +236 -0
  76. lightning_sdk/studio.py +30 -0
  77. {lightning_sdk-0.2.13.dist-info → lightning_sdk-0.2.15.dist-info}/METADATA +1 -1
  78. {lightning_sdk-0.2.13.dist-info → lightning_sdk-0.2.15.dist-info}/RECORD +83 -68
  79. /lightning_sdk/services/{finetune/__init__.py → finetune_llm.py} +0 -0
  80. {lightning_sdk-0.2.13.dist-info → lightning_sdk-0.2.15.dist-info}/LICENSE +0 -0
  81. {lightning_sdk-0.2.13.dist-info → lightning_sdk-0.2.15.dist-info}/WHEEL +0 -0
  82. {lightning_sdk-0.2.13.dist-info → lightning_sdk-0.2.15.dist-info}/entry_points.txt +0 -0
  83. {lightning_sdk-0.2.13.dist-info → lightning_sdk-0.2.15.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.13"
34
+ __version__ = "0.2.15"
35
35
  _check_version_and_prompt_upgrade(__version__)
36
36
  _set_tqdm_envvars_noninteractive()
@@ -0,0 +1,73 @@
1
+ from typing import Any, List, Optional
2
+
3
+ from lightning_sdk.lightning_cloud.openapi.models.update import Update as BaseStudioUpdateBody
4
+ from lightning_sdk.lightning_cloud.openapi.models.v1_cloud_space_environment_template import (
5
+ V1CloudSpaceEnvironmentTemplate,
6
+ )
7
+ from lightning_sdk.lightning_cloud.openapi.models.v1_cloud_space_environment_type import V1CloudSpaceEnvironmentType
8
+ from lightning_sdk.lightning_cloud.rest_client import LightningClient
9
+
10
+
11
+ class BaseStudioApi:
12
+ def __init__(self) -> None:
13
+ self._client = LightningClient(retry=False, max_tries=0)
14
+
15
+ def get_base_studio(self, base_studio_id: str, org_id: str) -> V1CloudSpaceEnvironmentTemplate:
16
+ """Retrieve the base studio by its ID."""
17
+ try:
18
+ return self._client.cloud_space_environment_template_service_get_cloud_space_environment_template(
19
+ base_studio_id, org_id
20
+ )
21
+ except ValueError as e:
22
+ raise ValueError(f"Base studio {base_studio_id} does not exist") from e
23
+
24
+ def update_base_studio(
25
+ self,
26
+ base_studio_id: str,
27
+ org_id: str,
28
+ name: Optional[str] = None,
29
+ allowed_machines: Optional[List[str]] = None,
30
+ default_machine: Optional[str] = None,
31
+ disabled: Optional[bool] = None,
32
+ environment_type: Optional[V1CloudSpaceEnvironmentType] = None,
33
+ machine_image_version: Optional[str] = None,
34
+ setup_script_text: Optional[str] = None,
35
+ ) -> V1CloudSpaceEnvironmentTemplate:
36
+ base_studio = self.get_base_studio(base_studio_id, org_id)
37
+
38
+ # Get the current configuration for the base studio
39
+ update_body = BaseStudioUpdateBody(
40
+ org_id=base_studio.org_id,
41
+ name=base_studio.name,
42
+ allowed_machines=base_studio.config.allowed_machines,
43
+ default_machine=base_studio.config.default_machine,
44
+ environment_type=base_studio.config.environment_type,
45
+ machine_image_version=base_studio.config.machine_image_version,
46
+ setup_script_text=base_studio.config.setup_script_text,
47
+ disabled=base_studio.disabled,
48
+ )
49
+
50
+ # Apply changes only if the new value is not None
51
+ apply_change(update_body, "name", name)
52
+ apply_change(update_body, "allowed_machines", allowed_machines)
53
+ apply_change(update_body, "default_machine", default_machine)
54
+ apply_change(update_body, "environment_type", environment_type)
55
+ apply_change(update_body, "machine_image_version", machine_image_version)
56
+ apply_change(update_body, "setup_script_text", setup_script_text)
57
+ apply_change(update_body, "disabled", disabled)
58
+
59
+ return self._client.cloud_space_environment_template_service_update_cloud_space_environment_template(
60
+ id=base_studio_id,
61
+ body=update_body,
62
+ )
63
+
64
+
65
+ def apply_change(spec: Any, key: str, value: Any) -> bool:
66
+ if value is None:
67
+ return False
68
+
69
+ if getattr(spec, key) != value:
70
+ setattr(spec, key, value)
71
+ return True
72
+
73
+ return False
@@ -0,0 +1,48 @@
1
+ from typing import Optional
2
+
3
+ from lightning_sdk.lightning_cloud.rest_client import LightningClient
4
+
5
+
6
+ class LicenseApi:
7
+ def __init__(self) -> None:
8
+ self._client = LightningClient(retry=False, max_tries=0)
9
+
10
+ def valid_license(
11
+ self,
12
+ license_key: str,
13
+ product_name: str,
14
+ product_version: Optional[str] = None,
15
+ product_type: str = "package",
16
+ ) -> bool:
17
+ """Check if the license key is valid.
18
+
19
+ Args:
20
+ license_key: The license key to check.
21
+ product_name: The name of the product.
22
+ product_version: The version of the product.
23
+ product_type: The type of the product. Default is "package".
24
+
25
+ Returns:
26
+ True if the license key is valid, False otherwise.
27
+ """
28
+ response, code, _ = self._client.product_license_service_validate_product_license_with_http_info(
29
+ license_key=license_key,
30
+ product_name=product_name,
31
+ product_version=product_version,
32
+ product_type=product_type,
33
+ )
34
+ if code != 200:
35
+ raise ConnectionError(f"Failed to validate license key: {code} - {response}")
36
+ return response.valid
37
+
38
+ def list_user_licenses(self, user_id: str) -> list:
39
+ """List all licenses for a user.
40
+
41
+ Args:
42
+ user_id: The ID of the user.
43
+
44
+ Returns:
45
+ A list of licenses for the user.
46
+ """
47
+ response = self._client.product_license_service_list_user_licenses(user_id=user_id)
48
+ return response.licenses
@@ -1,6 +1,11 @@
1
- from typing import List, Optional
1
+ import json
2
+ from typing import Generator, List, Optional, Union
3
+
4
+ from pip._vendor.urllib3 import HTTPResponse
2
5
 
3
6
  from lightning_sdk.lightning_cloud.openapi.models.v1_conversation_response_chunk import V1ConversationResponseChunk
7
+ from lightning_sdk.lightning_cloud.openapi.models.v1_response_choice import V1ResponseChoice
8
+ from lightning_sdk.lightning_cloud.openapi.models.v1_response_choice_delta import V1ResponseChoiceDelta
4
9
  from lightning_sdk.lightning_cloud.rest_client import LightningClient
5
10
 
6
11
 
@@ -20,14 +25,47 @@ class LLMApi:
20
25
  result = self._client.assistants_service_list_assistants(user_id=user_id)
21
26
  return result.assistants
22
27
 
28
+ def _stream_chat_response(self, result: HTTPResponse) -> Generator[V1ConversationResponseChunk, None, None]:
29
+ for line in result.stream():
30
+ decoded_lines = line.decode("utf-8").strip()
31
+ for decoded_line in decoded_lines.splitlines():
32
+ try:
33
+ payload = json.loads(decoded_line)
34
+ result_data = payload.get("result", {})
35
+
36
+ choices = []
37
+ for choice in result_data.get("choices", []):
38
+ delta = choice.get("delta", {})
39
+ choices.append(
40
+ V1ResponseChoice(
41
+ delta=V1ResponseChoiceDelta(**delta),
42
+ finish_reason=choice.get("finishReason"),
43
+ index=choice.get("index"),
44
+ )
45
+ )
46
+
47
+ yield V1ConversationResponseChunk(
48
+ choices=choices,
49
+ conversation_id=result_data.get("conversationId"),
50
+ executable=result_data.get("executable"),
51
+ id=result_data.get("id"),
52
+ throughput=result_data.get("throughput"),
53
+ )
54
+
55
+ except json.JSONDecodeError:
56
+ print("Error decoding JSON:", decoded_line)
57
+
23
58
  def start_conversation(
24
59
  self,
25
60
  prompt: str,
26
61
  system_prompt: Optional[str],
27
- max_completion_tokens: Optional[int],
62
+ max_completion_tokens: int,
28
63
  assistant_id: str,
29
- conversation_id: Optional[str],
30
- ) -> V1ConversationResponseChunk:
64
+ conversation_id: Optional[str] = None,
65
+ billing_project_id: Optional[str] = None,
66
+ name: Optional[str] = None,
67
+ stream: bool = False,
68
+ ) -> Union[V1ConversationResponseChunk, Generator[V1ConversationResponseChunk, None, None]]:
31
69
  body = {
32
70
  "message": {
33
71
  "author": {"role": "user"},
@@ -39,8 +77,23 @@ class LLMApi:
39
77
  ],
40
78
  },
41
79
  "max_completion_tokens": max_completion_tokens,
80
+ "conversation_id": conversation_id,
81
+ "billing_project_id": billing_project_id,
82
+ "name": name,
83
+ "stream": stream,
42
84
  }
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
85
+ result = self._client.assistants_service_start_conversation(body, assistant_id, _preload_content=not stream)
86
+ if not stream:
87
+ return result.result
88
+ return self._stream_chat_response(result)
89
+
90
+ def list_conversations(self, assistant_id: str) -> List[str]:
91
+ result = self._client.assistants_service_list_conversations(assistant_id)
92
+ return result.conversations
93
+
94
+ def get_conversation(self, assistant_id: str, conversation_id: str) -> V1ConversationResponseChunk:
95
+ result = self._client.assistants_service_get_conversation(assistant_id, conversation_id)
96
+ return result.messages
97
+
98
+ def reset_conversation(self, assistant_id: str, conversation_id: str) -> None:
99
+ self._client.assistants_service_delete_conversation(assistant_id, conversation_id)
@@ -5,7 +5,7 @@ import time
5
5
  import warnings
6
6
  import zipfile
7
7
  from threading import Event, Thread
8
- from typing import Any, Dict, Mapping, Optional, Tuple, Union
8
+ from typing import Any, Dict, Generator, Mapping, Optional, Tuple, Union
9
9
 
10
10
  import backoff
11
11
  import requests
@@ -285,6 +285,52 @@ class StudioApi:
285
285
  for response in responses:
286
286
  yield response.result
287
287
 
288
+ def run_studio_commands_and_yield(
289
+ self, studio_id: str, teamspace_id: str, *commands: str, timeout: float, check_interval: float
290
+ ) -> Generator[Tuple[str, int], None, None]:
291
+ """Run given commands in a given Studio and yield the output and exit code for the given timeout.
292
+
293
+ Args:
294
+ timeout: wait for this many seconds for the command to finish.
295
+ """
296
+ response_submit = self._client.cloud_space_service_execute_command_in_cloud_space(
297
+ IdExecuteBody1("; ".join(commands), detached=True),
298
+ project_id=teamspace_id,
299
+ id=studio_id,
300
+ )
301
+
302
+ if not response_submit:
303
+ raise RuntimeError("Unable to submit command")
304
+
305
+ if response_submit.session_name == "":
306
+ raise RuntimeError("The session name should be defined.")
307
+
308
+ start_time = time.time()
309
+ exit_code = None
310
+ while True:
311
+ for resp in self._get_detached_command_status(
312
+ studio_id=studio_id,
313
+ teamspace_id=teamspace_id,
314
+ session_id=response_submit.session_name,
315
+ ):
316
+ if time.time() - start_time >= timeout:
317
+ return
318
+
319
+ if resp.exit_code == -1:
320
+ break
321
+
322
+ if exit_code is None:
323
+ exit_code = resp.exit_code
324
+
325
+ elif exit_code != resp.exit_code:
326
+ raise RuntimeError("Cannot determine exit code")
327
+
328
+ if resp.exit_code is not None and resp.exit_code != 0:
329
+ raise RuntimeError(f"Command failed with exit code {resp.exit_code}. Output: {resp.output}")
330
+
331
+ yield resp.output, exit_code
332
+ time.sleep(check_interval)
333
+
288
334
  def run_studio_commands(self, studio_id: str, teamspace_id: str, *commands: str) -> Tuple[str, int]:
289
335
  """Run given commands in a given Studio."""
290
336
  response_submit = self._client.cloud_space_service_execute_command_in_cloud_space(
@@ -0,0 +1,70 @@
1
+ from typing import List, Optional, Union
2
+
3
+ from lightning_sdk.api.base_studio_api import BaseStudioApi
4
+ from lightning_sdk.api.user_api import UserApi
5
+ from lightning_sdk.lightning_cloud import login
6
+ from lightning_sdk.lightning_cloud.openapi.models.v1_cloud_space_environment_template import (
7
+ V1CloudSpaceEnvironmentTemplate,
8
+ )
9
+ from lightning_sdk.lightning_cloud.openapi.models.v1_cloud_space_environment_type import V1CloudSpaceEnvironmentType
10
+ from lightning_sdk.organization import Organization
11
+ from lightning_sdk.user import User
12
+ from lightning_sdk.utils.resolve import _resolve_org, _resolve_user
13
+
14
+
15
+ class BaseStudio:
16
+ def __init__(
17
+ self,
18
+ name: Optional[str] = None,
19
+ org: Optional[Union[str, Organization]] = None,
20
+ user: Optional[Union[str, User]] = None,
21
+ ) -> None:
22
+ """Initializes the BaseStudio instance with organization and user information.
23
+
24
+ Args:
25
+ org (Optional[Union[str, Organization]]): The organization for the base studio. If not provided,
26
+ it will be resolved through the authentication process.
27
+ user (Optional[Union[str, User]]): The user for the base studio. If not provided, it will be resolved
28
+ through the authentication process.
29
+
30
+ Raises:
31
+ ConnectionError: If there is an issue with the authentication process.
32
+ """
33
+ self._auth = login.Auth()
34
+ self._user = None
35
+
36
+ try:
37
+ self._auth.authenticate()
38
+ if user is None:
39
+ self._user = User(name=UserApi()._get_user_by_id(self._auth.user_id).username)
40
+ except ConnectionError as e:
41
+ raise e
42
+
43
+ self._user = _resolve_user(self._user or user)
44
+ self._org = _resolve_org(org)
45
+
46
+ self._base_studio_api = BaseStudioApi()
47
+
48
+ self._base_studio = self._base_studio_api.get_base_studio(name, self._org.id)
49
+
50
+ def update(
51
+ self,
52
+ name: Optional[str] = None,
53
+ allowed_machines: Optional[List[str]] = None,
54
+ default_machine: Optional[str] = None,
55
+ disabled: Optional[bool] = None,
56
+ environment_type: Optional[V1CloudSpaceEnvironmentType] = None,
57
+ machine_image_version: Optional[str] = None,
58
+ setup_script_text: Optional[str] = None,
59
+ ) -> V1CloudSpaceEnvironmentTemplate:
60
+ self._base_studio = self._base_studio_api.update_base_studio(
61
+ self._base_studio.id,
62
+ self._org.id,
63
+ name=name,
64
+ allowed_machines=allowed_machines,
65
+ default_machine=default_machine,
66
+ environment_type=environment_type,
67
+ machine_image_version=machine_image_version,
68
+ setup_script_text=setup_script_text,
69
+ disabled=disabled,
70
+ )
@@ -17,7 +17,7 @@ def delete() -> None:
17
17
 
18
18
 
19
19
  @delete.command(name="container")
20
- @click.argument("container")
20
+ @click.argument("name")
21
21
  @click.option(
22
22
  "--teamspace",
23
23
  default=None,
@@ -27,18 +27,16 @@ def delete() -> None:
27
27
  "If not provided, can be selected in an interactive menu."
28
28
  ),
29
29
  )
30
- def delete_container(container: str, teamspace: Optional[str] = None) -> None:
31
- """Delete the docker container CONTAINER."""
30
+ def container(name: str, teamspace: Optional[str] = None) -> None:
31
+ """Delete the docker container NAME."""
32
32
  api = LitContainer()
33
33
  menu = _TeamspacesMenu()
34
34
  resolved_teamspace = menu._resolve_teamspace(teamspace=teamspace)
35
35
  try:
36
- api.delete_container(container, resolved_teamspace.name, resolved_teamspace.owner.name)
37
- Console().print(f"Container {container} deleted successfully.")
36
+ api.delete_container(name, resolved_teamspace.name, resolved_teamspace.owner.name)
37
+ Console().print(f"Container {name} deleted successfully.")
38
38
  except Exception as e:
39
- raise StudioCliError(
40
- f"Could not delete container {container} from project {resolved_teamspace.name}: {e}"
41
- ) from None
39
+ raise StudioCliError(f"Could not delete container {name} from project {resolved_teamspace.name}: {e}") from None
42
40
 
43
41
 
44
42
  @delete.command(name="job")
@@ -1,3 +1,4 @@
1
+ import json
1
2
  import os
2
3
  import re
3
4
  from pathlib import Path
@@ -6,6 +7,7 @@ from typing import Optional
6
7
  import click
7
8
  from rich.console import Console
8
9
 
10
+ from lightning_sdk.api.license_api import LicenseApi
9
11
  from lightning_sdk.api.lit_container_api import LitContainerApi
10
12
  from lightning_sdk.cli.exceptions import StudioCliError
11
13
  from lightning_sdk.cli.studios_menu import _StudiosMenu
@@ -208,3 +210,26 @@ def _resolve_studio(studio: Optional[str]) -> Studio:
208
210
 
209
211
  with skip_studio_init():
210
212
  return Studio(**selected_studio)
213
+
214
+
215
+ @download.command(name="licenses")
216
+ def licenses() -> None:
217
+ """Download licenses for all products/packages.
218
+
219
+ Example:
220
+ lightning download licenses
221
+
222
+ """
223
+ user = _get_authed_user()
224
+ api = LicenseApi()
225
+ licenses = api.list_user_licenses(user.id)
226
+
227
+ user_home = Path.home()
228
+ lit_dir = user_home / ".lightning"
229
+ lit_dir.mkdir(parents=True, exist_ok=True)
230
+ licenses_file = lit_dir / "licenses.json"
231
+
232
+ licenses_short = {ll.product_name: ll.license_key for ll in licenses if ll.is_valid}
233
+ with licenses_file.open("w") as fp:
234
+ json.dump(licenses_short, fp, indent=4)
235
+ Console().print(f"Licenses downloaded to {licenses_file}", style="green")