Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[App] Support for headless apps #15875

Merged
merged 30 commits into from Dec 5, 2022
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
0cb2146
Add `is_headless` when dispatching in the cloud
ethanwharris Nov 29, 2022
1dc42d8
Bump cloud version
ethanwharris Nov 30, 2022
c6ad5cc
Add tests
ethanwharris Nov 30, 2022
fb2a336
Merge branch 'master' into feature/is_headless
ethanwharris Dec 1, 2022
335f76d
Dont open app page for headless apps locally
ethanwharris Dec 1, 2022
c1dc0a6
Refactor
ethanwharris Dec 1, 2022
80ec62d
Update CHANGELOG.md
ethanwharris Dec 1, 2022
7dadc33
Support dynamic UIs at runtime
ethanwharris Dec 2, 2022
df7a7fc
Comments
ethanwharris Dec 2, 2022
6e52607
Fix
ethanwharris Dec 2, 2022
04efa22
Updates
ethanwharris Dec 2, 2022
3e57541
Fixes and cleanup
ethanwharris Dec 2, 2022
3e8f525
Fix tests
ethanwharris Dec 2, 2022
a95af08
Dont open view page for headless apps
ethanwharris Dec 2, 2022
1b137fe
Fix test, resolve URL the right way
ethanwharris Dec 2, 2022
a1ee356
Remove launch
ethanwharris Dec 5, 2022
9516395
Clean
ethanwharris Dec 5, 2022
bac282b
Cleanup tests
ethanwharris Dec 5, 2022
fe86b32
Fixes
ethanwharris Dec 5, 2022
e636bb1
Updates
ethanwharris Dec 5, 2022
67ef5b5
Add test
ethanwharris Dec 5, 2022
5280bab
Merge branch 'master' into feature/is_headless
ethanwharris Dec 5, 2022
26e0735
Increase app cloud tests timeout
ethanwharris Dec 5, 2022
7d8ebb9
Merge branch 'feature/is_headless' of https://github.com/Lightning-AI…
ethanwharris Dec 5, 2022
02ced7f
Increase timeout
ethanwharris Dec 5, 2022
4152c02
Wait for running
ethanwharris Dec 5, 2022
987e95e
Revert timeouts
ethanwharris Dec 5, 2022
11d16f4
Clean
ethanwharris Dec 5, 2022
175b53f
Dont update if it hasnt changed
ethanwharris Dec 5, 2022
24b810d
Increase timeout
ethanwharris Dec 5, 2022
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion 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
Expand Down
5 changes: 5 additions & 0 deletions src/lightning_app/CHANGELOG.md
Expand Up @@ -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

Expand Down
18 changes: 2 additions & 16 deletions src/lightning_app/cli/lightning_cli.py
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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.
Expand All @@ -284,7 +271,6 @@ 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,
name=name,
env_vars=env_vars,
secrets=secrets,
Expand Down
20 changes: 14 additions & 6 deletions src/lightning_app/runners/cloud.py
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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://":
Expand Down
16 changes: 11 additions & 5 deletions src/lightning_app/runners/multiprocess.py
@@ -1,15 +1,17 @@
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
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
Expand All @@ -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()
Expand Down Expand Up @@ -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)
Expand All @@ -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")
16 changes: 12 additions & 4 deletions 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


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

Expand Down Expand Up @@ -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()
Expand All @@ -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")
13 changes: 13 additions & 0 deletions src/lightning_app/utilities/app_helpers.py
Expand Up @@ -22,6 +22,7 @@

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
Expand Down Expand Up @@ -527,3 +528,15 @@ 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,)):
ethanwharris marked this conversation as resolved.
Show resolved Hide resolved
for entry in component._layout:
if "target" in entry:
return False
return True
10 changes: 6 additions & 4 deletions src/lightning_app/utilities/layout.py
Expand Up @@ -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 assume it is a `Frontend`
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):
Expand Down Expand Up @@ -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, so we just record dummy content
ethanwharris marked this conversation as resolved.
Show resolved Hide resolved
entry["content"] = "mock"
entry["target"] = "mock"
else:
m = f"""
A dictionary returned by `{flow.__class__.__name__}.configure_layout()` contains an unsupported entry.
Expand Down
32 changes: 1 addition & 31 deletions tests/tests_app/cli/test_cli.py
Expand Up @@ -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()
Expand Down
23 changes: 23 additions & 0 deletions tests/tests_app/runners/test_cloud.py
Expand Up @@ -13,6 +13,7 @@
Body8,
Body9,
Externalv1Cluster,
Externalv1LightningappInstance,
Gridv1ImageSpec,
IdGetBody,
V1BuildSpec,
Expand Down Expand Up @@ -1414,3 +1415,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
13 changes: 13 additions & 0 deletions tests/tests_app/runners/test_multiprocess.py
@@ -1,6 +1,8 @@
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
Expand Down Expand Up @@ -81,3 +83,14 @@ 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(
"expected_url",
[
"http://127.0.0.1:7501/view",
"http://127.0.0.1:7501/view",
],
)
def test_get_app_url(expected_url):
assert MultiProcessRuntime._get_app_url() == expected_url