Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce attribute metadata. #96

Merged
merged 13 commits into from Nov 19, 2016
2 changes: 2 additions & 0 deletions CHANGELOG.rst
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
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
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
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
@@ -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
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
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