Repository: jcrist/msgspec (python)

Issue:
Support for attrs `field(validators=...)`
### Description

It would be great if msgspec supported `field(validators=...)` for attrs classes.

This is adjacent to #494.

An issue I'm currently facing is that, since msgspec supports attrs classes natively, there is no way to get those validators to work; Msgspec doesn't call them and I also can't make sure they're called via a `dec_hook`. 

A way to get this to work would be quite nice (= 

Pull request description:
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.

Fixes #535.

Patch:
diff --git a/docs/source/supported-types.rst b/docs/source/supported-types.rst
index 601f2c8..2d0d5ff 100644
--- a/docs/source/supported-types.rst
+++ b/docs/source/supported-types.rst
@@ -860,7 +860,13 @@ During decoding, any extra fields are ignored. An error is raised if a field's
 type doesn't match or if any required fields are missing.
 
 If the ``__attrs_pre_init__`` or ``__attrs_post_init__`` methods are defined on
-the class, they are called as part of the decoding process.
+the class, they are called as part of the decoding process. Likewise, if a
+class makes use of attrs' `validators
+<https://www.attrs.org/en/stable/examples.html#validators>`__, the validators
+will be called, and a `msgspec.ValidationError` raised on error. Note that
+attrs' `converters
+<https://www.attrs.org/en/stable/examples.html#conversion>`__ are not currently
+supported.
 
 When possible we recommend using `msgspec.Struct` instead of attrs_ types for
 specifying schemas - :doc:`structs` are faster, more ergonomic, and support
diff --git a/msgspec/_utils.py b/msgspec/_utils.py
index c7e1943..33d57ed 100644
--- a/msgspec/_utils.py
+++ b/msgspec/_utils.py
@@ -239,6 +239,8 @@ def get_dataclass_info(obj):
     else:
         from attrs import NOTHING, Factory
 
+        fields_with_validators = []
+
         for field in cls.__attrs_attrs__:
             name = field.name
             typ = hints[name]
@@ -248,8 +250,7 @@ def get_dataclass_info(obj):
                     if default.takes_self:
                         raise NotImplementedError(
                             "Support for default factories with `takes_self=True` "
-                            "is not implemented. "
-                            "File a GitHub issue if you need "
+                            "is not implemented. File a GitHub issue if you need "
                             "this feature!"
                         )
                     defaults.append(default.factory)
@@ -260,14 +261,30 @@ def get_dataclass_info(obj):
             else:
                 required.append((name, typ, False))
 
+            if field.validator is not None:
+                fields_with_validators.append(field)
+
         required.extend(optional)
 
         pre_init = getattr(cls, "__attrs_pre_init__", None)
         post_init = getattr(cls, "__attrs_post_init__", None)
 
+        if fields_with_validators:
+            post_init = _wrap_attrs_validators(fields_with_validators, post_init)
+
     return cls, tuple(required), tuple(defaults), pre_init, post_init
 
 
+def _wrap_attrs_validators(fields, post_init):
+    def inner(obj):
+        for field in fields:
+            field.validator(obj, field, getattr(obj, field.name))
+        if post_init is not None:
+            post_init(obj)
+
+    return inner
+
+
 def rebuild(cls, kwargs):
     """Used to unpickle Structs with keyword-only fields"""
     return cls(**kwargs)


Test patch:
diff --git a/tests/test_common.py b/tests/test_common.py
index 903b0df..b18a73d 100644
--- a/tests/test_common.py
+++ b/tests/test_common.py
@@ -3005,6 +3005,24 @@ class TestAttrs:
         with pytest.raises(ValueError, match="Oh no!"):
             proto.decode(proto.encode({"a": 1}), type=Example)
 
+    def test_decode_attrs_validators(self, proto):
+        def not2(self, attr, value):
+            if value == 2:
+                raise ValueError("Oh no!")
+
+        @attrs.define
+        class Example:
+            a: int = attrs.field(validator=[attrs.validators.gt(0), not2])
+
+        res = proto.decode(proto.encode({"a": 1}), type=Example)
+        assert res.a == 1
+
+        with pytest.raises(ValidationError):
+            res = proto.decode(proto.encode({"a": -1}), type=Example)
+
+        with pytest.raises(ValidationError, match="Oh no!"):
+            res = proto.decode(proto.encode({"a": 2}), type=Example)
+
     def test_decode_attrs_not_object(self, proto):
         @attrs.define
         class Example: