8Expose the on-demand complexity instrument through Click.
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.
23 L2 - command lifecycle
24 L3 - option and output details
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.
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)
51from __future__
import annotations
54from pathlib
import Path
78from oct.core.octrc
import load_octrc_safe
79from oct.core.project_root
import get_project_root
83 """A named refusal: the call could not be evaluated, so nothing is judged."""
85 exit_code = EXIT_REFUSAL
88@click.command(name="complexity")
92 type=click.Path(exists=
True, path_type=Path),
96 type=click.Path(exists=
True, dir_okay=
False, path_type=Path),
98 help=
"Existing coverage.py data file to map offline.",
104 help=
"Emit the full machine-readable report.",
108 type=click.IntRange(min=1),
110 help=
"Limit human output to the worst N functions.",
114 type=click.IntRange(min=1),
117 "Fail with exit 2 when a function exceeds this cyclomatic complexity "
118 f
"(advisory convention: {ADVISORY_MAX_CC})."
123 type=click.FloatRange(min=0.0, min_open=
True),
126 "Fail with exit 2 when a function exceeds this CRAP score (advisory "
127 f
"convention: {ADVISORY_MAX_CRAP}); requires --coverage-data."
133 help=
"Measure only git-modified Python files (as `oct lint --changed`).",
138 type=click.Path(dir_okay=
False, path_type=Path),
141 "Compare against a recorded baseline: debt at or below its recorded "
142 "value passes, anything new or worsened fails. Needs a threshold."
147 "write_baseline_path",
148 type=click.Path(dir_okay=
False, path_type=Path),
150 help=
"Record this invocation's measurements as a new baseline file.",
155 paths: tuple[Path, ...],
156 coverage_data: Path |
None,
160 max_crap: float |
None,
162 baseline_path: Path |
None,
163 write_baseline_path: Path |
None,
165 """Measure per-function cyclomatic complexity and optional CRAP scores."""
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):
188 if verdict.exit_code:
189 ctx.exit(verdict.exit_code)
193 thresholds: Thresholds,
194 baseline_path: Path |
None,
195 write_baseline_path: Path |
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:
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"
204 if baseline_path
is not None and not thresholds.active:
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"
213 report: AnalysisReport,
214 thresholds: Thresholds,
216 baseline_path: Path |
None,
217 write_baseline_path: Path |
None,
219 """Load or write a baseline as asked, then judge the measurements."""
221 recorded = load_baseline(baseline_path)
if baseline_path
else None
223 write_baseline(write_baseline_path, report.functions, root)
224 if write_baseline_path
227 except GateRefusalError
as exc:
229 return build_verdict(
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),
235 None if write_baseline_path
is None else str(write_baseline_path)
237 baseline_written_functions=written,
241def _resolve_root(ctx: click.Context) -> tuple[Path, click.ClickException |
None]:
242 """Return the project root, tolerating undiscoverable roots.
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.
251 return get_project_root(ctx),
None
252 except click.ClickException
as exc:
253 if ctx.obj
and ctx.obj.get(
"root_dir"):
255 return Path.cwd().resolve(), exc
261 max_crap: float |
None,
262 coverage_data: Path |
None,
264 """Resolve thresholds and refuse a CRAP limit that cannot be measured."""
266 thresholds = resolve_thresholds(max_cc, max_crap, load_octrc_safe(root))
267 except GateRefusalError
as exc:
269 if thresholds.max_crap
is not None and coverage_data
is None:
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})"
280 paths: tuple[Path, ...],
282) -> tuple[Path, ...]:
283 """Return the git-modified Python files, scoped exactly as the linter is.
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.
292 from oct.core
import git
as core_git
294 if paths
and not json_mode:
295 click.echo(
"Note: --changed overrides explicit PATH targets.", err=
True)
297 discovered = core_git.git_changed_files(root, extensions={
".py"})
298 except core_git.GitError
as exc:
300 f
"--changed could not determine the modified file set: {exc}"
304 for path
in discovered
305 if path.is_file()
and not is_excluded_path(path, root)
310 paths: tuple[Path, ...],
312 root_error: click.ClickException |
None,
313) -> tuple[Path, ...]:
314 """Return the paths to measure, defaulting to the project root."""
317 if root_error
is not None:
323 report: AnalysisReport,
326 verdict: Verdict |
None =
None,
328 """Write the report, adding the verdict only when a gate mode is active."""
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))
335 text = render_human(report, top=top)
336 if verdict
is not None:
337 text = f
"{text}\n{render_verdict(verdict)}"
Verdict _build_gate_verdict(AnalysisReport report, Thresholds thresholds, Path root, Path|None baseline_path, Path|None write_baseline_path)
None _check_baseline_options(Thresholds thresholds, Path|None baseline_path, Path|None write_baseline_path)
tuple[Path, click.ClickException|None] _resolve_root(click.Context ctx)
Thresholds _resolve_gate_thresholds(Path root, int|None max_cc, float|None max_crap, Path|None coverage_data)
None _emit_report(AnalysisReport report, bool json_mode, int|None top, Verdict|None verdict=None)
tuple[Path,...] _changed_targets(Path root, tuple[Path,...] paths, bool json_mode)
tuple[Path,...] _resolve_targets(tuple[Path,...] paths, Path root, click.ClickException|None root_error)