From bdadc7635bc391fca21a64915968e0d37d482811 Mon Sep 17 00:00:00 2001 From: Daniel Hollas Date: Fri, 16 Jan 2026 09:33:47 +0000 Subject: [PATCH 1/3] Remove lazy import from dataclasses --- Lib/dataclasses.py | 75 +++++++++++++++++++++------ Lib/inspect.py | 2 +- Lib/test/test_dataclasses/__init__.py | 7 +++ 3 files changed, 67 insertions(+), 17 deletions(-) diff --git a/Lib/dataclasses.py b/Lib/dataclasses.py index 730ced7299865e..eb8a7d9b62b9c7 100644 --- a/Lib/dataclasses.py +++ b/Lib/dataclasses.py @@ -2,7 +2,6 @@ import sys import copy import types -import inspect import keyword import itertools import annotationlib @@ -432,6 +431,38 @@ def _tuple_str(obj_name, fields): # Note the trailing comma, needed if this turns out to be a 1-tuple. return f'({",".join([f"{obj_name}.{f.name}" for f in fields])},)' +# NOTE: This is a vendored copy of `inspect.unwrap` to speed up import time +def _unwrap(func, *, stop=None): + """Get the object wrapped by *func*. + + Follows the chain of :attr:`__wrapped__` attributes returning the last + object in the chain. + + *stop* is an optional callback accepting an object in the wrapper chain + as its sole argument that allows the unwrapping to be terminated early if + the callback returns a true value. If the callback never returns a true + value, the last object in the chain is returned as usual. For example, + :func:`signature` uses this to stop unwrapping if any object in the + chain has a ``__signature__`` attribute defined. + + :exc:`ValueError` is raised if a cycle is encountered. + + """ + f = func # remember the original func for error reporting + # Memoise by id to tolerate non-hashable objects, but store objects to + # ensure they aren't destroyed, which would allow their IDs to be reused. + memo = {id(f): f} + recursion_limit = sys.getrecursionlimit() + while not isinstance(func, type) and hasattr(func, '__wrapped__'): + if stop is not None and stop(func): + break + func = func.__wrapped__ + id_func = id(func) + if (id_func in memo) or (len(memo) >= recursion_limit): + raise ValueError('wrapper loop when unwrapping {!r}'.format(f)) + memo[id_func] = func + return func + class _FuncBuilder: def __init__(self, globals): @@ -982,6 +1013,28 @@ def _hash_exception(cls, fields, func_builder): # See https://bugs.python.org/issue32929#msg312829 for an if-statement # version of this table. +class AutoDocstring: + """A non-data descriptor to autogenerate class docstring + from the signature of its __init__ method. + """ + + def __get__(self, _obj, cls): + import inspect + + try: + # In some cases fetching a signature is not possible. + # But, we surely should not fail in this case. + text_sig = str(inspect.signature( + cls, + annotation_format=annotationlib.Format.FORWARDREF, + )).replace(' -> None', '') + except (TypeError, ValueError): + text_sig = '' + + doc = cls.__name__ + text_sig + setattr(cls, '__doc__', doc) + return doc + def _process_class(cls, init, repr, eq, order, unsafe_hash, frozen, match_args, kw_only, slots, weakref_slot): @@ -1209,23 +1262,13 @@ def _process_class(cls, init, repr, eq, order, unsafe_hash, frozen, if hash_action: cls.__hash__ = hash_action(cls, field_list, func_builder) - # Generate the methods and add them to the class. This needs to be done - # before the __doc__ logic below, since inspect will look at the __init__ - # signature. + # Generate the methods and add them to the class. func_builder.add_fns_to_class(cls) if not getattr(cls, '__doc__'): - # Create a class doc-string. - try: - # In some cases fetching a signature is not possible. - # But, we surely should not fail in this case. - text_sig = str(inspect.signature( - cls, - annotation_format=annotationlib.Format.FORWARDREF, - )).replace(' -> None', '') - except (TypeError, ValueError): - text_sig = '' - cls.__doc__ = (cls.__name__ + text_sig) + # Create a class doc-string lazily via descriptor protocol + # to avoid importing `inspect` module. + cls.__doc__ = AutoDocstring() if match_args: # I could probably compute this once. @@ -1378,7 +1421,7 @@ def _add_slots(cls, is_frozen, weakref_slot, defined_fields): # given cell. for member in newcls.__dict__.values(): # If this is a wrapped function, unwrap it. - member = inspect.unwrap(member) + member = _unwrap(member) if isinstance(member, types.FunctionType): if _update_func_cell_for__class__(member, cls, newcls): diff --git a/Lib/inspect.py b/Lib/inspect.py index 0dba3c6628c6e5..8f7908c589959c 100644 --- a/Lib/inspect.py +++ b/Lib/inspect.py @@ -165,7 +165,7 @@ from collections import namedtuple, OrderedDict from weakref import ref as make_weakref -# Create constants for the compiler flags in Include/code.h +# Create constants for the compiler flags in Include/cpython/code.h # We try to get them from dis to avoid duplication mod_dict = globals() for k, v in dis.COMPILER_FLAG_NAMES.items(): diff --git a/Lib/test/test_dataclasses/__init__.py b/Lib/test/test_dataclasses/__init__.py index 3b335429b98500..35edd014f71088 100644 --- a/Lib/test/test_dataclasses/__init__.py +++ b/Lib/test/test_dataclasses/__init__.py @@ -2295,6 +2295,13 @@ class C: self.assertDocStrEqual(C.__doc__, "C()") + def test_docstring_slotted(self): + @dataclass(slots=True) + class C: + x: int + + self.assertDocStrEqual(C.__doc__, "C(x:int)") + def test_docstring_one_field(self): @dataclass class C: From 8713e1472d434ea41b5ad31299350cf46b912a73 Mon Sep 17 00:00:00 2001 From: Daniel Hollas Date: Mon, 2 Feb 2026 01:41:36 +0000 Subject: [PATCH 2/3] Simplify vendored unwrap function --- Lib/dataclasses.py | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/Lib/dataclasses.py b/Lib/dataclasses.py index eb8a7d9b62b9c7..913268402d8bd3 100644 --- a/Lib/dataclasses.py +++ b/Lib/dataclasses.py @@ -431,22 +431,14 @@ def _tuple_str(obj_name, fields): # Note the trailing comma, needed if this turns out to be a 1-tuple. return f'({",".join([f"{obj_name}.{f.name}" for f in fields])},)' -# NOTE: This is a vendored copy of `inspect.unwrap` to speed up import time -def _unwrap(func, *, stop=None): +# NOTE: This is a (simplified) vendored copy of `inspect.unwrap` to speed up import time +def _unwrap(func): """Get the object wrapped by *func*. Follows the chain of :attr:`__wrapped__` attributes returning the last object in the chain. - *stop* is an optional callback accepting an object in the wrapper chain - as its sole argument that allows the unwrapping to be terminated early if - the callback returns a true value. If the callback never returns a true - value, the last object in the chain is returned as usual. For example, - :func:`signature` uses this to stop unwrapping if any object in the - chain has a ``__signature__`` attribute defined. - :exc:`ValueError` is raised if a cycle is encountered. - """ f = func # remember the original func for error reporting # Memoise by id to tolerate non-hashable objects, but store objects to @@ -454,8 +446,6 @@ def _unwrap(func, *, stop=None): memo = {id(f): f} recursion_limit = sys.getrecursionlimit() while not isinstance(func, type) and hasattr(func, '__wrapped__'): - if stop is not None and stop(func): - break func = func.__wrapped__ id_func = id(func) if (id_func in memo) or (len(memo) >= recursion_limit): From ca36c68b7aeca6e637e8f41d62f9c769a8891a3a Mon Sep 17 00:00:00 2001 From: Daniel Hollas Date: Mon, 2 Feb 2026 04:24:35 +0000 Subject: [PATCH 3/3] Unvendor unwrap, call it only when needed --- Lib/dataclasses.py | 29 ++++++----------------------- 1 file changed, 6 insertions(+), 23 deletions(-) diff --git a/Lib/dataclasses.py b/Lib/dataclasses.py index 913268402d8bd3..06c5d627a11a4a 100644 --- a/Lib/dataclasses.py +++ b/Lib/dataclasses.py @@ -431,28 +431,6 @@ def _tuple_str(obj_name, fields): # Note the trailing comma, needed if this turns out to be a 1-tuple. return f'({",".join([f"{obj_name}.{f.name}" for f in fields])},)' -# NOTE: This is a (simplified) vendored copy of `inspect.unwrap` to speed up import time -def _unwrap(func): - """Get the object wrapped by *func*. - - Follows the chain of :attr:`__wrapped__` attributes returning the last - object in the chain. - - :exc:`ValueError` is raised if a cycle is encountered. - """ - f = func # remember the original func for error reporting - # Memoise by id to tolerate non-hashable objects, but store objects to - # ensure they aren't destroyed, which would allow their IDs to be reused. - memo = {id(f): f} - recursion_limit = sys.getrecursionlimit() - while not isinstance(func, type) and hasattr(func, '__wrapped__'): - func = func.__wrapped__ - id_func = id(func) - if (id_func in memo) or (len(memo) >= recursion_limit): - raise ValueError('wrapper loop when unwrapping {!r}'.format(f)) - memo[id_func] = func - return func - class _FuncBuilder: def __init__(self, globals): @@ -1009,6 +987,7 @@ class AutoDocstring: """ def __get__(self, _obj, cls): + # TODO: Make this top-level lazy import once PEP810 lands import inspect try: @@ -1410,8 +1389,12 @@ def _add_slots(cls, is_frozen, weakref_slot, defined_fields): # make an update, since all closures for a class will share a # given cell. for member in newcls.__dict__.values(): + # If this is a wrapped function, unwrap it. - member = _unwrap(member) + if not isinstance(member, type) and hasattr(member, '__wrapped__'): + # TODO: Make this top-level lazy import once PEP810 lands + import inspect + member = inspect.unwrap(member) if isinstance(member, types.FunctionType): if _update_func_cell_for__class__(member, cls, newcls):