Source code for magicclass.fields._group

from __future__ import annotations
from contextlib import contextmanager
from functools import wraps, cached_property
import weakref
import types
from typing import (
    Any,
    TYPE_CHECKING,
    Callable,
    Iterator,
    TypeVar,
    overload,
)
from abc import ABCMeta
from magicgui.widgets import Container
from magicgui.widgets._bases import Widget
from psygnal import SignalInstance

from ._fields import MagicField, MagicValueField, _FieldObject
from ..utils import Tooltips

if TYPE_CHECKING:
    from typing_extensions import Self, Literal


class _FieldGroupMeta(ABCMeta):
    _fields: dict[str, MagicField]

    def __new__(
        fcls: type,
        name: str,
        bases: tuple,
        namespace: dict,
        **kwds,
    ) -> _FieldGroupMeta:
        cls: _FieldGroupMeta = type.__new__(fcls, name, bases, namespace, **kwds)
        _tooltips = Tooltips(cls)
        _fields: dict[str, MagicField] = {}
        for base in cls.__mro__[1:-1]:
            if type(base) is _FieldGroupMeta:
                _fields.update(base._fields)

        for k, v in namespace.items():
            if isinstance(v, _FieldObject):
                if k in _View._METHODS:
                    raise ValueError(f"Attribute {k} cannot be used in HasFields.")
                _fields[k] = v
                if not v.tooltip:
                    v.tooltip = _tooltips.attributes.get(k, "")

        cls._fields = types.MappingProxyType(_fields)

        return cls


_C = TypeVar("_C", bound=type)


@overload
def dataclass_gui(
    cls: _C,
    init: bool = True,
    repr: bool = True,
    eq: bool = True,
    order: bool = False,
    unsafe_hash: bool = False,
    frozen: bool = False,
) -> _C | type[HasFields]:
    ...


@overload
def dataclass_gui(
    cls: Literal[None],
    init: bool = True,
    repr: bool = True,
    eq: bool = True,
    order: bool = False,
    unsafe_hash: bool = False,
    frozen: bool = False,
) -> Callable[[_C], _C | type[HasFields]]:
    ...


[docs]def dataclass_gui( cls=None, /, *, init=True, repr=True, eq=True, order=False, unsafe_hash=False, frozen=False, ): """ A dataclass-like decorator for GUI-implemented class. .. code-block:: python @dataclass_gui class A: i: int = vfield(int) s: str = vfield(str) is identical to: .. code-block:: python @dataclass class A(HasFields): i: int = vfield(int) s: str = vfield(str) Returns ------- HasField subtype GUI implemented class """ def _wrapper(cls: type): from dataclasses import dataclass, Field, MISSING cls_annot = cls.__dict__.get("__annotations__", {}) namespace: dict[str, Any] = {key: cls.__dict__[key] for key in cls_annot} newtype = type(cls.__name__, (cls, HasFields), namespace) if init: import inspect params: list[inspect.Parameter] = [] _empty = inspect.Parameter.empty for name, annot in cls_annot.items(): default = newtype.__dict__.get(name, _empty) if isinstance(default, MagicValueField): default = default.value elif isinstance(default, Field): default = default.default if default is MISSING: default = _empty param = inspect.Parameter( name=name, kind=inspect.Parameter.POSITIONAL_OR_KEYWORD, default=default, annotation=annot, ) params.append(param) signature = inspect.Signature(params) has_post_init = hasattr(newtype, "__post_init__") def __init__(self: HasFields, *args, **kwargs): bound = signature.bind(*args, **kwargs) with self.signals.blocked(): for k, v in bound.arguments.items(): setattr(self, k, v) if has_post_init: self.__post_init__() __init__.__annotations__ = {p: p.annotation for p in params} __init__.__annotations__["return"] = None cls.__init__ = __init__ if repr: def __repr__(self: HasFields) -> str: _repr = ", ".join( f"{name}={getattr(self, name)!r}" for name in cls_annot ) return f"{self.__class__.__name__}({_repr})" cls.__repr__ = __repr__ return dataclass( newtype, init=False, repr=False, eq=eq, order=order, unsafe_hash=unsafe_hash, frozen=frozen, ) return _wrapper if cls is None else _wrapper(cls)
[docs]class HasFields(metaclass=_FieldGroupMeta): """ A trait implemented with widgets and signals. Subclasses can easily handle widgets and their value-change signals using the same attribute names. >>> class A(HasFields): >>> a = vfield(int) >>> b = vfield(str) >>> ins = A() >>> ins.a # type: int >>> ins.widgets.a # type: SpinBox >>> ins.signals.a # type: SignalInstance """ @cached_property def widgets(self) -> WidgetView: """Return a view of widgets.""" return WidgetView(self) @cached_property def signals(self) -> SignalView: """Return a view of signals.""" return SignalView(self) def __repr__(self) -> str: """List up child widgets.""" _repr = ",\n\t".join( f"{name} = {wdt!r}" for name, wdt in self.widgets.iteritems() ) return f"{self.__class__.__name__}(\n\t{_repr}\n)"
# NOTE: Typing of FieldGroup is tricky. When it is referred to via __get__, it # must return a object of same type as itself to guarantee the child fields are # also defined there. Thus, FieldGroup must inherit magicgui Container although # the original container will never be used.
[docs]class FieldGroup(Container, HasFields, _FieldObject): def __init__( self, layout: str = "vertical", labels: bool = True, name: str | None = None, **kwargs, ): widgets = [fld.get_widget(self) for fld in self.__class__._fields.values()] super().__init__( layout=layout, widgets=widgets, labels=labels, name=name, **kwargs ) self._containers: dict[int, Self] = {} self._callbacks: list[Callable] = [] def __init_subclass__(cls) -> None: if "__init__" not in cls.__dict__.keys(): return cls.__base_init__ = cls.__init__ @wraps(cls.__init__) def __init__(self: cls, *args, **kwargs): self.__input_arguments = (args, kwargs) cls.__base_init__(self, *args, **kwargs) def __newlike__(self): args, kwargs = self.__input_arguments return cls(*args, **kwargs) cls.__init__ = __init__ cls.__newlike__ = __newlike__ def __newlike__(self) -> Self: """ Make a copy of a FieldGroup. This method needs override if __init__ is overrided in a subclass. """ return self.__class__( layout=self.layout, labels=self.labels, label=self.label, enabled=self.enabled, name=self.name, tooltip=self.tooltip, ) def __set_name__(self, owner: type, name: str): """Set variable name as the container's name.""" # self._parent_class = owner if self.name is None: self.name = name # Unlike Container, `self.x = value` should be allowed because `x` can be a value field. __setattr__ = object.__setattr__
[docs] def copy(self) -> Self: """Copy widget.""" wdt = self.__newlike__() for callback in self._callbacks: wdt.connect(callback) return wdt
@property def callbacks(self) -> tuple[Callable, ...]: """Return callbacks in an immutable way.""" return tuple(self._callbacks) @overload def __get__(self, obj: Literal[None], objtype: Any | None = None) -> Self: ... @overload def __get__(self, obj: Any, objtype: Any | None = None) -> Self: ... def __get__(self, obj, objtype=None): if obj is None: return self return self.get_widget(obj) def __set__(self, obj, value) -> None: raise AttributeError(f"Cannot set value to {self.__class__.__name__}.")
[docs] def get_widget(self, obj) -> Container: _id = id(obj) wdt = self._containers.get(_id, None) if wdt is None: wdt = self.copy() self._containers[_id] = wdt return wdt
[docs] def connect(self, callback: Callable): # self.changed.connect(callback) NOTE: the original container doesn't need signals! self._callbacks.append(callback) return callback
class _View: _METHODS = ( "iternames", "iterwidgets", "itersignals", ) def __init__(self, obj: HasFields): self._obj_ref = weakref.ref(obj) def __repr__(self) -> str: _it = zip(self.iternames(), self.iterwidgets()) _repr = ",\n\t".join(f"{name} = {repr(wdt)}" for name, wdt in _it) return f"{self.__class__.__name__}(\n\t{_repr}\n)" def __len__(self) -> int: obj = self._obj_ref() return len(obj.__class__._fields) def iternames(self) -> Iterator[str]: """Iterate widget names.""" return iter(self._obj_ref().__class__._fields.keys()) def iterwidgets(self) -> Iterator[Widget]: """Iterate widgets.""" obj = self._obj_ref() for fld in obj.__class__._fields.values(): wdt = fld.get_widget(obj) yield wdt def itersignals(self, skip_undef: bool = False) -> Iterator[SignalInstance | None]: """Iterate value-changed signals.""" obj = self._obj_ref() for fld in obj.__class__._fields.values(): wdt = fld.get_widget(obj) sig = getattr(wdt, "changed", None) if isinstance(sig, SignalInstance): yield sig elif not skip_undef: yield None class WidgetView(_View): """View of widgets.""" def __getattr__(self, name: str) -> Widget: obj = self._obj_ref() fld = obj.__class__._fields.get(name, None) if isinstance(fld, _FieldObject): return fld.get_widget(obj) raise AttributeError(f"{obj!r} does not have attribute {name!r}.") def __getitem__(self, key: str | int) -> Widget: """Similar to Container's __getitem__.""" if isinstance(key, int): obj = self._obj_ref() key = list(obj.__class__._fields.keys())[key] try: wdt = self.__getattr__(key) except AttributeError: raise KeyError(key) return wdt def iteritems(self) -> Iterator[tuple[str, Widget]]: """Iterate widget names and widgets themselves.""" return iter(zip(self.iternames(), self.iterwidgets())) def __iter__(self) -> Iterator[Widget]: """Iterate widgets.""" return self.iterwidgets() def as_container( self, layout: str = "vertical", labels: bool = True, keys: list[str] | None = None, **kwargs, ) -> Container: """Convert view into a Container widget.""" if keys is None: widgets = list(self) else: widgets = [getattr(self, name) for name in keys] return Container(layout=layout, widgets=widgets, labels=labels, **kwargs) def emit_all(self) -> None: """Emit all the signals with current value.""" for wdt, sig in zip(self.iterwidgets(), self.itersignals()): if sig is not None: sig.emit(wdt.value) class SignalView(_View): """View of signals.""" def __getattr__(self, name: str) -> SignalInstance: obj = self._obj_ref() fld = obj.__class__._fields.get(name, None) if isinstance(fld, _FieldObject): wdt = fld.get_widget(obj) sig = getattr(wdt, "changed", None) if not isinstance(sig, SignalInstance): raise AttributeError(f"Widget {wdt!r} does not have 'changed' signal") return sig raise AttributeError(f"{obj!r} does not have attribute {name!r}.") def __getitem__(self, key: str | int) -> SignalInstance: """Similar to list.__getitem__.""" if isinstance(key, int): obj = self._obj_ref() key = list(obj.__class__._fields.keys())[key] try: wdt = self.__getattr__(key) except AttributeError: raise KeyError(key) return wdt def iteritems(self) -> Iterator[tuple[str, SignalInstance | None]]: """Iterate widget names and value-changed signals.""" return iter(zip(self.iternames(), self.itersignals())) def __iter__(self) -> Iterator[SignalInstance | None]: return self.itersignals() def block(self) -> None: """Block all the signals.""" for sig in self.itersignals(skip_undef=True): sig.block() def unblock(self) -> None: """Unblock all the signals.""" for sig in self.itersignals(skip_undef=True): sig.unblock() @contextmanager def blocked(self): """Temporarly block all signals.""" self.block() try: yield finally: self.unblock()