Skip to content

Commit f5e89e5

Browse files
authored
fix: fix name collisions between dataclass fields and guiclass container attributes (#688)
* fix: fix name collisions on guiclass dataclass * fix test * lint * bump minimum version * undo private name change because magicclass is accessing it * use first not last * remove uvlock
1 parent e18ddc6 commit f5e89e5

File tree

7 files changed

+220
-35
lines changed

7 files changed

+220
-35
lines changed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ classifiers = [
3535
dynamic = ["version"]
3636
dependencies = [
3737
"docstring_parser>=0.7",
38-
"psygnal>=0.6.1",
38+
"psygnal>=0.8.0",
3939
"qtpy>=1.7.0",
4040
"superqt[iconify]>=0.6.1",
4141
"typing_extensions>=4.6",

src/magicgui/experimental.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,6 @@
44
removed in future releases. Use at your own risk.
55
"""
66

7-
from .schema._guiclass import button, guiclass, is_guiclass
7+
from .schema._guiclass import GuiBuilder, button, guiclass, is_guiclass
88

9-
__all__ = ["button", "guiclass", "is_guiclass"]
9+
__all__ = ["GuiBuilder", "button", "guiclass", "is_guiclass"]

src/magicgui/schema/_guiclass.py

Lines changed: 49 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from dataclasses import Field, dataclass, field, is_dataclass
1515
from typing import TYPE_CHECKING, Any, Callable, ClassVar, TypeVar, overload
1616

17-
from psygnal import SignalGroup, SignalInstance, evented
17+
from psygnal import SignalGroup, SignalInstance, evented, is_evented
1818
from psygnal import __version__ as psygnal_version
1919

2020
from magicgui.schema._ui_field import build_widget
@@ -149,13 +149,15 @@ def _deco(cls: T) -> T:
149149
"https://github.com/pyapp-kit/magicgui/issues."
150150
)
151151

152-
setattr(cls, gui_name, GuiBuilder(gui_name, follow_changes=follow_changes))
153-
152+
builder = GuiBuilder(
153+
gui_name,
154+
follow_changes=follow_changes,
155+
events_namespace=events_namespace,
156+
)
154157
if not is_dataclass(cls):
155158
cls = dataclass(cls, **dataclass_kwargs) # type: ignore
156-
cls = evented(cls, events_namespace=events_namespace)
157-
158-
setattr(cls, _GUICLASS_FLAG, True)
159+
setattr(cls, gui_name, builder)
160+
builder.__set_name__(cls, gui_name)
159161
return cls
160162

161163
return _deco(cls) if cls is not None else _deco
@@ -195,19 +197,56 @@ def _deco(func: F) -> F:
195197

196198

197199
class GuiBuilder:
198-
"""Descriptor that builds a widget for a dataclass or instance."""
200+
"""Descriptor that builds a widget for a dataclass or instance.
201+
202+
Parameters
203+
----------
204+
name : str, optional
205+
The name of the property that will return a `magicgui` widget.
206+
When used as a descriptor, the name of the class-attribute to which this
207+
descriptor is applied will be used.
208+
follow_changes : bool, optional
209+
If `True` (default), changes to the dataclass instance will be reflected in the
210+
gui, and changes to the gui will be reflected in the dataclass instance.
211+
events_namespace : str
212+
If the owner of this descriptor is not already evented when __set_name__ is
213+
called, it will be be made evented and the events namespace will be set to this
214+
value. By default, this is "events". (see `psygnal.SignalGroupDescriptor`
215+
for details.)
216+
"""
199217

200-
def __init__(self, name: str = "", follow_changes: bool = True):
218+
def __init__(
219+
self,
220+
name: str = "",
221+
follow_changes: bool = True,
222+
events_namespace: str = "events",
223+
):
201224
self._name = name
202225
self._follow_changes = follow_changes
226+
self._owner: type | None = None
227+
self._events_namespace = events_namespace
203228

204229
def __set_name__(self, owner: type, name: str) -> None:
205230
self._name = name
231+
self._owner = owner
232+
if not is_evented(owner):
233+
evented(owner, events_namespace=self._events_namespace)
234+
setattr(owner, _GUICLASS_FLAG, True)
235+
236+
def widget(self) -> ContainerWidget:
237+
"""Return a widget for the dataclass or instance."""
238+
if self._owner is None:
239+
raise TypeError(
240+
"Cannot call `widget` on an unbound `GuiBuilder` descriptor."
241+
)
242+
return build_widget(self._owner)
206243

207244
def __get__(
208245
self, instance: object | None, owner: type
209-
) -> ContainerWidget[BaseValueWidget]:
210-
wdg = build_widget(owner if instance is None else instance)
246+
) -> ContainerWidget[BaseValueWidget] | GuiBuilder:
247+
if instance is None:
248+
return self
249+
wdg = build_widget(instance)
211250

212251
# look for @button-decorated methods
213252
# TODO: move inside build_widget?

src/magicgui/schema/_ui_field.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -852,4 +852,5 @@ def _get_values(obj: Any) -> dict | None:
852852
def build_widget(cls_or_instance: Any) -> ContainerWidget[BaseValueWidget]:
853853
"""Build a magicgui widget from a dataclass, attrs, pydantic, or function."""
854854
values = None if isinstance(cls_or_instance, type) else _get_values(cls_or_instance)
855-
return _uifields_to_container(get_ui_fields(cls_or_instance), values=values)
855+
fields = get_ui_fields(cls_or_instance)
856+
return _uifields_to_container(fields, values=values)

src/magicgui/widgets/bases/_container_widget.py

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import contextlib
44
from collections.abc import Iterable, Mapping, MutableSequence, Sequence
5+
from itertools import chain
56
from typing import (
67
TYPE_CHECKING,
78
Any,
@@ -22,6 +23,7 @@
2223
from magicgui.widgets.bases._mixins import _OrientationMixin
2324

2425
from ._button_widget import ButtonWidget
26+
from ._named_list import NamedList
2527
from ._value_widget import BaseValueWidget
2628
from ._widget import Widget
2729

@@ -80,7 +82,7 @@ class BaseContainerWidget(Widget, _OrientationMixin, Sequence[WidgetVar]):
8082
_initialized = False
8183
# this is janky ... it's here to allow connections during __init__ by
8284
# avoiding a recursion error in __getattr__
83-
_list: list[WidgetVar] = [] # noqa: RUF012
85+
_list: NamedList[WidgetVar] = NamedList()
8486

8587
def __init__(
8688
self,
@@ -91,7 +93,7 @@ def __init__(
9193
labels: bool = True,
9294
**base_widget_kwargs: Unpack[WidgetKwargs],
9395
) -> None:
94-
self._list: list[WidgetVar] = []
96+
self._list: NamedList[WidgetVar] = NamedList()
9597
self._labels = labels
9698
self._layout = layout
9799
self._scrollable = scrollable
@@ -110,11 +112,14 @@ def __len__(self) -> int:
110112

111113
def __getattr__(self, name: str) -> WidgetVar:
112114
"""Return attribute ``name``. Will return a widget if present."""
113-
for widget in self._list:
114-
if name == widget.name:
115-
return widget
115+
if (wdg := self._list.get_by_name(name)) is not None:
116+
return wdg
116117
return object.__getattribute__(self, name) # type: ignore
117118

119+
def get_widget(self, name: str) -> WidgetVar | None:
120+
"""Return widget with name `name`, or None if one doesn't exist."""
121+
return self._list.get_by_name(name)
122+
118123
@overload
119124
def __getitem__(self, key: int | str) -> WidgetVar: ...
120125

@@ -429,17 +434,15 @@ def asdict(self) -> dict[str, Any]:
429434

430435
def update(
431436
self,
432-
mapping: Mapping | Iterable[tuple[str, Any]] | None = None,
437+
mapping: Mapping | Iterable[tuple[str, Any]] = (),
433438
**kwargs: Any,
434439
) -> None:
435440
"""Update the parameters in the widget from a mapping, iterable, or kwargs."""
436441
with self.changed.blocked():
437-
if mapping:
438-
items = mapping.items() if isinstance(mapping, Mapping) else mapping
439-
for key, value in items:
440-
getattr(self, key).value = value
441-
for key, value in kwargs.items():
442-
getattr(self, key).value = value
442+
items = mapping.items() if isinstance(mapping, Mapping) else mapping
443+
for key, value in chain(items, kwargs.items()):
444+
if isinstance(wdg := self._list.get_by_name(key), BaseValueWidget):
445+
wdg.value = value
443446
self.changed.emit(self)
444447

445448
@debounce
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
from __future__ import annotations
2+
3+
from typing import TYPE_CHECKING, Generic, TypeVar, overload
4+
5+
if TYPE_CHECKING:
6+
from collections.abc import Iterator, MutableSequence
7+
from typing import Protocol
8+
9+
class Named(Protocol):
10+
name: str
11+
12+
13+
T = TypeVar("T", bound="Named")
14+
15+
16+
class NamedList(Generic[T]):
17+
"""Container that maintains a list of objects with a 'name' attribute,.
18+
19+
Supports list-like insertion and O(1) lookup by name.
20+
"""
21+
22+
def __init__(self) -> None:
23+
self._list: list[T] = []
24+
self._dict: dict[str, T] = {}
25+
26+
def insert(self, index: int, item: T) -> None:
27+
"""Inserts an item at the specified index.
28+
29+
Raises ValueError if an item with the same name already exists.
30+
"""
31+
self._list.insert(index, item)
32+
# NB!
33+
# we don't actually assert name uniqueness here, because it ruins
34+
# the true list-like quality. So, when retrieving by name, you will
35+
# simply get the first item that has been inserted with that name.
36+
if item.name not in self._dict:
37+
self._dict[item.name] = item
38+
39+
def append(self, item: T) -> None:
40+
"""Appends an item at the end of the list."""
41+
self.insert(len(self._list), item)
42+
43+
def get_by_name(self, name: str) -> T | None:
44+
"""Returns the item with the given name, or None if not found."""
45+
return self._dict.get(name)
46+
47+
def remove_by_name(self, name: str) -> None:
48+
"""Removes the item with the given name."""
49+
item = self._dict.pop(name)
50+
self._list.remove(item)
51+
52+
@overload
53+
def __getitem__(self, key: int) -> T: ...
54+
@overload
55+
def __getitem__(self, key: slice) -> MutableSequence[T]: ...
56+
def __getitem__(self, index: int | slice) -> T | MutableSequence[T]:
57+
return self._list[index]
58+
59+
@overload
60+
def __delitem__(self, index: slice) -> None: ...
61+
@overload
62+
def __delitem__(self, index: int) -> None: ...
63+
def __delitem__(self, index: int | slice) -> None:
64+
if isinstance(index, slice):
65+
for item in self._list[index]:
66+
self._dict.pop(item.name, None)
67+
del self._list[index]
68+
else:
69+
item = self._list[index]
70+
del self._list[index]
71+
self._dict.pop(item.name, None)
72+
73+
def __len__(self) -> int:
74+
return len(self._list)
75+
76+
def __iter__(self) -> Iterator[T]:
77+
return iter(self._list)
78+
79+
def __repr__(self) -> str:
80+
return repr(self._list)

0 commit comments

Comments
 (0)