From 4210688d516aff9286f3fa8735e968be26075377 Mon Sep 17 00:00:00 2001 From: Daniel Hollas Date: Fri, 16 Jan 2026 09:33:47 +0000 Subject: [PATCH 1/8] Lazy import `inspect` in dataclasses module --- 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 0eed68d17c702b..05103deea82767 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 b1578d5bcb8ecaa02de3e4eb659e48f6093a611a Mon Sep 17 00:00:00 2001 From: Daniel Hollas Date: Mon, 2 Feb 2026 01:41:36 +0000 Subject: [PATCH 2/8] 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 43e263378b550b79897dcc2c0ee5b39aeb85ce72 Mon Sep 17 00:00:00 2001 From: Daniel Hollas Date: Mon, 2 Feb 2026 04:24:35 +0000 Subject: [PATCH 3/8] 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): From 6bc61998db92425f3a7a4e1b14bccabbecbf46df Mon Sep 17 00:00:00 2001 From: Daniel Hollas Date: Thu, 12 Feb 2026 01:04:33 +0000 Subject: [PATCH 4/8] Use PEP810! --- Lib/dataclasses.py | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/Lib/dataclasses.py b/Lib/dataclasses.py index 06c5d627a11a4a..1cf41e2cc1c1a8 100644 --- a/Lib/dataclasses.py +++ b/Lib/dataclasses.py @@ -8,6 +8,7 @@ import abc from reprlib import recursive_repr +lazy import inspect __all__ = ['dataclass', 'field', @@ -981,15 +982,12 @@ 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. - """ +# A non-data descriptor to autogenerate class docstring +# from the signature of its __init__ method on demand. +# The primary reason is to be able to lazy import `inspect` module. +class _AutoDocstring: def __get__(self, _obj, cls): - # TODO: Make this top-level lazy import once PEP810 lands - import inspect - try: # In some cases fetching a signature is not possible. # But, we surely should not fail in this case. @@ -1237,7 +1235,7 @@ def _process_class(cls, init, repr, eq, order, unsafe_hash, frozen, if not getattr(cls, '__doc__'): # Create a class doc-string lazily via descriptor protocol # to avoid importing `inspect` module. - cls.__doc__ = AutoDocstring() + cls.__doc__ = _AutoDocstring() if match_args: # I could probably compute this once. @@ -1392,8 +1390,6 @@ def _add_slots(cls, is_frozen, weakref_slot, defined_fields): # If this is a wrapped function, unwrap it. 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): From 9910cfd935f4c1aaf23cced3a136a2085c5ea3ff Mon Sep 17 00:00:00 2001 From: Daniel Hollas Date: Thu, 12 Feb 2026 18:05:26 +0000 Subject: [PATCH 5/8] Add blurb --- .../next/Library/2026-02-12-18-05-16.gh-issue-137855.2_PTbg.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 Misc/NEWS.d/next/Library/2026-02-12-18-05-16.gh-issue-137855.2_PTbg.rst diff --git a/Misc/NEWS.d/next/Library/2026-02-12-18-05-16.gh-issue-137855.2_PTbg.rst b/Misc/NEWS.d/next/Library/2026-02-12-18-05-16.gh-issue-137855.2_PTbg.rst new file mode 100644 index 00000000000000..586c7d3495ae26 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-02-12-18-05-16.gh-issue-137855.2_PTbg.rst @@ -0,0 +1 @@ +Reduce the import time of :mod:`dataclasses` module by ~20%. From 0d5efb7c75baf4080b5667aa3a551af304cf3ca8 Mon Sep 17 00:00:00 2001 From: Daniel Hollas Date: Thu, 12 Feb 2026 18:14:30 +0000 Subject: [PATCH 6/8] Add regression test --- Lib/test/test_dataclasses/__init__.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/Lib/test/test_dataclasses/__init__.py b/Lib/test/test_dataclasses/__init__.py index 35edd014f71088..2cf39911766ce9 100644 --- a/Lib/test/test_dataclasses/__init__.py +++ b/Lib/test/test_dataclasses/__init__.py @@ -27,11 +27,21 @@ import dataclasses # Needed for the string "dataclasses.InitVar[int]" to work as an annotation. from test import support -from test.support import import_helper +from test.support import cpython_only, import_helper # Just any custom exception we can catch. class CustomError(Exception): pass + +class TestImportTime(unittest.TestCase): + + @cpython_only + def test_lazy_import(self): + import_helper.ensure_lazy_imports( + "dataclasses", {"inspect"} + ) + + class TestCase(unittest.TestCase): def test_no_fields(self): @dataclass From ff59bd3ef63a78cfdc26390c3d6a25d55dccce86 Mon Sep 17 00:00:00 2001 From: Daniel Hollas Date: Thu, 12 Feb 2026 18:15:00 +0000 Subject: [PATCH 7/8] Remove unrelated change --- Lib/inspect.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/inspect.py b/Lib/inspect.py index 05103deea82767..0eed68d17c702b 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/cpython/code.h +# Create constants for the compiler flags in Include/code.h # We try to get them from dis to avoid duplication mod_dict = globals() for k, v in dis.COMPILER_FLAG_NAMES.items(): From 6f0db7602bb49b991a633b49a712cd109de15ad2 Mon Sep 17 00:00:00 2001 From: Daniel Hollas Date: Fri, 13 Feb 2026 12:20:00 +0000 Subject: [PATCH 8/8] move lazy import --- Lib/dataclasses.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/dataclasses.py b/Lib/dataclasses.py index 1cf41e2cc1c1a8..238aa02ee0f55e 100644 --- a/Lib/dataclasses.py +++ b/Lib/dataclasses.py @@ -2,13 +2,13 @@ import sys import copy import types +lazy import inspect import keyword import itertools import annotationlib import abc from reprlib import recursive_repr -lazy import inspect __all__ = ['dataclass', 'field',