Skip to content

Commit 94c49a8

Browse files
tyrallapre-commit-ci[bot]ilevkivskyi
authored
Add basic support for PEP 702 (@deprecated). (#17476)
Closes #16111 This PR provides only basic support. Many special cases might need additional attention (descriptors, some special methods like `__int__`, etc.). Other open issues are code comments, eventual documentation updates, the deprecation message style, etc.). But I wanted to offer these first steps before going on vacation (so I cannot respond to possible reviews too soon). Maybe someone wants to extend the list of (test) cases the basic support should address? --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Ivan Levkivskyi <[email protected]>
1 parent 4da779b commit 94c49a8

15 files changed

+860
-17
lines changed

docs/source/command_line.rst

+6
Original file line numberDiff line numberDiff line change
@@ -537,6 +537,12 @@ potentially problematic or redundant in some way.
537537

538538
This limitation will be removed in future releases of mypy.
539539

540+
.. option:: --report-deprecated-as-error
541+
542+
By default, mypy emits notes if your code imports or uses deprecated
543+
features. This flag converts such notes to errors, causing mypy to
544+
eventually finish with a non-zero exit code. Features are considered
545+
deprecated when decorated with ``warnings.deprecated``.
540546

541547
.. _miscellaneous-strictness-flags:
542548

docs/source/error_code_list2.rst

+38
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,44 @@ incorrect control flow or conditional checks that are accidentally always true o
231231
# Error: Statement is unreachable [unreachable]
232232
print('unreachable')
233233
234+
.. _code-deprecated:
235+
236+
Check that imported or used feature is deprecated [deprecated]
237+
--------------------------------------------------------------
238+
239+
By default, mypy generates a note if your code imports a deprecated feature explicitly with a
240+
``from mod import depr`` statement or uses a deprecated feature imported otherwise or defined
241+
locally. Features are considered deprecated when decorated with ``warnings.deprecated``, as
242+
specified in `PEP 702 <https://peps.python.org/pep-0702>`_. You can silence single notes via
243+
``# type: ignore[deprecated]`` or turn off this check completely via ``--disable-error-code=deprecated``.
244+
Use the :option:`--report-deprecated-as-error <mypy --report-deprecated-as-error>` option for
245+
more strictness, which turns all such notes into errors.
246+
247+
.. note::
248+
249+
The ``warnings`` module provides the ``@deprecated`` decorator since Python 3.13.
250+
To use it with older Python versions, import it from ``typing_extensions`` instead.
251+
252+
Examples:
253+
254+
.. code-block:: python
255+
256+
# mypy: report-deprecated-as-error
257+
258+
# Error: abc.abstractproperty is deprecated: Deprecated, use 'property' with 'abstractmethod' instead
259+
from abc import abstractproperty
260+
261+
from typing_extensions import deprecated
262+
263+
@deprecated("use new_function")
264+
def old_function() -> None:
265+
print("I am old")
266+
267+
# Error: __main__.old_function is deprecated: use new_function
268+
old_function()
269+
old_function() # type: ignore[deprecated]
270+
271+
234272
.. _code-redundant-expr:
235273

236274
Check that expression is redundant [redundant-expr]

mypy/checker.py

+46
Original file line numberDiff line numberDiff line change
@@ -2838,6 +2838,9 @@ def check_metaclass_compatibility(self, typ: TypeInfo) -> None:
28382838
)
28392839

28402840
def visit_import_from(self, node: ImportFrom) -> None:
2841+
for name, _ in node.names:
2842+
if (sym := self.globals.get(name)) is not None:
2843+
self.warn_deprecated(sym.node, node)
28412844
self.check_import(node)
28422845

28432846
def visit_import_all(self, node: ImportAll) -> None:
@@ -2926,6 +2929,16 @@ def visit_assignment_stmt(self, s: AssignmentStmt) -> None:
29262929
29272930
Handle all kinds of assignment statements (simple, indexed, multiple).
29282931
"""
2932+
2933+
if isinstance(s.rvalue, TempNode) and s.rvalue.no_rhs:
2934+
for lvalue in s.lvalues:
2935+
if (
2936+
isinstance(lvalue, NameExpr)
2937+
and isinstance(var := lvalue.node, Var)
2938+
and isinstance(instance := get_proper_type(var.type), Instance)
2939+
):
2940+
self.check_deprecated(instance.type, s)
2941+
29292942
# Avoid type checking type aliases in stubs to avoid false
29302943
# positives about modern type syntax available in stubs such
29312944
# as X | Y.
@@ -4671,6 +4684,16 @@ def visit_operator_assignment_stmt(self, s: OperatorAssignmentStmt) -> None:
46714684
if inplace:
46724685
# There is __ifoo__, treat as x = x.__ifoo__(y)
46734686
rvalue_type, method_type = self.expr_checker.check_op(method, lvalue_type, s.rvalue, s)
4687+
if isinstance(inst := get_proper_type(lvalue_type), Instance) and isinstance(
4688+
defn := inst.type.get_method(method), OverloadedFuncDef
4689+
):
4690+
for item in defn.items:
4691+
if (
4692+
isinstance(item, Decorator)
4693+
and isinstance(typ := item.func.type, CallableType)
4694+
and (bind_self(typ) == method_type)
4695+
):
4696+
self.warn_deprecated(item.func, s)
46744697
if not is_subtype(rvalue_type, lvalue_type):
46754698
self.msg.incompatible_operator_assignment(s.op, s)
46764699
else:
@@ -7535,6 +7558,29 @@ def has_valid_attribute(self, typ: Type, name: str) -> bool:
75357558
def get_expression_type(self, node: Expression, type_context: Type | None = None) -> Type:
75367559
return self.expr_checker.accept(node, type_context=type_context)
75377560

7561+
def check_deprecated(self, node: SymbolNode | None, context: Context) -> None:
7562+
"""Warn if deprecated and not directly imported with a `from` statement."""
7563+
if isinstance(node, Decorator):
7564+
node = node.func
7565+
if isinstance(node, (FuncDef, OverloadedFuncDef, TypeInfo)) and (
7566+
node.deprecated is not None
7567+
):
7568+
for imp in self.tree.imports:
7569+
if isinstance(imp, ImportFrom) and any(node.name == n[0] for n in imp.names):
7570+
break
7571+
else:
7572+
self.warn_deprecated(node, context)
7573+
7574+
def warn_deprecated(self, node: SymbolNode | None, context: Context) -> None:
7575+
"""Warn if deprecated."""
7576+
if isinstance(node, Decorator):
7577+
node = node.func
7578+
if isinstance(node, (FuncDef, OverloadedFuncDef, TypeInfo)) and (
7579+
(deprecated := node.deprecated) is not None
7580+
):
7581+
warn = self.msg.fail if self.options.report_deprecated_as_error else self.msg.note
7582+
warn(deprecated, context, code=codes.DEPRECATED)
7583+
75387584

75397585
class CollectArgTypeVarTypes(TypeTraverserVisitor):
75407586
"""Collects the non-nested argument types in a set."""

mypy/checkexpr.py

+32-7
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@
126126
validate_instance,
127127
)
128128
from mypy.typeops import (
129+
bind_self,
129130
callable_type,
130131
custom_special_method,
131132
erase_to_union_or_bound,
@@ -354,7 +355,9 @@ def visit_name_expr(self, e: NameExpr) -> Type:
354355
"""
355356
self.chk.module_refs.update(extract_refexpr_names(e))
356357
result = self.analyze_ref_expr(e)
357-
return self.narrow_type_from_binder(e, result)
358+
narrowed = self.narrow_type_from_binder(e, result)
359+
self.chk.check_deprecated(e.node, e)
360+
return narrowed
358361

359362
def analyze_ref_expr(self, e: RefExpr, lvalue: bool = False) -> Type:
360363
result: Type | None = None
@@ -1479,6 +1482,10 @@ def check_call_expr_with_callee_type(
14791482
object_type=object_type,
14801483
)
14811484
proper_callee = get_proper_type(callee_type)
1485+
if isinstance(e.callee, NameExpr) and isinstance(e.callee.node, OverloadedFuncDef):
1486+
for item in e.callee.node.items:
1487+
if isinstance(item, Decorator) and (item.func.type == callee_type):
1488+
self.chk.check_deprecated(item.func, e)
14821489
if isinstance(e.callee, RefExpr) and isinstance(proper_callee, CallableType):
14831490
# Cache it for find_isinstance_check()
14841491
if proper_callee.type_guard is not None:
@@ -3267,7 +3274,9 @@ def visit_member_expr(self, e: MemberExpr, is_lvalue: bool = False) -> Type:
32673274
"""Visit member expression (of form e.id)."""
32683275
self.chk.module_refs.update(extract_refexpr_names(e))
32693276
result = self.analyze_ordinary_member_access(e, is_lvalue)
3270-
return self.narrow_type_from_binder(e, result)
3277+
narrowed = self.narrow_type_from_binder(e, result)
3278+
self.chk.warn_deprecated(e.node, e)
3279+
return narrowed
32713280

32723281
def analyze_ordinary_member_access(self, e: MemberExpr, is_lvalue: bool) -> Type:
32733282
"""Analyse member expression or member lvalue."""
@@ -3956,7 +3965,7 @@ def lookup_definer(typ: Instance, attr_name: str) -> str | None:
39563965
# This is the case even if the __add__ method is completely missing and the __radd__
39573966
# method is defined.
39583967

3959-
variants_raw = [(left_op, left_type, right_expr)]
3968+
variants_raw = [(op_name, left_op, left_type, right_expr)]
39603969
elif (
39613970
is_subtype(right_type, left_type)
39623971
and isinstance(left_type, Instance)
@@ -3977,19 +3986,25 @@ def lookup_definer(typ: Instance, attr_name: str) -> str | None:
39773986
# As a special case, the alt_promote check makes sure that we don't use the
39783987
# __radd__ method of int if the LHS is a native int type.
39793988

3980-
variants_raw = [(right_op, right_type, left_expr), (left_op, left_type, right_expr)]
3989+
variants_raw = [
3990+
(rev_op_name, right_op, right_type, left_expr),
3991+
(op_name, left_op, left_type, right_expr),
3992+
]
39813993
else:
39823994
# In all other cases, we do the usual thing and call __add__ first and
39833995
# __radd__ second when doing "A() + B()".
39843996

3985-
variants_raw = [(left_op, left_type, right_expr), (right_op, right_type, left_expr)]
3997+
variants_raw = [
3998+
(op_name, left_op, left_type, right_expr),
3999+
(rev_op_name, right_op, right_type, left_expr),
4000+
]
39864001

39874002
# STEP 3:
39884003
# We now filter out all non-existent operators. The 'variants' list contains
39894004
# all operator methods that are actually present, in the order that Python
39904005
# attempts to invoke them.
39914006

3992-
variants = [(op, obj, arg) for (op, obj, arg) in variants_raw if op is not None]
4007+
variants = [(na, op, obj, arg) for (na, op, obj, arg) in variants_raw if op is not None]
39934008

39944009
# STEP 4:
39954010
# We now try invoking each one. If an operation succeeds, end early and return
@@ -3998,13 +4013,23 @@ def lookup_definer(typ: Instance, attr_name: str) -> str | None:
39984013

39994014
errors = []
40004015
results = []
4001-
for method, obj, arg in variants:
4016+
for name, method, obj, arg in variants:
40024017
with self.msg.filter_errors(save_filtered_errors=True) as local_errors:
40034018
result = self.check_method_call(op_name, obj, method, [arg], [ARG_POS], context)
40044019
if local_errors.has_new_errors():
40054020
errors.append(local_errors.filtered_errors())
40064021
results.append(result)
40074022
else:
4023+
if isinstance(obj, Instance) and isinstance(
4024+
defn := obj.type.get_method(name), OverloadedFuncDef
4025+
):
4026+
for item in defn.items:
4027+
if (
4028+
isinstance(item, Decorator)
4029+
and isinstance(typ := item.func.type, CallableType)
4030+
and bind_self(typ) == result[1]
4031+
):
4032+
self.chk.check_deprecated(item.func, context)
40084033
return result
40094034

40104035
# We finish invoking above operators and no early return happens. Therefore,

mypy/checkmember.py

+10-3
Original file line numberDiff line numberDiff line change
@@ -316,9 +316,12 @@ def analyze_instance_member_access(
316316

317317
if method.is_property:
318318
assert isinstance(method, OverloadedFuncDef)
319-
first_item = method.items[0]
320-
assert isinstance(first_item, Decorator)
321-
return analyze_var(name, first_item.var, typ, info, mx)
319+
getter = method.items[0]
320+
assert isinstance(getter, Decorator)
321+
if mx.is_lvalue and (len(items := method.items) > 1):
322+
mx.chk.warn_deprecated(items[1], mx.context)
323+
return analyze_var(name, getter.var, typ, info, mx)
324+
322325
if mx.is_lvalue:
323326
mx.msg.cant_assign_to_method(mx.context)
324327
if not isinstance(method, OverloadedFuncDef):
@@ -493,6 +496,8 @@ def analyze_member_var_access(
493496
# It was not a method. Try looking up a variable.
494497
v = lookup_member_var_or_accessor(info, name, mx.is_lvalue)
495498

499+
mx.chk.warn_deprecated(v, mx.context)
500+
496501
vv = v
497502
if isinstance(vv, Decorator):
498503
# The associated Var node of a decorator contains the type.
@@ -1010,6 +1015,8 @@ def analyze_class_attribute_access(
10101015
# on the class object itself rather than the instance.
10111016
return None
10121017

1018+
mx.chk.warn_deprecated(node.node, mx.context)
1019+
10131020
is_decorated = isinstance(node.node, Decorator)
10141021
is_method = is_decorated or isinstance(node.node, FuncBase)
10151022
if mx.is_lvalue:

mypy/errorcodes.py

+6
Original file line numberDiff line numberDiff line change
@@ -304,5 +304,11 @@ def __hash__(self) -> int:
304304
"General",
305305
)
306306

307+
DEPRECATED: Final = ErrorCode(
308+
"deprecated",
309+
"Warn when importing or using deprecated (overloaded) functions, methods or classes",
310+
"General",
311+
)
312+
307313
# This copy will not include any error codes defined later in the plugins.
308314
mypy_error_codes = error_codes.copy()

mypy/errors.py

+4-1
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020

2121
# Show error codes for some note-level messages (these usually appear alone
2222
# and not as a comment for a previous error-level message).
23-
SHOW_NOTE_CODES: Final = {codes.ANNOTATION_UNCHECKED}
23+
SHOW_NOTE_CODES: Final = {codes.ANNOTATION_UNCHECKED, codes.DEPRECATED}
2424

2525
# Do not add notes with links to error code docs to errors with these codes.
2626
# We can tweak this set as we get more experience about what is helpful and what is not.
@@ -194,6 +194,9 @@ def on_error(self, file: str, info: ErrorInfo) -> bool:
194194
Return True to filter out the error, preventing it from being seen by other
195195
ErrorWatcher further down the stack and from being recorded by Errors
196196
"""
197+
if info.code == codes.DEPRECATED:
198+
return False
199+
197200
self._has_new_errors = True
198201
if isinstance(self._filter, bool):
199202
should_filter = self._filter

mypy/main.py

+7
Original file line numberDiff line numberDiff line change
@@ -799,6 +799,13 @@ def add_invertible_flag(
799799
help="Warn about statements or expressions inferred to be unreachable",
800800
group=lint_group,
801801
)
802+
add_invertible_flag(
803+
"--report-deprecated-as-error",
804+
default=False,
805+
strict_flag=False,
806+
help="Report importing or using deprecated features as errors instead of notes",
807+
group=lint_group,
808+
)
802809

803810
# Note: this group is intentionally added here even though we don't add
804811
# --strict to this group near the end.

0 commit comments

Comments
 (0)