diff --git a/CHANGELOG.md b/CHANGELOG.md index eae99bf70..0d0e3f3ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,12 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - `redundant-condition`: Provide error message when a conditional statement within a function is guaranteed true. This checker requires `z3` option to be turned on. - `impossible-condition`: Provide error message when a conditional statement within a function is guaranteed false. This checker requires `z3` option to be turned on. +- `incompatible-argument-type`: Provide an error message when a function argument has an incompatible type. +- `incompatible-assignment`: Provide an error message when there is an incompatible assignment. +- `list-item-type-mismatch`: Provide an error message when a list item has an incompatible type. +- `unsupported-operand-types`: Provide an error message when an operation is attempted between incompatible types. +- `union-attr-error`: Provide an error message when accessing an attribute that may not exist on a Union type. +- `dict-item-type-mismatch`: Provide an error message when a dictionary entry has an incompatible key or value type. ### 🐛 Bug fixes diff --git a/docs/checkers/index.md b/docs/checkers/index.md index ebccbd2a5..357300af9 100644 --- a/docs/checkers/index.md +++ b/docs/checkers/index.md @@ -3194,6 +3194,71 @@ and `pdb.set_trace()`) are found. These breakpoints should be removed in product ``` +## Mypy-based checks + +The following errors are identified by the StaticTypeChecker, which uses Mypy to detect issues related to type annotations in Python code. +Further information on Mypy can be found in its [official documentation](https://mypy.readthedocs.io/en/stable/). + +(E9951)= + +### Incompatible Argument Type (E9951) + +This error occurs when a function is called with an argument that does not match the expected type for that parameter. See the [Mypy documentation](https://mypy.readthedocs.io/en/stable/error_code_list.html#check-argument-types-arg-type). + +```{literalinclude} /../examples/custom_checkers/static_type_checker_examples/e9951_incompatible_argument_type.py + +``` + +(E9952)= + +### Incompatible Assignment (E9952) + +This error occurs when an expression is assigned to a variable, but the types are incompatible. See the [Mypy documentation](https://mypy.readthedocs.io/en/stable/error_code_list.html#check-types-in-assignment-statement-assignment). + +```{literalinclude} /../examples/custom_checkers/static_type_checker_examples/e9952_incompatible_assignment.py + +``` + +(E9953)= + +### List Item Type Mismatch (E9953) + +This error occurs when a list item has a type that does not match the expected type for that position in the list. See the [Mypy documentation](https://mypy.readthedocs.io/en/stable/error_code_list.html#check-list-items-list-item). + +```{literalinclude} /../examples/custom_checkers/static_type_checker_examples/e9953_list_item_type_mismatch.py + +``` + +(E9954)= + +### Unsupported Operand Types (E9954) + +This error occurs when an operation is attempted between incompatible types, such as adding a string to an integer. See the [Mypy documentation](https://mypy.readthedocs.io/en/stable/error_code_list.html#check-uses-of-various-operators-operator). + +```{literalinclude} /../examples/custom_checkers/static_type_checker_examples/e9954_unsupported_operand_types.py + +``` + +(E9955)= + +### Union Attribute Error (E9955) + +This error occurs when attempting to access an attribute on a `Union` type that may not exist on all possible types in the union. See the [Mypy documentation](https://mypy.readthedocs.io/en/stable/error_code_list.html#check-that-attribute-exists-in-each-union-item-union-attr). + +```{literalinclude} /../examples/custom_checkers/static_type_checker_examples/e9955_union_attr_error.py + +``` + +(E9956)= + +### Dictionary Item Type Mismatch (E9956) + +This error occurs when a dictionary entry contains a key or value type that does not match the expected type. See the [Mypy documentation](https://mypy.readthedocs.io/en/stable/error_code_list.html#check-dict-items-dict-item). + +```{literalinclude} /../examples/custom_checkers/static_type_checker_examples/e9956_dict_item_type_mismatch.py + +``` + ## Modified iterators in for loops (W4701)= diff --git a/examples/custom_checkers/static_type_checker_examples/e9951_incompatible_argument_type.py b/examples/custom_checkers/static_type_checker_examples/e9951_incompatible_argument_type.py new file mode 100644 index 000000000..f356cae3e --- /dev/null +++ b/examples/custom_checkers/static_type_checker_examples/e9951_incompatible_argument_type.py @@ -0,0 +1,11 @@ +def calculate_area(radius: float) -> float: + """Calculate the area of a circle with the given radius""" + return 3.14159 * radius * radius + +area = calculate_area("five") # Error: Function argument should be float, but got str + +def convert_to_upper(text: str) -> str: + """Convert the given text to uppercase""" + return text.upper() + +result = convert_to_upper(5) # Error: Function argument should be str, but got int diff --git a/examples/custom_checkers/static_type_checker_examples/e9952_incompatible_assignment.py b/examples/custom_checkers/static_type_checker_examples/e9952_incompatible_assignment.py new file mode 100644 index 000000000..9d66cf8f3 --- /dev/null +++ b/examples/custom_checkers/static_type_checker_examples/e9952_incompatible_assignment.py @@ -0,0 +1,4 @@ +age: int = 25 +age = "twenty-five" # Error: Incompatible types in assignment (expression has type "str", variable has type "int") + +count: int = "ten" # Error: Incompatible types in assignment (expression has type "str", variable has type "int") diff --git a/examples/custom_checkers/static_type_checker_examples/e9953_list_item_type_mismatch.py b/examples/custom_checkers/static_type_checker_examples/e9953_list_item_type_mismatch.py new file mode 100644 index 000000000..9a0bfeb0b --- /dev/null +++ b/examples/custom_checkers/static_type_checker_examples/e9953_list_item_type_mismatch.py @@ -0,0 +1,5 @@ +names: list[str] = ["Alice", "Bob", 3] # Error: List item 2 has incompatible type "int"; expected "str" + +numbers: list[int] = [1, 2, "three"] # Error: List item 2 has incompatible type "str"; expected "int" + +mixed: list[float] = [1.1, 2.2, "3.3"] # Error: List item 2 has incompatible type "str"; expected "float" diff --git a/examples/custom_checkers/static_type_checker_examples/e9954_unsupported_operand_types.py b/examples/custom_checkers/static_type_checker_examples/e9954_unsupported_operand_types.py new file mode 100644 index 000000000..6adfb7464 --- /dev/null +++ b/examples/custom_checkers/static_type_checker_examples/e9954_unsupported_operand_types.py @@ -0,0 +1,5 @@ +result = "hello" - 5 # Error: Unsupported operand types for - ("str" and "int") + +total = 10 + "20" # Error: Unsupported operand types for + ("int" and "str") + +value = 5.5 * "3" # Error: Unsupported operand types for * ("float" and "str") diff --git a/examples/custom_checkers/static_type_checker_examples/e9955_union_attr_error.py b/examples/custom_checkers/static_type_checker_examples/e9955_union_attr_error.py new file mode 100644 index 000000000..4cbb33d6a --- /dev/null +++ b/examples/custom_checkers/static_type_checker_examples/e9955_union_attr_error.py @@ -0,0 +1,9 @@ +from typing import Union + +def get_status(status: Union[str, int, float]) -> str: + return (status + .upper()) # Error: Item "int" of "str | int | float" has no attribute "upper" + # Error: Item "float" of "str | int | float" has no attribute "upper" + +def get_keys(data: Union[dict, list]) -> list: + return data.keys() # Error: Item "list" of "dict | list" has no attribute "keys" diff --git a/examples/custom_checkers/static_type_checker_examples/e9956_dict_item_type_mismatch.py b/examples/custom_checkers/static_type_checker_examples/e9956_dict_item_type_mismatch.py new file mode 100644 index 000000000..080bb168a --- /dev/null +++ b/examples/custom_checkers/static_type_checker_examples/e9956_dict_item_type_mismatch.py @@ -0,0 +1,3 @@ +data: dict[int, str] = {1: "one", 2: "two", "three": 3} # Error: Dict entry 2 has incompatible type "str": "int"; expected "int": "str" + +info: dict[str, float] = {"pi": 3.14, "e": 2.71, 42: "1.618"} # Error: Dict entry 2 has incompatible type "int": "str"; expected "str": "float" diff --git a/examples/custom_checkers/static_type_checker_examples/static_type_checker_no_error.py b/examples/custom_checkers/static_type_checker_examples/static_type_checker_no_error.py new file mode 100644 index 000000000..ae343ef7d --- /dev/null +++ b/examples/custom_checkers/static_type_checker_examples/static_type_checker_no_error.py @@ -0,0 +1,45 @@ +# Correct Function Usage +def calculate_area(radius: float) -> float: + """Calculate the area of a circle.""" + return 3.14159 * radius * radius + +area = calculate_area(5.0) + +# Proper Variable Assignments +name: str = "Alice" +age: int = 30 +height: float = 5.9 + +# Correct List Usage +names: list[str] = ["Alice", "Bob", "Charlie"] +numbers: list[int] = [1, 2, 3, 4] +prices: list[float] = [9.99, 19.99, 29.99] + +# Correct Dict Usage +data: dict[int, str] = {1: "one", 2: "two", 3: "three"} +config: dict[str, float] = {"pi": 3.14, "e": 2.71} + +# Valid Operations +result = 10 + 5 +value = 3.5 * 2 +combined_name = "Alice" + " Bob" + +# Union with Compatible Attribute Access +from typing import Union + +def get_length(value: Union[str, list]) -> int: + return len(value) + +length_of_name = get_length("Alice") +length_of_list = get_length([1, 2, 3]) + +# Valid Empty Structures +empty_list: list[int] = [] +empty_dict: dict[str, int] = {} + +# Functions with Default Arguments +def greet(name: str = "Guest") -> str: + return f"Hello, {name}" + +greeting = greet() +custom_greeting = greet("Alice") diff --git a/pyproject.toml b/pyproject.toml index 242583093..aca850975 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,7 @@ dependencies = [ "click >= 8.0.1, < 9", "colorama ~= 0.4.6", "jinja2 ~= 3.1.2", + "mypy ~= 1.13", "pycodestyle ~= 2.11", "pygments >= 2.14,< 2.19", "pylint ~= 3.3.1", diff --git a/python_ta/checkers/static_type_checker.py b/python_ta/checkers/static_type_checker.py new file mode 100644 index 000000000..1c2af7466 --- /dev/null +++ b/python_ta/checkers/static_type_checker.py @@ -0,0 +1,122 @@ +import re +from typing import Optional + +from astroid import nodes +from mypy import api +from pylint.checkers import BaseRawFileChecker +from pylint.lint import PyLinter + + +class StaticTypeChecker(BaseRawFileChecker): + """Checker for static type checking using Mypy.""" + + name = "static_type_checker" + msgs = { + "E9951": ( + "Argument %s to %s has incompatible type %s; expected %s", + "incompatible-argument-type", + "Used when a function argument has an incompatible type", + ), + "E9952": ( + "Incompatible types in assignment (expression has type %s, variable has type %s)", + "incompatible-assignment", + "Used when there is an incompatible assignment", + ), + "E9953": ( + "List item %s has incompatible type %s; expected %s", + "list-item-type-mismatch", + "Used when a list item has an incompatible type", + ), + "E9954": ( + "Unsupported operand types for %s (%s and %s)", + "unsupported-operand-types", + "Used when an operation is attempted between incompatible types", + ), + "E9955": ( + "Item of type %s in Union has no attribute %s", + "union-attr-error", + "Used when accessing an attribute that may not exist on a Union type", + ), + "E9956": ( + "Dict entry %s has incompatible type %s: %s; expected %s: %s", + "dict-item-type-mismatch", + "Used when a dictionary entry has an incompatible key or value type", + ), + } + + COMMON_PATTERN = ( + r"^(?P[^:]+):(?P\d+):(?P\d+):" + r"(?P\d+):(?P\d+): error: (?P.+) \[(?P[\w-]+)\]" + ) + + SPECIFIC_PATTERNS = { + "arg-type": re.compile( + r"Argument (?P\d+) to \"(?P[^\"]+)\" has incompatible type \"(?P[^\"]+)\"; expected \"(?P[^\"]+)\"" + ), + "assignment": re.compile( + r"Incompatible types in assignment \(expression has type \"(?P[^\"]+)\", variable has type \"(?P[^\"]+)\"\)" + ), + "list-item": re.compile( + r"List item (?P\d+) has incompatible type \"(?P[^\"]+)\"; expected \"(?P[^\"]+)\"" + ), + "operator": re.compile( + r"Unsupported operand types for (?P\S+) \(\"(?P[^\"]+)\" and \"(?P[^\"]+)\"\)" + ), + "union-attr": re.compile( + r"Item \"(?P[^\"]+)\" of \"[^\"]+\" has no attribute \"(?P[^\"]+)\"" + ), + "dict-item": re.compile( + r"Dict entry (?P\d+) has incompatible type \"(?P[^\"]+)\": \"(?P[^\"]+)\"; expected \"(?P[^\"]+)\": \"(?P[^\"]+)\"" + ), + } + + def process_module(self, node: nodes.NodeNG) -> None: + """Run Mypy on the current file and handle type errors.""" + filename = node.stream().name + mypy_options = [ + "--ignore-missing-imports", + "--show-error-end", + ] + result, _, _ = api.run([filename] + mypy_options) + + for line in result.splitlines(): + common_match = re.match(self.COMMON_PATTERN, line) + if not common_match: + continue + + common_data = common_match.groupdict() + + specific_pattern = self.SPECIFIC_PATTERNS.get(common_data["code"]) + if specific_pattern: + specific_match = specific_pattern.search(common_data["message"]) + if specific_match: + specific_data = specific_match.groupdict() + self._add_message(common_data, specific_data) + + def _add_message(self, common_data: dict, specific_data: dict) -> None: + """Add a message using the common and specific data.""" + code = common_data["code"] + code_to_msgid = { + "arg-type": "incompatible-argument-type", + "assignment": "incompatible-assignment", + "list-item": "list-item-type-mismatch", + "operator": "unsupported-operand-types", + "union-attr": "union-attr-error", + "dict-item": "dict-item-type-mismatch", + } + msgid = code_to_msgid.get(code) + if not msgid: + return + self.add_message( + msgid, + line=int(common_data["start_line"]), + col_offset=int(common_data["start_col"]), + end_lineno=int(common_data["end_line"]), + end_col_offset=int(common_data["end_col"]), + args=tuple(specific_data.values()), + ) + + +def register(linter: PyLinter) -> None: + """Register the static type checker with the PyLinter.""" + linter.register_checker(StaticTypeChecker(linter)) diff --git a/python_ta/reporters/node_printers.py b/python_ta/reporters/node_printers.py index be3079830..4e34eb1be 100644 --- a/python_ta/reporters/node_printers.py +++ b/python_ta/reporters/node_printers.py @@ -646,6 +646,31 @@ def render_missing_return_statement(msg, node, source_lines=None): yield from render_context(msg.end_line + 1, msg.end_line + 3, source_lines) +def render_static_type_checker_errors(msg, _node=None, source_lines=None): + """Render a message for incompatible argument types.""" + start_line = msg.line + start_col = msg.column + end_line = msg.end_line + end_col = msg.end_column + yield from render_context(start_line - 2, start_line, source_lines) + + if start_line == end_line: + yield ( + start_line, + slice(start_col - 1, end_col), + LineType.ERROR, + source_lines[start_line - 1], + ) + else: + yield (start_line, slice(start_col - 1, None), LineType.ERROR, source_lines[start_line - 1]) + yield from ( + (line, slice(None, None), LineType.ERROR, source_lines[line - 1]) + for line in range(start_line + 1, end_line) + ) + yield (end_line, slice(None, end_col), LineType.ERROR, source_lines[end_line - 1]) + yield from render_context(end_line + 1, end_line + 3, source_lines) + + CUSTOM_MESSAGES = { "missing-module-docstring": render_missing_docstring, "missing-class-docstring": render_missing_docstring, @@ -657,6 +682,12 @@ def render_missing_return_statement(msg, node, source_lines=None): "missing-space-in-doctest": render_missing_space_in_doctest, "pep8-errors": render_pep8_errors, "missing-return-statement": render_missing_return_statement, + "incompatible-argument-type": render_static_type_checker_errors, + "incompatible-assignment": render_static_type_checker_errors, + "list-item-type-mismatch": render_static_type_checker_errors, + "unsupported-operand-types": render_static_type_checker_errors, + "union-attr-error": render_static_type_checker_errors, + "dict-item-type-mismatch": render_static_type_checker_errors, } diff --git a/tests/test_custom_checkers/test_static_type_checker.py b/tests/test_custom_checkers/test_static_type_checker.py new file mode 100644 index 000000000..d523db7e9 --- /dev/null +++ b/tests/test_custom_checkers/test_static_type_checker.py @@ -0,0 +1,219 @@ +import os + +import pylint.testutils +import pytest +from astroid import MANAGER + +from python_ta.checkers.static_type_checker import StaticTypeChecker + + +class TestStaticTypeChecker(pylint.testutils.CheckerTestCase): + CHECKER_CLASS = StaticTypeChecker + + def test_e9951_incompatible_argument_type(self) -> None: + file_path = os.path.normpath( + os.path.join( + __file__, + "../../../examples/custom_checkers/static_type_checker_examples/e9951_incompatible_argument_type.py", + ) + ) + mod = MANAGER.ast_from_file(file_path) + with self.assertAddsMessages( + pylint.testutils.MessageTest( + msg_id="incompatible-argument-type", + line=5, + col_offset=23, + end_line=5, + end_col_offset=28, + args=("1", "calculate_area", "str", "float"), + ), + pylint.testutils.MessageTest( + msg_id="incompatible-argument-type", + line=11, + col_offset=27, + end_line=11, + end_col_offset=27, + args=("1", "convert_to_upper", "int", "str"), + ), + ignore_position=True, + ): + self.checker.process_module(mod) + + def test_e9952_incompatible_assignment(self) -> None: + file_path = os.path.normpath( + os.path.join( + __file__, + "../../../examples/custom_checkers/static_type_checker_examples/e9952_incompatible_assignment.py", + ) + ) + mod = MANAGER.ast_from_file(file_path) + with self.assertAddsMessages( + pylint.testutils.MessageTest( + msg_id="incompatible-assignment", + line=2, + col_offset=7, + end_line=2, + end_col_offset=19, + args=("str", "int"), + ), + pylint.testutils.MessageTest( + msg_id="incompatible-assignment", + line=4, + col_offset=14, + end_line=4, + end_col_offset=18, + args=("str", "int"), + ), + ): + self.checker.process_module(mod) + + def test_e9953_list_item_type_mismatch(self) -> None: + file_path = os.path.normpath( + os.path.join( + __file__, + "../../../examples/custom_checkers/static_type_checker_examples/e9953_list_item_type_mismatch.py", + ) + ) + mod = MANAGER.ast_from_file(file_path) + with self.assertAddsMessages( + pylint.testutils.MessageTest( + msg_id="list-item-type-mismatch", + line=1, + col_offset=37, + end_line=1, + end_col_offset=37, + args=("2", "int", "str"), + ), + pylint.testutils.MessageTest( + msg_id="list-item-type-mismatch", + line=3, + col_offset=29, + end_line=3, + end_col_offset=35, + args=("2", "str", "int"), + ), + pylint.testutils.MessageTest( + msg_id="list-item-type-mismatch", + line=5, + col_offset=33, + end_line=5, + end_col_offset=37, + args=("2", "str", "float"), + ), + ): + self.checker.process_module(mod) + + def test_e9954_unsupported_operand_types(self) -> None: + file_path = os.path.normpath( + os.path.join( + __file__, + "../../../examples/custom_checkers/static_type_checker_examples/e9954_unsupported_operand_types.py", + ) + ) + mod = MANAGER.ast_from_file(file_path) + with self.assertAddsMessages( + pylint.testutils.MessageTest( + msg_id="unsupported-operand-types", + line=1, + col_offset=10, + end_line=1, + end_col_offset=10, + args=("-", "str", "int"), + ), + pylint.testutils.MessageTest( + msg_id="unsupported-operand-types", + line=3, + col_offset=14, + end_line=3, + end_col_offset=17, + args=("+", "int", "str"), + ), + pylint.testutils.MessageTest( + msg_id="unsupported-operand-types", + line=5, + col_offset=15, + end_line=5, + end_col_offset=17, + args=("*", "float", "str"), + ), + ): + self.checker.process_module(mod) + + def test_e9955_union_attr_error(self) -> None: + """Test for union attribute errors (E9955).""" + file_path = os.path.normpath( + os.path.join( + __file__, + "../../../examples/custom_checkers/static_type_checker_examples/e9955_union_attr_error.py", + ) + ) + mod = MANAGER.ast_from_file(file_path) + with self.assertAddsMessages( + pylint.testutils.MessageTest( + msg_id="union-attr-error", + line=4, + col_offset=13, + end_line=5, + end_col_offset=18, + args=("int", "upper"), + ), + pylint.testutils.MessageTest( + msg_id="union-attr-error", + line=4, + col_offset=13, + end_line=5, + end_col_offset=18, + args=("float", "upper"), + ), + pylint.testutils.MessageTest( + msg_id="union-attr-error", + line=9, + col_offset=12, + end_line=9, + end_col_offset=20, + args=("list[Any]", "keys"), + ), + ): + self.checker.process_module(mod) + + def test_e9956_dict_item_type_mismatch(self) -> None: + file_path = os.path.normpath( + os.path.join( + __file__, + "../../../examples/custom_checkers/static_type_checker_examples/e9956_dict_item_type_mismatch.py", + ) + ) + if not os.path.exists(file_path): + raise FileNotFoundError(f"Test file not found: {file_path}") + + mod = MANAGER.ast_from_file(file_path) + with self.assertAddsMessages( + pylint.testutils.MessageTest( + msg_id="dict-item-type-mismatch", + line=1, + col_offset=45, + end_line=1, + end_col_offset=54, + args=("2", "str", "int", "int", "str"), + ), + pylint.testutils.MessageTest( + msg_id="dict-item-type-mismatch", + line=3, + col_offset=50, + end_line=3, + end_col_offset=60, + args=("2", "int", "str", "str", "float"), + ), + ): + self.checker.process_module(mod) + + def test_no_errors_in_static_type_checker_no_error(self) -> None: + file_path = os.path.normpath( + os.path.join( + __file__, + "../../../examples/custom_checkers/static_type_checker_examples/static_type_checker_no_error.py", + ) + ) + mod = MANAGER.ast_from_file(file_path) + with self.assertNoMessages(): + self.checker.process_module(mod)