diff --git a/HISTORY.rst b/HISTORY.rst index 09377d9c..b38fd027 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -8,6 +8,8 @@ History * ``cattrs.Converter`` has been renamed to ``cattrs.BaseConverter``, and ``cattrs.GenConverter`` to ``cattrs.Converter``. The ``GenConverter`` symbol is still available for backwards compatibility, but is deprecated. If you were depending on functionality specific to the old ``Converter``, change your import to ``from cattrs import BaseConverter``. +* ``cattrs.Converter`` (what was previously the ``GenConverter``) now supports NewTypes. + (`#255 `_, `#94 `_) 22.1.0 (2022-04-03) ------------------- diff --git a/poetry.lock b/poetry.lock index 53f267f3..19624a9b 100644 --- a/poetry.lock +++ b/poetry.lock @@ -41,7 +41,7 @@ pytz = ">=2015.7" [[package]] name = "beautifulsoup4" -version = "4.11.0" +version = "4.11.1" description = "Screen-scraping library" category = "dev" optional = false @@ -145,7 +145,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [[package]] name = "exceptiongroup" -version = "1.0.0rc2" +version = "1.0.0rc3" description = "Backport of PEP 654 (exception groups)" category = "main" optional = false @@ -199,7 +199,7 @@ doc = ["myst-parser", "sphinx-copybutton", "sphinx-design", "sphinx-inline-tabs" [[package]] name = "hypothesis" -version = "6.41.0" +version = "6.43.1" description = "A library for property-based testing" category = "dev" optional = false @@ -463,14 +463,14 @@ zstd = ["zstandard"] [[package]] name = "pyparsing" -version = "3.0.7" -description = "Python parsing module" +version = "3.0.8" +description = "pyparsing module - Classes and methods to define and execute parsing grammars" category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=3.6.8" [package.extras] -diagrams = ["jinja2", "railroad-diagrams"] +diagrams = ["railroad-diagrams", "jinja2"] [[package]] name = "pytest" @@ -725,7 +725,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [[package]] name = "tox" -version = "3.24.5" +version = "3.25.0" description = "tox is a generic virtualenv management and test command line tool" category = "dev" optional = false @@ -785,7 +785,7 @@ socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] [[package]] name = "virtualenv" -version = "20.14.0" +version = "20.14.1" description = "Virtual Python Environment builder" category = "dev" optional = false @@ -817,7 +817,7 @@ testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest- [metadata] lock-version = "1.1" python-versions = "^3.7" -content-hash = "e5c7d1318501bd69513955937930d1819068aaaf4bae76bfa23b863e881d5ba4" +content-hash = "4815bf0b93a5fa68719086489ac0f07d4e6227f18566cd341e81ddf6a67001af" [metadata.files] alabaster = [ @@ -837,8 +837,8 @@ babel = [ {file = "Babel-2.9.1.tar.gz", hash = "sha256:bc0c176f9f6a994582230df350aa6e05ba2ebe4b3ac317eab29d9be5d2768da0"}, ] beautifulsoup4 = [ - {file = "beautifulsoup4-4.11.0-py3-none-any.whl", hash = "sha256:577b9e1c36d2ada780d807c5622e889d43172466658c2eb239e97296965cdddb"}, - {file = "beautifulsoup4-4.11.0.tar.gz", hash = "sha256:ac98f868e1cb8eb9932a61be75b4f7018a118a490e7fdb424a74a982430cfcbd"}, + {file = "beautifulsoup4-4.11.1-py3-none-any.whl", hash = "sha256:58d5c3d29f5a36ffeb94f02f0d786cd53014cf9b3b3951d42e0080d8a9498d30"}, + {file = "beautifulsoup4-4.11.1.tar.gz", hash = "sha256:ad9aa55b65ef2808eb405f46cf74df7fcb7044d5cbc26487f96eb2ef2e436693"}, ] black = [ {file = "black-22.3.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2497f9c2386572e28921fa8bec7be3e51de6801f7459dffd6e62492531c47e09"}, @@ -933,8 +933,8 @@ docutils = [ {file = "docutils-0.17.1.tar.gz", hash = "sha256:686577d2e4c32380bb50cbb22f575ed742d58168cee37e99117a854bcd88f125"}, ] exceptiongroup = [ - {file = "exceptiongroup-1.0.0rc2-py3-none-any.whl", hash = "sha256:83e465152bd0bc2bc40d9b75686854260f86946bb947c652b5cafc31cdff70e7"}, - {file = "exceptiongroup-1.0.0rc2.tar.gz", hash = "sha256:4d254b05231bed1d43079bdcfe0f1d66c0ab4783e6777a329355f9b78de3ad83"}, + {file = "exceptiongroup-1.0.0rc3-py3-none-any.whl", hash = "sha256:f734ede30b0d3f21f91d17bb83216e3ee780df0738a293e15d08925279623782"}, + {file = "exceptiongroup-1.0.0rc3.tar.gz", hash = "sha256:ed5799dc1260d2421564c21567f7ec84cffb39f149cf0e5e7e0aa6548e1f7288"}, ] filelock = [ {file = "filelock-3.6.0-py3-none-any.whl", hash = "sha256:f8314284bfffbdcfa0ff3d7992b023d4c628ced6feb957351d4c48d059f56bc0"}, @@ -949,8 +949,8 @@ furo = [ {file = "furo-2021.11.23.tar.gz", hash = "sha256:54cecac5f3b688b5c7370d72ecdf1cd91a6c53f0f42751f4a719184b562cde70"}, ] hypothesis = [ - {file = "hypothesis-6.41.0-py3-none-any.whl", hash = "sha256:ca931c5a6414f3f9636fdaf978a216ee9b5c4a6b4415adf628e9d5e5003dcd99"}, - {file = "hypothesis-6.41.0.tar.gz", hash = "sha256:de48abb676fc76e4397cd002926e5747cef518570d132221244d27e1075c0bec"}, + {file = "hypothesis-6.43.1-py3-none-any.whl", hash = "sha256:a4e30f0c787e23ac3081eae5ce327b92b5125010f000403d4b26b74061d8029b"}, + {file = "hypothesis-6.43.1.tar.gz", hash = "sha256:b0ef5f82bb925e540f5d26e7e06280fa88113a763cc3e6832c93301766fa6900"}, ] idna = [ {file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"}, @@ -1281,8 +1281,8 @@ pymongo = [ {file = "pymongo-3.12.3.tar.gz", hash = "sha256:0a89cadc0062a5e53664dde043f6c097172b8c1c5f0094490095282ff9995a5f"}, ] pyparsing = [ - {file = "pyparsing-3.0.7-py3-none-any.whl", hash = "sha256:a6c06a88f252e6c322f65faf8f418b16213b51bdfaece0524c1c1bc30c63c484"}, - {file = "pyparsing-3.0.7.tar.gz", hash = "sha256:18ee9022775d270c55187733956460083db60b37d0d0fb357445f3094eed3eea"}, + {file = "pyparsing-3.0.8-py3-none-any.whl", hash = "sha256:ef7b523f6356f763771559412c0d7134753f037822dad1b16945b7b846f7ad06"}, + {file = "pyparsing-3.0.8.tar.gz", hash = "sha256:7bf433498c016c4314268d95df76c81b842a4cb2b276fa3312cfb1e1d85f6954"}, ] pytest = [ {file = "pytest-7.1.1-py3-none-any.whl", hash = "sha256:92f723789a8fdd7180b6b06483874feca4c48a5c76968e03bb3e7f806a1869ea"}, @@ -1396,8 +1396,8 @@ tomlkit = [ {file = "tomlkit-0.7.2.tar.gz", hash = "sha256:d7a454f319a7e9bd2e249f239168729327e4dd2d27b17dc68be264ad1ce36754"}, ] tox = [ - {file = "tox-3.24.5-py2.py3-none-any.whl", hash = "sha256:be3362472a33094bce26727f5f771ca0facf6dafa217f65875314e9a6600c95c"}, - {file = "tox-3.24.5.tar.gz", hash = "sha256:67e0e32c90e278251fea45b696d0fef3879089ccbe979b0c556d35d5a70e2993"}, + {file = "tox-3.25.0-py2.py3-none-any.whl", hash = "sha256:0805727eb4d6b049de304977dfc9ce315a1938e6619c3ab9f38682bb04662a5a"}, + {file = "tox-3.25.0.tar.gz", hash = "sha256:37888f3092aa4e9f835fc8cc6dadbaaa0782651c41ef359e3a5743fcb0308160"}, ] typed-ast = [ {file = "typed_ast-1.5.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:183b183b7771a508395d2cbffd6db67d6ad52958a5fdc99f450d954003900266"}, @@ -1486,8 +1486,8 @@ urllib3 = [ {file = "urllib3-1.26.9.tar.gz", hash = "sha256:aabaf16477806a5e1dd19aa41f8c2b7950dd3c746362d7e3223dbe6de6ac448e"}, ] virtualenv = [ - {file = "virtualenv-20.14.0-py2.py3-none-any.whl", hash = "sha256:1e8588f35e8b42c6ec6841a13c5e88239de1e6e4e4cedfd3916b306dc826ec66"}, - {file = "virtualenv-20.14.0.tar.gz", hash = "sha256:8e5b402037287126e81ccde9432b95a8be5b19d36584f64957060a3488c11ca8"}, + {file = "virtualenv-20.14.1-py2.py3-none-any.whl", hash = "sha256:e617f16e25b42eb4f6e74096b9c9e37713cf10bf30168fb4a739f3fa8f898a3a"}, + {file = "virtualenv-20.14.1.tar.gz", hash = "sha256:ef589a79795589aada0c1c5b319486797c03b67ac3984c48c669c0e4f50df3a5"}, ] zipp = [ {file = "zipp-3.8.0-py3-none-any.whl", hash = "sha256:c4f6e5bbf48e74f7a38e7cc5b0480ff42b0ae5178957d564d18932525d5cf099"}, diff --git a/src/cattrs/_compat.py b/src/cattrs/_compat.py index 1d64eed9..6a21bd49 100644 --- a/src/cattrs/_compat.py +++ b/src/cattrs/_compat.py @@ -7,6 +7,7 @@ from typing import MutableMapping as TypingMutableMapping from typing import MutableSequence as TypingMutableSequence from typing import MutableSet as TypingMutableSet +from typing import NewType, Optional from typing import Sequence as TypingSequence from typing import Set as TypingSet from typing import Tuple, get_type_hints @@ -133,6 +134,16 @@ def is_union_type(obj): obj is Union or isinstance(obj, _GenericAlias) and obj.__origin__ is Union ) + def get_newtype_base(typ: Any) -> Optional[type]: + supertype = getattr(typ, "__supertype__", None) + if ( + supertype is not None + and getattr(typ, "__qualname__", "") == "NewType..new_type" + and typ.__module__ in ("typing", "typing_extensions") + ): + return supertype + return None + def is_sequence(type: Any) -> bool: return type in (List, list, Tuple, tuple) or ( type.__class__ is _GenericAlias @@ -258,6 +269,11 @@ def is_union_type(obj): or isinstance(obj, UnionType) ) + def get_newtype_base(typ: Any) -> Optional[type]: + if typ is NewType or isinstance(typ, NewType): + return typ.__supertype__ + return None + else: def is_union_type(obj): @@ -267,6 +283,16 @@ def is_union_type(obj): and obj.__origin__ is Union ) + def get_newtype_base(typ: Any) -> Optional[type]: + supertype = getattr(typ, "__supertype__", None) + if ( + supertype is not None + and getattr(typ, "__qualname__", "") == "NewType..new_type" + and typ.__module__ in ("typing", "typing_extensions") + ): + return supertype + return None + def is_sequence(type: Any) -> bool: origin = getattr(type, "__origin__", None) return ( diff --git a/src/cattrs/converters.py b/src/cattrs/converters.py index 60ee1be5..b6c3a0ff 100644 --- a/src/cattrs/converters.py +++ b/src/cattrs/converters.py @@ -20,6 +20,7 @@ Sequence, Set, fields, + get_newtype_base, get_origin, has, has_with_generic, @@ -150,6 +151,7 @@ def __init__( [ (lambda cl: cl is Any or cl is Optional or cl is None, lambda v, _: v), (is_generic_attrs, self._gen_structure_generic, True), + (lambda t: get_newtype_base(t) is not None, self._structure_newtype), (is_literal, self._structure_simple_literal), (is_literal_containing_enums, self._structure_enum_literal), (is_sequence, self._structure_list), @@ -393,6 +395,10 @@ def _structure_enum_literal(val, type): except KeyError: raise Exception(f"{val} not in literal {type}") from None + def _structure_newtype(self, val, type): + base = get_newtype_base(type) + return self._structure_func.dispatch(base)(val, base) + # Attrs classes. def structure_attrs_fromtuple(self, obj: Tuple[Any, ...], cl: Type[T]) -> T: @@ -715,9 +721,20 @@ def __init__( is_frozenset, lambda cl: self.gen_unstructure_iterable(cl, unstructure_to=frozenset), ) + self.register_unstructure_hook_factory( + lambda t: get_newtype_base(t) is not None, + lambda t: self._unstructure_func.dispatch(get_newtype_base(t)), + ) self.register_structure_hook_factory(is_annotated, self.gen_structure_annotated) self.register_structure_hook_factory(is_mapping, self.gen_structure_mapping) self.register_structure_hook_factory(is_counter, self.gen_structure_counter) + self.register_structure_hook_factory( + lambda t: get_newtype_base(t) is not None, self.get_structure_newtype + ) + + def get_structure_newtype(self, type: Type[T]) -> Callable[[Any, Any], T]: + base = get_newtype_base(type) + return self._structure_func.dispatch(base) def gen_unstructure_annotated(self, type): origin = type.__origin__ diff --git a/tests/__init__.py b/tests/__init__.py index bc4faf4d..20e6dbd0 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,26 +1,6 @@ -import keyword import os -import string -from collections import OrderedDict -from enum import Enum -from typing import ( - Any, - Dict, - List, - Mapping, - MutableMapping, - MutableSequence, - MutableSet, - Sequence, - Set, - Tuple, -) -import attr -from attr import NOTHING, make_class -from attr._make import _CountingAttr from hypothesis import HealthCheck, settings -from hypothesis import strategies as st settings.register_profile( "CI", settings(suppress_health_check=[HealthCheck.too_slow]), deadline=None @@ -28,401 +8,3 @@ if "CI" in os.environ: settings.load_profile("CI") - -PosArg = Any -PosArgs = Tuple[PosArg] -KwArgs = Dict[str, Any] - -primitive_strategies = st.sampled_from( - [ - (st.integers(), int), - (st.floats(allow_nan=False), float), - (st.text(), str), - (st.binary(), bytes), - ] -) - - -@st.composite -def enums_of_primitives(draw): - """Generate enum classes with primitive values.""" - names = draw( - st.sets(st.text(min_size=1).filter(lambda s: not s.endswith("_")), min_size=1) - ) - n = len(names) - vals = draw( - st.one_of( - st.sets( - st.one_of( - st.integers(), st.floats(allow_nan=False), st.text(min_size=1) - ), - min_size=n, - max_size=n, - ) - ) - ) - return Enum("HypEnum", list(zip(names, vals))) - - -list_types = st.sampled_from([List, Sequence, MutableSequence]) -set_types = st.sampled_from([Set, MutableSet]) - - -@st.composite -def lists_of_primitives(draw): - """Generate a strategy that yields tuples of list of primitives and types. - - For example, a sample value might be ([1,2], List[int]). - """ - prim_strat, t = draw(primitive_strategies) - list_t = draw(list_types.map(lambda list_t: list_t[t]) | list_types) - return draw(st.lists(prim_strat)), list_t - - -@st.composite -def mut_sets_of_primitives(draw): - """A strategy that generates mutable sets of primitives.""" - prim_strat, t = draw(primitive_strategies) - set_t = draw(set_types.map(lambda set_t: set_t[t]) | set_types) - return draw(st.sets(prim_strat)), set_t - - -@st.composite -def frozen_sets_of_primitives(draw): - """A strategy that generates frozen sets of primitives.""" - prim_strat, t = draw(primitive_strategies) - set_t = draw(st.just(Set) | st.just(Set[t])) - return frozenset(draw(st.sets(prim_strat))), set_t - - -h_tuple_types = st.sampled_from([Tuple, Sequence]) -h_tuples_of_primitives = primitive_strategies.flatmap( - lambda e: st.tuples( - st.lists(e[0]), - st.one_of(st.sampled_from([Tuple[e[1], ...], Sequence[e[1]]]), h_tuple_types), - ) -).map(lambda e: (tuple(e[0]), e[1])) - -dict_types = st.sampled_from([Dict, MutableMapping, Mapping]) - -seqs_of_primitives = st.one_of(lists_of_primitives(), h_tuples_of_primitives) - -sets_of_primitives = st.one_of(mut_sets_of_primitives(), frozen_sets_of_primitives()) - - -def create_generic_dict_type(type1, type2): - """Create a strategy for generating parameterized dict types.""" - return st.one_of( - dict_types, - dict_types.map(lambda t: t[type1, type2]), - dict_types.map(lambda t: t[Any, type2]), - dict_types.map(lambda t: t[type1, Any]), - ) - - -def create_dict_and_type(tuple_of_strats): - """Map two primitive strategies into a strategy for dict and type.""" - (prim_strat_1, type_1), (prim_strat_2, type_2) = tuple_of_strats - - return st.tuples( - st.dictionaries(prim_strat_1, prim_strat_2), - create_generic_dict_type(type_1, type_2), - ) - - -dicts_of_primitives = st.tuples(primitive_strategies, primitive_strategies).flatmap( - create_dict_and_type -) - - -def gen_attr_names(): - """ - Generate names for attributes, 'a'...'z', then 'aa'...'zz'. - ~702 different attribute names should be enough in practice. - Some short strings (such as 'as') are keywords, so we skip them. - - Every second attribute name is private (starts with an underscore). - """ - lc = string.ascii_lowercase - has_underscore = False - for c in lc: - yield c if not has_underscore else "_" + c - has_underscore = not has_underscore - for outer in lc: - for inner in lc: - res = outer + inner - if keyword.iskeyword(res): - continue - yield outer + inner - - -def _create_hyp_class( - attrs_and_strategy: List[Tuple[_CountingAttr, st.SearchStrategy[PosArgs]]], - frozen=None, -): - """ - A helper function for Hypothesis to generate attrs classes. - - The result is a tuple: an attrs class, a tuple of values to - instantiate it, and a kwargs dict for kw-only attributes. - """ - - def key(t): - return (t[0].default is not NOTHING, t[0].kw_only) - - attrs_and_strat = sorted(attrs_and_strategy, key=key) - attrs = [a[0] for a in attrs_and_strat] - for i, a in enumerate(attrs): - a.counter = i - vals = tuple((a[1]) for a in attrs_and_strat if not a[0].kw_only) - kwargs = {} - for attr_name, attr_and_strat in zip(gen_attr_names(), attrs_and_strat): - if attr_and_strat[0].kw_only: - if attr_name.startswith("_"): - attr_name = attr_name[1:] - kwargs[attr_name] = attr_and_strat[1] - return st.tuples( - st.builds( - lambda f: make_class( - "HypClass", OrderedDict(zip(gen_attr_names(), attrs)), frozen=f - ), - st.booleans() if frozen is None else st.just(frozen), - ), - st.tuples(*vals), - st.fixed_dictionaries(kwargs), - ) - - -def just_class(tup): - nested_cl = tup[1][0] - default = attr.Factory(nested_cl) - combined_attrs = list(tup[0]) - combined_attrs.append((attr.ib(default=default), st.just(nested_cl()))) - return _create_hyp_class(combined_attrs) - - -def just_class_with_type(tup): - nested_cl = tup[1][0] - default = attr.Factory(nested_cl) - combined_attrs = list(tup[0]) - combined_attrs.append( - (attr.ib(default=default, type=nested_cl), st.just(nested_cl())) - ) - return _create_hyp_class(combined_attrs) - - -def just_class_with_type_takes_self(tup): - nested_cl = tup[1][0] - default = attr.Factory(lambda _: nested_cl(), takes_self=True) - combined_attrs = list(tup[0]) - combined_attrs.append( - (attr.ib(default=default, type=nested_cl), st.just(nested_cl())) - ) - return _create_hyp_class(combined_attrs) - - -def just_frozen_class_with_type(tup): - nested_cl = tup[1][0] - combined_attrs = list(tup[0]) - combined_attrs.append( - (attr.ib(default=nested_cl(), type=nested_cl), st.just(nested_cl())) - ) - return _create_hyp_class(combined_attrs) - - -def list_of_class(tup): - nested_cl = tup[1][0] - default = attr.Factory(lambda: [nested_cl()]) - combined_attrs = list(tup[0]) - combined_attrs.append((attr.ib(default=default), st.just([nested_cl()]))) - return _create_hyp_class(combined_attrs) - - -def list_of_class_with_type(tup): - nested_cl = tup[1][0] - default = attr.Factory(lambda: [nested_cl()]) - combined_attrs = list(tup[0]) - combined_attrs.append( - (attr.ib(default=default, type=List[nested_cl]), st.just([nested_cl()])) - ) - return _create_hyp_class(combined_attrs) - - -def dict_of_class(tup): - nested_cl = tup[1][0] - default = attr.Factory(lambda: {"cls": nested_cl()}) - combined_attrs = list(tup[0]) - combined_attrs.append((attr.ib(default=default), st.just({"cls": nested_cl()}))) - return _create_hyp_class(combined_attrs) - - -def _create_hyp_nested_strategy(simple_class_strategy): - """ - Create a recursive attrs class. - Given a strategy for building (simpler) classes, create and return - a strategy for building classes that have as an attribute: - * just the simpler class - * a list of simpler classes - * a dict mapping the string "cls" to a simpler class. - """ - # A strategy producing tuples of the form ([list of attributes], ). - attrs_and_classes = st.tuples(lists_of_attrs(defaults=True), simple_class_strategy) - - return ( - attrs_and_classes.flatmap(just_class) - | attrs_and_classes.flatmap(just_class_with_type) - | attrs_and_classes.flatmap(list_of_class) - | attrs_and_classes.flatmap(list_of_class_with_type) - | attrs_and_classes.flatmap(dict_of_class) - | attrs_and_classes.flatmap(just_frozen_class_with_type) - | attrs_and_classes.flatmap(just_class_with_type_takes_self) - ) - - -@st.composite -def bare_attrs(draw, defaults=None, kw_only=None): - """ - Generate a tuple of an attribute and a strategy that yields values - appropriate for that attribute. - """ - default = NOTHING - if defaults is True or (defaults is None and draw(st.booleans())): - default = None - return ( - attr.ib( - default=default, kw_only=draw(st.booleans()) if kw_only is None else kw_only - ), - st.just(None), - ) - - -@st.composite -def int_attrs(draw, defaults=None, kw_only=None): - """ - Generate a tuple of an attribute and a strategy that yields ints for that - attribute. - """ - default = NOTHING - if defaults is True or (defaults is None and draw(st.booleans())): - default = draw(st.integers()) - return ( - attr.ib( - default=default, kw_only=draw(st.booleans()) if kw_only is None else kw_only - ), - st.integers(), - ) - - -@st.composite -def str_attrs(draw, defaults=None, type_annotations=None, kw_only=None): - """ - Generate a tuple of an attribute and a strategy that yields strs for that - attribute. - """ - default = NOTHING - if defaults is True or (defaults is None and draw(st.booleans())): - default = draw(st.text()) - if (type_annotations is None and draw(st.booleans())) or type_annotations: - type = str - else: - type = None - return ( - attr.ib( - default=default, - type=type, - kw_only=draw(st.booleans()) if kw_only is None else kw_only, - ), - st.text(), - ) - - -@st.composite -def float_attrs(draw, defaults=None, kw_only=None): - """ - Generate a tuple of an attribute and a strategy that yields floats for that - attribute. - """ - default = NOTHING - if defaults is True or (defaults is None and draw(st.booleans())): - default = draw(st.floats()) - return ( - attr.ib( - default=default, kw_only=draw(st.booleans()) if kw_only is None else kw_only - ), - st.floats(allow_nan=False), - ) - - -@st.composite -def dict_attrs(draw, defaults=None, kw_only=None): - """ - Generate a tuple of an attribute and a strategy that yields dictionaries - for that attribute. The dictionaries map strings to integers. - """ - default = NOTHING - val_strat = st.dictionaries(keys=st.text(), values=st.integers()) - if defaults is True or (defaults is None and draw(st.booleans())): - default_val = draw(val_strat) - default = attr.Factory(lambda: default_val) - return ( - attr.ib( - default=default, kw_only=draw(st.booleans()) if kw_only is None else kw_only - ), - val_strat, - ) - - -@st.composite -def optional_attrs(draw, defaults=None, kw_only=None): - """ - Generate a tuple of an attribute and a strategy that yields values - for that attribute. The strategy generates optional integers. - """ - default = NOTHING - val_strat = st.integers() | st.none() - if defaults is True or (defaults is None and draw(st.booleans())): - default = draw(val_strat) - - return ( - attr.ib( - default=default, kw_only=draw(st.booleans()) if kw_only is None else kw_only - ), - val_strat, - ) - - -def simple_attrs(defaults=None, kw_only=None): - return ( - bare_attrs(defaults, kw_only=kw_only) - | int_attrs(defaults, kw_only=kw_only) - | str_attrs(defaults, kw_only=kw_only) - | float_attrs(defaults, kw_only=kw_only) - | dict_attrs(defaults, kw_only=kw_only) - | optional_attrs(defaults, kw_only=kw_only) - ) - - -def lists_of_attrs(defaults=None, min_size=0, kw_only=None): - # Python functions support up to 255 arguments. - return st.lists( - simple_attrs(defaults, kw_only), min_size=min_size, max_size=10 - ).map(lambda l: sorted(l, key=lambda t: t[0]._default is not NOTHING)) - - -def simple_classes(defaults=None, min_attrs=0, frozen=None, kw_only=None): - """ - Return a strategy that yields tuples of simple classes and values to - instantiate them. - """ - return lists_of_attrs(defaults, min_size=min_attrs, kw_only=kw_only).flatmap( - lambda attrs_and_strategy: _create_hyp_class(attrs_and_strategy, frozen=frozen) - ) - - -# 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(defaults=True), _create_hyp_nested_strategy -) diff --git a/tests/conftest.py b/tests/conftest.py index c513accf..69154812 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,5 @@ +from os import environ + import pytest from hypothesis import HealthCheck, settings @@ -9,8 +11,14 @@ def converter(request): return request.param() +@pytest.fixture(params=(BaseConverter, Converter), scope="session") +def converter_cls(request): + return request.param + + settings.register_profile( "tests", suppress_health_check=(HealthCheck.too_slow,), deadline=None ) +settings.register_profile("fast", settings.get_profile("tests"), max_examples=10) -settings.load_profile("tests") +settings.load_profile("fast" if "FAST" in environ else "tests") diff --git a/tests/metadata/test_roundtrips.py b/tests/test_baseconverter.py similarity index 89% rename from tests/metadata/test_roundtrips.py rename to tests/test_baseconverter.py index 08759877..83c90f7a 100644 --- a/tests/metadata/test_roundtrips.py +++ b/tests/test_baseconverter.py @@ -10,12 +10,12 @@ from cattrs import BaseConverter, UnstructureStrategy from cattrs._compat import is_py310_plus -from . import nested_typed_classes, simple_typed_attrs, simple_typed_classes +from .typed import nested_typed_classes, simple_typed_attrs, simple_typed_classes unstructure_strats = one_of(just(s) for s in UnstructureStrategy) -@given(simple_typed_classes(), unstructure_strats) +@given(simple_typed_classes(newtypes=False), unstructure_strats) def test_simple_roundtrip(cls_and_vals, strat): """ Simple classes with metadata can be unstructured and restructured. @@ -27,7 +27,7 @@ def test_simple_roundtrip(cls_and_vals, strat): assert inst == converter.structure(converter.unstructure(inst), cl) -@given(simple_typed_attrs(defaults=True), unstructure_strats) +@given(simple_typed_attrs(defaults=True, newtypes=False), unstructure_strats) def test_simple_roundtrip_defaults(attr_and_strat, strat): """ Simple classes with metadata can be unstructured and restructured. @@ -43,7 +43,7 @@ def test_simple_roundtrip_defaults(attr_and_strat, strat): assert inst == converter.structure(converter.unstructure(inst), cl) -@given(nested_typed_classes()) +@given(nested_typed_classes(newtypes=False)) def test_nested_roundtrip(cls_and_vals): """ Nested classes with metadata can be unstructured and restructured. @@ -55,7 +55,7 @@ def test_nested_roundtrip(cls_and_vals): assert inst == converter.structure(converter.unstructure(inst), cl) -@given(nested_typed_classes(kw_only=False)) +@given(nested_typed_classes(kw_only=False, newtypes=False)) def test_nested_roundtrip_tuple(cls_and_vals): """ Nested classes with metadata can be unstructured and restructured. @@ -70,8 +70,8 @@ def test_nested_roundtrip_tuple(cls_and_vals): @settings(suppress_health_check=[HealthCheck.filter_too_much, HealthCheck.too_slow]) @given( - simple_typed_classes(defaults=False), - simple_typed_classes(defaults=False), + simple_typed_classes(defaults=False, newtypes=False), + simple_typed_classes(defaults=False, newtypes=False), unstructure_strats, ) def test_union_field_roundtrip(cl_and_vals_a, cl_and_vals_b, strat): @@ -113,8 +113,8 @@ def handler(obj, _): @pytest.mark.skipif(not is_py310_plus, reason="3.10+ union syntax") @settings(suppress_health_check=[HealthCheck.filter_too_much, HealthCheck.too_slow]) @given( - simple_typed_classes(defaults=False), - simple_typed_classes(defaults=False), + simple_typed_classes(defaults=False, newtypes=False), + simple_typed_classes(defaults=False, newtypes=False), unstructure_strats, ) def test_310_union_field_roundtrip(cl_and_vals_a, cl_and_vals_b, strat): @@ -153,7 +153,7 @@ def handler(obj, _): assert inst == converter.structure(converter.unstructure(inst), C) -@given(simple_typed_classes(defaults=False)) +@given(simple_typed_classes(defaults=False, newtypes=False)) def test_optional_field_roundtrip(cl_and_vals): """ Classes with optional fields can be unstructured and structured. @@ -175,7 +175,7 @@ class C(object): @pytest.mark.skipif(not is_py310_plus, reason="3.10+ union syntax") -@given(simple_typed_classes(defaults=False)) +@given(simple_typed_classes(defaults=False, newtypes=False)) def test_310_optional_field_roundtrip(cl_and_vals): """ Classes with optional fields can be unstructured and structured. diff --git a/tests/metadata/test_genconverter.py b/tests/test_converter.py similarity index 95% rename from tests/metadata/test_genconverter.py rename to tests/test_converter.py index 2d62f80b..fc441017 100644 --- a/tests/metadata/test_genconverter.py +++ b/tests/test_converter.py @@ -21,7 +21,7 @@ from cattrs.errors import ClassValidationError, ForbiddenExtraKeysError from cattrs.gen import make_dict_structure_fn, override -from . import ( +from .typed import ( nested_typed_classes, simple_typed_attrs, simple_typed_classes, @@ -44,14 +44,17 @@ def test_simple_roundtrip(cls_and_vals, detailed_validation): assert inst == converter.structure(unstructured, cl) -@given(simple_typed_classes(kw_only=False) | simple_typed_dataclasses(), booleans()) -def test_simple_roundtrip_tuple(cls_and_vals, detailed_validation): +@given( + simple_typed_classes(kw_only=False, newtypes=False) + | simple_typed_dataclasses(newtypes=False), + booleans(), +) +def test_simple_roundtrip_tuple(cls_and_vals, dv: bool): """ Simple classes with metadata can be unstructured and restructured. """ converter = Converter( - unstruct_strat=UnstructureStrategy.AS_TUPLE, - detailed_validation=detailed_validation, + unstruct_strat=UnstructureStrategy.AS_TUPLE, detailed_validation=dv ) cl, vals, _ = cls_and_vals inst = cl(*vals) @@ -75,7 +78,7 @@ def test_simple_roundtrip_defaults(attr_and_vals): assert inst == converter.structure(converter.unstructure(inst), cl) -@given(simple_typed_attrs(defaults=True, kw_only=False)) +@given(simple_typed_attrs(defaults=True, kw_only=False, newtypes=False)) def test_simple_roundtrip_defaults_tuple(attr_and_vals): """ Simple classes with metadata can be unstructured and restructured. @@ -90,7 +93,10 @@ def test_simple_roundtrip_defaults_tuple(attr_and_vals): assert inst == converter.structure(converter.unstructure(inst), cl) -@given(simple_typed_classes() | simple_typed_dataclasses(), unstructure_strats) +@given( + simple_typed_classes(newtypes=False) | simple_typed_dataclasses(newtypes=False), + unstructure_strats, +) def test_simple_roundtrip_with_extra_keys_forbidden(cls_and_vals, strat): """ Simple classes can be unstructured and restructured with forbid_extra_keys=True. @@ -200,8 +206,11 @@ def test_nested_roundtrip(cls_and_vals, omit_if_default): assert inst == converter.structure(unstructured, cl) -@given(nested_typed_classes(defaults=True, min_attrs=1, kw_only=False), booleans()) -def test_nested_roundtrip_tuple(cls_and_vals, omit_if_default): +@given( + nested_typed_classes(defaults=True, min_attrs=1, kw_only=False, newtypes=False), + booleans(), +) +def test_nested_roundtrip_tuple(cls_and_vals, omit_if_default: bool): """ Nested classes with metadata can be unstructured and restructured. """ @@ -217,8 +226,8 @@ def test_nested_roundtrip_tuple(cls_and_vals, omit_if_default): @settings(suppress_health_check=[HealthCheck.filter_too_much, HealthCheck.too_slow]) @given( - simple_typed_classes(defaults=False), - simple_typed_classes(defaults=False), + simple_typed_classes(defaults=False, newtypes=False), + simple_typed_classes(defaults=False, newtypes=False), unstructure_strats, ) def test_union_field_roundtrip(cl_and_vals_a, cl_and_vals_b, strat): @@ -262,8 +271,8 @@ def handler(obj, _): @pytest.mark.skipif(not is_py310_plus, reason="3.10+ union syntax") @settings(suppress_health_check=[HealthCheck.filter_too_much, HealthCheck.too_slow]) @given( - simple_typed_classes(defaults=False), - simple_typed_classes(defaults=False), + simple_typed_classes(defaults=False, newtypes=False), + simple_typed_classes(defaults=False, newtypes=False), unstructure_strats, ) def test_310_union_field_roundtrip(cl_and_vals_a, cl_and_vals_b, strat): diff --git a/tests/test_disambigutors.py b/tests/test_disambigutors.py index 464f8165..f5a850d7 100644 --- a/tests/test_disambigutors.py +++ b/tests/test_disambigutors.py @@ -8,7 +8,7 @@ from cattrs.disambiguators import create_uniq_field_dis_func -from . import simple_classes +from .untyped import simple_classes def test_edge_errors(): diff --git a/tests/test_gen_dict.py b/tests/test_gen_dict.py index aa77303a..c152b49b 100644 --- a/tests/test_gen_dict.py +++ b/tests/test_gen_dict.py @@ -12,12 +12,8 @@ from cattrs.errors import ClassValidationError, ForbiddenExtraKeysError from cattrs.gen import make_dict_structure_fn, make_dict_unstructure_fn, override -from . import nested_classes, simple_classes -from .metadata import ( - nested_typed_classes, - simple_typed_classes, - simple_typed_dataclasses, -) +from .typed import nested_typed_classes, simple_typed_classes, simple_typed_dataclasses +from .untyped import nested_classes, simple_classes @given(nested_classes | simple_classes()) @@ -166,11 +162,16 @@ def test_individual_overrides(converter_cls, cl_and_vals): assert attr.name in res -@given(nested_typed_classes() | simple_typed_classes() | simple_typed_dataclasses()) -def test_unmodified_generated_structuring(cl_and_vals): - converter = BaseConverter() +@given( + cl_and_vals=nested_typed_classes() + | simple_typed_classes() + | simple_typed_dataclasses(), + dv=..., +) +def test_unmodified_generated_structuring(cl_and_vals, dv: bool): + converter = Converter(detailed_validation=dv) cl, vals, kwargs = cl_and_vals - fn = make_dict_structure_fn(cl, converter) + fn = make_dict_structure_fn(cl, converter, _cattrs_detailed_validation=dv) inst = cl(*vals, **kwargs) @@ -189,7 +190,7 @@ def test_unmodified_generated_structuring(cl_and_vals): simple_typed_classes(min_attrs=1) | simple_typed_dataclasses(min_attrs=1), data() ) def test_renaming(cl_and_vals, data): - converter = BaseConverter() + converter = Converter() cl, vals, kwargs = cl_and_vals attrs = fields(cl) diff --git a/tests/test_structure.py b/tests/test_structure.py index fc897309..b4053c00 100644 --- a/tests/test_structure.py +++ b/tests/test_structure.py @@ -24,14 +24,14 @@ from cattrs._compat import copy_with, is_bare, is_union_type from cattrs.errors import IterableValidationError, StructureHandlerNotFoundError -from . import ( +from ._compat import change_type_param +from .untyped import ( dicts_of_primitives, enums_of_primitives, lists_of_primitives, primitive_strategies, seqs_of_primitives, ) -from ._compat import change_type_param NoneType = type(None) ints_and_type = tuples(integers(), just(int)) diff --git a/tests/test_structure_attrs.py b/tests/test_structure_attrs.py index 3c89dbb4..2a08c662 100644 --- a/tests/test_structure_attrs.py +++ b/tests/test_structure_attrs.py @@ -12,7 +12,7 @@ from cattrs._compat import is_py37 from cattrs.converters import BaseConverter, Converter -from . import simple_classes +from .untyped import simple_classes @given(simple_classes()) diff --git a/tests/test_unstructure.py b/tests/test_unstructure.py index 4bb1e435..5b4b3e8f 100644 --- a/tests/test_unstructure.py +++ b/tests/test_unstructure.py @@ -7,7 +7,7 @@ from cattr.converters import BaseConverter, UnstructureStrategy -from . import ( +from .untyped import ( dicts_of_primitives, enums_of_primitives, nested_classes, diff --git a/tests/metadata/__init__.py b/tests/typed.py similarity index 88% rename from tests/metadata/__init__.py rename to tests/typed.py index 116b15ff..3fcb9490 100644 --- a/tests/metadata/__init__.py +++ b/tests/typed.py @@ -1,4 +1,4 @@ -"""Tests for metadata functionality.""" +"""Strategies for attributes with types and classes using them.""" import sys from collections import OrderedDict from collections.abc import MutableSequence as AbcMutableSequence @@ -14,6 +14,7 @@ List, MutableSequence, MutableSet, + NewType, Sequence, Set, Tuple, @@ -22,7 +23,7 @@ ) import attr -from attr import NOTHING, Factory +from attr import NOTHING, Factory, frozen from attr._make import _CountingAttr from hypothesis import note from hypothesis.strategies import ( @@ -44,7 +45,7 @@ tuples, ) -from .. import gen_attr_names, make_class +from .untyped import gen_attr_names, make_class is_39_or_later = sys.version_info[:2] >= (3, 9) PosArg = Any @@ -54,28 +55,36 @@ def simple_typed_classes( - defaults=None, min_attrs=0, frozen=False, kw_only=None + defaults=None, min_attrs=0, frozen=False, kw_only=None, newtypes=True ) -> SearchStrategy[Tuple[Type, PosArgs, KwArgs]]: """Yield tuples of (class, values).""" return lists_of_typed_attrs( - defaults, min_size=min_attrs, for_frozen=frozen, kw_only=kw_only + defaults, + min_size=min_attrs, + for_frozen=frozen, + kw_only=kw_only, + newtypes=newtypes, ).flatmap(partial(_create_hyp_class, frozen=frozen)) -def simple_typed_dataclasses(defaults=None, min_attrs=0, frozen=False): +def simple_typed_dataclasses(defaults=None, min_attrs=0, frozen=False, newtypes=True): """Yield tuples of (class, values).""" return lists_of_typed_attrs( - defaults, min_size=min_attrs, for_frozen=frozen, allow_mutable_defaults=False + defaults, + min_size=min_attrs, + for_frozen=frozen, + allow_mutable_defaults=False, + newtypes=newtypes, ).flatmap(partial(_create_dataclass, frozen=frozen)) def simple_typed_classes_and_strats( - defaults=None, min_attrs=0, kw_only=None + defaults=None, min_attrs=0, kw_only=None, newtypes=True ) -> SearchStrategy[Tuple[Type, SearchStrategy[PosArgs], SearchStrategy[KwArgs]]]: """Yield tuples of (class, (strategies)).""" - return lists_of_typed_attrs(defaults, min_size=min_attrs, kw_only=kw_only).flatmap( - _create_hyp_class_and_strat - ) + return lists_of_typed_attrs( + defaults, min_size=min_attrs, kw_only=kw_only, newtypes=newtypes + ).flatmap(_create_hyp_class_and_strat) def lists_of_typed_attrs( @@ -84,6 +93,7 @@ def lists_of_typed_attrs( for_frozen=False, allow_mutable_defaults=True, kw_only=None, + newtypes=True, ) -> SearchStrategy[List[Tuple[_CountingAttr, SearchStrategy[PosArg]]]]: # Python functions support up to 255 arguments. return lists( @@ -92,6 +102,7 @@ def lists_of_typed_attrs( for_frozen=for_frozen, allow_mutable_defaults=allow_mutable_defaults, kw_only=kw_only, + newtypes=newtypes, ), min_size=min_size, max_size=50, @@ -101,7 +112,11 @@ def lists_of_typed_attrs( def simple_typed_attrs( - defaults=None, for_frozen=False, allow_mutable_defaults=True, kw_only=None + defaults=None, + for_frozen=False, + allow_mutable_defaults=True, + kw_only=None, + newtypes=True, ) -> SearchStrategy[Tuple[_CountingAttr, SearchStrategy[PosArgs]]]: if not is_39_or_later: res = ( @@ -112,6 +127,12 @@ def simple_typed_attrs( | frozenset_typed_attrs(defaults, legacy_types_only=True, kw_only=kw_only) | homo_tuple_typed_attrs(defaults, legacy_types_only=True, kw_only=kw_only) ) + if newtypes: + res = ( + res + | newtype_int_typed_attrs(defaults, kw_only) + | newtype_attrs_typed_attrs(defaults, kw_only) + ) if not for_frozen: res = ( res @@ -150,6 +171,12 @@ def simple_typed_attrs( | frozenset_typed_attrs(defaults, kw_only=kw_only) | homo_tuple_typed_attrs(defaults, kw_only=kw_only) ) + if newtypes: + res = ( + res + | newtype_int_typed_attrs(defaults, kw_only) + | newtype_attrs_typed_attrs(defaults, kw_only) + ) if not for_frozen: res = ( @@ -603,6 +630,52 @@ def homo_tuple_typed_attrs(draw, defaults=None, legacy_types_only=False, kw_only ) +@composite +def newtype_int_typed_attrs(draw: DrawFn, defaults=None, kw_only=None): + """ + Generate a tuple of an attribute and a strategy that yields ints for that + attribute. + """ + default = attr.NOTHING + if defaults is True or (defaults is None and draw(booleans())): + default = draw(integers()) + type = NewType("NewInt", int) + return ( + attr.ib( + type=type, + default=default, + kw_only=draw(booleans()) if kw_only is None else kw_only, + ), + integers(), + ) + + +@composite +def newtype_attrs_typed_attrs(draw: DrawFn, defaults=None, kw_only=None): + """ + Generate a tuple of an attribute and a strategy that yields values for that + attribute. + """ + default = attr.NOTHING + + @frozen + class NewTypeAttrs: + a: int + + if defaults is True or (defaults is None and draw(booleans())): + default = NewTypeAttrs(draw(integers())) + + type = NewType("NewAttrs", NewTypeAttrs) + return ( + attr.ib( + type=type, + default=default, + kw_only=draw(booleans()) if kw_only is None else kw_only, + ), + integers().map(NewTypeAttrs), + ) + + def just_class( tup: Tuple[ List[Tuple[_CountingAttr, SearchStrategy]], Tuple[Type, PosArgs, KwArgs] @@ -685,7 +758,7 @@ def dict_of_class( def _create_hyp_nested_strategy( - simple_class_strategy: SearchStrategy, kw_only=None + simple_class_strategy: SearchStrategy, kw_only=None, newtypes=True ) -> SearchStrategy[Tuple[Type, SearchStrategy[PosArgs], SearchStrategy[KwArgs]]]: """ Create a recursive attrs class. @@ -701,7 +774,9 @@ def _create_hyp_nested_strategy( Tuple[ List[Tuple[_CountingAttr, PosArgs]], Tuple[Type, SearchStrategy[PosArgs]], ] - ] = tuples(lists_of_typed_attrs(kw_only=kw_only), simple_class_strategy) + ] = tuples( + lists_of_typed_attrs(kw_only=kw_only, newtypes=newtypes), simple_class_strategy + ) return nested_classes(attrs_and_classes) @@ -748,21 +823,22 @@ def nested_classes( def nested_typed_classes_and_strat( - defaults=None, min_attrs=0, kw_only=None + defaults=None, min_attrs=0, kw_only=None, newtypes=True ) -> SearchStrategy[Tuple[Type, SearchStrategy[PosArgs]]]: return recursive( simple_typed_classes_and_strats( - defaults=defaults, min_attrs=min_attrs, kw_only=kw_only + defaults=defaults, min_attrs=min_attrs, kw_only=kw_only, newtypes=newtypes ), - partial(_create_hyp_nested_strategy, kw_only=kw_only), + partial(_create_hyp_nested_strategy, kw_only=kw_only, newtypes=newtypes), + max_leaves=20, ) @composite -def nested_typed_classes(draw, defaults=None, min_attrs=0, kw_only=None): +def nested_typed_classes(draw, defaults=None, min_attrs=0, kw_only=None, newtypes=True): cl, strat, kwarg_strat = draw( nested_typed_classes_and_strat( - defaults=defaults, min_attrs=min_attrs, kw_only=kw_only + defaults=defaults, min_attrs=min_attrs, kw_only=kw_only, newtypes=newtypes ) ) return cl, draw(strat), draw(kwarg_strat) diff --git a/tests/untyped.py b/tests/untyped.py new file mode 100644 index 00000000..98ed7d85 --- /dev/null +++ b/tests/untyped.py @@ -0,0 +1,420 @@ +"""Strategies for attributes without types and accompanying classes.""" +import keyword +import string +from collections import OrderedDict +from enum import Enum +from typing import ( + Any, + Dict, + List, + Mapping, + MutableMapping, + MutableSequence, + MutableSet, + Sequence, + Set, + Tuple, +) + +import attr +from attr import NOTHING, make_class +from attr._make import _CountingAttr +from hypothesis import strategies as st + +PosArg = Any +PosArgs = Tuple[PosArg] +KwArgs = Dict[str, Any] + +primitive_strategies = st.sampled_from( + [ + (st.integers(), int), + (st.floats(allow_nan=False), float), + (st.text(), str), + (st.binary(), bytes), + ] +) + + +@st.composite +def enums_of_primitives(draw): + """Generate enum classes with primitive values.""" + names = draw( + st.sets(st.text(min_size=1).filter(lambda s: not s.endswith("_")), min_size=1) + ) + n = len(names) + vals = draw( + st.one_of( + st.sets( + st.one_of( + st.integers(), st.floats(allow_nan=False), st.text(min_size=1) + ), + min_size=n, + max_size=n, + ) + ) + ) + return Enum("HypEnum", list(zip(names, vals))) + + +list_types = st.sampled_from([List, Sequence, MutableSequence]) +set_types = st.sampled_from([Set, MutableSet]) + + +@st.composite +def lists_of_primitives(draw): + """Generate a strategy that yields tuples of list of primitives and types. + + For example, a sample value might be ([1,2], List[int]). + """ + prim_strat, t = draw(primitive_strategies) + list_t = draw(list_types.map(lambda list_t: list_t[t]) | list_types) + return draw(st.lists(prim_strat)), list_t + + +@st.composite +def mut_sets_of_primitives(draw): + """A strategy that generates mutable sets of primitives.""" + prim_strat, t = draw(primitive_strategies) + set_t = draw(set_types.map(lambda set_t: set_t[t]) | set_types) + return draw(st.sets(prim_strat)), set_t + + +@st.composite +def frozen_sets_of_primitives(draw): + """A strategy that generates frozen sets of primitives.""" + prim_strat, t = draw(primitive_strategies) + set_t = draw(st.just(Set) | st.just(Set[t])) + return frozenset(draw(st.sets(prim_strat))), set_t + + +h_tuple_types = st.sampled_from([Tuple, Sequence]) +h_tuples_of_primitives = primitive_strategies.flatmap( + lambda e: st.tuples( + st.lists(e[0]), + st.one_of(st.sampled_from([Tuple[e[1], ...], Sequence[e[1]]]), h_tuple_types), + ) +).map(lambda e: (tuple(e[0]), e[1])) + +dict_types = st.sampled_from([Dict, MutableMapping, Mapping]) + +seqs_of_primitives = st.one_of(lists_of_primitives(), h_tuples_of_primitives) + +sets_of_primitives = st.one_of(mut_sets_of_primitives(), frozen_sets_of_primitives()) + + +def create_generic_dict_type(type1, type2): + """Create a strategy for generating parameterized dict types.""" + return st.one_of( + dict_types, + dict_types.map(lambda t: t[type1, type2]), + dict_types.map(lambda t: t[Any, type2]), + dict_types.map(lambda t: t[type1, Any]), + ) + + +def create_dict_and_type(tuple_of_strats): + """Map two primitive strategies into a strategy for dict and type.""" + (prim_strat_1, type_1), (prim_strat_2, type_2) = tuple_of_strats + + return st.tuples( + st.dictionaries(prim_strat_1, prim_strat_2), + create_generic_dict_type(type_1, type_2), + ) + + +dicts_of_primitives = st.tuples(primitive_strategies, primitive_strategies).flatmap( + create_dict_and_type +) + + +def gen_attr_names(): + """ + Generate names for attributes, 'a'...'z', then 'aa'...'zz'. + ~702 different attribute names should be enough in practice. + Some short strings (such as 'as') are keywords, so we skip them. + + Every second attribute name is private (starts with an underscore). + """ + lc = string.ascii_lowercase + has_underscore = False + for c in lc: + yield c if not has_underscore else "_" + c + has_underscore = not has_underscore + for outer in lc: + for inner in lc: + res = outer + inner + if keyword.iskeyword(res): + continue + yield outer + inner + + +def _create_hyp_class( + attrs_and_strategy: List[Tuple[_CountingAttr, st.SearchStrategy[PosArgs]]], + frozen=None, +): + """ + A helper function for Hypothesis to generate attrs classes. + + The result is a tuple: an attrs class, a tuple of values to + instantiate it, and a kwargs dict for kw-only attributes. + """ + + def key(t): + return (t[0].default is not NOTHING, t[0].kw_only) + + attrs_and_strat = sorted(attrs_and_strategy, key=key) + attrs = [a[0] for a in attrs_and_strat] + for i, a in enumerate(attrs): + a.counter = i + vals = tuple((a[1]) for a in attrs_and_strat if not a[0].kw_only) + kwargs = {} + for attr_name, attr_and_strat in zip(gen_attr_names(), attrs_and_strat): + if attr_and_strat[0].kw_only: + if attr_name.startswith("_"): + attr_name = attr_name[1:] + kwargs[attr_name] = attr_and_strat[1] + return st.tuples( + st.builds( + lambda f: make_class( + "HypClass", OrderedDict(zip(gen_attr_names(), attrs)), frozen=f + ), + st.booleans() if frozen is None else st.just(frozen), + ), + st.tuples(*vals), + st.fixed_dictionaries(kwargs), + ) + + +def just_class(tup): + nested_cl = tup[1][0] + default = attr.Factory(nested_cl) + combined_attrs = list(tup[0]) + combined_attrs.append((attr.ib(default=default), st.just(nested_cl()))) + return _create_hyp_class(combined_attrs) + + +def just_class_with_type(tup): + nested_cl = tup[1][0] + default = attr.Factory(nested_cl) + combined_attrs = list(tup[0]) + combined_attrs.append( + (attr.ib(default=default, type=nested_cl), st.just(nested_cl())) + ) + return _create_hyp_class(combined_attrs) + + +def just_class_with_type_takes_self(tup): + nested_cl = tup[1][0] + default = attr.Factory(lambda _: nested_cl(), takes_self=True) + combined_attrs = list(tup[0]) + combined_attrs.append( + (attr.ib(default=default, type=nested_cl), st.just(nested_cl())) + ) + return _create_hyp_class(combined_attrs) + + +def just_frozen_class_with_type(tup): + nested_cl = tup[1][0] + combined_attrs = list(tup[0]) + combined_attrs.append( + (attr.ib(default=nested_cl(), type=nested_cl), st.just(nested_cl())) + ) + return _create_hyp_class(combined_attrs) + + +def list_of_class(tup): + nested_cl = tup[1][0] + default = attr.Factory(lambda: [nested_cl()]) + combined_attrs = list(tup[0]) + combined_attrs.append((attr.ib(default=default), st.just([nested_cl()]))) + return _create_hyp_class(combined_attrs) + + +def list_of_class_with_type(tup): + nested_cl = tup[1][0] + default = attr.Factory(lambda: [nested_cl()]) + combined_attrs = list(tup[0]) + combined_attrs.append( + (attr.ib(default=default, type=List[nested_cl]), st.just([nested_cl()])) + ) + return _create_hyp_class(combined_attrs) + + +def dict_of_class(tup): + nested_cl = tup[1][0] + default = attr.Factory(lambda: {"cls": nested_cl()}) + combined_attrs = list(tup[0]) + combined_attrs.append((attr.ib(default=default), st.just({"cls": nested_cl()}))) + return _create_hyp_class(combined_attrs) + + +def _create_hyp_nested_strategy(simple_class_strategy): + """ + Create a recursive attrs class. + Given a strategy for building (simpler) classes, create and return + a strategy for building classes that have as an attribute: + * just the simpler class + * a list of simpler classes + * a dict mapping the string "cls" to a simpler class. + """ + # A strategy producing tuples of the form ([list of attributes], ). + attrs_and_classes = st.tuples(lists_of_attrs(defaults=True), simple_class_strategy) + + return ( + attrs_and_classes.flatmap(just_class) + | attrs_and_classes.flatmap(just_class_with_type) + | attrs_and_classes.flatmap(list_of_class) + | attrs_and_classes.flatmap(list_of_class_with_type) + | attrs_and_classes.flatmap(dict_of_class) + | attrs_and_classes.flatmap(just_frozen_class_with_type) + | attrs_and_classes.flatmap(just_class_with_type_takes_self) + ) + + +@st.composite +def bare_attrs(draw, defaults=None, kw_only=None): + """ + Generate a tuple of an attribute and a strategy that yields values + appropriate for that attribute. + """ + default = NOTHING + if defaults is True or (defaults is None and draw(st.booleans())): + default = None + return ( + attr.ib( + default=default, kw_only=draw(st.booleans()) if kw_only is None else kw_only + ), + st.just(None), + ) + + +@st.composite +def int_attrs(draw, defaults=None, kw_only=None): + """ + Generate a tuple of an attribute and a strategy that yields ints for that + attribute. + """ + default = NOTHING + if defaults is True or (defaults is None and draw(st.booleans())): + default = draw(st.integers()) + return ( + attr.ib( + default=default, kw_only=draw(st.booleans()) if kw_only is None else kw_only + ), + st.integers(), + ) + + +@st.composite +def str_attrs(draw, defaults=None, type_annotations=None, kw_only=None): + """ + Generate a tuple of an attribute and a strategy that yields strs for that + attribute. + """ + default = NOTHING + if defaults is True or (defaults is None and draw(st.booleans())): + default = draw(st.text()) + if (type_annotations is None and draw(st.booleans())) or type_annotations: + type = str + else: + type = None + return ( + attr.ib( + default=default, + type=type, + kw_only=draw(st.booleans()) if kw_only is None else kw_only, + ), + st.text(), + ) + + +@st.composite +def float_attrs(draw, defaults=None, kw_only=None): + """ + Generate a tuple of an attribute and a strategy that yields floats for that + attribute. + """ + default = NOTHING + if defaults is True or (defaults is None and draw(st.booleans())): + default = draw(st.floats()) + return ( + attr.ib( + default=default, kw_only=draw(st.booleans()) if kw_only is None else kw_only + ), + st.floats(allow_nan=False), + ) + + +@st.composite +def dict_attrs(draw, defaults=None, kw_only=None): + """ + Generate a tuple of an attribute and a strategy that yields dictionaries + for that attribute. The dictionaries map strings to integers. + """ + default = NOTHING + val_strat = st.dictionaries(keys=st.text(), values=st.integers()) + if defaults is True or (defaults is None and draw(st.booleans())): + default_val = draw(val_strat) + default = attr.Factory(lambda: default_val) + return ( + attr.ib( + default=default, kw_only=draw(st.booleans()) if kw_only is None else kw_only + ), + val_strat, + ) + + +@st.composite +def optional_attrs(draw, defaults=None, kw_only=None): + """ + Generate a tuple of an attribute and a strategy that yields values + for that attribute. The strategy generates optional integers. + """ + default = NOTHING + val_strat = st.integers() | st.none() + if defaults is True or (defaults is None and draw(st.booleans())): + default = draw(val_strat) + + return ( + attr.ib( + default=default, kw_only=draw(st.booleans()) if kw_only is None else kw_only + ), + val_strat, + ) + + +def simple_attrs(defaults=None, kw_only=None): + return ( + bare_attrs(defaults, kw_only=kw_only) + | int_attrs(defaults, kw_only=kw_only) + | str_attrs(defaults, kw_only=kw_only) + | float_attrs(defaults, kw_only=kw_only) + | dict_attrs(defaults, kw_only=kw_only) + | optional_attrs(defaults, kw_only=kw_only) + ) + + +def lists_of_attrs(defaults=None, min_size=0, kw_only=None): + # Python functions support up to 255 arguments. + return st.lists( + simple_attrs(defaults, kw_only), min_size=min_size, max_size=10 + ).map(lambda l: sorted(l, key=lambda t: t[0]._default is not NOTHING)) + + +def simple_classes(defaults=None, min_attrs=0, frozen=None, kw_only=None): + """ + Return a strategy that yields tuples of simple classes and values to + instantiate them. + """ + return lists_of_attrs(defaults, min_size=min_attrs, kw_only=kw_only).flatmap( + lambda attrs_and_strategy: _create_hyp_class(attrs_and_strategy, frozen=frozen) + ) + + +# 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(defaults=True), _create_hyp_nested_strategy +) diff --git a/tox.ini b/tox.ini index baaecb7f..48d4120f 100644 --- a/tox.ini +++ b/tox.ini @@ -5,7 +5,7 @@ python = 3.8: py38 3.9: py39, lint 3.10: py310 - pypy3: pypy3 + pypy-3: pypy3 [flake8] exclude = docs, cattr/vendor @@ -19,7 +19,7 @@ skipsdist = true [testenv:lint] basepython = python3.9 extras = dev -allowlist_externals = +allowlist_externals = make poetry commands = @@ -36,6 +36,16 @@ commands = coverage run --source cattr -m pytest tests {posargs} passenv = CI +[testenv:pypy3] +setenv = + PYTHONPATH = {toxinidir}:{toxinidir}/cattr +extras = dev +allowlist_externals = poetry +commands = + poetry install -v + coverage run --source cattr -m pytest tests {posargs} +passenv = CI FAST + [testenv:docs] basepython = python3.8 setenv =