add a `convert` keyword to attr.ib() that allows specifying a function to convert the passed-in value.

This commit is contained in:
Christopher Armstrong 2015-09-16 10:45:51 -05:00 committed by Hynek Schlawack
parent 253491908b
commit 365cd89921
2 changed files with 70 additions and 10 deletions

View File

@ -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))

View File

@ -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`.