├── .github └── workflows │ └── tests.yml ├── .gitignore ├── .readthedocs.yaml ├── ARCHITECTURE.rst ├── AUTHORS.rst ├── CONTRIBUTING.rst ├── HISTORY.rst ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.rst ├── browse_screenshot.png ├── cached-results-plan.txt ├── docs ├── Makefile ├── _templates │ ├── ghbuttons.html │ └── sidebarlogo.html ├── _themes │ ├── flask │ │ ├── LICENSE │ │ ├── layout.html │ │ ├── relations.html │ │ ├── static │ │ │ ├── flasky.css_t │ │ │ └── small_flask.css │ │ └── theme.conf │ └── flask_theme_support.py ├── browse_screenshot.png ├── conf.py ├── index.rst └── make.bat ├── e2e_projects ├── config │ ├── .python-version │ ├── README.md │ ├── config_pkg │ │ ├── __init__.py │ │ ├── ignore_me.py │ │ └── math.py │ ├── data │ │ └── data.json │ ├── pyproject.toml │ ├── tests │ │ └── test_main.py │ └── uv.lock └── my_lib │ ├── README.md │ ├── pyproject.toml │ ├── src │ └── my_lib │ │ └── __init__.py │ ├── tests │ └── test_my_lib.py │ └── uv.lock ├── mutmut ├── __init__.py ├── __main__.py ├── file_mutation.py ├── node_mutation.py ├── result_browser_layout.tcss └── trampoline_templates.py ├── requirements.txt ├── setup.cfg ├── setup.py ├── test_requirements.txt ├── tests ├── __init__.py ├── e2e │ ├── __init__.py │ ├── snapshots │ │ ├── config.json │ │ └── my_lib.json │ ├── test_cli_version.py │ └── test_e2e_result_snapshots.py ├── test_mutation.py └── test_mutmut3.py └── tox.ini /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Run tests 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | tests: 7 | name: Test Python ${{ matrix.python-version }} 8 | # Todo: Revert to ubuntu-latest when Python 3.7 support no longer needed 9 | runs-on: ubuntu-22.04 10 | strategy: 11 | fail-fast: false 12 | matrix: 13 | python-version: ["3.13", "3.12", "3.11", "3.10", "3.9"] 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v3 17 | 18 | - name: Set up Python ${{ matrix.python-version }} 19 | uses: actions/setup-python@v5 20 | with: 21 | python-version: ${{ matrix.python-version }} 22 | 23 | - name: Install dependencies 24 | run: | 25 | python -m pip install --upgrade pip 26 | pip install -r requirements.txt 27 | pip install -r test_requirements.txt 28 | 29 | - name: Run tests 30 | run: python -m pytest 31 | continue-on-error: false 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Mutant files 2 | e2e_projects/**/mutants 3 | 4 | *.py[cod] 5 | examples/db.sqlite3 6 | venv 7 | .venv 8 | table.css.map 9 | .idea 10 | .vscode 11 | .cache 12 | .DS_Store 13 | .pytest_cache 14 | testreport.xml 15 | test_fs/ 16 | 17 | # C extensions 18 | *.so 19 | 20 | # Packages 21 | *.egg 22 | *.egg-info 23 | dist 24 | build 25 | eggs 26 | parts 27 | sdist 28 | develop-eggs 29 | .installed.cfg 30 | .eggs 31 | 32 | # Installer logs 33 | pip-log.txt 34 | 35 | # Unit test / coverage reports 36 | .coverage 37 | .tox 38 | testreport.xml 39 | htmlcov 40 | 41 | # Translations 42 | *.mo 43 | 44 | # Mr Developer 45 | .mr.developer.cfg 46 | .project 47 | .pydevproject 48 | 49 | # Complexity 50 | output/*.html 51 | output/*/index.html 52 | 53 | # Sphinx 54 | docs/_build 55 | docs/tri*.rst 56 | 57 | # tests 58 | results/ 59 | test_projects/ 60 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | # Build documentation in the docs/ directory with Sphinx 4 | sphinx: 5 | configuration: docs/conf.py 6 | 7 | # Optionally set the version of Python and requirements required to build your docs 8 | python: 9 | install: 10 | - requirements: requirements.txt 11 | 12 | 13 | build: 14 | os: ubuntu-22.04 15 | tools: 16 | python: "3.12" 17 | -------------------------------------------------------------------------------- /ARCHITECTURE.rst: -------------------------------------------------------------------------------- 1 | Architecture 2 | ====================== 3 | 4 | This document gives an overview on how Mutmut works internally. 5 | 6 | Phases of ``mutmut run`` 7 | ------------------------ 8 | 9 | Generating mutants 10 | ^^^^^^^^^^^^^^^^^^ 11 | 12 | This phase creates a ``./mutants/`` directory, which will be used by all following phases. 13 | 14 | We start by copying ``paths_to_mutate`` to ``mutants/`` and then mutate the ``*.py`` files in there. Finally, we also copy ``also_copy`` paths to ``mutants/``, including the (guessed) test directories and some project files. 15 | 16 | The mutated files contains the original code and the mutants. With the ``MUTANT_UNDER_TEST`` environment variable, we can specify (among other things) which mutant should be enabled. If a mutant is not enabled, it will run the original code. 17 | 18 | 19 | Collecting tests and stats 20 | ^^^^^^^^^^^^^^^^^^^^^^^^^^ 21 | 22 | We collect a list of all tests and execute them. In this test run, we track which tests would execute which mutants, and how long they take. We use both stats for performance optimizations later on. The results are stored in ``mutants/mutmut-stats.json`` and global variables. 23 | 24 | 25 | Collecting mutation results 26 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^ 27 | 28 | We load mutation results from previous runs. Mutation results are loaded from ``.meta`` files next to the mutated code. For instance, the results of ``mutants/foo/bar.py`` will be loaded from ``mutants/foo/bar.py.meta``. 29 | 30 | 31 | Running clean tests 32 | ^^^^^^^^^^^^^^^^^^^ 33 | 34 | This step verifies that the test setup works. We disable all mutants and run all tests. As the tests use the original versions, this *should* succeed. 35 | 36 | 37 | Running forced fail test 38 | ^^^^^^^^^^^^^^^^^^^^^^^^ 39 | 40 | Here, we verify that the mutation setup works. We tell all mutants that they should raise an Exception, when being executed, and run all tests. We verify that at least one test failed, to ensure that enabling mutants works, and the tests run on mutated code. 41 | 42 | 43 | Running mutation testing 44 | ^^^^^^^^^^^^^^^^^^^^^^^^ 45 | 46 | We finally check, which mutations are caught by the test suite. 47 | 48 | For each mutant, we execute the test suite. If any of the tests fails, we successfully killed the mutant. To optimize performance, we only execute the tests that could cover the mutant and sort them by mutation time. We also skip mutants, which already have a result from a previous run. 49 | 50 | The results are stored in the ``.meta`` files. 51 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | Credits 3 | ======= 4 | 5 | * Anders Hovmöller 6 | * Felipe Pontes 7 | * William Orr 8 | * Trevin Gandhi 9 | * Daniel Hahler 10 | * Marcelo Da Cruz Pinto 11 | * Jakub Stolarski 12 | * Hristo Georgiev 13 | * Savo Kovačević 14 | * Nathan Klapstein 15 | * Brian Skinn 16 | * Jim Jazwiecki 17 | * neroks 18 | * John Vandenberg 19 | * Luca Simonetto 20 | * Emil Stenström 21 | * Roxane Bellott 22 | * Tomáš Chvátal 23 | * Frank Hoffmann <15r10nk-git@polarbit.de> 24 | * Éloi Rivard 25 | * Isidro Arias 26 | * Will Gibson 27 | * Dominic Amato 28 | * A_A 29 | * Luzin Boris 30 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | Contributing to Mutmut 2 | ====================== 3 | 4 | Setup 5 | ----- 6 | 7 | First fork the repository and clone your fork. 8 | 9 | Install the dependencies: 10 | 11 | .. code-block:: console 12 | 13 | pip install -r requirements.txt -r test_requirements.txt 14 | 15 | Running the tests 16 | ----------------- 17 | 18 | .. code-block:: console 19 | 20 | pytest 21 | 22 | This also runs E2E tests that verify that `mutmut run` produces the same output as before. If your code changes should change the output of `mutmut run` and this test fails, try to delete the `snapshots/*.json` files (as described in the test errors). 23 | 24 | If pytest terminates before reporting the test failures, it likely hit a case where mutmut calls `os._exit(...)`. Try looking at these calls first for troubleshooting. 25 | 26 | Running your local version of Mutmut against a test codebase 27 | ------------------------------------------------------------ 28 | 29 | You can install your local version of Mutmut and run it, including any changes you have made, as normal. 30 | 31 | .. code-block:: console 32 | 33 | python -m pip install --editable 34 | 35 | Documentation about mutmut's architecture 36 | ----------------------------------------- 37 | 38 | Please see ARCHITECTURE.rst for more details on how mutmut works. -------------------------------------------------------------------------------- /HISTORY.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | --------- 3 | 4 | 3.3.0 5 | ~~~~~ 6 | 7 | * Python 3.13 compatibility! 8 | 9 | * New argument `--show-killed` for `mutmut browse` 10 | 11 | * Fix to avoid accidentally importing the un-mutated original code 12 | 13 | * Handle segfault for mutant subprocesses 14 | 15 | * Added mutations for string literals 16 | 17 | * Added mutations for common string methods 18 | 19 | * Faster mutant generation via subprocesses 20 | 21 | * Fix `self` parameter for mutated class methods 22 | 23 | * Fix trampoline generation for function calls with 'orig' or 'mutants' as argument names. 24 | 25 | * Copy full source directory before creating mutants 26 | 27 | * Improved error message when forced fail test fails 28 | 29 | * Fixed issue with spaces in the python executable path 30 | 31 | * Do not mutate `__new__` 32 | 33 | * Annotate mutant dicts (and fixes compatibility with Pydantic) 34 | 35 | * Replaced parso with LibCST 36 | 37 | 38 | 3.2.3 39 | ~~~~~ 40 | 41 | * Crash with error message on invalid imports for `src` module 42 | 43 | * Autodetect simpler project configurations with `test_*.py` in the dir directly 44 | 45 | * Handle filenames (as opposed to dirnames) in paths_to_mutate 46 | 47 | * Also copy `setup.cfg` and `pyproject.toml` by default 48 | 49 | * Handle single line `paths_to_mutate` 50 | 51 | 52 | 3.2.2 53 | ~~~~~ 54 | 55 | * Fixed crash when running `mutmut results` 56 | 57 | 3.2.1 58 | ~~~~~ 59 | 60 | * Read `paths_to_mutate` from config file 61 | 62 | * Mutate `break` to `return` to avoid timeouts 63 | 64 | * Added debug mode. Enable with `debug=True` in `setup.cfg` under `[mutmut]` 65 | 66 | * Fixed new test detection. The old code incorrectly detected new tests when there were none, creating a much slower interaction loop for fixing mutants. 67 | 68 | * And many more fixes 69 | 70 | 3.2.0 71 | ~~~~~ 72 | 73 | * Timeouts for mutants implemented. 74 | 75 | * Browser: syntax highlighting for diff view 76 | 77 | * More fixes for generators. 78 | 79 | * Fix for `src`-style layout of projects. 80 | 81 | * Fixed bug where mutmut would recollect all tests on every run, slowing down startup. 82 | 83 | 84 | 3.1.0 85 | ~~~~~ 86 | 87 | * Correctly handle mutation for generator functions (`yield`). 88 | 89 | * Fixed so that `from __future__` lines are always first. 90 | 91 | * If no stats are collected exit directly, as that is a breaking error for mutation testing. 92 | 93 | * Changed name mangling to make mutants less likely to trigger name-based python magic, like in pytest where functions named `test_*` have special meaning. 94 | 95 | 96 | 3.0.5 97 | ~~~~~ 98 | 99 | * Another attempt to get the pypi package to work 100 | 101 | 102 | 3.0.4 103 | ~~~~~ 104 | 105 | * Another attempt to get the pypi package to work 106 | 107 | 3.0.3 108 | ~~~~~ 109 | 110 | * Fixed missing requirement in install package 111 | 112 | * Fixed missing file from the install package 113 | 114 | 3.0.2 115 | ~~~~~ 116 | 117 | * Fixed bad entrypoint definition 118 | 119 | * Ignore files that can't be parsed by `parso` 120 | 121 | 122 | 3.0.1 123 | ~~~~~ 124 | 125 | * Missed a file in distribution, so `browse` command was broken. 126 | 127 | 3.0.0 128 | ~~~~~ 129 | 130 | * Execution model switched to mutation schemata, which enabled parallell execution. 131 | 132 | * New terminal UI 133 | 134 | * Pytest only, which enabled better integration, cutting execution time significantly. 135 | 136 | 137 | 2.0.0 138 | ~~~~~ 139 | 140 | * New execution model. This should result in some modest speed improvements when using pytest. 141 | 142 | * A special execution mode when using the hammett test runner. This is MUCH MUCH faster. Please try it! 143 | 144 | * Dropped support for python < 3.7. If you need to use mutmut on older versions of python, please use mutmut 1.9.0 145 | 146 | * Some other speed improvements. 147 | 148 | 149 | 1.9.0 150 | ~~~~~ 151 | 152 | * `mutmut run 7` will always rerun the mutant `7` 153 | 154 | * `mutmut show ` to show all mutants for that file 155 | 156 | * `mutmut run ` to run mutation testing on that file 157 | 158 | * New experimental plugin system: create a file `mutmut_config.py` in your base directory. In it you can have an `init()` function, and a `pre_mutation(context)` function. You can set `context.skip = True` to skip a mutant, and you can modify `context.config.runner`, this is useful to limit the tests. Check out the `Context` class for what information you get. 159 | 160 | * Better display of `mutmut show`/`mutmut result` 161 | 162 | * Fixed a spurious mutant on assigning a local variable with type annotations 163 | 164 | 165 | 166 | 1.8.1 167 | ~~~~~ 168 | 169 | * mutmut now will rerun tests without mutation when tests have changed. This avoids a common pitfall of introducing a failing test and then having all mutants killed incorrectly 170 | 171 | 172 | 1.8.0 (2020-03-02) 173 | ~~~~~~~~~~~~~~~~~~ 174 | 175 | * Added `mutmut html` report generation. 176 | 177 | 1.7.0 (2020-02-29) 178 | ~~~~~~~~~~~~~~~~~~ 179 | 180 | * Bugfix for multiple assignment. Mutmut used to not handle `foo = bar = baz` correctly (Thanks Roxane Bellot!) 181 | 182 | * Bugfix for incorrect mutation of "in" operator (Thanks Roxane Bellot!) 183 | 184 | * Fixed bug where a mutant survived in the internal AST too long. This could cause mutmut to apply more than one mutant at a time. 185 | 186 | * Vastly improved startup performance when resuming a mutation run. 187 | 188 | * Added new experimental feature for advanced config at runtime of mutations 189 | 190 | 191 | 1.6.0 (2019-09-21) 192 | ~~~~~~~~~~~~~~~~~~ 193 | 194 | * Add `mutmut show [path to file]` command that shows all mutants for a given file 195 | 196 | * Better error messages if .coverage file isn't usable 197 | 198 | * Added support for windows paths in tests 199 | 200 | * Use the same python executable as mutmut is started as if possible 201 | 202 | * Dropped python 2 support 203 | 204 | * Added more assignment operator mutations 205 | 206 | * Bugfixes 207 | 208 | 209 | 1.5.0 (2019-04-10) 210 | ~~~~~~~~~~~~~~~~~~ 211 | 212 | * New mutation: None -> '' 213 | 214 | * Display all diffs for surviving mutants for a specific file with `mutmut show all path/to/file.py` 215 | 216 | * Display all diffs for surviving mutants with `mutmut show all` 217 | 218 | * Fixed a bug with grouping of the results for `mutmut results` 219 | 220 | * Fixed bug where `mutmut show X` sometimes showed no diff 221 | 222 | * Fixed bug where `mutmut apply X` sometimes didn't apply a mutation 223 | 224 | * Improved error message when trying to find the code 225 | 226 | * Fixed incorrect help message 227 | 228 | 1.4.0 (2019-03-26) 229 | ~~~~~~~~~~~~~~~~~~ 230 | 231 | * New setting: `--test-time-base=15.0`. This flag can be used to avoid issues with timing. 232 | 233 | * Post and pre hooks for the mutation step: `--pre-mutation=command` and `--post-mutation=command` if you want to run some command before and after a mutation testing round. 234 | 235 | * Fixed a bug with mutation of imports. 236 | 237 | * Fixed missing newline at end of the output of mutmut. 238 | 239 | * Support for mutating only lines specified by a patch file: `--use-patch-file=foo.patch`. 240 | 241 | * Fixed mutation of arguments in function call. 242 | 243 | * Looser heuristics for finding the source to mutate. This should mean more projects will just work out of the box. 244 | 245 | * Fixed mutation of arguments in function call for python 2.7. 246 | 247 | * Fixed a bug where if mutmut couldn't find the test code it thought the tests hadn't changed. Now mutmut treats this situation as the tests always being changed. 248 | 249 | * Fixed bug where the function body was skipped for mutation if a return type annotation existed. 250 | 251 | * 252 | 253 | 254 | 1.3.1 (2019-01-30) 255 | ~~~~~~~~~~~~~~~~~~ 256 | 257 | * Fixed a bug where mutmut crashed if a file contained exactly zero bytes. 258 | 259 | 260 | 1.3.0 (2019-01-23) 261 | ~~~~~~~~~~~~~~~~~~ 262 | 263 | * Fixed incorrect loading of coverage data when using the `--use-coverage` flag. 264 | 265 | * Fixed a bug when updating the cache. 266 | 267 | * Fixed incorrect handling of source files that didn't end with a newline. 268 | 269 | 270 | 1.2.0 (2019-01-10) 271 | ~~~~~~~~~~~~~~~~~~ 272 | 273 | * JUnit XML output: Run `mutmut junitxml` to output the results as a JUnit compatible XML file. 274 | 275 | * Python 2 compatibility fixes. 276 | 277 | * pypy compatibility fixes. 278 | 279 | * Fixed an issue where mutmut couldn't kill the spawned test process. 280 | 281 | * Travis tests now test much more thoroughly, both python2, 3, pypy and on windows. 282 | 283 | * The return code of mutmut now reflects what mutmut found during execution. 284 | 285 | * New command line option `--test-time-multiplier` to tweak the detection threshold for mutations that make the code slower. 286 | 287 | * Fixed compatibility with Windows. 288 | 289 | 290 | Thanks goes out Marcelo Da Cruz Pinto, Savo Kovačević, 291 | 292 | 293 | 1.1.0 (2018-12-10) 294 | ~~~~~~~~~~~~~~~~~~~ 295 | 296 | * New mutant: mutate the first argument of function calls to None if it's not already None 297 | 298 | * Totally overhauled cache system: now handles duplicates lines correctly. 299 | 300 | 301 | 1.0.1 (2018-11-18) 302 | ~~~~~~~~~~~~~~~~~~~ 303 | 304 | * Minor UX fixes: --version command was broken, incorrect documentation shown, missing newline at the very end. 305 | 306 | * Caching the baseline test time. This makes restarting/rechecking existing mutants much faster, with a small risk of that time being out of date. 307 | 308 | 309 | 1.0.0 (2018-11-12) 310 | ~~~~~~~~~~~~~~~~~~~ 311 | 312 | * Totally new user interface! Should be much easier to understand and it's easier to see that something is happening 313 | 314 | * Totally new cache handling. Mutmut will now know which mutants are already killed and not try them again, and it will know which mutants to retest if the tests change 315 | 316 | * Infinite loop detection now works in Python < 3.3 317 | 318 | * Added `--version` flag 319 | 320 | * Nice error message when no `.coverage` file is found when using the `--use-coverage` flag 321 | 322 | * Fixed crash when using `--use-coverage` flag. Thanks Daniel Hahler! 323 | 324 | * Added mutation based on finding on tri.struct 325 | 326 | 327 | 0.0.24 (2018-11-04) 328 | ~~~~~~~~~~~~~~~~~~~ 329 | 330 | * Stopped mutation of type annotation 331 | 332 | * Simple infinite loop detection: timeout on 10x the baseline time 333 | 334 | 335 | 0.0.23 (2018-11-03) 336 | ~~~~~~~~~~~~~~~~~~~ 337 | 338 | * Make number_mutation more robust to floats (Thanks Trevin Gandhi!) 339 | 340 | * Fixed crash when using Python 3 typing to declare a type but not assigning to that variable 341 | 342 | 343 | 344 | 0.0.22 (2018-10-07) 345 | ~~~~~~~~~~~~~~~~~~~ 346 | 347 | * Handle annotated assignment in Python 3.6. Thanks William Orr! 348 | 349 | 350 | 0.0.21 (2018-08-25) 351 | ~~~~~~~~~~~~~~~~~~~ 352 | 353 | * Fixed critical bug: mutmut reported killed mutants as surviving and vice versa. 354 | 355 | * Fixed an issue where the install failed on some systems. 356 | 357 | * Handle tests dirs spread out in the file system. This is the normal case for django projects for example. 358 | 359 | * Fixes for supporting both python 3 and 2. 360 | 361 | * Misc mutation fixes. 362 | 363 | * Ability to test a single mutation. 364 | 365 | * Feature to print the cache (--print-cache). 366 | 367 | * Turned off error recovery mode for parso. You will now get exceptions for invalid or unsupported python code. 368 | 369 | 370 | 0.0.20 (2018-08-02) 371 | ~~~~~~~~~~~~~~~~~~~ 372 | 373 | * Changed AST library from baron to parso 374 | 375 | * Some usability enhancements suggested by David M. Howcraft 376 | 377 | 378 | 0.0.19 (2018-07-20) 379 | ~~~~~~~~~~~~~~~~~~~ 380 | 381 | * Caching of mutation testing results. This is still rather primitive but can in some cases cut down on rerunning mutmut drastically. 382 | 383 | * New mutation IDs. They are now indexed per line instead of an index for the entire file. This means you can apply your mutations in any order you see fit and the rest of the apply commands will be unaffected. 384 | 385 | 386 | 0.0.18 (2018-04-27) 387 | ~~~~~~~~~~~~~~~~~~~ 388 | 389 | * Fixed bug where initial mutation count was wrong, which caused mutmut to miss mutants at the end of the file 390 | 391 | * Changed mutation API to always require a `Context` object. This makes is much easier to pass additional data out to the caller 392 | 393 | * Support specifying individual files to mutate (thanks Felipe Pontes!) 394 | 395 | 396 | 0.0.16 (2017-10-09) 397 | ~~~~~~~~~~~~~~~~~~~ 398 | 399 | * Improve error message when baron crashes a bit (fixes #10) 400 | 401 | * New mutation: right hand side of assignments 402 | 403 | * Fixed nasty bug where applying a mutation could apply a different mutation than the one that was found during mutation testing 404 | 405 | 406 | 0.0.14 (2017-09-02) 407 | ~~~~~~~~~~~~~~~~~~~ 408 | 409 | * Don't assume UNIX (fixes github issue #9: didn't work on windows) 410 | 411 | 412 | 0.0.12 (2017-08-27) 413 | ~~~~~~~~~~~~~~~~~~~ 414 | 415 | * Changed default runner to add `-x` flag to pytest. Could radically speed up tests if you're lucky! 416 | 417 | * New flag: `--show-times` 418 | 419 | * Now warns if a mutation triggers very long test times 420 | 421 | * Added a workaround for pytest-testmon (all tests deselected is return code 5 even though it's a success) 422 | 423 | 424 | 0.0.11 (2017-08-03) 425 | ~~~~~~~~~~~~~~~~~~~ 426 | 427 | * Fixed bug that made mutmut crash when setup.cfg was missing 428 | 429 | 430 | 0.0.10 (2017-07-16) 431 | ~~~~~~~~~~~~~~~~~~~ 432 | 433 | * Renamed parameter `--testsdir` to `--tests-dir` 434 | 435 | * Refactored handling of setup.cfg file. Much cleaner solution and adds `--dict-synonyms` command line parameter 436 | 437 | 438 | 0.0.9 (2017-07-05) 439 | ~~~~~~~~~~~~~~~~~~ 440 | 441 | * Bug with dict param mutations: it mutated all parameters, this could vastly decrease the odds of finding a mutant 442 | 443 | * New mutation: remove the body or return 0 instead of None 444 | 445 | 446 | 0.0.8 (2017-06-28) 447 | ~~~~~~~~~~~~~~~~~~ 448 | 449 | * Previous version had broken version on pypi 450 | 451 | 452 | 0.0.7 (2017-06-28) 453 | ~~~~~~~~~~~~~~~~~~ 454 | 455 | * Fixed bug where pragma didn't work for decorator mutations 456 | 457 | * Dict literals looking like `dict(a=foo)` now have mutated keys. You can also declare synonyms in setup.cfg. 458 | 459 | * Fix "from x import *" 460 | 461 | 462 | 0.0.6 (2017-06-13) 463 | ~~~~~~~~~~~~~~~~~~ 464 | 465 | * New mutation: remove decorators! 466 | 467 | * Improved status while running. This should make it easier to handle when you hit mutants that cause infinite loops. 468 | 469 | * Fixes failing attempts to mutate parentheses. (Thanks Hristo Georgiev!) 470 | 471 | 472 | 0.0.5 (2017-05-06) 473 | ~~~~~~~~~~~~~~~~~~ 474 | 475 | * Try to fix pypi package 476 | 477 | 478 | 0.0.4 (2017-05-06) 479 | ~~~~~~~~~~~~~~~~~~ 480 | 481 | * Try to fix pypi package 482 | 483 | 484 | 0.0.3 (2017-05-05) 485 | ~~~~~~~~~~~~~~~~~~ 486 | 487 | * Python 3 support (as far as baron supports it anyway) 488 | 489 | * Try running without mutations first to make sure we can run the test suite cleanly before starting mutation 490 | 491 | * Implemented feature to run mutation on covered lines only, this is useful for mutation testing existing tests when you don't have 100% coverage 492 | 493 | * Error message on incorrect invocation 494 | 495 | 496 | 0.0.2 (2016-12-01) 497 | ~~~~~~~~~~~~~~~~~~ 498 | 499 | * Tons of fixes 500 | 501 | 502 | 0.0.1 (2016-12-01) 503 | ~~~~~~~~~~~~~~~~~~ 504 | 505 | * Initial version 506 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016, Anders Hovmöller 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 5 | 6 | * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 7 | 8 | * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 9 | 10 | * Neither the name of mutmut nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 11 | 12 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 13 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include HISTORY.rst 2 | include LICENSE 3 | include README.rst 4 | include requirements.txt 5 | exclude tests/*.* 6 | recursive-include mutmut *.* 7 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: clean-pyc clean-build docs clean lint test coverage docs dist tag release-check 2 | 3 | help: 4 | @echo "clean-build - remove build artifacts" 5 | @echo "clean-pyc - remove Python file artifacts" 6 | @echo "lint - check style with flake8" 7 | @echo "test - run tests" 8 | @echo "coverage - check code coverage quickly with the default Python" 9 | @echo "docs - generate Sphinx HTML documentation, including API docs" 10 | @echo "dist - package" 11 | @echo "tag - set a tag with the current version number" 12 | @echo "release-check - check release tag" 13 | 14 | clean: clean-build clean-pyc 15 | rm -fr htmlcov/ 16 | 17 | clean-build: 18 | rm -fr build/ 19 | rm -fr dist/ 20 | rm -fr *.egg-info 21 | 22 | clean-pyc: 23 | find . -name '*.pyc' -exec rm -f {} + 24 | find . -name '*.pyo' -exec rm -f {} + 25 | find . -name '*~' -exec rm -f {} + 26 | 27 | clean-docs: 28 | rm -f docs/tri*.rst 29 | 30 | lint: 31 | tox -e lint 32 | 33 | test: 34 | tox 35 | 36 | coverage: 37 | tox -e coverage 38 | 39 | docs: 40 | tox -e docs 41 | 42 | dist: clean 43 | python -m build --no-isolation 44 | 45 | tag: 46 | python setup.py tag 47 | 48 | release-check: 49 | python setup.py release_check 50 | 51 | release: 52 | rm -rf dist/ build/ && python setup.py sdist bdist_wheel && twine upload dist/* 53 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | mutmut - python mutation tester 2 | =============================== 3 | 4 | .. image:: https://github.com/boxed/mutmut/actions/workflows/tests.yml/badge.svg 5 | :target: https://github.com/boxed/mutmut/actions/workflows/tests.yml 6 | 7 | .. image:: https://readthedocs.org/projects/mutmut/badge/?version=latest 8 | :target: https://mutmut.readthedocs.io/en/latest/?badge=latest 9 | :alt: Documentation Status 10 | 11 | 12 | Mutmut is a mutation testing system for Python, with a strong focus on ease 13 | of use. If you don't know what mutation testing is try starting with 14 | `this article `_. 15 | 16 | Some highlight features: 17 | 18 | - Found mutants can be applied on disk with a simple command making it very 19 | easy to work with the results 20 | - Remembers work that has been done, so you can work incrementally 21 | - Knows which tests to execute, speeding up mutation testing 22 | - Interactive terminal based UI 23 | - Parallel and fast execution 24 | 25 | .. image:: browse_screenshot.png 26 | 27 | 28 | If you want to mutate code outside of functions, you can try using mutmut 2, 29 | which has a different execution model than mutmut 3+. 30 | 31 | 32 | Requirements 33 | ------------ 34 | 35 | Mutmut must be run on a system with `fork` support. This means that if you want 36 | to run on windows, you must run inside WSL. 37 | 38 | 39 | 40 | Install and run 41 | --------------- 42 | 43 | You can get started with a simple: 44 | 45 | .. code-block:: console 46 | 47 | pip install mutmut 48 | mutmut run 49 | 50 | This will by run pytest on tests in the "tests" or "test" folder and 51 | it will try to figure out where the code to mutate is. 52 | 53 | 54 | 55 | You can stop the mutation run at any time and mutmut will restart where you 56 | left off. It will continue where it left off, and re-test functions that were 57 | modified since last run. 58 | 59 | To work with the results, use `mutmut browse` where you can see the mutants, 60 | retest them when you've updated your tests. 61 | 62 | You can also write a mutant to disk from the `browse` interface, or via 63 | `mutmut apply `. You should **REALLY** have the file you mutate under 64 | source code control and committed before you apply a mutant! 65 | 66 | 67 | Configuration 68 | ------------- 69 | 70 | In `setup.cfg` in the root of your project you can configure mutmut if you need to: 71 | 72 | .. code-block:: ini 73 | 74 | [mutmut] 75 | paths_to_mutate=src/ 76 | tests_dir=tests/ 77 | 78 | If you use `pyproject.toml`, you must specify the paths as array in a `tool.mutmut` section: 79 | 80 | .. code-block:: toml 81 | 82 | [tool.mutmut] 83 | paths_to_mutate = [ "src/" ] 84 | tests_dir = [ "tests/" ] 85 | 86 | See below for more options for configuring mutmut. 87 | 88 | 89 | Wildcards for testing mutants 90 | ----------------------------- 91 | 92 | Unix filename pattern matching style on mutants is supported. Example: 93 | 94 | .. code-block:: console 95 | 96 | mutmut run "my_module*" 97 | mutmut run "my_module.my_function*" 98 | 99 | In the `browse` TUI you can press `f` to retest a function, and `m` to retest 100 | an entire module. 101 | 102 | 103 | "also copy" files 104 | ----------------- 105 | 106 | To run the full test suite some files are often needed above the tests and the 107 | source. You can configure to copy extra files that you need by adding 108 | directories and files to `also_copy` in your `setup.cfg`: 109 | 110 | .. code-block:: ini 111 | 112 | also_copy= 113 | iommi/snapshots/ 114 | conftest.py 115 | 116 | 117 | Limit stack depth 118 | ----------------- 119 | 120 | In big code bases some functions are called incidentally by huge swaths of the 121 | codebase, but you really don't want tests that hit those executions to count 122 | for mutation testing purposes. Incidentally tested functions lead to slow 123 | mutation testing as hundreds of tests can be checked for things that should 124 | have clean and fast unit tests, and it leads to bad test suites as any 125 | introduced bug in those base functions will lead to many tests that fail which 126 | are hard to understand how they relate to the function with the change. 127 | 128 | You can configure mutmut to only count a test as being relevant for a function 129 | if the stack depth from the test to the function is below some limit. In your 130 | `setup.cfg` add: 131 | 132 | .. code-block:: ini 133 | 134 | max_stack_depth=8 135 | 136 | A lower value will increase mutation speed and lead to more localized tests, 137 | but will also lead to more surviving mutants that would otherwise have been 138 | caught. 139 | 140 | 141 | Exclude files from mutation 142 | --------------------------- 143 | 144 | You can exclude files from mutation in `setup.cfg`: 145 | 146 | .. code-block:: 147 | 148 | do_not_mutate= 149 | *__tests.py 150 | 151 | 152 | Whitelisting 153 | ------------ 154 | 155 | You can mark lines like this: 156 | 157 | .. code-block:: python 158 | 159 | some_code_here() # pragma: no mutate 160 | 161 | to stop mutation on those lines. Some cases we've found where you need to 162 | whitelist lines are: 163 | 164 | - The version string on your library. You really shouldn't have a test for this :P 165 | - Optimizing break instead of continue. The code runs fine when mutating break 166 | to continue, but it's slower. 167 | 168 | 169 | Example mutations 170 | ----------------- 171 | 172 | - Integer literals are changed by adding 1. So 0 becomes 1, 5 becomes 6, etc. 173 | - `<` is changed to `<=` 174 | - break is changed to continue and vice versa 175 | 176 | In general the idea is that the mutations should be as subtle as possible. 177 | See `node_mutation.py` for the full list and `test_mutation.py` for tests describing them. 178 | 179 | 180 | Workflow 181 | -------- 182 | 183 | This section describes how to work with mutmut to enhance your test suite. 184 | 185 | 1. Run mutmut with `mutmut run`. A full run is preferred but if you're just 186 | getting started you can exit in the middle and start working with what you 187 | have found so far. 188 | 2. Show the mutants with `mutmut browse` 189 | 3. Find a mutant you want to work on and write a test to try to kill it. 190 | 4. Press `r` to rerun the mutant and see if you successfully managed to kill it. 191 | 192 | Mutmut keeps the data of what it has done and the mutants in the `mutants/` 193 | directory.If you want to make sure you run a full mutmut run you can delete 194 | this directory to start from scratch. 195 | 196 | Contributing to Mutmut 197 | ---------------------- 198 | 199 | If you wish to contribute to Mutmut, please see our `contributing guide `_. 200 | -------------------------------------------------------------------------------- /browse_screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boxed/mutmut/b124c6ad46b0a9a048b5e47c8b7f93d33a9f2e6a/browse_screenshot.png -------------------------------------------------------------------------------- /cached-results-plan.txt: -------------------------------------------------------------------------------- 1 | Mutmut cached results plan: 2 | 3 | Unanswered question: 4 | When do we update the cache? It must be safe so that you can quit mutmut at any time and the cache won't be broken. 5 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SPHINXPROJ = mutmut 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -t docs 21 | -------------------------------------------------------------------------------- /docs/_templates/ghbuttons.html: -------------------------------------------------------------------------------- 1 |

Github

2 | 4 |

5 | -------------------------------------------------------------------------------- /docs/_templates/sidebarlogo.html: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /docs/_themes/flask/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2010 by Armin Ronacher. 2 | 3 | Some rights reserved. 4 | 5 | Redistribution and use in source and binary forms of the theme, with or 6 | without modification, are permitted provided that the following conditions 7 | are met: 8 | 9 | * Redistributions of source code must retain the above copyright 10 | notice, this list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above 13 | copyright notice, this list of conditions and the following 14 | disclaimer in the documentation and/or other materials provided 15 | with the distribution. 16 | 17 | * The names of the contributors may not be used to endorse or 18 | promote products derived from this software without specific 19 | prior written permission. 20 | 21 | We kindly ask you to only use these themes in an unmodified manner just 22 | for Flask and Flask-related products, not for unrelated projects. If you 23 | like the visual style and want to use it for your own projects, please 24 | consider making some larger changes to the themes (such as changing 25 | font faces, sizes, colors or margins). 26 | 27 | THIS THEME IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 28 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 29 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 30 | ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE 31 | LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 32 | CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 33 | SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 34 | INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 35 | CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 36 | ARISING IN ANY WAY OUT OF THE USE OF THIS THEME, EVEN IF ADVISED OF THE 37 | POSSIBILITY OF SUCH DAMAGE. 38 | -------------------------------------------------------------------------------- /docs/_themes/flask/layout.html: -------------------------------------------------------------------------------- 1 | {%- extends "basic/layout.html" %} 2 | {%- block extrahead %} 3 | {{ super() }} 4 | {% if theme_touch_icon %} 5 | 6 | {% endif %} 7 | 9 | 10 | Fork me 11 | 12 | {% endblock %} 13 | {%- block relbar2 %}{% endblock %} 14 | {% block header %} 15 | {{ super() }} 16 | {% if pagename == 'index' %} 17 |
18 | {% endif %} 19 | {% endblock %} 20 | {%- block footer %} 21 | 24 | {% if pagename == 'index' %} 25 |
26 | {% endif %} 27 | {%- endblock %} 28 | -------------------------------------------------------------------------------- /docs/_themes/flask/relations.html: -------------------------------------------------------------------------------- 1 |

Related Topics

2 | 20 | -------------------------------------------------------------------------------- /docs/_themes/flask/static/flasky.css_t: -------------------------------------------------------------------------------- 1 | /* 2 | * flasky.css_t 3 | * ~~~~~~~~~~~~ 4 | * 5 | * :copyright: Copyright 2010 by Armin Ronacher. 6 | * :license: Flask Design License, see LICENSE for details. 7 | */ 8 | 9 | {% set page_width = '940px' %} 10 | {% set sidebar_width = '220px' %} 11 | 12 | @import url("basic.css"); 13 | 14 | /* -- page layout ----------------------------------------------------------- */ 15 | 16 | body { 17 | font-family: 'Georgia', serif; 18 | font-size: 17px; 19 | background-color: white; 20 | color: #000; 21 | margin: 0; 22 | padding: 0; 23 | } 24 | 25 | div.document { 26 | width: {{ page_width }}; 27 | margin: 30px auto 0 auto; 28 | } 29 | 30 | div.documentwrapper { 31 | float: left; 32 | width: 100%; 33 | } 34 | 35 | div.bodywrapper { 36 | margin: 0 0 0 {{ sidebar_width }}; 37 | } 38 | 39 | div.sphinxsidebar { 40 | width: {{ sidebar_width }}; 41 | } 42 | 43 | hr { 44 | border: 1px solid #B1B4B6; 45 | } 46 | 47 | div.body { 48 | background-color: #ffffff; 49 | color: #3E4349; 50 | padding: 0 30px 0 30px; 51 | } 52 | 53 | img.floatingflask { 54 | padding: 0 0 10px 10px; 55 | float: right; 56 | } 57 | 58 | div.footer { 59 | width: {{ page_width }}; 60 | margin: 20px auto 30px auto; 61 | font-size: 14px; 62 | color: #888; 63 | text-align: right; 64 | } 65 | 66 | div.footer a { 67 | color: #888; 68 | } 69 | 70 | div.related { 71 | display: none; 72 | } 73 | 74 | div.sphinxsidebar a { 75 | color: #444; 76 | text-decoration: none; 77 | border-bottom: 1px dotted #999; 78 | } 79 | 80 | div.sphinxsidebar a:hover { 81 | border-bottom: 1px solid #999; 82 | } 83 | 84 | div.sphinxsidebar { 85 | font-size: 14px; 86 | line-height: 1.5; 87 | } 88 | 89 | div.sphinxsidebarwrapper { 90 | padding: 18px 10px; 91 | } 92 | 93 | div.sphinxsidebarwrapper p.logo { 94 | padding: 0 0 20px 0; 95 | margin: 0; 96 | text-align: center; 97 | } 98 | 99 | div.sphinxsidebar h3, 100 | div.sphinxsidebar h4 { 101 | font-family: 'Garamond', 'Georgia', serif; 102 | color: #444; 103 | font-size: 24px; 104 | font-weight: normal; 105 | margin: 0 0 5px 0; 106 | padding: 0; 107 | } 108 | 109 | div.sphinxsidebar h4 { 110 | font-size: 20px; 111 | } 112 | 113 | div.sphinxsidebar h3 a { 114 | color: #444; 115 | } 116 | 117 | div.sphinxsidebar p.logo a, 118 | div.sphinxsidebar h3 a, 119 | div.sphinxsidebar p.logo a:hover, 120 | div.sphinxsidebar h3 a:hover { 121 | border: none; 122 | } 123 | 124 | div.sphinxsidebar p { 125 | color: #555; 126 | margin: 10px 0; 127 | } 128 | 129 | div.sphinxsidebar ul { 130 | margin: 10px 0; 131 | padding: 0; 132 | color: #000; 133 | } 134 | 135 | div.sphinxsidebar input { 136 | border: 1px solid #ccc; 137 | font-family: 'Georgia', serif; 138 | font-size: 1em; 139 | } 140 | 141 | /* -- body styles ----------------------------------------------------------- */ 142 | 143 | a { 144 | color: #004B6B; 145 | text-decoration: underline; 146 | } 147 | 148 | a:hover { 149 | color: #6D4100; 150 | text-decoration: underline; 151 | } 152 | 153 | div.body h1, 154 | div.body h2, 155 | div.body h3, 156 | div.body h4, 157 | div.body h5, 158 | div.body h6 { 159 | font-family: 'Garamond', 'Georgia', serif; 160 | font-weight: normal; 161 | margin: 30px 0px 10px 0px; 162 | padding: 0; 163 | } 164 | 165 | {% if theme_index_logo %} 166 | div.indexwrapper h1 { 167 | text-indent: -999999px; 168 | background: url({{ theme_index_logo }}) no-repeat center center; 169 | height: {{ theme_index_logo_height }}; 170 | } 171 | {% endif %} 172 | 173 | div.body h1 { margin-top: 0; padding-top: 0; font-size: 240%; } 174 | div.body h2 { font-size: 180%; } 175 | div.body h3 { font-size: 150%; } 176 | div.body h4 { font-size: 130%; } 177 | div.body h5 { font-size: 100%; } 178 | div.body h6 { font-size: 100%; } 179 | 180 | a.headerlink { 181 | color: #ddd; 182 | padding: 0 4px; 183 | text-decoration: none; 184 | } 185 | 186 | a.headerlink:hover { 187 | color: #444; 188 | } 189 | 190 | div.body p, div.body dd, div.body li { 191 | line-height: 1.4em; 192 | } 193 | 194 | div.admonition { 195 | background: #fafafa; 196 | margin: 20px -30px; 197 | padding: 10px 30px; 198 | border-top: 1px solid #ccc; 199 | border-bottom: 1px solid #ccc; 200 | } 201 | 202 | div.admonition tt.xref, div.admonition a tt { 203 | border-bottom: 1px solid #fafafa; 204 | } 205 | 206 | dd div.admonition { 207 | margin-left: -60px; 208 | padding-left: 60px; 209 | } 210 | 211 | div.admonition p.admonition-title { 212 | font-family: 'Garamond', 'Georgia', serif; 213 | font-weight: normal; 214 | font-size: 24px; 215 | margin: 0 0 10px 0; 216 | padding: 0; 217 | line-height: 1; 218 | } 219 | 220 | div.admonition p.last { 221 | margin-bottom: 0; 222 | } 223 | 224 | div.highlight { 225 | background-color: white; 226 | } 227 | 228 | dt:target, .highlight { 229 | background: #FAF3E8; 230 | } 231 | 232 | div.note { 233 | background-color: #eee; 234 | border: 1px solid #ccc; 235 | } 236 | 237 | div.seealso { 238 | background-color: #ffc; 239 | border: 1px solid #ff6; 240 | } 241 | 242 | div.topic { 243 | background-color: #eee; 244 | } 245 | 246 | p.admonition-title { 247 | display: inline; 248 | } 249 | 250 | p.admonition-title:after { 251 | content: ":"; 252 | } 253 | 254 | pre, tt { 255 | font-family: 'Consolas', 'Menlo', 'Deja Vu Sans Mono', 'Bitstream Vera Sans Mono', monospace; 256 | font-size: 0.9em; 257 | } 258 | 259 | img.screenshot { 260 | } 261 | 262 | tt.descname, tt.descclassname { 263 | font-size: 0.95em; 264 | } 265 | 266 | tt.descname { 267 | padding-right: 0.08em; 268 | } 269 | 270 | img.screenshot { 271 | -moz-box-shadow: 2px 2px 4px #eee; 272 | -webkit-box-shadow: 2px 2px 4px #eee; 273 | box-shadow: 2px 2px 4px #eee; 274 | } 275 | 276 | table.docutils { 277 | border: 1px solid #888; 278 | -moz-box-shadow: 2px 2px 4px #eee; 279 | -webkit-box-shadow: 2px 2px 4px #eee; 280 | box-shadow: 2px 2px 4px #eee; 281 | } 282 | 283 | table.docutils td, table.docutils th { 284 | border: 1px solid #888; 285 | padding: 0.25em 0.7em; 286 | } 287 | 288 | table.field-list, table.footnote { 289 | border: none; 290 | -moz-box-shadow: none; 291 | -webkit-box-shadow: none; 292 | box-shadow: none; 293 | } 294 | 295 | table.footnote { 296 | margin: 15px 0; 297 | width: 100%; 298 | border: 1px solid #eee; 299 | background: #fdfdfd; 300 | font-size: 0.9em; 301 | } 302 | 303 | table.footnote + table.footnote { 304 | margin-top: -15px; 305 | border-top: none; 306 | } 307 | 308 | table.field-list th { 309 | padding: 0 0.8em 0 0; 310 | } 311 | 312 | table.field-list td { 313 | padding: 0; 314 | } 315 | 316 | table.footnote td.label { 317 | width: 0px; 318 | padding: 0.3em 0 0.3em 0.5em; 319 | } 320 | 321 | table.footnote td { 322 | padding: 0.3em 0.5em; 323 | } 324 | 325 | dl { 326 | margin: 0; 327 | padding: 0; 328 | } 329 | 330 | dl dd { 331 | margin-left: 30px; 332 | } 333 | 334 | blockquote { 335 | margin: 0 0 0 30px; 336 | padding: 0; 337 | } 338 | 339 | ul, ol { 340 | margin: 10px 0 10px 30px; 341 | padding: 0; 342 | } 343 | 344 | pre { 345 | background: #eee; 346 | padding: 7px 30px; 347 | margin: 15px -30px; 348 | line-height: 1.3em; 349 | } 350 | 351 | dl pre, blockquote pre, li pre { 352 | margin-left: -60px; 353 | padding-left: 60px; 354 | } 355 | 356 | dl dl pre { 357 | margin-left: -90px; 358 | padding-left: 90px; 359 | } 360 | 361 | tt { 362 | background-color: #ecf0f3; 363 | color: #222; 364 | /* padding: 1px 2px; */ 365 | } 366 | 367 | tt.xref, a tt { 368 | background-color: #FBFBFB; 369 | border-bottom: 1px solid white; 370 | } 371 | 372 | a.reference { 373 | text-decoration: none; 374 | border-bottom: 1px dotted #004B6B; 375 | } 376 | 377 | a.reference:hover { 378 | border-bottom: 1px solid #6D4100; 379 | } 380 | 381 | a.footnote-reference { 382 | text-decoration: none; 383 | font-size: 0.7em; 384 | vertical-align: top; 385 | border-bottom: 1px dotted #004B6B; 386 | } 387 | 388 | a.footnote-reference:hover { 389 | border-bottom: 1px solid #6D4100; 390 | } 391 | 392 | a:hover tt { 393 | background: #EEE; 394 | } 395 | -------------------------------------------------------------------------------- /docs/_themes/flask/static/small_flask.css: -------------------------------------------------------------------------------- 1 | /* 2 | * small_flask.css_t 3 | * ~~~~~~~~~~~~~~~~~ 4 | * 5 | * :copyright: Copyright 2010 by Armin Ronacher. 6 | * :license: Flask Design License, see LICENSE for details. 7 | */ 8 | 9 | body { 10 | margin: 0; 11 | padding: 20px 30px; 12 | } 13 | 14 | div.documentwrapper { 15 | float: none; 16 | background: white; 17 | } 18 | 19 | div.sphinxsidebar { 20 | display: block; 21 | float: none; 22 | width: 102.5%; 23 | margin: 50px -30px -20px -30px; 24 | padding: 10px 20px; 25 | background: #333; 26 | color: white; 27 | } 28 | 29 | div.sphinxsidebar h3, div.sphinxsidebar h4, div.sphinxsidebar p, 30 | div.sphinxsidebar h3 a { 31 | color: white; 32 | } 33 | 34 | div.sphinxsidebar a { 35 | color: #aaa; 36 | } 37 | 38 | div.sphinxsidebar p.logo { 39 | display: none; 40 | } 41 | 42 | div.document { 43 | width: 100%; 44 | margin: 0; 45 | } 46 | 47 | div.related { 48 | display: block; 49 | margin: 0; 50 | padding: 10px 0 20px 0; 51 | } 52 | 53 | div.related ul, 54 | div.related ul li { 55 | margin: 0; 56 | padding: 0; 57 | } 58 | 59 | div.footer { 60 | display: none; 61 | } 62 | 63 | div.bodywrapper { 64 | margin: 0; 65 | } 66 | 67 | div.body { 68 | min-height: 0; 69 | padding: 0; 70 | } 71 | -------------------------------------------------------------------------------- /docs/_themes/flask/theme.conf: -------------------------------------------------------------------------------- 1 | [theme] 2 | inherit = basic 3 | stylesheet = flasky.css 4 | pygments_style = flask_theme_support.FlaskyStyle 5 | 6 | [options] 7 | index_logo = 8 | index_logo_height = 120px 9 | touch_icon = 10 | -------------------------------------------------------------------------------- /docs/_themes/flask_theme_support.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (c) 2010 by Armin Ronacher. 3 | 4 | Some rights reserved. 5 | 6 | Redistribution and use in source and binary forms of the theme, with or 7 | without modification, are permitted provided that the following conditions 8 | are met: 9 | 10 | * Redistributions of source code must retain the above copyright 11 | notice, this list of conditions and the following disclaimer. 12 | 13 | * Redistributions in binary form must reproduce the above 14 | copyright notice, this list of conditions and the following 15 | disclaimer in the documentation and/or other materials provided 16 | with the distribution. 17 | 18 | * The names of the contributors may not be used to endorse or 19 | promote products derived from this software without specific 20 | prior written permission. 21 | 22 | We kindly ask you to only use these themes in an unmodified manner just 23 | for Flask and Flask-related products, not for unrelated projects. If you 24 | like the visual style and want to use it for your own projects, please 25 | consider making some larger changes to the themes (such as changing 26 | font faces, sizes, colors or margins). 27 | 28 | THIS THEME IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 29 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 30 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 31 | ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE 32 | LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 33 | CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 34 | SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 35 | INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 36 | CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 37 | ARISING IN ANY WAY OUT OF THE USE OF THIS THEME, EVEN IF ADVISED OF THE 38 | POSSIBILITY OF SUCH DAMAGE. 39 | """ 40 | # flasky extensions. flasky pygments style based on tango style 41 | from pygments.style import Style 42 | from pygments.token import Keyword, Name, Comment, String, Error, \ 43 | Number, Operator, Generic, Whitespace, Punctuation, Other, Literal 44 | 45 | 46 | class FlaskyStyle(Style): 47 | background_color = "#f8f8f8" 48 | default_style = "" 49 | 50 | styles = { 51 | # No corresponding class for the following: 52 | #Text: "", # class: '' 53 | Whitespace: "underline #f8f8f8", # class: 'w' 54 | Error: "#a40000 border:#ef2929", # class: 'err' 55 | Other: "#000000", # class 'x' 56 | 57 | Comment: "italic #8f5902", # class: 'c' 58 | Comment.Preproc: "noitalic", # class: 'cp' 59 | 60 | Keyword: "bold #004461", # class: 'k' 61 | Keyword.Constant: "bold #004461", # class: 'kc' 62 | Keyword.Declaration: "bold #004461", # class: 'kd' 63 | Keyword.Namespace: "bold #004461", # class: 'kn' 64 | Keyword.Pseudo: "bold #004461", # class: 'kp' 65 | Keyword.Reserved: "bold #004461", # class: 'kr' 66 | Keyword.Type: "bold #004461", # class: 'kt' 67 | 68 | Operator: "#582800", # class: 'o' 69 | Operator.Word: "bold #004461", # class: 'ow' - like keywords 70 | 71 | Punctuation: "bold #000000", # class: 'p' 72 | 73 | # because special names such as Name.Class, Name.Function, etc. 74 | # are not recognized as such later in the parsing, we choose them 75 | # to look the same as ordinary variables. 76 | Name: "#000000", # class: 'n' 77 | Name.Attribute: "#c4a000", # class: 'na' - to be revised 78 | Name.Builtin: "#004461", # class: 'nb' 79 | Name.Builtin.Pseudo: "#3465a4", # class: 'bp' 80 | Name.Class: "#000000", # class: 'nc' - to be revised 81 | Name.Constant: "#000000", # class: 'no' - to be revised 82 | Name.Decorator: "#888", # class: 'nd' - to be revised 83 | Name.Entity: "#ce5c00", # class: 'ni' 84 | Name.Exception: "bold #cc0000", # class: 'ne' 85 | Name.Function: "#000000", # class: 'nf' 86 | Name.Property: "#000000", # class: 'py' 87 | Name.Label: "#f57900", # class: 'nl' 88 | Name.Namespace: "#000000", # class: 'nn' - to be revised 89 | Name.Other: "#000000", # class: 'nx' 90 | Name.Tag: "bold #004461", # class: 'nt' - like a keyword 91 | Name.Variable: "#000000", # class: 'nv' - to be revised 92 | Name.Variable.Class: "#000000", # class: 'vc' - to be revised 93 | Name.Variable.Global: "#000000", # class: 'vg' - to be revised 94 | Name.Variable.Instance: "#000000", # class: 'vi' - to be revised 95 | 96 | Number: "#990000", # class: 'm' 97 | 98 | Literal: "#000000", # class: 'l' 99 | Literal.Date: "#000000", # class: 'ld' 100 | 101 | String: "#4e9a06", # class: 's' 102 | String.Backtick: "#4e9a06", # class: 'sb' 103 | String.Char: "#4e9a06", # class: 'sc' 104 | String.Doc: "italic #8f5902", # class: 'sd' - like a comment 105 | String.Double: "#4e9a06", # class: 's2' 106 | String.Escape: "#4e9a06", # class: 'se' 107 | String.Heredoc: "#4e9a06", # class: 'sh' 108 | String.Interpol: "#4e9a06", # class: 'si' 109 | String.Other: "#4e9a06", # class: 'sx' 110 | String.Regex: "#4e9a06", # class: 'sr' 111 | String.Single: "#4e9a06", # class: 's1' 112 | String.Symbol: "#4e9a06", # class: 'ss' 113 | 114 | Generic: "#000000", # class: 'g' 115 | Generic.Deleted: "#a40000", # class: 'gd' 116 | Generic.Emph: "italic #000000", # class: 'ge' 117 | Generic.Error: "#ef2929", # class: 'gr' 118 | Generic.Heading: "bold #000080", # class: 'gh' 119 | Generic.Inserted: "#00A000", # class: 'gi' 120 | Generic.Output: "#888", # class: 'go' 121 | Generic.Prompt: "#745334", # class: 'gp' 122 | Generic.Strong: "bold #000000", # class: 'gs' 123 | Generic.Subheading: "bold #800080", # class: 'gu' 124 | Generic.Traceback: "bold #a40000", # class: 'gt' 125 | } 126 | -------------------------------------------------------------------------------- /docs/browse_screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boxed/mutmut/b124c6ad46b0a9a048b5e47c8b7f93d33a9f2e6a/docs/browse_screenshot.png -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Configuration file for the Sphinx documentation builder. 4 | # 5 | # This file does only contain a selection of the most common options. For a 6 | # full list see the documentation: 7 | # http://www.sphinx-doc.org/en/master/config 8 | 9 | # -- Path setup -------------------------------------------------------------- 10 | 11 | # If extensions (or modules to document with autodoc) are in another directory, 12 | # add these directories to sys.path here. If the directory is relative to the 13 | # documentation root, use os.path.abspath to make it absolute, like shown here. 14 | # 15 | # import os 16 | # import sys 17 | # sys.path.insert(0, os.path.abspath('.')) 18 | 19 | 20 | # -- Project information ----------------------------------------------------- 21 | 22 | project = u'mutmut' 23 | copyright = u'2018, Anders Hovmöller' 24 | author = u'Anders Hovmöller' 25 | 26 | # The short X.Y version 27 | version = u'' 28 | # The full version, including alpha/beta/rc tags 29 | release = u'' 30 | 31 | 32 | # -- General configuration --------------------------------------------------- 33 | 34 | # If your documentation needs a minimal Sphinx version, state it here. 35 | # 36 | # needs_sphinx = '1.0' 37 | 38 | # Add any Sphinx extension module names here, as strings. They can be 39 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 40 | # ones. 41 | extensions = [ 42 | 'sphinx.ext.githubpages', 43 | ] 44 | 45 | # Add any paths that contain templates here, relative to this directory. 46 | templates_path = ['_templates'] 47 | 48 | # The suffix(es) of source filenames. 49 | # You can specify multiple suffix as a list of string: 50 | # 51 | # source_suffix = ['.rst', '.md'] 52 | source_suffix = '.rst' 53 | 54 | # The master toctree document. 55 | master_doc = 'index' 56 | 57 | # The language for content autogenerated by Sphinx. Refer to documentation 58 | # for a list of supported languages. 59 | # 60 | # This is also used if you do content translation via gettext catalogs. 61 | # Usually you set "language" from the command line for these cases. 62 | language = 'en' 63 | 64 | # List of patterns, relative to source directory, that match files and 65 | # directories to ignore when looking for source files. 66 | # This pattern also affects html_static_path and html_extra_path . 67 | exclude_patterns = [u'_build', 'Thumbs.db', '.DS_Store'] 68 | 69 | # The name of the Pygments (syntax highlighting) style to use. 70 | pygments_style = 'sphinx' 71 | 72 | 73 | # -- Options for HTML output ------------------------------------------------- 74 | 75 | # The theme to use for HTML and HTML Help pages. See the documentation for 76 | # a list of builtin themes. 77 | # 78 | html_theme = 'alabaster' 79 | 80 | # Theme options are theme-specific and customize the look and feel of a theme 81 | # further. For a list of options available for each theme, see the 82 | # documentation. 83 | # 84 | # html_theme_options = {} 85 | 86 | # Add any paths that contain custom static files (such as style sheets) here, 87 | # relative to this directory. They are copied after the builtin static files, 88 | # so a file named "default.css" will overwrite the builtin "default.css". 89 | # html_static_path = ['_static'] 90 | 91 | # Custom sidebar templates, must be a dictionary that maps document names 92 | # to template names. 93 | # 94 | # The default sidebars (for documents that don't match any pattern) are 95 | # defined by theme itself. Builtin themes are using these templates by 96 | # default: ``['localtoc.html', 'relations.html', 'sourcelink.html', 97 | # 'searchbox.html']``. 98 | # 99 | html_sidebars = { 100 | '**': [ 101 | 'sidebarlogo.html', 102 | 'localtoc.html', 103 | 'ghbuttons.html', 104 | 'searchbox.html' 105 | ] 106 | } 107 | 108 | 109 | # -- Options for HTMLHelp output --------------------------------------------- 110 | 111 | # Output file base name for HTML help builder. 112 | htmlhelp_basename = 'mutmutdoc' 113 | 114 | 115 | # -- Options for LaTeX output ------------------------------------------------ 116 | 117 | latex_elements = { 118 | # The paper size ('letterpaper' or 'a4paper'). 119 | # 120 | # 'papersize': 'letterpaper', 121 | 122 | # The font size ('10pt', '11pt' or '12pt'). 123 | # 124 | # 'pointsize': '10pt', 125 | 126 | # Additional stuff for the LaTeX preamble. 127 | # 128 | # 'preamble': '', 129 | 130 | # Latex figure (float) alignment 131 | # 132 | # 'figure_align': 'htbp', 133 | } 134 | 135 | # Grouping the document tree into LaTeX files. List of tuples 136 | # (source start file, target name, title, 137 | # author, documentclass [howto, manual, or own class]). 138 | latex_documents = [ 139 | (master_doc, 'mutmut.tex', u'mutmut Documentation', 140 | u'Anders Hovmöller', 'manual'), 141 | ] 142 | 143 | 144 | # -- Options for manual page output ------------------------------------------ 145 | 146 | # One entry per manual page. List of tuples 147 | # (source start file, name, description, authors, manual section). 148 | man_pages = [ 149 | (master_doc, 'mutmut', u'mutmut Documentation', 150 | [author], 1) 151 | ] 152 | 153 | 154 | # -- Options for Texinfo output ---------------------------------------------- 155 | 156 | # Grouping the document tree into Texinfo files. List of tuples 157 | # (source start file, target name, title, author, 158 | # dir menu entry, description, category) 159 | texinfo_documents = [ 160 | (master_doc, 'mutmut', u'mutmut Documentation', 161 | author, 'mutmut', 'One line description of project.', 162 | 'Miscellaneous'), 163 | ] 164 | 165 | 166 | # -- Extension configuration ------------------------------------------------- 167 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../README.rst 2 | 3 | .. _toc: 4 | 5 | Resources 6 | --------- 7 | 8 | - `Source Code on Github `_ 9 | - `Travis Testing `_ 10 | - `Python Package Index `_ 11 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | set SPHINXPROJ=mutmut 13 | 14 | if "%1" == "" goto help 15 | 16 | %SPHINXBUILD% >NUL 2>NUL 17 | if errorlevel 9009 ( 18 | echo. 19 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 20 | echo.installed, then set the SPHINXBUILD environment variable to point 21 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 22 | echo.may add the Sphinx directory to PATH. 23 | echo. 24 | echo.If you don't have Sphinx installed, grab it from 25 | echo.http://sphinx-doc.org/ 26 | exit /b 1 27 | ) 28 | 29 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 30 | goto end 31 | 32 | :help 33 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 34 | 35 | :end 36 | popd 37 | -------------------------------------------------------------------------------- /e2e_projects/config/.python-version: -------------------------------------------------------------------------------- 1 | 3.10 2 | -------------------------------------------------------------------------------- /e2e_projects/config/README.md: -------------------------------------------------------------------------------- 1 | This project uses most/all of the mutmut configuration in pyproject.toml. -------------------------------------------------------------------------------- /e2e_projects/config/config_pkg/__init__.py: -------------------------------------------------------------------------------- 1 | def hello() -> str: 2 | return "Hello from config!" 3 | -------------------------------------------------------------------------------- /e2e_projects/config/config_pkg/ignore_me.py: -------------------------------------------------------------------------------- 1 | def this_function_shall_NOT_be_mutated(): 2 | return 1 + 2 -------------------------------------------------------------------------------- /e2e_projects/config/config_pkg/math.py: -------------------------------------------------------------------------------- 1 | def add(a, b): 2 | return a + b 3 | 4 | def call_depth_two(): 5 | return call_depth_three() - 1 6 | 7 | def call_depth_three(): 8 | return call_depth_four() - 1 9 | 10 | def call_depth_four(): 11 | return call_depth_five() - 1 12 | 13 | def call_depth_five(): 14 | return 5 -------------------------------------------------------------------------------- /e2e_projects/config/data/data.json: -------------------------------------------------------------------------------- 1 | { 2 | "comment": "this should be copied to the mutants folder" 3 | } -------------------------------------------------------------------------------- /e2e_projects/config/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "config" 3 | version = "0.1.0" 4 | description = "Add your description here" 5 | readme = "README.md" 6 | authors = [] 7 | requires-python = ">=3.10" 8 | dependencies = [] 9 | 10 | [build-system] 11 | requires = ["hatchling"] 12 | build-backend = "hatchling.build" 13 | 14 | [tool.hatch.build.targets.wheel] 15 | include = [ 16 | "/config_pkg", 17 | "/tests", 18 | ] 19 | 20 | [dependency-groups] 21 | dev = [ 22 | "pytest>=8.3.5", 23 | ] 24 | 25 | [tool.mutmut] 26 | debug = true 27 | paths_to_mutate = [ "config_pkg/" ] 28 | do_not_mutate = [ "*ignore*" ] 29 | also_copy = [ "data" ] 30 | max_stack_depth=8 # Includes frames by mutmut, see https://github.com/boxed/mutmut/issues/378 31 | # NOTE: as of writing, the following options are not implemented 32 | # tests_dir = [ "my_tests/" ] 33 | -------------------------------------------------------------------------------- /e2e_projects/config/tests/test_main.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pathlib import Path 3 | from config_pkg import hello 4 | from config_pkg.math import add, call_depth_two 5 | from config_pkg.ignore_me import this_function_shall_NOT_be_mutated 6 | 7 | def test_hello(): 8 | assert hello() == "Hello from config!" 9 | 10 | def test_add(): 11 | assert add(1, 0) == 1 12 | 13 | def test_non_mutated_function(): 14 | assert this_function_shall_NOT_be_mutated() == 3 15 | 16 | def test_max_stack_depth(): 17 | # This test should only cover functions up to some depth 18 | # For more context, see https://github.com/boxed/mutmut/issues/378 19 | assert call_depth_two() == 2 20 | 21 | def test_data_exists(): 22 | path = (Path("data") / "data.json").resolve() 23 | assert path.exists() 24 | with open(path) as f: 25 | data = json.load(f) 26 | assert data['comment'] == 'this should be copied to the mutants folder' -------------------------------------------------------------------------------- /e2e_projects/config/uv.lock: -------------------------------------------------------------------------------- 1 | version = 1 2 | revision = 1 3 | requires-python = ">=3.10" 4 | 5 | [[package]] 6 | name = "colorama" 7 | version = "0.4.6" 8 | source = { registry = "https://pypi.org/simple" } 9 | sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } 10 | wheels = [ 11 | { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, 12 | ] 13 | 14 | [[package]] 15 | name = "config" 16 | version = "0.1.0" 17 | source = { editable = "." } 18 | 19 | [package.dev-dependencies] 20 | dev = [ 21 | { name = "pytest" }, 22 | ] 23 | 24 | [package.metadata] 25 | 26 | [package.metadata.requires-dev] 27 | dev = [{ name = "pytest", specifier = ">=8.3.5" }] 28 | 29 | [[package]] 30 | name = "exceptiongroup" 31 | version = "1.2.2" 32 | source = { registry = "https://pypi.org/simple" } 33 | sdist = { url = "https://files.pythonhosted.org/packages/09/35/2495c4ac46b980e4ca1f6ad6db102322ef3ad2410b79fdde159a4b0f3b92/exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc", size = 28883 } 34 | wheels = [ 35 | { url = "https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", size = 16453 }, 36 | ] 37 | 38 | [[package]] 39 | name = "iniconfig" 40 | version = "2.1.0" 41 | source = { registry = "https://pypi.org/simple" } 42 | sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793 } 43 | wheels = [ 44 | { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050 }, 45 | ] 46 | 47 | [[package]] 48 | name = "packaging" 49 | version = "24.2" 50 | source = { registry = "https://pypi.org/simple" } 51 | sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950 } 52 | wheels = [ 53 | { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 }, 54 | ] 55 | 56 | [[package]] 57 | name = "pluggy" 58 | version = "1.5.0" 59 | source = { registry = "https://pypi.org/simple" } 60 | sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 } 61 | wheels = [ 62 | { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 }, 63 | ] 64 | 65 | [[package]] 66 | name = "pytest" 67 | version = "8.3.5" 68 | source = { registry = "https://pypi.org/simple" } 69 | dependencies = [ 70 | { name = "colorama", marker = "sys_platform == 'win32'" }, 71 | { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, 72 | { name = "iniconfig" }, 73 | { name = "packaging" }, 74 | { name = "pluggy" }, 75 | { name = "tomli", marker = "python_full_version < '3.11'" }, 76 | ] 77 | sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891 } 78 | wheels = [ 79 | { url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634 }, 80 | ] 81 | 82 | [[package]] 83 | name = "tomli" 84 | version = "2.2.1" 85 | source = { registry = "https://pypi.org/simple" } 86 | sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175 } 87 | wheels = [ 88 | { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077 }, 89 | { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429 }, 90 | { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067 }, 91 | { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030 }, 92 | { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898 }, 93 | { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894 }, 94 | { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319 }, 95 | { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273 }, 96 | { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310 }, 97 | { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309 }, 98 | { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762 }, 99 | { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453 }, 100 | { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486 }, 101 | { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349 }, 102 | { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159 }, 103 | { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243 }, 104 | { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645 }, 105 | { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584 }, 106 | { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875 }, 107 | { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418 }, 108 | { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708 }, 109 | { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582 }, 110 | { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543 }, 111 | { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691 }, 112 | { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170 }, 113 | { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530 }, 114 | { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666 }, 115 | { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954 }, 116 | { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724 }, 117 | { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383 }, 118 | { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257 }, 119 | ] 120 | -------------------------------------------------------------------------------- /e2e_projects/my_lib/README.md: -------------------------------------------------------------------------------- 1 | This project will be E2E tested. It mainly serves as a "canary" that alerts you when code changes affect which mutants survive. -------------------------------------------------------------------------------- /e2e_projects/my_lib/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "my-lib" 3 | version = "0.1.0" 4 | description = "Add your description here" 5 | readme = "README.md" 6 | authors = [] 7 | requires-python = ">=3.10" 8 | dependencies = [] 9 | 10 | [build-system] 11 | requires = ["hatchling"] 12 | build-backend = "hatchling.build" 13 | 14 | [dependency-groups] 15 | dev = [ 16 | "pytest>=8.3.5", 17 | ] 18 | 19 | [tool.mutmut] 20 | debug = true -------------------------------------------------------------------------------- /e2e_projects/my_lib/src/my_lib/__init__.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Callable 2 | from functools import cache 3 | from typing import Union 4 | 5 | 6 | def hello() -> str: 7 | return "Hello from my-lib!" 8 | 9 | def badly_tested() -> str: 10 | return "Mutants for this method should survive" 11 | 12 | def untested() -> str: 13 | return "Mutants for this method should survive" 14 | 15 | def make_greeter(name: Union[str, None]) -> Callable[[], str]: 16 | def hi(): 17 | if name: 18 | return "Hi " + name 19 | else: 20 | return "Hey there!" 21 | 22 | return hi 23 | 24 | def fibonacci(n: int) -> int: 25 | if n <= 1: 26 | return n 27 | return fibonacci(n - 1) + fibonacci(n - 2) 28 | 29 | @cache 30 | def cached_fibonacci(n: int) -> int: 31 | if n <= 1: 32 | return n 33 | return fibonacci(n - 1) + fibonacci(n - 2) 34 | 35 | class Point: 36 | def __init__(self, x: int, y: int) -> None: 37 | self.x = x 38 | self.y = y 39 | 40 | def abs(self) -> 'Point': 41 | return Point(abs(self.x), abs(self.y)) 42 | 43 | def add(self, other: 'Point'): 44 | self.x += other.x 45 | self.y += other.y 46 | 47 | def to_origin(self): 48 | self.x = 0 49 | self.y = 0 50 | 51 | def ignored(self): 52 | self.foo = 'bar' # pragma: no mutate 53 | 54 | @staticmethod 55 | def from_coords(coords) -> 'Point': 56 | return Point(coords[0], coords[1]) 57 | 58 | @property 59 | def coords(self): 60 | return self.x, self.y -------------------------------------------------------------------------------- /e2e_projects/my_lib/tests/test_my_lib.py: -------------------------------------------------------------------------------- 1 | from my_lib import hello, Point, badly_tested, make_greeter, fibonacci, cached_fibonacci 2 | 3 | """These tests are flawed on purpose, some mutants survive and some are killed.""" 4 | 5 | def test_hello(): 6 | assert hello() == 'Hello from my-lib!' 7 | 8 | def test_badly_tested(): 9 | assert badly_tested() 10 | 11 | def test_greeter(): 12 | greet = make_greeter("mut") 13 | assert greet() == "Hi mut" 14 | 15 | def test_point(): 16 | p = Point(0, 1) 17 | p.add(Point(1, 0)) 18 | 19 | assert p.x == 1 20 | assert p.y == 1 21 | 22 | p.to_origin() 23 | 24 | assert p.x == 0 25 | 26 | assert isinstance(p.coords, tuple) 27 | 28 | def test_point_from_coords(): 29 | assert Point.from_coords((1, 2)).x == 1 30 | 31 | def test_fibonacci(): 32 | assert fibonacci(1) == 1 33 | assert cached_fibonacci(1) == 1 34 | -------------------------------------------------------------------------------- /e2e_projects/my_lib/uv.lock: -------------------------------------------------------------------------------- 1 | version = 1 2 | revision = 1 3 | requires-python = ">=3.10" 4 | 5 | [[package]] 6 | name = "colorama" 7 | version = "0.4.6" 8 | source = { registry = "https://pypi.org/simple" } 9 | sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } 10 | wheels = [ 11 | { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, 12 | ] 13 | 14 | [[package]] 15 | name = "exceptiongroup" 16 | version = "1.2.2" 17 | source = { registry = "https://pypi.org/simple" } 18 | sdist = { url = "https://files.pythonhosted.org/packages/09/35/2495c4ac46b980e4ca1f6ad6db102322ef3ad2410b79fdde159a4b0f3b92/exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc", size = 28883 } 19 | wheels = [ 20 | { url = "https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", size = 16453 }, 21 | ] 22 | 23 | [[package]] 24 | name = "iniconfig" 25 | version = "2.1.0" 26 | source = { registry = "https://pypi.org/simple" } 27 | sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793 } 28 | wheels = [ 29 | { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050 }, 30 | ] 31 | 32 | [[package]] 33 | name = "my-lib" 34 | version = "0.1.0" 35 | source = { editable = "." } 36 | 37 | [package.dev-dependencies] 38 | dev = [ 39 | { name = "pytest" }, 40 | ] 41 | 42 | [package.metadata] 43 | 44 | [package.metadata.requires-dev] 45 | dev = [{ name = "pytest", specifier = ">=8.3.5" }] 46 | 47 | [[package]] 48 | name = "packaging" 49 | version = "24.2" 50 | source = { registry = "https://pypi.org/simple" } 51 | sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950 } 52 | wheels = [ 53 | { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 }, 54 | ] 55 | 56 | [[package]] 57 | name = "pluggy" 58 | version = "1.5.0" 59 | source = { registry = "https://pypi.org/simple" } 60 | sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 } 61 | wheels = [ 62 | { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 }, 63 | ] 64 | 65 | [[package]] 66 | name = "pytest" 67 | version = "8.3.5" 68 | source = { registry = "https://pypi.org/simple" } 69 | dependencies = [ 70 | { name = "colorama", marker = "sys_platform == 'win32'" }, 71 | { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, 72 | { name = "iniconfig" }, 73 | { name = "packaging" }, 74 | { name = "pluggy" }, 75 | { name = "tomli", marker = "python_full_version < '3.11'" }, 76 | ] 77 | sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891 } 78 | wheels = [ 79 | { url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634 }, 80 | ] 81 | 82 | [[package]] 83 | name = "tomli" 84 | version = "2.2.1" 85 | source = { registry = "https://pypi.org/simple" } 86 | sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175 } 87 | wheels = [ 88 | { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077 }, 89 | { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429 }, 90 | { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067 }, 91 | { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030 }, 92 | { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898 }, 93 | { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894 }, 94 | { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319 }, 95 | { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273 }, 96 | { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310 }, 97 | { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309 }, 98 | { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762 }, 99 | { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453 }, 100 | { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486 }, 101 | { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349 }, 102 | { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159 }, 103 | { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243 }, 104 | { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645 }, 105 | { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584 }, 106 | { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875 }, 107 | { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418 }, 108 | { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708 }, 109 | { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582 }, 110 | { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543 }, 111 | { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691 }, 112 | { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170 }, 113 | { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530 }, 114 | { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666 }, 115 | { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954 }, 116 | { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724 }, 117 | { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383 }, 118 | { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257 }, 119 | ] 120 | -------------------------------------------------------------------------------- /mutmut/__init__.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | 3 | __version__ = '3.3.0' 4 | 5 | 6 | duration_by_test = {} 7 | stats_time = None 8 | config = None 9 | 10 | _stats = set() 11 | tests_by_mangled_function_name = defaultdict(set) 12 | 13 | 14 | def _reset_globals(): 15 | global duration_by_test, stats_time, config, _stats, tests_by_mangled_function_name 16 | 17 | duration_by_test = {} 18 | stats_time = None 19 | config = None 20 | _stats = set() 21 | tests_by_mangled_function_name = defaultdict(set) 22 | -------------------------------------------------------------------------------- /mutmut/__main__.py: -------------------------------------------------------------------------------- 1 | import ast 2 | import fnmatch 3 | import gc 4 | import inspect 5 | import itertools 6 | import json 7 | from multiprocessing import Pool, set_start_method 8 | import os 9 | import resource 10 | import shutil 11 | import signal 12 | import subprocess 13 | import sys 14 | from abc import ABC 15 | from collections import defaultdict 16 | from configparser import ( 17 | ConfigParser, 18 | NoOptionError, 19 | NoSectionError, 20 | ) 21 | from contextlib import contextmanager 22 | from dataclasses import dataclass 23 | from datetime import ( 24 | datetime, 25 | timedelta, 26 | ) 27 | from difflib import unified_diff 28 | from io import TextIOBase 29 | from json import JSONDecodeError 30 | from math import ceil 31 | from os import ( 32 | makedirs, 33 | walk, 34 | ) 35 | from os.path import ( 36 | isdir, 37 | isfile, 38 | ) 39 | from pathlib import Path 40 | from signal import SIGTERM 41 | from threading import Thread 42 | from time import ( 43 | process_time, 44 | sleep, 45 | ) 46 | from typing import ( 47 | Dict, 48 | List, 49 | Union, 50 | ) 51 | 52 | import click 53 | import libcst as cst 54 | import libcst.matchers as m 55 | from rich.text import Text 56 | from setproctitle import setproctitle 57 | 58 | import mutmut 59 | from mutmut.file_mutation import mutate_file_contents 60 | from mutmut.trampoline_templates import CLASS_NAME_SEPARATOR 61 | 62 | # Document: surviving mutants are retested when you ask mutmut to retest them, interactively in the UI or via command line 63 | 64 | # TODO: pragma no mutate should end up in `skipped` category 65 | # TODO: hash of function. If hash changes, retest all mutants as mutant IDs are not stable 66 | 67 | 68 | status_by_exit_code = { 69 | 1: 'killed', 70 | 3: 'killed', # internal error in pytest means a kill 71 | -24: 'killed', 72 | 0: 'survived', 73 | 5: 'no tests', 74 | 2: 'check was interrupted by user', 75 | None: 'not checked', 76 | 33: 'no tests', 77 | 34: 'skipped', 78 | 35: 'suspicious', 79 | 36: 'timeout', 80 | 24: 'timeout', # SIGXCPU 81 | 152: 'timeout', # SIGXCPU 82 | 255: 'timeout', 83 | -11: 'segfault', 84 | } 85 | 86 | emoji_by_status = { 87 | 'survived': '🙁', 88 | 'no tests': '🫥', 89 | 'timeout': '⏰', 90 | 'suspicious': '🤔', 91 | 'skipped': '🔇', 92 | 'check was interrupted by user': '🛑', 93 | 'not checked': '?', 94 | 'killed': '🎉', 95 | 'segfault': '💥', 96 | } 97 | 98 | exit_code_to_emoji = { 99 | exit_code: emoji_by_status[status] 100 | for exit_code, status in status_by_exit_code.items() 101 | } 102 | 103 | 104 | def guess_paths_to_mutate(): 105 | """Guess the path to source code to mutate 106 | 107 | :rtype: str 108 | """ 109 | this_dir = os.getcwd().split(os.sep)[-1] 110 | if isdir('lib'): 111 | return ['lib'] 112 | elif isdir('src'): 113 | return ['src'] 114 | elif isdir(this_dir): 115 | return [this_dir] 116 | elif isdir(this_dir.replace('-', '_')): 117 | return [this_dir.replace('-', '_')] 118 | elif isdir(this_dir.replace(' ', '_')): 119 | return [this_dir.replace(' ', '_')] 120 | elif isdir(this_dir.replace('-', '')): 121 | return [this_dir.replace('-', '')] 122 | elif isdir(this_dir.replace(' ', '')): 123 | return [this_dir.replace(' ', '')] 124 | if isfile(this_dir + '.py'): 125 | return [this_dir + '.py'] 126 | raise FileNotFoundError( 127 | 'Could not figure out where the code to mutate is. ' 128 | 'Please specify it by adding "paths_to_mutate=code_dir" in setup.cfg to the [mutmut] section.') 129 | 130 | 131 | def record_trampoline_hit(name): 132 | assert not name.startswith('src.'), f'Failed trampoline hit. Module name starts with `src.`, which is invalid' 133 | if mutmut.config.max_stack_depth != -1: 134 | f = inspect.currentframe() 135 | c = mutmut.config.max_stack_depth 136 | while c and f: 137 | if 'pytest' in f.f_code.co_filename or 'hammett' in f.f_code.co_filename: 138 | break 139 | f = f.f_back 140 | c -= 1 141 | 142 | if not c: 143 | return 144 | 145 | mutmut._stats.add(name) 146 | 147 | 148 | def walk_all_files(): 149 | for path in mutmut.config.paths_to_mutate: 150 | if not isdir(path): 151 | if isfile(path): 152 | yield '', str(path) 153 | continue 154 | for root, dirs, files in walk(path): 155 | for filename in files: 156 | yield root, filename 157 | 158 | 159 | def walk_source_files(): 160 | for root, filename in walk_all_files(): 161 | if filename.endswith('.py'): 162 | yield Path(root) / filename 163 | 164 | 165 | class MutmutProgrammaticFailException(Exception): 166 | pass 167 | 168 | 169 | class CollectTestsFailedException(Exception): 170 | pass 171 | 172 | 173 | class BadTestExecutionCommandsException(Exception): 174 | def __init__(self, pytest_args: list[str]) -> None: 175 | msg = f'Failed to run pytest with args: {pytest_args}. If your config sets debug=true, the original pytest error should be above.' 176 | super().__init__(msg) 177 | 178 | 179 | def copy_src_dir(): 180 | for path in mutmut.config.paths_to_mutate: 181 | output_path: Path = Path('mutants') / path 182 | if isdir(path): 183 | shutil.copytree(path, output_path, dirs_exist_ok=True) 184 | else: 185 | output_path.parent.mkdir(exist_ok=True, parents=True) 186 | shutil.copyfile(path, output_path) 187 | 188 | 189 | def create_mutants(max_children: int): 190 | with Pool(processes=max_children) as p: 191 | p.map(create_file_mutants, walk_source_files()) 192 | 193 | 194 | def create_file_mutants(path: Path): 195 | print(path) 196 | output_path = Path('mutants') / path 197 | makedirs(output_path.parent, exist_ok=True) 198 | 199 | if mutmut.config.should_ignore_for_mutation(path): 200 | shutil.copy(path, output_path) 201 | else: 202 | create_mutants_for_file(path, output_path) 203 | 204 | 205 | def copy_also_copy_files(): 206 | assert isinstance(mutmut.config.also_copy, list) 207 | for path in mutmut.config.also_copy: 208 | print(' also copying', path) 209 | path = Path(path) 210 | destination = Path('mutants') / path 211 | if not path.exists(): 212 | continue 213 | if path.is_file(): 214 | shutil.copy(path, destination) 215 | else: 216 | shutil.copytree(path, destination, dirs_exist_ok=True) 217 | 218 | 219 | def create_mutants_for_file(filename, output_path): 220 | input_stat = os.stat(filename) 221 | 222 | with open(filename) as f: 223 | source = f.read() 224 | 225 | with open(output_path, 'w') as out: 226 | mutant_names, hash_by_function_name = write_all_mutants_to_file(out=out, source=source, filename=filename) 227 | 228 | # validate no syntax errors of mutants 229 | with open(output_path) as f: 230 | try: 231 | ast.parse(f.read()) 232 | except (IndentationError, SyntaxError) as e: 233 | print(output_path, 'has invalid syntax: ', e) 234 | exit(1) 235 | 236 | source_file_mutation_data = SourceFileMutationData(path=filename) 237 | module_name = strip_prefix(str(filename)[:-len(filename.suffix)].replace(os.sep, '.'), prefix='src.') 238 | 239 | source_file_mutation_data.exit_code_by_key = { 240 | '.'.join([module_name, x]).replace('.__init__.', '.'): None 241 | for x in mutant_names 242 | } 243 | source_file_mutation_data.hash_by_function_name = hash_by_function_name 244 | assert None not in hash_by_function_name 245 | source_file_mutation_data.save() 246 | 247 | os.utime(output_path, (input_stat.st_atime, input_stat.st_mtime)) 248 | 249 | 250 | def write_all_mutants_to_file(*, out, source, filename): 251 | result, mutant_names = mutate_file_contents(filename, source) 252 | out.write(result) 253 | 254 | # TODO: function hashes are currently not used. Reimplement this when needed. 255 | hash_by_function_name = {} 256 | 257 | return mutant_names, hash_by_function_name 258 | 259 | 260 | class SourceFileMutationData: 261 | def __init__(self, *, path): 262 | self.estimated_time_of_tests_by_mutant = {} 263 | self.path = path 264 | self.meta_path = Path('mutants') / (str(path) + '.meta') 265 | self.meta = None 266 | self.key_by_pid = {} 267 | self.exit_code_by_key = {} 268 | self.hash_by_function_name = {} 269 | self.start_time_by_pid = {} 270 | self.estimated_time_of_tests_by_pid = {} 271 | 272 | def load(self): 273 | try: 274 | with open(self.meta_path) as f: 275 | self.meta = json.load(f) 276 | except FileNotFoundError: 277 | return 278 | 279 | self.exit_code_by_key = self.meta.pop('exit_code_by_key') 280 | self.hash_by_function_name = self.meta.pop('hash_by_function_name') 281 | assert not self.meta, self.meta # We should read all the data! 282 | 283 | def register_pid(self, *, pid, key, estimated_time_of_tests): 284 | self.key_by_pid[pid] = key 285 | self.start_time_by_pid[pid] = datetime.now() 286 | self.estimated_time_of_tests_by_pid[pid] = estimated_time_of_tests 287 | 288 | def register_result(self, *, pid, exit_code): 289 | assert self.key_by_pid[pid] in self.exit_code_by_key 290 | self.exit_code_by_key[self.key_by_pid[pid]] = exit_code 291 | # TODO: maybe rate limit this? Saving on each result can slow down mutation testing a lot if the test run is fast. 292 | del self.key_by_pid[pid] 293 | del self.start_time_by_pid[pid] 294 | self.save() 295 | 296 | def stop_children(self): 297 | for pid in self.key_by_pid.keys(): 298 | os.kill(pid, SIGTERM) 299 | 300 | def save(self): 301 | with open(self.meta_path, 'w') as f: 302 | json.dump(dict( 303 | exit_code_by_key=self.exit_code_by_key, 304 | hash_by_function_name=self.hash_by_function_name, 305 | ), f, indent=4) 306 | 307 | 308 | def unused(*_): 309 | pass 310 | 311 | 312 | def strip_prefix(s, *, prefix, strict=False): 313 | if s.startswith(prefix): 314 | return s[len(prefix):] 315 | assert strict is False, f"String '{s}' does not start with prefix '{prefix}'" 316 | return s 317 | 318 | 319 | class TestRunner(ABC): 320 | def run_stats(self, *, tests): 321 | raise NotImplementedError() 322 | 323 | def run_forced_fail(self): 324 | raise NotImplementedError() 325 | 326 | def prepare_main_test_run(self): 327 | pass 328 | 329 | def run_tests(self, *, mutant_name, tests): 330 | raise NotImplementedError() 331 | 332 | def list_all_tests(self): 333 | raise NotImplementedError() 334 | 335 | 336 | @contextmanager 337 | def change_cwd(path): 338 | old_cwd = os.path.abspath(os.getcwd()) 339 | os.chdir(path) 340 | try: 341 | yield 342 | finally: 343 | os.chdir(old_cwd) 344 | 345 | 346 | def collected_test_names(): 347 | return set(mutmut.duration_by_test.keys()) 348 | 349 | 350 | class ListAllTestsResult: 351 | def __init__(self, *, ids): 352 | assert isinstance(ids, set) 353 | self.ids = ids 354 | 355 | def clear_out_obsolete_test_names(self): 356 | count_before = sum(len(x) for x in mutmut.tests_by_mangled_function_name) 357 | mutmut.tests_by_mangled_function_name = defaultdict(set, **{ 358 | k: {test_name for test_name in test_names if test_name in self.ids} 359 | for k, test_names in mutmut.tests_by_mangled_function_name.items() 360 | }) 361 | count_after = sum(len(x) for x in mutmut.tests_by_mangled_function_name) 362 | if count_before != count_after: 363 | print(f'Removed {count_before - count_after} obsolete test names') 364 | save_stats() 365 | 366 | def new_tests(self): 367 | return self.ids - collected_test_names() 368 | 369 | 370 | class PytestRunner(TestRunner): 371 | # noinspection PyMethodMayBeStatic 372 | def execute_pytest(self, params: list[str], **kwargs): 373 | import pytest 374 | params += ['--rootdir=.'] 375 | if mutmut.config.debug: 376 | params = ['-vv'] + params 377 | print('python -m pytest ', ' '.join(params)) 378 | exit_code = int(pytest.main(params, **kwargs)) 379 | if mutmut.config.debug: 380 | print(' exit code', exit_code) 381 | if exit_code == 4: 382 | raise BadTestExecutionCommandsException(params) 383 | return exit_code 384 | 385 | def run_stats(self, *, tests): 386 | class StatsCollector: 387 | # noinspection PyMethodMayBeStatic 388 | def pytest_runtest_teardown(self, item, nextitem): 389 | unused(nextitem) 390 | for function in mutmut._stats: 391 | mutmut.tests_by_mangled_function_name[function].add(strip_prefix(item._nodeid, prefix='mutants/')) 392 | mutmut._stats.clear() 393 | 394 | # noinspection PyMethodMayBeStatic 395 | def pytest_runtest_makereport(self, item, call): 396 | mutmut.duration_by_test[item.nodeid] = call.duration 397 | 398 | stats_collector = StatsCollector() 399 | 400 | with change_cwd('mutants'): 401 | return int(self.execute_pytest(['-x', '-q'] + list(tests), plugins=[stats_collector])) 402 | 403 | def run_tests(self, *, mutant_name, tests): 404 | with change_cwd('mutants'): 405 | return int(self.execute_pytest(['-x', '-q'] + list(tests))) 406 | 407 | def run_forced_fail(self): 408 | with change_cwd('mutants'): 409 | return int(self.execute_pytest(['-x', '-q'])) 410 | 411 | def list_all_tests(self): 412 | class TestsCollector: 413 | def pytest_collection_modifyitems(self, items): 414 | self.nodeids = {item.nodeid for item in items} 415 | 416 | collector = TestsCollector() 417 | 418 | with change_cwd('mutants'): 419 | exit_code = int(self.execute_pytest(['-x', '-q', '--collect-only'], plugins=[collector])) 420 | if exit_code != 0: 421 | raise CollectTestsFailedException() 422 | 423 | return ListAllTestsResult(ids=collector.nodeids) 424 | 425 | 426 | class HammettRunner(TestRunner): 427 | def __init__(self): 428 | self.hammett_kwargs = None 429 | 430 | def run_stats(self, *, tests): 431 | import hammett 432 | print('Running hammett stats...') 433 | 434 | def post_test_callback(_name, **_): 435 | for function in mutmut._stats: 436 | mutmut.tests_by_mangled_function_name[function].add(_name) 437 | mutmut._stats.clear() 438 | 439 | return hammett.main(quiet=True, fail_fast=True, disable_assert_analyze=True, post_test_callback=post_test_callback, use_cache=False, insert_cwd=False) 440 | 441 | def run_forced_fail(self): 442 | import hammett 443 | return hammett.main(quiet=True, fail_fast=True, disable_assert_analyze=True, use_cache=False, insert_cwd=False) 444 | 445 | def prepare_main_test_run(self): 446 | import hammett 447 | self.hammett_kwargs = hammett.main_setup( 448 | quiet=True, 449 | fail_fast=True, 450 | disable_assert_analyze=True, 451 | use_cache=False, 452 | insert_cwd=False, 453 | ) 454 | 455 | def run_tests(self, *, mutant_name, tests): 456 | import hammett 457 | hammett.Config.workerinput = dict(workerinput=f'_{mutant_name}') 458 | return hammett.main_run_tests(**self.hammett_kwargs, tests=tests) 459 | 460 | 461 | def mangled_name_from_mutant_name(mutant_name): 462 | assert '__mutmut_' in mutant_name, mutant_name 463 | return mutant_name.partition('__mutmut_')[0] 464 | 465 | 466 | def orig_function_and_class_names_from_key(mutant_name): 467 | r = mangled_name_from_mutant_name(mutant_name) 468 | _, _, r = r.rpartition('.') 469 | class_name = None 470 | if CLASS_NAME_SEPARATOR in r: 471 | class_name = r[r.index(CLASS_NAME_SEPARATOR) + 1: r.rindex(CLASS_NAME_SEPARATOR)] 472 | r = r[r.rindex(CLASS_NAME_SEPARATOR) + 1:] 473 | else: 474 | assert r.startswith('x_'), r 475 | r = r[2:] 476 | return r, class_name 477 | 478 | 479 | spinner = itertools.cycle('⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏') 480 | 481 | 482 | def status_printer(): 483 | """Manage the printing and in-place updating of a line of characters 484 | 485 | .. note:: 486 | If the string is longer than a line, then in-place updating may not 487 | work (it will print a new line at each refresh). 488 | """ 489 | last_len = [0] 490 | last_update = [datetime(1900, 1, 1)] 491 | update_threshold = timedelta(seconds=0.1) 492 | 493 | def p(s, *, force_output=False): 494 | if not force_output and (datetime.now() - last_update[0]) < update_threshold: 495 | return 496 | s = next(spinner) + ' ' + s 497 | len_s = len(s) 498 | output = '\r' + s + (' ' * max(last_len[0] - len_s, 0)) 499 | sys.__stdout__.write(output) 500 | sys.__stdout__.flush() 501 | last_len[0] = len_s 502 | return p 503 | 504 | 505 | print_status = status_printer() 506 | 507 | 508 | @dataclass 509 | class Stat: 510 | not_checked: int 511 | killed: int 512 | survived: int 513 | total: int 514 | no_tests: int 515 | skipped: int 516 | suspicious: int 517 | timeout: int 518 | check_was_interrupted_by_user: int 519 | segfault: int 520 | 521 | 522 | def collect_stat(m: SourceFileMutationData): 523 | r = { 524 | k.replace(' ', '_'): 0 525 | for k in status_by_exit_code.values() 526 | } 527 | for k, v in m.exit_code_by_key.items(): 528 | # noinspection PyTypeChecker 529 | r[status_by_exit_code[v].replace(' ', '_')] += 1 530 | return Stat( 531 | **r, 532 | total=sum(r.values()), 533 | ) 534 | 535 | 536 | def calculate_summary_stats(source_file_mutation_data_by_path): 537 | stats = [collect_stat(x) for x in source_file_mutation_data_by_path.values()] 538 | return Stat( 539 | not_checked=sum(x.not_checked for x in stats), 540 | killed=sum(x.killed for x in stats), 541 | survived=sum(x.survived for x in stats), 542 | total=sum(x.total for x in stats), 543 | no_tests=sum(x.no_tests for x in stats), 544 | skipped=sum(x.skipped for x in stats), 545 | suspicious=sum(x.suspicious for x in stats), 546 | timeout=sum(x.timeout for x in stats), 547 | check_was_interrupted_by_user=sum(x.check_was_interrupted_by_user for x in stats), 548 | segfault=sum(x.segfault for x in stats), 549 | ) 550 | 551 | 552 | def print_stats(source_file_mutation_data_by_path, force_output=False): 553 | s = calculate_summary_stats(source_file_mutation_data_by_path) 554 | print_status(f'{(s.total - s.not_checked)}/{s.total} 🎉 {s.killed} 🫥 {s.no_tests} ⏰ {s.timeout} 🤔 {s.suspicious} 🙁 {s.survived} 🔇 {s.skipped}', force_output=force_output) 555 | 556 | 557 | def run_forced_fail_test(runner): 558 | os.environ['MUTANT_UNDER_TEST'] = 'fail' 559 | with CatchOutput(spinner_title='Running forced fail test') as catcher: 560 | try: 561 | if runner.run_forced_fail() == 0: 562 | catcher.dump_output() 563 | print("FAILED: Unable to force test failures") 564 | raise SystemExit(1) 565 | except MutmutProgrammaticFailException: 566 | pass 567 | os.environ['MUTANT_UNDER_TEST'] = '' 568 | print(' done') 569 | 570 | 571 | class CatchOutput: 572 | def __init__(self, callback=lambda s: None, spinner_title=None): 573 | self.strings = [] 574 | self.spinner_title = spinner_title or '' 575 | 576 | class StdOutRedirect(TextIOBase): 577 | def __init__(self, catcher): 578 | self.catcher = catcher 579 | 580 | def write(self, s): 581 | callback(s) 582 | if spinner_title: 583 | print_status(spinner_title) 584 | self.catcher.strings.append(s) 585 | return len(s) 586 | self.redirect = StdOutRedirect(self) 587 | 588 | # noinspection PyMethodMayBeStatic 589 | def stop(self): 590 | sys.stdout = sys.__stdout__ 591 | sys.stderr = sys.__stderr__ 592 | 593 | def start(self): 594 | if self.spinner_title: 595 | print_status(self.spinner_title) 596 | sys.stdout = self.redirect 597 | sys.stderr = self.redirect 598 | if mutmut.config.debug: 599 | self.stop() 600 | 601 | def dump_output(self): 602 | self.stop() 603 | for line in self.strings: 604 | print(line, end='') 605 | 606 | def __enter__(self): 607 | self.start() 608 | return self 609 | 610 | def __exit__(self, exc_type, exc_val, exc_tb): 611 | self.stop() 612 | if self.spinner_title: 613 | print() 614 | 615 | 616 | @dataclass 617 | class Config: 618 | also_copy: List[Path] 619 | do_not_mutate: List[str] 620 | max_stack_depth: int 621 | debug: bool 622 | paths_to_mutate: List[Path] 623 | 624 | def should_ignore_for_mutation(self, path): 625 | if not str(path).endswith('.py'): 626 | return True 627 | for p in self.do_not_mutate: 628 | if fnmatch.fnmatch(path, p): 629 | return True 630 | return False 631 | 632 | 633 | def config_reader(): 634 | path = Path('pyproject.toml') 635 | if path.exists(): 636 | if sys.version_info >= (3, 11): 637 | from tomllib import loads 638 | else: 639 | # noinspection PyPackageRequirements 640 | from toml import loads 641 | data = loads(path.read_text('utf-8')) 642 | 643 | try: 644 | config = data['tool']['mutmut'] 645 | except KeyError: 646 | pass 647 | else: 648 | def s(key, default): 649 | try: 650 | result = config[key] 651 | except KeyError: 652 | return default 653 | return result 654 | return s 655 | 656 | config_parser = ConfigParser() 657 | config_parser.read('setup.cfg') 658 | 659 | def s(key, default): 660 | try: 661 | result = config_parser.get('mutmut', key) 662 | except (NoOptionError, NoSectionError): 663 | return default 664 | if isinstance(default, list): 665 | if '\n' in result: 666 | result = [x for x in result.split("\n") if x] 667 | else: 668 | result = [result] 669 | elif isinstance(default, bool): 670 | result = result.lower() in ('1', 't', 'true') 671 | elif isinstance(default, int): 672 | result = int(result) 673 | return result 674 | return s 675 | 676 | 677 | def ensure_config_loaded(): 678 | if mutmut.config is None: 679 | mutmut.config = load_config() 680 | 681 | 682 | def load_config(): 683 | s = config_reader() 684 | 685 | return Config( 686 | do_not_mutate=s('do_not_mutate', []), 687 | also_copy=[ 688 | Path(y) 689 | for y in s('also_copy', []) 690 | ] + [ 691 | Path('tests/'), 692 | Path('test/'), 693 | Path('setup.cfg'), 694 | Path('pyproject.toml'), 695 | ] + list(Path('.').glob('test*.py')), 696 | max_stack_depth=s('max_stack_depth', -1), 697 | debug=s('debug', False), 698 | paths_to_mutate=[ 699 | Path(y) 700 | for y in s('paths_to_mutate', []) 701 | ] or guess_paths_to_mutate() 702 | ) 703 | 704 | 705 | 706 | @click.group() 707 | @click.version_option(mutmut.__version__) 708 | def cli(): 709 | pass 710 | 711 | 712 | def run_stats_collection(runner, tests=None): 713 | if tests is None: 714 | tests = [] # Meaning all... 715 | 716 | os.environ['MUTANT_UNDER_TEST'] = 'stats' 717 | os.environ['PY_IGNORE_IMPORTMISMATCH'] = '1' 718 | start_cpu_time = process_time() 719 | 720 | with CatchOutput(spinner_title='Running stats') as output_catcher: 721 | collect_stats_exit_code = runner.run_stats(tests=tests) 722 | if collect_stats_exit_code != 0: 723 | output_catcher.dump_output() 724 | print(f'failed to collect stats. runner returned {collect_stats_exit_code}') 725 | exit(1) 726 | 727 | print(' done') 728 | if not tests: # again, meaning all 729 | mutmut.stats_time = process_time() - start_cpu_time 730 | 731 | if not collected_test_names(): 732 | print('failed to collect stats, no active tests found') 733 | exit(1) 734 | 735 | save_stats() 736 | 737 | 738 | def collect_or_load_stats(runner): 739 | did_load = load_stats() 740 | 741 | if not did_load: 742 | # Run full stats 743 | run_stats_collection(runner) 744 | else: 745 | # Run incremental stats 746 | with CatchOutput(spinner_title='Listing all tests') as output_catcher: 747 | os.environ['MUTANT_UNDER_TEST'] = 'list_all_tests' 748 | try: 749 | all_tests_result = runner.list_all_tests() 750 | except CollectTestsFailedException: 751 | output_catcher.dump_output() 752 | print('Failed to collect list of tests') 753 | exit(1) 754 | 755 | all_tests_result.clear_out_obsolete_test_names() 756 | 757 | new_tests = all_tests_result.new_tests() 758 | 759 | if new_tests: 760 | print(f'Found {len(new_tests)} new tests, rerunning stats collection') 761 | run_stats_collection(runner, tests=new_tests) 762 | 763 | 764 | def load_stats(): 765 | did_load = False 766 | try: 767 | with open('mutants/mutmut-stats.json') as f: 768 | data = json.load(f) 769 | for k, v in data.pop('tests_by_mangled_function_name').items(): 770 | mutmut.tests_by_mangled_function_name[k] |= set(v) 771 | mutmut.duration_by_test = data.pop('duration_by_test') 772 | mutmut.stats_time = data.pop('stats_time') 773 | assert not data, data 774 | did_load = True 775 | except (FileNotFoundError, JSONDecodeError): 776 | pass 777 | return did_load 778 | 779 | 780 | def save_stats(): 781 | with open('mutants/mutmut-stats.json', 'w') as f: 782 | json.dump(dict( 783 | tests_by_mangled_function_name={k: list(v) for k, v in mutmut.tests_by_mangled_function_name.items()}, 784 | duration_by_test=mutmut.duration_by_test, 785 | stats_time=mutmut.stats_time, 786 | ), f, indent=4) 787 | 788 | 789 | def collect_source_file_mutation_data(*, mutant_names): 790 | source_file_mutation_data_by_path: Dict[str, SourceFileMutationData] = {} 791 | 792 | for path in walk_source_files(): 793 | if mutmut.config.should_ignore_for_mutation(path): 794 | continue 795 | assert path not in source_file_mutation_data_by_path 796 | m = SourceFileMutationData(path=path) 797 | m.load() 798 | source_file_mutation_data_by_path[str(path)] = m 799 | 800 | mutants = [ 801 | (m, mutant_name, result) 802 | for path, m in source_file_mutation_data_by_path.items() 803 | for mutant_name, result in m.exit_code_by_key.items() 804 | ] 805 | 806 | if mutant_names: 807 | filtered_mutants = [ 808 | (m, key, result) 809 | for m, key, result in mutants 810 | if key in mutant_names or any(fnmatch.fnmatch(key, mutant_name) for mutant_name in mutant_names) 811 | ] 812 | assert filtered_mutants, f'Filtered for specific mutants, but nothing matches\n\nFilter: {mutant_names}' 813 | mutants = filtered_mutants 814 | return mutants, source_file_mutation_data_by_path 815 | 816 | 817 | def estimated_worst_case_time(mutant_name): 818 | tests = mutmut.tests_by_mangled_function_name.get(mangled_name_from_mutant_name(mutant_name), set()) 819 | return sum(mutmut.duration_by_test[t] for t in tests) 820 | 821 | 822 | @cli.command() 823 | @click.argument('mutant_names', required=False, nargs=-1) 824 | def print_time_estimates(mutant_names): 825 | assert isinstance(mutant_names, (tuple, list)), mutant_names 826 | ensure_config_loaded() 827 | 828 | runner = PytestRunner() 829 | runner.prepare_main_test_run() 830 | 831 | collect_or_load_stats(runner) 832 | 833 | mutants, source_file_mutation_data_by_path = collect_source_file_mutation_data(mutant_names=mutant_names) 834 | 835 | times_and_keys = [ 836 | (estimated_worst_case_time(mutant_name), mutant_name) 837 | for m, mutant_name, result in mutants 838 | ] 839 | 840 | for time, key in sorted(times_and_keys): 841 | if not time: 842 | print(f'', key) 843 | else: 844 | print(f'{int(time*1000)}ms', key) 845 | 846 | 847 | @cli.command() 848 | @click.argument('mutant_name', required=True, nargs=1) 849 | def tests_for_mutant(mutant_name): 850 | if not load_stats(): 851 | print('Failed to load stats. Please run mutmut first to collect stats.') 852 | exit(1) 853 | 854 | tests = tests_for_mutant_names([mutant_name]) 855 | for test in sorted(tests): 856 | print(test) 857 | 858 | 859 | def stop_all_children(mutants): 860 | for m, _, _ in mutants: 861 | m.stop_children() 862 | 863 | 864 | def timeout_checker(mutants): 865 | def inner_timout_checker(): 866 | while True: 867 | sleep(1) 868 | 869 | now = datetime.now() 870 | for m, mutant_name, result in mutants: 871 | for pid, start_time in m.start_time_by_pid.items(): 872 | run_time = now - start_time 873 | if run_time.total_seconds() > (m.estimated_time_of_tests_by_mutant[mutant_name] + 1) * 4: 874 | try: 875 | os.kill(pid, signal.SIGXCPU) 876 | except ProcessLookupError: 877 | pass 878 | return inner_timout_checker 879 | 880 | 881 | @cli.command() 882 | @click.option('--max-children', type=int) 883 | @click.argument('mutant_names', required=False, nargs=-1) 884 | def run(mutant_names, *, max_children): 885 | # used to copy the global mutmut.config to subprocesses 886 | set_start_method('fork') 887 | 888 | assert isinstance(mutant_names, (tuple, list)), mutant_names 889 | _run(mutant_names, max_children) 890 | 891 | # separate function, so we can call it directly from the tests 892 | def _run(mutant_names: Union[tuple, list], max_children: Union[None, int]): 893 | # TODO: run no-ops once in a while to detect if we get false negatives 894 | # TODO: we should be able to get information on which tests killed mutants, which means we can get a list of tests and how many mutants each test kills. Those that kill zero mutants are redundant! 895 | os.environ['MUTANT_UNDER_TEST'] = 'mutant_generation' 896 | ensure_config_loaded() 897 | 898 | if max_children is None: 899 | max_children = os.cpu_count() or 4 900 | 901 | start = datetime.now() 902 | makedirs(Path('mutants'), exist_ok=True) 903 | with CatchOutput(spinner_title='Generating mutants'): 904 | copy_src_dir() 905 | create_mutants(max_children) 906 | copy_also_copy_files() 907 | 908 | time = datetime.now() - start 909 | print(f' done in {round(time.total_seconds()*1000)}ms', ) 910 | 911 | # ensure that the mutated source code can be imported by the tests 912 | source_code_paths = [Path('.'), Path('src'), Path('source')] 913 | for path in source_code_paths: 914 | mutated_path = Path('mutants') / path 915 | if mutated_path.exists(): 916 | sys.path.insert(0, str(mutated_path.absolute())) 917 | 918 | # ensure that the original code CANNOT be imported by the tests 919 | for path in source_code_paths: 920 | for i in range(len(sys.path)): 921 | while i < len(sys.path) and Path(sys.path[i]).resolve() == path.resolve(): 922 | del sys.path[i] 923 | 924 | # TODO: config/option for runner 925 | # runner = HammettRunner() 926 | runner = PytestRunner() 927 | runner.prepare_main_test_run() 928 | 929 | # TODO: run these steps only if we have mutants to test 930 | 931 | collect_or_load_stats(runner) 932 | 933 | mutants, source_file_mutation_data_by_path = collect_source_file_mutation_data(mutant_names=mutant_names) 934 | 935 | os.environ['MUTANT_UNDER_TEST'] = '' 936 | with CatchOutput(spinner_title='Running clean tests') as output_catcher: 937 | tests = tests_for_mutant_names(mutant_names) 938 | 939 | clean_test_exit_code = runner.run_tests(mutant_name=None, tests=tests) 940 | if clean_test_exit_code != 0: 941 | output_catcher.dump_output() 942 | print('Failed to run clean test') 943 | exit(1) 944 | print(' done') 945 | 946 | # this can't be the first thing, because it can fail deep inside pytest/django setup and then everything is destroyed 947 | run_forced_fail_test(runner) 948 | 949 | runner.prepare_main_test_run() 950 | 951 | def read_one_child_exit_status(): 952 | pid, wait_status = os.wait() 953 | exit_code = os.waitstatus_to_exitcode(wait_status) 954 | if mutmut.config.debug: 955 | print(' worker exit code', exit_code) 956 | source_file_mutation_data_by_pid[pid].register_result(pid=pid, exit_code=exit_code) 957 | 958 | source_file_mutation_data_by_pid: Dict[int, SourceFileMutationData] = {} # many pids map to one MutationData 959 | running_children = 0 960 | count_tried = 0 961 | 962 | # Run estimated fast mutants first, calculated as the estimated time for a surviving mutant. 963 | mutants = sorted(mutants, key=lambda x: estimated_worst_case_time(x[1])) 964 | 965 | gc.freeze() 966 | 967 | start = datetime.now() 968 | try: 969 | print('Running mutation testing') 970 | 971 | # Calculate times of tests 972 | for m, mutant_name, result in mutants: 973 | mutant_name = mutant_name.replace('__init__.', '') 974 | tests = mutmut.tests_by_mangled_function_name.get(mangled_name_from_mutant_name(mutant_name), []) 975 | estimated_time_of_tests = sum(mutmut.duration_by_test[test_name] for test_name in tests) 976 | m.estimated_time_of_tests_by_mutant[mutant_name] = estimated_time_of_tests 977 | 978 | Thread(target=timeout_checker(mutants), daemon=True).start() 979 | 980 | # Now do mutation 981 | for m, mutant_name, result in mutants: 982 | print_stats(source_file_mutation_data_by_path) 983 | 984 | mutant_name = mutant_name.replace('__init__.', '') 985 | 986 | # Rerun mutant if it's explicitly mentioned, but otherwise let the result stand 987 | if not mutant_names and result is not None: 988 | continue 989 | 990 | tests = mutmut.tests_by_mangled_function_name.get(mangled_name_from_mutant_name(mutant_name), []) 991 | 992 | # print(tests) 993 | if not tests: 994 | m.exit_code_by_key[mutant_name] = 33 995 | m.save() 996 | continue 997 | 998 | pid = os.fork() 999 | if not pid: 1000 | # In the child 1001 | os.environ['MUTANT_UNDER_TEST'] = mutant_name 1002 | setproctitle(f'mutmut: {mutant_name}') 1003 | 1004 | # Run fast tests first 1005 | tests = sorted(tests, key=lambda test_name: mutmut.duration_by_test[test_name]) 1006 | if not tests: 1007 | os._exit(33) 1008 | 1009 | estimated_time_of_tests = m.estimated_time_of_tests_by_mutant[mutant_name] 1010 | cpu_time_limit = ceil((estimated_time_of_tests + 1) * 2 + process_time()) * 10 1011 | resource.setrlimit(resource.RLIMIT_CPU, (cpu_time_limit, cpu_time_limit)) 1012 | 1013 | with CatchOutput(): 1014 | result = runner.run_tests(mutant_name=mutant_name, tests=tests) 1015 | 1016 | if result != 0: 1017 | # TODO: write failure information to stdout? 1018 | pass 1019 | os._exit(result) 1020 | else: 1021 | # in the parent 1022 | source_file_mutation_data_by_pid[pid] = m 1023 | m.register_pid(pid=pid, key=mutant_name, estimated_time_of_tests=estimated_time_of_tests) 1024 | running_children += 1 1025 | 1026 | if running_children >= max_children: 1027 | read_one_child_exit_status() 1028 | count_tried += 1 1029 | running_children -= 1 1030 | 1031 | try: 1032 | while running_children: 1033 | read_one_child_exit_status() 1034 | count_tried += 1 1035 | running_children -= 1 1036 | except ChildProcessError: 1037 | pass 1038 | except KeyboardInterrupt: 1039 | print('Stopping...') 1040 | stop_all_children(mutants) 1041 | 1042 | t = datetime.now() - start 1043 | 1044 | print_stats(source_file_mutation_data_by_path, force_output=True) 1045 | print() 1046 | print(f'{count_tried / t.total_seconds():.2f} mutations/second') 1047 | 1048 | if mutant_names: 1049 | print() 1050 | print('Mutant results') 1051 | print('--------------') 1052 | exit_code_by_key = {} 1053 | # If the user gave a specific list of mutants, print result for these specifically 1054 | for m, mutant_name, result in mutants: 1055 | exit_code_by_key[mutant_name] = m.exit_code_by_key[mutant_name] 1056 | 1057 | for mutant_name, exit_code in sorted(exit_code_by_key.items()): 1058 | print(emoji_by_status.get(status_by_exit_code.get(exit_code), '?'), mutant_name) 1059 | 1060 | print() 1061 | 1062 | 1063 | def tests_for_mutant_names(mutant_names): 1064 | tests = set() 1065 | for mutant_name in mutant_names: 1066 | if '*' in mutant_name: 1067 | for name, tests_of_this_name in mutmut.tests_by_mangled_function_name.items(): 1068 | if fnmatch.fnmatch(name, mutant_name): 1069 | tests |= set(tests_of_this_name) 1070 | else: 1071 | tests |= set(mutmut.tests_by_mangled_function_name[mangled_name_from_mutant_name(mutant_name)]) 1072 | return tests 1073 | 1074 | 1075 | @cli.command() 1076 | @click.option('--all', default=False) 1077 | def results(all): 1078 | ensure_config_loaded() 1079 | for path in walk_source_files(): 1080 | if not str(path).endswith('.py'): 1081 | continue 1082 | m = SourceFileMutationData(path=path) 1083 | m.load() 1084 | for k, v in m.exit_code_by_key.items(): 1085 | status = status_by_exit_code[v] 1086 | if status == 'killed' and not all: 1087 | continue 1088 | print(f' {k}: {status}') 1089 | 1090 | 1091 | def read_mutants_module(path) -> cst.Module: 1092 | with open(Path('mutants') / path) as f: 1093 | return cst.parse_module(f.read()) 1094 | 1095 | 1096 | def read_orig_module(path) -> cst.Module: 1097 | with open(path) as f: 1098 | return cst.parse_module(f.read()) 1099 | 1100 | 1101 | def find_function(module: cst.Module, name: str) -> Union[cst.FunctionDef, None]: 1102 | name = name.split('.')[-1] 1103 | return next(iter(m.findall(module, m.FunctionDef(m.Name(name)))), None) # type: ignore 1104 | 1105 | 1106 | def read_original_function(module: cst.Module, mutant_name: str): 1107 | orig_function_name, _ = orig_function_and_class_names_from_key(mutant_name) 1108 | orig_name = mangled_name_from_mutant_name(mutant_name) + '__mutmut_orig' 1109 | 1110 | result = find_function(module, orig_name) 1111 | if not result: 1112 | raise FileNotFoundError(f'Could not find original function "{orig_function_name}"') 1113 | return result.with_changes(name = cst.Name(orig_function_name)) 1114 | 1115 | 1116 | def read_mutant_function(module: cst.Module, mutant_name: str): 1117 | orig_function_name, _ = orig_function_and_class_names_from_key(mutant_name) 1118 | 1119 | result = find_function(module, mutant_name) 1120 | if not result: 1121 | raise FileNotFoundError(f'Could not find original function "{orig_function_name}"') 1122 | return result.with_changes(name = cst.Name(orig_function_name)) 1123 | 1124 | 1125 | def find_mutant(mutant_name): 1126 | for path in walk_source_files(): 1127 | if mutmut.config.should_ignore_for_mutation(path): 1128 | continue 1129 | 1130 | m = SourceFileMutationData(path=path) 1131 | m.load() 1132 | if mutant_name in m.exit_code_by_key: 1133 | return m 1134 | 1135 | raise FileNotFoundError(f'Could not find mutant {mutant_name}') 1136 | 1137 | 1138 | def get_diff_for_mutant(mutant_name, source=None, path=None): 1139 | if path is None: 1140 | m = find_mutant(mutant_name) 1141 | path = m.path 1142 | status = status_by_exit_code[m.exit_code_by_key[mutant_name]] 1143 | else: 1144 | status = 'not checked' 1145 | 1146 | print(f'# {mutant_name}: {status}') 1147 | 1148 | if source is None: 1149 | module = read_mutants_module(path) 1150 | else: 1151 | module = cst.parse_module(source) 1152 | orig_code = cst.Module([read_original_function(module, mutant_name)]).code.strip() 1153 | mutant_code = cst.Module([read_mutant_function(module, mutant_name)]).code.strip() 1154 | 1155 | path = str(path) # difflib requires str, not Path 1156 | return '\n'.join([ 1157 | line 1158 | for line in unified_diff(orig_code.split('\n'), mutant_code.split('\n'), fromfile=path, tofile=path, lineterm='') 1159 | ]) 1160 | 1161 | 1162 | @cli.command() 1163 | @click.argument('mutant_name') 1164 | def show(mutant_name): 1165 | ensure_config_loaded() 1166 | print(get_diff_for_mutant(mutant_name)) 1167 | return 1168 | 1169 | 1170 | @cli.command() 1171 | @click.argument('mutant_name') 1172 | def apply(mutant_name): 1173 | # try: 1174 | ensure_config_loaded() 1175 | apply_mutant(mutant_name) 1176 | # except FileNotFoundError as e: 1177 | # print(e) 1178 | 1179 | 1180 | def apply_mutant(mutant_name): 1181 | path = find_mutant(mutant_name).path 1182 | 1183 | orig_function_name, class_name = orig_function_and_class_names_from_key(mutant_name) 1184 | orig_function_name = orig_function_name.rpartition('.')[-1] 1185 | 1186 | orig_module = read_orig_module(path) 1187 | mutants_module = read_mutants_module(path) 1188 | 1189 | mutant_function = read_mutant_function(mutants_module, mutant_name) 1190 | mutant_function = mutant_function.with_changes(name=cst.Name(orig_function_name)) 1191 | 1192 | original_function = find_function(orig_module, orig_function_name) 1193 | if not original_function: 1194 | raise FileNotFoundError(f'Could not apply mutant {mutant_name}') 1195 | 1196 | new_module: cst.Module = orig_module.deep_replace(original_function, mutant_function) # type: ignore 1197 | 1198 | with open(path, 'w') as f: 1199 | f.write(new_module.code) 1200 | 1201 | 1202 | # TODO: junitxml, html commands 1203 | 1204 | @cli.command() 1205 | @click.option("--show-killed", is_flag=True, default=False, help="Display killed mutants.") 1206 | def browse(show_killed): 1207 | ensure_config_loaded() 1208 | 1209 | from textual.app import App 1210 | from textual.containers import Container 1211 | from textual.widgets import Footer 1212 | from textual.widgets import DataTable 1213 | from textual.widgets import Static 1214 | from textual.widget import Widget 1215 | from rich.syntax import Syntax 1216 | 1217 | class ResultBrowser(App): 1218 | loading_id = None 1219 | CSS_PATH = "result_browser_layout.tcss" 1220 | BINDINGS = [ 1221 | ("q", "quit()", "Quit"), 1222 | ("r", "retest_mutant()", "Retest mutant"), 1223 | ("f", "retest_function()", "Retest function"), 1224 | ("m", "retest_module()", "Retest module"), 1225 | ("a", "apply_mutant()", "Apply mutant to disk"), 1226 | ] 1227 | 1228 | columns = [ 1229 | ('path', 'Path'), 1230 | ] + [ 1231 | (status, Text(emoji, justify='right')) 1232 | for status, emoji in emoji_by_status.items() 1233 | ] 1234 | 1235 | cursor_type = 'row' 1236 | source_file_mutation_data_and_stat_by_path = None 1237 | 1238 | def compose(self): 1239 | with Container(classes='container'): 1240 | yield DataTable(id='files') 1241 | yield DataTable(id='mutants') 1242 | with Widget(id="diff_view_widget"): 1243 | yield Static(id='diff_view') 1244 | yield Footer() 1245 | 1246 | def on_mount(self): 1247 | # files table 1248 | # noinspection PyTypeChecker 1249 | files_table: DataTable = self.query_one('#files') 1250 | files_table.cursor_type = 'row' 1251 | for key, label in self.columns: 1252 | files_table.add_column(key=key, label=label) 1253 | 1254 | # mutants table 1255 | # noinspection PyTypeChecker 1256 | mutants_table: DataTable = self.query_one('#mutants') 1257 | mutants_table.cursor_type = 'row' 1258 | mutants_table.add_columns('name', 'status') 1259 | 1260 | self.read_data() 1261 | self.populate_files_table() 1262 | 1263 | def read_data(self): 1264 | ensure_config_loaded() 1265 | self.source_file_mutation_data_and_stat_by_path = {} 1266 | 1267 | for p in walk_source_files(): 1268 | if mutmut.config.should_ignore_for_mutation(p): 1269 | continue 1270 | source_file_mutation_data = SourceFileMutationData(path=p) 1271 | source_file_mutation_data.load() 1272 | stat = collect_stat(source_file_mutation_data) 1273 | 1274 | self.source_file_mutation_data_and_stat_by_path[str(p)] = source_file_mutation_data, stat 1275 | 1276 | def populate_files_table(self): 1277 | # noinspection PyTypeChecker 1278 | files_table: DataTable = self.query_one('#files') 1279 | # TODO: restore selection 1280 | selected_row = files_table.cursor_row 1281 | files_table.clear() 1282 | 1283 | for p, (source_file_mutation_data, stat) in sorted(self.source_file_mutation_data_and_stat_by_path.items()): 1284 | row = [p] + [ 1285 | Text(str(getattr(stat, k.replace(' ', '_'))), justify="right") 1286 | for k, _ in self.columns[1:] 1287 | ] 1288 | files_table.add_row(*row, key=str(p)) 1289 | 1290 | files_table.move_cursor(row=selected_row) 1291 | 1292 | def on_data_table_row_highlighted(self, event): 1293 | if not event.row_key or not event.row_key.value: 1294 | return 1295 | if event.data_table.id == 'files': 1296 | # noinspection PyTypeChecker 1297 | mutants_table: DataTable = self.query_one('#mutants') 1298 | mutants_table.clear() 1299 | source_file_mutation_data, stat = self.source_file_mutation_data_and_stat_by_path[event.row_key.value] 1300 | for k, v in source_file_mutation_data.exit_code_by_key.items(): 1301 | status = status_by_exit_code[v] 1302 | if status != 'killed' or show_killed: 1303 | mutants_table.add_row(k, emoji_by_status[status], key=k) 1304 | else: 1305 | assert event.data_table.id == 'mutants' 1306 | # noinspection PyTypeChecker 1307 | diff_view: Static = self.query_one('#diff_view') 1308 | if event.row_key.value is None: 1309 | diff_view.update('') 1310 | else: 1311 | diff_view.update('') 1312 | self.loading_id = event.row_key.value 1313 | 1314 | def load_thread(): 1315 | ensure_config_loaded() 1316 | try: 1317 | d = get_diff_for_mutant(event.row_key.value) 1318 | if event.row_key.value == self.loading_id: 1319 | diff_view.update(Syntax(d, "diff")) 1320 | except Exception as e: 1321 | diff_view.update(f"<{type(e)} {e}>") 1322 | 1323 | t = Thread(target=load_thread) 1324 | t.start() 1325 | 1326 | def retest(self, pattern): 1327 | with self.suspend(): 1328 | browse_index = sys.argv.index('browse') 1329 | initial_args = sys.argv[:browse_index] 1330 | subprocess.run([sys.executable, *initial_args, 'run', pattern]) 1331 | input('press enter to return to browser') 1332 | 1333 | self.read_data() 1334 | self.populate_files_table() 1335 | 1336 | def get_mutant_name_from_selection(self): 1337 | # noinspection PyTypeChecker 1338 | mutants_table: DataTable = self.query_one('#mutants') 1339 | if mutants_table.cursor_row is None: 1340 | return 1341 | 1342 | return mutants_table.get_row_at(mutants_table.cursor_row)[0] 1343 | 1344 | def action_retest_mutant(self): 1345 | self.retest(self.get_mutant_name_from_selection()) 1346 | 1347 | def action_retest_function(self): 1348 | self.retest(self.get_mutant_name_from_selection().rpartition('__mutmut_')[0] + '__mutmut_*') 1349 | 1350 | def action_retest_module(self): 1351 | self.retest(self.get_mutant_name_from_selection().rpartition('.')[0] + '.*') 1352 | 1353 | def action_apply_mutant(self): 1354 | ensure_config_loaded() 1355 | # noinspection PyTypeChecker 1356 | mutants_table: DataTable = self.query_one('#mutants') 1357 | if mutants_table.cursor_row is None: 1358 | return 1359 | apply_mutant(mutants_table.get_row_at(mutants_table.cursor_row)[0]) 1360 | 1361 | ResultBrowser().run() 1362 | 1363 | 1364 | if __name__ == '__main__': 1365 | cli() 1366 | -------------------------------------------------------------------------------- /mutmut/file_mutation.py: -------------------------------------------------------------------------------- 1 | """This module contains code for managing mutant creation for whole files.""" 2 | 3 | from collections import defaultdict 4 | from collections.abc import Iterable, Sequence, Mapping 5 | from dataclasses import dataclass 6 | from typing import Union 7 | import warnings 8 | import libcst as cst 9 | from libcst.metadata import PositionProvider, MetadataWrapper 10 | import libcst.matchers as m 11 | from mutmut.trampoline_templates import build_trampoline, mangle_function_name, trampoline_impl, yield_from_trampoline_impl 12 | from mutmut.node_mutation import mutation_operators, OPERATORS_TYPE 13 | 14 | NEVER_MUTATE_FUNCTION_NAMES = { "__getattribute__", "__setattr__", "__new__" } 15 | NEVER_MUTATE_FUNCTION_CALLS = { "len", "isinstance" } 16 | 17 | @dataclass 18 | class Mutation: 19 | original_node: cst.CSTNode 20 | mutated_node: cst.CSTNode 21 | contained_by_top_level_function: Union[cst.FunctionDef, None] 22 | 23 | 24 | def mutate_file_contents(filename: str, code: str) -> tuple[str, Sequence[str]]: 25 | """Create mutations for `code` and merge them to a single mutated file with trampolines. 26 | 27 | :return: A tuple of (mutated code, list of mutant function names)""" 28 | try: 29 | module, mutations = create_mutations(code) 30 | except cst.ParserSyntaxError as e: 31 | warnings.warn(SyntaxWarning(f'Unsupported syntax in {filename} ({str(e)}), skipping')) 32 | return code, [] 33 | 34 | return combine_mutations_to_source(module, mutations) 35 | 36 | 37 | def create_mutations( 38 | code: str 39 | ) -> tuple[cst.Module, list[Mutation]]: 40 | """Parse the code and create mutations.""" 41 | ignored_lines = pragma_no_mutate_lines(code) 42 | 43 | module = cst.parse_module(code) 44 | 45 | metadata_wrapper = MetadataWrapper(module) 46 | visitor = MutationVisitor(mutation_operators, ignored_lines) 47 | module = metadata_wrapper.visit(visitor) 48 | 49 | return module, visitor.mutations 50 | 51 | class OuterFunctionProvider(cst.BatchableMetadataProvider): 52 | """Link all nodes to the top-level function or method that contains them. 53 | 54 | For instance given this module: 55 | 56 | ``` 57 | def foo(): 58 | def bar(): 59 | x = 1 60 | ``` 61 | 62 | Then `self.get_metadata(OuterFunctionProvider, )` returns ``. 63 | """ 64 | def __init__(self): 65 | super().__init__() 66 | 67 | def visit_Module(self, node: cst.Module): 68 | for child in node.body: 69 | if isinstance(child, cst.FunctionDef): 70 | # mark all nodes inside the function to belong to this function 71 | child.visit(OuterFunctionVisitor(self, child)) 72 | elif isinstance(child, cst.ClassDef) and isinstance(child.body, cst.IndentedBlock): 73 | for method in child.body.body: 74 | # mark all nodes inside the class method to belong to this method 75 | method.visit(OuterFunctionVisitor(self, method)) 76 | 77 | # no need to recurse, we already visited all function and class method children 78 | return False 79 | 80 | 81 | class OuterFunctionVisitor(cst.CSTVisitor): 82 | """Mark all nodes as children of `top_level_node`.""" 83 | def __init__(self, provider: "OuterFunctionProvider", top_level_node: cst.CSTNode) -> None: 84 | self.provider = provider 85 | self.top_level_node = top_level_node 86 | super().__init__() 87 | 88 | def on_visit(self, node: cst.CSTNode): 89 | self.provider.set_metadata(node, self.top_level_node) 90 | return True 91 | 92 | 93 | class MutationVisitor(cst.CSTVisitor): 94 | """Iterate through all nodes in the module and create mutations for them. 95 | Ignore nodes at lines `ignore_lines` and several other cases (e.g. nodes within type annotations). 96 | 97 | The created mutations will be accessible at `self.mutations`.""" 98 | 99 | METADATA_DEPENDENCIES = (PositionProvider, OuterFunctionProvider) 100 | 101 | def __init__(self, operators: OPERATORS_TYPE, ignore_lines: set[int]): 102 | self.mutations: list[Mutation] = [] 103 | self._operators = operators 104 | self._ignored_lines = ignore_lines 105 | 106 | def on_visit(self, node): 107 | if self._skip_node_and_children(node): 108 | return False 109 | 110 | if self._should_mutate_node(node): 111 | self._create_mutations(node) 112 | 113 | # continue to mutate children 114 | return True 115 | 116 | def _create_mutations(self, node: cst.CSTNode): 117 | for t, operator in self._operators: 118 | if isinstance(node, t): 119 | for mutated_node in operator(node): 120 | mutation = Mutation( 121 | original_node=node, 122 | mutated_node=mutated_node, 123 | contained_by_top_level_function=self.get_metadata(OuterFunctionProvider, node, None), # type: ignore 124 | ) 125 | self.mutations.append(mutation) 126 | 127 | def _should_mutate_node(self, node: cst.CSTNode): 128 | # do not mutate nodes with a pragma: no mutate comment 129 | # currently, the position metadata does not always exist 130 | # (see https://github.com/Instagram/LibCST/issues/1322) 131 | position = self.get_metadata(PositionProvider,node, None) 132 | if position and position.start.line in self._ignored_lines: 133 | return False 134 | return True 135 | 136 | def _skip_node_and_children(self, node: cst.CSTNode): 137 | if (isinstance(node, cst.Call) and isinstance(node.func, cst.Name) and node.func.value in NEVER_MUTATE_FUNCTION_CALLS) \ 138 | or (isinstance(node, cst.FunctionDef) and node.name.value in NEVER_MUTATE_FUNCTION_NAMES): 139 | return True 140 | 141 | # ignore everything inside of type annotations 142 | if isinstance(node, cst.Annotation): 143 | return True 144 | 145 | # default args are executed at definition time 146 | # We want to prevent e.g. def foo(x = abs(-1)) mutating to def foo(x = abs(None)), 147 | # which would raise an Exception as soon as the function is defined (can break the whole import) 148 | # Therefore we only allow simple default values, where mutations should not raise exceptions 149 | if isinstance(node, cst.Param) and node.default and not isinstance(node.default, (cst.Name, cst.BaseNumber, cst.BaseString)): 150 | return True 151 | 152 | # ignore decorated functions, because 153 | # 1) copying them for the trampoline setup can cause side effects (e.g. multiple @app.post("/foo") definitions) 154 | # 2) decorators are executed when the function is defined, so we don't want to mutate their arguments and cause exceptions 155 | # 3) @property decorators break the trampoline signature assignment (which expects it to be a function) 156 | if isinstance(node, (cst.FunctionDef, cst.ClassDef)) and len(node.decorators): 157 | return True 158 | 159 | return False 160 | 161 | 162 | 163 | MODULE_STATEMENT = Union[cst.SimpleStatementLine, cst.BaseCompoundStatement] 164 | 165 | # convert str trampoline implementations to CST nodes with some whitespace 166 | trampoline_impl_cst = list(cst.parse_module(trampoline_impl).body) 167 | trampoline_impl_cst[-1] = trampoline_impl_cst[-1].with_changes(leading_lines = [cst.EmptyLine(), cst.EmptyLine()]) 168 | yield_from_trampoline_impl_cst = list(cst.parse_module(yield_from_trampoline_impl).body) 169 | yield_from_trampoline_impl_cst[-1] = yield_from_trampoline_impl_cst[-1].with_changes(leading_lines = [cst.EmptyLine(), cst.EmptyLine()]) 170 | 171 | 172 | def combine_mutations_to_source(module: cst.Module, mutations: Sequence[Mutation]) -> tuple[str, Sequence[str]]: 173 | """Create mutated functions and trampolines for all mutations and compile them to a single source code. 174 | 175 | :param module: The original parsed module 176 | :param mutations: Mutations that should be applied. 177 | :return: Mutated code and list of mutation names""" 178 | 179 | # copy start of the module (in particular __future__ imports) 180 | result: list[MODULE_STATEMENT] = get_statements_until_func_or_class(module.body) 181 | mutation_names: list[str] = [] 182 | 183 | # statements we still need to potentially mutate and add to the result 184 | remaining_statements = module.body[len(result):] 185 | 186 | # trampoline functions 187 | result.extend(trampoline_impl_cst) 188 | result.extend(yield_from_trampoline_impl_cst) 189 | 190 | mutations_within_function = group_by_top_level_node(mutations) 191 | 192 | # We now iterate through all top-level nodes. 193 | # If they are a function or class method, we mutate and add trampolines. 194 | # Else we keep the original node without modifications. 195 | for statement in remaining_statements: 196 | if isinstance(statement, cst.FunctionDef): 197 | func = statement 198 | func_mutants = mutations_within_function.get(func) 199 | if not func_mutants: 200 | result.append(func) 201 | continue 202 | nodes, mutant_names = function_trampoline_arrangement(func, func_mutants, class_name=None) 203 | result.extend(nodes) 204 | mutation_names.extend(mutant_names) 205 | elif isinstance(statement, cst.ClassDef): 206 | cls = statement 207 | if not isinstance(cls.body, cst.IndentedBlock): 208 | # we don't mutate single-line classes, e.g. `class A: a = 1; b = 2` 209 | result.append(cls) 210 | else: 211 | mutated_body = [] 212 | for method in cls.body.body: 213 | method_mutants = mutations_within_function.get(method) 214 | if not isinstance(method, cst.FunctionDef) or not method_mutants: 215 | mutated_body.append(method) 216 | continue 217 | nodes, mutant_names = function_trampoline_arrangement(method, method_mutants, class_name=cls.name.value) 218 | mutated_body.extend(nodes) 219 | mutation_names.extend(mutant_names) 220 | 221 | result.append(cls.with_changes(body=cls.body.with_changes(body=mutated_body))) 222 | else: 223 | result.append(statement) 224 | 225 | mutated_module = module.with_changes(body=result) 226 | return mutated_module.code, mutation_names 227 | 228 | def function_trampoline_arrangement(function: cst.FunctionDef, mutants: Iterable[Mutation], class_name: Union[str, None]) -> tuple[Sequence[MODULE_STATEMENT], Sequence[str]]: 229 | """Create mutated functions and a trampoline that switches between original and mutated versions. 230 | 231 | :return: A tuple of (nodes, mutant names)""" 232 | nodes: list[MODULE_STATEMENT] = [] 233 | mutant_names: list[str] = [] 234 | 235 | name = function.name.value 236 | mangled_name = mangle_function_name(name=name, class_name=class_name) + '__mutmut' 237 | _is_generator = is_generator(function) 238 | 239 | # copy of original function 240 | nodes.append(function.with_changes(name=cst.Name(mangled_name + '_orig'))) 241 | 242 | # mutated versions of the function 243 | for i, mutant in enumerate(mutants): 244 | mutant_name = f'{mangled_name}_{i+1}' 245 | mutant_names.append(mutant_name) 246 | mutated_method = function.with_changes(name=cst.Name(mutant_name)) 247 | mutated_method = deep_replace(mutated_method, mutant.original_node, mutant.mutated_node) 248 | nodes.append(mutated_method) # type: ignore 249 | 250 | # trampoline that forwards the calls 251 | trampoline = list(cst.parse_module(build_trampoline(orig_name=name, mutants=mutant_names, class_name=class_name, is_generator=_is_generator)).body) 252 | trampoline[0] = trampoline[0].with_changes(leading_lines=[cst.EmptyLine()]) 253 | nodes.extend(trampoline) 254 | 255 | return nodes, mutant_names 256 | 257 | 258 | def get_statements_until_func_or_class(statements: Sequence[MODULE_STATEMENT]) -> list[MODULE_STATEMENT]: 259 | """Get all statements until we encounter the first function or class definition""" 260 | result = [] 261 | 262 | for stmt in statements: 263 | if m.matches(stmt, m.FunctionDef() | m.ClassDef()): 264 | return result 265 | result.append(stmt) 266 | 267 | return result 268 | 269 | def group_by_top_level_node(mutations: Sequence[Mutation]) -> Mapping[cst.CSTNode, Sequence[Mutation]]: 270 | grouped: dict[cst.CSTNode, list[Mutation]] = defaultdict(list) 271 | for m in mutations: 272 | if m.contained_by_top_level_function: 273 | grouped[m.contained_by_top_level_function].append(m) 274 | 275 | return grouped 276 | 277 | def is_generator(function: cst.FunctionDef) -> bool: 278 | """Return True if the function has yield statement(s).""" 279 | visitor = IsGeneratorVisitor(function) 280 | function.visit(visitor) 281 | return visitor.is_generator 282 | 283 | class IsGeneratorVisitor(cst.CSTVisitor): 284 | """Check if a function is a generator. 285 | We do so by checking if any child is a Yield statement, but not looking into inner function definitions.""" 286 | def __init__(self, original_function: cst.FunctionDef): 287 | self.is_generator = False 288 | self.original_function: cst.FunctionDef = original_function 289 | 290 | def visit_FunctionDef(self, node): 291 | # do not recurse into inner function definitions 292 | if self.original_function != node: 293 | return False 294 | 295 | def visit_Yield(self, node): 296 | self.is_generator = True 297 | return False 298 | 299 | def pragma_no_mutate_lines(source: str) -> set[int]: 300 | return { 301 | i + 1 302 | for i, line in enumerate(source.split('\n')) 303 | if '# pragma:' in line and 'no mutate' in line.partition('# pragma:')[-1] 304 | } 305 | 306 | def deep_replace(tree: cst.CSTNode, old_node: cst.CSTNode, new_node: cst.CSTNode) -> cst.CSTNode: 307 | """Like the CSTNode.deep_replace method, except that we only replace up to one occurence of old_node.""" 308 | return tree.visit(ChildReplacementTransformer(old_node, new_node)) # type: ignore 309 | 310 | class ChildReplacementTransformer(cst.CSTTransformer): 311 | def __init__(self, old_node: cst.CSTNode, new_node: cst.CSTNode): 312 | self.old_node = old_node 313 | self.new_node = new_node 314 | self.replaced_node = False 315 | 316 | def on_visit(self, node: cst.CSTNode) -> bool: 317 | # If the node is one we are about to replace, we shouldn't 318 | # recurse down it, that would be a waste of time. 319 | # Also, we stop recursion when we already replaced the node. 320 | return not (self.replaced_node or node is self.old_node) 321 | 322 | def on_leave(self, original_node: cst.CSTNode, updated_node: cst.CSTNode) -> cst.CSTNode: 323 | if original_node is self.old_node: 324 | self.replaced_node = True 325 | return self.new_node 326 | return updated_node 327 | -------------------------------------------------------------------------------- /mutmut/node_mutation.py: -------------------------------------------------------------------------------- 1 | """This module contains the mutations for indidvidual nodes, e.g. replacing a != b with a == b.""" 2 | from typing import Any, Union 3 | from collections.abc import Callable, Iterable, Sequence 4 | import libcst as cst 5 | import libcst.matchers as m 6 | 7 | OPERATORS_TYPE = Sequence[ 8 | tuple[ 9 | type[cst.CSTNode], 10 | Callable[[Any], Iterable[cst.CSTNode]], 11 | ] 12 | ] 13 | 14 | def operator_number( 15 | node: cst.BaseNumber 16 | ) -> Iterable[cst.BaseNumber]: 17 | if isinstance(node, (cst.Integer, cst.Float)): 18 | yield node.with_changes(value=repr(node.evaluated_value + 1)) 19 | elif isinstance(node, cst.Imaginary): 20 | yield node.with_changes(value=repr(node.evaluated_value + 1j)) 21 | else: 22 | print("Unexpected number type", node) 23 | 24 | 25 | def operator_string( 26 | node: cst.BaseString 27 | ) -> Iterable[cst.BaseString]: 28 | if isinstance(node, cst.SimpleString): 29 | value = node.value 30 | prefix = value[ 31 | : min([x for x in [value.find('"'), value.find("'")] if x != -1]) 32 | ] 33 | value = value[len(prefix) :] 34 | 35 | if value.startswith('"""') or value.startswith("'''"): 36 | # We assume here that triple-quoted stuff are docs or other things 37 | # that mutation is meaningless for 38 | return 39 | 40 | supported_str_mutations: list[Callable[[str], str]] = [ 41 | lambda x: "XX" + x + "XX", 42 | lambda x: x.lower(), 43 | lambda x: x.upper(), 44 | lambda x: x.capitalize(), 45 | ] 46 | 47 | for mut_func in supported_str_mutations: 48 | new_value = f"{prefix}{value[0]}{mut_func(value[1:-1])}{value[-1]}" 49 | if new_value == value: 50 | continue 51 | yield node.with_changes(value=new_value) 52 | 53 | 54 | def operator_lambda( 55 | node: cst.Lambda 56 | ) -> Iterable[cst.Lambda]: 57 | if m.matches(node, m.Lambda(body=m.Name("None"))): 58 | yield node.with_changes(body=cst.Integer("0")) 59 | else: 60 | yield node.with_changes(body=cst.Name("None")) 61 | 62 | 63 | def operator_dict_arguments( 64 | node: cst.Call 65 | ) -> Iterable[cst.Call]: 66 | """mutate dict(a=b, c=d) to dict(aXX=b, c=d) and dict(a=b, cXX=d)""" 67 | if not m.matches(node.func, m.Name(value="dict")): 68 | return 69 | 70 | for i, arg in enumerate(node.args): 71 | if not arg.keyword: 72 | return 73 | keyword = arg.keyword 74 | mutated_keyword = keyword.with_changes(value=keyword.value + "XX") 75 | mutated_args = [ 76 | *node.args[:i], 77 | node.args[i].with_changes(keyword=mutated_keyword), 78 | *node.args[i+1:], 79 | ] 80 | yield node.with_changes(args=mutated_args) 81 | 82 | 83 | def operator_arg_removal( 84 | node: cst.Call 85 | ) -> Iterable[cst.Call]: 86 | """try to drop each arg in a function call, e.g. foo(a, b) -> foo(b), foo(a)""" 87 | for i, arg in enumerate(node.args): 88 | # replace with None 89 | if arg.star == '' and not m.matches(arg.value, m.Name("None")): 90 | mutated_arg = arg.with_changes(value=cst.Name("None")) 91 | yield node.with_changes(args=[*node.args[:i], mutated_arg, *node.args[i + 1 :]]) 92 | 93 | if len(node.args) > 1: 94 | for i in range(len(node.args)): 95 | arg = node.args[i] 96 | yield node.with_changes(args=[*node.args[:i], *node.args[i + 1 :]]) 97 | 98 | 99 | supported_str_methods_swap = [ 100 | ("lower", "upper"), 101 | ("upper", "lower"), 102 | ("lstrip", "rstrip"), 103 | ("rstrip", "lstrip"), 104 | ("find", "rfind"), 105 | ("rfind", "find"), 106 | ("ljust", "rjust"), 107 | ("rjust", "ljust"), 108 | ("index", "rindex"), 109 | ("rindex", "index"), 110 | ("split", "rsplit"), 111 | ("rsplit", "split"), 112 | ("removeprefix", "removesuffix"), 113 | ("removesuffix", "removeprefix"), 114 | ("partition", "rpartition"), 115 | ("rpartition", "partition") 116 | ] 117 | 118 | def operator_string_methods_swap( 119 | node: cst.Call 120 | ) -> Iterable[cst.Call]: 121 | """try to swap string method to opposite e.g. a.lower() -> a.upper()""" 122 | 123 | for old_call, new_call in supported_str_methods_swap: 124 | if m.matches(node.func, m.Attribute(value=m.DoNotCare(), attr=m.Name(value=old_call))): 125 | func_name = cst.ensure_type(node.func, cst.Attribute).attr 126 | yield node.with_deep_changes(func_name, value=new_call) 127 | 128 | 129 | def operator_remove_unary_ops( 130 | node: cst.UnaryOperation 131 | ) -> Iterable[cst.BaseExpression]: 132 | if isinstance(node.operator, (cst.Not, cst.BitInvert)): 133 | yield node.expression 134 | 135 | _keyword_mapping: dict[type[cst.CSTNode], type[cst.CSTNode]] = { 136 | cst.Is: cst.IsNot, 137 | cst.IsNot: cst.Is, 138 | cst.In: cst.NotIn, 139 | cst.NotIn: cst.In, 140 | cst.Break: cst.Return, 141 | cst.Continue: cst.Break, 142 | } 143 | 144 | def operator_keywords( 145 | node: cst.CSTNode 146 | ) -> Iterable[cst.CSTNode]: 147 | yield from _simple_mutation_mapping(node, _keyword_mapping) 148 | 149 | 150 | def operator_name(node: cst.Name) -> Iterable[cst.CSTNode]: 151 | name_mappings = { 152 | "True": "False", 153 | "False": "True", 154 | "deepcopy": "copy", 155 | # TODO: probably need to add a lot of things here... some builtins maybe, what more? 156 | } 157 | if node.value in name_mappings: 158 | yield node.with_changes(value=name_mappings[node.value]) 159 | 160 | _operator_mapping: dict[type[cst.CSTNode], type[cst.CSTNode]] = { 161 | cst.Plus: cst.Minus, 162 | cst.Add: cst.Subtract, 163 | cst.Minus: cst.Plus, 164 | cst.Subtract: cst.Add, 165 | cst.Multiply: cst.Divide, 166 | cst.Divide: cst.Multiply, 167 | cst.FloorDivide: cst.Divide, 168 | cst.Modulo: cst.Divide, 169 | cst.LeftShift: cst.RightShift, 170 | cst.RightShift: cst.LeftShift, 171 | cst.BitAnd: cst.BitOr, 172 | cst.BitOr: cst.BitAnd, 173 | cst.BitXor: cst.BitAnd, 174 | cst.Power: cst.Multiply, 175 | cst.AddAssign: cst.SubtractAssign, 176 | cst.SubtractAssign: cst.AddAssign, 177 | cst.MultiplyAssign: cst.DivideAssign, 178 | cst.DivideAssign: cst.MultiplyAssign, 179 | cst.FloorDivideAssign: cst.DivideAssign, 180 | cst.ModuloAssign: cst.DivideAssign, 181 | cst.LeftShiftAssign: cst.RightShiftAssign, 182 | cst.RightShiftAssign: cst.LeftShiftAssign, 183 | cst.BitAndAssign: cst.BitOrAssign, 184 | cst.BitOrAssign: cst.BitAndAssign, 185 | cst.BitXorAssign: cst.BitAndAssign, 186 | cst.PowerAssign: cst.MultiplyAssign, 187 | cst.LessThan: cst.LessThanEqual, 188 | cst.LessThanEqual: cst.LessThan, 189 | cst.GreaterThan: cst.GreaterThanEqual, 190 | cst.GreaterThanEqual: cst.GreaterThan, 191 | cst.Equal: cst.NotEqual, 192 | cst.NotEqual: cst.Equal, 193 | cst.And: cst.Or, 194 | cst.Or: cst.And, 195 | } 196 | 197 | def operator_swap_op( 198 | node: cst.CSTNode 199 | ) -> Iterable[cst.CSTNode]: 200 | yield from _simple_mutation_mapping(node, _operator_mapping) 201 | 202 | 203 | def operator_augmented_assignment( 204 | node: cst.AugAssign 205 | ) -> Iterable[cst.Assign]: 206 | """mutate all augmented assignments (+=, *=, |=, etc.) to normal = assignments""" 207 | yield cst.Assign([cst.AssignTarget(node.target)], node.value, node.semicolon) 208 | 209 | 210 | def operator_assignment( 211 | node: Union[cst.Assign, cst.AnnAssign] 212 | ) -> Iterable[cst.CSTNode]: 213 | """mutate `a = b` to `a = None` and `a = None` to `a = ""`""" 214 | if not node.value: 215 | # do not mutate `a: sometype` to an assignment `a: sometype = ""` 216 | return 217 | if m.matches(node.value, m.Name("None")): 218 | mutated_value = cst.SimpleString('""') 219 | else: 220 | mutated_value = cst.Name("None") 221 | 222 | yield node.with_changes(value=mutated_value) 223 | 224 | def operator_match(node: cst.Match) -> Iterable[cst.CSTNode]: 225 | """Drop the case statements in a match.""" 226 | if len(node.cases) > 1: 227 | for i in range(len(node.cases)): 228 | yield node.with_changes(cases=[*node.cases[:i], *node.cases[i+1:]]) 229 | 230 | # Operators that should be called on specific node types 231 | mutation_operators: OPERATORS_TYPE = [ 232 | (cst.BaseNumber, operator_number), 233 | (cst.BaseString, operator_string), 234 | (cst.Name, operator_name), 235 | (cst.Assign, operator_assignment), 236 | (cst.AnnAssign, operator_assignment), 237 | (cst.AugAssign, operator_augmented_assignment), 238 | (cst.UnaryOperation, operator_remove_unary_ops), 239 | (cst.Call, operator_dict_arguments), 240 | (cst.Call, operator_arg_removal), 241 | (cst.Call, operator_string_methods_swap), 242 | (cst.Lambda, operator_lambda), 243 | (cst.CSTNode, operator_keywords), 244 | (cst.CSTNode, operator_swap_op), 245 | (cst.Match, operator_match), 246 | ] 247 | 248 | 249 | def _simple_mutation_mapping( 250 | node: cst.CSTNode, mapping: dict[type[cst.CSTNode], type[cst.CSTNode]] 251 | ) -> Iterable[cst.CSTNode]: 252 | """Yield mutations from the node class mapping""" 253 | mutated_node_type = mapping.get(type(node)) 254 | if mutated_node_type: 255 | yield mutated_node_type() 256 | 257 | 258 | # TODO: detect regexes and mutate them in nasty ways? Maybe mutate all strings as if they are regexes 259 | -------------------------------------------------------------------------------- /mutmut/result_browser_layout.tcss: -------------------------------------------------------------------------------- 1 | Screen { 2 | layout: vertical; 3 | } 4 | .container { 5 | layout: horizontal; 6 | height: 50%; 7 | } 8 | 9 | 10 | DataTable { 11 | color: #c0c0c0; 12 | } 13 | 14 | DataTable:focus { 15 | color: white; 16 | } 17 | 18 | .datatable--cursor { 19 | color: black; 20 | background: white; 21 | } 22 | 23 | DataTable:focus .datatable--cursor { 24 | color: black; 25 | background: orange; 26 | } 27 | 28 | #diff_view_widget { 29 | height: 50%; 30 | overflow-y: scroll; 31 | } 32 | 33 | #diff_view { 34 | } 35 | -------------------------------------------------------------------------------- /mutmut/trampoline_templates.py: -------------------------------------------------------------------------------- 1 | CLASS_NAME_SEPARATOR = 'ǁ' 2 | 3 | def build_trampoline(*, orig_name, mutants, class_name, is_generator): 4 | mangled_name = mangle_function_name(name=orig_name, class_name=class_name) 5 | 6 | mutants_dict = f'{mangled_name}__mutmut_mutants : ClassVar[MutantDict] = {{\n' + ', \n '.join(f'{repr(m)}: {m}' for m in mutants) + '\n}' 7 | access_prefix = '' 8 | access_suffix = '' 9 | self_arg = '' 10 | if class_name is not None: 11 | access_prefix = f'object.__getattribute__(self, "' 12 | access_suffix = '")' 13 | self_arg = ', self' 14 | 15 | if is_generator: 16 | yield_statement = 'yield from ' # note the space at the end! 17 | trampoline_name = '_mutmut_yield_from_trampoline' 18 | else: 19 | yield_statement = '' 20 | trampoline_name = '_mutmut_trampoline' 21 | 22 | return f""" 23 | {mutants_dict} 24 | 25 | def {orig_name}({'self, ' if class_name is not None else ''}*args, **kwargs): 26 | result = {yield_statement}{trampoline_name}({access_prefix}{mangled_name}__mutmut_orig{access_suffix}, {access_prefix}{mangled_name}__mutmut_mutants{access_suffix}, args, kwargs{self_arg}) 27 | return result 28 | 29 | {orig_name}.__signature__ = _mutmut_signature({mangled_name}__mutmut_orig) 30 | {mangled_name}__mutmut_orig.__name__ = '{mangled_name}' 31 | """ 32 | 33 | def mangle_function_name(*, name, class_name): 34 | assert CLASS_NAME_SEPARATOR not in name 35 | if class_name: 36 | assert CLASS_NAME_SEPARATOR not in class_name 37 | prefix = f'x{CLASS_NAME_SEPARATOR}{class_name}{CLASS_NAME_SEPARATOR}' 38 | else: 39 | prefix = 'x_' 40 | return f'{prefix}{name}' 41 | 42 | # noinspection PyUnresolvedReferences 43 | # language=python 44 | trampoline_impl = """ 45 | from inspect import signature as _mutmut_signature 46 | from typing import Annotated 47 | from typing import Callable 48 | from typing import ClassVar 49 | 50 | 51 | MutantDict = Annotated[dict[str, Callable], "Mutant"] 52 | 53 | 54 | def _mutmut_trampoline(orig, mutants, call_args, call_kwargs, self_arg = None): 55 | \"""Forward call to original or mutated function, depending on the environment\""" 56 | import os 57 | mutant_under_test = os.environ['MUTANT_UNDER_TEST'] 58 | if mutant_under_test == 'fail': 59 | from mutmut.__main__ import MutmutProgrammaticFailException 60 | raise MutmutProgrammaticFailException('Failed programmatically') 61 | elif mutant_under_test == 'stats': 62 | from mutmut.__main__ import record_trampoline_hit 63 | record_trampoline_hit(orig.__module__ + '.' + orig.__name__) 64 | result = orig(*call_args, **call_kwargs) 65 | return result # for the yield case 66 | prefix = orig.__module__ + '.' + orig.__name__ + '__mutmut_' 67 | if not mutant_under_test.startswith(prefix): 68 | result = orig(*call_args, **call_kwargs) 69 | return result # for the yield case 70 | mutant_name = mutant_under_test.rpartition('.')[-1] 71 | if self_arg: 72 | # call to a class method where self is not bound 73 | result = mutants[mutant_name](self_arg, *call_args, **call_kwargs) 74 | else: 75 | result = mutants[mutant_name](*call_args, **call_kwargs) 76 | return result 77 | 78 | """ 79 | yield_from_trampoline_impl = trampoline_impl.replace('result = ', 'result = yield from ').replace('_mutmut_trampoline', '_mutmut_yield_from_trampoline') -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | libcst ~= 1.7.0 2 | click 3 | setproctitle 4 | textual 5 | # We use the tomlib builtin for Python 3.11 and newer 6 | toml>=0.10.2; python_version < '3.11' 7 | pytest 8 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [tool:pytest] 2 | testpaths=tests 3 | # --strict: warnings become errors. 4 | # -r fEsxXw: show extra test summary info for everything. 5 | addopts = --junitxml=testreport.xml --strict -r fEsxXw 6 | 7 | [flake8] 8 | ignore = E501,E721 9 | 10 | [coverage:report] 11 | exclude_lines = 12 | # Have to re-enable the standard pragma 13 | pragma: no cover 14 | def __repr__ 15 | 16 | [mutmut] 17 | runner=python -m hammett -x 18 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import os 3 | import re 4 | import io 5 | import inspect 6 | 7 | from setuptools import setup, find_packages, Command 8 | 9 | 10 | def read_file(name): 11 | with io.open(os.path.join(os.path.dirname(__file__), name), encoding='utf8') as f: 12 | return f.read() 13 | 14 | 15 | def read_reqs(name): 16 | return [line for line in read_file(name).split('\n') if line and not line.strip().startswith('#')] 17 | 18 | 19 | def read_version(): 20 | m = re.search(r'''__version__\s*=\s*['"]([^'"]*)['"]''', read_file('mutmut/__init__.py')) 21 | if m: 22 | return m.group(1) 23 | raise ValueError("couldn't find version") 24 | 25 | 26 | class Tag(Command): 27 | user_options = [] 28 | 29 | def initialize_options(self): 30 | pass 31 | 32 | def finalize_options(self): 33 | pass 34 | 35 | def run(self): 36 | from subprocess import call 37 | version = read_version() 38 | errno = call(['git', 'tag', '--annotate', version, '--message', 'Version {}'.format(version)]) 39 | if errno == 0: 40 | print("Added tag for version {}".format(version)) 41 | raise SystemExit(errno) 42 | 43 | 44 | class ReleaseCheck(Command): 45 | user_options = [] 46 | 47 | def initialize_options(self): 48 | pass 49 | 50 | def finalize_options(self): 51 | pass 52 | 53 | def run(self): 54 | from subprocess import check_output 55 | tag = check_output(['git', 'describe', '--all', '--exact-match', 'HEAD']).strip().decode().split('/')[-1] 56 | version = read_version() 57 | if tag != version: 58 | print('Missing {} tag on release'.format(version)) 59 | raise SystemExit(1) 60 | 61 | current_branch = check_output(['git', 'rev-parse', '--abbrev-ref', 'HEAD']).decode().strip() 62 | if current_branch != 'main': 63 | print('Only release from main') 64 | raise SystemExit(1) 65 | 66 | print("Ok to distribute files") 67 | 68 | running_inside_tests = any('pytest' in x[1] or 'hammett' in x[1] for x in inspect.stack()) 69 | 70 | # NB: _don't_ add namespace_packages to setup(), it'll break 71 | # everything using imp.find_module 72 | setup( 73 | name='mutmut', 74 | version=read_version(), 75 | description='mutation testing for Python 3', 76 | long_description='' if running_inside_tests else read_file('README.rst'), 77 | author='Anders Hovmöller', 78 | author_email='boxed@killingar.net', 79 | url='https://github.com/boxed/mutmut', 80 | packages=find_packages('.'), 81 | package_dir={'': '.'}, 82 | package_data={ 83 | 'mutmut': ['*.tcss'], 84 | }, 85 | include_package_data=True, 86 | license="BSD", 87 | zip_safe=False, 88 | keywords='mutmut mutant mutation test testing', 89 | install_requires=read_reqs('requirements.txt'), 90 | classifiers=[ 91 | 'Development Status :: 4 - Beta', 92 | 'Intended Audience :: Developers', 93 | 'License :: OSI Approved :: BSD License', 94 | 'Natural Language :: English', 95 | 'Programming Language :: Python :: 3', 96 | 'Programming Language :: Python :: 3.7', 97 | ], 98 | test_suite='tests', 99 | cmdclass={ 100 | 'tag': Tag, 101 | 'release_check': ReleaseCheck, 102 | }, 103 | # if I add entry_points while pytest runs, 104 | # it imports before the coverage collecting starts 105 | entry_points={ 106 | 'pytest11': [ 107 | 'mutmut = mutmut.pytestplugin', 108 | ], 109 | } if running_inside_tests else { 110 | 'console_scripts': ["mutmut = mutmut.__main__:cli"], 111 | }, 112 | ) 113 | -------------------------------------------------------------------------------- /test_requirements.txt: -------------------------------------------------------------------------------- 1 | pytest 2 | mock>=2.0.0 3 | coverage 4 | whatthepatch==0.0.6 5 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boxed/mutmut/b124c6ad46b0a9a048b5e47c8b7f93d33a9f2e6a/tests/__init__.py -------------------------------------------------------------------------------- /tests/e2e/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boxed/mutmut/b124c6ad46b0a9a048b5e47c8b7f93d33a9f2e6a/tests/e2e/__init__.py -------------------------------------------------------------------------------- /tests/e2e/snapshots/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "mutants/config_pkg/__init__.py.meta": { 3 | "config_pkg.x_hello__mutmut_1": 1, 4 | "config_pkg.x_hello__mutmut_2": 1, 5 | "config_pkg.x_hello__mutmut_3": 1 6 | }, 7 | "mutants/config_pkg/math.py.meta": { 8 | "config_pkg.math.x_add__mutmut_1": 0, 9 | "config_pkg.math.x_call_depth_two__mutmut_1": 1, 10 | "config_pkg.math.x_call_depth_two__mutmut_2": 1, 11 | "config_pkg.math.x_call_depth_three__mutmut_1": 1, 12 | "config_pkg.math.x_call_depth_three__mutmut_2": 1, 13 | "config_pkg.math.x_call_depth_four__mutmut_1": 33, 14 | "config_pkg.math.x_call_depth_four__mutmut_2": 33, 15 | "config_pkg.math.x_call_depth_five__mutmut_1": 33 16 | } 17 | } -------------------------------------------------------------------------------- /tests/e2e/snapshots/my_lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "mutants/src/my_lib/__init__.py.meta": { 3 | "my_lib.x_hello__mutmut_1": 1, 4 | "my_lib.x_hello__mutmut_2": 1, 5 | "my_lib.x_hello__mutmut_3": 1, 6 | "my_lib.x_badly_tested__mutmut_1": 0, 7 | "my_lib.x_badly_tested__mutmut_2": 0, 8 | "my_lib.x_badly_tested__mutmut_3": 0, 9 | "my_lib.x_untested__mutmut_1": 33, 10 | "my_lib.x_untested__mutmut_2": 33, 11 | "my_lib.x_untested__mutmut_3": 33, 12 | "my_lib.x_make_greeter__mutmut_1": 1, 13 | "my_lib.x_make_greeter__mutmut_2": 1, 14 | "my_lib.x_make_greeter__mutmut_3": 1, 15 | "my_lib.x_make_greeter__mutmut_4": 1, 16 | "my_lib.x_make_greeter__mutmut_5": 0, 17 | "my_lib.x_make_greeter__mutmut_6": 0, 18 | "my_lib.x_make_greeter__mutmut_7": 0, 19 | "my_lib.x_fibonacci__mutmut_1": 1, 20 | "my_lib.x_fibonacci__mutmut_2": 0, 21 | "my_lib.x_fibonacci__mutmut_3": 0, 22 | "my_lib.x_fibonacci__mutmut_4": 0, 23 | "my_lib.x_fibonacci__mutmut_5": 0, 24 | "my_lib.x_fibonacci__mutmut_6": 0, 25 | "my_lib.x_fibonacci__mutmut_7": 0, 26 | "my_lib.x_fibonacci__mutmut_8": 0, 27 | "my_lib.x_fibonacci__mutmut_9": 0, 28 | "my_lib.x\u01c1Point\u01c1__init____mutmut_1": 1, 29 | "my_lib.x\u01c1Point\u01c1__init____mutmut_2": 1, 30 | "my_lib.x\u01c1Point\u01c1abs__mutmut_1": 33, 31 | "my_lib.x\u01c1Point\u01c1abs__mutmut_2": 33, 32 | "my_lib.x\u01c1Point\u01c1abs__mutmut_3": 33, 33 | "my_lib.x\u01c1Point\u01c1abs__mutmut_4": 33, 34 | "my_lib.x\u01c1Point\u01c1abs__mutmut_5": 33, 35 | "my_lib.x\u01c1Point\u01c1abs__mutmut_6": 33, 36 | "my_lib.x\u01c1Point\u01c1add__mutmut_1": 0, 37 | "my_lib.x\u01c1Point\u01c1add__mutmut_2": 1, 38 | "my_lib.x\u01c1Point\u01c1add__mutmut_3": 1, 39 | "my_lib.x\u01c1Point\u01c1add__mutmut_4": 0, 40 | "my_lib.x\u01c1Point\u01c1to_origin__mutmut_1": 1, 41 | "my_lib.x\u01c1Point\u01c1to_origin__mutmut_2": 1, 42 | "my_lib.x\u01c1Point\u01c1to_origin__mutmut_3": 0, 43 | "my_lib.x\u01c1Point\u01c1to_origin__mutmut_4": 0 44 | } 45 | } -------------------------------------------------------------------------------- /tests/e2e/test_cli_version.py: -------------------------------------------------------------------------------- 1 | from mutmut import __version__ 2 | from mutmut.__main__ import cli 3 | from click.testing import CliRunner 4 | 5 | def test_cli_version(): 6 | result = CliRunner().invoke(cli, ["--version"]) 7 | 8 | assert result.exit_code == 0 9 | assert __version__ in result.output 10 | -------------------------------------------------------------------------------- /tests/e2e/test_e2e_result_snapshots.py: -------------------------------------------------------------------------------- 1 | from contextlib import contextmanager 2 | import json 3 | import os 4 | from pathlib import Path 5 | import shutil 6 | from typing import Any 7 | import mutmut 8 | from mutmut.__main__ import _run, walk_source_files, SourceFileMutationData, ensure_config_loaded 9 | 10 | 11 | @contextmanager 12 | def change_cwd(path): 13 | old_cwd = os.path.abspath(os.getcwd()) 14 | os.chdir(path) 15 | try: 16 | yield 17 | finally: 18 | os.chdir(old_cwd) 19 | 20 | 21 | def read_all_stats_for_project(project_path: Path) -> dict[str, dict]: 22 | """Create a single dict from all mutant results in *.meta files""" 23 | with change_cwd(project_path): 24 | ensure_config_loaded() 25 | 26 | stats = {} 27 | for p in walk_source_files(): 28 | if mutmut.config.should_ignore_for_mutation(p): # type: ignore 29 | continue 30 | data = SourceFileMutationData(path=p) 31 | data.load() 32 | stats[str(data.meta_path)] = data.exit_code_by_key 33 | 34 | return stats 35 | 36 | 37 | def read_json_file(path: Path): 38 | with open(path, 'r') as file: 39 | return json.load(file) 40 | 41 | 42 | def write_json_file(path: Path, data: Any): 43 | with open(path, 'w') as file: 44 | json.dump(data, file, indent=2) 45 | 46 | 47 | def asserts_results_did_not_change(project: str): 48 | """Runs mutmut on this project and verifies that the results stay the same for all mutations.""" 49 | project_path = Path("..").parent / "e2e_projects" / project 50 | 51 | mutants_path = project_path / "mutants" 52 | shutil.rmtree(mutants_path, ignore_errors=True) 53 | 54 | # mutmut run 55 | with change_cwd(project_path): 56 | _run([], None) 57 | 58 | results = read_all_stats_for_project(project_path) 59 | 60 | snapshot_path = Path("tests") / "e2e" / "snapshots" / (project + ".json") 61 | 62 | if snapshot_path.exists(): 63 | # compare results against previous snapshot 64 | previous_snapshot = read_json_file(snapshot_path) 65 | 66 | err_msg = f'Mutmut results changed for the E2E project \'{project}\'. If this change was on purpose, delete {snapshot_path} and rerun the tests.' 67 | assert results == previous_snapshot, err_msg 68 | else: 69 | # create the first snapshot 70 | write_json_file(snapshot_path, results) 71 | 72 | 73 | def test_my_lib_result_snapshot(): 74 | mutmut._reset_globals() 75 | asserts_results_did_not_change("my_lib") 76 | 77 | 78 | def test_config_result_snapshot(): 79 | mutmut._reset_globals() 80 | asserts_results_did_not_change("config") 81 | -------------------------------------------------------------------------------- /tests/test_mutation.py: -------------------------------------------------------------------------------- 1 | import os 2 | from unittest.mock import Mock, patch 3 | import pytest 4 | from libcst import parse_statement 5 | 6 | import mutmut 7 | from mutmut.__main__ import ( 8 | CLASS_NAME_SEPARATOR, 9 | get_diff_for_mutant, 10 | orig_function_and_class_names_from_key, 11 | run_forced_fail_test, 12 | Config, 13 | MutmutProgrammaticFailException, 14 | CatchOutput, 15 | ) 16 | from mutmut.trampoline_templates import trampoline_impl, yield_from_trampoline_impl, mangle_function_name 17 | from mutmut.file_mutation import create_mutations, mutate_file_contents, is_generator 18 | 19 | def mutants_for_source(source: str) -> list[str]: 20 | module, mutated_nodes = create_mutations(source) 21 | mutants: list[str] = [] 22 | for m in mutated_nodes: 23 | mutants.append(module.deep_replace(m.original_node, m.mutated_node).code) # type: ignore 24 | 25 | return mutants 26 | 27 | def mutated_module(source: str) -> str: 28 | mutated_code, _ = mutate_file_contents('', source) 29 | return mutated_code 30 | 31 | 32 | @pytest.mark.parametrize( 33 | 'original, expected', [ 34 | ('foo(a, *args, **kwargs)', [ 35 | 'foo(*args, **kwargs)', 36 | 'foo(None, *args, **kwargs)', 37 | 'foo(a, **kwargs)', 38 | 'foo(a, *args, )', 39 | ]), 40 | # ('break', 'continue'), # probably a bad idea. Can introduce infinite loops. 41 | ('break', 'return'), 42 | ('continue', 'break'), 43 | ('a.lower()', 'a.upper()'), 44 | ('a.upper()', 'a.lower()'), 45 | ('a.b.lower()', 'a.b.upper()'), 46 | ('a.b.upper()', 'a.b.lower()'), 47 | ('a.lstrip("!")', ['a.rstrip("!")', 'a.lstrip("XX!XX")', 'a.lstrip(None)']), 48 | ('a.rstrip("!")', ['a.lstrip("!")', 'a.rstrip("XX!XX")', 'a.rstrip(None)']), 49 | ('a.find("!")', ['a.rfind("!")', 'a.find("XX!XX")', 'a.find(None)']), 50 | ('a.rfind("!")', ['a.find("!")', 'a.rfind("XX!XX")', 'a.rfind(None)']), 51 | ('a.ljust(10, "+")', [ 52 | 'a.ljust("+")', 'a.ljust(10, "XX+XX")', 53 | 'a.ljust(10, )', 'a.ljust(10, None)', 54 | 'a.ljust(11, "+")', 'a.ljust(None, "+")', 55 | 'a.rjust(10, "+")' 56 | ]), 57 | ('a.rjust(10, "+")', [ 58 | 'a.ljust(10, "+")', 'a.rjust("+")', 59 | 'a.rjust(10, "XX+XX")', 'a.rjust(10, )', 60 | 'a.rjust(10, None)', 'a.rjust(11, "+")', 61 | 'a.rjust(None, "+")' 62 | ]), 63 | ('a.index("+")', ['a.rindex("+")', 'a.index("XX+XX")', 'a.index(None)']), 64 | ('a.rindex("+")', ['a.index("+")', 'a.rindex("XX+XX")', 'a.rindex(None)']), 65 | ('a.split()', 'a.rsplit()'), 66 | ('a.rsplit()', 'a.split()'), 67 | ('a.removeprefix("+")', ['a.removesuffix("+")', 'a.removeprefix("XX+XX")', 'a.removeprefix(None)']), 68 | ('a.removesuffix("+")', ['a.removeprefix("+")', 'a.removesuffix("XX+XX")', 'a.removesuffix(None)']), 69 | ('a.partition("++")', ['a.rpartition("++")', 'a.partition("XX++XX")', 'a.partition(None)']), 70 | ('a.rpartition("++")', ['a.partition("++")', 'a.rpartition("XX++XX")', 'a.rpartition(None)']), 71 | ('a(b)', 'a(None)'), 72 | ("dict(a=None)", ["dict(aXX=None)"]), 73 | ("dict(a=b)", ["dict(aXX=b)", 'dict(a=None)']), 74 | ('lambda **kwargs: Variable.integer(**setdefaults(kwargs, dict(show=False)))', [ 75 | 'lambda **kwargs: Variable.integer(**setdefaults(kwargs, dict(show=True)))', 76 | 'lambda **kwargs: Variable.integer(**setdefaults(kwargs, dict(show=None)))', 77 | 'lambda **kwargs: Variable.integer(**setdefaults(kwargs, dict(showXX=False)))', 78 | 'lambda **kwargs: Variable.integer(**setdefaults(None, dict(show=False)))', 79 | 'lambda **kwargs: Variable.integer(**setdefaults(kwargs, None))', 80 | 'lambda **kwargs: Variable.integer(**setdefaults(kwargs, ))', 81 | 'lambda **kwargs: Variable.integer(**setdefaults(dict(show=False)))', 82 | # TODO: this mutant would exist if we also mutate single-arg arglists (see implentation) 83 | # 'lambda **kwargs: Variable.integer()', 84 | 'lambda **kwargs: None', 85 | ]), 86 | ('x: list[A | None]', []), 87 | ('a: Optional[int] = None', 'a: Optional[int] = ""'), 88 | ('a: int = 1', ['a: int = 2', 'a: int = None']), 89 | ('a: str = "FoO"', ['a: str = "XXFoOXX"', 'a: str = "foo"', 'a: str = "FOO"', 'a: str = "Foo"', 'a: str = None']), 90 | ('lambda: 0', ['lambda: 1', 'lambda: None']), 91 | ("1 in (1, 2)", ['2 in (1, 2)', '1 not in (1, 2)', '1 in (2, 2)', '1 in (1, 3)']), 92 | ('1+1', ['2+1', '1 - 1', '1+2']), 93 | ('1', '2'), 94 | ('1-1', ['2-1', '1 + 1', '1-2']), 95 | ('1*1', ['2*1', '1 / 1', '1*2']), 96 | ('1/1', ['2/1', '1 * 1', '1/2']), 97 | ('1//1', ['2//1', '1 / 1', '1//2']), 98 | ('1%1', ['2%1', '1 / 1', '1%2']), 99 | ('1<<1', ['2<<1', '1 >> 1', '1<<2']), 100 | ('1>>1', ['2>>1', '1 << 1', '1>>2']), 101 | ('a&b', ['a | b']), 102 | ('a|b', ['a & b']), 103 | ('a^b', ['a & b']), 104 | ('a**b', ['a * b']), 105 | ('~a', ['a']), 106 | # ('1.0', '1.0000000000000002'), # using numpy features 107 | ('1.0', '2.0'), 108 | ('0.1', '1.1'), 109 | ('1e-3', '1.001'), 110 | ('True', 'False'), 111 | ('False', 'True'), 112 | ('"FoO"', ['"XXFoOXX"', '"foo"', '"FOO"', '"Foo"']), 113 | ("'FoO'", ["'XXFoOXX'", "'foo'", "'FOO'", "'Foo'"]), 114 | ("u'FoO'", ["u'XXFoOXX'", "u'foo'", "u'FOO'", "u'Foo'"]), 115 | ("10", "11"), 116 | ("10.", "11.0"), 117 | ("0o10", "9"), 118 | ("0x10", "17"), 119 | ("0b10", "3"), 120 | ("1<2", ['2<2', '1 <= 2', '1<3']), 121 | ('(1, 2)', ['(2, 2)', '(1, 3)']), 122 | ("1 not in (1, 2)", ['2 not in (1, 2)', '1 in (1, 2)', '1 not in (2, 2)', '1 not in (1, 3)']), # two spaces here because "not in" is two words 123 | ("foo is foo", "foo is not foo"), 124 | ("foo is not foo", "foo is foo"), 125 | ('a or b', 'a and b'), 126 | ('a and b', 'a or b'), 127 | ('not a', 'a'), 128 | ('a < b', ['a <= b']), 129 | ('a <= b', ['a < b']), 130 | ('a > b', ['a >= b']), 131 | ('a >= b', ['a > b']), 132 | ('a == b', ['a != b']), 133 | ('a != b', ['a == b']), 134 | ('a = b', 'a = None'), 135 | ('a = b = c = x', 'a = b = c = None'), 136 | 137 | # subscript 138 | ('a[None]', []), 139 | ('a[b]', []), 140 | ('s[0]', ['s[1]']), 141 | ('s[0] = a', ['s[1] = a', 's[0] = None']), 142 | ('s[1:]', ['s[2:]']), 143 | ('s[1:2]', ['s[2:2]', 's[1:3]']), 144 | 145 | ('1j', '2j'), 146 | ('1.0j', '2j'), 147 | ('0o1', '2'), 148 | ('1.0e10', '10000000001.0'), 149 | ('a = {x for x in y}', 'a = None'), 150 | ('x+=1', ['x = 1', 'x -= 1', 'x+=2']), 151 | ('x-=1', ['x = 1', 'x += 1', 'x-=2']), 152 | ('x*=1', ['x = 1', 'x /= 1', 'x*=2']), 153 | ('x/=1', ['x = 1', 'x *= 1', 'x/=2']), 154 | ('x//=1', ['x = 1', 'x /= 1', 'x//=2']), 155 | ('x%=1', ['x = 1', 'x /= 1', 'x%=2']), 156 | ('x<<=1', ['x = 1', 'x >>= 1', 'x<<=2']), 157 | ('x>>=1', ['x = 1', 'x <<= 1', 'x>>=2']), 158 | ('x&=1', ['x = 1', 'x |= 1', 'x&=2']), 159 | ('x|=1', ['x = 1', 'x &= 1', 'x|=2']), 160 | ('x^=1', ['x = 1', 'x &= 1', 'x^=2']), 161 | ('x**=1', ['x = 1', 'x *= 1', 'x**=2']), 162 | ('def foo(s: Int = 1): pass', 'def foo(s: Int = 2): pass'), 163 | # mutating default args with function calls could cause Exceptions at import time 164 | ('def foo(a = A("abc")): pass', []), 165 | ('a = None', 'a = ""'), 166 | ('lambda **kwargs: None', 'lambda **kwargs: 0'), 167 | ('lambda: None', 'lambda: 0'), 168 | ('def foo(s: str): pass', []), 169 | ('def foo(a, *, b): pass', []), 170 | ('a(None)', []), 171 | ("'''foo'''", []), # don't mutate things we assume to be docstrings 172 | ("r'''foo'''", []), # don't mutate things we assume to be docstrings 173 | ('"""foo"""', []), # don't mutate things we assume to be docstrings 174 | ('(x for x in [])', []), # don't mutate 'in' in generators 175 | ('from foo import *', []), 176 | ('from .foo import *', []), 177 | ('import foo', []), 178 | ('import foo as bar', []), 179 | ('foo.bar', []), 180 | ('for x in y: pass', []), 181 | ('def foo(a, *args, **kwargs): pass', []), 182 | ('import foo', []), 183 | ('isinstance(a, b)', []), 184 | ('len(a)', []), 185 | ('deepcopy(obj)', ['copy(obj)', 'deepcopy(None)']), 186 | ] 187 | ) 188 | def test_basic_mutations(original, expected): 189 | if isinstance(expected, str): 190 | expected = [expected] 191 | 192 | mutants = mutants_for_source(original) 193 | 194 | assert sorted(mutants) == sorted(expected) 195 | 196 | 197 | def test_do_not_mutate_annotations(): 198 | source = """ 199 | def foo() -> int: 200 | bar: Optional[int] 201 | return 202 | """.strip() 203 | 204 | mutants = mutants_for_source(source) 205 | for m in mutants: 206 | print(m) # pragma: no cover 207 | 208 | assert not mutants 209 | 210 | def test_do_not_mutate_specific_functions(): 211 | source = """ 212 | class A: 213 | def __new__(): 214 | return 1 + 2 215 | 216 | def __getattribute__(): 217 | return 1 + 2 218 | 219 | def __setattr__(): 220 | return 1 + 2 221 | """.strip() 222 | 223 | mutants = mutants_for_source(source) 224 | for m in mutants: 225 | print(m) # pragma: no cover 226 | 227 | assert not mutants 228 | 229 | def test_match_case(): 230 | source = """ 231 | match x: 232 | case Point(x=1): return 1 233 | case _: return 2""".strip() 234 | 235 | mutants = mutants_for_source(source) 236 | 237 | expected = [ 238 | """match x:\n case Point(x=1): return 1""", 239 | """match x:\n case _: return 2""", 240 | """match x:\n case Point(x=2): return 1\n case _: return 2""", 241 | """match x:\n case Point(x=1): return 2\n case _: return 2""", 242 | """match x:\n case Point(x=1): return 1\n case _: return 3""", 243 | ] 244 | 245 | assert sorted(mutants) == sorted(expected) 246 | 247 | def test_basic_class(): 248 | source = """ 249 | class Foo: 250 | def member(self): 251 | return 1 252 | """.strip() 253 | 254 | mutated_code = mutated_module(source) 255 | 256 | expected = """class Foo: 257 | def xǁFooǁmember__mutmut_orig(self): 258 | return 1 259 | def xǁFooǁmember__mutmut_1(self): 260 | return 2""" 261 | 262 | assert expected in mutated_code 263 | 264 | 265 | def test_function_with_annotation(): 266 | source = "def capitalize(s : str):\n return s[0].title() + s[1:] if s else s\n".strip() 267 | 268 | mutated_code = mutated_module(source) 269 | print(mutated_code) 270 | 271 | expected_defs = [ 272 | 'def x_capitalize__mutmut_1(s : str):\n return s[1].title() + s[1:] if s else s', 273 | 'def x_capitalize__mutmut_2(s : str):\n return s[0].title() - s[1:] if s else s', 274 | 'def x_capitalize__mutmut_3(s : str):\n return s[0].title() + s[2:] if s else s', 275 | ] 276 | 277 | for expected in expected_defs: 278 | print(expected) 279 | assert expected in mutated_code 280 | 281 | 282 | def test_pragma_no_mutate(): 283 | source = """def foo():\n return 1+1 # pragma: no mutate\n""".strip() 284 | mutants = mutants_for_source(source) 285 | assert not mutants 286 | 287 | 288 | def test_pragma_no_mutate_and_no_cover(): 289 | source = """def foo():\n return 1+1 # pragma: no cover, no mutate\n""".strip() 290 | mutants = mutants_for_source(source) 291 | assert not mutants 292 | 293 | def test_pragma_no_mutate_on_function_definition(): 294 | source = """ 295 | def foo(): # pragma: no mutate 296 | return 1+1""" 297 | mutants = mutants_for_source(source) 298 | assert mutants 299 | 300 | 301 | def test_mutate_dict(): 302 | source = 'dict(a=b, c=d)' 303 | 304 | mutants = mutants_for_source(source) 305 | 306 | expected = [ 307 | 'dict(a=None, c=d)', 308 | 'dict(aXX=b, c=d)', 309 | 'dict(a=b, c=None)', 310 | 'dict(a=b, cXX=d)', 311 | 'dict(c=d)', 312 | 'dict(a=b, )', 313 | ] 314 | 315 | assert sorted(mutants) == sorted(expected) 316 | 317 | 318 | def test_syntax_warning(): 319 | with pytest.warns(SyntaxWarning) as record: 320 | mutate_file_contents('some_file.py', ':!') 321 | 322 | assert len(record) == 1 323 | assert 'some_file.py' in record[0].message.args[0] # type: ignore 324 | 325 | 326 | def test_bug_github_issue_18(): 327 | source = """ 328 | @register.simple_tag(name='icon') 329 | def icon(name): 330 | if name is None: 331 | return '' 332 | tpl = '' 333 | return format_html(tpl, name) 334 | """.strip() 335 | mutants_for_source(source) 336 | 337 | 338 | def test_bug_github_issue_19_argument_mutation_crash(): 339 | source = """key = lambda a: "foo" 340 | filters = dict((key(field), False) for field in fields)""" 341 | mutants_for_source(source) 342 | 343 | 344 | def test_bug_github_issue_26(): 345 | source = """ 346 | def wrapper(): 347 | class ConfigurationOptions(Protocol): 348 | min_name_length: int 349 | """.strip() 350 | assert mutants_for_source(source) == [] 351 | 352 | 353 | def test_bug_github_issue_30(): 354 | source = """ 355 | def wrapper(): 356 | def from_checker(cls: Type['BaseVisitor'], checker) -> 'BaseVisitor': 357 | pass 358 | """.strip() 359 | assert mutants_for_source(source) == [] 360 | 361 | 362 | def test_bug_github_issue_77(): 363 | # Don't crash on this 364 | assert mutants_for_source('') == [] 365 | 366 | 367 | def test_multiline_dunder_whitelist(): 368 | source = """ 369 | __all__ = [ 370 | 1, 371 | 2, 372 | 'foo', 373 | 'bar', 374 | ] 375 | """.strip() 376 | mutated_code = mutated_module(source) 377 | assert source in mutated_code 378 | 379 | 380 | def test_bad_mutation_str_type_definition(): 381 | source = """ 382 | def foo(): 383 | foo: 'SomeType' 384 | """.strip() 385 | mutants = mutants_for_source(source) 386 | assert not mutants 387 | 388 | 389 | def test_orig_function_name_from_key(): 390 | assert orig_function_and_class_names_from_key(f'_{CLASS_NAME_SEPARATOR}Foo{CLASS_NAME_SEPARATOR}bar__mutmut_1') == ('bar', 'Foo') 391 | assert orig_function_and_class_names_from_key('x_bar__mutmut_1') == ('bar', None) 392 | 393 | 394 | def test_mangle_function_name(): 395 | assert mangle_function_name(name='bar', class_name=None) == 'x_bar' 396 | assert mangle_function_name(name='bar', class_name='Foo') == f'x{CLASS_NAME_SEPARATOR}Foo{CLASS_NAME_SEPARATOR}bar' 397 | 398 | 399 | def test_diff_ops(): 400 | source = """ 401 | def foo(): 402 | return 1 403 | 404 | 405 | class Foo: 406 | def member(self): 407 | return 3 408 | 409 | """.strip() 410 | 411 | mutants_source, mutant_names = mutate_file_contents('filename', source) 412 | assert len(mutant_names) == 2 413 | 414 | diff1 = get_diff_for_mutant(mutant_name=mutant_names[0], source=mutants_source, path='test.py').strip() 415 | diff2 = get_diff_for_mutant(mutant_name=mutant_names[1], source=mutants_source, path='test.py').strip() 416 | 417 | assert diff1 == ''' 418 | --- test.py 419 | +++ test.py 420 | @@ -1,2 +1,2 @@ 421 | def foo(): 422 | - return 1 423 | + return 2 424 | '''.strip() 425 | 426 | assert diff2 == ''' 427 | --- test.py 428 | +++ test.py 429 | @@ -1,2 +1,2 @@ 430 | def member(self): 431 | - return 3 432 | + return 4 433 | '''.strip() 434 | 435 | 436 | def test_from_future_still_first(): 437 | source = """ 438 | from __future__ import annotations 439 | from collections.abc import Iterable 440 | 441 | def foo(): 442 | return 1 443 | """.strip() 444 | mutated_source = mutated_module(source) 445 | assert mutated_source.split('\n')[0] == 'from __future__ import annotations' 446 | assert mutated_source.count('from __future__') == 1 447 | 448 | def test_from_future_with_docstring_still_first(): 449 | source = """ 450 | '''This documents the module''' 451 | from __future__ import annotations 452 | from collections.abc import Iterable 453 | 454 | def foo(): 455 | return 1 456 | """.strip() 457 | mutated_source = mutated_module(source) 458 | assert mutated_source.split('\n')[0] == "'''This documents the module'''" 459 | assert mutated_source.split('\n')[1] == 'from __future__ import annotations' 460 | assert mutated_source.count('from __future__') == 1 461 | 462 | 463 | def test_preserve_generators(): 464 | source = ''' 465 | def foo(): 466 | yield 1 467 | '''.strip() 468 | mutated_source = mutated_module(source) 469 | assert 'yield from _mutmut_yield_from_trampoline' in mutated_source 470 | 471 | 472 | def test_is_generator(): 473 | source = ''' 474 | def foo(): 475 | yield 1 476 | '''.strip() 477 | assert is_generator(parse_statement(source)) # type: ignore 478 | 479 | source = ''' 480 | def foo(): 481 | yield from bar() 482 | '''.strip() 483 | assert is_generator(parse_statement(source)) # type: ignore 484 | 485 | source = ''' 486 | def foo(): 487 | return 1 488 | '''.strip() 489 | assert not is_generator(parse_statement(source)) # type: ignore 490 | 491 | source = ''' 492 | def foo(): 493 | def bar(): 494 | yield 2 495 | return 1 496 | '''.strip() 497 | assert not is_generator(parse_statement(source)) # type: ignore 498 | 499 | 500 | # Negate the effects of CatchOutput because it does not play nicely with capfd in GitHub Actions 501 | @patch.object(CatchOutput, 'dump_output') 502 | @patch.object(CatchOutput, 'stop') 503 | @patch.object(CatchOutput, 'start') 504 | def test_run_forced_fail_test_with_failing_test(_start, _stop, _dump_output, capfd): 505 | mutmut._reset_globals() 506 | runner = _mocked_runner_run_forced_failed(return_value=1) 507 | 508 | run_forced_fail_test(runner) 509 | 510 | out, err = capfd.readouterr() 511 | 512 | print() 513 | print(f"out: {out}") 514 | print(f"err: {err}") 515 | assert 'done' in out 516 | assert os.environ['MUTANT_UNDER_TEST'] is '' 517 | 518 | 519 | # Negate the effects of CatchOutput because it does not play nicely with capfd in GitHub Actions 520 | @patch.object(CatchOutput, 'dump_output') 521 | @patch.object(CatchOutput, 'stop') 522 | @patch.object(CatchOutput, 'start') 523 | def test_run_forced_fail_test_with_mutmut_programmatic_fail_exception(_start, _stop, _dump_output, capfd): 524 | mutmut._reset_globals() 525 | runner = _mocked_runner_run_forced_failed(side_effect=MutmutProgrammaticFailException()) 526 | 527 | run_forced_fail_test(runner) 528 | 529 | out, err = capfd.readouterr() 530 | assert 'done' in out 531 | assert os.environ['MUTANT_UNDER_TEST'] is '' 532 | 533 | 534 | # Negate the effects of CatchOutput because it does not play nicely with capfd in GitHub Actions 535 | @patch.object(CatchOutput, 'dump_output') 536 | @patch.object(CatchOutput, 'stop') 537 | @patch.object(CatchOutput, 'start') 538 | def test_run_forced_fail_test_with_all_tests_passing(_start, _stop, _dump_output, capfd): 539 | mutmut._reset_globals() 540 | runner = _mocked_runner_run_forced_failed(return_value=0) 541 | 542 | with pytest.raises(SystemExit) as error: 543 | run_forced_fail_test(runner) 544 | 545 | assert error.value.code is 1 546 | out, err = capfd.readouterr() 547 | assert 'FAILED: Unable to force test failures' in out 548 | 549 | 550 | def _mocked_runner_run_forced_failed(return_value=None, side_effect=None): 551 | runner = Mock() 552 | runner.run_forced_fail = Mock( 553 | return_value=return_value, 554 | side_effect=side_effect 555 | ) 556 | return runner 557 | 558 | def test_do_not_mutate_top_level_decorators(): 559 | # Modifying top-level decorators could influence all mutations 560 | # because they are executed at import time 561 | source = """ 562 | @some_decorator(a = 2) 563 | def foo(): 564 | x = 1 + 2 565 | return x 566 | 567 | @unique 568 | class A(Enum): 569 | @property 570 | def x(self): 571 | return 1 + 2 572 | """.strip() 573 | 574 | mutants = mutants_for_source(source) 575 | for m in mutants: 576 | print(m) # pragma: no cover 577 | 578 | assert not mutants 579 | 580 | # TODO: implement removal of inner decorators 581 | @pytest.mark.skip 582 | def test_decorated_inner_functions_mutation(): 583 | source = """ 584 | def foo(): 585 | @decorator 586 | def inner(): 587 | pass""".strip() 588 | 589 | expected = """ 590 | def x_foo__mutmut_1(): 591 | 592 | def inner(): 593 | pass""".strip() 594 | 595 | mutants = mutants_for_source(source) 596 | assert mutants == [expected] 597 | 598 | 599 | def test_module_mutation(): 600 | source = """from __future__ import division 601 | import lib 602 | 603 | lib.foo() 604 | 605 | def foo(a, b): 606 | return a > b 607 | 608 | def bar(): 609 | yield 1 610 | 611 | class Adder: 612 | def __init__(self, amount): 613 | self.amount = amount 614 | 615 | def add(self, value): 616 | return self.amount + value 617 | 618 | print(Adder(1).add(2))""" 619 | 620 | src, _ = mutate_file_contents("file.py", source) 621 | 622 | assert src == f"""from __future__ import division 623 | import lib 624 | 625 | lib.foo() 626 | {trampoline_impl.strip()} 627 | {yield_from_trampoline_impl.strip()} 628 | 629 | def x_foo__mutmut_orig(a, b): 630 | return a > b 631 | 632 | def x_foo__mutmut_1(a, b): 633 | return a >= b 634 | 635 | x_foo__mutmut_mutants : ClassVar[MutantDict] = {{ 636 | 'x_foo__mutmut_1': x_foo__mutmut_1 637 | }} 638 | 639 | def foo(*args, **kwargs): 640 | result = _mutmut_trampoline(x_foo__mutmut_orig, x_foo__mutmut_mutants, args, kwargs) 641 | return result 642 | 643 | foo.__signature__ = _mutmut_signature(x_foo__mutmut_orig) 644 | x_foo__mutmut_orig.__name__ = 'x_foo' 645 | 646 | def x_bar__mutmut_orig(): 647 | yield 1 648 | 649 | def x_bar__mutmut_1(): 650 | yield 2 651 | 652 | x_bar__mutmut_mutants : ClassVar[MutantDict] = {{ 653 | 'x_bar__mutmut_1': x_bar__mutmut_1 654 | }} 655 | 656 | def bar(*args, **kwargs): 657 | result = yield from _mutmut_yield_from_trampoline(x_bar__mutmut_orig, x_bar__mutmut_mutants, args, kwargs) 658 | return result 659 | 660 | bar.__signature__ = _mutmut_signature(x_bar__mutmut_orig) 661 | x_bar__mutmut_orig.__name__ = 'x_bar' 662 | 663 | class Adder: 664 | def xǁAdderǁ__init____mutmut_orig(self, amount): 665 | self.amount = amount 666 | def xǁAdderǁ__init____mutmut_1(self, amount): 667 | self.amount = None 668 | 669 | xǁAdderǁ__init____mutmut_mutants : ClassVar[MutantDict] = {{ 670 | 'xǁAdderǁ__init____mutmut_1': xǁAdderǁ__init____mutmut_1 671 | }} 672 | 673 | def __init__(self, *args, **kwargs): 674 | result = _mutmut_trampoline(object.__getattribute__(self, "xǁAdderǁ__init____mutmut_orig"), object.__getattribute__(self, "xǁAdderǁ__init____mutmut_mutants"), args, kwargs, self) 675 | return result 676 | 677 | __init__.__signature__ = _mutmut_signature(xǁAdderǁ__init____mutmut_orig) 678 | xǁAdderǁ__init____mutmut_orig.__name__ = 'xǁAdderǁ__init__' 679 | 680 | def xǁAdderǁadd__mutmut_orig(self, value): 681 | return self.amount + value 682 | 683 | def xǁAdderǁadd__mutmut_1(self, value): 684 | return self.amount - value 685 | 686 | xǁAdderǁadd__mutmut_mutants : ClassVar[MutantDict] = {{ 687 | 'xǁAdderǁadd__mutmut_1': xǁAdderǁadd__mutmut_1 688 | }} 689 | 690 | def add(self, *args, **kwargs): 691 | result = _mutmut_trampoline(object.__getattribute__(self, "xǁAdderǁadd__mutmut_orig"), object.__getattribute__(self, "xǁAdderǁadd__mutmut_mutants"), args, kwargs, self) 692 | return result 693 | 694 | add.__signature__ = _mutmut_signature(xǁAdderǁadd__mutmut_orig) 695 | xǁAdderǁadd__mutmut_orig.__name__ = 'xǁAdderǁadd' 696 | 697 | print(Adder(1).add(2))""" 698 | -------------------------------------------------------------------------------- /tests/test_mutmut3.py: -------------------------------------------------------------------------------- 1 | from mutmut.trampoline_templates import ( 2 | trampoline_impl, 3 | yield_from_trampoline_impl, 4 | ) 5 | from mutmut.file_mutation import mutate_file_contents 6 | 7 | def mutated_module(source: str) -> str: 8 | mutated_code, _ = mutate_file_contents('', source) 9 | return mutated_code 10 | 11 | 12 | def test_mutate_file_contents(): 13 | source = """ 14 | a + 1 15 | 16 | def foo(a, b, c): 17 | return a + b * c 18 | """ 19 | trampolines = trampoline_impl.removesuffix('\n\n') + yield_from_trampoline_impl.removesuffix('\n\n') 20 | 21 | expected = f""" 22 | a + 1{trampolines} 23 | 24 | def x_foo__mutmut_orig(a, b, c): 25 | return a + b * c 26 | 27 | def x_foo__mutmut_1(a, b, c): 28 | return a - b * c 29 | 30 | def x_foo__mutmut_2(a, b, c): 31 | return a + b / c 32 | 33 | x_foo__mutmut_mutants : ClassVar[MutantDict] = {{ 34 | 'x_foo__mutmut_1': x_foo__mutmut_1, 35 | 'x_foo__mutmut_2': x_foo__mutmut_2 36 | }} 37 | 38 | def foo(*args, **kwargs): 39 | result = _mutmut_trampoline(x_foo__mutmut_orig, x_foo__mutmut_mutants, args, kwargs) 40 | return result 41 | 42 | foo.__signature__ = _mutmut_signature(x_foo__mutmut_orig) 43 | x_foo__mutmut_orig.__name__ = 'x_foo' 44 | """ 45 | 46 | result = mutated_module(source) 47 | 48 | assert result == expected 49 | 50 | 51 | def test_avoid_annotations(): 52 | source = """ 53 | def foo(a: List[int]) -> int: 54 | return 1 55 | """ 56 | 57 | expected = trampoline_impl.removesuffix('\n\n') + yield_from_trampoline_impl.removesuffix('\n\n') + """ 58 | def x_foo__mutmut_orig(a: List[int]) -> int: 59 | return 1 60 | def x_foo__mutmut_1(a: List[int]) -> int: 61 | return 2 62 | 63 | x_foo__mutmut_mutants : ClassVar[MutantDict] = { 64 | 'x_foo__mutmut_1': x_foo__mutmut_1 65 | } 66 | 67 | def foo(*args, **kwargs): 68 | result = _mutmut_trampoline(x_foo__mutmut_orig, x_foo__mutmut_mutants, args, kwargs) 69 | return result 70 | 71 | foo.__signature__ = _mutmut_signature(x_foo__mutmut_orig) 72 | x_foo__mutmut_orig.__name__ = 'x_foo' 73 | """ 74 | 75 | result = mutated_module(source) 76 | 77 | assert result == expected 78 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py37 3 | 4 | [testenv] 5 | commands = {envpython} -m hammett {posargs} 6 | usedevelop = True 7 | setenv = LANG=en_US.UTF-8 8 | deps = 9 | -rrequirements.txt 10 | -rtest_requirements.txt 11 | 12 | [testenv:coverage] 13 | basepython = python3.7 14 | usedevelop = True 15 | commands = 16 | {envpython} -m pytest --cov {posargs} 17 | {envpython} -m coverage report -m 18 | {envpython} -m coverage html 19 | deps = 20 | -rrequirements.txt 21 | -rtest_requirements.txt 22 | 23 | [testenv:lint] 24 | basepython = python3.7 25 | usedevelop = True 26 | commands = 27 | {envpython} -m flake8 mutmut tests setup.py {posargs} 28 | deps = 29 | flake8 30 | 31 | [testenv:venv] 32 | envdir = venv 33 | usedevelop = True 34 | basepython = python3.7 35 | commands = {posargs:python --version} 36 | deps = 37 | -rrequirements.txt 38 | -rtest_requirements.txt 39 | whitelist_externals = 40 | make 41 | --------------------------------------------------------------------------------