diff --git a/src/lightning_app/CHANGELOG.md b/src/lightning_app/CHANGELOG.md index 1888fef86a5ae..5610c4adc7c4d 100644 --- a/src/lightning_app/CHANGELOG.md +++ b/src/lightning_app/CHANGELOG.md @@ -26,7 +26,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/). - Add a `JustPyFrontend` to ease UI creation with `https://github.com/justpy-org/justpy` ([#15002](https://github.com/Lightning-AI/lightning/pull/15002)) - Added a layout endpoint to the Rest API and enable to disable pulling or pushing to the state ([#15367](https://github.com/Lightning-AI/lightning/pull/15367) - Added support for functions for `configure_api` and `configure_commands` to be executed in the Rest API process ([#15098](https://github.com/Lightning-AI/lightning/pull/15098) - +- Added support to start lightning app on cloud without needing to install dependencies locally ([#15019](https://github.com/Lightning-AI/lightning/pull/15019) ### Changed diff --git a/src/lightning_app/runners/cloud.py b/src/lightning_app/runners/cloud.py index 0909f9d732eec..919f7548bc09c 100644 --- a/src/lightning_app/runners/cloud.py +++ b/src/lightning_app/runners/cloud.py @@ -4,6 +4,7 @@ import string import sys import time +import traceback from dataclasses import dataclass from pathlib import Path from typing import Any, Callable, List, Optional, Union @@ -60,6 +61,7 @@ from lightning_app.utilities.app_helpers import Logger from lightning_app.utilities.cloud import _get_project from lightning_app.utilities.dependency_caching import get_hash +from lightning_app.utilities.load_app import _prettifiy_exception, load_app_from_file from lightning_app.utilities.packaging.app_config import AppConfig, find_config_file from lightning_app.utilities.packaging.lightning_utils import _prepare_lightning_wheels_and_requirements from lightning_app.utilities.secrets import _names_to_ids @@ -463,6 +465,30 @@ def _project_has_sufficient_credits(self, project: V1Membership, app: Optional[L return balance >= 1 + @classmethod + def load_app_from_file(cls, filepath: str) -> "LightningApp": + """This is meant to use only locally for cloud runtime.""" + try: + app = load_app_from_file(filepath, raise_exception=True) + except ModuleNotFoundError: + # this is very generic exception. + logger.info("Could not load the app locally. Starting the app directly on the cloud.") + # we want to format the exception as if no frame was on top. + exp, val, tb = sys.exc_info() + listing = traceback.format_exception(exp, val, tb) + # remove the entry for the first frame + del listing[1] + from lightning_app.testing.helpers import EmptyFlow + + # Create a mocking app. + app = LightningApp(EmptyFlow()) + + except FileNotFoundError as e: + raise e + except Exception: + _prettifiy_exception(filepath) + return app + def _create_mount_drive_spec(work_name: str, mount: Mount) -> V1LightningworkDrives: if mount.protocol == "s3://": diff --git a/src/lightning_app/runners/runtime.py b/src/lightning_app/runners/runtime.py index 85958749c43ba..ad0eb1c6bcc8f 100644 --- a/src/lightning_app/runners/runtime.py +++ b/src/lightning_app/runners/runtime.py @@ -56,7 +56,7 @@ def dispatch( runtime_type = RuntimeType(runtime_type) runtime_cls: Type[Runtime] = runtime_type.get_runtime() - app = load_app_from_file(str(entrypoint_file)) + app = runtime_cls.load_app_from_file(str(entrypoint_file)) env_vars = {} if env_vars is None else env_vars secrets = {} if secrets is None else secrets @@ -151,3 +151,8 @@ def _add_stopped_status_to_work(self, work: "lightning_app.LightningWork") -> No latest_call_hash = work._calls[CacheCallsKeys.LATEST_CALL_HASH] if latest_call_hash in work._calls: work._calls[latest_call_hash]["statuses"].append(make_status(WorkStageStatus.STOPPED)) + + @classmethod + def load_app_from_file(cls, filepath: str) -> "LightningApp": + + return load_app_from_file(filepath) diff --git a/src/lightning_app/utilities/load_app.py b/src/lightning_app/utilities/load_app.py index 614944bc7e249..2182162f3e0c3 100644 --- a/src/lightning_app/utilities/load_app.py +++ b/src/lightning_app/utilities/load_app.py @@ -16,7 +16,28 @@ logger = Logger(__name__) -def load_app_from_file(filepath: str) -> "LightningApp": +def _prettifiy_exception(filepath: str): + """Pretty print the exception that occurred when loading the app.""" + # we want to format the exception as if no frame was on top. + exp, val, tb = sys.exc_info() + listing = traceback.format_exception(exp, val, tb) + # remove the entry for the first frame + del listing[1] + listing = [ + f"Found an exception when loading your application from {filepath}. Please, resolve it to run your app.\n\n" + ] + listing + logger.error("".join(listing)) + sys.exit(1) + + +def load_app_from_file(filepath: str, raise_exception: bool = False) -> "LightningApp": + """Load a LightningApp from a file. + + Arguments: + filepath: The path to the file containing the LightningApp. + raise_exception: If True, raise an exception if the app cannot be loaded. + """ + # Taken from StreamLit: https://github.com/streamlit/streamlit/blob/develop/lib/streamlit/script_runner.py#L313 from lightning_app.core.app import LightningApp @@ -30,17 +51,10 @@ def load_app_from_file(filepath: str) -> "LightningApp": try: with _patch_sys_argv(): exec(code, module.__dict__) - except Exception: - # we want to format the exception as if no frame was on top. - exp, val, tb = sys.exc_info() - listing = traceback.format_exception(exp, val, tb) - # remove the entry for the first frame - del listing[1] - listing = [ - f"Found an exception when loading your application from {filepath}. Please, resolve it to run your app.\n\n" - ] + listing - logger.error("".join(listing)) - sys.exit(1) + except Exception as e: + if raise_exception: + raise e + _prettifiy_exception(filepath) apps = [v for v in module.__dict__.values() if isinstance(v, LightningApp)] if len(apps) > 1: diff --git a/tests/tests_app/runners/test_cloud.py b/tests/tests_app/runners/test_cloud.py index 41b2a379501b2..50be1ea32ccfb 100644 --- a/tests/tests_app/runners/test_cloud.py +++ b/tests/tests_app/runners/test_cloud.py @@ -1,4 +1,5 @@ import logging +import os from copy import copy from pathlib import Path from unittest import mock @@ -36,9 +37,10 @@ V1Work, ) -from lightning_app import LightningApp, LightningWork -from lightning_app.runners import backends, cloud +from lightning_app import _PROJECT_ROOT, LightningApp, LightningWork +from lightning_app.runners import backends, cloud, CloudRuntime from lightning_app.storage import Drive, Mount +from lightning_app.testing.helpers import EmptyFlow from lightning_app.utilities.cloud import _get_project from lightning_app.utilities.dependency_caching import get_hash from lightning_app.utilities.packaging.cloud_compute import CloudCompute @@ -1082,3 +1084,13 @@ def test_project_has_sufficient_credits(): for balance, result in credits_and_test_value: project = V1Membership(name="test-project1", project_id="test-project-id1", balance=balance) assert cloud_runtime._project_has_sufficient_credits(project) is result + + +@mock.patch( + "lightning_app.runners.cloud.load_app_from_file", + MagicMock(side_effect=ModuleNotFoundError("Module X not found")), +) +def test_load_app_from_file_module_error(): + empty_app = CloudRuntime.load_app_from_file(os.path.join(_PROJECT_ROOT, "examples", "app_v0", "app.py")) + assert isinstance(empty_app, LightningApp) + assert isinstance(empty_app.root, EmptyFlow)