Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Initial kw_only support for attrs classes #247

Merged
merged 6 commits into from
Apr 9, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion HISTORY.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ History
=======
22.2.0 (UNRELEASED)
-------------------

* cattrs now supports un/structuring `kw_only` fields on attrs classes into/from dictionaries.
(`#247 <https://github.com/python-attrs/cattrs/pull/247>`_)

22.1.0 (2022-04-03)
-------------------
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ clean-test: ## remove test and coverage artifacts
rm -fr htmlcov/

lint: ## check style with flake8
poetry run flake8 src/cattr tests
poetry run flake8 src/ tests
poetry run black --check src tests docs/conf.py

test: ## run tests quickly with the default Python
Expand Down
14 changes: 7 additions & 7 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@

# Get the project root dir, which is the parent dir of this
cwd = os.getcwd()
project_root = os.path.join(os.path.dirname(cwd), u"src")
project_root = os.path.join(os.path.dirname(cwd), "src")

# Insert the project root dir as the first element in the PYTHONPATH.
# This lets us ensure that the source package is imported, and that its
Expand Down Expand Up @@ -60,8 +60,8 @@
master_doc = "index"

# General information about the project.
project = u"cattrs"
copyright = u"2020, Tin Tvrtković"
project = "cattrs"
copyright = "2020, Tin Tvrtković"

# The version info for the project you're documenting, acts as replacement
# for |version| and |release|, also used in various other places throughout
Expand Down Expand Up @@ -212,7 +212,7 @@
# (source start file, target name, title, author, documentclass
# [howto/manual]).
latex_documents = [
("index", "cattrs.tex", u"cattrs Documentation", u"Tin Tvrtković", "manual")
("index", "cattrs.tex", "cattrs Documentation", "Tin Tvrtković", "manual")
]

# The name of an image file (relative to this directory) to place at
Expand Down Expand Up @@ -240,7 +240,7 @@

# One entry per manual page. List of tuples
# (source start file, name, description, authors, manual section).
man_pages = [("index", "cattrs", u"cattrs Documentation", [u"Tin Tvrtković"], 1)]
man_pages = [("index", "cattrs", "cattrs Documentation", ["Tin Tvrtković"], 1)]

# If true, show URL addresses after external links.
# man_show_urls = False
Expand All @@ -255,8 +255,8 @@
(
"index",
"cattrs",
u"cattrs Documentation",
u"Tin Tvrtković",
"cattrs Documentation",
"Tin Tvrtković",
"cattrs",
"Composable complex class support for attrs.",
"Miscellaneous",
Expand Down
327 changes: 172 additions & 155 deletions poetry.lock

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,12 @@ pymongo = "^3.12.1"
flake8 = "^3.9.0"
tox = "^3.23.0"
Sphinx = "^4.1.2"
pytest = "^6.2.3"
pytest = "^7.1.1"
pytest-benchmark = "^3.2.3"
hypothesis = "^6.9.2"
hypothesis = "^6.41.0"
pendulum = "^2.1.2"
isort = "^5.8.0"
black = "^21.12-beta.0"
black = "^22.3.0"
immutables = "^0.15"
ujson = "^5.1.0"
orjson = "^3.5.2"
Expand Down
2 changes: 1 addition & 1 deletion src/cattrs/converters.py
Original file line number Diff line number Diff line change
Expand Up @@ -398,7 +398,7 @@ def _structure_enum_literal(val, type):
def structure_attrs_fromtuple(self, obj: Tuple[Any, ...], cl: Type[T]) -> T:
"""Load an attrs class from a sequence (tuple)."""
conv_obj = [] # A list of converter parameters.
for a, value in zip(fields(cl), obj): # type: ignore
for a, value in zip(fields(cl), obj):
# We detect the type by the metadata.
converted = self._structure_attribute(a, value)
conv_obj.append(converted)
Expand Down
13 changes: 8 additions & 5 deletions src/cattrs/gen.py
Original file line number Diff line number Diff line change
Expand Up @@ -395,15 +395,18 @@ def make_dict_structure_fn(
internal_arg_parts[struct_handler_name] = handler
if handler == converter._structure_call:
internal_arg_parts[struct_handler_name] = t
invocation_lines.append(f"{struct_handler_name}(o['{kn}']),")
invocation_line = f"{struct_handler_name}(o['{kn}']),"
else:
type_name = f"__c_type_{an}"
internal_arg_parts[type_name] = t
invocation_lines.append(
f"{struct_handler_name}(o['{kn}'], {type_name}),"
)
invocation_line = f"{struct_handler_name}(o['{kn}'], {type_name}),"
else:
invocation_lines.append(f"o['{kn}'],")
invocation_line = f"o['{kn}'],"

if a.kw_only:
ian = an if (is_dc or an[0] != "_") else an[1:]
invocation_line = f"{ian}={invocation_line}"
invocation_lines.append(invocation_line)

# The second loop is for optional args.
if non_required:
Expand Down
106 changes: 76 additions & 30 deletions tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

import attr
from attr import NOTHING, make_class
from attr._make import _CountingAttr
from hypothesis import HealthCheck, settings
from hypothesis import strategies as st

Expand All @@ -28,6 +29,9 @@
if "CI" in os.environ:
settings.load_profile("CI")

PosArg = Any
PosArgs = Tuple[Any]
KwArgs = Dict[str, Any]

primitive_strategies = st.sampled_from(
[
Expand Down Expand Up @@ -152,22 +156,31 @@ def gen_attr_names():
yield outer + inner


def _create_hyp_class(attrs_and_strategy, frozen=None):
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, and a tuple of values to
instantiate it.
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
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)
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(
Expand All @@ -176,6 +189,7 @@ def key(t):
st.booleans() if frozen is None else st.just(frozen),
),
st.tuples(*vals),
st.fixed_dictionaries(kwargs),
)


Expand Down Expand Up @@ -267,31 +281,41 @@ def _create_hyp_nested_strategy(simple_class_strategy):


@st.composite
def bare_attrs(draw, defaults=None):
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), st.just(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):
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), 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):
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.
Expand All @@ -303,23 +327,35 @@ def str_attrs(draw, defaults=None, type_annotations=None):
type = str
else:
type = None
return (attr.ib(default=default, type=type), st.text())
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):
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), st.floats(allow_nan=False))
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):
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.
Expand All @@ -329,11 +365,16 @@ def dict_attrs(draw, defaults=None):
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), val_strat)
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):
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.
Expand All @@ -343,33 +384,38 @@ def optional_attrs(draw, defaults=None):
if defaults is True or (defaults is None and draw(st.booleans())):
default = draw(val_strat)

return (attr.ib(default=default), 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):
def simple_attrs(defaults=None, kw_only=None):
return (
bare_attrs(defaults)
| int_attrs(defaults)
| str_attrs(defaults)
| float_attrs(defaults)
| dict_attrs(defaults)
| optional_attrs(defaults)
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):
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), min_size=min_size, max_size=10).map(
lambda l: sorted(l, key=lambda t: t[0]._default is not NOTHING)
)
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):
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).flatmap(
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)
)

Expand Down
Loading