1"""
2Code Quality Metrics Calculator
3
4Parses Python source code using the `ast` module and computes:
5
61. Lines of Code (LOC)
7 - Total lines
8 - Blank lines
9 - Comment lines (# ...)
10 - Logical lines (non-blank, non-comment)
11
122. Cyclomatic Complexity (McCabe)
13 Complexity = 1 + number of decision points
14 Decision points counted: if, elif, for, while, and, or, except, with,
15 ternary expressions (IfExp), assert, comprehension conditions.
16 Interpretation:
17 1–10 Low risk, simple
18 11–20 Moderate complexity, some risk
19 21–50 High complexity, hard to test
20 >50 Very high risk, should refactor
21
223. Halstead Metrics
23 n1 = distinct operators, n2 = distinct operands
24 N1 = total operators, N2 = total operands
25 Vocabulary (n) = n1 + n2
26 Length (N) = N1 + N2
27 Volume (V) = N * log2(n)
28 Difficulty (D) = (n1/2) * (N2/n2)
29 Effort (E) = D * V
30 Time to implement (T, seconds) = E / 18
31
32Operators counted: augmented assignments, binary ops, unary ops,
33boolean ops, comparison ops, subscript, attribute access, calls.
34Operands counted: names, constants, string literals.
35
36Run:
37 python 07_code_metrics.py
38"""
39
40import ast
41import math
42import textwrap
43from dataclasses import dataclass, field
44from typing import Any
45
46
47# ---------------------------------------------------------------------------
48# Lines of Code
49# ---------------------------------------------------------------------------
50
51@dataclass
52class LOCMetrics:
53 total: int = 0
54 blank: int = 0
55 comment: int = 0
56 logical: int = 0
57
58 @classmethod
59 def from_source(cls, source: str) -> "LOCMetrics":
60 m = cls()
61 for line in source.splitlines():
62 m.total += 1
63 stripped = line.strip()
64 if not stripped:
65 m.blank += 1
66 elif stripped.startswith("#"):
67 m.comment += 1
68 else:
69 m.logical += 1
70 return m
71
72
73# ---------------------------------------------------------------------------
74# Cyclomatic Complexity
75# ---------------------------------------------------------------------------
76
77class CyclomaticVisitor(ast.NodeVisitor):
78 """Count decision points that increase cyclomatic complexity."""
79
80 def __init__(self) -> None:
81 self.complexity: int = 1 # base complexity
82
83 def visit_If(self, node: ast.If) -> None:
84 self.complexity += 1
85 self.generic_visit(node)
86
87 def visit_IfExp(self, node: ast.IfExp) -> None:
88 # Ternary: x if cond else y
89 self.complexity += 1
90 self.generic_visit(node)
91
92 def visit_For(self, node: ast.For) -> None:
93 self.complexity += 1
94 self.generic_visit(node)
95
96 def visit_While(self, node: ast.While) -> None:
97 self.complexity += 1
98 self.generic_visit(node)
99
100 def visit_ExceptHandler(self, node: ast.ExceptHandler) -> None:
101 self.complexity += 1
102 self.generic_visit(node)
103
104 def visit_With(self, node: ast.With) -> None:
105 # Each context manager item adds a potential branch
106 self.complexity += len(node.items) - 1 if len(node.items) > 1 else 0
107 self.generic_visit(node)
108
109 def visit_Assert(self, node: ast.Assert) -> None:
110 self.complexity += 1
111 self.generic_visit(node)
112
113 def visit_BoolOp(self, node: ast.BoolOp) -> None:
114 # Each 'and'/'or' adds a branch (n-1 for n values)
115 self.complexity += len(node.values) - 1
116 self.generic_visit(node)
117
118 def visit_comprehension(self, node: ast.comprehension) -> None:
119 # Each 'if' in a comprehension adds a branch
120 self.complexity += len(node.ifs)
121 self.generic_visit(node)
122
123
124def cyclomatic_complexity(tree: ast.AST) -> int:
125 visitor = CyclomaticVisitor()
126 visitor.visit(tree)
127 return visitor.complexity
128
129
130def complexity_rating(cc: int) -> str:
131 if cc <= 10:
132 return "Low (simple, easy to test)"
133 if cc <= 20:
134 return "Moderate (some risk)"
135 if cc <= 50:
136 return "High (hard to test, consider refactoring)"
137 return "Very High (must refactor)"
138
139
140# ---------------------------------------------------------------------------
141# Halstead Metrics
142# ---------------------------------------------------------------------------
143
144OPERATOR_TYPES = (
145 ast.Add, ast.Sub, ast.Mult, ast.Div, ast.FloorDiv, ast.Mod,
146 ast.Pow, ast.LShift, ast.RShift, ast.BitOr, ast.BitXor, ast.BitAnd,
147 ast.MatMult,
148 ast.UAdd, ast.USub, ast.Not, ast.Invert,
149 ast.And, ast.Or,
150 ast.Eq, ast.NotEq, ast.Lt, ast.LtE, ast.Gt, ast.GtE,
151 ast.Is, ast.IsNot, ast.In, ast.NotIn,
152)
153
154
155class HalsteadVisitor(ast.NodeVisitor):
156 def __init__(self) -> None:
157 self.operators: list[str] = []
158 self.operands: list[str] = []
159
160 def visit_BinOp(self, node: ast.BinOp) -> None:
161 self.operators.append(type(node.op).__name__)
162 self.generic_visit(node)
163
164 def visit_UnaryOp(self, node: ast.UnaryOp) -> None:
165 self.operators.append(type(node.op).__name__)
166 self.generic_visit(node)
167
168 def visit_BoolOp(self, node: ast.BoolOp) -> None:
169 self.operators.append(type(node.op).__name__)
170 self.generic_visit(node)
171
172 def visit_Compare(self, node: ast.Compare) -> None:
173 for op in node.ops:
174 self.operators.append(type(op).__name__)
175 self.generic_visit(node)
176
177 def visit_AugAssign(self, node: ast.AugAssign) -> None:
178 self.operators.append(f"{type(node.op).__name__}Assign")
179 self.generic_visit(node)
180
181 def visit_Assign(self, node: ast.Assign) -> None:
182 self.operators.append("Assign")
183 self.generic_visit(node)
184
185 def visit_Call(self, node: ast.Call) -> None:
186 self.operators.append("Call()")
187 self.generic_visit(node)
188
189 def visit_Attribute(self, node: ast.Attribute) -> None:
190 self.operators.append(".")
191 self.generic_visit(node)
192
193 def visit_Subscript(self, node: ast.Subscript) -> None:
194 self.operators.append("[]")
195 self.generic_visit(node)
196
197 def visit_Name(self, node: ast.Name) -> None:
198 self.operands.append(node.id)
199
200 def visit_Constant(self, node: ast.Constant) -> None:
201 self.operands.append(repr(node.value))
202
203
204@dataclass
205class HalsteadMetrics:
206 n1: int # distinct operators
207 n2: int # distinct operands
208 N1: int # total operators
209 N2: int # total operands
210 vocabulary: int
211 length: int
212 volume: float
213 difficulty: float
214 effort: float
215 time_seconds: float
216
217
218def halstead_metrics(tree: ast.AST) -> HalsteadMetrics:
219 visitor = HalsteadVisitor()
220 visitor.visit(tree)
221
222 ops = visitor.operators
223 opds = visitor.operands
224
225 n1 = len(set(ops))
226 n2 = len(set(opds))
227 N1 = len(ops)
228 N2 = len(opds)
229
230 vocab = n1 + n2
231 length = N1 + N2
232 volume = length * math.log2(vocab) if vocab > 1 else 0.0
233 difficulty = (n1 / 2) * (N2 / n2) if n2 > 0 else 0.0
234 effort = difficulty * volume
235 time_s = effort / 18.0
236
237 return HalsteadMetrics(
238 n1=n1, n2=n2, N1=N1, N2=N2,
239 vocabulary=vocab, length=length,
240 volume=round(volume, 1),
241 difficulty=round(difficulty, 2),
242 effort=round(effort, 1),
243 time_seconds=round(time_s, 1),
244 )
245
246
247# ---------------------------------------------------------------------------
248# All-in-one analysis
249# ---------------------------------------------------------------------------
250
251def analyze(source: str, label: str = "source") -> None:
252 tree = ast.parse(source)
253
254 loc = LOCMetrics.from_source(source)
255 cc = cyclomatic_complexity(tree)
256 hal = halstead_metrics(tree)
257
258 print(f"\n{'=' * 58}")
259 print(f" Metrics Report: {label}")
260 print("=" * 58)
261
262 print("\n [ Lines of Code ]")
263 print(f" Total lines : {loc.total}")
264 print(f" Blank lines : {loc.blank}")
265 print(f" Comment lines : {loc.comment}")
266 print(f" Logical lines : {loc.logical}")
267
268 print("\n [ Cyclomatic Complexity ]")
269 print(f" Complexity (CC): {cc}")
270 print(f" Risk rating : {complexity_rating(cc)}")
271
272 print("\n [ Halstead Metrics ]")
273 print(f" Distinct operators (n1): {hal.n1}")
274 print(f" Distinct operands (n2): {hal.n2}")
275 print(f" Total operators (N1): {hal.N1}")
276 print(f" Total operands (N2): {hal.N2}")
277 print(f" Vocabulary (n1+n2) : {hal.vocabulary}")
278 print(f" Length (N1+N2) : {hal.length}")
279 print(f" Volume (V) : {hal.volume} bits")
280 print(f" Difficulty (D) : {hal.difficulty}")
281 print(f" Effort (E) : {hal.effort}")
282 print(f" Impl. time (E/18) : {hal.time_seconds} seconds")
283 print()
284
285
286# ---------------------------------------------------------------------------
287# Sample functions to analyze
288# ---------------------------------------------------------------------------
289
290SAMPLE_SIMPLE = textwrap.dedent("""\
291 def add(a, b):
292 return a + b
293""")
294
295SAMPLE_MODERATE = textwrap.dedent("""\
296 def classify_score(score):
297 if score >= 90:
298 return "A"
299 elif score >= 80:
300 return "B"
301 elif score >= 70:
302 return "C"
303 elif score >= 60:
304 return "D"
305 else:
306 return "F"
307""")
308
309SAMPLE_COMPLEX = textwrap.dedent("""\
310 def merge_sorted(a, b):
311 result = []
312 i, j = 0, 0
313 while i < len(a) and j < len(b):
314 if a[i] <= b[j]:
315 result.append(a[i])
316 i += 1
317 else:
318 result.append(b[j])
319 j += 1
320 result.extend(a[i:])
321 result.extend(b[j:])
322 return result
323
324 def find_duplicates(items):
325 seen = set()
326 duplicates = []
327 for item in items:
328 if item in seen:
329 if item not in duplicates:
330 duplicates.append(item)
331 else:
332 seen.add(item)
333 return duplicates
334
335 def safe_divide(x, y, default=0):
336 try:
337 return x / y if y != 0 else default
338 except TypeError as e:
339 raise ValueError(f"Invalid operands: {e}") from e
340""")
341
342
343# ---------------------------------------------------------------------------
344# Entry point
345# ---------------------------------------------------------------------------
346
347if __name__ == "__main__":
348 print("\nCode Quality Metrics Calculator")
349 print("Using Python ast module for static analysis\n")
350
351 print(" Sample 1 — Simple function (add):")
352 print(" " + "-" * 30)
353 for line in SAMPLE_SIMPLE.splitlines():
354 print(f" {line}")
355
356 print("\n Sample 2 — Moderate complexity (score classifier):")
357 print(" " + "-" * 30)
358 for line in SAMPLE_MODERATE.splitlines():
359 print(f" {line}")
360
361 print("\n Sample 3 — Higher complexity (merge sort + utilities):")
362 print(" " + "-" * 30)
363 for line in SAMPLE_COMPLEX.splitlines():
364 print(f" {line}")
365
366 analyze(SAMPLE_SIMPLE, label="Simple function (add)")
367 analyze(SAMPLE_MODERATE, label="Moderate (score classifier)")
368 analyze(SAMPLE_COMPLEX, label="Higher complexity (merge + utils)")
369
370 print("=" * 58)
371 print(" Tip: aim for CC <= 10 and Halstead Volume < 1000 per")
372 print(" function to keep code readable and maintainable.")
373 print("=" * 58 + "\n")