8Compute stable per-function cyclomatic complexity from Python syntax trees.
12- Apply Plan 214's pinned decision-counting convention.
13- Score nested functions and lambdas independently from their parents.
14- Preserve file, qualname, and source-span coordinates for every result.
21 L3 - measurement details
26- Every function starts with complexity one.
27- The engine imports only Python standard-library modules.
28- Syntax errors propagate so orchestration can report them honestly.
32- ast (Python syntax tree)
33- tokenize (declared source encodings)
39from __future__
import annotations
43from dataclasses
import asdict, dataclass
44from pathlib
import Path
47@dataclass(frozen=True, slots=True)
49 """One stable per-function complexity measurement."""
57 def to_dict(self) -> dict[str, str | int]:
58 """Return a JSON-ready representation with every pinned P1 field."""
63 """Count decisions inside one function while excluding nested scopes."""
68 def count_function(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> int:
69 """Count decisions in a function body and return the final score."""
70 for statement
in node.body:
75 """Count decisions in a lambda expression and return its score."""
80 """Do not fold a nested synchronous function into its parent."""
83 """Do not fold a nested asynchronous function into its parent."""
86 """Do not fold a nested lambda into its parent."""
89 """Count each if node; an elif is represented as another if node."""
91 self.generic_visit(node)
94 """Count one conditional-expression decision."""
96 self.generic_visit(node)
99 """Count one synchronous loop decision."""
101 self.generic_visit(node)
104 """Count one asynchronous loop decision."""
106 self.generic_visit(node)
109 """Count one while-loop decision."""
111 self.generic_visit(node)
114 """Count each except clause, including a bare except."""
116 self.generic_visit(node)
119 """Count each written and/or operator in a boolean chain."""
120 self.
complexity += max(0, len(node.values) - 1)
121 self.generic_visit(node)
124 """Count a comprehension for-clause and each attached if filter."""
126 self.generic_visit(node)
129 """Count a match arm unless its pattern is the wildcard underscore."""
132 self.generic_visit(node)
135 """Count one assert decision, then inspect its expressions."""
137 self.generic_visit(node)
141 """Return whether a match pattern is the bare wildcard underscore."""
143 isinstance(pattern, ast.MatchAs)
144 and pattern.pattern
is None
145 and pattern.name
is None
150 """Collect independently-scored function records with lexical names."""
158 """Include class names in method qualnames."""
159 self.
scope.append(node.name)
160 self.generic_visit(node)
164 """Record and recurse through a synchronous function scope."""
166 self.
scope.append(node.name)
167 self.generic_visit(node)
171 """Record and recurse through an asynchronous function scope."""
173 self.
scope.append(node.name)
174 self.generic_visit(node)
178 """Record a lambda with a source-coordinate-qualified stable name."""
179 local_name = f
"<lambda>@{node.lineno}:{node.col_offset}"
180 qualname =
".".join([*self.
scope, local_name])
187 end_lineno=getattr(node,
"end_lineno", node.lineno),
188 complexity=counter.count_lambda(node),
191 self.
scope.append(local_name)
192 self.generic_visit(node)
197 node: ast.FunctionDef | ast.AsyncFunctionDef,
199 """Append one scored named-function record."""
204 qualname=
".".join([*self.
scope, node.name]),
206 end_lineno=getattr(node,
"end_lineno", node.lineno),
207 complexity=counter.count_function(node),
212def analyze_source(source: str, filename: str =
"<unknown>") -> list[FunctionComplexity]:
213 """Analyze Python source text and return records in source order.
215 ``SyntaxError`` is intentionally allowed to reach the caller. Directory
216 orchestration records that error explicitly instead of silently omitting
219 tree = ast.parse(source, filename=filename)
221 collector.visit(tree)
224 key=
lambda item: (item.lineno, item.qualname),
229 """Analyze one Python file using its declared source encoding."""
230 resolved = Path(path).resolve()
231 with tokenize.open(resolved)
as source_file:
232 source = source_file.read()
None visit_AsyncFor(self, ast.AsyncFor node)
int count_lambda(self, ast.Lambda node)
None visit_match_case(self, ast.match_case node)
None visit_Lambda(self, ast.Lambda node)
None visit_comprehension(self, ast.comprehension node)
None visit_BoolOp(self, ast.BoolOp node)
None visit_While(self, ast.While node)
None visit_IfExp(self, ast.IfExp node)
None visit_AsyncFunctionDef(self, ast.AsyncFunctionDef node)
None visit_FunctionDef(self, ast.FunctionDef node)
None visit_For(self, ast.For node)
int count_function(self, ast.FunctionDef|ast.AsyncFunctionDef node)
None visit_ExceptHandler(self, ast.ExceptHandler node)
None visit_Assert(self, ast.Assert node)
None visit_If(self, ast.If node)
None visit_Lambda(self, ast.Lambda node)
None _record_function(self, ast.FunctionDef|ast.AsyncFunctionDef node)
None __init__(self, str filename)
None visit_FunctionDef(self, ast.FunctionDef node)
None visit_ClassDef(self, ast.ClassDef node)
None visit_AsyncFunctionDef(self, ast.AsyncFunctionDef node)
dict[str, str|int] to_dict(self)
bool _is_wildcard_pattern(ast.pattern pattern)
list[FunctionComplexity] analyze_file(str|Path path)
list[FunctionComplexity] analyze_source(str source, str filename="<unknown>")