Skip to content

Commit

Permalink
Add basic support for PEP 702 (@deprecated). (#17476)
Browse files Browse the repository at this point in the history
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 <levkivskyi@gmail.com>
  • Loading branch information
3 people authored Oct 6, 2024
1 parent 4da779b commit 94c49a8
Show file tree
Hide file tree
Showing 15 changed files with 860 additions and 17 deletions.
6 changes: 6 additions & 0 deletions docs/source/command_line.rst
Original file line number Diff line number Diff line change
Expand Up @@ -537,6 +537,12 @@ potentially problematic or redundant in some way.

This limitation will be removed in future releases of mypy.

.. option:: --report-deprecated-as-error

By default, mypy emits notes if your code imports or uses deprecated
features. This flag converts such notes to errors, causing mypy to
eventually finish with a non-zero exit code. Features are considered
deprecated when decorated with ``warnings.deprecated``.

.. _miscellaneous-strictness-flags:

Expand Down
38 changes: 38 additions & 0 deletions docs/source/error_code_list2.rst
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,44 @@ incorrect control flow or conditional checks that are accidentally always true o
# Error: Statement is unreachable [unreachable]
print('unreachable')
.. _code-deprecated:

Check that imported or used feature is deprecated [deprecated]
--------------------------------------------------------------

By default, mypy generates a note if your code imports a deprecated feature explicitly with a
``from mod import depr`` statement or uses a deprecated feature imported otherwise or defined
locally. Features are considered deprecated when decorated with ``warnings.deprecated``, as
specified in `PEP 702 <https://peps.python.org/pep-0702>`_. You can silence single notes via
``# type: ignore[deprecated]`` or turn off this check completely via ``--disable-error-code=deprecated``.
Use the :option:`--report-deprecated-as-error <mypy --report-deprecated-as-error>` option for
more strictness, which turns all such notes into errors.

.. note::

The ``warnings`` module provides the ``@deprecated`` decorator since Python 3.13.
To use it with older Python versions, import it from ``typing_extensions`` instead.

Examples:

.. code-block:: python
# mypy: report-deprecated-as-error
# Error: abc.abstractproperty is deprecated: Deprecated, use 'property' with 'abstractmethod' instead
from abc import abstractproperty
from typing_extensions import deprecated
@deprecated("use new_function")
def old_function() -> None:
print("I am old")
# Error: __main__.old_function is deprecated: use new_function
old_function()
old_function() # type: ignore[deprecated]
.. _code-redundant-expr:

Check that expression is redundant [redundant-expr]
Expand Down
46 changes: 46 additions & 0 deletions mypy/checker.py
Original file line number Diff line number Diff line change
Expand Up @@ -2838,6 +2838,9 @@ def check_metaclass_compatibility(self, typ: TypeInfo) -> None:
)

def visit_import_from(self, node: ImportFrom) -> None:
for name, _ in node.names:
if (sym := self.globals.get(name)) is not None:
self.warn_deprecated(sym.node, node)
self.check_import(node)

def visit_import_all(self, node: ImportAll) -> None:
Expand Down Expand Up @@ -2926,6 +2929,16 @@ def visit_assignment_stmt(self, s: AssignmentStmt) -> None:
Handle all kinds of assignment statements (simple, indexed, multiple).
"""

if isinstance(s.rvalue, TempNode) and s.rvalue.no_rhs:
for lvalue in s.lvalues:
if (
isinstance(lvalue, NameExpr)
and isinstance(var := lvalue.node, Var)
and isinstance(instance := get_proper_type(var.type), Instance)
):
self.check_deprecated(instance.type, s)

# Avoid type checking type aliases in stubs to avoid false
# positives about modern type syntax available in stubs such
# as X | Y.
Expand Down Expand Up @@ -4671,6 +4684,16 @@ def visit_operator_assignment_stmt(self, s: OperatorAssignmentStmt) -> None:
if inplace:
# There is __ifoo__, treat as x = x.__ifoo__(y)
rvalue_type, method_type = self.expr_checker.check_op(method, lvalue_type, s.rvalue, s)
if isinstance(inst := get_proper_type(lvalue_type), Instance) and isinstance(
defn := inst.type.get_method(method), OverloadedFuncDef
):
for item in defn.items:
if (
isinstance(item, Decorator)
and isinstance(typ := item.func.type, CallableType)
and (bind_self(typ) == method_type)
):
self.warn_deprecated(item.func, s)
if not is_subtype(rvalue_type, lvalue_type):
self.msg.incompatible_operator_assignment(s.op, s)
else:
Expand Down Expand Up @@ -7535,6 +7558,29 @@ def has_valid_attribute(self, typ: Type, name: str) -> bool:
def get_expression_type(self, node: Expression, type_context: Type | None = None) -> Type:
return self.expr_checker.accept(node, type_context=type_context)

def check_deprecated(self, node: SymbolNode | None, context: Context) -> None:
"""Warn if deprecated and not directly imported with a `from` statement."""
if isinstance(node, Decorator):
node = node.func
if isinstance(node, (FuncDef, OverloadedFuncDef, TypeInfo)) and (
node.deprecated is not None
):
for imp in self.tree.imports:
if isinstance(imp, ImportFrom) and any(node.name == n[0] for n in imp.names):
break
else:
self.warn_deprecated(node, context)

def warn_deprecated(self, node: SymbolNode | None, context: Context) -> None:
"""Warn if deprecated."""
if isinstance(node, Decorator):
node = node.func
if isinstance(node, (FuncDef, OverloadedFuncDef, TypeInfo)) and (
(deprecated := node.deprecated) is not None
):
warn = self.msg.fail if self.options.report_deprecated_as_error else self.msg.note
warn(deprecated, context, code=codes.DEPRECATED)


class CollectArgTypeVarTypes(TypeTraverserVisitor):
"""Collects the non-nested argument types in a set."""
Expand Down
39 changes: 32 additions & 7 deletions mypy/checkexpr.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@
validate_instance,
)
from mypy.typeops import (
bind_self,
callable_type,
custom_special_method,
erase_to_union_or_bound,
Expand Down Expand Up @@ -354,7 +355,9 @@ def visit_name_expr(self, e: NameExpr) -> Type:
"""
self.chk.module_refs.update(extract_refexpr_names(e))
result = self.analyze_ref_expr(e)
return self.narrow_type_from_binder(e, result)
narrowed = self.narrow_type_from_binder(e, result)
self.chk.check_deprecated(e.node, e)
return narrowed

def analyze_ref_expr(self, e: RefExpr, lvalue: bool = False) -> Type:
result: Type | None = None
Expand Down Expand Up @@ -1479,6 +1482,10 @@ def check_call_expr_with_callee_type(
object_type=object_type,
)
proper_callee = get_proper_type(callee_type)
if isinstance(e.callee, NameExpr) and isinstance(e.callee.node, OverloadedFuncDef):
for item in e.callee.node.items:
if isinstance(item, Decorator) and (item.func.type == callee_type):
self.chk.check_deprecated(item.func, e)
if isinstance(e.callee, RefExpr) and isinstance(proper_callee, CallableType):
# Cache it for find_isinstance_check()
if proper_callee.type_guard is not None:
Expand Down Expand Up @@ -3267,7 +3274,9 @@ def visit_member_expr(self, e: MemberExpr, is_lvalue: bool = False) -> Type:
"""Visit member expression (of form e.id)."""
self.chk.module_refs.update(extract_refexpr_names(e))
result = self.analyze_ordinary_member_access(e, is_lvalue)
return self.narrow_type_from_binder(e, result)
narrowed = self.narrow_type_from_binder(e, result)
self.chk.warn_deprecated(e.node, e)
return narrowed

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

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

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

variants_raw = [(left_op, left_type, right_expr), (right_op, right_type, left_expr)]
variants_raw = [
(op_name, left_op, left_type, right_expr),
(rev_op_name, right_op, right_type, left_expr),
]

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

variants = [(op, obj, arg) for (op, obj, arg) in variants_raw if op is not None]
variants = [(na, op, obj, arg) for (na, op, obj, arg) in variants_raw if op is not None]

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

errors = []
results = []
for method, obj, arg in variants:
for name, method, obj, arg in variants:
with self.msg.filter_errors(save_filtered_errors=True) as local_errors:
result = self.check_method_call(op_name, obj, method, [arg], [ARG_POS], context)
if local_errors.has_new_errors():
errors.append(local_errors.filtered_errors())
results.append(result)
else:
if isinstance(obj, Instance) and isinstance(
defn := obj.type.get_method(name), OverloadedFuncDef
):
for item in defn.items:
if (
isinstance(item, Decorator)
and isinstance(typ := item.func.type, CallableType)
and bind_self(typ) == result[1]
):
self.chk.check_deprecated(item.func, context)
return result

# We finish invoking above operators and no early return happens. Therefore,
Expand Down
13 changes: 10 additions & 3 deletions mypy/checkmember.py
Original file line number Diff line number Diff line change
Expand Up @@ -316,9 +316,12 @@ def analyze_instance_member_access(

if method.is_property:
assert isinstance(method, OverloadedFuncDef)
first_item = method.items[0]
assert isinstance(first_item, Decorator)
return analyze_var(name, first_item.var, typ, info, mx)
getter = method.items[0]
assert isinstance(getter, Decorator)
if mx.is_lvalue and (len(items := method.items) > 1):
mx.chk.warn_deprecated(items[1], mx.context)
return analyze_var(name, getter.var, typ, info, mx)

if mx.is_lvalue:
mx.msg.cant_assign_to_method(mx.context)
if not isinstance(method, OverloadedFuncDef):
Expand Down Expand Up @@ -493,6 +496,8 @@ def analyze_member_var_access(
# It was not a method. Try looking up a variable.
v = lookup_member_var_or_accessor(info, name, mx.is_lvalue)

mx.chk.warn_deprecated(v, mx.context)

vv = v
if isinstance(vv, Decorator):
# The associated Var node of a decorator contains the type.
Expand Down Expand Up @@ -1010,6 +1015,8 @@ def analyze_class_attribute_access(
# on the class object itself rather than the instance.
return None

mx.chk.warn_deprecated(node.node, mx.context)

is_decorated = isinstance(node.node, Decorator)
is_method = is_decorated or isinstance(node.node, FuncBase)
if mx.is_lvalue:
Expand Down
6 changes: 6 additions & 0 deletions mypy/errorcodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -304,5 +304,11 @@ def __hash__(self) -> int:
"General",
)

DEPRECATED: Final = ErrorCode(
"deprecated",
"Warn when importing or using deprecated (overloaded) functions, methods or classes",
"General",
)

# This copy will not include any error codes defined later in the plugins.
mypy_error_codes = error_codes.copy()
5 changes: 4 additions & 1 deletion mypy/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@

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

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

self._has_new_errors = True
if isinstance(self._filter, bool):
should_filter = self._filter
Expand Down
7 changes: 7 additions & 0 deletions mypy/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -799,6 +799,13 @@ def add_invertible_flag(
help="Warn about statements or expressions inferred to be unreachable",
group=lint_group,
)
add_invertible_flag(
"--report-deprecated-as-error",
default=False,
strict_flag=False,
help="Report importing or using deprecated features as errors instead of notes",
group=lint_group,
)

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

0 comments on commit 94c49a8

Please sign in to comment.