PanelFrontend and Panel Web UI Intermediate docs (#13531)

Co-authored-by: thomas chaton <thomas@grid.ai>
Co-authored-by: Adrian Wälchli <aedu.waelchli@gmail.com>
Co-authored-by: Marc Skov Madsen <masma@orsted.com>
Co-authored-by: Laverne Henderson <laverne.henderson@coupa.com>
Co-authored-by: Felonious-Spellfire <felonious.spellfire@gmail.com>
Co-authored-by: Jirka Borovec <Borda@users.noreply.github.com>
Co-authored-by: Mansy <ahmed.mansy156@gmail.com>
Co-authored-by: Jirka <jirka.borovec@seznam.cz>
This commit is contained in:
Marc Skov Madsen 2022-08-30 02:33:21 +02:00 committed by GitHub
parent 2374465b01
commit 18e2a8eecd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 1695 additions and 18 deletions

View File

@ -0,0 +1,13 @@
:orphan:
.. role:: hidden
:class: hidden-section
.. currentmodule:: lightning_app.core
LightningApp
============
.. autoclass:: LightningApp
:members:
:noindex:

View File

@ -0,0 +1,13 @@
:orphan:
.. role:: hidden
:class: hidden-section
.. currentmodule:: lightning_app.core
LightningFlow
=============
.. autoclass:: LightningFlow
:members:
:noindex:

View File

@ -0,0 +1,13 @@
:orphan:
.. role:: hidden
:class: hidden-section
.. currentmodule:: lightning_app.core
LightningWork
=============
.. autoclass:: LightningWork
:members:
:noindex:

View File

@ -20,3 +20,4 @@ ___________________
~frontend.Frontend
~web.StaticWebFrontend
~stream_lit.StreamlitFrontend
~panel.PanelFrontend

View File

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

View File

@ -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 <https://pyviz.org/tools.html#dashboarding>`_. 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 <https://docs.Panel.io/>`_ and `awesome-panel.org <https://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
<div class="display-card-container">
<div class="row">
.. 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
</div>
</div>

View File

@ -0,0 +1,85 @@
:orphan:
.. toctree::
:maxdepth: 1
:hidden:
basic
intermediate
#######################
Add a web UI with Panel
#######################
.. raw:: html
<div class="display-card-container">
<div class="row">
.. 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
</div>
</div>
----
********
Examples
********
Here are a few example apps that use a Panel web UI.
.. raw:: html
<div class="display-card-container">
<div class="row">
.. 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
</div>
</div>

View File

@ -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 <basic.html>`_ 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 <https://grantjenks.com/docs/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 <https://blog.holoviz.org/panel_0.13.0.html#Notifications>`_. You can for example use them to provide notifications about runs starting or ending.
* Tabulator Table: Panel provides the `Tabulator table <https://blog.holoviz.org/panel_0.13.0.html#Expandable-rows>`_ 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 <https://blog.holoviz.org/panel_0.13.0.html#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 <https://panel.holoviz.org/reference/widgets/Terminal.html>`_ 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
<div class="display-card-container">
<div class="row">
.. 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
</div>
</div>

View File

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

View File

@ -1 +1,2 @@
streamlit>=1.3.1, <=1.11.1
panel>=0.12, <=0.13.1

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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"]()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,5 @@
if __name__ == "__main__":
import panel as pn
pn.pane.Markdown("# Panel App").servable()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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