From a58e944a68d321bc8b1bad46ac6373813b99cb6d Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Tue, 28 Nov 2023 18:48:59 -0800 Subject: [PATCH 1/6] Update @deprecated implementation --- CHANGELOG.md | 2 + src/test_typing_extensions.py | 8 ++++ src/typing_extensions.py | 78 +++++++++++++++++++++-------------- 3 files changed, 57 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2893d27a..9d0a1d0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ deprecated class now raises a `DeprecationWarning`. Patch by Jelle Zijlstra. - `@deprecated` now gives a better error message if you pass a non-`str` argument to the `msg` parameter. Patch by Alex Waygood. +- `@deprecated` is now implemented as a class for better introspectability. + Patch by Jelle Zijlstra. - Exclude `__match_args__` from `Protocol` members, this is a backport of https://github.com/python/cpython/pull/110683 diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index 702ba541..ecf6292f 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -582,6 +582,14 @@ class Foo: ... @deprecated def foo(): ... + def test_no_retained_references_to_wrapper_instance(self): + @deprecated('depr') + def d(): pass + + self.assertFalse(any( + isinstance(cell.cell_contents, deprecated) for cell in d.__closure__ + )) + class AnyTests(BaseTestCase): def test_can_subclass(self): diff --git a/src/typing_extensions.py b/src/typing_extensions.py index fc656de8..7eec6515 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -2287,15 +2287,12 @@ def method(self) -> None: else: _T = typing.TypeVar("_T") - def deprecated( - msg: str, - /, - *, - category: typing.Optional[typing.Type[Warning]] = DeprecationWarning, - stacklevel: int = 1, - ) -> typing.Callable[[_T], _T]: + class deprecated: """Indicate that a class, function or overload is deprecated. + When this decorator is applied to an object, the type checker + will generate a diagnostic on usage of the deprecated object. + Usage: @deprecated("Use B instead") @@ -2312,42 +2309,61 @@ def g(x: int) -> int: ... @overload def g(x: str) -> int: ... - When this decorator is applied to an object, the type checker - will generate a diagnostic on usage of the deprecated object. - - The warning specified by ``category`` will be emitted on use - of deprecated objects. For functions, that happens on calls; - for classes, on instantiation. If the ``category`` is ``None``, - no warning is emitted. The ``stacklevel`` determines where the + The warning specified by *category* will be emitted at runtime + on use of deprecated objects. For functions, that happens on calls; + for classes, on instantiation and on creation of subclasses. + If the *category* is ``None``, no warning is emitted at runtime. + The *stacklevel* determines where the warning is emitted. If it is ``1`` (the default), the warning is emitted at the direct caller of the deprecated object; if it is higher, it is emitted further up the stack. + Static type checker behavior is not affected by the *category* + and *stacklevel* arguments. - The decorator sets the ``__deprecated__`` - attribute on the decorated object to the deprecation message - passed to the decorator. If applied to an overload, the decorator + The deprecation message passed to the decorator is saved in the + ``__deprecated__`` attribute on the decorated object. + If applied to an overload, the decorator must be after the ``@overload`` decorator for the attribute to exist on the overload as returned by ``get_overloads()``. See PEP 702 for details. """ - if not isinstance(msg, str): - raise TypeError( - f"Expected an object of type str for 'msg', not {type(msg).__name__!r}" - ) - - def decorator(arg: _T, /) -> _T: + def __init__( + self, + message: str, + /, + *, + category: type[Warning] | None = DeprecationWarning, + stacklevel: int = 1, + ) -> None: + if not isinstance(message, str): + raise TypeError( + f"Expected an object of type str for 'msg', not {type(message).__name__!r}" + ) + self.message = message + self.category = category + self.stacklevel = stacklevel + + def __call__(self, arg: _T, /) -> _T: + # Make sure the inner functions created below don't + # retain a reference to self. + msg = self.message + category = self.category + stacklevel = self.stacklevel if category is None: arg.__deprecated__ = msg return arg elif isinstance(arg, type): + import functools + from types import MethodType + original_new = arg.__new__ @functools.wraps(original_new) def __new__(cls, *args, **kwargs): if cls is arg: - warnings.warn(msg, category=category, stacklevel=stacklevel + 1) + warn(msg, category=category, stacklevel=stacklevel + 1) if original_new is not object.__new__: return original_new(cls, *args, **kwargs) # Mirrors a similar check in object.__new__. @@ -2361,21 +2377,21 @@ def __new__(cls, *args, **kwargs): original_init_subclass = arg.__init_subclass__ # We need slightly different behavior if __init_subclass__ # is a bound method (likely if it was implemented in Python) - if isinstance(original_init_subclass, _types.MethodType): + if isinstance(original_init_subclass, MethodType): original_init_subclass = original_init_subclass.__func__ @functools.wraps(original_init_subclass) def __init_subclass__(*args, **kwargs): - warnings.warn(msg, category=category, stacklevel=stacklevel + 1) + warn(msg, category=category, stacklevel=stacklevel + 1) return original_init_subclass(*args, **kwargs) arg.__init_subclass__ = classmethod(__init_subclass__) # Or otherwise, which likely means it's a builtin such as - # object's implementation of __init_subclass__. + # type's implementation of __init_subclass__. else: @functools.wraps(original_init_subclass) def __init_subclass__(*args, **kwargs): - warnings.warn(msg, category=category, stacklevel=stacklevel + 1) + warn(msg, category=category, stacklevel=stacklevel + 1) return original_init_subclass(*args, **kwargs) arg.__init_subclass__ = __init_subclass__ @@ -2384,9 +2400,11 @@ def __init_subclass__(*args, **kwargs): __init_subclass__.__deprecated__ = msg return arg elif callable(arg): + import functools + @functools.wraps(arg) def wrapper(*args, **kwargs): - warnings.warn(msg, category=category, stacklevel=stacklevel + 1) + warn(msg, category=category, stacklevel=stacklevel + 1) return arg(*args, **kwargs) arg.__deprecated__ = wrapper.__deprecated__ = msg @@ -2397,8 +2415,6 @@ def wrapper(*args, **kwargs): f"a class or callable, not {arg!r}" ) - return decorator - # We have to do some monkey patching to deal with the dual nature of # Unpack/TypeVarTuple: From 181c737a7e258fd26ab3c72c97cadc4072f3a869 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Tue, 28 Nov 2023 18:50:15 -0800 Subject: [PATCH 2/6] Run the tests first Jelle --- src/typing_extensions.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/typing_extensions.py b/src/typing_extensions.py index 7eec6515..76962f59 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -2363,7 +2363,7 @@ def __call__(self, arg: _T, /) -> _T: @functools.wraps(original_new) def __new__(cls, *args, **kwargs): if cls is arg: - warn(msg, category=category, stacklevel=stacklevel + 1) + warnings.warn(msg, category=category, stacklevel=stacklevel + 1) if original_new is not object.__new__: return original_new(cls, *args, **kwargs) # Mirrors a similar check in object.__new__. @@ -2382,7 +2382,7 @@ def __new__(cls, *args, **kwargs): @functools.wraps(original_init_subclass) def __init_subclass__(*args, **kwargs): - warn(msg, category=category, stacklevel=stacklevel + 1) + warnings.warn(msg, category=category, stacklevel=stacklevel + 1) return original_init_subclass(*args, **kwargs) arg.__init_subclass__ = classmethod(__init_subclass__) @@ -2391,7 +2391,7 @@ def __init_subclass__(*args, **kwargs): else: @functools.wraps(original_init_subclass) def __init_subclass__(*args, **kwargs): - warn(msg, category=category, stacklevel=stacklevel + 1) + warnings.warn(msg, category=category, stacklevel=stacklevel + 1) return original_init_subclass(*args, **kwargs) arg.__init_subclass__ = __init_subclass__ @@ -2404,7 +2404,7 @@ def __init_subclass__(*args, **kwargs): @functools.wraps(arg) def wrapper(*args, **kwargs): - warn(msg, category=category, stacklevel=stacklevel + 1) + warnings.warn(msg, category=category, stacklevel=stacklevel + 1) return arg(*args, **kwargs) arg.__deprecated__ = wrapper.__deprecated__ = msg From 70425bef2a6de0622d93e12cbaafbbf7b42fbae7 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Tue, 28 Nov 2023 18:52:14 -0800 Subject: [PATCH 3/6] Back to the past --- src/typing_extensions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/typing_extensions.py b/src/typing_extensions.py index 76962f59..c65f4a09 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -2334,7 +2334,7 @@ def __init__( message: str, /, *, - category: type[Warning] | None = DeprecationWarning, + category: typing.Optional[typing.Type[Warning]] = DeprecationWarning, stacklevel: int = 1, ) -> None: if not isinstance(message, str): From b36782224b023c53a0ecbfd7d61ddf80e8dd55d7 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Tue, 28 Nov 2023 20:10:27 -0800 Subject: [PATCH 4/6] line too long --- src/typing_extensions.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/typing_extensions.py b/src/typing_extensions.py index c65f4a09..68ca47ae 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -2339,7 +2339,8 @@ def __init__( ) -> None: if not isinstance(message, str): raise TypeError( - f"Expected an object of type str for 'msg', not {type(message).__name__!r}" + "Expected an object of type str for 'message', not " + f"{type(message).__name__!r}" ) self.message = message self.category = category From b46b0c253daf2b8690f1dd2481b5ed071950339e Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Wed, 29 Nov 2023 06:32:09 -0800 Subject: [PATCH 5/6] Fix test --- src/test_typing_extensions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index ecf6292f..28b72368 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -570,14 +570,14 @@ def d(): def test_only_strings_allowed(self): with self.assertRaisesRegex( TypeError, - "Expected an object of type str for 'msg', not 'type'" + "Expected an object of type str for 'message', not 'type'" ): @deprecated class Foo: ... with self.assertRaisesRegex( TypeError, - "Expected an object of type str for 'msg', not 'function'" + "Expected an object of type str for 'message', not 'function'" ): @deprecated def foo(): ... From e848e84dc2f7f3f63c7c72de12ba7283b843adb4 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Wed, 29 Nov 2023 09:38:09 -0800 Subject: [PATCH 6/6] Update src/typing_extensions.py Co-authored-by: Alex Waygood --- src/typing_extensions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/typing_extensions.py b/src/typing_extensions.py index 68ca47ae..4f58a740 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -2388,7 +2388,7 @@ def __init_subclass__(*args, **kwargs): arg.__init_subclass__ = classmethod(__init_subclass__) # Or otherwise, which likely means it's a builtin such as - # type's implementation of __init_subclass__. + # object's implementation of __init_subclass__. else: @functools.wraps(original_init_subclass) def __init_subclass__(*args, **kwargs):