Repository: pydantic/pydantic

Issue:
setattr does not add extra field to model_extra (i.e. __pydantic_extra__)
### Initial Checks

- [X] I confirm that I'm using Pydantic V2 installed directly from the `main` branch, or equivalent

### Description

First off, I may have missed this in the migration guide, so my apologies if I missed this change there.

In Pydantic V1 if extra fields are allowed (`extra="allow"`), setting a non-field attribute on a model instance (`m.extra_field = "extra_value"`) adds the extra field to `__dict__` and thus the extra field is included in `dict()` and `json()` outputs. I would not expect in Pydantic V2 that the extra field would be set on `__dict__`, but instead `__pydantic_extra__`. However, this is not the case. So, any extra fields set by attribute (i.e. as above or `setattr(m, "extra_field", "extra_value"`) are not included in `model_dump()` or `model_dump_json()`.

### Example Code

```Python
from pydantic import BaseModel, ConfigDict


class Foo(BaseModel):
    model_config  = ConfigDict(extra="allow")

foo = Foo()
foo.extra_field = "extra_value"
assert "extra_field" not in foo.model_dump()
```


### Python, Pydantic & OS Version

```Text
pydantic version: 2.0
        pydantic-core version: 2.0.1 release build profile
                 install path: ~/home/user/venv/lib/python3.9/site-packages/pydantic
               python version: 3.9.16 (main, Mar 24 2023, 12:31:28)  [Clang 14.0.0 (clang-1400.0.29.202)]
                     platform: macOS-13.2.1-arm64-arm-64bit
     optional deps. installed: ['typing-extensions']
```


Selected Assignee: @samuelcolvin


Discussion:
Having had a look at the [code](https://github.com/pydantic/pydantic/blob/1cf7cef3dcc01c849d78fc89c5bbf81888f80c81/pydantic/main.py#L740-L742) for `__setattr__` it looks like there is not logic for checking if an extra type field is being added _and_ `extra="allow"`. So, `extra_field` _is_ being added to `__dict__`. That seems like a bug. I am assuming (I have not looked) there is logic in `pydantic-core` that is only dumping `model_fields`, so although `extra_field` is added to `__dict__`, it is not included.
I think your intuition is correct, this seems like a bug. Are you interested in submitting a PR with a fix?
@adriangb, for sure! I will submit a PR and ref this issue.

Resolving patch:
diff --git a/pydantic/main.py b/pydantic/main.py
--- a/pydantic/main.py
+++ b/pydantic/main.py
@@ -737,6 +737,9 @@ def __setattr__(self, name: str, value: Any) -> None:
         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:
+            # SAFETY: __pydantic_extra__ is not None when extra = 'allow'
+            self.__pydantic_extra__[name] = value  # type: ignore
         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
@@ -230,6 +230,10 @@ class Model(BaseModel):
     assert m.a == 10.2
     assert m.b == 12
     assert m.model_extra == {'b': 12}
+    m.c = 42
+    assert 'c' not in m.__dict__
+    assert m.__pydantic_extra__ == {'b': 12, 'c': 42}
+    assert m.model_dump() == {'a': 10.2, 'b': 12, 'c': 42}
 
 
 @pytest.mark.parametrize('extra', ['ignore', 'forbid', 'allow'])