Could anyone explain why these two reactive functions work differently and what I should do to get them to work the same?

Hi all.

I’m still struggling a bit with getting my head around param and panel.

Everything I want to do in terms of making apps is going to be visualising hierarchies of custom data types. Here I’ve made very simplified fictional example that doesn’t use any of the visualisation power of the holoviz ecosystem, to illustrate my problem.

  • The example is an app for visualising data about stamp collectors.
  • There are three parameterized classes: Collector, Album and Stamp. An Album has the parameter stamps, which is a list of Stamp objects contained in the Album, and Collector has the parameter
    albums, a list of Album objects associated with that Collector.
  • The app should present a dropdown to choose a Collector from a list.
  • Once a Collector has been chosen, it should present a list of that Collector’s Albums.
  • Based on the selection of one of those Albums, the app will display information on all the Stamps in the relevant Album.

So in this simple implementation, there are two reactive functions:

  1. collector_album_selector:
    • gets passed the panel.widgets.Select object that provides a choice of on Collector from the list of all Collectors.
    • returns another panel.widget.Selector object with a list of all the chosen Collector’s Albums
  2. stamps_layout:
    • gets passed the reactive function above, which provides the user’s choice of Album
    • returns a GridBox layout of Stamp info

My questions:

A. For reactive function (1), I was able to pass the widget and the bind function extracted the value automatically. For reactive function (2), the thing I was passing in was not a widget, but another reactive function that returns a widget. I found that this did not automatically provide the widget value, and I had to get that in the bound function using the .value property. Is there a better way of doing this, and is this going to cause me any problems?

B. More importantly, the first reactive function works as intended, updating the Album dropdown when the Collector dropdown is changed. However, the second reactive function only works when the first reactive function is triggered. At that time it returns the layout for the list of Stamps corresponding to the default (first) Album, but when the selection of Album is updated, it doesn’t react.

Perhaps this has something to do with instantiation, and perhaps I could get it working by fiddling with that, but I wanted to ask for guidance in order to really understand what’s going on here. Perhaps there’s somewhere I should be looking in the documentation, but I don’t think this use case is discussed there. As I said, for me this—chaining widgets in order to be able to drill down into hierarchically arranged data types—would be a central use case for these packages.

Many thanks for any clarification. Here’s the code…


import random

import panel as pn
import param as pm
import names


# Define the classes

class Stamp(pm.Parameterized):
    """Represents a stamp"""
    issued = pm.Integer(1900, bounds=(1800, 2030), doc="year of issue")
    value_in_euros = pm.Number(default=0.69, bounds=(0, None), doc="value in euros")

class Album(pm.Parameterized):
    """Represents a stamp album"""
    stamps = pm.List(default=[], item_type=Stamp)

class Collector(pm.Parameterized):
    """Represents a collector of stamps"""
    name = pm.String(default="", doc="Name of collector")
    albums = pm.List(default=[], item_type=Album)


# Generate some sample data…

def _get_stamps(num):
    """Return a list of num Stamp objects with random parameter values"""
    years = [random.randint(1800, 2030) for _ in range(0, num)]
    values = [round(random.random()*3, 2) for _ in range(0, num)]
    stamps = [Stamp(issued=year, value_in_euros=value) for year, value in zip(years, values)]
    return stamps

def _get_albums(num):
    """Return a list of num Album objects, each populated with a random number of Stamp objects"""
    albums = [Album(stamps=_get_stamps(random.randint(5, 18))) for _ in range(0, num)]
    return albums

def _get_collectors(num):
    """Return a list of num Collector objects, each """
    fullnames = [names.get_full_name() for _ in range(0, num)]
    collectors = [Collector(name=fullname, albums=_get_albums(random.randint(1,6))) for fullname in fullnames]
    return collectors

collectors = _get_collectors(8)


# Panel app

template = pn.template.BootstrapTemplate(title="JM\'s template")

# For the sidebar: a pn.widgets.Select object that lists the stamp collectors by name
collector_selector = pn.widgets.Select(name="Collector", options={collector.name: collector for collector in collectors})
template.sidebar.append(collector_selector)

def _album_block(collector):
    """Based on the list of albums held by a collector. Currently just provodes a pn.widgets.Select object to choose one
    from the list."""
    return pn.widgets.Select(name="Album", options={album.name: album for album in collector.albums})

def _stamp_block(stamp):
    """Returns a Markdown pane with a representation of the Stamp object passed"""
    return pn.pane.Markdown(f"Issued in {stamp.issued}, value: {stamp.value_in_euros}")

def _stamps_grid(album_selector):
    """Returns a grid of stamp representations from the list of Stamps contained in the Album object that is the
    current value of the album_selector, a pn.widgets.Select object."""
    album = album_selector.value
    return pn.GridBox(*[_stamp_block(stamp) for stamp in album.stamps], ncols=4)

# Define reactive functions
collector_album_selector = pn.bind(_album_block, collector_selector)
stamps_layout = pn.bind(_stamps_grid, collector_album_selector)

# Place reactive functions in the template
content = pn.Column(collector_album_selector, stamps_layout)
template.main.append(content)
template.show()




Hi - I’m still stuck with this, very grateful for any pointers. Thanks in advance!

I’m still learning pn.bind and I don’t know how to pass a returned object from pn.bind into another pn.bind.

I personally like to pre-define all my widgets/layouts, and then just modify its values/objects

import random

import panel as pn
import param as pm


# Define the classes


class Stamp(pm.Parameterized):
    """Represents a stamp"""

    issued = pm.Integer(1900, bounds=(1800, 2030), doc="year of issue")
    value_in_euros = pm.Number(default=0.69, bounds=(0, None), doc="value in euros")


class Album(pm.Parameterized):
    """Represents a stamp album"""

    stamps = pm.List(default=[], item_type=Stamp)


class Collector(pm.Parameterized):
    """Represents a collector of stamps"""

    name = pm.String(default="", doc="Name of collector")
    albums = pm.List(default=[], item_type=Album)


# Generate some sample data…


def _get_stamps(num):
    """Return a list of num Stamp objects with random parameter values"""
    years = [random.randint(1800, 2030) for _ in range(0, num)]
    values = [round(random.random() * 3, 2) for _ in range(0, num)]
    stamps = [
        Stamp(issued=year, value_in_euros=value) for year, value in zip(years, values)
    ]
    return stamps


def _get_albums(num):
    """Return a list of num Album objects, each populated with a random number of Stamp objects"""
    albums = [Album(stamps=_get_stamps(random.randint(5, 18))) for _ in range(0, num)]
    return albums


def _get_collectors(num):
    """Return a list of num Collector objects, each"""
    fullnames = [str(i) for i in range(0, num)]
    collectors = [
        Collector(name=fullname, albums=_get_albums(random.randint(1, 6)))
        for fullname in fullnames
    ]
    return collectors


collectors = _get_collectors(8)


def _album_block(collector):
    """Based on the list of albums held by a collector. Currently just provodes a pn.widgets.Select object to choose one
    from the list."""
    collector_album_selector.param.set_param(
        options={album.name: album for album in collector.albums},
        value=collector.albums[0],
    )


def _stamp_block(stamp):
    """Returns a Markdown pane with a representation of the Stamp object passed"""
    return pn.pane.Markdown(f"Issued in {stamp.issued}, value: {stamp.value_in_euros}")


def _stamps_grid(album):
    """Returns a grid of stamp representations from the list of Stamps contained in the Album object that is the
    current value of the album_selector, a pn.widgets.Select object."""
    stamps_layout.objects = [_stamp_block(stamp) for stamp in album.stamps]


template = pn.template.BootstrapTemplate(title="JM's template")

# For the sidebar: a pn.widgets.Select object that lists the stamp collectors by name
collector_selector = pn.widgets.Select(
    name="Collector", options={collector.name: collector for collector in collectors}
)
collector_album_selector = pn.widgets.Select(
    name="Album",
    options={album.name: album for album in collector_selector.value.albums},
)
stamps_layout = pn.GridBox(ncols=4)

content = pn.Column(collector_album_selector, stamps_layout)
template.main.append(content)

# Define interactivity
pn.bind(_album_block, collector_selector, watch=True)
pn.bind(_stamps_grid, collector_album_selector, watch=True)

template.show()

But I believe the problem you’re having should have a solution so you may want to submit a GitHub issue!

1 Like

This is pretty recent in Panel, you can now pass references to a Panel component, like widgets, Parameters or bound functions. Panel will take care of updating the component attribute when the references are updated. The end of your script would look like this, with the options of the w_album widget being updated whenever the value of w_collector changes.

w_collector = pn.widgets.Select(options=collectors)
w_album = pn.widgets.Select(options=pn.bind(lambda c: c.albums, w_collector))

def _stamp_block(stamp):
    """Returns a Markdown pane with a representation of the Stamp object passed"""
    return pn.pane.Markdown(f"Issued in {stamp.issued}, value: {stamp.value_in_euros}")

def _stamps_grid(album_selector):
    """Returns a grid of stamp representations from the list of Stamps contained in the Album object that is the
    current value of the album_selector, a pn.widgets.Select object."""
    album = album_selector
    return pn.GridBox(*[_stamp_block(stamp) for stamp in album.stamps], ncols=4)


pn.Column(
    w_collector,
    w_album,
    pn.bind(_stamps_grid, w_album)
)
2 Likes

Another approach you could experiment with is the support for nested Parameterized UIs (Create nested UIs — Panel v1.2.0).

import random

import panel as pn
import param as pm

pn.extension()

# Define the classes

class Stamp(pm.Parameterized):
    """Represents a stamp"""
    issued = pm.Integer(1900, bounds=(1800, 2030), doc="year of issue")
    value_in_euros = pm.Number(default=0.69, bounds=(0, None), doc="value in euros")

class Album(pm.Parameterized):
    """Represents a stamp album"""
    stamps = pm.Selector()

    def __init__(self, stamps, **params):
        super().__init__(**params)
        self.param.stamps.objects = stamps
    
class Collector(pm.Parameterized):
    """Represents a collector of stamps"""
    name = pm.String(default="", doc="Name of collector")
    albums = pm.Selector()
    
    def __init__(self, albums, **params):
        super().__init__(**params)
        self.param.albums.objects = albums

# Generate some sample data…

def _get_stamps(num):
    """Return a list of num Stamp objects with random parameter values"""
    years = [random.randint(1800, 2030) for _ in range(0, num)]
    values = [round(random.random()*3, 2) for _ in range(0, num)]
    stamps = [Stamp(issued=year, value_in_euros=value) for year, value in zip(years, values)]
    return stamps

def _get_albums(num):
    """Return a list of num Album objects, each populated with a random number of Stamp objects"""
    albums = [Album(stamps=_get_stamps(random.randint(5, 18))) for _ in range(0, num)]
    return albums

def _get_collectors(num):
    """Return a list of num Collector objects, each """
    fullnames = [str(i) for i in range(0, num)]
    collectors = [Collector(name=fullname, albums=_get_albums(random.randint(1,6))) for fullname in fullnames]
    return collectors

collectors = _get_collectors(8)

class Hierarchy(pm.Parameterized):
    collector = pm.Selector()

    def __init__(self, collectors, **params):
        super().__init__(**params)
        self.param.collector.objects = collectors
    
h = Hierarchy(collectors)

pn.panel(h)
1 Like

(@ahuang11 .param.set_param has been deprecated since Param 1.12.0 :upside_down_face: Not many users realized so as no run-time warning was put in place, there will be one starting from Param 2.0. It is replaced by .param.update.)

1 Like

Oh I semi-remember using param.update but I suppose it was habit to use param.set_param haha.

Slowly going away from the old methods param.watch to pn.bind

Thanks all, will take my time to study the suggestions above and post my experiences!

I like pn.bind(...) as much as I dislike pn.bind(..., watch=True) ^^

Very poetic…

Curious to hear why. Is it because people forget to use watch=True (me)?

Without watch I find pn.bind very clearly defined as it’s modeled on top of functools.partial, returning a partial/bound function. You pass that to Panel that then takes care of re-running the bound function when one of its references is updated to replace the output by the new one. So you can import a callable from a library and turn that into a small app easily:

from lib import model
import panel as pn

w = pn.widgets.FloatSlider()

pn.Column(w, pn.bind(model, input=w))

On the other hand pn.bind together with watch=True is conceptually different, you’re no longer interested in the output of the function but purely in its side effects. In which case, the bound function it returns is really of no interest.

That is actually close to the difference between @param.depends with or without watch=True, except that @param.depends has the advantage of being localized, decorating the callback, while pn.bind(..., watch=True) can be called anywhere. So for side-effects functions, I think I preferred decorating them with @param.depends(..., watch=True) as it clearly marked them as such.

Overall I have never been a big fan of @param.depends(..., watch=True) and param.bind(..., watch=True) feels worse. I’d probably prefer a top level param.watch function and decorator.

3 Likes

Hi — OK have had a chance to look at these suggestions now, thanks so much for taking the time to explain.

Do you have a view on which of these is preferable before I commit myself to any one of them? Is any one of them more robust or future-proof?

@ahuang11’s solution is the most intuitive to my way of thinking.

@maximlt’s first solution is probably closest conceptually to what I was originally trying to do. I was actually using 1.1.1, so have updated and tried this out now.

@maximlt’s second solution is probably closest to what I would like to be able to do, but it looks as if it’s quite constraining in terms of layout, as it relies on panel.Param if I understand correctly.

Thanks again

Try this out:

import random
import param as pm
import panel as pn
pn.extension()
pn.__version__

#import names
# Using Faker instead of names package (due to my version requirements)
from faker import Faker 
names = Faker()


class Stamp(pm.Parameterized):
    """Represents a stamp."""
    issued = pm.Integer(1900, bounds=(1800, 2030), doc="year of issue")
    value_in_euros = pm.Number(default=0.69, bounds=(0, None), doc="value in euros")
    
    def _repr_markdown_(self):
        """Added to simplify gridbox items creation."""
        pv = self.param.values()
        md = f"**{pv.get('name')}** :: "
        md += f"Issued in: {pv.get('issued')}, value: €{pv.get('value_in_euros'):,.2f}"
        return md
    

class Album(pm.Parameterized):
    """Represents an album of stamps."""
    stamps = pm.List(default=[], item_type=Stamp)

class Collector(pm.Parameterized):
    """Represents a collector of stamps"""
    name = pm.String(default="", doc="Name of collector")
    albums = pm.List(default=[], item_type=Album)


# Generate some sample data
def _get_stamps(num):
    """Return a list of num Stamp objects with random parameter values"""
    years = [random.randint(1800, 2030) for _ in range(0, num)]
    values = [round(random.random()*3, 2) for _ in range(0, num)]
    stamps = [Stamp(issued=year, value_in_euros=value) for year, value in zip(years, values)]
    return stamps

def _get_albums(num):
    """Return a list of num Album objects, each populated with a random number of Stamp objects"""
    albums = [Album(stamps=_get_stamps(random.randint(5, 18))) for _ in range(0, num)]
    return albums

def _get_collectors(num):
    """Return a list of num Collector objects, each """
    # Uncomment this next assignment if working with the names package:
    #fullnames = [names.get_full_name() for _ in range(0, num)]
    # Above modified for Faker instead of names package (due to my version requirements):
    #   from faker import Faker
    #   names = Faker()
    fullnames = [names.name() for _ in range(num)]  # comment out with names package
    collectors = [Collector(name=fullname, albums=_get_albums(random.randint(1,6))) for fullname in fullnames]

    return collectors



class CollectionViewer(pm.Parameterized):
    """Refactored from google_map_viewer.
    src: https://github.com/awesome-panel/awesome-panel/blob/master/examples/google_map_viewer.py"""
    
    collectors_names = pm.Selector(objects=collectors, label="Collectors")
    albums_names = pm.Selector(label="Albums", objects=[])

    settings_panel = pm.Parameter()
    display_panel = pm.Parameter()
    
    
    def __init__(self, grid_cols=1, **kwargs):
        super().__init__(**kwargs)
        self.settings_panel = pn.Param(self, parameters=["collectors_names", "albums_names"])
        self.display_panel = pn.GridBox([], ncols=grid_cols)
        self._update_albums()


        
    @pm.depends('collectors_names', watch=True)
    def _update_albums(self):
        A = self.collectors_names.albums
        self.param.albums_names.objects = A
        self.albums_names = A[0]
        self.view()
    
    
    @pm.depends('albums_names', watch=True)
    def view(self):
        what = [pn.pane.Markdown(f"## {self.collectors_names.name} - {self.albums_names.name}")]
        what += [pn.pane.Markdown(stamp) for stamp in self.albums_names.stamps]
        self.display_panel.objects = what
        return self.display_panel



template = pn.template.BootstrapTemplate(title="JM2\'s template")

collectors = _get_collectors(4)
viewer = CollectionViewer()

template.sidebar.append(viewer.settings_panel)
template.main.append(viewer.display_panel)

template.show()
1 Like

Thanks, have just seen this, will have a look when I get a chance.