holoviz / panel

Panel: The powerful data exploration & web app framework for Python
https://panel.holoviz.org
BSD 3-Clause "New" or "Revised" License
4.75k stars 517 forks source link

Code works with Panel 0.14.2 but breaks with Panel 1.2.3. #5723

Open m7200 opened 1 year ago

m7200 commented 1 year ago

ALL software version info

bleach==6.1.0 bokeh==3.2.2 Bottleneck==1.3.7 Cartopy==0.22.0 certifi==2023.7.22 cftime==1.6.3 charset-normalizer==3.3.0 click==8.1.7 cloudpickle==3.0.0 colorama==0.4.6 colorcet==3.0.1 contourpy==1.1.1 cycler==0.12.1 dask==2023.10.0 datashader==0.15.2 datashape==0.5.2 fonttools==4.43.1 fsspec==2023.9.2 geojson==2.5.0 geoviews==1.10.1 holoviews==1.17.1 idna==3.4 importlib-metadata==6.8.0 Jinja2==3.1.2 kiwisolver==1.4.5 linkify-it-py==2.0.2 llvmlite==0.40.1 locket==1.0.0 lz4==4.3.2 Markdown==3.5 markdown-it-py==3.0.0 MarkupSafe==2.1.3 matplotlib==3.7.2 mdit-py-plugins==0.4.0 mdurl==0.1.2 multipledispatch==1.0.0 netCDF4==1.6.4 numba==0.57.1 numpy==1.24.4 packaging==23.2 pandas==2.1.1 panel==1.2.3 param==1.13.0 partd==1.4.1 Pillow==10.1.0 pyarrow==13.0.0 pyct==0.5.0 pyparsing==3.0.9 pyproj==3.6.0 pyshp==2.3.1 python-dateutil==2.8.2 pytz==2023.3.post1 pyviz_comms==3.0.0 PyYAML==6.0.1 requests==2.31.0 scipy==1.9.3 shapely==2.0.1 six==1.16.0 toolz==0.12.0 tornado==6.3.3 tqdm==4.66.1 typing_extensions==4.8.0 tzdata==2023.3 uc-micro-py==1.0.2 urllib3==2.0.7 webencodings==0.5.1 xarray==2023.10.1 xyzservices==2023.10.0 zipp==3.17.0

Description of expected behavior and the observed behavior

I have the following code which works under Panel 0.14.2 and executes under 1.2.3 but does not produce a correct result.

The code under 0.14.2 creates two rows (A and B) with each row having a FloatInput and controls (up and down) which alter the order of the display of the two rows. Up arrow moves the row up, down arrow moves the row down. This works fine under 0.14.2. When Up arrow of layer A is pressed, layer A and its FloatInput move to the top (correct layer A value maintained 7.9). Under 1.2.3 when either using the up or down arrows results in the layer FloatInput values in both layers being modified (swapped).

Complete, minimal, self-contained example code that reproduces the issue

import panel as pn
import param

import logging

log = logging.getLogger('test_logger')
logging.basicConfig(format='%(lineno)d - %(name)s - %(asctime)s - %(levelname)s '
                    '- %(message)s', level=logging.DEBUG)
log.setLevel(logging.DEBUG)

pn.extension()

class LayerModel(param.Parameterized):

    scale_min = param.Number(0, instantiate=True)
    tname = param.String(default='none', instantiate=True)

    def handle_set_scale_min(self, *events):

        for event in events:
            if event.name == 'value':         
                self.scale_min = float(event.obj.value)
                log.info('handle_set_scale_min: %s' % self.scale_min)
                log.debug(f"(event: {event.name} changed from {event.old} to {event.new})")

                self.param.trigger('scale_min')

class LayerListItem(param.Parameterized):

    delete = param.Event()
    move_layer_up = param.Event()
    move_layer_down = param.Event()

class LayerList(param.Parameterized):

    def add_layer(self, layer_model):

        log.info('LayerList.add_layer')

        new_item = LayerListItem(
            parent=self, 
            layer_model=layer_model, 

        )       
        new_item.param.watch(self._delete_layer, ['delete'])
        new_item.param.watch(self._move_layer_up, ['move_layer_up'])
        new_item.param.watch(self._move_layer_down, ['move_layer_down'])

        self.layer_list.append(new_item)
        log.debug(f'New item {new_item}')
        self.param.trigger('layer_list')

    def _delete_layer(self, *events):
        for event in events:
            if event.name == 'delete':
                list_index = self.layer_list.index(event.obj)
                log.debug('DELETE layer at index: %s' % list_index)   
                layer_item = self.layer_list[list_index]  
                self.layer_list.remove(event.obj)
                self.param.trigger('layer_list')

    def _move_layer_up(self, *events):
        x = 0
        for event in events:
            if event.name == 'move_layer_up':
                # no move
                if len(self.layer_list) == 1:
                    return
                layer_index = self.layer_list.index(event.obj)

                if layer_index < len(self.layer_list) - 1:
                    self.layer_list.pop(layer_index)
                    self.layer_list.insert(layer_index+1, event.obj)
                    self.param.trigger('layer_list')

    def _move_layer_down(self, *events):
        for event in events:
            if event.name == 'move_layer_down':
                # do move
                if len(self.layer_list) == 1:
                    return
                layer_index = self.layer_list.index(event.obj)

                if layer_index > 0:
                    self.layer_list.pop(layer_index)
                    self.layer_list.insert(layer_index-1, event.obj)
                    self.param.trigger('layer_list')

    layer_list = param.List([], item_type=LayerListItem, instantiate=True)
    delete_layer = param.Action(_delete_layer)
    move_layer_up = param.Action(_move_layer_up)
    move_layer_down = param.Action(_move_layer_down)

class LayerLegendView():

    def __init__(self, layer_item): 

        self.layer_model = layer_item.layer_model

        self.title = pn.pane.HTML(
            object='Doc ' + self.layer_model.tname,
            styles={'font-weight': 'bold'}
        )

        self.scale_min_input = pn.widgets.FloatInput(
                value=self.layer_model.scale_min,
                name= self.layer_model.tname + "_min",
                max_width=120,
                sizing_mode='stretch_width'
        )

        log.debug(f'Min input {self.scale_min_input}')
        test = self.scale_min_input.param.watch(self.layer_model.handle_set_scale_min, 'value')
        log.debug(f'Watcher {test}')

        def z_up_button(item): 
            return pn.Param(
                item,
                widgets={
                    'move_layer_up': {
                        'widget_type': pn.widgets.Button,
                        'name': '▲',
                    }})

        def z_down_button(item): 
            return pn.Param(item,
                widgets={
                    'move_layer_down': {
                        'widget_type': pn.widgets.Button,
                        'name': '▼',
                    }}
            )

        def remove_button(item): 
            return pn.Param(
                item,
                widgets={
                    'delete': {
                        'widget_type': pn.widgets.Button,
                        'name': '✖',
                    }})

        self.control_pane = pn.Column(
            objects=[
                z_up_button(layer_item.param.move_layer_up),
                remove_button(layer_item.param.delete),
                z_down_button(layer_item.param.move_layer_down),
            ],
        )

        legend_margin = (0, 5, 0, 5)

        self.legend_pane = pn.Column(
            objects=[
                pn.Row(
                    self.title, 
                    sizing_mode='stretch_width',
                    margin=(10, 5, 0, 5)
                ), 
                pn.Row(
                    pn.Column(self.scale_min_input, width=80),
                    margin=legend_margin
                ),
            ],
            sizing_mode='stretch_width',
            styles=dict(border='1px dashed black'),
        )

    def ui(self):

        x = 0
        return pn.Column(
            '',
            pn.Row(
                self.legend_pane,
                self.control_pane
            ),
        )

def ui(layer_list_model): 

    @pn.depends(layer_list_model.param.layer_list)
    def render_list(item_list):

        def get_legend(layer_item):

            legend = LayerLegendView(layer_item)
            return legend.ui()

        col = pn.Column(sizing_mode='stretch_width')
        for ix, layer_item in enumerate(reversed(item_list)):
            log.debug(f'Current items in tem list {ix} layer {layer_item}')
        for layer_item in reversed(item_list):
            col.append(pn.Row(
                get_legend(layer_item),
                sizing_mode='stretch_width'
            ))

        log.debug(f'Column {col}')
        return col

    rv = pn.Column(render_list, loading_indicator=True)
    log.debug(f'Panel {rv}')
    return  rv

a = LayerModel(tname='A', scale_min=7.9, instantiate=True)
b = LayerModel(tname='B', scale_min=8.0, instantiate=True)

z = LayerList()
z.add_layer(a)
z.add_layer(b)

first_app = pn.Column(ui(z))

first_app.servable()

Stack traceback and/or browser JavaScript console output

Screenshots or screencasts of the bug in action

image

m7200 commented 1 year ago

Work around

import panel as pn
import param

import logging

log = logging.getLogger('test_logger')
logging.basicConfig(format='%(lineno)d - %(name)s - %(asctime)s - %(levelname)s '
                    '- %(message)s', level=logging.DEBUG)
log.setLevel(logging.DEBUG)

pn.extension()

class LayerModel(param.Parameterized):

    scale_min = param.Number(0, instantiate=True)
    tname = param.String(default='none', instantiate=True)

    def handle_set_scale_min(self, *events):

        for event in events:
            if event.name == 'value':         
                self.scale_min = float(event.obj.value)
                log.info('handle_set_scale_min: %s' % self.scale_min)
                log.debug(f"(event: {event.name} changed from {event.old} to {event.new})")

                self.param.trigger('scale_min')

class LayerListItem(param.Parameterized):

    delete = param.Event()
    move_layer_up = param.Event()
    move_layer_down = param.Event()

    def __init__(self, layer_model=None,  **kwds): 
        super().__init__(**kwds)
        self.layer_model=layer_model

class LayerList(param.Parameterized):

    def add_layer(self, layer_model):

        log.info('LayerList.add_layer')

        new_item = LayerListItem(
            parent=self, 
            layer_model=layer_model, 

        )       
        new_item.param.watch(self._delete_layer, ['delete'])
        new_item.param.watch(self._move_layer_up, ['move_layer_up'])
        new_item.param.watch(self._move_layer_down, ['move_layer_down'])

        self.layer_list.append(new_item)
        log.debug(f'New item {new_item}')
        self.param.trigger('layer_list')

    def _delete_layer(self, *events):
        for event in events:
            if event.name == 'delete':
                list_index = self.layer_list.index(event.obj)
                log.debug('DELETE layer at index: %s' % list_index)   
                layer_item = self.layer_list[list_index]  
                self.layer_list.remove(event.obj)
                self.param.trigger('layer_list')

    def _move_layer_up(self, *events):
        x = 0
        for event in events:
            if event.name == 'move_layer_up':
                # no move
                if len(self.layer_list) == 1:
                    return
                layer_index = self.layer_list.index(event.obj)

                if layer_index < len(self.layer_list) - 1:

                    tmp = self.layer_list[:]
                    tmp.pop(layer_index)
                    tmp.insert(layer_index+1, event.obj)

                    self.layer_list = []

                    self.layer_list[:] = tmp[:]

                    # self.layer_list.pop(layer_index)
                    # self.layer_list.insert(layer_index+1, event.obj)

                    self.param.trigger('layer_list')

    def _move_layer_down(self, *events):
        for event in events:
            if event.name == 'move_layer_down':
                # do move
                if len(self.layer_list) == 1:
                    return
                layer_index = self.layer_list.index(event.obj)

                if layer_index > 0:

                    tmp = self.layer_list[:]
                    tmp.pop(layer_index)
                    tmp.insert(layer_index-1, event.obj)

                    self.layer_list = []

                    self.layer_list[:] = tmp[:]
                    # self.layer_list.pop(layer_index)
                    # self.layer_list.insert(layer_index-1, event.obj)
                    self.param.trigger('layer_list')

    layer_list = param.List([], item_type=LayerListItem, instantiate=True)
    delete_layer = param.Action(_delete_layer)
    move_layer_up = param.Action(_move_layer_up)
    move_layer_down = param.Action(_move_layer_down)

class LayerLegendView():

    def __init__(self, layer_item): 

        self.layer_model = layer_item.layer_model

        self.title = pn.pane.HTML(
            object='Doc ' + self.layer_model.tname,
            styles={'font-weight': 'bold'}
        )

        self.scale_min_input = pn.widgets.FloatInput(
                value=self.layer_model.scale_min,
                name= self.layer_model.tname + "_min",
                max_width=120,
                sizing_mode='stretch_width'
        )

        log.debug(f'Min input {self.scale_min_input}')
        test = self.scale_min_input.param.watch(self.layer_model.handle_set_scale_min, 'value')
        # log.debug(f'Watcher {test}')

        def z_up_button(item): 
            return pn.Param(
                item,
                widgets={
                    'move_layer_up': {
                        'widget_type': pn.widgets.Button,
                        'name': '▲',
                    }})

        def z_down_button(item): 
            return pn.Param(item,
                widgets={
                    'move_layer_down': {
                        'widget_type': pn.widgets.Button,
                        'name': '▼',
                    }}
            )

        def remove_button(item): 
            return pn.Param(
                item,
                widgets={
                    'delete': {
                        'widget_type': pn.widgets.Button,
                        'name': '✖',
                    }})

        self.control_pane = pn.Column(
            objects=[
                z_up_button(layer_item.param.move_layer_up),
                remove_button(layer_item.param.delete),
                z_down_button(layer_item.param.move_layer_down),
            ],
        )

        legend_margin = (0, 5, 0, 5)

        self.legend_pane = pn.Column(
                pn.Row(
                    self.title, 
                    sizing_mode='stretch_width',
                    margin=(10, 5, 0, 5)
                ), 
                pn.Row(
                    self.scale_min_input,
                    margin=legend_margin
                ),
            sizing_mode='stretch_width',
            styles=dict(border='1px dashed black'),
        )

    def ui(self):

        x = 0
        return pn.Row(
                self.legend_pane,
                self.control_pane, sizing_mode='stretch_width'
            )

def ui(layer_list_model): 

    @pn.depends(layer_list_model.param.layer_list)
    def render_list(item_list):
        def get_legend(layer_item):
            legend = LayerLegendView(layer_item)
            return legend.ui()
        col = pn.Column(sizing_mode='stretch_width')
        for layer_item in reversed(item_list):
            col.append(get_legend(layer_item))

        log.debug(f'Column {col}')
        return col

    rv = pn.Column(render_list)#, loading_indicator=True)
    # rv = render_list(layer_list_model.param.layer_list)
    log.debug(f'Panel {rv}')
    return  rv

a = LayerModel(tname='A', scale_min=7.9, instantiate=True)
b = LayerModel(tname='B', scale_min=8.0, instantiate=True)

z = LayerList()
z.add_layer(a)
z.add_layer(b)

first_app = pn.Column(ui(z))

first_app.servable()

Problem still appears to be evident in Panel 1.3.0 and Param 2.0.0