diff --git a/src/attr/converters.py b/src/attr/converters.py index 2777db6d0..0b25726fe 100644 --- a/src/attr/converters.py +++ b/src/attr/converters.py @@ -4,6 +4,8 @@ from __future__ import absolute_import, division, print_function +from datetime import datetime + from ._compat import PY2 from ._make import NOTHING, Factory, pipe @@ -17,6 +19,13 @@ "pipe", "optional", "default_if_none", + "to_attrs", + "to_bool", + "to_dt", + "to_iterable", + "to_mapping", + "to_tuple", + "to_union", ] @@ -109,3 +118,226 @@ 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 + + """ + 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_bool(val): + """ + Convert "boolean" strings (e.g., from env. vars.) to real booleans. + + Values mapping to :code:`True`: + + - :code:`True` + - :code:`"true"` / :code:`"t"` + - :code:`"yes"` / :code:`"y"` + - :code:`"on"` + - :code:`"1"` + - :code:`1` + + Values mapping to :code:`False`: + + - :code:`False` + - :code:`"false"` / :code:`"f"` + - :code:`"no"` / :code:`"n"` + - :code:`"off"` + - :code:`"0"` + - :code:`0` + + Raise :exc:`ValueError` for any other value. + """ + if isinstance(val, str): + val = val.lower() + truthy = {True, "true", "t", "yes", "y", "on", "1", 1} + falsy = {False, "false", "f", "no", "n", "off", "0", 0} + try: + if val in truthy: + return True + if val in falsy: + return False + except TypeError: + # Raised when "val" is not hashable (e.g., lists) + pass + raise ValueError(f"Cannot convert value to bool: {val}") + + +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()` + + The ``Z`` suffix is also supported and will be replaced with ``+00:00``. + + :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. + """ + if not isinstance(val, (datetime, str)): + raise TypeError( + f'Invalid type "{type(val).__name__}"; expected "datetime" or ' + f'"str".' + ) + if isinstance(val, str): + if val[-1] == "Z": + val = val.replace("Z", "+00:00") + return datetime.fromisoformat(val) + return val + + +def to_enum(cls): + """ + Return a converter that creates an instance of the :class:`.Enum` *cls*. + + If the to be converted value is not already an enum, the converter will + first try to create one by name (``MyEnum[val]``) and, if that fails, by + value (``MyEnum(val)``). + + """ + + def convert(val): + if isinstance(val, cls): + return val + try: + return cls[val] + except KeyError: + return cls(val) + + return convert + + +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/converters.pyi b/src/attr/converters.pyi index d180e4646..2982ad5d4 100644 --- a/src/attr/converters.pyi +++ b/src/attr/converters.pyi @@ -1,7 +1,23 @@ -from typing import Callable, Optional, TypeVar, overload +from datetime import datetime +from enum import Enum +from typing import ( + Any, + Callable, + Collection, + Iterable, + List, + Mapping, + Optional, + Tuple, + Type, + TypeVar, + Union, + overload, +) from . import _ConverterType + _T = TypeVar("_T") def pipe(*validators: _ConverterType) -> _ConverterType: ... @@ -10,3 +26,53 @@ def optional(converter: _ConverterType) -> _ConverterType: ... def default_if_none(default: _T) -> _ConverterType: ... @overload def default_if_none(*, factory: Callable[[], _T]) -> _ConverterType: ... + +def to_attrs(cls: Type[_T]) -> Callable[[Union[_T, dict]], _T]: ... + +def to_dt(val: Union[datetime, str]) -> datetime: ... + +def to_bool(val: Union[bool, int, str]) -> bool: ... + +_E = TypeVar("_E", bound=Enum) +def to_enum(cls: Type[_E]) -> Callable[[Union[_E, Any]], _E]: ... + +# This is currently not expressible: +# cls: Type[_ITER] +# converter: Callable[[Any], _T] +# return: _ITER[_T] +_ITER = TypeVar("_ITER", bound=Iterable) +def to_iterable( + cls: Type[_ITER], converter: Callable[[Any], _T] +) -> Callable[[Iterable], _ITER]: ... + +# This is currently not expressible: +# cls: Type[_TUPEL] +# converters: List[Callable[[Any], T1], Callable[[Any], T2], ...] +# return: Callable[[Collection], _TUPEL[T1, T2, ...] +_TUPLE = TypeVar("_TUPLE", bound=Tuple) + +def to_tuple( + cls: Type[_TUPLE], converters: List[Callable[[Any], _T]] +) -> _TUPLE: ... + +_MAP = TypeVar("_MAP", bound=Mapping) +_KT = TypeVar("_KT") +_VT = TypeVar("_VT") + +# This is currently not expressible: +# cls: Type[_MAP] +# key_converter: Callable[[Any], _KT], +# val_converter: Callable[[Any], _VT], +# return: _MAP[_KT, _VT] +def to_mapping( + cls: Type[_MAP], + key_converter: Callable[[Any], _KT], + val_converter: Callable[[Any], _VT], +) -> _MAP: ... + +# This is currently not expressible: +# converter: List[Callable[[Any], _T1], Callable[[Any], _T2], ...] +# return: Callable[[Any], Union[T1, T2, ...]] +def to_union( + converters: List[Callable[[Any], Any]] +) -> Callable[[Any], Any]: ... diff --git a/tests/test_converters.py b/tests/test_converters.py index f86e07e29..b2b41d6e5 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,18 @@ 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_bool, + to_dt, + to_iterable, + to_mapping, + to_tuple, + to_union, +) class TestOptional(object): @@ -136,3 +150,270 @@ class C(object): c = C() assert True is c.a1 is c.a2 + + +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 TestToBool: + """Tests for `to_bool`.""" + + @pytest.mark.parametrize( + "val, expected", + [ + (True, True), + ("True", True), + ("TRUE", True), + ("true", True), + ("t", True), + ("yes", True), + ("Y", True), + ("on", True), + ("1", True), + (1, True), + (False, False), + ("False", False), + ("false", False), + ("fAlse", False), + ("NO", False), + ("n", False), + ("off", False), + ("0", False), + (0, False), + ], + ) + def test_to_bool(self, val, expected): + """ + Only a limited set of values can be converted to a bool. + """ + assert to_bool(val) is expected + + @pytest.mark.parametrize("val", ["", [], "spam", 2, -1]) + def test_to_bool_error(self, val): + """ + In contrast to ``bool()``, `to_bool` does no take Pythons default + truthyness into account. + + Everything that is not in the sets above raises an error. + """ + pytest.raises(ValueError, to_bool, val) + + +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 + + def test_to_union_error(self): + """ + A ValueError is raised when "to_union()" cannot convert a value. + """ + converter = to_union([int]) + with pytest.raises(ValueError): + converter("spam")