typemut 0.1.0__tar.gz
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-0.1.0/.github/workflows/ci.yml +32 -0
- typemut-0.1.0/.github/workflows/publish.yml +71 -0
- typemut-0.1.0/.gitignore +11 -0
- typemut-0.1.0/Makefile +38 -0
- typemut-0.1.0/PKG-INFO +256 -0
- typemut-0.1.0/README.md +227 -0
- typemut-0.1.0/pyproject.toml +55 -0
- typemut-0.1.0/src/typemut/__init__.py +3 -0
- typemut-0.1.0/src/typemut/cli.py +247 -0
- typemut-0.1.0/src/typemut/config.py +62 -0
- typemut-0.1.0/src/typemut/db.py +156 -0
- typemut-0.1.0/src/typemut/discovery.py +201 -0
- typemut-0.1.0/src/typemut/engine.py +102 -0
- typemut-0.1.0/src/typemut/operators/__init__.py +42 -0
- typemut-0.1.0/src/typemut/operators/annotated.py +78 -0
- typemut-0.1.0/src/typemut/operators/base.py +39 -0
- typemut-0.1.0/src/typemut/operators/container.py +74 -0
- typemut-0.1.0/src/typemut/operators/literal.py +93 -0
- typemut-0.1.0/src/typemut/operators/optional.py +118 -0
- typemut-0.1.0/src/typemut/operators/sibling.py +50 -0
- typemut-0.1.0/src/typemut/operators/union.py +78 -0
- typemut-0.1.0/src/typemut/registry.py +137 -0
- typemut-0.1.0/src/typemut/reporting/__init__.py +0 -0
- typemut-0.1.0/src/typemut/reporting/html.py +468 -0
- typemut-0.1.0/src/typemut/reporting/terminal.py +74 -0
- typemut-0.1.0/tests/__init__.py +0 -0
- typemut-0.1.0/tests/conftest.py +14 -0
- typemut-0.1.0/tests/fixtures/generics.py +25 -0
- typemut-0.1.0/tests/fixtures/pydantic_models.py +29 -0
- typemut-0.1.0/tests/fixtures/simple_unions.py +19 -0
- typemut-0.1.0/tests/test_config.py +39 -0
- typemut-0.1.0/tests/test_db.py +74 -0
- typemut-0.1.0/tests/test_discovery.py +70 -0
- typemut-0.1.0/tests/test_operators/__init__.py +0 -0
- typemut-0.1.0/tests/test_operators/test_annotated.py +32 -0
- typemut-0.1.0/tests/test_operators/test_container.py +49 -0
- typemut-0.1.0/tests/test_operators/test_literal.py +41 -0
- typemut-0.1.0/tests/test_operators/test_optional.py +80 -0
- typemut-0.1.0/tests/test_operators/test_sibling.py +41 -0
- typemut-0.1.0/tests/test_operators/test_union.py +60 -0
- typemut-0.1.0/tests/test_registry.py +32 -0
- typemut-0.1.0/uv.lock +284 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [main]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
test:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
strategy:
|
|
13
|
+
matrix:
|
|
14
|
+
python-version: ["3.11", "3.12", "3.13"]
|
|
15
|
+
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@v4
|
|
18
|
+
|
|
19
|
+
- name: Install uv
|
|
20
|
+
uses: astral-sh/setup-uv@v4
|
|
21
|
+
|
|
22
|
+
- name: Set up Python ${{ matrix.python-version }}
|
|
23
|
+
run: uv python install ${{ matrix.python-version }}
|
|
24
|
+
|
|
25
|
+
- name: Install
|
|
26
|
+
run: make install
|
|
27
|
+
|
|
28
|
+
- name: Run tests
|
|
29
|
+
run: make test
|
|
30
|
+
|
|
31
|
+
- name: Run mypy
|
|
32
|
+
run: make lint
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
name: Publish
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [published]
|
|
6
|
+
workflow_dispatch:
|
|
7
|
+
inputs:
|
|
8
|
+
target:
|
|
9
|
+
description: "Publish target"
|
|
10
|
+
required: true
|
|
11
|
+
type: choice
|
|
12
|
+
options:
|
|
13
|
+
- testpypi
|
|
14
|
+
- pypi
|
|
15
|
+
|
|
16
|
+
permissions:
|
|
17
|
+
id-token: write
|
|
18
|
+
|
|
19
|
+
jobs:
|
|
20
|
+
build:
|
|
21
|
+
runs-on: ubuntu-latest
|
|
22
|
+
steps:
|
|
23
|
+
- uses: actions/checkout@v4
|
|
24
|
+
|
|
25
|
+
- name: Install uv
|
|
26
|
+
uses: astral-sh/setup-uv@v4
|
|
27
|
+
|
|
28
|
+
- name: Build package
|
|
29
|
+
run: uv build
|
|
30
|
+
|
|
31
|
+
- name: Upload dist
|
|
32
|
+
uses: actions/upload-artifact@v4
|
|
33
|
+
with:
|
|
34
|
+
name: dist
|
|
35
|
+
path: dist/
|
|
36
|
+
|
|
37
|
+
publish-testpypi:
|
|
38
|
+
needs: build
|
|
39
|
+
runs-on: ubuntu-latest
|
|
40
|
+
if: >-
|
|
41
|
+
(github.event_name == 'workflow_dispatch' && github.event.inputs.target == 'testpypi') ||
|
|
42
|
+
(github.event_name == 'release' && github.event.release.prerelease)
|
|
43
|
+
environment: testpypi
|
|
44
|
+
steps:
|
|
45
|
+
- name: Download dist
|
|
46
|
+
uses: actions/download-artifact@v4
|
|
47
|
+
with:
|
|
48
|
+
name: dist
|
|
49
|
+
path: dist/
|
|
50
|
+
|
|
51
|
+
- name: Publish to TestPyPI
|
|
52
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
53
|
+
with:
|
|
54
|
+
repository-url: https://test.pypi.org/legacy/
|
|
55
|
+
|
|
56
|
+
publish-pypi:
|
|
57
|
+
needs: build
|
|
58
|
+
runs-on: ubuntu-latest
|
|
59
|
+
if: >-
|
|
60
|
+
(github.event_name == 'workflow_dispatch' && github.event.inputs.target == 'pypi') ||
|
|
61
|
+
(github.event_name == 'release' && !github.event.release.prerelease)
|
|
62
|
+
environment: pypi
|
|
63
|
+
steps:
|
|
64
|
+
- name: Download dist
|
|
65
|
+
uses: actions/download-artifact@v4
|
|
66
|
+
with:
|
|
67
|
+
name: dist
|
|
68
|
+
path: dist/
|
|
69
|
+
|
|
70
|
+
- name: Publish to PyPI
|
|
71
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
typemut-0.1.0/.gitignore
ADDED
typemut-0.1.0/Makefile
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
.PHONY: install test lint clean run
|
|
2
|
+
|
|
3
|
+
install:
|
|
4
|
+
uv sync --all-extras --all-groups
|
|
5
|
+
|
|
6
|
+
test:
|
|
7
|
+
uv run pytest tests/ -v
|
|
8
|
+
|
|
9
|
+
lint:
|
|
10
|
+
uv run mypy src/typemut/
|
|
11
|
+
|
|
12
|
+
clean:
|
|
13
|
+
rm -f typemut.sqlite
|
|
14
|
+
rm -rf .pytest_cache __pycache__ src/typemut/__pycache__
|
|
15
|
+
find . -name "__pycache__" -type d -exec rm -rf {} + 2>/dev/null || true
|
|
16
|
+
find . -name "*.pyc" -delete 2>/dev/null || true
|
|
17
|
+
|
|
18
|
+
# Run typemut on an external project:
|
|
19
|
+
# make run PROJECT=/path/to/project
|
|
20
|
+
# make run PROJECT=/path/to/project CONFIG=custom.toml
|
|
21
|
+
PROJECT ?= .
|
|
22
|
+
CONFIG ?= typemut.toml
|
|
23
|
+
DB ?= typemut.sqlite
|
|
24
|
+
|
|
25
|
+
run:
|
|
26
|
+
uv run typemut -C $(PROJECT) run --config $(CONFIG) --db $(DB)
|
|
27
|
+
|
|
28
|
+
init:
|
|
29
|
+
uv run typemut -C $(PROJECT) init --config $(CONFIG) --db $(DB)
|
|
30
|
+
|
|
31
|
+
exec:
|
|
32
|
+
uv run typemut -C $(PROJECT) exec --config $(CONFIG) --db $(DB)
|
|
33
|
+
|
|
34
|
+
report:
|
|
35
|
+
uv run typemut -C $(PROJECT) report --db $(DB)
|
|
36
|
+
|
|
37
|
+
html:
|
|
38
|
+
uv run typemut -C $(PROJECT) html --db $(DB)
|
typemut-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: typemut
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Mutation testing for type annotations
|
|
5
|
+
Project-URL: Homepage, https://github.com/nkhitrov/typemut
|
|
6
|
+
Project-URL: Repository, https://github.com/nkhitrov/typemut
|
|
7
|
+
Project-URL: Issues, https://github.com/nkhitrov/typemut/issues
|
|
8
|
+
Author-email: Niсл Khitrov <khitrov34@gmail.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
Keywords: mutation-testing,mypy,pyright,testing,type-annotations,type-checking
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
19
|
+
Classifier: Topic :: Software Development :: Testing
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.11
|
|
22
|
+
Requires-Dist: click>=8.0
|
|
23
|
+
Requires-Dist: parso>=0.8
|
|
24
|
+
Requires-Dist: rich>=13.0
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: mypy>=1.0; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# typemut
|
|
31
|
+
|
|
32
|
+
Mutation testing for Python type annotations.
|
|
33
|
+
|
|
34
|
+
Standard mutation testing tools (cosmic-ray, mutmut) mutate runtime code and check if tests catch it. **typemut** mutates only type annotations and checks if type checkers (mypy, pyright) catch the change.
|
|
35
|
+
|
|
36
|
+
- **Mutant killed** = type checker reports an error (types are strict enough)
|
|
37
|
+
- **Mutant survived** = no type error (types are too loose or type checker coverage is weak)
|
|
38
|
+
|
|
39
|
+
## Installation
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install typemut
|
|
43
|
+
# or
|
|
44
|
+
uv add typemut
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Quick Start
|
|
48
|
+
|
|
49
|
+
1. Create `typemut.toml` in your project root:
|
|
50
|
+
|
|
51
|
+
```toml
|
|
52
|
+
[typemut]
|
|
53
|
+
module-path = "src/myproject"
|
|
54
|
+
test-command = "make typecheck" # must exit non-zero on type errors
|
|
55
|
+
timeout = 30
|
|
56
|
+
|
|
57
|
+
[typemut.operators]
|
|
58
|
+
remove-union-member = true
|
|
59
|
+
swap-literal-value = true
|
|
60
|
+
swap-sibling-type = true
|
|
61
|
+
strip-annotated = true
|
|
62
|
+
remove-optional = true
|
|
63
|
+
add-optional = true
|
|
64
|
+
swap-container-type = true
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
2. Run:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
typemut run # full pipeline: discover + execute + report
|
|
71
|
+
typemut html --open # generate HTML report and open in browser
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Or from another directory:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
typemut -C /path/to/project run
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Commands
|
|
81
|
+
|
|
82
|
+
| Command | Description |
|
|
83
|
+
|---------|-------------|
|
|
84
|
+
| `typemut run` | Full pipeline: discover mutations, run type checker, show report |
|
|
85
|
+
| `typemut init` | Discover mutations and store in SQLite |
|
|
86
|
+
| `typemut exec` | Run type checker against each pending mutation |
|
|
87
|
+
| `typemut report` | Show terminal report |
|
|
88
|
+
| `typemut html` | Generate HTML report with diffs |
|
|
89
|
+
|
|
90
|
+
## What It Finds
|
|
91
|
+
|
|
92
|
+
typemut generates mutations of type annotations and checks whether the type checker catches them. Each mutation operator targets a specific class of type safety issues.
|
|
93
|
+
|
|
94
|
+
### RemoveUnionMember
|
|
95
|
+
|
|
96
|
+
Removes one member from a union type.
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
# Original
|
|
100
|
+
def handle(value: int | str | float) -> None: ...
|
|
101
|
+
|
|
102
|
+
# Mutant: remove str
|
|
103
|
+
def handle(value: int | float) -> None: ...
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
**Survived = your code doesn't distinguish between union members.** If removing `str` from the union causes no type error, it means no code path relies on `value` being a `str`. The union may be overly broad, or the type checker doesn't see the code that handles `str` specifically.
|
|
107
|
+
|
|
108
|
+
### RemoveOptional
|
|
109
|
+
|
|
110
|
+
Removes `None` from `X | None`.
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
# Original
|
|
114
|
+
def find_user(id: int) -> User | None: ...
|
|
115
|
+
|
|
116
|
+
# Mutant
|
|
117
|
+
def find_user(id: int) -> User: ...
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
**Survived = callers don't check for `None`.** The return type says "might be None" but no consumer's type annotations actually require a None-check. Either the None case is dead code, or callers use `# type: ignore`.
|
|
121
|
+
|
|
122
|
+
### AddOptional
|
|
123
|
+
|
|
124
|
+
Adds `| None` to return types and class fields (parameters are excluded — callers simply won't pass None, making those mutations uninformative).
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
# Original
|
|
128
|
+
class Config:
|
|
129
|
+
name: str
|
|
130
|
+
|
|
131
|
+
# Mutant
|
|
132
|
+
class Config:
|
|
133
|
+
name: str | None
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
**Survived = consumers don't rely on non-None guarantee.** The field claims to always have a value, but no typed code would break if it could be `None`. This often reveals missing type coverage in code that reads the field.
|
|
137
|
+
|
|
138
|
+
### SwapSiblingType
|
|
139
|
+
|
|
140
|
+
Replaces a class with its sibling (same base class).
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
class LoanState: ...
|
|
144
|
+
class ActiveLoan(LoanState): ...
|
|
145
|
+
class ClosedLoan(LoanState): ...
|
|
146
|
+
|
|
147
|
+
# Original
|
|
148
|
+
def process(loan: ActiveLoan) -> None: ...
|
|
149
|
+
|
|
150
|
+
# Mutant
|
|
151
|
+
def process(loan: ClosedLoan) -> None: ...
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
**Survived = the type system doesn't distinguish between sibling states.** Common in state machine patterns where the base class is typed as `Any` or all siblings share the same interface.
|
|
155
|
+
|
|
156
|
+
### SwapLiteralValue
|
|
157
|
+
|
|
158
|
+
Swaps values inside `Literal[...]` with other literal values from the same file.
|
|
159
|
+
|
|
160
|
+
```python
|
|
161
|
+
# Original
|
|
162
|
+
status: Literal["active"]
|
|
163
|
+
|
|
164
|
+
# Mutant (if "closed" exists in the same file)
|
|
165
|
+
status: Literal["closed"]
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
**Survived = literal values are interchangeable from the type checker's perspective.** The code doesn't use literal narrowing or overloads to distinguish between the values.
|
|
169
|
+
|
|
170
|
+
### StripAnnotated
|
|
171
|
+
|
|
172
|
+
Removes metadata from `Annotated[X, ...]`, leaving just the base type.
|
|
173
|
+
|
|
174
|
+
```python
|
|
175
|
+
# Original
|
|
176
|
+
age: Annotated[int, Gt(0)]
|
|
177
|
+
|
|
178
|
+
# Mutant
|
|
179
|
+
age: int
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
**Survived = expected in most cases.** `Annotated` metadata is typically runtime-only (Pydantic validators, etc.). Survived mutants here are normal unless you use mypy plugins that understand the metadata.
|
|
183
|
+
|
|
184
|
+
### SwapContainerType
|
|
185
|
+
|
|
186
|
+
Swaps between compatible container types.
|
|
187
|
+
|
|
188
|
+
```python
|
|
189
|
+
# Original
|
|
190
|
+
items: list[int]
|
|
191
|
+
|
|
192
|
+
# Mutant
|
|
193
|
+
items: tuple[int]
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
**Swap groups:** `list` <-> `tuple`, `set` <-> `frozenset`. Dict has no swap target.
|
|
197
|
+
|
|
198
|
+
**Survived = code doesn't rely on container-specific behavior at the type level.** For example, if code only iterates over items, both `list` and `tuple` work equally.
|
|
199
|
+
|
|
200
|
+
## Filtering
|
|
201
|
+
|
|
202
|
+
Annotations are automatically skipped when:
|
|
203
|
+
|
|
204
|
+
- The line contains `# type: ignore` or `# pragma: no mutate`
|
|
205
|
+
- The annotation is `Any` (mutations are meaningless — Any absorbs all types)
|
|
206
|
+
- `AddOptional` targets a function parameter (low signal — callers won't pass None)
|
|
207
|
+
|
|
208
|
+
## Config Reference
|
|
209
|
+
|
|
210
|
+
```toml
|
|
211
|
+
[typemut]
|
|
212
|
+
module-path = "src/myproject" # directory to scan for annotations
|
|
213
|
+
test-command = "make typecheck" # command to run type checker
|
|
214
|
+
timeout = 30 # seconds per mutation
|
|
215
|
+
excluded-modules = ["src/vendor/*.py"] # glob patterns to skip
|
|
216
|
+
skip-comments = ["type: ignore", "pragma: no mutate"]
|
|
217
|
+
db = "typemut.sqlite" # database file
|
|
218
|
+
|
|
219
|
+
[typemut.operators]
|
|
220
|
+
# all enabled by default, disable selectively
|
|
221
|
+
remove-union-member = true
|
|
222
|
+
swap-literal-value = true
|
|
223
|
+
swap-sibling-type = true
|
|
224
|
+
strip-annotated = true
|
|
225
|
+
remove-optional = true
|
|
226
|
+
add-optional = true
|
|
227
|
+
swap-container-type = true
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
## HTML Report
|
|
231
|
+
|
|
232
|
+
The HTML report shows:
|
|
233
|
+
- Summary stats and per-module mutation scores
|
|
234
|
+
- Each mutant as a collapsible card with unified diff
|
|
235
|
+
- Color-coded status: killed (green), survived (red), error (orange)
|
|
236
|
+
- Full type checker output per mutant
|
|
237
|
+
- Expand/Collapse All controls
|
|
238
|
+
|
|
239
|
+
```bash
|
|
240
|
+
typemut html --open # save and open in browser
|
|
241
|
+
typemut html -o report.html # save to specific file
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
## Development
|
|
245
|
+
|
|
246
|
+
```bash
|
|
247
|
+
make install # create venv and install with dev deps
|
|
248
|
+
make test # run tests
|
|
249
|
+
make lint # run mypy
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
## Dependencies
|
|
253
|
+
|
|
254
|
+
- **parso** — CST parsing (preserves formatting and whitespace)
|
|
255
|
+
- **rich** — terminal reporting
|
|
256
|
+
- **click** — CLI framework
|
typemut-0.1.0/README.md
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
# typemut
|
|
2
|
+
|
|
3
|
+
Mutation testing for Python type annotations.
|
|
4
|
+
|
|
5
|
+
Standard mutation testing tools (cosmic-ray, mutmut) mutate runtime code and check if tests catch it. **typemut** mutates only type annotations and checks if type checkers (mypy, pyright) catch the change.
|
|
6
|
+
|
|
7
|
+
- **Mutant killed** = type checker reports an error (types are strict enough)
|
|
8
|
+
- **Mutant survived** = no type error (types are too loose or type checker coverage is weak)
|
|
9
|
+
|
|
10
|
+
## Installation
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pip install typemut
|
|
14
|
+
# or
|
|
15
|
+
uv add typemut
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Quick Start
|
|
19
|
+
|
|
20
|
+
1. Create `typemut.toml` in your project root:
|
|
21
|
+
|
|
22
|
+
```toml
|
|
23
|
+
[typemut]
|
|
24
|
+
module-path = "src/myproject"
|
|
25
|
+
test-command = "make typecheck" # must exit non-zero on type errors
|
|
26
|
+
timeout = 30
|
|
27
|
+
|
|
28
|
+
[typemut.operators]
|
|
29
|
+
remove-union-member = true
|
|
30
|
+
swap-literal-value = true
|
|
31
|
+
swap-sibling-type = true
|
|
32
|
+
strip-annotated = true
|
|
33
|
+
remove-optional = true
|
|
34
|
+
add-optional = true
|
|
35
|
+
swap-container-type = true
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
2. Run:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
typemut run # full pipeline: discover + execute + report
|
|
42
|
+
typemut html --open # generate HTML report and open in browser
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Or from another directory:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
typemut -C /path/to/project run
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Commands
|
|
52
|
+
|
|
53
|
+
| Command | Description |
|
|
54
|
+
|---------|-------------|
|
|
55
|
+
| `typemut run` | Full pipeline: discover mutations, run type checker, show report |
|
|
56
|
+
| `typemut init` | Discover mutations and store in SQLite |
|
|
57
|
+
| `typemut exec` | Run type checker against each pending mutation |
|
|
58
|
+
| `typemut report` | Show terminal report |
|
|
59
|
+
| `typemut html` | Generate HTML report with diffs |
|
|
60
|
+
|
|
61
|
+
## What It Finds
|
|
62
|
+
|
|
63
|
+
typemut generates mutations of type annotations and checks whether the type checker catches them. Each mutation operator targets a specific class of type safety issues.
|
|
64
|
+
|
|
65
|
+
### RemoveUnionMember
|
|
66
|
+
|
|
67
|
+
Removes one member from a union type.
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
# Original
|
|
71
|
+
def handle(value: int | str | float) -> None: ...
|
|
72
|
+
|
|
73
|
+
# Mutant: remove str
|
|
74
|
+
def handle(value: int | float) -> None: ...
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
**Survived = your code doesn't distinguish between union members.** If removing `str` from the union causes no type error, it means no code path relies on `value` being a `str`. The union may be overly broad, or the type checker doesn't see the code that handles `str` specifically.
|
|
78
|
+
|
|
79
|
+
### RemoveOptional
|
|
80
|
+
|
|
81
|
+
Removes `None` from `X | None`.
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
# Original
|
|
85
|
+
def find_user(id: int) -> User | None: ...
|
|
86
|
+
|
|
87
|
+
# Mutant
|
|
88
|
+
def find_user(id: int) -> User: ...
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
**Survived = callers don't check for `None`.** The return type says "might be None" but no consumer's type annotations actually require a None-check. Either the None case is dead code, or callers use `# type: ignore`.
|
|
92
|
+
|
|
93
|
+
### AddOptional
|
|
94
|
+
|
|
95
|
+
Adds `| None` to return types and class fields (parameters are excluded — callers simply won't pass None, making those mutations uninformative).
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
# Original
|
|
99
|
+
class Config:
|
|
100
|
+
name: str
|
|
101
|
+
|
|
102
|
+
# Mutant
|
|
103
|
+
class Config:
|
|
104
|
+
name: str | None
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
**Survived = consumers don't rely on non-None guarantee.** The field claims to always have a value, but no typed code would break if it could be `None`. This often reveals missing type coverage in code that reads the field.
|
|
108
|
+
|
|
109
|
+
### SwapSiblingType
|
|
110
|
+
|
|
111
|
+
Replaces a class with its sibling (same base class).
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
class LoanState: ...
|
|
115
|
+
class ActiveLoan(LoanState): ...
|
|
116
|
+
class ClosedLoan(LoanState): ...
|
|
117
|
+
|
|
118
|
+
# Original
|
|
119
|
+
def process(loan: ActiveLoan) -> None: ...
|
|
120
|
+
|
|
121
|
+
# Mutant
|
|
122
|
+
def process(loan: ClosedLoan) -> None: ...
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
**Survived = the type system doesn't distinguish between sibling states.** Common in state machine patterns where the base class is typed as `Any` or all siblings share the same interface.
|
|
126
|
+
|
|
127
|
+
### SwapLiteralValue
|
|
128
|
+
|
|
129
|
+
Swaps values inside `Literal[...]` with other literal values from the same file.
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
# Original
|
|
133
|
+
status: Literal["active"]
|
|
134
|
+
|
|
135
|
+
# Mutant (if "closed" exists in the same file)
|
|
136
|
+
status: Literal["closed"]
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
**Survived = literal values are interchangeable from the type checker's perspective.** The code doesn't use literal narrowing or overloads to distinguish between the values.
|
|
140
|
+
|
|
141
|
+
### StripAnnotated
|
|
142
|
+
|
|
143
|
+
Removes metadata from `Annotated[X, ...]`, leaving just the base type.
|
|
144
|
+
|
|
145
|
+
```python
|
|
146
|
+
# Original
|
|
147
|
+
age: Annotated[int, Gt(0)]
|
|
148
|
+
|
|
149
|
+
# Mutant
|
|
150
|
+
age: int
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
**Survived = expected in most cases.** `Annotated` metadata is typically runtime-only (Pydantic validators, etc.). Survived mutants here are normal unless you use mypy plugins that understand the metadata.
|
|
154
|
+
|
|
155
|
+
### SwapContainerType
|
|
156
|
+
|
|
157
|
+
Swaps between compatible container types.
|
|
158
|
+
|
|
159
|
+
```python
|
|
160
|
+
# Original
|
|
161
|
+
items: list[int]
|
|
162
|
+
|
|
163
|
+
# Mutant
|
|
164
|
+
items: tuple[int]
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
**Swap groups:** `list` <-> `tuple`, `set` <-> `frozenset`. Dict has no swap target.
|
|
168
|
+
|
|
169
|
+
**Survived = code doesn't rely on container-specific behavior at the type level.** For example, if code only iterates over items, both `list` and `tuple` work equally.
|
|
170
|
+
|
|
171
|
+
## Filtering
|
|
172
|
+
|
|
173
|
+
Annotations are automatically skipped when:
|
|
174
|
+
|
|
175
|
+
- The line contains `# type: ignore` or `# pragma: no mutate`
|
|
176
|
+
- The annotation is `Any` (mutations are meaningless — Any absorbs all types)
|
|
177
|
+
- `AddOptional` targets a function parameter (low signal — callers won't pass None)
|
|
178
|
+
|
|
179
|
+
## Config Reference
|
|
180
|
+
|
|
181
|
+
```toml
|
|
182
|
+
[typemut]
|
|
183
|
+
module-path = "src/myproject" # directory to scan for annotations
|
|
184
|
+
test-command = "make typecheck" # command to run type checker
|
|
185
|
+
timeout = 30 # seconds per mutation
|
|
186
|
+
excluded-modules = ["src/vendor/*.py"] # glob patterns to skip
|
|
187
|
+
skip-comments = ["type: ignore", "pragma: no mutate"]
|
|
188
|
+
db = "typemut.sqlite" # database file
|
|
189
|
+
|
|
190
|
+
[typemut.operators]
|
|
191
|
+
# all enabled by default, disable selectively
|
|
192
|
+
remove-union-member = true
|
|
193
|
+
swap-literal-value = true
|
|
194
|
+
swap-sibling-type = true
|
|
195
|
+
strip-annotated = true
|
|
196
|
+
remove-optional = true
|
|
197
|
+
add-optional = true
|
|
198
|
+
swap-container-type = true
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
## HTML Report
|
|
202
|
+
|
|
203
|
+
The HTML report shows:
|
|
204
|
+
- Summary stats and per-module mutation scores
|
|
205
|
+
- Each mutant as a collapsible card with unified diff
|
|
206
|
+
- Color-coded status: killed (green), survived (red), error (orange)
|
|
207
|
+
- Full type checker output per mutant
|
|
208
|
+
- Expand/Collapse All controls
|
|
209
|
+
|
|
210
|
+
```bash
|
|
211
|
+
typemut html --open # save and open in browser
|
|
212
|
+
typemut html -o report.html # save to specific file
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
## Development
|
|
216
|
+
|
|
217
|
+
```bash
|
|
218
|
+
make install # create venv and install with dev deps
|
|
219
|
+
make test # run tests
|
|
220
|
+
make lint # run mypy
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
## Dependencies
|
|
224
|
+
|
|
225
|
+
- **parso** — CST parsing (preserves formatting and whitespace)
|
|
226
|
+
- **rich** — terminal reporting
|
|
227
|
+
- **click** — CLI framework
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "typemut"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Mutation testing for type annotations"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Niсл Khitrov", email = "khitrov34@gmail.com" },
|
|
14
|
+
]
|
|
15
|
+
keywords = ["mutation-testing", "type-annotations", "mypy", "pyright", "type-checking", "testing"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 3 - Alpha",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Programming Language :: Python :: 3.13",
|
|
24
|
+
"Topic :: Software Development :: Testing",
|
|
25
|
+
"Topic :: Software Development :: Quality Assurance",
|
|
26
|
+
"Typing :: Typed",
|
|
27
|
+
]
|
|
28
|
+
dependencies = [
|
|
29
|
+
"parso>=0.8",
|
|
30
|
+
"rich>=13.0",
|
|
31
|
+
"click>=8.0",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
[project.urls]
|
|
35
|
+
Homepage = "https://github.com/nkhitrov/typemut"
|
|
36
|
+
Repository = "https://github.com/nkhitrov/typemut"
|
|
37
|
+
Issues = "https://github.com/nkhitrov/typemut/issues"
|
|
38
|
+
|
|
39
|
+
[project.scripts]
|
|
40
|
+
typemut = "typemut.cli:main"
|
|
41
|
+
|
|
42
|
+
[tool.hatch.version]
|
|
43
|
+
path = "src/typemut/__init__.py"
|
|
44
|
+
|
|
45
|
+
[tool.hatch.build.targets.wheel]
|
|
46
|
+
packages = ["src/typemut"]
|
|
47
|
+
|
|
48
|
+
[tool.pytest.ini_options]
|
|
49
|
+
testpaths = ["tests"]
|
|
50
|
+
|
|
51
|
+
[project.optional-dependencies]
|
|
52
|
+
dev = [
|
|
53
|
+
"pytest>=7.0",
|
|
54
|
+
"mypy>=1.0",
|
|
55
|
+
]
|