[App] Support for headless apps (#15875)

* Add `is_headless` when dispatching in the cloud

* Bump cloud version

* Add tests

* Dont open app page for headless apps locally

* Refactor

* Update CHANGELOG.md

* Support dynamic UIs at runtime

* Comments

* Fix

* Updates

* Fixes and cleanup

* Fix tests

* Dont open view page for headless apps

* Fix test, resolve URL the right way

* Remove launch

* Clean

* Cleanup tests

* Fixes

* Updates

* Add test

* Increase app cloud tests timeout

* Increase timeout

* Wait for running

* Revert timeouts

* Clean

* Dont update if it hasnt changed

* Increase timeout
This commit is contained in:
Ethan Harris 2022-12-05 21:58:22 +00:00 committed by GitHub
parent b4d99e3cc1
commit 32cf1faa07
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 338 additions and 86 deletions

View File

@ -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:

View File

@ -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

View File

@ -20,8 +20,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

View File

@ -2,13 +2,13 @@ import os
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 @@ from lightning_app.utilities.network import LightningClient
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 _run_app(
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,

View File

@ -29,6 +29,8 @@ from lightning_app.storage.path import _storage_root_dir
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 @@ class LightningApp:
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 @@ class LightningApp:
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 @@ class LightningApp:
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.

View File

@ -7,8 +7,9 @@ import time
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 @@ from lightning_app.core.constants import (
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 @@ class CloudRuntime(Runtime):
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 @@ class CloudRuntime(Runtime):
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 @@ class CloudRuntime(Runtime):
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://":

View File

@ -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.core.constants import APP_SERVER_IN_CLOUD
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 @@ class MultiProcessRuntime(Runtime):
# 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 @@ class MultiProcessRuntime(Runtime):
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")

View File

@ -4,7 +4,7 @@ import sys
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

View File

@ -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 @@ class SingleProcessRuntime(Runtime):
# 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 @@ class SingleProcessRuntime(Runtime):
raise
finally:
self.terminate()
@staticmethod
def _get_app_url() -> str:
return os.getenv("APP_SERVER_HOST", "http://127.0.0.1:7501/view")

View File

@ -14,6 +14,7 @@ from time import sleep
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 = [

View File

@ -19,9 +19,11 @@ from unittest.mock import MagicMock
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),
)

View File

@ -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.

View File

@ -4,45 +4,15 @@ from unittest.mock import MagicMock
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()

View File

@ -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"},

View File

@ -13,6 +13,7 @@ from lightning_cloud.openapi import (
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 @@ class TestAppCreationClient:
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 @@ class TestAppCreationClient:
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 @@ class TestAppCreationClient:
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 @@ class TestAppCreationClient:
body = Body8(
app_entrypoint_file=mock.ANY,
enable_app_server=True,
is_headless=False,
flow_servers=[],
image_spec=None,
works=[],
@ -455,6 +461,7 @@ class TestAppCreationClient:
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 @@ class TestAppCreationClient:
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 @@ class TestAppCreationClient:
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 @@ class TestAppCreationClient:
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 @@ class TestAppCreationClient:
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 @@ class TestAppCreationClient:
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 @@ class TestAppCreationClient:
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 @@ class TestAppCreationClient:
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 @@ class TestAppCreationClient:
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 @@ class TestAppCreationClient:
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 @@ class TestAppCreationClient:
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 test_incompatible_cloud_compute_and_build_config():
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

View File

@ -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 @@ class ContxtFlow(LightningFlow):
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

View File

@ -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

View File

@ -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 test_is_static_method():
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)),
)

View File

@ -7,6 +7,8 @@ import requests
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 @@ from lightning_app.testing.testing import run_app_in_cloud
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()