diff --git a/CHANGELOG.md b/CHANGELOG.md index 16775856..2893d27a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,9 @@ - All parameters on `NewType.__call__` are now positional-only. This means that the signature of `typing_extensions.NewType.__call__` now exactly matches the signature of `typing.NewType.__call__`. Patch by Alex Waygood. -- `typing.deprecated` now gives a better error message if you pass a non-`str` +- Fix bug with using `@deprecated` on a mixin class. Inheriting from a + 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. - Exclude `__match_args__` from `Protocol` members, this is a backport of https://github.com/python/cpython/pull/110683 diff --git a/doc/index.rst b/doc/index.rst index 28b795a3..39885861 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -553,6 +553,11 @@ Decorators .. versionadded:: 4.5.0 + .. versionchanged:: 4.9.0 + + Inheriting from a deprecated class now also raises a runtime + :py:exc:`DeprecationWarning`. + .. decorator:: final See :py:func:`typing.final` and :pep:`591`. In ``typing`` since 3.8. diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index ffc84266..702ba541 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -418,6 +418,93 @@ def __new__(cls, x): self.assertEqual(instance.x, 42) self.assertTrue(new_called) + def test_mixin_class(self): + @deprecated("Mixin will go away soon") + class Mixin: + pass + + class Base: + def __init__(self, a) -> None: + self.a = a + + with self.assertWarnsRegex(DeprecationWarning, "Mixin will go away soon"): + class Child(Base, Mixin): + pass + + instance = Child(42) + self.assertEqual(instance.a, 42) + + def test_existing_init_subclass(self): + @deprecated("C will go away soon") + class C: + def __init_subclass__(cls) -> None: + cls.inited = True + + with self.assertWarnsRegex(DeprecationWarning, "C will go away soon"): + C() + + with self.assertWarnsRegex(DeprecationWarning, "C will go away soon"): + class D(C): + pass + + self.assertTrue(D.inited) + self.assertIsInstance(D(), D) # no deprecation + + def test_existing_init_subclass_in_base(self): + class Base: + def __init_subclass__(cls, x) -> None: + cls.inited = x + + @deprecated("C will go away soon") + class C(Base, x=42): + pass + + self.assertEqual(C.inited, 42) + + with self.assertWarnsRegex(DeprecationWarning, "C will go away soon"): + C() + + with self.assertWarnsRegex(DeprecationWarning, "C will go away soon"): + class D(C, x=3): + pass + + self.assertEqual(D.inited, 3) + + def test_init_subclass_has_correct_cls(self): + init_subclass_saw = None + + @deprecated("Base will go away soon") + class Base: + def __init_subclass__(cls) -> None: + nonlocal init_subclass_saw + init_subclass_saw = cls + + self.assertIsNone(init_subclass_saw) + + with self.assertWarnsRegex(DeprecationWarning, "Base will go away soon"): + class C(Base): + pass + + self.assertIs(init_subclass_saw, C) + + def test_init_subclass_with_explicit_classmethod(self): + init_subclass_saw = None + + @deprecated("Base will go away soon") + class Base: + @classmethod + def __init_subclass__(cls) -> None: + nonlocal init_subclass_saw + init_subclass_saw = cls + + self.assertIsNone(init_subclass_saw) + + with self.assertWarnsRegex(DeprecationWarning, "Base will go away soon"): + class C(Base): + pass + + self.assertIs(init_subclass_saw, C) + def test_function(self): @deprecated("b will go away soon") def b(): diff --git a/src/typing_extensions.py b/src/typing_extensions.py index 78ae1635..c8c6853b 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -2343,21 +2343,45 @@ def decorator(arg: _T, /) -> _T: return arg elif isinstance(arg, type): original_new = arg.__new__ - has_init = arg.__init__ is not object.__init__ @functools.wraps(original_new) def __new__(cls, *args, **kwargs): - warnings.warn(msg, category=category, stacklevel=stacklevel + 1) + if cls is arg: + 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__. - elif not has_init and (args or kwargs): + elif cls.__init__ is object.__init__ and (args or kwargs): raise TypeError(f"{cls.__name__}() takes no arguments") else: return original_new(cls) arg.__new__ = staticmethod(__new__) + + 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): + 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) + 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__. + else: + @functools.wraps(original_init_subclass) + def __init_subclass__(*args, **kwargs): + warnings.warn(msg, category=category, stacklevel=stacklevel + 1) + return original_init_subclass(*args, **kwargs) + + arg.__init_subclass__ = __init_subclass__ + arg.__deprecated__ = __new__.__deprecated__ = msg + __init_subclass__.__deprecated__ = msg return arg elif callable(arg): @functools.wraps(arg)