From 1b419e304b01bbbcb1d0d7b0515efdbc7de73588 Mon Sep 17 00:00:00 2001 From: inso Date: Sun, 11 Sep 2016 08:54:39 +0200 Subject: [PATCH] Add attr.astuple Fixes #77 and fixes #78 --- .gitignore | 2 +- CHANGELOG.rst | 16 +++-- docs/api.rst | 13 ++++ docs/examples.rst | 27 +++++++- src/attr/__init__.py | 2 + src/attr/_funcs.py | 74 ++++++++++++++++++++- src/attr/exceptions.py | 4 +- tests/test_funcs.py | 147 ++++++++++++++++++++++++++++++++++++++++- tests/utils.py | 22 +++++- 9 files changed, 291 insertions(+), 16 deletions(-) diff --git a/.gitignore b/.gitignore index 4390c907..a4ca3f8c 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,4 @@ docs/_build/ htmlcov dist .cache -.hypothesis +.hypothesis \ No newline at end of file diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 0aa5fc67..181ef8a7 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -11,15 +11,17 @@ The third digit is only for regressions. Changes: ^^^^^^^^ +- Add ``attr.astuple()`` that -- similarly to ``attr.asdict()`` -- returns the instance as a tuple. + `#77 `_ - Converts now work with frozen classes. `#76 `_ - Instantiation of ``attrs`` classes with converters is now significantly faster. `#80 `_ - Pickling now works with ``__slots__`` classes. `#81 `_ -- ``attr.assoc`` now works with ``__slots__`` classes. +- ``attr.assoc()`` now works with ``__slots__`` classes. `#84 `_ -- The tuple returned by ``attr.fields`` now also allows to access the ``Attribute`` instances by name. +- The tuple returned by ``attr.fields()`` now also allows to access the ``Attribute`` instances by name. Yes, we've subclassed ``tuple`` so you don't have to! Therefore ``attr.fields(C).x`` is equivalent to the deprecated ``C.x`` and works with ``__slots__`` classes. `#88 `_ @@ -43,7 +45,7 @@ Deprecations: ^^^^^^^^^^^^^ - Accessing ``Attribute`` instances on class objects is now deprecated and will stop working in 2017. - If you need introspection please use the ``__attrs_attrs__`` attribute or the ``attr.fields`` function that carry them too. + If you need introspection please use the ``__attrs_attrs__`` attribute or the ``attr.fields()`` function that carry them too. In the future, the attributes that are defined on the class body and are usually overwritten in your ``__init__`` method are simply removed after ``@attr.s`` has been applied. This will remove the confusing error message if you write your own ``__init__`` and forget to initialize some attribute. @@ -56,15 +58,15 @@ Deprecations: Changes: ^^^^^^^^ -- ``attr.asdict``\ 's ``dict_factory`` arguments is now propagated on recursion. +- ``attr.asdict()``\ 's ``dict_factory`` arguments is now propagated on recursion. `#45 `_ -- ``attr.asdict``, ``attr.has`` and ``attr.fields`` are significantly faster. +- ``attr.asdict()``, ``attr.has()`` and ``attr.fields()`` are significantly faster. `#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 `_ -- ``attr.asdict`` now takes ``retain_collection_types`` as an argument. +- ``attr.asdict()`` now takes ``retain_collection_types`` as an argument. If ``True``, it does not convert attributes of type ``tuple`` or ``set`` to ``list``. `#69 `_ @@ -95,7 +97,7 @@ Changes: - Allow the case of initializing attributes that are set to ``init=False``. This allows for clean initializer parameter lists while being able to initialize attributes to default values. `#32 `_ -- ``attr.asdict`` can now produce arbitrary mappings instead of Python ``dict``\ s when provided with a ``dict_factory`` argument. +- ``attr.asdict()`` can now produce arbitrary mappings instead of Python ``dict``\ s when provided with a ``dict_factory`` argument. `#40 `_ - Multiple performance improvements. diff --git a/docs/api.rst b/docs/api.rst index e69bdd56..8a1eddab 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -161,6 +161,19 @@ Helpers {'y': {'y': 3, 'x': 2}, 'x': 1} +.. autofunction:: attr.astuple + + For example: + + .. doctest:: + + >>> @attr.s + ... class C(object): + ... x = attr.ib() + ... y = attr.ib() + >>> attr.astuple(C(1,2)) + (1, 2) + ``attrs`` includes some handy helpers for filtering: .. autofunction:: attr.filters.include diff --git a/docs/examples.rst b/docs/examples.rst index c7e50203..341bad2c 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -162,8 +162,8 @@ On Python 3 it overrides the implicit detection. .. _asdict: -Converting to Dictionaries --------------------------- +Converting to Collections Types +------------------------------- When you have a class with data, it often is very convenient to transform that class into a :class:`dict` (for example if you want to serialize it to JSON): @@ -210,6 +210,29 @@ For the common case where you want to :func:`include ` or ... filter=attr.filters.include(int, attr.fields(C).x)) {'z': 3, 'x': 'foo'} +Other times, all you want is a tuple and ``attrs`` won't let you down: + +.. doctest:: + + >>> import sqlite3 + >>> import attr + >>> @attr.s + ... class Foo: + ... a = attr.ib() + ... b = attr.ib() + >>> foo = Foo(2, 3) + >>> with sqlite3.connect(":memory:") as conn: + ... c = conn.cursor() + ... c.execute("CREATE TABLE foo (x INTEGER PRIMARY KEY ASC, y)") #doctest: +ELLIPSIS + ... c.execute("INSERT INTO foo VALUES (?, ?)", attr.astuple(foo)) #doctest: +ELLIPSIS + ... foo2 = Foo(*c.execute("SELECT x, y FROM foo").fetchone()) + + + >>> foo == foo2 + True + + + Defaults -------- diff --git a/src/attr/__init__.py b/src/attr/__init__.py index bdf61c9d..09d72f20 100644 --- a/src/attr/__init__.py +++ b/src/attr/__init__.py @@ -3,6 +3,7 @@ from __future__ import absolute_import, division, print_function from ._funcs import ( asdict, assoc, + astuple, has, ) from ._make import ( @@ -46,6 +47,7 @@ __all__ = [ "Factory", "NOTHING", "asdict", + "astuple", "assoc", "attr", "attrib", diff --git a/src/attr/_funcs.py b/src/attr/_funcs.py index 480521cb..274034e6 100644 --- a/src/attr/_funcs.py +++ b/src/attr/_funcs.py @@ -25,7 +25,7 @@ def asdict(inst, recurse=True, filter=None, dict_factory=dict, example, to produce ordered dictionaries instead of normal Python dictionaries, pass in ``collections.OrderedDict``. :param bool retain_collection_types: Do not convert to ``list`` when - encountering an attribute which is type ``tuple`` or ``set``. Only + encountering an attribute whose type is ``tuple`` or ``set``. Only meaningful if ``recurse`` is ``True``. :rtype: return type of *dict_factory* @@ -67,6 +67,78 @@ def asdict(inst, recurse=True, filter=None, dict_factory=dict, return rv +def astuple(inst, recurse=True, filter=None, tuple_factory=tuple, + retain_collection_types=False): + """ + Return the ``attrs`` attribute values of *inst* as a tuple. + + Optionally recurse into other ``attrs``-decorated classes. + + :param inst: Instance of an ``attrs``-decorated class. + :param bool recurse: Recurse into classes that are also + ``attrs``-decorated. + :param callable filter: A callable whose return code determines whether an + attribute or element is included (``True``) or dropped (``False``). Is + called with the :class:`attr.Attribute` as the first argument and the + value as the second argument. + :param callable tuple_factory: A callable to produce tuples from. For + example, to produce lists instead of tuples. + :param bool retain_collection_types: Do not convert to ``list`` + or ``dict`` when encountering an attribute which type is + ``tuple``, ``dict`` or ``set``. Only meaningful if ``recurse`` is + ``True``. + + :rtype: return type of *tuple_factory* + + :raise attr.exceptions.NotAnAttrsClassError: If *cls* is not an ``attrs`` + class. + + .. versionadded:: 16.2.0 + """ + attrs = fields(inst.__class__) + rv = [] + retain = retain_collection_types # Very long. :/ + for a in attrs: + v = getattr(inst, a.name) + if filter is not None and not filter(a, v): + continue + if recurse is True: + if has(v.__class__): + rv.append(astuple(v, recurse=True, filter=filter, + tuple_factory=tuple_factory, + retain_collection_types=retain)) + elif isinstance(v, (tuple, list, set)): + cf = v.__class__ if retain is True else list + rv.append(cf([ + astuple(j, recurse=True, filter=filter, + tuple_factory=tuple_factory, + retain_collection_types=retain) + if has(j.__class__) else j + for j in v + ])) + elif isinstance(v, dict): + df = v.__class__ if retain is True else dict + rv.append(df( + ( + astuple( + kk, + tuple_factory=tuple_factory, + retain_collection_types=retain + ) if has(kk.__class__) else kk, + astuple( + vv, + tuple_factory=tuple_factory, + retain_collection_types=retain + ) if has(vv.__class__) else vv + ) + for kk, vv in iteritems(v))) + else: + rv.append(v) + else: + rv.append(v) + return rv if tuple_factory is list else tuple_factory(rv) + + def has(cls): """ Check whether *cls* is a class with ``attrs`` attributes. diff --git a/src/attr/exceptions.py b/src/attr/exceptions.py index 79225138..cdfabda4 100644 --- a/src/attr/exceptions.py +++ b/src/attr/exceptions.py @@ -6,7 +6,9 @@ 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``. + and subclassing :exc:`AttributeError`. + + .. versionadded:: 16.1.0 """ msg = "can't set attribute" args = [msg] diff --git a/tests/test_funcs.py b/tests/test_funcs.py index 4011ef45..acab8bd8 100644 --- a/tests/test_funcs.py +++ b/tests/test_funcs.py @@ -15,6 +15,7 @@ from .utils import simple_classes, nested_classes from attr._funcs import ( asdict, assoc, + astuple, has, ) from attr._make import ( @@ -30,7 +31,7 @@ SEQUENCE_TYPES = (list, tuple) class TestAsDict(object): """ - Tests for `asdict`. + Tests for `astuple`. """ @given(st.sampled_from(MAPPING_TYPES)) def test_shallow(self, C, dict_factory): @@ -156,6 +157,150 @@ class TestAsDict(object): assert [a.name for a in fields(cls)] == list(dict_instance.keys()) +class TestAsTuple(object): + """ + Tests for `astuple`. + """ + @given(st.sampled_from(SEQUENCE_TYPES)) + def test_shallow(self, C, tuple_factory): + """ + Shallow astuple returns correct dict. + """ + assert (tuple_factory([1, 2]) == + astuple(C(x=1, y=2), False, tuple_factory=tuple_factory)) + + @given(st.sampled_from(SEQUENCE_TYPES)) + def test_recurse(self, C, tuple_factory): + """ + Deep astuple returns correct tuple. + """ + assert (tuple_factory([tuple_factory([1, 2]), + tuple_factory([3, 4])]) + == astuple(C( + C(1, 2), + C(3, 4), + ), + tuple_factory=tuple_factory)) + + @given(nested_classes, st.sampled_from(SEQUENCE_TYPES)) + def test_recurse_property(self, cls, tuple_class): + """ + Property tests for recursive astuple. + """ + obj = cls() + obj_tuple = astuple(obj, tuple_factory=tuple_class) + + def assert_proper_tuple_class(obj, obj_tuple): + assert isinstance(obj_tuple, tuple_class) + for index, field in enumerate(fields(obj.__class__)): + field_val = getattr(obj, field.name) + if has(field_val.__class__): + # This field holds a class, recurse the assertions. + assert_proper_tuple_class(field_val, obj_tuple[index]) + + assert_proper_tuple_class(obj, obj_tuple) + + @given(nested_classes, st.sampled_from(SEQUENCE_TYPES)) + def test_recurse_retain(self, cls, tuple_class): + """ + Property tests for asserting collection types are retained. + """ + obj = cls() + obj_tuple = astuple(obj, tuple_factory=tuple_class, + retain_collection_types=True) + + def assert_proper_col_class(obj, obj_tuple): + # Iterate over all attributes, and if they are lists or mappings + # in the original, assert they are the same class in the dumped. + for index, field in enumerate(fields(obj.__class__)): + field_val = getattr(obj, field.name) + if has(field_val.__class__): + # This field holds a class, recurse the assertions. + 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 + 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) + elif isinstance(field_val, dict): + orig = field_val + tupled = obj_tuple[index] + assert type(orig) is type(tupled) # noqa: E721 + for obj_e, obj_tuple_e in zip(orig.items(), + tupled.items()): + if has(obj_e[0].__class__): # Dict key + assert_proper_col_class(obj_e[0], obj_tuple_e[0]) + if has(obj_e[1].__class__): # Dict value + assert_proper_col_class(obj_e[1], obj_tuple_e[1]) + + assert_proper_col_class(obj, obj_tuple) + + @given(st.sampled_from(SEQUENCE_TYPES)) + def test_filter(self, C, tuple_factory): + """ + Attributes that are supposed to be skipped are skipped. + """ + assert tuple_factory([tuple_factory([1, ]), ]) == astuple(C( + C(1, 2), + C(3, 4), + ), filter=lambda a, v: a.name != "y", tuple_factory=tuple_factory) + + @given(container=st.sampled_from(SEQUENCE_TYPES)) + def test_lists_tuples(self, container, C): + """ + If recurse is True, also recurse into lists. + """ + assert ((1, [(2, 3), (4, 5), "a"]) + == astuple(C(1, container([C(2, 3), C(4, 5), "a"]))) + ) + + @given(st.sampled_from(SEQUENCE_TYPES)) + def test_dicts(self, C, tuple_factory): + """ + If recurse is True, also recurse into dicts. + """ + res = astuple(C(1, {"a": C(4, 5)}), tuple_factory=tuple_factory) + assert tuple_factory([1, {"a": tuple_factory([4, 5])}]) == res + assert isinstance(res, tuple_factory) + + @given(container=st.sampled_from(SEQUENCE_TYPES)) + def test_lists_tuples_retain_type(self, container, C): + """ + If recurse and retain_collection_types are True, also recurse + into lists and do not convert them into list. + """ + assert ( + (1, container([(2, 3), (4, 5), "a"])) + == astuple(C(1, container([C(2, 3), C(4, 5), "a"])), + retain_collection_types=True)) + + @given(container=st.sampled_from(MAPPING_TYPES)) + def test_dicts_retain_type(self, container, C): + """ + If recurse and retain_collection_types are True, also recurse + into lists and do not convert them into list. + """ + assert ( + (1, container({"a": (4, 5)})) + == astuple(C(1, container({"a": C(4, 5)})), + retain_collection_types=True)) + + @given(simple_classes(), st.sampled_from(SEQUENCE_TYPES)) + def test_roundtrip(self, cls, tuple_class): + """ + Test dumping to tuple and back for Hypothesis-generated classes. + """ + instance = cls() + tuple_instance = astuple(instance, tuple_factory=tuple_class) + + assert isinstance(tuple_instance, tuple_class) + + roundtrip_instance = cls(*tuple_instance) + + assert instance == roundtrip_instance + + class TestHas(object): """ Tests for `has`. diff --git a/tests/utils.py b/tests/utils.py index ed0486bd..d91894c2 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -7,6 +7,8 @@ from __future__ import absolute_import, division, print_function import keyword import string +from collections import OrderedDict + from hypothesis import strategies as st import attr @@ -85,8 +87,8 @@ def _create_hyp_nested_strategy(simple_class_strategy): Given a strategy for building (simpler) classes, create and return a strategy for building classes that have as an attribute: either just - the simpler class, a list of simpler classes, or a dict mapping the string - "cls" to a simpler class. + the simpler class, a list of simpler classes, a tuple of simpler classes, + an ordered dict or a dict mapping the string "cls" to a simpler class. """ # Use a tuple strategy to combine simple attributes and an attr class. def just_class(tup): @@ -100,19 +102,33 @@ def _create_hyp_nested_strategy(simple_class_strategy): combined_attrs.append(attr.ib(default=default)) return _create_hyp_class(combined_attrs) + def tuple_of_class(tup): + default = attr.Factory(lambda: (tup[1](),)) + combined_attrs = list(tup[0]) + combined_attrs.append(attr.ib(default=default)) + return _create_hyp_class(combined_attrs) + def dict_of_class(tup): default = attr.Factory(lambda: {"cls": tup[1]()}) combined_attrs = list(tup[0]) combined_attrs.append(attr.ib(default=default)) return _create_hyp_class(combined_attrs) + def ordereddict_of_class(tup): + default = attr.Factory(lambda: OrderedDict([("cls", tup[1]())])) + combined_attrs = list(tup[0]) + combined_attrs.append(attr.ib(default=default)) + return _create_hyp_class(combined_attrs) + # A strategy producing tuples of the form ([list of attributes], ). attrs_and_classes = st.tuples(list_of_attrs, simple_class_strategy) return st.one_of(attrs_and_classes.map(just_class), attrs_and_classes.map(list_of_class), - attrs_and_classes.map(dict_of_class)) + attrs_and_classes.map(tuple_of_class), + 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))