Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Magic factory #117

Merged
merged 25 commits into from
Jan 23, 2021
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
c4c5a57
update doc
tlambert03 Jan 21, 2021
b2d8690
wip
tlambert03 Jan 21, 2021
c897bb4
fix type issue
tlambert03 Jan 21, 2021
48b6f8a
remove outdated test that fails type check
tlambert03 Jan 21, 2021
da33f0e
working self-reference in magic_factory
tlambert03 Jan 21, 2021
733b20f
better MagicFactory
tlambert03 Jan 21, 2021
242e72c
settle for globally defined factories
tlambert03 Jan 21, 2021
a0c08d9
Merge branch 'master' into magic-factory
tlambert03 Jan 21, 2021
6593c36
one more test, comments, make private
tlambert03 Jan 21, 2021
b0c5f90
add warning for local factory self-reference
tlambert03 Jan 21, 2021
e5434cc
pass __name__
tlambert03 Jan 22, 2021
d9803cf
better name choice
tlambert03 Jan 22, 2021
ff3e551
Merge branch 'master' into magic-factory
tlambert03 Jan 22, 2021
6e54067
fix type hints
tlambert03 Jan 22, 2021
9b41554
complete type hints
tlambert03 Jan 22, 2021
d025fe2
add typing checks
tlambert03 Jan 22, 2021
8182070
skip typesafety tests on windows
tlambert03 Jan 22, 2021
0ea8a01
Update magicgui/_magicgui.py
tlambert03 Jan 23, 2021
92c1081
remove unused sentinel
tlambert03 Jan 23, 2021
5b4ef9a
expand comment about self-reference
tlambert03 Jan 23, 2021
817e496
more comments
tlambert03 Jan 23, 2021
8941cb9
Merge branch 'magic-factory' of https://github.com/tlambert03/magicgu…
tlambert03 Jan 23, 2021
ab80f32
Merge branch 'master' into magic-factory
tlambert03 Jan 23, 2021
e97610b
clarify
tlambert03 Jan 23, 2021
d3a6f4d
add another UNSET comment
tlambert03 Jan 23, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 0 additions & 4 deletions examples/widget_demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,6 @@ def widget_demo(
filename : str, optional
Pick a path, by default Path.home()

Returns
-------
[type]
[description]
"""
return locals().values()

Expand Down
3 changes: 2 additions & 1 deletion magicgui/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,14 @@
__email__ = "talley.lambert@gmail.com"


from ._magicgui import magicgui
from ._magicgui import magic_factory, magicgui
from .application import event_loop, use_app
from .type_map import register_type

__all__ = [
"event_loop",
"magicgui",
"magic_factory",
"register_type",
"use_app",
]
Expand Down
152 changes: 124 additions & 28 deletions magicgui/_magicgui.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,64 @@
from __future__ import annotations

from functools import partial
from typing import TYPE_CHECKING, Callable, Optional, Union, overload
from warnings import warn

from typing_extensions import Literal

if TYPE_CHECKING:
from magicgui.application import AppRef
from magicgui.widgets import FunctionGui


class MagicFactory(partial):
"""partial subclass dedicated that returns a FunctionGui instance.

This is mostly for type checking and IDE type annotations (isinstance(MagicFactory))
"""

# TODO, only repr things that are not defaults
def __repr__(self):
"""Return string repr."""
args = [repr(x) for x in self.args]
args.extend(f"{k}={v!r}" for (k, v) in self.keywords.items())
return f"MagicFactory({', '.join(args)})"

def __call__(self, /, *args, **keywords) -> FunctionGui:
"""Call the wrapped _magicgui and return a FunctionGui."""
return super().__call__(*args, **keywords)


def _magicgui(factory=False, **kwargs):
"""Actual private magicui decorator.

if factory is `True` will return a MagicFactory instance, that can be called
to return a `FunctionGui` instance. See docstring of ``magicgui`` for parameters
tlambert03 marked this conversation as resolved.
Show resolved Hide resolved
"""
function = kwargs.pop("function", None)
if "result" in kwargs["param_options"]:
warn(
"\n\nThe 'result' option is deprecated and will be removed in the future."
"Please use `result_widget=True` instead.\n",
FutureWarning,
)

kwargs["param_options"].pop("result")
kwargs["result_widget"] = True
tlambert03 marked this conversation as resolved.
Show resolved Hide resolved

def inner_func(func: Callable) -> Union[FunctionGui, MagicFactory]:
from magicgui.widgets import FunctionGui

if factory:
return MagicFactory(FunctionGui, function=func, **kwargs)
return FunctionGui(function=func, **kwargs)

if function is None:
return inner_func
else:
return inner_func(function)
Comment on lines +49 to +52
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In what situations does function=None make sense? Is this that weird trick that could be avoided with toolz.curry? =)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, we've had this discussion before! 😂 ... but I don't want to depend on toolz

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

... and function=None makes sense when you provide parameters to the decorator:

@magicgui(call_button=True)  # magicgui is being called with function=None
def function():
    ...

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fine. 🤷 I think the toolz logic ends up so much cleaner, but I'll accept this for now. 😂

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i agree. looks much better that way.



@overload
def magicgui( # noqa
function: Callable,
Expand All @@ -26,7 +77,7 @@ def magicgui( # noqa

@overload
def magicgui( # noqa
function=None,
function: Literal[None] = None,
*,
layout: str = "horizontal",
labels: bool = True,
Expand Down Expand Up @@ -103,34 +154,79 @@ def magicgui(
>>> my_function.a.value == 1 # True
>>> my_function.b.value = 'world'
"""
if "result" in param_options:
warn(
"\n\nThe 'result' option is deprecated and will be removed in the future."
"Please use `result_widget=True` instead.\n",
FutureWarning,
)
return _magicgui(**locals())

param_options.pop("result")
result_widget = True

def inner_func(func: Callable) -> FunctionGui:
from magicgui.widgets import FunctionGui
@overload
def magic_factory( # noqa
function: Callable,
*,
layout: str = "horizontal",
labels: bool = True,
tooltips: bool = True,
call_button: Union[bool, str] = False,
auto_call: bool = False,
result_widget: bool = False,
app: AppRef = None,
**param_options: dict,
) -> MagicFactory:
...

func_gui = FunctionGui(
function=func,
call_button=call_button,
layout=layout,
labels=labels,
tooltips=tooltips,
param_options=param_options,
auto_call=auto_call,
result_widget=result_widget,
app=app,
)
func_gui.__wrapped__ = func
return func_gui

if function is None:
return inner_func
else:
return inner_func(function)
@overload
def magic_factory( # noqa
function: Literal[None] = None,
*,
layout: str = "horizontal",
labels: bool = True,
tooltips: bool = True,
call_button: Union[bool, str] = False,
auto_call: bool = False,
result_widget: bool = False,
app: AppRef = None,
**param_options: dict,
) -> Callable[[Callable], MagicFactory]:
...


def magic_factory(
function: Optional[Callable] = None,
*,
layout: str = "horizontal",
labels: bool = True,
tooltips: bool = True,
call_button: Union[bool, str] = False,
auto_call: bool = False,
result_widget: bool = False,
app: AppRef = None,
**param_options: dict,
):
"""Return a :class:`MagicFactory` for ``function``."""
return _magicgui(factory=True, **locals())


_factory_doc = magicgui.__doc__.split("Returns")[0] + ( # type: ignore
tlambert03 marked this conversation as resolved.
Show resolved Hide resolved
"""
Returns
-------
result : MagicFactory or Callable[[F], MagicFactory]
If ``function`` is not ``None`` (such as when this is used as a bare decorator),
returns a MagicFactory instance.
If ``function`` is ``None`` such as when arguments are provided like
``magic_factory(auto_call=True)``, then returns a function that can be used as a
decorator.

Examples
--------
>>> @magic_factory
... def my_function(a: int = 1, b: str = 'hello'):
... pass
...
>>> my_widget = my_function()
>>> my_widget.show()
>>> my_widget.a.value == 1 # Trueq
>>> my_widget.b.value = 'world'
"""
)

magic_factory.__doc__ += "\n\n Parameters" + _factory_doc.split("Parameters")[1] # type: ignore # noqa
19 changes: 10 additions & 9 deletions magicgui/widgets/_function_gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ def __init__(
if tooltips:
_inject_tooltips_from_docstrings(function.__doc__, param_options)

self._function = function
self.__wrapped__ = function
tlambert03 marked this conversation as resolved.
Show resolved Hide resolved
sig = magic_signature(function, gui_options=param_options)
super().__init__(
layout=layout,
Expand Down Expand Up @@ -186,6 +186,7 @@ def __call__(self, *args: Any, **kwargs: Any):

gui() # calls the original function with the current parameters
"""
function = self.__wrapped__
sig = self.__signature__
try:
bound = sig.bind(*args, **kwargs)
Expand All @@ -194,20 +195,20 @@ def __call__(self, *args: Any, **kwargs: Any):
match = re.search("argument: '(.+)'", str(e))
missing = match.groups()[0] if match else "<param>"
msg = (
f"{e} in call to '{self._function.__name__}{sig}'.\n"
f"{e} in call to '{function.__name__}{sig}'.\n"
"To avoid this error, you can bind a value or callback to the "
f"parameter:\n\n {self._function.__name__}.{missing}.bind(value)"
f"parameter:\n\n {function.__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._function.__name__}{sig}: ..."
f" def {function.__name__}{sig}: ..."
)
raise TypeError(msg) from None
else:
raise

bound.apply_defaults()

value = self._function(*bound.args, **bound.kwargs)
value = function(*bound.args, **bound.kwargs)
self._call_count += 1
if self._result_widget is not None:
with self._result_widget.changed.blocker():
Expand All @@ -224,13 +225,13 @@ def __call__(self, *args: Any, **kwargs: Any):

def __repr__(self) -> str:
"""Return string representation of instance."""
fname = f"{self._function.__module__}.{self._function.__name__}"
fname = f"{self.__wrapped__.__module__}.{self.__wrapped__.__name__}"
return f"<FunctionGui {fname}{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._function.__name__ + " result")
return self._result_name or (self.__wrapped__.__name__ + " result")

@result_name.setter
def result_name(self, value: str):
Expand All @@ -240,7 +241,7 @@ def result_name(self, value: str):
def copy(self) -> "FunctionGui":
"""Return a copy of this FunctionGui."""
return FunctionGui(
function=self._function,
function=self.__wrapped__,
call_button=bool(self._call_button),
layout=self.layout,
labels=self.labels,
Expand Down Expand Up @@ -276,7 +277,7 @@ def __get__(self, obj, objtype=None) -> FunctionGui:
if obj is not None:
obj_id = id(obj)
if obj_id not in self._bound_instances:
method = getattr(obj.__class__, self._function.__name__)
method = getattr(obj.__class__, self.__wrapped__.__name__)
p0 = list(inspect.signature(method).parameters)[0]
prior, self._param_options = self._param_options, {p0: {"bind": obj}}
try:
Expand Down
16 changes: 0 additions & 16 deletions tests/test_magicgui.py
Original file line number Diff line number Diff line change
Expand Up @@ -326,22 +326,6 @@ def func(mood: int = 1, hi: str = "hello"):
assert func.mood.choices == (1, 2, 3)


@pytest.mark.skip(reason="does not yet work")
def test_positions():
"""Test that providing position options puts widget in the right place."""

def func(a=1, b=2, c=3):
pass

def get_layout_items(layout):
return [layout.itemAt(i).widget().objectName() for i in range(layout.count())]

gui = magicgui(func)
assert get_layout_items(gui.layout()) == ["a", "b", "c"]
gui = magicgui(func, a={"position": 2}, b={"position": 1}).Gui()
assert get_layout_items(gui.layout()) == ["c", "b", "a"]


@pytest.mark.parametrize(
"labels",
[
Expand Down