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:
parent
2374465b01
commit
18e2a8eecd
|
@ -0,0 +1,13 @@
|
|||
:orphan:
|
||||
|
||||
.. role:: hidden
|
||||
:class: hidden-section
|
||||
.. currentmodule:: lightning_app.core
|
||||
|
||||
|
||||
LightningApp
|
||||
============
|
||||
|
||||
.. autoclass:: LightningApp
|
||||
:members:
|
||||
:noindex:
|
|
@ -0,0 +1,13 @@
|
|||
:orphan:
|
||||
|
||||
.. role:: hidden
|
||||
:class: hidden-section
|
||||
.. currentmodule:: lightning_app.core
|
||||
|
||||
|
||||
LightningFlow
|
||||
=============
|
||||
|
||||
.. autoclass:: LightningFlow
|
||||
:members:
|
||||
:noindex:
|
|
@ -0,0 +1,13 @@
|
|||
:orphan:
|
||||
|
||||
.. role:: hidden
|
||||
:class: hidden-section
|
||||
.. currentmodule:: lightning_app.core
|
||||
|
||||
|
||||
LightningWork
|
||||
=============
|
||||
|
||||
.. autoclass:: LightningWork
|
||||
:members:
|
||||
:noindex:
|
|
@ -20,3 +20,4 @@ ___________________
|
|||
~frontend.Frontend
|
||||
~web.StaticWebFrontend
|
||||
~stream_lit.StreamlitFrontend
|
||||
~panel.PanelFrontend
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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:
|
||||
|
||||
|
|
|
@ -1 +1,2 @@
|
|||
streamlit>=1.3.1, <=1.11.1
|
||||
panel>=0.12, <=0.13.1
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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"]
|
||||
|
|
|
@ -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"]
|
|
@ -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()
|
|
@ -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.")
|
|
@ -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 "*"
|
|
@ -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"]()
|
|
@ -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.
|
||||
|
|
|
@ -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
|
|
@ -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")
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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()
|
|
@ -0,0 +1,5 @@
|
|||
if __name__ == "__main__":
|
||||
|
||||
import panel as pn
|
||||
|
||||
pn.pane.Markdown("# Panel App").servable()
|
|
@ -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()
|
|
@ -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
|
|
@ -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
|
|
@ -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__
|
|
@ -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"
|
|
@ -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()
|
||||
|
|
Loading…
Reference in New Issue