diff --git a/docs/guide/builtins.rst b/docs/guide/builtins.rst index 6f2b1484..873ee92a 100644 --- a/docs/guide/builtins.rst +++ b/docs/guide/builtins.rst @@ -40,6 +40,7 @@ Built-in Rules - :class:`UseAssertIn` - :class:`UseAssertIsNotNone` - :class:`UseAsyncSleepInAsyncDef` +- :class:`UseBuiltinTypes` - :class:`UseClsInClassmethod` - :class:`UseFstring` - :class:`UseTypesFromTyping` @@ -1007,6 +1008,52 @@ Built-in Rules from time import sleep async def func(): sleep(1) +.. class:: UseBuiltinTypes + + Enforces the use of builtin types instead of their aliases from the ``typing`` + module in Python 3.10 and later. + + .. attribute:: AUTOFIX + :type: Yes + + .. attribute:: PYTHON_VERSION + :type: '>= 3.10' + + .. attribute:: VALID + + .. code:: python + + def fuction(list: list[str]) -> None: + pass + .. code:: python + + def function() -> None: + thing: dict[str, str] = {} + + .. attribute:: INVALID + + .. code:: python + + from typing import List + def whatever(list: List[str]) -> None: + pass + + # suggested fix + from typing import List + def whatever(list: list[str]) -> None: + pass + + .. code:: python + + from typing import Dict + def func() -> None: + thing: Dict[str, str] = {} + + # suggested fix + from typing import Dict + def func() -> None: + thing: dict[str, str] = {} + .. class:: UseClsInClassmethod Enforces using ``cls`` as the first argument in a ``@classmethod``. diff --git a/pyproject.toml b/pyproject.toml index 5eb0080c..f3689299 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -78,7 +78,7 @@ target-version = ["py38"] [tool.fixit] enable = ["fixit.rules"] -python-version = "3.10" +python-version = "3.8" formatter = "ufmt" [[tool.fixit.overrides]] diff --git a/src/fixit/rules/use_builtin_types.py b/src/fixit/rules/use_builtin_types.py new file mode 100644 index 00000000..223ea0e2 --- /dev/null +++ b/src/fixit/rules/use_builtin_types.py @@ -0,0 +1,124 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. + +from typing import Set + +import libcst +from libcst.metadata import QualifiedNameProvider, ScopeProvider + +from fixit import Invalid, LintRule, Valid + + +REPLACE_TYPING_TYPE_ANNOTATION: str = ( + "You are using typing.{typing_type} as a type annotation " + + "but you should use {correct_type} instead." +) + +TYPING_TYPE_TO_REPLACE: Set[str] = {"Dict", "List", "Set", "Tuple", "Type"} +QUALIFIED_TYPES_TO_REPLACE: Set[str] = {f"typing.{s}" for s in TYPING_TYPE_TO_REPLACE} + + +class UseBuiltinTypes(LintRule): + """ + Enforces the use of builtin types instead of their aliases from the ``typing`` + module in Python 3.10 and later. + """ + + PYTHON_VERSION = ">= 3.10" + + METADATA_DEPENDENCIES = ( + QualifiedNameProvider, + ScopeProvider, + ) + VALID = [ + Valid( + """ + def fuction(list: list[str]) -> None: + pass + """ + ), + Valid( + """ + def function() -> None: + thing: dict[str, str] = {} + """ + ), + Valid( + """ + def function() -> None: + thing: tuple[str] + """ + ), + ] + INVALID = [ + Invalid( + """ + from typing import List + def whatever(list: List[str]) -> None: + pass + """, + expected_replacement=""" + from typing import List + def whatever(list: list[str]) -> None: + pass + """, + ), + Invalid( + """ + from typing import Dict + def func() -> None: + thing: Dict[str, str] = {} + """, + expected_replacement=""" + from typing import Dict + def func() -> None: + thing: dict[str, str] = {} + """, + ), + Invalid( + """ + from typing import Tuple + def func() -> None: + thing: Tuple[str] + """, + expected_replacement=""" + from typing import Tuple + def func() -> None: + thing: tuple[str] + """, + ), + ] + + def __init__(self) -> None: + super().__init__() + self.annotation_counter: int = 0 + + def visit_Annotation(self, node: libcst.Annotation) -> None: + self.annotation_counter += 1 + + def leave_Annotation(self, original_node: libcst.Annotation) -> None: + self.annotation_counter -= 1 + + def visit_Name(self, node: libcst.Name) -> None: + qualified_names = self.get_metadata(QualifiedNameProvider, node, set()) + + is_typing_type = node.value in TYPING_TYPE_TO_REPLACE and all( + qualified_name.name in QUALIFIED_TYPES_TO_REPLACE + for qualified_name in qualified_names + ) + + if self.annotation_counter > 0 and is_typing_type: + correct_type = node.value.title().lower() + scope = self.get_metadata(ScopeProvider, node) + replacement = None + if scope is not None and correct_type in scope: + replacement = node.with_changes(value=correct_type) + self.report( + node, + REPLACE_TYPING_TYPE_ANNOTATION.format( + typing_type=node.value, correct_type=correct_type + ), + replacement=replacement, + )