Repository: pydantic/pydantic

Issue:
Handle frozen and extra configs for dataclasses



Discussion:

Patch:
diff --git a/pydantic/_internal/_dataclasses.py b/pydantic/_internal/_dataclasses.py
--- a/pydantic/_internal/_dataclasses.py
+++ b/pydantic/_internal/_dataclasses.py
@@ -10,8 +10,7 @@
 
 from pydantic_core import ArgsKwargs, SchemaSerializer, SchemaValidator, core_schema
 
-from pydantic.config import Extra
-
+from ..config import ConfigDict
 from ..errors import PydanticUndefinedAnnotation
 from ..fields import FieldInfo
 from . import _decorators
@@ -24,7 +23,6 @@
 __all__ = 'StandardDataclass', 'PydanticDataclass', 'prepare_dataclass'
 
 if typing.TYPE_CHECKING:
-    from ..config import ConfigDict
 
     class StandardDataclass(typing.Protocol):
         __dataclass_fields__: ClassVar[dict[str, Any]]
@@ -99,13 +97,6 @@ def prepare_dataclass(
     # this works because cls has been transformed into a dataclass by the time "cls" is called
     cls.__pydantic_serializer__ = SchemaSerializer(schema, core_config)
 
-    extra = config.get('extra', Extra.ignore)  # ignore is the default in V1
-    if extra is Extra.allow:
-        raise ValueError(
-            'extra=Extra.allow is not allowed for dataclasses.'
-            ' Only Extra.ignore (the default) and Extra.forbid are allowed.'
-        )
-
     if config.get('validate_assignment'):
 
         @wraps(cls.__setattr__)
diff --git a/pydantic/_internal/_generate_schema.py b/pydantic/_internal/_generate_schema.py
--- a/pydantic/_internal/_generate_schema.py
+++ b/pydantic/_internal/_generate_schema.py
@@ -167,27 +167,25 @@ def generate_config(config: ConfigDict, cls: type[Any]) -> core_schema.CoreConfi
     """
     Create a pydantic-core config from a pydantic config.
     """
-    core_config = core_schema.CoreConfig(
-        title=config['title'] or cls.__name__,
-        typed_dict_extra_behavior=config['extra'].value,
-        allow_inf_nan=config['allow_inf_nan'],
-        populate_by_name=config['populate_by_name'],
-        str_strip_whitespace=config['str_strip_whitespace'],
-        str_to_lower=config['str_to_lower'],
-        str_to_upper=config['str_to_upper'],
-        strict=config['strict'],
-        ser_json_timedelta=config['ser_json_timedelta'],
-        ser_json_bytes=config['ser_json_bytes'],
-        from_attributes=config['from_attributes'],
-        validate_default=config['validate_default'],
+    extra = None if config['extra'] is None else config['extra'].value
+    core_config = core_schema.CoreConfig(  # type: ignore[misc]
+        **core_schema.dict_not_none(
+            title=config['title'] or cls.__name__,
+            extra_fields_behavior=extra,
+            allow_inf_nan=config['allow_inf_nan'],
+            populate_by_name=config['populate_by_name'],
+            str_strip_whitespace=config['str_strip_whitespace'],
+            str_to_lower=config['str_to_lower'],
+            str_to_upper=config['str_to_upper'],
+            strict=config['strict'],
+            ser_json_timedelta=config['ser_json_timedelta'],
+            ser_json_bytes=config['ser_json_bytes'],
+            from_attributes=config['from_attributes'],
+            validate_default=config['validate_default'],
+            str_max_length=config.get('str_max_length'),
+            str_min_length=config.get('str_min_length'),
+        )
     )
-    str_max_length = config.get('str_max_length')
-    if str_max_length is not None:
-        core_config['str_max_length'] = str_max_length
-    str_min_length = config.get('str_min_length')
-    if str_min_length is not None:
-        core_config['str_min_length'] = str_min_length
-
     return core_config
 
 
diff --git a/pydantic/config.py b/pydantic/config.py
--- a/pydantic/config.py
+++ b/pydantic/config.py
@@ -51,7 +51,7 @@ class _ConfigDict(TypedDict, total=False):
     str_strip_whitespace: bool
     str_min_length: int
     str_max_length: int | None
-    extra: Extra
+    extra: Extra | None
     frozen: bool
     populate_by_name: bool
     use_enum_values: bool
@@ -104,7 +104,8 @@ def __missing__(self, key: str) -> Any:
     str_strip_whitespace=False,
     str_min_length=0,
     str_max_length=None,
-    extra=Extra.ignore,
+    # let the model / dataclass decide how to handle it
+    extra=None,
     frozen=False,
     populate_by_name=False,
     use_enum_values=False,
@@ -234,7 +235,7 @@ def build_config(
 
 
 def prepare_config(config: ConfigDict, cls_name: str) -> None:
-    if not isinstance(config['extra'], Extra):
+    if not isinstance(config['extra'], (Extra, type(None))):
         try:
             config['extra'] = Extra(config['extra'])
         except ValueError:
diff --git a/pydantic/json_schema.py b/pydantic/json_schema.py
--- a/pydantic/json_schema.py
+++ b/pydantic/json_schema.py
@@ -656,7 +656,7 @@ def model_schema(self, schema: core_schema.ModelSchema) -> JsonSchemaValue:
         json_schema = self.generate_inner(schema['schema'])
 
         if 'config' in schema:
-            if schema['config'].get('typed_dict_extra_behavior') == 'forbid':
+            if schema['config'].get('extra_fields_behavior') == 'forbid':
                 if '$ref' in json_schema:
                     # hack: update the definition from the typed_dict_schema
                     referenced_schema = self.get_schema_from_definitions(JsonRef(json_schema['$ref']))


Test patch:
diff --git a/tests/test_dataclasses.py b/tests/test_dataclasses.py
--- a/tests/test_dataclasses.py
+++ b/tests/test_dataclasses.py
@@ -137,8 +137,14 @@ def double_a(cls, v: int) -> int:
     'config',
     [
         ConfigDict(validate_assignment=False),
+        ConfigDict(extra=None),
+        ConfigDict(extra=Extra.forbid),
         ConfigDict(extra=Extra.ignore),
+        ConfigDict(validate_assignment=False, extra=None),
+        ConfigDict(validate_assignment=False, extra=Extra.forbid),
         ConfigDict(validate_assignment=False, extra=Extra.ignore),
+        ConfigDict(validate_assignment=False, extra=Extra.allow),
+        ConfigDict(validate_assignment=True, extra=Extra.allow),
     ],
 )
 def test_validate_assignment_extra_unknown_field_assigned_allowed(config: ConfigDict):
@@ -157,15 +163,9 @@ class MyDataclass:
     'config',
     [
         ConfigDict(validate_assignment=True),
-        pytest.param(
-            ConfigDict(extra=Extra.forbid), marks=pytest.mark.xfail(reason='dataclasses do not respect Extra')
-        ),
+        ConfigDict(validate_assignment=True, extra=None),
         ConfigDict(validate_assignment=True, extra=Extra.forbid),
         ConfigDict(validate_assignment=True, extra=Extra.ignore),
-        pytest.param(
-            ConfigDict(validate_assignment=False, extra=Extra.forbid),
-            marks=pytest.mark.xfail(reason='dataclasses do not respect Extra'),
-        ),
     ],
 )
 def test_validate_assignment_extra_unknown_field_assigned_errors(config: ConfigDict):
@@ -1349,30 +1349,39 @@ class Foo:
     assert foo.__dict__ == {'x': 1, '__pydantic_initialised__': True}
 
 
-@pytest.mark.xfail(reason='model_config["extra"] is not respected')
 def test_ignore_extra_subclass():
-    @pydantic.dataclasses.dataclass(config=dict(extra=Extra.ignore))
+    @pydantic.dataclasses.dataclass(config=ConfigDict(extra=Extra.ignore))
     class Foo:
         x: int
 
-    @pydantic.dataclasses.dataclass(config=dict(extra=Extra.ignore))
+    @pydantic.dataclasses.dataclass(config=ConfigDict(extra=Extra.ignore))
     class Bar(Foo):
         y: int
 
     bar = Bar(**{'x': '1', 'y': '2', 'z': '3'})
-    assert bar.__dict__ == {'x': 1, 'y': 2, '__pydantic_initialised__': True}
+    assert bar.__dict__ == {'x': 1, 'y': 2}
 
 
 def test_allow_extra():
-    msg = (
-        r'extra=Extra.allow is not allowed for dataclasses.'
-        r' Only Extra.ignore (the default) and Extra.forbid are allowed.'
-    )
-    with pytest.raises(ValueError, match=re.escape(msg)):
+    @pydantic.dataclasses.dataclass(config=ConfigDict(extra=Extra.allow))
+    class Foo:
+        x: int
+
+    foo = Foo(**{'x': '1', 'y': '2'})
+    assert foo.__dict__ == {'x': 1, 'y': '2'}
 
-        @pydantic.dataclasses.dataclass(config=ConfigDict(extra=Extra.allow))
-        class _:
-            pass
+
+def test_allow_extra_subclass():
+    @pydantic.dataclasses.dataclass(config=ConfigDict(extra=Extra.allow))
+    class Foo:
+        x: int
+
+    @pydantic.dataclasses.dataclass(config=ConfigDict(extra=Extra.allow))
+    class Bar(Foo):
+        y: int
+
+    bar = Bar(**{'x': '1', 'y': '2', 'z': '3'})
+    assert bar.__dict__ == {'x': 1, 'y': 2, 'z': '3'}
 
 
 def test_forbid_extra():
@@ -1425,7 +1434,7 @@ class Foo:
 
 
 def test_extra_forbid_list_error():
-    @pydantic.dataclasses.dataclass
+    @pydantic.dataclasses.dataclass(config=ConfigDict(extra=Extra.forbid))
     class Bar:
         ...
 
diff --git a/tests/test_edge_cases.py b/tests/test_edge_cases.py
--- a/tests/test_edge_cases.py
+++ b/tests/test_edge_cases.py
@@ -1278,7 +1278,7 @@ class Child(Mixin, Parent, json_encoders={str: str_encoder}):
 
     assert BaseModel.model_config['frozen'] is False
     assert BaseModel.model_config['populate_by_name'] is False
-    assert BaseModel.model_config['extra'] is Extra.ignore
+    assert BaseModel.model_config['extra'] is None
     assert BaseModel.model_config['use_enum_values'] is False
     assert BaseModel.model_config['json_encoders'] == {}
 
@@ -1290,7 +1290,7 @@ class Child(Mixin, Parent, json_encoders={str: str_encoder}):
 
     assert Mixin.model_config['frozen'] is False
     assert Mixin.model_config['populate_by_name'] is False
-    assert Mixin.model_config['extra'] is Extra.ignore
+    assert Mixin.model_config['extra'] is None
     assert Mixin.model_config['use_enum_values'] is True
     assert Mixin.model_config['json_encoders'][int] is int2_encoder