From d5f854ba46f659622b59ff1de7e8aea5db905d67 Mon Sep 17 00:00:00 2001 From: Pierre Sassoulas Date: Sun, 14 Sep 2025 12:16:04 +0200 Subject: [PATCH 1/4] [vendor mccabe] Vendor mccabe to reduce supply chain risks and optimize analysis --- pylint/extensions/mccabe.py | 224 +++++++++++++++++++++++++++++++++++- pyproject.toml | 2 +- 2 files changed, 222 insertions(+), 4 deletions(-) diff --git a/pylint/extensions/mccabe.py b/pylint/extensions/mccabe.py index 3cdbbc162d..ca8f67d4a5 100644 --- a/pylint/extensions/mccabe.py +++ b/pylint/extensions/mccabe.py @@ -2,16 +2,27 @@ # For details: https://github.com/pylint-dev/pylint/blob/main/LICENSE # Copyright (c) https://github.com/pylint-dev/pylint/blob/main/CONTRIBUTORS.txt -"""Module to add McCabe checker class for pylint.""" +# mypy: ignore-errors +# pylint: disable=consider-using-f-string,inconsistent-return-statements,consider-using-generator,redefined-builtin +# pylint: disable=super-with-arguments,too-many-function-args,bad-super-call + +"""Module to add McCabe checker class for pylint. + +Based on: +http://nedbatchelder.com/blog/200803/python_code_complexity_microtool.html +Later integrated in pycqa/mccabe under the MIT License then vendored in pylint +under the GPL License. +""" from __future__ import annotations +import ast +from ast import iter_child_nodes +from collections import defaultdict from collections.abc import Sequence from typing import TYPE_CHECKING, Any, TypeAlias, TypeVar from astroid import nodes -from mccabe import PathGraph as Mccabe_PathGraph -from mccabe import PathGraphingAstVisitor as Mccabe_PathGraphingAstVisitor from pylint import checkers from pylint.checkers.utils import only_required_for_messages @@ -20,6 +31,213 @@ if TYPE_CHECKING: from pylint.lint import PyLinter + +class ASTVisitor: + """Performs a depth-first walk of the AST.""" + + def __init__(self): + self.node = None + self._cache = {} + + def default(self, node, *args): + for child in iter_child_nodes(node): + self.dispatch(child, *args) + + def dispatch(self, node, *args): + self.node = node + klass = node.__class__ + meth = self._cache.get(klass) + if meth is None: + className = klass.__name__ + meth = getattr(self.visitor, "visit" + className, self.default) + self._cache[klass] = meth + return meth(node, *args) + + def preorder(self, tree, visitor, *args): + """Do preorder walk of tree using visitor.""" + self.visitor = visitor + visitor.visit = self.dispatch + self.dispatch(tree, *args) # XXX *args make sense? + + +class PathNode: + def __init__(self, name, look="circle"): + self.name = name + self.look = look + + def to_dot(self): + print('node [shape=%s,label="%s"] %d;' % (self.look, self.name, self.dot_id())) + + def dot_id(self): + return id(self) + + +class Mccabe_PathGraph: + def __init__(self, name, entity, lineno, column=0): + self.name = name + self.entity = entity + self.lineno = lineno + self.column = column + self.nodes = defaultdict(list) + + def connect(self, n1, n2): + self.nodes[n1].append(n2) + # Ensure that the destination node is always counted. + self.nodes[n2] = [] + + def to_dot(self): + print("subgraph {") + for node in self.nodes: + node.to_dot() + for node, nexts in self.nodes.items(): + for next in nexts: + print("%s -- %s;" % (node.dot_id(), next.dot_id())) + print("}") + + def complexity(self): + """Return the McCabe complexity for the graph. + + V-E+2 + """ + num_edges = sum([len(n) for n in self.nodes.values()]) + num_nodes = len(self.nodes) + return num_edges - num_nodes + 2 + + +class Mccabe_PathGraphingAstVisitor(ASTVisitor): + """A visitor for a parsed Abstract Syntax Tree which finds executable + statements. + """ + + def __init__(self): + super(Mccabe_PathGraphingAstVisitor, self).__init__() + self.classname = "" + self.graphs = {} + self.reset() + + def reset(self): + self.graph = None + self.tail = None + + def dispatch_list(self, node_list): + for node in node_list: + self.dispatch(node) + + def visitFunctionDef(self, node): + + if self.classname: + entity = "%s%s" % (self.classname, node.name) + else: + entity = node.name + + name = "%d:%d: %r" % (node.lineno, node.col_offset, entity) + + if self.graph is not None: + # closure + pathnode = self.appendPathNode(name) + self.tail = pathnode + self.dispatch_list(node.body) + bottom = PathNode("", look="point") + self.graph.connect(self.tail, bottom) + self.graph.connect(pathnode, bottom) + self.tail = bottom + else: + self.graph = PathGraph(name, entity, node.lineno, node.col_offset) + pathnode = PathNode(name) + self.tail = pathnode + self.dispatch_list(node.body) + self.graphs["%s%s" % (self.classname, node.name)] = self.graph + self.reset() + + visitAsyncFunctionDef = visitFunctionDef + + def visitClassDef(self, node): + old_classname = self.classname + self.classname += node.name + "." + self.dispatch_list(node.body) + self.classname = old_classname + + def appendPathNode(self, name): + if not self.tail: + return + pathnode = PathNode(name) + self.graph.connect(self.tail, pathnode) + self.tail = pathnode + return pathnode + + def visitSimpleStatement(self, node): + if node.lineno is None: + lineno = 0 + else: + lineno = node.lineno + name = "Stmt %d" % lineno + self.appendPathNode(name) + + def default(self, node, *args): + if isinstance(node, ast.stmt): + self.visitSimpleStatement(node) + else: + super(PathGraphingAstVisitor, self).default(node, *args) + + def visitLoop(self, node): + name = "Loop %d" % node.lineno + self._subgraph(node, name) + + visitAsyncFor = visitFor = visitWhile = visitLoop + + def visitIf(self, node): + name = "If %d" % node.lineno + self._subgraph(node, name) + + def _subgraph(self, node, name, extra_blocks=()): + """Create the subgraphs representing any `if` and `for` statements.""" + if self.graph is None: + # global loop + self.graph = PathGraph(name, name, node.lineno, node.col_offset) + pathnode = PathNode(name) + self._subgraph_parse(node, pathnode, extra_blocks) + self.graphs["%s%s" % (self.classname, name)] = self.graph + self.reset() + else: + pathnode = self.appendPathNode(name) + self._subgraph_parse(node, pathnode, extra_blocks) + + def _subgraph_parse(self, node, pathnode, extra_blocks): + """Parse the body and any `else` block of `if` and `for` statements.""" + loose_ends = [] + self.tail = pathnode + self.dispatch_list(node.body) + loose_ends.append(self.tail) + for extra in extra_blocks: + self.tail = pathnode + self.dispatch_list(extra.body) + loose_ends.append(self.tail) + if node.orelse: + self.tail = pathnode + self.dispatch_list(node.orelse) + loose_ends.append(self.tail) + else: + loose_ends.append(pathnode) + if pathnode: + bottom = PathNode("", look="point") + for le in loose_ends: + self.graph.connect(le, bottom) + self.tail = bottom + + def visitTryExcept(self, node): + name = "TryExcept %d" % node.lineno + self._subgraph(node, name, extra_blocks=node.handlers) + + visitTry = visitTryExcept + + def visitWith(self, node): + name = "With %d" % node.lineno + self.appendPathNode(name) + self.dispatch_list(node.body) + + visitAsyncWith = visitWith + + _StatementNodes: TypeAlias = ( nodes.Assert | nodes.Assign diff --git a/pyproject.toml b/pyproject.toml index e7215271d8..19bea034bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,7 +45,6 @@ dependencies = [ "dill>=0.3.6; python_version>='3.11'", "dill>=0.3.7; python_version>='3.12'", "isort>=4.2.5,!=5.13,<7", - "mccabe>=0.6,<0.8", "platformdirs>=2.2", "tomli>=1.1; python_version<'3.11'", "tomlkit>=0.10.1", @@ -177,6 +176,7 @@ lint.ignore = [ "PTH208", # Use `pathlib.Path.iterdir()` instead" "RUF012", # mutable default values in class attributes ] +lint.per-file-ignores."pylint/extensions/mccabe.py" = [ "UP008", "UP031" ] lint.pydocstyle.convention = "pep257" [tool.isort] From 9fbf3ce2238d84822b34333a1d2d4f0a1f3e03f4 Mon Sep 17 00:00:00 2001 From: Pierre Sassoulas Date: Mon, 15 Sep 2025 22:05:59 +0200 Subject: [PATCH 2/4] Merge mccabe to what is in pylint, remove unused code --- pylint/extensions/mccabe.py | 282 +++++++++--------------------------- pyproject.toml | 1 - 2 files changed, 65 insertions(+), 218 deletions(-) diff --git a/pylint/extensions/mccabe.py b/pylint/extensions/mccabe.py index ca8f67d4a5..23612ad91d 100644 --- a/pylint/extensions/mccabe.py +++ b/pylint/extensions/mccabe.py @@ -3,8 +3,7 @@ # Copyright (c) https://github.com/pylint-dev/pylint/blob/main/CONTRIBUTORS.txt # mypy: ignore-errors -# pylint: disable=consider-using-f-string,inconsistent-return-statements,consider-using-generator,redefined-builtin -# pylint: disable=super-with-arguments,too-many-function-args,bad-super-call +# pylint: disable=unused-argument,consider-using-generator """Module to add McCabe checker class for pylint. @@ -16,9 +15,6 @@ from __future__ import annotations -import ast -from ast import iter_child_nodes -from collections import defaultdict from collections.abc import Sequence from typing import TYPE_CHECKING, Any, TypeAlias, TypeVar @@ -32,212 +28,6 @@ from pylint.lint import PyLinter -class ASTVisitor: - """Performs a depth-first walk of the AST.""" - - def __init__(self): - self.node = None - self._cache = {} - - def default(self, node, *args): - for child in iter_child_nodes(node): - self.dispatch(child, *args) - - def dispatch(self, node, *args): - self.node = node - klass = node.__class__ - meth = self._cache.get(klass) - if meth is None: - className = klass.__name__ - meth = getattr(self.visitor, "visit" + className, self.default) - self._cache[klass] = meth - return meth(node, *args) - - def preorder(self, tree, visitor, *args): - """Do preorder walk of tree using visitor.""" - self.visitor = visitor - visitor.visit = self.dispatch - self.dispatch(tree, *args) # XXX *args make sense? - - -class PathNode: - def __init__(self, name, look="circle"): - self.name = name - self.look = look - - def to_dot(self): - print('node [shape=%s,label="%s"] %d;' % (self.look, self.name, self.dot_id())) - - def dot_id(self): - return id(self) - - -class Mccabe_PathGraph: - def __init__(self, name, entity, lineno, column=0): - self.name = name - self.entity = entity - self.lineno = lineno - self.column = column - self.nodes = defaultdict(list) - - def connect(self, n1, n2): - self.nodes[n1].append(n2) - # Ensure that the destination node is always counted. - self.nodes[n2] = [] - - def to_dot(self): - print("subgraph {") - for node in self.nodes: - node.to_dot() - for node, nexts in self.nodes.items(): - for next in nexts: - print("%s -- %s;" % (node.dot_id(), next.dot_id())) - print("}") - - def complexity(self): - """Return the McCabe complexity for the graph. - - V-E+2 - """ - num_edges = sum([len(n) for n in self.nodes.values()]) - num_nodes = len(self.nodes) - return num_edges - num_nodes + 2 - - -class Mccabe_PathGraphingAstVisitor(ASTVisitor): - """A visitor for a parsed Abstract Syntax Tree which finds executable - statements. - """ - - def __init__(self): - super(Mccabe_PathGraphingAstVisitor, self).__init__() - self.classname = "" - self.graphs = {} - self.reset() - - def reset(self): - self.graph = None - self.tail = None - - def dispatch_list(self, node_list): - for node in node_list: - self.dispatch(node) - - def visitFunctionDef(self, node): - - if self.classname: - entity = "%s%s" % (self.classname, node.name) - else: - entity = node.name - - name = "%d:%d: %r" % (node.lineno, node.col_offset, entity) - - if self.graph is not None: - # closure - pathnode = self.appendPathNode(name) - self.tail = pathnode - self.dispatch_list(node.body) - bottom = PathNode("", look="point") - self.graph.connect(self.tail, bottom) - self.graph.connect(pathnode, bottom) - self.tail = bottom - else: - self.graph = PathGraph(name, entity, node.lineno, node.col_offset) - pathnode = PathNode(name) - self.tail = pathnode - self.dispatch_list(node.body) - self.graphs["%s%s" % (self.classname, node.name)] = self.graph - self.reset() - - visitAsyncFunctionDef = visitFunctionDef - - def visitClassDef(self, node): - old_classname = self.classname - self.classname += node.name + "." - self.dispatch_list(node.body) - self.classname = old_classname - - def appendPathNode(self, name): - if not self.tail: - return - pathnode = PathNode(name) - self.graph.connect(self.tail, pathnode) - self.tail = pathnode - return pathnode - - def visitSimpleStatement(self, node): - if node.lineno is None: - lineno = 0 - else: - lineno = node.lineno - name = "Stmt %d" % lineno - self.appendPathNode(name) - - def default(self, node, *args): - if isinstance(node, ast.stmt): - self.visitSimpleStatement(node) - else: - super(PathGraphingAstVisitor, self).default(node, *args) - - def visitLoop(self, node): - name = "Loop %d" % node.lineno - self._subgraph(node, name) - - visitAsyncFor = visitFor = visitWhile = visitLoop - - def visitIf(self, node): - name = "If %d" % node.lineno - self._subgraph(node, name) - - def _subgraph(self, node, name, extra_blocks=()): - """Create the subgraphs representing any `if` and `for` statements.""" - if self.graph is None: - # global loop - self.graph = PathGraph(name, name, node.lineno, node.col_offset) - pathnode = PathNode(name) - self._subgraph_parse(node, pathnode, extra_blocks) - self.graphs["%s%s" % (self.classname, name)] = self.graph - self.reset() - else: - pathnode = self.appendPathNode(name) - self._subgraph_parse(node, pathnode, extra_blocks) - - def _subgraph_parse(self, node, pathnode, extra_blocks): - """Parse the body and any `else` block of `if` and `for` statements.""" - loose_ends = [] - self.tail = pathnode - self.dispatch_list(node.body) - loose_ends.append(self.tail) - for extra in extra_blocks: - self.tail = pathnode - self.dispatch_list(extra.body) - loose_ends.append(self.tail) - if node.orelse: - self.tail = pathnode - self.dispatch_list(node.orelse) - loose_ends.append(self.tail) - else: - loose_ends.append(pathnode) - if pathnode: - bottom = PathNode("", look="point") - for le in loose_ends: - self.graph.connect(le, bottom) - self.tail = bottom - - def visitTryExcept(self, node): - name = "TryExcept %d" % node.lineno - self._subgraph(node, name, extra_blocks=node.handlers) - - visitTry = visitTryExcept - - def visitWith(self, node): - name = "With %d" % node.lineno - self.appendPathNode(name) - self.dispatch_list(node.body) - - visitAsyncWith = visitWith - - _StatementNodes: TypeAlias = ( nodes.Assert | nodes.Assign @@ -263,32 +53,68 @@ def visitWith(self, node): ) -class PathGraph(Mccabe_PathGraph): # type: ignore[misc] +class PathGraph: def __init__(self, node: _SubGraphNodes | nodes.FunctionDef): - super().__init__(name="", entity="", lineno=1) + self.name = "" self.root = node + self.nodes = {} + def connect(self, n1, n2): + if n1 not in self.nodes: + self.nodes[n1] = [] + self.nodes[n1].append(n2) + # Ensure that the destination node is always counted. + if n2 not in self.nodes: + self.nodes[n2] = [] + + def complexity(self): + """Return the McCabe complexity for the graph. + + V-E+2 + """ + num_edges = sum([len(n) for n in self.nodes.values()]) + num_nodes = len(self.nodes) + return num_edges - num_nodes + 2 + + +class PathGraphingAstVisitor: + """A visitor for a parsed Abstract Syntax Tree which finds executable + statements. + """ -class PathGraphingAstVisitor(Mccabe_PathGraphingAstVisitor): # type: ignore[misc] def __init__(self) -> None: - super().__init__() + self.classname = "" + self.graphs = {} + self._cache = {} self._bottom_counter = 0 self.graph: PathGraph | None = None + self.tail = None + + def reset(self): + self.graph = None + self.tail = None def default(self, node: nodes.NodeNG, *args: Any) -> None: for child in node.get_children(): self.dispatch(child, *args) def dispatch(self, node: nodes.NodeNG, *args: Any) -> Any: - self.node = node klass = node.__class__ meth = self._cache.get(klass) if meth is None: class_name = klass.__name__ - meth = getattr(self.visitor, "visit" + class_name, self.default) + meth = getattr(self, "visit" + class_name, self.default) self._cache[klass] = meth return meth(node, *args) + def preorder(self, tree, visitor): + """Do preorder walk of tree using visitor.""" + self.dispatch(tree) + + def dispatch_list(self, node_list): + for node in node_list: + self.dispatch(node) + def visitFunctionDef(self, node: nodes.FunctionDef) -> None: if self.graph is not None: # closure @@ -309,6 +135,12 @@ def visitFunctionDef(self, node: nodes.FunctionDef) -> None: visitAsyncFunctionDef = visitFunctionDef + def visitClassDef(self, node: nodes.ClassDef) -> None: + old_classname = self.classname + self.classname += node.name + "." + self.dispatch_list(node.body) + self.classname = old_classname + def visitSimpleStatement(self, node: _StatementNodes) -> None: self._append_node(node) @@ -324,6 +156,22 @@ def visitWith(self, node: nodes.With) -> None: visitAsyncWith = visitWith + def visitLoop(self, node: nodes.For | nodes.While) -> None: + name = f"loop_{id(node)}" + self._subgraph(node, name) + + visitAsyncFor = visitFor = visitWhile = visitLoop + + def visitIf(self, node: nodes.If) -> None: + name = f"if_{id(node)}" + self._subgraph(node, name) + + def visitTryExcept(self, node: nodes.Try) -> None: + name = f"try_{id(node)}" + self._subgraph(node, name, extra_blocks=node.handlers) + + visitTry = visitTryExcept + def visitMatch(self, node: nodes.Match) -> None: self._subgraph(node, f"match_{id(node)}", node.cases) diff --git a/pyproject.toml b/pyproject.toml index 19bea034bd..4510f7b4df 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -176,7 +176,6 @@ lint.ignore = [ "PTH208", # Use `pathlib.Path.iterdir()` instead" "RUF012", # mutable default values in class attributes ] -lint.per-file-ignores."pylint/extensions/mccabe.py" = [ "UP008", "UP031" ] lint.pydocstyle.convention = "pep257" [tool.isort] From 6364d3680a14fe7e8e7601172d9d3ebd480517a3 Mon Sep 17 00:00:00 2001 From: Pierre Sassoulas Date: Mon, 15 Sep 2025 22:08:14 +0200 Subject: [PATCH 3/4] [mccabe] Remove unused code that was used to draw graphics originally --- doc/whatsnew/fragments/10551.internal | 4 + pylint/extensions/mccabe.py | 210 +++++++++----------------- 2 files changed, 75 insertions(+), 139 deletions(-) create mode 100644 doc/whatsnew/fragments/10551.internal diff --git a/doc/whatsnew/fragments/10551.internal b/doc/whatsnew/fragments/10551.internal new file mode 100644 index 0000000000..1c78cdbdac --- /dev/null +++ b/doc/whatsnew/fragments/10551.internal @@ -0,0 +1,4 @@ +The dependency to mccabe was removed and its content is now vendored in pylint. Optimization were done as a result +because mccabe was a code to dot graph generator and pylint only need to calculate the mccabe score not draw graphics. + +Refs #10551 diff --git a/pylint/extensions/mccabe.py b/pylint/extensions/mccabe.py index 23612ad91d..1b3461949a 100644 --- a/pylint/extensions/mccabe.py +++ b/pylint/extensions/mccabe.py @@ -2,9 +2,6 @@ # For details: https://github.com/pylint-dev/pylint/blob/main/LICENSE # Copyright (c) https://github.com/pylint-dev/pylint/blob/main/CONTRIBUTORS.txt -# mypy: ignore-errors -# pylint: disable=unused-argument,consider-using-generator - """Module to add McCabe checker class for pylint. Based on: @@ -15,8 +12,7 @@ from __future__ import annotations -from collections.abc import Sequence -from typing import TYPE_CHECKING, Any, TypeAlias, TypeVar +from typing import TYPE_CHECKING, Any from astroid import nodes @@ -28,38 +24,11 @@ from pylint.lint import PyLinter -_StatementNodes: TypeAlias = ( - nodes.Assert - | nodes.Assign - | nodes.AugAssign - | nodes.Delete - | nodes.Raise - | nodes.Yield - | nodes.Import - | nodes.Call - | nodes.Subscript - | nodes.Pass - | nodes.Continue - | nodes.Break - | nodes.Global - | nodes.Return - | nodes.Expr - | nodes.Await -) - -_SubGraphNodes: TypeAlias = nodes.If | nodes.Try | nodes.For | nodes.While | nodes.Match -_AppendableNodeT = TypeVar( - "_AppendableNodeT", bound=_StatementNodes | nodes.While | nodes.FunctionDef -) - - class PathGraph: - def __init__(self, node: _SubGraphNodes | nodes.FunctionDef): - self.name = "" - self.root = node - self.nodes = {} + def __init__(self) -> None: + self.nodes: dict[Any, list[Any]] = {} - def connect(self, n1, n2): + def connect(self, n1: Any, n2: Any) -> None: if n1 not in self.nodes: self.nodes[n1] = [] self.nodes[n1].append(n2) @@ -67,12 +36,12 @@ def connect(self, n1, n2): if n2 not in self.nodes: self.nodes[n2] = [] - def complexity(self): + def complexity(self) -> int: """Return the McCabe complexity for the graph. V-E+2 """ - num_edges = sum([len(n) for n in self.nodes.values()]) + num_edges = sum(len(n) for n in self.nodes.values()) num_nodes = len(self.nodes) return num_edges - num_nodes + 2 @@ -83,155 +52,119 @@ class PathGraphingAstVisitor: """ def __init__(self) -> None: - self.classname = "" - self.graphs = {} - self._cache = {} + self.graphs: dict[str, tuple[PathGraph, nodes.NodeNG]] = {} self._bottom_counter = 0 self.graph: PathGraph | None = None - self.tail = None + self.tail: Any = None - def reset(self): - self.graph = None - self.tail = None + def dispatch(self, node: nodes.NodeNG) -> None: + meth = getattr(self, "visit" + node.__class__.__name__, self.default) + meth(node) - def default(self, node: nodes.NodeNG, *args: Any) -> None: + def default(self, node: nodes.NodeNG) -> None: for child in node.get_children(): - self.dispatch(child, *args) - - def dispatch(self, node: nodes.NodeNG, *args: Any) -> Any: - klass = node.__class__ - meth = self._cache.get(klass) - if meth is None: - class_name = klass.__name__ - meth = getattr(self, "visit" + class_name, self.default) - self._cache[klass] = meth - return meth(node, *args) - - def preorder(self, tree, visitor): - """Do preorder walk of tree using visitor.""" - self.dispatch(tree) - - def dispatch_list(self, node_list): - for node in node_list: - self.dispatch(node) + self.dispatch(child) def visitFunctionDef(self, node: nodes.FunctionDef) -> None: if self.graph is not None: # closure - pathnode = self._append_node(node) - self.tail = pathnode - self.dispatch_list(node.body) + self.graph.connect(self.tail, node) + self.tail = node + for child in node.body: + self.dispatch(child) bottom = f"{self._bottom_counter}" self._bottom_counter += 1 self.graph.connect(self.tail, bottom) self.graph.connect(node, bottom) self.tail = bottom else: - self.graph = PathGraph(node) + self.graph = PathGraph() self.tail = node - self.dispatch_list(node.body) - self.graphs[f"{self.classname}{node.name}"] = self.graph - self.reset() + for child in node.body: + self.dispatch(child) + self.graphs[node.name] = (self.graph, node) + self.graph = None + self.tail = None visitAsyncFunctionDef = visitFunctionDef - def visitClassDef(self, node: nodes.ClassDef) -> None: - old_classname = self.classname - self.classname += node.name + "." - self.dispatch_list(node.body) - self.classname = old_classname - - def visitSimpleStatement(self, node: _StatementNodes) -> None: - self._append_node(node) + def visitAssert(self, node: nodes.NodeNG) -> None: + if self.tail and self.graph: + self.graph.connect(self.tail, node) + self.tail = node - visitAssert = visitAssign = visitAugAssign = visitDelete = visitRaise = ( - visitYield - ) = visitImport = visitCall = visitSubscript = visitPass = visitContinue = ( - visitBreak - ) = visitGlobal = visitReturn = visitExpr = visitAwait = visitSimpleStatement + visitAssign = visitAugAssign = visitDelete = visitRaise = visitYield = ( + visitImport + ) = visitCall = visitSubscript = visitPass = visitContinue = visitBreak = ( + visitGlobal + ) = visitReturn = visitExpr = visitAwait = visitAssert def visitWith(self, node: nodes.With) -> None: - self._append_node(node) - self.dispatch_list(node.body) + if self.tail and self.graph: + self.graph.connect(self.tail, node) + self.tail = node + for child in node.body: + self.dispatch(child) visitAsyncWith = visitWith - def visitLoop(self, node: nodes.For | nodes.While) -> None: - name = f"loop_{id(node)}" - self._subgraph(node, name) - - visitAsyncFor = visitFor = visitWhile = visitLoop + def visitFor(self, node: nodes.For | nodes.While) -> None: + self._subgraph(node, node.handlers if isinstance(node, nodes.Try) else []) - def visitIf(self, node: nodes.If) -> None: - name = f"if_{id(node)}" - self._subgraph(node, name) + visitAsyncFor = visitWhile = visitIf = visitFor - def visitTryExcept(self, node: nodes.Try) -> None: - name = f"try_{id(node)}" - self._subgraph(node, name, extra_blocks=node.handlers) - - visitTry = visitTryExcept + def visitTry(self, node: nodes.Try) -> None: + self._subgraph(node, node.handlers) def visitMatch(self, node: nodes.Match) -> None: - self._subgraph(node, f"match_{id(node)}", node.cases) - - def _append_node(self, node: _AppendableNodeT) -> _AppendableNodeT | None: - if not self.tail or not self.graph: - return None - self.graph.connect(self.tail, node) - self.tail = node - return node + self._subgraph(node, node.cases) def _subgraph( - self, - node: _SubGraphNodes, - name: str, - extra_blocks: Sequence[nodes.ExceptHandler | nodes.MatchCase] = (), + self, node: nodes.NodeNG, extra_blocks: list[nodes.NodeNG] | None = None ) -> None: - """Create the subgraphs representing any `if`, `for` or `match` statements.""" + if extra_blocks is None: + extra_blocks = [] if self.graph is None: - # global loop - self.graph = PathGraph(node) - self._subgraph_parse(node, node, extra_blocks) - self.graphs[f"{self.classname}{name}"] = self.graph - self.reset() + self.graph = PathGraph() + self._parse(node, extra_blocks) + self.graphs[f"loop_{id(node)}"] = (self.graph, node) + self.graph = None + self.tail = None else: - self._append_node(node) - self._subgraph_parse(node, node, extra_blocks) - - def _subgraph_parse( - self, - node: _SubGraphNodes, - pathnode: _SubGraphNodes, - extra_blocks: Sequence[nodes.ExceptHandler | nodes.MatchCase], - ) -> None: - """Parse `match`/`case` blocks, or the body and `else` block of `if`/`for` - statements. - """ + if self.tail: + self.graph.connect(self.tail, node) + self.tail = node + self._parse(node, extra_blocks) + + def _parse(self, node: nodes.NodeNG, extra_blocks: list[nodes.NodeNG]) -> None: loose_ends = [] if isinstance(node, nodes.Match): for case in extra_blocks: if isinstance(case, nodes.MatchCase): self.tail = node - self.dispatch_list(case.body) + for child in case.body: + self.dispatch(child) loose_ends.append(self.tail) loose_ends.append(node) else: self.tail = node - self.dispatch_list(node.body) + for child in node.body: + self.dispatch(child) loose_ends.append(self.tail) for extra in extra_blocks: self.tail = node - self.dispatch_list(extra.body) + for child in extra.body: + self.dispatch(child) loose_ends.append(self.tail) - if node.orelse: + if hasattr(node, "orelse") and node.orelse: self.tail = node - self.dispatch_list(node.orelse) + for child in node.orelse: + self.dispatch(child) loose_ends.append(self.tail) else: loose_ends.append(node) - if node and self.graph: + if self.graph: bottom = f"{self._bottom_counter}" self._bottom_counter += 1 for end in loose_ends: @@ -267,16 +200,15 @@ class McCabeMethodChecker(checkers.BaseChecker): ) @only_required_for_messages("too-complex") - def visit_module(self, node: nodes.Module) -> None: + def visit_module(self, module: nodes.Module) -> None: """Visit an astroid.Module node to check too complex rating and add message if is greater than max_complexity stored from options. """ visitor = PathGraphingAstVisitor() - for child in node.body: - visitor.preorder(child, visitor) - for graph in visitor.graphs.values(): + for child in module.body: + visitor.dispatch(child) + for graph, node in visitor.graphs.values(): complexity = graph.complexity() - node = graph.root if hasattr(node, "name"): node_name = f"'{node.name}'" else: From 035e9e5940bd62e0ad4ca57cc3ac7ca8ba202b75 Mon Sep 17 00:00:00 2001 From: Pierre Sassoulas Date: Mon, 15 Sep 2025 23:31:20 +0200 Subject: [PATCH 4/4] Optimize the visitor function and typing --- pylint/extensions/mccabe.py | 75 +++++++++++++++++++++++++++---------- 1 file changed, 56 insertions(+), 19 deletions(-) diff --git a/pylint/extensions/mccabe.py b/pylint/extensions/mccabe.py index 1b3461949a..fb3a009022 100644 --- a/pylint/extensions/mccabe.py +++ b/pylint/extensions/mccabe.py @@ -12,7 +12,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, TypeAlias from astroid import nodes @@ -23,6 +23,25 @@ if TYPE_CHECKING: from pylint.lint import PyLinter +SimpleNode: TypeAlias = ( + nodes.Assert + | nodes.Assign + | nodes.AugAssign + | nodes.Delete + | nodes.Raise + | nodes.Yield + | nodes.Import + | nodes.Call + | nodes.Subscript + | nodes.Pass + | nodes.Continue + | nodes.Break + | nodes.Global + | nodes.Return + | nodes.Expr + | nodes.Await +) + class PathGraph: def __init__(self) -> None: @@ -58,14 +77,42 @@ def __init__(self) -> None: self.tail: Any = None def dispatch(self, node: nodes.NodeNG) -> None: - meth = getattr(self, "visit" + node.__class__.__name__, self.default) - meth(node) + { + "FunctionDef": self.visitFunctionDef, + "AsyncFunctionDef": self.visitFunctionDef, + "With": self.visitWith, + "AsyncWith": self.visitWith, + "For": self.visitFor, + "AsyncFor": self.visitFor, + "While": self.visitFor, + "If": self.visitFor, + "Try": self.visitTry, + "Match": self.visitMatch, + "Assert": self.visitSimpleNode, + "Assign": self.visitSimpleNode, + "AugAssign": self.visitSimpleNode, + "Delete": self.visitSimpleNode, + "Raise": self.visitSimpleNode, + "Yield": self.visitSimpleNode, + "Import": self.visitSimpleNode, + "Call": self.visitSimpleNode, + "Subscript": self.visitSimpleNode, + "Pass": self.visitSimpleNode, + "Continue": self.visitSimpleNode, + "Break": self.visitSimpleNode, + "Global": self.visitSimpleNode, + "Return": self.visitSimpleNode, + "Expr": self.visitSimpleNode, + "Await": self.visitSimpleNode, + }.get(node.__class__.__name__, self.default)(node) def default(self, node: nodes.NodeNG) -> None: for child in node.get_children(): self.dispatch(child) - def visitFunctionDef(self, node: nodes.FunctionDef) -> None: + def visitFunctionDef( + self, node: nodes.FunctionDef | nodes.AsyncFunctionDef + ) -> None: if self.graph is not None: # closure self.graph.connect(self.tail, node) @@ -86,33 +133,23 @@ def visitFunctionDef(self, node: nodes.FunctionDef) -> None: self.graph = None self.tail = None - visitAsyncFunctionDef = visitFunctionDef - - def visitAssert(self, node: nodes.NodeNG) -> None: + def visitSimpleNode(self, node: SimpleNode) -> None: if self.tail and self.graph: self.graph.connect(self.tail, node) self.tail = node - visitAssign = visitAugAssign = visitDelete = visitRaise = visitYield = ( - visitImport - ) = visitCall = visitSubscript = visitPass = visitContinue = visitBreak = ( - visitGlobal - ) = visitReturn = visitExpr = visitAwait = visitAssert - - def visitWith(self, node: nodes.With) -> None: + def visitWith(self, node: nodes.With | nodes.AsyncWith) -> None: if self.tail and self.graph: self.graph.connect(self.tail, node) self.tail = node for child in node.body: self.dispatch(child) - visitAsyncWith = visitWith - - def visitFor(self, node: nodes.For | nodes.While) -> None: + def visitFor( + self, node: nodes.For | nodes.AsyncFor | nodes.While | nodes.If + ) -> None: self._subgraph(node, node.handlers if isinstance(node, nodes.Try) else []) - visitAsyncFor = visitWhile = visitIf = visitFor - def visitTry(self, node: nodes.Try) -> None: self._subgraph(node, node.handlers)