guidata icon indicating copy to clipboard operation
guidata copied to clipboard

Is this a bug in ChoiceWidget ?

Open picca opened this issue 1 year ago • 1 comments

Hello, I am trying to make some code written by a scientific releasable.

I find this in the code

#these lines are needed to correct an error in guidata
import inspect
from guidata.dataset.qtitemwidgets import ChoiceWidget
string = inspect.getsource(ChoiceWidget.get)
ind0 = string.index('self._first_call = False')
ind1 = string.index('self.index_changed(idx)')
txt = "error not corrected in python module guidata.dataset.qtitemwidgets\n"
txt += "in ChoiceWidget.get(self):"
txt +=  "inverting lines 'self._first_call = False' and 'self.index_changed(idx)' is needed"
assert (ind0 < ind1),txt

do you have an opinion about this ???

Cheers

Fred

picca avatar Jun 05 '24 16:06 picca

Hello Fred, Thanks for the feedback!

The fact it's needed to inverse those two lines as a workaround does not ring a bell, unfortunately. I'm not comfortable with changing this without a test case failure.

Could you provide a simple example that reproduce the bug reported by your colleague?

Maybe he/she is using a callback on a ChoiceItem, but I would really need to be able to reproduce it in order to fix it.

Now is a good time for a fix as I'm planning a maintenance release in the next days or so.

PierreRaybaut avatar Jun 05 '24 19:06 PierreRaybaut

If there are callbacks for two ChoiceItem, and if each of these callbacks has an action on the other ChoiceItem, it creates a nested loop. The code, for get() is:

def get(self) -> None:
    """Update widget contents from data item value"""
    self.initialize_widget()
    value = self.item.get()
    if value is not None:
        idx = 0
        _choices = self.item.get_prop_value("data", "choices")
        for key, _val, _img in _choices:
            if key == value:
                break
            idx += 1
        self.set_widget_value(idx)
        if self._first_call:
            self.index_changed(idx)
            self._first_call = False

In that case, each get() function is called alternatively by the two callbacks and self._first_call = False never occurs

gprevot avatar Sep 26 '24 08:09 gprevot

Hi,

Thanks for the feedback. Could you please write a simple script demonstrating what is the current (wrong) behavior and helping understand what is the behavior you would expect?

Example of script that you could start with and add your own code to illustrate your bug report:

import guidata.dataset as gds
from guidata.env import execenv
from guidata.qthelpers import qt_app_context


class Parameters(gds.DataSet):
    """Example dataset"""

    def cb_example1(self, item, value):
        """Example callback function #1"""
        execenv.print("\n*** Callback #1 ***\nitem: ", item, "\nvalue:", value)
        # Do something here

    def cb_example2(self, item, value):
        """Example callback function #2"""
        execenv.print("\n*** Callback #2 ***\nitem: ", item, "\nvalue:", value)
        # Do something here

    _choices = [
        (16, "first choice"),
        (32, "second choice"),
        (64, "third choice"),
    ]

    choice1 = gds.ChoiceItem(
        "Choice item #1",
        _choices,
        default=64,
    ).set_prop("display", callback=cb_example1)

    choice2 = (
        gds.ChoiceItem(
            "Choice item #2",
            _choices,
            default=32,
        )
        .set_pos(col=1, colspan=2)
        .set_prop("display", callback=cb_example2)
    )


def test_callbacks():
    """Test callbacks"""
    with qt_app_context():
        prm = Parameters()
        prm.edit()


if __name__ == "__main__":
    test_callbacks()

PierreRaybaut avatar Oct 01 '24 14:10 PierreRaybaut

Hello,

When I wrote my code few years ago, I found the only way to work was
to invert the two lines in ChoiceWidget

In my code, I had a class derived from DataSet, SpotShapeParam, with
two callbacks: when changing the lattice, it changes the list of
choices for latticedomain

best regards

Geoffroy

class UpdateProp(ItemProperty): """An 'operator property' prop: ItemProperty instance func: function """ def init(self, prop = None, func = None): self.property = prop self.function = func

 def __call__(self, instance, item, value):   #DataSet instance,  

DataItem instance, value of the dataitem self.function(instance, item, value) return self

def update_domain(spotshapeparam, choice_item, latticedomain): """update a SpotShapeParam instance parameters when lattice has
been changed""" if spotshapeparam.lattice is not None: if latticedomain is not None: hx, kx =
spotshapeparam.lattice.get_node_coordinates(spotshapeparam.x0,
spotshapeparam.y0, latticedomain) h = np.round(hx) k = np.round(kx) spotshapeparam._items[2].set_from_string(spotshapeparam, '%d'%h) spotshapeparam._items[3].set_from_string(spotshapeparam, '%d'%k) spotshapeparam._items[4].set_from_string(spotshapeparam, '%g'%hx) spotshapeparam._items[5].set_from_string(spotshapeparam, '%g'%kx)

def update_lattice(spotshapeparam, choice_item, lattice): """update a SpotShapeParam instance parameters when lattice has
been changed""" if lattice is not None: #update the value of h and k with the corresponding lattice spotshapeparam.latticedomain =
min(spotshapeparam.latticedomain,lattice.ndomains-1) spotshapeparam.update_domain_choices(lattice) #hx, kx = lattice.get_node_coordinates(spotshapeparam.x0,
spotshapeparam.y0, self.latticedomain) if spotshapeparam.latticedomain is not None: hx, kx = lattice.get_node_coordinates(spotshapeparam.x0,
spotshapeparam.y0, spotshapeparam.latticedomain) h = np.round(hx) k = np.round(kx) spotshapeparam._items[2].set_from_string(spotshapeparam, '%d'%h) spotshapeparam._items[3].set_from_string(spotshapeparam, '%d'%k) spotshapeparam._items[4].set_from_string(spotshapeparam, '%g'%hx) spotshapeparam._items[5].set_from_string(spotshapeparam, '%g'%kx)

class SpotShapeParam(DataSet): """DataSet for defining parameters of a SpotShape instance""" x0 = FloatItem(("x0"), default = 1.) y0 = FloatItem(("y0"), default = 0.) h = IntItem(("h_integer"), default = 0) k = IntItem(("k_integer"), default = 0) hx = FloatItem(("h_float"), default = 0.) kx = FloatItem(("k_float"), default = 0.) lattice = ChoiceItem(("associated lattice"),(None,'None'))
#instance of LatticeGrid or ReconstructionGrid lattice.set_prop("display", callback = UpdateProp(func = update_lattice)) latticedomain = ChoiceItem(
("domain index"),(None,'None')) latticedomain.set_prop("display", callback = UpdateProp(func =
update_domain))

 def update_param(self, obj):
     self.x0 = obj.x0
     self.y0 = obj.y0
     self.h = obj.h
     self.k = obj.k
     self.hx = obj.hx
     self.kx = obj.kx
     self.lattice = obj.lattice
     self.latticedomain = obj.latticedomain

 def update_shape(self, obj):
     obj.set_pos(self.x0, self.y0)
     obj.h = self.h
     obj.k = self.k
     obj.hx = self.hx
     obj.kx = self.kx
     obj.lattice = self.lattice
     obj.latticedomain = self.latticedomain
     self.update_param(obj)

 def update_choices(self,lattices):
     #lattices is the list of lattices and reconstructions in the plot
     _choices = [self._items[6]._normalize_choice(None,'None')]     

self._items[6] is a 'guidata.dataset.dataitems.ChoiceItem

     for lattice in lattices:
         label = lattice.shapeparam.label
         _choices.append(self._items[6]._normalize_choice(lattice, label))
     self._items[6].set_prop("data", choices = _choices)    #we  

update the property of the associated widget

 def update_domain_choices(self, lattice):
     #when a LatticeGrid/ReconstructionGrid instance has been  

chosen, update the choices of domains if lattice is None: _choices = [(None,'None')] else: _choices = [] for idom in range(lattice.ndomains):

_choices.append(self._items[7]._normalize_choice(idom,'%d'%idom))

     self._items[7].set_prop("data", choices = _choices)

Pierre Raybaut @.***> a écrit :

Hi,

Thanks for the feedback. Could you please write a simple script demonstrating what is the
current (wrong) behavior and helping understand what is the behavior
you would expect?

Example of script that you could start with and add your own code to
illustrate your bug report:

import guidata.dataset as gds
from guidata.env import execenv
from guidata.qthelpers import qt_app_context


class Parameters(gds.DataSet):
    """Example dataset"""

    def cb_example1(self, item, value):
        """Example callback function #1"""
        execenv.print("\n*** Callback #1 ***\nitem: ", item,  
"\nvalue:", value)
        # Do something here

    def cb_example2(self, item, value):
        """Example callback function #2"""
        execenv.print("\n*** Callback #2 ***\nitem: ", item,  
"\nvalue:", value)
        # Do something here

    _choices = [
        (16, "first choice"),
        (32, "second choice"),
        (64, "third choice"),
    ]

    choice1 = gds.ChoiceItem(
        "Choice item #1",
        _choices,
        default=64,
    ).set_prop("display", callback=cb_example1)

    choice2 = (
        gds.ChoiceItem(
            "Choice item #2",
            _choices,
            default=32,
        )
        .set_pos(col=1, colspan=2)
        .set_prop("display", callback=cb_example2)
    )


def test_callbacks():
    """Test callbacks"""
    with qt_app_context():
        prm = Parameters()
        prm.edit()


if __name__ == "__main__":
    test_callbacks()

-- Reply to this email directly or view it on GitHub: https://github.com/PlotPyStack/guidata/issues/75#issuecomment-2386210081 You are receiving this because you commented.

Message ID: @.***>

-- Geoffroy Prévot Directeur Adjoint Institut des NanoSciences de Paris 4, place Jussieu 75252 PARIS cedex 05 https://fr.linkedin.com/in/geoffroy-pr%C3%A9vot-493016233

gprevot avatar Oct 01 '24 15:10 gprevot

Hi,

Thanks for the update. Unfortunately, given the use case, reading the code won't be enough to help investigate the issue. Would you please help me to write a standalone test script highlighting your specific use case?

So far, I've wrote the following but this is not working yet, but it should be a good start for you to try and reproduce the behavior you mentioned earlier:

# -*- coding: utf-8 -*-
#
# Licensed under the terms of the BSD 3-Clause
# (see guidata/LICENSE for details)

"""
Demonstrates how items may trigger callbacks when activated, or how
the list of choices may be dynamically changed.
"""

# guitest: show

import numpy as np

import guidata.dataset as gds
from guidata.env import execenv
from guidata.qthelpers import qt_app_context


def _(x):
    return x


class ShapeParam(gds.DataSet):
    """DataSet for defining parameters of a shape"""

    x0 = gds.FloatItem(_("x0"), default=1.0)
    y0 = gds.FloatItem(_("y0"), default=0.0)
    label = gds.StringItem(_("label"), default="default")


class LatticeGrid:
    """LatticeGrid class"""

    def __init__(self, ndomains=1):
        self.shapeparam = ShapeParam()
        self.ndomains = ndomains

    def get_node_coordinates(self, x0, y0, latticedomain):
        return x0, y0


class UpdateProp(gds.ItemProperty):
    """An 'operator property'
    prop: ItemProperty instance
    func: function
    """

    def __init__(self, prop=None, func=None):
        self.property = prop
        self.function = func

    def __call__(self, instance, item, value):
        self.function(instance, item, value)
        return self


class SpotShapeParam(gds.DataSet):
    """DataSet for defining parameters of a SpotShape instance"""

    def update_domain(self, choice_item, latticedomain):
        """update a SpotShapeParam instance parameters when lattice has been changed"""
        if self.lattice is not None:
            self.lattice: LatticeGrid
            if latticedomain is not None:
                hx, kx = self.lattice.get_node_coordinates(
                    self.x0, self.y0, latticedomain
                )
                h = np.round(hx)
                k = np.round(kx)
                self._items[2].set_from_string(self, "%d" % h)
                self._items[3].set_from_string(self, "%d" % k)
                self._items[4].set_from_string(self, "%g" % hx)
                self._items[5].set_from_string(self, "%g" % kx)

    def update_lattice(self, choice_item, lattice: LatticeGrid | None):
        """update a SpotShapeParam instance parameters when lattice has been changed"""
        if lattice is not None:
            # update the value of h and k with the corresponding lattice
            self.latticedomain = min(self.latticedomain, lattice.ndomains - 1)
            self.update_domain_choices(lattice)
            if self.latticedomain is not None:
                hx, kx = lattice.get_node_coordinates(
                    self.x0, self.y0, self.latticedomain
                )
                h = np.round(hx)
                k = np.round(kx)
                self._items[2].set_from_string(self, "%d" % h)
                self._items[3].set_from_string(self, "%d" % k)
                self._items[4].set_from_string(self, "%g" % hx)
                self._items[5].set_from_string(self, "%g" % kx)

    x0 = gds.FloatItem(_("x0"), default=1.0)
    y0 = gds.FloatItem(_("y0"), default=0.0)
    h = gds.IntItem(_("h_integer"), default=0)
    k = gds.IntItem(_("k_integer"), default=0)
    hx = gds.FloatItem(_("h_float"), default=0.0)
    kx = gds.FloatItem(_("k_float"), default=0.0)
    lattice = gds.ChoiceItem(
        _("associated lattice"), ((None, "None"),)
    )  # instance of LatticeGrid or ReconstructionGrid
    lattice.set_prop("display", callback=UpdateProp(func=update_lattice))
    latticedomain = gds.ChoiceItem(_("domain index"), ((None, "None"),))
    latticedomain.set_prop("display", callback=UpdateProp(func=update_domain))

    def update_domain_choices(self, lattice):
        # when a LatticeGrid/ReconstructionGrid instance has been chosen,
        # update the choices of domains
        if lattice is None:
            _choices = [(None, "None")]
        else:
            _choices = []
            for idom in range(lattice.ndomains):
                _choices.append(self._items[7]._normalize_choice(idom, "%d" % idom))
        self._items[7].set_prop("data", choices=_choices)


def test_callbacks():
    """Test callbacks"""
    with qt_app_context():
        prm = SpotShapeParam()
        execenv.print(prm)
        if prm.edit():
            execenv.print(prm)
        prm.view()
        execenv.print("OK")


if __name__ == "__main__":
    test_callbacks()

PierreRaybaut avatar Nov 14 '24 09:11 PierreRaybaut

(There is an opportunity here because we are currently fixing bugs around guidata)

PierreRaybaut avatar Nov 14 '24 09:11 PierreRaybaut

Hello

I remember that in a previous version of guidata, I had some trouble
with this __first_call property that was never passing to False when having more than one ChoiceWidget
with an associated callback. I had found the solution of inverting the lines. With the new versions of guidata, this is no more the case, so I
cannot say anything more, this modification seems not to be necessary!

best regards

Geoffroy

Pierre Raybaut @.***> a écrit :

Hi,

Thanks for the update. Unfortunately, given the use case, reading the code won't be enough
to help investigate the issue. Would you please help me to write a standalone test script
highlighting your specific use case?

So far, I've wrote the following but this is not working yet, but it
should be a good start for you to try and reproduce the behavior you
mentioned earlier:

# -*- coding: utf-8 -*-
#
# Licensed under the terms of the BSD 3-Clause
# (see guidata/LICENSE for details)

"""
Demonstrates how items may trigger callbacks when activated, or how
the list of choices may be dynamically changed.
"""

# guitest: show

import numpy as np

import guidata.dataset as gds
from guidata.env import execenv
from guidata.qthelpers import qt_app_context


def _(x):
    return x


class ShapeParam(gds.DataSet):
    """DataSet for defining parameters of a shape"""

    x0 = gds.FloatItem(_("x0"), default=1.0)
    y0 = gds.FloatItem(_("y0"), default=0.0)
    label = gds.StringItem(_("label"), default="default")


class LatticeGrid:
    """LatticeGrid class"""

    def __init__(self, shapeparam: ShapeParam, ndomains=1):
        self.shapeparam = shapeparam
        self.ndomains = ndomains

    def get_node_coordinates(self, x0, y0, latticedomain):
        return x0, y0


class UpdateProp(gds.ItemProperty):
    """An 'operator property'
    prop: ItemProperty instance
    func: function
    """

    def __init__(self, prop=None, func=None):
        self.property = prop
        self.function = func

    def __call__(self, instance, item, value):
        self.function(instance, item, value)
        return self


class SpotShapeParam(gds.DataSet):
    """DataSet for defining parameters of a SpotShape instance"""

    def update_domain(self, choice_item, latticedomain):
        """update a SpotShapeParam instance parameters when lattice  
has been changed"""
        if self.lattice is not None:
            self.lattice: LatticeGrid
            if latticedomain is not None:
                hx, kx = self.lattice.get_node_coordinates(
                    self.x0, self.y0, latticedomain
                )
                h = np.round(hx)
                k = np.round(kx)
                self._items[2].set_from_string(self, "%d" % h)
                self._items[3].set_from_string(self, "%d" % k)
                self._items[4].set_from_string(self, "%g" % hx)
                self._items[5].set_from_string(self, "%g" % kx)

    def update_lattice(self, choice_item, lattice: LatticeGrid | None):
        """update a SpotShapeParam instance parameters when lattice  
has been changed"""
        if lattice is not None:
            # update the value of h and k with the corresponding lattice
            self.latticedomain = min(self.latticedomain,  
lattice.ndomains - 1)
            self.update_domain_choices(lattice)
            if self.latticedomain is not None:
                hx, kx = lattice.get_node_coordinates(
                    self.x0, self.y0, self.latticedomain
                )
                h = np.round(hx)
                k = np.round(kx)
                self._items[2].set_from_string(self, "%d" % h)
                self._items[3].set_from_string(self, "%d" % k)
                self._items[4].set_from_string(self, "%g" % hx)
                self._items[5].set_from_string(self, "%g" % kx)

    x0 = gds.FloatItem(_("x0"), default=1.0)
    y0 = gds.FloatItem(_("y0"), default=0.0)
    h = gds.IntItem(_("h_integer"), default=0)
    k = gds.IntItem(_("k_integer"), default=0)
    hx = gds.FloatItem(_("h_float"), default=0.0)
    kx = gds.FloatItem(_("k_float"), default=0.0)
    lattice = gds.ChoiceItem(
        _("associated lattice"), (None, "None")
    )  # instance of LatticeGrid or ReconstructionGrid
    lattice.set_prop("display", callback=UpdateProp(func=update_lattice))
    latticedomain = gds.ChoiceItem(_("domain index"), (None, "None"))
    latticedomain.set_prop("display",  
callback=UpdateProp(func=update_domain))

    def update_domain_choices(self, lattice):
        # when a LatticeGrid/ReconstructionGrid instance has been chosen,
        # update the choices of domains
        if lattice is None:
            _choices = [(None, "None")]
        else:
            _choices = []
            for idom in range(lattice.ndomains):
                 
_choices.append(self._items[7]._normalize_choice(idom, "%d" % idom))
        self._items[7].set_prop("data", choices=_choices)


def test_callbacks():
    """Test callbacks"""
    with qt_app_context():
        prm = SpotShapeParam()
        execenv.print(prm)
        if prm.edit():
            execenv.print(prm)
        prm.view()
        execenv.print("OK")


if __name__ == "__main__":
    test_callbacks()

-- Reply to this email directly or view it on GitHub: https://github.com/PlotPyStack/guidata/issues/75#issuecomment-2475819267 You are receiving this because you commented.

Message ID: @.***>

-- Geoffroy Prévot Directeur Adjoint Institut des NanoSciences de Paris 4, place Jussieu 75252 PARIS cedex 05 https://fr.linkedin.com/in/geoffroy-pr%C3%A9vot-493016233

gprevot avatar Nov 14 '24 16:11 gprevot

Thanks for the good news. I'm closing the issue.

PierreRaybaut avatar Nov 14 '24 18:11 PierreRaybaut