typemut 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- typemut/__init__.py +3 -0
- typemut/cli.py +247 -0
- typemut/config.py +62 -0
- typemut/db.py +156 -0
- typemut/discovery.py +201 -0
- typemut/engine.py +102 -0
- typemut/operators/__init__.py +42 -0
- typemut/operators/annotated.py +78 -0
- typemut/operators/base.py +39 -0
- typemut/operators/container.py +74 -0
- typemut/operators/literal.py +93 -0
- typemut/operators/optional.py +118 -0
- typemut/operators/sibling.py +50 -0
- typemut/operators/union.py +78 -0
- typemut/registry.py +137 -0
- typemut/reporting/__init__.py +0 -0
- typemut/reporting/html.py +468 -0
- typemut/reporting/terminal.py +74 -0
- typemut-0.1.0.dist-info/METADATA +256 -0
- typemut-0.1.0.dist-info/RECORD +22 -0
- typemut-0.1.0.dist-info/WHEEL +4 -0
- typemut-0.1.0.dist-info/entry_points.txt +2 -0
typemut/__init__.py
ADDED
typemut/cli.py
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""CLI entry point for typemut."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
|
|
10
|
+
from typemut.config import Config, load_config
|
|
11
|
+
from typemut.db import Database, MutantRow
|
|
12
|
+
from typemut.discovery import discover_annotations, discover_files
|
|
13
|
+
|
|
14
|
+
console = Console()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _load(config_path: str, db_path: str | None) -> tuple[Config, Database]:
|
|
18
|
+
cfg = load_config(Path(config_path))
|
|
19
|
+
db = Database(Path(db_path or cfg.db_path))
|
|
20
|
+
return cfg, db
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@click.group()
|
|
24
|
+
@click.option(
|
|
25
|
+
"-C",
|
|
26
|
+
"--project-dir",
|
|
27
|
+
default=None,
|
|
28
|
+
type=click.Path(exists=True, file_okay=False),
|
|
29
|
+
help="Change to this directory before running (target project root).",
|
|
30
|
+
)
|
|
31
|
+
@click.pass_context
|
|
32
|
+
def main(ctx: click.Context, project_dir: str | None) -> None:
|
|
33
|
+
"""typemut — Mutation testing for type annotations."""
|
|
34
|
+
import os
|
|
35
|
+
|
|
36
|
+
if project_dir:
|
|
37
|
+
os.chdir(project_dir)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@main.command()
|
|
41
|
+
@click.option("--config", "config_path", default="typemut.toml", help="Config file.")
|
|
42
|
+
@click.option("--db", "db_path", default=None, help="Database file.")
|
|
43
|
+
def init(config_path: str, db_path: str | None) -> None:
|
|
44
|
+
"""Discover all possible mutations and store in database."""
|
|
45
|
+
cfg, db = _load(config_path, db_path)
|
|
46
|
+
|
|
47
|
+
# Import operators here to avoid circular imports
|
|
48
|
+
from typemut.registry import Registry
|
|
49
|
+
from typemut.operators import get_enabled_operators
|
|
50
|
+
|
|
51
|
+
module_dir = Path(cfg.module_path)
|
|
52
|
+
if not module_dir.exists():
|
|
53
|
+
console.print(f"[red]Module path not found: {module_dir}[/red]")
|
|
54
|
+
raise SystemExit(1)
|
|
55
|
+
|
|
56
|
+
files = discover_files(module_dir, cfg.excluded_modules)
|
|
57
|
+
console.print(f"Found [bold]{len(files)}[/bold] Python files in {module_dir}")
|
|
58
|
+
|
|
59
|
+
# Build registry from all files
|
|
60
|
+
registry = Registry.from_files(files)
|
|
61
|
+
|
|
62
|
+
operators = get_enabled_operators(cfg.operators)
|
|
63
|
+
console.print(f"Enabled operators: {', '.join(op.name for op in operators)}")
|
|
64
|
+
|
|
65
|
+
db.clear()
|
|
66
|
+
total = 0
|
|
67
|
+
|
|
68
|
+
for py_file in files:
|
|
69
|
+
annotations = discover_annotations(
|
|
70
|
+
py_file, skip_comments=cfg.skip_comments
|
|
71
|
+
)
|
|
72
|
+
mutants: list[MutantRow] = []
|
|
73
|
+
|
|
74
|
+
for ann in annotations:
|
|
75
|
+
for op in operators:
|
|
76
|
+
for mutation in op.find_mutations(ann.node, ann.context, registry):
|
|
77
|
+
mutants.append(
|
|
78
|
+
MutantRow(
|
|
79
|
+
id=None,
|
|
80
|
+
module_path=str(py_file),
|
|
81
|
+
operator=mutation.operator,
|
|
82
|
+
line=mutation.line,
|
|
83
|
+
col=mutation.col,
|
|
84
|
+
original_annotation=mutation.original,
|
|
85
|
+
mutated_annotation=mutation.mutated,
|
|
86
|
+
description=mutation.description,
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
if mutants:
|
|
91
|
+
db.insert_many(mutants)
|
|
92
|
+
total += len(mutants)
|
|
93
|
+
|
|
94
|
+
console.print(
|
|
95
|
+
f"[green]Found {total} type annotation mutations "
|
|
96
|
+
f"across {len(files)} modules[/green]"
|
|
97
|
+
)
|
|
98
|
+
db.close()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@main.command("exec")
|
|
102
|
+
@click.option("--config", "config_path", default="typemut.toml", help="Config file.")
|
|
103
|
+
@click.option("--db", "db_path", default=None, help="Database file.")
|
|
104
|
+
@click.option("--jobs", default=1, help="Number of parallel jobs.")
|
|
105
|
+
def exec_cmd(config_path: str, db_path: str | None, jobs: int) -> None:
|
|
106
|
+
"""Run type checker against each mutation."""
|
|
107
|
+
cfg, db = _load(config_path, db_path)
|
|
108
|
+
|
|
109
|
+
from typemut.engine import check_baseline, run_all_mutants
|
|
110
|
+
|
|
111
|
+
pending = db.get_pending()
|
|
112
|
+
if not pending:
|
|
113
|
+
console.print("[yellow]No pending mutants. Run 'typemut init' first.[/yellow]")
|
|
114
|
+
db.close()
|
|
115
|
+
return
|
|
116
|
+
|
|
117
|
+
console.print("Running baseline check...")
|
|
118
|
+
ok, output = check_baseline(cfg.test_command, cfg.timeout)
|
|
119
|
+
if not ok:
|
|
120
|
+
console.print("[red]Baseline check failed — type checker reports errors on unmodified code:[/red]")
|
|
121
|
+
console.print(output)
|
|
122
|
+
db.close()
|
|
123
|
+
raise SystemExit(1)
|
|
124
|
+
console.print("[green]Baseline clean.[/green]")
|
|
125
|
+
|
|
126
|
+
console.print(f"Running [bold]{len(pending)}[/bold] mutations...")
|
|
127
|
+
run_all_mutants(db, pending, cfg.test_command, cfg.timeout)
|
|
128
|
+
db.close()
|
|
129
|
+
console.print("[green]Done.[/green]")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@main.command()
|
|
133
|
+
@click.option("--db", "db_path", default="typemut.sqlite", help="Database file.")
|
|
134
|
+
def report(db_path: str) -> None:
|
|
135
|
+
"""Show mutation testing results."""
|
|
136
|
+
db = Database(Path(db_path))
|
|
137
|
+
|
|
138
|
+
from typemut.reporting.terminal import print_report
|
|
139
|
+
|
|
140
|
+
print_report(db, console)
|
|
141
|
+
db.close()
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@main.command()
|
|
145
|
+
@click.option("--db", "db_path", default="typemut.sqlite", help="Database file.")
|
|
146
|
+
@click.option("-o", "--output", "out_path", default=None, help="Output file (default: stdout).")
|
|
147
|
+
@click.option("--open", "open_browser", is_flag=True, help="Open report in browser.")
|
|
148
|
+
def html(db_path: str, out_path: str | None, open_browser: bool) -> None:
|
|
149
|
+
"""Generate HTML report."""
|
|
150
|
+
db = Database(Path(db_path))
|
|
151
|
+
|
|
152
|
+
from typemut.reporting.html import generate_html
|
|
153
|
+
|
|
154
|
+
report = generate_html(db)
|
|
155
|
+
db.close()
|
|
156
|
+
|
|
157
|
+
if out_path:
|
|
158
|
+
Path(out_path).write_text(report)
|
|
159
|
+
console.print(f"Report saved to [bold]{out_path}[/bold]")
|
|
160
|
+
else:
|
|
161
|
+
out_path = "typemut-report.html"
|
|
162
|
+
Path(out_path).write_text(report)
|
|
163
|
+
console.print(f"Report saved to [bold]{out_path}[/bold]")
|
|
164
|
+
|
|
165
|
+
if open_browser:
|
|
166
|
+
import webbrowser
|
|
167
|
+
webbrowser.open(f"file://{Path(out_path).resolve()}")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@main.command()
|
|
171
|
+
@click.option("--config", "config_path", default="typemut.toml", help="Config file.")
|
|
172
|
+
@click.option("--db", "db_path", default=None, help="Database file.")
|
|
173
|
+
def run(config_path: str, db_path: str | None) -> None:
|
|
174
|
+
"""Run full pipeline: discover mutations, execute, and report."""
|
|
175
|
+
from typemut.engine import check_baseline, run_all_mutants
|
|
176
|
+
from typemut.operators import get_enabled_operators
|
|
177
|
+
from typemut.registry import Registry
|
|
178
|
+
from typemut.reporting.terminal import print_report
|
|
179
|
+
|
|
180
|
+
cfg, db = _load(config_path, db_path)
|
|
181
|
+
|
|
182
|
+
module_dir = Path(cfg.module_path)
|
|
183
|
+
if not module_dir.exists():
|
|
184
|
+
console.print(f"[red]Module path not found: {module_dir}[/red]")
|
|
185
|
+
raise SystemExit(1)
|
|
186
|
+
|
|
187
|
+
# Init
|
|
188
|
+
files = discover_files(module_dir, cfg.excluded_modules)
|
|
189
|
+
console.print(f"Found [bold]{len(files)}[/bold] Python files in {module_dir}")
|
|
190
|
+
|
|
191
|
+
registry = Registry.from_files(files)
|
|
192
|
+
operators = get_enabled_operators(cfg.operators)
|
|
193
|
+
console.print(f"Enabled operators: {', '.join(op.name for op in operators)}")
|
|
194
|
+
|
|
195
|
+
db.clear()
|
|
196
|
+
total = 0
|
|
197
|
+
|
|
198
|
+
for py_file in files:
|
|
199
|
+
annotations = discover_annotations(py_file, skip_comments=cfg.skip_comments)
|
|
200
|
+
mutants: list[MutantRow] = []
|
|
201
|
+
|
|
202
|
+
for ann in annotations:
|
|
203
|
+
for op in operators:
|
|
204
|
+
for mutation in op.find_mutations(ann.node, ann.context, registry):
|
|
205
|
+
mutants.append(
|
|
206
|
+
MutantRow(
|
|
207
|
+
id=None,
|
|
208
|
+
module_path=str(py_file),
|
|
209
|
+
operator=mutation.operator,
|
|
210
|
+
line=mutation.line,
|
|
211
|
+
col=mutation.col,
|
|
212
|
+
original_annotation=mutation.original,
|
|
213
|
+
mutated_annotation=mutation.mutated,
|
|
214
|
+
description=mutation.description,
|
|
215
|
+
)
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
if mutants:
|
|
219
|
+
db.insert_many(mutants)
|
|
220
|
+
total += len(mutants)
|
|
221
|
+
|
|
222
|
+
console.print(f"[green]Discovered {total} mutations[/green]")
|
|
223
|
+
|
|
224
|
+
if total == 0:
|
|
225
|
+
console.print("[yellow]Nothing to test.[/yellow]")
|
|
226
|
+
db.close()
|
|
227
|
+
return
|
|
228
|
+
|
|
229
|
+
# Baseline check
|
|
230
|
+
console.print("Running baseline check...")
|
|
231
|
+
ok, output = check_baseline(cfg.test_command, cfg.timeout)
|
|
232
|
+
if not ok:
|
|
233
|
+
console.print("[red]Baseline check failed — type checker reports errors on unmodified code:[/red]")
|
|
234
|
+
console.print(output)
|
|
235
|
+
db.close()
|
|
236
|
+
raise SystemExit(1)
|
|
237
|
+
console.print("[green]Baseline clean.[/green]")
|
|
238
|
+
|
|
239
|
+
# Exec
|
|
240
|
+
pending = db.get_pending()
|
|
241
|
+
console.print(f"Running [bold]{len(pending)}[/bold] mutations...")
|
|
242
|
+
run_all_mutants(db, pending, cfg.test_command, cfg.timeout)
|
|
243
|
+
|
|
244
|
+
# Report
|
|
245
|
+
console.print()
|
|
246
|
+
print_report(db, console)
|
|
247
|
+
db.close()
|
typemut/config.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""TOML config parsing for typemut."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import tomllib
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class OperatorsConfig:
|
|
12
|
+
remove_union_member: bool = True
|
|
13
|
+
swap_literal_value: bool = True
|
|
14
|
+
swap_sibling_type: bool = True
|
|
15
|
+
strip_annotated: bool = True
|
|
16
|
+
remove_optional: bool = True
|
|
17
|
+
add_optional: bool = True
|
|
18
|
+
swap_container_type: bool = True
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class Config:
|
|
23
|
+
module_path: str = "src"
|
|
24
|
+
test_command: str = "mypy src/"
|
|
25
|
+
timeout: int = 30
|
|
26
|
+
excluded_modules: list[str] = field(default_factory=list)
|
|
27
|
+
skip_comments: list[str] = field(
|
|
28
|
+
default_factory=lambda: ["type: ignore", "pragma: no mutate"]
|
|
29
|
+
)
|
|
30
|
+
operators: OperatorsConfig = field(default_factory=OperatorsConfig)
|
|
31
|
+
db_path: str = "typemut.sqlite"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def load_config(path: Path) -> Config:
|
|
35
|
+
"""Load config from a TOML file."""
|
|
36
|
+
text = path.read_text()
|
|
37
|
+
raw = tomllib.loads(text)
|
|
38
|
+
|
|
39
|
+
section = raw.get("typemut", {})
|
|
40
|
+
ops_raw = section.pop("operators", {})
|
|
41
|
+
|
|
42
|
+
operators = OperatorsConfig(
|
|
43
|
+
remove_union_member=ops_raw.get("remove-union-member", True),
|
|
44
|
+
swap_literal_value=ops_raw.get("swap-literal-value", True),
|
|
45
|
+
swap_sibling_type=ops_raw.get("swap-sibling-type", True),
|
|
46
|
+
strip_annotated=ops_raw.get("strip-annotated", True),
|
|
47
|
+
remove_optional=ops_raw.get("remove-optional", True),
|
|
48
|
+
add_optional=ops_raw.get("add-optional", True),
|
|
49
|
+
swap_container_type=ops_raw.get("swap-container-type", True),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
return Config(
|
|
53
|
+
module_path=section.get("module-path", "src"),
|
|
54
|
+
test_command=section.get("test-command", "mypy src/"),
|
|
55
|
+
timeout=section.get("timeout", 30),
|
|
56
|
+
excluded_modules=section.get("excluded-modules", []),
|
|
57
|
+
skip_comments=section.get(
|
|
58
|
+
"skip-comments", ["type: ignore", "pragma: no mutate"]
|
|
59
|
+
),
|
|
60
|
+
operators=operators,
|
|
61
|
+
db_path=section.get("db", "typemut.sqlite"),
|
|
62
|
+
)
|
typemut/db.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""SQLite database for mutation results."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sqlite3
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
SCHEMA = """\
|
|
10
|
+
CREATE TABLE IF NOT EXISTS mutants (
|
|
11
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
12
|
+
module_path TEXT NOT NULL,
|
|
13
|
+
operator TEXT NOT NULL,
|
|
14
|
+
line INTEGER NOT NULL,
|
|
15
|
+
col INTEGER NOT NULL DEFAULT 0,
|
|
16
|
+
original_annotation TEXT NOT NULL,
|
|
17
|
+
mutated_annotation TEXT NOT NULL,
|
|
18
|
+
description TEXT NOT NULL,
|
|
19
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
20
|
+
output TEXT,
|
|
21
|
+
duration_seconds REAL
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
CREATE INDEX IF NOT EXISTS idx_status ON mutants(status);
|
|
25
|
+
CREATE INDEX IF NOT EXISTS idx_module ON mutants(module_path);
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class MutantRow:
|
|
31
|
+
id: int | None
|
|
32
|
+
module_path: str
|
|
33
|
+
operator: str
|
|
34
|
+
line: int
|
|
35
|
+
col: int
|
|
36
|
+
original_annotation: str
|
|
37
|
+
mutated_annotation: str
|
|
38
|
+
description: str
|
|
39
|
+
status: str = "pending"
|
|
40
|
+
output: str | None = None
|
|
41
|
+
duration_seconds: float | None = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Database:
|
|
45
|
+
def __init__(self, path: Path) -> None:
|
|
46
|
+
self.path = path
|
|
47
|
+
self.conn = sqlite3.connect(str(path))
|
|
48
|
+
self.conn.row_factory = sqlite3.Row
|
|
49
|
+
self._init_schema()
|
|
50
|
+
|
|
51
|
+
def _init_schema(self) -> None:
|
|
52
|
+
self.conn.executescript(SCHEMA)
|
|
53
|
+
|
|
54
|
+
def insert_mutant(self, mutant: MutantRow) -> int:
|
|
55
|
+
cursor = self.conn.execute(
|
|
56
|
+
"""INSERT INTO mutants
|
|
57
|
+
(module_path, operator, line, col, original_annotation,
|
|
58
|
+
mutated_annotation, description, status)
|
|
59
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
60
|
+
(
|
|
61
|
+
mutant.module_path,
|
|
62
|
+
mutant.operator,
|
|
63
|
+
mutant.line,
|
|
64
|
+
mutant.col,
|
|
65
|
+
mutant.original_annotation,
|
|
66
|
+
mutant.mutated_annotation,
|
|
67
|
+
mutant.description,
|
|
68
|
+
mutant.status,
|
|
69
|
+
),
|
|
70
|
+
)
|
|
71
|
+
self.conn.commit()
|
|
72
|
+
return cursor.lastrowid # type: ignore[return-value]
|
|
73
|
+
|
|
74
|
+
def insert_many(self, mutants: list[MutantRow]) -> None:
|
|
75
|
+
self.conn.executemany(
|
|
76
|
+
"""INSERT INTO mutants
|
|
77
|
+
(module_path, operator, line, col, original_annotation,
|
|
78
|
+
mutated_annotation, description, status)
|
|
79
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
80
|
+
[
|
|
81
|
+
(
|
|
82
|
+
m.module_path,
|
|
83
|
+
m.operator,
|
|
84
|
+
m.line,
|
|
85
|
+
m.col,
|
|
86
|
+
m.original_annotation,
|
|
87
|
+
m.mutated_annotation,
|
|
88
|
+
m.description,
|
|
89
|
+
m.status,
|
|
90
|
+
)
|
|
91
|
+
for m in mutants
|
|
92
|
+
],
|
|
93
|
+
)
|
|
94
|
+
self.conn.commit()
|
|
95
|
+
|
|
96
|
+
def update_result(
|
|
97
|
+
self,
|
|
98
|
+
mutant_id: int,
|
|
99
|
+
status: str,
|
|
100
|
+
output: str | None = None,
|
|
101
|
+
duration: float | None = None,
|
|
102
|
+
) -> None:
|
|
103
|
+
self.conn.execute(
|
|
104
|
+
"""UPDATE mutants
|
|
105
|
+
SET status = ?, output = ?, duration_seconds = ?
|
|
106
|
+
WHERE id = ?""",
|
|
107
|
+
(status, output, duration, mutant_id),
|
|
108
|
+
)
|
|
109
|
+
self.conn.commit()
|
|
110
|
+
|
|
111
|
+
def get_pending(self) -> list[MutantRow]:
|
|
112
|
+
rows = self.conn.execute(
|
|
113
|
+
"SELECT * FROM mutants WHERE status = 'pending' ORDER BY id"
|
|
114
|
+
).fetchall()
|
|
115
|
+
return [self._row_to_mutant(r) for r in rows]
|
|
116
|
+
|
|
117
|
+
def get_all(self) -> list[MutantRow]:
|
|
118
|
+
rows = self.conn.execute("SELECT * FROM mutants ORDER BY id").fetchall()
|
|
119
|
+
return [self._row_to_mutant(r) for r in rows]
|
|
120
|
+
|
|
121
|
+
def get_summary(self) -> dict[str, dict[str, int]]:
|
|
122
|
+
"""Return per-module summary: {module: {killed: N, survived: N, ...}}."""
|
|
123
|
+
rows = self.conn.execute(
|
|
124
|
+
"""SELECT module_path, status, COUNT(*) as cnt
|
|
125
|
+
FROM mutants GROUP BY module_path, status"""
|
|
126
|
+
).fetchall()
|
|
127
|
+
summary: dict[str, dict[str, int]] = {}
|
|
128
|
+
for row in rows:
|
|
129
|
+
module = row["module_path"]
|
|
130
|
+
if module not in summary:
|
|
131
|
+
summary[module] = {}
|
|
132
|
+
summary[module][row["status"]] = row["cnt"]
|
|
133
|
+
return summary
|
|
134
|
+
|
|
135
|
+
def clear(self) -> None:
|
|
136
|
+
self.conn.execute("DELETE FROM mutants")
|
|
137
|
+
self.conn.commit()
|
|
138
|
+
|
|
139
|
+
def close(self) -> None:
|
|
140
|
+
self.conn.close()
|
|
141
|
+
|
|
142
|
+
@staticmethod
|
|
143
|
+
def _row_to_mutant(row: sqlite3.Row) -> MutantRow:
|
|
144
|
+
return MutantRow(
|
|
145
|
+
id=row["id"],
|
|
146
|
+
module_path=row["module_path"],
|
|
147
|
+
operator=row["operator"],
|
|
148
|
+
line=row["line"],
|
|
149
|
+
col=row["col"],
|
|
150
|
+
original_annotation=row["original_annotation"],
|
|
151
|
+
mutated_annotation=row["mutated_annotation"],
|
|
152
|
+
description=row["description"],
|
|
153
|
+
status=row["status"],
|
|
154
|
+
output=row["output"],
|
|
155
|
+
duration_seconds=row["duration_seconds"],
|
|
156
|
+
)
|
typemut/discovery.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Find type annotation nodes in Python source using parso."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import fnmatch
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import parso
|
|
11
|
+
from parso.python.tree import (
|
|
12
|
+
BaseNode,
|
|
13
|
+
Leaf,
|
|
14
|
+
Module,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AnnotationContext(Enum):
|
|
19
|
+
VARIABLE = "variable"
|
|
20
|
+
PARAMETER = "parameter"
|
|
21
|
+
RETURN = "return"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class AnnotationNode:
|
|
26
|
+
file: Path
|
|
27
|
+
node: BaseNode | Leaf
|
|
28
|
+
context: AnnotationContext
|
|
29
|
+
line: int
|
|
30
|
+
col: int
|
|
31
|
+
code: str
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _get_annotation_from_annassign(node: BaseNode) -> BaseNode | Leaf | None:
|
|
35
|
+
"""Extract annotation node from an annassign (e.g. `x: int = 5`).
|
|
36
|
+
|
|
37
|
+
annassign structure: ':', annotation [, '=', value]
|
|
38
|
+
The annotation is the child after the ':' operator.
|
|
39
|
+
"""
|
|
40
|
+
children = node.children
|
|
41
|
+
# children[0] is ':', children[1] is the annotation
|
|
42
|
+
if len(children) >= 2:
|
|
43
|
+
return children[1]
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _get_annotation_from_tfpdef(node: BaseNode) -> BaseNode | Leaf | None:
|
|
48
|
+
"""Extract annotation node from a tfpdef (e.g. `x: int` in function params).
|
|
49
|
+
|
|
50
|
+
tfpdef structure: name, ':', annotation
|
|
51
|
+
"""
|
|
52
|
+
children = node.children
|
|
53
|
+
if len(children) >= 3:
|
|
54
|
+
return children[2]
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _get_return_annotation(funcdef: BaseNode) -> BaseNode | Leaf | None:
|
|
59
|
+
"""Extract return annotation from funcdef.
|
|
60
|
+
|
|
61
|
+
Look for '->' operator and take the next sibling.
|
|
62
|
+
"""
|
|
63
|
+
children = funcdef.children
|
|
64
|
+
for i, child in enumerate(children):
|
|
65
|
+
if hasattr(child, "value") and child.value == "->":
|
|
66
|
+
if i + 1 < len(children):
|
|
67
|
+
return children[i + 1]
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _line_text(file_lines: list[str], line: int) -> str:
|
|
72
|
+
"""Get the text of a specific line (1-indexed)."""
|
|
73
|
+
if 1 <= line <= len(file_lines):
|
|
74
|
+
return file_lines[line - 1]
|
|
75
|
+
return ""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _should_skip_line(line_text: str, skip_comments: list[str]) -> bool:
|
|
79
|
+
"""Check if a line contains any skip comment."""
|
|
80
|
+
return any(comment in line_text for comment in skip_comments)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _is_any(node: BaseNode | Leaf) -> bool:
|
|
84
|
+
"""Check if an annotation node is just `Any`."""
|
|
85
|
+
if isinstance(node, Leaf) and node.value == "Any":
|
|
86
|
+
return True
|
|
87
|
+
return False
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def discover_annotations(
|
|
91
|
+
file: Path,
|
|
92
|
+
source: str | None = None,
|
|
93
|
+
skip_comments: list[str] | None = None,
|
|
94
|
+
) -> list[AnnotationNode]:
|
|
95
|
+
"""Discover all type annotation nodes in a Python source file."""
|
|
96
|
+
if source is None:
|
|
97
|
+
source = file.read_text()
|
|
98
|
+
if skip_comments is None:
|
|
99
|
+
skip_comments = ["type: ignore", "pragma: no mutate"]
|
|
100
|
+
|
|
101
|
+
file_lines = source.splitlines()
|
|
102
|
+
tree = parso.parse(source)
|
|
103
|
+
annotations: list[AnnotationNode] = []
|
|
104
|
+
|
|
105
|
+
def visit(node: BaseNode | Leaf) -> None:
|
|
106
|
+
if isinstance(node, BaseNode):
|
|
107
|
+
# Variable annotation: x: int
|
|
108
|
+
if node.type == "annassign":
|
|
109
|
+
ann = _get_annotation_from_annassign(node)
|
|
110
|
+
if ann is not None and not _is_any(ann):
|
|
111
|
+
line = ann.start_pos[0]
|
|
112
|
+
if not _should_skip_line(
|
|
113
|
+
_line_text(file_lines, line), skip_comments
|
|
114
|
+
):
|
|
115
|
+
annotations.append(
|
|
116
|
+
AnnotationNode(
|
|
117
|
+
file=file,
|
|
118
|
+
node=ann,
|
|
119
|
+
context=AnnotationContext.VARIABLE,
|
|
120
|
+
line=line,
|
|
121
|
+
col=ann.start_pos[1],
|
|
122
|
+
code=ann.value if isinstance(ann, Leaf) else _node_code(ann),
|
|
123
|
+
)
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
# Parameter annotation: def f(x: int)
|
|
127
|
+
elif node.type == "tfpdef":
|
|
128
|
+
ann = _get_annotation_from_tfpdef(node)
|
|
129
|
+
if ann is not None and not _is_any(ann):
|
|
130
|
+
line = ann.start_pos[0]
|
|
131
|
+
if not _should_skip_line(
|
|
132
|
+
_line_text(file_lines, line), skip_comments
|
|
133
|
+
):
|
|
134
|
+
annotations.append(
|
|
135
|
+
AnnotationNode(
|
|
136
|
+
file=file,
|
|
137
|
+
node=ann,
|
|
138
|
+
context=AnnotationContext.PARAMETER,
|
|
139
|
+
line=line,
|
|
140
|
+
col=ann.start_pos[1],
|
|
141
|
+
code=ann.value if isinstance(ann, Leaf) else _node_code(ann),
|
|
142
|
+
)
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
# Return annotation: def f() -> int
|
|
146
|
+
elif node.type == "funcdef":
|
|
147
|
+
ann = _get_return_annotation(node)
|
|
148
|
+
if ann is not None and not _is_any(ann):
|
|
149
|
+
line = ann.start_pos[0]
|
|
150
|
+
if not _should_skip_line(
|
|
151
|
+
_line_text(file_lines, line), skip_comments
|
|
152
|
+
):
|
|
153
|
+
annotations.append(
|
|
154
|
+
AnnotationNode(
|
|
155
|
+
file=file,
|
|
156
|
+
node=ann,
|
|
157
|
+
context=AnnotationContext.RETURN,
|
|
158
|
+
line=line,
|
|
159
|
+
col=ann.start_pos[1],
|
|
160
|
+
code=ann.value if isinstance(ann, Leaf) else _node_code(ann),
|
|
161
|
+
)
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
# Recurse into children
|
|
165
|
+
for child in node.children:
|
|
166
|
+
visit(child)
|
|
167
|
+
|
|
168
|
+
visit(tree)
|
|
169
|
+
return annotations
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _node_code(node: BaseNode | Leaf) -> str:
|
|
173
|
+
"""Get the exact source code text of a node, preserving whitespace."""
|
|
174
|
+
code = node.get_code()
|
|
175
|
+
# get_code() includes the prefix (leading whitespace) of the first leaf.
|
|
176
|
+
# Strip it to get just the annotation text.
|
|
177
|
+
first = node
|
|
178
|
+
while hasattr(first, "children") and first.children:
|
|
179
|
+
first = first.children[0]
|
|
180
|
+
if hasattr(first, "prefix"):
|
|
181
|
+
prefix = first.prefix
|
|
182
|
+
if code.startswith(prefix):
|
|
183
|
+
code = code[len(prefix) :]
|
|
184
|
+
return code
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def discover_files(
|
|
188
|
+
module_path: Path,
|
|
189
|
+
excluded_modules: list[str] | None = None,
|
|
190
|
+
) -> list[Path]:
|
|
191
|
+
"""Find all Python files in the given module path, respecting exclusions."""
|
|
192
|
+
if excluded_modules is None:
|
|
193
|
+
excluded_modules = []
|
|
194
|
+
|
|
195
|
+
files: list[Path] = []
|
|
196
|
+
for py_file in sorted(module_path.rglob("*.py")):
|
|
197
|
+
rel = str(py_file)
|
|
198
|
+
if any(fnmatch.fnmatch(rel, pattern) for pattern in excluded_modules):
|
|
199
|
+
continue
|
|
200
|
+
files.append(py_file)
|
|
201
|
+
return files
|