Skip to content

Commit f57160b

Browse files
ethanwharrismanskx
andauthored
[App] Mock missing package imports when launching in the cloud (#15711)
Co-authored-by: manskx <[email protected]>
1 parent befd3f6 commit f57160b

File tree

8 files changed

+108
-24
lines changed

8 files changed

+108
-24
lines changed

src/lightning_app/CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/).
1818
- Added a friendly error message when attempting to run the default cloud compute with a custom base image configured ([#14929](https://github.com/Lightning-AI/lightning/pull/14929))
1919

2020

21+
- Improved support for running apps when dependencies aren't installed ([#15711](https://github.com/Lightning-AI/lightning/pull/15711))
22+
23+
2124
### Changed
2225

2326
-

src/lightning_app/runners/cloud.py

Lines changed: 8 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
import string
55
import sys
66
import time
7-
import traceback
87
from dataclasses import dataclass
98
from pathlib import Path
109
from typing import Any, Callable, List, Optional, Union
@@ -63,7 +62,7 @@
6362
from lightning_app.utilities.app_helpers import Logger
6463
from lightning_app.utilities.cloud import _get_project
6564
from lightning_app.utilities.dependency_caching import get_hash
66-
from lightning_app.utilities.load_app import _prettifiy_exception, load_app_from_file
65+
from lightning_app.utilities.load_app import load_app_from_file
6766
from lightning_app.utilities.packaging.app_config import _get_config_file, AppConfig
6867
from lightning_app.utilities.packaging.lightning_utils import _prepare_lightning_wheels_and_requirements
6968
from lightning_app.utilities.secrets import _names_to_ids
@@ -475,26 +474,17 @@ def _project_has_sufficient_credits(self, project: V1Membership, app: Optional[L
475474

476475
@classmethod
477476
def load_app_from_file(cls, filepath: str) -> "LightningApp":
478-
"""This is meant to use only locally for cloud runtime."""
477+
"""Load a LightningApp from a file, mocking the imports."""
479478
try:
480-
app = load_app_from_file(filepath, raise_exception=True)
481-
except ModuleNotFoundError:
482-
# this is very generic exception.
483-
logger.info("Could not load the app locally. Starting the app directly on the cloud.")
484-
# we want to format the exception as if no frame was on top.
485-
exp, val, tb = sys.exc_info()
486-
listing = traceback.format_exception(exp, val, tb)
487-
# remove the entry for the first frame
488-
del listing[1]
489-
from lightning_app.testing.helpers import EmptyFlow
490-
491-
# Create a mocking app.
492-
app = LightningApp(EmptyFlow())
493-
479+
app = load_app_from_file(filepath, raise_exception=True, mock_imports=True)
494480
except FileNotFoundError as e:
495481
raise e
496482
except Exception:
497-
_prettifiy_exception(filepath)
483+
from lightning_app.testing.helpers import EmptyFlow
484+
485+
# Create a generic app.
486+
logger.info("Could not load the app locally. Starting the app directly on the cloud.")
487+
app = LightningApp(EmptyFlow())
498488
return app
499489

500490

src/lightning_app/testing/testing.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -361,7 +361,7 @@ def run_app_in_cloud(
361361
except playwright._impl._api_types.TimeoutError:
362362
print("'Create Project' dialog not visible, skipping.")
363363

364-
admin_page.locator(f"text={name}").click()
364+
admin_page.locator(f"role=link[name='{name}']").click()
365365
sleep(5)
366366
# Scroll to the bottom of the page. Used to capture all logs.
367367
admin_page.evaluate(

src/lightning_app/utilities/app_helpers.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import abc
22
import asyncio
3+
import builtins
34
import enum
45
import functools
56
import inspect
@@ -10,9 +11,11 @@
1011
import threading
1112
import time
1213
from abc import ABC, abstractmethod
14+
from contextlib import contextmanager
1315
from copy import deepcopy
1416
from dataclasses import dataclass, field
1517
from typing import Any, Callable, Dict, Generator, List, Mapping, Optional, Tuple, Type, TYPE_CHECKING
18+
from unittest.mock import MagicMock
1619

1720
import websockets
1821
from deepdiff import Delta
@@ -486,6 +489,29 @@ def _load_state_dict(root_flow: "LightningFlow", state: Dict[str, Any], strict:
486489
raise Exception(f"The component {component_name} was re-created during state reloading.")
487490

488491

492+
class _MagicMockJsonSerializable(MagicMock):
493+
@staticmethod
494+
def __json__():
495+
return "{}"
496+
497+
498+
def _mock_import(*args, original_fn=None):
499+
try:
500+
return original_fn(*args)
501+
except Exception:
502+
return _MagicMockJsonSerializable()
503+
504+
505+
@contextmanager
506+
def _mock_missing_imports():
507+
original_fn = builtins.__import__
508+
builtins.__import__ = functools.partial(_mock_import, original_fn=original_fn)
509+
try:
510+
yield
511+
finally:
512+
builtins.__import__ = original_fn
513+
514+
489515
def is_static_method(klass_or_instance, attr) -> bool:
490516
return isinstance(inspect.getattr_static(klass_or_instance, attr), staticmethod)
491517

src/lightning_app/utilities/layout.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import lightning_app
66
from lightning_app.frontend.frontend import Frontend
7+
from lightning_app.utilities.app_helpers import _MagicMockJsonSerializable
78
from lightning_app.utilities.cloud import is_running_in_cloud
89

910

@@ -39,6 +40,9 @@ def _collect_layout(app: "lightning_app.LightningApp", flow: "lightning_app.Ligh
3940
# When running locally, the target will get overwritten by the dispatcher when launching the frontend servers
4041
# When running in the cloud, the frontend code will construct the URL based on the flow name
4142
return flow._layout
43+
elif isinstance(layout, _MagicMockJsonSerializable):
44+
# Do nothing
45+
pass
4246
elif isinstance(layout, dict):
4347
layout = _collect_content_layout([layout], flow)
4448
elif isinstance(layout, (list, tuple)) and all(isinstance(item, dict) for item in layout):
@@ -103,6 +107,9 @@ def _collect_content_layout(layout: List[Dict], flow: "lightning_app.LightningFl
103107
else:
104108
entry["content"] = ""
105109
entry["target"] = ""
110+
elif isinstance(entry["content"], _MagicMockJsonSerializable):
111+
# Do nothing
112+
pass
106113
else:
107114
m = f"""
108115
A dictionary returned by `{flow.__class__.__name__}.configure_layout()` contains an unsupported entry.

src/lightning_app/utilities/load_app.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,15 @@
44
import traceback
55
import types
66
from contextlib import contextmanager
7+
from copy import copy
78
from typing import Dict, List, TYPE_CHECKING, Union
89

910
from lightning_app.utilities.exceptions import MisconfigurationException
1011

1112
if TYPE_CHECKING:
1213
from lightning_app import LightningApp, LightningFlow, LightningWork
1314

14-
from lightning_app.utilities.app_helpers import Logger
15+
from lightning_app.utilities.app_helpers import _mock_missing_imports, Logger
1516

1617
logger = Logger(__name__)
1718

@@ -30,7 +31,7 @@ def _prettifiy_exception(filepath: str):
3031
sys.exit(1)
3132

3233

33-
def load_app_from_file(filepath: str, raise_exception: bool = False) -> "LightningApp":
34+
def load_app_from_file(filepath: str, raise_exception: bool = False, mock_imports: bool = False) -> "LightningApp":
3435
"""Load a LightningApp from a file.
3536
3637
Arguments:
@@ -50,7 +51,11 @@ def load_app_from_file(filepath: str, raise_exception: bool = False) -> "Lightni
5051
module = _create_fake_main_module(filepath)
5152
try:
5253
with _patch_sys_argv():
53-
exec(code, module.__dict__)
54+
if mock_imports:
55+
with _mock_missing_imports():
56+
exec(code, module.__dict__)
57+
else:
58+
exec(code, module.__dict__)
5459
except Exception as e:
5560
if raise_exception:
5661
raise e
@@ -140,7 +145,7 @@ def _patch_sys_argv():
140145
"""
141146
from lightning_app.cli.lightning_cli import run_app
142147

143-
original_argv = sys.argv
148+
original_argv = copy(sys.argv)
144149
# 1: Remove the CLI command
145150
if sys.argv[:3] == ["lightning", "run", "app"]:
146151
sys.argv = sys.argv[3:]

tests/tests_app/runners/test_cloud.py

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import logging
22
import os
3+
import sys
34
from copy import copy
45
from pathlib import Path
56
from unittest import mock
@@ -43,7 +44,7 @@
4344
from lightning_app.runners import backends, cloud, CloudRuntime
4445
from lightning_app.runners.cloud import _validate_build_spec_and_compute
4546
from lightning_app.storage import Drive, Mount
46-
from lightning_app.testing.helpers import EmptyFlow
47+
from lightning_app.testing.helpers import EmptyFlow, EmptyWork
4748
from lightning_app.utilities.cloud import _get_project
4849
from lightning_app.utilities.dependency_caching import get_hash
4950
from lightning_app.utilities.packaging.cloud_compute import CloudCompute
@@ -1230,6 +1231,57 @@ def test_load_app_from_file_module_error():
12301231
assert isinstance(empty_app.root, EmptyFlow)
12311232

12321233

1234+
@pytest.mark.parametrize(
1235+
"lines",
1236+
[
1237+
[
1238+
"import this_package_is_not_real",
1239+
"from lightning_app import LightningApp",
1240+
"from lightning_app.testing.helpers import EmptyWork",
1241+
"app = LightningApp(EmptyWork())",
1242+
],
1243+
[
1244+
"from this_package_is_not_real import this_module_is_not_real",
1245+
"from lightning_app import LightningApp",
1246+
"from lightning_app.testing.helpers import EmptyWork",
1247+
"app = LightningApp(EmptyWork())",
1248+
],
1249+
[
1250+
"import this_package_is_not_real",
1251+
"from this_package_is_not_real import this_module_is_not_real",
1252+
"from lightning_app import LightningApp",
1253+
"from lightning_app.testing.helpers import EmptyWork",
1254+
"app = LightningApp(EmptyWork())",
1255+
],
1256+
[
1257+
"import this_package_is_not_real",
1258+
"from lightning_app import LightningApp",
1259+
"from lightning_app.core.flow import _RootFlow",
1260+
"from lightning_app.testing.helpers import EmptyWork",
1261+
"class MyFlow(_RootFlow):",
1262+
" def configure_layout(self):",
1263+
" return [{'name': 'test', 'content': this_package_is_not_real()}]",
1264+
"app = LightningApp(MyFlow(EmptyWork()))",
1265+
],
1266+
],
1267+
)
1268+
@pytest.mark.skipif(sys.platform != "linux", reason="Causing conflicts on non-linux")
1269+
def test_load_app_from_file_mock_imports(tmpdir, lines):
1270+
path = copy(sys.path)
1271+
app_file = os.path.join(tmpdir, "app.py")
1272+
1273+
with open(app_file, "w") as f:
1274+
f.write("\n".join(lines))
1275+
1276+
app = CloudRuntime.load_app_from_file(app_file)
1277+
assert isinstance(app, LightningApp)
1278+
assert isinstance(app.root.work, EmptyWork)
1279+
1280+
# Cleanup PATH to prevent conflict with other tests
1281+
sys.path = path
1282+
os.remove(app_file)
1283+
1284+
12331285
def test_incompatible_cloud_compute_and_build_config():
12341286
"""Test that an exception is raised when a build config has a custom image defined, but the cloud compute is
12351287
the default.

tests/tests_app/utilities/test_proxies.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ def proxy_setattr():
6767

6868
@pytest.mark.parametrize("parallel", [True, False])
6969
@pytest.mark.parametrize("cache_calls", [False, True])
70+
@pytest.mark.skipif(sys.platform == "win32", reason="TODO (@ethanwharris): Fix this on Windows")
7071
def test_work_runner(parallel, cache_calls):
7172
"""This test validates the `WorkRunner` runs the work.run method and properly populates the `delta_queue`,
7273
`error_queue` and `readiness_queue`."""

0 commit comments

Comments
 (0)