diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 4d00b065a..e9e9cc5d3 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -39,6 +39,8 @@ Changes: `#48 `_ `#51 `_ - Add ``attr.attrs`` and ``attr.attrib`` as a more consistent aliases for ``attr.s`` and ``attr.ib``. +- Add ``frozen`` option to ``attr.s`` that will make instances best-effort immutable. + `#60 `_ ---- diff --git a/docs/api.rst b/docs/api.rst index 13340d840..4983327c1 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -18,7 +18,7 @@ What follows is the API explanation, if you'd like a more hands-on introduction, Core ---- -.. autofunction:: attr.s(these=None, repr_ns=None, repr=True, cmp=True, hash=True, init=True, slots=False) +.. autofunction:: attr.s(these=None, repr_ns=None, repr=True, cmp=True, hash=True, init=True, slots=False, frozen=False) .. note:: @@ -102,6 +102,9 @@ Core C(x=[]) +.. autoexception:: attr.exceptions.FrozenInstanceError + + Helpers ------- diff --git a/docs/examples.rst b/docs/examples.rst index 3f6af0603..21166789a 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -466,6 +466,21 @@ I guess that means Clojure can be shut down now, sorry Rich! >>> i1 == i2 False +If you're still not convinced that Python + ``attrs`` is the better Clojure, maybe immutable-ish classes can change your mind: + +.. doctest:: + + >>> @attr.s(frozen=True) + ... class C(object): + ... x = attr.ib() + >>> i = C(1) + >>> i.x = 2 + Traceback (most recent call last): + ... + attr.exceptions.FrozenInstanceError: can't set attribute + >>> i.x + 1 + Sometimes you may want to create a class programmatically. ``attrs`` won't let you down and gives you :func:`attr.make_class` : diff --git a/src/attr/__init__.py b/src/attr/__init__.py index 6cf494cc6..fdfe8b4e2 100644 --- a/src/attr/__init__.py +++ b/src/attr/__init__.py @@ -19,6 +19,7 @@ get_run_validators, set_run_validators, ) +from . import exceptions from . import filters from . import validators @@ -49,6 +50,7 @@ "attrib", "attributes", "attrs", + "exceptions", "fields", "filters", "get_run_validators", diff --git a/src/attr/_make.py b/src/attr/_make.py index 1ff36a797..e9e0ede05 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -3,8 +3,9 @@ import hashlib import linecache -from ._compat import exec_, iteritems, isclass, iterkeys from . import _config +from ._compat import exec_, iteritems, isclass, iterkeys +from .exceptions import FrozenInstanceError class _Nothing(object): @@ -145,8 +146,16 @@ def _transform_attrs(cls, these): had_default = True +def _frozen_setattrs(self, name, value): + """ + Attached to frozen classes as __setattr__. + """ + raise FrozenInstanceError() + + def attributes(maybe_cls=None, these=None, repr_ns=None, - repr=True, cmp=True, hash=True, init=True, slots=False): + repr=True, cmp=True, hash=True, init=True, + slots=False, frozen=False): """ A class decorator that adds `dunder `_\ -methods according to the @@ -161,33 +170,42 @@ def attributes(maybe_cls=None, these=None, repr_ns=None, If *these* is not `None`, the class body is *ignored*. :type these: :class:`dict` of :class:`str` to :func:`attr.ib` - :param repr_ns: When using nested classes, there's no way in Python 2 to - automatically detect that. Therefore it's possible to set the + :param str repr_ns: When using nested classes, there's no way in Python 2 + to automatically detect that. Therefore it's possible to set the namespace explicitly for a more meaningful ``repr`` output. - - :param repr: Create a ``__repr__`` method with a human readable + :param bool repr: Create a ``__repr__`` method with a human readable represantation of ``attrs`` attributes.. - :type repr: bool - - :param cmp: Create ``__eq__``, ``__ne__``, ``__lt__``, ``__le__``, + :param bool cmp: Create ``__eq__``, ``__ne__``, ``__lt__``, ``__le__``, ``__gt__``, and ``__ge__`` methods that compare the class as if it were a tuple of its ``attrs`` attributes. But the attributes are *only* compared, if the type of both classes is *identical*! - :type cmp: bool + :param bool hash: Create a ``__hash__`` method that returns the + :func:`hash` of a tuple of all ``attrs`` attribute values. + :param bool init: Create a ``__init__`` method that initialiazes the + ``attrs`` attributes. Leading underscores are stripped for the + argument name. + :param bool slots: Create a slots_-style class that's more + memory-efficient. See :ref:`slots` for further ramifications. + :param bool frozen: Make instances immutable after initialization. If + someone attempts to modify a frozen instance, + :exc:`attr.exceptions.FrozenInstanceError` is raised. + + Please note: - :param hash: Create a ``__hash__`` method that returns the :func:`hash` of - a tuple of all ``attrs`` attribute values. - :type hash: bool + 1. This is achieved by installing a custom ``__setattr__`` method + on your class so you can't implement an own one. - :param init: Create a ``__init__`` method that initialiazes the ``attrs`` - attributes. Leading underscores are stripped for the argument name. - :type init: bool + 2. True immutability is impossible in Python. - :param slots: Create a slots_-style class that's more memory-efficient. - See :ref:`slots` for further ramifications. - :type slots: bool + 3. This *does* have a minor a runtime performance impact when + initializing new instances. In other words: ``__init__`` is + slightly slower with ``frozen=True``. - .. _slots: https://docs.python.org/3.5/reference/datamodel.html#slots + .. _slots: https://docs.python.org/3.5/reference/datamodel.html#slots + + .. versionadded:: 16.0.0 *slots* + + .. versionadded:: 16.1.0 *frozen* """ def wrap(cls): if getattr(cls, "__class__", None) is None: @@ -209,8 +227,10 @@ def wrap(cls): if hash is True: cls = _add_hash(cls) if init is True: - cls = _add_init(cls) - if slots: + cls = _add_init(cls, frozen) + if frozen is True: + cls.__setattr__ = _frozen_setattrs + if slots is True: cls_dict = dict(cls.__dict__) cls_dict["__slots__"] = tuple(ca_list) for ca_name in ca_list: @@ -367,7 +387,10 @@ def repr_(self): return cls -def _add_init(cls): +def _add_init(cls, frozen): + """ + Add a __init__ method to *cls*. If *frozen* is True, make it immutable. + """ attrs = [a for a in cls.__attrs_attrs__ if a.init or a.default is not NOTHING] @@ -378,14 +401,21 @@ def _add_init(cls): sha1.hexdigest() ) - script = _attrs_to_script(attrs) + script = _attrs_to_script(attrs, frozen) locs = {} bytecode = compile(script, unique_filename, "exec") attr_dict = dict((a.name, a) for a in attrs) - exec_(bytecode, {"NOTHING": NOTHING, - "attr_dict": attr_dict, - "validate": validate, - "_convert": _convert}, locs) + globs = { + "NOTHING": NOTHING, + "attr_dict": attr_dict, + "validate": validate, + "_convert": _convert + } + if frozen is True: + # Save the lookup overhead in __init__ if we need to circumvent + # immutability. + globs["_cached_setattr"] = object.__setattr__ + exec_(bytecode, globs, locs) init = locs["__init__"] # In order of debuggers like PDB being able to step through the code, @@ -450,11 +480,31 @@ def _convert(inst): setattr(inst, a.name, a.convert(getattr(inst, a.name))) -def _attrs_to_script(attrs): +def _attrs_to_script(attrs, frozen): """ Return a valid Python script of an initializer for *attrs*. + + If *frozen* is True, we cannot set the attributes directly so we use + a cached ``object.__setattr__``. """ lines = [] + if frozen is True: + lines.append( + "_setattr = _cached_setattr.__get__(self, self.__class__)" + ) + + def fmt_setter(attr_name, value): + return "_setattr('%(attr_name)s', %(value)s)" % { + "attr_name": attr_name, + "value": value, + } + else: + def fmt_setter(attr_name, value): + return "self.%(attr_name)s = %(value)s" % { + "attr_name": attr_name, + "value": value, + } + args = [] has_validator = False has_convert = False @@ -467,14 +517,16 @@ def _attrs_to_script(attrs): arg_name = a.name.lstrip("_") if a.init is False: if isinstance(a.default, Factory): - lines.append("""\ -self.{attr_name} = attr_dict["{attr_name}"].default.factory()""".format( - attr_name=attr_name, + lines.append(fmt_setter( + attr_name, + "attr_dict['{attr_name}'].default.factory()" + .format(attr_name=attr_name) )) else: - lines.append("""\ -self.{attr_name} = attr_dict["{attr_name}"].default""".format( - attr_name=attr_name, + lines.append(fmt_setter( + attr_name, + "attr_dict['{attr_name}'].default" + .format(attr_name=attr_name) )) elif a.default is not NOTHING and not isinstance(a.default, Factory): args.append( @@ -483,26 +535,21 @@ def _attrs_to_script(attrs): attr_name=attr_name, ) ) - lines.append("self.{attr_name} = {arg_name}".format( - arg_name=arg_name, - attr_name=attr_name, - )) + lines.append(fmt_setter(attr_name, arg_name)) elif a.default is not NOTHING and isinstance(a.default, Factory): args.append("{arg_name}=NOTHING".format(arg_name=arg_name)) - lines.extend("""\ -if {arg_name} is not NOTHING: - self.{attr_name} = {arg_name} -else: - self.{attr_name} = attr_dict["{attr_name}"].default.factory()""" - .format(attr_name=attr_name, - arg_name=arg_name) - .split("\n")) + lines.append("if {arg_name} is not NOTHING:" + .format(arg_name=arg_name)) + lines.append(" " + fmt_setter(attr_name, arg_name)) + lines.append("else:") + lines.append(" " + fmt_setter( + attr_name, + "attr_dict['{attr_name}'].default.factory()" + .format(attr_name=attr_name) + )) else: args.append(arg_name) - lines.append("self.{attr_name} = {arg_name}".format( - attr_name=attr_name, - arg_name=arg_name, - )) + lines.append(fmt_setter(attr_name, arg_name)) if has_convert: lines.append("_convert(self)") @@ -511,10 +558,10 @@ def _attrs_to_script(attrs): return """\ def __init__(self, {args}): - {setters} + {lines} """.format( args=", ".join(args), - setters="\n ".join(lines) if lines else "pass", + lines="\n ".join(lines) if lines else "pass", ) @@ -544,7 +591,7 @@ def __init__(self, **kw): raise TypeError("Missing argument '{arg}'.".format(arg=a)) def __setattr__(self, name, value): - raise AttributeError("can't set attribute") # To mirror namedtuple. + raise FrozenInstanceError() @classmethod def from_counting_attr(cls, name, ca): diff --git a/src/attr/exceptions.py b/src/attr/exceptions.py new file mode 100644 index 000000000..f07db4980 --- /dev/null +++ b/src/attr/exceptions.py @@ -0,0 +1,12 @@ +from __future__ import absolute_import, division, print_function + + +class FrozenInstanceError(AttributeError): + """ + A frozen/immutable instance has been attempted to be modified. + + It mirrors the behavior of ``namedtuples`` by using the same error message + and subclassing :exc:`AttributeError``. + """ + msg = "can't set attribute" + args = [msg] diff --git a/tests/test_dark_magic.py b/tests/test_dark_magic.py index e3c8928ed..5178e0b6f 100644 --- a/tests/test_dark_magic.py +++ b/tests/test_dark_magic.py @@ -8,6 +8,7 @@ from attr._compat import TYPE from attr._make import Attribute, NOTHING +from attr.exceptions import FrozenInstanceError @attr.s @@ -62,6 +63,11 @@ class SubSlots(SuperSlots): y = attr.ib() +@attr.s(frozen=True, slots=True) +class Frozen(object): + x = attr.ib() + + class TestDarkMagic(object): """ Integration tests. @@ -114,12 +120,12 @@ class C3(object): assert "C3(_x=1)" == repr(C3(x=1)) - @given(booleans()) - def test_programmatic(self, slots): + @given(booleans(), booleans()) + def test_programmatic(self, slots, frozen): """ `attr.make_class` works. """ - PC = attr.make_class("PC", ["a", "b"], slots=slots) + PC = attr.make_class("PC", ["a", "b"], slots=slots, frozen=frozen) assert ( Attribute(name="a", default=NOTHING, validator=None, repr=True, cmp=True, hash=True, init=True), @@ -155,3 +161,19 @@ class Sub2(base): i = Sub2(x=obj) assert i.x is i.meth() is obj assert "Sub2(x={obj})".format(obj=obj) == repr(i) + + @pytest.mark.parametrize("frozen_class", [ + Frozen, # has slots=True + attr.make_class("FrozenToo", ["x"], slots=False, frozen=True), + ]) + def test_frozen_instance(self, frozen_class): + """ + Frozen instances can't be modified (easily). + """ + frozen = frozen_class(1) + + with pytest.raises(FrozenInstanceError) as e: + frozen.x = 2 + + assert e.value.args[0] == "can't set attribute" + assert 1 == frozen.x diff --git a/tests/test_dunders.py b/tests/test_dunders.py index 3a734cff7..4e6317b9d 100644 --- a/tests/test_dunders.py +++ b/tests/test_dunders.py @@ -35,7 +35,7 @@ class InitC(object): __attrs_attrs__ = [simple_attr("a"), simple_attr("b")] -InitC = _add_init(InitC) +InitC = _add_init(InitC, False) class TestAddCmp(object): @@ -219,12 +219,13 @@ class TestAddInit(object): """ Tests for `_add_init`. """ - @given(booleans()) - def test_init(self, slots): + @given(booleans(), booleans()) + def test_init(self, slots, frozen): """ If `init` is False, ignore that attribute. """ - C = make_class("C", {"a": attr(init=False), "b": attr()}, slots=slots) + C = make_class("C", {"a": attr(init=False), "b": attr()}, + slots=slots, frozen=frozen) with pytest.raises(TypeError) as e: C(a=1, b=2) @@ -233,8 +234,8 @@ def test_init(self, slots): e.value.args[0] ) - @given(booleans()) - def test_no_init_default(self, slots): + @given(booleans(), booleans()) + def test_no_init_default(self, slots, frozen): """ If `init` is False but a Factory is specified, don't allow passing that argument but initialize it anyway. @@ -243,7 +244,7 @@ def test_no_init_default(self, slots): "_a": attr(init=False, default=42), "_b": attr(init=False, default=Factory(list)), "c": attr() - }, slots=slots) + }, slots=slots, frozen=frozen) with pytest.raises(TypeError): C(a=1, c=2) with pytest.raises(TypeError): @@ -252,8 +253,8 @@ def test_no_init_default(self, slots): i = C(23) assert (42, [], 23) == (i._a, i._b, i.c) - @given(booleans()) - def test_no_init_order(self, slots): + @given(booleans(), booleans()) + def test_no_init_order(self, slots, frozen): """ If an attribute is `init=False`, it's legal to come after a mandatory attribute. @@ -261,7 +262,7 @@ def test_no_init_order(self, slots): make_class("C", { "a": attr(default=Factory(list)), "b": attr(init=False), - }, slots=slots) + }, slots=slots, frozen=frozen) def test_sets_attributes(self): """ @@ -282,7 +283,7 @@ class C(object): simple_attr(name="c", default=None), ] - C = _add_init(C) + C = _add_init(C, False) i = C() assert 2 == i.a assert "hallo" == i.b @@ -300,7 +301,7 @@ class C(object): simple_attr(name="a", default=Factory(list)), simple_attr(name="b", default=Factory(D)), ] - C = _add_init(C) + C = _add_init(C, False) i = C() assert [] == i.a assert isinstance(i.b, D) @@ -363,7 +364,7 @@ def test_underscores(self): class C(object): __attrs_attrs__ = [simple_attr("_private")] - C = _add_init(C) + C = _add_init(C, False) i = C(private=42) assert 42 == i._private