Skip to content

Commit 4425516

Browse files
committed
support signatures with PEP 649 deferred annotations
1 parent 431a597 commit 4425516

File tree

13 files changed

+211
-11
lines changed

13 files changed

+211
-11
lines changed

e2e_projects/my_lib/src/my_lib/__init__.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,3 +97,17 @@ def create_a_segfault_when_mutated():
9797
# when we mutate False->True, then this will segfault
9898
if False:
9999
ctypes.string_at(0)
100+
101+
def some_func_clone(a, b: str = "111", c: Callable[[str], int] | None = None) -> int | None: pass # pragma: no mutate
102+
def some_func(a, b: str = "111", c: Callable[[str], int] | None = None) -> int | None:
103+
if a and c:
104+
return c(b)
105+
return None
106+
107+
def func_with_star_clone(a, *, b, **kwargs): pass # pragma: no mutate
108+
def func_with_star(a, *, b, **kwargs):
109+
return a + b + len(kwargs)
110+
111+
def func_with_arbitrary_args_clone(*args, **kwargs): pass # pragma: no mutate
112+
def func_with_arbitrary_args(*args, **kwargs):
113+
return len(args) + len(kwargs)

e2e_projects/my_lib/tests/test_my_lib.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
from my_lib import hello, Point, badly_tested, make_greeter, fibonacci, cached_fibonacci, escape_sequences, simple_consumer, async_consumer, create_a_segfault_when_mutated
1+
import inspect
2+
from my_lib import *
23
import pytest
34

45
"""These tests are flawed on purpose, some mutants survive and some are killed."""
@@ -47,3 +48,13 @@ async def test_async_consumer():
4748

4849
def test_handles_segfaults():
4950
create_a_segfault_when_mutated()
51+
52+
def test_that_signatures_are_preserved():
53+
assert inspect.signature(some_func) == inspect.signature(some_func_clone)
54+
assert inspect.signature(func_with_star) == inspect.signature(func_with_star_clone)
55+
assert inspect.signature(func_with_arbitrary_args) == inspect.signature(func_with_arbitrary_args_clone)
56+
57+
def test_signature_functions_are_callable():
58+
assert some_func(True, c=lambda s: int(s), b="222") == 222
59+
assert func_with_star(1, b=2, x='x', y='y', z='z') == 6
60+
assert func_with_arbitrary_args('a', 'b', foo=123, bar=456) == 4
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
This project will be E2E tested. It mainly serves as a "canary" that alerts you when code changes affect which mutants survive.
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
[project]
2+
name = "py3-14-features"
3+
version = "0.1.0"
4+
description = "Add your description here"
5+
readme = "README.md"
6+
authors = []
7+
requires-python = ">=3.14"
8+
dependencies = []
9+
10+
[build-system]
11+
requires = ["hatchling"]
12+
build-backend = "hatchling.build"
13+
14+
[dependency-groups]
15+
dev = [
16+
"pytest>=8.3.5",
17+
]
18+
19+
[tool.mutmut]
20+
debug = true
21+
22+
[tool.pytest.ini_options]
23+
asyncio_default_fixture_loop_scope = "function"
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
from typing import TYPE_CHECKING
2+
from dataclasses import dataclass
3+
4+
if TYPE_CHECKING:
5+
from collections.abc import Collection
6+
7+
# verify that mutmut can handle type-check-only annotations
8+
def get_len(data: Collection):
9+
# (the + 0 is just so we get a surviving and a killed mutant; not relevant for this test case)
10+
return len(data) + 0
11+
12+
def get_len_clone(data: Collection): pass # pragma: no mutate
13+
14+
15+
# verify that mutmut can handle annotations that area
16+
def get_foo_len(data: Foo) -> int:
17+
return len(data.foo) + 0
18+
19+
def get_foo_len_clone(data: Foo) -> int: pass # pragma: no mutate
20+
21+
@dataclass
22+
class Foo:
23+
foo: list[str]
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import annotationlib
2+
import inspect
3+
from py3_14_features import get_len, get_foo_len_clone, get_foo_len, get_len_clone, Foo
4+
5+
def test_func_with_type_only_annotation():
6+
assert get_len([1, 2, 3]) == 3
7+
8+
def test_lazy_loaded_signature():
9+
assert get_foo_len(Foo(["abc", "def"])) == 2
10+
11+
def test_annotations():
12+
# the get_len trampoline should have the correct annotations
13+
assert annotationlib.get_annotations(get_len, format=annotationlib.Format.STRING) == {'data': 'Collection'}
14+
15+
# 'Foo' should be available at this point, so we do not need the STRING format
16+
assert annotationlib.get_annotations(get_foo_len) == annotationlib.get_annotations(get_foo_len_clone)
17+
18+
def test_signature():
19+
# mutmut currently only achieves a stringified version, because we cannot eagerly evalute the signature
20+
assert inspect.signature(get_len, annotation_format=inspect.Format.STRING) == inspect.signature(get_len_clone, annotation_format=inspect.Format.STRING)
21+

e2e_projects/py3_14_features/uv.lock

Lines changed: 69 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/mutmut/trampoline_templates.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ def {orig_name}({'self, ' if class_name is not None else ''}*args, **kwargs):
2121
result = {trampoline_name}({access_prefix}{mangled_name}__mutmut_orig{access_suffix}, {access_prefix}{mangled_name}__mutmut_mutants{access_suffix}, args, kwargs{self_arg})
2222
return result
2323
24-
{orig_name}.__signature__ = _mutmut_signature({mangled_name}__mutmut_orig)
24+
_mutmut_copy_signature({orig_name}, {mangled_name}__mutmut_orig)
2525
{mangled_name}__mutmut_orig.__name__ = '{mangled_name}'
2626
"""
2727

@@ -37,11 +37,27 @@ def mangle_function_name(*, name, class_name):
3737
# noinspection PyUnresolvedReferences
3838
# language=python
3939
trampoline_impl = """
40-
from inspect import signature as _mutmut_signature
40+
import inspect as _mutmut_inspect
41+
import sys as _mutmut_sys
4142
from typing import Annotated
4243
from typing import Callable
4344
from typing import ClassVar
4445
46+
def _mutmut_copy_signature(trampoline, original_method):
47+
if _mutmut_sys.version_info >= (3, 14):
48+
# PEP 649 introduced deferred loading for annotations
49+
# When some_method.__annotations__ is accessed, Python uses some_method.__annotate__(format) to compute it
50+
# By copying the original __annotate__ method, we provide get the original annotations
51+
trampoline.__annotate__ = original_method.__annotate__
52+
53+
try:
54+
trampoline.__signature__ = _mutmut_inspect.signature(original_method)
55+
except NameError:
56+
# Also, because of PEP 649, it can happen that we cannot eagerly evaluate the signature
57+
# In this case, fall back to stringifying the signature (which could cause different behaviour with runtime introspection)
58+
trampoline.__signature__ = _mutmut_inspect.signature(original_method, annotation_format=_mutmut_inspect.Format.STRING)
59+
60+
4561
4662
MutantDict = Annotated[dict[str, Callable], "Mutant"]
4763

tests/e2e/snapshots/my_lib.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,12 @@
6464
"my_lib.x_escape_sequences__mutmut_5": 0,
6565
"my_lib.x_create_a_segfault_when_mutated__mutmut_1": -11,
6666
"my_lib.x_create_a_segfault_when_mutated__mutmut_2": 0,
67-
"my_lib.x_create_a_segfault_when_mutated__mutmut_3": 0
67+
"my_lib.x_create_a_segfault_when_mutated__mutmut_3": 0,
68+
"my_lib.x_some_func__mutmut_1": 0,
69+
"my_lib.x_some_func__mutmut_2": 0,
70+
"my_lib.x_some_func__mutmut_3": 1,
71+
"my_lib.x_func_with_star__mutmut_1": 1,
72+
"my_lib.x_func_with_star__mutmut_2": 1,
73+
"my_lib.x_func_with_arbitrary_args__mutmut_1": 1
6874
}
6975
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"mutants/src/py3_14_features/__init__.py.meta": {
3+
"py3_14_features.x_get_len__mutmut_1": 0,
4+
"py3_14_features.x_get_len__mutmut_2": 1,
5+
"py3_14_features.x_get_foo_len__mutmut_1": 0,
6+
"py3_14_features.x_get_foo_len__mutmut_2": 1
7+
}
8+
}

0 commit comments

Comments
 (0)