From 0fac9215b8f70a0cf2eab9d5851702c30f9300f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tin=20Tvrtkovi=C4=87?= Date: Sat, 19 Nov 2016 09:47:03 +0100 Subject: [PATCH] Introduce attribute metadata. (#96) --- CHANGELOG.rst | 2 + docs/api.rst | 6 +-- docs/examples.rst | 25 +++++++++++- docs/extending.rst | 42 ++++++++++++++++++- src/attr/_compat.py | 57 +++++++++++++++++++++++++- src/attr/_make.py | 88 ++++++++++++++++++++++++---------------- tests/test_dark_magic.py | 1 + tests/test_dunders.py | 1 + tests/test_funcs.py | 3 +- tests/test_make.py | 78 +++++++++++++++++++++++++++++++++-- tests/utils.py | 35 ++++++++++++---- 11 files changed, 286 insertions(+), 52 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index bcd3e49d8..44b10ad45 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -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 `_ - Don't overwrite ``__name__`` with ``__qualname__`` for ``attr.s(slots=True)`` classes. `#99 `_ diff --git a/docs/api.rst b/docs/api.rst index 8a1eddab4..320668464 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -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 @@ -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 diff --git a/docs/examples.rst b/docs/examples.rst index 23e0ff59c..a307aa3b0 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -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 `. + + .. _slots: Slots @@ -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() diff --git a/docs/extending.rst b/docs/extending.rst index 9a40a4185..604fb0f68 100644 --- a/docs/extending.rst +++ b/docs/extending.rst @@ -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:: @@ -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] + diff --git a/src/attr/_compat.py b/src/attr/_compat.py index 792eda853..8cbcf16f4 100644 --- a/src/attr/_compat.py +++ b/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) @@ -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) @@ -33,3 +85,6 @@ def iteritems(d): def iterkeys(d): return d.keys() + + def metadata_proxy(d): + return types.MappingProxyType(dict(d)) diff --git a/src/attr/_make.py b/src/attr/_make.py index b1fdf4455..98fbc1d7d 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -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): @@ -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. @@ -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, @@ -106,6 +109,7 @@ def attr(default=NOTHING, validator=None, hash=hash, init=init, convert=convert, + metadata=metadata, ) @@ -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] @@ -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) @@ -698,42 +702,44 @@ 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): """ @@ -741,13 +747,20 @@ def __setstate__(self, state): """ __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] ) @@ -756,17 +769,23 @@ 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 @@ -774,6 +793,7 @@ def __init__(self, default, validator, repr, cmp, hash, init, convert): self.hash = hash self.init = init self.convert = convert + self.metadata = metadata _CountingAttr = _add_cmp(_add_repr(_CountingAttr)) diff --git a/tests/test_dark_magic.py b/tests/test_dark_magic.py index 56c74f7ae..a36352ef5 100644 --- a/tests/test_dark_magic.py +++ b/tests/test_dark_magic.py @@ -23,6 +23,7 @@ class C1Slots(object): x = attr.ib(validator=attr.validators.instance_of(int)) y = attr.ib() + foo = None diff --git a/tests/test_dunders.py b/tests/test_dunders.py index 4e6317b9d..f4aaa6378 100644 --- a/tests/test_dunders.py +++ b/tests/test_dunders.py @@ -35,6 +35,7 @@ class InitC(object): __attrs_attrs__ = [simple_attr("a"), simple_attr("b")] + InitC = _add_init(InitC, False) diff --git a/tests/test_funcs.py b/tests/test_funcs.py index b644a256c..473808041 100644 --- a/tests/test_funcs.py +++ b/tests/test_funcs.py @@ -219,7 +219,8 @@ def assert_proper_col_class(obj, obj_tuple): assert_proper_col_class(field_val, obj_tuple[index]) elif isinstance(field_val, (list, tuple)): # This field holds a sequence of something. - assert type(field_val) is type(obj_tuple[index]) # noqa: E721 + expected_type = type(obj_tuple[index]) + assert type(field_val) is expected_type # noqa: E721 for obj_e, obj_tuple_e in zip(field_val, obj_tuple[index]): if has(obj_e.__class__): assert_proper_col_class(obj_e, obj_tuple_e) diff --git a/tests/test_make.py b/tests/test_make.py index 96c146a14..9ea26299e 100644 --- a/tests/test_make.py +++ b/tests/test_make.py @@ -3,11 +3,12 @@ """ from __future__ import absolute_import, division, print_function +from operator import attrgetter import pytest from hypothesis import given -from hypothesis.strategies import booleans, integers, sampled_from +from hypothesis.strategies import booleans, integers, lists, sampled_from, text from attr import _config from attr._compat import PY2 @@ -24,9 +25,10 @@ ) from attr.exceptions import NotAnAttrsClassError -from .utils import simple_attr, simple_attrs, simple_classes +from .utils import (gen_attr_names, list_of_attrs, simple_attr, simple_attrs, + simple_attrs_without_metadata, simple_classes) -attrs = simple_attrs.map(lambda c: Attribute.from_counting_attr('name', c)) +attrs = simple_attrs.map(lambda c: Attribute.from_counting_attr("name", c)) class TestCountingAttr(object): @@ -103,7 +105,8 @@ class C(object): "No mandatory attributes allowed after an attribute with a " "default value or factory. Attribute in question: Attribute" "(name='y', default=NOTHING, validator=None, repr=True, " - "cmp=True, hash=True, init=True, convert=None)", + "cmp=True, hash=True, init=True, convert=None, " + "metadata=mappingproxy({}))", ) == e.value.args def test_these(self): @@ -512,3 +515,70 @@ def raiser(_, __, ___): with pytest.raises(Exception) as e: C(1) assert (obj,) == e.value.args + + +# Hypothesis seems to cache values, so the lists of attributes come out +# unsorted. +sorted_lists_of_attrs = list_of_attrs.map( + lambda l: sorted(l, key=attrgetter("counter"))) + + +class TestMetadata(object): + """ + Tests for metadata handling. + """ + + @given(sorted_lists_of_attrs) + def test_metadata_present(self, list_of_attrs): + """ + Assert dictionaries are copied and present. + """ + C = make_class("C", dict(zip(gen_attr_names(), list_of_attrs))) + + for hyp_attr, class_attr in zip(list_of_attrs, fields(C)): + if hyp_attr.metadata is None: + # The default is a singleton empty dict. + assert class_attr.metadata is not None + assert len(class_attr.metadata) == 0 + else: + assert hyp_attr.metadata == class_attr.metadata + + # Once more, just to assert getting items and iteration. + for k in class_attr.metadata: + assert hyp_attr.metadata[k] == class_attr.metadata[k] + assert (hyp_attr.metadata.get(k) == + class_attr.metadata.get(k)) + + @given(simple_classes(), text()) + def test_metadata_immutability(self, C, string): + """ + The metadata dict should be best-effort immutable. + """ + for a in fields(C): + with pytest.raises(TypeError): + a.metadata[string] = string + with pytest.raises(AttributeError): + a.metadata.update({string: string}) + with pytest.raises(AttributeError): + a.metadata.clear() + with pytest.raises(AttributeError): + a.metadata.setdefault(string, string) + + for k in a.metadata: + # For some reason, Python 3's MappingProxyType throws an + # IndexError for deletes on a large integer key. + with pytest.raises((TypeError, IndexError)): + del a.metadata[k] + with pytest.raises(AttributeError): + a.metadata.pop(k) + with pytest.raises(AttributeError): + a.metadata.popitem() + + @given(lists(simple_attrs_without_metadata, min_size=2, max_size=5)) + def test_empty_metadata_singleton(self, list_of_attrs): + """ + All empty metadata attributes share the same empty metadata dict. + """ + C = make_class("C", dict(zip(gen_attr_names(), list_of_attrs))) + for a in fields(C)[1:]: + assert a.metadata is fields(C)[0].metadata diff --git a/tests/utils.py b/tests/utils.py index d91894c2f..7d4f32882 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -55,7 +55,7 @@ def returns_distinct_classes(self): assert simple_class() is not simple_class() -def _gen_attr_names(): +def gen_attr_names(): """ Generate names for attributes, 'a'...'z', then 'aa'...'zz'. @@ -78,7 +78,7 @@ def _create_hyp_class(attrs): """ A helper function for Hypothesis to generate attrs classes. """ - return make_class('HypClass', dict(zip(_gen_attr_names(), attrs))) + return make_class('HypClass', dict(zip(gen_attr_names(), attrs))) def _create_hyp_nested_strategy(simple_class_strategy): @@ -130,6 +130,7 @@ def ordereddict_of_class(tup): attrs_and_classes.map(dict_of_class), attrs_and_classes.map(ordereddict_of_class)) + bare_attrs = st.just(attr.ib(default=None)) int_attrs = st.integers().map(lambda i: attr.ib(default=i)) str_attrs = st.text().map(lambda s: attr.ib(default=s)) @@ -137,11 +138,29 @@ def ordereddict_of_class(tup): dict_attrs = (st.dictionaries(keys=st.text(), values=st.integers()) .map(lambda d: attr.ib(default=d))) -simple_attrs = st.one_of(bare_attrs, int_attrs, str_attrs, float_attrs, - dict_attrs) +simple_attrs_without_metadata = (bare_attrs | int_attrs | str_attrs | + float_attrs | dict_attrs) + + +@st.composite +def simple_attrs_with_metadata(draw): + """ + Create a simple attribute with arbitrary metadata. + """ + c_attr = draw(simple_attrs) + keys = st.booleans() | st.binary() | st.integers() | st.text() + vals = st.booleans() | st.binary() | st.integers() | st.text() + metadata = draw(st.dictionaries(keys=keys, values=vals)) + + return attr.ib(c_attr.default, c_attr.validator, c_attr.repr, + c_attr.cmp, c_attr.hash, c_attr.init, c_attr.convert, + metadata) + + +simple_attrs = simple_attrs_without_metadata | simple_attrs_with_metadata() # Python functions support up to 255 arguments. -list_of_attrs = st.lists(simple_attrs, average_size=9, max_size=50) +list_of_attrs = st.lists(simple_attrs, average_size=3, max_size=9) @st.composite @@ -167,10 +186,12 @@ class HypClass: frozen_flag = draw(st.booleans()) if frozen is None else frozen slots_flag = draw(st.booleans()) if slots is None else slots - return make_class('HypClass', dict(zip(_gen_attr_names(), attrs)), + return make_class('HypClass', dict(zip(gen_attr_names(), attrs)), slots=slots_flag, frozen=frozen_flag) + # Ok, so st.recursive works by taking a base strategy (in this case, # simple_classes) and a special function. This function receives a strategy, # and returns another strategy (building on top of the base strategy). -nested_classes = st.recursive(simple_classes(), _create_hyp_nested_strategy) +nested_classes = st.recursive(simple_classes(), _create_hyp_nested_strategy, + max_leaves=10)