diff --git a/Doc/library/types.rst b/Doc/library/types.rst index 01f4df3c89091f..ef2a8808a97687 100644 --- a/Doc/library/types.rst +++ b/Doc/library/types.rst @@ -521,6 +521,44 @@ Additional Utility Classes and Functions .. versionadded:: 3.4 +.. function:: lookup_special_method(obj, attr, /) + + Lookup special method name ``attr`` on ``obj``. + + Lookup method ``attr`` on ``obj`` without looking in the instance + dictionary. For methods defined in class ``__dict__`` or ``__slots__``, it + returns the unbound function (descriptor), not a bound method. The + caller is responsible for passing the object as the first argument when + calling it: + + .. code-block:: python + + >>> class A: + ... def __enter__(self): + ... return "A.__enter__" + ... + >>> class B: + ... __slots__ = ("__enter__",) + ... def __init__(self): + ... def __enter__(self): + ... return "B.__enter__" + ... self.__enter__ = __enter__ + ... + >>> a = A() + >>> b = B() + >>> enter_a = types.lookup_special_method(a, "__enter__") + >>> enter_b = types.lookup_special_method(b, "__enter__") + >>> enter_a(a) + 'A.__enter__' + >>> enter_b(b) + 'B.__enter__' + + For other descriptors (property, etc.), it returns the result of the + descriptor's ``__get__`` method. Returns ``None`` if the method is not + found. + + .. versionadded:: next + Coroutine Utility Functions --------------------------- diff --git a/Doc/whatsnew/3.15.rst b/Doc/whatsnew/3.15.rst index 0e440ccfd011f0..593d2cd657a061 100644 --- a/Doc/whatsnew/3.15.rst +++ b/Doc/whatsnew/3.15.rst @@ -1064,6 +1064,8 @@ types This represents the type of the :attr:`frame.f_locals` attribute, as described in :pep:`667`. +* Expose ``_PyObject_LookupSpecialMethod`` as + :func:`types.lookup_special_method`. unicodedata ----------- diff --git a/Lib/test/test_types.py b/Lib/test/test_types.py index 39d57c5f5b61c9..3ef42b26ff64f0 100644 --- a/Lib/test/test_types.py +++ b/Lib/test/test_types.py @@ -43,7 +43,8 @@ class TypesTests(unittest.TestCase): def test_names(self): c_only_names = {'CapsuleType', 'LazyImportType'} ignored = {'new_class', 'resolve_bases', 'prepare_class', - 'get_original_bases', 'DynamicClassAttribute', 'coroutine'} + 'get_original_bases', 'DynamicClassAttribute', 'coroutine', + 'lookup_special_method'} for name in c_types.__all__: if name not in c_only_names | ignored: @@ -59,7 +60,7 @@ def test_names(self): 'MemberDescriptorType', 'MethodDescriptorType', 'MethodType', 'MethodWrapperType', 'ModuleType', 'NoneType', 'NotImplementedType', 'SimpleNamespace', 'TracebackType', - 'UnionType', 'WrapperDescriptorType', + 'UnionType', 'WrapperDescriptorType', 'lookup_special_method', } self.assertEqual(all_names, set(c_types.__all__)) self.assertEqual(all_names - c_only_names, set(py_types.__all__)) @@ -726,6 +727,77 @@ def test_frame_locals_proxy_type(self): self.assertIsNotNone(frame) self.assertIsInstance(frame.f_locals, types.FrameLocalsProxyType) + def _test_lookup_special_method(self, lookup): + class CM1: + def __enter__(self): + return "__enter__ from class __dict__" + + class CM2: + def __init__(self): + def __enter__(self): + return "__enter__ from instance __dict__" + self.__enter__ = __enter__ + + class CM3: + __slots__ = ("__enter__",) + def __init__(self): + def __enter__(self): + return "__enter__ from __slots__" + self.__enter__ = __enter__ + cm1 = CM1() + meth = lookup(cm1, "__enter__") + self.assertIsNotNone(meth) + with self.assertRaisesRegex( + TypeError, "missing 1 required positional argument") as cm: + meth() + self.assertEqual(meth(cm1), "__enter__ from class __dict__") + + meth = lookup(cm1, "__missing__") + self.assertIsNone(meth) + + with self.assertRaisesRegex(TypeError, "attribute name must be string"): + lookup(cm1, 123) + + cm2 = CM2() + meth = lookup(cm2, "__enter__") + self.assertIsNone(meth) + + cm3 = CM3() + meth = lookup(cm3, "__enter__") + self.assertIsNotNone(meth) + self.assertEqual(meth(cm3), "__enter__ from __slots__") + + meth = lookup([], "__len__") + self.assertIsNotNone(meth) + self.assertEqual(meth([]), 0) + + class Person: + @classmethod + def hi(cls): + return f"hi from {cls.__name__}" + @staticmethod + def hello(): + return "hello from static method" + @property + def name(self): + return "name from property" + p = Person() + meth = lookup(p, "hi") + self.assertIsNotNone(meth) + self.assertEqual(meth(), "hi from Person") + + meth = lookup(p, "hello") + self.assertIsNotNone(meth) + self.assertEqual(meth(), "hello from static method") + + self.assertEqual(lookup(p, "name"), "name from property") + + def test_lookup_special_method(self): + c_lookup = getattr(c_types, "lookup_special_method") + py_lookup = getattr(py_types, "lookup_special_method") + self._test_lookup_special_method(c_lookup) + self._test_lookup_special_method(py_lookup) + class UnionTests(unittest.TestCase): diff --git a/Lib/types.py b/Lib/types.py index b4f9a5c5140860..60f4b9a431eb1e 100644 --- a/Lib/types.py +++ b/Lib/types.py @@ -81,6 +81,59 @@ def _m(self): pass del sys, _f, _g, _C, _c, _ag, _cell_factory # Not for export + def lookup_special_method(obj, attr, /): + """Lookup special method name `attr` on `obj`. + + Lookup method `attr` on `obj` without looking in the instance + dictionary. For methods defined in class `__dict__` or `__slots__`, it + returns the unbound function (descriptor), not a bound method. The + caller is responsible for passing the object as the first argument when + calling it: + + >>> class A: + ... def __enter__(self): + ... return "A.__enter__" + ... + >>> class B: + ... __slots__ = ("__enter__",) + ... def __init__(self): + ... def __enter__(self): + ... return "B.__enter__" + ... self.__enter__ = __enter__ + ... + >>> a = A() + >>> b = B() + >>> enter_a = types.lookup_special_method(a, "__enter__") + >>> enter_b = types.lookup_special_method(b, "__enter__") + >>> enter_a(a) + 'A.__enter__' + >>> enter_b(b) + 'B.__enter__' + + For other descriptors (classmethod, staticmethod, property, etc.), it + returns the result of the descriptor's `__get__` method. Returns `None` + if the method is not found. + """ + from inspect import getattr_static + cls = type(obj) + if not isinstance(attr, str): + raise TypeError( + f"attribute name must be string, not '{type(attr).__name__}'" + ) + try: + descr = getattr_static(cls, attr) + except AttributeError: + return None + if hasattr(descr, "__get__"): + if isinstance(descr, ( + FunctionType, MethodDescriptorType, WrapperDescriptorType)): + # do not create bound method to mimic the behavior of + # _PyObject_LookupSpecialMethod + return descr + else: + return descr.__get__(obj, cls) + return descr + # Provide a PEP 3115 compliant mechanism for class creation def new_class(name, bases=(), kwds=None, exec_body=None): diff --git a/Misc/NEWS.d/next/Library/2026-02-19-17-46-59.gh-issue-144989.JAuJyG.rst b/Misc/NEWS.d/next/Library/2026-02-19-17-46-59.gh-issue-144989.JAuJyG.rst new file mode 100644 index 00000000000000..78e6ef94ac5b34 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-02-19-17-46-59.gh-issue-144989.JAuJyG.rst @@ -0,0 +1,2 @@ +Expose ``_PyObject_LookupSpecialMethod`` as +:func:`types.lookup_special_method`. diff --git a/Modules/_typesmodule.c b/Modules/_typesmodule.c index 6c9e7a0a3ba053..051b05965548c2 100644 --- a/Modules/_typesmodule.c +++ b/Modules/_typesmodule.c @@ -6,6 +6,79 @@ #include "pycore_namespace.h" // _PyNamespace_Type #include "pycore_object.h" // _PyNone_Type, _PyNotImplemented_Type #include "pycore_unionobject.h" // _PyUnion_Type +#include "pycore_typeobject.h" // _PyObject_LookupSpecialMethod +#include "pycore_stackref.h" // _PyStackRef +#include "clinic/_typesmodule.c.h" + +/*[clinic input] +module _types +[clinic start generated code]*/ +/*[clinic end generated code: output=da39a3ee5e6b4b0d input=530308b1011b659d]*/ + +/*[clinic input] +_types.lookup_special_method + + obj: 'O' + attr: 'O' + / + +Lookup special method name `attr` on `obj`. + +Lookup method `attr` on `obj` without looking in the instance +dictionary. For methods defined in class `__dict__` or `__slots__`, it +returns the unbound function (descriptor), not a bound method. The +caller is responsible for passing the object as the first argument when +calling it: + +>>> class A: +... def __enter__(self): +... return "A.__enter__" +... +>>> class B: +... __slots__ = ("__enter__",) +... def __init__(self): +... def __enter__(self): +... return "B.__enter__" +... self.__enter__ = __enter__ +... +>>> a = A() +>>> b = B() +>>> enter_a = types.lookup_special_method(a, "__enter__") +>>> enter_b = types.lookup_special_method(b, "__enter__") +>>> enter_a(a) +'A.__enter__' +>>> enter_b(b) +'B.__enter__' + +For other descriptors (property, etc.), it returns the result of the +descriptor's `__get__` method. Returns `None` if the method is not +found. +[clinic start generated code]*/ + +static PyObject * +_types_lookup_special_method_impl(PyObject *module, PyObject *obj, + PyObject *attr) +/*[clinic end generated code: output=890e22cc0b8e0d34 input=e317288370125cd5]*/ +{ + if (!PyUnicode_Check(attr)) { + PyErr_Format(PyExc_TypeError, + "attribute name must be string, not '%.200s'", + Py_TYPE(attr)->tp_name); + return NULL; + } + _PyStackRef method_and_self[2]; + method_and_self[0] = PyStackRef_NULL; + method_and_self[1] = PyStackRef_FromPyObjectBorrow(obj); + int result = _PyObject_LookupSpecialMethod(attr, method_and_self); + if (result == -1) { + return NULL; + } + if (result == 0) { + Py_RETURN_NONE; + } + PyObject *method = PyStackRef_AsPyObjectSteal(method_and_self[0]); + return method; +} static int _types_exec(PyObject *m) @@ -60,12 +133,18 @@ static struct PyModuleDef_Slot _typesmodule_slots[] = { {0, NULL} }; +static PyMethodDef _typesmodule_methods[] = { + _TYPES_LOOKUP_SPECIAL_METHOD_METHODDEF + {NULL, NULL, 0, NULL} +}; + static struct PyModuleDef typesmodule = { .m_base = PyModuleDef_HEAD_INIT, .m_name = "_types", .m_doc = "Define names for built-in types.", .m_size = 0, .m_slots = _typesmodule_slots, + .m_methods = _typesmodule_methods, }; PyMODINIT_FUNC diff --git a/Modules/clinic/_typesmodule.c.h b/Modules/clinic/_typesmodule.c.h new file mode 100644 index 00000000000000..fe20e34a65e9d1 --- /dev/null +++ b/Modules/clinic/_typesmodule.c.h @@ -0,0 +1,67 @@ +/*[clinic input] +preserve +[clinic start generated code]*/ + +#include "pycore_modsupport.h" // _PyArg_CheckPositional() + +PyDoc_STRVAR(_types_lookup_special_method__doc__, +"lookup_special_method($module, obj, attr, /)\n" +"--\n" +"\n" +"Lookup special method name `attr` on `obj`.\n" +"\n" +"Lookup method `attr` on `obj` without looking in the instance\n" +"dictionary. For methods defined in class `__dict__` or `__slots__`, it\n" +"returns the unbound function (descriptor), not a bound method. The\n" +"caller is responsible for passing the object as the first argument when\n" +"calling it:\n" +"\n" +">>> class A:\n" +"... def __enter__(self):\n" +"... return \"A.__enter__\"\n" +"...\n" +">>> class B:\n" +"... __slots__ = (\"__enter__\",)\n" +"... def __init__(self):\n" +"... def __enter__(self):\n" +"... return \"B.__enter__\"\n" +"... self.__enter__ = __enter__\n" +"...\n" +">>> a = A()\n" +">>> b = B()\n" +">>> enter_a = types.lookup_special_method(a, \"__enter__\")\n" +">>> enter_b = types.lookup_special_method(b, \"__enter__\")\n" +">>> enter_a(a)\n" +"\'A.__enter__\'\n" +">>> enter_b(b)\n" +"\'B.__enter__\'\n" +"\n" +"For other descriptors (property, etc.), it returns the result of the\n" +"descriptor\'s `__get__` method. Returns `None` if the method is not\n" +"found."); + +#define _TYPES_LOOKUP_SPECIAL_METHOD_METHODDEF \ + {"lookup_special_method", _PyCFunction_CAST(_types_lookup_special_method), METH_FASTCALL, _types_lookup_special_method__doc__}, + +static PyObject * +_types_lookup_special_method_impl(PyObject *module, PyObject *obj, + PyObject *attr); + +static PyObject * +_types_lookup_special_method(PyObject *module, PyObject *const *args, Py_ssize_t nargs) +{ + PyObject *return_value = NULL; + PyObject *obj; + PyObject *attr; + + if (!_PyArg_CheckPositional("lookup_special_method", nargs, 2, 2)) { + goto exit; + } + obj = args[0]; + attr = args[1]; + return_value = _types_lookup_special_method_impl(module, obj, attr); + +exit: + return return_value; +} +/*[clinic end generated code: output=664edfaf350f71d0 input=a9049054013a1b77]*/