Skip to content

Commit 741cd85

Browse files
authored
feat: allow fields to be frozen and add strict + context support to validate_assignment (#221)
* feat: allow fields to be `frozen` * add test for `validate_assignment` with `strict` field * add `strict` and `context` support to `validate_assignment`
1 parent fdf42b8 commit 741cd85

File tree

7 files changed

+110
-6
lines changed

7 files changed

+110
-6
lines changed

pydantic_core/_pydantic_core.pyi

+3-1
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@ class SchemaValidator:
2121
def isinstance_json(
2222
self, input: 'str | bytes | bytearray', strict: 'bool | None' = None, context: Any = None
2323
) -> bool: ...
24-
def validate_assignment(self, field: str, input: Any, data: 'dict[str, Any]') -> 'dict[str, Any]': ...
24+
def validate_assignment(
25+
self, field: str, input: Any, data: 'dict[str, Any]', strict: 'bool | None' = None, context: Any = None
26+
) -> 'dict[str, Any]': ...
2527

2628
class SchemaError(Exception):
2729
pass

pydantic_core/_types.py

+1
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ class TypedDictField(TypedDict, total=False):
125125
default_factory: Callable[[], Any]
126126
on_error: Literal['raise', 'omit', 'fallback_on_default'] # default: 'raise'
127127
alias: Union[str, List[Union[str, int]], List[List[Union[str, int]]]]
128+
frozen: bool
128129

129130

130131
class TypedDictSchema(TypedDict, total=False):

src/errors/kinds.rs

+2
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ pub enum ErrorKind {
3030
DictAttributesType,
3131
#[strum(message = "Field required")]
3232
Missing,
33+
#[strum(message = "Field is frozen")]
34+
Frozen,
3335
#[strum(message = "Extra inputs are not permitted")]
3436
ExtraForbidden,
3537
#[strum(message = "Keys should be strings")]

src/validators/mod.rs

+11-3
Original file line numberDiff line numberDiff line change
@@ -175,12 +175,20 @@ impl SchemaValidator {
175175
}
176176
}
177177

178-
pub fn validate_assignment(&self, py: Python, field: String, input: &PyAny, data: &PyDict) -> PyResult<PyObject> {
178+
pub fn validate_assignment(
179+
&self,
180+
py: Python,
181+
field: String,
182+
input: &PyAny,
183+
data: &PyDict,
184+
strict: Option<bool>,
185+
context: Option<&PyAny>,
186+
) -> PyResult<PyObject> {
179187
let extra = Extra {
180188
data: Some(data),
181189
field: Some(field.as_str()),
182-
strict: None,
183-
context: None,
190+
strict,
191+
context,
184192
};
185193
let r = self
186194
.validator

src/validators/typed_dict.rs

+8-1
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ struct TypedDictField {
3232
default: Option<PyObject>,
3333
default_factory: Option<PyObject>,
3434
validator: CombinedValidator,
35+
frozen: bool,
3536
}
3637

3738
impl TypedDictField {
@@ -182,8 +183,10 @@ impl BuildValidator for TypedDictValidator {
182183
default,
183184
default_factory,
184185
on_error,
186+
frozen: field_info.get_as::<bool>(intern!(py, "frozen"))?.unwrap_or(false),
185187
});
186188
}
189+
187190
Ok(Self {
188191
fields,
189192
check_extra,
@@ -433,7 +436,11 @@ impl TypedDictValidator {
433436
};
434437

435438
if let Some(field) = self.fields.iter().find(|f| f.name == field) {
436-
prepare_result(field.validator.validate(py, input, extra, slots, recursion_guard))
439+
if field.frozen {
440+
Err(ValError::new_with_loc(ErrorKind::Frozen, input, field.name.to_string()))
441+
} else {
442+
prepare_result(field.validator.validate(py, input, extra, slots, recursion_guard))
443+
}
437444
} else if self.check_extra && !self.forbid_extra {
438445
// this is the "allow" case of extra_behavior
439446
match self.extra_validator {

tests/test_validation_context.py

+27-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import pytest
22

3-
from pydantic_core import ValidationError
3+
from pydantic_core import SchemaValidator, ValidationError
44

55
from .conftest import PyAndJson
66

@@ -89,3 +89,29 @@ def f(input_value, *, validator, context, **kwargs):
8989
v.isinstance_test('foobar')
9090

9191
assert v.isinstance_test('foobar', None, {'error'}) is False
92+
93+
94+
def test_validate_assignment_with_context():
95+
def f1(input_value, *, context, **kwargs):
96+
context['f1'] = input_value
97+
return input_value + f'| context: {context}'
98+
99+
def f2(input_value, *, context, **kwargs):
100+
context['f2'] = input_value
101+
return input_value + f'| context: {context}'
102+
103+
v = SchemaValidator(
104+
{
105+
'type': 'typed-dict',
106+
'fields': {
107+
'f1': {'schema': {'type': 'function', 'mode': 'plain', 'function': f1}},
108+
'f2': {'schema': {'type': 'function', 'mode': 'plain', 'function': f2}},
109+
},
110+
}
111+
)
112+
113+
m1 = v.validate_python({'f1': '1', 'f2': '2'}, None, {'x': 'y'})
114+
assert m1 == {'f1': "1| context: {'x': 'y', 'f1': '1'}", 'f2': "2| context: {'x': 'y', 'f1': '1', 'f2': '2'}"}
115+
116+
m2 = v.validate_assignment('f1', '3', m1, None, {'x': 'y'})
117+
assert m2 == {'f1': "3| context: {'x': 'y', 'f1': '3'}", 'f2': "2| context: {'x': 'y', 'f1': '1', 'f2': '2'}"}

tests/validators/test_typed_dict.py

+58
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,24 @@ def test_validate_assignment():
241241
assert v.validate_assignment('field_a', b'abc', {'field_a': 'test'}) == ({'field_a': 'abc'}, {'field_a'})
242242

243243

244+
def test_validate_assignment_strict_field():
245+
v = SchemaValidator(
246+
{
247+
'type': 'typed-dict',
248+
'return_fields_set': True,
249+
'fields': {'field_a': {'schema': {'type': 'str', 'strict': True}}},
250+
}
251+
)
252+
253+
assert v.validate_python({'field_a': 'test'}) == ({'field_a': 'test'}, {'field_a'})
254+
255+
with pytest.raises(ValidationError) as exc_info:
256+
v.validate_assignment('field_a', b'abc', {'field_a': 'test'})
257+
assert exc_info.value.errors() == [
258+
{'input_value': b'abc', 'kind': 'str_type', 'loc': ['field_a'], 'message': 'Input should be a valid string'}
259+
]
260+
261+
244262
def test_validate_assignment_functions():
245263
calls = []
246264

@@ -336,6 +354,24 @@ def test_validate_assignment_allow_extra_validate():
336354
]
337355

338356

357+
def test_validate_assignment_with_strict():
358+
v = SchemaValidator(
359+
{'type': 'typed-dict', 'fields': {'x': {'schema': {'type': 'str'}}, 'y': {'schema': {'type': 'int'}}}}
360+
)
361+
362+
r = v.validate_python({'x': 'a', 'y': '123'})
363+
assert r == {'x': 'a', 'y': 123}
364+
365+
assert v.validate_assignment('y', '124', r) == {'x': 'a', 'y': 124}
366+
367+
with pytest.raises(ValidationError) as exc_info:
368+
v.validate_assignment('y', '124', r, True)
369+
370+
assert exc_info.value.errors() == [
371+
{'kind': 'int_type', 'loc': ['y'], 'message': 'Input should be a valid integer', 'input_value': '124'}
372+
]
373+
374+
339375
def test_json_error():
340376
v = SchemaValidator(
341377
{'type': 'typed-dict', 'fields': {'field_a': {'schema': {'type': 'list', 'items_schema': 'int'}}}}
@@ -1211,3 +1247,25 @@ def wrap_function(input_value, *, validator, **kwargs):
12111247
assert v.validate_test({'x': ['foo']}) == {'x': '1'}
12121248
assert v.validate_test({'x': ['foo', 'bar']}) == {'x': '2'}
12131249
assert v.validate_test({'x': {'a': 'b'}}) == {'x': "{'a': 'b'}"}
1250+
1251+
1252+
def test_frozen_field():
1253+
v = SchemaValidator(
1254+
{
1255+
'type': 'typed-dict',
1256+
'fields': {
1257+
'name': {'schema': {'type': 'str'}},
1258+
'age': {'schema': {'type': 'int'}},
1259+
'is_developer': {'schema': {'type': 'bool'}, 'default': True, 'frozen': True},
1260+
},
1261+
}
1262+
)
1263+
r1 = v.validate_python({'name': 'Samuel', 'age': '36'})
1264+
assert r1 == {'name': 'Samuel', 'age': 36, 'is_developer': True}
1265+
r2 = v.validate_assignment('age', '35', r1)
1266+
assert r2 == {'name': 'Samuel', 'age': 35, 'is_developer': True}
1267+
with pytest.raises(ValidationError) as exc_info:
1268+
v.validate_assignment('is_developer', False, r2)
1269+
assert exc_info.value.errors() == [
1270+
{'kind': 'frozen', 'loc': ['is_developer'], 'message': 'Field is frozen', 'input_value': False}
1271+
]

0 commit comments

Comments
 (0)