diff --git a/.azure/app-cloud-e2e.yml b/.azure/app-cloud-e2e.yml index 971210d840fe0..821ffc8c426f1 100644 --- a/.azure/app-cloud-e2e.yml +++ b/.azure/app-cloud-e2e.yml @@ -93,7 +93,7 @@ jobs: 'App: custom_work_dependencies': name: "custom_work_dependencies" dir: "local" - timeoutInMinutes: "10" + timeoutInMinutes: "15" cancelTimeoutInMinutes: "1" # values: https://docs.microsoft.com/en-us/azure/devops/pipelines/process/phases?view=azure-devops&tabs=yaml#workspace workspace: diff --git a/requirements/app/base.txt b/requirements/app/base.txt index 5c39e9ddf4f74..872fa2f4c84b1 100644 --- a/requirements/app/base.txt +++ b/requirements/app/base.txt @@ -1,4 +1,4 @@ -lightning-cloud>=0.5.11 +lightning-cloud>=0.5.12 packaging typing-extensions>=4.0.0, <=4.4.0 deepdiff>=5.7.0, <=5.8.1 diff --git a/src/lightning_app/CHANGELOG.md b/src/lightning_app/CHANGELOG.md index b7e01e77224cb..ccabdc4079c1c 100644 --- a/src/lightning_app/CHANGELOG.md +++ b/src/lightning_app/CHANGELOG.md @@ -19,8 +19,13 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/). ### Changed - The `MultiNode` components now warn the user when running with `num_nodes > 1` locally ([#15806](https://github.com/Lightning-AI/lightning/pull/15806)) + - Cluster creation and deletion now waits by default [#15458](https://github.com/Lightning-AI/lightning/pull/15458) +- Running an app without a UI locally no longer opens the browser ([#15875](https://github.com/Lightning-AI/lightning/pull/15875)) + +- Apps without UIs no longer activate the "Open App" button when running in the cloud ([#15875](https://github.com/Lightning-AI/lightning/pull/15875)) + ### Deprecated diff --git a/src/lightning_app/cli/lightning_cli.py b/src/lightning_app/cli/lightning_cli.py index 3474fa733ffbe..8fcf7a7f10dc1 100644 --- a/src/lightning_app/cli/lightning_cli.py +++ b/src/lightning_app/cli/lightning_cli.py @@ -2,13 +2,13 @@ import shutil import sys from pathlib import Path -from typing import Any, Tuple, Union +from typing import Tuple, Union import arrow import click import inquirer import rich -from lightning_cloud.openapi import Externalv1LightningappInstance, V1LightningappInstanceState, V1LightningworkState +from lightning_cloud.openapi import V1LightningappInstanceState, V1LightningworkState from lightning_cloud.openapi.rest import ApiException from lightning_utilities.core.imports import RequirementCache from requests.exceptions import ConnectionError @@ -48,15 +48,6 @@ logger = Logger(__name__) -def get_app_url(runtime_type: RuntimeType, *args: Any, need_credits: bool = False) -> str: - if runtime_type == RuntimeType.CLOUD: - lit_app: Externalv1LightningappInstance = args[0] - action = "?action=add_credits" if need_credits else "" - return f"{get_lightning_cloud_url()}/me/apps/{lit_app.id}{action}" - else: - return os.getenv("APP_SERVER_HOST", "http://127.0.0.1:7501/view") - - def main() -> None: # Check environment and versions if not in the cloud if "LIGHTNING_APP_STATE_URL" not in os.environ: @@ -269,10 +260,6 @@ def _run_app( secrets = _format_input_env_variables(secret) - def on_before_run(*args: Any, **kwargs: Any) -> None: - if open_ui and not without_server: - click.launch(get_app_url(runtime_type, *args, **kwargs)) - click.echo("Your Lightning App is starting. This won't take long.") # TODO: Fixme when Grid utilities are available. @@ -284,7 +271,7 @@ def on_before_run(*args: Any, **kwargs: Any) -> None: start_server=not without_server, no_cache=no_cache, blocking=blocking, - on_before_run=on_before_run, + open_ui=open_ui, name=name, env_vars=env_vars, secrets=secrets, diff --git a/src/lightning_app/core/app.py b/src/lightning_app/core/app.py index ce1e81d57d9ea..377a6a0d9e220 100644 --- a/src/lightning_app/core/app.py +++ b/src/lightning_app/core/app.py @@ -29,6 +29,8 @@ from lightning_app.utilities import frontend from lightning_app.utilities.app_helpers import ( _delta_to_app_state_delta, + _handle_is_headless, + _is_headless, _LightningAppRef, _should_dispatch_app, Logger, @@ -148,6 +150,8 @@ def __init__( self._update_layout() + self.is_headless: Optional[bool] = None + self._original_state = None self._last_state = self.state self.state_accumulate_wait = STATE_ACCUMULATE_WAIT @@ -412,6 +416,7 @@ def run_once(self): self.backend.update_work_statuses(self.works) self._update_layout() + self._update_is_headless() self.maybe_apply_changes() if self.checkpointing and self._should_snapshot(): @@ -510,6 +515,16 @@ def _update_layout(self) -> None: layout = _collect_layout(self, component) component._layout = layout + def _update_is_headless(self) -> None: + is_headless = _is_headless(self) + + # If `is_headless` changed, handle it. + # This ensures support for apps which dynamically add a UI at runtime. + if self.is_headless != is_headless: + self.is_headless = is_headless + + _handle_is_headless(self) + def _apply_restarting(self) -> bool: self._reset_original_state() # apply stage after restoring the original state. diff --git a/src/lightning_app/runners/cloud.py b/src/lightning_app/runners/cloud.py index d3613975f6076..6ef7770124aae 100644 --- a/src/lightning_app/runners/cloud.py +++ b/src/lightning_app/runners/cloud.py @@ -7,8 +7,9 @@ from dataclasses import dataclass from pathlib import Path from textwrap import dedent -from typing import Any, Callable, List, Optional, Union +from typing import Any, List, Optional, Union +import click from lightning_cloud.openapi import ( Body3, Body4, @@ -56,12 +57,13 @@ ENABLE_MULTIPLE_WORKS_IN_NON_DEFAULT_CONTAINER, ENABLE_PULLING_STATE_ENDPOINT, ENABLE_PUSHING_STATE_ENDPOINT, + get_lightning_cloud_url, ) from lightning_app.runners.backends.cloud import CloudBackend from lightning_app.runners.runtime import Runtime from lightning_app.source_code import LocalSourceCodeDir from lightning_app.storage import Drive, Mount -from lightning_app.utilities.app_helpers import Logger +from lightning_app.utilities.app_helpers import _is_headless, 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 load_app_from_file @@ -192,9 +194,9 @@ class CloudRuntime(Runtime): def dispatch( self, - on_before_run: Optional[Callable] = None, name: str = "", cluster_id: str = None, + open_ui: bool = True, **kwargs: Any, ) -> None: """Method to dispatch and run the :class:`~lightning_app.core.app.LightningApp` in the cloud.""" @@ -405,6 +407,7 @@ def dispatch( local_source=True, dependency_cache_key=app_spec.dependency_cache_key, user_requested_flow_compute_config=app_spec.user_requested_flow_compute_config, + is_headless=_is_headless(self.app), ) # create / upload the new app release @@ -464,12 +467,12 @@ def dispatch( logger.error(e.body) sys.exit(1) - if on_before_run: - on_before_run(lightning_app_instance, need_credits=not has_sufficient_credits) - if lightning_app_instance.status.phase == V1LightningappInstanceState.FAILED: raise RuntimeError("Failed to create the application. Cannot upload the source code.") + if open_ui: + click.launch(self._get_app_url(lightning_app_instance, not has_sufficient_credits)) + if cleanup_handle: cleanup_handle() @@ -538,6 +541,11 @@ def load_app_from_file(cls, filepath: str) -> "LightningApp": app = LightningApp(EmptyFlow()) return app + @staticmethod + def _get_app_url(lightning_app_instance: Externalv1LightningappInstance, need_credits: bool = False) -> str: + action = "?action=add_credits" if need_credits else "" + return f"{get_lightning_cloud_url()}/me/apps/{lightning_app_instance.id}{action}" + def _create_mount_drive_spec(work_name: str, mount: Mount) -> V1LightningworkDrives: if mount.protocol == "s3://": diff --git a/src/lightning_app/runners/multiprocess.py b/src/lightning_app/runners/multiprocess.py index 343996cbdd732..673e8601043d7 100644 --- a/src/lightning_app/runners/multiprocess.py +++ b/src/lightning_app/runners/multiprocess.py @@ -1,7 +1,9 @@ import multiprocessing import os from dataclasses import dataclass -from typing import Any, Callable, Optional, Union +from typing import Any, Union + +import click from lightning_app.api.http_methods import _add_tags_to_api, _validate_api from lightning_app.core.api import start_server @@ -9,7 +11,7 @@ from lightning_app.runners.backends import Backend from lightning_app.runners.runtime import Runtime from lightning_app.storage.orchestrator import StorageOrchestrator -from lightning_app.utilities.app_helpers import is_overridden +from lightning_app.utilities.app_helpers import _is_headless, is_overridden from lightning_app.utilities.commands.base import _commands_to_api, _prepare_commands from lightning_app.utilities.component import _set_flow_context, _set_frontend_context from lightning_app.utilities.load_app import extract_metadata_from_app @@ -29,7 +31,7 @@ class MultiProcessRuntime(Runtime): backend: Union[str, Backend] = "multiprocessing" _has_triggered_termination: bool = False - def dispatch(self, *args: Any, on_before_run: Optional[Callable] = None, **kwargs: Any): + def dispatch(self, *args: Any, open_ui: bool = True, **kwargs: Any): """Method to dispatch and run the LightningApp.""" try: _set_flow_context() @@ -101,8 +103,8 @@ def dispatch(self, *args: Any, on_before_run: Optional[Callable] = None, **kwarg # wait for server to be ready has_started_queue.get() - if on_before_run: - on_before_run(self, self.app) + if open_ui and not _is_headless(self.app): + click.launch(self._get_app_url()) # Connect the runtime to the application. self.app.connect(self) @@ -125,3 +127,7 @@ def terminate(self): for port in ports: disable_port(port) super().terminate() + + @staticmethod + def _get_app_url() -> str: + return os.getenv("APP_SERVER_HOST", "http://127.0.0.1:7501/view") diff --git a/src/lightning_app/runners/runtime.py b/src/lightning_app/runners/runtime.py index b892e27f8ae93..ab552f136fde0 100644 --- a/src/lightning_app/runners/runtime.py +++ b/src/lightning_app/runners/runtime.py @@ -4,7 +4,7 @@ from dataclasses import dataclass, field from pathlib import Path from threading import Thread -from typing import Any, Callable, Dict, List, Optional, Type, TYPE_CHECKING, Union +from typing import Any, Dict, List, Optional, Type, TYPE_CHECKING, Union from lightning_app import LightningApp, LightningFlow from lightning_app.core.constants import APP_SERVER_HOST, APP_SERVER_PORT @@ -28,7 +28,7 @@ def dispatch( host: str = APP_SERVER_HOST, port: int = APP_SERVER_PORT, blocking: bool = True, - on_before_run: Optional[Callable] = None, + open_ui: bool = True, name: str = "", env_vars: Dict[str, str] = None, secrets: Dict[str, str] = None, @@ -45,7 +45,7 @@ def dispatch( host: Server host address port: Server port blocking: Whether for the wait for the UI to start running. - on_before_run: Callable to be executed before run. + open_ui: Whether to open the UI in the browser. name: Name of app execution env_vars: Dict of env variables to be set on the app secrets: Dict of secrets to be passed as environment variables to the app @@ -82,7 +82,7 @@ def dispatch( ) # a cloud dispatcher will return the result while local # dispatchers will be running the app in the main process - return runtime.dispatch(on_before_run=on_before_run, name=name, no_cache=no_cache, cluster_id=cluster_id) + return runtime.dispatch(open_ui=open_ui, name=name, no_cache=no_cache, cluster_id=cluster_id) @dataclass diff --git a/src/lightning_app/runners/singleprocess.py b/src/lightning_app/runners/singleprocess.py index b12b86e8625aa..61a67ce9ba904 100644 --- a/src/lightning_app/runners/singleprocess.py +++ b/src/lightning_app/runners/singleprocess.py @@ -1,9 +1,13 @@ import multiprocessing as mp -from typing import Any, Callable, Optional +import os +from typing import Any + +import click from lightning_app.core.api import start_server from lightning_app.core.queues import QueuingSystem from lightning_app.runners.runtime import Runtime +from lightning_app.utilities.app_helpers import _is_headless from lightning_app.utilities.load_app import extract_metadata_from_app @@ -13,7 +17,7 @@ class SingleProcessRuntime(Runtime): def __post_init__(self): pass - def dispatch(self, *args, on_before_run: Optional[Callable] = None, **kwargs: Any): + def dispatch(self, *args, open_ui: bool = True, **kwargs: Any): """Method to dispatch and run the LightningApp.""" queue = QueuingSystem.SINGLEPROCESS @@ -42,8 +46,8 @@ def dispatch(self, *args, on_before_run: Optional[Callable] = None, **kwargs: An # wait for server to be ready. has_started_queue.get() - if on_before_run: - on_before_run() + if open_ui and not _is_headless(self.app): + click.launch(self._get_app_url()) try: self.app._run() @@ -52,3 +56,7 @@ def dispatch(self, *args, on_before_run: Optional[Callable] = None, **kwargs: An raise finally: self.terminate() + + @staticmethod + def _get_app_url() -> str: + return os.getenv("APP_SERVER_HOST", "http://127.0.0.1:7501/view") diff --git a/src/lightning_app/testing/testing.py b/src/lightning_app/testing/testing.py index 2ce426ecf109e..3e09012ac431c 100644 --- a/src/lightning_app/testing/testing.py +++ b/src/lightning_app/testing/testing.py @@ -14,6 +14,7 @@ from typing import Any, Callable, Dict, Generator, List, Optional, Type import requests +from lightning_cloud.openapi import V1LightningappInstanceState from lightning_cloud.openapi.rest import ApiException from requests import Session from rich import print @@ -394,15 +395,34 @@ def run_app_in_cloud( process = Process(target=_print_logs, kwargs={"app_id": app_id}) process.start() - while True: - try: - with admin_page.context.expect_page() as page_catcher: - admin_page.locator('[data-cy="open"]').click() - view_page = page_catcher.value - view_page.wait_for_load_state(timeout=0) - break - except (playwright._impl._api_types.Error, playwright._impl._api_types.TimeoutError): - pass + if not app.spec.is_headless: + while True: + try: + with admin_page.context.expect_page() as page_catcher: + admin_page.locator('[data-cy="open"]').click() + view_page = page_catcher.value + view_page.wait_for_load_state(timeout=0) + break + except (playwright._impl._api_types.Error, playwright._impl._api_types.TimeoutError): + pass + else: + view_page = None + + # Wait until the app is running + while True: + sleep(1) + + lit_apps = [ + app + for app in client.lightningapp_instance_service_list_lightningapp_instances( + project_id=project.project_id + ).lightningapps + if app.name == name + ] + app = lit_apps[0] + + if app.status.phase == V1LightningappInstanceState.RUNNING: + break # TODO: is re-creating this redundant? lit_apps = [ diff --git a/src/lightning_app/utilities/app_helpers.py b/src/lightning_app/utilities/app_helpers.py index d63e33db6addb..a000af3e71fe6 100644 --- a/src/lightning_app/utilities/app_helpers.py +++ b/src/lightning_app/utilities/app_helpers.py @@ -19,9 +19,11 @@ import websockets from deepdiff import Delta +from lightning_cloud.openapi import AppinstancesIdBody, Externalv1LightningappInstance import lightning_app from lightning_app.utilities.exceptions import LightningAppStateException +from lightning_app.utilities.tree import breadth_first if TYPE_CHECKING: from lightning_app.core.app import LightningApp @@ -527,3 +529,51 @@ def _should_dispatch_app() -> bool: and not bool(int(os.getenv("LIGHTNING_DISPATCHED", "0"))) and "LIGHTNING_APP_STATE_URL" not in os.environ ) + + +def _is_headless(app: "LightningApp") -> bool: + """Utility which returns True if the given App has no ``Frontend`` objects or URLs exposed through + ``configure_layout``.""" + if app.frontends: + return False + for component in breadth_first(app.root, types=(lightning_app.LightningFlow,)): + for entry in component._layout: + if "target" in entry: + return False + return True + + +def _handle_is_headless(app: "LightningApp"): + """Utility for runtime-specific handling of changes to the ``is_headless`` property.""" + app_id = os.getenv("LIGHTNING_CLOUD_APP_ID", None) + project_id = os.getenv("LIGHTNING_CLOUD_PROJECT_ID", None) + + if app_id is None or project_id is None: + return + + from lightning_app.utilities.network import LightningClient + + client = LightningClient() + list_apps_response = client.lightningapp_instance_service_list_lightningapp_instances(project_id=project_id) + + current_lightningapp_instance: Optional[Externalv1LightningappInstance] = None + for lightningapp_instance in list_apps_response.lightningapps: + if lightningapp_instance.id == app_id: + current_lightningapp_instance = lightningapp_instance + break + + if not current_lightningapp_instance: + raise RuntimeError( + "App was not found. Please open an issue at https://github.com/lightning-AI/lightning/issues." + ) + + if current_lightningapp_instance.spec.is_headless == app.is_headless: + return + + current_lightningapp_instance.spec.is_headless = app.is_headless + + client.lightningapp_instance_service_update_lightningapp_instance( + project_id=project_id, + id=current_lightningapp_instance.id, + body=AppinstancesIdBody(name=current_lightningapp_instance.name, spec=current_lightningapp_instance.spec), + ) diff --git a/src/lightning_app/utilities/layout.py b/src/lightning_app/utilities/layout.py index 9235993ad31d1..15079fcb6964b 100644 --- a/src/lightning_app/utilities/layout.py +++ b/src/lightning_app/utilities/layout.py @@ -41,8 +41,9 @@ def _collect_layout(app: "lightning_app.LightningApp", flow: "lightning_app.Ligh # When running in the cloud, the frontend code will construct the URL based on the flow name return flow._layout elif isinstance(layout, _MagicMockJsonSerializable): - # Do nothing - pass + # The import was mocked, we set a dummy `Frontend` so that `is_headless` knows there is a UI + app.frontends.setdefault(flow.name, "mock") + return flow._layout elif isinstance(layout, dict): layout = _collect_content_layout([layout], flow) elif isinstance(layout, (list, tuple)) and all(isinstance(item, dict) for item in layout): @@ -108,8 +109,9 @@ def _collect_content_layout(layout: List[Dict], flow: "lightning_app.LightningFl entry["content"] = "" entry["target"] = "" elif isinstance(entry["content"], _MagicMockJsonSerializable): - # Do nothing - pass + # The import was mocked, we just record dummy content so that `is_headless` knows there is a UI + entry["content"] = "mock" + entry["target"] = "mock" else: m = f""" A dictionary returned by `{flow.__class__.__name__}.configure_layout()` contains an unsupported entry. diff --git a/tests/tests_app/cli/test_cli.py b/tests/tests_app/cli/test_cli.py index 49f4f4c2df389..b33db38532785 100644 --- a/tests/tests_app/cli/test_cli.py +++ b/tests/tests_app/cli/test_cli.py @@ -4,45 +4,15 @@ import pytest from click.testing import CliRunner -from lightning_cloud.openapi import Externalv1LightningappInstance from lightning_app import __version__ -from lightning_app.cli.lightning_cli import _main, get_app_url, login, logout, run +from lightning_app.cli.lightning_cli import _main, login, logout, run from lightning_app.cli.lightning_cli_create import create, create_cluster from lightning_app.cli.lightning_cli_delete import delete, delete_cluster from lightning_app.cli.lightning_cli_list import get_list, list_apps, list_clusters -from lightning_app.runners.runtime_type import RuntimeType from lightning_app.utilities.exceptions import _ApiExceptionHandler -@pytest.mark.parametrize( - "runtime_type, extra_args, lightning_cloud_url, expected_url", - [ - ( - RuntimeType.CLOUD, - (Externalv1LightningappInstance(id="test-app-id"),), - "https://b975913c4b22eca5f0f9e8eff4c4b1c315340a0d.staging.lightning.ai", - "https://b975913c4b22eca5f0f9e8eff4c4b1c315340a0d.staging.lightning.ai/me/apps/test-app-id", - ), - ( - RuntimeType.CLOUD, - (Externalv1LightningappInstance(id="test-app-id"),), - "http://localhost:9800", - "http://localhost:9800/me/apps/test-app-id", - ), - (RuntimeType.SINGLEPROCESS, tuple(), "", "http://127.0.0.1:7501/view"), - (RuntimeType.SINGLEPROCESS, tuple(), "http://localhost:9800", "http://127.0.0.1:7501/view"), - (RuntimeType.MULTIPROCESS, tuple(), "", "http://127.0.0.1:7501/view"), - (RuntimeType.MULTIPROCESS, tuple(), "http://localhost:9800", "http://127.0.0.1:7501/view"), - ], -) -def test_start_target_url(runtime_type, extra_args, lightning_cloud_url, expected_url): - with mock.patch( - "lightning_app.cli.lightning_cli.get_lightning_cloud_url", mock.MagicMock(return_value=lightning_cloud_url) - ): - assert get_app_url(runtime_type, *extra_args) == expected_url - - @pytest.mark.parametrize("command", [_main, run, get_list, create, delete]) def test_commands(command): runner = CliRunner() diff --git a/tests/tests_app/cli/test_run_app.py b/tests/tests_app/cli/test_run_app.py index f4d90c9ed58fc..184e7311dd6b4 100644 --- a/tests/tests_app/cli/test_run_app.py +++ b/tests/tests_app/cli/test_run_app.py @@ -105,7 +105,7 @@ def test_lightning_run_app_cloud(mock_dispatch: mock.MagicMock, open_ui, caplog, RuntimeType.CLOUD, start_server=True, blocking=False, - on_before_run=mock.ANY, + open_ui=open_ui, name="", no_cache=True, env_vars={"FOO": "bar"}, @@ -148,7 +148,7 @@ def test_lightning_run_app_cloud_with_run_app_commands(mock_dispatch: mock.Magic RuntimeType.CLOUD, start_server=True, blocking=False, - on_before_run=mock.ANY, + open_ui=open_ui, name="", no_cache=True, env_vars={"FOO": "bar"}, diff --git a/tests/tests_app/runners/test_cloud.py b/tests/tests_app/runners/test_cloud.py index 1f525341224eb..e89e1e8aa468d 100644 --- a/tests/tests_app/runners/test_cloud.py +++ b/tests/tests_app/runners/test_cloud.py @@ -13,6 +13,7 @@ Body8, Body9, Externalv1Cluster, + Externalv1LightningappInstance, Gridv1ImageSpec, IdGetBody, V1BuildSpec, @@ -84,6 +85,7 @@ def get_cloud_runtime_request_body(**kwargs) -> "Body8": default_request_body = dict( app_entrypoint_file=mock.ANY, enable_app_server=True, + is_headless=True, flow_servers=[], image_spec=None, works=[], @@ -284,6 +286,7 @@ def test_run_on_byoc_cluster(self, monkeypatch): monkeypatch.setattr(cloud, "LocalSourceCodeDir", mock.MagicMock()) monkeypatch.setattr(cloud, "_prepare_lightning_wheels_and_requirements", mock.MagicMock()) app = mock.MagicMock() + app.is_headless = False app.flows = [] app.frontend = {} cloud_runtime = cloud.CloudRuntime(app=app, entrypoint_file="entrypoint.py") @@ -298,6 +301,7 @@ def test_run_on_byoc_cluster(self, monkeypatch): cluster_id="test1234", app_entrypoint_file=mock.ANY, enable_app_server=True, + is_headless=False, flow_servers=[], image_spec=None, works=[], @@ -332,6 +336,7 @@ def test_requirements_file(self, monkeypatch): monkeypatch.setattr(cloud, "LocalSourceCodeDir", mock.MagicMock()) monkeypatch.setattr(cloud, "_prepare_lightning_wheels_and_requirements", mock.MagicMock()) app = mock.MagicMock() + app.is_headless = False app.flows = [] app.frontend = {} cloud_runtime = cloud.CloudRuntime(app=app, entrypoint_file="entrypoint.py") @@ -345,6 +350,7 @@ def test_requirements_file(self, monkeypatch): body = Body8( app_entrypoint_file=mock.ANY, enable_app_server=True, + is_headless=False, flow_servers=[], image_spec=None, works=[], @@ -455,6 +461,7 @@ def test_call_with_work_app(self, lightningapps, start_with_flow, monkeypatch, t monkeypatch.setattr(cloud, "LocalSourceCodeDir", mock.MagicMock()) monkeypatch.setattr(cloud, "_prepare_lightning_wheels_and_requirements", mock.MagicMock()) app = mock.MagicMock() + app.is_headless = False work = MyWork(start_with_flow=start_with_flow, cloud_compute=CloudCompute("custom")) work._name = "test-work" @@ -478,6 +485,7 @@ def test_call_with_work_app(self, lightningapps, start_with_flow, monkeypatch, t local_source=True, app_entrypoint_file="entrypoint.py", enable_app_server=True, + is_headless=False, flow_servers=[], dependency_cache_key=get_hash(requirements_file), user_requested_flow_compute_config=mock.ANY, @@ -626,6 +634,7 @@ def test_call_with_work_app_and_attached_drives(self, lightningapps, monkeypatch monkeypatch.setattr(cloud, "LocalSourceCodeDir", mock.MagicMock()) monkeypatch.setattr(cloud, "_prepare_lightning_wheels_and_requirements", mock.MagicMock()) app = mock.MagicMock() + app.is_headless = False mocked_drive = MagicMock(spec=Drive) setattr(mocked_drive, "id", "foobar") @@ -662,6 +671,7 @@ def test_call_with_work_app_and_attached_drives(self, lightningapps, monkeypatch local_source=True, app_entrypoint_file="entrypoint.py", enable_app_server=True, + is_headless=False, flow_servers=[], dependency_cache_key=get_hash(requirements_file), user_requested_flow_compute_config=mock.ANY, @@ -760,6 +770,7 @@ def test_call_with_work_app_and_app_comment_command_execution_set(self, lightnin monkeypatch.setattr(cloud, "LocalSourceCodeDir", mock.MagicMock()) monkeypatch.setattr(cloud, "_prepare_lightning_wheels_and_requirements", mock.MagicMock()) app = mock.MagicMock() + app.is_headless = False work = MyWork(cloud_compute=CloudCompute("custom")) work._state = {"_port"} @@ -785,6 +796,7 @@ def test_call_with_work_app_and_app_comment_command_execution_set(self, lightnin local_source=True, app_entrypoint_file="entrypoint.py", enable_app_server=True, + is_headless=False, flow_servers=[], dependency_cache_key=get_hash(requirements_file), user_requested_flow_compute_config=mock.ANY, @@ -875,6 +887,7 @@ def test_call_with_work_app_and_multiple_attached_drives(self, lightningapps, mo monkeypatch.setattr(cloud, "LocalSourceCodeDir", mock.MagicMock()) monkeypatch.setattr(cloud, "_prepare_lightning_wheels_and_requirements", mock.MagicMock()) app = mock.MagicMock() + app.is_headless = False mocked_lit_drive = MagicMock(spec=Drive) setattr(mocked_lit_drive, "id", "foobar") @@ -942,6 +955,7 @@ def test_call_with_work_app_and_multiple_attached_drives(self, lightningapps, mo local_source=True, app_entrypoint_file="entrypoint.py", enable_app_server=True, + is_headless=False, flow_servers=[], dependency_cache_key=get_hash(requirements_file), user_requested_flow_compute_config=mock.ANY, @@ -981,6 +995,7 @@ def test_call_with_work_app_and_multiple_attached_drives(self, lightningapps, mo local_source=True, app_entrypoint_file="entrypoint.py", enable_app_server=True, + is_headless=False, flow_servers=[], dependency_cache_key=get_hash(requirements_file), user_requested_flow_compute_config=mock.ANY, @@ -1076,6 +1091,7 @@ def test_call_with_work_app_and_attached_mount_and_drive(self, lightningapps, mo monkeypatch.setattr(cloud, "LocalSourceCodeDir", mock.MagicMock()) monkeypatch.setattr(cloud, "_prepare_lightning_wheels_and_requirements", mock.MagicMock()) app = mock.MagicMock() + app.is_headless = False mocked_drive = MagicMock(spec=Drive) setattr(mocked_drive, "id", "foobar") @@ -1118,6 +1134,7 @@ def test_call_with_work_app_and_attached_mount_and_drive(self, lightningapps, mo local_source=True, app_entrypoint_file="entrypoint.py", enable_app_server=True, + is_headless=False, flow_servers=[], dependency_cache_key=get_hash(requirements_file), image_spec=Gridv1ImageSpec( @@ -1414,3 +1431,25 @@ def run(self): with pytest.raises(ValueError, match="You requested a custom base image for the Work with name"): _validate_build_spec_and_compute(Work()) + + +@pytest.mark.parametrize( + "lightning_app_instance, lightning_cloud_url, expected_url", + [ + ( + Externalv1LightningappInstance(id="test-app-id"), + "https://b975913c4b22eca5f0f9e8eff4c4b1c315340a0d.staging.lightning.ai", + "https://b975913c4b22eca5f0f9e8eff4c4b1c315340a0d.staging.lightning.ai/me/apps/test-app-id", + ), + ( + Externalv1LightningappInstance(id="test-app-id"), + "http://localhost:9800", + "http://localhost:9800/me/apps/test-app-id", + ), + ], +) +def test_get_app_url(lightning_app_instance, lightning_cloud_url, expected_url): + with mock.patch( + "lightning_app.runners.cloud.get_lightning_cloud_url", mock.MagicMock(return_value=lightning_cloud_url) + ): + assert CloudRuntime._get_app_url(lightning_app_instance) == expected_url diff --git a/tests/tests_app/runners/test_multiprocess.py b/tests/tests_app/runners/test_multiprocess.py index 0693e38a35f77..2e1a34ab38677 100644 --- a/tests/tests_app/runners/test_multiprocess.py +++ b/tests/tests_app/runners/test_multiprocess.py @@ -1,6 +1,9 @@ +import os from unittest import mock from unittest.mock import Mock +import pytest + from lightning_app import LightningApp, LightningFlow, LightningWork from lightning_app.frontend import StaticWebFrontend, StreamlitFrontend from lightning_app.runners import MultiProcessRuntime @@ -81,3 +84,15 @@ def run(self): def test_multiprocess_runtime_sets_context(): """Test that the runtime sets the global variable COMPONENT_CONTEXT in Flow and Work.""" MultiProcessRuntime(LightningApp(ContxtFlow())).dispatch() + + +@pytest.mark.parametrize( + "env,expected_url", + [ + ({}, "http://127.0.0.1:7501/view"), + ({"APP_SERVER_HOST": "http://test"}, "http://test"), + ], +) +def test_get_app_url(env, expected_url): + with mock.patch.dict(os.environ, env): + assert MultiProcessRuntime._get_app_url() == expected_url diff --git a/tests/tests_app/runners/test_singleprocess.py b/tests/tests_app/runners/test_singleprocess.py index 3b2ad69185077..998f23e66296f 100644 --- a/tests/tests_app/runners/test_singleprocess.py +++ b/tests/tests_app/runners/test_singleprocess.py @@ -1,3 +1,8 @@ +import os +from unittest import mock + +import pytest + from lightning_app import LightningFlow from lightning_app.core.app import LightningApp from lightning_app.runners import SingleProcessRuntime @@ -16,3 +21,15 @@ def test_single_process_runtime(tmpdir): app = LightningApp(Flow()) SingleProcessRuntime(app, start_server=False).dispatch(on_before_run=on_before_run) + + +@pytest.mark.parametrize( + "env,expected_url", + [ + ({}, "http://127.0.0.1:7501/view"), + ({"APP_SERVER_HOST": "http://test"}, "http://test"), + ], +) +def test_get_app_url(env, expected_url): + with mock.patch.dict(os.environ, env): + assert SingleProcessRuntime._get_app_url() == expected_url diff --git a/tests/tests_app/utilities/test_app_helpers.py b/tests/tests_app/utilities/test_app_helpers.py index cdf526b74f1c7..791d2011f7651 100644 --- a/tests/tests_app/utilities/test_app_helpers.py +++ b/tests/tests_app/utilities/test_app_helpers.py @@ -1,9 +1,19 @@ +import os from unittest import mock import pytest +from lightning_cloud.openapi import ( + AppinstancesIdBody, + V1LightningappInstanceSpec, + V1LightningappInstanceState, + V1ListLightningappInstancesResponse, +) -from lightning_app import LightningFlow, LightningWork +from lightning_app import LightningApp, LightningFlow, LightningWork from lightning_app.utilities.app_helpers import ( + _handle_is_headless, + _is_headless, + _MagicMockJsonSerializable, AppStatePlugin, BaseStatePlugin, InMemoryStateStore, @@ -102,3 +112,89 @@ def c(self): assert is_static_method(A, "a") assert is_static_method(A, "b") assert not is_static_method(A, "c") + + +class FlowWithURLLayout(Flow): + def configure_layout(self): + return {"name": "test", "content": "https://appurl"} + + +class FlowWithWorkLayout(Flow): + def __init__(self): + super().__init__() + + self.work = Work() + + def configure_layout(self): + return {"name": "test", "content": self.work} + + +class FlowWithMockedFrontend(Flow): + def configure_layout(self): + return _MagicMockJsonSerializable() + + +class FlowWithMockedContent(Flow): + def configure_layout(self): + return [{"name": "test", "content": _MagicMockJsonSerializable()}] + + +class NestedFlow(Flow): + def __init__(self): + super().__init__() + + self.flow = Flow() + + +class NestedFlowWithURLLayout(Flow): + def __init__(self): + super().__init__() + + self.flow = FlowWithURLLayout() + + +@pytest.mark.parametrize( + "flow,expected", + [ + (Flow, True), + (FlowWithURLLayout, False), + (FlowWithWorkLayout, False), + (FlowWithMockedFrontend, False), + (FlowWithMockedContent, False), + (NestedFlow, True), + (NestedFlowWithURLLayout, False), + ], +) +def test_is_headless(flow, expected): + flow = flow() + app = LightningApp(flow) + assert _is_headless(app) == expected + + +@mock.patch("lightning_app.utilities.network.LightningClient") +def test_handle_is_headless(mock_client): + project_id = "test_project_id" + app_id = "test_app_id" + app_name = "test_app_name" + + lightningapps = [mock.MagicMock()] + lightningapps[0].id = app_id + lightningapps[0].name = app_name + lightningapps[0].status.phase = V1LightningappInstanceState.RUNNING + lightningapps[0].spec = V1LightningappInstanceSpec(app_id=app_id) + + mock_client().lightningapp_instance_service_list_lightningapp_instances.return_value = ( + V1ListLightningappInstancesResponse(lightningapps=lightningapps) + ) + + app = mock.MagicMock() + app.is_headless = True + + with mock.patch.dict(os.environ, {"LIGHTNING_CLOUD_APP_ID": app_id, "LIGHTNING_CLOUD_PROJECT_ID": project_id}): + _handle_is_headless(app) + + mock_client().lightningapp_instance_service_update_lightningapp_instance.assert_called_once_with( + project_id=project_id, + id=app_id, + body=AppinstancesIdBody(name="test_app_name", spec=V1LightningappInstanceSpec(app_id=app_id, is_headless=True)), + ) diff --git a/tests/tests_examples_app/public/test_commands_and_api.py b/tests/tests_examples_app/public/test_commands_and_api.py index 3105624432b78..a6a015d02a84a 100644 --- a/tests/tests_examples_app/public/test_commands_and_api.py +++ b/tests/tests_examples_app/public/test_commands_and_api.py @@ -7,6 +7,8 @@ from tests_examples_app.public import _PATH_EXAMPLES from lightning_app.testing.testing import run_app_in_cloud +from lightning_app.utilities.cloud import _get_project +from lightning_app.utilities.network import LightningClient @pytest.mark.timeout(300) @@ -14,7 +16,7 @@ def test_commands_and_api_example_cloud() -> None: with run_app_in_cloud(os.path.join(_PATH_EXAMPLES, "app_commands_and_api")) as ( admin_page, - view_page, + _, fetch_logs, _, ): @@ -34,7 +36,19 @@ def test_commands_and_api_example_cloud() -> None: sleep(5) # 5: Send a request to the Rest API directly. - base_url = view_page.url.replace("/view", "").replace("/child_flow", "") + client = LightningClient() + project = _get_project(client) + + lit_apps = [ + app + for app in client.lightningapp_instance_service_list_lightningapp_instances( + project_id=project.project_id + ).lightningapps + if app.id == app_id + ] + app = lit_apps[0] + + base_url = app.status.url resp = requests.post(base_url + "/user/command_without_client?name=awesome") assert resp.status_code == 200, resp.json()