8000 Rebuild dataclass fields before schema generation by Viicos · Pull Request #11949 · pydantic/pydantic · GitHub
[go: up one dir, main page]

Skip to content

Rebuild dataclass fields before schema generation #11949

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Jun 12, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 27 additions & 23 deletions .github/workflows/third-party.yml
8000
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
# - Make sure Pydantic is installed in editable mode (e.g. `uv pip install -e ./pydantic-latest`)
# so that the path appears in the `pip list` output (and so we can be assured Pydantic was properly
# installed from the provided path).
# - If using `uv run`, make use to use the `--no-sync`, because uv has the nasty habit of syncing the venv
# on each `uv run` invocation, which will reinstall the locked `pydantic`/`pydantic-core` version.
name: Third party tests

on:
Expand Down Expand Up @@ -61,14 +63,14 @@ jobs:
- name: Install FastAPI dependencies
run: |
uv venv --python ${{ matrix.python-version }}
uv pip install -r requirements-tests.txt
uv pip install --no-progress -r requirements-tests.txt
uv pip install -e ./pydantic-latest

- name: List installed dependencies
run: uv pip list

- name: Run FastAPI tests
run: PYTHONPATH=./docs_src uv run --no-project pytest tests
run: PYTHONPATH=./docs_src uv run --no-project --no-sync pytest tests

test-sqlmodel:
name: Test SQLModel (main branch) on Python ${{ matrix.python-version }}
Expand Down Expand Up @@ -101,14 +103,14 @@ jobs:
- name: Install SQLModel dependencies
run: |
uv venv --python ${{ matrix.python-version }}
uv pip install -r requirements-tests.txt
uv pip install --no-progress -r requirements-tests.txt
uv pip install -e ./pydantic-latest

- name: List installed dependencies
run: uv pip list

- name: Run SQLModel tests
run: uv run --no-project pytest tests
run: uv run --no-project --no-sync pytest tests

test-beanie:
name: Test Beanie (main branch) on Python ${{ matrix.python-version }}
Expand Down Expand Up @@ -232,7 +234,7 @@ jobs:
- name: Install Pandera dependencies
run: |
pip install uv
uv sync --extra pandas --extra fastapi --extra pandas --group dev --group testing --group docs
uv sync --no-progress --extra pandas --extra fastapi --extra pandas --group dev --group testing --group docs
uv pip uninstall --system pydantic pydantic-core
uv pip install --system -e ./pydantic-latest

Expand All @@ -242,7 +244,7 @@ jobs:
- name: Run Pandera tests
# Pandera's CI uses nox sessions which encapsulate the logic to install a specific Pydantic version.
# Instead, manually run pytest (we run core, pandas and FastAPI tests):
run: uv run pytest tests/base tests/pandas tests/fastapi
run: uv run --no-sync pytest tests/base tests/pandas tests/fastapi

test-odmantic:
name: Test ODMantic (main branch) on Python ${{ matrix.python-version }}
Expand Down Expand Up @@ -394,26 +396,26 @@ jobs:
- name: 🔧 uv install
working-directory: ./server
run: |
uv sync --dev
uv sync --no-progress --dev
uv pip uninstall pydantic
uv pip install -e ./../pydantic-latest
uv run task generate_dev_jwks
uv run --no-sync task generate_dev_jwks

- name: List installed dependencies
working-directory: ./server
run: uv pip list

- name: ⚗️ alembic migrate
working-directory: ./server
run: uv run task db_migrate
run: uv run --no-sync task db_migrate

- name: ⚗️ alembic check
working-directory: ./server
run: uv run alembic check
run: uv run --no-sync alembic check

- name: 🐍 Run polar tests (pytest)
working-directory: ./server
run: uv run pytest -n auto --no-cov
run: uv run --no-sync pytest -n auto --no-cov

test-bentoml:
name: Test BentoML (main branch) on Python ${{ matrix.python-version }}
Expand Down Expand Up @@ -486,7 +488,7 @@ jobs:
with:
path: pydantic-latest

- name: Install UV
- name: Install uv
uses: astral-sh/setup-uv@v6
with:
python-version: ${{ matrix.python-version }}
Expand All @@ -495,7 +497,7 @@ jobs:
- name: Install Semantic Kernel dependencies
working-directory: ./python
run: |
uv sync --all-extras --dev --prerelease=if-necessary-or-explicit
uv sync --no-progress --all-extras --dev --prerelease=if-necessary-or-explicit
uv pip uninstall pydantic
uv pip install -e ../pydantic-latest

Expand All @@ -505,7 +507,7 @@ jobs:

- name: Run Semantic Kernel tests
working-directory: ./python
run: uv run --frozen pytest ./tests/unit
run: uv run --frozen --no-sync pytest ./tests/unit

test-langchain:
name: Test LangChain (main branch) on Python ${{ matrix.python-version }}
Expand All @@ -531,14 +533,16 @@ jobs:
with:
path: pydantic-latest

- name: Install UV
- name: Install uv
uses: astral-sh/setup-uv@v6
with:
python-version: ${{ matrix.python-version }}

- name: Install LangChain dependencies
run: |
uv sync --group test
uv sync --no-progress --group test
uv sync --no-progress --directory ./libs/core --group test
uv sync --no-progress --directory ./libs/langchain --group test
uv pip uninstall pydantic
uv pip install -e ./pydantic-latest

Expand All @@ -547,11 +551,11 @@ jobs:

- name: Run LangChain core tests
working-directory: ./libs/core
run: make test
run: UV_NO_SYNC=1 make test

- name: Run LangChain main tests
working-directory: ./libs/langchain
run: make test
run: UV_NO_SYNC=1 make test

test-dify:
name: Test Dify (main branch) on Python ${{ matrix.python-version }}
Expand Down Expand Up @@ -583,19 +587,19 @@ jobs:

- name: Install uv
shell: bash
run: pip install uv~=0.6.14
run: pip install uv~=0.7.0

- name: Install Dify dependencies
run: |
uv sync --directory api --dev
uv sync --no-progress --directory api --dev
uv pip --directory api uninstall pydantic
uv pip --directory api install -e ../pydantic-latest

- name: List installed dependencies
run: uv pip --directory api list

- name: Run Dify unit tests
run: uv run --project api bash dev/pytest/pytest_unit_tests.sh
< 8000 /td> run: uv run --no-sync --project api bash dev/pytest/pytest_unit_tests.sh

test-cadwyn:
name: Test Cadwyn (main branch) on Python ${{ matrix.python-version }}
Expand Down Expand Up @@ -628,14 +632,14 @@ jobs:

- name: Install Cadwyn dependencies
run: |
uv sync --dev --all-extras
uv sync --no-progress --dev --all-extras
uv pip install -e ./pydantic-latest

- name: List installed dependencies
run: uv pip list

- name: Run Cadwyn tests
run: uv run --no-project pytest tests docs_src
run: uv run --no-project --no-sync pytest tests docs_src


create-issue-on-failure:
Expand Down
3 changes: 3 additions & 0 deletions pydantic/_internal/_dataclasses.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ class PydanticDataclass(StandardDataclass, typing.Protocol):
__pydantic_serializer__: ClassVar[SchemaSerializer]
__pydantic_validator__: ClassVar[SchemaValidator | PluggableSchemaValidator]

@classmethod
def __pydantic_fields_complete__(self) -> bool: ...

else:
# See PyCharm issues https://youtrack.jetbrains.com/issue/PY-21915
# and https://youtrack.jetbrains.com/issue/PY-51428
Expand Down
56 changes: 54 additions & 2 deletions pydantic/_internal/_fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@

from ..fields import FieldInfo
from ..main import BaseModel
from ._dataclasses import StandardDataclass
from ._dataclasses import PydanticDataclass, StandardDataclass
from ._decorators import DecoratorInfos


Expand Down Expand Up @@ -485,7 +485,7 @@ def collect_dataclass_fields(
continue

globalns, localns = ns_resolver.types_namespace
ann_type, _ = _typing_extra.try_eval_type(dataclass_field.type, globalns, localns)
ann_type, evaluated = _typing_extra.try_eval_type(dataclass_field.type, globalns, localns)

if _typing_extra.is_classvar_annotation(ann_type):
continue
Expand All @@ -512,10 +512,16 @@ def collect_dataclass_fields(
field_info = FieldInfo_.from_annotated_attribute(
ann_type, dataclass_field.default, _source=AnnotationSource.DATACLASS
)
field_info._original_assignment = dataclass_field.default
else:
field_info = FieldInfo_.from_annotated_attribute(
ann_type, dataclass_field, _source=AnnotationSource.DATACLASS
)
field_info._original_assignment = dataclass_field

if not evaluated:
field_info._complete = False
field_info._original_annotation = ann_type

fields[ann_name] = field_info
update_field_from_config(config_wrapper, ann_name, field_info)
Expand Down Expand Up @@ -545,6 +551,52 @@ def collect_dataclass_fields(
return fields


def rebuild_dataclass_fields(
cls: type[PydanticDataclass],
*,
config_wrapper: ConfigWrapper,
ns_resolver: NsResolver,
typevars_map: Mapping[TypeVar, Any],
) -> dict[str, FieldInfo]:
"""Rebuild the (already present) dataclass fields by trying to reevaluate annotations.

This function should be called whenever a dataclass with incomplete fields is encountered.

Raises:
NameError: If one of the annotations failed to evaluate.

Note:
This function *doesn't* mutate the dataclass fields in place, as it can be called during
schema generation, where you don't want to mutate other dataclass's fields.
"""
FieldInfo_ = import_cached_field_info()

rebuilt_fields: dict[str, FieldInfo] = {}
with ns_resolver.push(cls):
for f_name, field_info in cls.__pydantic_fields__.items():
if field_info._complete:
rebuilt_fields[f_name] = field_info
else:
existing_desc = field_info.description
ann = _typing_extra.eval_type(
field_info._original_annotation,
*ns_resolver.types_namespace,
)
ann = _generics.replace_types(ann, typevars_map)
new_field = FieldInfo_.from_annotated_attribute(
ann,
field_info._original_assignment,
_source=AnnotationSource.DATACLASS,
)

# The description might come from the docstring if `use_attribute_docstrings` was `True`:
new_field.description = new_field.description if new_field.description is not None else existing_desc
update_field_from_config(config_wrapper, f_name, new_field)
rebuilt_fields[f_name] = new_field

return rebuilt_fields


def is_valid_field_name(name: str) -> bool:
return not name.startswith('_')

Expand Down
30 changes: 22 additions & 8 deletions pydantic/_internal/_generate_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@
from ._docs_extraction import extract_docstrings_from_cls
from ._fields import (
collect_dataclass_fields,
rebuild_dataclass_fields,
rebuild_model_fields,
takes_validated_data_argument,
update_field_from_config,
Expand Down Expand Up @@ -1779,14 +1780,27 @@ def _dataclass_schema(

with self._ns_resolver.push(dataclass), self._config_wrapper_stack.push(config):
if is_pydantic_dataclass(dataclass):
# Copy the field info instances to avoid mutating the `FieldInfo` instances
# of the generic dataclass generic origin (e.g. `apply_typevars_map` below).
# Note that we don't apply `deepcopy` on `__pydantic_fields__` because we
# don't want to copy the `FieldInfo` attributes:
fields = {f_name: copy(field_info) for f_name, field_info in dataclass.__pydantic_fields__.items()}
if typevars_map:
for field in fields.values():
field.apply_typevars_map(typevars_map, *self._types_namespace)
if dataclass.__pydantic_fields_complete__():
# Copy the field info instances to avoid mutating the `FieldInfo` instances
# of the generic dataclass generic origin (e.g. `apply_typevars_map` below).
# Note that we don't apply `deepcopy` on `__pydantic_fields__` because we
# don't want to copy the `FieldInfo` attributes:
fields = {
f_name: copy(field_info) for f_name, field_info in dataclass.__pydantic_fields__.items()
}
if typevars_map:
for field in fields.values():
field.apply_typevars_map(typevars_map, *self._types_namespace)
else:
try:
fields = rebuild_dataclass_fields(
dataclass,
config_wrapper=self._config_wrapper,
ns_resolver=self._ns_resolver,
typevars_map=typevars_map or {},
)
except NameError as e:
raise PydanticUndefinedAnnotation.from_name_error(e) from e
else:
fields = collect_dataclass_fields(
dataclass,
Expand Down
9 changes: 9 additions & 0 deletions pydantic/dataclasses.py
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,7 @@ def _dataclass_setstate(self: Any, state: list[Any]) -> None:
original_cls.__firstlineno__ = firstlineno
cls.__firstlineno__ = firstlineno
cls.__qualname__ = original_cls.__qualname__
cls.__pydantic_fields_complete__ = classmethod(_pydantic_fields_complete)
cls.__pydantic_complete__ = False # `complete_dataclass` will set it to `True` if successful.
# TODO `parent_namespace` is currently None, but we could do the same thing as Pydantic models:
# fetch the parent ns using `parent_frame_namespace` (if the dataclass was defined in a function),
Expand All @@ -319,6 +320,14 @@ def _dataclass_setstate(self: Any, state: list[Any]) -> None:
return create_dataclass if _cls is None else create_dataclass(_cls)


def _pydantic_fields_complete(cls: type[PydanticDataclass]) -> bool:
"""Return whether the fields where successfully collected (i.e. type hints were successfully resolves).

This is a private property, not meant to be used outside Pydantic.
"""
return all(field_info._complete for field_info in cls.__pydantic_fields__.values())


__getattr__ = getattr_migration(__name__)

if sys.version_info < (3, 11):
Expand Down
20 changes: 20 additions & 0 deletions tests/test_dataclasses.py
Original file line number Diff line number Diff line change
Expand Up @@ -3129,3 +3129,23 @@ class A:
a: int

assert 'a' in A.__pydantic_fields__ # pyright: ignore[reportAttributeAccessIssue]


def test_dataclass_fields_rebuilt_before_schema_generation() -> None:
"""https://github.com/pydantic/pydantic/issues/11947"""

def update_schema(schema: dict[str, Any]) -> None:
schema['test'] = schema['title']

@pydantic.dataclasses.dataclass
class A:
a: """Annotated[
Forward,
Field(field_title_generator=lambda name, _: name, json_schema_extra=update_schema)
]""" = True

Forward = bool

ta = TypeAdapter(A)

assert ta.json_schema()['properties']['a']['test'] == 'a'
Loading
0