Repository: hanjinliu/magic-class (language: python)

Issue:
Partialize macro recording if `partial` is used
If `partial(self.func, xxx)` is appended, it should recorded as `self.func(xxx, ...)`.
This makes implementation of functions such as "open recent" or "repeat with last parameter set" very simple.


Discussion / hints:

Fix patch:
diff --git a/magicclass/_gui/_base.py b/magicclass/_gui/_base.py
index 5ad31e0b..315c279a 100644
--- a/magicclass/_gui/_base.py
+++ b/magicclass/_gui/_base.py
@@ -16,7 +16,6 @@
 import inspect
 import warnings
 from enum import Enum
-import warnings
 from docstring_parser import parse, compose
 from qtpy.QtWidgets import QWidget, QDockWidget
 
@@ -35,8 +34,8 @@
 )
 from magicgui.application import use_app
 from magicgui.widgets._bases.widget import Widget
-from magicgui.widgets._bases import ButtonWidget, ValueWidget
-from macrokit import Expr, Head, Symbol, symbol
+from magicgui.widgets._bases import ButtonWidget
+from macrokit import Symbol
 
 from .keybinding import as_shortcut
 from .mgui_ext import (
@@ -47,8 +46,9 @@
     _LabeledWidgetAction,
     mguiLike,
 )
-from .utils import copy_class, get_parameters, callable_to_classes
+from .utils import copy_class, callable_to_classes
 from ._macro import GuiMacro
+from ._macro_utils import inject_recorder, value_widget_callback
 from ._icon import get_icon
 
 from ..utils import (
@@ -60,7 +60,6 @@
     is_instance_method,
     method_as_getter,
     eval_attribute,
-    thread_worker,
 )
 from ..widgets import Separator, FreeWidget
 from ..fields import MagicField
@@ -70,6 +69,7 @@
     split_annotated_type,
 )
 from ..wrappers import upgrade_signature
+from ..types import BoundLiteral
 
 if TYPE_CHECKING:
     import numpy as np
@@ -926,6 +926,7 @@ def func(*args, **kwargs):
         if isinstance(param, MagicParameter):
             _param = MagicParameter(
                 name=param.name,
+                kind=param.kind,
                 default=param.default,
                 annotation=split_annotated_type(param.annotation)[0],
                 gui_options=param.options.copy(),
@@ -935,7 +936,19 @@ def func(*args, **kwargs):
 
             # If bound method is a class method, use self.method(widget).
             if isinstance(_arg_bind, str):
-                _arg_bind = eval_attribute(type(self), _arg_bind)
+                try:
+                    _arg_bind = eval_attribute(type(self), _arg_bind)
+                except Exception:
+                    pass
+                else:
+                    warnings.warn(
+                        "Binding method name string is deprecated for the safety reason. "
+                        "Please use method itself.",
+                        DeprecationWarning,
+                    )
+
+            if isinstance(_arg_bind, BoundLiteral):
+                _arg_bind = _arg_bind.eval(type(self))
 
             if is_instance_method(_arg_bind):
                 _param.options["bind"] = method_as_getter(self, _arg_bind)
@@ -1114,186 +1127,6 @@ def _child_that_has_widget(
     return child_instance
 
 
-def value_widget_callback(
-    gui: MagicTemplate,
-    widget: ValueWidget,
-    name: str | list[str],
-    getvalue: bool = True,
-):
-    """Define a ValueWidget callback, including macro recording."""
-    if isinstance(name, str):
-        sym_name = Symbol(name)
-    else:
-        sym_name = Symbol(name[0])
-        for n in name[1:]:
-            sym_name = Expr(head=Head.getattr, args=[sym_name, n])
-
-    if getvalue:
-        sub = Expr(head=Head.getattr, args=[sym_name, Symbol("value")])  # name.value
-    else:
-        sub = sym_name
-
-    def _set_value():
-        if not widget.enabled or not gui.macro.active:
-            # If widget is read only, it means that value is set in script (not manually).
-            # Thus this event should not be recorded as a macro.
-            return None
-
-        gui.changed.emit(gui)
-
-        # Make an expression of
-        # >>> x.name.value = value
-        # or
-        # >>> x.name = value
-        target = Expr(Head.getattr, [symbol(gui), sub])
-        expr = Expr(Head.assign, [target, widget.value])
-        if gui.macro._last_setval == target and len(gui.macro) > 0:
-            gui.macro.pop()
-            gui.macro._erase_last()
-        else:
-            gui.macro._last_setval = target
-        gui.macro.append(expr)
-        return None
-
-    return _set_value
-
-
-def nested_function_gui_callback(gui: MagicTemplate, fgui: FunctionGui):
-    """Define a FunctionGui callback, including macro recording."""
-    fgui_name = Symbol(fgui.name)
-
-    def _after_run():
-        if not fgui.enabled or not gui.macro.active:
-            # If widget is read only, it means that value is set in script (not manually).
-            # Thus this event should not be recorded as a macro.
-            return None
-        inputs = get_parameters(fgui)
-        args = [Expr(head=Head.kw, args=[Symbol(k), v]) for k, v in inputs.items()]
-        # args[0] is self
-        sub = Expr(head=Head.getattr, args=[symbol(gui), fgui_name])  # {x}.func
-        expr = Expr(head=Head.call, args=[sub] + args[1:])  # {x}.func(args...)
-
-        if fgui._auto_call:
-            # Auto-call will cause many redundant macros. To avoid this, only the last input
-            # will be recorded in magic-class.
-            last_expr = gui.macro[-1]
-            if (
-                last_expr.head == Head.call
-                and last_expr.args[0].head == Head.getattr
-                and last_expr.at(0, 1) == expr.at(0, 1)
-                and len(gui.macro) > 0
-            ):
-                gui.macro.pop()
-                gui.macro._erase_last()
-
-        gui.macro.append(expr)
-        gui.macro._last_setval = None
-
-    return _after_run
-
-
-_SELF = inspect.Parameter("self", inspect.Parameter.POSITIONAL_OR_KEYWORD)
-
-
-def _inject_recorder(func: Callable, is_method: bool = True) -> Callable:
-    """Inject macro recording functionality into a function."""
-    sig = get_signature(func)
-    if is_method:
-        sig = sig.replace(
-            parameters=list(sig.parameters.values())[1:],
-            return_annotation=sig.return_annotation,
-        )
-        _func = func
-    else:
-
-        @functools_wraps(func)
-        def _func(self, *args, **kwargs):
-            return func(*args, **kwargs)
-
-        _func.__signature__ = sig.replace(
-            parameters=[_SELF] + list(sig.parameters.values()),
-            return_annotation=sig.return_annotation,
-        )
-
-    _record_macro = _define_macro_recorder(sig, _func)
-
-    if not isinstance(_func, thread_worker):
-
-        @functools_wraps(_func)
-        def _recordable(bgui: MagicTemplate, *args, **kwargs):
-            with bgui.macro.blocked():
-                out = _func.__get__(bgui)(*args, **kwargs)
-            if bgui.macro.active:
-                _record_macro(bgui, *args, **kwargs)
-            return out
-
-        if hasattr(_func, "__signature__"):
-            _recordable.__signature__ = _func.__signature__
-        return _recordable
-
-    else:
-        _func._set_recorder(_record_macro)
-        return _func
-
-
-def _define_macro_recorder(sig: inspect.Signature, func: Callable):
-    if isinstance(sig, MagicMethodSignature):
-        opt = sig.additional_options
-        _auto_call = opt.get("auto_call", False)
-    else:
-        _auto_call = False
-
-    if sig.return_annotation is inspect.Parameter.empty:
-
-        def _record_macro(bgui: MagicTemplate, *args, **kwargs):
-            bound = sig.bind(*args, **kwargs)
-            kwargs = dict(bound.arguments.items())
-            expr = Expr.parse_method(bgui, func, (), kwargs)
-            if _auto_call:
-                # Auto-call will cause many redundant macros. To avoid this, only the last
-                # input will be recorded in magic-class.
-                last_expr = bgui.macro[-1]
-                if (
-                    last_expr.head == Head.call
-                    and last_expr.args[0].head == Head.getattr
-                    and last_expr.at(0, 1) == expr.at(0, 1)
-                    and len(bgui.macro) > 0
-                ):
-                    bgui.macro.pop()
-                    bgui.macro._erase_last()
-
-            bgui.macro.append(expr)
-            bgui.macro._last_setval = None
-            return None
-
-    else:
-        _cname_ = "_call_with_return_callback"
-
-        def _record_macro(bgui: MagicTemplate, *args, **kwargs):
-            bound = sig.bind(*args, **kwargs)
-            kwargs = dict(bound.arguments.items())
-            expr = Expr.parse_method(bgui, _cname_, (func.__name__,), kwargs)
-            if _auto_call:
-                # Auto-call will cause many redundant macros. To avoid this, only the last
-                # input will be recorded in magic-class.
-                last_expr = bgui.macro[-1]
-                if (
-                    last_expr.head == Head.call
-                    and last_expr.args[0].head == Head.getattr
-                    and last_expr.at(0, 1) == expr.at(0, 1)
-                    and last_expr.args[1] == expr.args[1]
-                    and len(bgui.macro) > 0
-                ):
-                    bgui.macro.pop()
-                    bgui.macro._erase_last()
-
-            bgui.macro.append(expr)
-            bgui.macro._last_setval = None
-            return None
-
-    return _record_macro
-
-
 def convert_attributes(cls: type[_T], hide: tuple[type, ...]) -> dict[str, Any]:
     """
     Convert class attributes into macro recordable ones.
@@ -1328,7 +1161,7 @@ def convert_attributes(cls: type[_T], hide: tuple[type, ...]) -> dict[str, Any]:
                 # private method, non-action-like object, not-callable object are passed.
                 new_attr = obj
             elif callable(obj) and get_additional_option(obj, "record", True):
-                new_attr = _inject_recorder(obj)
+                new_attr = inject_recorder(obj)
             else:
                 new_attr = obj
 
diff --git a/magicclass/_gui/_macro_utils.py b/magicclass/_gui/_macro_utils.py
new file mode 100644
index 00000000..2891178f
--- /dev/null
+++ b/magicclass/_gui/_macro_utils.py
@@ -0,0 +1,271 @@
+from __future__ import annotations
+from typing import TYPE_CHECKING, Callable
+import inspect
+from functools import partial, partialmethod, wraps as functools_wraps
+from macrokit import Symbol, Expr, Head, symbol
+from magicgui.widgets import FunctionGui
+from magicgui.widgets._bases import ValueWidget
+from .utils import get_parameters
+from ..utils import get_signature, thread_worker
+from ..signature import MagicMethodSignature
+
+
+if TYPE_CHECKING:
+    from ._base import MagicTemplate
+
+
+def value_widget_callback(
+    gui: MagicTemplate,
+    widget: ValueWidget,
+    name: str | list[str],
+    getvalue: bool = True,
+):
+    """Define a ValueWidget callback, including macro recording."""
+    if isinstance(name, str):
+        sym_name = Symbol(name)
+    else:
+        sym_name = Symbol(name[0])
+        for n in name[1:]:
+            sym_name = Expr(head=Head.getattr, args=[sym_name, n])
+
+    if getvalue:
+        sub = Expr(head=Head.getattr, args=[sym_name, Symbol("value")])  # name.value
+    else:
+        sub = sym_name
+
+    def _set_value():
+        if not widget.enabled or not gui.macro.active:
+            # If widget is read only, it means that value is set in script (not manually).
+            # Thus this event should not be recorded as a macro.
+            return None
+
+        gui.changed.emit(gui)
+
+        # Make an expression of
+        # >>> x.name.value = value
+        # or
+        # >>> x.name = value
+        target = Expr(Head.getattr, [symbol(gui), sub])
+        expr = Expr(Head.assign, [target, widget.value])
+        if gui.macro._last_setval == target and len(gui.macro) > 0:
+            gui.macro.pop()
+            gui.macro._erase_last()
+        else:
+            gui.macro._last_setval = target
+        gui.macro.append(expr)
+        return None
+
+    return _set_value
+
+
+def nested_function_gui_callback(gui: MagicTemplate, fgui: FunctionGui):
+    """Define a FunctionGui callback, including macro recording."""
+    fgui_name = Symbol(fgui.name)
+
+    def _after_run():
+        if not fgui.enabled or not gui.macro.active:
+            # If widget is read only, it means that value is set in script (not manually).
+            # Thus this event should not be recorded as a macro.
+            return None
+        inputs = get_parameters(fgui)
+        args = [Expr(head=Head.kw, args=[Symbol(k), v]) for k, v in inputs.items()]
+        # args[0] is self
+        sub = Expr(head=Head.getattr, args=[symbol(gui), fgui_name])  # {x}.func
+        expr = Expr(head=Head.call, args=[sub] + args[1:])  # {x}.func(args...)
+
+        if fgui._auto_call:
+            # Auto-call will cause many redundant macros. To avoid this, only the last input
+            # will be recorded in magic-class.
+            last_expr = gui.macro[-1]
+            if (
+                last_expr.head == Head.call
+                and last_expr.args[0].head == Head.getattr
+                and last_expr.at(0, 1) == expr.at(0, 1)
+                and len(gui.macro) > 0
+            ):
+                gui.macro.pop()
+                gui.macro._erase_last()
+
+        gui.macro.append(expr)
+        gui.macro._last_setval = None
+
+    return _after_run
+
+
+_SELF = inspect.Parameter("self", inspect.Parameter.POSITIONAL_OR_KEYWORD)
+
+
+def inject_recorder(func: Callable, is_method: bool = True) -> Callable:
+    """Inject macro recording functionality into a function."""
+    sig = get_signature(func)
+    if is_method:
+        sig = sig.replace(
+            parameters=list(sig.parameters.values())[1:],
+            return_annotation=sig.return_annotation,
+        )
+        _func = func
+    else:
+        if isinstance(func, partial):
+
+            @functools_wraps(func)
+            @partialmethod
+            def _func(self, *args, **kwargs):
+                return func(*args, **kwargs)
+
+            _func.func.__name__ = func.__name__  # need update for macro recording
+
+        else:
+
+            @functools_wraps(func)
+            def _func(self, *args, **kwargs):
+                return func(*args, **kwargs)
+
+        _func.__signature__ = sig.replace(
+            parameters=[_SELF] + list(sig.parameters.values()),
+            return_annotation=sig.return_annotation,
+        )
+
+    if isinstance(_func, (partial, partialmethod)):
+        _record_macro = _define_macro_recorder_for_partial(sig, _func)
+    else:
+        _record_macro = _define_macro_recorder(sig, _func)
+
+    if not isinstance(_func, thread_worker):
+
+        @functools_wraps(_func)
+        def _recordable(bgui: MagicTemplate, *args, **kwargs):
+            with bgui.macro.blocked():
+                out = _func.__get__(bgui)(*args, **kwargs)
+            if bgui.macro.active:
+                _record_macro(bgui, *args, **kwargs)
+            return out
+
+        if hasattr(_func, "__signature__"):
+            _recordable.__signature__ = _func.__signature__
+        return _recordable
+
+    else:
+        _func._set_recorder(_record_macro)
+        return _func
+
+
+def _define_macro_recorder(sig: inspect.Signature, func: Callable):
+    if isinstance(sig, MagicMethodSignature):
+        opt = sig.additional_options
+        _auto_call = opt.get("auto_call", False)
+    else:
+        _auto_call = False
+
+    if sig.return_annotation is inspect.Parameter.empty:
+
+        def _record_macro(bgui: MagicTemplate, *args, **kwargs):
+            bound = sig.bind(*args, **kwargs)
+            kwargs = dict(bound.arguments.items())
+            expr = Expr.parse_method(bgui, func, (), kwargs)
+            if _auto_call:
+                # Auto-call will cause many redundant macros. To avoid this, only the last
+                # input will be recorded in magic-class.
+                last_expr = bgui.macro[-1]
+                if (
+                    last_expr.head == Head.call
+                    and last_expr.args[0].head == Head.getattr
+                    and last_expr.at(0, 1) == expr.at(0, 1)
+                    and len(bgui.macro) > 0
+                ):
+                    bgui.macro.pop()
+                    bgui.macro._erase_last()
+
+            bgui.macro.append(expr)
+            bgui.macro._last_setval = None
+            return None
+
+    else:
+        _cname_ = "_call_with_return_callback"
+
+        def _record_macro(bgui: MagicTemplate, *args, **kwargs):
+            bound = sig.bind(*args, **kwargs)
+            kwargs = dict(bound.arguments.items())
+            expr = Expr.parse_method(bgui, _cname_, (func.__name__,), kwargs)
+            if _auto_call:
+                # Auto-call will cause many redundant macros. To avoid this, only the last
+                # input will be recorded in magic-class.
+                last_expr = bgui.macro[-1]
+                if (
+                    last_expr.head == Head.call
+                    and last_expr.args[0].head == Head.getattr
+                    and last_expr.at(0, 1) == expr.at(0, 1)
+                    and last_expr.args[1] == expr.args[1]
+                    and len(bgui.macro) > 0
+                ):
+                    bgui.macro.pop()
+                    bgui.macro._erase_last()
+
+            bgui.macro.append(expr)
+            bgui.macro._last_setval = None
+            return None
+
+    return _record_macro
+
+
+def _define_macro_recorder_for_partial(
+    sig: inspect.Signature,
+    func: partial | partialmethod,
+):
+    base_func = func.func
+    if isinstance(sig, MagicMethodSignature):
+        opt = sig.additional_options
+        _auto_call = opt.get("auto_call", False)
+    else:
+        _auto_call = False
+
+    if sig.return_annotation is inspect.Parameter.empty:
+
+        def _record_macro(bgui: MagicTemplate, *args, **kwargs):
+            bound = sig.bind(*args, **kwargs)
+            bound.apply_defaults()
+            kwargs = bound.arguments
+            expr = Expr.parse_method(bgui, base_func, (), kwargs)
+            if _auto_call:
+                # Auto-call will cause many redundant macros. To avoid this, only the last
+                # input will be recorded in magic-class.
+                last_expr = bgui.macro[-1]
+                if (
+                    last_expr.head == Head.call
+                    and last_expr.args[0].head == Head.getattr
+                    and last_expr.at(0, 1) == expr.at(0, 1)
+                    and len(bgui.macro) > 0
+                ):
+                    bgui.macro.pop()
+                    bgui.macro._erase_last()
+
+            bgui.macro.append(expr)
+            bgui.macro._last_setval = None
+            return None
+
+    else:
+        _cname_ = "_call_with_return_callback"
+
+        def _record_macro(bgui: MagicTemplate, *args, **kwargs):
+            bound = sig.bind(*args, **kwargs)
+            bound.apply_defaults()
+            kwargs = bound.arguments
+            expr = Expr.parse_method(bgui, _cname_, (base_func.__name__,), kwargs)
+            if _auto_call:
+                # Auto-call will cause many redundant macros. To avoid this, only the last
+                # input will be recorded in magic-class.
+                last_expr = bgui.macro[-1]
+                if (
+                    last_expr.head == Head.call
+                    and last_expr.args[0].head == Head.getattr
+                    and last_expr.at(0, 1) == expr.at(0, 1)
+                    and last_expr.args[1] == expr.args[1]
+                    and len(bgui.macro) > 0
+                ):
+                    bgui.macro.pop()
+                    bgui.macro._erase_last()
+
+            bgui.macro.append(expr)
+            bgui.macro._last_setval = None
+            return None
+
+    return _record_macro
diff --git a/magicclass/_gui/class_gui.py b/magicclass/_gui/class_gui.py
index 74e05109..275fdfd1 100644
--- a/magicclass/_gui/class_gui.py
+++ b/magicclass/_gui/class_gui.py
@@ -17,10 +17,9 @@
     BaseGui,
     PopUpMode,
     ErrorMode,
-    value_widget_callback,
-    nested_function_gui_callback,
 )
 from .utils import format_error, connect_magicclasses
+from ._macro_utils import value_widget_callback, nested_function_gui_callback
 from ..widgets import (
     ButtonContainer,
     GroupBoxContainer,
@@ -273,16 +272,16 @@ def _convert_attributes_into_widgets(self):
 
     def _fast_insert(self, key: int, obj: Widget | Callable) -> None:
         if isinstance(obj, Callable):
-            # Sometimes uses want to dynamically add new functions to GUI.
+            # Sometimes users want to dynamically add new functions to GUI.
             if isinstance(obj, FunctionGui):
                 if obj.parent is None:
                     f = nested_function_gui_callback(self, obj)
                     obj.called.connect(f)
                 widget = obj
             else:
-                from ._base import _inject_recorder
+                from ._base import inject_recorder
 
-                obj = _inject_recorder(obj, is_method=False).__get__(self)
+                obj = inject_recorder(obj, is_method=False).__get__(self)
                 widget = self._create_widget_from_method(obj)
 
             method_name = getattr(obj, "__name__", None)
diff --git a/magicclass/_gui/menu_gui.py b/magicclass/_gui/menu_gui.py
index 04da6dcb..eb241124 100644
--- a/magicclass/_gui/menu_gui.py
+++ b/magicclass/_gui/menu_gui.py
@@ -15,10 +15,9 @@
     PopUpMode,
     ErrorMode,
     ContainerLikeGui,
-    nested_function_gui_callback,
-    _inject_recorder,
 )
 from .utils import format_error, connect_magicclasses
+from ._macro_utils import nested_function_gui_callback, inject_recorder
 
 from ..signature import get_additional_option
 from ..fields import MagicField
@@ -169,7 +168,7 @@ def _fast_insert(
                     obj.called.connect(f)
                 _obj = obj
             else:
-                obj = _inject_recorder(obj, is_method=False).__get__(self)
+                obj = inject_recorder(obj, is_method=False).__get__(self)
                 _obj = self._create_widget_from_method(obj)
 
             method_name = getattr(obj, "__name__", None)
diff --git a/magicclass/_gui/toolbar.py b/magicclass/_gui/toolbar.py
index 7c6f7b67..e04fad40 100644
--- a/magicclass/_gui/toolbar.py
+++ b/magicclass/_gui/toolbar.py
@@ -15,11 +15,10 @@
     PopUpMode,
     ErrorMode,
     ContainerLikeGui,
-    nested_function_gui_callback,
-    _inject_recorder,
 )
 from .utils import format_error, connect_magicclasses
 from .menu_gui import ContextMenuGui, MenuGui, MenuGuiBase, insert_action_like
+from ._macro_utils import inject_recorder, nested_function_gui_callback
 
 from ..signature import get_additional_option
 from ..fields import MagicField
@@ -237,7 +236,7 @@ def _fast_insert(self, key: int, obj: AbstractAction | Callable) -> None:
                     obj.called.connect(f)
                 _obj = obj
             else:
-                obj = _inject_recorder(obj, is_method=False).__get__(self)
+                obj = inject_recorder(obj, is_method=False).__get__(self)
                 _obj = self._create_widget_from_method(obj)
 
             method_name = getattr(obj, "__name__", None)
diff --git a/magicclass/types.py b/magicclass/types.py
index 9d88f52b..4a618331 100644
--- a/magicclass/types.py
+++ b/magicclass/types.py
@@ -129,9 +129,27 @@ def bound(obj):
         raise TypeError("'bound' can only convert callable, MagicField or type objects")
     while isinstance(outtype, _AnnotatedAlias):
         outtype, _ = split_annotated_type(outtype)
+    if isinstance(obj, str):
+        obj = BoundLiteral(obj)
     return Annotated[outtype, {"bind": obj, "widget_type": EmptyWidget}]
 
 
+class BoundLiteral:
+    """
+    A class used to represent a future evaluable expression.
+
+    This object will be created when a string is passed to the ``Bound[...]`` type.
+    """
+
+    def __init__(self, expr: str):
+        self._expr = expr
+
+    def eval(self, cls: type) -> Any:
+        from .utils import eval_attribute
+
+        return eval_attribute(cls, self._expr)
+
+
 class _BoundAlias(type):
     """
     This metaclass is necessary for ``mypy`` to reveal type.
diff --git a/magicclass/utils/__init__.py b/magicclass/utils/__init__.py
index a8837864..f4d476e4 100644
--- a/magicclass/utils/__init__.py
+++ b/magicclass/utils/__init__.py
@@ -22,5 +22,6 @@
 )
 
 from ._click import click
+from ._partial import partial, partialmethod
 from .qtsignal import QtSignal
 from .qthreading import thread_worker, Timer, Callback
diff --git a/magicclass/utils/_partial.py b/magicclass/utils/_partial.py
new file mode 100644
index 00000000..54d149a8
--- /dev/null
+++ b/magicclass/utils/_partial.py
@@ -0,0 +1,153 @@
+from __future__ import annotations
+import functools
+from typing import Any, TYPE_CHECKING
+from types import MethodType
+import inspect
+from magicgui.widgets import EmptyWidget
+from ..signature import get_signature, upgrade_signature
+
+if TYPE_CHECKING:
+    from ..signature import MagicMethodSignature
+
+_PARTIALIZE = {"gui_only": True, "widget_type": EmptyWidget, "visible": False}
+
+
+class partial(functools.partial):
+    """
+    Partialize a function and its signature.
+
+    This object is similar to ``functools.partial``, but it also update
+    widget options to build magicgui widget with subset of widgets. More
+    precisely, partializing ``x=0`` will add option
+    ``x={"gui_only": True, "widget_type": EmptyWidget, "visible": False}``.
+
+    Parameters
+    ----------
+    func : Callable
+        Callable object to be partialized.
+
+    Examples
+    --------
+    Suppose you have a magic class.
+
+    >>> @magicclass
+    >>> class A:
+    >>>     def f(self, i: int): ...
+
+    You can partialize method ``f``.
+
+    >>> ui = A()
+    >>> ui.append(partial(ui.f, i=1))
+    """
+
+    __signature__: MagicMethodSignature
+
+    def __new__(cls, func, /, *args, **kwargs):
+        # prepare widget options
+        options: dict[str, Any] = {}
+        bound = inspect.signature(func).bind_partial(*args, **kwargs)
+        for name in bound.arguments.keys():
+            options[name] = _PARTIALIZE
+
+        # unwrap function if it is a method
+        if isinstance(func, MethodType):
+            _func = _unwrap_method(func)
+            options["self"] = {"bind": func.__self__, "widget_type": EmptyWidget}
+        else:
+            _func = func
+
+        # construct partial object
+        self = functools.partial.__new__(cls, _func, *args, **kwargs)
+        self.__signature__ = get_signature(self)
+        self.__name__ = _func.__name__
+
+        upgrade_signature(self, gui_options=options)
+        return self
+
+    def set_options(
+        self,
+        text: str | None = None,
+        **kwargs,
+    ):
+        """Set options for the buttons or actions."""
+        kwargs.update(text=text)
+        upgrade_signature(self, caller_options=kwargs)
+        return self
+
+
+class partialmethod(functools.partialmethod):
+    """
+    Partialize a method and its signature.
+
+    This object is similar to ``functools.partialmethod``, but it also update
+    widget options to build magicgui widget with subset of widgets. More
+    precisely, partializing ``x=0`` will add option
+    ``x={"gui_only": True, "widget_type": EmptyWidget, "visible": False}``.
+
+    Parameters
+    ----------
+    func : Callable
+        Callable object to be partialized.
+
+    Examples
+    --------
+
+    >>> @magicclass
+    >>> class A:
+    >>>     def f(self, i: int): ...
+    >>>     g = partialmethod(f, i=1)
+
+    """
+
+    __signature__: MagicMethodSignature
+
+    def __init__(self, func, /, *args, **kwargs):
+        # prepare widget options
+        options: dict[str, Any] = {}
+        bound = inspect.signature(func).bind_partial(*args, **kwargs)
+        for name in bound.arguments.keys():
+            options[name] = _PARTIALIZE
+
+        # unwrap function if it is a method
+        if isinstance(func, MethodType):
+            _func = _unwrap_method(func)
+            options["self"] = {"bind": func.__self__, "widget_type": EmptyWidget}
+        else:
+            _func = func
+
+        # construct partial object
+        super().__init__(_func, *args, **kwargs)
+        self.__signature__ = get_signature(partial(_func, *args, **kwargs))
+        self.__name__ = _func.__name__
+
+        upgrade_signature(self, gui_options=options)
+
+    def __call__(self, *args: Any, **kwargs: Any):
+        # needed to be defined because magicclass checks callable
+        raise TypeError("partialmethod object is not callable")
+
+    def __set_name__(self, owner, name):
+        self.__signature__.caller_options.setdefault("text", name.replace("_", " "))
+
+    def set_options(
+        self,
+        text: str | None = None,
+        **kwargs,
+    ):
+        """Set options for the buttons or actions."""
+        kwargs.update(text=text)
+        upgrade_signature(self, caller_options=kwargs)
+        return self
+
+
+def _unwrap_method(func: MethodType):
+    def _unwrapped(*args, **kwargs):
+        return func(*args, **kwargs)
+
+    _unwrapped.__name__ = func.__name__
+    _unwrapped.__qualname__ = func.__qualname__
+    _unwrapped.__annotations__ = func.__annotations__
+    _unwrapped.__module__ = func.__module__
+    _unwrapped.__doc__ = func.__doc__
+    _unwrapped.__signature__ = get_signature(func)
+    return _unwrapped
diff --git a/rst/main/chap_make_better.rst b/rst/main/chap_make_better.rst
index 723943ae..3f246582 100644
--- a/rst/main/chap_make_better.rst
+++ b/rst/main/chap_make_better.rst
@@ -12,6 +12,7 @@ and tidiness of the source code.
    use_wraps
    use_bind
    use_choices
+   use_partial
    use_preview
    use_worker
    use_icon
diff --git a/rst/main/use_partial.rst b/rst/main/use_partial.rst
new file mode 100644
index 00000000..2605f4a8
--- /dev/null
+++ b/rst/main/use_partial.rst
@@ -0,0 +1,89 @@
+================================
+Partialize Functions and Widgets
+================================
+
+``functools.partial`` is a function that takes a function and some arguments to
+partialize it. However, whether ``magicgui`` widget should be partialized is another
+problem. In ``magicgui``, ``functools.partial`` object does **not** partialize
+the widget. It only affects the default value of the parameters.
+
+.. code-block:: python
+
+    def f(i: int, j: int):
+        return i + j
+
+    mgui = magicgui(partial(f, j=2))  # mgui still has two widgets corresponding to i and j
+
+Partializing widgets
+--------------------
+
+In ``magicclass``, you can use ``partial`` class in ``magicclass.utils`` instead of
+``functools.partial``. It basically works in a same way, but also properly partialize
+widget options.
+
+.. code-block:: python
+
+    from magicgui import magicgui
+    from magicclass.utils import partial
+
+    def func(self, x: int, y: int, z: int):
+        print(x, y, z)
+
+    pfunc = partial(func, x=0)
+    mgui = magicgui(pfunc)
+    mgui.show(True)  # mgui has only two widgets corresponding to y and z
+
+.. note::
+
+    To partialize method, you have to use ``partialmethod`` in ``magicclass.utils``,
+    just like when you have to use ``functools.partialmethod``.
+
+    .. code-block:: python
+
+        from magicclass import magicclass
+        from magicclass.utils import partialmethod
+
+        @magicclass
+        class A:
+            def func(self, x: int, y: int, z: int):
+                print(x, y, z)
+
+            func_0 = partialmethod(func, x=0)  # use partialmethod!
+
+Example: "open recent"
+----------------------
+
+``partial`` is very useful when you want to dynamically create buttons or menu actions that
+call a function with some defined arguments. Following example shows how to make a "open
+recent" menu.
+
+.. code-block:: python
+
+    from pathlib import Path
+    from magicclass import magicclass, magicmenu
+    from magicclass.utils import partial
+
+    @magicclass
+    class A:
+        @magicmenu
+        class File:
+            def open_file(self, path: Path):
+                text = str(path)
+                print("Opening:", text)  # do something
+
+                # create a partial function
+                pfunc = partial(self.open_file, path=path).set_options(text=text)
+
+                # to avoid adding duplicated menu actions, check if the same action
+                # already exists.
+                if text not in self.open_recent._text_list():
+                    self.open_recent.append(pfunc)
+
+            @magicmenu
+            class open_recent:
+                # recently opened files will be appended here
+                def _text_list(self) -> "list[str]":
+                    return [a.text for a in self]
+
+    ui = A()
+    ui.show()


Test patch:
diff --git a/tests/test_partial.py b/tests/test_partial.py
new file mode 100644
index 00000000..02cd7b56
--- /dev/null
+++ b/tests/test_partial.py
@@ -0,0 +1,61 @@
+from magicclass import magicclass
+from magicclass.utils import partial, partialmethod
+from unittest.mock import MagicMock
+import pytest
+
+def test_partial_call():
+    def f(i: int, j: int):
+        return i + j
+
+    pf0 = partial(f, 1)
+
+    assert pf0(2) == 3
+    assert pf0(j=2) == 3
+    with pytest.raises(TypeError):
+        pf0(i=3)
+    with pytest.raises(TypeError):
+        pf0()
+
+    pf1 = partial(f, j=10)
+
+    assert pf1(2) == 12
+    assert pf1(i=4) == 14
+    with pytest.raises(TypeError):
+        pf1(j=4)
+    with pytest.raises(TypeError):
+        pf1()
+
+def test_partial_into_gui():
+    mock = MagicMock()
+    @magicclass
+    class A:
+        def f(self, i: int):
+            mock(i)
+
+    ui = A()
+    ui.append(partial(ui.f, i=1).set_options(text="f(1)"))
+    assert ui[-1].text == "f(1)"
+    mock.assert_not_called()
+    ui[-1].changed()
+    mock.assert_called_once_with(1)
+    assert str(ui.macro[-1]) == "ui.f(i=1)"
+    ui.append(partial(ui.f, i=2).set_options(text="f(2)"))
+    assert ui[-1].text == "f(2)"
+    ui[-1].changed()
+    mock.assert_called_with(2)
+    assert str(ui.macro[-1]) == "ui.f(i=2)"
+
+def test_partialmethod():
+    mock = MagicMock()
+    @magicclass
+    class A:
+        def f(self, i: int, j: int):
+            mock(i, j)
+        g = partialmethod(f, i=1)
+
+    ui = A()
+    assert ui[-1].text == "g"
+    ui[-1].changed()
+    mock.assert_not_called()
+    ui[-1].mgui.call_button.clicked()
+    mock.assert_called_once_with(1, 0)