Repository: pydantic/pydantic

Issue:
v2 regression: `__setattr__` hides bugs from type checkers
### Initial Checks

- [X] I confirm that I'm using Pydantic V2

### Description

Apparently the type safety of Pydantic models has deteriorated significantly from v1 to v2 due to the exposure of a `__setattr__` method that accepts `Any`. This opens the door for a lot of serious bugs, because the type checkers fail to catch fundamental bugs like the following.

### Example Code

```py
from pydantic import BaseModel

class Foo(BaseModel):
    some_field: int

def patch_object(foo: Foo) -> None:
    # Ohoh, we got the field name wrong...
    foo.some_feeld = 42

foo = Foo(some_field=1)
patch_object(foo)
assert foo.some_field == 42
```

With Pydantic V1 this obvious bug was detected by both mypy and pyright. Mypy would for instance produce a very clear and helpful type error here:

```
error: "Foo" has no attribute "some_feeld"; maybe "some_field"?  [attr-defined]
```

With Pydantic V2, the type checker completely misses the bug.

It should be quite straightforward to make the field access of `BaseModel` type safe again, by simply not exposing the `__setattr__` method towards type checkers. Basically just this pattern ([example on mypy playground](https://mypy-play.net/?mypy=latest&python=3.12&gist=97dbc3dc8d51e14e3067d74ac32e6948)):

```py
from typing import Any, TYPE_CHECKING

class BaseModel:
    if not TYPE_CHECKING:
        def __setattr__(self, name: str, value: Any) -> None:
            ...

class Foo(BaseModel):
    some_field: int

def patch_object(foo: Foo) -> None:
    # Now the type checker will see the bug...
    foo.some_feeld = 42
```

Of course, in a completely strict sense this would be a "breaking" change, but since it looks rather like an bug/accident compared to V1, it would be really nice if this could be made type safe again. We just ran into such a fundamental bug resulting from this regression. This behavior is highly bug prone and also totally unexpected: Any type annotated dataclass-like type (including Pydantic V1 models) is naturally expected to respect the field annotations. After all, getting a field name wrong is one of the most basic of all bugs, and a primary reason for people to use type annotations and type checkers. If the code does not work at runtime (it crashes!), it should be flagged at type checking time.


### Python, Pydantic & OS Version

I tried a range of pydantic versions including the upcoming 2.6.0b1. It looks like this used to work in 1.X but has started to fail in 2.X.

```Text
             pydantic version: 2.6.0b1
        pydantic-core version: 2.16.1
          pydantic-core build: profile=release pgo=true
                 install path: /home/ekf2abt/git/scripts/pi_23/debug_pydantic_v2/venv/lib/python3.8/site-packages/pydantic
               python version: 3.8.10 (default, Nov 22 2023, 10:22:35)  [GCC 9.4.0]
                     platform: Linux-5.4.0-166-generic-x86_64-with-glibc2.29
             related packages: typing_extensions-4.9.0 mypy-1.8.0
                       commit: unknown
```



Discussion:
@bluenote10,

Thanks for reporting this. My initial thought is that moving the `__setattr__` logic into a `if not typing.TYPE_CHECKING` block would be fine, especially given that `__getattr__` is already in one, with the justification:

> We put `__getattr__` in a non-TYPE_CHECKING block because otherwise, mypy allows arbitrary attribute access

Would you be willing to open a PR with a fix?

Again, thanks for bringing this to our attention 🚀. I'll double check with the team that I'm not overlooking a significant breaking change that would come from this modification, and get back to you :).

Update: PR welcome with a fix for this - we don't think this change will be significantly breaking in any way :).
> Update: PR welcome with a fix for this

Great, thanks!

> we don't think this change will be significantly breaking in any way :).

In fact, it probably should just be "breaking" in places that have a bug :wink:

> Would you be willing to open a PR with a fix?

Sure, I should be able to look into it this week-end!

Resolving patch:
diff --git a/pydantic/main.py b/pydantic/main.py
--- a/pydantic/main.py
+++ b/pydantic/main.py
@@ -733,6 +733,7 @@ def __deepcopy__(self: Model, memo: dict[int, Any] | None = None) -> Model:
 
     if not typing.TYPE_CHECKING:
         # We put `__getattr__` in a non-TYPE_CHECKING block because otherwise, mypy allows arbitrary attribute access
+        # The same goes for __setattr__ and __delattr__, see: https://github.com/pydantic/pydantic/issues/8643
 
         def __getattr__(self, item: str) -> Any:
             private_attributes = object.__getattribute__(self, '__private_attributes__')
@@ -766,74 +767,74 @@ def __getattr__(self, item: str) -> Any:
                         # this is the current error
                         raise AttributeError(f'{type(self).__name__!r} object has no attribute {item!r}')
 
-    def __setattr__(self, name: str, value: Any) -> None:
-        if name in self.__class_vars__:
-            raise AttributeError(
-                f'{name!r} is a ClassVar of `{self.__class__.__name__}` and cannot be set on an instance. '
-                f'If you want to set a value on the class, use `{self.__class__.__name__}.{name} = value`.'
-            )
-        elif not _fields.is_valid_field_name(name):
-            if self.__pydantic_private__ is None or name not in self.__private_attributes__:
-                _object_setattr(self, name, value)
-            else:
-                attribute = self.__private_attributes__[name]
-                if hasattr(attribute, '__set__'):
-                    attribute.__set__(self, value)  # type: ignore
+        def __setattr__(self, name: str, value: Any) -> None:
+            if name in self.__class_vars__:
+                raise AttributeError(
+                    f'{name!r} is a ClassVar of `{self.__class__.__name__}` and cannot be set on an instance. '
+                    f'If you want to set a value on the class, use `{self.__class__.__name__}.{name} = value`.'
+                )
+            elif not _fields.is_valid_field_name(name):
+                if self.__pydantic_private__ is None or name not in self.__private_attributes__:
+                    _object_setattr(self, name, value)
                 else:
-                    self.__pydantic_private__[name] = value
-            return
+                    attribute = self.__private_attributes__[name]
+                    if hasattr(attribute, '__set__'):
+                        attribute.__set__(self, value)  # type: ignore
+                    else:
+                        self.__pydantic_private__[name] = value
+                return
 
-        self._check_frozen(name, value)
-
-        attr = getattr(self.__class__, name, None)
-        if isinstance(attr, property):
-            attr.__set__(self, value)
-        elif self.model_config.get('validate_assignment', None):
-            self.__pydantic_validator__.validate_assignment(self, name, value)
-        elif self.model_config.get('extra') != 'allow' and name not in self.model_fields:
-            # 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:
-            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._check_frozen(name, value)
+
+            attr = getattr(self.__class__, name, None)
+            if isinstance(attr, property):
+                attr.__set__(self, value)
+            elif self.model_config.get('validate_assignment', None):
+                self.__pydantic_validator__.validate_assignment(self, name, value)
+            elif self.model_config.get('extra') != 'allow' and name not in self.model_fields:
+                # 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:
+                if self.model_extra and name in self.model_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)
-
-    def __delattr__(self, item: str) -> Any:
-        if item in self.__private_attributes__:
-            attribute = self.__private_attributes__[item]
-            if hasattr(attribute, '__delete__'):
-                attribute.__delete__(self)  # type: ignore
-                return
+                    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)
 
-            try:
-                # Note: self.__pydantic_private__ cannot be None if self.__private_attributes__ has items
-                del self.__pydantic_private__[item]  # type: ignore
-                return
-            except KeyError as exc:
-                raise AttributeError(f'{type(self).__name__!r} object has no attribute {item!r}') from exc
+        def __delattr__(self, item: str) -> Any:
+            if item in self.__private_attributes__:
+                attribute = self.__private_attributes__[item]
+                if hasattr(attribute, '__delete__'):
+                    attribute.__delete__(self)  # type: ignore
+                    return
 
-        self._check_frozen(item, None)
+                try:
+                    # Note: self.__pydantic_private__ cannot be None if self.__private_attributes__ has items
+                    del self.__pydantic_private__[item]  # type: ignore
+                    return
+                except KeyError as exc:
+                    raise AttributeError(f'{type(self).__name__!r} object has no attribute {item!r}') from exc
 
-        if item in self.model_fields:
-            object.__delattr__(self, item)
-        elif self.__pydantic_extra__ is not None and item in self.__pydantic_extra__:
-            del self.__pydantic_extra__[item]
-        else:
-            try:
+            self._check_frozen(item, None)
+
+            if item in self.model_fields:
                 object.__delattr__(self, item)
-            except AttributeError:
-                raise AttributeError(f'{type(self).__name__!r} object has no attribute {item!r}')
+            elif self.__pydantic_extra__ is not None and item in self.__pydantic_extra__:
+                del self.__pydantic_extra__[item]
+            else:
+                try:
+                    object.__delattr__(self, item)
+                except AttributeError:
+                    raise AttributeError(f'{type(self).__name__!r} object has no attribute {item!r}')
 
     def _check_frozen(self, name: str, value: Any) -> None:
         if self.model_config.get('frozen', None):


Test patch: