oct
Loading...
Searching...
No Matches
engine.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oct/complexity/engine.py
4
5"""
6Purpose
7-------
8Compute stable per-function cyclomatic complexity from Python syntax trees.
9
10Responsibilities
11----------------
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.
15
16Diagnostics
17-----------
18Domain: OCT-COMPLEXITY
19Levels:
20 L2 - lifecycle
21 L3 - measurement details
22 L4 - deep tracing
23
24Contracts
25---------
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.
29
30Dependencies
31------------
32- ast (Python syntax tree)
33- tokenize (declared source encodings)
34"""
35
36# OCT-LINT: disable-file=dbg_usage reason="Plan 214 P1 pins the engine as zero-dependency, so importing oc_diagnostics is forbidden"
37# OCT-LINT: disable-file=dbg_import reason="Plan 214 P1 pins the engine as zero-dependency"
38
39from __future__ import annotations
40
41import ast
42import tokenize
43from dataclasses import asdict, dataclass
44from pathlib import Path
45
46
47@dataclass(frozen=True, slots=True)
49 """One stable per-function complexity measurement."""
50
51 file: str
52 qualname: str
53 lineno: int
54 end_lineno: int
55 complexity: int
56
57 def to_dict(self) -> dict[str, str | int]:
58 """Return a JSON-ready representation with every pinned P1 field."""
59 return asdict(self)
60
61
62class _DecisionCounter(ast.NodeVisitor):
63 """Count decisions inside one function while excluding nested scopes."""
64
65 def __init__(self) -> None:
66 self.complexity = 1
67
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:
71 self.visit(statement)
72 return self.complexity
73
74 def count_lambda(self, node: ast.Lambda) -> int:
75 """Count decisions in a lambda expression and return its score."""
76 self.visit(node.body)
77 return self.complexity
78
79 def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
80 """Do not fold a nested synchronous function into its parent."""
81
82 def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
83 """Do not fold a nested asynchronous function into its parent."""
84
85 def visit_Lambda(self, node: ast.Lambda) -> None:
86 """Do not fold a nested lambda into its parent."""
87
88 def visit_If(self, node: ast.If) -> None:
89 """Count each if node; an elif is represented as another if node."""
90 self.complexity += 1
91 self.generic_visit(node)
92
93 def visit_IfExp(self, node: ast.IfExp) -> None:
94 """Count one conditional-expression decision."""
95 self.complexity += 1
96 self.generic_visit(node)
97
98 def visit_For(self, node: ast.For) -> None:
99 """Count one synchronous loop decision."""
100 self.complexity += 1
101 self.generic_visit(node)
102
103 def visit_AsyncFor(self, node: ast.AsyncFor) -> None:
104 """Count one asynchronous loop decision."""
105 self.complexity += 1
106 self.generic_visit(node)
107
108 def visit_While(self, node: ast.While) -> None:
109 """Count one while-loop decision."""
110 self.complexity += 1
111 self.generic_visit(node)
112
113 def visit_ExceptHandler(self, node: ast.ExceptHandler) -> None:
114 """Count each except clause, including a bare except."""
115 self.complexity += 1
116 self.generic_visit(node)
117
118 def visit_BoolOp(self, node: ast.BoolOp) -> None:
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)
122
123 def visit_comprehension(self, node: ast.comprehension) -> None:
124 """Count a comprehension for-clause and each attached if filter."""
125 self.complexity += 1 + len(node.ifs)
126 self.generic_visit(node)
127
128 def visit_match_case(self, node: ast.match_case) -> None:
129 """Count a match arm unless its pattern is the wildcard underscore."""
130 if not _is_wildcard_pattern(node.pattern):
131 self.complexity += 1
132 self.generic_visit(node)
133
134 def visit_Assert(self, node: ast.Assert) -> None:
135 """Count one assert decision, then inspect its expressions."""
136 self.complexity += 1
137 self.generic_visit(node)
138
139
140def _is_wildcard_pattern(pattern: ast.pattern) -> bool:
141 """Return whether a match pattern is the bare wildcard underscore."""
142 return (
143 isinstance(pattern, ast.MatchAs)
144 and pattern.pattern is None
145 and pattern.name is None
146 )
147
148
149class _FunctionCollector(ast.NodeVisitor):
150 """Collect independently-scored function records with lexical names."""
151
152 def __init__(self, filename: str) -> None:
153 self.filename = filename
154 self.records: list[FunctionComplexity] = []
155 self.scope: list[str] = []
156
157 def visit_ClassDef(self, node: ast.ClassDef) -> None:
158 """Include class names in method qualnames."""
159 self.scope.append(node.name)
160 self.generic_visit(node)
161 self.scope.pop()
162
163 def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
164 """Record and recurse through a synchronous function scope."""
165 self._record_function(node)
166 self.scope.append(node.name)
167 self.generic_visit(node)
168 self.scope.pop()
169
170 def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
171 """Record and recurse through an asynchronous function scope."""
172 self._record_function(node)
173 self.scope.append(node.name)
174 self.generic_visit(node)
175 self.scope.pop()
176
177 def visit_Lambda(self, node: ast.Lambda) -> None:
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])
181 counter = _DecisionCounter()
182 self.records.append(
184 file=self.filename,
185 qualname=qualname,
186 lineno=node.lineno,
187 end_lineno=getattr(node, "end_lineno", node.lineno),
188 complexity=counter.count_lambda(node),
189 )
190 )
191 self.scope.append(local_name)
192 self.generic_visit(node)
193 self.scope.pop()
194
196 self,
197 node: ast.FunctionDef | ast.AsyncFunctionDef,
198 ) -> None:
199 """Append one scored named-function record."""
200 counter = _DecisionCounter()
201 self.records.append(
203 file=self.filename,
204 qualname=".".join([*self.scope, node.name]),
205 lineno=node.lineno,
206 end_lineno=getattr(node, "end_lineno", node.lineno),
207 complexity=counter.count_function(node),
208 )
209 )
210
211
212def analyze_source(source: str, filename: str = "<unknown>") -> list[FunctionComplexity]:
213 """Analyze Python source text and return records in source order.
214
215 ``SyntaxError`` is intentionally allowed to reach the caller. Directory
216 orchestration records that error explicitly instead of silently omitting
217 the file.
218 """
219 tree = ast.parse(source, filename=filename)
220 collector = _FunctionCollector(filename)
221 collector.visit(tree)
222 return sorted(
223 collector.records,
224 key=lambda item: (item.lineno, item.qualname),
225 )
226
227
228def analyze_file(path: str | Path) -> list[FunctionComplexity]:
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()
233 return analyze_source(source, filename=str(resolved))
None visit_AsyncFor(self, ast.AsyncFor node)
Definition engine.py:103
int count_lambda(self, ast.Lambda node)
Definition engine.py:74
None visit_match_case(self, ast.match_case node)
Definition engine.py:128
None visit_Lambda(self, ast.Lambda node)
Definition engine.py:85
None visit_comprehension(self, ast.comprehension node)
Definition engine.py:123
None visit_BoolOp(self, ast.BoolOp node)
Definition engine.py:118
None visit_While(self, ast.While node)
Definition engine.py:108
None visit_IfExp(self, ast.IfExp node)
Definition engine.py:93
None visit_AsyncFunctionDef(self, ast.AsyncFunctionDef node)
Definition engine.py:82
None visit_FunctionDef(self, ast.FunctionDef node)
Definition engine.py:79
None visit_For(self, ast.For node)
Definition engine.py:98
int count_function(self, ast.FunctionDef|ast.AsyncFunctionDef node)
Definition engine.py:68
None visit_ExceptHandler(self, ast.ExceptHandler node)
Definition engine.py:113
None visit_Assert(self, ast.Assert node)
Definition engine.py:134
None visit_If(self, ast.If node)
Definition engine.py:88
None visit_Lambda(self, ast.Lambda node)
Definition engine.py:177
None _record_function(self, ast.FunctionDef|ast.AsyncFunctionDef node)
Definition engine.py:198
None __init__(self, str filename)
Definition engine.py:152
None visit_FunctionDef(self, ast.FunctionDef node)
Definition engine.py:163
None visit_ClassDef(self, ast.ClassDef node)
Definition engine.py:157
None visit_AsyncFunctionDef(self, ast.AsyncFunctionDef node)
Definition engine.py:170
dict[str, str|int] to_dict(self)
Definition engine.py:57
bool _is_wildcard_pattern(ast.pattern pattern)
Definition engine.py:140
list[FunctionComplexity] analyze_file(str|Path path)
Definition engine.py:228
list[FunctionComplexity] analyze_source(str source, str filename="<unknown>")
Definition engine.py:212