Skip to content

Commit

Permalink
Fix deprecating a mixin; warn when inheriting from a deprecated class (
Browse files Browse the repository at this point in the history
…#294)

Co-authored-by: Alex Waygood <Alex.Waygood@Gmail.com>
  • Loading branch information
JelleZijlstra and AlexWaygood authored Nov 4, 2023
1 parent fc9acbd commit f9f257c
Show file tree
Hide file tree
Showing 4 changed files with 122 additions and 4 deletions.
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions doc/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
87 changes: 87 additions & 0 deletions src/test_typing_extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
30 changes: 27 additions & 3 deletions src/typing_extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down

0 comments on commit f9f257c

Please sign in to comment.