diff --git a/docs/source-app/api_reference/api/lightning_app.core.LightningApp.rst b/docs/source-app/api_reference/api/lightning_app.core.LightningApp.rst new file mode 100644 index 0000000000..269a4e8f11 --- /dev/null +++ b/docs/source-app/api_reference/api/lightning_app.core.LightningApp.rst @@ -0,0 +1,13 @@ +:orphan: + +.. role:: hidden + :class: hidden-section +.. currentmodule:: lightning_app.core + + +LightningApp +============ + +.. autoclass:: LightningApp + :members: + :noindex: diff --git a/docs/source-app/api_reference/api/lightning_app.core.LightningFlow.rst b/docs/source-app/api_reference/api/lightning_app.core.LightningFlow.rst new file mode 100644 index 0000000000..336efd4d71 --- /dev/null +++ b/docs/source-app/api_reference/api/lightning_app.core.LightningFlow.rst @@ -0,0 +1,13 @@ +:orphan: + +.. role:: hidden + :class: hidden-section +.. currentmodule:: lightning_app.core + + +LightningFlow +============= + +.. autoclass:: LightningFlow + :members: + :noindex: diff --git a/docs/source-app/api_reference/api/lightning_app.core.LightningWork.rst b/docs/source-app/api_reference/api/lightning_app.core.LightningWork.rst new file mode 100644 index 0000000000..db80b79e41 --- /dev/null +++ b/docs/source-app/api_reference/api/lightning_app.core.LightningWork.rst @@ -0,0 +1,13 @@ +:orphan: + +.. role:: hidden + :class: hidden-section +.. currentmodule:: lightning_app.core + + +LightningWork +============= + +.. autoclass:: LightningWork + :members: + :noindex: diff --git a/docs/source-app/api_reference/frontend.rst b/docs/source-app/api_reference/frontend.rst index 4a4ba082a2..f5e57516bc 100644 --- a/docs/source-app/api_reference/frontend.rst +++ b/docs/source-app/api_reference/frontend.rst @@ -20,3 +20,4 @@ ___________________ ~frontend.Frontend ~web.StaticWebFrontend ~stream_lit.StreamlitFrontend + ~panel.PanelFrontend diff --git a/docs/source-app/workflows/add_web_ui/index_content.rst b/docs/source-app/workflows/add_web_ui/index_content.rst index 9602537a53..ceef98b4ea 100644 --- a/docs/source-app/workflows/add_web_ui/index_content.rst +++ b/docs/source-app/workflows/add_web_ui/index_content.rst @@ -25,6 +25,14 @@ Web UIs for non Javascript Developers :height: 150 :tag: basic +.. displayitem:: + :header: Panel + :description: Learn how to add a web UI built in Python with Panel. + :col_css: col-md-4 + :button_link: panel/index.html + :height: 150 + :tag: basic + .. displayitem:: :header: Jupyter Notebook :description: Learn how to enable a web UI that is a Jupyter Notebook. diff --git a/docs/source-app/workflows/add_web_ui/panel/basic.rst b/docs/source-app/workflows/add_web_ui/panel/basic.rst new file mode 100644 index 0000000000..695e6cdee2 --- /dev/null +++ b/docs/source-app/workflows/add_web_ui/panel/basic.rst @@ -0,0 +1,358 @@ +:orphan: + +############################### +Add a web UI with Panel (basic) +############################### + +**Audience:** Users who want to add a web UI written with Python and Panel. + +**Prereqs:** Basic Python knowledge. + +---- + +************** +What is Panel? +************** + +`Panel`_ and the `HoloViz`_ ecosystem provide unique and powerful +features such as big data visualization using `DataShader`_, easy cross filtering +using `HoloViews`_, streaming and much more. + +* Panel is highly flexible and ties into the PyData and Jupyter ecosystems as you can develop in notebooks and use ipywidgets. You can also develop in .py files. + +* Panel is one of the most popular data app frameworks in Python with `more than 400.000 downloads a month `_. It's especially popular in the scientific community. + +* Panel is used, for example, by Rapids to power `CuxFilter`_, a CuDF based big data visualization framework. + +* Panel can be deployed on your favorite server or cloud including `Lightning`_. + +.. figure:: https://pl-flash-data.s3.amazonaws.com/assets_lightning/docs/images/frontend/panel/panel-intro.gif + :alt: Example Panel App + + Example Panel App + +Panel is **particularly well suited for Lightning Apps** that need to display live progress. This is because the Panel server can react +to state changes and asynchronously push messages from the server to the client using web socket communication. + +.. figure:: https://pl-flash-data.s3.amazonaws.com/assets_lightning/docs/images/frontend/panel/panel-streaming-intro.gif + :alt: Example Panel Streaming App + + Example Panel Streaming App + +Install Panel with: + +.. code:: bash + + pip install panel + +---- + +********************* +Run a basic Panel App +********************* + +In the next few sections, we'll build an App step-by-step. + +First, create a file named ``app_panel.py`` with the App content: + +.. code:: python + + # app_panel.py + + import panel as pn + + pn.panel("Hello **Panel ⚡** World").servable() + +Then, create a file named ``app.py`` with the following App content: + +.. code:: python + + # app.py + + import lightning as L + from lightning.app.frontend.panel import PanelFrontend + + + class LitPanel(L.LightningFlow): + + def configure_layout(self): + return PanelFrontend("app_panel.py") + + + class LitApp(L.LightningFlow): + def __init__(self): + super().__init__() + self.lit_panel = LitPanel() + + def configure_layout(self): + return {"name": "home", "content": self.lit_panel} + + + app = L.LightningApp(LitApp()) + +Finally, add ``panel`` to your ``requirements.txt`` file: + +.. code:: bash + + echo 'panel' >> requirements.txt + +.. note:: This is a best practice to make Apps reproducible. + +---- + +*********** +Run the App +*********** + +Run the App locally: + +.. code:: bash + + lightning run app app.py + +The App should look like this: + +.. figure:: https://pl-flash-data.s3.amazonaws.com/assets_lightning/docs/images/frontend/panel/panel-lightning-basic.png + :alt: Basic Panel Lightning App + + Basic Panel Lightning App + +Now, run it on the cloud: + +.. code:: bash + + lightning run app app.py --cloud + +---- + +************************* +Step-by-step walk-through +************************* + +In this section, we explain each part of the code in detail. + +---- + +0. Define a Panel app +^^^^^^^^^^^^^^^^^^^^^ + +First, find the Panel app you want to integrate. In this example, that app looks like: + +.. code:: python + + import panel as pn + + pn.panel("Hello **Panel ⚡** World").servable() + +Refer to the `Panel documentation `_ and `awesome-panel.org `_ for more complex examples. + +---- + +1. Add Panel to a Component +^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Link this app to the Lightning App by using the ``PanelFrontend`` class which needs to be returned from +the ``configure_layout`` method of the Lightning Component you want to connect to Panel. + +.. code:: python + :emphasize-lines: 7-10 + + import lightning as L + from lightning.app.frontend.panel import PanelFrontend + + + class LitPanel(L.LightningFlow): + + def configure_layout(self): + return PanelFrontend("app_panel.py") + + + class LitApp(L.LightningFlow): + def __init__(self): + super().__init__() + self.lit_panel = LitPanel() + + def configure_layout(self): + return {"name": "home", "content": self.lit_panel} + + + app = L.LightningApp(LitApp()) + +The argument of the ``PanelFrontend`` class, points to the script, notebook, or function that +runs your Panel app. + +---- + +2. Route the UI in the root component +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The second step, is to tell the Root component in which tab to render this component's UI. +In this case, we render the ``LitPanel`` UI in the ``home`` tab of the app. + +.. code:: python + :emphasize-lines: 16-17 + + import lightning as L + from lightning.app.frontend.panel import PanelFrontend + + + class LitPanel(L.LightningFlow): + + def configure_layout(self): + return PanelFrontend("app_panel.py") + + + class LitApp(L.LightningFlow): + def __init__(self): + super().__init__() + self.lit_panel = LitPanel() + + def configure_layout(self): + return {"name": "home", "content": self.lit_panel} + +---- + +************* +Tips & Tricks +************* + +0. Use autoreload while developing +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +To speed up your development workflow, you can run your Lightning App with Panel **autoreload** by +setting the environment variable ``PANEL_AUTORELOAD`` to ``yes``. + +Try running the following: + +.. code-block:: + + PANEL_AUTORELOAD=yes lightning run app app.py + +.. figure:: https://pl-flash-data.s3.amazonaws.com/assets_lightning/docs/images/frontend/panel/panel-lightning-autoreload.gif + :alt: Basic Panel Lightning App with autoreload + + Basic Panel Lightning App with autoreload + +1. Theme your App +^^^^^^^^^^^^^^^^^ + +To theme your App you, can use the Lightning accent color ``#792EE5`` with the `FastListTemplate`_. + +Try replacing the contents of ``app_panel.py`` with the following: + +.. code:: bash + + # app_panel.py + + import panel as pn + import plotly.express as px + + ACCENT = "#792EE5" + + pn.extension("plotly", sizing_mode="stretch_width", template="fast") + pn.state.template.param.update( + title="⚡ Hello Panel + Lightning ⚡", accent_base_color=ACCENT, header_background=ACCENT + ) + + pn.config.raw_css.append( + """ + .bk-root:first-of-type { + height: calc( 100vh - 200px ) !important; + } + """ + ) + + + def get_panel_theme(): + """Returns 'default' or 'dark'""" + return pn.state.session_args.get("theme", [b"default"])[0].decode() + + + def get_plotly_template(): + if get_panel_theme() == "dark": + return "plotly_dark" + return "plotly_white" + + + def get_plot(length=5): + xseries = [index for index in range(length + 1)] + yseries = [x**2 for x in xseries] + fig = px.line( + x=xseries, + y=yseries, + template=get_plotly_template(), + color_discrete_sequence=[ACCENT], + range_x=(0, 10), + markers=True, + ) + fig.layout.autosize = True + return fig + + + length = pn.widgets.IntSlider(value=5, start=1, end=10, name="Length") + dynamic_plot = pn.panel( + pn.bind(get_plot, length=length), sizing_mode="stretch_both", config={"responsive": True} + ) + pn.Column(length, dynamic_plot).servable() + + +Install some additional libraries and remember to add the dependencies to the ``requirements.txt`` file: + + +.. code:: bash + + echo 'plotly' >> requirements.txt + echo 'pandas' >> requirements.txt + +Finally run the App + +.. code:: bash + + lightning run app app.py + +.. figure:: https://pl-flash-data.s3.amazonaws.com/assets_lightning/docs/images/frontend/panel/panel-lightning-theme.gif + :alt: Basic Panel Plotly Lightning App with theming + + Basic Panel Plotly Lightning App with theming + +.. _Panel: https://panel.holoviz.org/ +.. _FastListTemplate: https://panel.holoviz.org/reference/templates/FastListTemplate.html#templates-gallery-fastlisttemplate +.. _HoloViz: https://holoviz.org/ +.. _DataShader: https://datashader.org/ +.. _HoloViews: https://holoviews.org/ +.. _Lightning: https://lightning.ai/ +.. _CuxFilter: https://github.com/rapidsai/cuxfilter +.. _AwesomePanel: https://awesome-panel.org/home + + +---- + +********** +Next Steps +********** + +.. raw:: html + +
+
+ +.. displayitem:: + :header: 2: Enable two-way communication + :description: Enable two-way communication between Panel and a Lightning App. + :col_css: col-md-6 + :button_link: intermediate.html + :height: 150 + :tag: intermediate + +.. displayitem:: + :header: Add a web user interface (UI) + :description: Users who want to add a UI to their Lightning Apps + :col_css: col-md-6 + :button_link: ../index.html + :height: 150 + :tag: intermediate + +.. raw:: html + +
+
diff --git a/docs/source-app/workflows/add_web_ui/panel/index.rst b/docs/source-app/workflows/add_web_ui/panel/index.rst new file mode 100644 index 0000000000..0d48a1dc9f --- /dev/null +++ b/docs/source-app/workflows/add_web_ui/panel/index.rst @@ -0,0 +1,85 @@ +:orphan: + +.. toctree:: + :maxdepth: 1 + :hidden: + + basic + intermediate + +####################### +Add a web UI with Panel +####################### + +.. raw:: html + +
+
+ +.. Add callout items below this line + +.. displayitem:: + :header: 1: Connect Panel + :description: Learn how to connect Panel to a Lightning Component. + :col_css: col-md-6 + :button_link: basic.html + :height: 150 + :tag: basic + +.. displayitem:: + :header: 2: Enable two-way communication + :description: Enable two-way communication between Panel and a Lightning App. + :col_css: col-md-6 + :button_link: intermediate.html + :height: 150 + :tag: intermediate + +.. raw:: html + +
+
+ +---- + +******** +Examples +******** + +Here are a few example apps that use a Panel web UI. + + +.. raw:: html + +
+
+ +.. Add callout items below this line + +.. displayitem:: + :header: Example 1 + :description: Show off your work! Contribute an example. + :col_css: col-md-4 + :button_link: ../../../contribute_app.html + :height: 150 + :tag: Waiting for contributed example + +.. displayitem:: + :header: Example 2 + :description: Show off your work! Contribute an example. + :col_css: col-md-4 + :button_link: ../../../contribute_app.html + :height: 150 + :tag: Waiting for contributed example + +.. displayitem:: + :header: Example 3 + :description: Show off your work! Contribute an example. + :col_css: col-md-4 + :button_link: ../../../contribute_app.html + :height: 150 + :tag: Waiting for contributed example + +.. raw:: html + +
+
diff --git a/docs/source-app/workflows/add_web_ui/panel/intermediate.rst b/docs/source-app/workflows/add_web_ui/panel/intermediate.rst new file mode 100644 index 0000000000..171f91d82c --- /dev/null +++ b/docs/source-app/workflows/add_web_ui/panel/intermediate.rst @@ -0,0 +1,210 @@ +:orphan: + +###################################### +Add a web UI with Panel (intermediate) +###################################### + +**Audience:** Users who want to communicate between the Lightning App and Panel. + +**Prereqs:** Must have read the `Panel basic `_ guide. + +---- + +************************************** +Interact with the Component from Panel +************************************** + +The ``PanelFrontend`` enables user interactions with the Lightning App using widgets. +You can modify the state variables of a Lightning Component using the ``AppStateWatcher``. + +For example, here we increase the ``count`` variable of the Lightning Component every time a user +presses a button: + +.. code:: python + + # app_panel.py + + import panel as pn + from lightning.app.frontend.panel import AppStateWatcher + + pn.extension(sizing_mode="stretch_width") + + app = AppStateWatcher() + + submit_button = pn.widgets.Button(name="submit") + + @pn.depends(submit_button, watch=True) + def submit(_): + app.state.count += 1 + + @pn.depends(app.param.state) + def current_count(_): + return f"current count: {app.state.count}" + + pn.Column( + submit_button, + current_count, + ).servable() + + + +.. code:: python + + # app.py + + import lightning as L + from lightning.app.frontend.panel import PanelFrontend + + class LitPanel(L.LightningFlow): + def __init__(self): + super().__init__() + self.count = 0 + self.last_count = 0 + + def run(self): + if self.count != self.last_count: + self.last_count = self.count + print("Count changed to: ", self.count) + + def configure_layout(self): + return PanelFrontend("app_panel.py") + + + class LitApp(L.LightningFlow): + def __init__(self): + super().__init__() + self.lit_panel = LitPanel() + + def run(self): + self.lit_panel.run() + + def configure_layout(self): + return {"name": "home", "content": self.lit_panel} + + + app = L.LightningApp(LitApp()) + +.. figure:: https://pl-flash-data.s3.amazonaws.com/assets_lightning/docs/images/frontend/panel/panel-lightning-counter-from-frontend.gif + :alt: Panel Lightning App updating a counter from the frontend + + Panel Lightning App updating a counter from the frontend + +---- + +************************************ +Interact with Panel from a Component +************************************ + +To update the `PanelFrontend` from any Lightning Component, update the property in the Component. +Make sure to call the ``run`` method from the parent component. + +In this example, we update the ``count`` value of the Component: + +.. code:: python + + # app_panel.py + + import panel as pn + from lightning.app.frontend.panel import AppStateWatcher + + app = AppStateWatcher() + + pn.extension(sizing_mode="stretch_width") + + def counter(state): + return f"Counter: {state.count}" + + last_update = pn.bind(counter, app.param.state) + + pn.panel(last_update).servable() + +.. code:: python + + # app.py + + from datetime import datetime as dt + from lightning.app.frontend.panel import PanelFrontend + + import lightning as L + + + class LitPanel(L.LightningFlow): + def __init__(self): + super().__init__() + self.count = 0 + self._last_update = dt.now() + + def run(self): + now = dt.now() + if (now - self._last_update).microseconds >= 250: + self.count += 1 + self._last_update = now + print("Counter changed to: ", self.count) + + def configure_layout(self): + return PanelFrontend("app_panel.py") + + + class LitApp(L.LightningFlow): + def __init__(self): + super().__init__() + self.lit_panel = LitPanel() + + def run(self): + self.lit_panel.run() + + def configure_layout(self): + tab1 = {"name": "home", "content": self.lit_panel} + return tab1 + + app = L.LightningApp(LitApp()) + +.. figure:: https://pl-flash-data.s3.amazonaws.com/assets_lightning/docs/images/frontend/panel/panel-lightning-counter-from-component.gif + :alt: Panel Lightning App updating a counter from the component + + Panel Lightning App updating a counter from the Component + +---- + +************* +Tips & Tricks +************* + +* Caching: Panel provides the easy to use ``pn.state.cache`` memory based, ``dict`` caching. If you are looking for something persistent try `DiskCache `_ its really powerful and simple to use. You can use it to communicate large amounts of data between the components and frontend(s). + +* Notifications: Panel provides easy to use `notifications `_. You can for example use them to provide notifications about runs starting or ending. + +* Tabulator Table: Panel provides the `Tabulator table `_ which features expandable rows. The table is useful to provide for example an overview of you runs. But you can dig into the details by clicking and expanding the row. + +* Task Scheduling: Panel provides easy to use `task scheduling `_. You can use this to for example read and display files created by your components on a scheduled basis. + +* Terminal: Panel provides the `Xterm.js terminal `_ which can be used to display live logs from your components and allow you to provide a terminal interface to your component. + +.. figure:: https://pl-flash-data.s3.amazonaws.com/assets_lightning/docs/images/frontend/panel/panel-lightning-github-runner.gif + :alt: Panel Lightning App running models on github + + Panel Lightning App running models on GitHub + +---- + +********** +Next Steps +********** + +.. raw:: html + +
+
+ +.. displayitem:: + :header: Add a web user interface (UI) + :description: Users who want to add a UI to their Lightning Apps + :col_css: col-md-6 + :button_link: ../index.html + :height: 150 + :tag: intermediate + +.. raw:: html + +
+
diff --git a/docs/source-app/workflows/build_lightning_component/intermediate.rst b/docs/source-app/workflows/build_lightning_component/intermediate.rst index 871224ba4f..2533cbac35 100644 --- a/docs/source-app/workflows/build_lightning_component/intermediate.rst +++ b/docs/source-app/workflows/build_lightning_component/intermediate.rst @@ -9,8 +9,9 @@ Develop a Lightning Component (intermediate) ***************************** Add a web user interface (UI) ***************************** -Every Lightning Component can have its own user interface (UI). Lightning Components support any kind -of UI interface such as react.js, vue.js, streamlit, gradio, dash, web urls, etc...(`full list here <../add_web_ui/index.html>`_). +Every lightning component can have its own user interface (UI). Lightning components support any kind +of UI interface such as dash, gradio, panel, react.js, streamlit, vue.js, web urls, +etc...(`full list here <../add_web_ui/index.html>`_). Let's say that we have a user interface defined in html: diff --git a/requirements/app/ui.txt b/requirements/app/ui.txt index f0e4b2cdef..1fb2214f83 100644 --- a/requirements/app/ui.txt +++ b/requirements/app/ui.txt @@ -1 +1,2 @@ streamlit>=1.3.1, <=1.11.1 +panel>=0.12, <=0.13.1 diff --git a/src/lightning_app/CHANGELOG.md b/src/lightning_app/CHANGELOG.md index 1e74509e23..18a3e4ac82 100644 --- a/src/lightning_app/CHANGELOG.md +++ b/src/lightning_app/CHANGELOG.md @@ -4,7 +4,17 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/). -## [0.6.0] - 2022-08-DD + +## [0.7.0] - 2022-MM-DD + +### Added + +- Adds `PanelFrontend` to easily create complex UI in Python ([#13531](https://github.com/Lightning-AI/lightning/pull/13531)) + + + + +## [0.6.0] - 2022-08-23 ### Added diff --git a/src/lightning_app/frontend/__init__.py b/src/lightning_app/frontend/__init__.py index ed8c6abb94..955df9d375 100644 --- a/src/lightning_app/frontend/__init__.py +++ b/src/lightning_app/frontend/__init__.py @@ -1,5 +1,6 @@ from lightning_app.frontend.frontend import Frontend +from lightning_app.frontend.panel import PanelFrontend from lightning_app.frontend.stream_lit import StreamlitFrontend from lightning_app.frontend.web import StaticWebFrontend -__all__ = ["Frontend", "StaticWebFrontend", "StreamlitFrontend"] +__all__ = ["Frontend", "PanelFrontend", "StaticWebFrontend", "StreamlitFrontend"] diff --git a/src/lightning_app/frontend/panel/__init__.py b/src/lightning_app/frontend/panel/__init__.py new file mode 100644 index 0000000000..ba76dd1dce --- /dev/null +++ b/src/lightning_app/frontend/panel/__init__.py @@ -0,0 +1,6 @@ +"""The PanelFrontend and AppStateWatcher make it easy to create Lightning Apps with the Panel data app +framework.""" +from lightning_app.frontend.panel.app_state_watcher import AppStateWatcher +from lightning_app.frontend.panel.panel_frontend import PanelFrontend + +__all__ = ["PanelFrontend", "AppStateWatcher"] diff --git a/src/lightning_app/frontend/panel/app_state_comm.py b/src/lightning_app/frontend/panel/app_state_comm.py new file mode 100644 index 0000000000..f7d9c01e7d --- /dev/null +++ b/src/lightning_app/frontend/panel/app_state_comm.py @@ -0,0 +1,86 @@ +"""The watch_app_state function enables us to trigger a callback function when ever the app state changes.""" +# Todo: Refactor with Streamlit +# Note: It would be nice one day to just watch changes within the Flow scope instead of whole app +from __future__ import annotations + +import asyncio +import logging +import os +import threading +from typing import Callable + +import websockets + +from lightning_app.core.constants import APP_SERVER_PORT + +_logger = logging.getLogger(__name__) + +_CALLBACKS = [] +_THREAD: None | threading.Thread = None + + +def _get_ws_port(): + if "LIGHTNING_APP_STATE_URL" in os.environ: + return 8080 + return APP_SERVER_PORT + + +def _get_ws_url(): + port = _get_ws_port() + return f"ws://localhost:{port}/api/v1/ws" + + +def _run_callbacks(): + for callback in _CALLBACKS: + callback() + + +def _target_fn(): + async def update_fn(): + ws_url = _get_ws_url() + _logger.debug("connecting to web socket %s", ws_url) + async with websockets.connect(ws_url) as websocket: # pylint: disable=no-member + while True: + await websocket.recv() + # Note: I have not seen use cases where the two lines below are needed + # Changing '< 0.2' to '< 1' makes the App very sluggish to the end user + # Also the implementation can cause the App state to lag behind because only 1 update + # is received per 0.2 second (or 1 second). + # while (time.time() - last_updated) < 0.2: + # time.sleep(0.05) + + # Todo: Add some kind of throttling. If 10 messages are received within 100ms then + # there is no need to trigger the app state changed, request state and update + # 10 times. + _logger.debug("App State Changed. Running callbacks") + _run_callbacks() + + asyncio.run(update_fn()) + + +def _start_websocket(): + global _THREAD # pylint: disable=global-statement + if not _THREAD: + _logger.debug("Starting the watch_app_state thread.") + _THREAD = threading.Thread(target=_target_fn) + _THREAD.setDaemon(True) + _THREAD.start() + _logger.debug("thread started") + + +def watch_app_state(callback: Callable): + """Start the process that serves the UI at the given hostname and port number. + + Arguments: + callback: A function to run when the App state changes. Must be thread safe. + + Example: + + .. code-block:: python + + def handle_state_change(): + print("The App State changed.") + watch_app_state(handle_state_change) + """ + _CALLBACKS.append(callback) + _start_websocket() diff --git a/src/lightning_app/frontend/panel/app_state_watcher.py b/src/lightning_app/frontend/panel/app_state_watcher.py new file mode 100644 index 0000000000..49eee09ea8 --- /dev/null +++ b/src/lightning_app/frontend/panel/app_state_watcher.py @@ -0,0 +1,106 @@ +"""The AppStateWatcher enables a Frontend to. + +- subscribe to App state changes +- to access and change the App state. + +This is particularly useful for the PanelFrontend but can be used by other Frontends too. +""" +from __future__ import annotations + +import logging +import os + +from lightning_app.frontend.panel.app_state_comm import watch_app_state +from lightning_app.frontend.utils import _get_flow_state +from lightning_app.utilities.imports import _is_param_available, requires +from lightning_app.utilities.state import AppState + +_logger = logging.getLogger(__name__) + + +if _is_param_available(): + from param import ClassSelector, edit_constant, Parameterized +else: + Parameterized = object + ClassSelector = dict + + +class AppStateWatcher(Parameterized): + """The AppStateWatcher enables a Frontend to: + + - Subscribe to any App state changes. + - To access and change the App state from the UI. + + This is particularly useful for the PanelFrontend, but can be used by + other Frontend's too. + + Example: + + .. code-block:: python + + import param + + app = AppStateWatcher() + + app.state.counter = 1 + + + @param.depends(app.param.state, watch=True) + def update(state): + print(f"The counter was updated to {state.counter}") + + + app.state.counter += 1 + + This would print ``The counter was updated to 2``. + + The AppStateWatcher is built on top of Param which is a framework like dataclass, attrs and + Pydantic which additionally provides powerful and unique features for building reactive apps. + + Please note the AppStateWatcher is a singleton, i.e. only one instance is instantiated + """ + + state: AppState = ClassSelector( + class_=AppState, + constant=True, + doc="The AppState holds the state of the app reduced to the scope of the Flow", + ) + + def __new__(cls): + # This makes the AppStateWatcher a *singleton*. + # The AppStateWatcher is a singleton to minimize the number of requests etc.. + if not hasattr(cls, "_instance"): + cls._instance = super().__new__(cls) + return cls._instance + + @requires("param") + def __init__(self): + # It's critical to initialize only once + # See https://github.com/holoviz/param/issues/643 + if not hasattr(self, "_initialized"): + super().__init__(name="singleton") + self._start_watching() + self.param.state.allow_None = False + self._initialized = True + + # The below was observed when using mocks during testing + if not self.state: + raise Exception(".state has not been set.") + if not self.state._state: + raise Exception(".state._state has not been set.") + + def _start_watching(self): + # Create a thread listening to state changes. + watch_app_state(self._update_flow_state) + self._update_flow_state() + + def _get_flow_state(self) -> AppState: + flow = os.environ["LIGHTNING_FLOW_NAME"] + return _get_flow_state(flow) + + def _update_flow_state(self): + # Todo: Consider whether to only update if ._state changed + # This might be much more performant. + with edit_constant(self): + self.state = self._get_flow_state() + _logger.debug("Requested App State.") diff --git a/src/lightning_app/frontend/panel/panel_frontend.py b/src/lightning_app/frontend/panel/panel_frontend.py new file mode 100644 index 0000000000..d89ed89875 --- /dev/null +++ b/src/lightning_app/frontend/panel/panel_frontend.py @@ -0,0 +1,171 @@ +"""The PanelFrontend wraps your Panel code in your LightningFlow.""" +from __future__ import annotations + +import inspect +import logging +import os +import pathlib +import subprocess +import sys +from typing import Callable, TextIO + +from lightning_app.frontend.frontend import Frontend +from lightning_app.frontend.utils import _get_frontend_environment +from lightning_app.utilities.cloud import is_running_in_cloud +from lightning_app.utilities.imports import requires +from lightning_app.utilities.log import get_frontend_logfile + +_logger = logging.getLogger("PanelFrontend") + + +def has_panel_autoreload() -> bool: + """Returns True if the PANEL_AUTORELOAD environment variable is set to 'yes' or 'true'. + + Please note the casing of value does not matter + """ + return os.environ.get("PANEL_AUTORELOAD", "no").lower() in ["yes", "y", "true"] + + +class PanelFrontend(Frontend): + """The PanelFrontend enables you to serve Panel code as a Frontend for your LightningFlow. + + To use this frontend, you must first install the `panel` package: + + .. code-block:: bash + + pip install panel + + Example: + + `panel_app_basic.py` + + .. code-block:: python + + import panel as pn + + pn.panel("Hello **Panel ⚡** World").servable() + + `app_basic.py` + + .. code-block:: python + + import lightning as L + from lightning.app.frontend.panel import PanelFrontend + + + class LitPanel(L.LightningFlow): + def configure_layout(self): + return PanelFrontend("panel_app_basic.py") + + + class LitApp(L.LightningFlow): + def __init__(self): + super().__init__() + self.lit_panel = LitPanel() + + def configure_layout(self): + return {"name": "home", "content": self.lit_panel} + + + app = L.LightningApp(LitApp()) + + You can start the Lightning server with Panel autoreload by setting the `PANEL_AUTORELOAD` + environment variable to 'yes': `AUTORELOAD=yes lightning run app app_basic.py`. + + Args: + entry_point: A pure function or the path to a .py or .ipynb file. + The function must be a pure function that contains your Panel code. + The function can optionally accept an `AppStateWatcher` argument. + + Raises: + TypeError: Raised if the entry_point is a class method + """ + + @requires("panel") + def __init__(self, entry_point: Callable | str): + super().__init__() + + if inspect.ismethod(entry_point): + raise TypeError( + "The `PanelFrontend` doesn't support `entry_point` being a method. Please, use a pure function." + ) + + self.entry_point = entry_point + self._process: None | subprocess.Popen = None + self._log_files: dict[str, TextIO] = {} + _logger.debug("PanelFrontend Frontend with %s is initialized.", entry_point) + + def start_server(self, host: str, port: int) -> None: + _logger.debug("PanelFrontend starting server on %s:%s", host, port) + + # 1: Prepare environment variables and arguments. + env = _get_frontend_environment( + self.flow.name, + self.entry_point, + port, + host, + ) + command = self._get_popen_args(host, port) + + if is_running_in_cloud(): + self._open_log_files() + + self._process = subprocess.Popen(command, env=env, **self._log_files) # pylint: disable=consider-using-with + + def stop_server(self) -> None: + if self._process is None: + raise RuntimeError("Server is not running. Call `PanelFrontend.start_server()` first.") + self._process.kill() + self._close_log_files() + + def _close_log_files(self): + for file_ in self._log_files.values(): + if not file_.closed: + file_.close() + self._log_files = {} + + def _open_log_files(self) -> None: + # Don't log to file when developing locally. Makes it harder to debug. + self._close_log_files() + + std_err_out = get_frontend_logfile("error.log") + std_out_out = get_frontend_logfile("output.log") + stderr = std_err_out.open("wb") + stdout = std_out_out.open("wb") + self._log_files = {"stdout": stderr, "stderr": stdout} + + def _get_popen_args(self, host: str, port: int) -> list: + if callable(self.entry_point): + path = str(pathlib.Path(__file__).parent / "panel_serve_render_fn.py") + else: + path = pathlib.Path(self.entry_point) + + abs_path = str(path) + # The app is served at http://localhost:{port}/{flow}/{entry_point} + # Lightning embeds http://localhost:{port}/{flow} but this redirects to the above and + # seems to work fine. + command = [ + sys.executable, + "-m", + "panel", + "serve", + abs_path, + "--port", + str(port), + "--address", + host, + "--prefix", + self.flow.name, + "--allow-websocket-origin", + _get_allowed_hosts(), + ] + if has_panel_autoreload(): + command.append("--autoreload") + _logger.debug("PanelFrontend command %s", command) + return command + + +def _get_allowed_hosts() -> str: + """Returns a comma separated list of host[:port] that should be allowed to connect.""" + # TODO: Enable only lightning.ai domain in the cloud + return "*" diff --git a/src/lightning_app/frontend/panel/panel_serve_render_fn.py b/src/lightning_app/frontend/panel/panel_serve_render_fn.py new file mode 100644 index 0000000000..7aff3d5c3e --- /dev/null +++ b/src/lightning_app/frontend/panel/panel_serve_render_fn.py @@ -0,0 +1,52 @@ +"""This file gets run by Python to launch a Panel Server with Lightning. + +We will call the ``render_fn`` that the user provided to the PanelFrontend. + +It requires the following environment variables to be set + + +- LIGHTNING_RENDER_FUNCTION +- LIGHTNING_RENDER_MODULE_FILE + +Example: + +.. code-block:: bash + + python panel_serve_render_fn +""" +import inspect +import os +import pydoc +from typing import Callable + +from lightning_app.frontend.panel.app_state_watcher import AppStateWatcher + + +def _get_render_fn_from_environment(render_fn_name: str, render_fn_module_file: str) -> Callable: + """Returns the render_fn function to serve in the Frontend.""" + module = pydoc.importfile(render_fn_module_file) + return getattr(module, render_fn_name) + + +def _get_render_fn(): + render_fn_name = os.environ["LIGHTNING_RENDER_FUNCTION"] + render_fn_module_file = os.environ["LIGHTNING_RENDER_MODULE_FILE"] + render_fn = _get_render_fn_from_environment(render_fn_name, render_fn_module_file) + if inspect.signature(render_fn).parameters: + + def _render_fn_wrapper(): + app = AppStateWatcher() + return render_fn(app) + + return _render_fn_wrapper + return render_fn + + +if __name__.startswith("bokeh"): + import panel as pn + + # I use caching for efficiency reasons. It shaves off 10ms from having + # to get_render_fn_from_environment every time + if "lightning_render_fn" not in pn.state.cache: + pn.state.cache["lightning_render_fn"] = _get_render_fn() + pn.state.cache["lightning_render_fn"]() diff --git a/src/lightning_app/frontend/streamlit_base.py b/src/lightning_app/frontend/streamlit_base.py index af2a8314e0..c57ad2f9f9 100644 --- a/src/lightning_app/frontend/streamlit_base.py +++ b/src/lightning_app/frontend/streamlit_base.py @@ -4,9 +4,9 @@ From here, we will call the render function that the user provided in ``configur """ import os import pydoc -from typing import Callable, Union +from typing import Callable -from lightning_app.core.flow import LightningFlow +from lightning_app.frontend.utils import _reduce_to_flow_scope from lightning_app.utilities.app_helpers import StreamLitStatePlugin from lightning_app.utilities.state import AppState @@ -20,19 +20,10 @@ def _get_render_fn_from_environment() -> Callable: return getattr(module, render_fn_name) -def _app_state_to_flow_scope(state: AppState, flow: Union[str, LightningFlow]) -> AppState: - """Returns a new AppState with the scope reduced to the given flow, as if the given flow as the root.""" - flow_name = flow.name if isinstance(flow, LightningFlow) else flow - flow_name_parts = flow_name.split(".")[1:] # exclude root - flow_state = state - for part in flow_name_parts: - flow_state = getattr(flow_state, part) - return flow_state - - def main(): + """Run the render_fn with the current flow_state.""" # Fetch the information of which flow attaches to this streamlit instance - flow_state = _app_state_to_flow_scope(app_state, flow=os.environ["LIGHTNING_FLOW_NAME"]) + flow_state = _reduce_to_flow_scope(app_state, flow=os.environ["LIGHTNING_FLOW_NAME"]) # Call the provided render function. # Pass it the state, scoped to the current flow. diff --git a/src/lightning_app/frontend/utils.py b/src/lightning_app/frontend/utils.py new file mode 100644 index 0000000000..1795445ef1 --- /dev/null +++ b/src/lightning_app/frontend/utils.py @@ -0,0 +1,57 @@ +"""Utility functions for lightning Frontends.""" +from __future__ import annotations + +import inspect +import os +from typing import Callable + +from lightning_app.core.flow import LightningFlow +from lightning_app.utilities.state import AppState + + +def _reduce_to_flow_scope(state: AppState, flow: str | LightningFlow) -> AppState: + """Returns a new AppState with the scope reduced to the given flow.""" + flow_name = flow.name if isinstance(flow, LightningFlow) else flow + flow_name_parts = flow_name.split(".")[1:] # exclude root + flow_state = state + for part in flow_name_parts: + flow_state = getattr(flow_state, part) + return flow_state + + +def _get_flow_state(flow: str) -> AppState: + """Returns an AppState scoped to the current Flow. + + Returns: + AppState: An AppState scoped to the current Flow. + """ + app_state = AppState() + app_state._request_state() # pylint: disable=protected-access + flow_state = _reduce_to_flow_scope(app_state, flow) + return flow_state + + +def _get_frontend_environment(flow: str, render_fn_or_file: Callable | str, port: int, host: str) -> os._Environ: + """Returns an _Environ with the environment variables for serving a Frontend app set. + + Args: + flow: The name of the flow, for example root.lit_frontend + render_fn_or_file: A function to render + port: The port number, for example 54321 + host: The host, for example 'localhost' + + Returns: + os._Environ: An environment + """ + env = os.environ.copy() + env["LIGHTNING_FLOW_NAME"] = flow + env["LIGHTNING_RENDER_PORT"] = str(port) + env["LIGHTNING_RENDER_ADDRESS"] = str(host) + + if isinstance(render_fn_or_file, str): + env["LIGHTNING_RENDER_FILE"] = render_fn_or_file + else: + env["LIGHTNING_RENDER_FUNCTION"] = render_fn_or_file.__name__ + env["LIGHTNING_RENDER_MODULE_FILE"] = inspect.getmodule(render_fn_or_file).__file__ + + return env diff --git a/src/lightning_app/utilities/imports.py b/src/lightning_app/utilities/imports.py index 90c149e551..090af1b879 100644 --- a/src/lightning_app/utilities/imports.py +++ b/src/lightning_app/utilities/imports.py @@ -105,6 +105,11 @@ def _is_streamlit_available() -> bool: return _module_available("streamlit") +@functools.lru_cache() +def _is_param_available() -> bool: + return _module_available("param") + + @functools.lru_cache() def _is_streamlit_tensorboard_available() -> bool: return _module_available("streamlit_tensorboard") diff --git a/src/lightning_app/utilities/state.py b/src/lightning_app/utilities/state.py index 300bca3453..378c3e20ec 100644 --- a/src/lightning_app/utilities/state.py +++ b/src/lightning_app/utilities/state.py @@ -66,7 +66,7 @@ class AppState: my_affiliation: Tuple[str, ...] = None, plugin: Optional[BaseStatePlugin] = None, ) -> None: - """The AppState class enable streamlit user to interact their application state. + """The AppState class enables Frontend users to interact with their application state. When the state isn't defined, it would be pulled from the app REST API Server. If the state gets modified by the user, the new state would be sent to the API Server. diff --git a/tests/tests_app/frontend/conftest.py b/tests/tests_app/frontend/conftest.py new file mode 100644 index 0000000000..673fcf1905 --- /dev/null +++ b/tests/tests_app/frontend/conftest.py @@ -0,0 +1,73 @@ +"""Test configuration.""" +# pylint: disable=protected-access +from unittest import mock + +import pytest + +FLOW_SUB = "lit_flow" +FLOW = f"root.{FLOW_SUB}" +PORT = 61896 + +FLOW_STATE = { + "vars": { + "_paths": {}, + "_layout": {"target": f"http://localhost:{PORT}/{FLOW}"}, + }, + "calls": {}, + "flows": {}, + "works": {}, + "structures": {}, + "changes": {}, +} + +APP_STATE = { + "vars": {"_paths": {}, "_layout": [{"name": "home", "content": FLOW}]}, + "calls": {}, + "flows": { + FLOW_SUB: FLOW_STATE, + }, + "works": {}, + "structures": {}, + "changes": {}, + "app_state": {"stage": "running"}, +} + + +def _request_state(self): + _state = APP_STATE + self._store_state(_state) + + +@pytest.fixture() +def flow(): + return FLOW + + +@pytest.fixture(autouse=True, scope="module") +def mock_request_state(): + """Avoid requests to the api.""" + with mock.patch("lightning_app.utilities.state.AppState._request_state", _request_state): + yield + + +def do_nothing(): + """Be lazy!""" + + +@pytest.fixture(autouse=True, scope="module") +def mock_start_websocket(): + """Avoid starting the websocket.""" + with mock.patch("lightning_app.frontend.panel.app_state_comm._start_websocket", do_nothing): + yield + + +@pytest.fixture +def app_state_state(): + """Returns an AppState dict.""" + return APP_STATE.copy() + + +@pytest.fixture +def flow_state_state(): + """Returns an AppState dict scoped to the flow.""" + return FLOW_STATE.copy() diff --git a/tests/tests_app/frontend/panel/__init__.py b/tests/tests_app/frontend/panel/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/tests_app/frontend/panel/app_panel.py b/tests/tests_app/frontend/panel/app_panel.py new file mode 100644 index 0000000000..6b54261e37 --- /dev/null +++ b/tests/tests_app/frontend/panel/app_panel.py @@ -0,0 +1,5 @@ +if __name__ == "__main__": + + import panel as pn + + pn.pane.Markdown("# Panel App").servable() diff --git a/tests/tests_app/frontend/panel/test_app_state_comm.py b/tests/tests_app/frontend/panel/test_app_state_comm.py new file mode 100644 index 0000000000..3766a1ccce --- /dev/null +++ b/tests/tests_app/frontend/panel/test_app_state_comm.py @@ -0,0 +1,39 @@ +"""The watch_app_state function enables us to trigger a callback function whenever the App state changes.""" +import os +from unittest import mock + +from lightning_app.core.constants import APP_SERVER_PORT +from lightning_app.frontend.panel.app_state_comm import _get_ws_url, _run_callbacks, watch_app_state + +FLOW_SUB = "lit_flow" +FLOW = f"root.{FLOW_SUB}" + + +def do_nothing(): + """Be lazy!""" + + +def test_get_ws_url_when_local(): + """The websocket uses port APP_SERVER_PORT when local.""" + assert _get_ws_url() == f"ws://localhost:{APP_SERVER_PORT}/api/v1/ws" + + +@mock.patch.dict(os.environ, {"LIGHTNING_APP_STATE_URL": "some_url"}) +def test_get_ws_url_when_cloud(): + """The websocket uses port 8080 when LIGHTNING_APP_STATE_URL is set.""" + assert _get_ws_url() == "ws://localhost:8080/api/v1/ws" + + +@mock.patch.dict(os.environ, {"LIGHTNING_FLOW_NAME": "FLOW"}) +def test_watch_app_state(): + """We can watch the App state and a callback function will be run when it changes.""" + callback = mock.MagicMock() + # When + watch_app_state(callback) + + # Here we would like to send messages using the web socket + # For testing the web socket is not started. See conftest.py + # So we need to manually trigger _run_callbacks here + _run_callbacks() + # Then + callback.assert_called_once() diff --git a/tests/tests_app/frontend/panel/test_app_state_watcher.py b/tests/tests_app/frontend/panel/test_app_state_watcher.py new file mode 100644 index 0000000000..25b99c8b25 --- /dev/null +++ b/tests/tests_app/frontend/panel/test_app_state_watcher.py @@ -0,0 +1,85 @@ +"""The AppStateWatcher enables a Frontend to. + +- subscribe to App state changes. +- to access and change the App state. + +This is particularly useful for the PanelFrontend, but can be used by other Frontends too. +""" +# pylint: disable=protected-access +import os +from unittest import mock + +import pytest + +from lightning_app.frontend.panel.app_state_watcher import AppStateWatcher +from lightning_app.utilities.state import AppState + +FLOW_SUB = "lit_flow" +FLOW = f"root.{FLOW_SUB}" +PORT = 61896 + + +@pytest.fixture(autouse=True) +def mock_settings_env_vars(): + """Set the LIGHTNING environment variables.""" + with mock.patch.dict( + os.environ, + { + "LIGHTNING_FLOW_NAME": FLOW, + "LIGHTNING_RENDER_ADDRESS": "localhost", + "LIGHTNING_RENDER_PORT": f"{PORT}", + }, + ): + yield + + +def test_init(flow_state_state: dict): + """We can instantiate the AppStateWatcher. + + - the .state is set + - the .state is scoped to the flow state + """ + # When + app = AppStateWatcher() + # Needed as AppStateWatcher is singleton and might have been + # instantiated and the state changed in other tests + app._update_flow_state() + + # Then + assert isinstance(app.state, AppState) + assert app.state._state == flow_state_state + + +def test_update_flow_state(flow_state_state: dict): + """We can update the state. + + - the .state is scoped to the flow state + """ + app = AppStateWatcher() + org_state = app.state + app._update_flow_state() + assert app.state is not org_state + assert app.state._state == flow_state_state + + +def test_is_singleton(): + """The AppStateWatcher is a singleton for efficiency reasons. + + Its key that __new__ and __init__ of AppStateWatcher is only called once. See + https://github.com/holoviz/param/issues/643 + """ + # When + app1 = AppStateWatcher() + name1 = app1.name + state1 = app1.state + + app2 = AppStateWatcher() + name2 = app2.name + state2 = app2.state + + # Then + assert app1 is app2 + assert name1 == name2 + assert app1.name == name2 + assert state1 is state2 + assert app1.state is state2 diff --git a/tests/tests_app/frontend/panel/test_panel_frontend.py b/tests/tests_app/frontend/panel/test_panel_frontend.py new file mode 100644 index 0000000000..c310183787 --- /dev/null +++ b/tests/tests_app/frontend/panel/test_panel_frontend.py @@ -0,0 +1,164 @@ +"""The PanelFrontend wraps your Panel code in your LightningFlow.""" +# pylint: disable=protected-access, too-few-public-methods +import os +import runpy +import sys +from unittest import mock +from unittest.mock import Mock + +import pytest + +from lightning_app import LightningFlow +from lightning_app.frontend.panel import panel_serve_render_fn, PanelFrontend +from lightning_app.frontend.panel.panel_frontend import has_panel_autoreload +from lightning_app.utilities.state import AppState + + +def test_stop_server_not_running(): + """If the server is not running but stopped an Exception should be raised.""" + frontend = PanelFrontend(entry_point=Mock()) + with pytest.raises(RuntimeError, match="Server is not running."): + frontend.stop_server() + + +def _noop_render_fn(_): + pass + + +class MockFlow(LightningFlow): + """Test Flow.""" + + @property + def name(self): + """Return name.""" + return "root.my.flow" + + def run(self): # pylint: disable=arguments-differ + """Be lazy!""" + + +@mock.patch("lightning_app.frontend.panel.panel_frontend.subprocess") +def test_panel_frontend_start_stop_server(subprocess_mock): + """Test that `PanelFrontend.start_server()` invokes subprocess.Popen with the right parameters.""" + # Given + frontend = PanelFrontend(entry_point=_noop_render_fn) + frontend.flow = MockFlow() + # When + frontend.start_server(host="hostname", port=1111) + # Then + subprocess_mock.Popen.assert_called_once() + + env_variables = subprocess_mock.method_calls[0].kwargs["env"] + call_args = subprocess_mock.method_calls[0].args[0] + assert call_args == [ + sys.executable, + "-m", + "panel", + "serve", + panel_serve_render_fn.__file__, + "--port", + "1111", + "--address", + "hostname", + "--prefix", + "root.my.flow", + "--allow-websocket-origin", + "*", + ] + + assert env_variables["LIGHTNING_FLOW_NAME"] == "root.my.flow" + assert env_variables["LIGHTNING_RENDER_ADDRESS"] == "hostname" + assert env_variables["LIGHTNING_RENDER_FUNCTION"] == "_noop_render_fn" + assert env_variables["LIGHTNING_RENDER_MODULE_FILE"] == __file__ + assert env_variables["LIGHTNING_RENDER_PORT"] == "1111" + + assert "LIGHTNING_FLOW_NAME" not in os.environ + assert "LIGHTNING_RENDER_FUNCTION" not in os.environ + assert "LIGHTNING_RENDER_MODULE_FILE" not in os.environ + assert "LIGHTNING_RENDER_MODULE_PORT" not in os.environ + assert "LIGHTNING_RENDER_MODULE_ADDRESS" not in os.environ + # When + frontend.stop_server() + # Then + subprocess_mock.Popen().kill.assert_called_once() + + +def _call_me(state): + assert isinstance(state, AppState) + print(state) + + +@mock.patch.dict( + os.environ, + { + "LIGHTNING_FLOW_NAME": "root", + "LIGHTNING_RENDER_FUNCTION": "_call_me", + "LIGHTNING_RENDER_MODULE_FILE": __file__, + "LIGHTNING_RENDER_ADDRESS": "127.0.0.1", + "LIGHTNING_RENDER_PORT": "61896", + }, +) +def test_panel_wrapper_calls_entry_point(*_): + """Run the panel_serve_entry_point.""" + runpy.run_module("lightning_app.frontend.panel.panel_serve_render_fn") + + +def test_method_exception(): + """The PanelFrontend does not support entry_point being a method and should raise an Exception.""" + + class _DummyClass: + def _render_fn(self): + pass + + with pytest.raises(TypeError, match="being a method"): + PanelFrontend(entry_point=_DummyClass()._render_fn) + + +def test_open_close_log_files(): + """We can open and close the log files.""" + frontend = PanelFrontend(_noop_render_fn) + assert not frontend._log_files + # When + frontend._open_log_files() + # Then + stdout = frontend._log_files["stdout"] + stderr = frontend._log_files["stderr"] + assert not stdout.closed + assert not stderr.closed + + # When + frontend._close_log_files() + # Then + assert not frontend._log_files + assert stdout.closed + assert stderr.closed + + # We can close even if not open + frontend._close_log_files() + + +@pytest.mark.parametrize( + ["value", "expected"], + ( + ("Yes", True), + ("yes", True), + ("YES", True), + ("Y", True), + ("y", True), + ("True", True), + ("true", True), + ("TRUE", True), + ("No", False), + ("no", False), + ("NO", False), + ("N", False), + ("n", False), + ("False", False), + ("false", False), + ("FALSE", False), + ), +) +def test_has_panel_autoreload(value, expected): + """We can get and set autoreload using the environment variable PANEL_AUTORELOAD.""" + with mock.patch.dict(os.environ, {"PANEL_AUTORELOAD": value}): + assert has_panel_autoreload() == expected diff --git a/tests/tests_app/frontend/panel/test_panel_serve_render_fn.py b/tests/tests_app/frontend/panel/test_panel_serve_render_fn.py new file mode 100644 index 0000000000..810367fe15 --- /dev/null +++ b/tests/tests_app/frontend/panel/test_panel_serve_render_fn.py @@ -0,0 +1,79 @@ +"""The panel_serve_render_fn_or_file file gets run by Python to launch a Panel Server with Lightning. + +These tests are for serving a render_fn function. +""" +import inspect +import os +from unittest import mock + +import pytest + +from lightning_app.frontend.panel.app_state_watcher import AppStateWatcher +from lightning_app.frontend.panel.panel_serve_render_fn import _get_render_fn, _get_render_fn_from_environment + + +@pytest.fixture(autouse=True) +def _mock_settings_env_vars(): + with mock.patch.dict( + os.environ, + { + "LIGHTNING_FLOW_NAME": "root.lit_flow", + "LIGHTNING_RENDER_ADDRESS": "localhost", + "LIGHTNING_RENDER_MODULE_FILE": __file__, + "LIGHTNING_RENDER_PORT": "61896", + }, + ): + yield + + +def render_fn(app): + """Test render_fn function with app args.""" + return app + + +@mock.patch.dict( + os.environ, + { + "LIGHTNING_RENDER_FUNCTION": "render_fn", + }, +) +def test_get_view_fn_args(): + """We have a helper get_view_fn function that create a function for our view. + + If the render_fn provides an argument an AppStateWatcher is provided as argument + """ + result = _get_render_fn() + assert isinstance(result(), AppStateWatcher) + + +def render_fn_no_args(): + """Test function with no arguments.""" + return "no_args" + + +@mock.patch.dict( + os.environ, + { + "LIGHTNING_RENDER_FUNCTION": "render_fn_no_args", + }, +) +def test_get_view_fn_no_args(): + """We have a helper get_view_fn function that create a function for our view. + + If the render_fn provides an argument an AppStateWatcher is provided as argument + """ + result = _get_render_fn() + assert result() == "no_args" + + +def render_fn_2(): + """Do nothing.""" + + +def test_get_render_fn_from_environment(): + """We have a method to get the render_fn from the environment.""" + # When + result = _get_render_fn_from_environment("render_fn_2", __file__) + # Then + assert result.__name__ == render_fn_2.__name__ + assert inspect.getmodule(result).__file__ == __file__ diff --git a/tests/tests_app/frontend/test_utils.py b/tests/tests_app/frontend/test_utils.py new file mode 100644 index 0000000000..711eac464d --- /dev/null +++ b/tests/tests_app/frontend/test_utils.py @@ -0,0 +1,42 @@ +"""We have some utility functions that can be used across frontends.""" + +from lightning_app.frontend.utils import _get_flow_state, _get_frontend_environment +from lightning_app.utilities.state import AppState + + +def test_get_flow_state(flow_state_state: dict, flow): + """We have a method to get an AppState scoped to the Flow state.""" + # When + flow_state = _get_flow_state(flow) + # Then + assert isinstance(flow_state, AppState) + assert flow_state._state == flow_state_state # pylint: disable=protected-access + + +def some_fn(_): + """Be lazy!""" + + +def test_get_frontend_environment_fn(): + """We have a utility function to get the frontend render_fn environment.""" + # When + env = _get_frontend_environment(flow="root.lit_frontend", render_fn_or_file=some_fn, host="myhost", port=1234) + # Then + assert env["LIGHTNING_FLOW_NAME"] == "root.lit_frontend" + assert env["LIGHTNING_RENDER_ADDRESS"] == "myhost" + assert env["LIGHTNING_RENDER_FUNCTION"] == "some_fn" + assert env["LIGHTNING_RENDER_MODULE_FILE"] == __file__ + assert env["LIGHTNING_RENDER_PORT"] == "1234" + + +def test_get_frontend_environment_file(): + """We have a utility function to get the frontend render_fn environment.""" + # When + env = _get_frontend_environment( + flow="root.lit_frontend", render_fn_or_file="app_panel.py", host="myhost", port=1234 + ) + # Then + assert env["LIGHTNING_FLOW_NAME"] == "root.lit_frontend" + assert env["LIGHTNING_RENDER_ADDRESS"] == "myhost" + assert env["LIGHTNING_RENDER_FILE"] == "app_panel.py" + assert env["LIGHTNING_RENDER_PORT"] == "1234" diff --git a/tests/tests_app/frontend/utilities/__init__.py b/tests/tests_app/frontend/utilities/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/tests_app/utilities/test_cloud.py b/tests/tests_app/utilities/test_cloud.py index db5a3efdf1..573ec46106 100644 --- a/tests/tests_app/utilities/test_cloud.py +++ b/tests/tests_app/utilities/test_cloud.py @@ -6,9 +6,11 @@ from lightning_app.utilities.cloud import is_running_in_cloud @mock.patch.dict(os.environ, clear=True) def test_is_running_locally(): + """We can determine if Lightning is running locally.""" assert not is_running_in_cloud() @mock.patch.dict(os.environ, {"LIGHTNING_APP_STATE_URL": "127.0.0.1"}) def test_is_running_cloud(): + """We can determine if Lightning is running in the cloud.""" assert is_running_in_cloud()