Skip to content

Commit f256531

Browse files
authored
Add support for extra keys validation for model fields (#1671)
This is only implemented for model fields but this could be done for dataclass/typed dict as well. I only went with models for now as it is the only data type where extra validation can be customized through `__pydantic_extra__`. This also only implements validation support. Extra values can have serialization customized but I don't think we should also allow serialization customization for extra keys as we don't have such a concept for "normal" field names.
1 parent ac17f0c commit f256531

File tree

3 files changed

+60
-7
lines changed

3 files changed

+60
-7
lines changed

python/pydantic_core/core_schema.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3013,6 +3013,7 @@ class ModelFieldsSchema(TypedDict, total=False):
30133013
computed_fields: list[ComputedField]
30143014
strict: bool
30153015
extras_schema: CoreSchema
3016+
extras_keys_schema: CoreSchema
30163017
extra_behavior: ExtraBehavior
30173018
from_attributes: bool
30183019
ref: str
@@ -3027,14 +3028,15 @@ def model_fields_schema(
30273028
computed_fields: list[ComputedField] | None = None,
30283029
strict: bool | None = None,
30293030
extras_schema: CoreSchema | None = None,
3031+
extras_keys_schema: CoreSchema | None = None,
30303032
extra_behavior: ExtraBehavior | None = None,
30313033
from_attributes: bool | None = None,
30323034
ref: str | None = None,
30333035
metadata: dict[str, Any] | None = None,
30343036
serialization: SerSchema | None = None,
30353037
) -> ModelFieldsSchema:
30363038
"""
3037-
Returns a schema that matches a typed dict, e.g.:
3039+
Returns a schema that matches the fields of a Pydantic model, e.g.:
30383040
30393041
```py
30403042
from pydantic_core import SchemaValidator, core_schema
@@ -3048,15 +3050,16 @@ def model_fields_schema(
30483050
```
30493051
30503052
Args:
3051-
fields: The fields to use for the typed dict
3053+
fields: The fields of the model
30523054
model_name: The name of the model, used for error messages, defaults to "Model"
30533055
computed_fields: Computed fields to use when serializing the model, only applies when directly inside a model
3054-
strict: Whether the typed dict is strict
3055-
extras_schema: The extra validator to use for the typed dict
3056+
strict: Whether the model is strict
3057+
extras_schema: The schema to use when validating extra input data
3058+
extras_keys_schema: The schema to use when validating the keys of extra input data
30563059
ref: optional unique identifier of the schema, used to reference the schema in other places
30573060
metadata: Any other information you want to include with the schema, not used by pydantic-core
3058-
extra_behavior: The extra behavior to use for the typed dict
3059-
from_attributes: Whether the typed dict should be populated from attributes
3061+
extra_behavior: The extra behavior to use for the model fields
3062+
from_attributes: Whether the model fields should be populated from attributes
30603063
serialization: Custom serialization schema
30613064
"""
30623065
return _dict_not_none(
@@ -3066,6 +3069,7 @@ def model_fields_schema(
30663069
computed_fields=computed_fields,
30673070
strict=strict,
30683071
extras_schema=extras_schema,
3072+
extras_keys_schema=extras_keys_schema,
30693073
extra_behavior=extra_behavior,
30703074
from_attributes=from_attributes,
30713075
ref=ref,

src/validators/model_fields.rs

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ pub struct ModelFieldsValidator {
3434
model_name: String,
3535
extra_behavior: ExtraBehavior,
3636
extras_validator: Option<Box<CombinedValidator>>,
37+
extras_keys_validator: Option<Box<CombinedValidator>>,
3738
strict: bool,
3839
from_attributes: bool,
3940
loc_by_alias: bool,
@@ -62,6 +63,11 @@ impl BuildValidator for ModelFieldsValidator {
6263
(Some(_), _) => return py_schema_err!("extras_schema can only be used if extra_behavior=allow"),
6364
(_, _) => None,
6465
};
66+
let extras_keys_validator = match (schema.get_item(intern!(py, "extras_keys_schema"))?, &extra_behavior) {
67+
(Some(v), ExtraBehavior::Allow) => Some(Box::new(build_validator(&v, config, definitions)?)),
68+
(Some(_), _) => return py_schema_err!("extras_keys_schema can only be used if extra_behavior=allow"),
69+
(_, _) => None,
70+
};
6571
let model_name: String = schema
6672
.get_as(intern!(py, "model_name"))?
6773
.unwrap_or_else(|| "Model".to_string());
@@ -98,6 +104,7 @@ impl BuildValidator for ModelFieldsValidator {
98104
model_name,
99105
extra_behavior,
100106
extras_validator,
107+
extras_keys_validator,
101108
strict,
102109
from_attributes,
103110
loc_by_alias: config.get_as(intern!(py, "loc_by_alias"))?.unwrap_or(true),
@@ -244,6 +251,7 @@ impl Validator for ModelFieldsValidator {
244251
fields_set_vec: &'a mut Vec<Py<PyString>>,
245252
extra_behavior: ExtraBehavior,
246253
extras_validator: Option<&'a CombinedValidator>,
254+
extras_keys_validator: Option<&'a CombinedValidator>,
247255
state: &'a mut ValidationState<'s, 'py>,
248256
}
249257

@@ -294,7 +302,22 @@ impl Validator for ModelFieldsValidator {
294302
}
295303
ExtraBehavior::Ignore => {}
296304
ExtraBehavior::Allow => {
297-
let py_key = either_str.as_py_string(self.py, self.state.cache_str());
305+
let py_key = match self.extras_keys_validator {
306+
Some(validator) => {
307+
match validator.validate(self.py, raw_key.borrow_input(), self.state) {
308+
Ok(value) => value.downcast_bound::<PyString>(self.py)?.clone(),
309+
Err(ValError::LineErrors(line_errors)) => {
310+
for err in line_errors {
311+
self.errors.push(err.with_outer_location(raw_key.clone()));
312+
}
313+
continue;
314+
}
315+
Err(err) => return Err(err),
316+
}
317+
}
318+
None => either_str.as_py_string(self.py, self.state.cache_str()),
319+
};
320+
298321
if let Some(validator) = self.extras_validator {
299322
match validator.validate(self.py, value, self.state) {
300323
Ok(value) => {
@@ -326,6 +349,7 @@ impl Validator for ModelFieldsValidator {
326349
fields_set_vec: &mut fields_set_vec,
327350
extra_behavior: self.extra_behavior,
328351
extras_validator: self.extras_validator.as_deref(),
352+
extras_keys_validator: self.extras_keys_validator.as_deref(),
329353
state,
330354
})??;
331355

tests/validators/test_model_fields.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,13 @@ def test_allow_extra_invalid():
213213
)
214214
)
215215

216+
with pytest.raises(SchemaError, match='extras_keys_schema can only be used if extra_behavior=allow'):
217+
SchemaValidator(
218+
schema=core_schema.model_fields_schema(
219+
fields={}, extras_keys_schema=core_schema.int_schema(), extra_behavior='ignore'
220+
)
221+
)
222+
216223

217224
def test_allow_extra_wrong():
218225
with pytest.raises(SchemaError, match='Invalid extra_behavior: `wrong`'):
@@ -1758,6 +1765,24 @@ def test_extra_behavior_ignore(config: Union[core_schema.CoreConfig, None], sche
17581765
assert 'not_f' not in m
17591766

17601767

1768+
def test_extra_behavior_allow_keys_validation() -> None:
1769+
v = SchemaValidator(
1770+
core_schema.model_fields_schema(
1771+
{}, extra_behavior='allow', extras_keys_schema=core_schema.str_schema(max_length=3)
1772+
)
1773+
)
1774+
1775+
m, model_extra, fields_set = v.validate_python({'ext': 123})
1776+
assert m == {}
1777+
assert model_extra == {'ext': 123}
1778+
assert fields_set == {'ext'}
1779+
1780+
with pytest.raises(ValidationError) as exc_info:
1781+
v.validate_python({'extra_too_long': 123})
1782+
1783+
assert exc_info.value.errors()[0]['type'] == 'string_too_long'
1784+
1785+
17611786
@pytest.mark.parametrize('config_by_alias', [None, True, False])
17621787
@pytest.mark.parametrize('config_by_name', [None, True, False])
17631788
@pytest.mark.parametrize('runtime_by_alias', [None, True, False])

0 commit comments

Comments
 (0)