diff --git a/doc/data/messages/u/unnecessary-default-type-args/bad.py b/doc/data/messages/u/unnecessary-default-type-args/bad.py new file mode 100644 index 0000000000..e3d97799a5 --- /dev/null +++ b/doc/data/messages/u/unnecessary-default-type-args/bad.py @@ -0,0 +1,4 @@ +from collections.abc import AsyncGenerator, Generator + +a1: AsyncGenerator[int, None] # [unnecessary-default-type-args] +b1: Generator[int, None, None] # [unnecessary-default-type-args] diff --git a/doc/data/messages/u/unnecessary-default-type-args/details.rst b/doc/data/messages/u/unnecessary-default-type-args/details.rst new file mode 100644 index 0000000000..754f532caa --- /dev/null +++ b/doc/data/messages/u/unnecessary-default-type-args/details.rst @@ -0,0 +1,6 @@ +At the moment, this check only works for ``Generator`` and ``AsyncGenerator``. + +Starting with Python 3.13, the ``SendType`` and ``ReturnType`` default to ``None``. +As such it's no longer necessary to specify them. The ``collections.abc`` variants +don't validate the number of type arguments. Therefore the defaults for these +can be used in earlier versions as well. diff --git a/doc/data/messages/u/unnecessary-default-type-args/good.py b/doc/data/messages/u/unnecessary-default-type-args/good.py new file mode 100644 index 0000000000..e77c0ee429 --- /dev/null +++ b/doc/data/messages/u/unnecessary-default-type-args/good.py @@ -0,0 +1,4 @@ +from collections.abc import AsyncGenerator, Generator + +a1: AsyncGenerator[int] +b1: Generator[int] diff --git a/doc/data/messages/u/unnecessary-default-type-args/pylintrc b/doc/data/messages/u/unnecessary-default-type-args/pylintrc new file mode 100644 index 0000000000..825e13ec0b --- /dev/null +++ b/doc/data/messages/u/unnecessary-default-type-args/pylintrc @@ -0,0 +1,2 @@ +[main] +load-plugins=pylint.extensions.typing diff --git a/doc/data/messages/u/unnecessary-default-type-args/related.rst b/doc/data/messages/u/unnecessary-default-type-args/related.rst new file mode 100644 index 0000000000..1f988ae98b --- /dev/null +++ b/doc/data/messages/u/unnecessary-default-type-args/related.rst @@ -0,0 +1,2 @@ +- `Python documentation for AsyncGenerator `_ +- `Python documentation for Generator `_ diff --git a/doc/user_guide/checkers/extensions.rst b/doc/user_guide/checkers/extensions.rst index 95462f9218..00f9963b72 100644 --- a/doc/user_guide/checkers/extensions.rst +++ b/doc/user_guide/checkers/extensions.rst @@ -688,6 +688,9 @@ Typing checker Messages :consider-alternative-union-syntax (R6003): *Consider using alternative Union syntax instead of '%s'%s* Emitted when 'typing.Union' or 'typing.Optional' is used instead of the alternative Union syntax 'int | None'. +:unnecessary-default-type-args (R6007): *Type `%s` has unnecessary default type args. Change it to `%s`.* + Emitted when types have default type args which can be omitted. Mainly used + for `typing.Generator` and `typing.AsyncGenerator`. :redundant-typehint-argument (R6006): *Type `%s` is used more than once in union type annotation. Remove redundant typehints.* Duplicated type arguments will be skipped by `mypy` tool, therefore should be removed to avoid confusion. diff --git a/doc/user_guide/messages/messages_overview.rst b/doc/user_guide/messages/messages_overview.rst index 6ad50562f8..fc487fc25c 100644 --- a/doc/user_guide/messages/messages_overview.rst +++ b/doc/user_guide/messages/messages_overview.rst @@ -545,6 +545,7 @@ All messages in the refactor category: refactor/too-many-statements refactor/trailing-comma-tuple refactor/unnecessary-comprehension + refactor/unnecessary-default-type-args refactor/unnecessary-dict-index-lookup refactor/unnecessary-list-index-lookup refactor/use-a-generator diff --git a/doc/whatsnew/fragments/9938.new_check b/doc/whatsnew/fragments/9938.new_check new file mode 100644 index 0000000000..a13556f7de --- /dev/null +++ b/doc/whatsnew/fragments/9938.new_check @@ -0,0 +1,4 @@ +Add ``unnecessary-default-type-args`` to the ``typing`` extension to detect the use +of unnecessary default type args for ``typing.Generator`` and ``typing.AsyncGenerator``. + +Refs #9938 diff --git a/pylint/checkers/exceptions.py b/pylint/checkers/exceptions.py index a503000560..3834f260be 100644 --- a/pylint/checkers/exceptions.py +++ b/pylint/checkers/exceptions.py @@ -35,7 +35,7 @@ def predicate(obj: Any) -> bool: def _annotated_unpack_infer( stmt: nodes.NodeNG, context: InferenceContext | None = None -) -> Generator[tuple[nodes.NodeNG, SuccessfulInferenceResult], None, None]: +) -> Generator[tuple[nodes.NodeNG, SuccessfulInferenceResult]]: """Recursively generate nodes inferred by the given statement. If the inferred value is a list or a tuple, recurse on the elements. diff --git a/pylint/checkers/symilar.py b/pylint/checkers/symilar.py index c488168659..1e82633e65 100644 --- a/pylint/checkers/symilar.py +++ b/pylint/checkers/symilar.py @@ -468,7 +468,7 @@ def _get_similarity_report( # pylint: disable = too-many-locals def _find_common( self, lineset1: LineSet, lineset2: LineSet - ) -> Generator[Commonality, None, None]: + ) -> Generator[Commonality]: """Find similarities in the two given linesets. This the core of the algorithm. The idea is to compute the hashes of a @@ -541,7 +541,7 @@ def _find_common( if eff_cmn_nb > self.namespace.min_similarity_lines: yield com - def _iter_sims(self) -> Generator[Commonality, None, None]: + def _iter_sims(self) -> Generator[Commonality]: """Iterate on similarities among all files, by making a Cartesian product. """ diff --git a/pylint/checkers/variables.py b/pylint/checkers/variables.py index 012d515e08..47f6b92d6e 100644 --- a/pylint/checkers/variables.py +++ b/pylint/checkers/variables.py @@ -249,9 +249,7 @@ class C: ... return frame.lineno < defframe.lineno # type: ignore[no-any-return] -def _infer_name_module( - node: nodes.Import, name: str -) -> Generator[InferenceResult, None, None]: +def _infer_name_module(node: nodes.Import, name: str) -> Generator[InferenceResult]: context = astroid.context.InferenceContext() context.lookupname = name return node.infer(context, asname=False) # type: ignore[no-any-return] diff --git a/pylint/extensions/typing.py b/pylint/extensions/typing.py index 2956465cf6..f9ef83babb 100644 --- a/pylint/extensions/typing.py +++ b/pylint/extensions/typing.py @@ -85,6 +85,7 @@ class DeprecatedTypingAliasMsg(NamedTuple): parent_subscript: bool = False +# pylint: disable-next=too-many-instance-attributes class TypingChecker(BaseChecker): """Find issue specifically related to type annotations.""" @@ -130,6 +131,12 @@ class TypingChecker(BaseChecker): "Duplicated type arguments will be skipped by `mypy` tool, therefore should be " "removed to avoid confusion.", ), + "R6007": ( + "Type `%s` has unnecessary default type args. Change it to `%s`.", + "unnecessary-default-type-args", + "Emitted when types have default type args which can be omitted. " + "Mainly used for `typing.Generator` and `typing.AsyncGenerator`.", + ), } options = ( ( @@ -174,6 +181,7 @@ def open(self) -> None: self._py37_plus = py_version >= (3, 7) self._py39_plus = py_version >= (3, 9) self._py310_plus = py_version >= (3, 10) + self._py313_plus = py_version >= (3, 13) self._should_check_typing_alias = self._py39_plus or ( self._py37_plus and self.linter.config.runtime_typing is False @@ -248,6 +256,33 @@ def visit_annassign(self, node: nodes.AnnAssign) -> None: self._check_union_types(types, node) + @only_required_for_messages("unnecessary-default-type-args") + def visit_subscript(self, node: nodes.Subscript) -> None: + inferred = safe_infer(node.value) + if ( # pylint: disable=too-many-boolean-expressions + isinstance(inferred, nodes.ClassDef) + and ( + inferred.qname() in {"typing.Generator", "typing.AsyncGenerator"} + and self._py313_plus + or inferred.qname() + in {"_collections_abc.Generator", "_collections_abc.AsyncGenerator"} + ) + and isinstance(node.slice, nodes.Tuple) + and all( + (isinstance(el, nodes.Const) and el.value is None) + for el in node.slice.elts[1:] + ) + ): + suggested_str = ( + f"{node.value.as_string()}[{node.slice.elts[0].as_string()}]" + ) + self.add_message( + "unnecessary-default-type-args", + args=(node.as_string(), suggested_str), + node=node, + confidence=HIGH, + ) + @staticmethod def _is_deprecated_union_annotation( annotation: nodes.NodeNG, union_name: str diff --git a/pylint/pyreverse/diadefslib.py b/pylint/pyreverse/diadefslib.py index 88aea482ed..59a2f59560 100644 --- a/pylint/pyreverse/diadefslib.py +++ b/pylint/pyreverse/diadefslib.py @@ -84,7 +84,7 @@ def add_class(self, node: nodes.ClassDef) -> None: def get_ancestors( self, node: nodes.ClassDef, level: int - ) -> Generator[nodes.ClassDef, None, None]: + ) -> Generator[nodes.ClassDef]: """Return ancestor nodes of a class node.""" if level == 0: return @@ -95,7 +95,7 @@ def get_ancestors( def get_associated( self, klass_node: nodes.ClassDef, level: int - ) -> Generator[nodes.ClassDef, None, None]: + ) -> Generator[nodes.ClassDef]: """Return associated nodes of a class node.""" if level == 0: return diff --git a/pylint/testutils/checker_test_case.py b/pylint/testutils/checker_test_case.py index 6b26674b53..951f38c0b9 100644 --- a/pylint/testutils/checker_test_case.py +++ b/pylint/testutils/checker_test_case.py @@ -40,7 +40,7 @@ def assertNoMessages(self) -> Iterator[None]: @contextlib.contextmanager def assertAddsMessages( self, *messages: MessageTest, ignore_position: bool = False - ) -> Generator[None, None, None]: + ) -> Generator[None]: """Assert that exactly the given method adds the given messages. The list of messages must exactly match *all* the messages added by the diff --git a/pylint/testutils/utils.py b/pylint/testutils/utils.py index 1ff999b28d..3036d1fd62 100644 --- a/pylint/testutils/utils.py +++ b/pylint/testutils/utils.py @@ -27,7 +27,7 @@ def _patch_streams(out: TextIO) -> Iterator[None]: @contextlib.contextmanager def _test_sys_path( replacement_sys_path: list[str] | None = None, -) -> Generator[None, None, None]: +) -> Generator[None]: original_path = sys.path try: if replacement_sys_path is not None: @@ -40,7 +40,7 @@ def _test_sys_path( @contextlib.contextmanager def _test_cwd( current_working_directory: str | Path | None = None, -) -> Generator[None, None, None]: +) -> Generator[None]: original_dir = os.getcwd() try: if current_working_directory is not None: @@ -53,7 +53,7 @@ def _test_cwd( @contextlib.contextmanager def _test_environ_pythonpath( new_pythonpath: str | None = None, -) -> Generator[None, None, None]: +) -> Generator[None]: original_pythonpath = os.environ.get("PYTHONPATH") if new_pythonpath: os.environ["PYTHONPATH"] = new_pythonpath diff --git a/pylint/utils/pragma_parser.py b/pylint/utils/pragma_parser.py index 12513e2843..5e066653e4 100644 --- a/pylint/utils/pragma_parser.py +++ b/pylint/utils/pragma_parser.py @@ -86,7 +86,7 @@ class InvalidPragmaError(PragmaParserError): """Thrown in case the pragma is invalid.""" -def parse_pragma(pylint_pragma: str) -> Generator[PragmaRepresenter, None, None]: +def parse_pragma(pylint_pragma: str) -> Generator[PragmaRepresenter]: action: str | None = None messages: list[str] = [] assignment_required = False diff --git a/tests/functional/ext/typing/unnecessary_default_type_args.py b/tests/functional/ext/typing/unnecessary_default_type_args.py new file mode 100644 index 0000000000..e2d1d700de --- /dev/null +++ b/tests/functional/ext/typing/unnecessary_default_type_args.py @@ -0,0 +1,17 @@ +# pylint: disable=missing-docstring,deprecated-typing-alias +import collections.abc as ca +import typing as t + +a1: t.Generator[int, str, str] +a2: t.Generator[int, None, None] +a3: t.Generator[int] +b1: t.AsyncGenerator[int, str] +b2: t.AsyncGenerator[int, None] +b3: t.AsyncGenerator[int] + +c1: ca.Generator[int, str, str] +c2: ca.Generator[int, None, None] # [unnecessary-default-type-args] +c3: ca.Generator[int] +d1: ca.AsyncGenerator[int, str] +d2: ca.AsyncGenerator[int, None] # [unnecessary-default-type-args] +d3: ca.AsyncGenerator[int] diff --git a/tests/functional/ext/typing/unnecessary_default_type_args.rc b/tests/functional/ext/typing/unnecessary_default_type_args.rc new file mode 100644 index 0000000000..63e11a4e6b --- /dev/null +++ b/tests/functional/ext/typing/unnecessary_default_type_args.rc @@ -0,0 +1,3 @@ +[main] +py-version=3.10 +load-plugins=pylint.extensions.typing diff --git a/tests/functional/ext/typing/unnecessary_default_type_args.txt b/tests/functional/ext/typing/unnecessary_default_type_args.txt new file mode 100644 index 0000000000..2d36ba46a6 --- /dev/null +++ b/tests/functional/ext/typing/unnecessary_default_type_args.txt @@ -0,0 +1,2 @@ +unnecessary-default-type-args:13:4:13:33::Type `ca.Generator[int, None, None]` has unnecessary default type args. Change it to `ca.Generator[int]`.:HIGH +unnecessary-default-type-args:16:4:16:32::Type `ca.AsyncGenerator[int, None]` has unnecessary default type args. Change it to `ca.AsyncGenerator[int]`.:HIGH diff --git a/tests/functional/ext/typing/unnecessary_default_type_args_py313.py b/tests/functional/ext/typing/unnecessary_default_type_args_py313.py new file mode 100644 index 0000000000..9dec4c4075 --- /dev/null +++ b/tests/functional/ext/typing/unnecessary_default_type_args_py313.py @@ -0,0 +1,17 @@ +# pylint: disable=missing-docstring,deprecated-typing-alias +import collections.abc as ca +import typing as t + +a1: t.Generator[int, str, str] +a2: t.Generator[int, None, None] # [unnecessary-default-type-args] +a3: t.Generator[int] +b1: t.AsyncGenerator[int, str] +b2: t.AsyncGenerator[int, None] # [unnecessary-default-type-args] +b3: t.AsyncGenerator[int] + +c1: ca.Generator[int, str, str] +c2: ca.Generator[int, None, None] # [unnecessary-default-type-args] +c3: ca.Generator[int] +d1: ca.AsyncGenerator[int, str] +d2: ca.AsyncGenerator[int, None] # [unnecessary-default-type-args] +d3: ca.AsyncGenerator[int] diff --git a/tests/functional/ext/typing/unnecessary_default_type_args_py313.rc b/tests/functional/ext/typing/unnecessary_default_type_args_py313.rc new file mode 100644 index 0000000000..d2db5fe7ca --- /dev/null +++ b/tests/functional/ext/typing/unnecessary_default_type_args_py313.rc @@ -0,0 +1,3 @@ +[main] +py-version=3.13 +load-plugins=pylint.extensions.typing diff --git a/tests/functional/ext/typing/unnecessary_default_type_args_py313.txt b/tests/functional/ext/typing/unnecessary_default_type_args_py313.txt new file mode 100644 index 0000000000..228f499663 --- /dev/null +++ b/tests/functional/ext/typing/unnecessary_default_type_args_py313.txt @@ -0,0 +1,4 @@ +unnecessary-default-type-args:6:4:6:32::Type `t.Generator[int, None, None]` has unnecessary default type args. Change it to `t.Generator[int]`.:HIGH +unnecessary-default-type-args:9:4:9:31::Type `t.AsyncGenerator[int, None]` has unnecessary default type args. Change it to `t.AsyncGenerator[int]`.:HIGH +unnecessary-default-type-args:13:4:13:33::Type `ca.Generator[int, None, None]` has unnecessary default type args. Change it to `ca.Generator[int]`.:HIGH +unnecessary-default-type-args:16:4:16:32::Type `ca.AsyncGenerator[int, None]` has unnecessary default type args. Change it to `ca.AsyncGenerator[int]`.:HIGH diff --git a/tests/pyreverse/test_inspector.py b/tests/pyreverse/test_inspector.py index d28d99584f..b388569ac7 100644 --- a/tests/pyreverse/test_inspector.py +++ b/tests/pyreverse/test_inspector.py @@ -26,7 +26,7 @@ @pytest.fixture -def project(get_project: GetProjectCallable) -> Generator[Project, None, None]: +def project(get_project: GetProjectCallable) -> Generator[Project]: with _test_cwd(TESTS): project = get_project("data", "data") linker = inspector.Linker(project)