From d3a98f06ab519e77bb97e2d8d60ef36cd93f2e68 Mon Sep 17 00:00:00 2001 From: Sanjay Santhanam <51058514+Sanjays2402@users.noreply.github.com> Date: Thu, 30 Jul 2026 20:19:13 -0700 Subject: [PATCH] fix: don't route non-widget attribute deletion through remove() ContainerWidget.__delattr__ unconditionally called self.remove(name), so deleting any plain attribute that is not a current child widget raised ValueError from MutableSequence.remove -- including deleting a reference to a widget that had already been removed from the container. __delattr__ now looks for a matching child widget by name and removes it, otherwise falls back to object.__delattr__, which keeps normal Python semantics (AttributeError for a name that does not exist). --- .../widgets/bases/_container_widget.py | 8 ++++++-- tests/test_container.py | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/magicgui/widgets/bases/_container_widget.py b/src/magicgui/widgets/bases/_container_widget.py index 9a2ebc31f..319cdbb0d 100644 --- a/src/magicgui/widgets/bases/_container_widget.py +++ b/src/magicgui/widgets/bases/_container_widget.py @@ -341,8 +341,12 @@ def remove(self, value: Widget | str) -> None: super().remove(value) # type: ignore def __delattr__(self, name: str) -> None: - """Delete a widget by name.""" - self.remove(name) + """Delete a widget by name, or a plain attribute if no widget matches.""" + for widget in self._list: + if name == widget.name: + self.remove(widget) + return + object.__delattr__(self, name) def __delitem__(self, key: int | slice) -> None: """Delete a widget by integer or slice index.""" diff --git a/tests/test_container.py b/tests/test_container.py index 15a85880f..19dbeebb9 100644 --- a/tests/test_container.py +++ b/tests/test_container.py @@ -117,6 +117,24 @@ def test_delete_widget(): container.index(a) +def test_delete_non_widget_attribute(): + """Deleting a plain attribute does not go through widget removal.""" + a = widgets.Label(name="a") + container = widgets.Container(widgets=[a]) + container.x = 5 + del container.x + assert not hasattr(container, "x") + + # a widget that was already removed can still be dereferenced + container.remove(a) + container.a_ref = a + del container.a_ref + + # a name that is neither a widget nor an attribute still raises AttributeError + with pytest.raises(AttributeError): + del container.nonexistent + + def test_reset_choice_recursion(): """Test that reset_choices recursion works for multiple types of widgets.""" x = 0