Repository: pydantic/pydantic

Issue:
Setattribute with Extra.allow is hiding method when it's defined on class.
### Initial Checks

- [X] I confirm that I'm using Pydantic V2

### Description

When setting an attribute, it is saving it with \_\_setattr\_\_ into \_\_pydantic\_extra\_\_ (https://github.com/pydantic/pydantic/blob/b403666455bae4fdc62404236c45c1b9939f96fa/pydantic/main.py#L802). 
On the other hand, when getting an attribute, it is running the \_\_getattr**ibute**\_\_ implementation, which is basically looking for the object dict, then the class hierarchy then the \_\_getattr\_\_ implementation afterwards only. 
If Extra.allow is set, the setattr is saving it to a place which is not accessed in the same order by default. See the example code. It prints the "1,1,patched" instead of "1,patched,patched".

### Example Code

```Python
from pydantic import BaseModel, Extra

class LookupPython():
    id: int

    def __init__(self, id) -> None:
        self.id = id

    def func(self):
        return self.id

class LookupPydantic(BaseModel, extra=Extra.allow):
    id: int

    def func(self):
        return self.id
    
lookups = [ LookupPython(id=1), LookupPydantic(id=1) ]

for lookup in lookups:
    print( type(lookup) )
    print( repr(lookup) )
    print( lookup.func() )
    lookup.func = lambda: "patched"
    print( repr(lookup) )
    print( lookup.func() )
    lookup.__dict__["func"] = lambda: "patched"
    print( repr(lookup) )
    print( lookup.func() )
    print( "" )
```
Output:
```
<class '__main__.LookupPython'>
<__main__.LookupPython object at 0x7ff7c92ba5d0>
1
<__main__.LookupPython object at 0x7ff7c92ba5d0>
patched
<__main__.LookupPython object at 0x7ff7c92ba5d0>
patched

<class '__main__.LookupPydantic'>
LookupPydantic(id=1)
1
LookupPydantic(id=1, func=<function <lambda> at 0x7ff7c8e54cc0>)
1
LookupPydantic(id=1, func=<function <lambda> at 0x7ff7c8e54cc0>)
patched
```

### Python, Pydantic & OS Version

```Text
pydantic version: 2.4.0
        pydantic-core version: 2.10.0
          pydantic-core build: profile=release pgo=true
                 install path: /home/kali/.local/lib/python3.10/site-packages/pydantic
               python version: 3.10.8 (main, Oct 24 2022, 10:07:16) [GCC 12.2.0]
                     platform: Linux-5.15.90.1-microsoft-standard-WSL2-x86_64-with-glibc2.35
             related packages: typing_extensions-4.8.0 fastapi-0.85.0 typing_extensions-4.3.0 email-validator-1.1.2
```



Discussion:
Hi @vendito-david,

Thanks for reporting this! Also, thanks for the in depth code example that runs out of the box 😄.

Like you mentioned, this is constrained to just the case where you're setting a method, as the following works with attributes:
```py
from pydantic import BaseModel

class LookupPython():
    id: int

    def __init__(self, id) -> None:
        self.id = id

class LookupPydantic(BaseModel, extra='allow'):
    id: int

python_lookup = LookupPython(id=1)
pydantic_lookup = LookupPydantic(id=1)

python_lookup.id = 2
print(python_lookup.id)
#> 2

pydantic_lookup.id = 2
print(pydantic_lookup.id)
#> 2
```

Are you interested in opening a PR to help fix this issue? ⭐ 
Hi, this is vendito-david on my non-corporate user. I made an issue instead of PR because i am not that familiar with the new pydantic v2 and your conventions yet.
Hi @vendito-david (@mullerdavid ),

No worries at all. I'll work on a fix for this + update here with progress 😄.

Resolving patch:
diff --git a/pydantic/main.py b/pydantic/main.py
--- a/pydantic/main.py
+++ b/pydantic/main.py
@@ -798,8 +798,17 @@ def __setattr__(self, name: str, value: Any) -> None:
             # TODO - matching error
             raise ValueError(f'"{self.__class__.__name__}" object has no field "{name}"')
         elif self.model_config.get('extra') == 'allow' and name not in self.model_fields:
-            # SAFETY: __pydantic_extra__ is not None when extra = 'allow'
-            self.__pydantic_extra__[name] = value  # type: ignore
+            if self.model_extra and name in self.model_extra:
+                self.__pydantic_extra__[name] = value  # type: ignore
+            else:
+                try:
+                    getattr(self, name)
+                except AttributeError:
+                    # attribute does not already exist on instance, so put it in extra
+                    self.__pydantic_extra__[name] = value  # type: ignore
+                else:
+                    # attribute _does_ already exist on instance, and was not in extra, so update it
+                    _object_setattr(self, name, value)
         else:
             self.__dict__[name] = value
             self.__pydantic_fields_set__.add(name)


Test patch:
diff --git a/tests/test_main.py b/tests/test_main.py
--- a/tests/test_main.py
+++ b/tests/test_main.py
@@ -7,6 +7,7 @@
 from dataclasses import dataclass
 from datetime import date, datetime
 from enum import Enum
+from functools import partial
 from typing import (
     Any,
     Callable,
@@ -342,6 +343,25 @@ class Model(BaseModel):
     assert model.c == 1
 
 
+def test_reassign_instance_method_with_extra_allow():
+    class Model(BaseModel):
+        model_config = ConfigDict(extra='allow')
+        name: str
+
+        def not_extra_func(self) -> str:
+            return f'hello {self.name}'
+
+    def not_extra_func_replacement(self_sub: Model) -> str:
+        return f'hi {self_sub.name}'
+
+    m = Model(name='james')
+    assert m.not_extra_func() == 'hello james'
+
+    m.not_extra_func = partial(not_extra_func_replacement, m)
+    assert m.not_extra_func() == 'hi james'
+    assert 'not_extra_func' in m.__dict__
+
+
 def test_extra_ignored():
     class Model(BaseModel):
         model_config = ConfigDict(extra='ignore')