From a997306932fd46efdc4091e35fe7d60fa8ab6305 Mon Sep 17 00:00:00 2001 From: Sam Mosleh Date: Tue, 10 Mar 2026 13:58:58 +0400 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=90=9B=20Change=20cwd=20for=20coverag?= =?UTF-8?q?e=20reports?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mutate_only_covered_lines/pyproject.toml | 5 +++++ .../src/mutate_only_covered_lines/__init__.py | 5 +++++ .../src/mutate_only_covered_lines/omit_me.py | 2 ++ .../tests/main/test_mutate_only_covered_lines.py | 5 ++++- src/mutmut/__main__.py | 15 +++++++++++++-- src/mutmut/code_coverage.py | 6 ++---- tests/e2e/test_e2e_coverage.py | 3 ++- 7 files changed, 33 insertions(+), 8 deletions(-) create mode 100644 e2e_projects/mutate_only_covered_lines/src/mutate_only_covered_lines/omit_me.py diff --git a/e2e_projects/mutate_only_covered_lines/pyproject.toml b/e2e_projects/mutate_only_covered_lines/pyproject.toml index e09b3712..e51233f0 100644 --- a/e2e_projects/mutate_only_covered_lines/pyproject.toml +++ b/e2e_projects/mutate_only_covered_lines/pyproject.toml @@ -16,6 +16,11 @@ dev = [ "pytest>=8.3.5", ] +[tool.coverage.run] +omit = [ + "src/mutate_only_covered_lines/omit_me.py", +] + [tool.mutmut] debug = true mutate_only_covered_lines = true diff --git a/e2e_projects/mutate_only_covered_lines/src/mutate_only_covered_lines/__init__.py b/e2e_projects/mutate_only_covered_lines/src/mutate_only_covered_lines/__init__.py index 2c0c2bde..83a65e59 100644 --- a/e2e_projects/mutate_only_covered_lines/src/mutate_only_covered_lines/__init__.py +++ b/e2e_projects/mutate_only_covered_lines/src/mutate_only_covered_lines/__init__.py @@ -1,3 +1,5 @@ +from mutate_only_covered_lines.omit_me import this_function_shall_NOT_be_mutated + def hello_mutate_only_covered_lines(simple_branch: bool) -> str: if simple_branch: return "Hello from mutate_only_covered_lines! (true)" @@ -40,3 +42,6 @@ def mutate_only_covered_lines_multiline(simple_branch: bool) -> str: ] return f"Hello from mutate_only_covered_lines!" \ f" (false) {x} {y}" + +def do_not_mutate_external_ommited_function(): + return this_function_shall_NOT_be_mutated() diff --git a/e2e_projects/mutate_only_covered_lines/src/mutate_only_covered_lines/omit_me.py b/e2e_projects/mutate_only_covered_lines/src/mutate_only_covered_lines/omit_me.py new file mode 100644 index 00000000..60c1c7f3 --- /dev/null +++ b/e2e_projects/mutate_only_covered_lines/src/mutate_only_covered_lines/omit_me.py @@ -0,0 +1,2 @@ +def this_function_shall_NOT_be_mutated(): + return 3 + 4 diff --git a/e2e_projects/mutate_only_covered_lines/tests/main/test_mutate_only_covered_lines.py b/e2e_projects/mutate_only_covered_lines/tests/main/test_mutate_only_covered_lines.py index f1b135a4..2fe08be7 100644 --- a/e2e_projects/mutate_only_covered_lines/tests/main/test_mutate_only_covered_lines.py +++ b/e2e_projects/mutate_only_covered_lines/tests/main/test_mutate_only_covered_lines.py @@ -1,5 +1,5 @@ from mutate_only_covered_lines.ignore_me import this_function_shall_NOT_be_mutated -from mutate_only_covered_lines import hello_mutate_only_covered_lines, mutate_only_covered_lines_multiline, function_with_pragma +from mutate_only_covered_lines import hello_mutate_only_covered_lines, mutate_only_covered_lines_multiline, function_with_pragma, do_not_mutate_external_ommited_function """This tests the mutate_only_covered_lines feature.""" @@ -14,3 +14,6 @@ def test_mutate_only_covered_lines_multiline(): def call_ignored_function(): assert this_function_shall_NOT_be_mutated() == 3 + +def test_do_not_mutate_external_ommited_function(): + assert do_not_mutate_external_ommited_function() == 7 diff --git a/src/mutmut/__main__.py b/src/mutmut/__main__.py index 2a710c87..b436d3e3 100644 --- a/src/mutmut/__main__.py +++ b/src/mutmut/__main__.py @@ -56,7 +56,6 @@ import click import libcst as cst from rich.text import Text -from setproctitle import setproctitle import mutmut from mutmut.code_coverage import gather_coverage @@ -643,6 +642,19 @@ def run_tests(self, *, mutant_name: str | None, tests: Iterable[str]) -> int: with change_cwd("mutants"): return int(self.execute_pytest(pytest_args)) + def collect_main_test_coverage(self, cov: Any) -> int: + with change_cwd("mutants"), cov.collect(): + self.prepare_main_test_run() + pytest_args = [ + "-x", + "-q", + "-p", + "no:randomly", + "-p", + "no:random-order", + ] + self._pytest_add_cli_args_test_selection + return int(self.execute_pytest(pytest_args)) + def run_forced_fail(self) -> int: pytest_args = ["-x", "-q"] + self._pytest_add_cli_args_test_selection with change_cwd("mutants"): @@ -1370,7 +1382,6 @@ def read_one_child_exit_status() -> None: if not pid: # In the child os.environ["MUTANT_UNDER_TEST"] = mutant_name - setproctitle(f"mutmut: {mutant_name}") # Run fast tests first sorted_tests = sorted(tests, key=lambda test_name: mutmut.duration_by_test[test_name]) diff --git a/src/mutmut/code_coverage.py b/src/mutmut/code_coverage.py index d8d69fbf..17e43f31 100644 --- a/src/mutmut/code_coverage.py +++ b/src/mutmut/code_coverage.py @@ -41,10 +41,8 @@ def gather_coverage(runner: TestRunner, source_files: Iterable[Path]) -> dict[st mutants_path = Path("mutants") # Run the tests and gather coverage - cov = coverage.Coverage(source=[str(mutants_path.absolute())], data_file=None) - with cov.collect(): - runner.prepare_main_test_run() - runner.run_tests(mutant_name=None, tests=[]) + cov = coverage.Coverage(data_file=None) + runner.collect_main_test_coverage(cov) # Build mapping of filenames to covered lines # The CoverageData object is a wrapper around sqlite, and this diff --git a/tests/e2e/test_e2e_coverage.py b/tests/e2e/test_e2e_coverage.py index 0bb351c0..112180cb 100644 --- a/tests/e2e/test_e2e_coverage.py +++ b/tests/e2e/test_e2e_coverage.py @@ -42,6 +42,7 @@ def test_mutate_only_covered_lines_result_snapshot(): "mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_30": 1, "mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_31": 1, "mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_32": 1, - } + }, + "mutants/src/mutate_only_covered_lines/omit_me.py.meta": {}, } ) From c46133792127a131b2a8a17886c53ea9bc839354 Mon Sep 17 00:00:00 2001 From: Sam Mosleh Date: Tue, 10 Mar 2026 14:28:59 +0400 Subject: [PATCH 2/3] =?UTF-8?q?=E2=9C=8F=EF=B8=8F=20Run=20pre-commit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .pre-commit-config.yaml | 42 ++++++++++++++++++++--------------------- pyproject.toml | 1 - src/mutmut/__main__.py | 9 ++++++++- 3 files changed, 29 insertions(+), 23 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e235efeb..e7aaf139 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,36 +1,36 @@ default_language_version: - python: python3.10 + python: python3.14 repos: -- repo: https://github.com/pre-commit/pre-commit-hooks + - repo: https://github.com/pre-commit/pre-commit-hooks rev: v6.0.0 hooks: - - id: check-docstring-first + - id: check-docstring-first exclude: ^e2e_projects/ - - id: check-json - - id: check-merge-conflict + - id: check-json + - id: check-merge-conflict exclude: \.rst$ - - id: check-yaml - - id: debug-statements + - id: check-yaml + - id: debug-statements exclude: tests/data/test_generation/invalid_syntax\.py - - id: end-of-file-fixer - - id: trailing-whitespace -- repo: https://github.com/astral-sh/ruff-pre-commit + - id: end-of-file-fixer + - id: trailing-whitespace + - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.15.4 hooks: - - id: ruff + - id: ruff args: [--fix, --exit-non-zero-on-fix] - - id: ruff-format -- repo: https://github.com/pre-commit/mirrors-mypy + - id: ruff-format + - repo: https://github.com/pre-commit/mirrors-mypy rev: v1.19.1 hooks: - - id: mypy + - id: mypy args: [--config-file=mypy.ini] exclude: (tests/|docs/) additional_dependencies: - - click>=8.0.0 - - coverage>=7.3.0 - - libcst>=1.8.5 - - pytest>=6.2.5 - - setproctitle>=1.1.0 - - textual>=1.0.0 - - types-toml>=0.10.2 + - click>=8.0.0 + - coverage>=7.3.0 + - libcst>=1.8.5 + - pytest>=6.2.5 + - setproctitle>=1.1.0 + - textual>=1.0.0 + - types-toml>=0.10.2 diff --git a/pyproject.toml b/pyproject.toml index 2f29f048..c4e6a8de 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -95,7 +95,6 @@ ignore = [ "F402", # import shadowed by loop variable "F841", # local variable assigned but never used "UP007", # use X | Y for type annotations - "UP038", # use X | Y in isinstance ] [tool.ruff.lint.isort] diff --git a/src/mutmut/__main__.py b/src/mutmut/__main__.py index b436d3e3..d8463170 100644 --- a/src/mutmut/__main__.py +++ b/src/mutmut/__main__.py @@ -5,6 +5,7 @@ import sys from collections.abc import Iterable from collections.abc import Iterator +from typing import TYPE_CHECKING from typing import Any from mutmut.type_checking import TypeCheckingError @@ -63,6 +64,9 @@ from mutmut.file_mutation import mutate_file_contents from mutmut.trampoline_templates import CLASS_NAME_SEPARATOR +if TYPE_CHECKING: + from coverage import Coverage + # Document: surviving mutants are retested when you ask mutmut to retest them, interactively in the UI or via command line # TODO: pragma no mutate should end up in `skipped` category @@ -539,6 +543,9 @@ def prepare_main_test_run(self) -> None: def run_tests(self, *, mutant_name: str | None, tests: Iterable[str]) -> int: raise NotImplementedError() + def collect_main_test_coverage(self, cov: Coverage) -> int: + raise NotImplementedError() + def list_all_tests(self) -> ListAllTestsResult: raise NotImplementedError() @@ -642,7 +649,7 @@ def run_tests(self, *, mutant_name: str | None, tests: Iterable[str]) -> int: with change_cwd("mutants"): return int(self.execute_pytest(pytest_args)) - def collect_main_test_coverage(self, cov: Any) -> int: + def collect_main_test_coverage(self, cov: Coverage) -> int: with change_cwd("mutants"), cov.collect(): self.prepare_main_test_run() pytest_args = [ From a33dd23f6dd8eb0d987236a74bc965c312f6ee94 Mon Sep 17 00:00:00 2001 From: Sam Mosleh Date: Wed, 11 Mar 2026 02:44:16 +0400 Subject: [PATCH 3/3] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Refactor=20pytest=20ar?= =?UTF-8?q?gs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .pre-commit-config.yaml | 42 ++++++++++++++++++++--------------------- src/mutmut/__main__.py | 39 +++++++++++++++----------------------- 2 files changed, 36 insertions(+), 45 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e7aaf139..e235efeb 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,36 +1,36 @@ default_language_version: - python: python3.14 + python: python3.10 repos: - - repo: https://github.com/pre-commit/pre-commit-hooks +- repo: https://github.com/pre-commit/pre-commit-hooks rev: v6.0.0 hooks: - - id: check-docstring-first + - id: check-docstring-first exclude: ^e2e_projects/ - - id: check-json - - id: check-merge-conflict + - id: check-json + - id: check-merge-conflict exclude: \.rst$ - - id: check-yaml - - id: debug-statements + - id: check-yaml + - id: debug-statements exclude: tests/data/test_generation/invalid_syntax\.py - - id: end-of-file-fixer - - id: trailing-whitespace - - repo: https://github.com/astral-sh/ruff-pre-commit + - id: end-of-file-fixer + - id: trailing-whitespace +- repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.15.4 hooks: - - id: ruff + - id: ruff args: [--fix, --exit-non-zero-on-fix] - - id: ruff-format - - repo: https://github.com/pre-commit/mirrors-mypy + - id: ruff-format +- repo: https://github.com/pre-commit/mirrors-mypy rev: v1.19.1 hooks: - - id: mypy + - id: mypy args: [--config-file=mypy.ini] exclude: (tests/|docs/) additional_dependencies: - - click>=8.0.0 - - coverage>=7.3.0 - - libcst>=1.8.5 - - pytest>=6.2.5 - - setproctitle>=1.1.0 - - textual>=1.0.0 - - types-toml>=0.10.2 + - click>=8.0.0 + - coverage>=7.3.0 + - libcst>=1.8.5 + - pytest>=6.2.5 + - setproctitle>=1.1.0 + - textual>=1.0.0 + - types-toml>=0.10.2 diff --git a/src/mutmut/__main__.py b/src/mutmut/__main__.py index d8463170..4076ef90 100644 --- a/src/mutmut/__main__.py +++ b/src/mutmut/__main__.py @@ -57,6 +57,7 @@ import click import libcst as cst from rich.text import Text +from setproctitle import setproctitle import mutmut from mutmut.code_coverage import gather_coverage @@ -613,6 +614,14 @@ def execute_pytest(self, params: list[str], **kwargs: Any) -> int: raise BadTestExecutionCommandsException(params) return exit_code + def _pytest_args_regular_run(self, tests: Iterable[str]) -> list[str]: + pytest_args = ["-x", "-q", "-p", "no:randomly", "-p", "no:random-order"] + if tests: + pytest_args += list(tests) + else: + pytest_args += self._pytest_add_cli_args_test_selection + return pytest_args + def run_stats(self, *, tests: Iterable[str]) -> int: class StatsCollector: # noinspection PyMethodMayBeStatic @@ -632,40 +641,20 @@ def pytest_runtest_makereport(self, item: Any, call: Any) -> None: stats_collector = StatsCollector() - pytest_args = ["-x", "-q"] - if tests: - pytest_args += list(tests) - else: - pytest_args += self._pytest_add_cli_args_test_selection with change_cwd("mutants"): - return int(self.execute_pytest(pytest_args, plugins=[stats_collector])) + return int(self.execute_pytest(self._pytest_args_regular_run(tests), plugins=[stats_collector])) def run_tests(self, *, mutant_name: str | None, tests: Iterable[str]) -> int: - pytest_args = ["-x", "-q", "-p", "no:randomly", "-p", "no:random-order"] - if tests: - pytest_args += list(tests) - else: - pytest_args += self._pytest_add_cli_args_test_selection with change_cwd("mutants"): - return int(self.execute_pytest(pytest_args)) + return int(self.execute_pytest(self._pytest_args_regular_run(tests))) def collect_main_test_coverage(self, cov: Coverage) -> int: with change_cwd("mutants"), cov.collect(): self.prepare_main_test_run() - pytest_args = [ - "-x", - "-q", - "-p", - "no:randomly", - "-p", - "no:random-order", - ] + self._pytest_add_cli_args_test_selection - return int(self.execute_pytest(pytest_args)) + return int(self.execute_pytest(self._pytest_args_regular_run([]))) def run_forced_fail(self) -> int: - pytest_args = ["-x", "-q"] + self._pytest_add_cli_args_test_selection - with change_cwd("mutants"): - return int(self.execute_pytest(pytest_args)) + return self.run_tests(mutant_name=None, tests=[]) def list_all_tests(self) -> ListAllTestsResult: class TestsCollector: @@ -996,6 +985,7 @@ def load_config() -> Config: Path("setup.cfg"), Path("pyproject.toml"), Path("pytest.ini"), + Path(".coveragerc"), Path(".gitignore"), ] + list(Path(".").glob("test*.py")), @@ -1389,6 +1379,7 @@ def read_one_child_exit_status() -> None: if not pid: # In the child os.environ["MUTANT_UNDER_TEST"] = mutant_name + setproctitle(f"mutmut: {mutant_name}") # Run fast tests first sorted_tests = sorted(tests, key=lambda test_name: mutmut.duration_by_test[test_name])