From 12d788ba50083edf4a76b1c029d5ef125db671be Mon Sep 17 00:00:00 2001 From: Grzegorz Bokota Date: Mon, 24 Oct 2022 02:11:48 +0200 Subject: [PATCH] Enable option to raise excpetion if magicgui cannot determine widget for provided value/annotation (#476) * initial implementation * add tests * add argument to magic factory --- magicgui/_magicgui.py | 5 ++++ magicgui/_magicgui.pyi | 8 ++++++ magicgui/signature.py | 33 +++++++++++++++++++----- magicgui/type_map.py | 13 +++++++++- magicgui/widgets/_bases/create_widget.py | 8 +++++- magicgui/widgets/_function_gui.py | 6 ++++- tests/test_magicgui.py | 30 ++++++++++++++++++++- 7 files changed, 93 insertions(+), 10 deletions(-) diff --git a/magicgui/_magicgui.py b/magicgui/_magicgui.py index 3959347ae..29f5420aa 100644 --- a/magicgui/_magicgui.py +++ b/magicgui/_magicgui.py @@ -25,6 +25,7 @@ def magicgui( main_window: bool = False, app: AppRef = None, persist: bool = False, + raise_on_unknown: bool = False, **param_options: dict, ): """Return a :class:`FunctionGui` for ``function``. @@ -65,6 +66,9 @@ def magicgui( disk and restored when the widget is loaded again with ``persist = True``. Call ``magicgui._util.user_cache_dir()`` to get the default cache location. By default False. + raise_on_unknown : bool, optional + If ``True``, raise an error if magicgui cannot determine widget for function + argument or return type. If ``False``, ignore unknown types. By default False. **param_options : dict of dict Any additional keyword arguments will be used as parameter-specific options. @@ -108,6 +112,7 @@ def magic_factory( app: AppRef = None, persist: bool = False, widget_init: Callable[[FunctionGui], None] | None = None, + raise_on_unknown: bool = False, **param_options: dict, ): """Return a :class:`MagicFactory` for ``function``.""" diff --git a/magicgui/_magicgui.pyi b/magicgui/_magicgui.pyi index 6ec7f5b6d..f3732fc21 100644 --- a/magicgui/_magicgui.pyi +++ b/magicgui/_magicgui.pyi @@ -35,6 +35,7 @@ def magicgui( # noqa main_window: Literal[False] = False, app: AppRef = None, persist: bool = False, + raise_on_unknown: bool = False, **param_options: dict, ) -> FunctionGui[_R]: ... @overload # noqa: E302 @@ -51,6 +52,7 @@ def magicgui( # noqa main_window: Literal[False] = False, app: AppRef = None, persist: bool = False, + raise_on_unknown: bool = False, **param_options: dict, ) -> Callable[[Callable[..., _R]], FunctionGui[_R]]: ... @overload # noqa: E302 @@ -67,6 +69,7 @@ def magicgui( # noqa main_window: Literal[True], app: AppRef = None, persist: bool = False, + raise_on_unknown: bool = False, **param_options: dict, ) -> MainFunctionGui[_R]: ... @overload # noqa: E302 @@ -83,6 +86,7 @@ def magicgui( # noqa main_window: Literal[True], app: AppRef = None, persist: bool = False, + raise_on_unknown: bool = False, **param_options: dict, ) -> Callable[[Callable[..., _R]], MainFunctionGui[_R]]: ... @overload # noqa: E302 @@ -100,6 +104,7 @@ def magic_factory( # noqa app: AppRef = None, persist: bool = False, widget_init: Callable[[FunctionGui], None] | None = None, + raise_on_unknown: bool = False, **param_options: dict, ) -> MagicFactory[FunctionGui[_R]]: ... @overload # noqa: E302 @@ -117,6 +122,7 @@ def magic_factory( # noqa app: AppRef = None, persist: bool = False, widget_init: Callable[[FunctionGui], None] | None = None, + raise_on_unknown: bool = False, **param_options: dict, ) -> Callable[[Callable[..., _R]], MagicFactory[FunctionGui[_R]]]: ... @overload # noqa: E302 @@ -134,6 +140,7 @@ def magic_factory( # noqa app: AppRef = None, persist: bool = False, widget_init: Callable[[FunctionGui], None] | None = None, + raise_on_unknown: bool = False, **param_options: dict, ) -> MagicFactory[MainFunctionGui[_R]]: ... @overload # noqa: E302 @@ -151,5 +158,6 @@ def magic_factory( # noqa app: AppRef = None, persist: bool = False, widget_init: Callable[[FunctionGui], None] | None = None, + raise_on_unknown: bool = False, **param_options: dict, ) -> Callable[[Callable[..., _R]], MagicFactory[MainFunctionGui[_R]]]: ... diff --git a/magicgui/signature.py b/magicgui/signature.py index 7ea238b02..d009416b3 100644 --- a/magicgui/signature.py +++ b/magicgui/signature.py @@ -107,9 +107,11 @@ def __init__( default: Any = inspect.Parameter.empty, annotation: Any = inspect.Parameter.empty, gui_options: dict = None, + raise_on_unknown: bool = False, ): _annotation = make_annotated(annotation, gui_options) super().__init__(name, kind, default=default, annotation=_annotation) + self.raise_on_unknown = raise_on_unknown @property def options(self) -> WidgetOptions: @@ -118,7 +120,7 @@ def options(self) -> WidgetOptions: def __repr__(self) -> str: """Return __repr__, replacing NoneType if present.""" - rep = super().__repr__()[:-1] + f" {self.options}>" + rep = f"{super().__repr__()[:-1]} {self.options}>" rep = rep.replace(": NoneType = ", "=") return rep @@ -144,6 +146,7 @@ def to_widget(self, app: AppRef = None) -> Widget: annotation=annotation, app=app, options=options, + raise_on_unknown=self.raise_on_unknown, ) widget.param_kind = self.kind return widget @@ -161,7 +164,10 @@ def from_widget(cls, widget: Widget) -> MagicParameter: @classmethod def from_parameter( - cls, param: inspect.Parameter, gui_options: dict = None + cls, + param: inspect.Parameter, + gui_options: dict = None, + raise_on_unknown: bool = False, ) -> MagicParameter: """Create MagicParameter from an inspect.Parameter.""" if isinstance(param, MagicParameter): @@ -172,6 +178,7 @@ def from_parameter( default=param.default, annotation=param.annotation, gui_options=gui_options, + raise_on_unknown=raise_on_unknown, ) @@ -198,15 +205,20 @@ def __init__( *, return_annotation=inspect.Signature.empty, gui_options: dict[str, dict] = None, + raise_on_unknown: bool = False, ): params = [ - MagicParameter.from_parameter(p, (gui_options or {}).get(p.name)) + MagicParameter.from_parameter( + p, (gui_options or {}).get(p.name), raise_on_unknown + ) for p in parameters or [] ] super().__init__(params, return_annotation=return_annotation) @classmethod - def from_signature(cls, sig: inspect.Signature, gui_options=None) -> MagicSignature: + def from_signature( + cls, sig: inspect.Signature, gui_options=None, raise_on_unknown=False + ) -> MagicSignature: """Convert regular inspect.Signature to MagicSignature.""" if type(sig) is cls: return cast(MagicSignature, sig) @@ -216,6 +228,7 @@ def from_signature(cls, sig: inspect.Signature, gui_options=None) -> MagicSignat list(sig.parameters.values()), return_annotation=sig.return_annotation, gui_options=gui_options, + raise_on_unknown=raise_on_unknown, ) def widgets(self, app: AppRef = None) -> MappingProxyType: @@ -254,7 +267,11 @@ def replace( def magic_signature( - obj: Callable, *, gui_options: dict[str, dict] = None, follow_wrapped: bool = True + obj: Callable, + *, + gui_options: dict[str, dict] = None, + follow_wrapped: bool = True, + raise_on_unknown: bool = False, ) -> MagicSignature: """Create a MagicSignature from a callable object. @@ -270,6 +287,8 @@ def magic_signature( Will be passed to `MagicSignature.from_signature` by default None follow_wrapped : bool, optional passed to inspect.signature, by default True + raise_on_unknown : bool, optional + If True, raise an error if a parameter annotation is not recognized. Returns ------- @@ -297,4 +316,6 @@ def magic_signature( s = "s" if len(bad) > 1 else "" raise TypeError(f"Value for parameter{s} {bad} must be a dict") - return MagicSignature.from_signature(sig, gui_options=gui_options) + return MagicSignature.from_signature( + sig, gui_options=gui_options, raise_on_unknown=raise_on_unknown + ) diff --git a/magicgui/type_map.py b/magicgui/type_map.py index b650dd872..84a7caa79 100644 --- a/magicgui/type_map.py +++ b/magicgui/type_map.py @@ -155,6 +155,7 @@ def pick_widget_type( annotation: type[Any] | None = None, options: WidgetOptions | None = None, is_result: bool = False, + raise_on_unknown: bool = True, ) -> WidgetTuple: """Pick the appropriate widget type for ``value`` with ``annotation``.""" if is_result and annotation is inspect.Parameter.empty: @@ -208,6 +209,11 @@ def pick_widget_type( _cls, opts = _widget_type return _cls, {**options, **opts} # type: ignore + if raise_on_unknown: + raise ValueError( + f"No widget found for type {_type} and annotation {annotation}" + ) + return widgets.EmptyWidget, {"visible": False} @@ -216,6 +222,7 @@ def get_widget_class( annotation: type[Any] | None = None, options: WidgetOptions | None = None, is_result: bool = False, + raise_on_unknown: bool = True, ) -> tuple[WidgetClass, WidgetOptions]: """Return a WidgetClass appropriate for the given parameters. @@ -231,6 +238,8 @@ def get_widget_class( is_result : bool, optional Identifies whether the returned widget should be tailored to an input or to an output. + raise_on_unknown : bool, optional + Raise exception if no widget is found for the given type, by default True Returns ------- @@ -240,7 +249,9 @@ def get_widget_class( """ _options = cast(WidgetOptions, options) - widget_type, _options = pick_widget_type(value, annotation, _options, is_result) + widget_type, _options = pick_widget_type( + value, annotation, _options, is_result, raise_on_unknown + ) if isinstance(widget_type, str): widget_class: WidgetClass = _import_class(widget_type) diff --git a/magicgui/widgets/_bases/create_widget.py b/magicgui/widgets/_bases/create_widget.py index 4e3686d3b..62518611b 100644 --- a/magicgui/widgets/_bases/create_widget.py +++ b/magicgui/widgets/_bases/create_widget.py @@ -23,6 +23,7 @@ def create_widget( widget_type: str | type[_protocols.WidgetProtocol] | None = None, options: WidgetOptions = dict(), is_result: bool = False, + raise_on_unknown: bool = True, ): """Create and return appropriate widget subclass. @@ -62,6 +63,8 @@ def create_widget( is_result : boolean, optional Whether the widget belongs to an input or an output. By defult, an input is assumed. + raise_on_unknown : bool, optional + Raise exception if no widget is found for the given type, by default True Returns ------- @@ -77,6 +80,7 @@ def create_widget( options = options.copy() kwargs = locals().copy() _kind = kwargs.pop("param_kind", None) + kwargs.pop("raise_on_unknown") _is_result = kwargs.pop("is_result", None) _app = use_app(kwargs.pop("app")) assert _app.native @@ -87,7 +91,9 @@ def create_widget( if widget_type: options["widget_type"] = widget_type - wdg_class, opts = get_widget_class(value, annotation, options, is_result) + wdg_class, opts = get_widget_class( + value, annotation, options, is_result, raise_on_unknown + ) if issubclass(wdg_class, Widget): opts.update(kwargs.pop("options")) diff --git a/magicgui/widgets/_function_gui.py b/magicgui/widgets/_function_gui.py index a3a20b394..5cfc4d353 100644 --- a/magicgui/widgets/_function_gui.py +++ b/magicgui/widgets/_function_gui.py @@ -136,6 +136,7 @@ def __init__( param_options: dict[str, dict] | None = None, name: str = None, persist: bool = False, + raise_on_unknown=False, **kwargs, ): if not callable(function): @@ -151,7 +152,9 @@ def __init__( elif not isinstance(param_options, dict): raise TypeError("'param_options' must be a dict of dicts") - sig = magic_signature(function, gui_options=param_options) + sig = magic_signature( + function, gui_options=param_options, raise_on_unknown=raise_on_unknown + ) self.return_annotation = sig.return_annotation self._tooltips = tooltips if tooltips: @@ -221,6 +224,7 @@ def _disable_button_and_call(): annotation=self._return_annotation, gui_only=True, is_result=True, + raise_on_unknown=raise_on_unknown, ) self.append(self._result_widget) diff --git a/tests/test_magicgui.py b/tests/test_magicgui.py index 08b440a0a..24f0285ec 100644 --- a/tests/test_magicgui.py +++ b/tests/test_magicgui.py @@ -373,7 +373,7 @@ def get_layout_items(gui): gui = magicgui(func, labels=labels) assert get_layout_items(gui) == ["a", "b", "c", "call_button"] - gui.insert(1, widgets.create_widget(name="new")) + gui.insert(1, widgets.create_widget(name="new", raise_on_unknown=False)) assert get_layout_items(gui) == ["a", "new", "b", "c", "call_button"] @@ -842,3 +842,31 @@ def test_nonscrollable(a: int = 1, y: str = "a"): assert test_nonscrollable.native is test_nonscrollable.root_native_widget assert not isinstance(test_nonscrollable.native, QScrollArea) + + +def test_unknown_exception_magicgui(): + """Test that an unknown type is raised as a RuntimeError.""" + + class A: + pass + + with pytest.raises(ValueError, match="No widget found for type"): + + @magicgui(raise_on_unknown=True) + def func(a: A): + print(a) + + +def test_unknown_exception_create_widget(): + """Test that an unknown type is raised as a RuntimeError.""" + + class A: + pass + + with pytest.raises(ValueError, match="No widget found for type"): + widgets.create_widget(A, raise_on_unknown=True) + with pytest.raises(ValueError, match="No widget found for type"): + widgets.create_widget(A) + assert isinstance( + widgets.create_widget(A, raise_on_unknown=False), widgets.EmptyWidget + )