diff --git a/src/attr/_make.py b/src/attr/_make.py index 4b9c654b..241f62f7 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -41,7 +41,8 @@ Sentinel to indicate the lack of a value when ``None`` is ambiguous. def attr(default=NOTHING, validator=None, - repr=True, cmp=True, hash=True, init=True): + repr=True, cmp=True, hash=True, init=True, + convert=None): """ Create a new attribute on a class. @@ -81,6 +82,13 @@ def attr(default=NOTHING, validator=None, :param init: Include this attribute in the generated ``__init__`` method. :type init: bool + + :param convert: :func:`callable` that is called by ``attrs``-generated + ``__init__`` methods to convert attribute's value to the desired format. + It is given the passed-in value, and the returned value will be used as + the new value of the attribute. + :type convert: callable + """ return _CountingAttr( default=default, @@ -89,6 +97,7 @@ def attr(default=NOTHING, validator=None, cmp=cmp, hash=hash, init=init, + convert=convert, ) @@ -342,7 +351,8 @@ def _add_init(cl): attr_dict = dict((a.name, a) for a in attrs) exec_(bytecode, {"NOTHING": NOTHING, "attr_dict": attr_dict, - "validate": validate}, locs) + "validate": validate, + "_convert": _convert}, locs) init = locs["__init__"] # In order of debuggers like PDB being able to step through the code, @@ -395,6 +405,19 @@ def validate(inst): a.validator(inst, a, getattr(inst, a.name)) +def _convert(inst): + """ + Convert all attributes on *inst* that have a converter. + + Leaves all exceptions through. + + :param inst: Instance of a class with ``attrs`` attributes. + """ + for a in fields(inst.__class__): + if a.convert is not None: + setattr(inst, a.name, a.convert(getattr(inst, a.name))) + + def _attrs_to_script(attrs): """ Return a valid Python script of an initializer for *attrs*. @@ -402,9 +425,12 @@ def _attrs_to_script(attrs): lines = [] args = [] has_validator = False + has_convert = False for a in attrs: if a.validator is not None: has_validator = True + if a.convert is not None: + has_convert = True attr_name = a.name arg_name = a.name.lstrip("_") if a.default is not NOTHING and not isinstance(a.default, Factory): @@ -437,6 +463,8 @@ else: if has_validator: lines.append("validate(self)") + if has_convert: + lines.append("_convert(self)") return """\ def __init__(self, {args}): @@ -456,17 +484,22 @@ class Attribute(object): Plus *all* arguments of :func:`attr.ib`. """ _attributes = [ - "name", "default", "validator", "repr", "cmp", "hash", "init", + "name", "default", "validator", "repr", "cmp", "hash", "init", "convert" ] # we can't use ``attrs`` so we have to cheat a little. + _optional = {"convert": None} + def __init__(self, **kw): if len(kw) > len(Attribute._attributes): raise TypeError("Too many arguments.") - try: - for a in Attribute._attributes: + for a in Attribute._attributes: + try: setattr(self, a, kw[a]) - except KeyError: - raise TypeError("Missing argument '{arg}'.".format(arg=a)) + except KeyError: + if a in Attribute._optional: + setattr(self, a, self._optional[a]) + else: + raise TypeError("Missing argument '{arg}'.".format(arg=a)) @classmethod def from_counting_attr(cl, name, ca): @@ -498,7 +531,7 @@ class _CountingAttr(object): ] counter = 0 - def __init__(self, default, validator, repr, cmp, hash, init): + def __init__(self, default, validator, repr, cmp, hash, init, convert): _CountingAttr.counter += 1 self.counter = _CountingAttr.counter self.default = default @@ -507,6 +540,7 @@ class _CountingAttr(object): self.cmp = cmp self.hash = hash self.init = init + self.convert = convert _CountingAttr = _add_cmp(_add_repr(_CountingAttr)) diff --git a/tests/test_make.py b/tests/test_make.py index f9720bc3..3fa100f5 100644 --- a/tests/test_make.py +++ b/tests/test_make.py @@ -96,7 +96,7 @@ class TestTransformAttrs(object): "No mandatory attributes allowed after an attribute with a " "default value or factory. Attribute in question: Attribute" "(name='y', default=NOTHING, validator=None, repr=True, " - "cmp=True, hash=True, init=True)", + "cmp=True, hash=True, init=True, convert=None)", ) == e.value.args def test_these(self): @@ -297,7 +297,7 @@ class TestAttribute(object): with pytest.raises(TypeError) as e: Attribute(name="foo", default=NOTHING, factory=NOTHING, validator=None, - repr=True, cmp=True, hash=True, init=True) + repr=True, cmp=True, hash=True, init=True, convert=None) assert ("Too many arguments.",) == e.value.args @@ -392,6 +392,32 @@ class TestFields(object): in zip(fields(C), C.__attrs_attrs__)) +class TestConvert(object): + """ + Tests for attribute conversion. + """ + def test_convert(self): + """ + Return value of convert is used as the attribute's value. + """ + C = make_class("C", {"x": attr(convert=lambda v: v + 1), + "y": attr()}) + c = C(1, 2) + assert c.x == 2 + assert c.y == 2 + + def test_convert_after_validate(self): + """ + Validation happens before conversion. + """ + def validator(inst, attr, val): + raise RuntimeError("foo") + C = make_class("C", {"x": attr(validator=validator, convert=lambda v: 1 / 0), + "y": attr()}) + with pytest.raises(RuntimeError): + C(1, 2) + + class TestValidate(object): """ Tests for `validate`.