oct
Loading...
Searching...
No Matches
command.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oct/complexity/command.py
4
5"""
6Purpose
7-------
8Expose the on-demand complexity instrument through Click.
9
10Responsibilities
11----------------
12- Parse paths, coverage data, JSON mode, and human truncation options.
13- Parse the plan-226 gate options: thresholds and their exit-code contract.
14- Scope a call to the git-modified file set under ``--changed``.
15- Record and compare the don't-get-worse baseline.
16- Default analysis to the discovered Option C project root.
17- Convert dependency and path failures into clear CLI errors.
18
19Diagnostics
20-----------
21Domain: OCT-COMPLEXITY
22Levels:
23 L2 - command lifecycle
24 L3 - option and output details
25 L4 - deep tracing
26
27Contracts
28---------
29- ``--top`` affects human output only.
30- With no threshold active the command is informational and exits 0, byte for
31 byte as it did before plan 226.
32- With a threshold active the command exits 0 when clean, 2 when any function
33 breaches, and 3 when the call cannot be honestly evaluated.
34- Machine-readable stdout contains JSON only.
35
36Dependencies
37------------
38- click (CLI adapter)
39- oct.complexity.baseline (ratchet document I/O)
40- oct.complexity.service (analysis and rendering)
41- oct.complexity.thresholds (verdict and exit-code contract)
42- oct.core.octrc (optional complexity configuration section)
43- oct.core.project_root (default target discovery)
44- oct.complexity.coverage (missing coverage.py error, raised as a CLI error)
45- oct.core.git (lazy; git-modified Python files for --changed)
46"""
47
48# OCT-LINT: disable-file=dbg_usage reason="Diagnostics would contaminate the command's JSON-only stdout contract"
49# OCT-LINT: disable-file=dbg_import reason="Diagnostics would contaminate the command's JSON-only stdout contract"
50
51from __future__ import annotations
52
53import json
54from pathlib import Path
55
56import click
57
58from oct.complexity.baseline import load_baseline, write_baseline
59from oct.complexity.coverage import CoverageUnavailableError
60from oct.complexity.service import (
61 AnalysisReport,
62 analyze_paths,
63 is_excluded_path,
64 render_human,
65)
66from oct.complexity.thresholds import (
67 ADVISORY_MAX_CC,
68 ADVISORY_MAX_CRAP,
69 EXIT_REFUSAL,
70 GateRefusalError,
71 Thresholds,
72 Verdict,
73 build_verdict,
74 evaluate,
75 render_verdict,
76 resolve_thresholds,
77)
78from oct.core.octrc import load_octrc_safe
79from oct.core.project_root import get_project_root
80
81
82class GateRefusal(click.ClickException):
83 """A named refusal: the call could not be evaluated, so nothing is judged."""
84
85 exit_code = EXIT_REFUSAL
86
87
88@click.command(name="complexity")
89@click.argument(
90 "paths",
91 nargs=-1,
92 type=click.Path(exists=True, path_type=Path),
93)
94@click.option(
95 "--coverage-data",
96 type=click.Path(exists=True, dir_okay=False, path_type=Path),
97 default=None,
98 help="Existing coverage.py data file to map offline.",
99)
100@click.option(
101 "--json",
102 "json_mode",
103 is_flag=True,
104 help="Emit the full machine-readable report.",
105)
106@click.option(
107 "--top",
108 type=click.IntRange(min=1),
109 default=None,
110 help="Limit human output to the worst N functions.",
111)
112@click.option(
113 "--max-cc",
114 type=click.IntRange(min=1),
115 default=None,
116 help=(
117 "Fail with exit 2 when a function exceeds this cyclomatic complexity "
118 f"(advisory convention: {ADVISORY_MAX_CC})."
119 ),
120)
121@click.option(
122 "--max-crap",
123 type=click.FloatRange(min=0.0, min_open=True),
124 default=None,
125 help=(
126 "Fail with exit 2 when a function exceeds this CRAP score (advisory "
127 f"convention: {ADVISORY_MAX_CRAP}); requires --coverage-data."
128 ),
129)
130@click.option(
131 "--changed",
132 is_flag=True,
133 help="Measure only git-modified Python files (as `oct lint --changed`).",
134)
135@click.option(
136 "--baseline",
137 "baseline_path",
138 type=click.Path(dir_okay=False, path_type=Path),
139 default=None,
140 help=(
141 "Compare against a recorded baseline: debt at or below its recorded "
142 "value passes, anything new or worsened fails. Needs a threshold."
143 ),
144)
145@click.option(
146 "--write-baseline",
147 "write_baseline_path",
148 type=click.Path(dir_okay=False, path_type=Path),
149 default=None,
150 help="Record this invocation's measurements as a new baseline file.",
151)
152@click.pass_context
153def complexity(
154 ctx: click.Context,
155 paths: tuple[Path, ...],
156 coverage_data: Path | None,
157 json_mode: bool,
158 top: int | None,
159 max_cc: int | None,
160 max_crap: float | None,
161 changed: bool,
162 baseline_path: Path | None,
163 write_baseline_path: Path | None,
164) -> None:
165 """Measure per-function cyclomatic complexity and optional CRAP scores."""
166 root, root_error = _resolve_root(ctx)
167 thresholds = _resolve_gate_thresholds(root, max_cc, max_crap, coverage_data)
168 _check_baseline_options(thresholds, baseline_path, write_baseline_path)
169 if changed:
170 targets = _changed_targets(root, paths, json_mode)
171 else:
172 targets = _resolve_targets(paths, root, root_error)
173 try:
174 report = analyze_paths(targets, coverage_data=coverage_data)
175 except (CoverageUnavailableError, FileNotFoundError) as exc:
176 raise click.ClickException(str(exc)) from exc
177 if not (thresholds.active or baseline_path or write_baseline_path):
178 _emit_report(report, json_mode, top)
179 return
180 verdict = _build_gate_verdict(
181 report,
182 thresholds,
183 root,
184 baseline_path,
185 write_baseline_path,
186 )
187 _emit_report(report, json_mode, top, verdict=verdict)
188 if verdict.exit_code:
189 ctx.exit(verdict.exit_code)
190
191
193 thresholds: Thresholds,
194 baseline_path: Path | None,
195 write_baseline_path: Path | None,
196) -> None:
197 """Refuse baseline combinations that could not produce an honest verdict."""
198 if baseline_path is not None and write_baseline_path is not None:
199 raise GateRefusal(
200 "--baseline and --write-baseline are mutually exclusive: recording "
201 "and ratcheting in one call would judge against a baseline that "
202 "the same call is replacing"
203 )
204 if baseline_path is not None and not thresholds.active:
205 raise GateRefusal(
206 "--baseline requires an active threshold (--max-cc / --max-crap, "
207 "or the octrc 'complexity' section): with none, the ratchet has "
208 "nothing to compare and would pass silently"
209 )
210
211
213 report: AnalysisReport,
214 thresholds: Thresholds,
215 root: Path,
216 baseline_path: Path | None,
217 write_baseline_path: Path | None,
218) -> Verdict:
219 """Load or write a baseline as asked, then judge the measurements."""
220 try:
221 recorded = load_baseline(baseline_path) if baseline_path else None
222 written = (
223 write_baseline(write_baseline_path, report.functions, root)
224 if write_baseline_path
225 else None
226 )
227 except GateRefusalError as exc:
228 raise GateRefusal(str(exc)) from exc
229 return build_verdict(
230 thresholds,
231 evaluate(report.functions, thresholds, root, baseline=recorded),
232 baseline=None if baseline_path is None else str(baseline_path),
233 baseline_functions=None if recorded is None else len(recorded),
234 baseline_written=(
235 None if write_baseline_path is None else str(write_baseline_path)
236 ),
237 baseline_written_functions=written,
238 )
239
240
241def _resolve_root(ctx: click.Context) -> tuple[Path, click.ClickException | None]:
242 """Return the project root, tolerating undiscoverable roots.
243
244 Configuration lookup and relative-path display need a root even when the
245 caller measures an explicit path outside any project, so a failed
246 discovery falls back to the working directory and the original error is
247 handed back for the one caller that must still fail on it. An explicit
248 ``--root-dir`` is never softened: that error is the operator's own.
249 """
250 try:
251 return get_project_root(ctx), None
252 except click.ClickException as exc:
253 if ctx.obj and ctx.obj.get("root_dir"):
254 raise
255 return Path.cwd().resolve(), exc
256
257
259 root: Path,
260 max_cc: int | None,
261 max_crap: float | None,
262 coverage_data: Path | None,
263) -> Thresholds:
264 """Resolve thresholds and refuse a CRAP limit that cannot be measured."""
265 try:
266 thresholds = resolve_thresholds(max_cc, max_crap, load_octrc_safe(root))
267 except GateRefusalError as exc:
268 raise GateRefusal(str(exc)) from exc
269 if thresholds.max_crap is not None and coverage_data is None:
270 raise GateRefusal(
271 "--max-crap requires --coverage-data: CRAP cannot be computed "
272 "without coverage measurements, and a CC-only downgrade would "
273 f"pass silently (--max-crap came from the {thresholds.max_crap_source})"
274 )
275 return thresholds
276
277
279 root: Path,
280 paths: tuple[Path, ...],
281 json_mode: bool,
282) -> tuple[Path, ...]:
283 """Return the git-modified Python files, scoped exactly as the linter is.
284
285 Discovery is :func:`oct.core.git.git_changed_files`, the same helper
286 ``oct lint --changed`` uses, and the result passes through the service's
287 own exclusion set so ``--changed`` narrows the measured universe without
288 widening it. A git failure is refused rather than collapsed to an empty
289 list: an empty measurement reads as a pass, and a gate that passes
290 because git broke is worse than one that stops.
291 """
292 from oct.core import git as core_git
293
294 if paths and not json_mode:
295 click.echo("Note: --changed overrides explicit PATH targets.", err=True)
296 try:
297 discovered = core_git.git_changed_files(root, extensions={".py"})
298 except core_git.GitError as exc:
299 raise GateRefusal(
300 f"--changed could not determine the modified file set: {exc}"
301 ) from exc
302 return tuple(
303 path
304 for path in discovered
305 if path.is_file() and not is_excluded_path(path, root)
306 )
307
308
310 paths: tuple[Path, ...],
311 root: Path,
312 root_error: click.ClickException | None,
313) -> tuple[Path, ...]:
314 """Return the paths to measure, defaulting to the project root."""
315 if paths:
316 return paths
317 if root_error is not None:
318 raise root_error
319 return (root,)
320
321
323 report: AnalysisReport,
324 json_mode: bool,
325 top: int | None,
326 verdict: Verdict | None = None,
327) -> None:
328 """Write the report, adding the verdict only when a gate mode is active."""
329 if json_mode:
330 payload = report.to_dict()
331 if verdict is not None:
332 payload["verdict"] = verdict.to_dict()
333 click.echo(json.dumps(payload, indent=2, sort_keys=True))
334 return
335 text = render_human(report, top=top)
336 if verdict is not None:
337 text = f"{text}\n{render_verdict(verdict)}"
338 click.echo(text)
Verdict _build_gate_verdict(AnalysisReport report, Thresholds thresholds, Path root, Path|None baseline_path, Path|None write_baseline_path)
Definition command.py:218
None _check_baseline_options(Thresholds thresholds, Path|None baseline_path, Path|None write_baseline_path)
Definition command.py:196
tuple[Path, click.ClickException|None] _resolve_root(click.Context ctx)
Definition command.py:241
Thresholds _resolve_gate_thresholds(Path root, int|None max_cc, float|None max_crap, Path|None coverage_data)
Definition command.py:263
None _emit_report(AnalysisReport report, bool json_mode, int|None top, Verdict|None verdict=None)
Definition command.py:327
tuple[Path,...] _changed_targets(Path root, tuple[Path,...] paths, bool json_mode)
Definition command.py:282
tuple[Path,...] _resolve_targets(tuple[Path,...] paths, Path root, click.ClickException|None root_error)
Definition command.py:313