Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
dependency: ["base", "z3-solver"]

steps:
Expand Down Expand Up @@ -81,16 +81,16 @@ jobs:
github-token: ${{ secrets.GITHUB_TOKEN }}
with:
parallel-finished: true
carryforward: "run-3.9-base,run-3.9-z3-solver,run-3.10-base,run-3.10-z3-solver,run-3.11-base,run-3.11-z3-solver,run-3.12-base,run-3.12-z3-solver,run-3.13-base,run-3.13-z3-solver"
carryforward: "run-3.10-base,run-3.10-z3-solver,run-3.11-base,run-3.11-z3-solver,run-3.12-base,run-3.12-z3-solver,run-3.13-base,run-3.13-z3-solver,run-3.14-base,run-3.14-z3-solver"

docs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Set up Python 3.11
- name: Set up Python 3.14
uses: actions/setup-python@v6
with:
python-version: 3.11
python-version: 3.14
- name: Cache pip
uses: actions/[email protected]
with:
Expand Down
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,21 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- Improved RI checking to raise a warning when a `NameError` is raised and the missing name matches an instance attribute, and is due to an omitted `self.` in the RI.
- Extended `AccumulationTable` class to support multiple loops in sequence within the same context manager
- Added a solution to prevent possible large snippets created by the `render_generic` function
- Support Python 3.14

### 💫 New checkers

Pylint checkers v4.0:

- `break-in-finally`
- `deprecated-attribute`

For more information on these checkers, please see the
[Pylint release notes](http://pylint.pycqa.org/en/latest/whatsnew/index.html). Note that the above
list only contains the Pylint checkers enabled by default in PythonTA.

Custom checkers:

- `unnecessary-f-string`: Added new checker that checks f-string to see if it only consists of a single bare format expression that can be replaced with the string representation of that expression
- `simplifiable-if`: Added a new checker that checks if an `if` or `elif` branch only contains a single nested `if` statement with a single branch.

Expand All @@ -42,6 +54,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- Fixed `test_html_server.py` tests to be compatible with Windows
- Updated `conftest.py` and `test_black.py` to be compatible with pytest v9, removing usage of some deprecated features.
- Updated tests in `test_accumulation_table.py` to cover multi-loop behavior and added new cases
- Updated to pylint v4.0 and astroid v4.0

## [2.11.1] - 2025-08-17

Expand Down
7 changes: 4 additions & 3 deletions docs/usage/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,12 +66,12 @@ The following Pylint checks are disabled by default.
You can re-enable them by using the `enable` option.

```text
E0100, E0105, E0106, E0110, E0112, E0113, E0114, E0115, E0116, E0117, E0118,
E0100, E0105, E0106, E0110, E0112, E0113, E0114, E0115, E0117, E0118,
E0236, E0237, E0238, E0240, E0242, E0243, E0244, E0305, E0308, E0309, E0310, E0311, E0312, E0313,
E0402,
E0603, E0604, E0605, E0606,
E0703, W0707,
E1124, E1125, E1132, E1139, E1142,
E1124, E1125, E1132, E1139, E1142, E1145,
E1200, E1201, E1205, E1206,
E1300, E1301, E1302, E1303, E1304,
W1406,
Expand Down Expand Up @@ -105,7 +105,8 @@ threading,
unnecessary-dunder-call,
unsupported_version,
E2502, E2510, E2511, E2512, E2513, E2514, E2515,
missing-timeout, positional-only-arguments-expected
missing-timeout, positional-only-arguments-expected,
match_statements,
```

## PythonTA general configuration options
Expand Down
11 changes: 5 additions & 6 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ authors = [
license = {text = "MIT"}
readme = "README.md"
dependencies = [
"aiohttp >= 3.11.18,< 3.14.0",
"astroid ~= 3.3.5",
"aiohttp >= 3.13.0,< 3.14.0",
"astroid ~= 4.0.2",
"black",
"click >= 8.0.1, < 9",
"colorama ~= 0.4.6",
Expand All @@ -17,7 +17,7 @@ dependencies = [
"mypy ~= 1.13",
"pycodestyle ~= 2.11",
"pygments >= 2.14,< 2.20",
"pylint ~= 3.3.1",
"pylint ~= 4.0.3",
"requests >= 2.28,< 2.33",
"six",
"tabulate ~= 0.9.0",
Expand All @@ -27,7 +27,7 @@ dependencies = [
"wrapt >= 1.15.0, < 3"
]
dynamic = ["version"]
requires-python = ">=3.9"
requires-python = ">=3.10"

[project.optional-dependencies]
dev = [
Expand All @@ -49,7 +49,6 @@ cfg = [
]
z3 = [
"z3-solver",
"importlib_resources ; python_version<'3.9'"
]

[project.scripts]
Expand Down Expand Up @@ -83,7 +82,7 @@ extend-exclude = '''
^/tests/fixtures/
'''
line-length = 100
target-version = ['py38']
target-version = ['py310', 'py311', 'py312', 'py313', 'py314']


[tool.isort]
Expand Down
4 changes: 2 additions & 2 deletions python_ta/cfg/cfg_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

import graphviz
from astroid import nodes
from astroid.builder import AstroidBuilder
from astroid.manager import AstroidManager

from .visitor import CFGVisitor

Expand Down Expand Up @@ -72,7 +72,7 @@ def _generate(
return

file_name = os.path.splitext(os.path.basename(abs_path))[0]
module = AstroidBuilder().file_build(abs_path)
module = AstroidManager().ast_from_file(abs_path)

# invoke Z3Visitor if z3 dependency is enabled
if z3_enabled:
Expand Down
2 changes: 1 addition & 1 deletion python_ta/cfg/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
except ImportError:
ExprRef = Any

from astroid import (
from astroid.nodes import (
AnnAssign,
Arguments,
Assign,
Expand Down
4 changes: 2 additions & 2 deletions python_ta/checkers/forbidden_io_function_checker.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from re import sub
from typing import Union

from astroid import BoundMethod, FunctionDef, nodes
from astroid import BoundMethod, nodes
from pylint.checkers import BaseChecker
from pylint.checkers.utils import only_required_for_messages, safe_infer
from pylint.lint import PyLinter
Expand Down Expand Up @@ -73,7 +73,7 @@ def visit_call(self, node: nodes.Call) -> None:
def _resolve_qualname(node: nodes.Call) -> Union[str, None]:
"""Resolves the qualified name for function and method calls"""
if (inferred_definition := safe_infer(node.func)) is not None:
if isinstance(inferred_definition, (BoundMethod, FunctionDef)):
if isinstance(inferred_definition, (BoundMethod, nodes.FunctionDef)):
return sub(r"^[^.]*\.", "", inferred_definition.qname())
if isinstance(node.func, nodes.Name):
return node.func.name
Expand Down
7 changes: 4 additions & 3 deletions python_ta/config/.pylintrc
Original file line number Diff line number Diff line change
Expand Up @@ -74,12 +74,12 @@ ignore-module-names =

# Disable the message, report, category or checker with the given id(s).
disable=
E0100, E0105, E0106, E0110, E0112, E0113, E0114, E0115, E0116, E0117, E0118,
E0100, E0105, E0106, E0110, E0112, E0113, E0114, E0115, E0117, E0118,
E0236, E0237, E0238, E0240, E0242, E0243, E0244, E0305, E0308, E0309, E0310, E0311, E0312, E0313,
E0402,
E0603, E0604, E0605, E0606,
E0703, W0707,
E1124, E1125, E1132, E1139, E1142,
E1124, E1125, E1132, E1139, E1142, E1145,
E1200, E1201, E1205, E1206,
E1300, E1301, E1302, E1303, E1304,
W1406,
Expand Down Expand Up @@ -113,7 +113,8 @@ disable=
unnecessary-dunder-call,
unsupported_version,
E2502, E2510, E2511, E2512, E2513, E2514, E2515,
missing-timeout, positional-only-arguments-expected
missing-timeout, positional-only-arguments-expected,
match_statements,


# Enable single-letter identifiers
Expand Down
3 changes: 3 additions & 0 deletions python_ta/contracts/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,9 @@ def new_setattr(self: klass, name: str, value: Any) -> None:
mutated_instances.append(self)

for attr, value in klass.__dict__.items():
# Skip built-in __annotate_func__, which was introduced in Python 3.14
if attr == "__annotate_func__":
continue
if inspect.isroutine(value):
if isinstance(value, (staticmethod, classmethod)):
# Don't check rep invariants for staticmethod and classmethod
Expand Down
24 changes: 14 additions & 10 deletions python_ta/debug/accumulation_table.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@

import astroid
import tabulate
from astroid.nodes import (
AssignName,
For,
Module,
NodeNG,
While,
)

if TYPE_CHECKING:
import types
Expand Down Expand Up @@ -43,18 +50,18 @@ def get_with_lines(lines: list[str], num_whitespace: int) -> str:
return "\n".join(lines[:endpoint])


def _is_nested_loop(node: astroid.NodeNG, with_module: astroid.Module) -> bool:
def _is_nested_loop(node: NodeNG, with_module: Module) -> bool:
"""Helper checks the ancestor chain of a given loop node (For or While) to check if it is nested within the context
manager."""
curr_node = node
while curr_node.parent is not None and curr_node.parent is not with_module:
if isinstance(curr_node.parent, (astroid.For, astroid.While)):
if isinstance(curr_node.parent, (For, While)):
return True
curr_node = node.parent
return False


def get_loop_nodes(frame: types.FrameType) -> Generator[Union[astroid.For, astroid.While]]:
def get_loop_nodes(frame: types.FrameType) -> Generator[Union[For, While]]:
"""Yield the For or While node(s) from the frame containing the accumulator loop(s)"""
func_string = inspect.cleandoc(inspect.getsource(frame))
with_stmt_index = inspect.getlineno(frame) - frame.f_code.co_firstlineno
Expand All @@ -64,10 +71,8 @@ def get_loop_nodes(frame: types.FrameType) -> Generator[Union[astroid.For, astro
with_lines = get_with_lines(lst_from_with_stmt, num_whitespace)

with_module = astroid.parse(with_lines)
for statement in with_module.nodes_of_class((astroid.For, astroid.While)):
if isinstance(statement, (astroid.For, astroid.While)) and not _is_nested_loop(
statement, with_module
):
for statement in with_module.nodes_of_class((For, While)):
if isinstance(statement, (For, While)) and not _is_nested_loop(statement, with_module):
yield statement


Expand Down Expand Up @@ -253,10 +258,9 @@ def _setup_table(self) -> None:
loop_lineno = inspect.getlineno(func_frame) + node.lineno
loop_variables = {}

if isinstance(node, astroid.For):
if isinstance(node, For):
loop_variables = {
nested_node.name: []
for nested_node in node.target.nodes_of_class(astroid.AssignName)
nested_node.name: [] for nested_node in node.target.nodes_of_class(AssignName)
}

# Determine accumulators for this specific loop
Expand Down
3 changes: 2 additions & 1 deletion python_ta/util/servers/persistent_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ def __init__(self, port: int):
self.latest_html = LOADING_HTML
self.websockets = set()
self.server_started = False
self.loop = asyncio.get_event_loop()
self.loop = asyncio.new_event_loop()
asyncio.set_event_loop(self.loop)

async def handle_report(self, request: web.Request) -> web.Response:
"""Serve the current HTML content at the root endpoint ('/')."""
Expand Down
22 changes: 11 additions & 11 deletions python_ta/z3/z3_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,15 @@ class Z3Parser:
- types: dictionary mapping variable names in astroid expression to their type name or z3 variable.
"""

node: astroid.NodeNG
node: nodes.NodeNG
types: dict[str, Union[str, z3.ExprRef]]

def __init__(self, types: Optional[dict[str, Union[str, z3.ExprRef]]] = None):
if types is None:
types = {}
self.types = types

def parse(self, node: astroid.NodeNG) -> z3.ExprRef:
def parse(self, node: nodes.NodeNG) -> z3.ExprRef:
"""
Convert astroid node to z3 expression and return it.
If an error is encountered or a case is not considered, return None.
Expand Down Expand Up @@ -86,7 +86,7 @@ def apply_name(self, name: str) -> z3.ExprRef:

return x

def parse_compare(self, node: astroid.Compare) -> z3.ExprRef:
def parse_compare(self, node: nodes.Compare) -> z3.ExprRef:
"""Convert an astroid Compare node to z3 expression."""
left, ops = node.left, node.ops
left = self.parse(left)
Expand Down Expand Up @@ -160,29 +160,29 @@ def apply_bool_op(self, op: str, values: Union[z3.ExprRef, list[z3.ExprRef]]) ->

return value

def parse_unary_op(self, node: astroid.UnaryOp) -> z3.ExprRef:
def parse_unary_op(self, node: nodes.UnaryOp) -> z3.ExprRef:
"""Convert an astroid UnaryOp node to a z3 expression."""
left, op = node.operand, node.op
left = self.parse(left)
return self.apply_unary_op(left, op)

def parse_bin_op(self, node: astroid.BinOp) -> z3.ExprRef:
def parse_bin_op(self, node: nodes.BinOp) -> z3.ExprRef:
"""Convert an astroid BinOp node to a z3 expression."""
left, op, right = node.left, node.op, node.right
left = self.parse(left)
right = self.parse(right)

return self.apply_bin_op(left, op, right)

def parse_bool_op(self, node: astroid.BoolOp) -> z3.ExprRef:
def parse_bool_op(self, node: nodes.BoolOp) -> z3.ExprRef:
"""Convert an astroid BoolOp node to a z3 expression."""
op, values = node.op, node.values
values = [self.parse(x) for x in values]

return self.apply_bool_op(op, values)

def parse_container_op(
self, node: Union[nodes.List, astroid.Set, astroid.Tuple]
self, node: Union[nodes.List, nodes.Set, nodes.Tuple]
) -> list[z3.ExprRef]:
"""Convert an astroid List, Set, Tuple node to a list of z3 expressions."""
return [self.parse(element) for element in node.elts]
Expand Down Expand Up @@ -214,7 +214,7 @@ def apply_in_op(
f"Unhandled binary operation {op} with operator types {left} and {right}."
)

def _parse_number_literal(self, node: astroid.NodeNG) -> Optional[Union[int, float]]:
def _parse_number_literal(self, node: nodes.NodeNG) -> Optional[Union[int, float]]:
"""
If the subtree from `node` represent a number literal, return the value
Otherwise, return None
Expand All @@ -233,7 +233,7 @@ def _parse_number_literal(self, node: astroid.NodeNG) -> Optional[Union[int, flo
else:
return None

def parse_subscript_op(self, node: astroid.Subscript) -> z3.ExprRef:
def parse_subscript_op(self, node: nodes.Subscript) -> z3.ExprRef:
"""
Convert an astroid Subscript node to z3 expression.
This method only supports string values and integer literal (both positive and negative) indexes
Expand Down Expand Up @@ -278,7 +278,7 @@ def parse_subscript_op(self, node: astroid.Subscript) -> z3.ExprRef:

raise Z3ParseException(f"Unhandled subscript operator type {slice}")

def parse_arguments(self, node: astroid.Arguments) -> dict[str, z3.ExprRef]:
def parse_arguments(self, node: nodes.Arguments) -> dict[str, z3.ExprRef]:
"""Convert an astroid Arguments node's parameters to z3 variables."""
z3_vars = {}

Expand All @@ -289,7 +289,7 @@ def parse_arguments(self, node: astroid.Arguments) -> dict[str, z3.ExprRef]:
continue

inferred = safe_infer(ann)
if inferred is None or not isinstance(inferred, astroid.ClassDef):
if inferred is None or not isinstance(inferred, nodes.ClassDef):
continue

self.types[arg.name] = inferred.name
Expand Down
Loading