07_code_metrics.py

Download
python 374 lines 11.1 KB
  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")