12
12
from typing import Any , Dict , List , Optional , Tuple , Union
13
13
14
14
import click
15
+ import rich
15
16
from lightning_cloud .openapi import (
16
17
Body3 ,
17
18
Body4 ,
67
68
from lightning_app .runners .backends .cloud import CloudBackend
68
69
from lightning_app .runners .runtime import Runtime
69
70
from lightning_app .source_code import LocalSourceCodeDir
70
- from lightning_app .source_code .copytree import _filter_ignored , _parse_lightningignore
71
+ from lightning_app .source_code .copytree import _filter_ignored , _IGNORE_FUNCTION , _parse_lightningignore
71
72
from lightning_app .storage import Drive , Mount
72
73
from lightning_app .utilities .app_helpers import _is_headless , Logger
73
74
from lightning_app .utilities .auth import _credential_string_to_basic_auth_params
@@ -106,6 +107,90 @@ def _to_clean_dict(swagger_object, map_attributes):
106
107
class CloudRuntime (Runtime ):
107
108
backend : Union [str , CloudBackend ] = "cloud"
108
109
110
+ def open (self , name : str , cluster_id : Optional [str ] = None ):
111
+ """Method to open a CloudSpace with the root folder uploaded."""
112
+ try :
113
+ # Check for feature support
114
+ user = self .backend .client .auth_service_get_user ()
115
+ if not user .features .code_tab :
116
+ rich .print (
117
+ "[red]The `lightning open` command has not been enabled for your account. "
118
+ "To request access, please contact [email protected] [/red]"
119
+ )
120
+ sys .exit (1 )
121
+
122
+ # Dispatch in four phases: resolution, validation, spec creation, API transactions
123
+ # Resolution
124
+ cloudspace_config = self ._resolve_config (name , load = False )
125
+ root = self ._resolve_root ()
126
+ ignore_functions = self ._resolve_open_ignore_functions ()
127
+ repo = self ._resolve_repo (root , ignore_functions )
128
+ project = self ._resolve_project ()
129
+ existing_cloudspaces = self ._resolve_existing_cloudspaces (project , cloudspace_config .name )
130
+ cluster_id = self ._resolve_cluster_id (cluster_id , project .project_id , existing_cloudspaces )
131
+ existing_cloudspace , existing_run_instance = self ._resolve_existing_run_instance (
132
+ cluster_id , project .project_id , existing_cloudspaces
133
+ )
134
+ cloudspace_name = self ._resolve_cloudspace_name (
135
+ cloudspace_config .name ,
136
+ existing_cloudspace ,
137
+ existing_cloudspaces ,
138
+ )
139
+ needs_credits = self ._resolve_needs_credits (project )
140
+
141
+ # Validation
142
+ # Note: We do not validate the repo here since open only uploads a directory if asked explicitly
143
+ self ._validate_cluster_id (cluster_id , project .project_id )
144
+
145
+ # Spec creation
146
+ run_body = self ._get_run_body (cluster_id , [], None , [], True , root , self .start_server )
147
+
148
+ if existing_run_instance is not None :
149
+ print (
150
+ f"Re-opening the CloudSpace { cloudspace_config .name } . "
151
+ "This operation will create a new run but will not overwrite the files in your CloudSpace."
152
+ )
153
+ else :
154
+ print (f"The name of the CloudSpace is: { cloudspace_config .name } " )
155
+
156
+ # API transactions
157
+ cloudspace_id = self ._api_create_cloudspace_if_not_exists (
158
+ project .project_id ,
159
+ cloudspace_name ,
160
+ existing_cloudspace ,
161
+ )
162
+ self ._api_stop_existing_run_instance (project .project_id , existing_run_instance )
163
+ run = self ._api_create_run (project .project_id , cloudspace_id , run_body )
164
+ self ._api_package_and_upload_repo (repo , run )
165
+
166
+ if getattr (run , "cluster_id" , None ):
167
+ print (f"Running on { run .cluster_id } " )
168
+
169
+ # TODO: We shouldn't need to create an instance here
170
+ if existing_run_instance is not None :
171
+ run_instance = self ._api_transfer_run_instance (
172
+ project .project_id ,
173
+ run .id ,
174
+ existing_run_instance .id ,
175
+ V1LightningappInstanceState .STOPPED ,
176
+ )
177
+ else :
178
+ run_instance = self ._api_create_run_instance (
179
+ cluster_id ,
180
+ project .project_id ,
181
+ cloudspace_name ,
182
+ cloudspace_id ,
183
+ run .id ,
184
+ V1LightningappInstanceState .STOPPED ,
185
+ )
186
+
187
+ if "PYTEST_CURRENT_TEST" not in os .environ :
188
+ click .launch (self ._get_app_url (run_instance , "code" , needs_credits ))
189
+
190
+ except ApiException as e :
191
+ logger .error (e .body )
192
+ sys .exit (1 )
193
+
109
194
def dispatch (
110
195
self ,
111
196
name : str = "" ,
@@ -116,10 +201,10 @@ def dispatch(
116
201
) -> None :
117
202
"""Method to dispatch and run the :class:`~lightning_app.core.app.LightningApp` in the cloud."""
118
203
# not user facing error ideally - this should never happen in normal user workflow
119
- if not self .entrypoint_file :
204
+ if not self .entrypoint :
120
205
raise ValueError (
121
206
"Entrypoint file not provided. Did you forget to "
122
- "initialize the Runtime object with `entrypoint_file ` argument?"
207
+ "initialize the Runtime object with `entrypoint ` argument?"
123
208
)
124
209
125
210
cleanup_handle = None
@@ -213,20 +298,20 @@ def dispatch(
213
298
env_vars ,
214
299
auth ,
215
300
)
301
+
302
+ if run_instance .status .phase == V1LightningappInstanceState .FAILED :
303
+ raise RuntimeError ("Failed to create the application. Cannot upload the source code." )
304
+
305
+ # TODO: Remove testing dependency, but this would open a tab for each test...
306
+ if open_ui and "PYTEST_CURRENT_TEST" not in os .environ :
307
+ click .launch (self ._get_app_url (run_instance , "logs" if run .is_headless else "web-ui" , needs_credits ))
216
308
except ApiException as e :
217
309
logger .error (e .body )
218
310
sys .exit (1 )
219
311
finally :
220
312
if cleanup_handle :
221
313
cleanup_handle ()
222
314
223
- if run_instance .status .phase == V1LightningappInstanceState .FAILED :
224
- raise RuntimeError ("Failed to create the application. Cannot upload the source code." )
225
-
226
- # TODO: Remove testing dependency, but this would open a tab for each test...
227
- if open_ui and "PYTEST_CURRENT_TEST" not in os .environ :
228
- click .launch (self ._get_app_url (run_instance , needs_credits ))
229
-
230
315
@classmethod
231
316
def load_app_from_file (cls , filepath : str ) -> "LightningApp" :
232
317
"""Load a LightningApp from a file, mocking the imports."""
@@ -248,36 +333,55 @@ def load_app_from_file(cls, filepath: str) -> "LightningApp":
248
333
del os .environ ["LAI_RUNNING_IN_CLOUD" ]
249
334
return app
250
335
251
- def _resolve_config (self , name : Optional [str ]) -> AppConfig :
336
+ def _resolve_config (self , name : Optional [str ], load : bool = True ) -> AppConfig :
252
337
"""Find and load the config file if it exists (otherwise create an empty config).
253
338
254
339
Override the name if provided.
255
340
"""
256
- config_file = _get_config_file (self .entrypoint_file )
257
- cloudspace_config = AppConfig .load_from_file (config_file ) if config_file .exists () else AppConfig ()
341
+ config_file = _get_config_file (self .entrypoint )
342
+ cloudspace_config = AppConfig .load_from_file (config_file ) if config_file .exists () and load else AppConfig ()
258
343
if name :
259
344
# Override the name if provided
260
345
cloudspace_config .name = name
261
346
return cloudspace_config
262
347
263
348
def _resolve_root (self ) -> Path :
264
349
"""Determine the root of the project."""
265
- return Path (self .entrypoint_file ).absolute ().parent
350
+ root = Path (self .entrypoint ).absolute ()
351
+ if root .is_file ():
352
+ root = root .parent
353
+ return root
354
+
355
+ def _resolve_open_ignore_functions (self ) -> List [_IGNORE_FUNCTION ]:
356
+ """Used by the ``open`` method.
266
357
267
- def _resolve_repo (self , root : Path ) -> LocalSourceCodeDir :
358
+ If the entrypoint is a file, return an ignore function that will ignore everything except that file so only the
359
+ file gets uploaded.
360
+ """
361
+ entrypoint = self .entrypoint .absolute ()
362
+ if entrypoint .is_file ():
363
+ return [lambda src , paths : [path for path in paths if path .absolute () == entrypoint ]]
364
+ return []
365
+
366
+ def _resolve_repo (
367
+ self ,
368
+ root : Path ,
369
+ ignore_functions : Optional [List [_IGNORE_FUNCTION ]] = None ,
370
+ ) -> LocalSourceCodeDir :
268
371
"""Gather and merge all lightningignores from the app children and create the ``LocalSourceCodeDir``
269
372
object."""
270
-
271
- flow_lightningignores = [flow .lightningignore for flow in self .app .flows ]
272
- work_lightningignores = [work .lightningignore for work in self .app .works ]
273
- lightningignores = flow_lightningignores + work_lightningignores
274
- if lightningignores :
275
- merged = sum (lightningignores , tuple ())
276
- logger .debug (f"Found the following lightningignores: { merged } " )
277
- patterns = _parse_lightningignore (merged )
278
- ignore_functions = [partial (_filter_ignored , root , patterns )]
279
- else :
280
- ignore_functions = None
373
+ if ignore_functions is None :
374
+ ignore_functions = []
375
+
376
+ if self .app is not None :
377
+ flow_lightningignores = [flow .lightningignore for flow in self .app .flows ]
378
+ work_lightningignores = [work .lightningignore for work in self .app .works ]
379
+ lightningignores = flow_lightningignores + work_lightningignores
380
+ if lightningignores :
381
+ merged = sum (lightningignores , tuple ())
382
+ logger .debug (f"Found the following lightningignores: { merged } " )
383
+ patterns = _parse_lightningignore (merged )
384
+ ignore_functions = [* ignore_functions , partial (_filter_ignored , root , patterns )]
281
385
282
386
return LocalSourceCodeDir (path = root , ignore_functions = ignore_functions )
283
387
@@ -562,7 +666,7 @@ def _get_run_body(
562
666
self ,
563
667
cluster_id : str ,
564
668
flow_servers : List [V1Flowserver ],
565
- network_configs : List [V1NetworkConfig ],
669
+ network_configs : Optional [ List [V1NetworkConfig ] ],
566
670
works : List [V1Work ],
567
671
no_cache : bool ,
568
672
root : Path ,
@@ -571,24 +675,28 @@ def _get_run_body(
571
675
"""Get the specification of the run creation request."""
572
676
# The entry point file needs to be relative to the root of the uploaded source file directory,
573
677
# because the backend will invoke the lightning commands relative said source directory
574
- app_entrypoint_file = Path (self .entrypoint_file ).absolute ().relative_to (root )
678
+ # TODO: we shouldn't set this if the entrypoint isn't a file but the backend gives an error if we don't
679
+ app_entrypoint_file = Path (self .entrypoint ).absolute ().relative_to (root )
575
680
576
681
run_body = CloudspaceIdRunsBody (
577
682
cluster_id = cluster_id ,
578
683
app_entrypoint_file = str (app_entrypoint_file ),
579
684
enable_app_server = start_server ,
580
685
flow_servers = flow_servers ,
581
686
network_config = network_configs ,
582
- user_requested_flow_compute_config = V1UserRequestedFlowComputeConfig (
583
- name = self .app .flow_cloud_compute .name ,
584
- shm_size = self .app .flow_cloud_compute .shm_size ,
585
- preemptible = False ,
586
- ),
587
687
works = works ,
588
688
local_source = True ,
589
- is_headless = _is_headless (self .app ),
590
689
)
591
690
691
+ if self .app is not None :
692
+ run_body .user_requested_flow_compute_config = V1UserRequestedFlowComputeConfig (
693
+ name = self .app .flow_cloud_compute .name ,
694
+ shm_size = self .app .flow_cloud_compute .shm_size ,
695
+ preemptible = False ,
696
+ )
697
+
698
+ run_body .is_headless = _is_headless (self .app )
699
+
592
700
# if requirements file at the root of the repository is present,
593
701
# we pass just the file name to the backend, so backend can find it in the relative path
594
702
requirements_file = root / "requirements.txt"
@@ -695,9 +803,9 @@ def _api_transfer_run_instance(
695
803
run_id : str ,
696
804
instance_id : str ,
697
805
desired_state : V1LightningappInstanceState ,
698
- queue_server_type : V1QueueServerType ,
699
- env_vars : List [V1EnvVar ],
700
- auth : V1LightningAuth ,
806
+ queue_server_type : Optional [ V1QueueServerType ] = None ,
807
+ env_vars : Optional [ List [V1EnvVar ]] = None ,
808
+ auth : Optional [ V1LightningAuth ] = None ,
701
809
) -> Externalv1LightningappInstance :
702
810
"""Transfer an existing instance to the given run ID and update its specification.
703
811
@@ -732,9 +840,9 @@ def _api_create_run_instance(
732
840
cloudspace_id : str ,
733
841
run_id : str ,
734
842
desired_state : V1LightningappInstanceState ,
735
- queue_server_type : V1QueueServerType ,
736
- env_vars : List [V1EnvVar ],
737
- auth : V1LightningAuth ,
843
+ queue_server_type : Optional [ V1QueueServerType ] = None ,
844
+ env_vars : Optional [ List [V1EnvVar ]] = None ,
845
+ auth : Optional [ V1LightningAuth ] = None ,
738
846
) -> Externalv1LightningappInstance :
739
847
"""Create a new instance of the given run with the given specification."""
740
848
return self .backend .client .cloud_space_service_create_lightning_run_instance (
@@ -775,7 +883,12 @@ def _print_specs(run_body: CloudspaceIdRunsBody, print_format: str) -> None:
775
883
requirements_path = getattr (getattr (run_body .image_spec , "dependency_file_info" , "" ), "path" , "" )
776
884
logger .info (f"requirements_path: { requirements_path } " )
777
885
778
- @staticmethod
779
- def _get_app_url (lightning_app_instance : Externalv1LightningappInstance , need_credits : bool = False ) -> str :
886
+ def _get_app_url (
887
+ self ,
888
+ run_instance : Externalv1LightningappInstance ,
889
+ tab : str ,
890
+ need_credits : bool = False ,
891
+ ) -> str :
892
+ user = self .backend .client .auth_service_get_user ()
780
893
action = "?action=add_credits" if need_credits else ""
781
- return f"{ get_lightning_cloud_url ()} /me /apps/{ lightning_app_instance .id } { action } "
894
+ return f"{ get_lightning_cloud_url ()} /{ user . username } /apps/{ run_instance .id } / { tab } { action } "
0 commit comments