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 ADDED
@@ -0,0 +1,3 @@
1
+ """typemut — Mutation testing for type annotations."""
2
+
3
+ __version__ = "0.1.0"
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