Repository: quora/pyanalyze
Pull request #578 (2023-01-01 03:25:37)

PEP 702 (@deprecated) support
- Add the decorator
- Support for deprecated overloads
- Add support for runtime and some stub deprecations
- changelog



Patch:
diff --git a/docs/changelog.md b/docs/changelog.md
index 1bc8370..1f0023f 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -2,6 +2,7 @@
 
 ## Unreleased
 
+- Support for PEP 702 (`@typing.deprecated`) (#578)
 - Simplify import handling; stop trying to import modules at type checking time (#566)
 - Suggest using keyword arguments on calls with too many positional arguments (#572)
 - Emit an error for unknown `TypedDict` keys (#567)
diff --git a/pyanalyze/annotations.py b/pyanalyze/annotations.py
index 3d00970..9117567 100644
--- a/pyanalyze/annotations.py
+++ b/pyanalyze/annotations.py
@@ -63,6 +63,7 @@ from .extensions import (
     NoReturnGuard,
     ParameterTypeGuard,
     TypeGuard,
+    deprecated,
 )
 from .find_unused import used
 from .functions import FunctionDefNode
@@ -949,6 +950,12 @@ class Pep655Value(Value):
     value: Value
 
 
+@dataclass
+class DecoratorValue(Value):
+    decorator: object
+    args: Tuple[Value, ...]
+
+
 class _Visitor(ast.NodeVisitor):
     def __init__(self, ctx: Context) -> None:
         self.ctx = ctx
@@ -1100,6 +1107,14 @@ class _Visitor(ast.NodeVisitor):
                 return None
             tv = ParamSpec(name_val.val)
             return TypeVarValue(tv, is_paramspec=True)
+        elif is_typing_name(func.val, "deprecated") or func.val is deprecated:
+            if node.keywords:
+                self.ctx.show_error(
+                    "deprecated() does not accept keyword arguments", node=node
+                )
+                return None
+            arg_values = tuple(self.visit(arg) for arg in node.args)
+            return DecoratorValue(deprecated, arg_values)
         elif isinstance(func.val, type):
             if func.val is object:
                 return AnyValue(AnySource.inference)
diff --git a/pyanalyze/arg_spec.py b/pyanalyze/arg_spec.py
index aa9c2c0..b0fab9c 100644
--- a/pyanalyze/arg_spec.py
+++ b/pyanalyze/arg_spec.py
@@ -662,7 +662,7 @@ class ArgSpecCache:
                             "%kwargs", ParameterKind.VAR_KEYWORD, annotation=annotation
                         )
                     )
-                return Signature.make(params, td_type)
+                return Signature.make(params, td_type, callable=obj)
 
         if is_newtype(obj):
             assert hasattr(obj, "__supertype__")
diff --git a/pyanalyze/error_code.py b/pyanalyze/error_code.py
index a75302d..337af42 100644
--- a/pyanalyze/error_code.py
+++ b/pyanalyze/error_code.py
@@ -101,6 +101,7 @@ class ErrorCode(enum.Enum):
     incompatible_yield = 81
     invalid_import = 82
     too_many_positional_args = 83
+    deprecated = 84
 
 
 # Allow testing unannotated functions without too much fuss
@@ -222,6 +223,7 @@ ERROR_DESCRIPTION = {
     ErrorCode.invalid_annotated_assignment: "Invalid annotated assignment",
     ErrorCode.unused_assignment: "Assigned value is never used",
     ErrorCode.incompatible_yield: "Incompatible yield type",
+    ErrorCode.deprecated: "Use of deprecated feature",
     ErrorCode.invalid_import: "Invalid import",
     ErrorCode.too_many_positional_args: (
         "Call with many positional arguments should use keyword arguments"
diff --git a/pyanalyze/extensions.py b/pyanalyze/extensions.py
index 4cbe526..cb03e09 100644
--- a/pyanalyze/extensions.py
+++ b/pyanalyze/extensions.py
@@ -383,7 +383,7 @@ def reveal_type(value: _T) -> _T:
 
     This is automatically exposed as a global during type checking, so in
     code that is not run at import, `reveal_type()` can be used without
-    being impoorted.
+    being imported.
 
     Example::
 
@@ -446,6 +446,40 @@ def assert_error() -> Iterator[None]:
     yield
 
 
+def deprecated(__msg: str) -> typing.Callable[[_T], _T]:
+    """Indicate that a class, function or overload is deprecated.
+
+    Usage::
+
+        @deprecated("Use B instead")
+        class A:
+            pass
+        @deprecated("Use g instead")
+        def f():
+            pass
+        @deprecated("int support is deprecated")
+        @overload
+        def g(x: int) -> int: ...
+        @overload
+        def g(x: str) -> int: ...
+
+    When this decorator is applied to an object, the type checker
+    will generate a diagnostic on usage of the deprecated object.
+
+    No runtime warning is issued. The decorator sets the ``__deprecated__``
+    attribute on the decorated object to the deprecation message
+    passed to the decorator.
+
+    See PEP 702 for details.
+    """
+
+    def decorator(__arg: _T) -> _T:
+        __arg.__deprecated__ = __msg
+        return __arg
+
+    return decorator
+
+
 _overloads: Dict[str, List[Callable[..., Any]]] = defaultdict(list)
 _type_evaluations: Dict[str, List[Callable[..., Any]]] = defaultdict(list)
 
@@ -469,7 +503,7 @@ else:
         """A version of `typing.overload` that is inspectable at runtime.
 
         If this decorator is used for a function `some_module.some_function`, calling
-        :func:`pyanalyze.extensiions.get_overloads("some_module.some_function")` will
+        :func:`pyanalyze.extensions.get_overloads("some_module.some_function")` will
         return all the runtime overloads.
 
         """
diff --git a/pyanalyze/name_check_visitor.py b/pyanalyze/name_check_visitor.py
index fb826bd..d5f178e 100644
--- a/pyanalyze/name_check_visitor.py
+++ b/pyanalyze/name_check_visitor.py
@@ -103,6 +103,7 @@ from .safe import (
     is_hashable,
     safe_getattr,
     safe_hasattr,
+    safe_isinstance,
     safe_issubclass,
 )
 from .shared_options import EnforceNoUnused, ImportPaths, Paths
@@ -153,12 +154,15 @@ from .suggested_type import (
 from .type_object import get_mro, TypeObject
 from .value import (
     AlwaysPresentExtension,
+    DeprecatedExtension,
+    SkipDeprecatedExtension,
     annotate_value,
     AnnotatedValue,
     AnySource,
     AnyValue,
     AssertErrorExtension,
     AsyncTaskIncompleteValue,
+    CallableValue,
     CanAssign,
     CanAssignError,
     check_hashability,
@@ -2210,6 +2214,45 @@ class NameCheckVisitor(node_visitor.ReplacingNodeVisitor):
                 defining_scope = self.scopes.module_scope()
             self._set_name_in_scope(name, node, ReferencingValue(defining_scope, name))
 
+    def check_deprecation(self, node: ast.AST, value: Value) -> bool:
+        if isinstance(value, AnnotatedValue):
+            if value.has_metadata_of_type(SkipDeprecatedExtension):
+                return False
+            for metadata in value.get_metadata_of_type(DeprecatedExtension):
+                self._show_error_if_checking(
+                    node,
+                    f"{value} is deprecated: {metadata.deprecation_message}",
+                    error_code=ErrorCode.deprecated,
+                )
+                return True
+            return self.check_deprecation(node, value.value)
+        if isinstance(value, UnboundMethodValue):
+            method = value.get_method()
+            if method is None:
+                return False
+            return self.check_deprecation(node, KnownValue(method))
+        if isinstance(value, CallableValue):
+            if not isinstance(value.signature, Signature):
+                return False
+            if value.signature.deprecated is None:
+                return False
+            deprecated = value.signature.deprecated
+        elif isinstance(value, KnownValue):
+            deprecated = safe_getattr(value.val, "__deprecated__", None)
+            if deprecated is None:
+                return False
+        else:
+            return False
+        if not safe_isinstance(deprecated, str):
+            # happens with Mock objects
+            return False
+        self._show_error_if_checking(
+            node,
+            f"{value} is deprecated: {deprecated}",
+            error_code=ErrorCode.deprecated,
+        )
+        return True
+
     # Imports
 
     def visit_Import(self, node: ast.Import) -> None:
@@ -2225,11 +2268,23 @@ class NameCheckVisitor(node_visitor.ReplacingNodeVisitor):
                 alias.name if alias.asname is not None else alias.name.split(".")[0]
             )
             mod = self._get_module(varname, node)
-            self._set_alias_in_scope(alias, mod)
+            self._set_alias_in_scope(alias, mod, node=node)
 
     def _set_alias_in_scope(
-        self, alias: ast.alias, value: Value, force_public: bool = False
+        self,
+        alias: ast.alias,
+        value: Value,
+        *,
+        force_public: bool = False,
+        node: ast.AST,
     ) -> None:
+        # aliases only have a lineno attached in 3.10+
+        if sys.version_info >= (3, 10):
+            error_node = alias
+        else:
+            error_node = node
+        if self.check_deprecation(error_node, value):
+            value = annotate_value(value, [SkipDeprecatedExtension()])
         if alias.asname is not None:
             self._set_name_in_scope(
                 alias.asname,
@@ -2317,7 +2372,7 @@ class NameCheckVisitor(node_visitor.ReplacingNodeVisitor):
                             ErrorCode.import_failed,
                         )
                         val = AnyValue(AnySource.error)
-                    self._set_alias_in_scope(alias, val)
+                    self._set_alias_in_scope(alias, val, node=node)
                 return
 
         is_init = self.filename.endswith("/__init__.py")
@@ -2353,7 +2408,7 @@ class NameCheckVisitor(node_visitor.ReplacingNodeVisitor):
                 continue
             val = self._get_import_from_value(source_module, alias.name, node)
             self._set_alias_in_scope(
-                alias, val, force_public=is_init and node.level == 1
+                alias, val, force_public=is_init and node.level == 1, node=node
             )
 
     def _get_import_from_value(
@@ -4232,6 +4287,7 @@ class NameCheckVisitor(node_visitor.ReplacingNodeVisitor):
             varname_value = self.checker.maybe_get_variable_name_value(node.id)
             if varname_value is not None and self._should_use_varname_value(value):
                 value = varname_value
+            self.check_deprecation(node, value)
             return Composite(value, VarnameWithOrigin(node.id, origin), node)
         elif self._is_write_ctx(node.ctx):
             if self._name_node_to_statement is not None:
@@ -4556,6 +4612,7 @@ class NameCheckVisitor(node_visitor.ReplacingNodeVisitor):
                 use_fallback=True,
                 ignore_none=self.options.get_value_for(IgnoreNoneAttributes),
             )
+            self.check_deprecation(node, value)
             if self._should_use_varname_value(value):
                 varname_value = self.checker.maybe_get_variable_name_value(node.attr)
                 if varname_value is not None:
diff --git a/pyanalyze/signature.py b/pyanalyze/signature.py
index 8f232c6..562bf4d 100644
--- a/pyanalyze/signature.py
+++ b/pyanalyze/signature.py
@@ -37,6 +37,7 @@ from qcore.helpers import safe_str
 from typing_extensions import assert_never, Literal, Protocol, Self
 
 from .error_code import ErrorCode
+from .safe import safe_getattr
 from .node_visitor import Replacement
 from .options import IntegerOption
 from .stacked_scopes import (
@@ -261,6 +262,8 @@ class CallReturn(NamedTuple):
 
     return_value: Value
     """The return value of the function."""
+    sig: "Signature"
+    """Signature that was used for this call."""
     is_error: bool = False
     """Whether there was an error in this call. Used only for overload resolutioon."""
     used_any_for_match: bool = False
@@ -539,6 +542,8 @@ class Signature:
     """Whether type checking can call the actual function to retrieve a precise return value."""
     evaluator: Optional[Evaluator] = None
     """Type evaluator for this function."""
+    deprecated: Optional[str] = None
+    """Deprecation message for this callable."""
     typevars_of_params: Dict[str, List[TypeVarLike]] = field(
         init=False, default_factory=dict, repr=False, compare=False, hash=False
     )
@@ -1111,7 +1116,7 @@ class Signature:
         if self._return_key in self.typevars_of_params:
             typevar_values = {tv: AnyValue(source) for tv in self.all_typevars}
             return_value = return_value.substitute_typevars(typevar_values)
-        return CallReturn(return_value, is_error=True)
+        return CallReturn(return_value, is_error=True, sig=self)
 
     def check_call(
         self,
@@ -1330,6 +1335,7 @@ class Signature:
             is_error=had_error,
             used_any_for_match=used_any,
             remaining_arguments=new_args,
+            sig=self,
         )
 
     def _maybe_perform_call(
@@ -1774,6 +1780,7 @@ class Signature:
         is_asynq: bool = False,
         allow_call: bool = False,
         evaluator: Optional[Evaluator] = None,
+        deprecated: Optional[str] = None,
     ) -> "Signature":
         """Create a :class:`Signature` object.
 
@@ -1826,6 +1833,8 @@ class Signature:
             else:
                 param_dict[param.name] = param
                 i += 1
+        if deprecated is None and callable is not None:
+            deprecated = safe_getattr(callable, "__deprecated__", None)
         return cls(
             param_dict,
             return_value=return_annotation,
@@ -1835,6 +1844,7 @@ class Signature:
             is_asynq=is_asynq,
             allow_call=allow_call,
             evaluator=evaluator,
+            deprecated=deprecated,
         )
 
     def __str__(self) -> str:
@@ -2303,13 +2313,22 @@ class OverloadedSignature:
                 any_rets.append(ret)
             else:
                 # We got a clean match!
-                return self._unite_rets(any_rets, union_and_any_rets, union_rets, ret)
+                return self._unite_rets(
+                    any_rets,
+                    union_and_any_rets,
+                    union_rets,
+                    ret,
+                    visitor=visitor,
+                    node=node,
+                )
 
         if any_rets:
             # We don't do this if we have union_rets, because if we got here, we
             # didn't get any clean matches. Therefore, we must have some remaining
             # union members we haven't handled.
-            return self._unite_rets(any_rets, union_and_any_rets, union_rets)
+            return self._unite_rets(
+                any_rets, union_and_any_rets, union_rets, visitor=visitor, node=node
+            )
 
         # None of the signatures matched
         errors = list(itertools.chain.from_iterable(errors_per_overload))
@@ -2330,6 +2349,9 @@ class OverloadedSignature:
         union_and_any_rets: Sequence[CallReturn],
         union_rets: Sequence[CallReturn],
         clean_ret: Optional[CallReturn] = None,
+        *,
+        visitor: "NameCheckVisitor",
+        node: Optional[ast.AST],
     ) -> Value:
         if any_rets or union_and_any_rets:
             deduped = {ret.return_value for ret in any_rets}
@@ -2339,16 +2361,25 @@ class OverloadedSignature:
                 and not union_and_any_rets
                 and clean_ret is None
             ):
-                return any_rets[0].return_value
-            return AnyValue(AnySource.multiple_overload_matches)
+                rets = any_rets
+            else:
+                return AnyValue(AnySource.multiple_overload_matches)
         elif union_rets:
             if clean_ret is not None:
                 rets = [*union_rets, clean_ret]
             else:
                 rets = union_rets
-            return unite_values(*[r.return_value for r in rets])
-        assert clean_ret is not None
-        return clean_ret.return_value
+        else:
+            assert clean_ret is not None
+            rets = [clean_ret]
+        for ret in rets:
+            if ret.sig.deprecated is not None:
+                visitor.show_error(
+                    node,
+                    f"Use of deprecated overload {ret.sig}: {ret.sig.deprecated}",
+                    ErrorCode.deprecated,
+                )
+        return unite_values(*[r.return_value for r in rets])
 
     def _make_detail(
         self,
diff --git a/pyanalyze/stubs/pyanalyze-stubs/extensions.pyi b/pyanalyze/stubs/pyanalyze-stubs/extensions.pyi
index 9edba0c..3f7431a 100644
--- a/pyanalyze/stubs/pyanalyze-stubs/extensions.pyi
+++ b/pyanalyze/stubs/pyanalyze-stubs/extensions.pyi
@@ -14,4 +14,5 @@ def is_positional(arg: Any) -> bool: ...
 def is_keyword(arg: Any) -> bool: ...
 def is_of_type(arg: Any, type: Any, *, exclude_any: bool = ...) -> bool: ...
 def show_error(message: str, *, argument: Optional[Any] = ...) -> bool: ...
+def deprecated(__message: str) -> Callable[..., Any]: ...
 def __getattr__(self, __arg: str) -> Any: ...
diff --git a/pyanalyze/typeshed.py b/pyanalyze/typeshed.py
index ba18dba..6bd693a 100644
--- a/pyanalyze/typeshed.py
+++ b/pyanalyze/typeshed.py
@@ -39,13 +39,19 @@ from .analysis_lib import is_positional_only_arg_name
 from .annotations import (
     Context,
     make_type_var_value,
+    DecoratorValue,
     Pep655Value,
     SyntheticEvaluator,
     type_from_value,
     value_from_ast,
 )
 from .error_code import ErrorCode
-from .extensions import evaluated, overload, real_overload
+from .extensions import (
+    evaluated,
+    overload,
+    real_overload,
+    deprecated as deprecated_decorator,
+)
 from .node_visitor import Failure
 from .options import Options, PathSequenceOption
 from .safe import all_of_type, hasattr_static, is_typing_name, safe_isinstance
@@ -63,6 +69,9 @@ from .value import (
     AnyValue,
     CallableValue,
     CanAssignContext,
+    DeprecatedExtension,
+    Extension,
+    annotate_value,
     extract_typevars,
     GenericValue,
     KnownValue,
@@ -795,6 +804,7 @@ class TypeshedFinder:
         allow_call: bool = False,
     ) -> Optional[Signature]:
         is_classmethod = is_staticmethod = is_evaluated = False
+        deprecated = None
         for decorator_ast in node.decorator_list:
             decorator = self._parse_expr(decorator_ast, mod)
             if (
@@ -814,8 +824,14 @@ class TypeshedFinder:
             elif decorator == KnownValue(evaluated):
                 is_evaluated = True
                 continue
-            # might be @overload or something else we don't recognize
-            return None
+            elif (
+                isinstance(decorator, DecoratorValue)
+                and decorator.decorator is deprecated_decorator
+            ):
+                arg = decorator.args[0]
+                if isinstance(arg, KnownValue) and isinstance(arg.val, str):
+                    deprecated = arg.val
+            # something we don't recognize; ignore it
         if node.returns is None:
             return_value = AnyValue(AnySource.unannotated)
         else:
@@ -876,6 +892,7 @@ class TypeshedFinder:
             else return_value,
             allow_call=allow_call,
             evaluator=evaluator,
+            deprecated=deprecated,
         )
 
     def _parse_param_list(
@@ -982,10 +999,27 @@ class TypeshedFinder:
         ctx = _AnnotationContext(finder=self, module=module)
         return value_from_ast(info.ast.value, ctx=ctx)
 
+    def _extract_metadata(self, module: str, node: ast.ClassDef) -> Sequence[Extension]:
+        metadata = []
+        for decorator in node.decorator_list:
+            decorator_val = self._parse_expr(decorator, module)
+            if (
+                isinstance(decorator_val, DecoratorValue)
+                and decorator_val.decorator is deprecated_decorator
+            ):
+                arg = decorator_val.args[0]
+                if isinstance(arg, KnownValue) and isinstance(arg.val, str):
+                    metadata.append(DeprecatedExtension(arg.val))
+        return metadata
+
     def make_synthetic_type(self, module: str, info: typeshed_client.NameInfo) -> Value:
         fq_name = f"{module}.{info.name}"
         bases = self._get_bases_from_info(info, module, fq_name)
         typ = TypedValue(fq_name)
+        if isinstance(info.ast, ast.ClassDef):
+            metadata = self._extract_metadata(module, info.ast)
+        else:
+            metadata = []
         if bases is not None:
             if any(
                 (isinstance(base, KnownValue) and is_typing_name(base.val, "TypedDict"))
@@ -993,7 +1027,10 @@ class TypeshedFinder:
                 for base in bases
             ):
                 typ = self._make_typeddict(module, info, bases)
-        return SubclassValue(typ, exactly=True)
+        val = SubclassValue(typ, exactly=True)
+        if metadata:
+            return annotate_value(val, metadata)
+        return val
 
     def _make_typeddict(
         self, module: str, info: typeshed_client.NameInfo, bases: Sequence[Value]
diff --git a/pyanalyze/value.py b/pyanalyze/value.py
index 426e0a0..bb5d218 100644
--- a/pyanalyze/value.py
+++ b/pyanalyze/value.py
@@ -1925,6 +1925,18 @@ class AssertErrorExtension(Extension):
     """Used for the implementation of :func:`pyanalyze.extensions.assert_error`."""
 
 
+@dataclass(frozen=True)
+class SkipDeprecatedExtension(Extension):
+    """Indicates that use of this value should not trigger deprecation errors."""
+
+
+@dataclass(frozen=True)
+class DeprecatedExtension(Extension):
+    """Indicates that use of this value should trigger a deprecation error."""
+
+    deprecation_message: str
+
+
 @dataclass(frozen=True)
 class AnnotatedValue(Value):
     """Value representing a `PEP 593 <https://www.python.org/dev/peps/pep-0593/>`_ Annotated object.


Test patch:
diff --git a/pyanalyze/stubs/_pyanalyze_tests-stubs/deprecated.pyi b/pyanalyze/stubs/_pyanalyze_tests-stubs/deprecated.pyi
new file mode 100644
index 0000000..a289fd5
--- /dev/null
+++ b/pyanalyze/stubs/_pyanalyze_tests-stubs/deprecated.pyi
@@ -0,0 +1,17 @@
+from pyanalyze.extensions import deprecated
+from typing import overload
+
+@overload
+@deprecated("int support is deprecated")
+def deprecated_overload(x: int) -> int: ...
+@overload
+def deprecated_overload(x: str) -> str: ...
+@deprecated("no functioning capybaras")
+def deprecated_function(x: int) -> int: ...
+
+class Capybara:
+    @deprecated("no methodical capybaras")
+    def deprecated_method(self, x: int) -> int: ...
+
+@deprecated("no classy capybaras")
+class DeprecatedCapybara: ...
diff --git a/pyanalyze/test_attributes.py b/pyanalyze/test_attributes.py
index a1e5639..c44ff96 100644
--- a/pyanalyze/test_attributes.py
+++ b/pyanalyze/test_attributes.py
@@ -147,6 +147,8 @@ class TestAttributes(TestNameCheckVisitorBase):
         # Inspired by pyspark.sql.types.Row
         class Row(tuple):
             def __getattr__(self, attr):
+                if attr.startswith("__"):
+                    raise AttributeError(attr)
                 return attr.upper()
 
         def capybara():
diff --git a/pyanalyze/test_deprecated.py b/pyanalyze/test_deprecated.py
new file mode 100644
index 0000000..724aac2
--- /dev/null
+++ b/pyanalyze/test_deprecated.py
@@ -0,0 +1,91 @@
+# static analysis: ignore
+from .test_name_check_visitor import TestNameCheckVisitorBase
+from .test_node_visitor import assert_passes, skip_before
+
+
+class TestStub(TestNameCheckVisitorBase):
+    @assert_passes()
+    def test(self):
+        def capybara():
+            from _pyanalyze_tests.deprecated import deprecated_overload
+            from _pyanalyze_tests.deprecated import deprecated_function  # E: deprecated
+            from _pyanalyze_tests.deprecated import DeprecatedCapybara  # E: deprecated
+
+            deprecated_overload(1)  # E: deprecated
+            deprecated_overload("x")
+
+            deprecated_function(1)
+            print(deprecated_function)
+            DeprecatedCapybara()
+            print(DeprecatedCapybara)
+
+    @skip_before((3, 10))
+    @assert_passes()
+    def test_multiline_import(self):
+        def capybara():
+            from _pyanalyze_tests.deprecated import (
+                deprecated_overload,
+                deprecated_function,  # E: deprecated
+                DeprecatedCapybara,  # E: deprecated
+            )
+
+            return [deprecated_function, deprecated_overload, DeprecatedCapybara]
+
+
+class TestRuntime(TestNameCheckVisitorBase):
+    @assert_passes()
+    def test_overload(self):
+        from pyanalyze.extensions import deprecated, overload
+
+        @overload
+        @deprecated("int support is deprecated")
+        def deprecated_overload(x: int) -> int:
+            ...
+
+        @overload
+        def deprecated_overload(x: str) -> str:
+            ...
+
+        def deprecated_overload(x):
+            return x
+
+        def capybara():
+            deprecated_overload(1)  # E: deprecated
+            deprecated_overload("x")
+
+    @assert_passes()
+    def test_function(self):
+        from pyanalyze.extensions import deprecated
+
+        @deprecated("no functioning capybaras")
+        def deprecated_function(x: int) -> int:
+            return x
+
+        def capybara():
+            print(deprecated_function)  # E: deprecated
+            deprecated_function(1)  # E: deprecated
+
+    @assert_passes()
+    def test_method(self):
+        from pyanalyze.extensions import deprecated
+
+        class Cls:
+            @deprecated("no methodical capybaras")
+            def deprecated_method(self, x: int) -> int:
+                return x
+
+        def capybara():
+            Cls().deprecated_method(1)  # E: deprecated
+            print(Cls.deprecated_method)  # E: deprecated
+
+    @assert_passes()
+    def test_class(self):
+        from pyanalyze.extensions import deprecated
+
+        @deprecated("no classy capybaras")
+        class DeprecatedClass:
+            pass
+
+        def capybara():
+            print(DeprecatedClass)  # E: deprecated
+            return DeprecatedClass()  # E: deprecated