Skip to content

Commit

Permalink
Introduce attribute metadata. (#96)
Browse files Browse the repository at this point in the history
  • Loading branch information
Tinche authored and hynek committed Nov 19, 2016
1 parent cbfb229 commit 0fac921
Show file tree
Hide file tree
Showing 11 changed files with 286 additions and 52 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ The third digit is only for regressions.
Changes:
^^^^^^^^

- Attributes now can have user-defined metadata which greatly improves ``attrs``'s extensibility.
`#96 <https://github.com/hynek/attrs/pull/96>`_
- Don't overwrite ``__name__`` with ``__qualname__`` for ``attr.s(slots=True)`` classes.
`#99 <https://github.com/hynek/attrs/issues/99>`_

Expand Down
6 changes: 3 additions & 3 deletions docs/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ Core
... class C(object):
... x = attr.ib()
>>> C.x
Attribute(name='x', default=NOTHING, validator=None, repr=True, cmp=True, hash=True, init=True, convert=None)
Attribute(name='x', default=NOTHING, validator=None, repr=True, cmp=True, hash=True, init=True, convert=None, metadata=mappingproxy({}))


.. autofunction:: attr.make_class
Expand Down Expand Up @@ -125,9 +125,9 @@ Helpers
... x = attr.ib()
... y = attr.ib()
>>> attr.fields(C)
(Attribute(name='x', default=NOTHING, validator=None, repr=True, cmp=True, hash=True, init=True, convert=None), Attribute(name='y', default=NOTHING, validator=None, repr=True, cmp=True, hash=True, init=True, convert=None))
(Attribute(name='x', default=NOTHING, validator=None, repr=True, cmp=True, hash=True, init=True, convert=None, metadata=mappingproxy({})), Attribute(name='y', default=NOTHING, validator=None, repr=True, cmp=True, hash=True, init=True, convert=None, metadata=mappingproxy({})))
>>> attr.fields(C)[1]
Attribute(name='y', default=NOTHING, validator=None, repr=True, cmp=True, hash=True, init=True, convert=None)
Attribute(name='y', default=NOTHING, validator=None, repr=True, cmp=True, hash=True, init=True, convert=None, metadata=mappingproxy({}))
>>> attr.fields(C).y is attr.fields(C)[1]
True

Expand Down
25 changes: 24 additions & 1 deletion docs/examples.rst
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,29 @@ Converters are run *before* validators, so you can use validators to check the f
ValueError: x must be be at least 0.


.. _metadata:

Metadata
--------

All ``attrs`` attributes may include arbitrary metadata in the form on a read-only dictionary.

.. doctest::

>>> @attr.s
... class C(object):
... x = attr.ib(metadata={'my_metadata': 1})
>>> attr.fields(C).x.metadata
mappingproxy({'my_metadata': 1})
>>> attr.fields(C).x.metadata['my_metadata']
1

Metadata is not used by ``attrs``, and is meant to enable rich functionality in third-party libraries.
The metadata dictionary follows the normal dictionary rules: keys need to be hashable, and both keys and values are recommended to be immutable.

If you're the author of a third-party library with ``attrs`` integration, please see :ref:`Extending Metadata <extending_metadata>`.


.. _slots:

Slots
Expand Down Expand Up @@ -458,7 +481,7 @@ Slot classes are a little different than ordinary, dictionary-backed classes:
... class C(object):
... x = attr.ib()
>>> C.x
Attribute(name='x', default=NOTHING, validator=None, repr=True, cmp=True, hash=True, init=True, convert=None)
Attribute(name='x', default=NOTHING, validator=None, repr=True, cmp=True, hash=True, init=True, convert=None, metadata=mappingproxy({}))
>>> @attr.s(slots=True)
... class C(object):
... x = attr.ib()
Expand Down
42 changes: 41 additions & 1 deletion docs/extending.rst
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ So it is fairly simple to build your own decorators on top of ``attrs``:
... @attr.s
... class C(object):
... a = attr.ib()
(Attribute(name='a', default=NOTHING, validator=None, repr=True, cmp=True, hash=True, init=True, convert=None),)
(Attribute(name='a', default=NOTHING, validator=None, repr=True, cmp=True, hash=True, init=True, convert=None, metadata=mappingproxy({})),)


.. warning::
Expand All @@ -36,3 +36,43 @@ So it is fairly simple to build your own decorators on top of ``attrs``:
pass

f = a(b(original_f))

.. _extending_metadata:

Metadata
--------

If you're the author of a third-party library with ``attrs`` integration, you may want to take advantage of attribute metadata.

Here are some tips for effective use of metadata:

- Try making your metadata keys and values immutable.
This keeps the entire ``Attribute`` instances immutable too.

- To avoid metadata key collisions, consider exposing your metadata keys from your modules.::

from mylib import MY_METADATA_KEY

@attr.s
class C(object):
x = attr.ib(metadata={MY_METADATA_KEY: 1})

Metadata should be composable, so consider supporting this approach even if you decide implementing your metadata in one of the following ways.

- Expose ``attr.ib`` wrappers for your specific metadata.
This is a more graceful approach if your users don't require metadata from other libraries.

.. doctest::

>>> MY_TYPE_METADATA = '__my_type_metadata'
>>>
>>> def typed(cls, default=attr.NOTHING, validator=None, repr=True, cmp=True, hash=True, init=True, convert=None, metadata={}):
... metadata = dict() if not metadata else metadata
... metadata[MY_TYPE_METADATA] = cls
... return attr.ib(default, validator, repr, cmp, hash, init, convert, metadata)
>>>
>>> @attr.s
... class C(object):
... x = typed(int, default=1, init=False)
>>> attr.fields(C).x.metadata[MY_TYPE_METADATA]
<class 'int'>
57 changes: 56 additions & 1 deletion src/attr/_compat.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
from __future__ import absolute_import, division, print_function

import sys
import types


PY2 = sys.version_info[0] == 2


if PY2:
import types
from UserDict import IterableUserDict

# We 'bundle' isclass instead of using inspect as importing inspect is
# fairly expensive (order of 10-15 ms for a modern machine in 2016)
Expand All @@ -22,6 +23,57 @@ def iteritems(d):

def iterkeys(d):
return d.iterkeys()

# Python 2 is bereft of a read-only dict proxy, so we make one!
class ReadOnlyDict(IterableUserDict):
"""
Best-effort read-only dict wrapper.
"""

def __setitem__(self, key, val):
# We gently pretend we're a Python 3 mappingproxy.
raise TypeError("'mappingproxy' object does not support item "
"assignment")

def update(self, _):
# We gently pretend we're a Python 3 mappingproxy.
raise AttributeError("'mappingproxy' object has no attribute "
"'update'")

def __delitem__(self, _):
# We gently pretend we're a Python 3 mappingproxy.
raise TypeError("'mappingproxy' object does not support item "
"deletion")

def clear(self):
# We gently pretend we're a Python 3 mappingproxy.
raise AttributeError("'mappingproxy' object has no attribute "
"'clear'")

def pop(self, key, default=None):
# We gently pretend we're a Python 3 mappingproxy.
raise AttributeError("'mappingproxy' object has no attribute "
"'pop'")

def popitem(self):
# We gently pretend we're a Python 3 mappingproxy.
raise AttributeError("'mappingproxy' object has no attribute "
"'popitem'")

def setdefault(self, key, default=None):
# We gently pretend we're a Python 3 mappingproxy.
raise AttributeError("'mappingproxy' object has no attribute "
"'setdefault'")

def __repr__(self):
# Override to be identical to the Python 3 version.
return "mappingproxy(" + repr(self.data) + ")"

def metadata_proxy(d):
res = ReadOnlyDict()
res.data.update(d) # We blocked update, so we have to do it like this.
return res

else:
def isclass(klass):
return isinstance(klass, type)
Expand All @@ -33,3 +85,6 @@ def iteritems(d):

def iterkeys(d):
return d.keys()

def metadata_proxy(d):
return types.MappingProxyType(dict(d))
88 changes: 54 additions & 34 deletions src/attr/_make.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,14 @@
from operator import itemgetter

from . import _config
from ._compat import iteritems, isclass, iterkeys
from ._compat import iteritems, isclass, iterkeys, metadata_proxy
from .exceptions import FrozenInstanceError, NotAnAttrsClassError

# This is used at least twice, so cache it here.
_obj_setattr = object.__setattr__
_init_convert_pat = '__attr_convert_{}'
_init_convert_pat = "__attr_convert_{}"
_tuple_property_pat = " {attr_name} = property(itemgetter({index}))"
_empty_metadata_singleton = metadata_proxy({})


class _Nothing(object):
Expand Down Expand Up @@ -48,7 +49,7 @@ def __hash__(self):

def attr(default=NOTHING, validator=None,
repr=True, cmp=True, hash=True, init=True,
convert=None):
convert=None, metadata={}):
"""
Create a new attribute on a class.
Expand Down Expand Up @@ -97,6 +98,8 @@ def attr(default=NOTHING, validator=None,
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. The
value is converted before being passed to the validator, if any.
:param metadata: An arbitrary mapping, to be used by third-party
components.
"""
return _CountingAttr(
default=default,
Expand All @@ -106,6 +109,7 @@ def attr(default=NOTHING, validator=None,
hash=hash,
init=init,
convert=convert,
metadata=metadata,
)


Expand All @@ -132,8 +136,8 @@ class MyClassAttributes(tuple):
))
else:
attr_class_template.append(" pass")
globs = {'itemgetter': itemgetter}
eval(compile("\n".join(attr_class_template), '', 'exec'), globs)
globs = {"itemgetter": itemgetter}
eval(compile("\n".join(attr_class_template), "", "exec"), globs)
return globs[attr_class_name]


Expand Down Expand Up @@ -283,7 +287,7 @@ def wrap(cls):
for ca_name in ca_list:
# It might not actually be in there, e.g. if using 'these'.
cls_dict.pop(ca_name, None)
cls_dict.pop('__dict__', None)
cls_dict.pop("__dict__", None)

qualname = getattr(cls, "__qualname__", None)
cls = type(cls.__name__, cls.__bases__, cls_dict)
Expand Down Expand Up @@ -698,56 +702,65 @@ class Attribute(object):
Plus *all* arguments of :func:`attr.ib`.
"""
__slots__ = ('name', 'default', 'validator', 'repr', 'cmp', 'hash', 'init',
'convert')

_optional = {"convert": None}
__slots__ = ("name", "default", "validator", "repr", "cmp", "hash", "init",
"convert", "metadata")

def __init__(self, name, default, validator, repr, cmp, hash, init,
convert=None):
convert=None, metadata=None):
# Cache this descriptor here to speed things up later.
__bound_setattr = _obj_setattr.__get__(self, Attribute)

__bound_setattr('name', name)
__bound_setattr('default', default)
__bound_setattr('validator', validator)
__bound_setattr('repr', repr)
__bound_setattr('cmp', cmp)
__bound_setattr('hash', hash)
__bound_setattr('init', init)
__bound_setattr('convert', convert)
__bound_setattr("name", name)
__bound_setattr("default", default)
__bound_setattr("validator", validator)
__bound_setattr("repr", repr)
__bound_setattr("cmp", cmp)
__bound_setattr("hash", hash)
__bound_setattr("init", init)
__bound_setattr("convert", convert)
__bound_setattr("metadata", (metadata_proxy(metadata) if metadata
else _empty_metadata_singleton))

def __setattr__(self, name, value):
raise FrozenInstanceError()

@classmethod
def from_counting_attr(cls, name, ca):
return cls(name=name,
**dict((k, getattr(ca, k))
for k
in Attribute.__slots__
if k != "name"))
inst_dict = dict((k, getattr(ca, k))
for k
in Attribute.__slots__
if k != "name")
return cls(name=name, **inst_dict)

# Don't use _add_pickle since fields(Attribute) doesn't work
def __getstate__(self):
"""
Play nice with pickle.
"""
return tuple(getattr(self, name) for name in self.__slots__)
return tuple(getattr(self, name) if name != "metadata"
else dict(self.metadata)
for name in self.__slots__)

def __setstate__(self, state):
"""
Play nice with pickle.
"""
__bound_setattr = _obj_setattr.__get__(self, Attribute)
for name, value in zip(self.__slots__, state):
__bound_setattr(name, value)
if name != "metadata":
__bound_setattr(name, value)
else:
__bound_setattr(name, metadata_proxy(value) if value else
_empty_metadata_singleton)


_a = [Attribute(name=name, default=NOTHING, validator=None,
repr=True, cmp=True, hash=True, init=True)
repr=True, cmp=True, hash=(name != "metadata"), init=True)
for name in Attribute.__slots__]

Attribute = _add_hash(
_add_cmp(_add_repr(Attribute, attrs=_a), attrs=_a), attrs=_a
_add_cmp(_add_repr(Attribute, attrs=_a), attrs=_a),
attrs=[a for a in _a if a.hash]
)


Expand All @@ -756,24 +769,31 @@ class _CountingAttr(object):
Intermediate representation of attributes that uses a counter to preserve
the order in which the attributes have been defined.
"""
__attrs_attrs__ = [
__slots__ = ("counter", "default", "repr", "cmp", "hash", "init",
"metadata", "validator", "convert")
__attrs_attrs__ = tuple(
Attribute(name=name, default=NOTHING, validator=None,
repr=True, cmp=True, hash=True, init=True)
for name
in ("counter", "default", "repr", "cmp", "hash", "init",)
]
counter = 0
) + (
Attribute(name="metadata", default=None, validator=None,
repr=True, cmp=True, hash=False, init=True),
)
cls_counter = 0

def __init__(self, default, validator, repr, cmp, hash, init, convert):
_CountingAttr.counter += 1
self.counter = _CountingAttr.counter
def __init__(self, default, validator, repr, cmp, hash, init, convert,
metadata):
_CountingAttr.cls_counter += 1
self.counter = _CountingAttr.cls_counter
self.default = default
self.validator = validator
self.repr = repr
self.cmp = cmp
self.hash = hash
self.init = init
self.convert = convert
self.metadata = metadata


_CountingAttr = _add_cmp(_add_repr(_CountingAttr))
Expand Down
1 change: 1 addition & 0 deletions tests/test_dark_magic.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ class C1Slots(object):
x = attr.ib(validator=attr.validators.instance_of(int))
y = attr.ib()


foo = None


Expand Down
Loading

0 comments on commit 0fac921

Please sign in to comment.