Repository: boxed/mutmut

Issue:
Incompatibility with Pydantic model which contains a method
For the following minimal example:
```python
from pydantic import BaseModel

class Model(BaseModel):
    max_retries: int = 2

    def get_max_tries(self) -> int:
        return self.max_retries + 1
```

running `mutmut run` produces error:
```
> mutmut run
⠦ Generating mutants
    done in 1ms
⠇ Running stats     
=========================================================================================================================================================================================================== ERRORS ============================================================================================================================================================================================================
_____________________________________________________________________________________________________________________________________________________________________________________________ ERROR collecting tests/test_main.py _____________________________________________________________________________________________________________________________________________________________________________________________
tests/test_main.py:1: in <module>
    from src.main import Model
src/main.py:49: in <module>
    class Model(BaseModel):
../.venv/lib/python3.12/site-packages/pydantic/_internal/_model_construction.py:113: in __new__
    private_attributes = inspect_namespace(
../.venv/lib/python3.12/site-packages/pydantic/_internal/_model_construction.py:506: in inspect_namespace
    raise PydanticUserError(
E   pydantic.errors.PydanticUserError: A non-annotated attribute was detected: `xǁModelǁget_max_tries__mutmut_mutants = {'xǁModelǁget_max_tries__mutmut_1': <function Model.xǁModelǁget_max_tries__mutmut_1 at 0x1062d4c20>, 'xǁModelǁget_max_tries__mutmut_2': <function Model.xǁModelǁget_max_tries__mutmut_2 at 0x1062d4cc0>}`. All model fields require a type annotation; if `xǁModelǁget_max_tries__mutmut_mutants` is not meant to be a field, you may be able to resolve this error by annotating it as a `ClassVar` or updating `model_config['ignored_types']`.
E   
E   For further information visit https://errors.pydantic.dev/2.10/u/model-field-missing-annotation
=================================================================================================================================================================================================== short test summary info ===================================================================================================================================================================================================
ERROR tests/test_main.py - pydantic.errors.PydanticUserError: A non-annotated attribute was detected: `xǁModelǁget_max_tries__mutmut_mutants = {'xǁModelǁget_max_tries__mutmut_1': <function Model.xǁModelǁget_max_tries__mutmut_1 at 0x1062d4c20>, 'xǁModelǁget_max_tries__mutmut_2': <function Model.xǁModelǁget_max_tries__mutmut_2 at 0x1062d4cc0>}`. All model fields require a type annotation; if `xǁModelǁget_max_tr...
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! stopping after 1 failures !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
1 error in 0.13s
failed to collect stats. runner returned 2
```

The content of `mutants/src/main.py`:
```python
from inspect import signature as _mutmut_signature

def _mutmut_trampoline(orig, mutants, *args, **kwargs):
    import os
    mutant_under_test = os.environ['MUTANT_UNDER_TEST']
    if mutant_under_test == 'fail':
        from mutmut.__main__ import MutmutProgrammaticFailException
        raise MutmutProgrammaticFailException('Failed programmatically')      
    elif mutant_under_test == 'stats':
        from mutmut.__main__ import record_trampoline_hit
        record_trampoline_hit(orig.__module__ + '.' + orig.__name__)
        result = orig(*args, **kwargs)
        return result  # for the yield case
    prefix = orig.__module__ + '.' + orig.__name__ + '__mutmut_'
    if not mutant_under_test.startswith(prefix):
        result = orig(*args, **kwargs)
        return result  # for the yield case
    mutant_name = mutant_under_test.rpartition('.')[-1]
    result = mutants[mutant_name](*args, **kwargs)
    return result


from inspect import signature as _mutmut_signature

def _mutmut_yield_from_trampoline(orig, mutants, *args, **kwargs):
    import os
    mutant_under_test = os.environ['MUTANT_UNDER_TEST']
    if mutant_under_test == 'fail':
        from mutmut.__main__ import MutmutProgrammaticFailException
        raise MutmutProgrammaticFailException('Failed programmatically')      
    elif mutant_under_test == 'stats':
        from mutmut.__main__ import record_trampoline_hit
        record_trampoline_hit(orig.__module__ + '.' + orig.__name__)
        result = yield from orig(*args, **kwargs)
        return result  # for the yield case
    prefix = orig.__module__ + '.' + orig.__name__ + '__mutmut_'
    if not mutant_under_test.startswith(prefix):
        result = yield from orig(*args, **kwargs)
        return result  # for the yield case
    mutant_name = mutant_under_test.rpartition('.')[-1]
    result = yield from mutants[mutant_name](*args, **kwargs)
    return result


from pydantic import BaseModel


class Model(BaseModel):
    max_retries: int = 2

    def xǁModelǁget_max_tries__mutmut_orig(self) -> int:
        return self.max_retries + 1

    def xǁModelǁget_max_tries__mutmut_1(self) -> int:
        return self.max_retries - 1

    def xǁModelǁget_max_tries__mutmut_2(self) -> int:
        return self.max_retries + 2

    xǁModelǁget_max_tries__mutmut_mutants = {
    'xǁModelǁget_max_tries__mutmut_1': xǁModelǁget_max_tries__mutmut_1, 
        'xǁModelǁget_max_tries__mutmut_2': xǁModelǁget_max_tries__mutmut_2
    }

    def get_max_tries(self, *args, **kwargs):
        result = _mutmut_trampoline(object.__getattribute__(self, "xǁModelǁget_max_tries__mutmut_orig"), object.__getattribute__(self, "xǁModelǁget_max_tries__mutmut_mutants"), *args, **kwargs)
        return result 

    get_max_tries.__signature__ = _mutmut_signature(xǁModelǁget_max_tries__mutmut_orig)
    xǁModelǁget_max_tries__mutmut_orig.__name__ = 'xǁModelǁget_max_tries'
```

is this something that is going to be fixed?

Discussion:
@boxed: The error says "or updating `model_config['ignored_types']`.". Can you do that?@przybylop: I can't - https://docs.pydantic.dev/latest/api/config/#pydantic.config.ConfigDict.ignored_types

@boxed: You'll have to do better than that. This is a hobby project of mine. I am not paid to do this. WHY can't you use that? Just linking to the docs that don't really explain much isn't helpful.@przybylop: sorry, should have been more specific - in order to use `model_config['ignored_types']`, I'd have to include `xǁModelǁget_max_tries__mutmut_mutants` attribute (generated by mutmut) in the `Model` class and annotate it with some type `T`, so that I could then include `T` into `model_config['ignored_types']`. Modifying the production code to satisfy mutation tests framework doesn't seem to be a good idea.@boxed: That sounds like you're describing the other part of the error message. The error is "by annotating it as a `ClassVar` or updating `model_config['ignored_types']" The key word being "or". 

Can't you figure out what the type is right now and add it to the ignore list? @azmeuk: From what I understand, one can add types in `model_config['ignored_types']` so attributes that have those types are ignored by Pydantic.
The problem here is that `xǁModelǁget_max_tries__mutmut_mutants` and the other attributes generated by mutmut are untyped.
The error message advise to add a type, whether it is a `ClassVar` or anything else that can be added to `model_config` so Pydantic would leave the attribute alone.

@boxed annotating the `xǁ... ` attributes with `ClassVar` sounds unintrusive to me, as this is vanilla Python and not specific to pydantic. Would it be something acceptable for you?@boxed: Maybe a ClassVar subtype called Mutant?@azmeuk: Indeed maybe marking attributes with something special to mutmut could help in that kind of situations.
Conceptually, I am not sure subtyping ClassVar is the correct thing to do here, and anyway it looks that it is forbidden by Python:

```python
foobar/__init__.py:4: in <module>
    class Mutant(ClassVar):
/usr/lib/python3.13/typing.py:551: in __mro_entries__
    raise TypeError(f"Cannot subclass {self!r}")
E   TypeError: Cannot subclass typing.ClassVar
```

Maybe the usage of `Annotated` with a custom object `Mutant` would better describe the attribute parameters. I don't know how Pydantic behaves with such a type when used alone, but this can be used alongside with `ClassVar`:

```python
MutantDict = Annotated[dict[str, Callable], "Mutant"]

class Model(BaseModel):
    xǁModelǁget_max_tries__mutmut_mutants : ClassVar[MutantDict] = {
        'xǁModelǁget_max_tries__mutmut_1': xǁModelǁget_max_tries__mutmut_1, 
        'xǁModelǁget_max_tries__mutmut_2': xǁModelǁget_max_tries__mutmut_2
    }
```

I manually edited the mutant file with those types, and it does not break Pydantic anymore.@boxed: Nice investigation! Do you want to tackle a PR for this? It seems like a pretty small change.

Solution patch:
diff --git a/HISTORY.rst b/HISTORY.rst
index d273cb0..62715e4 100644
--- a/HISTORY.rst
+++ b/HISTORY.rst
@@ -1,6 +1,11 @@
 Changelog
 ---------
 
+Unreleased
+~~~~~~~~~~
+
+* Annotate mutant dicts (and fixes compatibility with Pydantic)
+
 3.2.3
 ~~~~~
 
diff --git a/mutmut/__main__.py b/mutmut/__main__.py
index a3e37eb..589f0c4 100644
--- a/mutmut/__main__.py
+++ b/mutmut/__main__.py
@@ -188,6 +188,13 @@ class BadTestExecutionCommandsException(Exception):
 # language=python
 trampoline_impl = """
 from inspect import signature as _mutmut_signature
+from typing import Annotated
+from typing import Callable
+from typing import ClassVar
+
+
+MutantDict = Annotated[dict[str, Callable], "Mutant"]
+
 
 def _mutmut_trampoline(orig, mutants, *args, **kwargs):
     import os
@@ -318,7 +325,7 @@ def build_trampoline(*, orig_name, mutants, class_name, is_generator):
 
     mangled_name = mangle_function_name(name=orig_name, class_name=class_name)
 
-    mutants_dict = f'{mangled_name}__mutmut_mutants = {{\n' + ', \n    '.join(f'{repr(m)}: {m}' for m in mutants) + '\n}'
+    mutants_dict = f'{mangled_name}__mutmut_mutants : ClassVar[MutantDict] = {{\n' + ', \n    '.join(f'{repr(m)}: {m}' for m in mutants) + '\n}'
     access_prefix = ''
     access_suffix = ''
     if class_name is not None:


Test patch:
diff --git a/tests/test_mutmut3.py b/tests/test_mutmut3.py
index 3c8ee4b..18ecda6 100644
--- a/tests/test_mutmut3.py
+++ b/tests/test_mutmut3.py
@@ -28,7 +28,7 @@ def x_foo__mutmut_1(a, b, c):
 def x_foo__mutmut_2(a, b, c):
     return a + b / c
 
-x_foo__mutmut_mutants = {
+x_foo__mutmut_mutants : ClassVar[MutantDict] = {
 'x_foo__mutmut_1': x_foo__mutmut_1, 
     'x_foo__mutmut_2': x_foo__mutmut_2
 }
@@ -63,7 +63,7 @@ def x_foo__mutmut_orig(a: List[int]) -> int:
 def x_foo__mutmut_1(a: List[int]) -> int:
     return 2
 
-x_foo__mutmut_mutants = {
+x_foo__mutmut_mutants : ClassVar[MutantDict] = {
 'x_foo__mutmut_1': x_foo__mutmut_1
 }