Skip to content

Commit d40bc43

Browse files
committed
Support attrs validators
This adds support for attrs validators. Validators are run after decoding the instance has completed, but before calling `__attrs_post_init__`. Since the builtin attrs validators include the attribute name in the error message, we don't add the field to the error path. This feels *a bit* weird, but is way easier since all of this can be done in pure python. When possible, it'd be better to use msgspec's builtin constraints for handling validation. These will be much more performant, and integrate better with the rest of the system.
1 parent 5414de7 commit d40bc43

File tree

3 files changed

+44
-3
lines changed

3 files changed

+44
-3
lines changed

docs/source/supported-types.rst

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -860,7 +860,13 @@ During decoding, any extra fields are ignored. An error is raised if a field's
860860
type doesn't match or if any required fields are missing.
861861

862862
If the ``__attrs_pre_init__`` or ``__attrs_post_init__`` methods are defined on
863-
the class, they are called as part of the decoding process.
863+
the class, they are called as part of the decoding process. Likewise, if a
864+
class makes use of attrs' `validators
865+
<https://www.attrs.org/en/stable/examples.html#validators>`__, the validators
866+
will be called, and a `msgspec.ValidationError` raised on error. Note that
867+
attrs' `converters
868+
<https://www.attrs.org/en/stable/examples.html#conversion>`__ are not currently
869+
supported.
864870

865871
When possible we recommend using `msgspec.Struct` instead of attrs_ types for
866872
specifying schemas - :doc:`structs` are faster, more ergonomic, and support

msgspec/_utils.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,8 @@ def get_dataclass_info(obj):
239239
else:
240240
from attrs import NOTHING, Factory
241241

242+
fields_with_validators = []
243+
242244
for field in cls.__attrs_attrs__:
243245
name = field.name
244246
typ = hints[name]
@@ -248,8 +250,7 @@ def get_dataclass_info(obj):
248250
if default.takes_self:
249251
raise NotImplementedError(
250252
"Support for default factories with `takes_self=True` "
251-
"is not implemented. "
252-
"File a GitHub issue if you need "
253+
"is not implemented. File a GitHub issue if you need "
253254
"this feature!"
254255
)
255256
defaults.append(default.factory)
@@ -260,14 +261,30 @@ def get_dataclass_info(obj):
260261
else:
261262
required.append((name, typ, False))
262263

264+
if field.validator is not None:
265+
fields_with_validators.append(field)
266+
263267
required.extend(optional)
264268

265269
pre_init = getattr(cls, "__attrs_pre_init__", None)
266270
post_init = getattr(cls, "__attrs_post_init__", None)
267271

272+
if fields_with_validators:
273+
post_init = _wrap_attrs_validators(fields_with_validators, post_init)
274+
268275
return cls, tuple(required), tuple(defaults), pre_init, post_init
269276

270277

278+
def _wrap_attrs_validators(fields, post_init):
279+
def inner(obj):
280+
for field in fields:
281+
field.validator(obj, field, getattr(obj, field.name))
282+
if post_init is not None:
283+
post_init(obj)
284+
285+
return inner
286+
287+
271288
def rebuild(cls, kwargs):
272289
"""Used to unpickle Structs with keyword-only fields"""
273290
return cls(**kwargs)

tests/test_common.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3005,6 +3005,24 @@ def __attrs_pre_init__(self):
30053005
with pytest.raises(ValueError, match="Oh no!"):
30063006
proto.decode(proto.encode({"a": 1}), type=Example)
30073007

3008+
def test_decode_attrs_validators(self, proto):
3009+
def not2(self, attr, value):
3010+
if value == 2:
3011+
raise ValueError("Oh no!")
3012+
3013+
@attrs.define
3014+
class Example:
3015+
a: int = attrs.field(validator=[attrs.validators.gt(0), not2])
3016+
3017+
res = proto.decode(proto.encode({"a": 1}), type=Example)
3018+
assert res.a == 1
3019+
3020+
with pytest.raises(ValidationError):
3021+
res = proto.decode(proto.encode({"a": -1}), type=Example)
3022+
3023+
with pytest.raises(ValidationError, match="Oh no!"):
3024+
res = proto.decode(proto.encode({"a": 2}), type=Example)
3025+
30083026
def test_decode_attrs_not_object(self, proto):
30093027
@attrs.define
30103028
class Example:

0 commit comments

Comments
 (0)