oct
Loading...
Searching...
No Matches
service.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oct/complexity/service.py
4
5"""
6Purpose
7-------
8Provide shared analysis, ordering, and rendering for command adapters.
9
10Responsibilities
11----------------
12- Discover Python files while excluding environment and build trees.
13- Record every parse failure instead of silently omitting files.
14- Produce stable worst-first CC or CRAP ordering.
15- Render full JSON data and compact ASCII human output.
16- Expose the shared relative-path display that gate output and baseline
17 identities are built from.
18
19Diagnostics
20-----------
21Domain: OCT-COMPLEXITY
22Levels:
23 L2 - analysis lifecycle
24 L3 - file and result details
25 L4 - deep tracing
26
27Contracts
28---------
29- JSON results are never truncated by ``--top``.
30- CC-only rows explicitly identify coverage as not measured.
31- The service measures and never gates.
32
33Dependencies
34------------
35- oct.complexity.coverage (optional coverage mapping)
36- oct.complexity.engine (complexity records)
37- oct.complexity.metrics (CRAP formula)
38"""
39
40# OCT-LINT: disable-file=dbg_usage reason="The measurement service emits only its explicit report and must not add diagnostics noise to JSON or ASCII output"
41# OCT-LINT: disable-file=dbg_import reason="Diagnostics would contaminate explicit JSON and ASCII report output"
42
43from __future__ import annotations
44
45import importlib.util
46import os
47from dataclasses import asdict, dataclass
48from pathlib import Path
49from typing import Iterable, Sequence
50
51from oct.complexity.coverage import CoveredFunction, map_coverage
52from oct.complexity.engine import FunctionComplexity, analyze_file
53from oct.complexity.metrics import calculate_crap
54
55
56_EXCLUDED_DIRECTORY_NAMES = frozenset(
57 {
58 ".git",
59 ".hg",
60 ".mypy_cache",
61 ".nox",
62 ".pytest_cache",
63 ".ruff_cache",
64 ".svn",
65 ".tox",
66 ".venv",
67 "__pycache__",
68 "build",
69 "dist",
70 "node_modules",
71 "tests",
72 "venv",
73 }
74)
75
76
77@dataclass(frozen=True, slots=True)
79 """One source file that could not be parsed or read."""
80
81 file: str
82 error: str
83 lineno: int | None
84 offset: int | None
85
86 def to_dict(self) -> dict[str, str | int | None]:
87 """Return a JSON-ready failure record."""
88 return asdict(self)
89
90
91@dataclass(frozen=True, slots=True)
93 """One complete result row, with nullable P2 fields in CC-only mode."""
94
95 file: str
96 qualname: str
97 lineno: int
98 end_lineno: int
99 complexity: int
100 coverage: float | None
101 coverage_status: str
102 covered_statements: int | None
103 statement_count: int | None
104 crap: float | None
105
106 def to_dict(self) -> dict[str, str | int | float | None]:
107 """Return every stable field for JSON output."""
108 return asdict(self)
109
110
111@dataclass(frozen=True, slots=True)
113 """All measurements and all parse failures for one command invocation."""
114
115 functions: tuple[FunctionResult, ...]
116 parse_errors: tuple[ParseFailure, ...]
117 coverage_message: str | None
118
119 def to_dict(self) -> dict[str, object]:
120 """Return full, untruncated machine-readable output."""
121 return {
122 "functions": [item.to_dict() for item in self.functions],
123 "parse_errors": [item.to_dict() for item in self.parse_errors],
124 "coverage_message": self.coverage_message,
125 }
126
127
129 paths: Sequence[str | Path],
130 coverage_data: str | Path | None = None,
131) -> AnalysisReport:
132 """Analyze Python paths and optionally enrich every record with coverage."""
133 records: list[FunctionComplexity] = []
134 parse_errors: list[ParseFailure] = []
135 for filename in discover_python_files(paths):
136 try:
137 records.extend(analyze_file(filename))
138 except SyntaxError as exc:
139 parse_errors.append(
141 file=str(filename),
142 error=str(exc),
143 lineno=exc.lineno,
144 offset=exc.offset,
145 )
146 )
147 except (OSError, UnicodeError) as exc:
148 parse_errors.append(
150 file=str(filename),
151 error=str(exc),
152 lineno=None,
153 offset=None,
154 )
155 )
156 if coverage_data is None:
157 results = [_without_coverage(record) for record in records]
158 coverage_message = _coverage_absence_message()
159 results.sort(key=_complexity_sort_key)
160 else:
161 results = [
162 _with_coverage(item)
163 for item in map_coverage(records, coverage_data)
164 ]
165 coverage_message = None
166 results.sort(key=_crap_sort_key)
167 parse_errors.sort(key=lambda item: (item.file.casefold(), item.lineno or 0))
168 return AnalysisReport(
169 functions=tuple(results),
170 parse_errors=tuple(parse_errors),
171 coverage_message=coverage_message,
172 )
173
174
175def discover_python_files(paths: Sequence[str | Path]) -> list[Path]:
176 """Return unique Python files under paths with build/cache trees excluded."""
177 discovered: dict[str, Path] = {}
178 for raw_path in paths:
179 path = Path(raw_path).resolve()
180 if not path.exists():
181 raise FileNotFoundError(f"path not found: {path}")
182 candidates: Iterable[Path]
183 if path.is_file():
184 candidates = (path,) if path.suffix.casefold() == ".py" else ()
185 else:
186 candidates = path.rglob("*.py")
187 for candidate in candidates:
188 resolved = candidate.resolve()
189 if is_excluded_path(resolved, path if path.is_dir() else path.parent):
190 continue
191 discovered[_normalized_path(resolved)] = resolved
192 return sorted(discovered.values(), key=lambda item: str(item).casefold())
193
194
195def is_excluded_path(path: Path, scan_root: Path) -> bool:
196 """Return whether a discovered path crosses an excluded directory."""
197 try:
198 relative_parts = path.relative_to(scan_root).parts[:-1]
199 except ValueError:
200 relative_parts = path.parts[:-1]
201 return any(part in _EXCLUDED_DIRECTORY_NAMES for part in relative_parts)
202
203
204def _without_coverage(record: FunctionComplexity) -> FunctionResult:
205 """Convert an engine record into an explicit CC-only result."""
206 return FunctionResult(
207 file=record.file,
208 qualname=record.qualname,
209 lineno=record.lineno,
210 end_lineno=record.end_lineno,
211 complexity=record.complexity,
212 coverage=None,
213 coverage_status="not-measured",
214 covered_statements=None,
215 statement_count=None,
216 crap=None,
217 )
218
219
220def _with_coverage(item: CoveredFunction) -> FunctionResult:
221 """Convert a covered record and calculate its CRAP score."""
222 return FunctionResult(
223 file=item.file,
224 qualname=item.qualname,
225 lineno=item.lineno,
226 end_lineno=item.end_lineno,
227 complexity=item.complexity,
228 coverage=item.coverage,
229 coverage_status=item.coverage_status,
230 covered_statements=item.covered_statements,
231 statement_count=item.statement_count,
232 crap=calculate_crap(item.complexity, item.coverage),
233 )
234
235
236def _complexity_sort_key(item: FunctionResult) -> tuple[object, ...]:
237 """Sort CC-only rows worst-first with deterministic tie breaking."""
238 return (
239 -item.complexity,
240 item.file.casefold(),
241 item.lineno,
242 item.qualname,
243 )
244
245
246def _crap_sort_key(item: FunctionResult) -> tuple[object, ...]:
247 """Sort covered rows by CRAP worst-first, then complexity."""
248 return (
249 -(item.crap or 0.0),
250 -item.complexity,
251 item.file.casefold(),
252 item.lineno,
253 item.qualname,
254 )
255
256
258 """Explain exactly why CRAP columns are absent in CC-only mode."""
259 if importlib.util.find_spec("coverage") is None:
260 return (
261 "Coverage unavailable: coverage.py is not installed; "
262 "CRAP columns are absent."
263 )
264 return (
265 "Coverage unavailable: no --coverage-data was provided; "
266 "CRAP columns are absent."
267 )
268
269
270def render_human(report: AnalysisReport, top: int | None = None) -> str:
271 """Render a compact worst-first ASCII report for terminal users."""
272 shown = report.functions[:top] if top is not None else report.functions
273 lines: list[str] = []
274 if report.coverage_message:
275 lines.append(report.coverage_message)
276 if shown and shown[0].coverage is not None:
277 lines.append(" CC Coverage CRAP Status Function Span")
278 for item in shown:
279 lines.append(
280 f"{item.complexity:>3} {item.coverage * 100:>7.1f}% "
281 f"{item.crap:>9.2f} {item.coverage_status:<14} "
282 f"{item.qualname} {_span(item)}"
283 )
284 else:
285 lines.append(" CC Function Span")
286 for item in shown:
287 lines.append(
288 f"{item.complexity:>3} {item.qualname} {_span(item)}"
289 )
290 lines.append(f"Showing {len(shown)} of {len(report.functions)} functions.")
291 if report.parse_errors:
292 lines.append("Parse errors:")
293 for error in report.parse_errors:
294 location = f":{error.lineno}" if error.lineno is not None else ""
295 lines.append(f" {error.file}{location}: {error.error}")
296 return "\n".join(lines)
297
298
299def _span(item: FunctionResult) -> str:
300 """Return one source span suitable for terminal output."""
301 return f"{item.file}:{item.lineno}-{item.end_lineno}"
302
303
304def _normalized_path(path: Path) -> str:
305 """Return a deterministic filesystem identity key."""
306 return os.path.normcase(str(path))
307
308
309def relative_display_path(file: str | Path, root: str | Path) -> str:
310 """Return *file* relative to *root* with forward separators.
311
312 Gate output and baseline identities have to survive being read on another
313 machine, so the separator is normalised and the path is made relative
314 whenever it lies under *root*. A file outside *root* (a different drive,
315 a temporary fixture) keeps its absolute POSIX spelling rather than
316 growing a misleading chain of parent segments.
317 """
318 resolved = Path(file).resolve()
319 try:
320 return resolved.relative_to(Path(root).resolve()).as_posix()
321 except ValueError:
322 return resolved.as_posix()
323
324
325def function_identity(file: str | Path, qualname: str, root: str | Path) -> str:
326 """Return the stable ``relpath::qualname`` identity of one function.
327
328 A renamed or moved function is honestly a new function under this
329 identity, which is the point: the ratchet grandfathers recorded debt,
330 not the name it happened to carry.
331 """
332 return f"{relative_display_path(file, root)}::{qualname}"
dict[str, object] to_dict(self)
Definition service.py:119
dict[str, str|int|float|None] to_dict(self)
Definition service.py:106
dict[str, str|int|None] to_dict(self)
Definition service.py:86
AnalysisReport analyze_paths(Sequence[str|Path] paths, str|Path|None coverage_data=None)
Definition service.py:131
str relative_display_path(str|Path file, str|Path root)
Definition service.py:309
tuple[object,...] _crap_sort_key(FunctionResult item)
Definition service.py:246
str _coverage_absence_message()
Definition service.py:257
FunctionResult _without_coverage(FunctionComplexity record)
Definition service.py:204
FunctionResult _with_coverage(CoveredFunction item)
Definition service.py:220
bool is_excluded_path(Path path, Path scan_root)
Definition service.py:195
str function_identity(str|Path file, str qualname, str|Path root)
Definition service.py:325
list[Path] discover_python_files(Sequence[str|Path] paths)
Definition service.py:175
str _span(FunctionResult item)
Definition service.py:299
str render_human(AnalysisReport report, int|None top=None)
Definition service.py:270
tuple[object,...] _complexity_sort_key(FunctionResult item)
Definition service.py:236
str _normalized_path(Path path)
Definition service.py:304