diff --git a/HISTORY.rst b/HISTORY.rst index 05d8ba4c..dd8c776e 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -18,6 +18,7 @@ Unreleased * Invalidate cached results automatically when result-affecting config fields change +* Make `mutmut browse`, `show` and `apply` much faster and use far less memory, by storing where each mutant is in the mutated file instead of parsing the whole file to find it 3.6.0 ~~~~~ diff --git a/src/mutmut/__main__.py b/src/mutmut/__main__.py index f2fa8db3..8a1bc80e 100644 --- a/src/mutmut/__main__.py +++ b/src/mutmut/__main__.py @@ -33,7 +33,6 @@ from abc import ABC from collections import defaultdict from collections.abc import Callable -from collections.abc import Sequence from dataclasses import dataclass from dataclasses import field from datetime import datetime @@ -50,7 +49,7 @@ from os.path import isdir from os.path import isfile from pathlib import Path -from threading import Thread +from threading import Lock from time import process_time from types import TracebackType @@ -62,8 +61,10 @@ from mutmut.code_coverage import gather_coverage from mutmut.code_coverage import get_covered_lines_for_file from mutmut.configuration import Config +from mutmut.mutation.data import MutantLineSpans from mutmut.mutation.data import SourceFileMutationData from mutmut.mutation.file_mutation import FailedTypeCheckMutant +from mutmut.mutation.file_mutation import MutatedFile from mutmut.mutation.file_mutation import filter_mutants_with_type_checker from mutmut.mutation.file_mutation import mutate_file_contents from mutmut.mutation.trampoline_templates import CLASS_NAME_SEPARATOR @@ -319,12 +320,14 @@ def create_mutants_for_file(filename: Path, output_path: Path) -> FileMutationRe with open(output_path, "w") as out: try: - mutant_names, hash_by_function_name = write_all_mutants_to_file(out=out, source=source, filename=filename) + mutated_file = write_all_mutants_to_file(out=out, source=source, filename=filename) except cst.ParserSyntaxError as e: # if libcst cannot parse it, then copy the source without any mutations warnings.append(SyntaxWarning(f"Unsupported syntax in {filename} ({str(e)}), skipping")) out.write(source) - mutant_names, hash_by_function_name = [], {} + mutated_file = MutatedFile( + code=source, mutant_names=[], line_span_by_function_name={}, hash_by_function_name={} + ) # validate no syntax errors of mutants with open(output_path) as f: @@ -335,13 +338,15 @@ def create_mutants_for_file(filename: Path, output_path: Path) -> FileMutationRe invalid_syntax_error.__cause__ = e return FileMutationResult(warnings=warnings, error=invalid_syntax_error) + hash_by_function_name = mutated_file.hash_by_function_name + data = SourceFileMutationData(path=filename) data.load() old_hashes = data.hash_by_function_name changed = {f for f, h in hash_by_function_name.items() if old_hashes.get(f) != h} merged: dict[str, int | None] = {} - for name in mutant_names: + for name in mutated_file.mutant_names: key = get_mutant_name(filename, name) func = mangled_name_from_mutant_name(key).rpartition(".")[2] if func not in hash_by_function_name or func in changed: @@ -349,9 +354,11 @@ def create_mutants_for_file(filename: Path, output_path: Path) -> FileMutationRe else: merged[key] = data.exit_code_by_key.get(key) data.exit_code_by_key = merged - data.hash_by_function_name = hash_by_function_name + data.hash_by_function_name = dict(hash_by_function_name) data.save() + MutantLineSpans(path=filename, span_by_function_name=mutated_file.line_span_by_function_name).save() + current_hashes_qualified = {get_mutant_name(filename, func): h for func, h in hash_by_function_name.items()} changed_functions_qualified = {get_mutant_name(filename, func) for func in changed} @@ -362,13 +369,13 @@ def create_mutants_for_file(filename: Path, output_path: Path) -> FileMutationRe ) -def write_all_mutants_to_file(*, out: TextIOBase, source: str, filename: Path) -> tuple[Sequence[str], dict[str, str]]: - result, mutant_names, hash_by_function_name = mutate_file_contents( +def write_all_mutants_to_file(*, out: TextIOBase, source: str, filename: Path) -> MutatedFile: + mutated_file = mutate_file_contents( str(filename), source, get_covered_lines_for_file(str(filename), mutmut._covered_lines) ) - out.write(result) + out.write(mutated_file.code) - return mutant_names, hash_by_function_name + return mutated_file def unused(*_: object) -> None: @@ -1610,26 +1617,91 @@ def find_mutant(mutant_name: str) -> SourceFileMutationData: raise FileNotFoundError(f"Could not find mutant {mutant_name}") +def generated_function_names(mutant_name: str) -> tuple[str, str]: + """Get the names of the generated original and mutated function for a mutant. + + :return: A tuple of (name of the unmutated copy, name of the mutant).""" + generated_mutant_name = mutant_name.rpartition(".")[-1] + return mangled_name_from_mutant_name(generated_mutant_name) + "__mutmut_orig", generated_mutant_name + + +def read_functions_from_index(mutant_name: str, path: Path | str) -> tuple[cst.FunctionDef, cst.FunctionDef] | None: + """Read the unmutated copy and the mutant of a function from a mutated file, using the line span index. + + Only the lines of those two functions are parsed, instead of the whole mutated file. Mutated + files are often orders of magnitude larger than the file they were generated from, so this is + the difference between parsing a few lines and parsing tens of megabytes. + + Both functions are named after the function they were generated from, so that a diff of the + two shows only the mutation. + + :return: A tuple of (unmutated function, mutated function), or None if there is no usable index.""" + line_spans = MutantLineSpans.load(path) + if line_spans is None: + return None + + orig_name, generated_mutant_name = generated_function_names(mutant_name) + try: + sources = line_spans.read_function_sources([orig_name, generated_mutant_name]) + except (KeyError, ValueError): + # the index is incomplete or malformed, so fall back to parsing the file + return None + + orig_function_name, class_name = orig_function_and_class_names_from_key(mutant_name) + orig_source, mutant_source = sources + orig_function = parse_generated_function(orig_source, name=orig_name, is_method=class_name is not None) + mutant_function = parse_generated_function( + mutant_source, name=generated_mutant_name, is_method=class_name is not None + ) + if orig_function is None or mutant_function is None: + # the index does not match the mutated file, so fall back to parsing the file + return None + + return ( + orig_function.with_changes(name=cst.Name(orig_function_name)), + mutant_function.with_changes(name=cst.Name(orig_function_name)), + ) + + +def parse_generated_function(source: str, *, name: str, is_method: bool) -> cst.FunctionDef | None: + """Parse a function that was read out of a mutated file by itself. + + :param source: The source of the function, still indented as it was in the file. + :param is_method: Whether the function is a method, and therefore indented. + :return: The function, or None if `source` does not contain a function called `name`.""" + if is_method: + # the source is indented as a class body, so it needs a class to live in to parse + source = "class _:\n" + source + + try: + module = cst.parse_module(source) + except cst.ParserSyntaxError: + # the index points at lines that are not a function, so it must be out of date + return None + + function = find_top_level_function_or_method(module, name) + if function is None: + return None + + # comments and blank lines above the function became the module header when parsing it on its + # own, but they belong to the function + return function.with_changes(leading_lines=[*module.header, *function.leading_lines]) + + def get_diff_for_mutant( mutant_name: str, source: str | None = None, path: Path | str | None = None, ) -> str: if path is None: - m = find_mutant(mutant_name) - path = m.path - status = status_by_exit_code[m.exit_code_by_key[mutant_name]] - else: - status = "not checked" + path = find_mutant(mutant_name).path - print(f"# {mutant_name}: {status}") + functions = None if source is not None else read_functions_from_index(mutant_name, path) + if functions is None: + module = read_mutants_module(path) if source is None else cst.parse_module(source) + functions = (read_original_function(module, mutant_name), read_mutant_function(module, mutant_name)) - if source is None: - module = read_mutants_module(path) - else: - module = cst.parse_module(source) - orig_code = cst.Module([read_original_function(module, mutant_name)]).code.strip() - mutant_code = cst.Module([read_mutant_function(module, mutant_name)]).code.strip() + orig_code, mutant_code = (cst.Module([function]).code.strip() for function in functions) path_str = str(path) return "\n".join( @@ -1646,7 +1718,9 @@ def get_diff_for_mutant( @click.argument("mutant_name") def show(mutant_name: str) -> None: Config.ensure_loaded() - print(get_diff_for_mutant(mutant_name)) + m = find_mutant(mutant_name) + print(f"# {mutant_name}: {status_by_exit_code[m.exit_code_by_key[mutant_name]]}") + print(get_diff_for_mutant(mutant_name, path=m.path)) return @@ -1667,15 +1741,18 @@ def apply_mutant(mutant_name: str) -> None: orig_function_name = orig_function_name.rpartition(".")[-1] orig_module = read_orig_module(path) - mutants_module = read_mutants_module(path) - - mutant_function = read_mutant_function(mutants_module, mutant_name) - mutant_function = mutant_function.with_changes(name=cst.Name(orig_function_name)) original_function = find_top_level_function_or_method(orig_module, orig_function_name) if not original_function: raise FileNotFoundError(f"Could not apply mutant {mutant_name}") + functions_from_index = read_functions_from_index(mutant_name, path) + if functions_from_index is not None: + _, mutant_function = functions_from_index + else: + mutant_function = read_mutant_function(read_mutants_module(path), mutant_name) + mutant_function = mutant_function.with_changes(name=cst.Name(orig_function_name)) + new_module: cst.Module = orig_module.deep_replace(original_function, mutant_function) # type: ignore[arg-type] with open(path, "w") as f: @@ -1687,16 +1764,18 @@ def apply_mutant(mutant_name: str) -> None: def browse(show_killed: bool) -> None: Config.ensure_loaded() + from rich.console import RenderableType from rich.syntax import Syntax + from textual import work from textual.app import App from textual.containers import Container from textual.widget import Widget from textual.widgets import DataTable from textual.widgets import Footer from textual.widgets import Static + from textual.worker import get_current_worker class ResultBrowser(App[None]): - loading_id = None CSS_PATH = "result_browser_layout.tcss" BINDINGS = [ ("q", "quit()", "Quit"), @@ -1714,6 +1793,7 @@ class ResultBrowser(App[None]): cursor_type = "row" source_file_mutation_data_and_stat_by_path: dict[str, tuple[SourceFileMutationData, Stat]] = {} path_by_name: dict[str, Path] = {} + diff_load_lock = Lock() def compose(self) -> Iterable[Any]: with Container(classes="container"): @@ -1785,7 +1865,6 @@ def on_data_table_row_highlighted(self, event: Any) -> None: # noinspection PyTypeChecker description_view: Static = self.query_one("#description") # type: ignore[assignment] mutant_name = event.row_key.value - self.loading_id = mutant_name path = self.path_by_name.get(mutant_name) source_file_mutation_data, stat = self.source_file_mutation_data_and_stat_by_path[str(path)] @@ -1832,17 +1911,31 @@ def on_data_table_row_highlighted(self, event: Any) -> None: diff_view: Static = self.query_one("#diff_view") # type: ignore[assignment] diff_view.update("") - def load_thread() -> None: - Config.ensure_loaded() - try: - d = get_diff_for_mutant(event.row_key.value, path=path) - if event.row_key.value == self.loading_id: - diff_view.update(Syntax(d, "diff")) - except Exception as e: - diff_view.update(f"<{type(e)} {e}>") - - t = Thread(target=load_thread) - t.start() + self.load_diff(mutant_name, path, diff_view) + + @work(exclusive=True, thread=True, group="load_diff") + def load_diff(self, mutant_name: str, path: Path | None, diff_view: Static) -> None: + """Load the diff for a mutant and display it. + + Only one diff is loaded at a time, and moving on to another mutant cancels the loads + that have not started yet. Otherwise moving through a long list of mutants piles up + loads for diffs that are never going to be displayed.""" + worker = get_current_worker() + + with self.diff_load_lock: + if worker.is_cancelled: + return + + Config.ensure_loaded() + try: + update: RenderableType = Syntax(get_diff_for_mutant(mutant_name, path=path), "diff") + except Exception as e: + update = f"<{type(e)} {e}>" + + if worker.is_cancelled: + return + + self.call_from_thread(diff_view.update, update) def retest(self, pattern: str | None) -> None: if pattern is None: diff --git a/src/mutmut/mutation/data.py b/src/mutmut/mutation/data.py index fc7f583a..316d9c07 100644 --- a/src/mutmut/mutation/data.py +++ b/src/mutmut/mutation/data.py @@ -1,10 +1,102 @@ import json import os import signal +from collections.abc import Mapping +from collections.abc import Sequence +from dataclasses import dataclass from datetime import datetime +from itertools import islice from pathlib import Path +@dataclass(frozen=True) +class LineSpan: + """A range of lines in a file, 1-based and inclusive on both ends.""" + + start: int + end: int + + +class MutantLineSpans: + """Index of the lines each generated function occupies in a mutated file. + + Mutated files are much bigger than the sources they were generated from, so parsing one + just to display a single mutant is slow and memory hungry. This index is written when the + mutants are generated, which lets us read one function out of a mutated file without + parsing it at all. + """ + + format_version = 1 + + def __init__(self, *, path: Path | str, span_by_function_name: Mapping[str, LineSpan]) -> None: + self.path = path + self.mutants_path = Path("mutants") / str(path) + self.index_path = Path("mutants") / (str(path) + ".spans") + self.span_by_function_name = span_by_function_name + + @classmethod + def load(cls, path: Path | str) -> "MutantLineSpans | None": + """Read the index for `path`, or return None if there is none we understand. + + Mutants generated by an older version of mutmut have no index, and callers are + expected to fall back to parsing the mutated file in that case. + """ + index = cls(path=path, span_by_function_name={}) + try: + with open(index.index_path) as f: + data = json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + return None + + if data.get("version") != cls.format_version: + return None + + index.span_by_function_name = {name: LineSpan(start, end) for name, (start, end) in data["spans"].items()} + return index + + def save(self) -> None: + with open(self.index_path, "w") as f: + json.dump( + { + "version": self.format_version, + "spans": {name: [span.start, span.end] for name, span in self.span_by_function_name.items()}, + }, + f, + ) + + def read_function_sources(self, function_names: Sequence[str]) -> list[str]: + """Read the source of the named generated functions out of the mutated file. + + The source is returned exactly as it appears in the file, so the source of a method is + still indented. Raises KeyError if a name is not in the index. + """ + spans = [self.span_by_function_name[name] for name in function_names] + return read_line_spans(self.mutants_path, spans) + + +def read_line_spans(file_path: Path | str, spans: Sequence[LineSpan]) -> list[str]: + """Read the given line spans from a file in a single forward pass. + + The spans may be given in any order, but they must not overlap. + """ + sources: list[str] = [""] * len(spans) + + with open(file_path) as f: + line_number = 1 + for i in sorted(range(len(spans)), key=lambda i: spans[i].start): + span = spans[i] + if span.start < line_number: + raise ValueError(f"Overlapping spans in {file_path}: {spans}") + + # skip forward to the start of the span, then read it + for _ in islice(f, span.start - line_number): + pass + sources[i] = "".join(islice(f, span.end - span.start + 1)) + line_number = span.end + 1 + + return sources + + class SourceFileMutationData: def __init__(self, *, path: Path | str) -> None: self.estimated_time_of_tests_by_mutant: dict[str, float] = {} diff --git a/src/mutmut/mutation/file_mutation.py b/src/mutmut/mutation/file_mutation.py index f0bb9cff..cc8b0d76 100644 --- a/src/mutmut/mutation/file_mutation.py +++ b/src/mutmut/mutation/file_mutation.py @@ -8,6 +8,7 @@ from collections.abc import Mapping from collections.abc import Sequence from dataclasses import dataclass +from dataclasses import replace from pathlib import Path from typing import Union from typing import cast @@ -18,6 +19,7 @@ from libcst.metadata import PositionProvider from mutmut.configuration import Config +from mutmut.mutation.data import LineSpan from mutmut.mutation.mutators import OPERATORS_TYPE from mutmut.mutation.mutators import mutation_operators from mutmut.mutation.pragma_handling import IgnoredCode @@ -85,19 +87,25 @@ class Mutation: contained_by_top_level_function: cst.FunctionDef | None -def mutate_file_contents( - filename: str, code: str, covered_lines: set[int] | None = None -) -> tuple[str, Sequence[str], dict[str, str]]: - """Create mutations for `code` and merge them to a single mutated file with trampolines. +@dataclass(frozen=True) +class MutatedFile: + """The mutated version of a single source file.""" - :return: A tuple of (mutated code, list of mutant function names, hash by function name).""" - module, mutations, ignored_classes, ignored_functions = create_mutations(filename, code, covered_lines) + code: str + mutant_names: Sequence[str] + #: Where each generated function (the mutants and the `__mutmut_orig` copy) ended up in `code` + line_span_by_function_name: Mapping[str, LineSpan] + #: Hash of each mutated function, used to detect which functions changed between runs + hash_by_function_name: Mapping[str, str] - mutated_code, mutant_names = combine_mutations_to_source(module, mutations, ignored_classes, ignored_functions) - hash_by_function_name = _compute_mutated_function_hashes(code, module, mutations) +def mutate_file_contents(filename: str, code: str, covered_lines: set[int] | None = None) -> MutatedFile: + """Create mutations for `code` and merge them to a single mutated file with trampolines.""" + module, mutations, ignored_classes, ignored_functions = create_mutations(filename, code, covered_lines) + + mutated_file = combine_mutations_to_source(module, mutations, ignored_classes, ignored_functions) - return mutated_code, mutant_names, hash_by_function_name + return replace(mutated_file, hash_by_function_name=_compute_mutated_function_hashes(code, module, mutations)) def create_mutations( @@ -299,14 +307,15 @@ def combine_mutations_to_source( mutations: Sequence[Mutation], ignored_classes: set[str] | None = None, ignored_functions: set[str] | None = None, -) -> tuple[str, Sequence[str]]: +) -> MutatedFile: """Create mutated functions and trampolines for all mutations and compile them to a single source code. + The function hashes are left empty, as they are computed from the original source code. + :param module: The original parsed module. :param mutations: Mutations that should be applied. :param ignored_classes: Class names to skip transformation for (e.g., enums with pragma: no mutate class). - :param ignored_functions: Function names to skip transformation for (pragma: no mutate function). - :return: Mutated code and list of mutation names.""" + :param ignored_functions: Function names to skip transformation for (pragma: no mutate function).""" ignored_classes = ignored_classes or set() ignored_functions = ignored_functions or set() @@ -369,7 +378,75 @@ def combine_mutations_to_source( result.append(statement) mutated_module = module.with_changes(body=result) - return mutated_module.code, mutation_names + code, line_spans = render_and_collect_line_spans(mutated_module) + return MutatedFile( + code=code, + mutant_names=mutation_names, + line_span_by_function_name=line_spans, + hash_by_function_name={}, + ) + + +def render_and_collect_line_spans(module: cst.Module) -> tuple[str, dict[str, LineSpan]]: + """Render `module` and record which lines each generated function ended up on. + + Rendering statement by statement produces exactly the same code as `module.code` for the + same cost, and it tells us how many lines each statement takes up, which is what we need + for the line span index. Doing it this way means `mutmut show`/`browse`/`apply` can pick a + single function out of a mutated file without parsing it.""" + parts = [module.code_for_node(empty_line) for empty_line in module.header] + line_spans: dict[str, LineSpan] = {} + line = 1 + sum(part.count("\n") for part in parts) + + for statement in module.body: + part = module.code_for_node(statement) + collect_line_spans(module, statement, part, start_line=line, line_spans=line_spans) + parts.append(part) + line += part.count("\n") + + parts.extend(module.code_for_node(empty_line) for empty_line in module.footer) + + code = "".join(parts) + if not module.has_trailing_newline: + # `Module._codegen_impl` drops the last newline in this case + code = code.removesuffix(module.default_newline) + + return code, line_spans + + +def collect_line_spans( + module: cst.Module, + statement: MODULE_STATEMENT, + part: str, + *, + start_line: int, + line_spans: dict[str, LineSpan], +) -> None: + """Record the line spans of the generated functions in `statement`, which begins on `start_line`. + + :param part: The rendered code of `statement`, as produced by `module.code_for_node`.""" + if isinstance(statement, cst.FunctionDef): + if is_mutated_method_name(statement.name.value): + line_spans[statement.name.value] = LineSpan(start_line, start_line + part.count("\n") - 1) + return + + if not isinstance(statement, cst.ClassDef) or not isinstance(statement.body, cst.IndentedBlock): + return + + body = statement.body + if not any(isinstance(child, cst.FunctionDef) and is_mutated_method_name(child.name.value) for child in body.body): + # nothing was mutated in this class, so there is no need to render its methods + return + + child_parts = [module.code_for_node(child) for child in body.body] + # everything in the class that comes before the body: leading lines, decorators and the `class` statement + header_line_count = part.count("\n") - sum(child_part.count("\n") for child_part in child_parts) - len(body.footer) + + line = start_line + header_line_count + for child, child_part in zip(body.body, child_parts, strict=True): + if isinstance(child, cst.FunctionDef) and is_mutated_method_name(child.name.value): + line_spans[child.name.value] = LineSpan(line, line + child_part.count("\n") - 1) + line += child_part.count("\n") def function_trampoline_arrangement( diff --git a/tests/mutation/test_line_spans.py b/tests/mutation/test_line_spans.py new file mode 100644 index 00000000..0c4226b6 --- /dev/null +++ b/tests/mutation/test_line_spans.py @@ -0,0 +1,174 @@ +"""Tests for the line span index that lets us read a single function out of a mutated file.""" + +from pathlib import Path + +import libcst as cst +import pytest +from libcst.metadata import MetadataWrapper +from libcst.metadata import WhitespaceInclusivePositionProvider + +from mutmut.mutation.data import LineSpan +from mutmut.mutation.data import MutantLineSpans +from mutmut.mutation.data import read_line_spans +from mutmut.mutation.file_mutation import mutate_file_contents + +TRICKY_SOURCE = '''\ +# a comment above everything +import functools + + +@functools.cache +async def fetch(x=1): + return x + 1 + + +# a comment about the class +class Thing( + dict, +): + """A docstring mentioning def fetch( in passing.""" + + @property + def timeout(self): + return 30 + + def multiline( + self, + a=1, + ): + return a + 2 + + def with_unindented_string(self): + text = """ +def multiline( +""" + return text + str(3) +''' + + +def spans_from_libcst(code: str) -> dict[str, tuple[int, int]]: + """Get the line span of every function in `code`, according to libcst itself.""" + module = cst.parse_module(code) + positions = MetadataWrapper(module, unsafe_skip_copy=True).resolve(WhitespaceInclusivePositionProvider) + + spans = {} + for node, position in positions.items(): + if not isinstance(node, cst.FunctionDef): + continue + # libcst's whitespace inclusive end includes the trailing newline, which puts it on the + # next line, while our spans end on the last line that has content + end_line = position.end.line - 1 if position.end.column == 0 else position.end.line + spans[node.name.value] = (position.start.line, end_line) + return spans + + +@pytest.mark.parametrize( + "source", + [ + pytest.param("def f():\n return 1\n", id="single function"), + pytest.param("def f():\n return 1", id="no trailing newline"), + pytest.param("# leading comment\n\n\ndef f():\n return 1\n", id="leading comments"), + pytest.param("class C:\n def m(self):\n return 1\n", id="method"), + pytest.param("class C:\n\tdef m(self):\n\t\treturn 1\n", id="tabs"), + pytest.param("def f():\r\n return 1\r\n", id="crlf"), + pytest.param("class Klaß:\n def méthod(self):\n return 1\n", id="non-ascii names"), + pytest.param("def f():\n return 1\n\n\n# trailing comment\n", id="trailing comment"), + pytest.param(TRICKY_SOURCE, id="tricky"), + ], +) +def test_line_spans_match_the_generated_code(source: str): + mutated_file = mutate_file_contents("test.py", source) + + assert mutated_file.line_span_by_function_name, "expected at least one mutant" + assert mutated_file.line_span_by_function_name.keys() <= spans_from_libcst(mutated_file.code).keys() + + expected = spans_from_libcst(mutated_file.code) + for name, span in mutated_file.line_span_by_function_name.items(): + assert (span.start, span.end) == expected[name], f"wrong span for {name}" + + +def test_line_spans_cover_both_the_mutants_and_the_original(tmp_path: Path): + mutated_file = mutate_file_contents("test.py", "def f():\n return 1\n") + + names = set(mutated_file.line_span_by_function_name) + assert "x_f__mutmut_orig" in names + assert names == {"x_f__mutmut_orig", *mutated_file.mutant_names} + + +def test_read_function_sources(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + source = "class C:\n def m(self):\n return 1\n" + mutated_file = mutate_file_contents("test.py", source) + + monkeypatch.chdir(tmp_path) + (tmp_path / "mutants").mkdir() + (tmp_path / "mutants" / "test.py").write_text(mutated_file.code) + line_spans = MutantLineSpans(path="test.py", span_by_function_name=mutated_file.line_span_by_function_name) + line_spans.save() + + loaded = MutantLineSpans.load("test.py") + assert loaded is not None + assert loaded.span_by_function_name == dict(mutated_file.line_span_by_function_name) + + (orig, mutant) = loaded.read_function_sources(["xǁCǁm__mutmut_orig", "xǁCǁm__mutmut_1"]) + # methods are read as they appear in the file, so they are still indented + assert orig == " def xǁCǁm__mutmut_orig(self):\n return 1\n" + assert mutant == " def xǁCǁm__mutmut_1(self):\n return 2\n" + + with pytest.raises(KeyError): + loaded.read_function_sources(["x_does_not_exist__mutmut_1"]) + + +def test_load_returns_none_without_a_usable_index(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "mutants").mkdir() + + # mutants generated by an older version of mutmut have no index at all + assert MutantLineSpans.load("test.py") is None + + (tmp_path / "mutants" / "test.py.spans").write_text("this is not json") + assert MutantLineSpans.load("test.py") is None + + (tmp_path / "mutants" / "test.py.spans").write_text('{"version": 999, "spans": {}}') + assert MutantLineSpans.load("test.py") is None + + +def test_a_stale_index_is_ignored(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + """An index that does not match the mutated file must not produce a bogus result.""" + from mutmut.__main__ import read_functions_from_index + + source = "def f():\n return 1\n" + mutated_file = mutate_file_contents("test.py", source) + + monkeypatch.chdir(tmp_path) + (tmp_path / "mutants").mkdir() + (tmp_path / "mutants" / "test.py").write_text(mutated_file.code) + + names = ["x_f__mutmut_orig", "x_f__mutmut_1"] + stale_spans = [ + # spans pointing at lines that are not a function at all + dict.fromkeys(names, LineSpan(1, 3)), + {name: LineSpan(i * 2 + 1, i * 2 + 2) for i, name in enumerate(names)}, + # a span for the mutant, but not for the function it mutates + {"x_f__mutmut_1": mutated_file.line_span_by_function_name["x_f__mutmut_1"]}, + ] + for span_by_function_name in stale_spans: + MutantLineSpans(path="test.py", span_by_function_name=span_by_function_name).save() + assert read_functions_from_index("test.x_f__mutmut_1", "test.py") is None + + # and with a good index it does find the functions + MutantLineSpans(path="test.py", span_by_function_name=mutated_file.line_span_by_function_name).save() + assert read_functions_from_index("test.x_f__mutmut_1", "test.py") is not None + + +def test_read_line_spans(tmp_path: Path): + path = tmp_path / "lines.txt" + path.write_text("".join(f"line {i}\n" for i in range(1, 11))) + + assert read_line_spans(path, [LineSpan(1, 2)]) == ["line 1\nline 2\n"] + assert read_line_spans(path, [LineSpan(10, 10)]) == ["line 10\n"] + + # several spans are read in one pass, in whatever order they are given + assert read_line_spans(path, [LineSpan(9, 10), LineSpan(2, 3)]) == ["line 9\nline 10\n", "line 2\nline 3\n"] + + with pytest.raises(ValueError): + read_line_spans(path, [LineSpan(1, 5), LineSpan(3, 6)]) diff --git a/tests/mutation/test_mutation.py b/tests/mutation/test_mutation.py index e67786c0..6c602d98 100644 --- a/tests/mutation/test_mutation.py +++ b/tests/mutation/test_mutation.py @@ -4,6 +4,8 @@ import subprocess import tempfile from collections import defaultdict +from collections.abc import Sequence +from pathlib import Path from unittest.mock import Mock from unittest.mock import patch @@ -20,6 +22,7 @@ from mutmut.__main__ import _refresh_change_detection_baseline from mutmut.__main__ import _report_watched_file_changes from mutmut.__main__ import _reset_mutant_results +from mutmut.__main__ import apply_mutant from mutmut.__main__ import compute_watched_file_hashes from mutmut.__main__ import get_diff_for_mutant from mutmut.__main__ import git_changed_non_py_files @@ -30,6 +33,7 @@ from mutmut.__main__ import record_trampoline_hit from mutmut.__main__ import run_forced_fail_test from mutmut.configuration import Config +from mutmut.mutation.data import MutantLineSpans from mutmut.mutation.data import SourceFileMutationData from mutmut.mutation.file_mutation import compute_function_hashes from mutmut.mutation.file_mutation import create_mutations @@ -49,8 +53,7 @@ def mutants_for_source(source: str, covered_lines: set[int] | None = None) -> li def mutated_module(source: str) -> str: - mutated_code, _, _ = mutate_file_contents("", source) - return mutated_code + return mutate_file_contents("", source).code @pytest.mark.parametrize( @@ -819,7 +822,8 @@ def member(self): """.strip() - mutants_source, mutant_names, _ = mutate_file_contents("filename", source) + mutated_file = mutate_file_contents("filename", source) + mutants_source, mutant_names = mutated_file.code, mutated_file.mutant_names assert len(mutant_names) == 2 diff1 = get_diff_for_mutant(mutant_name=mutant_names[0], source=mutants_source, path="test.py").strip() @@ -850,6 +854,97 @@ def member(self): ) +DIFF_SOURCE = '''\ +import functools + + +# a comment above the function +async def foo( + a=1, +): + return a + 1 + + +class Foo: + """Docstring.""" + + def member(self): + return 3 + + def with_unindented_string(self): + text = """ +def member( +""" + return text + str(4) +''' + + +def write_mutants_dir(directory: Path, source: str, *, write_line_spans: bool) -> Sequence[str]: + """Set up a project with a mutants directory the way `mutmut run` does, in the current directory. + + :return: The names of the mutants that were generated.""" + (directory / "pyproject.toml").write_text('[tool.mutmut]\nsource_paths = ["test.py"]\n') + (directory / "test.py").write_text(source) + + mutated_file = mutate_file_contents("test.py", source) + (directory / "mutants").mkdir() + (directory / "mutants" / "test.py").write_text(mutated_file.code) + if write_line_spans: + MutantLineSpans(path="test.py", span_by_function_name=mutated_file.line_span_by_function_name).save() + + # `apply_mutant` and `show` look mutants up in the meta file + source_file_mutation_data = SourceFileMutationData(path="test.py") + mutant_names = [get_mutant_name(Path("test.py"), name) for name in mutated_file.mutant_names] + source_file_mutation_data.exit_code_by_key = dict.fromkeys(mutant_names) + source_file_mutation_data.save() + + return mutant_names + + +def test_diff_from_line_span_index_matches_diff_from_parsing_the_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + """The line span index is a shortcut, so it has to produce exactly the same diff.""" + with_index = tmp_path / "with_index" + without_index = tmp_path / "without_index" + with_index.mkdir() + without_index.mkdir() + + monkeypatch.chdir(with_index) + mutant_names = write_mutants_dir(with_index, DIFF_SOURCE, write_line_spans=True) + assert MutantLineSpans.load("test.py") is not None + fast_diffs = [get_diff_for_mutant(name, path="test.py") for name in mutant_names] + + # without the index, mutmut falls back to parsing the whole mutated file + monkeypatch.chdir(without_index) + assert write_mutants_dir(without_index, DIFF_SOURCE, write_line_spans=False) == mutant_names + assert MutantLineSpans.load("test.py") is None + slow_diffs = [get_diff_for_mutant(name, path="test.py") for name in mutant_names] + + # mutants of a top level function, of a method, and of a method containing an unindented string + assert len(fast_diffs) == 8 + assert fast_diffs == slow_diffs + for diff in fast_diffs: + assert diff.strip(), "expected every mutant to differ from the original" + + +@pytest.mark.parametrize("mutant_index", [0, -1], ids=["top level function", "method"]) +def test_apply_mutant_from_line_span_index(mutant_index: int, tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + """Applying a mutant must not depend on whether the line span index is there.""" + applied = {} + for write_line_spans in (True, False): + directory = tmp_path / f"line_spans_{write_line_spans}" + directory.mkdir() + monkeypatch.chdir(directory) + mutant_names = write_mutants_dir(directory, DIFF_SOURCE, write_line_spans=write_line_spans) + + apply_mutant(mutant_names[mutant_index]) + applied[write_line_spans] = (directory / "test.py").read_text() + + assert applied[True] == applied[False] + assert applied[True] != DIFF_SOURCE + # the mutated function keeps its name, and the comment above it is left alone + assert "# a comment above the function\nasync def foo(\n" in applied[True] + + def test_from_future_still_first(): source = """ from __future__ import annotations @@ -1088,7 +1183,8 @@ def foo(): def bar(): return 2 """.strip() - _, mutant_names, hashes = mutate_file_contents("test.py", source) + mutated_file = mutate_file_contents("test.py", source) + mutant_names, hashes = mutated_file.mutant_names, mutated_file.hash_by_function_name assert mutant_names # every mutated function appears in the hashes @@ -1120,20 +1216,22 @@ def bar(): src_path = pathlib.Path(tmp) / "mymod.py" src_path.write_text(source_v1) - _, mutant_names_v1, hashes_v1 = mutate_file_contents("mymod.py", source_v1) + mutated_v1 = mutate_file_contents("mymod.py", source_v1) + mutant_names_v1, hashes_v1 = mutated_v1.mutant_names, mutated_v1.hash_by_function_name data = SourceFileMutationData(path=src_path) data.exit_code_by_key = {} for name in mutant_names_v1: key = get_mutant_name(src_path, name) data.exit_code_by_key[key] = 1 # fake "killed" - data.hash_by_function_name = hashes_v1 + data.hash_by_function_name = dict(hashes_v1) data.meta_path = pathlib.Path(tmp) / "mutants" / (str(src_path) + ".meta") data.meta_path.parent.mkdir(parents=True, exist_ok=True) data.save() # simulate second run with foo changed - _, mutant_names_v2, hashes_v2 = mutate_file_contents("mymod.py", source_v2) + mutated_v2 = mutate_file_contents("mymod.py", source_v2) + mutant_names_v2, hashes_v2 = mutated_v2.mutant_names, mutated_v2.hash_by_function_name prior = SourceFileMutationData(path=src_path) prior.meta_path = data.meta_path @@ -1177,7 +1275,8 @@ def method(self): src_path = pathlib.Path(tmp) / "mymod.py" - _, mutant_names_v1, hashes_v1 = mutate_file_contents("mymod.py", source_v1) + mutated_v1 = mutate_file_contents("mymod.py", source_v1) + mutant_names_v1, hashes_v1 = mutated_v1.mutant_names, mutated_v1.hash_by_function_name assert mutant_names_v1, "expected at least one method mutant" data = SourceFileMutationData(path=src_path) @@ -1185,12 +1284,13 @@ def method(self): for name in mutant_names_v1: key = get_mutant_name(src_path, name) data.exit_code_by_key[key] = 1 - data.hash_by_function_name = hashes_v1 + data.hash_by_function_name = dict(hashes_v1) data.meta_path = pathlib.Path(tmp) / "mutants" / (str(src_path) + ".meta") data.meta_path.parent.mkdir(parents=True, exist_ok=True) data.save() - _, mutant_names_v2, hashes_v2 = mutate_file_contents("mymod.py", source_v2) + mutated_v2 = mutate_file_contents("mymod.py", source_v2) + mutant_names_v2, hashes_v2 = mutated_v2.mutant_names, mutated_v2.hash_by_function_name prior = SourceFileMutationData(path=src_path) prior.meta_path = data.meta_path diff --git a/tests/mutation/test_mutation_runtime.py b/tests/mutation/test_mutation_runtime.py index e51521db..35b5efb0 100644 --- a/tests/mutation/test_mutation_runtime.py +++ b/tests/mutation/test_mutation_runtime.py @@ -20,7 +20,8 @@ def describe(self): return self.name.lower() """.strip() - mutated_code, mutant_names, _ = mutate_file_contents("test.py", source) + mutated_file = mutate_file_contents("test.py", source) + mutated_code, mutant_names = mutated_file.code, mutated_file.mutant_names assert len(mutant_names) > 0, "Should have at least one mutant" monkeypatch.setenv("MUTANT_UNDER_TEST", "none") @@ -65,7 +66,8 @@ def from_name(cls, name: str) -> "Color": return vals[name] """.strip() - mutated_code, mutant_names, _ = mutate_file_contents("test.py", source) + mutated_file = mutate_file_contents("test.py", source) + mutated_code, mutant_names = mutated_file.code, mutated_file.mutant_names assert len(mutant_names) > 0, "Should have at least one mutant" monkeypatch.setenv("MUTANT_UNDER_TEST", "none") @@ -88,7 +90,8 @@ def add(a, b): return a + b """.strip() - mutated_code, mutant_names, _ = mutate_file_contents("test.py", source) + mutated_file = mutate_file_contents("test.py", source) + mutated_code, mutant_names = mutated_file.code, mutated_file.mutant_names assert len(mutant_names) > 0, "Should have at least one mutant" monkeypatch.setenv("MUTANT_UNDER_TEST", "none") @@ -117,7 +120,8 @@ def __init__(self, value): self.value = value """.strip() - mutated_code, mutant_names, _ = mutate_file_contents("test.py", source) + mutated_file = mutate_file_contents("test.py", source) + mutated_code, mutant_names = mutated_file.code, mutated_file.mutant_names assert len(mutant_names) > 0, "Should have at least one mutant" monkeypatch.setenv("MUTANT_UNDER_TEST", "none") @@ -140,7 +144,8 @@ def foo(a: int, b: int = 2): return a + b """.strip() - mutated_code, mutant_names, _ = mutate_file_contents("test.py", source) + mutated_file = mutate_file_contents("test.py", source) + mutated_code, mutant_names = mutated_file.code, mutated_file.mutant_names assert len(mutant_names) > 0, "Should have at least one mutant" monkeypatch.setenv("MUTANT_UNDER_TEST", "none") diff --git a/tests/test_mutation regression.py b/tests/test_mutation regression.py index 81766649..b324f38b 100644 --- a/tests/test_mutation regression.py +++ b/tests/test_mutation regression.py @@ -48,7 +48,7 @@ def default(cls) -> "Color": print(Adder(1).add(2))""" - src, _, _ = mutate_file_contents("file.py", source) + src = mutate_file_contents("file.py", source).code assert src == snapshot("""\ from __future__ import division