diff --git a/pyproject.toml b/pyproject.toml index c45f3cd..148ac26 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "poodle" description = "Mutation Testing Tool" -version = "1.3.1" +version = "1.3.2" license = { file = "LICENSE" } keywords = [ "test", @@ -66,6 +66,9 @@ filterwarnings = [ [tool.poodle] file_filters = ["test_*.py", "*_test.py", 'cli.py', 'run.py', '__init__.py'] reporters = ["summary", "html"] +# skip_delete_folder = true +skip_mutators = ["all"] +add_mutators = ["Decorator"] [tool.poodle.runner_opts] command_line = "pytest -x --assert=plain -o pythonpath='{PYTHONPATH}' --sort-mode=mutcov" diff --git a/src/poodle/__init__.py b/src/poodle/__init__.py index 29a8da1..5a8d108 100644 --- a/src/poodle/__init__.py +++ b/src/poodle/__init__.py @@ -8,7 +8,7 @@ from pathlib import Path from typing import Any -__version__ = "1.3.1" +__version__ = "1.3.2" class PoodleTestingFailedError(Exception): diff --git a/src/poodle/config.py b/src/poodle/config.py index 66ecbb8..2f8e662 100644 --- a/src/poodle/config.py +++ b/src/poodle/config.py @@ -120,6 +120,7 @@ def build_config( # noqa: PLR0913 reporters=get_reporters(config_file_data, cmd_report, cmd_html, cmd_json), reporter_opts=get_dict_from_config("reporter_opts", config_file_data, command_line=cmd_reporter_opts), fail_under=get_float_from_config("fail_under", config_file_data, command_line=cmd_fail_under), + skip_delete_folder=get_bool_from_config("skip_delete_folder", config_file_data, default=False), ) diff --git a/src/poodle/core.py b/src/poodle/core.py index ecf5a76..b08e038 100644 --- a/src/poodle/core.py +++ b/src/poodle/core.py @@ -3,18 +3,13 @@ from __future__ import annotations import logging -import shutil -from typing import TYPE_CHECKING from . import PoodleNoMutantsFoundError, PoodleTestingFailedError, __version__ from .data_types import PoodleConfig, PoodleWork from .mutate import create_mutants_for_all_mutators, initialize_mutators from .report import generate_reporters from .run import clean_run_each_source_folder, get_runner, run_mutant_trails -from .util import calc_timeout, create_temp_zips, create_unified_diff, display_percent, pprint_str - -if TYPE_CHECKING: - from pathlib import Path +from .util import calc_timeout, create_temp_zips, create_unified_diff, delete_folder, display_percent, pprint_str logger = logging.getLogger(__name__) @@ -25,7 +20,7 @@ def main_process(config: PoodleConfig) -> None: print_header(work) logger.info("\n%s", pprint_str(config)) - delete_folder(config.work_folder) + delete_folder(config.work_folder, config) create_temp_zips(work) work.mutators = initialize_mutators(work) @@ -33,6 +28,14 @@ def main_process(config: PoodleConfig) -> None: work.reporters = list(generate_reporters(config)) mutants = create_mutants_for_all_mutators(work) + mutants.sort( + key=lambda mutant: ( + mutant.source_folder, + str(mutant.source_file) or "", + mutant.lineno, + mutant.mutator_name, + ) + ) if not mutants: raise PoodleNoMutantsFoundError("No mutants were found to test!") work.echo(f"Identified {len(mutants)} mutants") @@ -47,7 +50,7 @@ def main_process(config: PoodleConfig) -> None: for reporter in work.reporters: reporter(config=config, echo=work.echo, testing_results=results) - delete_folder(config.work_folder) + delete_folder(config.work_folder, config) if config.fail_under and results.summary.success_rate < config.fail_under / 100: display_fail_under = display_percent(config.fail_under / 100) @@ -80,10 +83,3 @@ def print_header(work: PoodleWork) -> None: if work.config.fail_under: work.echo(f" - Coverage Goal: {work.config.fail_under:.2f}%") work.echo() - - -def delete_folder(folder: Path) -> None: - """Delete a folder.""" - if folder.exists(): - logger.info("delete %s", folder) - shutil.rmtree(folder) diff --git a/src/poodle/data_types/data.py b/src/poodle/data_types/data.py index 25ea085..edc9739 100644 --- a/src/poodle/data_types/data.py +++ b/src/poodle/data_types/data.py @@ -43,7 +43,6 @@ class PoodleConfig: file_copy_flags: int | None file_copy_filters: list[str] work_folder: Path - max_workers: int | None log_format: str @@ -65,6 +64,8 @@ class PoodleConfig: fail_under: float | None + skip_delete_folder: bool | None + @dataclass class FileMutation: diff --git a/src/poodle/data_types/interfaces.py b/src/poodle/data_types/interfaces.py index ddae3d8..5a3e220 100644 --- a/src/poodle/data_types/interfaces.py +++ b/src/poodle/data_types/interfaces.py @@ -69,9 +69,10 @@ def get_location(node: ast.AST) -> tuple[int, int, int, int]: if not hasattr(n, "lineno"): continue - if n.lineno < lineno: + if n.lineno < lineno: # decorators lineno = n.lineno - col_offset = n.col_offset + if n.col_offset < col_offset: + col_offset = n.col_offset elif n.lineno == lineno and n.col_offset < col_offset: col_offset = n.col_offset @@ -111,6 +112,14 @@ def is_annotation(cls, node: ast.AST, child_node: ast.AST | None = None) -> bool return cls.is_annotation(node.parent, child_node=node) + @classmethod + def unparse(cls, node: ast.AST, indent: int) -> str: + """Unparse AST node to string. Indent any lines that are not the first line.""" + lines = ast.unparse(node).splitlines(keepends=True) + if len(lines) > 1: + lines[1:] = [f"{' ' * indent}{line}" for line in lines[1:]] + return "".join(lines) + # runner method signature: def runner( # type: ignore [empty-body] diff --git a/src/poodle/mutate.py b/src/poodle/mutate.py index 567ea1d..88d3d13 100644 --- a/src/poodle/mutate.py +++ b/src/poodle/mutate.py @@ -49,9 +49,10 @@ def initialize_mutators(work: PoodleWork) -> list[Callable | Mutator]: """Initialize all mutators from standard list and from config options.""" - mutators: list[Any] = [ - mutator for name, mutator in builtin_mutators.items() if name not in work.config.skip_mutators - ] + skip_mutators = [name.lower() for name in work.config.skip_mutators] + mutators: list[Any] = [] + if "all" not in skip_mutators: + mutators.extend([mutator for name, mutator in builtin_mutators.items() if name.lower() not in skip_mutators]) mutators.extend(work.config.add_mutators) return [initialize_mutator(work, mut_def) for mut_def in mutators] @@ -64,6 +65,9 @@ def initialize_mutator(work: PoodleWork, mutator_def: Any) -> Callable | Mutator """ logger.debug(mutator_def) + if mutator_def in builtin_mutators: + mutator_def = builtin_mutators[mutator_def] + if isinstance(mutator_def, str): try: mutator_def = dynamic_import(mutator_def) diff --git a/src/poodle/mutators/calls.py b/src/poodle/mutators/calls.py index 6a3ba31..b972654 100644 --- a/src/poodle/mutators/calls.py +++ b/src/poodle/mutators/calls.py @@ -110,4 +110,4 @@ def visit_FunctionDef(self, node: ast.FunctionDef) -> None: for idx in range(len(node.decorator_list)): new_node = deepcopy(node) new_node.decorator_list.pop(idx) - self.mutants.append(self.create_file_mutation(node, ast.unparse(new_node))) + self.mutants.append(self.create_file_mutation(node, self.unparse(new_node, new_node.col_offset))) diff --git a/src/poodle/run.py b/src/poodle/run.py index b5e8a8d..64b204c 100644 --- a/src/poodle/run.py +++ b/src/poodle/run.py @@ -4,7 +4,6 @@ import concurrent.futures import logging -import shutil import time from typing import TYPE_CHECKING, Callable from zipfile import ZipFile @@ -14,7 +13,7 @@ from . import PoodleTrialRunError from .data_types import Mutant, MutantTrial, MutantTrialResult, PoodleConfig, PoodleWork, TestingResults, TestingSummary from .runners import command_line -from .util import dynamic_import, mutate_lines +from .util import delete_folder, dynamic_import, mutate_lines if TYPE_CHECKING: from pathlib import Path @@ -176,7 +175,7 @@ def run_mutant_trial( # noqa: PLR0913 timeout=timeout, ) - shutil.rmtree(run_folder) + delete_folder(run_folder, config) duration = time.time() - start logger.debug("END: run_id=%s - Elapsed Time %.2f s", run_id, duration) diff --git a/src/poodle/util.py b/src/poodle/util.py index ca08763..bdb1949 100644 --- a/src/poodle/util.py +++ b/src/poodle/util.py @@ -5,6 +5,7 @@ import difflib import json import logging +import shutil from copy import deepcopy from io import StringIO from pprint import pprint @@ -107,10 +108,11 @@ def create_unified_diff(mutant: Mutant) -> str | None: if mutant.source_file: file_lines = mutant.source_file.read_text("utf-8").splitlines(keepends=True) file_name = str(mutant.source_file) + mutant_lines = "".join(mutate_lines(mutant, file_lines)).splitlines(keepends=True) return "".join( difflib.unified_diff( a=file_lines, - b=mutate_lines(mutant, file_lines), + b=mutant_lines, fromfile=file_name, tofile=f"[Mutant] {file_name}:{mutant.lineno}", ) @@ -131,3 +133,10 @@ def from_json(data: str, datatype: type[PoodleSerialize]) -> PoodleSerialize: def display_percent(value: float) -> str: """Convert float to string with percent sign.""" return f"{value * 1000 // 1 / 10:.3g}%" + + +def delete_folder(folder: pathlib.Path, config: PoodleConfig) -> None: + """Delete a folder.""" + if folder.exists() and not config.skip_delete_folder: + logger.info("delete %s", folder) + shutil.rmtree(folder) diff --git a/tests/data_types/test_data.py b/tests/data_types/test_data.py index 730f45c..59c04ae 100644 --- a/tests/data_types/test_data.py +++ b/tests/data_types/test_data.py @@ -54,6 +54,8 @@ class PoodleConfigStub(PoodleConfig): fail_under: float | None = None + skip_delete_folder: bool = False + class TestPoodleConfig: @staticmethod @@ -84,6 +86,7 @@ def create_poodle_config(): reporters=["summary"], reporter_opts={"summary": "value"}, fail_under=95.0, + skip_delete_folder=False, ) def test_poodle_config(self): diff --git a/tests/data_types/test_interfaces.py b/tests/data_types/test_interfaces.py index a8f4179..41b017b 100644 --- a/tests/data_types/test_interfaces.py +++ b/tests/data_types/test_interfaces.py @@ -54,7 +54,7 @@ def test_get_location(self): ], ) - assert Mutator.get_location(ast.parse(module).body[0]) == (1, 1, 3, 12) + assert Mutator.get_location(ast.parse(module).body[0]) == (1, 0, 3, 12) @mock.patch("ast.walk") def test_get_location_no_ends(self, walk): @@ -84,12 +84,28 @@ def test_get_location_walk_no_location(self, walk): assert Mutator.get_location(node) == (5, 8, 5, 12) @mock.patch("ast.walk") - def test_get_location_lt_lineno(self, walk): + def test_get_location_lt_lineno_eq_col(self, walk): node = mock.MagicMock(lineno=5, col_offset=8, end_lineno=5, end_col_offset=12) walk.return_value = [ - mock.MagicMock(lineno=4, col_offset=10, end_lineno=5, end_col_offset=8), + mock.MagicMock(lineno=4, col_offset=8, end_lineno=5, end_col_offset=8), ] - assert Mutator.get_location(node) == (4, 10, 5, 12) + assert Mutator.get_location(node) == (4, 8, 5, 12) + + @mock.patch("ast.walk") + def test_get_location_lt_lineno_gt_col(self, walk): + node = mock.MagicMock(lineno=5, col_offset=8, end_lineno=5, end_col_offset=12) + walk.return_value = [ + mock.MagicMock(lineno=4, col_offset=9, end_lineno=5, end_col_offset=8), + ] + assert Mutator.get_location(node) == (4, 8, 5, 12) + + @mock.patch("ast.walk") + def test_get_location_lt_lineno_lt_col(self, walk): + node = mock.MagicMock(lineno=5, col_offset=8, end_lineno=5, end_col_offset=12) + walk.return_value = [ + mock.MagicMock(lineno=4, col_offset=7, end_lineno=5, end_col_offset=8), + ] + assert Mutator.get_location(node) == (4, 7, 5, 12) @mock.patch("ast.walk") def test_get_location_eq_lineno_lt_col(self, walk): diff --git a/tests/mutators/test_calls.py b/tests/mutators/test_calls.py index e0f6b9e..e7520b9 100644 --- a/tests/mutators/test_calls.py +++ b/tests/mutators/test_calls.py @@ -178,13 +178,39 @@ class TestDecoratorMutator: def test_mutator_name(self): assert DecoratorMutator.mutator_name == "Decorator" - def test_decorator_mutator(self, mock_echo): + def test_decorator_mutator_single(self, mock_echo): + config = mock.MagicMock(mutator_opts={}) + mutator = DecoratorMutator(config=config, echo=mock_echo, other="value") + module = "\n".join( # noqa: FLY002 + [ + "class Example:", + " @dec.abc", + " def example(y):", + " return y", + ], + ) + file_mutants = mutator.create_mutations(ast.parse(module)) + + assert len(file_mutants) == 1 + + for mut in file_mutants: + assert mut.lineno == 2 + assert mut.end_lineno == 4 + assert mut.col_offset == 4 + assert mut.end_col_offset == 16 + + assert file_mutants[0].text.splitlines() == [ + "def example(y):", + " return y", + ] + + def test_decorator_mutator_multi(self, mock_echo): config = mock.MagicMock(mutator_opts={}) mutator = DecoratorMutator(config=config, echo=mock_echo, other="value") module = "\n".join( # noqa: FLY002 [ "@dec1", - "@dec2", + "@dec2(a)", "@dec1", "def example(y):", " return y", @@ -197,12 +223,12 @@ def test_decorator_mutator(self, mock_echo): for mut in file_mutants: assert mut.lineno == 1 assert mut.end_lineno == 5 - assert mut.col_offset == 1 + assert mut.col_offset == 0 assert mut.end_col_offset == 12 assert file_mutants[0].text == "\n".join( # noqa: FLY002 [ - "@dec2", + "@dec2(a)", "@dec1", "def example(y):", " return y", @@ -221,7 +247,7 @@ def test_decorator_mutator(self, mock_echo): assert file_mutants[2].text == "\n".join( # noqa: FLY002 [ "@dec1", - "@dec2", + "@dec2(a)", "def example(y):", " return y", ] diff --git a/tests/test_config.py b/tests/test_config.py index de5eff1..90e7392 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -460,6 +460,13 @@ def test_build_config_fail_under(self, get_float_from_config, get_config_file_da assert config_data.fail_under == get_float_from_config.return_value get_float_from_config.assert_any_call("fail_under", config_file_data, command_line=50) + @pytest.mark.usefixtures("_setup_build_config_mocks") + def test_build_config_skip_delete_folder(self, get_bool_from_config, get_config_file_data): + config_file_data = get_config_file_data.return_value + config_data = self.build_config_with() + assert config_data.skip_delete_folder == get_bool_from_config.return_value + get_bool_from_config.assert_any_call("skip_delete_folder", config_file_data, default=False) + @mock.patch("poodle.config.get_config_file_data") @mock.patch("poodle.config.get_project_info") def test_build_config_defaults(self, get_project_info, get_config_file_data): @@ -504,6 +511,7 @@ def test_build_config_defaults(self, get_project_info, get_config_file_data): reporters=["summary", "not_found"], reporter_opts={}, fail_under=None, + skip_delete_folder=False, ) diff --git a/tests/test_core.py b/tests/test_core.py index 62d9bb5..aa0bdbd 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -138,7 +138,7 @@ def test_main_process_setup( pprint_str.assert_called_once_with(config) logger_mock.info.assert_called_once_with("\n%s", pprint_str.return_value) - delete_folder.assert_called_with(config.work_folder) + delete_folder.assert_called_with(config.work_folder, config) assert delete_folder.call_count == 2 create_temp_zips.assert_called_once_with(work) @@ -350,28 +350,3 @@ def test_print_header_goal(self): mock.call(), ] ) - - -class TestDeleteFolder: - @pytest.fixture() - def shutil(self): - with mock.patch("poodle.core.shutil") as shutil: - yield shutil - - def test_delete_folder_exists(self, shutil, logger_mock): - folder = mock.MagicMock() - folder.exists.return_value = True - - core.delete_folder(folder) - - logger_mock.info.assert_called_once_with("delete %s", folder) - shutil.rmtree.assert_called_once_with(folder) - - def test_delete_folder_not_exists(self, shutil, logger_mock): - folder = mock.MagicMock() - folder.exists.return_value = False - - core.delete_folder(folder) - - logger_mock.info.assert_not_called() - shutil.rmtree.assert_not_called() diff --git a/tests/test_mutate.py b/tests/test_mutate.py index 562e5bb..790a7a1 100644 --- a/tests/test_mutate.py +++ b/tests/test_mutate.py @@ -1,3 +1,4 @@ +import importlib from unittest import mock import pytest @@ -7,6 +8,11 @@ from tests.data_types.test_data import PoodleConfigStub +@pytest.fixture(autouse=True) +def _reset(): + importlib.reload(mutate) + + @pytest.fixture() def logger_mock(): with mock.patch("poodle.mutate.logger") as logger_mock: @@ -36,16 +42,41 @@ def fake_mutator(*_, **__) -> list[FileMutation]: class TestInit: - @mock.patch("poodle.mutate.builtin_mutators") - def test_initialize_mutators(self, builtin_mutators): + def test_initialize_mutators(self): config = PoodleConfigStub(skip_mutators=["m2"], add_mutators=["tests.test_mutate.fake_mutator"]) work = PoodleWork(config) m1 = FakeMutator(config, echo=mock.MagicMock()) m2 = FakeMutator(config, echo=mock.MagicMock()) m3 = FakeMutator(config, echo=mock.MagicMock()) - builtin_mutators.items.return_value = [("m1", m1), ("m2", m2), ("m3", m3)] + mutate.builtin_mutators = {"m1": m1, "m2": m2, "m3": m3} assert mutate.initialize_mutators(work) == [m1, m3, fake_mutator] + def test_initialize_mutators_lower(self): + config = PoodleConfigStub(skip_mutators=["M1", "m2"], add_mutators=["tests.test_mutate.fake_mutator"]) + work = PoodleWork(config) + m1 = FakeMutator(config, echo=mock.MagicMock()) + m2 = FakeMutator(config, echo=mock.MagicMock()) + m3 = FakeMutator(config, echo=mock.MagicMock()) + mutate.builtin_mutators = {"m1": m1, "m2": m2, "m3": m3} + assert mutate.initialize_mutators(work) == [m3, fake_mutator] + + def test_initialize_mutators_all(self): + config = PoodleConfigStub(skip_mutators=["ALL"], add_mutators=["m2", "tests.test_mutate.fake_mutator"]) + work = PoodleWork(config) + m1 = FakeMutator(config, echo=mock.MagicMock()) + m2 = FakeMutator(config, echo=mock.MagicMock()) + m3 = FakeMutator(config, echo=mock.MagicMock()) + mutate.builtin_mutators = {"m1": m1, "m2": m2, "m3": m3} + assert mutate.initialize_mutators(work) == [m2, fake_mutator] + + def test_initialize_mutator_builtin(self, logger_mock): + config = PoodleConfigStub() + work = PoodleWork(config) + m1 = FakeMutator(config, echo=mock.MagicMock()) + mutate.builtin_mutators = {"m1": m1} + assert mutate.initialize_mutator(work, "m1") == m1 + logger_mock.debug.assert_called_with("m1") + def test_initialize_mutator_str_class(self, logger_mock): config = PoodleConfigStub() work = PoodleWork(config) diff --git a/tests/test_run.py b/tests/test_run.py index f0b7e48..fc471be 100644 --- a/tests/test_run.py +++ b/tests/test_run.py @@ -301,10 +301,10 @@ def create_mutant(self, folder, source_file): @mock.patch("poodle.run.logging") @mock.patch("poodle.run.ZipFile") @mock.patch("poodle.run.mutate_lines") - @mock.patch("poodle.run.shutil") + @mock.patch("poodle.run.delete_folder") def test_run_mutant_trial( self, - mock_shutil, + delete_folder, mutate_lines, zip_file_cls, mock_logging, @@ -361,7 +361,7 @@ def test_run_mutant_trial( timeout=10, ) - mock_shutil.rmtree.assert_called_with(run_folder) + delete_folder.assert_called_with(run_folder, config) mock_logger.debug.assert_any_call("END: run_id=%s - Elapsed Time %.2f s", "1", 2) @@ -370,10 +370,10 @@ def test_run_mutant_trial( @mock.patch("poodle.run.logging") @mock.patch("poodle.run.ZipFile") @mock.patch("poodle.run.mutate_lines") - @mock.patch("poodle.run.shutil") + @mock.patch("poodle.run.delete_folder") def test_run_mutant_trial_no_source( self, - mock_shutil, + delete_folder, mutate_lines, zip_file_cls, mock_logging, @@ -428,7 +428,7 @@ def test_run_mutant_trial_no_source( timeout=10, ) - mock_shutil.rmtree.assert_called_with(run_folder) + delete_folder.assert_called_with(run_folder, config) mock_logger.debug.assert_any_call("END: run_id=%s - Elapsed Time %.2f s", "1", 2) diff --git a/tests/test_util.py b/tests/test_util.py index ed421b2..d65f352 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -267,6 +267,36 @@ def test_create_unified_diff(self): ) assert util.create_unified_diff(mutant) == diff_str + def test_create_unified_diff_multiline(self): + mutant = Mutant( + mutator_name="Example", + lineno=2, + col_offset=2, + end_lineno=3, + end_col_offset=22, + text="Hello Poodle!\n3 Poodles are smart", + source_folder=mock.MagicMock(), + source_file=mock.MagicMock(), + ) + file_lines = [ + "1 The quick brown fox jumps over the lazy dog\n", + "2 Hello World!\n", + "3 Poodles are the best!\n", + ] + mutant.source_file.read_text.return_value.splitlines.return_value = file_lines + mutant.source_file.__str__.return_value = "src/example.py" + diff_str = ( + "--- src/example.py\n" + "+++ [Mutant] src/example.py:2\n" + "@@ -1,3 +1,3 @@\n" + " 1 The quick brown fox jumps over the lazy dog\n" + "-2 Hello World!\n" + "-3 Poodles are the best!\n" + "+2 Hello Poodle!\n" + "+3 Poodles are smart!\n" + ) + assert util.create_unified_diff(mutant) == diff_str + def test_create_unified_diff_no_file(self): mutant = Mutant( mutator_name="Example", @@ -297,3 +327,37 @@ class TestDisplayPercent: ) def test_display_percent(self, value, expected): assert util.display_percent(value) == expected + + +class TestDeleteFolder: + @pytest.fixture() + def shutil(self): + with mock.patch("poodle.util.shutil") as shutil: + yield shutil + + def test_delete_folder_exists(self, shutil, mock_logger): + folder = mock.MagicMock() + folder.exists.return_value = True + + util.delete_folder(folder, PoodleConfigStub(skip_delete_folder=False)) + + mock_logger.info.assert_called_once_with("delete %s", folder) + shutil.rmtree.assert_called_once_with(folder) + + def test_delete_folder_exists_skip(self, shutil, mock_logger): + folder = mock.MagicMock() + folder.exists.return_value = True + + util.delete_folder(folder, PoodleConfigStub(skip_delete_folder=True)) + + mock_logger.info.assert_not_called() + shutil.rmtree.assert_not_called() + + def test_delete_folder_not_exists(self, shutil, mock_logger): + folder = mock.MagicMock() + folder.exists.return_value = False + + util.delete_folder(folder, PoodleConfigStub(skip_delete_folder=False)) + + mock_logger.info.assert_not_called() + shutil.rmtree.assert_not_called()