8000 Recollect fields during rebuild if collection failed · pydantic/pydantic@9f735a6 · GitHub
[go: up one dir, main page]

Skip to content

Commit 9f735a6

Browse files
committed
Recollect fields during rebuild if collection failed
This is the biggest change in this PR, and the hardest to reason about and the trickiest to implement. Instead of re-evaluating annotations on a field-by-field basis, we assume all fields have their annotation evaluated (and consequently the `FieldInfo` attributes -- some of them set depending on the annotation -- are correct). To do so, we collect model fields (if necessary, thanks to the new `__pydantic_fields_complete__` attribute) in `_model_schema()`, and then have `_common_field_schema()` called with a fully built `FieldInfo` instance (this mimics what is done for dataclasses). When `model_rebuild()` is called, we also want to recollect fields if it wasn't successful the first time. This introduces challenges, such as keeping the model assignments to be able to recollect fields and call `FieldInfo.from_annotated_attribute()`.
1 parent 023de55 commit 9f735a6

File tree

5 files changed

+87
-65
lines changed

5 files changed

+87
-65
lines changed

pydantic/_internal/_fields.py

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ def collect_model_fields( # noqa: C901
7878
ns_resolver: NsResolver | None,
7979
*,
8080
typevars_map: dict[Any, Any] | None = None,
81-
) -> tuple[dict[str, FieldInfo], set[str]]:
81+
) -> tuple[dict[str, FieldInfo], set[str], bool]:
8282
"""Collect the fields of a nascent pydantic model.
8383
8484
Also collect the names of any ClassVars present in the type hints.
@@ -92,7 +92,8 @@ def collect_model_fields( # noqa: C901
9292
typevars_map: A dictionary mapping type variables to their concrete types.
9393
9494
Returns:
95-
A tuple contains fields and class variables.
95+
A three-tuple containing the model fields, the names of the class variables and a boolean indicating if
96+
annotation evaluation a succeeded.
9697
9798
Raises:
9899
NameError:
@@ -110,6 +111,7 @@ def collect_model_fields( # noqa: C901
110111
parent_fields_lookup.update(model_fields)
111112

112113
type_hints = _typing_extra.get_model_type_hints(cls, ns_resolver=ns_resolver)
114+
evaluation_suceeded = True
113115

114116
# https://docs.python.org/3/howto/annotations.html#accessing-the-annotations-dict-of-an-object-in-python-3-9-and-older
115117
# annotations is only used for finding fields in parent classes
@@ -179,6 +181,12 @@ def collect_model_fields( # noqa: C901
179181
f"Unexpected field with name {ann_name!r}; only 'root' is allowed as a field of a `RootModel`"
180182
)
181183

184+
# At this point, the field is not a private attribute/class variable. If the type annotation did not
185+
# evaluate successfully, we set the `evaluation_suceeded` flag that is used to decide if we continue with
186+
# core schema generation:
187+
if evaluation_suceeded and not evaluated:
188+
evaluation_suceeded = evaluated
189+
182190
# when building a generic model with `MyModel[int]`, the generic_origin check makes sure we don't get
183191
# "... shadows an attribute" warnings
184192
generic_origin = getattr(cls, '__pydantic_generic_metadata__', {}).get('origin')
@@ -209,7 +217,6 @@ def collect_model_fields( # noqa: C901
209217
if assigned_value is PydanticUndefined:
210218
if ann_name in annotations:
211219
field_info = FieldInfo_.from_annotation(ann_type)
212-
field_info.evaluated = evaluated
213220
else:
214221
# if field has no default value and is not in __annotations__ this means that it is
215222
# defined in a base class and we can take it from there
@@ -222,7 +229,6 @@ def collect_model_fields( # noqa: C901
222229
# generated thanks to models not being fully defined while initializing recursive models.
223230
# Nothing stops us from just creating a new FieldInfo for this type hint, so we do this.
224231
field_info = FieldInfo_.from_annotation(ann_type)
225-
field_info.evaluated = evaluated
226232
else:
227233
_warn_on_nested_alias_in_annotation(ann_type, ann_name)
228234
if isinstance(assigned_value, FieldInfo_) and ismethoddescriptor(assigned_value.default):
@@ -234,14 +240,6 @@ def collect_model_fields( # noqa: C901
234240
assigned_value.default = assigned_value.default.__get__(None, cls)
235241

236242
field_info = FieldInfo_.from_annotated_attribute(ann_type, assigned_value)
237-
field_info.evaluated = evaluated
238-
# attributes which are fields are removed from the class namespace:
239-
# 1. To match the behaviour of annotation-only fields
240-
# 2. To avoid false positives in the NameError check above
241-
try:
242-
delattr(cls, ann_name)
243-
except AttributeError:
244-
pass # indicates the attribute was on a parent class
245243

246244
# Use cls.__dict__['__pydantic_decorators__'] instead of cls.__pydantic_decorators__
247245
# to make sure the decorators have already been built for this exact class
@@ -256,7 +254,7 @@ def collect_model_fields( # noqa: C901
256254

257255
if config_wrapper.use_attribute_docstrings:
258256
_update_fields_from_docstrings(cls, fields)
259-
return fields, class_vars
257+
return fields, class_vars, evaluation_suceeded
260258

261259

262260
def _warn_on_nested_alias_in_annotation(ann_type: type[Any], ann_name: str) -> None:

pydantic/_internal/_generate_schema.py

Lines changed: 33 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -84,9 +84,9 @@
8484
inspect_validator,
8585
)
8686
from ._docs_extraction import extract_docstrings_from_cls
87-
from ._fields import collect_dataclass_fields, takes_validated_data_argument
87+
from ._fields import collect_dataclass_fields, collect_model_fields, takes_validated_data_argument
8888
from ._forward_ref import PydanticRecursiveRef
89-
from ._generics import get_standard_typevars_map, has_instance_in_type, replace_types
89+
from ._generics import get_standard_typevars_map, replace_types
9090
from ._import_utils import import_cached_base_model, import_cached_field_info
9191
from ._mock_val_ser import MockCoreSchema
9292
from ._namespace_utils import NamespacesTuple, NsResolver
@@ -695,6 +695,8 @@ def generate_schema(
695695

696696
def _model_schema(self, cls: type[BaseModel]) -> core_schema.CoreSchema:
697697
"""Generate schema for a Pydantic model."""
698+
BaseModel_ = import_cached_base_model()
699+
698700
with self.defs.get_schema_or_ref(cls) as (model_ref, maybe_schema):
699701
if maybe_schema is not None:
700702
return maybe_schema
@@ -716,22 +718,38 @@ def _model_schema(self, cls: type[BaseModel]) -> core_schema.CoreSchema:
716718
else:
717719
return schema
718720

719-
fields = getattr(cls, '__pydantic_fields__', {})
720-
decorators = cls.__pydantic_decorators__
721-
computed_fields = decorators.computed_fields
722-
check_decorator_fields_exist(
723-
chain(
724-
decorators.field_validators.values(),
725-
decorators.field_serializers.values(),
726-
decorators.validators.values(),
727-
),
728-
{*fields.keys(), *computed_fields.keys()},
729-
)
730721
config_wrapper = ConfigWrapper(cls.model_config, check=False)
731-
core_config = config_wrapper.core_config(title=cls.__name__)
732-
model_validators = decorators.model_validators.values()
733722

734723
with self._config_wrapper_stack.push(config_wrapper), self._ns_resolver.push(cls):
724+
core_config = self._config_wrapper.core_config(title=cls.__name__)
725+
726+
if cls.__pydantic_fields_complete__ or cls is BaseModel_:
727+
fields = getattr(cls, '__pydantic_fields__', {})
728+
else:
729+
fields, _, evaluation_suceeded = collect_model_fields(
730+
cls,
731+
config_wrapper=self._config_wrapper,
732+
ns_resolver=self._ns_resolver,
733+
typevars_map=self._typevars_map,
734+
)
735+
if not evaluation_suceeded:
736+
raise PydanticUndefinedAnnotation(
737+
name='<unknown>', message=f'An annotation is not defined in model {cls}'
738+
)
739+
740+
decorators = cls.__pydantic_decorators__
741+
computed_fields = decorators.computed_fields
742+
check_decorator_fields_exist(
743+
chain(
744+
decorators.field_validators.values(),
745+
decorators.field_serializers.values(),
746+
decorators.validators.values(),
747+
),
748+
{*fields.keys(), *computed_fields.keys()},
749+
)
750+
751+
model_validators = decorators.model_validators.values()
752+
735753
extras_schema = None
736754
if core_config.get('extra_fields_behavior') == 'allow':
737755
assert cls.__mro__[0] is cls
@@ -1296,32 +1314,6 @@ def _apply_field_title_generator_to_field_info(
12961314
def _common_field_schema( # C901
12971315
self, name: str, field_info: FieldInfo, decorators: DecoratorInfos
12981316
) -> _CommonField:
1299-
# Update FieldInfo annotation if appropriate:
1300-
FieldInfo = import_cached_field_info()
1301-
if not field_info.evaluated:
1302-
# TODO Can we use field_info.apply_typevars_map here?
1303-
try:
1304-
evaluated_type = _typing_extra.eval_type(field_info.annotation, *self._types_namespace)
1305-
except NameError as e:
1306-
raise PydanticUndefinedAnnotation.from_name_error(e) from e
1307-
evaluated_type = replace_types(evaluated_type, self._typevars_map)
1308-
field_info.evaluated = True
1309-
if not has_instance_in_type(evaluated_type, PydanticRecursiveRef):
1310-
new_field_info = FieldInfo.from_annotation(evaluated_type)
1311-
field_info.annotation = new_field_info.annotation
1312-
1313-
# Handle any field info attributes that may have been obtained from now-resolved annotations
1314-
for k, v in new_field_info._attributes_set.items():
1315-
# If an attribute is already set, it means it was set by assigning to a call to Field (or just a
1316-
# default value), and that should take the highest priority. So don't overwrite existing attributes.
1317-
# We skip over "attributes" that are present in the metadata_lookup dict because these won't
1318-
# actually end up as attributes of the `FieldInfo` instance.
1319-
if k not in field_info._attributes_set and k not in field_info.metadata_lookup:
1320-
setattr(field_info, k, v)
1321-
1322-
# Finally, ensure the field info also reflects all the `_attributes_set` that are actually metadata.
1323-
field_info.metadata = [*new_field_info.metadata, *field_info.metadata]
1324-
13251317
source_type, annotations = field_info.annotation, field_info.metadata
13261318

13271319
def set_discriminator(schema: CoreSchema) -> CoreSchema:

pydantic/_internal/_model_construction.py

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,9 @@ def wrapped_model_post_init(self: BaseModel, context: Any, /) -> None:
203203
'parameters': parameters,
204204
}
205205

206-
cls.__pydantic_complete__ = False # Ensure this specific class gets completed
206+
# Do not inherit these attributes from the parent model, if any:
207+
cls.__pydantic_complete__ = False
208+
cls.__pydantic_fields_complete__ = False
207209

208210
# preserve `__set_name__` protocol defined in https://peps.python.org/pep-0487
209211
# for attributes not in `new_namespace` (e.g. private attributes)
@@ -216,9 +218,6 @@ def wrapped_model_post_init(self: BaseModel, context: Any, /) -> None:
216218
if isinstance(parent_namespace, dict):
217219
parent_namespace = unpack_lenient_weakvaluedict(parent_namespace)
218220

219-
if config_wrapper.frozen and '__hash__' not in namespace:
220-
set_default_hash_func(cls, bases)
221-
222221
complete_model_class(
223222
cls,
224223
config_wrapper,
@@ -227,6 +226,9 @@ def wrapped_model_post_init(self: BaseModel, context: Any, /) -> None:
227226
create_model_module=_create_model_module,
228227
)
229228

229+
if config_wrapper.frozen and '__hash__' not in namespace:
230+
set_default_hash_func(cls, bases)
231+
230232
# If this is placed before the complete_model_class call above,
231233
# the generic computed fields return type is set to PydanticUndefined
232234
cls.__pydantic_computed_fields__ = {
@@ -509,7 +511,7 @@ def set_model_fields(
509511
cls: type[BaseModel],
510512
config_wrapper: ConfigWrapper,
511513
ns_resolver: NsResolver | None,
512-
) -> None:
514+
) -> bool:
513515
"""Collect and set `cls.__pydantic_fields__` and `cls.__class_vars__`.
514516
515517
Args:
@@ -518,7 +520,22 @@ def set_model_fields(
518520
ns_resolver: Namespace resolver to use when getting model annotations.
519521
"""
520522
typevars_map = get_model_typevars_map(cls)
521-
fields, class_vars = collect_model_fields(cls, config_wrapper, ns_resolver, typevars_map=typevars_map)
523+
fields, class_vars, evaluation_suceeded = collect_model_fields(
524+
cls, config_wrapper, ns_resolver, typevars_map=typevars_map
525+
)
526+
527+
if evaluation_suceeded:
528+
# attributes which are fields are removed from the class namespace:
529+
# 1. To match the behaviour of annotation-only fields
530+
# 2. To avoid false positives in the NameError check above.
531+
# We only remove them if annotation evaluation suceeded, as otherwise
532+
# fields collection may be performed one more time, and we to keep assignments
533+
# (so that we can call `FieldInfo.from_annotated_attribute()`).
534+
for ann_name in fields:
535+
try:
536+
delattr(cls, ann_name)
537+
except AttributeError: # indicates the attribute was on a parent class
538+
pass
522539

523540
cls.__pydantic_fields__ = fields
524541
cls.__class_vars__.update(class_vars)
@@ -535,6 +552,8 @@ def set_model_fields(
535552
if value is not None and value.default is not PydanticUndefined:
536553
setattr(cls, k, value.default)
537554

555+
return evaluation_suceeded
556+
538557

539558
def complete_model_class(
540559
cls: type[BaseModel],
@@ -563,12 +582,22 @@ def complete_model_class(
563582
PydanticUndefinedAnnotation: If `PydanticUndefinedAnnotation` occurs in`__get_pydantic_core_schema__`
564583
and `raise_errors=True`.
565584
"""
585+
if not cls.__pydantic_fields_complete__:
586+
evaluation_suceeded = set_model_fields(cls, config_wrapper=config_wrapper, ns_resolver=ns_resolver)
587+
if not evaluation_suceeded:
588+
if raise_errors:
589+
raise PydanticUndefinedAnnotation(
590+
name='<unknown>', message=f'An annotation is not defined in model {cls}'
591+
)
592+
set_model_mocks(cls)
593+
return False
594+
595+
cls.__pydantic_fields_complete__ = True
596+
566597
if config_wrapper.defer_build:
567598
set_model_mocks(cls)
568599
return False
569600

570-
set_model_fields(cls, config_wrapper=config_wrapper, ns_resolver=ns_resolver)
571-
572601
typevars_map = get_model_typevars_map(cls)
573602
gen_schema = GenerateSchema(
574603
config_wrapper,

pydantic/fields.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,6 @@ class FieldInfo(_repr.Representation):
155155

156156
__slots__ = (
157157
'annotation',
158-
'evaluated',
159158
'default',
160159
'default_factory',
161160
'alias',
@@ -209,7 +208,6 @@ def __init__(self, **kwargs: Unpack[_FieldInfoInputs]) -> None:
209208
self._attributes_set = {k: v for k, v in kwargs.items() if v is not _Unset}
210209
kwargs = {k: _DefaultValues.get(k) if v is _Unset else v for k, v in kwargs.items()} # type: ignore
211210
self.annotation = kwargs.get('annotation')
212-
self.evaluated = False
213211

214212
default = kwargs.pop('default', PydanticUndefined)
215213
if default is Ellipsis:
@@ -680,7 +678,7 @@ def __repr_args__(self) -> ReprArgs:
680678
for s in self.__slots__:
681679
# TODO: properly make use of the protocol (https://rich.readthedocs.io/en/stable/pretty.html#rich-repr-protocol)
682680
# By yielding a three-tuple:
683-
if s in ('_attributes_set', 'annotation', 'evaluated'):
681+
if s in ('_attributes_set', 'annotation'):
684682
continue
685683
elif s == 'metadata' and not self.metadata:
686684
continue

pydantic/main.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,11 @@ class BaseModel(metaclass=_model_construction.ModelMetaclass):
147147
__pydantic_complete__: ClassVar[bool] = False
148148
"""Whether model building is completed, or if there are still undefined fields."""
149149

150+
__pydantic_fields_complete__: ClassVar[bool] = False
151+
"""Whether the fields where successfully collected. This is a private attribute, not meant
152+
to be used outside Pydantic.
153+
"""
154+
150155
__pydantic_core_schema__: ClassVar[CoreSchema]
151156
"""The core schema of the model."""
152157

0 commit comments

Comments
 (0)
0