MyPy & PEP 702 (@deprecated)

💡
2024/11/05 EDIT : Quite ironically, this blog post is deprecated. PEP 702 is now supported since MyPy 1.12, so this whole work is now irrelevant. I keep it for posterity.

I tend to use Python a lot, both in my job and personal projects. It's a good language, but it still lacks some useful features. However, it seems that Python is evolving in a good direction. The recent PEP 695 signals a promising step forward in my opinion (despite it won't probably be supported by MyPy any time soon). Alongside this progression, modern code checkers like linters (such as Ruff), formatters (like Black), and type checkers (including MyPy) have become indispensable allies to improve your codebase (and also save a tremendous amount of time by spotting mistakes before running any code).

For a long time, I've been looking for something that would help me to update deprecated code. This is very common for a lot of languages (like Java or Rust). Some solutions already exist for Python, like libraries such as deprecated or deprecation but they are not really suitable for what I want to achieve : they will only trigger a warning at runtime. Ideally, I would like to identify deprecated functions or classes usage when code is written. While linting tools like Ruff or PyLint provide some support, they rely on static rules that necessitate manual updates and only extend to explicitly declared libraries.

Back in the past, I've already build a custom checker for PyLint for this very purpose. However, since then, I've moved to Ruff which is way faster. Unfortunately, such approach cannot be replicated to Ruff as far as I'm aware of. Below is the snippet that used to serve me to identify deprecated code (inspired by this), but I wouldn't recommend using it anymore.

import dataclasses as dc
from typing import TYPE_CHECKING
from typing import Dict, Optional
from astroid.nodes import Call, Name
from astroid.bases import Instance
from astroid import Uninferable
from pylint.checkers import BaseChecker
from pylint.checkers.utils import safe_infer
from pylint.interfaces import IAstroidChecker

if TYPE_CHECKING:
    from pylint.lint import PyLinter


@dc.dataclass
class _Deprecation:
    reason: Optional[str] = None
    version: Optional[str] = None
    type_: Optional[str] = None


class DeprecatedChecker(BaseChecker):
    __implements__ = IAstroidChecker

    name = "no-deprecated"
    priority = -1
    msgs = {
        "W0001": (
            "%s is deprecated%s; reason : %s",
            "no-deprecated",
            "Functions/Classes that have been marked via annotations "
            "as deprecated should not be used."
        )
    }

    _QNAME = "deprecated.classic.deprecated"

    def __init__(self, linter: Optional["PyLinter"] = None):
        super().__init__(linter)

        self._deprecations: Dict[str, _Deprecation] = {}

    def visit_name(self, node):
        # Not sure mandatory
        if not isinstance(node, Name):
            return

        called = safe_infer(node)

        # Avoid flagging on instanciated objects, only raising a warning
        # when instanciating/using the function/class
        if (
            called is None
            or isinstance(called, Instance)
            or not hasattr(called, "decorators")
        ):
            return

        # Check the decorators
        decorators = called.decorators.nodes if called.decorators else []

        # I think this is somehow not optimal and could be improved
        for n in decorators:
            c = None
            reason = None
            version = None

            # In case we have a call, we need to extract the thing being called
            if isinstance(n, Call):
                c = n
                n = n.func

                if c.args is not None and len(c.args) == 1:
                    reason = f"\"{c.args[0].value}\""

                # So we can extract the useful decorator data from the args or kwargs
                if c.keywords is not None:
                    for kw in c.keywords:
                        if kw.arg == "version":
                            version = f" since version \"{kw.value.value}\""
                        elif kw.arg == "reason":
                            reason = f"\"{kw.value.value}\""

            # Then, we need to check if we find the proper decorator
            for i in n.infer():
                if i is not None and i != Uninferable and i.qname() == self._QNAME:
                    self.add_message(
                        "no-deprecated",
                        node = node,
                        args = (
                            called.name,
                            version or "",
                            reason or "not specified"
                        )
                    )

                    return


def register(linter: "PyLinter"):
    linter.register_checker(DeprecatedChecker(linter))

plugin.py

With the upcoming PEP 702 (which is already backported to previous versions of Python with the typing-extensions package), things are probably going to get more serious. As it's now part of the standard library, we can expect modern type checkers to support it. As far as I know, it's currently only supported by Pyright (and thus Pylance). I've seen some related code in the MyPy source code, but it doesn't seem to be implemented yet (and worst case, it is still a fun exercise to dive a bit into the MyPy internals).

The solution I went for is to write a custom MyPy plugin to address this issue. If you want to try it for yourself, I've bundled it in a library. The source code is available on GitHub. You can install it directly from PyPI.

pip install mypypp

Don't forget to configure MyPy to use it. For example, within a pyproject.toml file.

[tool.mypy]
plugins = ["mypypp.deprecated"]
💡
Because of the PEP 702, it only supports the native deprecated decorator. Other libraries such as deprecated or deprecation are not supported. It won't be too difficult to implement though.

I've been testing it with Python 3.12 on the following files, and it appears to work like a charm.

from __future__ import annotations

from typing import final

from typing_extensions import deprecated


@deprecated("This function shouldn't be used.")
def function() -> None: ...


@deprecated("This class shouldn't be used.")
@final
class Class: ...


@final
class Test:
    @deprecated("This method shouldn't be used.")
    def instance_method(self) -> None: ...

    @deprecated("This method shouldn't be used.")
    @classmethod
    def class_method(cls) -> None: ...

    @deprecated("This method shouldn't be used.")
    @staticmethod
    def static_method() -> None: ...


if __name__ == "__main__":
    function()

    _ = Class()

    Test().instance_method()
    Test().class_method()
    Test().static_method()

    Test.class_method()
    Test.static_method()

definitions.py

from __future__ import annotations

from definitions import Class, Test, function

if __name__ == "__main__":
    function()

    _ = Class()

    Test().instance_method()
    Test().class_method()
    Test().static_method()

    Test.class_method()
    Test.static_method()

external.py

When running MyPy, I correctly get a deprecation error on each deprecated code usage. It also directly integrates in your editor (such as VSCode) if it's configured properly. Pretty neat.

mypy .
definitions.py:33: error: The function "function" is deprecated : This function shouldn't be used.  [deprecated]
definitions.py:35: error: The class "Class" is deprecated : This class shouldn't be used.  [deprecated]
definitions.py:37: error: The method "Test.instance_method" is deprecated : This method shouldn't be used.  [deprecated]
definitions.py:38: error: The method "Test.class_method" is deprecated : This method shouldn't be used.  [deprecated]
definitions.py:39: error: The method "Test.static_method" is deprecated : This method shouldn't be used.  [deprecated]
definitions.py:41: error: The method "Test.class_method" is deprecated : This method shouldn't be used.  [deprecated]
definitions.py:42: error: The method "Test.static_method" is deprecated : This method shouldn't be used.  [deprecated]
external.py:7: error: The function "function" is deprecated : This function shouldn't be used.  [deprecated]
external.py:9: error: The class "Class" is deprecated : This class shouldn't be used.  [deprecated]
external.py:11: error: The method "Test.instance_method" is deprecated : This method shouldn't be used.  [deprecated]
external.py:12: error: The method "Test.class_method" is deprecated : This method shouldn't be used.  [deprecated]
external.py:13: error: The method "Test.static_method" is deprecated : This method shouldn't be used.  [deprecated]
external.py:15: error: The method "Test.class_method" is deprecated : This method shouldn't be used.  [deprecated]
external.py:16: error: The method "Test.static_method" is deprecated : This method shouldn't be used.  [deprecated]
Found 14 errors in 2 files (checked 2 source files)

I think it's a good starting point. I hope this will soon be integrated into MyPy. I'm pretty sure that this plugin can be improved as I don't really have a deep knowledge about all the MyPy internals. At the moment, it is unable to flag imports of deprecated class or function (which Pylance manages to do), so there's definitely room for improvement.