Skip to content

Commit

Permalink
Merge pull request #27 from WiredNerd/dev
Browse files Browse the repository at this point in the history
🔧 Fix problem when mutating a function with decorators
  • Loading branch information
WiredNerd committed Jan 8, 2024
2 parents b6e6e0a + 6f91038 commit 3b64cbd
Show file tree
Hide file tree
Showing 18 changed files with 217 additions and 72 deletions.
5 changes: 4 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion src/poodle/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from pathlib import Path
from typing import Any

__version__ = "1.3.1"
__version__ = "1.3.2"


class PoodleTestingFailedError(Exception):
Expand Down
1 change: 1 addition & 0 deletions src/poodle/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
)


Expand Down
26 changes: 11 additions & 15 deletions src/poodle/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand All @@ -25,14 +20,22 @@ 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)
work.runner = get_runner(config)
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")
Expand All @@ -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)
Expand Down Expand Up @@ -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)
3 changes: 2 additions & 1 deletion src/poodle/data_types/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -65,6 +64,8 @@ class PoodleConfig:

fail_under: float | None

skip_delete_folder: bool | None


@dataclass
class FileMutation:
Expand Down
13 changes: 11 additions & 2 deletions src/poodle/data_types/interfaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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]
Expand Down
10 changes: 7 additions & 3 deletions src/poodle/mutate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion src/poodle/mutators/calls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
5 changes: 2 additions & 3 deletions src/poodle/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

import concurrent.futures
import logging
import shutil
import time
from typing import TYPE_CHECKING, Callable
from zipfile import ZipFile
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down
11 changes: 10 additions & 1 deletion src/poodle/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import difflib
import json
import logging
import shutil
from copy import deepcopy
from io import StringIO
from pprint import pprint
Expand Down Expand Up @@ -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}",
)
Expand All @@ -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)
3 changes: 3 additions & 0 deletions tests/data_types/test_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ class PoodleConfigStub(PoodleConfig):

fail_under: float | None = None

skip_delete_folder: bool = False


class TestPoodleConfig:
@staticmethod
Expand Down Expand Up @@ -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):
Expand Down
24 changes: 20 additions & 4 deletions tests/data_types/test_interfaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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):
Expand Down
36 changes: 31 additions & 5 deletions tests/mutators/test_calls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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",
]
Expand Down
8 changes: 8 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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,
)


Expand Down
Loading

0 comments on commit 3b64cbd

Please sign in to comment.