Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions mypy/checkexpr.py
Original file line number Diff line number Diff line change
Expand Up @@ -1664,6 +1664,14 @@ def check_callable_call(
See the docstring of check_call for more information.
"""
# Check implicit calls to deprecated class constructors.
# Only the non-overload case is handled here. Overloaded constructors are handled
# separately during overload resolution. `callable_node` is `None` for an overload
# item so deprecation checks are not duplicated.
if isinstance(callable_node, RefExpr) and isinstance(callable_node.node, TypeInfo):
self.chk.check_deprecated(callable_node.node.get_method("__new__"), context)
self.chk.check_deprecated(callable_node.node.get_method("__init__"), context)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This feels like the wrong place for this. But I don't remember where would be better. Where is overloaded __init__ checked for deprecation?

Copy link
Contributor Author

@bzoracler bzoracler Oct 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overloaded __init__ doesn't have a special place - it is checked with all other overloaded function/method calls.

I had a look through the situations where @deprecated activates, and I gathered the following:

  • If a function/class is @deprecated, any RefExpr (regardless whether it is further used in a CallExpr) triggers a report (see visit_name_expr and visit_member_expr);
  • If a function/class CallExpr has overload implementations, then reports are triggered during overload resolution (this includes overloaded class constructors).
  • If it's in a type annotation, it's done during semantic analysis.

IMO @deprecated class constructors aren't similar to any of the 3 situations above, so this implementation can't be placed adjacent to where any of the 3 situations above activates.

I placed it in check_callable_call because it kind of mirrors where the same check is done for overloads (check_overload_call)

mypy/mypy/checkexpr.py

Lines 2770 to 2774 in 11dbe33

self.chk.warn_deprecated(c.definition, context)
return unioned_result
if inferred_result is not None:
if isinstance(c := get_proper_type(inferred_result[1]), CallableType):
self.chk.warn_deprecated(c.definition, context)

Another place could be visit_call_expr_inner

def visit_call_expr_inner(self, e: CallExpr, allow_none_return: bool = False) -> Type:

but any other suggestions are welcome.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would have wanted it to be in the same place as the thing that warns in this case:

from typing_extensions import deprecated

class A:
    @deprecated("don't add As")
    def __add__(self, o: object) -> int:
        return 5

a = A()
a + a  # warning here

... but there is no warning!

I assume there's some lookup on the callable node to get e.g. __add__ or __init__? IMO the deprecated check should go after that. And then the member access utility should mark a callable as deprecated if it's from the __init__ if the __new__ is deprecated (?).

I haven't tried to implement this so maybe this is completely off base and what you have is correct :^)

Copy link
Contributor Author

@bzoracler bzoracler Oct 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For @deprecated() to activate you have to turn it on somewhere on a configuration, your example does show a warning (see mypy Playground).

I assume there's some lookup on the callable node to get e.g. __add__ or __init__?

Thank you, I'll take another look - yes, implicit dunder activation might be a more natural place to put this. (Not __init__ though, that never got resolved by itself before this PR; it only got resolved as part of an overload without special casing).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, I think this can only be implemented in a place which specifically deals with call expressions.

I had a look and doing it this way

And then the member access utility should mark a callable as deprecated if it's from the __init__ if the __new__ is deprecated (?).

and I believe we will end up with a lot of false positives. A dummy implementation by placing it at the end of this range in ExpressionChecker.analyze_ref_expr results in

from typing_extensions import deprecated

class A: 
    @deprecated("")
    def __init__(self) -> None: ...

A  # E: ... [deprecated]

This is because mypy uses CallableType to represent a class A in a lot of expression contexts without the user having any intention of actually making the call A(), but this CallableType is synthesised by <Class>.__init__ or <Class>.__new__. So an expression A automatically triggers type checking paths for A.__init__ or A.__new__ because of mypy implementation details, then further triggers a deprecation report, even if the user only intended to mean A and not A().

This is unlike __add__, because only a + a implies access to .__add__ (not a itself).

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for digging into this!


# Always unpack **kwargs before checking a call.
callee = callee.with_unpacked_kwargs().with_normalized_var_args()
if callable_name is None and callee.name:
Expand Down
49 changes: 44 additions & 5 deletions test-data/unit/check-deprecated.test
Original file line number Diff line number Diff line change
Expand Up @@ -315,18 +315,57 @@ class E: ...
[builtins fixtures/tuple.pyi]


[case testDeprecatedClassInitMethod]
[case testDeprecatedClassConstructor]
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I changed this test to check implicit calls to class constructors.

The previous test didn't seem to directly check for __init__ methods; instead, the error is reported for any usage of C (including a plain expression statement, C on its own line), and did not require accessing __init__ (or any other attribute):

from typing_extensions import deprecated

@deprecated("Warning")
class C: ...

C  # E: class __main__.C is deprecated: Warning

# flags: --enable-error-code=deprecated

from typing_extensions import deprecated

@deprecated("use C2 instead")
class C:
@deprecated("call `make_c()` instead")
def __init__(self) -> None: ...
@classmethod
def make_c(cls) -> C: ...

c: C # E: class __main__.C is deprecated: use C2 instead
C() # E: class __main__.C is deprecated: use C2 instead
C.__init__(c) # E: class __main__.C is deprecated: use C2 instead
class C2(C): ...

C() # E: function __main__.C.__init__ is deprecated: call `make_c()` instead
C2() # E: function __main__.C.__init__ is deprecated: call `make_c()` instead

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe you want to add a test line this? (here and similar below)

class CC(C): ...
CC()  # E: function __main__.C.__init__ is deprecated: call `make_c()` instead

class D:
@deprecated("call `make_d()` instead")
def __new__(cls) -> D: ...
@classmethod
def make_d(cls) -> D: ...

class D2(D): ...

D() # E: function __main__.D.__new__ is deprecated: call `make_d()` instead
D2() # E: function __main__.D.__new__ is deprecated: call `make_d()` instead

[builtins fixtures/tuple.pyi]


[case testDeprecatedSuperClassConstructor]
# flags: --enable-error-code=deprecated

from typing_extensions import deprecated, Self

class A:
@deprecated("call `self.initialise()` instead")
def __init__(self) -> None: ...
def initialise(self) -> None: ...

class B(A):
def __init__(self) -> None:
super().__init__() # E: function __main__.A.__init__ is deprecated: call `self.initialise()` instead

class C:
@deprecated("call `object.__new__(cls)` instead")
def __new__(cls) -> Self: ...

class D(C):
def __new__(cls) -> Self:
return super().__new__(cls) # E: function __main__.C.__new__ is deprecated: call `object.__new__(cls)` instead

[builtins fixtures/tuple.pyi]

Expand Down