Skip to content

Commit 09dbcf2

Browse files
committed
Add FieldInfo.asdict() method, improve documentation around FieldInfo
Add an example of how to support dynamic model creation deriving from an existing one, without applying mutations/copies on `FieldInfo` instances. Better document some `FieldInfo` attributes, undocument most of methods that should be private. Fix some unrelated documentation about fields. Backport of: #12411
1 parent 5da4331 commit 09dbcf2

File tree

9 files changed

+396
-14
lines changed

9 files changed

+396
-14
lines changed

docs/api/fields.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,10 @@
88
- ModelPrivateAttr
99
- computed_field
1010
- ComputedFieldInfo
11+
filters:
12+
- "!^from_field$"
13+
- "!^from_annotation$"
14+
- "!^from_annotated_attribute$"
15+
- "!^merge_field_infos$"
16+
- "!^rebuild_annotation$"
17+
- "!^apply_typevars_map$"

docs/concepts/fields.md

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,33 @@ assignment form instead.
9696
2. The [`Field()`][pydantic.fields.Field] function is applied to the "top-level" union type,
9797
hence the `deprecated` flag will be applied to the field.
9898

99+
## Inspecting model fields
100+
101+
The fields of a model can be inspected using the [`model_fields`][pydantic.main.BaseModel.model_fields] class attribute
102+
(or the `__pydantic_fields__` attribute for [Pydantic dataclasses](./dataclasses.md)). It is a mapping of field names
103+
to their definition (represented as [`FieldInfo`][pydantic.fields.FieldInfo] instances).
104+
105+
```python
106+
from typing import Annotated
107+
108+
from pydantic import BaseModel, Field, WithJsonSchema
109+
110+
111+
class Model(BaseModel):
112+
a: Annotated[
113+
int, Field(gt=1), WithJsonSchema({'extra': 'data'}), Field(alias='b')
114+
] = 1
115+
116+
117+
field_info = Model.model_fields['a']
118+
print(field_info.annotation)
119+
#> <class 'int'>
120+
print(field_info.alias)
121+
#> b
122+
print(field_info.metadata)
123+
#> [Gt(gt=1), WithJsonSchema(json_schema={'extra': 'data'}, mode=None)]
124+
```
125+
99126
## Default values
100127

101128
Default values for fields can be provided using the normal assignment syntax or by providing a value
@@ -603,9 +630,13 @@ except ValidationError as e:
603630

604631
1. Since `name` field is frozen, the assignment is not allowed.
605632

606-
## Exclude
633+
<!-- old anchor added for backwards compatibility -->
634+
<!-- markdownlint-disable-next-line no-empty-links -->
635+
[](){#exclude}
636+
637+
## Excluding fields
607638

608-
The `exclude` parameter can be used to control which fields should be excluded from the
639+
The `exclude` and `exclude_if` parameters can be used to control which fields should be excluded from the
609640
model when exporting the model.
610641

611642
See the following example:
@@ -624,9 +655,9 @@ print(user.model_dump()) # (1)!
624655
#> {'name': 'John'}
625656
```
626657

627-
1. The `age` field is not included in the `model_dump()` output, since it is excluded.
658+
1. The `age` field is not included in the [`model_dump()`][pydantic.BaseModel.model_dump] output, since it is excluded.
628659

629-
See the [Serialization] section for more details.
660+
See the dedicated [serialization section](./serialization.md#field-inclusion-and-exclusion) for more details.
630661

631662
## Deprecated fields
632663

docs/concepts/models.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1415,6 +1415,8 @@ except ValidationError as e:
14151415
* the model must be defined globally
14161416
* the `__module__` argument must be provided
14171417

1418+
See also: the [dynamic model example](../examples/dynamic_models.md), providing guidelines to derive an optional model from another one.
1419+
14181420
## `RootModel` and custom root types
14191421

14201422
??? api "API Documentation"

docs/contributing.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,7 @@ def bar(self, baz: int) -> str:
190190
return 'bar'
191191
```
192192

193-
You may include example code in docstrings. This code should be complete, self-contained, and runnable. Docstring examples are tested, so make sure they are correct and complete. See [`FieldInfo.from_annotated_attribute`][pydantic.fields.FieldInfo.from_annotated_attribute] for an example.
193+
You may include example code in docstrings. This code should be complete, self-contained, and runnable. Docstring examples are tested, so make sure they are correct and complete. See [`BeforeValidator`][pydantic.functional_validators.AfterValidator] for an example.
194194

195195
!!! note "Class and instance attributes"
196196
Class attributes should be documented in the class docstring.

docs/examples/dynamic_models.md

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
Models can be [created dynamically](../concepts/models.md#dynamic-model-creation) using the [`create_model()`][pydantic.create_model]
2+
factory function.
3+
4+
In this example, we will show how to dynamically derive a model from an existing one, making every field optional. To achieve this,
5+
we will make use of the [`model_fields`][pydantic.main.BaseModel.model_fields] model class attribute, and derive new annotations
6+
from the field definitions to be passed to the [`create_model()`][pydantic.create_model] factory. Of course, this example can apply
7+
to any use case where you need to derive a new model from another (remove default values, add aliases, etc).
8+
9+
=== "Python 3.9"
10+
11+
```python {lint="skip" linenums="1"}
12+
from typing import Annotated, Union
13+
14+
from pydantic import BaseModel, Field, create_model
15+
16+
17+
def make_fields_optional(model_cls: type[BaseModel]) -> type[BaseModel]:
18+
new_fields = {}
19+
20+
for f_name, f_info in model_cls.model_fields.items():
21+
f_dct = f_info.asdict()
22+
new_fields[f_name] = (
23+
Annotated[(Union[f_dct['annotation'], None], *f_dct['metadata'], Field(**f_dct['attributes']))],
24+
None,
25+
)
26+
27+
return create_model(
28+
f'{type.__name__}Optional',
29+
__base__=model_cls, # (1)!
30+
**new_fields,
31+
)
32+
```
33+
34+
1. Using the original model as a base will inherit the [validators](../concepts/validators.md), [computed fields](../concepts/fields.md#the-computed_field-decorator), etc.
35+
The parent fields are overridden by the ones we define.
36+
37+
=== "Python 3.10"
38+
39+
```python {lint="skip" requires="3.10" linenums="1"}
40+
from typing import Annotated
41+
42+
from pydantic import BaseModel, Field, create_model
43+
44+
45+
def make_fields_optional(model_cls: type[BaseModel]) -> type[BaseModel]:
46+
new_fields = {}
47+
48+
for f_name, f_info in model_cls.model_fields.items():
49+
f_dct = f_info.asdict()
50+
new_fields[f_name] = (
51+
Annotated[(f_dct['annotation'] | None, *f_dct['metadata'], Field(**f_dct['attributes']))],
52+
None,
53+
)
54+
55+
return create_model(
56+
f'{type.__name__}Optional',
57+
__base__=model_cls, # (1)!
58+
**new_fields,
59+
)
60+
```
61+
62+
1. Using the original model as a base will inherit the [validators](../concepts/validators.md), [computed fields](../concepts/fields.md#the-computed_field-decorator), etc.
63+
The parent fields are overridden by the ones we define.
64+
65+
=== "Python 3.11 and above"
66+
67+
```python {lint="skip" requires="3.11" linenums="1"}
68+
from typing import Annotated
69+
70+
from pydantic import BaseModel, Field, create_model
71+
72+
73+
def make_fields_optional(model_cls: type[BaseModel]) -> type[BaseModel]:
74+
new_fields = {}
75+
76+
for f_name, f_info in model_cls.model_fields.items():
77+
f_dct = f_info.asdict()
78+
new_fields[f_name] = (
79+
Annotated[f_dct['annotation'] | None, *f_dct['metadata'], Field(**f_dct['attributes'])],
80+
None,
81+
)
82+
83+
return create_model(
84+
f'{type.__name__}Optional',
85+
__base__=model_cls, # (1)!
86+
**new_fields,
87+
)
88+
```
89+
90+
1. Using the original model as a base will inherit the [validators](../concepts/validators.md), [computed fields](../concepts/fields.md#the-computed_field-decorator), etc.
91+
The parent fields are overridden by the ones we define.
92+
93+
For each field, we generate a dictionary representation of the [`FieldInfo`][pydantic.fields.FieldInfo] instance
94+
using the [`asdict()`][pydantic.fields.FieldInfo.asdict] method, containing the annotation, metadata and attributes.
95+
96+
With the following model:
97+
98+
```python {lint="skip" test="skip"}
99+
class Model(BaseModel):
100+
f: Annotated[int, Field(gt=1), WithJsonSchema({'extra': 'data'}), Field(title='F')] = 1
101+
```
102+
103+
The [`FieldInfo`][pydantic.fields.FieldInfo] instance of `f` will have three items in its dictionary representation:
104+
105+
* `annotation`: `int`.
106+
* `metadata`: A list containing the type-specific constraints and other metadata: `[Gt(1), WithJsonSchema({'extra': 'data'})]`.
107+
* `attributes`: The remaining field-specific attributes: `{'title': 'F'}`.
108+
109+
With that in mind, we can recreate an annotation that "simulates" the one from the original model:
110+
111+
=== "Python 3.9 and above"
112+
113+
```python {lint="skip" test="skip"}
114+
new_annotation = Annotated[(
115+
f_dct['annotation'] | None, # (1)!
116+
*f_dct['metadata'], # (2)!
117+
Field(**f_dct['attributes']), # (3)!
118+
)]
119+
```
120+
121+
1. We create a new annotation from the existing one, but adding `None` as an allowed value
122+
(in our previous example, this is equivalent to `int | None`).
123+
124+
2. We unpack the metadata to be reused (in our previous example, this is equivalent to
125+
specifying `Field(gt=1)` and `WithJsonSchema({'extra': 'data'})` as [`Annotated`][typing.Annotated]
126+
metadata).
127+
128+
3. We specify the field-specific attributes by using the [`Field()`][pydantic.Field] function
129+
(in our previous example, this is equivalent to `Field(title='F')`).
130+
131+
=== "Python 3.11 and above"
132+
133+
```python {lint="skip" test="skip"}
134+
new_annotation = Annotated[
135+
f_dct['annotation'] | None, # (1)!
136+
*f_dct['metadata'], # (2)!
137+
Field(**f_dct['attributes']), # (3)!
138+
]
139+
```
140+
141+
1. We create a new annotation from the existing one, but adding `None` as an allowed value
142+
(in our previous example, this is equivalent to `int | None`).
143+
144+
2. We unpack the metadata to be reused (in our previous example, this is equivalent to
145+
specifying `Field(gt=1)` and `WithJsonSchema({'extra': 'data'})` as [`Annotated`][typing.Annotated]
146+
metadata).
147+
148+
3. We specify the field-specific attributes by using the [`Field()`][pydantic.Field] function
149+
(in our previous example, this is equivalent to `Field(title='F')`).
150+
151+
and specify `None` as a default value (the second element of the tuple for the field definition accepted by [`create_model()`][pydantic.create_model]).
152+
153+
Here is a demonstration of our factory function:
154+
155+
```python {lint="skip" test="skip"}
156+
from pydantic import BaseModel, Field
157+
158+
159+
class Model(BaseModel):
160+
a: Annotated[int, Field(gt=1)]
161+
162+
163+
ModelOptional = make_fields_optional(Model)
164+
165+
m = ModelOptional()
166+
print(m.a)
167+
#> None
168+
```
169+
170+
A couple notes on the implementation:
171+
172+
* Our `make_fields_optional()` function is defined as returning an arbitrary Pydantic model class (`-> type[BaseModel]`).
173+
An alternative solution can be to use a type variable to preserve the input class:
174+
175+
=== "Python 3.9 and above"
176+
177+
```python {lint="skip" test="skip"}
178+
ModelTypeT = TypeVar('ModelTypeT', bound=type[BaseModel])
179+
180+
def make_fields_optional(model_cls: ModelTypeT) -> ModelTypeT:
181+
...
182+
```
183+
184+
=== "Python 3.12 and above"
185+
186+
```python {lint="skip" test="skip"}
187+
def make_fields_optional[ModelTypeT: type[BaseModel]](model_cls: ModelTypeT) -> ModelTypeT:
188+
...
189+
```
190+
191+
However, note that static type checkers *won't* be able to understand that all fields are now optional.
192+
193+
* The experimental [`MISSING` sentinel](../concepts/experimental.md#missing-sentinel) can be used as an alternative to `None`
194+
for the default values. Simply replace `None` by `MISSING` in the new annotation and default value.
195+
196+
* You might be tempted to make a copy of the original [`FieldInfo`][pydantic.fields.FieldInfo] instances, add a
197+
default and/or perform other mutations, to then reuse it as [`Annotated`][typing.Annotated] metadata. While this
198+
may work in some cases, it is **not** a supported pattern, and could break or be deprecated at any point. We strongly
199+
encourage using the pattern from this example instead.

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@ nav:
166166
- Queues: examples/queues.md
167167
- Databases: examples/orms.md
168168
- Custom Validators: examples/custom_validators.md
169+
- Dynamic models: examples/dynamic_models.md
169170
- Error Messages:
170171
- Error Handling: errors/errors.md
171172
- Validation Errors: errors/validation_errors.md

pydantic/_internal/_fields.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
import dataclasses
66
import warnings
77
from collections.abc import Mapping
8-
from copy import copy
98
from functools import cache
109
from inspect import Parameter, ismethoddescriptor, signature
1110
from re import Pattern
@@ -348,7 +347,7 @@ def collect_model_fields( # noqa: C901
348347
else:
349348
# The field was present on one of the (possibly multiple) base classes
350349
# copy the field to make sure typevar substitutions don't cause issues with the base classes
351-
field_info = copy(parent_fields_lookup[ann_name])
350+
field_info = parent_fields_lookup[ann_name]._copy()
352351

353352
else: # An assigned value is present (either the default value, or a `Field()` function)
354353
if isinstance(assigned_value, FieldInfo_) and ismethoddescriptor(assigned_value.default):

0 commit comments

Comments
 (0)