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/mutation/command.py
4
5"""
6Purpose
7-------
8Expose the in-house mutation engine through Click as ``oct mutate``.
9
10Responsibilities
11----------------
12- Parse the target, the test path, the per-mutant timeout, and the output mode.
13- Enumerate without running anything under ``--list``.
14- Stream progress to stderr so stdout keeps its JSON-only contract.
15- Convert a named refusal into a clear CLI error rather than a stack trace.
16
17Diagnostics
18-----------
19Domain: OCT-MUTATION
20Levels:
21 L2 - command lifecycle
22 L3 - option and output details
23 L4 - deep tracing
24
25Contracts
26---------
27- ``--tests`` is required for a run; there is no default that would silently
28 run a whole suite once per mutant.
29- Exit code is 0 whenever the run completes; mutation outcomes do not set exit
30 codes in v1, which is report-only and gates nothing.
31- Machine-readable stdout contains JSON only.
32- ``--list`` writes nothing to the target and starts no subprocess.
33
34Dependencies
35------------
36- click (CLI adapter)
37- oct.core.git (repository discovery for stable mutant ids)
38- oct.mutation.engine (enumeration)
39- oct.mutation.report (rendering)
40- oct.mutation.runner (execution and classification)
41- oct.mutation.sandbox (named refusals)
42"""
43
44# OCT-LINT: disable-file=dbg_usage reason="Diagnostics would contaminate the command's JSON-only stdout contract"
45# OCT-LINT: disable-file=dbg_import reason="Diagnostics would contaminate the command's JSON-only stdout contract"
46
47from __future__ import annotations
48
49import json
50from pathlib import Path
51
52import click
53
54from oct.core import git as core_git
55from oct.mutation.engine import enumerate_file, relative_id
56from oct.mutation.report import render_human, render_list
57from oct.mutation.runner import DEFAULT_TIMEOUT_SECONDS, run_mutants
58from oct.mutation.sandbox import MutationRefusal
59
60
61@click.command(name="mutate")
62@click.option(
63 "--target",
64 required=True,
65 type=click.Path(exists=True, dir_okay=False, path_type=Path),
66 help="Python file to mutate.",
67)
68@click.option(
69 "--tests",
70 default=None,
71 type=click.Path(exists=True, path_type=Path),
72 help="Test file or directory to run once per mutant (required for a run).",
73)
74@click.option(
75 "--timeout",
76 type=click.IntRange(min=1),
77 default=DEFAULT_TIMEOUT_SECONDS,
78 show_default=True,
79 help="Seconds allowed for one test run before the mutant is timed out.",
80)
81@click.option(
82 "--json",
83 "json_mode",
84 is_flag=True,
85 help="Emit the full machine-readable report.",
86)
87@click.option(
88 "--list",
89 "list_mode",
90 is_flag=True,
91 help="Enumerate the mutants without running any tests.",
92)
94 target: Path,
95 tests: Path | None,
96 timeout: int,
97 json_mode: bool,
98 list_mode: bool,
99) -> None:
100 """Mutate a file and report which mutants the tests fail to notice."""
101 root = _id_anchor(target)
102 if list_mode:
103 _emit_list(target, root, json_mode)
104 return
105 if tests is None:
106 raise click.UsageError(
107 "--tests is required for a run: mutation runs the given tests once "
108 "per mutant, so there is no default that could be safe to assume."
109 )
110 try:
111 report = run_mutants(
112 target=target,
113 tests=tests,
114 timeout=timeout,
115 on_progress=_progress,
116 )
117 except MutationRefusal as exc:
118 raise click.ClickException(str(exc)) from exc
119 if json_mode:
120 click.echo(json.dumps(report.to_dict(), indent=2, sort_keys=True))
121 return
122 click.echo(render_human(report))
123
124
125def _id_anchor(target: Path) -> Path | None:
126 """Return the repository root that mutant ids are relative to, if any.
127
128 ``--list`` and a real run must agree on ids, so both anchor on the same
129 thing. A target outside any repository still enumerates: only running
130 requires a repository, because only running rewrites the file.
131 """
132 resolved = Path(target).resolve()
133 if not core_git.is_git_repo(resolved.parent):
134 return None
135 try:
136 return core_git.get_repo_root(resolved.parent)
137 except core_git.GitError:
138 return None
139
140
141def _emit_list(target: Path, root: Path | None, json_mode: bool) -> None:
142 """Write the enumeration for ``--list`` in the requested output mode."""
143 relpath = relative_id(target, root)
144 try:
145 mutants = enumerate_file(target, root=root)
146 except SyntaxError as exc:
147 raise click.ClickException(f"could not parse {relpath}: {exc}") from exc
148 if json_mode:
149 payload = {
150 "target": relpath,
151 "totals": {"mutants": len(mutants)},
152 "mutants": [mutant.to_dict() for mutant in mutants],
153 }
154 click.echo(json.dumps(payload, indent=2, sort_keys=True))
155 return
156 click.echo(render_list(mutants, relpath))
157
158
159def _progress(message: str) -> None:
160 """Write one progress line to stderr, keeping stdout free for the report."""
161 click.echo(message, err=True)
None _progress(str message)
Definition command.py:159
None _emit_list(Path target, Path|None root, bool json_mode)
Definition command.py:141
Path|None _id_anchor(Path target)
Definition command.py:125
None mutate(Path target, Path|None tests, int timeout, bool json_mode, bool list_mode)
Definition command.py:99