diff --git a/HISTORY.rst b/HISTORY.rst index 05d8ba4c..eba5ea0b 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -4,6 +4,8 @@ Changelog Unreleased ~~~~~~~~~~ +* Fix the trampoline dropping a generator's return value, so ``yield from`` on a mutated generator no longer yields ``None`` instead of its result + * Per-function source hashing for incremental cache invalidation — only re-test mutants in functions that changed * Cross-call dependency tracking — invalidate mutants in callers when a called function changes diff --git a/src/mutmut/mutation/trampoline.py b/src/mutmut/mutation/trampoline.py index d70f98c4..7cbd4cf4 100644 --- a/src/mutmut/mutation/trampoline.py +++ b/src/mutmut/mutation/trampoline.py @@ -93,7 +93,10 @@ def trampoline(*args: P.args, **kwargs: P.kwargs) -> R: @wraps(decorated_func) def _trampoline_wrapper(*args: P.args, **kwargs: P.kwargs) -> R: # type: ignore - yield from trampoline(*args, **kwargs) # type: ignore + # ``return`` the delegation result so a generator's StopIteration value + # (``return`` inside the generator) is forwarded to the caller's + # ``yield from``, matching the PEP 380 expansion. + return (yield from trampoline(*args, **kwargs)) # type: ignore elif inspect.iscoroutinefunction(decorated_func): @wraps(decorated_func) diff --git a/tests/mutation/test_trampoline.py b/tests/mutation/test_trampoline.py index bd81bf56..e76fcbf9 100644 --- a/tests/mutation/test_trampoline.py +++ b/tests/mutation/test_trampoline.py @@ -13,6 +13,7 @@ mutants_simple_func = {} mutants_generator_func = {} +mutants_returning_generator_func = {} mutants_async_func = {} mutants_async_generator_func = {} mutants_cleanup_async_gen = {} @@ -53,6 +54,25 @@ def generator_func_1(numbers: list[int]): yield n * 3 +@wrap_in_trampoline(mutants_returning_generator_func) +def returning_generator_func(numbers: list[int]): + for n in numbers: + yield n * 2 + return "done" + + +def returning_generator_func_orig(numbers: list[int]): + for n in numbers: + yield n * 2 + return "done" + + +def returning_generator_func_1(numbers: list[int]): + for n in numbers: + yield n * 3 + return "done" + + @wrap_in_trampoline(mutants_async_func) async def async_func(a: int, b: int): await asyncio.sleep(0) @@ -199,6 +219,8 @@ def __init__(self, number: int): mutants_simple_func["simple_func__mutmut_1"] = simple_func_1 mutants_generator_func["_mutmut_orig"] = generator_func_orig mutants_generator_func["generator_func__mutmut_1"] = generator_func_1 +mutants_returning_generator_func["_mutmut_orig"] = returning_generator_func_orig +mutants_returning_generator_func["returning_generator_func__mutmut_1"] = returning_generator_func_1 mutants_async_func["_mutmut_orig"] = async_func_orig mutants_async_func["async_func__mutmut_1"] = async_func_1 mutants_async_generator_func["_mutmut_orig"] = async_generator_func_orig @@ -240,6 +262,24 @@ def test_generator_func_mutated(self, monkeypatch): monkeypatch.setenv("MUTANT_UNDER_TEST", "test_trampoline.generator_func__mutmut_1") assert list(generator_func([1, 2, 3])) == [3, 6, 9], "Should call mutated func" + def test_generator_func_return_value_original(self, monkeypatch): + monkeypatch.setenv("MUTANT_UNDER_TEST", "") + + def consume(): + result = yield from returning_generator_func([1, 2, 3]) + yield result + + assert list(consume()) == [2, 4, 6, "done"], "Should forward the return value" + + def test_generator_func_return_value_mutated(self, monkeypatch): + monkeypatch.setenv("MUTANT_UNDER_TEST", "test_trampoline.returning_generator_func__mutmut_1") + + def consume(): + result = yield from returning_generator_func([1, 2, 3]) + yield result + + assert list(consume()) == [3, 6, 9, "done"], "Should forward the return value" + @pytest.mark.asyncio async def test_async_func_original(self, monkeypatch): monkeypatch.setenv("MUTANT_UNDER_TEST", "")