Skip to content

Commit d33b50b

Browse files
Fix recursion error when connecting to container self.method during subclass init (#539)
* fix: fix getattr during init * test: add test * style(pre-commit.ci): auto fixes [...] --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent f669a9c commit d33b50b

File tree

3 files changed

+218
-197
lines changed

3 files changed

+218
-197
lines changed

src/magicgui/widgets/bases/_container_widget.py

+13-8
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ class ContainerWidget(Widget, _OrientationMixin, MutableSequence[WidgetVar]):
8181
)
8282
_widget: protocols.ContainerProtocol
8383
_initialized = False
84+
_list: list[WidgetVar] = []
8485

8586
def __init__(
8687
self,
@@ -107,15 +108,15 @@ def __init__(
107108

108109
def __getattr__(self, name: str) -> WidgetVar:
109110
"""Return attribute ``name``. Will return a widget if present."""
110-
for widget in self:
111+
for widget in self._list:
111112
if name == widget.name:
112113
return widget
113114
return object.__getattribute__(self, name) # type: ignore
114115

115116
def __setattr__(self, name: str, value: Any) -> None:
116117
"""Set attribute ``name``. Prevents changing widget if present, (use del)."""
117118
if self._initialized:
118-
for widget in self:
119+
for widget in self._list:
119120
if name == widget.name:
120121
raise AttributeError(
121122
"Cannot set attribute with same name as a widget\n"
@@ -188,7 +189,7 @@ def __setitem__(self, key: Any, value: Any) -> NoReturn:
188189
def __dir__(self) -> list[str]:
189190
"""Add subwidget names to the dir() call for this widget."""
190191
d = list(super().__dir__())
191-
d.extend([w.name for w in self if not w.gui_only])
192+
d.extend([w.name for w in self._list if not w.gui_only])
192193
return d
193194

194195
def insert(self, key: int, widget: WidgetVar) -> None:
@@ -217,7 +218,7 @@ def _unify_label_widths(self) -> None:
217218
if not self._initialized:
218219
return
219220

220-
need_labels = [w for w in self if not isinstance(w, ButtonWidget)]
221+
need_labels = [w for w in self._list if not isinstance(w, ButtonWidget)]
221222
if self.layout == "vertical" and self.labels and need_labels:
222223
measure = use_app().get_obj("get_text_width")
223224
widest_label = max(measure(w.label) for w in need_labels)
@@ -254,15 +255,17 @@ def reset_choices(self, *_: Any) -> None:
254255
choices as when the widget was instantiated, if the callable relies on external
255256
state.
256257
"""
257-
for widget in self:
258+
for widget in self._list:
258259
if hasattr(widget, "reset_choices"):
259260
widget.reset_choices()
260261

261262
@property
262263
def __signature__(self) -> MagicSignature:
263264
"""Return a MagicSignature object representing the current state of the gui."""
264265
params = [
265-
MagicParameter.from_widget(w) for w in self if w.name and not w.gui_only
266+
MagicParameter.from_widget(w)
267+
for w in self._list
268+
if w.name and not w.gui_only
266269
]
267270
# if we have multiple non-default parameters and some but not all of them are
268271
# "bound" to fallback values, we may have non-default arguments
@@ -315,7 +318,9 @@ def labels(self, value: bool) -> None:
315318
def asdict(self) -> dict[str, Any]:
316319
"""Return state of widget as dict."""
317320
return {
318-
w.name: getattr(w, "value", None) for w in self if w.name and not w.gui_only
321+
w.name: getattr(w, "value", None)
322+
for w in self._list
323+
if w.name and not w.gui_only
319324
}
320325

321326
def update(
@@ -342,7 +347,7 @@ def _dump(self, path: str | Path) -> None:
342347
path = Path(path)
343348
path.parent.mkdir(parents=True, exist_ok=True)
344349
_dict = {}
345-
for widget in self:
350+
for widget in self._list:
346351
try:
347352
# not all values will be pickleable and restorable...
348353
# for now, don't even try

tests/test_container.py

+205
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
import pytest
2+
3+
from magicgui import magicgui, use_app, widgets
4+
5+
6+
@pytest.mark.parametrize("scrollable", [False, True])
7+
def test_container_widget(scrollable):
8+
"""Test basic container functionality."""
9+
container = widgets.Container(labels=False, scrollable=scrollable)
10+
labela = widgets.Label(value="hi", name="labela")
11+
labelb = widgets.Label(value="hi", name="labelb")
12+
container.append(labela)
13+
container.extend([labelb])
14+
# different ways to index
15+
assert container[0] == labela
16+
assert container["labelb"] == labelb
17+
assert container[:1] == [labela]
18+
assert container[-1] == labelb
19+
20+
with pytest.raises(NotImplementedError):
21+
container[0] = "something"
22+
23+
assert container.layout == "vertical"
24+
with pytest.raises(NotImplementedError):
25+
container.layout = "horizontal"
26+
27+
assert all(x in dir(container) for x in ["labela", "labelb"])
28+
29+
assert container.margins
30+
container.margins = (8, 8, 8, 8)
31+
assert container.margins == (8, 8, 8, 8)
32+
33+
del container[1:]
34+
del container[-1]
35+
assert not container
36+
container.close()
37+
38+
39+
@pytest.mark.parametrize("scrollable", [False, True])
40+
def test_container_label_widths(scrollable):
41+
"""Test basic container functionality."""
42+
container = widgets.Container(layout="vertical", scrollable=scrollable)
43+
labela = widgets.Label(value="hi", name="labela")
44+
labelb = widgets.Label(value="hi", name="I have a very long label")
45+
46+
def _label_width():
47+
measure = use_app().get_obj("get_text_width")
48+
return max(
49+
measure(w.label)
50+
for w in container
51+
if not isinstance(w, widgets.bases.ButtonWidget)
52+
)
53+
54+
container.append(labela)
55+
before = _label_width()
56+
container.append(labelb)
57+
assert _label_width() > before
58+
container.close()
59+
60+
61+
@pytest.mark.parametrize("scrollable", [False, True])
62+
def test_labeled_widget_container(scrollable):
63+
"""Test that _LabeledWidgets follow their children."""
64+
from magicgui.widgets._concrete import _LabeledWidget
65+
66+
w1 = widgets.Label(value="hi", name="w1")
67+
w2 = widgets.Label(value="hi", name="w2")
68+
container = widgets.Container(
69+
widgets=[w1, w2], layout="vertical", scrollable=scrollable
70+
)
71+
assert w1._labeled_widget
72+
lw = w1._labeled_widget()
73+
assert isinstance(lw, _LabeledWidget)
74+
assert not lw.visible
75+
container.show()
76+
assert w1.visible
77+
assert lw.visible
78+
w1.hide()
79+
assert not w1.visible
80+
assert not lw.visible
81+
w1.label = "another label"
82+
assert lw._label_widget.value == "another label"
83+
container.close()
84+
85+
86+
@pytest.mark.parametrize("scrollable", [False, True])
87+
def test_visible_in_container(scrollable):
88+
"""Test that visibility depends on containers."""
89+
w1 = widgets.Label(value="hi", name="w1")
90+
w2 = widgets.Label(value="hi", name="w2")
91+
w3 = widgets.Label(value="hi", name="w3", visible=False)
92+
container = widgets.Container(widgets=[w2, w3], scrollable=scrollable)
93+
assert not w1.visible
94+
assert not w2.visible
95+
assert not w3.visible
96+
assert not container.visible
97+
container.show()
98+
assert container.visible
99+
assert w2.visible
100+
assert not w3.visible
101+
w1.show()
102+
assert w1.visible
103+
container.close()
104+
105+
106+
def test_delete_widget():
107+
"""We can delete widgets from containers."""
108+
a = widgets.Label(name="a")
109+
container = widgets.Container(widgets=[a])
110+
# we can delete widgets
111+
del container.a
112+
with pytest.raises(AttributeError):
113+
container.a
114+
115+
# they disappear from the layout
116+
with pytest.raises(ValueError):
117+
container.index(a)
118+
119+
120+
def test_reset_choice_recursion():
121+
"""Test that reset_choices recursion works for multiple types of widgets."""
122+
x = 0
123+
124+
def get_choices(widget):
125+
nonlocal x
126+
x += 1
127+
return list(range(x))
128+
129+
@magicgui(c={"choices": get_choices})
130+
def f(c):
131+
pass
132+
133+
assert f.c.choices == (0,)
134+
135+
container = widgets.Container(widgets=[f])
136+
container.reset_choices()
137+
assert f.c.choices == (0, 1)
138+
container.reset_choices()
139+
assert f.c.choices == (0, 1, 2)
140+
141+
142+
def test_container_indexing_with_native_mucking():
143+
"""Mostly make sure that the inner model isn't messed up.
144+
145+
keeping indexes with a manipulated native model *may* be something to do in future.
146+
"""
147+
l1 = widgets.Label(name="l1")
148+
l2 = widgets.Label(name="l2")
149+
l3 = widgets.Label(name="l3")
150+
c = widgets.Container(widgets=[l1, l2, l3])
151+
assert c[-1] == l3
152+
# so far they should be in sync
153+
native = c.native.layout()
154+
assert native.count() == len(c)
155+
# much with native layout
156+
native.addStretch()
157+
# haven't changed the magicgui container
158+
assert len(c) == 3
159+
assert c[-1] == l3
160+
# though it has changed the native model
161+
assert native.count() == 4
162+
163+
164+
def test_containers_show_nested_containers():
165+
"""make sure showing a container shows a nested FunctionGui."""
166+
167+
@magicgui
168+
def func(x: int, y: str):
169+
pass
170+
171+
assert not func.visible
172+
c2 = widgets.Container(widgets=[func])
173+
assert not c2.visible
174+
c2.show()
175+
assert c2.visible and func.visible
176+
c2.close()
177+
assert not func.visible
178+
179+
180+
def test_container_removal():
181+
c = widgets.Container()
182+
s = widgets.Slider(label="label")
183+
assert len(c) == 0
184+
assert c.native.layout().count() == 0
185+
186+
c.append(s)
187+
assert len(c) == 1
188+
assert c.native.layout().count() == 1
189+
190+
c.pop()
191+
assert len(c) == 0
192+
assert c.native.layout().count() == 0
193+
194+
195+
def test_connection_during_init() -> None:
196+
class C(widgets.Container):
197+
def __init__(self) -> None:
198+
btn = widgets.PushButton()
199+
btn.changed.connect(self._on_clicked)
200+
super().__init__(widgets=[btn])
201+
202+
def _on_clicked(self):
203+
...
204+
205+
assert isinstance(C(), widgets.Container)

0 commit comments

Comments
 (0)