lightning-sdk 2025.10.14__py3-none-any.whl → 2025.10.23__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 (76) hide show
  1. lightning_sdk/__init__.py +6 -3
  2. lightning_sdk/api/base_studio_api.py +13 -9
  3. lightning_sdk/api/job_api.py +4 -1
  4. lightning_sdk/api/license_api.py +26 -59
  5. lightning_sdk/api/studio_api.py +7 -2
  6. lightning_sdk/base_studio.py +30 -17
  7. lightning_sdk/cli/base_studio/list.py +1 -3
  8. lightning_sdk/cli/entrypoint.py +11 -34
  9. lightning_sdk/cli/groups.py +7 -0
  10. lightning_sdk/cli/license/__init__.py +14 -0
  11. lightning_sdk/cli/license/get.py +15 -0
  12. lightning_sdk/cli/license/list.py +45 -0
  13. lightning_sdk/cli/license/set.py +13 -0
  14. lightning_sdk/cli/studio/connect.py +42 -92
  15. lightning_sdk/cli/studio/create.py +23 -1
  16. lightning_sdk/cli/studio/start.py +12 -2
  17. lightning_sdk/cli/utils/get_base_studio.py +24 -0
  18. lightning_sdk/cli/utils/handle_machine_and_gpus_args.py +69 -0
  19. lightning_sdk/cli/utils/logging.py +121 -0
  20. lightning_sdk/cli/utils/ssh_connection.py +1 -1
  21. lightning_sdk/constants.py +1 -0
  22. lightning_sdk/helpers.py +53 -34
  23. lightning_sdk/job/base.py +7 -0
  24. lightning_sdk/job/job.py +8 -0
  25. lightning_sdk/job/v1.py +3 -0
  26. lightning_sdk/job/v2.py +4 -0
  27. lightning_sdk/lightning_cloud/login.py +260 -10
  28. lightning_sdk/lightning_cloud/openapi/__init__.py +16 -3
  29. lightning_sdk/lightning_cloud/openapi/api/auth_service_api.py +279 -0
  30. lightning_sdk/lightning_cloud/openapi/api/k8_s_cluster_service_api.py +117 -0
  31. lightning_sdk/lightning_cloud/openapi/api/product_license_service_api.py +108 -108
  32. lightning_sdk/lightning_cloud/openapi/models/__init__.py +16 -3
  33. lightning_sdk/lightning_cloud/openapi/models/create_machine_request_represents_the_request_to_create_a_machine.py +27 -1
  34. lightning_sdk/lightning_cloud/openapi/models/externalv1_cloud_space_instance_status.py +27 -1
  35. lightning_sdk/lightning_cloud/openapi/models/id_fork_body1.py +27 -1
  36. lightning_sdk/lightning_cloud/openapi/models/license_key_validate_body.py +123 -0
  37. lightning_sdk/lightning_cloud/openapi/models/update1.py +27 -1
  38. lightning_sdk/lightning_cloud/openapi/models/v1_create_license_request.py +175 -0
  39. lightning_sdk/lightning_cloud/openapi/models/v1_data_connection.py +27 -1
  40. lightning_sdk/lightning_cloud/openapi/models/v1_delete_license_response.py +97 -0
  41. lightning_sdk/lightning_cloud/openapi/models/v1_external_cluster_spec.py +27 -1
  42. lightning_sdk/lightning_cloud/openapi/models/v1_external_search_user.py +27 -1
  43. lightning_sdk/lightning_cloud/openapi/models/v1_filesystem_metric.py +201 -0
  44. lightning_sdk/lightning_cloud/openapi/models/v1_get_cloud_space_transfer_estimate_response.py +29 -3
  45. lightning_sdk/lightning_cloud/openapi/models/v1_incident.py +27 -1
  46. lightning_sdk/lightning_cloud/openapi/models/v1_incident_detail.py +149 -0
  47. lightning_sdk/lightning_cloud/openapi/models/v1_incident_event.py +27 -1
  48. lightning_sdk/lightning_cloud/openapi/models/v1_license.py +227 -0
  49. lightning_sdk/lightning_cloud/openapi/models/v1_list_filesystem_metrics_response.py +123 -0
  50. lightning_sdk/lightning_cloud/openapi/models/{v1_list_product_licenses_response.py → v1_list_license_response.py} +16 -16
  51. lightning_sdk/lightning_cloud/openapi/models/v1_list_platform_notifications_response.py +123 -0
  52. lightning_sdk/lightning_cloud/openapi/models/v1_machine.py +27 -1
  53. lightning_sdk/lightning_cloud/openapi/models/v1_platform_notification.py +279 -0
  54. lightning_sdk/lightning_cloud/openapi/models/v1_reset_api_key_request.py +97 -0
  55. lightning_sdk/lightning_cloud/openapi/models/v1_reset_api_key_response.py +123 -0
  56. lightning_sdk/lightning_cloud/openapi/models/v1_slack_notifier.py +53 -1
  57. lightning_sdk/lightning_cloud/openapi/models/v1_token_login_request.py +123 -0
  58. lightning_sdk/lightning_cloud/openapi/models/v1_token_login_response.py +123 -0
  59. lightning_sdk/lightning_cloud/openapi/models/v1_token_owner_type.py +104 -0
  60. lightning_sdk/lightning_cloud/openapi/models/v1_user_features.py +139 -191
  61. lightning_sdk/lightning_cloud/openapi/models/{v1_product_license_check_response.py → v1_validate_license_response.py} +21 -21
  62. lightning_sdk/lightning_cloud/rest_client.py +48 -45
  63. lightning_sdk/machine.py +5 -0
  64. lightning_sdk/pipeline/steps.py +1 -0
  65. lightning_sdk/studio.py +55 -13
  66. lightning_sdk/utils/config.py +18 -3
  67. lightning_sdk/utils/license.py +13 -0
  68. lightning_sdk/utils/resolve.py +6 -1
  69. {lightning_sdk-2025.10.14.dist-info → lightning_sdk-2025.10.23.dist-info}/METADATA +1 -1
  70. {lightning_sdk-2025.10.14.dist-info → lightning_sdk-2025.10.23.dist-info}/RECORD +74 -54
  71. lightning_sdk/lightning_cloud/openapi/models/v1_product_license.py +0 -435
  72. lightning_sdk/services/license.py +0 -363
  73. {lightning_sdk-2025.10.14.dist-info → lightning_sdk-2025.10.23.dist-info}/LICENSE +0 -0
  74. {lightning_sdk-2025.10.14.dist-info → lightning_sdk-2025.10.23.dist-info}/WHEEL +0 -0
  75. {lightning_sdk-2025.10.14.dist-info → lightning_sdk-2025.10.23.dist-info}/entry_points.txt +0 -0
  76. {lightning_sdk-2025.10.14.dist-info → lightning_sdk-2025.10.23.dist-info}/top_level.txt +0 -0
@@ -2,11 +2,13 @@
2
2
 
3
3
  import subprocess
4
4
  import sys
5
- from typing import Dict, Optional, Set
5
+ from contextlib import suppress
6
+ from typing import Optional
6
7
 
7
8
  import click
8
9
 
9
- from lightning_sdk.base_studio import BaseStudio
10
+ from lightning_sdk.cli.utils.get_base_studio import get_base_studio_id
11
+ from lightning_sdk.cli.utils.handle_machine_and_gpus_args import handle_machine_and_gpus_args
10
12
  from lightning_sdk.cli.utils.richt_print import studio_name_link
11
13
  from lightning_sdk.cli.utils.save_to_config import save_studio_to_config, save_teamspace_to_config
12
14
  from lightning_sdk.cli.utils.ssh_connection import configure_ssh_internal
@@ -16,81 +18,42 @@ from lightning_sdk.machine import CloudProvider, Machine
16
18
  from lightning_sdk.studio import Studio
17
19
  from lightning_sdk.utils.names import random_unique_name
18
20
 
19
- DEFAULT_MACHINE = "CPU"
20
21
 
22
+ def _parse_args_or_get_from_current_studio(
23
+ teamspace: Optional[str],
24
+ cloud_account: Optional[str],
25
+ studio_type: Optional[str],
26
+ machine: Optional[str],
27
+ gpus: Optional[str],
28
+ cloud_provider: Optional[str],
29
+ name: Optional[str],
30
+ ) -> tuple[Optional[str], Optional[str], Optional[str], Optional[str]]:
31
+ # Parse args provided by user
32
+ menu = TeamspacesMenu()
33
+ resolved_teamspace = menu(teamspace)
34
+ save_teamspace_to_config(resolved_teamspace, overwrite=False)
21
35
 
22
- def _split_gpus_spec(gpus: str) -> tuple[str, int]:
23
- machine_name, machine_val = gpus.split(":", 1)
24
- machine_name = machine_name.strip()
25
- machine_val = machine_val.strip()
26
-
27
- if not machine_val.isdigit() or int(machine_val) <= 0:
28
- raise ValueError(f"Invalid GPU count '{machine_val}'. Must be a positive integer.")
29
-
30
- machine_num = int(machine_val)
31
- return machine_name, machine_num
32
-
33
-
34
- def _construct_available_gpus(machine_options: Dict[str, str]) -> Set[str]:
35
- # returns available gpus:count
36
- available_gpus = set()
37
- for v in machine_options.values():
38
- if "_X_" in v:
39
- gpu_type_num = v.replace("_X_", ":")
40
- available_gpus.add(gpu_type_num)
41
- else:
42
- available_gpus.add(v)
43
- return available_gpus
44
-
45
-
46
- def _get_machine_from_gpus(gpus: str) -> Machine:
47
- machine_name = gpus
48
- machine_num = 1
49
-
50
- if ":" in gpus:
51
- machine_name, machine_num = _split_gpus_spec(gpus)
52
-
53
- machine_options = {
54
- m.name.lower(): m.name for m in Machine.__dict__.values() if isinstance(m, Machine) and m._include_in_cli
55
- }
56
-
57
- if machine_num == 1:
58
- # e.g. gpus=L4 or gpus=L4:1
59
- gpu_key = machine_name.lower()
60
- try:
61
- return machine_options[gpu_key]
62
- except KeyError:
63
- available = ", ".join(_construct_available_gpus(machine_options))
64
- raise ValueError(f"Invalid GPU type '{machine_name}'. Available options: {available}") from None
65
-
66
- # Else: e.g. gpus=L4:4
67
- gpu_key = f"{machine_name.lower()}_x_{machine_num}"
68
- try:
69
- return machine_options[gpu_key]
70
- except KeyError:
71
- available = ", ".join(_construct_available_gpus(machine_options))
72
- raise ValueError(f"Invalid GPU configuration '{gpus}'. Available options: {available}") from None
36
+ template_id = get_base_studio_id(studio_type)
73
37
 
38
+ if cloud_provider is not None:
39
+ cloud_provider = CloudProvider(cloud_provider)
74
40
 
75
- def _get_base_studio_id(studio_type: Optional[str]) -> Optional[str]:
76
- base_studios = BaseStudio()
77
- base_studios = base_studios.list()
78
- template_id = None
41
+ name = name or random_unique_name()
79
42
 
80
- if base_studios and len(base_studios):
81
- # if not specified by user, use the first existing template studio
82
- template_id = base_studios[0].id
83
- # else, try to match the provided studio_type to base studio name
84
- if studio_type:
85
- normalized_studio_type = studio_type.lower().replace(" ", "-")
86
- match = next(
87
- (s for s in base_studios if s.name.lower().replace(" ", "-") == normalized_studio_type),
88
- None,
89
- )
90
- if match:
91
- template_id = match.id
43
+ with suppress(ValueError):
44
+ # Gets current studio context to use its parameters as defaults
45
+ s = Studio()
46
+ if not teamspace:
47
+ resolved_teamspace = s.teamspace
48
+ save_teamspace_to_config(resolved_teamspace, overwrite=False)
49
+ if not cloud_account:
50
+ cloud_account = s.cloud_account
51
+ if not template_id:
52
+ template_id = s._studio.environment_template_id
53
+ if not machine and not gpus:
54
+ machine = s.machine
92
55
 
93
- return template_id
56
+ return resolved_teamspace, cloud_account, template_id, machine, cloud_provider, name
94
57
 
95
58
 
96
59
  @click.command("connect")
@@ -124,6 +87,7 @@ def _get_base_studio_id(studio_type: Optional[str]) -> Optional[str]:
124
87
  "Defaults to the first available template.",
125
88
  type=click.STRING,
126
89
  )
90
+ @click.option("--interruptible", is_flag=True, help="Start the studio on an interruptible instance.")
127
91
  def connect_studio(
128
92
  name: Optional[str] = None,
129
93
  teamspace: Optional[str] = None,
@@ -132,29 +96,21 @@ def connect_studio(
132
96
  machine: Optional[str] = None,
133
97
  gpus: Optional[str] = None,
134
98
  studio_type: Optional[str] = None,
99
+ interruptible: bool = False,
135
100
  ) -> None:
136
101
  """Connect to a Studio.
137
102
 
138
103
  Example:
139
104
  lightning studio connect
140
105
  """
141
- menu = TeamspacesMenu()
142
-
143
- resolved_teamspace = menu(teamspace)
144
- save_teamspace_to_config(resolved_teamspace, overwrite=False)
145
-
146
- if cloud_provider is not None:
147
- cloud_provider = CloudProvider(cloud_provider)
148
-
149
- name = name or random_unique_name()
150
-
151
- # check for available base studios
152
- template_id = _get_base_studio_id(studio_type)
106
+ teamspace, cloud_account, template_id, machine, cloud_provider, name = _parse_args_or_get_from_current_studio(
107
+ teamspace, cloud_account, studio_type, machine, gpus, cloud_provider, name
108
+ )
153
109
 
154
110
  try:
155
111
  studio = Studio(
156
112
  name=name,
157
- teamspace=resolved_teamspace,
113
+ teamspace=teamspace,
158
114
  create_ok=True,
159
115
  cloud_provider=cloud_provider,
160
116
  cloud_account=cloud_account,
@@ -167,16 +123,10 @@ def connect_studio(
167
123
 
168
124
  Studio.show_progress = True
169
125
 
170
- if machine and gpus:
171
- raise click.UsageError("Options --machine and --gpu are mutually exclusive. Provide only one.")
172
- elif gpus:
173
- machine = _get_machine_from_gpus(gpus.strip())
174
- elif not machine:
175
- machine = DEFAULT_MACHINE
126
+ machine = handle_machine_and_gpus_args(machine, gpus)
176
127
 
177
128
  save_studio_to_config(studio)
178
- # by default, interruptible is False
179
- studio.start(machine=machine, interruptible=False)
129
+ studio.start(machine=machine, interruptible=interruptible)
180
130
 
181
131
  ssh_private_key_path = configure_ssh_internal()
182
132
 
@@ -4,6 +4,7 @@ from typing import Optional
4
4
 
5
5
  import click
6
6
 
7
+ from lightning_sdk.cli.utils.get_base_studio import get_base_studio_id
7
8
  from lightning_sdk.cli.utils.richt_print import studio_name_link
8
9
  from lightning_sdk.cli.utils.save_to_config import save_teamspace_to_config
9
10
  from lightning_sdk.cli.utils.teamspace_selection import TeamspacesMenu
@@ -25,18 +26,34 @@ from lightning_sdk.studio import VM, Studio
25
26
  help="The cloud account to create the studio on. Defaults to teamspace default.",
26
27
  type=click.STRING,
27
28
  )
29
+ @click.option(
30
+ "--studio-type",
31
+ help="The base studio template name to use for creating the studio. "
32
+ "Must be lowercase and hyphenated (use '-' instead of spaces). "
33
+ "Run 'lightning base-studio list' to see all available templates. "
34
+ "Defaults to the first available template.",
35
+ type=click.STRING,
36
+ )
28
37
  def create_studio(
29
38
  name: Optional[str] = None,
30
39
  teamspace: Optional[str] = None,
31
40
  cloud_provider: Optional[str] = None,
32
41
  cloud_account: Optional[str] = None,
42
+ studio_type: Optional[str] = None,
33
43
  ) -> None:
34
44
  """Create a new Studio.
35
45
 
36
46
  Example:
37
47
  lightning studio create
38
48
  """
39
- create_impl(name=name, teamspace=teamspace, cloud_provider=cloud_provider, cloud_account=cloud_account, vm=False)
49
+ create_impl(
50
+ name=name,
51
+ teamspace=teamspace,
52
+ cloud_provider=cloud_provider,
53
+ cloud_account=cloud_account,
54
+ vm=False,
55
+ studio_type=studio_type,
56
+ )
40
57
 
41
58
 
42
59
  def create_impl(
@@ -45,6 +62,7 @@ def create_impl(
45
62
  cloud_provider: Optional[str],
46
63
  cloud_account: Optional[str],
47
64
  vm: bool,
65
+ studio_type: Optional[str],
48
66
  ) -> None:
49
67
  menu = TeamspacesMenu()
50
68
 
@@ -57,6 +75,9 @@ def create_impl(
57
75
  create_cls = VM if vm else Studio
58
76
  cls_name = create_cls.__qualname__
59
77
 
78
+ # check for available base studios
79
+ template_id = get_base_studio_id(studio_type)
80
+
60
81
  try:
61
82
  create_cls = VM if vm else Studio
62
83
  studio = create_cls(
@@ -65,6 +86,7 @@ def create_impl(
65
86
  create_ok=True,
66
87
  cloud_provider=cloud_provider,
67
88
  cloud_account=cloud_account,
89
+ studio_type=template_id,
68
90
  )
69
91
  except (RuntimeError, ValueError, ApiException):
70
92
  if name:
@@ -4,6 +4,7 @@ from typing import Optional
4
4
 
5
5
  import click
6
6
 
7
+ from lightning_sdk.cli.utils.handle_machine_and_gpus_args import handle_machine_and_gpus_args
7
8
  from lightning_sdk.cli.utils.richt_print import studio_name_link
8
9
  from lightning_sdk.cli.utils.save_to_config import save_studio_to_config
9
10
  from lightning_sdk.cli.utils.studio_selection import StudiosMenu
@@ -32,8 +33,7 @@ from lightning_sdk.studio import VM, Studio
32
33
  @click.option(
33
34
  "--cloud-provider",
34
35
  help=(
35
- "The cloud provider to start the studio on. Defaults to teamspace default. "
36
- "Only used if --create is specified."
36
+ "The cloud provider to start the studio on. Defaults to teamspace default. Only used if --create is specified."
37
37
  ),
38
38
  type=click.Choice(m.name for m in list(CloudProvider)),
39
39
  )
@@ -42,11 +42,17 @@ from lightning_sdk.studio import VM, Studio
42
42
  help="The cloud account to start the studio on. Defaults to teamspace default. Only used if --create is specified.",
43
43
  type=click.STRING,
44
44
  )
45
+ @click.option(
46
+ "--gpus",
47
+ help="The number and type of GPUs to start the studio on (format: TYPE:COUNT, e.g. L4:4)",
48
+ type=click.STRING,
49
+ )
45
50
  def start_studio(
46
51
  name: Optional[str] = None,
47
52
  teamspace: Optional[str] = None,
48
53
  create: bool = False,
49
54
  machine: str = "CPU",
55
+ gpus: Optional[str] = None,
50
56
  interruptible: bool = False,
51
57
  cloud_provider: Optional[str] = None,
52
58
  cloud_account: Optional[str] = None,
@@ -62,6 +68,7 @@ def start_studio(
62
68
  teamspace=teamspace,
63
69
  create=create,
64
70
  machine=machine,
71
+ gpus=gpus,
65
72
  interruptible=interruptible,
66
73
  cloud_provider=cloud_provider,
67
74
  cloud_account=cloud_account,
@@ -74,6 +81,7 @@ def start_impl(
74
81
  teamspace: Optional[str],
75
82
  create: bool,
76
83
  machine: str,
84
+ gpus: Optional[str],
77
85
  interruptible: bool,
78
86
  cloud_provider: Optional[str],
79
87
  cloud_account: Optional[str],
@@ -98,6 +106,8 @@ def start_impl(
98
106
  cloud_account=cloud_account,
99
107
  )
100
108
 
109
+ machine = handle_machine_and_gpus_args(machine, gpus)
110
+
101
111
  save_studio_to_config(studio)
102
112
 
103
113
  Studio.show_progress = True
@@ -0,0 +1,24 @@
1
+ from typing import Optional
2
+
3
+ from lightning_sdk.base_studio import BaseStudio
4
+
5
+
6
+ def get_base_studio_id(studio_type: Optional[str]) -> Optional[str]:
7
+ base_studios = BaseStudio()
8
+ base_studios = base_studios.list()
9
+ template_id = None
10
+
11
+ if base_studios and len(base_studios):
12
+ # if not specified by user, use the first existing template studio
13
+ template_id = base_studios[0].id
14
+ # else, try to match the provided studio_type to base studio name
15
+ if studio_type:
16
+ normalized_studio_type = studio_type.lower().replace(" ", "-")
17
+ match = next(
18
+ (s for s in base_studios if s.name.lower().replace(" ", "-") == normalized_studio_type),
19
+ None,
20
+ )
21
+ if match:
22
+ template_id = match.id
23
+
24
+ return template_id
@@ -0,0 +1,69 @@
1
+ from typing import Dict, Optional, Set
2
+
3
+ import click
4
+
5
+ from lightning_sdk.machine import DEFAULT_MACHINE, Machine
6
+
7
+
8
+ def _split_gpus_spec(gpus: str) -> tuple[str, int]:
9
+ machine_name, machine_val = gpus.split(":", 1)
10
+ machine_name = machine_name.strip()
11
+ machine_val = machine_val.strip()
12
+
13
+ if not machine_val.isdigit() or int(machine_val) <= 0:
14
+ raise ValueError(f"Invalid GPU count '{machine_val}'. Must be a positive integer.")
15
+
16
+ machine_num = int(machine_val)
17
+ return machine_name, machine_num
18
+
19
+
20
+ def _construct_available_gpus(machine_options: Dict[str, str]) -> Set[str]:
21
+ # returns available gpus:count
22
+ available_gpus = set()
23
+ for v in machine_options.values():
24
+ if "_X_" in v:
25
+ gpu_type_num = v.replace("_X_", ":")
26
+ available_gpus.add(gpu_type_num)
27
+ else:
28
+ available_gpus.add(v)
29
+ return available_gpus
30
+
31
+
32
+ def _get_machine_from_gpus(gpus: str) -> Machine:
33
+ machine_name = gpus
34
+ machine_num = 1
35
+
36
+ if ":" in gpus:
37
+ machine_name, machine_num = _split_gpus_spec(gpus)
38
+
39
+ machine_options = {
40
+ m.name.lower(): m.name for m in Machine.__dict__.values() if isinstance(m, Machine) and m._include_in_cli
41
+ }
42
+
43
+ if machine_num == 1:
44
+ # e.g. gpus=L4 or gpus=L4:1
45
+ gpu_key = machine_name.lower()
46
+ try:
47
+ return machine_options[gpu_key]
48
+ except KeyError:
49
+ available = ", ".join(_construct_available_gpus(machine_options))
50
+ raise ValueError(f"Invalid GPU type '{machine_name}'. Available options: {available}") from None
51
+
52
+ # Else: e.g. gpus=L4:4
53
+ gpu_key = f"{machine_name.lower()}_x_{machine_num}"
54
+ try:
55
+ return machine_options[gpu_key]
56
+ except KeyError:
57
+ available = ", ".join(_construct_available_gpus(machine_options))
58
+ raise ValueError(f"Invalid GPU configuration '{gpus}'. Available options: {available}") from None
59
+
60
+
61
+ def handle_machine_and_gpus_args(machine: Optional[str], gpus: Optional[str]) -> str:
62
+ if machine and gpus:
63
+ raise click.UsageError("Options --machine and --gpus are mutually exclusive. Provide only one.")
64
+ elif gpus:
65
+ machine = _get_machine_from_gpus(gpus.strip())
66
+ elif not machine:
67
+ machine = DEFAULT_MACHINE
68
+
69
+ return machine
@@ -0,0 +1,121 @@
1
+ import shlex
2
+ import sys
3
+ import traceback
4
+ from contextlib import suppress
5
+ from time import time
6
+ from types import TracebackType
7
+ from typing import Optional, Type
8
+
9
+ import click
10
+ from rich.console import Group
11
+ from rich.panel import Panel
12
+ from rich.syntax import Syntax
13
+ from rich.text import Text
14
+
15
+ from lightning_sdk.cli.utils import rich_to_str
16
+ from lightning_sdk.constants import _LIGHTNING_DEBUG
17
+ from lightning_sdk.lightning_cloud.openapi.models.v1_create_sdk_command_history_request import (
18
+ V1CreateSDKCommandHistoryRequest,
19
+ )
20
+ from lightning_sdk.lightning_cloud.openapi.models.v1_sdk_command_history_severity import V1SDKCommandHistorySeverity
21
+ from lightning_sdk.lightning_cloud.openapi.models.v1_sdk_command_history_type import V1SDKCommandHistoryType
22
+ from lightning_sdk.lightning_cloud.rest_client import LightningClient
23
+
24
+
25
+ def _log_command(message: str = "", duration: int = 0, error: Optional[str] = None) -> None:
26
+ original_command = " ".join(shlex.quote(arg) for arg in sys.argv)
27
+ client = LightningClient(retry=False, max_tries=0)
28
+
29
+ body = V1CreateSDKCommandHistoryRequest(
30
+ command=original_command,
31
+ duration=duration,
32
+ message=message,
33
+ project_id=None,
34
+ severity=V1SDKCommandHistorySeverity.INFO,
35
+ type=V1SDKCommandHistoryType.CLI,
36
+ )
37
+
38
+ if error:
39
+ body.severity = V1SDKCommandHistorySeverity.WARNING if error == "0" else V1SDKCommandHistorySeverity.ERROR
40
+ body.message = body.message + f" | Error: {error}"
41
+
42
+ # limit characters
43
+ body.message = body.message[:1000]
44
+
45
+ with suppress(Exception):
46
+ client.s_dk_command_history_service_create_sdk_command_history(body=body)
47
+
48
+
49
+ def _notify_exception(exception_type: Type[BaseException], value: BaseException, tb: TracebackType) -> None:
50
+ """CLI won't show tracebacks, just print the exception message."""
51
+ message = str(value.args[0]) if value.args else str(value) or "An unknown error occurred"
52
+
53
+ error_text = Text()
54
+ error_text.append(f"{exception_type.__name__}: ", style="bold red")
55
+ error_text.append(message, style="white")
56
+
57
+ renderables = [error_text]
58
+
59
+ if _LIGHTNING_DEBUG:
60
+ tb_text = "".join(traceback.format_exception(exception_type, value, tb))
61
+ renderables.append(Text("\n\nFull traceback:\n", style="bold yellow"))
62
+ renderables.append(Syntax(tb_text, "python", theme="monokai light", line_numbers=False, word_wrap=True))
63
+ else:
64
+ renderables.append(Text("\n\n🐞 To view the full traceback, set: LIGHTNING_DEBUG=1"))
65
+
66
+ renderables.append(Text("\n📘 Need help? Run: lightning <command> --help", style="cyan"))
67
+
68
+ text = rich_to_str(Panel(Group(*renderables), title="⚡ Lightning CLI Error", border_style="red"))
69
+ click.echo(text, color=True)
70
+
71
+
72
+ def logging_excepthook(exception_type: Type[BaseException], value: BaseException, tb: TracebackType) -> None:
73
+ try:
74
+ tb_str = "".join(traceback.format_exception(exception_type, value, tb))
75
+ ctx = click.get_current_context(silent=True)
76
+ command_context = ctx.command_path if ctx else "outside_command_context"
77
+
78
+ message = (
79
+ f"Command: {command_context} | Type: {exception_type.__name__!s} | Value: {value!s} | Traceback: {tb_str}"
80
+ )
81
+ _log_command(message=message)
82
+ finally:
83
+ _notify_exception(exception_type, value, tb)
84
+
85
+
86
+ class CommandLoggingGroup(click.Group):
87
+ def _format_ctx(self, ctx: click.Context) -> str:
88
+ parts = []
89
+ for k, v in ctx.params.items():
90
+ if v is True:
91
+ parts.append(f"--{k}")
92
+ elif v is False or v is None:
93
+ continue
94
+ else:
95
+ parts.append(f"--{k} {v}")
96
+ params = " ".join(parts)
97
+ args = " ".join(ctx.args)
98
+ return (
99
+ f"""Commands: {ctx.command_path} | Subcommand: {ctx.invoked_subcommand} | Params: {params} | Args:{args}"""
100
+ )
101
+
102
+ def invoke(self, ctx: click.Context) -> any:
103
+ """Overrides the default invoke to wrap command execution with tracking."""
104
+ start_time = time()
105
+ error_message = None
106
+
107
+ try:
108
+ return super().invoke(ctx)
109
+ except click.ClickException as e:
110
+ error_message = str(e)
111
+ e.show()
112
+ ctx.exit(e.exit_code)
113
+ except Exception as e:
114
+ error_message = str(e)
115
+ raise
116
+ finally:
117
+ _log_command(
118
+ message=self._format_ctx(ctx),
119
+ duration=int(time() - start_time),
120
+ error=error_message,
121
+ )
@@ -16,7 +16,7 @@ def configure_ssh_internal(force_download: bool = False) -> str:
16
16
 
17
17
 
18
18
  def download_ssh_keys(
19
- api_key: str | None,
19
+ api_key: Optional[str],
20
20
  force_download: bool = False,
21
21
  ssh_key_name: str = "lightning_rsa",
22
22
  ) -> str:
@@ -29,3 +29,4 @@ class Store:
29
29
 
30
30
 
31
31
  __GLOBAL_LIGHTNING_UNIQUE_IDS_STORE__ = Store()
32
+ _LIGHTNING_DISABLE_VERSION_CHECK = int(os.getenv("LIGHTNING_DISABLE_VERSION_CHECK", "0"))
lightning_sdk/helpers.py CHANGED
@@ -1,4 +1,3 @@
1
- import functools
2
1
  import importlib
3
2
  import os
4
3
  import sys
@@ -10,48 +9,68 @@ import tqdm
10
9
  import tqdm.std
11
10
  from packaging import version as packaging_version
12
11
 
13
- __package_name__ = "lightning-sdk"
12
+ from lightning_sdk.constants import _LIGHTNING_DISABLE_VERSION_CHECK
14
13
 
15
14
 
16
- @functools.lru_cache(maxsize=1)
17
- def _get_newer_version(curr_version: str) -> Optional[str]:
18
- """Check PyPI for newer versions of ``lightning-sdk``.
19
-
20
- Returning the newest version if different from the current or ``None`` otherwise.
15
+ class VersionChecker:
16
+ """Handles version checking and upgrade prompts for lightning-sdk.
21
17
 
18
+ This class ensures that version check warnings are only shown once per session,
19
+ preventing duplicate warnings in multithreaded scenarios.
22
20
  """
23
- if packaging_version.parse(curr_version).is_prerelease:
24
- return None
25
- try:
26
- response = requests.get(f"https://pypi.org/pypi/{__package_name__}/json")
27
- response_json = response.json()
28
- releases = response_json["releases"]
29
- if curr_version not in releases:
30
- # Always return None if not installed from PyPI (e.g. dev versions)
21
+
22
+ def __init__(self, package_name: str = "lightning-sdk") -> None:
23
+ self.package_name = package_name
24
+ self._warning_shown = False
25
+ self._cached_version: Optional[str] = None
26
+
27
+ def _get_newer_version(self, curr_version: str) -> Optional[str]:
28
+ """Check PyPI for newer versions of ``lightning-sdk``.
29
+
30
+ Returning the newest version if different from the current or ``None`` otherwise.
31
+ """
32
+ if self._cached_version is not None:
33
+ return self._cached_version
34
+
35
+ if _LIGHTNING_DISABLE_VERSION_CHECK == 1 or packaging_version.parse(curr_version).is_prerelease:
36
+ self._cached_version = None
31
37
  return None
32
- latest_version = response_json["info"]["version"]
33
- parsed_version = packaging_version.parse(latest_version)
34
- is_invalid = response_json["info"]["yanked"] or parsed_version.is_devrelease or parsed_version.is_prerelease
35
- return None if curr_version == latest_version or is_invalid else latest_version
36
- except requests.exceptions.RequestException:
37
- return None
38
38
 
39
+ try:
40
+ response = requests.get(f"https://pypi.org/pypi/{self.package_name}/json")
41
+ response_json = response.json()
42
+ releases = response_json["releases"]
43
+ if curr_version not in releases:
44
+ # Always return None if not installed from PyPI (e.g. dev versions)
45
+ self._cached_version = None
46
+ return None
47
+ latest_version = response_json["info"]["version"]
48
+ parsed_version = packaging_version.parse(latest_version)
49
+ is_invalid = response_json["info"]["yanked"] or parsed_version.is_devrelease or parsed_version.is_prerelease
50
+ self._cached_version = None if curr_version == latest_version or is_invalid else latest_version
51
+ return self._cached_version
52
+ except requests.exceptions.RequestException:
53
+ self._cached_version = None
54
+ return None
39
55
 
40
- def _check_version_and_prompt_upgrade(curr_version: str) -> None:
41
- """Checks that the current version of ``lightning-sdk`` is the latest on PyPI.
56
+ def check_and_prompt_upgrade(self, curr_version: str) -> None:
57
+ """Checks that the current version of ``lightning-sdk`` is the latest on PyPI.
42
58
 
43
- If not, warn the user to upgrade ``lightning-sdk``.
59
+ If not, warn the user to upgrade ``lightning-sdk``.
60
+ Tracks if the warning has already been shown in this session to avoid duplicate warnings.
61
+ """
62
+ if self._warning_shown:
63
+ return
44
64
 
45
- """
46
- new_version = _get_newer_version(curr_version)
47
- if new_version:
48
- warnings.warn(
49
- f"A newer version of {__package_name__} is available ({new_version}). "
50
- f"Please consider upgrading with `pip install -U {__package_name__}`. "
51
- "Not all platform functionality can be guaranteed to work with the current version.",
52
- UserWarning,
53
- )
54
- return
65
+ new_version = self._get_newer_version(curr_version)
66
+ if new_version:
67
+ warnings.warn(
68
+ f"A newer version of {self.package_name} is available ({new_version}). "
69
+ f"Please consider upgrading with `pip install -U {self.package_name}`. "
70
+ "Not all platform functionality can be guaranteed to work with the current version.",
71
+ UserWarning,
72
+ )
73
+ self._warning_shown = True
55
74
 
56
75
 
57
76
  def _set_tqdm_envvars_noninteractive() -> None: