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 40039a18..70fc48c0 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 @@ -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]: @@ -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 @@ -441,6 +443,12 @@ 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 + 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 @@ -718,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) @@ -786,7 +812,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. @@ -849,7 +875,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 diff --git a/src/magicgui/widgets/bases/_container_widget.py b/src/magicgui/widgets/bases/_container_widget.py index 9a2ebc31..7aa3b397 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,