From 2c19db38ea2b6b46d07edde9f7d6cf52f99ad6df Mon Sep 17 00:00:00 2001 From: A_A <21040751+Otto-AA@users.noreply.github.com> Date: Sat, 14 Feb 2026 10:23:52 +0100 Subject: [PATCH 1/6] Filter mutants with type checking --- README.rst | 20 ++ e2e_projects/type_checking/.gitignore | 0 e2e_projects/type_checking/README.md | 1 + e2e_projects/type_checking/pyproject.toml | 32 +++ .../src/type_checking/__init__.py | 23 ++ .../type_checking/tests/test_type_checking.py | 10 + e2e_projects/type_checking/uv.lock | 200 ++++++++++++++++++ pyproject.toml | 2 + src/mutmut/__main__.py | 171 ++++++++++++--- src/mutmut/file_mutation.py | 15 +- src/mutmut/trampoline_templates.py | 44 ++-- src/mutmut/type_checking.py | 98 +++++++++ tests/e2e/test_e2e_type_checking.py | 24 +++ tests/test_mutation regression.py | 104 ++++----- tests/test_mutation.py | 140 +----------- uv.lock | 42 ++++ 16 files changed, 685 insertions(+), 241 deletions(-) create mode 100644 e2e_projects/type_checking/.gitignore create mode 100644 e2e_projects/type_checking/README.md create mode 100644 e2e_projects/type_checking/pyproject.toml create mode 100644 e2e_projects/type_checking/src/type_checking/__init__.py create mode 100644 e2e_projects/type_checking/tests/test_type_checking.py create mode 100644 e2e_projects/type_checking/uv.lock create mode 100644 src/mutmut/type_checking.py create mode 100644 tests/e2e/test_e2e_type_checking.py diff --git a/README.rst b/README.rst index e86af6d5..6e8c6bd3 100644 --- a/README.rst +++ b/README.rst @@ -167,6 +167,26 @@ If you only want to mutate lines that are called (according to coverage.py), you mutate_only_covered_lines=true +Filter generated mutants with type checker +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +When your project is type checked, you can also use it to filter out invalid mutants. +For instance, mutmut mutates `x: str = 'foo'` to `x: str = None` which can easily caught by type checkers. + +To enable this filtering, configure the `type_check_command` to output json results as follows: + +.. code-block:: + + # for pyrefly + type_check_command = ['pyrefly', 'check', '--output-format=json'] + # for mypy + type_check_command = ['mypy', 'traces_parser', '--output', 'json'] + +Currently, only `pyrefly` and `mypy` are supported. +With `pyright` and `ty`, mutating a class method `Foo.bar()` can break the types of all methods of `Foo`, +and therefore mutmut cannot match the type error with the mutant that caused the type error. + + Enable debug output (increase verbosity) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/e2e_projects/type_checking/.gitignore b/e2e_projects/type_checking/.gitignore new file mode 100644 index 00000000..e69de29b diff --git a/e2e_projects/type_checking/README.md b/e2e_projects/type_checking/README.md new file mode 100644 index 00000000..efdcc6cf --- /dev/null +++ b/e2e_projects/type_checking/README.md @@ -0,0 +1 @@ +This project uses type checking to detect invalid mutants. \ No newline at end of file diff --git a/e2e_projects/type_checking/pyproject.toml b/e2e_projects/type_checking/pyproject.toml new file mode 100644 index 00000000..4603161a --- /dev/null +++ b/e2e_projects/type_checking/pyproject.toml @@ -0,0 +1,32 @@ +[project] +name = "type-checking" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +authors = [] +requires-python = ">=3.10" +dependencies = [] + +[build-system] +requires = ["uv_build>=0.9.18,<0.10.0"] +build-backend = "uv_build" + +[dependency-groups] +dev = [ + "pyrefly>=0.52.0", + "pyright>=1.1.408", + "pytest>=8.2.0", +] + +[tool.mutmut] +debug = true +type_check_command = ["pyrefly", "check", "--output-format=json"] + +[tool.pyrefly] +project-includes = [ + "**/*.py*", + "**/*.ipynb", +] + +[tool.pyright] +typeCheckingMode = "strict" \ No newline at end of file diff --git a/e2e_projects/type_checking/src/type_checking/__init__.py b/e2e_projects/type_checking/src/type_checking/__init__.py new file mode 100644 index 00000000..cb280488 --- /dev/null +++ b/e2e_projects/type_checking/src/type_checking/__init__.py @@ -0,0 +1,23 @@ +def hello() -> str: + greeting: str = "Hello from type-checking!" + return greeting + +def a_hello_wrapper() -> str: + # verify that hello() keeps the return type str + # (if not, this will type error and not be mutated) + return hello() + "2" + +class Person: + def set_name(self, name: str): + self.name = name + + def get_name(self): + # return type should be inferred as "str" + return self.name + +def mutate_me(): + p = Person() + p.set_name('charlie') + # Verify that p.get_name keeps the return type str + name: str = p.get_name() + return name diff --git a/e2e_projects/type_checking/tests/test_type_checking.py b/e2e_projects/type_checking/tests/test_type_checking.py new file mode 100644 index 00000000..ed5af874 --- /dev/null +++ b/e2e_projects/type_checking/tests/test_type_checking.py @@ -0,0 +1,10 @@ +from type_checking import * + +def test_hello(): + assert hello() == "Hello from type-checking!" + +def test_a_hello_wrapper(): + assert isinstance(a_hello_wrapper(), str) + +def test_mutate_me(): + assert mutate_me() == "charlie" \ No newline at end of file diff --git a/e2e_projects/type_checking/uv.lock b/e2e_projects/type_checking/uv.lock new file mode 100644 index 00000000..7b10a104 --- /dev/null +++ b/e2e_projects/type_checking/uv.lock @@ -0,0 +1,200 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pyrefly" +version = "0.52.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/93/bc/a65b3f8a04b941121868c07f1e65db223c1a101b6adf0ff3db5240ad24ea/pyrefly-0.52.0.tar.gz", hash = "sha256:abe022b68e67a2fd9adad4f8fe2deced2a786df32601b0eecbb00b40ea1f3b93", size = 4967100, upload-time = "2026-02-09T15:30:03.745Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/32/74a3b3ed6b38fef8aba3437e02824bf670b017123126bb83597c0aa42e98/pyrefly-0.52.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:90d7bf2fb812ee3a920a962da2aa2387e2f4109c62604e5be1a736046a3260c7", size = 11773462, upload-time = "2026-02-09T15:29:44.995Z" }, + { url = "https://files.pythonhosted.org/packages/31/d4/efb4aecca57bc42871b3004af04324e637057902417d89757c4077474b98/pyrefly-0.52.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:848764fdbc474fd36412d7ccf230d13a12ab3b2c28968124d9e9d51df79b7b8e", size = 11355651, upload-time = "2026-02-09T15:29:46.992Z" }, + { url = "https://files.pythonhosted.org/packages/d8/b9/80e0becaaafe0ca55b06868e942aa7f68a42644a156fdc7bedf2ae851d65/pyrefly-0.52.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:43b712830df1247798fb79f478a236b0ffbe5983bdde5eb2f5b99a9411e09f35", size = 31906389, upload-time = "2026-02-09T15:29:49.138Z" }, + { url = "https://files.pythonhosted.org/packages/44/78/f6ff1e9c86eebad5feef97301789bb9ef22a5816931809cbb063e5e6acb9/pyrefly-0.52.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:baa4130c460ad7c8d7efcff9017b7bc74c71736c5959ebfc2b7e405c2ce07d5d", size = 34292755, upload-time = "2026-02-09T15:29:52.12Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d4/5798fbec917aa2481de9ed4dc550824383b115c67b57be2ca6da43a91850/pyrefly-0.52.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3297751b1b13ecb582af48c8798e0b652c41c33a7e4ed72676164b29561655f6", size = 36943447, upload-time = "2026-02-09T15:29:54.858Z" }, + { url = "https://files.pythonhosted.org/packages/67/91/963f6afb1cc0fd020f925137d64f437b777fd31907ac34589e9a9f949069/pyrefly-0.52.0-py3-none-win32.whl", hash = "sha256:d24ed11ef5eab93625df0bb4e67f7f946208b2b0ed4359b78f69cabbc6f78e3d", size = 10836046, upload-time = "2026-02-09T15:29:57.661Z" }, + { url = "https://files.pythonhosted.org/packages/be/e7/d2699327bef724d79b0afb11723497369a2876ec5715a78878abf49253dd/pyrefly-0.52.0-py3-none-win_amd64.whl", hash = "sha256:0e5bee368fbdce6430b7672304bc4e36f11bc3b72ad067cbfde934d380701a3b", size = 11622998, upload-time = "2026-02-09T15:29:59.595Z" }, + { url = "https://files.pythonhosted.org/packages/ff/57/491936d2293fee8ef91c2d16a841022decfd0824d1eda37ea87e667c41b9/pyrefly-0.52.0-py3-none-win_arm64.whl", hash = "sha256:8cabc07740e90c0baea12a1e7c48d6422130a19331033e8d9a16dd63e7e90db0", size = 11131664, upload-time = "2026-02-09T15:30:01.957Z" }, +] + +[[package]] +name = "pyright" +version = "1.1.408" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/b2/5db700e52554b8f025faa9c3c624c59f1f6c8841ba81ab97641b54322f16/pyright-1.1.408.tar.gz", hash = "sha256:f28f2321f96852fa50b5829ea492f6adb0e6954568d1caa3f3af3a5f555eb684", size = 4400578, upload-time = "2026-01-08T08:07:38.795Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/82/a2c93e32800940d9573fb28c346772a14778b84ba7524e691b324620ab89/pyright-1.1.408-py3-none-any.whl", hash = "sha256:090b32865f4fdb1e0e6cd82bf5618480d48eecd2eb2e70f960982a3d9a4c17c1", size = 6399144, upload-time = "2026-01-08T08:07:37.082Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, + { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, + { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, + { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, + { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, + { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, + { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, + { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, + { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, + { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, + { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, + { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, + { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, + { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, + { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, + { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, + { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, + { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, + { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, + { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, + { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, + { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, + { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, + { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, + { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, + { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, + { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, + { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, +] + +[[package]] +name = "type-checking" +version = "0.1.0" +source = { editable = "." } + +[package.dev-dependencies] +dev = [ + { name = "pyrefly" }, + { name = "pyright" }, + { name = "pytest" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +dev = [ + { name = "pyrefly", specifier = ">=0.52.0" }, + { name = "pyright", specifier = ">=1.1.408" }, + { name = "pytest", specifier = ">=8.2.0" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] diff --git a/pyproject.toml b/pyproject.toml index dd03dcb7..e1f9e0a8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,6 +54,8 @@ source-include = ["HISTORY.rst"] [dependency-groups] dev = [ "inline-snapshot>=0.32.0", + "pyrefly>=0.52.0", + "pyright>=1.1.408", "pytest-asyncio>=1.0.0", "ruff>=0.15.1", ] diff --git a/src/mutmut/__main__.py b/src/mutmut/__main__.py index 6c30f6c7..5f575c4f 100644 --- a/src/mutmut/__main__.py +++ b/src/mutmut/__main__.py @@ -1,3 +1,7 @@ +from typing import Iterable +from mutmut.type_checking import TypeCheckingError +from mutmut.type_checking import run_type_checker +from typing import Any import os import sys import platform @@ -47,12 +51,6 @@ process_time, sleep, ) -from typing import ( - Dict, - List, - Union, - Optional, -) import warnings import click @@ -83,6 +81,7 @@ 34: 'skipped', 35: 'suspicious', 36: 'timeout', + 6: 'caught by type check', -24: 'timeout', # SIGXCPU 24: 'timeout', # SIGXCPU 152: 'timeout', # SIGXCPU @@ -97,6 +96,7 @@ 'timeout': '⏰', 'suspicious': '🤔', 'skipped': '🔇', + 'caught by type check': '🧙', 'check was interrupted by user': '🛑', 'not checked': '?', 'killed': '🎉', @@ -186,7 +186,7 @@ def __init__(self, pytest_args: list[str]) -> None: class InvalidGeneratedSyntaxException(Exception): - def __init__(self, file: Union[Path, str]) -> None: + def __init__(self, file: Path | str) -> None: super().__init__(f'Mutmut generated invalid python syntax for {file}. ' 'If the original file has valid python syntax, please file an issue ' 'with a minimal reproducible example file.') @@ -211,7 +211,7 @@ def copy_src_dir(): class FileMutationResult: """Dataclass to transfer warnings and errors from child processes to the parent""" warnings: list[Warning] = field(default_factory=list) - error: Optional[Exception] = None + error: Exception | None = None unmodified: bool = False ignored: bool = False @@ -352,6 +352,7 @@ def __init__(self, *, path): self.key_by_pid = {} self.exit_code_by_key = {} self.durations_by_key = {} + self.type_check_error_by_key = {} self.start_time_by_pid = {} def load(self): @@ -364,7 +365,8 @@ def load(self): self.exit_code_by_key = meta.pop('exit_code_by_key') self.durations_by_key = meta.pop('durations_by_key') self.estimated_time_of_tests_by_mutant = meta.pop('estimated_durations_by_key') - assert not meta, f'Meta file {self.meta_path} constains unexpected keys: {set(meta.keys())}' + self.type_check_error_by_key = meta.pop('type_check_error_by_key') + assert not meta, f'Meta file {self.meta_path} contains unexpected keys: {set(meta.keys())}' def register_pid(self, *, pid, key): self.key_by_pid[pid] = key @@ -391,9 +393,103 @@ def save(self): json.dump(dict( exit_code_by_key=self.exit_code_by_key, durations_by_key=self.durations_by_key, + type_check_error_by_key=self.type_check_error_by_key, estimated_durations_by_key=self.estimated_time_of_tests_by_mutant, ), f, indent=4) +def filter_mutants_with_type_checker(): + with change_cwd(Path('mutants')): + errors = run_type_checker(mutmut.config.type_check_command) + grouped_errors = group_by_path(errors) + + mutants_to_skip: list[FailedTypeCheckMutant] = [] + + for path, errors_of_file in grouped_errors.items(): + with open(path, 'r', encoding='utf-8') as file: + source = file.read() + wrapper = cst.MetadataWrapper(cst.parse_module(source)) + visitor = MutatedMethodsCollector(path) + wrapper.visit(visitor) + mutated_methods = visitor.found_mutants + + for error in errors_of_file: + assert error.file_path == visitor.file + mutant = next((m for m in mutated_methods if m.line_number_start <= error.line_number <= m.line_number_end), None) + if mutant is None: + raise Exception(f'Could not find mutant for error {error.file_path}:{error.line_number} ({error.error_description})') + + module_name = strip_prefix(str(path.relative_to(Path('.').absolute()))[:-len(path.suffix)].replace(os.sep, '.'), prefix='src.') + + mutant_name = '.'.join([module_name, mutant.function_name]).replace('.__init__.', '.') + mutants_to_skip.append(FailedTypeCheckMutant( + method_location=mutant, + name=mutant_name, + error=error, + )) + + return mutants_to_skip + + +def group_by_path(errors: list[TypeCheckingError]) -> dict[Path, list[TypeCheckingError]]: + grouped: dict[Path, list[TypeCheckingError]] = defaultdict(list) + + for error in errors: + grouped[error.file_path].append(error) + + return grouped + +@dataclass +class MutatedMethodLocation: + file: Path + function_name: str + line_number_start: int + line_number_end: int + + +@dataclass +class FailedTypeCheckMutant: + method_location: MutatedMethodLocation + name: str + error: TypeCheckingError + + +class MutatedMethodsCollector(cst.CSTVisitor): + METADATA_DEPENDENCIES = (cst.metadata.PositionProvider,) + + def __init__(self, file: Path): + self.file = file + self.found_mutants: list[MutatedMethodLocation] = [] + + def visit_FunctionDef(self, node: cst.FunctionDef) -> bool: + name = node.name.value + if is_mutated_method_name(name): + range = self.get_metadata(cst.metadata.PositionProvider, node) + self.found_mutants.append(MutatedMethodLocation( + file=self.file, + function_name=name, + line_number_start=range.start.line, + line_number_end=range.end.line, + )) + + # do not continue visting children of this function + # mutated methods are not nested within other methods + return False + +def is_mutated_method_name(name: str): + return name.startswith(('x_', 'xǁ')) and '__mutmut' in name + +def parse_mutant_methods(file_paths: Iterable[Path]) -> dict[Path, list[MutatedMethodLocation]]: + methods: dict[Path, list[MutatedMethodLocation]] = {} + + for path in file_paths: + with open(path, 'r', encoding='utf-8') as file: + source = file.read() + module = cst.parse_module(source) + + return methods + + + def unused(*_): pass @@ -459,8 +555,8 @@ def new_tests(self): class PytestRunner(TestRunner): def __init__(self): - self._pytest_add_cli_args: List[str] = mutmut.config.pytest_add_cli_args - self._pytest_add_cli_args_test_selection: List[str] = mutmut.config.pytest_add_cli_args_test_selection + self._pytest_add_cli_args: list[str] = mutmut.config.pytest_add_cli_args + self._pytest_add_cli_args_test_selection: list[str] = mutmut.config.pytest_add_cli_args_test_selection # tests_dir is a special case of a test selection option, # so also use pytest_add_cli_args_test_selection for the implementation @@ -642,6 +738,7 @@ class Stat: timeout: int check_was_interrupted_by_user: int segfault: int + caught_by_type_check: int def collect_stat(m: SourceFileMutationData): @@ -671,12 +768,13 @@ def calculate_summary_stats(source_file_mutation_data_by_path): timeout=sum(x.timeout for x in stats), check_was_interrupted_by_user=sum(x.check_was_interrupted_by_user for x in stats), segfault=sum(x.segfault for x in stats), + caught_by_type_check=sum(x.caught_by_type_check for x in stats), ) def print_stats(source_file_mutation_data_by_path, force_output=False): s = calculate_summary_stats(source_file_mutation_data_by_path) - 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) + print_status(f'{(s.total - s.not_checked)}/{s.total} 🎉 {s.killed} 🫥 {s.no_tests} ⏰ {s.timeout} 🤔 {s.suspicious} 🙁 {s.survived} 🔇 {s.skipped} 🧙 {s.caught_by_type_check}', force_output=force_output) def run_forced_fail_test(runner): @@ -743,15 +841,16 @@ def __exit__(self, exc_type, exc_val, exc_tb): @dataclass class Config: - also_copy: List[Path] - do_not_mutate: List[str] + also_copy: list[Path] + do_not_mutate: list[str] max_stack_depth: int debug: bool - paths_to_mutate: List[Path] - pytest_add_cli_args: List[str] - pytest_add_cli_args_test_selection: List[str] - tests_dir: List[str] + paths_to_mutate: list[Path] + pytest_add_cli_args: list[str] + pytest_add_cli_args_test_selection: list[str] + tests_dir: list[str] mutate_only_covered_lines: bool + type_check_command: list[str] def should_ignore_for_mutation(self, path): if not str(path).endswith('.py'): @@ -788,7 +887,7 @@ def s(key, default): config_parser = ConfigParser() config_parser.read('setup.cfg') - def s(key: str, default): + def s(key: str, default) -> Any: try: result = config_parser.get('mutmut', key) except (NoOptionError, NoSectionError): @@ -825,6 +924,7 @@ def load_config(): Path('setup.cfg'), Path('pyproject.toml'), Path('pytest.ini'), + Path('.gitignore'), ] + list(Path('.').glob('test*.py')), max_stack_depth=s('max_stack_depth', -1), debug=s('debug', False), @@ -836,6 +936,7 @@ def load_config(): tests_dir=s('tests_dir', []), pytest_add_cli_args=s('pytest_add_cli_args', []), pytest_add_cli_args_test_selection=s('pytest_add_cli_args_test_selection', []), + type_check_command=s('type_check_command', []), ) @@ -954,7 +1055,7 @@ def save_cicd_stats(source_file_mutation_data_by_path): def export_cicd_stats(): ensure_config_loaded() - source_file_mutation_data_by_path: Dict[str, SourceFileMutationData] = {} + source_file_mutation_data_by_path: dict[str, SourceFileMutationData] = {} for path in walk_source_files(): if mutmut.config.should_ignore_for_mutation(path): @@ -980,7 +1081,7 @@ def export_cicd_stats(): def collect_source_file_mutation_data(*, mutant_names): - source_file_mutation_data_by_path: Dict[str, SourceFileMutationData] = {} + source_file_mutation_data_by_path: dict[str, SourceFileMutationData] = {} for path in walk_source_files(): if mutmut.config.should_ignore_for_mutation(path): @@ -1085,7 +1186,7 @@ def run(mutant_names, *, max_children): _run(mutant_names, max_children) # separate function, so we can call it directly from the tests -def _run(mutant_names: Union[tuple, list], max_children: Union[None, int]): +def _run(mutant_names: tuple | list, max_children: None | int): # TODO: run no-ops once in a while to detect if we get false negatives # 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! os.environ['MUTANT_UNDER_TEST'] = 'mutant_generation' @@ -1106,6 +1207,18 @@ def _run(mutant_names: Union[tuple, list], max_children: Union[None, int]): time = datetime.now() - start print(f' done in {round(time.total_seconds()*1000)}ms ({stats.mutated} files mutated, {stats.ignored} ignored, {stats.unmodified} unmodified)', ) + if mutmut.config.type_check_command: + with CatchOutput(spinner_title='Filtering mutations with type checker'): + failed_type_check_mutants = filter_mutants_with_type_checker() + else: + failed_type_check_mutants = [] + + if mutmut.config.type_check_command: + with CatchOutput(spinner_title='Filtering mutations with type checker'): + failed_type_check_mutants = filter_mutants_with_type_checker() + else: + failed_type_check_mutants = [] + # TODO: config/option for runner # runner = HammettRunner() runner = PytestRunner() @@ -1140,7 +1253,7 @@ def read_one_child_exit_status(): print(' worker exit code', exit_code) source_file_mutation_data_by_pid[pid].register_result(pid=pid, exit_code=exit_code) - source_file_mutation_data_by_pid: Dict[int, SourceFileMutationData] = {} # many pids map to one MutationData + source_file_mutation_data_by_pid: dict[int, SourceFileMutationData] = {} # many pids map to one MutationData running_children = 0 count_tried = 0 @@ -1180,6 +1293,13 @@ def read_one_child_exit_status(): m.save() continue + failed_type_check_mutant = next((m for m in failed_type_check_mutants if m.name == mutant_name), None) + if failed_type_check_mutant: + m.exit_code_by_key[mutant_name] = 6 + m.type_check_error_by_key[mutant_name] = failed_type_check_mutant.error.error_description + m.save() + continue + pid = os.fork() if not pid: # In the child @@ -1284,7 +1404,7 @@ def read_orig_module(path) -> cst.Module: return cst.parse_module(f.read()) -def find_top_level_function_or_method(module: cst.Module, name: str) -> Union[cst.FunctionDef, None]: +def find_top_level_function_or_method(module: cst.Module, name: str) -> cst.FunctionDef | None: name = name.split('.')[-1] for child in module.body: if isinstance(child, cst.FunctionDef) and child.name.value == name: @@ -1511,6 +1631,7 @@ def on_data_table_row_highlighted(self, event): status = status_by_exit_code[exit_code] estimated_duration = source_file_mutation_data.estimated_time_of_tests_by_mutant.get(mutant_name, '?') duration = source_file_mutation_data.durations_by_key.get(mutant_name, '?') + type_check_error = source_file_mutation_data.type_check_error_by_key.get(mutant_name, '?') view_tests_description = f'(press t to view tests executed for this mutant)' @@ -1523,6 +1644,8 @@ def on_data_table_row_highlighted(self, event): description = f'Skipped ({exit_code=})' case 'check was interrupted by user': description = f'User interrupted ({exit_code=})' + case 'caught by type check': + description = f'Caught by type checker ({exit_code=}): {type_check_error}' case 'timeout': description = (f'Timeout ({exit_code=}): Timed out because tests did not finish within {duration:.3f} seconds. ' f'Tests without mutation took {estimated_duration:.3f} seconds. {view_tests_description}') diff --git a/src/mutmut/file_mutation.py b/src/mutmut/file_mutation.py index fa28e0ec..374096e3 100644 --- a/src/mutmut/file_mutation.py +++ b/src/mutmut/file_mutation.py @@ -10,7 +10,7 @@ from mutmut.trampoline_templates import create_trampoline_lookup, mangle_function_name, trampoline_impl from mutmut.node_mutation import mutation_operators, OPERATORS_TYPE -NEVER_MUTATE_FUNCTION_NAMES = { "__getattribute__", "__setattr__", "__new__" } +NEVER_MUTATE_FUNCTION_NAMES = { "__getattribute__", "__setattr__", "__new__"} NEVER_MUTATE_FUNCTION_CALLS = { "len", "isinstance" } @dataclass @@ -234,6 +234,10 @@ def function_trampoline_arrangement(function: cst.FunctionDef, mutants: Iterable name = function.name.value mangled_name = mangle_function_name(name=name, class_name=class_name) + '__mutmut' + # trampoline with same signature, that forwards the calls to the activated mutant/original method + # (put first, s.t. it stays next to @overload definitions of this function. mypy needs this) + nodes.append(create_trampoline_wrapper(function, mangled_name, class_name)) + # copy of original function nodes.append(function.with_changes(name=cst.Name(mangled_name + '_orig'))) @@ -245,12 +249,9 @@ def function_trampoline_arrangement(function: cst.FunctionDef, mutants: Iterable mutated_method = deep_replace(mutated_method, mutant.original_node, mutant.mutated_node) nodes.append(mutated_method) # type: ignore - # trampoline that forwards the calls - trampoline = create_trampoline_wrapper(function, mangled_name, class_name) mutants_dict = list(cst.parse_module(create_trampoline_lookup(orig_name=name, mutants=mutant_names, class_name=class_name)).body) mutants_dict[0] = mutants_dict[0].with_changes(leading_lines=[cst.EmptyLine()]) - nodes.append(trampoline) nodes.extend(mutants_dict) return nodes, mutant_names @@ -316,12 +317,14 @@ def _get_local_name(func_name: str) -> cst.BaseExpression: # return await _mutmut_trampoline(...) result_statement = cst.SimpleStatementLine([cst.Return(cst.Await(result))]) + type_ignore_whitespace = cst.TrailingWhitespace(comment=cst.Comment('# type: ignore')) + function.whitespace_after_type_parameters return function.with_changes( body=cst.IndentedBlock( [ - cst.SimpleStatementLine([args_assignemnt]), - cst.SimpleStatementLine([kwargs_assignment]), + cst.SimpleStatementLine([args_assignemnt], trailing_whitespace=type_ignore_whitespace), + cst.SimpleStatementLine([kwargs_assignment], trailing_whitespace=type_ignore_whitespace), result_statement, ], ), diff --git a/src/mutmut/trampoline_templates.py b/src/mutmut/trampoline_templates.py index 1f5c6a7b..77cb5eef 100644 --- a/src/mutmut/trampoline_templates.py +++ b/src/mutmut/trampoline_templates.py @@ -3,7 +3,7 @@ def create_trampoline_lookup(*, orig_name, mutants, class_name): mangled_name = mangle_function_name(name=orig_name, class_name=class_name) - mutants_dict = f'{mangled_name}__mutmut_mutants : ClassVar[MutantDict] = {{\n' + ', \n '.join(f'{repr(m)}: {m}' for m in mutants) + '\n}' + mutants_dict = f'{mangled_name}__mutmut_mutants : ClassVar[MutantDict] = {{ # type: ignore\n' + ', \n '.join(f'{repr(m)}: {m}' for m in mutants) + '\n}' return f""" {mutants_dict} {mangled_name}__mutmut_orig.__name__ = '{mangled_name}' @@ -25,32 +25,32 @@ def mangle_function_name(*, name, class_name): from typing import Callable from typing import ClassVar -MutantDict = Annotated[dict[str, Callable], "Mutant"] +MutantDict = Annotated[dict[str, Callable], "Mutant"] # type: ignore -def _mutmut_trampoline(orig, mutants, call_args, call_kwargs, self_arg = None): +def _mutmut_trampoline(orig, mutants, call_args, call_kwargs, self_arg = None): # type: ignore \"""Forward call to original or mutated function, depending on the environment\""" - import os - mutant_under_test = os.environ['MUTANT_UNDER_TEST'] - if mutant_under_test == 'fail': - from mutmut.__main__ import MutmutProgrammaticFailException - raise MutmutProgrammaticFailException('Failed programmatically') - elif mutant_under_test == 'stats': - from mutmut.__main__ import record_trampoline_hit - record_trampoline_hit(orig.__module__ + '.' + orig.__name__) + import os # type: ignore + mutant_under_test = os.environ['MUTANT_UNDER_TEST'] # type: ignore + if mutant_under_test == 'fail': # type: ignore + from mutmut.__main__ import MutmutProgrammaticFailException # type: ignore + raise MutmutProgrammaticFailException('Failed programmatically') # type: ignore + elif mutant_under_test == 'stats': # type: ignore + from mutmut.__main__ import record_trampoline_hit # type: ignore + record_trampoline_hit(orig.__module__ + '.' + orig.__name__) # type: ignore # (for class methods, orig is bound and thus does not need the explicit self argument) - result = orig(*call_args, **call_kwargs) - return result - prefix = orig.__module__ + '.' + orig.__name__ + '__mutmut_' - if not mutant_under_test.startswith(prefix): - result = orig(*call_args, **call_kwargs) - return result - mutant_name = mutant_under_test.rpartition('.')[-1] - if self_arg is not None: + result = orig(*call_args, **call_kwargs) # type: ignore + return result # type: ignore + prefix = orig.__module__ + '.' + orig.__name__ + '__mutmut_' # type: ignore + if not mutant_under_test.startswith(prefix): # type: ignore + result = orig(*call_args, **call_kwargs) # type: ignore + return result # type: ignore + mutant_name = mutant_under_test.rpartition('.')[-1] # type: ignore + if self_arg is not None: # type: ignore # call to a class method where self is not bound - result = mutants[mutant_name](self_arg, *call_args, **call_kwargs) + result = mutants[mutant_name](self_arg, *call_args, **call_kwargs) # type: ignore else: - result = mutants[mutant_name](*call_args, **call_kwargs) - return result + result = mutants[mutant_name](*call_args, **call_kwargs) # type: ignore + return result # type: ignore """ diff --git a/src/mutmut/type_checking.py b/src/mutmut/type_checking.py new file mode 100644 index 00000000..999fdf2d --- /dev/null +++ b/src/mutmut/type_checking.py @@ -0,0 +1,98 @@ +from typing import cast +import json +import subprocess +from pathlib import Path +from dataclasses import dataclass + + +@dataclass +class TypeCheckingError: + file_path: Path + line_number: int + """line number (first line is 1)""" + error_description: str + + +def run_type_checker(type_check_command: list[str]) -> list[TypeCheckingError]: + errors = [] + + completed_process = subprocess.run(type_check_command, capture_output=True, encoding='utf-8') + + try: + if 'mypy' in type_check_command: + report = [json.loads(line) for line in completed_process.stdout.splitlines()] + else: + report = json.loads(completed_process.stdout) + except json.JSONDecodeError as e: + raise Exception(f'type check command did not return JSON. Got: {completed_process.stdout} (stderr: {completed_process.stderr})') + + if 'pyrefly' in type_check_command: + errors = parse_pyrefly_report(cast(dict, report)) + elif 'mypy' in type_check_command: + errors = parse_mypy_report(report) + elif 'ty' in type_check_command: + errors = parse_ty_report(report) + else: + errors = parse_pyright_report(cast(dict, report)) + + return errors + + +def parse_pyright_report(result: dict) -> list[TypeCheckingError]: + if not 'generalDiagnostics' in result: + raise Exception(f'Invalid pyright report. Could not find key "generalDiagnostics". Found: {set(result.keys())}') + + errors = [] + for diagnostic in result['generalDiagnostics']: + errors.append(TypeCheckingError( + file_path=Path(diagnostic['file']), + line_number=diagnostic['range']['start']['line'] + 1, + error_description=diagnostic['message'], + )) + + return errors + +def parse_pyrefly_report(result: dict) -> list[TypeCheckingError]: + if not 'errors' in result: + raise Exception(f'Invalid pyrefly report. Could not find key "errors". Found: {set(result.keys())}') + + errors = [] + + for error in result['errors']: + errors.append(TypeCheckingError( + file_path=Path(error['path']).absolute(), + line_number=error['line'], + error_description=error['concise_description'], + )) + + return errors + +def parse_mypy_report(result: list[dict]) -> list[TypeCheckingError]: + errors = [] + + for diagnostic in result: + if diagnostic['severity'] != 'error': + continue + errors.append(TypeCheckingError( + file_path=Path(diagnostic['file']).absolute(), + line_number=diagnostic['line'], + error_description=diagnostic['message'], + )) + + return errors + +def parse_ty_report(result: list[dict]) -> list[TypeCheckingError]: + errors = [] + + for diagnostic in result: + # assuming the gitlab code quality report format, these severities seem okay + # https://docs.gitlab.com/ci/testing/code_quality/#code-quality-report-format + if diagnostic['severity'] not in ('major', 'critical', 'blocker'): + continue + errors.append(TypeCheckingError( + file_path=Path(diagnostic['location']['path']).absolute(), + line_number=diagnostic['location']['positions']['begin']['line'], + error_description=diagnostic['description'], + )) + + return errors \ No newline at end of file diff --git a/tests/e2e/test_e2e_type_checking.py b/tests/e2e/test_e2e_type_checking.py new file mode 100644 index 00000000..95368061 --- /dev/null +++ b/tests/e2e/test_e2e_type_checking.py @@ -0,0 +1,24 @@ +from inline_snapshot import snapshot + +from tests.e2e.e2e_utils import run_mutmut_on_project + + +def test_type_checking_result_snapshot(): + assert run_mutmut_on_project("type_checking") == snapshot( + { + "mutants/src/type_checking/__init__.py.meta": { + "type_checking.x_hello__mutmut_1": 6, + "type_checking.x_hello__mutmut_2": 1, + "type_checking.x_hello__mutmut_3": 1, + "type_checking.x_hello__mutmut_4": 1, + "type_checking.x_a_hello_wrapper__mutmut_1": 6, + "type_checking.x_a_hello_wrapper__mutmut_2": 0, + "type_checking.xǁPersonǁset_name__mutmut_1": 6, + "type_checking.x_mutate_me__mutmut_1": 6, + "type_checking.x_mutate_me__mutmut_2": 6, + "type_checking.x_mutate_me__mutmut_3": 1, + "type_checking.x_mutate_me__mutmut_4": 1, + "type_checking.x_mutate_me__mutmut_5": 6, + } + } + ) diff --git a/tests/test_mutation regression.py b/tests/test_mutation regression.py index 2aaded4c..f2d69e31 100644 --- a/tests/test_mutation regression.py +++ b/tests/test_mutation regression.py @@ -19,8 +19,8 @@ def test_create_trampoline_wrapper_async_method(): assert _get_trampoline_wrapper(source, "x_foo__mutmut") == snapshot("""\ async def foo(a: str, b, *args, **kwargs) -> dict[str, int]: - args = [a, b, *args] - kwargs = {**kwargs} + args = [a, b, *args]# type: ignore + kwargs = {**kwargs}# type: ignore return await _mutmut_trampoline(x_foo__mutmut_orig, x_foo__mutmut_mutants, args, kwargs, None)\ """) @@ -34,8 +34,8 @@ async def foo(): assert _get_trampoline_wrapper(source, "x_foo__mutmut") == snapshot("""\ async def foo(): - args = [] - kwargs = {} + args = []# type: ignore + kwargs = {}# type: ignore async for i in _mutmut_trampoline(x_foo__mutmut_orig, x_foo__mutmut_mutants, args, kwargs, None): yield i\ """) @@ -46,8 +46,8 @@ def test_create_trampoline_wrapper_with_positionals_only_args(): assert _get_trampoline_wrapper(source, "x_foo__mutmut") == snapshot("""\ def foo(p1, p2=None, /, p_or_kw=None, *, kw): - args = [p1, p2, p_or_kw] - kwargs = {'kw': kw} + args = [p1, p2, p_or_kw]# type: ignore + kwargs = {'kw': kw}# type: ignore return _mutmut_trampoline(x_foo__mutmut_orig, x_foo__mutmut_mutants, args, kwargs, None)\ """) @@ -59,8 +59,8 @@ def test_create_trampoline_wrapper_for_class_method(): source, "x_foo__mutmut", class_name="Person" ) == snapshot("""\ def foo(self, a, b): - args = [a, b] - kwargs = {} + args = [a, b]# type: ignore + kwargs = {}# type: ignore return _mutmut_trampoline(object.__getattribute__(self, 'x_foo__mutmut_orig'), object.__getattribute__(self, 'x_foo__mutmut_mutants'), args, kwargs, self)\ """) @@ -99,33 +99,38 @@ def add(self, value): from typing import Callable from typing import ClassVar -MutantDict = Annotated[dict[str, Callable], "Mutant"] +MutantDict = Annotated[dict[str, Callable], "Mutant"] # type: ignore -def _mutmut_trampoline(orig, mutants, call_args, call_kwargs, self_arg = None): +def _mutmut_trampoline(orig, mutants, call_args, call_kwargs, self_arg = None): # type: ignore """Forward call to original or mutated function, depending on the environment""" - import os - mutant_under_test = os.environ['MUTANT_UNDER_TEST'] - if mutant_under_test == 'fail': - from mutmut.__main__ import MutmutProgrammaticFailException - raise MutmutProgrammaticFailException('Failed programmatically') \n\ - elif mutant_under_test == 'stats': - from mutmut.__main__ import record_trampoline_hit - record_trampoline_hit(orig.__module__ + '.' + orig.__name__) + import os # type: ignore + mutant_under_test = os.environ['MUTANT_UNDER_TEST'] # type: ignore + if mutant_under_test == 'fail': # type: ignore + from mutmut.__main__ import MutmutProgrammaticFailException # type: ignore + raise MutmutProgrammaticFailException('Failed programmatically') # type: ignore + elif mutant_under_test == 'stats': # type: ignore + from mutmut.__main__ import record_trampoline_hit # type: ignore + record_trampoline_hit(orig.__module__ + '.' + orig.__name__) # type: ignore # (for class methods, orig is bound and thus does not need the explicit self argument) - result = orig(*call_args, **call_kwargs) - return result - prefix = orig.__module__ + '.' + orig.__name__ + '__mutmut_' - if not mutant_under_test.startswith(prefix): - result = orig(*call_args, **call_kwargs) - return result - mutant_name = mutant_under_test.rpartition('.')[-1] - if self_arg is not None: + result = orig(*call_args, **call_kwargs) # type: ignore + return result # type: ignore + prefix = orig.__module__ + '.' + orig.__name__ + '__mutmut_' # type: ignore + if not mutant_under_test.startswith(prefix): # type: ignore + result = orig(*call_args, **call_kwargs) # type: ignore + return result # type: ignore + mutant_name = mutant_under_test.rpartition('.')[-1] # type: ignore + if self_arg is not None: # type: ignore # call to a class method where self is not bound - result = mutants[mutant_name](self_arg, *call_args, **call_kwargs) + result = mutants[mutant_name](self_arg, *call_args, **call_kwargs) # type: ignore else: - result = mutants[mutant_name](*call_args, **call_kwargs) - return result + result = mutants[mutant_name](*call_args, **call_kwargs) # type: ignore + return result # type: ignore + +def foo(a: list[int], b): + args = [a, b]# type: ignore + kwargs = {}# type: ignore + return _mutmut_trampoline(x_foo__mutmut_orig, x_foo__mutmut_mutants, args, kwargs, None) def x_foo__mutmut_orig(a: list[int], b): return a[0] > b @@ -136,60 +141,55 @@ def x_foo__mutmut_1(a: list[int], b): def x_foo__mutmut_2(a: list[int], b): return a[0] >= b -def foo(a: list[int], b): - args = [a, b] - kwargs = {} - return _mutmut_trampoline(x_foo__mutmut_orig, x_foo__mutmut_mutants, args, kwargs, None) - -x_foo__mutmut_mutants : ClassVar[MutantDict] = { +x_foo__mutmut_mutants : ClassVar[MutantDict] = { # type: ignore 'x_foo__mutmut_1': x_foo__mutmut_1, \n\ 'x_foo__mutmut_2': x_foo__mutmut_2 } x_foo__mutmut_orig.__name__ = 'x_foo' +def bar(): + args = []# type: ignore + kwargs = {}# type: ignore + return _mutmut_trampoline(x_bar__mutmut_orig, x_bar__mutmut_mutants, args, kwargs, None) + def x_bar__mutmut_orig(): yield 1 def x_bar__mutmut_1(): yield 2 -def bar(): - args = [] - kwargs = {} - return _mutmut_trampoline(x_bar__mutmut_orig, x_bar__mutmut_mutants, args, kwargs, None) - -x_bar__mutmut_mutants : ClassVar[MutantDict] = { +x_bar__mutmut_mutants : ClassVar[MutantDict] = { # type: ignore 'x_bar__mutmut_1': x_bar__mutmut_1 } x_bar__mutmut_orig.__name__ = 'x_bar' class Adder: + def __init__(self, amount): + args = [amount]# type: ignore + kwargs = {}# type: ignore + return _mutmut_trampoline(object.__getattribute__(self, 'xǁAdderǁ__init____mutmut_orig'), object.__getattribute__(self, 'xǁAdderǁ__init____mutmut_mutants'), args, kwargs, self) def xǁAdderǁ__init____mutmut_orig(self, amount): self.amount = amount def xǁAdderǁ__init____mutmut_1(self, amount): self.amount = None - def __init__(self, amount): - args = [amount] - kwargs = {} - return _mutmut_trampoline(object.__getattribute__(self, 'xǁAdderǁ__init____mutmut_orig'), object.__getattribute__(self, 'xǁAdderǁ__init____mutmut_mutants'), args, kwargs, self) \n\ - xǁAdderǁ__init____mutmut_mutants : ClassVar[MutantDict] = { + xǁAdderǁ__init____mutmut_mutants : ClassVar[MutantDict] = { # type: ignore 'xǁAdderǁ__init____mutmut_1': xǁAdderǁ__init____mutmut_1 } xǁAdderǁ__init____mutmut_orig.__name__ = 'xǁAdderǁ__init__' + def add(self, value): + args = [value]# type: ignore + kwargs = {}# type: ignore + return _mutmut_trampoline(object.__getattribute__(self, 'xǁAdderǁadd__mutmut_orig'), object.__getattribute__(self, 'xǁAdderǁadd__mutmut_mutants'), args, kwargs, self) + def xǁAdderǁadd__mutmut_orig(self, value): return self.amount + value def xǁAdderǁadd__mutmut_1(self, value): return self.amount - value - - def add(self, value): - args = [value] - kwargs = {} - return _mutmut_trampoline(object.__getattribute__(self, 'xǁAdderǁadd__mutmut_orig'), object.__getattribute__(self, 'xǁAdderǁadd__mutmut_mutants'), args, kwargs, self) \n\ - xǁAdderǁadd__mutmut_mutants : ClassVar[MutantDict] = { + xǁAdderǁadd__mutmut_mutants : ClassVar[MutantDict] = { # type: ignore 'xǁAdderǁadd__mutmut_1': xǁAdderǁadd__mutmut_1 } xǁAdderǁadd__mutmut_orig.__name__ = 'xǁAdderǁadd' diff --git a/tests/test_mutation.py b/tests/test_mutation.py index 831b83e0..76200a6b 100644 --- a/tests/test_mutation.py +++ b/tests/test_mutation.py @@ -1,10 +1,9 @@ +import pytest import os from typing import Union from unittest.mock import Mock, patch import libcst as cst -import pytest -from inline_snapshot import snapshot import mutmut from mutmut.__main__ import ( @@ -311,7 +310,7 @@ def member(self): mutated_code = mutated_module(source) - expected = """class Foo: + expected = """ def xǁFooǁmember__mutmut_orig(self): return 1 def xǁFooǁmember__mutmut_1(self): @@ -638,8 +637,7 @@ def x(self): assert not mutants -# TODO: implement removal of inner decorators -@pytest.mark.skip +@pytest.mark.skip(reason="Feature not yet implemented") def test_decorated_inner_functions_mutation(): source = """ def foo(): @@ -655,135 +653,3 @@ def inner(): mutants = mutants_for_source(source) assert mutants == [expected] - - -def test_module_mutation(): - """Regression test, for a complete module with functions, type annotations and a class""" - source = """from __future__ import division -import lib - -lib.foo() - -def foo(a: list[int], b): - return a[0] > b - -def bar(): - yield 1 - -class Adder: - def __init__(self, amount): - self.amount = amount - - def add(self, value): - return self.amount + value - -print(Adder(1).add(2))""" - - src, _ = mutate_file_contents("file.py", source) - - assert src == snapshot('''\ -from __future__ import division -import lib - -lib.foo() -from typing import Annotated -from typing import Callable -from typing import ClassVar - -MutantDict = Annotated[dict[str, Callable], "Mutant"] - - -def _mutmut_trampoline(orig, mutants, call_args, call_kwargs, self_arg = None): - """Forward call to original or mutated function, depending on the environment""" - import os - mutant_under_test = os.environ['MUTANT_UNDER_TEST'] - if mutant_under_test == 'fail': - from mutmut.__main__ import MutmutProgrammaticFailException - raise MutmutProgrammaticFailException('Failed programmatically') \n\ - elif mutant_under_test == 'stats': - from mutmut.__main__ import record_trampoline_hit - record_trampoline_hit(orig.__module__ + '.' + orig.__name__) - # (for class methods, orig is bound and thus does not need the explicit self argument) - result = orig(*call_args, **call_kwargs) - return result - prefix = orig.__module__ + '.' + orig.__name__ + '__mutmut_' - if not mutant_under_test.startswith(prefix): - result = orig(*call_args, **call_kwargs) - return result - mutant_name = mutant_under_test.rpartition('.')[-1] - if self_arg is not None: - # call to a class method where self is not bound - result = mutants[mutant_name](self_arg, *call_args, **call_kwargs) - else: - result = mutants[mutant_name](*call_args, **call_kwargs) - return result - -def x_foo__mutmut_orig(a: list[int], b): - return a[0] > b - -def x_foo__mutmut_1(a: list[int], b): - return a[1] > b - -def x_foo__mutmut_2(a: list[int], b): - return a[0] >= b - -def foo(a: list[int], b): - args = [a, b] - kwargs = {} - return _mutmut_trampoline(x_foo__mutmut_orig, x_foo__mutmut_mutants, args, kwargs, None) - -x_foo__mutmut_mutants : ClassVar[MutantDict] = { -'x_foo__mutmut_1': x_foo__mutmut_1, \n\ - 'x_foo__mutmut_2': x_foo__mutmut_2 -} -x_foo__mutmut_orig.__name__ = 'x_foo' - -def x_bar__mutmut_orig(): - yield 1 - -def x_bar__mutmut_1(): - yield 2 - -def bar(): - args = [] - kwargs = {} - return _mutmut_trampoline(x_bar__mutmut_orig, x_bar__mutmut_mutants, args, kwargs, None) - -x_bar__mutmut_mutants : ClassVar[MutantDict] = { -'x_bar__mutmut_1': x_bar__mutmut_1 -} -x_bar__mutmut_orig.__name__ = 'x_bar' - -class Adder: - def xǁAdderǁ__init____mutmut_orig(self, amount): - self.amount = amount - def xǁAdderǁ__init____mutmut_1(self, amount): - self.amount = None - def __init__(self, amount): - args = [amount] - kwargs = {} - return _mutmut_trampoline(object.__getattribute__(self, 'xǁAdderǁ__init____mutmut_orig'), object.__getattribute__(self, 'xǁAdderǁ__init____mutmut_mutants'), args, kwargs, self) - \n\ - xǁAdderǁ__init____mutmut_mutants : ClassVar[MutantDict] = { - 'xǁAdderǁ__init____mutmut_1': xǁAdderǁ__init____mutmut_1 - } - xǁAdderǁ__init____mutmut_orig.__name__ = 'xǁAdderǁ__init__' - - def xǁAdderǁadd__mutmut_orig(self, value): - return self.amount + value - - def xǁAdderǁadd__mutmut_1(self, value): - return self.amount - value - - def add(self, value): - args = [value] - kwargs = {} - return _mutmut_trampoline(object.__getattribute__(self, 'xǁAdderǁadd__mutmut_orig'), object.__getattribute__(self, 'xǁAdderǁadd__mutmut_mutants'), args, kwargs, self) - \n\ - xǁAdderǁadd__mutmut_mutants : ClassVar[MutantDict] = { - 'xǁAdderǁadd__mutmut_1': xǁAdderǁadd__mutmut_1 - } - xǁAdderǁadd__mutmut_orig.__name__ = 'xǁAdderǁadd' - -print(Adder(1).add(2))\ -''') diff --git a/uv.lock b/uv.lock index 58b84f53..20ce2e9b 100644 --- a/uv.lock +++ b/uv.lock @@ -268,6 +268,8 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "pyrefly" }, + { name = "pyright" }, { name = "inline-snapshot" }, { name = "pytest-asyncio" }, { name = "ruff" }, @@ -286,11 +288,22 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ + { name = "pyrefly", specifier = ">=0.52.0" }, + { name = "pyright", specifier = ">=1.1.408" }, { name = "inline-snapshot", specifier = ">=0.32.0" }, { name = "pytest-asyncio", specifier = ">=1.0.0" }, { name = "ruff", specifier = ">=0.15.1" }, ] +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + [[package]] name = "packaging" version = "25.0" @@ -327,6 +340,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] +[[package]] +name = "pyrefly" +version = "0.52.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/93/bc/a65b3f8a04b941121868c07f1e65db223c1a101b6adf0ff3db5240ad24ea/pyrefly-0.52.0.tar.gz", hash = "sha256:abe022b68e67a2fd9adad4f8fe2deced2a786df32601b0eecbb00b40ea1f3b93", size = 4967100, upload-time = "2026-02-09T15:30:03.745Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/32/74a3b3ed6b38fef8aba3437e02824bf670b017123126bb83597c0aa42e98/pyrefly-0.52.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:90d7bf2fb812ee3a920a962da2aa2387e2f4109c62604e5be1a736046a3260c7", size = 11773462, upload-time = "2026-02-09T15:29:44.995Z" }, + { url = "https://files.pythonhosted.org/packages/31/d4/efb4aecca57bc42871b3004af04324e637057902417d89757c4077474b98/pyrefly-0.52.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:848764fdbc474fd36412d7ccf230d13a12ab3b2c28968124d9e9d51df79b7b8e", size = 11355651, upload-time = "2026-02-09T15:29:46.992Z" }, + { url = "https://files.pythonhosted.org/packages/d8/b9/80e0becaaafe0ca55b06868e942aa7f68a42644a156fdc7bedf2ae851d65/pyrefly-0.52.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:43b712830df1247798fb79f478a236b0ffbe5983bdde5eb2f5b99a9411e09f35", size = 31906389, upload-time = "2026-02-09T15:29:49.138Z" }, + { url = "https://files.pythonhosted.org/packages/44/78/f6ff1e9c86eebad5feef97301789bb9ef22a5816931809cbb063e5e6acb9/pyrefly-0.52.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:baa4130c460ad7c8d7efcff9017b7bc74c71736c5959ebfc2b7e405c2ce07d5d", size = 34292755, upload-time = "2026-02-09T15:29:52.12Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d4/5798fbec917aa2481de9ed4dc550824383b115c67b57be2ca6da43a91850/pyrefly-0.52.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3297751b1b13ecb582af48c8798e0b652c41c33a7e4ed72676164b29561655f6", size = 36943447, upload-time = "2026-02-09T15:29:54.858Z" }, + { url = "https://files.pythonhosted.org/packages/67/91/963f6afb1cc0fd020f925137d64f437b777fd31907ac34589e9a9f949069/pyrefly-0.52.0-py3-none-win32.whl", hash = "sha256:d24ed11ef5eab93625df0bb4e67f7f946208b2b0ed4359b78f69cabbc6f78e3d", size = 10836046, upload-time = "2026-02-09T15:29:57.661Z" }, + { url = "https://files.pythonhosted.org/packages/be/e7/d2699327bef724d79b0afb11723497369a2876ec5715a78878abf49253dd/pyrefly-0.52.0-py3-none-win_amd64.whl", hash = "sha256:0e5bee368fbdce6430b7672304bc4e36f11bc3b72ad067cbfde934d380701a3b", size = 11622998, upload-time = "2026-02-09T15:29:59.595Z" }, + { url = "https://files.pythonhosted.org/packages/ff/57/491936d2293fee8ef91c2d16a841022decfd0824d1eda37ea87e667c41b9/pyrefly-0.52.0-py3-none-win_arm64.whl", hash = "sha256:8cabc07740e90c0baea12a1e7c48d6422130a19331033e8d9a16dd63e7e90db0", size = 11131664, upload-time = "2026-02-09T15:30:01.957Z" }, +] + +[[package]] +name = "pyright" +version = "1.1.408" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/b2/5db700e52554b8f025faa9c3c624c59f1f6c8841ba81ab97641b54322f16/pyright-1.1.408.tar.gz", hash = "sha256:f28f2321f96852fa50b5829ea492f6adb0e6954568d1caa3f3af3a5f555eb684", size = 4400578, upload-time = "2026-01-08T08:07:38.795Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/82/a2c93e32800940d9573fb28c346772a14778b84ba7524e691b324620ab89/pyright-1.1.408-py3-none-any.whl", hash = "sha256:090b32865f4fdb1e0e6cd82bf5618480d48eecd2eb2e70f960982a3d9a4c17c1", size = 6399144, upload-time = "2026-01-08T08:07:37.082Z" }, +] + [[package]] name = "pytest" version = "9.0.2" From e76774c3c52db3d9eb5c2bbd0c7f01c64fc9e140 Mon Sep 17 00:00:00 2001 From: A_A <21040751+Otto-AA@users.noreply.github.com> Date: Sun, 22 Feb 2026 16:07:44 +0100 Subject: [PATCH 2/6] Update pyrefly to 0.53.0 for CI tests --- pyproject.toml | 2 +- uv.lock | 30 +++++++++++++++--------------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e1f9e0a8..ee6da2a2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,7 +54,7 @@ source-include = ["HISTORY.rst"] [dependency-groups] dev = [ "inline-snapshot>=0.32.0", - "pyrefly>=0.52.0", + "pyrefly>=0.53.0", "pyright>=1.1.408", "pytest-asyncio>=1.0.0", "ruff>=0.15.1", diff --git a/uv.lock b/uv.lock index 20ce2e9b..a96ff06e 100644 --- a/uv.lock +++ b/uv.lock @@ -268,9 +268,9 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "inline-snapshot" }, { name = "pyrefly" }, { name = "pyright" }, - { name = "inline-snapshot" }, { name = "pytest-asyncio" }, { name = "ruff" }, ] @@ -288,9 +288,9 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ - { name = "pyrefly", specifier = ">=0.52.0" }, - { name = "pyright", specifier = ">=1.1.408" }, { name = "inline-snapshot", specifier = ">=0.32.0" }, + { name = "pyrefly", specifier = ">=0.53.0" }, + { name = "pyright", specifier = ">=1.1.408" }, { name = "pytest-asyncio", specifier = ">=1.0.0" }, { name = "ruff", specifier = ">=0.15.1" }, ] @@ -342,18 +342,18 @@ wheels = [ [[package]] name = "pyrefly" -version = "0.52.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/93/bc/a65b3f8a04b941121868c07f1e65db223c1a101b6adf0ff3db5240ad24ea/pyrefly-0.52.0.tar.gz", hash = "sha256:abe022b68e67a2fd9adad4f8fe2deced2a786df32601b0eecbb00b40ea1f3b93", size = 4967100, upload-time = "2026-02-09T15:30:03.745Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/32/74a3b3ed6b38fef8aba3437e02824bf670b017123126bb83597c0aa42e98/pyrefly-0.52.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:90d7bf2fb812ee3a920a962da2aa2387e2f4109c62604e5be1a736046a3260c7", size = 11773462, upload-time = "2026-02-09T15:29:44.995Z" }, - { url = "https://files.pythonhosted.org/packages/31/d4/efb4aecca57bc42871b3004af04324e637057902417d89757c4077474b98/pyrefly-0.52.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:848764fdbc474fd36412d7ccf230d13a12ab3b2c28968124d9e9d51df79b7b8e", size = 11355651, upload-time = "2026-02-09T15:29:46.992Z" }, - { url = "https://files.pythonhosted.org/packages/d8/b9/80e0becaaafe0ca55b06868e942aa7f68a42644a156fdc7bedf2ae851d65/pyrefly-0.52.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:43b712830df1247798fb79f478a236b0ffbe5983bdde5eb2f5b99a9411e09f35", size = 31906389, upload-time = "2026-02-09T15:29:49.138Z" }, - { url = "https://files.pythonhosted.org/packages/44/78/f6ff1e9c86eebad5feef97301789bb9ef22a5816931809cbb063e5e6acb9/pyrefly-0.52.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:baa4130c460ad7c8d7efcff9017b7bc74c71736c5959ebfc2b7e405c2ce07d5d", size = 34292755, upload-time = "2026-02-09T15:29:52.12Z" }, - { url = "https://files.pythonhosted.org/packages/c6/d4/5798fbec917aa2481de9ed4dc550824383b115c67b57be2ca6da43a91850/pyrefly-0.52.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3297751b1b13ecb582af48c8798e0b652c41c33a7e4ed72676164b29561655f6", size = 36943447, upload-time = "2026-02-09T15:29:54.858Z" }, - { url = "https://files.pythonhosted.org/packages/67/91/963f6afb1cc0fd020f925137d64f437b777fd31907ac34589e9a9f949069/pyrefly-0.52.0-py3-none-win32.whl", hash = "sha256:d24ed11ef5eab93625df0bb4e67f7f946208b2b0ed4359b78f69cabbc6f78e3d", size = 10836046, upload-time = "2026-02-09T15:29:57.661Z" }, - { url = "https://files.pythonhosted.org/packages/be/e7/d2699327bef724d79b0afb11723497369a2876ec5715a78878abf49253dd/pyrefly-0.52.0-py3-none-win_amd64.whl", hash = "sha256:0e5bee368fbdce6430b7672304bc4e36f11bc3b72ad067cbfde934d380701a3b", size = 11622998, upload-time = "2026-02-09T15:29:59.595Z" }, - { url = "https://files.pythonhosted.org/packages/ff/57/491936d2293fee8ef91c2d16a841022decfd0824d1eda37ea87e667c41b9/pyrefly-0.52.0-py3-none-win_arm64.whl", hash = "sha256:8cabc07740e90c0baea12a1e7c48d6422130a19331033e8d9a16dd63e7e90db0", size = 11131664, upload-time = "2026-02-09T15:30:01.957Z" }, +version = "0.53.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/73/262196c4ea5afec6389366a4b3d49f67655c6396efa1a4053cca37be7c8d/pyrefly-0.53.0.tar.gz", hash = "sha256:aef117e8abb9aa4cf17fc64fbf450d825d3c65fc9de3c02ed20129ebdd57aa74", size = 5040338, upload-time = "2026-02-17T21:15:44.877Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/9b/3af46ac06dcfd7b27f15e991d2d4f0082519e6906b1f304f511e4db3ad5f/pyrefly-0.53.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:79d7fb35dff0988b3943c26f74cc752fad54357a0bc33f7db665f02d1c9a5bcc", size = 12041081, upload-time = "2026-02-17T21:15:24.769Z" }, + { url = "https://files.pythonhosted.org/packages/79/4f/23422479153f8f88d1699461bf8f22e32320bb0fc1272774ea8a17463302/pyrefly-0.53.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e1d98b1e86f3c38db44860695b7986e731238e1b19c3cad7a3050476a8f6f84d", size = 11604301, upload-time = "2026-02-17T21:15:27.23Z" }, + { url = "https://files.pythonhosted.org/packages/d8/9a/f4cc6b81a464c31c3112b46abbd44ccd569f01c71a0abf39eeccf6ace914/pyrefly-0.53.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb9f2440f7e0c70aa18400f44aed994c326a1ab00f2b01cf7253a63fc62d7c6b", size = 32674148, upload-time = "2026-02-17T21:15:29.762Z" }, + { url = "https://files.pythonhosted.org/packages/4c/cc/fa98606a628380b7ae4623dbc30843e8fed6b7a631c89503bdf123e47453/pyrefly-0.53.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e4e826a5ff2aba2c41e02e6094580751c512db7916e60728cd8612dbcf178d7b", size = 35099098, upload-time = "2026-02-17T21:15:32.383Z" }, + { url = "https://files.pythonhosted.org/packages/71/d2/ab4105ee90495314a8ad6be4d6736c9f20e4b0ceb49cf015ddc84c394c25/pyrefly-0.53.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf4c69410c7a96b417a390a0e3d340f4370fdab02f9d3eaa222c4bd42e3ce24a", size = 37777824, upload-time = "2026-02-17T21:15:35.474Z" }, + { url = "https://files.pythonhosted.org/packages/38/99/0779b7202d801cdf67f08159cf7dd318d23114661143689a767d9b8a98f1/pyrefly-0.53.0-py3-none-win32.whl", hash = "sha256:00687bb6be6e366b8c0137a89625da40ced3b9212a65e561857ff888fe88e6e8", size = 11111961, upload-time = "2026-02-17T21:15:38.455Z" }, + { url = "https://files.pythonhosted.org/packages/bd/71/dc7e59f0acb81dcf3f56e7ad30e740a08527403cb1d657caca9d44fef803/pyrefly-0.53.0-py3-none-win_amd64.whl", hash = "sha256:e0512e6f7af44ae01cfddba096ff7740e15cbd1d0497a3d34a7afcb504e2b300", size = 11888648, upload-time = "2026-02-17T21:15:40.471Z" }, + { url = "https://files.pythonhosted.org/packages/5d/72/2a7c00a439c6593430289a4581426efe0bee73f6e5a443f501969e104300/pyrefly-0.53.0-py3-none-win_arm64.whl", hash = "sha256:5066e2102769683749102421b8b8667cae26abe1827617f04e8df4317e0a94af", size = 11368150, upload-time = "2026-02-17T21:15:42.74Z" }, ] [[package]] From fbb1c6092400d6601dc0328e9d0ba90e6f85a5d6 Mon Sep 17 00:00:00 2001 From: A_A <21040751+Otto-AA@users.noreply.github.com> Date: Sun, 22 Feb 2026 16:21:39 +0100 Subject: [PATCH 3/6] Add type check results parsing unit tests --- tests/test_type_checking.py | 113 ++++++++++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 tests/test_type_checking.py diff --git a/tests/test_type_checking.py b/tests/test_type_checking.py new file mode 100644 index 00000000..b786b04a --- /dev/null +++ b/tests/test_type_checking.py @@ -0,0 +1,113 @@ +from inline_snapshot import snapshot +from mutmut.type_checking import parse_mypy_report, parse_pyrefly_report + +from pathlib import Path +from mutmut.type_checking import TypeCheckingError + + +def test_mypy_parsing(): + mypy_output = [ + { + "file": "src/type_checking/__init__.py", + "line": 40, + "column": 20, + "message": 'Incompatible types in assignment (expression has type "None", variable has type "str")', + "hint": None, + "code": "assignment", + "severity": "error", + }, + { + "file": "src/type_checking/__init__.py", + "line": 73, + "column": 11, + "message": 'Unsupported left operand type for - ("str")', + "hint": None, + "code": "operator", + "severity": "error", + }, + { + "file": "src/type_checking/__init__.py", + "line": 114, + "column": 4, + "message": "By default the bodies of untyped functions are not checked, consider using --check-untyped-defs", + "hint": None, + "code": "annotation-unchecked", + "severity": "note", + }, + ] + + result = parse_mypy_report(mypy_output) + _make_pahts_relative(result) + + assert result == snapshot( + [ + TypeCheckingError( + file_path=Path("src/type_checking/__init__.py"), + line_number=40, + error_description='Incompatible types in assignment (expression has type "None", variable has type "str")', + ), + TypeCheckingError( + file_path=Path("src/type_checking/__init__.py"), + line_number=73, + error_description='Unsupported left operand type for - ("str")', + ), + ] + ) + + +def test_pyrefly_parsing(): + pyrefly_output = { + "errors": [ + { + "line": 40, + "column": 21, + "stop_line": 40, + "stop_column": 25, + "path": "src/type_checking/__init__.py", + "code": -2, + "name": "bad-assignment", + "description": "`None` is not assignable to `str`", + "concise_description": "`None` is not assignable to `str`", + "severity": "error", + }, + { + "line": 73, + "column": 12, + "stop_line": 73, + "stop_column": 25, + "path": "src/type_checking/__init__.py", + "code": -2, + "name": "unsupported-operation", + "description": "`-` is not supported between `str` and `Literal['2']`\n Cannot find `__sub__` or `__rsub__`", + "concise_description": "`-` is not supported between `str` and `Literal['2']`", + "severity": "error", + }, + ] + } + + result = parse_pyrefly_report(pyrefly_output) + _make_pahts_relative(result) + + assert result == snapshot( + [ + TypeCheckingError( + file_path=Path("src/type_checking/__init__.py"), + line_number=40, + error_description="`None` is not assignable to `str`", + ), + TypeCheckingError( + file_path=Path("src/type_checking/__init__.py"), + line_number=73, + error_description="`-` is not supported between `str` and `Literal['2']`", + ), + ] + ) + + +def _make_pahts_relative(errors: list[TypeCheckingError]): + cwd = Path(".").resolve() + for error in errors: + # make sure that we mapped the relative paths to absolute paths + assert cwd in error.file_path.parents + # then convert it to relative path, so it's easy to use snapshot(...) + error.file_path = error.file_path.relative_to(cwd) From d623ee5e4a7394fa8babefe303e94a5a27d16197 Mon Sep 17 00:00:00 2001 From: A_A <21040751+Otto-AA@users.noreply.github.com> Date: Sun, 22 Feb 2026 16:34:05 +0100 Subject: [PATCH 4/6] Hide mutants caught by type checker in browse --- src/mutmut/__main__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/mutmut/__main__.py b/src/mutmut/__main__.py index 5f575c4f..4174ff07 100644 --- a/src/mutmut/__main__.py +++ b/src/mutmut/__main__.py @@ -1514,7 +1514,7 @@ def apply_mutant(mutant_name): @cli.command() -@click.option("--show-killed", is_flag=True, default=False, help="Display killed mutants.") +@click.option("--show-killed", is_flag=True, default=False, help="Display mutants killed by tests and type checker.") def browse(show_killed): ensure_config_loaded() @@ -1616,7 +1616,7 @@ def on_data_table_row_highlighted(self, event): source_file_mutation_data, stat = self.source_file_mutation_data_and_stat_by_path[event.row_key.value] for k, v in source_file_mutation_data.exit_code_by_key.items(): status = status_by_exit_code[v] - if status != 'killed' or show_killed: + if status not in ('killed', 'caught by type check') or show_killed: mutants_table.add_row(k, emoji_by_status[status], key=k) else: assert event.data_table.id == 'mutants' From df7e32ee2bacc88cc011bc62e92e5b88056e0e84 Mon Sep 17 00:00:00 2001 From: A_A <21040751+Otto-AA@users.noreply.github.com> Date: Sun, 22 Feb 2026 17:10:00 +0100 Subject: [PATCH 5/6] code cleanup --- pyproject.toml | 1 - src/mutmut/__main__.py | 61 ++++++++++++----------------- tests/e2e/test_e2e_type_checking.py | 12 +++--- uv.lock | 24 ------------ 4 files changed, 30 insertions(+), 68 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ee6da2a2..507bf2a6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,7 +55,6 @@ source-include = ["HISTORY.rst"] dev = [ "inline-snapshot>=0.32.0", "pyrefly>=0.53.0", - "pyright>=1.1.408", "pytest-asyncio>=1.0.0", "ruff>=0.15.1", ] diff --git a/src/mutmut/__main__.py b/src/mutmut/__main__.py index 4174ff07..2b83ca6c 100644 --- a/src/mutmut/__main__.py +++ b/src/mutmut/__main__.py @@ -81,7 +81,7 @@ 34: 'skipped', 35: 'suspicious', 36: 'timeout', - 6: 'caught by type check', + 37: 'caught by type check', -24: 'timeout', # SIGXCPU 24: 'timeout', # SIGXCPU 152: 'timeout', # SIGXCPU @@ -326,16 +326,21 @@ def create_mutants_for_file(filename: Path, output_path: Path) -> FileMutationRe return FileMutationResult(warnings=warnings, error=invalid_syntax_error) source_file_mutation_data = SourceFileMutationData(path=filename) - module_name = strip_prefix(str(filename)[:-len(filename.suffix)].replace(os.sep, '.'), prefix='src.') - source_file_mutation_data.exit_code_by_key = { - '.'.join([module_name, x]).replace('.__init__.', '.'): None - for x in mutant_names + get_mutant_name(filename, mutant_name): None for mutant_name in mutant_names } source_file_mutation_data.save() return FileMutationResult(warnings=warnings) +def get_mutant_name(relative_source_path: Path, mutant_method_name: str) -> str: + module_name = str(relative_source_path)[:-len(relative_source_path.suffix)].replace(os.sep, '.') + module_name = strip_prefix(module_name, prefix='src.') + + # FYI, we currently use "mutant_name" inconsistently, for both the whole identifier including the path and only the mangled method name + mutant_name = f'{module_name}.{mutant_method_name}' + mutant_name = mutant_name.replace('.__init__.', '.') + return mutant_name def write_all_mutants_to_file(*, out, source, filename): result, mutant_names = mutate_file_contents(filename, source, get_covered_lines_for_file(filename, mutmut._covered_lines)) @@ -352,7 +357,7 @@ def __init__(self, *, path): self.key_by_pid = {} self.exit_code_by_key = {} self.durations_by_key = {} - self.type_check_error_by_key = {} + self.type_check_error_by_key: dict[str, str] = {} self.start_time_by_pid = {} def load(self): @@ -400,11 +405,11 @@ def save(self): def filter_mutants_with_type_checker(): with change_cwd(Path('mutants')): errors = run_type_checker(mutmut.config.type_check_command) - grouped_errors = group_by_path(errors) + errors_by_path = group_by_path(errors) - mutants_to_skip: list[FailedTypeCheckMutant] = [] + mutants_to_skip: dict[str, FailedTypeCheckMutant] = {} - for path, errors_of_file in grouped_errors.items(): + for path, errors_of_file in errors_by_path.items(): with open(path, 'r', encoding='utf-8') as file: source = file.read() wrapper = cst.MetadataWrapper(cst.parse_module(source)) @@ -416,16 +421,17 @@ def filter_mutants_with_type_checker(): assert error.file_path == visitor.file mutant = next((m for m in mutated_methods if m.line_number_start <= error.line_number <= m.line_number_end), None) if mutant is None: - raise Exception(f'Could not find mutant for error {error.file_path}:{error.line_number} ({error.error_description})') + raise Exception(f'Could not find mutant for type error {error.file_path}:{error.line_number} ({error.error_description}). ' + 'Probably, a code mutation influenced types in unexpected locations. ' + 'If your project normally has no type errors and uses mypy/pyrefly, please file an issue with steps to reproduce on github.') - module_name = strip_prefix(str(path.relative_to(Path('.').absolute()))[:-len(path.suffix)].replace(os.sep, '.'), prefix='src.') + mutant_name = get_mutant_name(path.relative_to(Path('.').absolute()), mutant.function_name) - mutant_name = '.'.join([module_name, mutant.function_name]).replace('.__init__.', '.') - mutants_to_skip.append(FailedTypeCheckMutant( + mutants_to_skip[mutant_name] = FailedTypeCheckMutant( method_location=mutant, name=mutant_name, error=error, - )) + ) return mutants_to_skip @@ -478,18 +484,6 @@ def visit_FunctionDef(self, node: cst.FunctionDef) -> bool: def is_mutated_method_name(name: str): return name.startswith(('x_', 'xǁ')) and '__mutmut' in name -def parse_mutant_methods(file_paths: Iterable[Path]) -> dict[Path, list[MutatedMethodLocation]]: - methods: dict[Path, list[MutatedMethodLocation]] = {} - - for path in file_paths: - with open(path, 'r', encoding='utf-8') as file: - source = file.read() - module = cst.parse_module(source) - - return methods - - - def unused(*_): pass @@ -1209,15 +1203,9 @@ def _run(mutant_names: tuple | list, max_children: None | int): if mutmut.config.type_check_command: with CatchOutput(spinner_title='Filtering mutations with type checker'): - failed_type_check_mutants = filter_mutants_with_type_checker() - else: - failed_type_check_mutants = [] - - if mutmut.config.type_check_command: - with CatchOutput(spinner_title='Filtering mutations with type checker'): - failed_type_check_mutants = filter_mutants_with_type_checker() + mutants_caught_by_type_checker = filter_mutants_with_type_checker() else: - failed_type_check_mutants = [] + mutants_caught_by_type_checker = {} # TODO: config/option for runner # runner = HammettRunner() @@ -1287,15 +1275,14 @@ def read_one_child_exit_status(): tests = mutmut.tests_by_mangled_function_name.get(mangled_name_from_mutant_name(mutant_name), []) - # print(tests) if not tests: m.exit_code_by_key[mutant_name] = 33 m.save() continue - failed_type_check_mutant = next((m for m in failed_type_check_mutants if m.name == mutant_name), None) + failed_type_check_mutant = mutants_caught_by_type_checker.get(mutant_name) if failed_type_check_mutant: - m.exit_code_by_key[mutant_name] = 6 + m.exit_code_by_key[mutant_name] = 37 m.type_check_error_by_key[mutant_name] = failed_type_check_mutant.error.error_description m.save() continue diff --git a/tests/e2e/test_e2e_type_checking.py b/tests/e2e/test_e2e_type_checking.py index 95368061..e3aa1c7f 100644 --- a/tests/e2e/test_e2e_type_checking.py +++ b/tests/e2e/test_e2e_type_checking.py @@ -7,18 +7,18 @@ def test_type_checking_result_snapshot(): assert run_mutmut_on_project("type_checking") == snapshot( { "mutants/src/type_checking/__init__.py.meta": { - "type_checking.x_hello__mutmut_1": 6, + "type_checking.x_hello__mutmut_1": 37, "type_checking.x_hello__mutmut_2": 1, "type_checking.x_hello__mutmut_3": 1, "type_checking.x_hello__mutmut_4": 1, - "type_checking.x_a_hello_wrapper__mutmut_1": 6, + "type_checking.x_a_hello_wrapper__mutmut_1": 37, "type_checking.x_a_hello_wrapper__mutmut_2": 0, - "type_checking.xǁPersonǁset_name__mutmut_1": 6, - "type_checking.x_mutate_me__mutmut_1": 6, - "type_checking.x_mutate_me__mutmut_2": 6, + "type_checking.xǁPersonǁset_name__mutmut_1": 37, + "type_checking.x_mutate_me__mutmut_1": 37, + "type_checking.x_mutate_me__mutmut_2": 37, "type_checking.x_mutate_me__mutmut_3": 1, "type_checking.x_mutate_me__mutmut_4": 1, - "type_checking.x_mutate_me__mutmut_5": 6, + "type_checking.x_mutate_me__mutmut_5": 37, } } ) diff --git a/uv.lock b/uv.lock index a96ff06e..6dbba1fe 100644 --- a/uv.lock +++ b/uv.lock @@ -270,7 +270,6 @@ dependencies = [ dev = [ { name = "inline-snapshot" }, { name = "pyrefly" }, - { name = "pyright" }, { name = "pytest-asyncio" }, { name = "ruff" }, ] @@ -290,20 +289,10 @@ requires-dist = [ dev = [ { name = "inline-snapshot", specifier = ">=0.32.0" }, { name = "pyrefly", specifier = ">=0.53.0" }, - { name = "pyright", specifier = ">=1.1.408" }, { name = "pytest-asyncio", specifier = ">=1.0.0" }, { name = "ruff", specifier = ">=0.15.1" }, ] -[[package]] -name = "nodeenv" -version = "1.10.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, -] - [[package]] name = "packaging" version = "25.0" @@ -356,19 +345,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5d/72/2a7c00a439c6593430289a4581426efe0bee73f6e5a443f501969e104300/pyrefly-0.53.0-py3-none-win_arm64.whl", hash = "sha256:5066e2102769683749102421b8b8667cae26abe1827617f04e8df4317e0a94af", size = 11368150, upload-time = "2026-02-17T21:15:42.74Z" }, ] -[[package]] -name = "pyright" -version = "1.1.408" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "nodeenv" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/74/b2/5db700e52554b8f025faa9c3c624c59f1f6c8841ba81ab97641b54322f16/pyright-1.1.408.tar.gz", hash = "sha256:f28f2321f96852fa50b5829ea492f6adb0e6954568d1caa3f3af3a5f555eb684", size = 4400578, upload-time = "2026-01-08T08:07:38.795Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/82/a2c93e32800940d9573fb28c346772a14778b84ba7524e691b324620ab89/pyright-1.1.408-py3-none-any.whl", hash = "sha256:090b32865f4fdb1e0e6cd82bf5618480d48eecd2eb2e70f960982a3d9a4c17c1", size = 6399144, upload-time = "2026-01-08T08:07:37.082Z" }, -] - [[package]] name = "pytest" version = "9.0.2" From b420d92720b5cca4257bef9baed11283c9332dea Mon Sep 17 00:00:00 2001 From: A_A <21040751+Otto-AA@users.noreply.github.com> Date: Sun, 22 Feb 2026 17:59:17 +0100 Subject: [PATCH 6/6] Mention limitations of type checking filtering --- README.rst | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 6e8c6bd3..c54962cb 100644 --- a/README.rst +++ b/README.rst @@ -170,9 +170,17 @@ If you only want to mutate lines that are called (according to coverage.py), you Filter generated mutants with type checker ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -When your project is type checked, you can also use it to filter out invalid mutants. +When your project is type checked using `mypy` or `pyrefly`, you can also use it to filter out invalid mutants. For instance, mutmut mutates `x: str = 'foo'` to `x: str = None` which can easily caught by type checkers. +Using this filter can improve performance and reduce noise, however it can also hide a few relevant mutations: + +1. `x: str = None` may not be valid, but if your tests do not detect such a change it indicates that + the value of `x` is not properly tested (even if your type checker would catch this particular modification) +2. In some edge cases with class properties (usually in the `__init__` method), the way `mypy` and `pyrefly` infer types does not work well + with the way mutmut mutates code. Some valid mutations like changing `self.x = 123` to `self.x = None` can + be filtered out, even though the may be valid. + To enable this filtering, configure the `type_check_command` to output json results as follows: .. code-block::