[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:
parent
b4d99e3cc1
commit
32cf1faa07
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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://":
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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 = [
|
||||
|
|
|
@ -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),
|
||||
)
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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"},
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)),
|
||||
)
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
Loading…
Reference in New Issue