Skip to content

Commit

Permalink
Evolve and docs. (#135)
Browse files Browse the repository at this point in the history
assoc is now deprecated in favor of evolve.
  • Loading branch information
Tinche authored and hynek committed Jan 21, 2017
1 parent 1481227 commit 0d3cf3f
Show file tree
Hide file tree
Showing 8 changed files with 147 additions and 12 deletions.
2 changes: 2 additions & 0 deletions .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
13 changes: 11 additions & 2 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,18 @@ Versions follow `CalVer <http://calver.org>`_ 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 <https://github.com/hynek/attrs/issues/116>`_
`#124 <https://github.com/hynek/attrs/pull/124>`_
`#135 <https://github.com/hynek/attrs/pull/135>`_

Backward-incompatible changes:
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Expand All @@ -18,7 +27,7 @@ Backward-incompatible changes:
Deprecations:
^^^^^^^^^^^^^

*none*
- ``assoc`` is now deprecated in favor of ``evolve`` and will stop working in 2018.


Changes:
Expand Down
19 changes: 19 additions & 0 deletions docs/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions docs/examples.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <https://clojuredocs.org/clojure.core/assoc>`_ and ``attrs`` shamelessly imitates it: :func:`attr.assoc`:
In Clojure that function is called `assoc <https://clojuredocs.org/clojure.core/assoc>`_ and ``attrs`` shamelessly imitates it: :func:`attr.evolve`:

.. doctest::

Expand All @@ -536,7 +536,7 @@ In Clojure that function is called `assoc <https://clojuredocs.org/clojure.core/
>>> 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
Expand Down
2 changes: 2 additions & 0 deletions src/attr/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
asdict,
assoc,
astuple,
evolve,
has,
)
from ._make import (
Expand Down Expand Up @@ -53,6 +54,7 @@
"attrib",
"attributes",
"attrs",
"evolve",
"exceptions",
"fields",
"filters",
Expand Down
43 changes: 43 additions & 0 deletions src/attr/_funcs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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))
72 changes: 66 additions & 6 deletions tests/test_funcs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
4 changes: 2 additions & 2 deletions tox.ini
Original file line number Diff line number Diff line change
@@ -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]
Expand All @@ -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}

Expand Down

0 comments on commit 0d3cf3f

Please sign in to comment.