diff --git a/.travis.yml b/.travis.yml index 088f7dd7d..e7a835d99 100644 --- a/.travis.yml +++ b/.travis.yml @@ -14,6 +14,8 @@ matrix: env: TOXENV=py34 - python: "3.5" env: TOXENV=py35 + - python: "3.6" + env: TOXENV=py36 - python: "pypy" env: TOXENV=pypy diff --git a/CHANGELOG.rst b/CHANGELOG.rst index affeaa71d..1356139e8 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -5,9 +5,18 @@ Versions follow `CalVer `_ with a strict backwards compatibil The third digit is only for regressions. -16.4.0 (UNRELEASED) +17.1.0 (UNRELEASED) ------------------- +Changes: +^^^^^^^^ + +- Add ``attr.evolve`` that, given an instance of an ``attrs`` class and field changes as keyword arguments, will instantiate a copy of the given instance with the changes applied. + ``evolve`` replaces ``assoc``, which is now deprecated. + ``evolve`` is significantly faster than ``assoc``, and requires the class have an initializer that can take the field values as keyword arguments (like ``attrs`` itself can generate). + `#116 `_ + `#124 `_ + `#135 `_ Backward-incompatible changes: ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -18,7 +27,7 @@ Backward-incompatible changes: Deprecations: ^^^^^^^^^^^^^ -*none* +- ``assoc`` is now deprecated in favor of ``evolve`` and will stop working in 2018. Changes: diff --git a/docs/api.rst b/docs/api.rst index b3cb60a5e..8be1aec07 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -182,6 +182,25 @@ Helpers See :ref:`asdict` for examples. +.. autofunction:: attr.evolve + + For example: + + .. doctest:: + + >>> @attr.s + ... class C(object): + ... x = attr.ib() + ... y = attr.ib() + >>> i1 = C(1, 2) + >>> i1 + C(x=1, y=2) + >>> i2 = attr.evolve(i1, y=3) + >>> i2 + C(x=1, y=3) + >>> i1 == i2 + False + .. autofunction:: assoc For example: diff --git a/docs/examples.rst b/docs/examples.rst index dbca8a465..ca335d81c 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -525,7 +525,7 @@ Please note that true immutability is impossible in Python but it will :ref:`get By themselves, immutable classes are useful for long-lived objects that should never change; like configurations for example. In order to use them in regular program flow, you'll need a way to easily create new instances with changed attributes. -In Clojure that function is called `assoc `_ and ``attrs`` shamelessly imitates it: :func:`attr.assoc`: +In Clojure that function is called `assoc `_ and ``attrs`` shamelessly imitates it: :func:`attr.evolve`: .. doctest:: @@ -536,7 +536,7 @@ In Clojure that function is called `assoc >> i1 = C(1, 2) >>> i1 C(x=1, y=2) - >>> i2 = attr.assoc(i1, y=3) + >>> i2 = attr.evolve(i1, y=3) >>> i2 C(x=1, y=3) >>> i1 == i2 diff --git a/src/attr/__init__.py b/src/attr/__init__.py index 82bd900f5..536f15bb5 100644 --- a/src/attr/__init__.py +++ b/src/attr/__init__.py @@ -4,6 +4,7 @@ asdict, assoc, astuple, + evolve, has, ) from ._make import ( @@ -53,6 +54,7 @@ "attrib", "attributes", "attrs", + "evolve", "exceptions", "fields", "filters", diff --git a/src/attr/_funcs.py b/src/attr/_funcs.py index 274034e61..7eaabf245 100644 --- a/src/attr/_funcs.py +++ b/src/attr/_funcs.py @@ -164,7 +164,13 @@ def assoc(inst, **changes): be found on *cls*. :raise attr.exceptions.NotAnAttrsClassError: If *cls* is not an ``attrs`` class. + + .. deprecated:: 17.1.0 + Use :func:`evolve` instead. """ + import warnings + warnings.warn("assoc is deprecated and will be removed after 2018/01.", + DeprecationWarning) new = copy.copy(inst) attrs = fields(inst.__class__) for k, v in iteritems(changes): @@ -176,3 +182,40 @@ def assoc(inst, **changes): ) _obj_setattr(new, k, v) return new + + +def evolve(inst, **changes): + """ + Create a new instance, based on *inst* with *changes* applied. + + :param inst: Instance of a class with ``attrs`` attributes. + :param changes: Keyword changes in the new copy. + + :return: A copy of inst with *changes* incorporated. + + :raise attr.exceptions.AttrsAttributeNotFoundError: If *attr_name* couldn't + be found on *cls*. + :raise attr.exceptions.NotAnAttrsClassError: If *cls* is not an ``attrs`` + class. + + .. versionadded:: 17.1.0 + """ + cls = inst.__class__ + for a in fields(cls): + attr_name = a.name # To deal with private attributes. + if attr_name[0] == "_": + init_name = attr_name[1:] + if attr_name not in changes: + changes[init_name] = getattr(inst, attr_name) + else: + # attr_name is in changes, it needs to be translated. + changes[init_name] = changes.pop(attr_name) + else: + if attr_name not in changes: + changes[attr_name] = getattr(inst, attr_name) + try: + return cls(**changes) + except TypeError as exc: + k = exc.args[0].split("'")[1] + raise AttrsAttributeNotFoundError( + "{k} is not an attrs attribute on {cl}.".format(k=k, cl=cls)) diff --git a/tests/test_funcs.py b/tests/test_funcs.py index 772e60da5..36969c7ef 100644 --- a/tests/test_funcs.py +++ b/tests/test_funcs.py @@ -12,17 +12,17 @@ from .utils import simple_classes, nested_classes -from attr._funcs import ( +from attr import ( + attr, + attributes, asdict, assoc, astuple, - has, -) -from attr._make import ( - attr, - attributes, + evolve, fields, + has, ) + from attr.exceptions import AttrsAttributeNotFoundError MAPPING_TYPES = (dict, OrderedDict) @@ -401,3 +401,63 @@ class C(object): y = attr() assert C(3, 2) == assoc(C(1, 2), x=3) + + +class TestEvolve(object): + """ + Tests for `evolve`. + """ + @given(slots=st.booleans(), frozen=st.booleans()) + def test_empty(self, slots, frozen): + """ + Empty classes without changes get copied. + """ + @attributes(slots=slots, frozen=frozen) + class C(object): + pass + + i1 = C() + i2 = evolve(i1) + + assert i1 is not i2 + assert i1 == i2 + + @given(simple_classes()) + def test_no_changes(self, C): + """ + No changes means a verbatim copy. + """ + i1 = C() + i2 = evolve(i1) + + assert i1 is not i2 + assert i1 == i2 + + @given(simple_classes(), st.data()) + def test_change(self, C, data): + """ + Changes work. + """ + # Take the first attribute, and change it. + assume(fields(C)) # Skip classes with no attributes. + field_names = [a.name for a in fields(C)] + original = C() + chosen_names = data.draw(st.sets(st.sampled_from(field_names))) + change_dict = {name: data.draw(st.integers()) + for name in chosen_names} + changed = evolve(original, **change_dict) + for k, v in change_dict.items(): + assert getattr(changed, k) == v + + @given(simple_classes()) + def test_unknown(self, C): + """ + Wanting to change an unknown attribute raises an + AttrsAttributeNotFoundError. + """ + # No generated class will have a four letter attribute. + with pytest.raises(AttrsAttributeNotFoundError) as e: + evolve(C(), aaaa=2) + assert ( + "aaaa is not an attrs attribute on {cls!r}.".format(cls=C), + ) == e.value.args diff --git a/tox.ini b/tox.ini index b597dd363..5ac85f605 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py27,py34,py35,pypy,flake8,manifest,docs,readme,coverage-report +envlist = py27,py34,py35,py36,pypy,flake8,manifest,docs,readme,coverage-report [testenv] @@ -12,7 +12,7 @@ deps = -rdev-requirements.txt commands = coverage run --parallel -m pytest {posargs} -[testenv:py35] +[testenv:py36] deps = -rdev-requirements.txt commands = coverage run --parallel -m pytest {posargs}