Skip to content
Draft
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
4 changes: 4 additions & 0 deletions doc/whatsnew/fragments/10551.internal
Original file line number Diff line number Diff line change
@@ -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
221 changes: 128 additions & 93 deletions pylint/extensions/mccabe.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,19 @@
# 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."""
"""Module to add McCabe checker class for pylint.

Check notice on line 5 in pylint/extensions/mccabe.py

View workflow job for this annotation

GitHub Actions / pylint

C0402

Wrong spelling of a word 'vendored' in a docstring:

Check notice on line 5 in pylint/extensions/mccabe.py

View workflow job for this annotation

GitHub Actions / pylint

C0402

Wrong spelling of a word 'mccabe' in a docstring:

Check notice on line 5 in pylint/extensions/mccabe.py

View workflow job for this annotation

GitHub Actions / pylint

C0402

Wrong spelling of a word 'pycqa' in a docstring:

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

from collections.abc import Sequence
from typing import TYPE_CHECKING, Any, TypeAlias, TypeVar
from typing import TYPE_CHECKING, Any, TypeAlias

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
Expand All @@ -20,7 +23,7 @@
if TYPE_CHECKING:
from pylint.lint import PyLinter

_StatementNodes: TypeAlias = (
SimpleNode: TypeAlias = (
nodes.Assert
| nodes.Assign
| nodes.AugAssign
Expand All @@ -39,133 +42,166 @@
| 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) -> None:
self.nodes: dict[Any, list[Any]] = {}

def connect(self, n1: Any, n2: Any) -> None:
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) -> int:
"""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 PathGraph(Mccabe_PathGraph): # type: ignore[misc]
def __init__(self, node: _SubGraphNodes | nodes.FunctionDef):
super().__init__(name="", entity="", lineno=1)
self.root = node

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.graphs: dict[str, tuple[PathGraph, nodes.NodeNG]] = {}
self._bottom_counter = 0
self.graph: PathGraph | None = None

def default(self, node: nodes.NodeNG, *args: Any) -> None:
self.tail: Any = None

def dispatch(self, node: nodes.NodeNG) -> None:
{
"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, *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)
self._cache[klass] = meth
return meth(node, *args)

def visitFunctionDef(self, node: nodes.FunctionDef) -> None:
self.dispatch(child)

def visitFunctionDef(
self, node: nodes.FunctionDef | nodes.AsyncFunctionDef
) -> 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
for child in node.body:
self.dispatch(child)
self.graphs[node.name] = (self.graph, node)
self.graph = None
self.tail = None

def visitSimpleNode(self, node: SimpleNode) -> None:
if self.tail and self.graph:
self.graph.connect(self.tail, node)
self.tail = node
self.dispatch_list(node.body)
self.graphs[f"{self.classname}{node.name}"] = self.graph
self.reset()

visitAsyncFunctionDef = visitFunctionDef

def visitSimpleStatement(self, node: _StatementNodes) -> None:
self._append_node(node)

visitAssert = visitAssign = visitAugAssign = visitDelete = visitRaise = (
visitYield
) = visitImport = visitCall = visitSubscript = visitPass = visitContinue = (
visitBreak
) = visitGlobal = visitReturn = visitExpr = visitAwait = visitSimpleStatement
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)

def visitWith(self, node: nodes.With) -> None:
self._append_node(node)
self.dispatch_list(node.body)
def visitFor(
self, node: nodes.For | nodes.AsyncFor | nodes.While | nodes.If
) -> None:
self._subgraph(node, node.handlers if isinstance(node, nodes.Try) else [])

visitAsyncWith = visitWith
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:
Expand Down Expand Up @@ -201,16 +237,15 @@
)

@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:
Expand Down
1 change: 0 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading