Skip to content

Add instance-level validators #372

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/372.change.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
``@attr.s`` now also has a *validator* argument that takes a callable and is run with the fully initialized instance.
34 changes: 28 additions & 6 deletions src/attr/_make.py
Original file line number Diff line number Diff line change
Expand Up @@ -399,15 +399,19 @@ class _ClassBuilder(object):
__slots__ = (
"_cls", "_cls_dict", "_attrs", "_super_names", "_attr_names", "_slots",
"_frozen", "_has_post_init", "_delete_attribs", "_super_attr_map",
"_inst_validator"
)

def __init__(self, cls, these, slots, frozen, auto_attribs):
def __init__(
self, cls, these, slots, frozen, auto_attribs, inst_validator
):
attrs, super_attrs, super_map = _transform_attrs(
cls, these, auto_attribs
)

self._cls = cls
self._cls_dict = dict(cls.__dict__) if slots else {}
self._inst_validator = inst_validator
self._attrs = attrs
self._super_names = set(a.name for a in super_attrs)
self._super_attr_map = super_map
Expand Down Expand Up @@ -573,6 +577,7 @@ def add_init(self):
self._frozen,
self._slots,
self._super_attr_map,
self._inst_validator,
)
)

Expand Down Expand Up @@ -610,7 +615,8 @@ def _add_method_dunders(self, method):

def attrs(maybe_cls=None, these=None, repr_ns=None,
repr=True, cmp=True, hash=None, init=True,
slots=False, frozen=False, str=False, auto_attribs=False):
slots=False, frozen=False, str=False, auto_attribs=False,
validator=None):
r"""
A class decorator that adds `dunder
<https://wiki.python.org/moin/DunderAlias>`_\ -methods according to the
Expand Down Expand Up @@ -707,6 +713,9 @@ def attrs(maybe_cls=None, these=None, repr_ns=None,
Attributes annotated as :data:`typing.ClassVar` are **ignored**.

.. _`PEP 526`: https://www.python.org/dev/peps/pep-0526/
:param callable validator: Run after a new instance is fully initialized
with the instance as its only argument. *validator* runs *after*
the attribute validators.

.. versionadded:: 16.0.0 *slots*
.. versionadded:: 16.1.0 *frozen*
Expand All @@ -718,12 +727,15 @@ def attrs(maybe_cls=None, these=None, repr_ns=None,
.. versionchanged:: 18.1.0
If *these* is passed, no attributes are deleted from the class body.
.. versionchanged:: 18.1.0 If *these* is ordered, the order is retained.
.. versionadded:: 18.1.0 *validator*
"""
def wrap(cls):
if getattr(cls, "__class__", None) is None:
raise TypeError("attrs only works with new-style classes.")

builder = _ClassBuilder(cls, these, slots, frozen, auto_attribs)
builder = _ClassBuilder(
cls, these, slots, frozen, auto_attribs, validator,
)

if repr is True:
builder.add_repr(repr_ns)
Expand Down Expand Up @@ -1020,7 +1032,9 @@ def _add_repr(cls, ns=None, attrs=None):
return cls


def _make_init(attrs, post_init, frozen, slots, super_attr_map):
def _make_init(
attrs, post_init, frozen, slots, super_attr_map, inst_validator
):
attrs = [
a
for a in attrs
Expand All @@ -1040,6 +1054,7 @@ def _make_init(attrs, post_init, frozen, slots, super_attr_map):
slots,
post_init,
super_attr_map,
inst_validator,
)
locs = {}
bytecode = compile(script, unique_filename, "exec")
Expand Down Expand Up @@ -1078,6 +1093,7 @@ def _add_init(cls, frozen):
frozen,
_is_slot_cls(cls),
{},
None,
)
return cls

Expand Down Expand Up @@ -1166,7 +1182,9 @@ def _is_slot_attr(a_name, super_attr_map):
return a_name in super_attr_map and _is_slot_cls(super_attr_map[a_name])


def _attrs_to_init_script(attrs, frozen, slots, post_init, super_attr_map):
def _attrs_to_init_script(
attrs, frozen, slots, post_init, super_attr_map, inst_validator
):
"""
Return a script of an initializer for *attrs* and a dict of globals.

Expand Down Expand Up @@ -1355,7 +1373,8 @@ def fmt_setter_with_converter(attr_name, value_var):
if a.init is True and a.converter is None and a.type is not None:
annotations[arg_name] = a.type

if attrs_to_validate: # we can skip this if there are no validators.
# We can skip this if there are no validators.
if attrs_to_validate or inst_validator:
names_for_globals["_config"] = _config
lines.append("if _config._run_validators is True:")
for a in attrs_to_validate:
Expand All @@ -1365,6 +1384,9 @@ def fmt_setter_with_converter(attr_name, value_var):
val_name, attr_name, a.name))
names_for_globals[val_name] = a.validator
names_for_globals[attr_name] = a
if inst_validator:
names_for_globals["inst_validator"] = inst_validator
lines.append(" inst_validator(self)")
if post_init:
lines.append("self.__attrs_post_init__()")

Expand Down
18 changes: 18 additions & 0 deletions tests/test_dark_magic.py
Original file line number Diff line number Diff line change
Expand Up @@ -413,3 +413,21 @@ class Sub(Base):

with pytest.raises(FrozenInstanceError):
i.b = "3"

def test_inst_validator(self):
"""
Instance-level validators run.
"""
def v(i):
if i.x != 42:
raise ValueError

@attr.s(validator=v)
class C(object):
x = attr.ib()
y = attr.ib()

C(42, 42)

with pytest.raises(ValueError):
C(23, 42)
29 changes: 15 additions & 14 deletions tests/test_make.py
Original file line number Diff line number Diff line change
Expand Up @@ -667,6 +667,18 @@ def test_make_class_ordered(self):

assert "C(a=1, b=2)" == repr(C())

def test_repr_str(self):
"""
Trying to add a `__str__` without having a `__repr__` raises a
ValueError.
"""
with pytest.raises(ValueError) as ei:
make_class("C", {}, repr=False, str=True)

assert (
"__str__ can only be generated if a __repr__ exists.",
) == ei.value.args


class TestFields(object):
"""
Expand Down Expand Up @@ -1068,26 +1080,14 @@ class TestClassBuilder(object):
"""
Tests for `_ClassBuilder`.
"""
def test_repr_str(self):
"""
Trying to add a `__str__` without having a `__repr__` raises a
ValueError.
"""
with pytest.raises(ValueError) as ei:
make_class("C", {}, repr=False, str=True)

assert (
"__str__ can only be generated if a __repr__ exists.",
) == ei.value.args

def test_repr(self):
"""
repr of builder itself makes sense.
"""
class C(object):
pass

b = _ClassBuilder(C, None, True, True, False)
b = _ClassBuilder(C, None, True, True, False, None)

assert "<_ClassBuilder(cls=C)>" == repr(b)

Expand All @@ -1098,7 +1098,7 @@ def test_returns_self(self):
class C(object):
x = attr.ib()

b = _ClassBuilder(C, None, True, True, False)
b = _ClassBuilder(C, None, True, True, False, None)

cls = b.add_cmp().add_hash().add_init().add_repr("ns").add_str() \
.build_class()
Expand Down Expand Up @@ -1137,6 +1137,7 @@ class C(object):

b = _ClassBuilder(
C, these=None, slots=False, frozen=False, auto_attribs=False,
inst_validator=None,
)
b._cls = {} # no __module__; no __qualname__

Expand Down