From 8faf455e1a014ecb38bc7970f80bcf16b2e37421 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 3 Mar 2025 21:22:47 +0000 Subject: [PATCH 1/7] ci(pre-commit.ci): autoupdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/crate-ci/typos: v1.29.9 → v1.30.0](https://github.com/crate-ci/typos/compare/v1.29.9...v1.30.0) - [github.com/astral-sh/ruff-pre-commit: v0.9.7 → v0.9.9](https://github.com/astral-sh/ruff-pre-commit/compare/v0.9.7...v0.9.9) --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index cd402778..046c9455 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,12 +10,12 @@ repos: - id: validate-pyproject - repo: https://github.com/crate-ci/typos - rev: v1.29.9 + rev: v1.30.0 hooks: - id: typos - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.9.7 + rev: v0.9.9 hooks: - id: ruff args: ["--fix", "--unsafe-fixes"] From 26ab96ee88600c55620ca09c5decc30928d6a1bf Mon Sep 17 00:00:00 2001 From: Michael Rohdenburg Date: Thu, 19 Jun 2025 17:43:51 +0200 Subject: [PATCH 2/7] feat: enable nesting of model container widgets --- src/magicgui/schema/_ui_field.py | 15 ++++++++++++++- src/magicgui/widgets/bases/_container_widget.py | 12 +++++++----- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/src/magicgui/schema/_ui_field.py b/src/magicgui/schema/_ui_field.py index a14c5abd..c74519e0 100644 --- a/src/magicgui/schema/_ui_field.py +++ b/src/magicgui/schema/_ui_field.py @@ -441,7 +441,20 @@ def create_widget(self, value: T | _Undefined = Undefined) -> BaseValueWidget[T] opts["min"] = d["exclusive_minimum"] + m value = value if value is not Undefined else self.get_default() # type: ignore - cls, kwargs = get_widget_class(value=value, annotation=self.type, options=opts) + try: + cls, kwargs = get_widget_class( + value=value, annotation=self.type, options=opts + ) + except ValueError: + try: + wdg = build_widget(self.type) + wdg.label = self.name if self.name else "" + return wdg + except TypeError as e: + raise TypeError( + f"Could not create widget for field {self.name!r} ", + f"with value {value!r}", + ) from e return cls(**kwargs) # type: ignore diff --git a/src/magicgui/widgets/bases/_container_widget.py b/src/magicgui/widgets/bases/_container_widget.py index 9415b2ed..fdda48a9 100644 --- a/src/magicgui/widgets/bases/_container_widget.py +++ b/src/magicgui/widgets/bases/_container_widget.py @@ -426,11 +426,13 @@ def __repr__(self) -> str: def asdict(self) -> dict[str, Any]: """Return state of widget as dict.""" - return { - w.name: getattr(w, "value", None) - for w in self._list - if w.name and not w.gui_only - } + ret = {} + for w in self._list: + if w.name and not w.gui_only: + ret[w.name] = getattr(w, "value", None) + if isinstance(w, ContainerWidget) and w.widget_type == "Container": + ret[w.label] = w.asdict() + return ret def update( self, From 0b88814bc5c249503d312d513a78be309673dff0 Mon Sep 17 00:00:00 2001 From: Michael Rohdenburg Date: Thu, 19 Jun 2025 18:12:59 +0200 Subject: [PATCH 3/7] refactor: add proper typing for nested widgets --- src/magicgui/schema/_guiclass.py | 4 +++- src/magicgui/schema/_ui_field.py | 10 ++++++---- src/magicgui/types.py | 10 +++++++++- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/src/magicgui/schema/_guiclass.py b/src/magicgui/schema/_guiclass.py index bd2ca157..723d3adb 100644 --- a/src/magicgui/schema/_guiclass.py +++ b/src/magicgui/schema/_guiclass.py @@ -27,6 +27,8 @@ from typing_extensions import TypeGuard + from magicgui.types import NestedValueWidgets + # fmt: off class GuiClassProtocol(Protocol): """Protocol for a guiclass.""" @@ -243,7 +245,7 @@ def widget(self) -> ContainerWidget: def __get__( self, instance: object | None, owner: type - ) -> ContainerWidget[BaseValueWidget] | GuiBuilder: + ) -> ContainerWidget[NestedValueWidgets] | GuiBuilder: if instance is None: return self wdg = build_widget(instance) diff --git a/src/magicgui/schema/_ui_field.py b/src/magicgui/schema/_ui_field.py index addb7551..8ceca626 100644 --- a/src/magicgui/schema/_ui_field.py +++ b/src/magicgui/schema/_ui_field.py @@ -20,7 +20,7 @@ from typing_extensions import TypeGuard, get_args, get_origin -from magicgui.types import JsonStringFormats, Undefined, _Undefined +from magicgui.types import JsonStringFormats, NestedValueWidgets, Undefined, _Undefined if TYPE_CHECKING: from collections.abc import Iterable, Iterator, Mapping @@ -394,7 +394,9 @@ def parse_annotated(self) -> UiField[T]: kwargs.pop("name", None) return dc.replace(self, **kwargs) - def create_widget(self, value: T | _Undefined = Undefined) -> BaseValueWidget[T]: + def create_widget( + self, value: T | _Undefined = Undefined + ) -> BaseValueWidget[T] | NestedValueWidgets: """Create a new Widget for this field.""" from magicgui.type_map import get_widget_class @@ -799,7 +801,7 @@ def _uifields_to_container( values: Mapping[str, Any] | None = None, *, container_kwargs: Mapping | None = None, -) -> ContainerWidget[BaseValueWidget]: +) -> ContainerWidget[NestedValueWidgets]: """Create a container widget from a sequence of UiFields. This function is the heart of build_widget. @@ -862,7 +864,7 @@ def _get_values(obj: Any) -> dict | None: # TODO: unify this with magicgui -def build_widget(cls_or_instance: Any) -> ContainerWidget[BaseValueWidget]: +def build_widget(cls_or_instance: Any) -> ContainerWidget[NestedValueWidgets]: """Build a magicgui widget from a dataclass, attrs, pydantic, or function.""" values = None if isinstance(cls_or_instance, type) else _get_values(cls_or_instance) fields = get_ui_fields(cls_or_instance) diff --git a/src/magicgui/types.py b/src/magicgui/types.py index 9ebfdab9..ec844535 100644 --- a/src/magicgui/types.py +++ b/src/magicgui/types.py @@ -11,7 +11,12 @@ if TYPE_CHECKING: from magicgui.widgets import FunctionGui - from magicgui.widgets.bases import CategoricalWidget, Widget + from magicgui.widgets.bases import ( + BaseValueWidget, + CategoricalWidget, + ContainerWidget, + Widget, + ) from magicgui.widgets.protocols import WidgetProtocol @@ -29,6 +34,9 @@ class ChoicesDict(TypedDict): WidgetRef = Union[str, WidgetClass] #: A :attr:`WidgetClass` (or a string representation of one) and a dict of kwargs WidgetTuple = tuple[WidgetRef, dict[str, Any]] +#: A [`ValueWidget`][magicgui.widgets.ValueWidget] class or a +#: [`ContainerWidget`][magicgui.widgets.ContainerWidget] class for nesting those +NestedValueWidgets = Union["BaseValueWidget", "ContainerWidget[NestedValueWidgets]"] #: An iterable that can be used as a valid argument for widget ``choices`` ChoicesIterable = Union[Iterable[tuple[str, Any]], Iterable[Any]] #: An callback that can be used as a valid argument for widget ``choices``. It takes From 5ab31e56c7efb9bc29d0ceb83491a27d1373cb03 Mon Sep 17 00:00:00 2001 From: Michael Rohdenburg Date: Fri, 12 Sep 2025 14:52:28 +0200 Subject: [PATCH 4/7] fix: check for dataclass-like structures before building nested widgets --- src/magicgui/schema/_ui_field.py | 43 ++++++++++++++++++++------------ 1 file changed, 27 insertions(+), 16 deletions(-) diff --git a/src/magicgui/schema/_ui_field.py b/src/magicgui/schema/_ui_field.py index 8ceca626..70fc48c0 100644 --- a/src/magicgui/schema/_ui_field.py +++ b/src/magicgui/schema/_ui_field.py @@ -326,9 +326,9 @@ def __post_init__(self) -> None: def get_default(self) -> T | None: """Return the default value for this field.""" return ( - self.default # TODO: deepcopy mutable defaults? + self.default if self.default_factory is None - else self.default_factory() + else self.default_factory() # TODO: deepcopy mutable defaults? ) def asdict(self, include_unset: bool = True) -> dict[str, Any]: @@ -443,20 +443,13 @@ def create_widget( opts["min"] = d["exclusive_minimum"] + m value = value if value is not Undefined else self.get_default() # type: ignore - try: - cls, kwargs = get_widget_class( - value=value, annotation=self.type, options=opts - ) - except ValueError: - try: - wdg = build_widget(self.type) - wdg.label = self.name if self.name else "" - return wdg - except TypeError as e: - raise TypeError( - f"Could not create widget for field {self.name!r} ", - f"with value {value!r}", - ) from e + # build a nesting container widget from a dataclass-like object + if _is_dataclass_like(self.type): + wdg = build_widget(self.type) + wdg.label = self.name if self.name else "" + return wdg + # create widget subclass for everything else + cls, kwargs = get_widget_class(value=value, annotation=self.type, options=opts) return cls(**kwargs) # type: ignore @@ -733,6 +726,24 @@ def _ui_fields_from_annotation(cls: type) -> Iterator[UiField]: yield field.parse_annotated() +def _is_dataclass_like(object: Any) -> bool: + # check if it's a pydantic1 style dataclass + model = _get_pydantic_model(object) + if model is not None: + if hasattr(model, "model_fields"): + return True + # check if it's a pydantic2 style dataclass + if hasattr(object, "__pydantic_fields__"): + return True + # check if it's a (non-pydantic) dataclass + if dc.is_dataclass(object): + return True + # check if it's an attrs class + if _is_attrs_model(object): + return True + return False + + def _iter_ui_fields(object: Any) -> Iterator[UiField]: # check if it's a pydantic model model = _get_pydantic_model(object) From 96766bfd09fbc8b1bf4f5d483fea414414a0df71 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 30 Dec 2025 13:52:32 -0500 Subject: [PATCH 5/7] minimize diff --- src/magicgui/schema/_guiclass.py | 4 +--- src/magicgui/schema/_ui_field.py | 19 +++++++++---------- src/magicgui/types.py | 10 +--------- .../widgets/bases/_container_widget.py | 15 ++++++++++----- 4 files changed, 21 insertions(+), 27 deletions(-) diff --git a/src/magicgui/schema/_guiclass.py b/src/magicgui/schema/_guiclass.py index 723d3adb..bd2ca157 100644 --- a/src/magicgui/schema/_guiclass.py +++ b/src/magicgui/schema/_guiclass.py @@ -27,8 +27,6 @@ from typing_extensions import TypeGuard - from magicgui.types import NestedValueWidgets - # fmt: off class GuiClassProtocol(Protocol): """Protocol for a guiclass.""" @@ -245,7 +243,7 @@ def widget(self) -> ContainerWidget: def __get__( self, instance: object | None, owner: type - ) -> ContainerWidget[NestedValueWidgets] | GuiBuilder: + ) -> ContainerWidget[BaseValueWidget] | GuiBuilder: if instance is None: return self wdg = build_widget(instance) diff --git a/src/magicgui/schema/_ui_field.py b/src/magicgui/schema/_ui_field.py index 70fc48c0..ddef1e81 100644 --- a/src/magicgui/schema/_ui_field.py +++ b/src/magicgui/schema/_ui_field.py @@ -20,7 +20,7 @@ from typing_extensions import TypeGuard, get_args, get_origin -from magicgui.types import JsonStringFormats, NestedValueWidgets, Undefined, _Undefined +from magicgui.types import JsonStringFormats, Undefined, _Undefined if TYPE_CHECKING: from collections.abc import Iterable, Iterator, Mapping @@ -326,9 +326,9 @@ def __post_init__(self) -> None: def get_default(self) -> T | None: """Return the default value for this field.""" return ( - self.default + self.default # TODO: deepcopy mutable defaults? if self.default_factory is None - else self.default_factory() # TODO: deepcopy mutable defaults? + else self.default_factory() ) def asdict(self, include_unset: bool = True) -> dict[str, Any]: @@ -394,9 +394,7 @@ def parse_annotated(self) -> UiField[T]: kwargs.pop("name", None) return dc.replace(self, **kwargs) - def create_widget( - self, value: T | _Undefined = Undefined - ) -> BaseValueWidget[T] | NestedValueWidgets: + def create_widget(self, value: T | _Undefined = Undefined) -> BaseValueWidget[T]: """Create a new Widget for this field.""" from magicgui.type_map import get_widget_class @@ -446,8 +444,9 @@ def create_widget( # build a nesting container widget from a dataclass-like object if _is_dataclass_like(self.type): wdg = build_widget(self.type) - wdg.label = self.name if self.name else "" - return wdg + wdg.name = self.name or "" + wdg.label = self.name or "" + return wdg # type: ignore[return-value] # create widget subclass for everything else cls, kwargs = get_widget_class(value=value, annotation=self.type, options=opts) return cls(**kwargs) # type: ignore @@ -812,7 +811,7 @@ def _uifields_to_container( values: Mapping[str, Any] | None = None, *, container_kwargs: Mapping | None = None, -) -> ContainerWidget[NestedValueWidgets]: +) -> ContainerWidget[BaseValueWidget]: """Create a container widget from a sequence of UiFields. This function is the heart of build_widget. @@ -875,7 +874,7 @@ def _get_values(obj: Any) -> dict | None: # TODO: unify this with magicgui -def build_widget(cls_or_instance: Any) -> ContainerWidget[NestedValueWidgets]: +def build_widget(cls_or_instance: Any) -> ContainerWidget[BaseValueWidget]: """Build a magicgui widget from a dataclass, attrs, pydantic, or function.""" values = None if isinstance(cls_or_instance, type) else _get_values(cls_or_instance) fields = get_ui_fields(cls_or_instance) diff --git a/src/magicgui/types.py b/src/magicgui/types.py index ec844535..9ebfdab9 100644 --- a/src/magicgui/types.py +++ b/src/magicgui/types.py @@ -11,12 +11,7 @@ if TYPE_CHECKING: from magicgui.widgets import FunctionGui - from magicgui.widgets.bases import ( - BaseValueWidget, - CategoricalWidget, - ContainerWidget, - Widget, - ) + from magicgui.widgets.bases import CategoricalWidget, Widget from magicgui.widgets.protocols import WidgetProtocol @@ -34,9 +29,6 @@ class ChoicesDict(TypedDict): WidgetRef = Union[str, WidgetClass] #: A :attr:`WidgetClass` (or a string representation of one) and a dict of kwargs WidgetTuple = tuple[WidgetRef, dict[str, Any]] -#: A [`ValueWidget`][magicgui.widgets.ValueWidget] class or a -#: [`ContainerWidget`][magicgui.widgets.ContainerWidget] class for nesting those -NestedValueWidgets = Union["BaseValueWidget", "ContainerWidget[NestedValueWidgets]"] #: An iterable that can be used as a valid argument for widget ``choices`` ChoicesIterable = Union[Iterable[tuple[str, Any]], Iterable[Any]] #: An callback that can be used as a valid argument for widget ``choices``. It takes diff --git a/src/magicgui/widgets/bases/_container_widget.py b/src/magicgui/widgets/bases/_container_widget.py index 7aa3b397..6e03d0d0 100644 --- a/src/magicgui/widgets/bases/_container_widget.py +++ b/src/magicgui/widgets/bases/_container_widget.py @@ -426,12 +426,14 @@ def __repr__(self) -> str: def asdict(self) -> dict[str, Any]: """Return state of widget as dict.""" - ret = {} + ret: dict[str, Any] = {} for w in self._list: - if w.name and not w.gui_only: - ret[w.name] = getattr(w, "value", None) + if not w.name or w.gui_only: + continue if isinstance(w, ContainerWidget) and w.widget_type == "Container": - ret[w.label] = w.asdict() + ret[w.name] = w.asdict() + else: + ret[w.name] = getattr(w, "value", None) return ret def update( @@ -443,7 +445,10 @@ def update( with self.changed.blocked(): items = mapping.items() if isinstance(mapping, Mapping) else mapping for key, value in chain(items, kwargs.items()): - if isinstance(wdg := self._list.get_by_name(key), BaseValueWidget): + wdg = self._list.get_by_name(key) + if isinstance(wdg, ContainerWidget) and isinstance(value, Mapping): + wdg.update(value) + elif isinstance(wdg, BaseValueWidget): wdg.value = value self.changed.emit(self) From 0146dbfb5630327b0d59f4a052686a5efe2b7898 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 30 Dec 2025 15:44:09 -0500 Subject: [PATCH 6/7] use modelContainerWidget --- src/magicgui/schema/_guiclass.py | 18 ++-- src/magicgui/schema/_ui_field.py | 101 ++++++------------ src/magicgui/widgets/__init__.py | 2 + src/magicgui/widgets/_concrete.py | 99 +++++++++++++++++ .../widgets/bases/_container_widget.py | 62 +++++++---- tests/test_gui_class.py | 12 +-- tests/test_ui_field.py | 66 +++++++++++- 7 files changed, 255 insertions(+), 105 deletions(-) diff --git a/src/magicgui/schema/_guiclass.py b/src/magicgui/schema/_guiclass.py index bd2ca157..f7e80bad 100644 --- a/src/magicgui/schema/_guiclass.py +++ b/src/magicgui/schema/_guiclass.py @@ -19,7 +19,7 @@ from magicgui.schema._ui_field import build_widget from magicgui.widgets import PushButton -from magicgui.widgets.bases import BaseValueWidget, ContainerWidget +from magicgui.widgets.bases import BaseValueWidget if TYPE_CHECKING: from collections.abc import Mapping @@ -27,12 +27,15 @@ from typing_extensions import TypeGuard + from magicgui.widgets._concrete import ModelContainerWidget + from magicgui.widgets.bases._container_widget import BaseContainerWidget + # fmt: off class GuiClassProtocol(Protocol): """Protocol for a guiclass.""" @property - def gui(self) -> ContainerWidget: ... + def gui(self) -> ModelContainerWidget: ... @property def events(self) -> SignalGroup: ... # fmt: on @@ -233,7 +236,7 @@ def __set_name__(self, owner: type, name: str) -> None: evented(owner, events_namespace=self._events_namespace) setattr(owner, _GUICLASS_FLAG, True) - def widget(self) -> ContainerWidget: + def widget(self) -> ModelContainerWidget: """Return a widget for the dataclass or instance.""" if self._owner is None: raise TypeError( @@ -243,7 +246,7 @@ def widget(self) -> ContainerWidget: def __get__( self, instance: object | None, owner: type - ) -> ContainerWidget[BaseValueWidget] | GuiBuilder: + ) -> ModelContainerWidget[BaseValueWidget] | GuiBuilder: if instance is None: return self wdg = build_widget(instance) @@ -253,7 +256,8 @@ def __get__( for k, v in vars(owner).items(): if hasattr(v, _BUTTON_ATTR): kwargs = getattr(v, _BUTTON_ATTR) - button = PushButton(**kwargs) + # gui_only=True excludes button from model value construction + button = PushButton(gui_only=True, **kwargs) if instance is not None: # call the bound method if we're in an instance button.clicked.connect(getattr(instance, k)) @@ -277,7 +281,7 @@ def __get__( def bind_gui_to_instance( - gui: ContainerWidget, instance: Any, two_way: bool = True + gui: BaseContainerWidget, instance: Any, two_way: bool = True ) -> None: """Set change events in `gui` to update the corresponding attributes in `model`. @@ -340,7 +344,7 @@ def bind_gui_to_instance( signals[name].connect_setattr(widget, "value") -def unbind_gui_from_instance(gui: ContainerWidget, instance: Any) -> None: +def unbind_gui_from_instance(gui: BaseContainerWidget, instance: Any) -> None: """Unbind a gui from an instance. This will disconnect all events that were connected by `bind_gui_to_instance`. diff --git a/src/magicgui/schema/_ui_field.py b/src/magicgui/schema/_ui_field.py index ddef1e81..b0741d82 100644 --- a/src/magicgui/schema/_ui_field.py +++ b/src/magicgui/schema/_ui_field.py @@ -15,7 +15,6 @@ Literal, TypeVar, Union, - cast, ) from typing_extensions import TypeGuard, get_args, get_origin @@ -23,7 +22,7 @@ from magicgui.types import JsonStringFormats, Undefined, _Undefined if TYPE_CHECKING: - from collections.abc import Iterable, Iterator, Mapping + from collections.abc import Iterator from typing import Protocol import attrs @@ -32,7 +31,8 @@ from attrs import Attribute from pydantic.fields import FieldInfo, ModelField - from magicgui.widgets.bases import BaseValueWidget, ContainerWidget + from magicgui.widgets import ModelContainerWidget + from magicgui.widgets.bases import BaseValueWidget class HasAttrs(Protocol): """Protocol for objects that have an ``attrs`` attribute.""" @@ -441,12 +441,15 @@ def create_widget(self, value: T | _Undefined = Undefined) -> BaseValueWidget[T] opts["min"] = d["exclusive_minimum"] + m value = value if value is not Undefined else self.get_default() # type: ignore - # build a nesting container widget from a dataclass-like object + # build a container widget from a dataclass-like object + # TODO: should this eventually move to get_widget_class? if _is_dataclass_like(self.type): wdg = build_widget(self.type) wdg.name = self.name or "" wdg.label = self.name or "" - return wdg # type: ignore[return-value] + if value is not None: + wdg.value = value + return wdg # create widget subclass for everything else cls, kwargs = get_widget_class(value=value, annotation=self.type, options=opts) return cls(**kwargs) # type: ignore @@ -806,76 +809,40 @@ def get_ui_fields(cls_or_instance: object) -> tuple[UiField, ...]: return tuple(_iter_ui_fields(cls_or_instance)) -def _uifields_to_container( - ui_fields: Iterable[UiField], - values: Mapping[str, Any] | None = None, - *, - container_kwargs: Mapping | None = None, -) -> ContainerWidget[BaseValueWidget]: - """Create a container widget from a sequence of UiFields. +# TODO: unify this with magicgui +# this todo could be the same thing as moving the logic in create_widget above +# to get_widget_cls... +def build_widget(cls_or_instance: Any) -> ModelContainerWidget: + """Build a magicgui widget from a dataclass, attrs, pydantic, or function. - This function is the heart of build_widget. + Returns a ModelContainerWidget whose `.value` property returns an instance + of the model type, constructed from the current widget values. Parameters ---------- - ui_fields : Iterable[UiField] - A sequence of UiFields to use to create the container. - values : Mapping[str, Any], optional - A mapping of field name to values to use to initialize each widget the - container, by default None. - container_kwargs : Mapping, optional - A mapping of keyword arguments to pass to the container constructor, - by default None. + cls_or_instance : Any + The class or instance to build the widget from. Returns ------- - ContainerWidget[ValueWidget] - A container widget with a widget for each UiField. + ModelContainerWidget + The constructed widget. """ - from magicgui import widgets - - container = widgets.Container( - widgets=[field.create_widget() for field in ui_fields], - **(container_kwargs or {}), - ) - if values is not None: - container.update(values) - return container - - -def _get_values(obj: Any) -> dict | None: - """Return a dict of values from an object. - - The object can be a dataclass, attrs, pydantic object or named tuple. - """ - if isinstance(obj, dict): - return obj - - # named tuple - if isinstance(obj, tuple) and hasattr(obj, "_asdict"): - return cast("dict", obj._asdict()) - - # dataclass - if dc.is_dataclass(type(obj)): - return dc.asdict(obj) - - # attrs - attr = sys.modules.get("attr") - if attr is not None and attr.has(obj): - return cast("dict", attr.asdict(obj)) - - # pydantic models - if hasattr(obj, "model_dump"): - return cast("dict", obj.model_dump()) - elif hasattr(obj, "dict"): - return cast("dict", obj.dict()) - - return None + from magicgui.widgets import ModelContainerWidget + # Get the class (type) for the model + if isinstance(cls_or_instance, type): + model_type = cls_or_instance + value = None + else: + model_type = type(cls_or_instance) + value = cls_or_instance -# TODO: unify this with magicgui -def build_widget(cls_or_instance: Any) -> ContainerWidget[BaseValueWidget]: - """Build a magicgui widget from a dataclass, attrs, pydantic, or function.""" - values = None if isinstance(cls_or_instance, type) else _get_values(cls_or_instance) fields = get_ui_fields(cls_or_instance) - return _uifields_to_container(fields, values=values) + inner_widgets = [f.create_widget() for f in fields] + + return ModelContainerWidget( + value_type=model_type, + widgets=inner_widgets, + value=value, + ) diff --git a/src/magicgui/widgets/__init__.py b/src/magicgui/widgets/__init__.py index 98b9ced7..4bdd2e68 100644 --- a/src/magicgui/widgets/__init__.py +++ b/src/magicgui/widgets/__init__.py @@ -29,6 +29,7 @@ LiteralEvalLineEdit, LogSlider, MainWindow, + ModelContainerWidget, Password, ProgressBar, PushButton, @@ -92,6 +93,7 @@ "LogSlider", "MainFunctionGui", "MainWindow", + "ModelContainerWidget", "Password", "ProgressBar", "PushButton", diff --git a/src/magicgui/widgets/_concrete.py b/src/magicgui/widgets/_concrete.py index 7eb60a09..982afe10 100644 --- a/src/magicgui/widgets/_concrete.py +++ b/src/magicgui/widgets/_concrete.py @@ -11,6 +11,7 @@ import inspect import math import os +import sys from pathlib import Path from typing import ( TYPE_CHECKING, @@ -68,6 +69,7 @@ WidgetVar = TypeVar("WidgetVar", bound=Widget) WidgetTypeVar = TypeVar("WidgetTypeVar", bound=type[Widget]) _V = TypeVar("_V") +_M = TypeVar("_M") # For model/dataclass types @overload @@ -994,6 +996,103 @@ def set_value(self, vals: Sequence[Any]) -> None: self.changed.emit(self.value) +class ModelContainerWidget(ValuedContainerWidget[_M], Generic[_M]): + """A container widget for dataclass-like models (dataclass, pydantic, attrs). + + This widget wraps a structured type (dataclass, pydantic model, attrs class, etc.) + and provides a `.value` property that returns an instance of that type, constructed + from the values of its child widgets. + + Parameters + ---------- + value_type : type[_M] + The model class to construct when getting the value. + widgets : Sequence[Widget], optional + Child widgets representing the model's fields. + **kwargs : Any + Additional arguments passed to ValuedContainerWidget. + """ + + def __init__( + self, + value_type: type[_M], + widgets: Sequence[Widget] = (), + value: _M | None | _Undefined = Undefined, + **kwargs: Any, + ) -> None: + self._value_type = value_type + super().__init__(widgets=widgets, **kwargs) + # Connect child widget changes to emit our changed signal + for w in self._list: + if isinstance(w, BaseValueWidget): + w.changed.connect(self._on_child_changed) + if not isinstance(value, _Undefined): + self.set_value(value) + + def _on_child_changed(self, _: Any = None) -> None: + """Emit changed signal when any child widget changes.""" + self.changed.emit(self.value) + + def get_value(self) -> _M: + """Construct a model instance from child widget values.""" + values: dict[str, Any] = {} + for w in self._list: + if not w.name or w.gui_only: + continue + if hasattr(w, "value"): + values[w.name] = w.value + return self._value_type(**values) + + def set_value(self, value: _M | None) -> None: + """Distribute model instance values to child widgets.""" + if value is None: + return + + vals = self._get_values(value) + if vals is None: + return + with self.changed.blocked(): + for w in self._list: + if w.name and hasattr(w, "value") and w.name in vals: + w.value = vals[w.name] + + def __repr__(self) -> str: + """Return string representation.""" + return f"<{self.__class__.__name__} value_type={self._value_type.__name__!r}>" + + @staticmethod + def _get_values(obj: Any) -> dict | None: + """Return a dict of values from an object. + + The object can be a dataclass, attrs, pydantic object or named tuple. + """ + if isinstance(obj, dict): + return obj + + # named tuple + if isinstance(obj, tuple) and hasattr(obj, "_asdict"): + return cast("dict", obj._asdict()) + + import dataclasses + + # dataclass + if dataclasses.is_dataclass(type(obj)): + return dataclasses.asdict(obj) + + # attrs + attr = sys.modules.get("attr") + if attr is not None and attr.has(obj): + return cast("dict", attr.asdict(obj)) + + # pydantic models + if hasattr(obj, "model_dump"): + return cast("dict", obj.model_dump()) + elif hasattr(obj, "dict"): + return cast("dict", obj.dict()) + + return None + + @backend_widget class ToolBar(ToolBarWidget): """Toolbar that contains a set of controls.""" diff --git a/src/magicgui/widgets/bases/_container_widget.py b/src/magicgui/widgets/bases/_container_widget.py index 6e03d0d0..e80e10b8 100644 --- a/src/magicgui/widgets/bases/_container_widget.py +++ b/src/magicgui/widgets/bases/_container_widget.py @@ -230,6 +230,14 @@ def _pop_widget(self, index: int) -> WidgetVar: del self._list[index] return item + def asdict(self) -> dict[str, Any]: + """Return state of widget as dict.""" + return { + w.name: getattr(w, "value", None) + for w in self._list + if w.name and not w.gui_only + } + class ValuedContainerWidget( BaseContainerWidget[Widget], BaseValueWidget[T], Generic[T] @@ -269,6 +277,29 @@ def __init__( if self._bound_value is not Undefined and "visible" not in base_widget_kwargs: self.hide() + def insert(self, index: int, value: Widget) -> None: + """Insert `value` (a widget) at ``index``.""" + if isinstance(value, (BaseValueWidget, BaseContainerWidget)): + value.changed.connect(lambda: self.changed.emit(self.value)) + self._insert_widget(index, value) + + def append(self, widget: Widget) -> None: + """Append a widget to the container.""" + self.insert(len(self), widget) + + def update( + self, + mapping: Mapping | Iterable[tuple[str, Any]] = (), + **kwargs: Any, + ) -> None: + """Update the parameters in the widget from a mapping, iterable, or kwargs.""" + with self.changed.blocked(): + items = mapping.items() if isinstance(mapping, Mapping) else mapping + for key, value in chain(items, kwargs.items()): + if isinstance(wdg := self._list.get_by_name(key), BaseValueWidget): + wdg.value = value + self.changed.emit(self.value) + class ContainerWidget(BaseContainerWidget[WidgetVar], MutableSequence[WidgetVar]): """Container widget that can insert/remove child widgets. @@ -330,6 +361,10 @@ def __setattr__(self, name: str, value: Any) -> None: ) object.__setattr__(self, name, value) + def append(self, value: WidgetVar) -> None: + """Append a widget to the container.""" + self.insert(len(self), value) + def index(self, value: Any, start: int = 0, stop: int = 9223372036854775807) -> int: """Return index of a specific widget instance (or widget name).""" if isinstance(value, str): @@ -372,11 +407,11 @@ def __dir__(self) -> list[str]: d.extend([w.name for w in self._list if not w.gui_only]) return d - def insert(self, key: int, widget: WidgetVar) -> None: - """Insert widget at ``key``.""" - if isinstance(widget, (BaseValueWidget, BaseContainerWidget)): - widget.changed.connect(lambda: self.changed.emit(self)) - self._insert_widget(key, widget) + def insert(self, index: int, value: WidgetVar) -> None: + """Insert widget at ``index``.""" + if isinstance(value, (BaseValueWidget, BaseContainerWidget)): + value.changed.connect(lambda: self.changed.emit(self)) + self._insert_widget(index, value) @property def __signature__(self) -> MagicSignature: @@ -424,18 +459,6 @@ def __repr__(self) -> str: NO_VALUE = "NO_VALUE" - def asdict(self) -> dict[str, Any]: - """Return state of widget as dict.""" - ret: dict[str, Any] = {} - for w in self._list: - if not w.name or w.gui_only: - continue - if isinstance(w, ContainerWidget) and w.widget_type == "Container": - ret[w.name] = w.asdict() - else: - ret[w.name] = getattr(w, "value", None) - return ret - def update( self, mapping: Mapping | Iterable[tuple[str, Any]] = (), @@ -445,10 +468,7 @@ def update( with self.changed.blocked(): items = mapping.items() if isinstance(mapping, Mapping) else mapping for key, value in chain(items, kwargs.items()): - wdg = self._list.get_by_name(key) - if isinstance(wdg, ContainerWidget) and isinstance(value, Mapping): - wdg.update(value) - elif isinstance(wdg, BaseValueWidget): + if isinstance(wdg := self._list.get_by_name(key), BaseValueWidget): wdg.value = value self.changed.emit(self) diff --git a/tests/test_gui_class.py b/tests/test_gui_class.py index 3f457dd4..efaa3df4 100644 --- a/tests/test_gui_class.py +++ b/tests/test_gui_class.py @@ -16,7 +16,7 @@ is_guiclass, unbind_gui_from_instance, ) -from magicgui.widgets import Container, PushButton +from magicgui.widgets import Container, ModelContainerWidget, PushButton def test_guiclass() -> None: @@ -44,7 +44,7 @@ def func(self) -> dict: assert foo.a == 1 assert foo.b == "bar" - assert isinstance(foo.gui, Container) + assert isinstance(foo.gui, ModelContainerWidget) assert isinstance(foo.gui.func, PushButton) assert foo.gui.a.value == 1 assert foo.gui.b.value == "bar" @@ -88,7 +88,7 @@ def func(self) -> dict: assert foo.a == 1 assert foo.b == "bar" - assert isinstance(foo.gui, Container) + assert isinstance(foo.gui, ModelContainerWidget) assert isinstance(foo.gui.get_widget("func"), PushButton) assert foo.gui.a.value == 1 assert foo.gui.b.value == "bar" @@ -128,7 +128,7 @@ class Foo: foo = Foo() assert foo.a == 1 assert foo.b == "bar" - assert isinstance(foo.gui, Container) + assert isinstance(foo.gui, ModelContainerWidget) @pytest.mark.skipif( @@ -158,7 +158,7 @@ class Foo: # note that with slots=True, the gui is recreated on every access assert foo.gui is not gui - assert isinstance(gui, Container) + assert isinstance(gui, ModelContainerWidget) assert gui.a.value == 1 foo.b = "baz" assert gui.b.value == "baz" @@ -230,6 +230,6 @@ class Foo: annotation: str = "bar" foo = Foo() - assert isinstance(foo.gui, Container) + assert isinstance(foo.gui, ModelContainerWidget) foo.gui.update({"name": "baz", "annotation": "qux"}) assert asdict(foo) == {"name": "baz", "annotation": "qux"} diff --git a/tests/test_ui_field.py b/tests/test_ui_field.py index f5d7f835..4bb77263 100644 --- a/tests/test_ui_field.py +++ b/tests/test_ui_field.py @@ -5,7 +5,7 @@ from typing_extensions import TypedDict from magicgui.schema._ui_field import UiField, build_widget, get_ui_fields -from magicgui.widgets import Container +from magicgui.widgets import ModelContainerWidget EXPECTED = ( UiField(name="a", type=int, nullable=True), @@ -18,7 +18,7 @@ def _assert_uifields(cls, instantiate=True): result = tuple(get_ui_fields(cls)) assert result == EXPECTED wdg = build_widget(cls) - assert isinstance(wdg, Container) + assert isinstance(wdg, ModelContainerWidget) assert wdg.asdict() == { "a": 0, "b": "", @@ -28,7 +28,7 @@ def _assert_uifields(cls, instantiate=True): instance = cls(a=1, b="hi") assert tuple(get_ui_fields(instance)) == EXPECTED wdg2 = build_widget(instance) - assert isinstance(wdg2, Container) + assert isinstance(wdg2, ModelContainerWidget) assert wdg2.asdict() == { "a": 1, "b": "hi", @@ -204,9 +204,67 @@ class Foo: # assert wdg.g.max_items == 5 # TODO -def test_resolved_type(): +def test_resolved_type() -> None: f: UiField[int] = UiField(type=Annotated["int", UiField(minimum=0)]) assert f.resolved_type is int f = UiField(type="int") assert f.resolved_type is int + + +def test_nested_dataclass() -> None: + """Test nested dataclass builds ModelContainerWidget with .value support.""" + + @dataclass + class Inner: + x: int = 1 + y: str = "hello" + + @dataclass + class Outer: + inner: Inner + a: int = 5 + + wdg = build_widget(Outer) + assert isinstance(wdg, ModelContainerWidget) + + # Check nested widget is a ModelContainerWidget with correct name + assert wdg.inner.name == "inner" + assert isinstance(wdg.inner, ModelContainerWidget) + + # Check child widget values + assert wdg.inner.x.value == 0 + assert wdg.inner.y.value == "" + + # KEY FEATURE: nested container has .value that returns model instance + inner_value = wdg.inner.value + assert isinstance(inner_value, Inner) + assert inner_value.x == 0 + assert inner_value.y == "" + + # Modify values via child widgets + wdg.a.value = 10 + wdg.inner.x.value = 42 + wdg.inner.y.value = "world" + + # Check .value reflects changes + inner_value = wdg.inner.value + assert inner_value.x == 42 + assert inner_value.y == "world" + + # asdict returns model instances for nested containers + result = wdg.asdict() + assert result["a"] == 10 + assert isinstance(result["inner"], Inner) + assert result["inner"].x == 42 + assert result["inner"].y == "world" + + # Setting .value on nested container updates child widgets + wdg.inner.value = Inner(x=99, y="updated") + assert wdg.inner.x.value == 99 + assert wdg.inner.y.value == "updated" + + # update() on outer container works with model instances + wdg.update({"a": 100, "inner": Inner(x=1, y="reset")}) + assert wdg.a.value == 100 + assert wdg.inner.value == Inner(x=1, y="reset") From 89f2972211b98285a1fe08c11b4a1e30b467cf1f Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 30 Dec 2025 15:49:32 -0500 Subject: [PATCH 7/7] fix docs --- docs/scripts/_gen_widgets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/scripts/_gen_widgets.py b/docs/scripts/_gen_widgets.py index f92cde13..daae9a2d 100644 --- a/docs/scripts/_gen_widgets.py +++ b/docs/scripts/_gen_widgets.py @@ -77,7 +77,7 @@ def _snap_image(_obj: type, _name: str) -> str: from qtpy.QtWidgets import QVBoxLayout, QWidget outer = QWidget() - if _obj is widgets.Container: + if _obj in (widgets.Container, widgets.ModelContainerWidget): return "" if issubclass(_obj, widgets.FunctionGui): return ""