Repository: ppizarror/pygame-menu

Issue:
Function `is_callable` does not accept callable objects
**Describe the bug**
The `is_callable` function is supposed to check that an object is callable, for example to add it as a button action. There are two problems with it:

1. There is already a builtin to do this: `callable`.
2. The `is_callable` function is more restrictive than `callable`: it only allows functions, methods and `partial` objects. It does not allow objects with a `__call__` method, for no particular reason.

I think that unless there is a powerful reason not to, the `is_callable` function should be replaced by the `callable` builtin.


Discussion:

Patch:
diff --git a/pygame_menu/_decorator.py b/pygame_menu/_decorator.py
index 62992743..3e57b65e 100644
--- a/pygame_menu/_decorator.py
+++ b/pygame_menu/_decorator.py
@@ -21,7 +21,7 @@ import pygame.gfxdraw as gfxdraw
 from pygame_menu._base import Base
 from pygame_menu.font import FontType
 from pygame_menu.utils import assert_list_vector, assert_color, make_surface, \
-    is_callable, assert_vector, uuid4, warn
+    assert_vector, uuid4, warn
 
 from pygame_menu._types import List, Tuple2NumberType, ColorInputType, Tuple, \
     Any, Dict, Union, NumberType, Tuple2IntType, Optional, Callable, NumberInstance, \
@@ -658,7 +658,7 @@ class Decorator(Base):
         :param pass_args: If ``False`` function is called without (surface, object) as args
         :return: ID of the decoration
         """
-        assert is_callable(fun), 'fun must be a callable type'
+        assert callable(fun), 'fun must be a callable type'
         assert isinstance(pass_args, bool)
         if pass_args:
             return self._add_decor(DECORATION_CALLABLE, prev, fun)
diff --git a/pygame_menu/menu.py b/pygame_menu/menu.py
index 47a9480e..47d7b85e 100644
--- a/pygame_menu/menu.py
+++ b/pygame_menu/menu.py
@@ -27,7 +27,7 @@ from pygame_menu.locals import ALIGN_CENTER, ALIGN_LEFT, ALIGN_RIGHT, \
 from pygame_menu._scrollarea import ScrollArea, get_scrollbars_from_position
 from pygame_menu.sound import Sound
 from pygame_menu.themes import Theme, THEME_DEFAULT
-from pygame_menu.utils import is_callable, assert_vector, make_surface, warn, \
+from pygame_menu.utils import assert_vector, make_surface, warn, \
     check_key_pressed_valid, mouse_motion_current_mouse_position, get_finger_pos, \
     print_menu_widget_structure
 from pygame_menu.widgets import Frame, Widget, MenuBar
@@ -779,7 +779,7 @@ class Menu(Base):
         :param onbeforeopen: Onbeforeopen callback, it can be a function or None
         :return: Self reference
         """
-        assert is_callable(onbeforeopen) or onbeforeopen is None, \
+        assert callable(onbeforeopen) or onbeforeopen is None, \
             'onbeforeopen must be callable (function-type) or None'
         self._onbeforeopen = onbeforeopen
         return self
@@ -806,7 +806,7 @@ class Menu(Base):
         :param onupdate: Onupdate callback, it can be a function or None
         :return: Self reference
         """
-        assert is_callable(onupdate) or onupdate is None, \
+        assert callable(onupdate) or onupdate is None, \
             'onupdate must be a callable (function-type) or None'
         self._onupdate = onupdate
         return self
@@ -832,7 +832,7 @@ class Menu(Base):
         :param onclose: Onclose callback, it can be a function, a pygame-menu event, or None
         :return: Self reference
         """
-        assert is_callable(onclose) or _events.is_event(onclose) or onclose is None, \
+        assert callable(onclose) or _events.is_event(onclose) or onclose is None, \
             'onclose must be a MenuAction (event), callable (function-type), or None'
         if onclose == _events.NONE:
             onclose = None
@@ -860,7 +860,7 @@ class Menu(Base):
         :param onreset: Onreset callback, it can be a function or None
         :return: Self reference
         """
-        assert is_callable(onreset) or onreset is None, \
+        assert callable(onreset) or onreset is None, \
             'onreset must be a callable (function-type) or None'
         self._onreset = onreset
         return self
@@ -882,7 +882,7 @@ class Menu(Base):
         :return: Self reference
         """
         if onwindowmouseover is not None:
-            assert is_callable(onwindowmouseover), \
+            assert callable(onwindowmouseover), \
                 'onwindowmouseover must be callable (function-type) or None'
         self._onwindowmouseover = onwindowmouseover
         return self
@@ -904,7 +904,7 @@ class Menu(Base):
         :return: Self reference
         """
         if onwindowmouseleave is not None:
-            assert is_callable(onwindowmouseleave), \
+            assert callable(onwindowmouseleave), \
                 'onwindowmouseleave must be callable (function-type) or None'
         self._onwindowmouseleave = onwindowmouseleave
         return self
@@ -926,7 +926,7 @@ class Menu(Base):
         :return: Self reference
         """
         if onwidgetchange is not None:
-            assert is_callable(onwidgetchange), \
+            assert callable(onwidgetchange), \
                 'onwidgetchange must be callable (function-type) or None'
         self._onwidgetchange = onwidgetchange
         return self
@@ -948,7 +948,7 @@ class Menu(Base):
         :return: Self reference
         """
         if onmouseover is not None:
-            assert is_callable(onmouseover), \
+            assert callable(onmouseover), \
                 'onmouseover must be callable (function-type) or None'
         self._onmouseover = onmouseover
         return self
@@ -970,7 +970,7 @@ class Menu(Base):
         :return: Self reference
         """
         if onmouseleave is not None:
-            assert is_callable(onmouseleave), \
+            assert callable(onmouseleave), \
                 'onmouseleave must be callable (function-type) or None'
         self._onmouseleave = onmouseleave
         return self
@@ -1676,7 +1676,7 @@ class Menu(Base):
                     self.full_reset()
 
             # If action is callable (function)
-            elif is_callable(onclose):
+            elif callable(onclose):
                 try:
                     onclose(self)
                 except TypeError:
@@ -2867,7 +2867,7 @@ class Menu(Base):
         # Check background function
         bgfun_accept_menu = False
         if bgfun:
-            assert is_callable(bgfun), \
+            assert callable(bgfun), \
                 'background function must be callable (function-type) object'
             try:
                 bgfun(self._current)
diff --git a/pygame_menu/themes.py b/pygame_menu/themes.py
index 9da90097..0a78e044 100644
--- a/pygame_menu/themes.py
+++ b/pygame_menu/themes.py
@@ -32,7 +32,7 @@ from pygame_menu.locals import POSITION_NORTHWEST, POSITION_SOUTHEAST, ALIGN_CEN
     CURSOR_ARROW
 from pygame_menu._scrollarea import get_scrollbars_from_position
 from pygame_menu.utils import assert_alignment, assert_cursor, assert_vector, \
-    assert_position, assert_color, is_callable, format_color, assert_position_vector
+    assert_position, assert_color, format_color, assert_position_vector
 from pygame_menu.widgets import HighlightSelection, NoneSelection, MENUBAR_STYLE_ADAPTIVE, \
     MENUBAR_STYLE_SIMPLE, MENUBAR_STYLE_TITLE_ONLY, MENUBAR_STYLE_TITLE_ONLY_DIAGONAL, \
     MENUBAR_STYLE_NONE, MENUBAR_STYLE_UNDERLINE, MENUBAR_STYLE_UNDERLINE_TITLE
@@ -721,7 +721,7 @@ class Theme(object):
                     assert_alignment(value)
 
                 elif val_type == callable or val_type == 'function' or val_type == 'callable':
-                    assert is_callable(value), \
+                    assert callable(value), \
                         'value must be callable type'
 
                 elif val_type == 'color':
diff --git a/pygame_menu/utils.py b/pygame_menu/utils.py
index 153d7eec..04bed641 100644
--- a/pygame_menu/utils.py
+++ b/pygame_menu/utils.py
@@ -22,7 +22,6 @@ __all__ = [
     'format_color',
     'get_cursor',
     'get_finger_pos',
-    'is_callable',
     'load_pygame_image_file',
     'make_surface',
     'mouse_motion_current_mouse_position',
@@ -42,10 +41,8 @@ __all__ = [
 
 ]
 
-import functools
 import sys
 import traceback
-import types
 import uuid
 import warnings
 
@@ -359,9 +356,10 @@ def is_callable(func: Any) -> bool:
     :param func: Function object
     :return: ``True`` if function
     """
-    # noinspection PyTypeChecker
-    return isinstance(func, (types.FunctionType, types.BuiltinFunctionType,
-                             types.MethodType, functools.partial))
+    e = 'is_callable(func) method will be removed in v5, consider using built-in' \
+        ' callable(func) method instead'
+    warnings.warn(e, DeprecationWarning)
+    return callable(func)
 
 
 def load_pygame_image_file(image_path: str, **kwargs) -> 'pygame.Surface':
diff --git a/pygame_menu/version.py b/pygame_menu/version.py
index d21d275f..4ac92dfe 100644
--- a/pygame_menu/version.py
+++ b/pygame_menu/version.py
@@ -34,6 +34,6 @@ class Version(tuple):
     patch = property(lambda self: self[2])
 
 
-vernum = Version(4, 2, 6)
+vernum = Version(4, 2, 7)
 ver = str(vernum)
 rev = ''
diff --git a/pygame_menu/widgets/core/widget.py b/pygame_menu/widgets/core/widget.py
index d74681de..6aba29c4 100644
--- a/pygame_menu/widgets/core/widget.py
+++ b/pygame_menu/widgets/core/widget.py
@@ -45,7 +45,7 @@ from pygame_menu.locals import POSITION_NORTHWEST, POSITION_SOUTHWEST, POSITION_
     POSITION_SOUTHEAST, ALIGN_CENTER
 from pygame_menu.sound import Sound
 from pygame_menu.utils import make_surface, assert_alignment, assert_color, \
-    assert_position, assert_vector, is_callable, parse_padding, uuid4, \
+    assert_position, assert_vector, parse_padding, uuid4, \
     mouse_motion_current_mouse_position, PYGAME_V2, set_pygame_cursor, warn, \
     get_cursor, ShadowGenerator
 from pygame_menu.widgets.core.selection import Selection
@@ -484,7 +484,7 @@ class Widget(Base):
         :return: Self reference
         """
         if onchange:
-            assert is_callable(onchange), \
+            assert callable(onchange), \
                 'onchange must be callable (function-type) or None'
         self._onchange = onchange
         return self
@@ -503,7 +503,7 @@ class Widget(Base):
         :return: Self reference
         """
         if onreturn:
-            assert is_callable(onreturn), \
+            assert callable(onreturn), \
                 'onreturn must be callable (function-type) or None'
         self._onreturn = onreturn
         return self
@@ -522,7 +522,7 @@ class Widget(Base):
         :return: Self reference
         """
         if onselect:
-            assert is_callable(onselect), \
+            assert callable(onselect), \
                 'onselect must be callable (function-type) or None'
         self._onselect = onselect
         return self
@@ -541,7 +541,7 @@ class Widget(Base):
         :return: Self reference
         """
         if onmouseover:
-            assert is_callable(onmouseover), \
+            assert callable(onmouseover), \
                 'onmouseover must be callable (function-type) or None'
         self._onmouseover = onmouseover
         return self
@@ -560,7 +560,7 @@ class Widget(Base):
         :return: Self reference
         """
         if onmouseleave:
-            assert is_callable(onmouseleave), \
+            assert callable(onmouseleave), \
                 'onmouseleave must be callable (function-type) or None'
         self._onmouseleave = onmouseleave
         return self
@@ -2777,7 +2777,7 @@ class Widget(Base):
         :param draw_callback: Function
         :return: Callback ID
         """
-        assert is_callable(draw_callback), \
+        assert callable(draw_callback), \
             'draw callback must be callable (function-type)'
         callback_id = uuid4()
         self._draw_callbacks[callback_id] = draw_callback
@@ -2832,7 +2832,7 @@ class Widget(Base):
         :param update_callback: Function
         :return: Callback ID
         """
-        assert is_callable(update_callback), \
+        assert callable(update_callback), \
             'update callback must be callable (function-type)'
         callback_id = uuid4()
         self._update_callbacks[callback_id] = update_callback
diff --git a/pygame_menu/widgets/widget/button.py b/pygame_menu/widgets/widget/button.py
index 7d4a3b43..12b884fc 100644
--- a/pygame_menu/widgets/widget/button.py
+++ b/pygame_menu/widgets/widget/button.py
@@ -20,7 +20,7 @@ import webbrowser
 
 from abc import ABC
 from pygame_menu.locals import FINGERUP, CURSOR_HAND
-from pygame_menu.utils import is_callable, assert_color, get_finger_pos, warn
+from pygame_menu.utils import assert_color, get_finger_pos, warn
 from pygame_menu.widgets.core.widget import AbstractWidgetManager, Widget
 
 from pygame_menu._types import Any, CallbackType, Callable, Union, List, Tuple, \
@@ -89,7 +89,7 @@ class Button(Widget):
         :param callback: Callback when selecting the widget, executed in :py:meth:`pygame_menu.widgets.core.widget.Widget.set_selected`
         """
         if callback is not None:
-            assert is_callable(callback), \
+            assert callable(callback), \
                 'callback must be callable (function-type) or None'
         self._onselect = callback
 
@@ -107,7 +107,7 @@ class Button(Widget):
         :param callback: Function
         :param args: Arguments used by the function once triggered
         """
-        assert is_callable(callback), \
+        assert callable(callback), \
             'only callable (function-type) are allowed'
 
         # If return is a Menu object, remove it from submenus list
@@ -395,7 +395,7 @@ class ButtonManager(AbstractWidgetManager, ABC):
             widget = Button(title, button_id, self._menu.full_reset)
 
         # If element is a function or callable
-        elif is_callable(action):
+        elif callable(action):
             if not accept_kwargs:
                 widget = Button(title, button_id, action, *args)
             else:
diff --git a/pygame_menu/widgets/widget/dropselect_multiple.py b/pygame_menu/widgets/widget/dropselect_multiple.py
index 95bd1a3c..9194b33b 100644
--- a/pygame_menu/widgets/widget/dropselect_multiple.py
+++ b/pygame_menu/widgets/widget/dropselect_multiple.py
@@ -28,7 +28,7 @@ import pygame_menu
 from abc import ABC
 from pygame_menu.font import FontType
 from pygame_menu.locals import POSITION_NORTHWEST, POSITION_SOUTHEAST
-from pygame_menu.utils import assert_color, assert_vector, is_callable
+from pygame_menu.utils import assert_color, assert_vector
 from pygame_menu.widgets.core.widget import AbstractWidgetManager, Widget
 from pygame_menu.widgets.widget.button import Button
 from pygame_menu.widgets.widget.dropselect import DropSelect
@@ -326,7 +326,7 @@ class DropSelectMultiple(DropSelect):
         elif isinstance(self._selection_placeholder_format, str):
             return self._placeholder_selected.format(self._selection_placeholder_format.join(list_items))
 
-        elif is_callable(self._selection_placeholder_format):
+        elif callable(self._selection_placeholder_format):
             try:
                 o = self._selection_placeholder_format(list_items)
             except TypeError:
diff --git a/pygame_menu/widgets/widget/label.py b/pygame_menu/widgets/widget/label.py
index 40215126..ac3fe6a2 100644
--- a/pygame_menu/widgets/widget/label.py
+++ b/pygame_menu/widgets/widget/label.py
@@ -17,7 +17,7 @@ import textwrap
 import time
 
 from abc import ABC
-from pygame_menu.utils import assert_color, is_callable, warn, uuid4
+from pygame_menu.utils import assert_color, warn, uuid4
 from pygame_menu.widgets.core.widget import Widget, AbstractWidgetManager
 
 from pygame_menu._types import Any, CallbackType, List, Union, Tuple, Optional, \
@@ -112,7 +112,7 @@ class Label(Widget):
         :return: Self reference
         """
         if generator is not None:
-            assert is_callable(generator)
+            assert callable(generator)
         self._title_generator = generator
 
         # Update update widgets
diff --git a/pygame_menu/widgets/widget/menulink.py b/pygame_menu/widgets/widget/menulink.py
index f4c74772..85c8f198 100644
--- a/pygame_menu/widgets/widget/menulink.py
+++ b/pygame_menu/widgets/widget/menulink.py
@@ -17,7 +17,6 @@ import pygame_menu
 from abc import ABC
 from pygame_menu.widgets.core.widget import AbstractWidgetManager
 from pygame_menu.widgets.widget.none import NoneWidget
-from pygame_menu.utils import is_callable
 
 from pygame_menu._types import Callable
 
@@ -45,7 +44,7 @@ class MenuLink(NoneWidget):
             link_id: str = ''
     ) -> None:
         assert isinstance(menu, pygame_menu.Menu)
-        assert is_callable(menu_opener_handler), \
+        assert callable(menu_opener_handler), \
             'menu opener handler must be callable (a function)'
         super(MenuLink, self).__init__(
             widget_id=link_id
diff --git a/pygame_menu/widgets/widget/progressbar.py b/pygame_menu/widgets/widget/progressbar.py
index 8b5f0d08..bac40518 100644
--- a/pygame_menu/widgets/widget/progressbar.py
+++ b/pygame_menu/widgets/widget/progressbar.py
@@ -24,7 +24,7 @@ from abc import ABC
 from pygame_menu.font import FontType, assert_font
 from pygame_menu.locals import ALIGN_LEFT, ALIGN_CENTER
 from pygame_menu.utils import assert_color, assert_vector, make_surface, \
-    is_callable, assert_alignment, parse_padding
+    assert_alignment, parse_padding
 from pygame_menu.widgets.core.widget import Widget, WidgetTransformationNotImplemented, \
     AbstractWidgetManager
 
@@ -150,7 +150,7 @@ class ProgressBar(Widget):
 
         # Check progress text
         assert isinstance(progress_text_enabled, bool)
-        assert is_callable(progress_text_format)
+        assert callable(progress_text_format)
         assert isinstance(progress_text_format(0), str)
         assert isinstance(progress_text_placeholder, str)
         assert_alignment(progress_text_align)
diff --git a/pygame_menu/widgets/widget/rangeslider.py b/pygame_menu/widgets/widget/rangeslider.py
index fae66c28..def64235 100644
--- a/pygame_menu/widgets/widget/rangeslider.py
+++ b/pygame_menu/widgets/widget/rangeslider.py
@@ -29,7 +29,7 @@ from pygame_menu.locals import POSITION_NORTH, POSITION_SOUTH
 from pygame_menu.font import FontType, assert_font
 from pygame_menu.locals import FINGERUP, FINGERDOWN, FINGERMOTION
 from pygame_menu.utils import check_key_pressed_valid, assert_color, assert_vector, \
-    make_surface, get_finger_pos, assert_position, parse_padding, is_callable
+    make_surface, get_finger_pos, assert_position, parse_padding
 from pygame_menu.widgets.core.widget import Widget, WidgetTransformationNotImplemented, \
     AbstractWidgetManager
 
@@ -375,7 +375,7 @@ class RangeSlider(Widget):
         assert isinstance(slider_text_value_triangle, bool)
 
         # Check the value format function
-        assert is_callable(value_format)
+        assert callable(value_format)
         assert isinstance(value_format(0), str), \
             'value_format must be a function that accepts only 1 argument ' \
             '(value) and must return a string'


Test patch:
diff --git a/test/test_themes.py b/test/test_themes.py
index 5ceaf8d1..c486d4e3 100644
--- a/test/test_themes.py
+++ b/test/test_themes.py
@@ -222,7 +222,6 @@ class ThemeTest(BaseTest):
             [pygame_menu.locals.POSITION_WEST, bool]
         ]
 
-        self.assertRaises(AssertionError, lambda: t._get({}, '', 'callable', bool))
         self.assertRaises(AssertionError, lambda: t._get({}, '', 'color', [1, 1, 1, 256]))
         self.assertRaises(AssertionError, lambda: t._get({}, '', 'color', [11, 1, -1]))
         self.assertRaises(AssertionError, lambda: t._get({}, '', 'color', [11, 1, -1]))
diff --git a/test/test_utils.py b/test/test_utils.py
index c81bf283..84fca73f 100644
--- a/test/test_utils.py
+++ b/test/test_utils.py
@@ -18,6 +18,13 @@ import pygame_menu.utils as ut
 
 class UtilsTest(BaseTest):
 
+    def test_callable(self) -> None:
+        """
+        Test is callable.
+        """
+        self.assertTrue(ut.is_callable(bool))
+        self.assertFalse(ut.is_callable(1))
+
     def test_position_str(self) -> None:
         """
         Test position assert values as str.
diff --git a/test/test_widget_button.py b/test/test_widget_button.py
index 3ae5116f..af6421a1 100644
--- a/test/test_widget_button.py
+++ b/test/test_widget_button.py
@@ -35,8 +35,6 @@ class ButtonWidgetTest(BaseTest):
 
         # Invalid ones
         invalid = [
-            bool,  # type
-            object,  # object
             1,  # int
             'a',  # str
             True,  # bool
@@ -66,7 +64,7 @@ class ButtonWidgetTest(BaseTest):
             self.assertTrue(menu.add.button('b1', v) is not None)
 
         btn = menu.add.button('b1', menu2)
-        for v in [menu, 1, bool, object, [1, 2, 3], (1, 2, 3)]:
+        for v in [menu, 1, [1, 2, 3], (1, 2, 3)]:
             self.assertRaises(AssertionError, lambda: btn.update_callback(v))
         btn.update_callback(test)