Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ Changes:
`#48 <https://github.com/hynek/attrs/issues/48>`_
`#51 <https://github.com/hynek/attrs/issues/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 <https://github.com/hynek/attrs/issues/60>`_


----
Expand Down
5 changes: 4 additions & 1 deletion docs/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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::

Expand Down Expand Up @@ -102,6 +102,9 @@ Core
C(x=[])


.. autoexception:: attr.exceptions.FrozenInstanceError


Helpers
-------

Expand Down
15 changes: 15 additions & 0 deletions docs/examples.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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)

This comment was marked as spam.

This comment was marked as spam.

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

Expand Down
2 changes: 2 additions & 0 deletions src/attr/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
get_run_validators,
set_run_validators,
)
from . import exceptions
from . import filters
from . import validators

Expand Down Expand Up @@ -49,6 +50,7 @@
"attrib",
"attributes",
"attrs",
"exceptions",
"fields",
"filters",
"get_run_validators",
Expand Down
155 changes: 101 additions & 54 deletions src/attr/_make.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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
<https://wiki.python.org/moin/DunderAlias>`_\ -methods according to the
Expand All @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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]

Expand All @@ -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,
Expand Down Expand Up @@ -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__)"

This comment was marked as spam.

This comment was marked as spam.

This comment was marked as spam.

)

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
Expand All @@ -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(
Expand All @@ -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)")
Expand All @@ -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",
)


Expand Down Expand Up @@ -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):
Expand Down
12 changes: 12 additions & 0 deletions src/attr/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
from __future__ import absolute_import, division, print_function


class FrozenInstanceError(AttributeError):

This comment was marked as spam.

"""
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]
28 changes: 25 additions & 3 deletions tests/test_dark_magic.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

from attr._compat import TYPE
from attr._make import Attribute, NOTHING
from attr.exceptions import FrozenInstanceError


@attr.s
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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
Loading