Skip to content

Commit f367138

Browse files
authored
Add exclude_if logic at the field level (#1535)
1 parent eeb51e8 commit f367138

File tree

9 files changed

+162
-35
lines changed

9 files changed

+162
-35
lines changed

python/pydantic_core/core_schema.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2897,6 +2897,7 @@ class TypedDictField(TypedDict, total=False):
28972897
serialization_alias: str
28982898
serialization_exclude: bool # default: False
28992899
metadata: dict[str, Any]
2900+
serialization_exclude_if: Callable[[Any], bool] # default None
29002901

29012902

29022903
def typed_dict_field(
@@ -2907,6 +2908,7 @@ def typed_dict_field(
29072908
serialization_alias: str | None = None,
29082909
serialization_exclude: bool | None = None,
29092910
metadata: dict[str, Any] | None = None,
2911+
serialization_exclude_if: Callable[[Any], bool] | None = None,
29102912
) -> TypedDictField:
29112913
"""
29122914
Returns a schema that matches a typed dict field, e.g.:
@@ -2923,6 +2925,7 @@ def typed_dict_field(
29232925
validation_alias: The alias(es) to use to find the field in the validation data
29242926
serialization_alias: The alias to use as a key when serializing
29252927
serialization_exclude: Whether to exclude the field when serializing
2928+
serialization_exclude_if: A callable that determines whether to exclude the field when serializing based on its value.
29262929
metadata: Any other information you want to include with the schema, not used by pydantic-core
29272930
"""
29282931
return _dict_not_none(
@@ -2932,6 +2935,7 @@ def typed_dict_field(
29322935
validation_alias=validation_alias,
29332936
serialization_alias=serialization_alias,
29342937
serialization_exclude=serialization_exclude,
2938+
serialization_exclude_if=serialization_exclude_if,
29352939
metadata=metadata,
29362940
)
29372941

@@ -3023,6 +3027,7 @@ class ModelField(TypedDict, total=False):
30233027
validation_alias: Union[str, list[Union[str, int]], list[list[Union[str, int]]]]
30243028
serialization_alias: str
30253029
serialization_exclude: bool # default: False
3030+
serialization_exclude_if: Callable[[Any], bool] # default: None
30263031
frozen: bool
30273032
metadata: dict[str, Any]
30283033

@@ -3033,6 +3038,7 @@ def model_field(
30333038
validation_alias: str | list[str | int] | list[list[str | int]] | None = None,
30343039
serialization_alias: str | None = None,
30353040
serialization_exclude: bool | None = None,
3041+
serialization_exclude_if: Callable[[Any], bool] | None = None,
30363042
frozen: bool | None = None,
30373043
metadata: dict[str, Any] | None = None,
30383044
) -> ModelField:
@@ -3050,6 +3056,7 @@ def model_field(
30503056
validation_alias: The alias(es) to use to find the field in the validation data
30513057
serialization_alias: The alias to use as a key when serializing
30523058
serialization_exclude: Whether to exclude the field when serializing
3059+
serialization_exclude_if: A Callable that determines whether to exclude a field during serialization based on its value.
30533060
frozen: Whether the field is frozen
30543061
metadata: Any other information you want to include with the schema, not used by pydantic-core
30553062
"""
@@ -3059,6 +3066,7 @@ def model_field(
30593066
validation_alias=validation_alias,
30603067
serialization_alias=serialization_alias,
30613068
serialization_exclude=serialization_exclude,
3069+
serialization_exclude_if=serialization_exclude_if,
30623070
frozen=frozen,
30633071
metadata=metadata,
30643072
)
@@ -3251,6 +3259,7 @@ class DataclassField(TypedDict, total=False):
32513259
serialization_alias: str
32523260
serialization_exclude: bool # default: False
32533261
metadata: dict[str, Any]
3262+
serialization_exclude_if: Callable[[Any], bool] # default: None
32543263

32553264

32563265
def dataclass_field(
@@ -3264,6 +3273,7 @@ def dataclass_field(
32643273
serialization_alias: str | None = None,
32653274
serialization_exclude: bool | None = None,
32663275
metadata: dict[str, Any] | None = None,
3276+
serialization_exclude_if: Callable[[Any], bool] | None = None,
32673277
frozen: bool | None = None,
32683278
) -> DataclassField:
32693279
"""
@@ -3289,6 +3299,7 @@ def dataclass_field(
32893299
validation_alias: The alias(es) to use to find the field in the validation data
32903300
serialization_alias: The alias to use as a key when serializing
32913301
serialization_exclude: Whether to exclude the field when serializing
3302+
serialization_exclude_if: A callable that determines whether to exclude the field when serializing based on its value.
32923303
metadata: Any other information you want to include with the schema, not used by pydantic-core
32933304
frozen: Whether the field is frozen
32943305
"""
@@ -3302,6 +3313,7 @@ def dataclass_field(
33023313
validation_alias=validation_alias,
33033314
serialization_alias=serialization_alias,
33043315
serialization_exclude=serialization_exclude,
3316+
serialization_exclude_if=serialization_exclude_if,
33053317
metadata=metadata,
33063318
frozen=frozen,
33073319
)

src/serializers/fields.rs

Lines changed: 40 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ pub(super) struct SerField {
2828
pub serializer: Option<CombinedSerializer>,
2929
pub required: bool,
3030
pub serialize_by_alias: Option<bool>,
31+
pub serialization_exclude_if: Option<Py<PyAny>>,
3132
}
3233

3334
impl_py_gc_traverse!(SerField { serializer });
@@ -40,6 +41,7 @@ impl SerField {
4041
serializer: Option<CombinedSerializer>,
4142
required: bool,
4243
serialize_by_alias: Option<bool>,
44+
serialization_exclude_if: Option<Py<PyAny>>,
4345
) -> Self {
4446
let alias_py = alias.as_ref().map(|alias| PyString::new(py, alias.as_str()).into());
4547
Self {
@@ -49,6 +51,7 @@ impl SerField {
4951
serializer,
5052
required,
5153
serialize_by_alias,
54+
serialization_exclude_if,
5255
}
5356
}
5457

@@ -71,6 +74,18 @@ impl SerField {
7174
}
7275
}
7376

77+
fn serialization_exclude_if(exclude_if_callable: Option<&Py<PyAny>>, value: &Bound<'_, PyAny>) -> PyResult<bool> {
78+
if let Some(exclude_if_callable) = exclude_if_callable {
79+
let py = value.py();
80+
let result = exclude_if_callable.call1(py, (value,))?;
81+
let exclude = result.extract::<bool>(py)?;
82+
if exclude {
83+
return Ok(true);
84+
}
85+
}
86+
Ok(false)
87+
}
88+
7489
fn exclude_default(value: &Bound<'_, PyAny>, extra: &Extra, serializer: &CombinedSerializer) -> PyResult<bool> {
7590
if extra.exclude_defaults {
7691
if let Some(default) = serializer.get_default(value.py())? {
@@ -170,16 +185,16 @@ impl GeneralFieldsSerializer {
170185
if let Some((next_include, next_exclude)) = self.filter.key_filter(&key, include, exclude)? {
171186
if let Some(field) = op_field {
172187
if let Some(ref serializer) = field.serializer {
173-
if !exclude_default(&value, &field_extra, serializer)? {
174-
let value = serializer.to_python(
175-
&value,
176-
next_include.as_ref(),
177-
next_exclude.as_ref(),
178-
&field_extra,
179-
)?;
180-
let output_key = field.get_key_py(output_dict.py(), &field_extra);
181-
output_dict.set_item(output_key, value)?;
188+
if exclude_default(&value, &field_extra, serializer)? {
189+
continue;
182190
}
191+
if serialization_exclude_if(field.serialization_exclude_if.as_ref(), &value)? {
192+
continue;
193+
}
194+
let value =
195+
serializer.to_python(&value, next_include.as_ref(), next_exclude.as_ref(), &field_extra)?;
196+
let output_key = field.get_key_py(output_dict.py(), &field_extra);
197+
output_dict.set_item(output_key, value)?;
183198
}
184199

185200
if field.required {
@@ -251,17 +266,23 @@ impl GeneralFieldsSerializer {
251266
if let Some((next_include, next_exclude)) = filter {
252267
if let Some(field) = self.fields.get(key_str) {
253268
if let Some(ref serializer) = field.serializer {
254-
if !exclude_default(&value, &field_extra, serializer).map_err(py_err_se_err)? {
255-
let s = PydanticSerializer::new(
256-
&value,
257-
serializer,
258-
next_include.as_ref(),
259-
next_exclude.as_ref(),
260-
&field_extra,
261-
);
262-
let output_key = field.get_key_json(key_str, &field_extra);
263-
map.serialize_entry(&output_key, &s)?;
269+
if exclude_default(&value, &field_extra, serializer).map_err(py_err_se_err)? {
270+
continue;
271+
}
272+
if serialization_exclude_if(field.serialization_exclude_if.as_ref(), &value)
273+
.map_err(py_err_se_err)?
274+
{
275+
continue;
264276
}
277+
let s = PydanticSerializer::new(
278+
&value,
279+
serializer,
280+
next_include.as_ref(),
281+
next_exclude.as_ref(),
282+
&field_extra,
283+
);
284+
let output_key = field.get_key_json(key_str, &field_extra);
285+
map.serialize_entry(&output_key, &s)?;
265286
}
266287
} else if self.mode == FieldsMode::TypedDictAllow {
267288
let output_key = infer_json_key(&key, &field_extra).map_err(py_err_se_err)?;

src/serializers/type_serializers/dataclass.rs

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,24 +43,35 @@ impl BuildSerializer for DataclassArgsBuilder {
4343
let name: String = field_info.get_as_req(intern!(py, "name"))?;
4444

4545
let key_py: Py<PyString> = PyString::new(py, &name).into();
46-
4746
if !field_info.get_as(intern!(py, "init_only"))?.unwrap_or(false) {
4847
if field_info.get_as(intern!(py, "serialization_exclude"))? == Some(true) {
49-
fields.insert(name, SerField::new(py, key_py, None, None, true, serialize_by_alias));
48+
fields.insert(
49+
name,
50+
SerField::new(py, key_py, None, None, true, serialize_by_alias, None),
51+
);
5052
} else {
5153
let schema = field_info.get_as_req(intern!(py, "schema"))?;
5254
let serializer = CombinedSerializer::build(&schema, config, definitions)
5355
.map_err(|e| py_schema_error_type!("Field `{}`:\n {}", index, e))?;
5456

5557
let alias = field_info.get_as(intern!(py, "serialization_alias"))?;
58+
let serialization_exclude_if: Option<Py<PyAny>> =
59+
field_info.get_as(intern!(py, "serialization_exclude_if"))?;
5660
fields.insert(
5761
name,
58-
SerField::new(py, key_py, alias, Some(serializer), true, serialize_by_alias),
62+
SerField::new(
63+
py,
64+
key_py,
65+
alias,
66+
Some(serializer),
67+
true,
68+
serialize_by_alias,
69+
serialization_exclude_if,
70+
),
5971
);
6072
}
6173
}
6274
}
63-
6475
let computed_fields = ComputedFields::new(schema, config, definitions)?;
6576

6677
Ok(GeneralFieldsSerializer::new(fields, fields_mode, None, computed_fields).into())

src/serializers/type_serializers/model.rs

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,17 +56,29 @@ impl BuildSerializer for ModelFieldsBuilder {
5656
let key_py: Py<PyString> = key_py.into();
5757

5858
if field_info.get_as(intern!(py, "serialization_exclude"))? == Some(true) {
59-
fields.insert(key, SerField::new(py, key_py, None, None, true, serialize_by_alias));
59+
fields.insert(
60+
key,
61+
SerField::new(py, key_py, None, None, true, serialize_by_alias, None),
62+
);
6063
} else {
6164
let alias: Option<String> = field_info.get_as(intern!(py, "serialization_alias"))?;
62-
65+
let serialization_exclude_if: Option<Py<PyAny>> =
66+
field_info.get_as(intern!(py, "serialization_exclude_if"))?;
6367
let schema = field_info.get_as_req(intern!(py, "schema"))?;
6468
let serializer = CombinedSerializer::build(&schema, config, definitions)
6569
.map_err(|e| py_schema_error_type!("Field `{}`:\n {}", key, e))?;
6670

6771
fields.insert(
6872
key,
69-
SerField::new(py, key_py, alias, Some(serializer), true, serialize_by_alias),
73+
SerField::new(
74+
py,
75+
key_py,
76+
alias,
77+
Some(serializer),
78+
true,
79+
serialize_by_alias,
80+
serialization_exclude_if,
81+
),
7082
);
7183
}
7284
}

src/serializers/type_serializers/typed_dict.rs

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,16 +54,28 @@ impl BuildSerializer for TypedDictBuilder {
5454
let required = field_info.get_as(intern!(py, "required"))?.unwrap_or(total);
5555

5656
if field_info.get_as(intern!(py, "serialization_exclude"))? == Some(true) {
57-
fields.insert(key, SerField::new(py, key_py, None, None, required, serialize_by_alias));
57+
fields.insert(
58+
key,
59+
SerField::new(py, key_py, None, None, required, serialize_by_alias, None),
60+
);
5861
} else {
5962
let alias: Option<String> = field_info.get_as(intern!(py, "serialization_alias"))?;
60-
63+
let serialization_exclude_if: Option<Py<PyAny>> =
64+
field_info.get_as(intern!(py, "serialization_exclude_if"))?;
6165
let schema = field_info.get_as_req(intern!(py, "schema"))?;
6266
let serializer = CombinedSerializer::build(&schema, config, definitions)
6367
.map_err(|e| py_schema_error_type!("Field `{}`:\n {}", key, e))?;
6468
fields.insert(
6569
key,
66-
SerField::new(py, key_py, alias, Some(serializer), required, serialize_by_alias),
70+
SerField::new(
71+
py,
72+
key_py,
73+
alias,
74+
Some(serializer),
75+
required,
76+
serialize_by_alias,
77+
serialization_exclude_if,
78+
),
6779
);
6880
}
6981
}

tests/serializers/test_dataclasses.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,9 @@ def test_serialization_exclude():
5454
core_schema.dataclass_args_schema(
5555
'Foo',
5656
[
57-
core_schema.dataclass_field(name='a', schema=core_schema.str_schema()),
57+
core_schema.dataclass_field(
58+
name='a', schema=core_schema.str_schema(), serialization_exclude_if=lambda x: x == 'bye'
59+
),
5860
core_schema.dataclass_field(name='b', schema=core_schema.bytes_schema(), serialization_exclude=True),
5961
],
6062
),
@@ -63,12 +65,18 @@ def test_serialization_exclude():
6365
s = SchemaSerializer(schema)
6466
assert s.to_python(Foo(a='hello', b=b'more')) == {'a': 'hello'}
6567
assert s.to_python(Foo(a='hello', b=b'more'), mode='json') == {'a': 'hello'}
68+
# a = 'bye' excludes it
69+
assert s.to_python(Foo(a='bye', b=b'more'), mode='json') == {}
6670
j = s.to_json(Foo(a='hello', b=b'more'))
67-
6871
if on_pypy:
6972
assert json.loads(j) == {'a': 'hello'}
7073
else:
7174
assert j == b'{"a":"hello"}'
75+
j = s.to_json(Foo(a='bye', b=b'more'))
76+
if on_pypy:
77+
assert json.loads(j) == {}
78+
else:
79+
assert j == b'{}'
7280

7381

7482
def test_serialization_alias():

tests/serializers/test_functions.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -517,7 +517,9 @@ def __init__(self, **kwargs):
517517
MyModel,
518518
core_schema.typed_dict_schema(
519519
{
520-
'a': core_schema.typed_dict_field(core_schema.any_schema()),
520+
'a': core_schema.typed_dict_field(
521+
core_schema.any_schema(), serialization_exclude_if=lambda x: isinstance(x, int) and x >= 2
522+
),
521523
'b': core_schema.typed_dict_field(core_schema.any_schema()),
522524
'c': core_schema.typed_dict_field(core_schema.any_schema(), serialization_exclude=True),
523525
}
@@ -541,6 +543,14 @@ def __init__(self, **kwargs):
541543
assert s.to_json(m, exclude={'b'}) == b'{"a":1}'
542544
assert calls == 6
543545

546+
m = MyModel(a=2, b=b'foobar', c='excluded')
547+
assert s.to_python(m) == {'b': b'foobar'}
548+
assert calls == 7
549+
assert s.to_python(m, mode='json') == {'b': 'foobar'}
550+
assert calls == 8
551+
assert s.to_json(m) == b'{"b":"foobar"}'
552+
assert calls == 9
553+
544554

545555
def test_function_plain_model():
546556
calls = 0
@@ -559,7 +569,9 @@ def __init__(self, **kwargs):
559569
MyModel,
560570
core_schema.typed_dict_schema(
561571
{
562-
'a': core_schema.typed_dict_field(core_schema.any_schema()),
572+
'a': core_schema.typed_dict_field(
573+
core_schema.any_schema(), serialization_exclude_if=lambda x: x == 100
574+
),
563575
'b': core_schema.typed_dict_field(core_schema.any_schema()),
564576
'c': core_schema.typed_dict_field(core_schema.any_schema(), serialization_exclude=True),
565577
}

0 commit comments

Comments
 (0)