From a9a9233fda43350f9b596022340fb4404b034046 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Mon, 23 Sep 2024 08:25:53 -0700 Subject: [PATCH 1/2] gh-124342: Wrap __annotate__ functions in functools.update_wrapper --- Doc/library/functools.rst | 29 +++++++++------ Doc/whatsnew/3.14.rst | 8 +++++ Lib/functools.py | 36 ++++++++++++++++--- Lib/test/test_functools.py | 24 +++++++++++-- ...-09-23-08-25-48.gh-issue-124342.7QzKqp.rst | 2 ++ 5 files changed, 82 insertions(+), 17 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2024-09-23-08-25-48.gh-issue-124342.7QzKqp.rst diff --git a/Doc/library/functools.rst b/Doc/library/functools.rst index 008cde399baed2..24ebbc165f5b20 100644 --- a/Doc/library/functools.rst +++ b/Doc/library/functools.rst @@ -638,18 +638,21 @@ The :mod:`functools` module defines the following functions: .. versionadded:: 3.8 -.. function:: update_wrapper(wrapper, wrapped, assigned=WRAPPER_ASSIGNMENTS, updated=WRAPPER_UPDATES) +.. function:: update_wrapper(wrapper, wrapped, assigned=WRAPPER_ASSIGNMENTS, updated=WRAPPER_UPDATES, *, delegated=WRAPPER_DELEGATIONS) Update a *wrapper* function to look like the *wrapped* function. The optional - arguments are tuples to specify which attributes of the original function are - assigned directly to the matching attributes on the wrapper function and which + arguments are tuples to specify how particular attributes are handled. + + The attributes in *assigned* are assigned directly to the matching attributes + on the wrapper function. The default value is the module-level constant + ``WRAPPER_ASSIGNMENTS``, which contains ``__module__``, ``__name__``, ``__qualname__``, + ``__type_params__``, and ``__doc__``. For names in *updated*, attributes of the wrapper function are updated with the corresponding attributes - from the original function. The default values for these arguments are the - module level constants ``WRAPPER_ASSIGNMENTS`` (which assigns to the wrapper - function's ``__module__``, ``__name__``, ``__qualname__``, ``__annotations__``, - ``__type_params__``, and ``__doc__``, the documentation string) - and ``WRAPPER_UPDATES`` (which - updates the wrapper function's ``__dict__``, i.e. the instance dictionary). + from the original function. It defaults to ``WRAPPER_UPDATES``, which contains + ``__dict__``, i.e. the instance dictionary. An attribute-specific delegation + mechanism is used for attributes in *delegated*. The default value, + ``WRAPPER_DELEGATIONS``, contains only ``__annotate__``, for which a wrapper + :term:`annotate function` is generated. Other attributes cannot be added to *delegated*. To allow access to the original function for introspection and other purposes (e.g. bypassing a caching decorator such as :func:`lru_cache`), this function @@ -681,12 +684,16 @@ The :mod:`functools` module defines the following functions: .. versionchanged:: 3.12 The ``__type_params__`` attribute is now copied by default. + .. versionchanged:: 3.14 + The ``__annotations__`` attribute is no longer copied by default. Instead, + the ``__annotate__`` attribute is delegated. See :pep:`749`. + -.. decorator:: wraps(wrapped, assigned=WRAPPER_ASSIGNMENTS, updated=WRAPPER_UPDATES) +.. decorator:: wraps(wrapped, assigned=WRAPPER_ASSIGNMENTS, updated=WRAPPER_UPDATES, *, delegated=WRAPPER_DELEGATIONS) This is a convenience function for invoking :func:`update_wrapper` as a function decorator when defining a wrapper function. It is equivalent to - ``partial(update_wrapper, wrapped=wrapped, assigned=assigned, updated=updated)``. + ``partial(update_wrapper, wrapped=wrapped, assigned=assigned, updated=updated, delegated=delegated)``. For example:: >>> from functools import wraps diff --git a/Doc/whatsnew/3.14.rst b/Doc/whatsnew/3.14.rst index 53399aa4e50fa6..3e0bc08ab9bed7 100644 --- a/Doc/whatsnew/3.14.rst +++ b/Doc/whatsnew/3.14.rst @@ -168,6 +168,14 @@ Added support for converting any objects that have the (Contributed by Serhiy Storchaka in :gh:`82017`.) +functools +--------- + +* :func:`functools.update_wrapper` and :func:`functools.wraps` now have + special support for wrapping :term:`annotate functions `. + A new parameter *delegated* was added to both. (Contributed by Jelle Zijlstra + in :gh:`124342`.) + http ---- diff --git a/Lib/functools.py b/Lib/functools.py index 49ea9a2f6999f5..dee979a64d01d3 100644 --- a/Lib/functools.py +++ b/Lib/functools.py @@ -32,12 +32,15 @@ # wrapper functions that can handle naive introspection WRAPPER_ASSIGNMENTS = ('__module__', '__name__', '__qualname__', '__doc__', - '__annotate__', '__type_params__') + '__type_params__') WRAPPER_UPDATES = ('__dict__',) +WRAPPER_DELEGATIONS = ('__annotate__',) def update_wrapper(wrapper, wrapped, assigned = WRAPPER_ASSIGNMENTS, - updated = WRAPPER_UPDATES): + updated = WRAPPER_UPDATES, + *, + delegated = WRAPPER_DELEGATIONS): """Update a wrapper function to look like the wrapped function wrapper is the function to be updated @@ -48,6 +51,11 @@ def update_wrapper(wrapper, updated is a tuple naming the attributes of the wrapper that are updated with the corresponding attribute from the wrapped function (defaults to functools.WRAPPER_UPDATES) + delegated is a tuple naming attributes of the wrapper for which + resolution should be delegated to the wrapped object using custom + logic (defaults to functools.WRAPPER_DELEGATIONS). Only the + __annotate__ attribute is supported; any other attribute will raise + an error. """ for attr in assigned: try: @@ -58,6 +66,14 @@ def update_wrapper(wrapper, setattr(wrapper, attr, value) for attr in updated: getattr(wrapper, attr).update(getattr(wrapped, attr, {})) + for attr in delegated: + if attr == "__annotate__": + def __annotate__(format): + func = _get_get_annotations() + return func(wrapped, format=format) + wrapper.__annotate__ = __annotate__ + else: + raise ValueError(f"Unsupported delegated attribute {attr!r}") # Issue #17482: set __wrapped__ last so we don't inadvertently copy it # from the wrapped function when updating __dict__ wrapper.__wrapped__ = wrapped @@ -66,7 +82,9 @@ def update_wrapper(wrapper, def wraps(wrapped, assigned = WRAPPER_ASSIGNMENTS, - updated = WRAPPER_UPDATES): + updated = WRAPPER_UPDATES, + *, + delegated = WRAPPER_DELEGATIONS): """Decorator factory to apply update_wrapper() to a wrapper function Returns a decorator that invokes update_wrapper() with the decorated @@ -76,7 +94,7 @@ def wraps(wrapped, update_wrapper(). """ return partial(update_wrapper, wrapped=wrapped, - assigned=assigned, updated=updated) + assigned=assigned, updated=updated, delegated=delegated) ################################################################################ @@ -1048,3 +1066,13 @@ def __get__(self, instance, owner=None): return val __class_getitem__ = classmethod(GenericAlias) + + +################################################################################ +### internal helpers +################################################################################ + +@cache +def _get_get_annotations(): + from annotationlib import get_annotations + return get_annotations diff --git a/Lib/test/test_functools.py b/Lib/test/test_functools.py index 837f3795f0842d..67db86cb030103 100644 --- a/Lib/test/test_functools.py +++ b/Lib/test/test_functools.py @@ -1,4 +1,5 @@ import abc +import annotationlib import builtins import collections import collections.abc @@ -747,20 +748,39 @@ def wrapper(*args): pass functools.update_wrapper(wrapper, inner) self.assertEqual(wrapper.__annotations__, {'x': int}) - self.assertIs(wrapper.__annotate__, inner.__annotate__) + self.check_annotate_matches(wrapper, inner) def with_forward_ref(x: undefined): pass def wrapper(*args): pass functools.update_wrapper(wrapper, with_forward_ref) - self.assertIs(wrapper.__annotate__, with_forward_ref.__annotate__) + # VALUE raises NameError + self.check_annotate_matches(wrapper, with_forward_ref, skip=(annotationlib.Format.VALUE,)) with self.assertRaises(NameError): wrapper.__annotations__ undefined = str self.assertEqual(wrapper.__annotations__, {'x': undefined}) + def test_update_wrapper_with_modified_annotations(self): + def inner(x: int): pass + def wrapper(*args): pass + + inner.__annotations__["x"] = str + functools.update_wrapper(wrapper, inner) + self.assertEqual(wrapper.__annotations__, {'x': str}) + self.check_annotate_matches(wrapper, inner) + + def check_annotate_matches(self, wrapper, wrapped, skip=()): + for format in annotationlib.Format: + if format in skip: + continue + with self.subTest(format=format): + wrapper_annos = annotationlib.get_annotations(wrapper, format=format) + wrapped_annos = annotationlib.get_annotations(wrapped, format=format) + self.assertEqual(wrapper_annos, wrapped_annos) + class TestWraps(TestUpdateWrapper): diff --git a/Misc/NEWS.d/next/Library/2024-09-23-08-25-48.gh-issue-124342.7QzKqp.rst b/Misc/NEWS.d/next/Library/2024-09-23-08-25-48.gh-issue-124342.7QzKqp.rst new file mode 100644 index 00000000000000..2081d337f8f1ae --- /dev/null +++ b/Misc/NEWS.d/next/Library/2024-09-23-08-25-48.gh-issue-124342.7QzKqp.rst @@ -0,0 +1,2 @@ +Add custom support for wrapping ``__annotate__`` functions to +:func:`functools.update_wrapper` and :func:`functools.wraps`. From 99220cfebcd292968892964baebd09a515a4e3ef Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Tue, 24 Sep 2024 11:15:27 -0700 Subject: [PATCH 2/2] Alternative approach --- Lib/functools.py | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/Lib/functools.py b/Lib/functools.py index dee979a64d01d3..09cf2c079c5e48 100644 --- a/Lib/functools.py +++ b/Lib/functools.py @@ -69,8 +69,14 @@ def update_wrapper(wrapper, for attr in delegated: if attr == "__annotate__": def __annotate__(format): - func = _get_get_annotations() - return func(wrapped, format=format) + if format == 1: # VALUE + return getattr(wrapped, "__annotations__", {}) + get_annotate_function = _get_get_annotate_function() + call_annotate_function = _get_call_annotate_function() + annotate_function = get_annotate_function(wrapped) + if annotate_function is None: + return {} + return call_annotate_function(annotate_function, format=format) wrapper.__annotate__ = __annotate__ else: raise ValueError(f"Unsupported delegated attribute {attr!r}") @@ -1076,3 +1082,15 @@ def __get__(self, instance, owner=None): def _get_get_annotations(): from annotationlib import get_annotations return get_annotations + + +@cache +def _get_get_annotate_function(): + from annotationlib import get_annotate_function + return get_annotate_function + + +@cache +def _get_call_annotate_function(): + from annotationlib import call_annotate_function + return call_annotate_function