Repository: pydantic/pydantic
Pull request: Fix inheriting annotations in dataclasses
## Change Summary

We recently modified how we handle making pydantic `Field`s compatible with `dataclass` fields (various changes in 2.6). This fixes an inheritance issue introduced by those changes.

Thanks @alexmojaki for the starter code for the fix!

## Related issue number

Fix https://github.com/pydantic/pydantic/issues/8670

## Checklist

* [ ] The pull request title is a good summary of the changes - it will be used in the changelog
* [ ] Unit tests for the changes exist
* [ ] Tests pass on CI
* [ ] Documentation reflects the changes where applicable
* [ ] My PR is ready to review, **please add a comment including the phrase "please review" to assign reviewers**


Selected Reviewer: @lig

Problem statement:
dataclasses lose annotation if Field used in inherited class
### Initial Checks

- [X] I confirm that I'm using Pydantic V2

### Description

pydantic 2.6 (confirmed this works in 2.5).

```
Traceback (most recent call last):
  File "pydantic2.6.py", line 9, in <module>
    class B(A):
  File ".../pydantic/dataclasses.py", line 218, in create_dataclass
    cls = dataclasses.dataclass(  # type: ignore[call-overload]
  File ".../dataclasses.py", line 1019, in dataclass
    return wrap(cls)
  File ".../dataclasses.py", line 1011, in wrap
    return _process_class(cls, init, repr, eq, order, unsafe_hash, frozen)
  File ".../dataclasses.py", line 885, in _process_class
    raise TypeError(f'{name!r} is a field but has no type annotation')
TypeError: 'a' is a field but has no type annotation
```

### Example Code

```Python
from pydantic import dataclasses

class A:
    a: int = dataclasses.Field()

@dataclasses.dataclass()
class B(A):
    ...
```


### Python, Pydantic & OS Version

```Text
pydantic version: 2.6.0
        pydantic-core version: 2.16.1
          pydantic-core build: profile=release pgo=false
                 install path: .../lib/python3.8/site-packages/pydantic
               python version: 3.8.18 | packaged by conda-forge | (default, Dec 23 2023, 17:23:49)  [Clang 15.0.7 ]
                     platform: macOS-10.16-x86_64-i386-64bit
             related packages: typing_extensions-4.9.0 mypy-1.8.0 fastapi-0.104.1
                       commit: unknown
```

Hints from issue discussion:
@mwalsh161,

Thanks for reporting this. We'll come up with a fix and hopefully get a patch release out tomorrow!
The issue has been introduced in https://github.com/pydantic/pydantic/commit/2e459bb563dbc296c2bcffbfd1a3caf21241b8e0
@hramezani yep, thanks. I'll open a PR early tomorrow morning.
Thanks for the quick turnaround!
@mwalsh161,

What's the use case here for having a class `A` with a `pydantic.dataclasses.Field()` default, when `A` itself isn't decorated with `pydantic.dataclasses.dataclass`?
In the stdlib dataclasses source in 3.8, it says:

```python
    for b in cls.__mro__[-1:0:-1]:
        # Only process classes that have been processed by our
        # decorator.  That is, they have a _FIELDS attribute.
        base_fields = getattr(b, _FIELDS, None)
```

Indeed, fields in undecorated base classes are ignored:

```python
import dataclasses


# Uncomment to fix error
# @dataclasses.dataclass()
class A:
    a: int = dataclasses.field()


@dataclasses.dataclass()
class B(A):
    b: str


# TypeError: __init__() got an unexpected keyword argument 'a'
print(B(a=1, b='c'))
```

so maybe pydantic should be imitating that standard?
Excellent point - that was a transcription error on my part (our actual use-case wraps both). That case will yield the same error (just reconfirmed on the test script as well).

Commit message: fix annotations issue + tests

Patch to review:
diff --git a/pydantic/dataclasses.py b/pydantic/dataclasses.py
index bb26540cce7..d9c9c903b1d 100644
--- a/pydantic/dataclasses.py
+++ b/pydantic/dataclasses.py
@@ -93,7 +93,7 @@ def dataclass(
 
 
 @dataclass_transform(field_specifiers=(dataclasses.field, Field))
-def dataclass(
+def dataclass(  # noqa: C901
     _cls: type[_T] | None = None,
     *,
     init: Literal[False] = False,
@@ -153,26 +153,33 @@ def make_pydantic_fields_compatible(cls: type[Any]) -> None:
         into
           `x: int = dataclasses.field(default=pydantic.Field(..., kw_only=True), kw_only=True)`
         """
-        # In Python < 3.9, `__annotations__` might not be present if there are no fields.
-        # we therefore need to use `getattr` to avoid an `AttributeError`.
-        for field_name in getattr(cls, '__annotations__', []):
-            field_value = getattr(cls, field_name, None)
-            # Process only if this is an instance of `FieldInfo`.
-            if not isinstance(field_value, FieldInfo):
-                continue
-
-            # Initialize arguments for the standard `dataclasses.field`.
-            field_args: dict = {'default': field_value}
-
-            # Handle `kw_only` for Python 3.10+
-            if sys.version_info >= (3, 10) and field_value.kw_only:
-                field_args['kw_only'] = True
-
-            # Set `repr` attribute if it's explicitly specified to be not `True`.
-            if field_value.repr is not True:
-                field_args['repr'] = field_value.repr
-
-            setattr(cls, field_name, dataclasses.field(**field_args))
+        for annotation_cls in cls.__mro__:
+            # In Python < 3.9, `__annotations__` might not be present if there are no fields.
+            # we therefore need to use `getattr` to avoid an `AttributeError`.
+            annotations = getattr(annotation_cls, '__annotations__', [])
+            for field_name in annotations:
+                field_value = getattr(cls, field_name, None)
+                # Process only if this is an instance of `FieldInfo`.
+                if not isinstance(field_value, FieldInfo):
+                    continue
+
+                # Initialize arguments for the standard `dataclasses.field`.
+                field_args: dict = {'default': field_value}
+
+                # Handle `kw_only` for Python 3.10+
+                if sys.version_info >= (3, 10) and field_value.kw_only:
+                    field_args['kw_only'] = True
+
+                # Set `repr` attribute if it's explicitly specified to be not `True`.
+                if field_value.repr is not True:
+                    field_args['repr'] = field_value.repr
+
+                setattr(cls, field_name, dataclasses.field(**field_args))
+                # In Python 3.8, dataclasses checks cls.__dict__['__annotations__'] for annotations,
+                # so we must make sure it's initialized before we add to it.
+                if cls.__dict__.get('__annotations__') is None:
+                    cls.__annotations__ = {}
+                cls.__annotations__[field_name] = annotations[field_name]
 
     def create_dataclass(cls: type[Any]) -> type[PydanticDataclass]:
         """Create a Pydantic dataclass from a regular dataclass.
diff --git a/tests/test_dataclasses.py b/tests/test_dataclasses.py
index fcf635f8b8d..8b2b5403435 100644
--- a/tests/test_dataclasses.py
+++ b/tests/test_dataclasses.py
@@ -2759,3 +2759,88 @@ def test_disallow_init_false_and_init_var_true() -> None:
         @pydantic.dataclasses.dataclass
         class Foo:
             bar: str = Field(..., init=False, init_var=True)
+
+
+def test_annotations_valid_for_field_inheritance() -> None:
+    # testing https://github.com/pydantic/pydantic/issues/8670
+
+    @pydantic.dataclasses.dataclass()
+    class A:
+        a: int = pydantic.dataclasses.Field()
+
+    @pydantic.dataclasses.dataclass()
+    class B(A):
+        ...
+
+    assert B.__pydantic_fields__['a'].annotation is int
+
+    assert B(a=1).a == 1
+
+
+def test_annotations_valid_for_field_inheritance_with_existing_field() -> None:
+    # variation on testing https://github.com/pydantic/pydantic/issues/8670
+
+    @pydantic.dataclasses.dataclass()
+    class A:
+        a: int = pydantic.dataclasses.Field()
+
+    @pydantic.dataclasses.dataclass()
+    class B(A):
+        b: str = pydantic.dataclasses.Field()
+
+    assert B.__pydantic_fields__['a'].annotation is int
+    assert B.__pydantic_fields__['b'].annotation is str
+
+    b = B(a=1, b='b')
+    assert b.a == 1
+    assert b.b == 'b'
+
+
+def test_annotations_with_override() -> None:
+    @pydantic.dataclasses.dataclass()
+    class A:
+        a: int
+        b: int
+        c: int = pydantic.dataclasses.Field()
+        d: int = pydantic.dataclasses.Field()
+
+    # note, the order of fields is different here, as to test that the annotation
+    # is correctly set on the field no matter the base's default / current class's default
+    @pydantic.dataclasses.dataclass()
+    class B(A):
+        a: str
+        c: str
+        b: str = pydantic.dataclasses.Field()
+        d: str = pydantic.dataclasses.Field()
+
+    b = B(a='a', b='b', c='c', d='d')
+    for field_name in ['a', 'b', 'c', 'd']:
+        assert B.__pydantic_fields__[field_name].annotation is str
+        assert getattr(b, field_name) == field_name
+
+
+def test_annotation_with_double_override() -> None:
+    @pydantic.dataclasses.dataclass()
+    class A:
+        a: int
+        b: int
+        c: int = pydantic.dataclasses.Field()
+        d: int = pydantic.dataclasses.Field()
+
+    # note, the order of fields is different here, as to test that the annotation
+    # is correctly set on the field no matter the base's default / current class's default
+    @pydantic.dataclasses.dataclass()
+    class B(A):
+        a: str
+        c: str
+        b: str = pydantic.dataclasses.Field()
+        d: str = pydantic.dataclasses.Field()
+
+    @pydantic.dataclasses.dataclass()
+    class C(B):
+        ...
+
+    c = C(a='a', b='b', c='c', d='d')
+    for field_name in ['a', 'b', 'c', 'd']:
+        assert C.__pydantic_fields__[field_name].annotation is str
+        assert getattr(c, field_name) == field_name


Code review comments:
diff_hunk: @@ -2759,3 +2759,88 @@ def test_disallow_init_false_and_init_var_true() -> None:
         @pydantic.dataclasses.dataclass
         class Foo:
             bar: str = Field(..., init=False, init_var=True)
+
+
+def test_annotations_valid_for_field_inheritance() -> None:
+    # testing https://github.com/pydantic/pydantic/issues/8670
+
+    @pydantic.dataclasses.dataclass()
+    class A:
+        a: int = pydantic.dataclasses.Field()
+
+    @pydantic.dataclasses.dataclass()
+    class B(A):
+        ...
+
+    assert B.__pydantic_fields__['a'].annotation is int
+
+    assert B(a=1).a == 1
+
+
+def test_annotations_valid_for_field_inheritance_with_existing_field() -> None:
+    # variation on testing https://github.com/pydantic/pydantic/issues/8670
+
+    @pydantic.dataclasses.dataclass()
+    class A:
+        a: int = pydantic.dataclasses.Field()
+
+    @pydantic.dataclasses.dataclass()
+    class B(A):
+        b: str = pydantic.dataclasses.Field()
+
+    assert B.__pydantic_fields__['a'].annotation is int
+    assert B.__pydantic_fields__['b'].annotation is str
+
+    b = B(a=1, b='b')
+    assert b.a == 1
+    assert b.b == 'b'
+
+
+def test_annotations_with_override() -> None:
+    @pydantic.dataclasses.dataclass()
+    class A:
+        a: int
+        b: int
+        c: int = pydantic.dataclasses.Field()
+        d: int = pydantic.dataclasses.Field()
+
+    # note, the order of fields is different here, as to test that the annotation
+    # is correctly set on the field no matter the base's default / current class's default
+    @pydantic.dataclasses.dataclass()
+    class B(A):
+        a: str
+        c: str
+        b: str = pydantic.dataclasses.Field()
+        d: str = pydantic.dataclasses.Field()
+
+    b = B(a='a', b='b', c='c', d='d')
+    for field_name in ['a', 'b', 'c', 'd']:
+        assert B.__pydantic_fields__[field_name].annotation is str
+        assert getattr(b, field_name) == field_name
+
+
+def test_annotation_with_double_override() -> None:
+    @pydantic.dataclasses.dataclass()
+    class A:
+        a: int
+        b: int
+        c: int = pydantic.dataclasses.Field()
+        d: int = pydantic.dataclasses.Field()
+
+    # note, the order of fields is different here, as to test that the annotation
+    # is correctly set on the field no matter the base's default / current class's default
+    @pydantic.dataclasses.dataclass()
+    class B(A):
+        a: str
+        c: str
+        b: str = pydantic.dataclasses.Field()
+        d: str = pydantic.dataclasses.Field()
+
+    @pydantic.dataclasses.dataclass()
+    class C(B):
+        ...
+
+    c = C(a='a', b='b', c='c', d='d')
path: tests/test_dataclasses.py
text: @user1:
Now that all classes are decorated, I think you can go back to checking both B and C in a loop, and then the previous test can be removed.