From 919d996bb334e1076eac08ca3f0e4b262861ac3c Mon Sep 17 00:00:00 2001 From: Andrew <15331990+ahuang11@users.noreply.github.com> Date: Mon, 16 Sep 2024 01:56:52 -0700 Subject: [PATCH] Start best practices notebook (#6819) --- doc/how_to/best_practices/dev_experience.md | 411 +++++++++++++++++++ doc/how_to/best_practices/index.md | 31 ++ doc/how_to/best_practices/user_experience.md | 358 ++++++++++++++++ doc/how_to/index.md | 7 + doc/how_to/prepare_to_share.md | 1 + 5 files changed, 808 insertions(+) create mode 100644 doc/how_to/best_practices/dev_experience.md create mode 100644 doc/how_to/best_practices/index.md create mode 100644 doc/how_to/best_practices/user_experience.md diff --git a/doc/how_to/best_practices/dev_experience.md b/doc/how_to/best_practices/dev_experience.md new file mode 100644 index 0000000000..7f1307565b --- /dev/null +++ b/doc/how_to/best_practices/dev_experience.md @@ -0,0 +1,411 @@ +# Developer Experience + +```{pyodide} +import time + +import param +import panel as pn + +pn.extension() +``` + +The best practices described on this page serve as a checklist of items to keep in mind as you are developing your application. They include items we see users frequently get confused about or things that are easily missed but can make a big difference to the user experience of your application(s). + +:::{note} +- Good: recommended, works. +- Okay: works (with intended behavior), potentially inefficient. +- Bad: Deprecated (may or may not work), just don't do it. +- Wrong: Not intended behavior, won't really work. +::: + +## Bind on reference value, not value + +### Good + +Be sure to bind on `obj.param.{parameter}`, not just `{parameter}`. + +```{pyodide} +def show_clicks(clicks): + return f"Number of clicks: {clicks}" + +button = pn.widgets.Button(name="Click me!") +clicks = pn.bind(show_clicks, button.param.clicks) +pn.Row(button, clicks) +``` + +### Wrong + +If only on `{parameter}`, it will not trigger an update on change. + +```{pyodide} +def show_clicks(clicks): + return f"Number of clicks: {clicks}" + +button = pn.widgets.Button(name="Click me!") +clicks = pn.bind(show_clicks, button.clicks) # not button.clicks! +pn.Row(button, clicks) +``` + +## Inherit from `pn.viewer.Viewer` + +### Good + +Instead of inheriting from `param.Parameterized`, using `pn.viewable.Viewer` allows direct invocation of the class, resembling a native Panel object. + +For example, it's possible to use `ExampleApp().servable()` instead of `ExampleApp().view().servable()`. + +```{pyodide} +class ExampleApp(pn.viewable.Viewer): + + ... + + def __panel__(self): + return pn.template.FastListTemplate( + main=[...], + sidebar=[...], + ) + +ExampleApp().servable(); # semi-colon to suppress output in notebook +``` + +### Okay + +This method works, but should be reserved for cases where there's no Panel output. + +```{pyodide} +class ExampleApp(param.Parameterized): + + ... + + def view(self): + return pn.template.FastListTemplate( + main=[...], + sidebar=[...], + ) + +ExampleApp().view().servable(); # semi-colon to suppress output in notebook +``` + +## Build widgets from parameters + +### Good + +To translate multiple parameters into widgets, use `pn.Param`. + +```{pyodide} +class ExampleApp(pn.viewable.Viewer): + + width = param.Integer(default=100, bounds=(1, 200), label="Width of box") + height = param.Integer(default=100, bounds=(1, 250), label="Height of box") + color = param.Color(default="red", label="Color of box") + + def __panel__(self): + return pn.Column( + pn.Param(self, widgets={"height": pn.widgets.IntInput}), + pn.pane.HTML( + width=self.param.width, + height=self.param.height, + styles={"background-color": self.param.color}, + ), + ) + + +ExampleApp() +``` + +### Good + +You can also use `from_param` to manually build each component. + +```{pyodide} +class ExampleApp(pn.viewable.Viewer): + + width = param.Integer(default=100, bounds=(1, 200), label="Width of box") + height = param.Integer(default=100, bounds=(1, 250), label="Height of box") + color = param.Color(default="red", label="Color of box") + + def __panel__(self): + width_slider = pn.widgets.IntSlider.from_param(self.param.width) + height_input = pn.widgets.IntInput.from_param(self.param.height) + color_picker = pn.widgets.ColorPicker.from_param(self.param.color) + return pn.Column( + width_slider, + height_input, + color_picker, + pn.pane.HTML( + width=self.param.width, + height=self.param.height, + styles={"background-color": self.param.color}, + ), + ) + + +ExampleApp() +``` + +### Bad + +If you instantiate individually through `param`, it's not bidirectional. + + +```{pyodide} +class ExampleApp(pn.viewable.Viewer): + + width = param.Integer(default=100, bounds=(1, 200), label="Width of box") + height = param.Integer(default=100, bounds=(1, 250), label="Height of box") + color = param.Color(default="red", label="Color of box") + + def __panel__(self): + width_slider = pn.widgets.IntSlider( + value=self.param.width, + start=self.param["width"].bounds[0], + end=self.param["width"].bounds[1], + name=self.param["width"].label, + ) + height_input = pn.widgets.IntInput( + value=self.param.height, + start=self.param["height"].bounds[0], + end=self.param["height"].bounds[1], + name=self.param["height"].label, + ) + color_picker = pn.widgets.ColorPicker( + value=self.param.color, + name=self.param["color"].label, + width=200, + ) + return pn.Column( + width_slider, + height_input, + color_picker, + pn.pane.HTML( + width=self.param.width, + height=self.param.height, + styles={"background-color": self.param.color}, + ), + ) + + +ExampleApp() +``` + +### Bad + +It's possible to `link` each widget to `self` with `bidirectional=True`, but certain keyword arguments, like `bounds`, cannot be linked easily. + +```{pyodide} +class ExampleApp(pn.viewable.Viewer): + + width = param.Integer(default=100, bounds=(1, 200), label="Width of box") + height = param.Integer(default=100, bounds=(1, 250), label="Height of box") + color = param.Color(default="red", label="Color of box") + + def __panel__(self): + width_slider = pn.widgets.IntSlider() + height_input = pn.widgets.IntInput() + color_picker = pn.widgets.ColorPicker() + + width_slider.link(self, value="width", bidirectional=True) + height_input.link(self, value="height", bidirectional=True) + color_picker.link(self, value="color", bidirectional=True) + + return pn.Column( + width_slider, + height_input, + color_picker, + pn.pane.HTML( + width=self.param.width, + height=self.param.height, + styles={"background-color": self.param.color}, + ), + ) + + +ExampleApp() +``` + +### Wrong + +Widgets should not be used as parameters since all instances of the class will share the widget class: + +```{pyodide} +class ExampleApp(pn.viewable.Viewer): + + width = pn.widgets.IntSlider() + height = pn.widgets.IntInput() + color = pn.widgets.ColorPicker() +``` + +## Show templates in notebooks + +### Good + +Templates, at the time of writing, are not able to be rendered properly in Jupyter notebooks. + +To continue working with templates in notebooks, call `show` to pop up a new browser window. + +```{pyodide} +template = pn.template.FastListTemplate( + main=[...], + sidebar=[...], +) + +# template.show() # commented out to disable opening a new browser tab in example +``` + +### Okay + +Alternatively, you can use a barebones notebook template like the one below. + +```{pyodide} +class NotebookPlaceholderTemplate(pn.viewable.Viewer): + main = param.List() + sidebar = param.List() + header = param.List() + title = param.String() + + def __panel__(self): + title = pn.pane.Markdown(f"# {self.title}", sizing_mode="stretch_width") + # pastel blue + header_row = pn.Row( + title, + *self.header, + sizing_mode="stretch_width", + styles={"background": "#e6f2ff"}, + ) + main_col = pn.WidgetBox(*self.main, sizing_mode="stretch_both") + sidebar_col = pn.WidgetBox( + *self.sidebar, width=300, sizing_mode="stretch_height" + ) + return pn.Column( + header_row, + pn.Row(sidebar_col, main_col, sizing_mode="stretch_both"), + sizing_mode="stretch_both", + min_height=400, + ) + +template = pn.template.FastListTemplate( + main=[...], + sidebar=[...], +) + +template; +``` + +## Yield to show intermediate values + +### Good + +Use a generator (yield) to provide incremental updates. + +```{pyodide} +def increment_to_value(value): + for i in range(value): + time.sleep(0.1) + yield i + +slider = pn.widgets.IntSlider(start=1, end=10) +output = pn.bind(increment_to_value, slider.param.value_throttled) +pn.Row(slider, output) +``` + +## Watch side effects + +### Good + +For functions that trigger side effects, i.e. do not return anything (or returns None), be sure to set `watch=True` on `pn.bind` or `pn.depends`. + +```{pyodide} +def print_clicks(clicks): + print(f"Number of clicks: {clicks}") + +button = pn.widgets.Button(name="Click me!") +pn.bind(print_clicks, button.param.clicks, watch=True) +button +``` + +### Good + +For buttons, you can also use `on_click`. + +```{pyodide} +def print_clicks(event): + clicks = event.new + print(f"Number of clicks: {clicks}") + +button = pn.widgets.Button(name="Click me!", on_click=print_clicks) +button +``` + +### Okay + +For all other widgets, use `obj.param.watch()` for side effects. + +```{pyodide} +def print_clicks(event): + clicks = event.new + print(f"Number of clicks: {clicks}") + +button = pn.widgets.Button(name="Click me!") +button.param.watch(print_clicks, "clicks") +button +``` + +## Refreshing layout objects + +### Good + +Updating the `objects` on a layout should be done via the methods on the layout itself: + +```{pyodide} +def print_objects(event): + print(f"Got new {[pane.object for pane in event.new]}") + +col = pn.Column("a", "b") + +col.param.watch(print_objects, 'objects') + +col +``` + +```{pyodide} +col[:] = ["c", *col.objects[1:]] +``` + +### Wrong + +Modifying container `objects` by index will not trigger the callback. + +```{pyodide} +def print_objects(event): + print(f"Got new {event.new}") + +col = pn.Column("a", "b") + +col.param.watch(print_objects, "objects") + +col +``` + +```{pyodide} +col.objects[0] = ["c"] # does not trigger +``` + +### Good + +However, you **can** modify the container by index using the APIs on the component itself. + +```{pyodide} +def print_objects(event): + print(f"Got new {[pane.object for pane in event.new]}") + +col = pn.Column("a", "b") + +col.param.watch(print_objects, "objects") + +col +``` + +```{pyodide} +# col.objects[0] = 'Foo' # no +col[0] = 'Foo' # yes +``` diff --git a/doc/how_to/best_practices/index.md b/doc/how_to/best_practices/index.md new file mode 100644 index 0000000000..1aa64a2658 --- /dev/null +++ b/doc/how_to/best_practices/index.md @@ -0,0 +1,31 @@ +# Apply best practices + +This section is to provide a checklist of best practices for developing with Panel and designing Panel applications so that you can create maintainable, production-ready, polished, and performant web applications. + +::::{grid} 1 2 2 3 +:gutter: 1 1 1 2 + +:::{grid-item-card} {octicon}`gear;2.5em;sd-mr-1 sd-animate-grow50` Developer Experience +:link: dev_experience +:link-type: doc + +A checklist of best practices for developing with Panel +::: + +:::{grid-item-card} {octicon}`gear;2.5em;sd-mr-1 sd-animate-grow50` User Experience +:link: user_experience +:link-type: doc + +A checklist of best practices for designing Panel applications +::: + +:::: + +```{toctree} +:titlesonly: +:hidden: +:maxdepth: 2 + +dev_experience +user_experience +``` diff --git a/doc/how_to/best_practices/user_experience.md b/doc/how_to/best_practices/user_experience.md new file mode 100644 index 0000000000..58ce154758 --- /dev/null +++ b/doc/how_to/best_practices/user_experience.md @@ -0,0 +1,358 @@ +# User Experience + +```{pyodide} +import time +import random + +import param +import pandas as pd +import panel as pn + +pn.extension() +``` + +The best practices described on this page serve as a checklist of items to keep in mind as you are developing your application. They include items we see users frequently get confused about or things that are easily missed but can make a big difference to the user experience of your application(s). + +:::{note} +- Good: recommended, works. +- Okay: works (with intended behavior), potentially inefficient. +- Bad: Deprecated (may or may not work), just don't do it. +- Wrong: Not intended behavior, won't really work. +::: + +## Update params effectively + +### Good + +Use `obj.param.update`: + +- to update multiple parameters on an object simultaneously +- as a context manager to temporarily set values, restoring original values on completion + +```{pyodide} +def run(event): + with progress.param.update( + bar_color="primary", + active=True, + ): + for i in range(0, 101): + time.sleep(0.01) + progress.value = i + +button = pn.widgets.Button(name="Run", on_click=run) +progress = pn.indicators.Progress(value=100, active=False, bar_color="dark") +pn.Row(button, progress) +``` + +### Okay + +The following shows setting parameters individually, which could be inefficient. + +```{pyodide} +def run(event): + try: + progress.bar_color = "primary" + progress.active = True + for i in range(0, 101): + time.sleep(0.01) + progress.value = i + finally: + progress.bar_color = "dark" + progress.active = False + +button = pn.widgets.Button(name="Run", on_click=run) +progress = pn.indicators.Progress(value=100, active=False, bar_color="dark") +pn.Row(button, progress) +``` + +## Throttle slider callbacks + +### Good + +To prevent sliders from triggering excessive callbacks, set `throttled=True` so that it only triggers once upon mouse-up. + +```{pyodide} +pn.extension(throttled=True) + +def callback(value): + time.sleep(2) + return f"# {value}" + +slider = pn.widgets.IntSlider(end=10) +output = pn.bind(callback, slider) +pn.Row(slider, output) +``` + +### Good + +Alternatively, limit the scope by binding against `value_throttled` instead of `value`. + +```{pyodide} +def callback(value): + time.sleep(2) + return f"# {value}" + +slider = pn.widgets.IntSlider(end=10) +output = pn.bind(callback, slider.param.value_throttled) +pn.Row(slider, output) +``` + +### Bad + +If the operation is expensive, binding against `value` can be really slow. + +```{pyodide} +def callback(value): + time.sleep(2) + return f"# {value}" + +slider = pn.widgets.IntSlider(end=10) +output = pn.bind(callback, slider.param.value) +pn.Row(slider, output) +``` + +## Defer expensive operations + +### Good + +Its easy defer the execution of all bound and displayed functions with `pn.extension(defer_load=True)` (note this applies to served applications, not to interactive notebook environments): + +```{pyodide} +pn.extension(defer_load=True, loading_indicator=True) + +def onload(): + time.sleep(5) # simulate expensive operations + return pn.Column( + "Welcome to this app!", + ) + +layout = pn.Column("Check this out!", onload) +# layout.show() +``` + +### Okay + +If you need finer control, start by instantiating the initial layout with placeholder `pn.Columns`, then populate it later in `onload`. + +```{pyodide} +import time + +def onload(): + time.sleep(1) # simulate expensive operations + layout[:] = ["Welcome to this app!"] + +layout = pn.Column("Loading...") +display(layout) +pn.state.onload(onload) +``` + +## Show indicator while computing + +### Good + +Set `loading=True` to show a spinner while processing to let the user know it's working. + +```{pyodide} +def compute(event): + with layout.param.update(loading=True): + time.sleep(3) + layout.append("Computation complete!") + +button = pn.widgets.Button(name="Compute", on_click=compute) +layout = pn.Column("Click below to compute", button) + +layout +``` + +### Okay + +You can also wrap a `try/finally` to do the same thing. + +```{pyodide} +def compute(event): + try: + layout.loading = True + time.sleep(3) + layout.append("Computation complete!") + finally: + layout.loading = False + +button = pn.widgets.Button(name="Compute", on_click=compute) +layout = pn.Column("Click below to compute", button) + +layout +``` + +## Manage exceptions gracefully + +### Good + +Use: +- `try` block to update values on success +- `except` block to update values on exception +- `finally` block to update values regardless + +```{pyodide} +import time + +def compute(divisor): + try: + busy.value = True + time.sleep(1) + output = 1 / divisor + text.value = "Success!" + except Exception as exc: + output = "Undefined" + text.value = f"Error: {exc}" + finally: + busy.value = False + return f"Output: {output}" + +busy = pn.widgets.LoadingSpinner(width=10, height=10) +text = pn.widgets.StaticText() + +slider = pn.widgets.IntSlider(name="Divisor") +output = pn.bind(compute, slider) + +layout = pn.Column(pn.Row(busy, text), slider, output) +layout +``` + +## Cache values for speed + +### Good + +Wrap the decorator `pn.cache` for automatic handling. + +```{pyodide} +@pn.cache +def callback(value): + time.sleep(2) + return f"# {value}" + +slider = pn.widgets.IntSlider(end=3) +output = pn.bind(callback, slider.param.value_throttled) +pn.Row(slider, output) +``` + +### Okay + +Or, manually handle the cache with `pn.state.cache`. + +```{pyodide} +def callback(value): + output = pn.state.cache.get(value) + if output is None: + time.sleep(2) + output = f"# {value}" + pn.state.cache[value] = output + return output + +slider = pn.widgets.IntSlider(end=3) +output = pn.bind(callback, slider.param.value_throttled) +pn.Row(slider, output) +``` + +## Preserve axes ranges on update + +### Good + +To prevent the plot from resetting to its original axes ranges when zoomed in, simply wrap `hv.DynamicMap`. + +```{pyodide} +import numpy as np +import holoviews as hv +hv.extension("bokeh") + +data = [] + +def add_point(clicks): + data.append((np.random.random(), (np.random.random()))) + return hv.Scatter(data) + +button = pn.widgets.Button(name="Add point") +plot = hv.DynamicMap(pn.bind(add_point, button.param.clicks)) +pn.Column(button, plot) +``` + +### Okay + +If you want the object to be completely refreshed, simply drop `hv.DynamicMap`. If it's a long computation, it's good to set `loading_indicator=True`. + +```{pyodide} +import numpy as np +import holoviews as hv +hv.extension("bokeh") +pn.extension(defer_load=True, loading_indicator=True) + +data = [] + +def add_point(clicks): + data.append((np.random.random(), (np.random.random()))) + return hv.Scatter(data) + +button = pn.widgets.Button(name="Add point") +plot = pn.bind(add_point, button.param.clicks) +pn.Column(button, plot) +``` + +## FlexBox instead of Column/Row + +### Good + +`pn.FlexBox` automatically moves objects to another row/column, depending on the space available. + +```{pyodide} +rcolor = lambda: "#%06x" % random.randint(0, 0xFFFFFF) + +pn.FlexBox( + pn.pane.HTML(str(5), styles=dict(background=rcolor()), width=1000, height=100), + pn.pane.HTML(str(5), styles=dict(background=rcolor()), width=1000, height=100) +) +``` + +### Okay + +`pn.Column`/`pn.Row` will overflow if the content is too long/wide. + +```{pyodide} +rcolor = lambda: "#%06x" % random.randint(0, 0xFFFFFF) + +pn.Row( + pn.pane.HTML(str(5), styles=dict(background=rcolor()), width=1000, height=100), + pn.pane.HTML(str(5), styles=dict(background=rcolor()), width=1000, height=100) +) +``` + +## Reuse objects for efficiency + +### Good + +Imagine Panel components as placeholders and use them as such rather than re-creating them on update. + +```{pyodide} +def randomize(event): + df_pane.object = pd.DataFrame(np.random.randn(10, 3), columns=list("ABC")) + + +button = pn.widgets.Button(name="Compute", on_click=randomize) +df_pane = pn.pane.DataFrame() +button.param.trigger("clicks") # initialize + +pn.Column(button, df_pane) +``` + +### Okay + +This instantiates the `pn.pane.DataFrame` on every click. + +```{pyodide} +def randomize(clicks): + return pn.pane.DataFrame(pd.DataFrame(np.random.randn(10, 3), columns=list("ABC"))) + +button = pn.widgets.Button(name="Compute") +df_pane = pn.bind(randomize, button.param.clicks) +button.param.trigger("clicks") # initialize + +pn.Column(button, df_pane) +``` diff --git a/doc/how_to/index.md b/doc/how_to/index.md index b10256724a..67295f41fd 100644 --- a/doc/how_to/index.md +++ b/doc/how_to/index.md @@ -189,6 +189,13 @@ How to cache data across sessions and memoize the output of functions. How to improve the scalability of your Panel application. ::: +:::{grid-item-card} {octicon}`pulse;2.5em;sd-mr-1 sd-animate-grow50` Best Practices +:link: best_practices/index +:link-type: doc + +A checklist of best practices for improving the development and user experience with Panel. +::: + :::{grid-item-card} {octicon}`shield-check;2.5em;sd-mr-1 sd-animate-grow50` Add authentication :link: authentication/index :link-type: doc diff --git a/doc/how_to/prepare_to_share.md b/doc/how_to/prepare_to_share.md index ce4e7dd578..f77e9a3d9a 100644 --- a/doc/how_to/prepare_to_share.md +++ b/doc/how_to/prepare_to_share.md @@ -14,5 +14,6 @@ Apply Templates Improve Performance Cache Data Improve Scalability +Best Practices Add Authentication ```