"""The FunctionGui class is a Container subclass designed to represent a function.
The core `magicgui` decorator returns an instance of a FunctionGui widget.
"""
from __future__ import annotations
import inspect
import re
from collections import deque
from contextlib import contextmanager
from pathlib import Path
from types import FunctionType
from typing import (
TYPE_CHECKING,
Any,
Callable,
Deque,
ForwardRef,
Generic,
TypeVar,
cast,
)
from magicgui.application import AppRef
from magicgui.events import Signal
from magicgui.signature import MagicSignature, magic_signature
from magicgui.widgets import Container, LineEdit, MainWindow, ProgressBar, PushButton
from magicgui.widgets._protocols import ContainerProtocol, MainWindowProtocol
if TYPE_CHECKING:
from magicgui.widgets import TextEdit
def _inject_tooltips_from_docstrings(docstring: str | None, sig: MagicSignature):
"""Update ``sig`` gui options with tooltips extracted from ``docstring``."""
from docstring_parser import parse
if not docstring:
return
doc_params = {p.arg_name: p.description for p in parse(docstring).params}
# deal with the (numpydocs) case when there are multiple parameters separated
# by a comma
for k, v in list(doc_params.items()):
if "," in k:
for split_key in k.split(","):
doc_params[split_key.strip()] = v
del doc_params[k]
for name, description in doc_params.items():
# this is to catch potentially bad arg_name parsing in docstring_parser
# if using napoleon style google docstringss
argname = name.split(" ", maxsplit=1)[0]
desc = description.replace("`", "") if description else ""
# use setdefault so as not to override an explicitly provided tooltip
sig.parameters[argname].options.setdefault("tooltip", desc)
_R = TypeVar("_R")
[docs]class FunctionGui(Container, Generic[_R]):
"""Wrapper for a container of widgets representing a callable object.
Parameters
----------
function : Callable
A callable to turn into a GUI
call_button : bool, str, or None, optional
If True, create an additional button that calls the original function when
clicked. If a ``str``, set the button text. by default False when
auto_call is True, and True otherwise.
layout : str, optional
The type of layout to use. Must be one of {'horizontal', 'vertical'}.
by default "horizontal".
labels : bool, optional
Whether labels are shown in the widget. by default True
tooltips : bool, optional
Whether tooltips are shown when hovering over widgets. by default True
app : magicgui.Application or str, optional
A backend to use, by default ``None`` (use the default backend.)
visible : bool, optional
Whether to immediately show the widget. If ``False``, widget is explicitly
hidden. If ``None``, widget is not shown, but will be shown if a parent
container is shown, by default None.
auto_call : bool, optional
If True, changing any parameter in either the GUI or the widget attributes
will call the original function with the current settings. by default False
result_widget : bool, optional
Whether to display a LineEdit widget the output of the function when called,
by default False
param_options : dict, optional
A dict of name: widget_options dict for each parameter in the function.
Will be passed to `magic_signature` by default ``None``
name : str, optional
A name to assign to the Container widget, by default `function.__name__`
persist : bool, optional
If `True`, when parameter values change in the widget, they will be stored to
disk (in `~/.config/magicgui/cache`) and restored when the widget is loaded
again with ``persist = True``. By default, `False`.
Raises
------
TypeError
If unexpected keyword arguments are provided
"""
called = Signal(object)
_widget: ContainerProtocol
def __init__(
self,
function: Callable[..., _R],
call_button: bool | str | None = None,
layout: str = "vertical",
labels: bool = True,
tooltips: bool = True,
app: AppRef = None,
visible: bool = None,
auto_call: bool = False,
result_widget: bool = False,
param_options: dict[str, dict] | None = None,
name: str = None,
persist: bool = False,
**kwargs,
):
if not callable(function):
raise TypeError("'function' argument to FunctionGui must be callable.")
# consume extra Widget keywords
extra = set(kwargs) - {"annotation", "gui_only"}
if extra:
s = "s" if len(extra) > 1 else ""
raise TypeError(f"FunctionGui got unexpected keyword argument{s}: {extra}")
if param_options is None:
param_options = {}
elif not isinstance(param_options, dict):
raise TypeError("'param_options' must be a dict of dicts")
sig = magic_signature(function, gui_options=param_options)
self.return_annotation = sig.return_annotation
if tooltips:
_inject_tooltips_from_docstrings(function.__doc__, sig)
self.persist = persist
self._function = function
self.__wrapped__ = function
# it's conceivable that function is not actually an instance of FunctionType
# we can still support any generic callable, but we need to be careful not to
# access attributes (like `__name__` that only function objects have).
# Mypy doesn't seem catch this at this point:
# https://github.com/python/mypy/issues/9934
self._callable_name = (
getattr(function, "__name__", None)
or f"{function.__module__}.{function.__class__}"
)
super().__init__(
layout=layout,
labels=labels,
visible=visible,
widgets=list(sig.widgets(app).values()),
name=name or self._callable_name,
)
self._param_options = param_options
self._result_name = ""
self._call_count: int = 0
# a deque of Progressbars to be created by (possibly nested) tqdm_mgui iterators
self._tqdm_pbars: Deque[ProgressBar] = deque()
# the nesting level of tqdm_mgui iterators in a given __call__
self._tqdm_depth: int = 0
if call_button is None:
call_button = not auto_call
self._call_button: PushButton | None = None
if call_button:
text = call_button if isinstance(call_button, str) else "Run"
self._call_button = PushButton(gui_only=True, text=text, name="call_button")
if not auto_call: # (otherwise it already gets called)
@self._call_button.changed.connect
def _disable_button_and_call():
# disable the call button until the function has finished
self._call_button = cast(PushButton, self._call_button)
self._call_button.enabled = False
try:
self.__call__()
finally:
self._call_button.enabled = True
self.append(self._call_button)
self._result_widget: LineEdit | None = None
if result_widget:
self._result_widget = LineEdit(gui_only=True, name="result")
self._result_widget.enabled = False
self.append(self._result_widget)
if persist:
self._load(quiet=True)
self._auto_call = auto_call
self.changed.connect(self._on_change)
def _on_change(self):
if self.persist:
self._dump()
if self._auto_call:
self()
@property
def call_count(self) -> int:
"""Return the number of times the function has been called."""
return self._call_count
[docs] def reset_call_count(self) -> None:
"""Reset the call count to 0."""
self._call_count = 0
@property
def return_annotation(self):
"""Return annotation to use when converting to :class:`inspect.Signature`.
ForwardRefs will be resolve when setting the annotation.
"""
return self._return_annotation
@return_annotation.setter
def return_annotation(self, value):
if isinstance(value, (str, ForwardRef)):
from magicgui.type_map import _evaluate_forwardref
value = _evaluate_forwardref(value)
self._return_annotation = value
@property
def __signature__(self) -> MagicSignature:
"""Return a MagicSignature object representing the current state of the gui."""
return super().__signature__.replace(return_annotation=self.return_annotation)
def __call__(self, *args: Any, **kwargs: Any) -> _R:
"""Call the original function with the current parameter values from the Gui.
It is also possible to override the current parameter values from the GUI by
providing args/kwargs to the function call. Only those provided will override
the ones from the gui. A `called` signal will also be emitted with the results.
Returns
-------
result : Any
whatever the return value of the original function would have been.
Examples
--------
gui = FunctionGui(func, show=True)
# then change parameters in the gui, or by setting: gui.param.value = something
gui() # calls the original function with the current parameters
"""
sig = self.__signature__
try:
bound = sig.bind(*args, **kwargs)
except TypeError as e:
if "missing a required argument" in str(e):
match = re.search("argument: '(.+)'", str(e))
missing = match.groups()[0] if match else "<param>"
msg = (
f"{e} in call to '{self._callable_name}{sig}'.\n"
"To avoid this error, you can bind a value or callback to the "
f"parameter:\n\n {self._callable_name}.{missing}.bind(value)"
"\n\nOr use the 'bind' option in the magicgui decorator:\n\n"
f" @magicgui({missing}={{'bind': value}})\n"
f" def {self._callable_name}{sig}: ..."
)
raise TypeError(msg) from None
else:
raise
bound.apply_defaults()
self._tqdm_depth = 0 # reset the tqdm stack count
with _function_name_pointing_to_widget(self):
value = self._function(*bound.args, **bound.kwargs)
self._call_count += 1
if self._result_widget is not None:
with self._result_widget.changed.blocked():
self._result_widget.value = value
return_type = sig.return_annotation
if return_type:
from magicgui.type_map import _type2callback
for callback in _type2callback(return_type):
callback(self, value, return_type)
self.called.emit(value)
return value
def __repr__(self) -> str:
"""Return string representation of instance."""
return f"<{type(self).__name__} {self._callable_name}{self.__signature__}>"
@property
def result_name(self) -> str:
"""Return a name that can be used for the result of this magicfunction."""
return self._result_name or (self._callable_name + " result")
@result_name.setter
def result_name(self, value: str):
"""Set the result name of this FunctionGui widget."""
self._result_name = value
[docs] def copy(self) -> FunctionGui:
"""Return a copy of this FunctionGui."""
return FunctionGui(
function=self._function,
call_button=self._call_button.text if self._call_button else None,
layout=self.layout,
labels=self.labels,
param_options=self._param_options,
auto_call=self._auto_call,
result_widget=bool(self._result_widget),
app=None,
)
_bound_instances: dict[int, FunctionGui] = {}
def __get__(self, obj, objtype=None) -> FunctionGui:
"""Provide descriptor protocol.
This allows the @magicgui decorator to work on a function as well as a method.
If a method on a class is decorated with `@magicgui`, then accessing the
attribute on an instance of that class will return a version of the FunctionGui
in which the first argument of the function is bound to the instance. (Just like
what you'd expect with the @property decorator.)
Example
-------
>>> class MyClass:
... @magicgui
... def my_method(self, x=1):
... print(locals())
...
>>> c = MyClass()
>>> c.my_method # the FunctionGui that can be used as a widget
>>> c.my_method(x=34) # calling it works as usual, with `c` provided as `self`
{'self': <__main__.MyClass object at 0x7fb610e455e0>, 'x': 34}
"""
if obj is None:
return self
obj_id = id(obj)
if obj_id not in self._bound_instances:
method = getattr(obj.__class__, self._function.__name__)
p0 = list(inspect.signature(method).parameters)[0]
prior, self._param_options = self._param_options, {
p0: {"bind": obj},
**self._param_options,
}
try:
self._bound_instances[obj_id] = self.copy()
finally:
self._param_options = prior
return self._bound_instances[obj_id]
def __set__(self, obj, value):
"""Prevent setting a magicgui attribute."""
raise AttributeError("Can't set magicgui attribute")
@property
def _dump_path(self) -> Path:
from .._util import user_cache_dir
name = getattr(self._function, "__qualname__", self._callable_name)
name = name.replace("<", "-").replace(">", "-") # e.g. <locals>
return user_cache_dir() / f"{self._function.__module__}.{name}"
def _dump(self, path=None):
super()._dump(path or self._dump_path)
def _load(self, path=None, quiet=False):
super()._load(path or self._dump_path, quiet=quiet)
[docs]class MainFunctionGui(FunctionGui[_R], MainWindow):
"""Container of widgets as a Main Application Window."""
_widget: MainWindowProtocol
def __init__(self, function: Callable, *args, **kwargs):
super().__init__(function, *args, **kwargs)
self.create_menu_item("Help", "Documentation", callback=self._show_docs)
self._help_text_edit: TextEdit | None = None
def _show_docs(self):
if not self._help_text_edit:
from magicgui.widgets import TextEdit
docs = self._function.__doc__
html = _docstring_to_html(docs) if docs else "None"
self._help_text_edit = TextEdit(value=html)
self._help_text_edit.read_only = True
self._help_text_edit.width = 600
self._help_text_edit.height = 400
self._help_text_edit.show()
def _docstring_to_html(docs: str) -> str:
"""Convert docstring into rich text html."""
from docstring_parser import parse
ds = parse(docs)
ptemp = "<li><p><strong>{}</strong> (<em>{}</em>) - {}</p></li>"
plist = [ptemp.format(p.arg_name, p.type_name, p.description) for p in ds.params]
params = "<h3>Parameters</h3><ul>{}</ul>".format("".join(plist))
short = f"<p>{ds.short_description}</p>" if ds.short_description else ""
long = f"<p>{ds.long_description}</p>" if ds.long_description else ""
return re.sub(r"``?([^`]+)``?", r"<code>\1</code>", f"{short}{long}{params}")
_UNSET = object()
@contextmanager
def _function_name_pointing_to_widget(function_gui: FunctionGui):
"""Context in which the name of the function points to the function_gui instance.
When calling the function provided to FunctionGui, we make sure that the name
of the function points to the FunctionGui object itself.
In standard ``@magicgui`` usage, this will have been the case anyway.
Doing this here allows the function name in a ``@magic_factory``-decorated function
to *also* refer to the function gui instance created by the factory, (rather than
to the :class:`~magicgui._magicgui.MagicFactory` object).
Examples
--------
>>> @magicgui
>>> def func():
... # using "func" in the body here will refer to the widget.
... print(type(func))
>>>
>>> func() # prints 'magicgui.widgets._function_gui.FunctionGui'
>>> @magic_factory
>>> def func():
... # using "func" in the body here will refer to the *widget* not the factory.
... print(type(func))
>>>
>>> widget = func()
>>> widget() # *also* prints 'magicgui.widgets._function_gui.FunctionGui'
"""
function = function_gui._function
if not isinstance(function, FunctionType):
# it's not a function object, so we don't know how to patch it...
yield
return
func_name = function.__name__
# see https://docs.python.org/3/library/inspect.html for details on code objects
code = function.__code__
if func_name in code.co_names:
# This indicates that the function name was used inside the body of the
# function, and points to some object in the module's global namespace.
# function.__globals__ here points to the module-level globals in which the
# function was defined.
original_value = function.__globals__.get(func_name)
function.__globals__[func_name] = function_gui
try:
yield
finally:
function.__globals__[func_name] = original_value
elif function.__closure__ and func_name in code.co_freevars:
# This indicates that the function name was used inside the body of the
# function, and points to some object defined in a local scope (closure), rather
# than the module's global namespace.
# the position of the function name in code.co_freevars tells us where to look
# for the value in the function.__closure__ tuple.
idx = code.co_freevars.index(func_name)
original_value = function.__closure__[idx].cell_contents
function.__closure__[idx].cell_contents = function_gui
try:
yield
finally:
function.__closure__[idx].cell_contents = original_value
else:
yield