diff --git a/docs/api.rst b/docs/api.rst index aa2294374..68b401418 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -436,6 +436,102 @@ Validators attr.exceptions.NotCallableError: 'x' must be callable (got 'not a callable' that is a ). +.. autofunction:: attr.validators.lt + + For example: + + .. doctest:: + + >>> @attr.s + ... class C(object): + ... x = attr.ib(validator=attr.validators.lt(3)) + ... + >>> C(2) + C(x=2) + >>> C(3) + Traceback (most recent call last): + ... + ValueError: 'x' must be < 3: 3 + + +.. autofunction:: attr.validators.le + + For example: + + .. doctest:: + + >>> @attr.s + ... class C(object): + ... x = attr.ib(validator=attr.validators.le(3)) + ... + >>> C(3) + C(x=3) + >>> C(4) + Traceback (most recent call last): + ... + ValueError: 'x' must be <= 3: 4 + + +.. autofunction:: attr.validators.ge + + For example: + + .. doctest:: + + >>> @attr.s + ... class C(object): + ... x = attr.ib(validator=attr.validators.ge(3)) + ... + >>> C(3) + C(x=3) + >>> C(2) + Traceback (most recent call last): + ... + ValueError: 'x' must be >= 3: 2 + + +.. autofunction:: attr.validators.gt + + For example: + + .. doctest:: + + >>> @attr.s + ... class C(object): + ... x = attr.ib(validator=attr.validators.gt(3)) + ... + >>> C(4) + C(x=4) + >>> C(3) + Traceback (most recent call last): + ... + ValueError: 'x' must be > 3: 3 + + +.. autofunction:: attr.validators.maxlen + + For example: + + .. doctest:: + + >>> @attr.s + ... class C(object): + ... x = attr.ib(validator=attr.validators.maxlen(4)) + ... + >>> C('spam') + C(x='spam') + >>> C(list(range(4))) + C(x=[0, 1, 2, 3]) + >>> C('bacon') + Traceback (most recent call last): + ... + ValueError: Lenght of 'x' must be <= 4: 5 + >>> C(list(range(5))) + Traceback (most recent call last): + ... + ValueError: Lenght of 'x' must be <= 4: 5 + + .. autofunction:: attr.validators.matches_re For example: @@ -549,6 +645,29 @@ Converters C(x='') +.. autofunction:: attr.converters.to_attrs + +.. autofunction:: attr.converters.to_dt + +.. autofunction:: attr.converters.to_iterable + +.. autofunction:: attr.converters.to_tuple + +.. autofunction:: attr.converters.to_mapping + +.. autofunction:: attr.converters.to_union + +Hooks +----- + +Hooks for automatic data conversion based on type annotations. + +.. autofunction:: attr.hooks.make_auto_converter + +.. autofunction:: attr.hooks.auto_convert + +.. autofunction:: attr.hooks.auto_serialize + .. _api_setters: Setters diff --git a/docs/examples.rst b/docs/examples.rst index 508aa9f6e..b25ed19ee 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -505,16 +505,16 @@ If you don't mind annotating *all* attributes, you can even drop the `attr.ib` a .. doctest:: - >>> import typing + >>> from typing import Any, ClassVar, List >>> @attr.s(auto_attribs=True) ... class AutoC: - ... cls_var: typing.ClassVar[int] = 5 # this one is ignored - ... l: typing.List[int] = attr.Factory(list) + ... cls_var: ClassVar[int] = 5 # this one is ignored + ... l: List[int] = attr.Factory(list) ... x: int = 1 ... foo: str = attr.ib( ... default="every attrib needs a type if auto_attribs=True" ... ) - ... bar: typing.Any = None + ... bar: Any = None >>> attr.fields(AutoC).l.type typing.List[int] >>> attr.fields(AutoC).x.type @@ -557,10 +557,53 @@ This will replace the *type* attribute in the respective fields. >>> attr.fields(A).b.type -.. warning:: - ``attrs`` itself doesn't have any features that work on top of type metadata *yet*. - However it's useful for writing your own validators or serialization frameworks. +Automatic type conversion +------------------------- + +Based on the type annoations shown in the examples above, attrs can automatically create converters for a class' attributes. +This can be very useful when loading data from JSON or dumping to it. + +.. doctest:: + + >>> from datetime import datetime + >>> from enum import Enum + >>> from typing import List, Set + >>> import json + >>> + >>> import attr + >>> + >>> + >>> class LeEnum(Enum): + ... spam = "Le spam" + ... eggs = "Le eggs" + ... + >>> @attr.frozen(field_transformer=attr.auto_convert) + ... class Child: + ... x: int = attr.ib() + ... y: int = attr.ib(converter=float) + ... + >>> @attr.frozen(kw_only=True, field_transformer=attr.auto_convert) + ... class Parent: + ... child: Child + ... d: datetime + ... e: LeEnum + ... f: List[float] + ... + >>> DATA = { + ... "d": "2020-05-04T13:37:00", + ... "e": "Le spam", + ... "f": [2.3, "1"], # There is a string in it :-O + ... "child": {"x": "23", "y": "42"}, + ... } + >>> p = Parent(**DATA) + >>> p + Parent(child=Child(x=23, y=42.0), d=datetime.datetime(2020, 5, 4, 13, 37), e=, f=[2.3, 1.0]) + >>> d = attr.asdict(p, value_serializer=attr.auto_serialize) + >>> d + {'child': {'x': 23, 'y': 42.0}, 'd': '2020-05-04T13:37:00', 'e': 'Le spam', 'f': [2.3, 1.0]} + >>> # Do a JSON round-trip: + >>> assert Parent(**json.loads(json.dumps(d))) == p Slots diff --git a/docs/extending.rst b/docs/extending.rst index ccd257c8f..ee231ef19 100644 --- a/docs/extending.rst +++ b/docs/extending.rst @@ -228,6 +228,21 @@ A more realistic example would be to automatically convert data that you, e.g., Data(a=3, b='spam', c=datetime.datetime(2020, 5, 4, 13, 37)) +Automatically convert data +++++++++++++++++++++++++++ + +todo + + +Extend the auto converter ++++++++++++++++++++++++++ + +todo + + + + + Customize Value Serialization in ``asdict()`` --------------------------------------------- diff --git a/src/attr/__init__.py b/src/attr/__init__.py index 2073552dd..56033f025 100644 --- a/src/attr/__init__.py +++ b/src/attr/__init__.py @@ -4,7 +4,7 @@ from functools import partial -from . import converters, exceptions, filters, setters, validators +from . import converters, exceptions, filters, hooks, setters, validators from ._config import get_run_validators, set_run_validators from ._funcs import asdict, assoc, astuple, evolve, has, resolve_types from ._make import ( @@ -19,6 +19,7 @@ validate, ) from ._version_info import VersionInfo +from .hooks import auto_convert, auto_serialize __version__ = "20.3.0.dev0" @@ -52,6 +53,8 @@ "attrib", "attributes", "attrs", + "auto_convert", + "auto_serialize", "converters", "evolve", "exceptions", @@ -60,6 +63,7 @@ "filters", "get_run_validators", "has", + "hooks", "ib", "make_class", "resolve_types", diff --git a/src/attr/_compat.py b/src/attr/_compat.py index bed5b1357..fb2463e22 100644 --- a/src/attr/_compat.py +++ b/src/attr/_compat.py @@ -18,6 +18,17 @@ ordered_dict = OrderedDict +if sys.version_info[:2] >= (3, 6): + from typing import get_args, get_origin +else: + + def get_args(t): + return getattr(t, "__args__", None) + + def get_origin(t): + return getattr(t, "__origin__", None) + + if PY2: from collections import Mapping, Sequence diff --git a/src/attr/converters.py b/src/attr/converters.py index 715ce1785..879ca64be 100644 --- a/src/attr/converters.py +++ b/src/attr/converters.py @@ -4,16 +4,27 @@ from __future__ import absolute_import, division, print_function +from datetime import datetime + from ._make import NOTHING, Factory, pipe __all__ = [ - "pipe", - "optional", "default_if_none", + "optional", + "pipe", + "to_attrs", + "to_dt", + "to_iterable", + "to_mapping", + "to_tuple", + "to_union", ] +NoneType = type(None) + + def optional(converter): """ A converter that allows an attribute to be optional. An optional attribute @@ -83,3 +94,164 @@ def default_if_none_converter(val): return default return default_if_none_converter + + +def to_attrs(cls): + """ + A converter that creates an instance of *cls* from a dict but leaves + instances of that class as they are. + + Classes can define a ``from_dict()`` classmethod which will be called + instead of the their `__init__()`. This can be useful if you want to + create different sub classes of *cls* depending on the data (e.g., + a ``Cat`` or a ``Dog`` inheriting ``Animal``). + + :param type cls: The class to convert data to. + :returns: The converter function for *cls*. + :rtype: callable + + .. versionadded:: 20.3.0 + + """ + type_ = cls.from_dict if hasattr(cls, "from_dict") else cls + + def convert(val): + if not isinstance(val, (cls, dict)): + raise TypeError( + f'Invalid type "{type(val).__name__}"; expected ' + f'"{cls.__name__}" or "dict".' + ) + return type_(**val) if isinstance(val, dict) else val + + n = cls.__name__ + convert.__doc__ = f""" + Convert *data* to an intance of {n} if it is not already an instance + of it. + + :param Union[dict, {n}] data: The input data + :returns: The converted data + :rtype: {n} + :raises TypeError: if *data* is neither a dict nor an instance of {n}. + """ + + return convert + + +def to_dt(val): + """ + Convert an ISO formatted string to :class:`datetime.datetime`. Leave the + input untouched if it is already a datetime. + + See: :func:`datetime.datetime.fromisoformat` + + :param Union[str, datetime.datetime] data: The input data + :returns: A parsed datetime object + :rtype: datetime.datetime + :raises TypeError: If *val* is neither a str nor a datetime. + + .. versionadded:: 20.3.0 + """ + if not isinstance(val, (datetime, str)): + raise TypeError( + f'Invalid type "{type(val).__name__}"; expected "datetime" or ' + f'"str".' + ) + return datetime.fromisoformat(val) if isinstance(val, str) else val + + +def to_iterable(cls, converter): + """ + A converter that creates a *cls* iterable (e.g., ``list``) and calls + *converter* for each element. + + :param Type[Iterable] cls: The type of the iterable to create + :param callable converter: The converter to apply to all items of the + input data. + :returns: The converter function + :rtype: callable + """ + + def convert(val): + return cls(converter(d) for d in val) + + return convert + + +def to_tuple(cls, converters): + """ + A converter that creates a struct-like tuple (or namedtuple or similar) + and converts each item via the corresponding converter from *converters* + + The input value must have exactly as many elements as there are converters. + + :param Type[Tuple] cls: The type of the tuple to create + :param List[callable] converters: The respective converters for each tuple + item. + :returns: The converter function + :rtype: callable + """ + + def convert(val): + if len(val) != len(converters): + raise TypeError( + "Value must have {} items but has: {}".format( + len(converters), len(val) + ) + ) + return cls(c(v) for c, v in zip(converters, val)) + + return convert + + +def to_mapping(cls, key_converter, val_converter): + """ + A converter that creates a mapping and converts all keys and values using + the respective converters. + + :param Type[Mapping] cls: The mapping type to create (e.g., ``dict``). + :param callable key_converter: The converter function to apply to all keys. + :param callable val_converter: The converter function to apply to all + values. + :returns: The converter function + :rtype: callable + """ + + def convert(val): + return cls( + (key_converter(k), val_converter(v)) for k, v in val.items() + ) + + return convert + + +def to_union(converters): + """ + A converter that applies a number of converters to the input value and + returns the result of the first converter that does not raise a + :exc:`TypeError` or :exc:`ValueError`. + + If the input value already has one of the required types, it will be + returned unchanged. + + :param List[callable] converters: A list of converters to try on the input. + :returns: The converter function + :rtype: callable + + """ + + def convert(val): + if type(val) in converters: + # Preserve val as-is if it already has a matching type. + # Otherwise float(3.2) would be converted to int + # if the converters are [int, float]. + return val + for converter in converters: + try: + return converter(val) + except (TypeError, ValueError): + pass + raise ValueError( + "Failed to convert value to any Union type: {}".format(val) + ) + + return convert diff --git a/src/attr/hooks.py b/src/attr/hooks.py new file mode 100644 index 000000000..2151a9e50 --- /dev/null +++ b/src/attr/hooks.py @@ -0,0 +1,135 @@ +""" +Commonly useful hooks. +""" + +from __future__ import absolute_import, division, print_function + +import sys + +from datetime import datetime +from enum import Enum + +from ._compat import get_args, get_origin +from .converters import ( + to_attrs, + to_dt, + to_iterable, + to_mapping, + to_tuple, + to_union, +) + + +if sys.version_info[:2] >= (3, 6): + from typing import Union, get_type_hints +else: + get_type_hints = None + + +__all__ = [ + "auto_convert", + "auto_serialize", + "make_auto_converter", +] + + +def make_auto_converter(converters): + """ + Return a + + """ + if get_type_hints is None: + raise RuntimeError("This function only works from Python 3.6 upwards") + + def auto_convert(cls, attribs): + """ + A field transformer that tries to convert all attribs of a class to their + annotated type. + """ + # We cannot use attrs.resolve_types() here, + # because "cls" is not yet a finished attrs class: + type_hints = get_type_hints(cls) + results = [] + for attrib in attribs: + # Do not override explicitly defined converters! + if attrib.converter is None: + converter = _get_converter(type_hints[attrib.name], converters) + attrib = attrib.assoc(converter=converter) + results.append(attrib) + + return results + + return auto_convert + + +def _get_converter( + type_, + converters, + iterable_types=frozenset({list, set, frozenset}), + tuple_types=frozenset({tuple}), + mapping_types=frozenset({dict}), +): + """ + Recursively resolves concrete and generic types and return a proper converter. + """ + # Detect whether "type_" is a container type. Currently we need + # to check, e.g., for "typing.List". From python 3.9, we also + # need to check for "list" directly. + origin = get_origin(type_) + if origin is None: + # Get converter for concrete type + if getattr(type_, "__attrs_attrs__", None) is not None: + # Attrs classes + converter = to_attrs(type_) + else: + # Check if type is in converters dict + for convert_type, convert_func in converters.items(): + if issubclass(type_, convert_type): + converter = convert_func + break + else: + # Fall back to simple types like bool, int, float, str, Enum, ... + converter = type_ + else: + # Get converter for generic type + args = get_args(type_) + if origin in iterable_types: + item_converter = _get_converter(args[0], converters) + converter = to_iterable(origin, item_converter) + elif origin in tuple_types: + if len(args) == 2 and args[1] == ...: + # "frozen list" variant of tuple + item_converter = _get_converter(args[0], converters) + converter = to_iterable(tuple, item_converter) + else: + # "struct" variant of tuple + item_converters = [_get_converter(t, converters) for t in args] + converter = to_tuple(tuple, item_converters) + elif origin in mapping_types: + key_converter = _get_converter(args[0], converters) + val_converter = _get_converter(args[1], converters) + converter = to_mapping(dict, key_converter, val_converter) + elif origin is Union: + item_converters = [_get_converter(t, converters) for t in args] + converter = to_union(item_converters) + else: + raise TypeError( + f"Cannot create converter for generic type: {type_}" + ) + + return converter + + +auto_convert = make_auto_converter({datetime: to_dt}) +"""Auto-convert :class:`datetime.datetime` as well as other stuff.""" + + +def auto_serialize(inst, attrib, value): + """Inverse hook to :func:`auto_convert` for use with + :func:`attrs.asdict()`. + """ + if isinstance(value, datetime): + return datetime.isoformat(value) + if isinstance(value, Enum): + return value.value + return value diff --git a/src/attr/validators.py b/src/attr/validators.py index b9a73054e..262065896 100644 --- a/src/attr/validators.py +++ b/src/attr/validators.py @@ -4,6 +4,7 @@ from __future__ import absolute_import, division, print_function +import operator import re from ._make import _AndValidator, and_, attrib, attrs @@ -17,6 +18,11 @@ "in_", "instance_of", "is_callable", + "lt", + "le", + "ge", + "gt", + "maxlen", "matches_re", "optional", "provides", @@ -67,6 +73,111 @@ def instance_of(type): return _InstanceOfValidator(type) +@attrs(repr=False, frozen=True, slots=True) +class _NumberValidator(object): + bound = attrib() + compare_op = attrib() + compare_func = attrib() + + def __call__(self, inst, attr, value): + """ + We use a callable class to be able to change the ``__repr__``. + """ + if not self.compare_func(value, self.bound): + raise ValueError( + "'{name}' must be {op} {bound}: {value}".format( + name=attr.name, + op=self.compare_op, + bound=self.bound, + value=value, + ) + ) + + def __repr__(self): + return "".format( + op=self.compare_op, bound=self.bound + ) + + +def lt(val): + """ + A validator that raises `ValueError` if the initializer is called + with a number larger or equal to *val*. + + :param int val: Exclusive upper bound for values + + .. versionadded:: 20.3.0 + """ + return _NumberValidator(val, "<", operator.lt) + + +def le(val): + """ + A validator that raises `ValueError` if the initializer is called + with a number greater than *val*. + + :param int val: Inclusive upper bound for values + + .. versionadded:: 20.3.0 + """ + return _NumberValidator(val, "<=", operator.le) + + +def ge(val): + """ + A validator that raises `ValueError` if the initializer is called + with a number smaller than *val*. + + :param int val: Inclusive lower bound for values + + .. versionadded:: 20.3.0 + """ + return _NumberValidator(val, ">=", operator.ge) + + +def gt(val): + """ + A validator that raises `ValueError` if the initializer is called + with a number smaller or equal to *val*. + + :param int val: Exclusive lower bound for values + + .. versionadded:: 20.3.0 + """ + return _NumberValidator(val, ">", operator.gt) + + +@attrs(repr=False, frozen=True, slots=True) +class _MaxLengthValidator: + max_length = attrib() + + def __call__(self, inst, attr, value): + """ + We use a callable class to be able to change the ``__repr__``. + """ + if len(value) > self.max_length: + raise ValueError( + "Length of '{name}' must be <= {max}: {len}".format( + name=attr.name, max=self.max_length, len=len(value) + ) + ) + + def __repr__(self): + return "".format(max=self.max_length) + + +def maxlen(length): + """ + A validator that raises `ValueError` if the initializer is called + with a string or iterable that is longer than *length*. + + :param int length: Maximum length of the string or iterable + + .. versionadded:: 20.3.0 + """ + return _MaxLengthValidator(length) + + @attrs(repr=False, frozen=True, slots=True) class _MatchesReValidator(object): regex = attrib() diff --git a/tests/test_converters.py b/tests/test_converters.py index f86e07e29..ef46395c8 100644 --- a/tests/test_converters.py +++ b/tests/test_converters.py @@ -4,6 +4,9 @@ from __future__ import absolute_import +import sys + +from datetime import datetime, timedelta, timezone from distutils.util import strtobool import pytest @@ -11,7 +14,17 @@ import attr from attr import Factory, attrib -from attr.converters import default_if_none, optional, pipe +from attr.converters import ( + default_if_none, + optional, + pipe, + to_attrs, + to_dt, + to_iterable, + to_mapping, + to_tuple, + to_union, +) class TestOptional(object): @@ -101,6 +114,221 @@ def test_none_factory(self): assert [] == c(None) +class TestToAttrs: + """Tests for to_attrs().""" + + def test_from_data(self): + """ + Dicts can be converted to class instances. + """ + + @attr.s + class C: + x = attr.ib() + y = attr.ib() + + converter = to_attrs(C) + assert converter({"x": 2, "y": 3}) == C(2, 3) + + def test_from_inst(self): + """ + Existing instances remain unchanged. + """ + + @attr.s + class C: + x = attr.ib() + y = attr.ib() + + inst = C(2, 3) + converter = to_attrs(C) + assert converter(inst) is inst + + @pytest.mark.skipif( + sys.version_info < (3, 6), + reason="__init_subclass__ is not yet supported", + ) + def test_from_dict_factory(self): + """ + Classes can specify a "from_dict" factory that will be called. + """ + + @attr.s + class Animal: + type = attr.ib() + __classes__ = {} + + def __init_subclass__(cls, **kwargs): + super().__init_subclass__(**kwargs) + cls.__classes__[cls.__name__] = cls + + @classmethod + def from_dict(cls, **attribs): + cls_name = attribs["type"] + return cls.__classes__[cls_name](**attribs) + + @attr.s(kw_only=True) + class Cat(Animal): + x = attr.ib() + + @attr.s(kw_only=True) + class Dog(Animal): + x = attr.ib() + y = attr.ib(default=3) + + converter = to_attrs(Animal) + assert converter({"type": "Cat", "x": 2}) == Cat(type="Cat", x=2) + assert converter({"type": "Dog", "x": 2}) == Dog(type="Dog", x=2, y=3) + + def test_invalid_cls(self): + """ + Raise TypeError when neither a dict nor an instance of the class is + passed. + """ + + @attr.s + class C: + x = attr.ib() + y = attr.ib() + + converter = to_attrs(C) + with pytest.raises(TypeError): + converter([2, 3]) + + +class TestToDt: + """Tests for to_dt().""" + + def test_from_dt(self): + """ + Existing datetimes are returned unchanged. + """ + dt = datetime(2020, 5, 4, 13, 37) + result = to_dt(dt) + assert result is dt + + @pytest.mark.parametrize( + "input, expected", + [ + ("2020-05-04 13:37:00", datetime(2020, 5, 4, 13, 37)), + ("2020-05-04T13:37:00", datetime(2020, 5, 4, 13, 37)), + # ( + # "2020-05-04T13:37:00Z", + # datetime(2020, 5, 4, 13, 37, tzinfo=timezone.utc)), + # ), + ( + "2020-05-04T13:37:00+00:00", + datetime(2020, 5, 4, 13, 37, tzinfo=timezone.utc), + ), + ( + "2020-05-04T13:37:00+02:00", + datetime( + 2020, + 5, + 4, + 13, + 37, + tzinfo=timezone(timedelta(seconds=7200)), + ), + ), + ], + ) + def test_from_str(self, input, expected): + """ + Existing datetimes are returned unchanged. + """ + result = to_dt(input) + assert result == expected + + def test_invalid_input(self): + """ + Invalid inputs raises a TypeError. + """ + with pytest.raises(TypeError): + to_dt(3) + + +class TestToIterable: + """Tests for to_iterable().""" + + @pytest.mark.parametrize("cls", [list, set, tuple]) + def test_to_iterable(self, cls): + """ + An iterable's data and the iterable itself can be converted to + different types. + """ + converter = to_iterable(cls, int) + assert converter(["1", "2", "3"]) == cls([1, 2, 3]) + + +class TestToTuple: + """Tests for to_tuple().""" + + @pytest.mark.parametrize("cls", [tuple]) + def test_to_tuple(self, cls): + """ + Struct-like tuples can contain different data types. + """ + converter = to_tuple(cls, [int, float, str]) + assert converter(["1", "2.2", "s"]) == cls([1, 2.2, "s"]) + + @pytest.mark.parametrize("val", [["1", "2.2", "s"], ["1"]]) + def test_tuple_wrong_input_length(self, val): + """ + Input data must have exactly as many elements as the tuple definition + has converters. + """ + converter = to_tuple(tuple, [int, float]) + with pytest.raises( + TypeError, + match="Value must have 2 items but has: {}".format(len(val)), + ): + converter(val) + + +class TestToMapping: + """Tests for to_mapping().""" + + @pytest.mark.parametrize("cls", [dict]) + def test_to_dict(self, cls): + """ + Keys and values of dicts can be converted to (different) types. + """ + converter = to_mapping(cls, int, float) + assert converter({"1": "2", "2": "2.5"}) == cls([(1, 2.0), (2, 2.5)]) + + +class TestToUnion: + """Tests for to_union().""" + + @pytest.mark.parametrize( + "types, val, expected_type, expected_val", + [ + ([type(None), int], None, type(None), None), + ([type(None), int], "3", int, 3), + ([int, float], "3", int, 3), + ([int, float], 3.2, float, 3.2), # Do not cast 3.2 to int! + ([int, float], "3.2", float, 3.2), + ([int, float, str], "3.2s", str, "3.2s"), + ([int, float, bool, str], "3.2", str, "3.2"), + ([int, float, bool, str], True, bool, True), + ([int, float, bool, str], "True", str, "True"), + ([int, float, bool, str], "", str, ""), + ], + ) + def test_to_union(self, types, val, expected_type, expected_val): + """ + Union data is converted to the first matching type. If the input data + already has a valid type, it is returned without conversion. For + example, floats will not be converted to ints when the type is + "Union[int, float]". + """ + converter = to_union(types) + result = converter(val) + assert type(result) is expected_type + assert result == expected_val + + class TestPipe(object): def test_success(self): """ diff --git a/tests/test_hooks.py b/tests/test_hooks.py index 56049c876..1e3c6db0b 100644 --- a/tests/test_hooks.py +++ b/tests/test_hooks.py @@ -1,8 +1,20 @@ +import functools +import json + from datetime import datetime -from typing import Dict, List +from enum import Enum +from typing import Dict, List, Optional, Set, Tuple, Type, Union + +import pytest import attr +from attr.hooks import auto_convert, auto_serialize +from attr.validators import le + + +auto_converter = functools.partial(attr.frozen, field_transformer=auto_convert) + class TestTransformHook: """ @@ -185,3 +197,183 @@ class Parent: (None, None, "spam"), (inst.c["spam"], "x", inst.c["spam"].x), ] + + +class LeEnum(Enum): + spam = "Le spam" + eggs = "Le eggs" + + +@auto_converter +class Child: + x: int = attr.ib() + y: int = attr.ib(converter=float) + + +@auto_converter(kw_only=True) +class Parent: + child: Child + a: float + b: float = attr.field(default=3.14, validator=le(2)) + c: LeEnum + d: datetime + e: "List[Child]" + f: Set[datetime] + + +class TestAutoConvertHook: + """Tests for the bundled auto-convert hook.""" + + DATA = { + "a": "1", + "b": "2", + "c": "Le spam", + "d": "2020-05-04T13:37:00", + "e": [{"x": "23", "y": "42"}], + "f": ["2020-05-04T13:37:00", "2020-05-04T13:37:00"], + "child": {"x": "23", "y": "42"}, + } + + @pytest.fixture(scope="class") + def parent(self): + return Parent(**self.DATA) + + def test_convert_to_parent(self, parent): + """ + The auto_convert hook must convert attrs classes, datetimes, enums and + basic type as well as basic containers. + """ + assert parent == Parent( + a=1.0, + b=2.0, + c=LeEnum.spam, + d=datetime(2020, 5, 4, 13, 37), + e=[Child(23, 42)], + f={datetime(2020, 5, 4, 13, 37)}, + child=Child(23, 42), + ) + + def test_serialize_from_parent(self, parent): + """ + The serialize_hook must be able to serialize the same types as the + auto_convert hook. The set with duplicate entries of attrib "f" is + not the same as in the original dict! + """ + d = attr.asdict(parent, value_serializer=auto_serialize) + assert d == { + "a": 1.0, + "b": 2.0, + "c": "Le spam", + "d": "2020-05-04T13:37:00", + "e": [{"x": 23, "y": 42}], + "f": ["2020-05-04T13:37:00"], + "child": {"x": 23, "y": 42}, + } + + def test_json_roundtrip(self, parent): + """ + The roundtrip "inst -> JSON -> inst" must result in the same inst. + """ + d = attr.asdict(parent, value_serializer=auto_serialize) + assert Parent(**json.loads(json.dumps(d))) == parent + + def test_convert_to_struct_tuple(self): + """ + Tuples can be defined like structs. + """ + + @auto_converter + class C: + x: Tuple[int, float, str] + + c = C(["3", "3.2", "3.2"]) + assert c.x == (3, 3.2, "3.2") + + def test_convert_to_iterable_tuple(self): + """ + Tuples can be defined like (immutable) lists. + """ + + @auto_converter + class C: + x: Tuple[int, ...] + + c = C(["3", "2", "1"]) + assert c.x == (3, 2, 1) + + def test_to_mapping(self): + """ + Converters are applied to dict keys and values. + """ + + @auto_converter + class C: + x: Dict[int, float] + + c = C({"1": "2"}) + assert c.x == {1: 2.0} + + @pytest.mark.parametrize("val, expected", [({}, None), ({"x": "2"}, 2)]) + def test_convert_to_optional(self, val, expected): + """ + Conversion to Optional works with and without a value. + """ + + @auto_converter + class C: + x: Optional[int] = None + + c = C(**val) + assert c.x == expected + assert type(c.x) is type(expected) + + def test_convert_to_union(self): + """ + Union values resolve to the first matching type + """ + + @auto_converter + class C: + x: Union[int, float] + + c = C(**{"x": "3.2"}) + assert c.x == 3.2 + assert type(c.x) is float + + def test_invalid_generic_type(self): + """ + Annotating a generic type that the converter doesn't know leads to + a TypeError. + """ + with pytest.raises( + TypeError, match="Cannot create converter for generic type:" + ): + + @auto_converter + class C: + x: Type[int] + + def test_nested_conversion(self): + """ + Generics (like lists and dicts) can be nested and will still be + properly converted. + """ + + @auto_converter + class A: + x: int + y: int + + @auto_converter + class C: + x: Dict[Tuple[int, int], List[Dict[int, A]]] + + c = C( + { + ("1", "2"): [ + {"3": {"x": "4", "y": "5"}}, + {"6": {"x": "7", "y": "8"}}, + ], + } + ) + assert c.x == {(1, 2): [{3: A(4, 5)}, {6: A(7, 8)}]} diff --git a/tests/test_validators.py b/tests/test_validators.py index 4aeec9990..08f56ba56 100644 --- a/tests/test_validators.py +++ b/tests/test_validators.py @@ -10,17 +10,22 @@ import attr -from attr import has +from attr import fields, has from attr import validators as validator_module from attr._compat import PY2, TYPE from attr.validators import ( and_, deep_iterable, deep_mapping, + ge, + gt, in_, instance_of, is_callable, + le, + lt, matches_re, + maxlen, optional, provides, ) @@ -93,6 +98,157 @@ def test_repr(self): ) == repr(v) +class TestLtLeGeGt(object): + """ + Tests for `maxlen`. + """ + + BOUND = 4 + + def test_in_all(self): + """ + validator is in ``__all__``. + """ + assert all( + f.__name__ in validator_module.__all__ for f in [lt, le, ge, gt] + ) + + @pytest.mark.parametrize("v", [lt, le, ge, gt]) + def test_retrieve_bound(self, v): + """ + The configured bound for the comparison can be extracted from the + Attribute. + """ + + @attr.s + class Tester(object): + value = attr.ib(validator=v(self.BOUND)) + + assert fields(Tester).value.validator.bound == self.BOUND + + @pytest.mark.parametrize( + "v, value", + [ + (lt, 3), + (le, 3), + (le, 4), + (ge, 4), + (ge, 5), + (gt, 5), + ], + ) + def test_check_valid(self, v, value): + """Silent if value {op} bound.""" + + @attr.s + class Tester(object): + value = attr.ib(validator=v(self.BOUND)) + + Tester(value) # shouldn't raise exceptions + + @pytest.mark.parametrize( + "v, value", + [ + (lt, 4), + (le, 5), + (ge, 3), + (gt, 4), + ], + ) + def test_check_invalid(self, v, value): + """Raise ValueError if value {op} bound.""" + + @attr.s + class Tester(object): + value = attr.ib(validator=v(self.BOUND)) + + with pytest.raises(ValueError): + Tester(value) + + @pytest.mark.parametrize("v", [lt, le, ge, gt]) + def test_repr(self, v): + """ + __repr__ is meaningful. + """ + nv = v(23) + assert repr(nv) == "".format( + op=nv.compare_op, bound=23 + ) + + +class TestMaxlen(object): + """ + Tests for `maxlen`. + """ + + MAX_LENGTH = 4 + + def test_in_all(self): + """ + validator is in ``__all__``. + """ + assert maxlen.__name__ in validator_module.__all__ + + def test_retrieve_maxlen(self): + """ + The configured max. length can be extracted from the Attribute + """ + + @attr.s + class Tester(object): + value = attr.ib(validator=maxlen(self.MAX_LENGTH)) + + assert fields(Tester).value.validator.max_length == self.MAX_LENGTH + + @pytest.mark.parametrize( + "value", + [ + "", + "foo", + "spam", + [], + list(range(MAX_LENGTH)), + {"spam": 3, "eggs": 4}, + ], + ) + def test_check_valid(self, value): + """ + Silent if len(value) <= maxlen. + Values can be strings and other iterables. + """ + + @attr.s + class Tester(object): + value = attr.ib(validator=maxlen(self.MAX_LENGTH)) + + Tester(value) # shouldn't raise exceptions + + @pytest.mark.parametrize( + "value", + [ + "bacon", + list(range(6)), + ], + ) + def test_check_invalid(self, value): + """ + Raise ValueError if len(value) > maxlen. + """ + + @attr.s + class Tester(object): + value = attr.ib(validator=maxlen(self.MAX_LENGTH)) + + with pytest.raises(ValueError): + Tester(value) + + def test_repr(self): + """ + __repr__ is meaningful. + """ + assert repr(maxlen(23)) == "" + + class TestMatchesRe(object): """ Tests for `matches_re`. @@ -153,13 +309,11 @@ def test_catches_invalid_func(self): if not PY2: assert ( "'func' must be one of None, fullmatch, match, search." - == ei.value.args[0] - ) + ) == ei.value.args[0] else: assert ( "'func' must be one of None, match, search." - == ei.value.args[0] - ) + ) == ei.value.args[0] @pytest.mark.parametrize( "func", [None, getattr(re, "fullmatch", None), re.match, re.search]