diff --git a/doc/data/messages/m/missing-param-type-annotation/bad.py b/doc/data/messages/m/missing-param-type-annotation/bad.py new file mode 100644 index 0000000000..a12423d7dc --- /dev/null +++ b/doc/data/messages/m/missing-param-type-annotation/bad.py @@ -0,0 +1,10 @@ +def greet(name): # [missing-param-type-annotation] + return f"Hello, {name}!" + + +def add(x, y) -> int: # [missing-param-type-annotation] + return x + y + + +def process(*args, **kwargs): # [missing-param-type-annotation] + return combine(args, kwargs) diff --git a/doc/data/messages/m/missing-param-type-annotation/details.rst b/doc/data/messages/m/missing-param-type-annotation/details.rst new file mode 100644 index 0000000000..baa72cab0a --- /dev/null +++ b/doc/data/messages/m/missing-param-type-annotation/details.rst @@ -0,0 +1,20 @@ +Type annotations improve code readability and enable better static analysis. This check +ensures that all function and method parameters have type annotations, making the expected +types clear and allowing type checkers like mypy to verify correct usage. + +This check is opt-in (disabled by default) to maintain backward compatibility. Enable it +with ``--enable=missing-param-type-annotation``. + +The check automatically skips: + +- ``self`` and ``cls`` parameters in methods +- Parameters in abstract methods (``@abstractmethod``, ``@abstractproperty``) +- Parameters in overload stub definitions (``@typing.overload``) + +All parameter types are checked, including: + +- Regular positional parameters +- Positional-only parameters (before ``/``) +- Keyword-only parameters (after ``*``) +- Variadic positional parameters (``*args``) +- Variadic keyword parameters (``**kwargs``) diff --git a/doc/data/messages/m/missing-param-type-annotation/good.py b/doc/data/messages/m/missing-param-type-annotation/good.py new file mode 100644 index 0000000000..e41e3abbaf --- /dev/null +++ b/doc/data/messages/m/missing-param-type-annotation/good.py @@ -0,0 +1,15 @@ +def greet(name: str) -> str: + return f"Hello, {name}!" + + +def add(x: int, y: int) -> int: + return x + y + + +def process(*args: str, **kwargs: bool) -> dict: + return combine(args, kwargs) + + +class Calculator: + def compute(self, x: int, y: int) -> int: # self doesn't need annotation + return x + y diff --git a/doc/data/messages/m/missing-return-type-annotation/bad.py b/doc/data/messages/m/missing-return-type-annotation/bad.py new file mode 100644 index 0000000000..5dfe959191 --- /dev/null +++ b/doc/data/messages/m/missing-return-type-annotation/bad.py @@ -0,0 +1,6 @@ +def calculate_sum(numbers): # [missing-return-type-annotation] + return sum(numbers) + + +async def fetch_data(url): # [missing-return-type-annotation] + return await get(url) diff --git a/doc/data/messages/m/missing-return-type-annotation/details.rst b/doc/data/messages/m/missing-return-type-annotation/details.rst new file mode 100644 index 0000000000..a6124b11a0 --- /dev/null +++ b/doc/data/messages/m/missing-return-type-annotation/details.rst @@ -0,0 +1,13 @@ +Type annotations improve code readability and enable better static analysis. This check +ensures that all functions and methods have return type annotations, making the code's +intent clearer and allowing type checkers like mypy to verify correctness. + +This check is opt-in (disabled by default) to maintain backward compatibility. Enable it +with ``--enable=missing-return-type-annotation``. + +The check automatically skips: + +- ``__init__`` methods (which implicitly return None) +- Abstract methods (``@abstractmethod``, ``@abstractproperty``) +- Properties and their setters/deleters +- Overload stub definitions (``@typing.overload``) diff --git a/doc/data/messages/m/missing-return-type-annotation/good.py b/doc/data/messages/m/missing-return-type-annotation/good.py new file mode 100644 index 0000000000..740c8e7e58 --- /dev/null +++ b/doc/data/messages/m/missing-return-type-annotation/good.py @@ -0,0 +1,11 @@ +def calculate_sum(numbers: list[int]) -> int: + return sum(numbers) + + +async def fetch_data(url: str) -> dict: + return await get(url) + + +class Calculator: + def __init__(self, initial: int): # __init__ doesn't need return type + self.value = initial diff --git a/doc/whatsnew/fragments/3853.new_check b/doc/whatsnew/fragments/3853.new_check new file mode 100644 index 0000000000..5d5bfab207 --- /dev/null +++ b/doc/whatsnew/fragments/3853.new_check @@ -0,0 +1,5 @@ +Add ``missing-return-type-annotation`` and ``missing-param-type-annotation`` checks to enforce type annotation presence in functions and methods. + +These new convention-level checks help teams enforce type annotation standards. Both checks are opt-in (disabled by default) and can be enabled independently for granular control. The checks intelligently skip ``self``/``cls`` parameters, ``__init__`` methods (return type only), and methods decorated with ``@abstractmethod``, ``@property``, or ``@typing.overload``. + +Closes #3853 diff --git a/pylint/checkers/type_annotations.py b/pylint/checkers/type_annotations.py new file mode 100644 index 0000000000..e0407c3289 --- /dev/null +++ b/pylint/checkers/type_annotations.py @@ -0,0 +1,181 @@ +# Licensed under the GPL: https://www.gnu.org/licenses/old-licenses/gpl-2.0.html +# For details: https://github.com/pylint-dev/pylint/blob/main/LICENSE +# Copyright (c) https://github.com/pylint-dev/pylint/blob/main/CONTRIBUTORS.txt + +"""Checker for type annotations in function definitions.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from astroid import nodes + +from pylint import checkers +from pylint.checkers import utils + +if TYPE_CHECKING: + from pylint.lint import PyLinter + + +class TypeAnnotationChecker(checkers.BaseChecker): + """Checker for enforcing type annotations on functions and methods. + + This checker verifies that functions and methods have appropriate + type annotations for return values and parameters. + """ + + name = "type-annotation" + msgs = { + "C3801": ( + "Missing return type annotation for function %r", + "missing-return-type-annotation", + "Used when a function or method does not have a return type annotation. " + "Type annotations improve code readability and help with static type checking.", + ), + "C3802": ( + "Missing type annotation for parameter %r in function %r", + "missing-param-type-annotation", + "Used when a function or method parameter does not have a type annotation. " + "Type annotations improve code readability and help with static type checking.", + ), + } + + @utils.only_required_for_messages( + "missing-return-type-annotation", "missing-param-type-annotation" + ) + def visit_functiondef(self, node: nodes.FunctionDef) -> None: + """Check for missing type annotations in regular functions.""" + self._check_return_type_annotation(node) + self._check_param_type_annotations(node) + + @utils.only_required_for_messages( + "missing-return-type-annotation", "missing-param-type-annotation" + ) + def visit_asyncfunctiondef(self, node: nodes.AsyncFunctionDef) -> None: + """Check for missing type annotations in async functions.""" + self._check_return_type_annotation(node) + self._check_param_type_annotations(node) + + def _check_return_type_annotation( + self, node: nodes.FunctionDef | nodes.AsyncFunctionDef + ) -> None: + """Check if a function has a return type annotation. + + Args: + node: The function definition node to check + """ + # Skip if function already has return type annotation + if node.returns is not None: + return + + # Skip if function has type comment with return type + if node.type_comment_returns: + return + + # Skip __init__ methods as they implicitly return None + if node.name == "__init__": + return + + # Skip abstract methods (often overridden with proper annotations) + if utils.decorated_with(node, ["abc.abstractmethod", "abc.abstractproperty"]): + return + + # Skip overload decorators (stub definitions) + if utils.decorated_with( + node, ["typing.overload", "typing_extensions.overload"] + ): + return + + # Skip property setters and delete methods (return value not meaningful) + if utils.decorated_with( + node, ["property", "*.setter", "*.deleter", "builtins.property"] + ): + return + + # Emit the message + self.add_message("missing-return-type-annotation", node=node, args=(node.name,)) + + def _check_param_type_annotations( + self, node: nodes.FunctionDef | nodes.AsyncFunctionDef + ) -> None: + """Check if function parameters have type annotations. + + Args: + node: The function definition node to check + """ + # Skip abstract methods + if utils.decorated_with(node, ["abc.abstractmethod", "abc.abstractproperty"]): + return + + # Skip overload decorators + if utils.decorated_with( + node, ["typing.overload", "typing_extensions.overload"] + ): + return + + arguments = node.args + + # Check positional-only args + if arguments.posonlyargs: + annotations = arguments.posonlyargs_annotations or [] + for idx, arg in enumerate(arguments.posonlyargs): + if arg.name in {"self", "cls"}: + continue + if idx >= len(annotations) or annotations[idx] is None: + self.add_message( + "missing-param-type-annotation", + node=node, + args=(arg.name, node.name), + ) + + # Check regular args (skip self/cls for methods) + if arguments.args: + annotations = arguments.annotations or [] + start_idx = 0 + # Skip 'self' or 'cls' for methods + if ( + arguments.args + and arguments.args[0].name in {"self", "cls"} + and isinstance(node.parent, nodes.ClassDef) + ): + start_idx = 1 + + for idx, arg in enumerate(arguments.args[start_idx:], start=start_idx): + if idx >= len(annotations) or annotations[idx] is None: + self.add_message( + "missing-param-type-annotation", + node=node, + args=(arg.name, node.name), + ) + + # Check *args + if arguments.vararg and not arguments.varargannotation: + self.add_message( + "missing-param-type-annotation", + node=node, + args=(arguments.vararg, node.name), + ) + + # Check keyword-only args + if arguments.kwonlyargs: + annotations = arguments.kwonlyargs_annotations or [] + for idx, arg in enumerate(arguments.kwonlyargs): + if idx >= len(annotations) or annotations[idx] is None: + self.add_message( + "missing-param-type-annotation", + node=node, + args=(arg.name, node.name), + ) + + # Check **kwargs + if arguments.kwarg and not arguments.kwargannotation: + self.add_message( + "missing-param-type-annotation", + node=node, + args=(arguments.kwarg, node.name), + ) + + +def register(linter: PyLinter) -> None: + """Register the checker with the linter.""" + linter.register_checker(TypeAnnotationChecker(linter)) diff --git a/tests/checkers/unittest_type_annotations.py b/tests/checkers/unittest_type_annotations.py new file mode 100644 index 0000000000..97aff9233d --- /dev/null +++ b/tests/checkers/unittest_type_annotations.py @@ -0,0 +1,271 @@ +# Licensed under the GPL: https://www.gnu.org/licenses/old-licenses/gpl-2.0.html +# For details: https://github.com/pylint-dev/pylint/blob/main/LICENSE +# Copyright (c) https://github.com/pylint-dev/pylint/blob/main/CONTRIBUTORS.txt + +"""Tests for the type_annotations checker.""" + +from __future__ import annotations + +import astroid + +from pylint.checkers.type_annotations import TypeAnnotationChecker +from pylint.testutils import CheckerTestCase, MessageTest + + +class TestTypeAnnotationChecker(CheckerTestCase): + """Tests for TypeAnnotationChecker.""" + + CHECKER_CLASS = TypeAnnotationChecker + + def test_missing_return_type_annotation(self) -> None: + """Test detection of missing return type annotation.""" + node = astroid.extract_node( + """ + def foo(x): #@ + return x + """ + ) + with self.assertAddsMessages( + MessageTest( + msg_id="missing-return-type-annotation", + args=("foo",), + node=node, + line=2, + col_offset=0, + end_line=2, + end_col_offset=7, + ), + MessageTest( + msg_id="missing-param-type-annotation", + args=("x", "foo"), + node=node, + line=2, + col_offset=0, + end_line=2, + end_col_offset=7, + ), + ): + self.checker.visit_functiondef(node) + + def test_function_with_return_type_annotation(self) -> None: + """Test that functions with return type annotations don't trigger warnings.""" + node = astroid.extract_node( + """ + def foo(x: int) -> int: #@ + return x + """ + ) + with self.assertNoMessages(): + self.checker.visit_functiondef(node) + + def test_init_method_skipped(self) -> None: + """Test that __init__ methods are skipped for return type.""" + node = astroid.extract_node( + """ + class MyClass: + def __init__(self, x): #@ + self.x = x + """ + ) + # __init__ should skip return type check, but still check params + with self.assertAddsMessages( + MessageTest( + msg_id="missing-param-type-annotation", + args=("x", "__init__"), + node=node, + line=3, + col_offset=4, + end_line=3, + end_col_offset=16, + ) + ): + self.checker.visit_functiondef(node) + + def test_async_function_missing_return_type(self) -> None: + """Test detection in async functions.""" + node = astroid.extract_node( + """ + async def foo(x): #@ + return x + """ + ) + with self.assertAddsMessages( + MessageTest( + msg_id="missing-return-type-annotation", + args=("foo",), + node=node, + line=2, + col_offset=0, + end_line=2, + end_col_offset=13, + ), + MessageTest( + msg_id="missing-param-type-annotation", + args=("x", "foo"), + node=node, + line=2, + col_offset=0, + end_line=2, + end_col_offset=13, + ), + ): + self.checker.visit_asyncfunctiondef(node) + + def test_missing_param_type_annotation(self) -> None: + """Test detection of missing parameter type annotation.""" + node = astroid.extract_node( + """ + def foo(x) -> int: #@ + return x + """ + ) + with self.assertAddsMessages( + MessageTest( + msg_id="missing-param-type-annotation", + args=("x", "foo"), + node=node, + line=2, + col_offset=0, + end_line=2, + end_col_offset=7, + ) + ): + self.checker.visit_functiondef(node) + + def test_function_with_all_annotations(self) -> None: + """Test that fully annotated functions don't trigger warnings.""" + node = astroid.extract_node( + """ + def foo(x: int, y: str) -> bool: #@ + return True + """ + ) + with self.assertNoMessages(): + self.checker.visit_functiondef(node) + + def test_method_self_parameter_skipped(self) -> None: + """Test that 'self' parameter is skipped in methods.""" + node = astroid.extract_node( + """ + class MyClass: + def foo(self, x: int) -> int: #@ + return x + """ + ) + with self.assertNoMessages(): + self.checker.visit_functiondef(node) + + def test_classmethod_cls_parameter_skipped(self) -> None: + """Test that 'cls' parameter is skipped in class methods.""" + node = astroid.extract_node( + """ + class MyClass: + @classmethod + def foo(cls, x: int) -> int: #@ + return x + """ + ) + with self.assertNoMessages(): + self.checker.visit_functiondef(node) + + def test_abstract_method_skipped(self) -> None: + """Test that abstract methods are skipped.""" + node = astroid.extract_node( + """ + from abc import abstractmethod + + class MyClass: + @abstractmethod + def foo(self, x): #@ + pass + """ + ) + with self.assertNoMessages(): + self.checker.visit_functiondef(node) + + def test_property_skipped(self) -> None: + """Test that property methods are skipped.""" + node = astroid.extract_node( + """ + class MyClass: + @property + def foo(self): #@ + return 42 + """ + ) + with self.assertNoMessages(): + self.checker.visit_functiondef(node) + + def test_vararg_missing_annotation(self) -> None: + """Test detection of missing *args annotation.""" + node = astroid.extract_node( + """ + def foo(*args) -> None: #@ + pass + """ + ) + with self.assertAddsMessages( + MessageTest( + msg_id="missing-param-type-annotation", + args=("args", "foo"), + node=node, + line=2, + col_offset=0, + end_line=2, + end_col_offset=7, + ) + ): + self.checker.visit_functiondef(node) + + def test_kwarg_missing_annotation(self) -> None: + """Test detection of missing **kwargs annotation.""" + node = astroid.extract_node( + """ + def foo(**kwargs) -> None: #@ + pass + """ + ) + with self.assertAddsMessages( + MessageTest( + msg_id="missing-param-type-annotation", + args=("kwargs", "foo"), + node=node, + line=2, + col_offset=0, + end_line=2, + end_col_offset=7, + ) + ): + self.checker.visit_functiondef(node) + + def test_fully_annotated_with_varargs(self) -> None: + """Test that fully annotated functions with *args and **kwargs work.""" + node = astroid.extract_node( + """ + def foo(x: int, *args: str, **kwargs: bool) -> None: #@ + pass + """ + ) + with self.assertNoMessages(): + self.checker.visit_functiondef(node) + + def test_keyword_only_args_missing_annotation(self) -> None: + """Test detection of missing keyword-only argument annotations.""" + node = astroid.extract_node( + """ + def foo(x: int, *, y) -> None: #@ + pass + """ + ) + with self.assertAddsMessages( + MessageTest( + msg_id="missing-param-type-annotation", + args=("y", "foo"), + node=node, + line=2, + col_offset=0, + end_line=2, + end_col_offset=7, + ) + ): + self.checker.visit_functiondef(node) diff --git a/tests/message/conftest.py b/tests/message/conftest.py index 57567f4385..b56dade6b9 100644 --- a/tests/message/conftest.py +++ b/tests/message/conftest.py @@ -16,12 +16,12 @@ @pytest.fixture -def msgid(): +def msgid() -> str: return "W1234" @pytest.fixture -def symbol(): +def symbol() -> str: return "msg-symbol"