Skip to content

Commit 29c5419

Browse files
dmontagusydney-runkledavidhewitt
authored
Add support for dataclass fields init (#1163)
Co-authored-by: sydney-runkle <[email protected]> Co-authored-by: Sydney Runkle <[email protected]> Co-authored-by: David Hewitt <[email protected]>
1 parent 5a1385b commit 29c5419

File tree

3 files changed

+144
-0
lines changed

3 files changed

+144
-0
lines changed

python/pydantic_core/core_schema.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2985,6 +2985,7 @@ class DataclassField(TypedDict, total=False):
29852985
name: Required[str]
29862986
schema: Required[CoreSchema]
29872987
kw_only: bool # default: True
2988+
init: bool # default: True
29882989
init_only: bool # default: False
29892990
frozen: bool # default: False
29902991
validation_alias: Union[str, List[Union[str, int]], List[List[Union[str, int]]]]
@@ -2998,6 +2999,7 @@ def dataclass_field(
29982999
schema: CoreSchema,
29993000
*,
30003001
kw_only: bool | None = None,
3002+
init: bool | None = None,
30013003
init_only: bool | None = None,
30023004
validation_alias: str | list[str | int] | list[list[str | int]] | None = None,
30033005
serialization_alias: str | None = None,
@@ -3023,6 +3025,7 @@ def dataclass_field(
30233025
name: The name to use for the argument parameter
30243026
schema: The schema to use for the argument parameter
30253027
kw_only: Whether the field can be set with a positional argument as well as a keyword argument
3028+
init: Whether the field should be validated during initialization
30263029
init_only: Whether the field should be omitted from `__dict__` and passed to `__post_init__`
30273030
validation_alias: The alias(es) to use to find the field in the validation data
30283031
serialization_alias: The alias to use as a key when serializing
@@ -3035,6 +3038,7 @@ def dataclass_field(
30353038
name=name,
30363039
schema=schema,
30373040
kw_only=kw_only,
3041+
init=init,
30383042
init_only=init_only,
30393043
validation_alias=validation_alias,
30403044
serialization_alias=serialization_alias,

src/validators/dataclass.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ struct Field {
2626
kw_only: bool,
2727
name: String,
2828
py_name: Py<PyString>,
29+
init: bool,
2930
init_only: bool,
3031
lookup_key: LookupKey,
3132
validator: CombinedValidator,
@@ -107,6 +108,7 @@ impl BuildValidator for DataclassArgsValidator {
107108
py_name: py_name.into(),
108109
lookup_key,
109110
validator,
111+
init: field.get_as(intern!(py, "init"))?.unwrap_or(true),
110112
init_only: field.get_as(intern!(py, "init_only"))?.unwrap_or(false),
111113
frozen: field.get_as::<bool>(intern!(py, "frozen"))?.unwrap_or(false),
112114
});
@@ -176,6 +178,23 @@ impl Validator for DataclassArgsValidator {
176178
($args:ident, $get_method:ident, $get_macro:ident, $slice_macro:ident) => {{
177179
// go through fields getting the value from args or kwargs and validating it
178180
for (index, field) in self.fields.iter().enumerate() {
181+
if (!field.init) {
182+
match field.validator.default_value(py, Some(field.name.as_str()), state) {
183+
Ok(Some(value)) => {
184+
// Default value exists, and passed validation if required
185+
set_item!(field, value);
186+
},
187+
Ok(None) | Err(ValError::Omit) => continue,
188+
// Note: this will always use the field name even if there is an alias
189+
// However, we don't mind so much because this error can only happen if the
190+
// default value fails validation, which is arguably a developer error.
191+
// We could try to "fix" this in the future if desired.
192+
Err(ValError::LineErrors(line_errors)) => errors.extend(line_errors),
193+
Err(err) => return Err(err),
194+
};
195+
continue;
196+
};
197+
179198
let mut pos_value = None;
180199
if let Some(args) = $args.args {
181200
if !field.kw_only {

tests/validators/test_dataclasses.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1592,3 +1592,124 @@ def _wrap_validator(cls, v, validator, info):
15921592
gc.collect()
15931593

15941594
assert ref() is None
1595+
1596+
1597+
init_test_cases = [
1598+
({'a': 'hello', 'b': 'bye'}, 'ignore', {'a': 'hello', 'b': 'HELLO'}),
1599+
({'a': 'hello'}, 'ignore', {'a': 'hello', 'b': 'HELLO'}),
1600+
# note, for the case below, we don't actually support this case in Pydantic
1601+
# it's disallowed in Pydantic to have a model with extra='allow' and a field
1602+
# with init=False, so this case isn't really possible at the momment
1603+
# however, no conflict arises here because we don't pass in the value for b
1604+
# to __init__
1605+
({'a': 'hello'}, 'allow', {'a': 'hello', 'b': 'HELLO'}),
1606+
(
1607+
{'a': 'hello', 'b': 'bye'},
1608+
'forbid',
1609+
Err(
1610+
'Unexpected keyword argument',
1611+
errors=[
1612+
{
1613+
'type': 'unexpected_keyword_argument',
1614+
'loc': ('b',),
1615+
'msg': 'Unexpected keyword argument',
1616+
'input': 'bye',
1617+
}
1618+
],
1619+
),
1620+
),
1621+
({'a': 'hello'}, 'forbid', {'a': 'hello', 'b': 'HELLO'}),
1622+
]
1623+
1624+
1625+
@pytest.mark.parametrize(
1626+
'input_value,extra_behavior,expected',
1627+
[
1628+
*init_test_cases,
1629+
# special case - when init=False, extra='allow', and the value is provided
1630+
# currently, it's disallowed in Pydantic to have a model with extra='allow'
1631+
# and a field with init=False, so this case isn't really possible at the momment
1632+
# TODO: open to changing this behavior, and changes won't be significantly breaking
1633+
# because we currently don't support this case
1634+
({'a': 'hello', 'b': 'bye'}, 'allow', {'a': 'hello', 'b': 'HELLO'}),
1635+
],
1636+
)
1637+
def test_dataclass_args_init(input_value, extra_behavior, expected):
1638+
@dataclasses.dataclass
1639+
class Foo:
1640+
a: str
1641+
b: str
1642+
1643+
def __post_init__(self):
1644+
self.b = self.a.upper()
1645+
1646+
schema = core_schema.dataclass_schema(
1647+
Foo,
1648+
core_schema.dataclass_args_schema(
1649+
'Foo',
1650+
[
1651+
core_schema.dataclass_field(name='a', schema=core_schema.str_schema()),
1652+
core_schema.dataclass_field(name='b', schema=core_schema.str_schema(), init=False),
1653+
],
1654+
extra_behavior=extra_behavior,
1655+
),
1656+
['a', 'b'],
1657+
post_init=True,
1658+
)
1659+
1660+
v = SchemaValidator(schema)
1661+
1662+
if isinstance(expected, Err):
1663+
with pytest.raises(ValidationError, match=re.escape(expected.message)) as exc_info:
1664+
v.validate_python(input_value)
1665+
1666+
if expected.errors is not None:
1667+
assert exc_info.value.errors(include_url=False) == expected.errors
1668+
else:
1669+
assert dataclasses.asdict(v.validate_python(input_value)) == expected
1670+
1671+
1672+
@pytest.mark.parametrize(
1673+
'input_value,extra_behavior,expected',
1674+
[
1675+
*init_test_cases,
1676+
# special case - allow override of default, even when init=False, if extra='allow'
1677+
# TODO: we haven't really decided if this should be allowed or not
1678+
# currently, it's disallowed in Pydantic to have a model with extra='allow'
1679+
# and a field with init=False, so this case isn't really possible at the momment
1680+
({'a': 'hello', 'b': 'bye'}, 'allow', {'a': 'hello', 'b': 'bye'}),
1681+
],
1682+
)
1683+
def test_dataclass_args_init_with_default(input_value, extra_behavior, expected):
1684+
@dataclasses.dataclass
1685+
class Foo:
1686+
a: str
1687+
b: str
1688+
1689+
schema = core_schema.dataclass_schema(
1690+
Foo,
1691+
core_schema.dataclass_args_schema(
1692+
'Foo',
1693+
[
1694+
core_schema.dataclass_field(name='a', schema=core_schema.str_schema()),
1695+
core_schema.dataclass_field(
1696+
name='b',
1697+
schema=core_schema.with_default_schema(schema=core_schema.str_schema(), default='HELLO'),
1698+
init=False,
1699+
),
1700+
],
1701+
extra_behavior=extra_behavior,
1702+
),
1703+
['a', 'b'],
1704+
)
1705+
1706+
v = SchemaValidator(schema)
1707+
1708+
if isinstance(expected, Err):
1709+
with pytest.raises(ValidationError, match=re.escape(expected.message)) as exc_info:
1710+
v.validate_python(input_value)
1711+
1712+
if expected.errors is not None:
1713+
assert exc_info.value.errors(include_url=False) == expected.errors
1714+
else:
1715+
assert dataclasses.asdict(v.validate_python(input_value)) == expected

0 commit comments

Comments
 (0)