Skip to content

Commit

Permalink
Enable checks based on mypy static type checking (#1115)
Browse files Browse the repository at this point in the history
Adds mypy to project dependencies.
  • Loading branch information
CulmoneY authored Jan 7, 2025
1 parent ab2e81d commit 6e44c4d
Show file tree
Hide file tree
Showing 13 changed files with 526 additions and 0 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
65 changes: 65 additions & 0 deletions docs/checkers/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)=
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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")
Original file line number Diff line number Diff line change
@@ -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"
Original file line number Diff line number Diff line change
@@ -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")
Original file line number Diff line number Diff line change
@@ -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"
Original file line number Diff line number Diff line change
@@ -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"
Original file line number Diff line number Diff line change
@@ -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")
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
122 changes: 122 additions & 0 deletions python_ta/checkers/static_type_checker.py
Original file line number Diff line number Diff line change
@@ -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<file>[^:]+):(?P<start_line>\d+):(?P<start_col>\d+):"
r"(?P<end_line>\d+):(?P<end_col>\d+): error: (?P<message>.+) \[(?P<code>[\w-]+)\]"
)

SPECIFIC_PATTERNS = {
"arg-type": re.compile(
r"Argument (?P<arg_num>\d+) to \"(?P<func_name>[^\"]+)\" has incompatible type \"(?P<incomp_type>[^\"]+)\"; expected \"(?P<exp_type>[^\"]+)\""
),
"assignment": re.compile(
r"Incompatible types in assignment \(expression has type \"(?P<expr_type>[^\"]+)\", variable has type \"(?P<var_type>[^\"]+)\"\)"
),
"list-item": re.compile(
r"List item (?P<item_index>\d+) has incompatible type \"(?P<item_type>[^\"]+)\"; expected \"(?P<exp_type>[^\"]+)\""
),
"operator": re.compile(
r"Unsupported operand types for (?P<operator>\S+) \(\"(?P<left_type>[^\"]+)\" and \"(?P<right_type>[^\"]+)\"\)"
),
"union-attr": re.compile(
r"Item \"(?P<item_type>[^\"]+)\" of \"[^\"]+\" has no attribute \"(?P<attribute>[^\"]+)\""
),
"dict-item": re.compile(
r"Dict entry (?P<entry_index>\d+) has incompatible type \"(?P<key_type>[^\"]+)\": \"(?P<value_type>[^\"]+)\"; expected \"(?P<exp_key_type>[^\"]+)\": \"(?P<exp_value_type>[^\"]+)\""
),
}

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))
31 changes: 31 additions & 0 deletions python_ta/reporters/node_printers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
}


Expand Down
Loading

0 comments on commit 6e44c4d

Please sign in to comment.