diff --git a/Doc/library/types.rst b/Doc/library/types.rst index 01f4df3c89091f..77c8296c14690e 100644 --- a/Doc/library/types.rst +++ b/Doc/library/types.rst @@ -521,6 +521,16 @@ Additional Utility Classes and Functions .. versionadded:: 3.4 +.. function:: lookup_special(object, name, /) + lookup_special(object, name, default, /) + + Lookup method name *name* on *object* skipping the instance dictionary. + *name* must be a string. If the named special attribute does not exist, + *default* is returned if provided, otherwise :exc:`AttributeError` is + raised. + + .. versionadded:: next + Coroutine Utility Functions --------------------------- diff --git a/Doc/whatsnew/3.15.rst b/Doc/whatsnew/3.15.rst index 0e440ccfd011f0..a246c5be5ede75 100644 --- a/Doc/whatsnew/3.15.rst +++ b/Doc/whatsnew/3.15.rst @@ -1064,6 +1064,7 @@ types This represents the type of the :attr:`frame.f_locals` attribute, as described in :pep:`667`. +* Expose ``_PyObject_LookupSpecial`` as :func:`types.lookup_special`. unicodedata ----------- diff --git a/Lib/test/test_inspect/test_inspect.py b/Lib/test/test_inspect/test_inspect.py index e4a3a7d9add2c2..4d19ac1d2f3adc 100644 --- a/Lib/test/test_inspect/test_inspect.py +++ b/Lib/test/test_inspect/test_inspect.py @@ -6154,6 +6154,10 @@ def test_builtins_have_signatures(self): methods_no_signature, methods_unsupported_signature) def test_types_module_has_signatures(self): + no_signature = set() + # These need PEP 457 groups + needs_groups = {'lookup_special'} + no_signature |= needs_groups unsupported_signature = {'CellType'} methods_no_signature = { 'AsyncGeneratorType': {'athrow'}, @@ -6161,7 +6165,7 @@ def test_types_module_has_signatures(self): 'GeneratorType': {'throw'}, 'FrameLocalsProxyType': {'setdefault', 'pop', 'get'}, } - self._test_module_has_signatures(types, + self._test_module_has_signatures(types, no_signature, unsupported_signature=unsupported_signature, methods_no_signature=methods_no_signature) diff --git a/Lib/test/test_types.py b/Lib/test/test_types.py index 39d57c5f5b61c9..8f2d84bca78a36 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'} 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', } self.assertEqual(all_names, set(c_types.__all__)) self.assertEqual(all_names - c_only_names, set(py_types.__all__)) @@ -726,6 +727,64 @@ def test_frame_locals_proxy_type(self): self.assertIsNotNone(frame) self.assertIsInstance(frame.f_locals, types.FrameLocalsProxyType) + def _test_lookup_special(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() + with self.assertRaisesRegex(TypeError, "attribute name must be string"): + lookup(cm1, 123) + with self.assertRaises(AttributeError): + lookup(cm1, "__missing__") + self.assertEqual(lookup(cm1, "__missing__", "default"), "default") + meth = lookup(cm1, "__enter__") + self.assertEqual(meth(), "__enter__ from class __dict__") + + cm2 = CM2() + with self.assertRaises(AttributeError): + lookup(cm2, "__enter__") + + cm3 = CM3() + meth = lookup(cm3, "__enter__") + self.assertEqual(meth(cm3), "__enter__ from __slots__") + + meth = lookup([], "__len__") + 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() + self.assertEqual(lookup(p, "hi")(), "hi from Person") + self.assertEqual(lookup(p, "hello")(), "hello from static method") + self.assertEqual(lookup(p, "name"), "name from property") + + def test_lookup_special(self): + c_lookup = getattr(c_types, "lookup_special") + py_lookup = getattr(py_types, "lookup_special") + self._test_lookup_special(c_lookup) + self._test_lookup_special(py_lookup) + class UnionTests(unittest.TestCase): diff --git a/Lib/types.py b/Lib/types.py index b4f9a5c5140860..cf79d1b5053e30 100644 --- a/Lib/types.py +++ b/Lib/types.py @@ -81,6 +81,29 @@ def _m(self): pass del sys, _f, _g, _C, _c, _ag, _cell_factory # Not for export + def lookup_special(object, name, *args): + """Lookup method name `name` on `object` skipping the instance + dictionary. + + `name` must be a string. If the named special attribute does not exist, + `default` is returned if provided, otherwise AttributeError is raised. + """ + from inspect import getattr_static + cls = type(object) + if not isinstance(name, str): + raise TypeError( + f"attribute name must be string, not '{type(name).__name__}'" + ) + try: + descr = getattr_static(cls, name) + except AttributeError: + if args: + return args[0] + raise + if hasattr(descr, "__get__"): + return descr.__get__(object, 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..6e5cf92a893d46 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-02-19-17-46-59.gh-issue-144989.JAuJyG.rst @@ -0,0 +1 @@ +Expose ``_PyObject_LookupSpecial`` as :func:`types.lookup_special`. diff --git a/Modules/_typesmodule.c b/Modules/_typesmodule.c index 6c9e7a0a3ba053..0a8a9d6f52f52b 100644 --- a/Modules/_typesmodule.c +++ b/Modules/_typesmodule.c @@ -6,6 +6,45 @@ #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_LookupSpecial +#include "pycore_modsupport.h" // _PyArg_CheckPositional + +PyDoc_STRVAR(lookup_special_doc, +"lookup_special(object, name[, default], /)\n\ +\n\ +Lookup method name `name` on `object` skipping the instance dictionary.\n\ +`name` must be a string. If the named special attribute does not\n\ +exist,`default` is returned if provided, otherwise `AttributeError` is raised."); + +static PyObject * +_types_lookup_special_impl(PyObject *self, PyObject *const *args, Py_ssize_t nargs) +{ + PyObject *v, *name, *result; + + if (!_PyArg_CheckPositional("lookup_special", nargs, 2, 3)) + return NULL; + + v = args[0]; + name = args[1]; + if (!PyUnicode_Check(name)) { + PyErr_Format(PyExc_TypeError, + "attribute name must be string, not '%.200s'", + Py_TYPE(name)->tp_name); + return NULL; + } + result = _PyObject_LookupSpecial(v, name); + if (result == NULL) { + if (nargs > 2) { + PyObject *dflt = args[2]; + return Py_NewRef(dflt); + } else { + PyErr_Format(PyExc_AttributeError, + "'%.50s' object has no special attribute '%U'", + Py_TYPE(v)->tp_name, name); + } + } + return result; +} static int _types_exec(PyObject *m) @@ -60,12 +99,18 @@ static struct PyModuleDef_Slot _typesmodule_slots[] = { {0, NULL} }; +static PyMethodDef _typesmodule_methods[] = { + {"lookup_special", _PyCFunction_CAST(_types_lookup_special_impl), + METH_FASTCALL, lookup_special_doc}, + {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