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鈥檒l occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support attrs alias and private attributes in st.builds #3807

Merged
merged 2 commits into from Dec 8, 2023
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
7 changes: 7 additions & 0 deletions hypothesis-python/RELEASE.rst
@@ -0,0 +1,7 @@
RELEASE_TYPE: patch

This patch fixes an issue where :func:`~hypothesis.strategies.builds` could not be used with :pypi:`attrs` objects that defined private attributes (i.e. attributes with a leading underscore). See also :issue:`3791`.

This patch also adds support more generally for using :func:`~hypothesis.strategies.builds` with attrs' ``alias`` parameter, which was previously unsupported.

This patch increases the minimum required version of attrs to 22.2.0.
2 changes: 1 addition & 1 deletion hypothesis-python/setup.py
Expand Up @@ -96,7 +96,7 @@ def local_file(name):
zip_safe=False,
extras_require=extras,
install_requires=[
"attrs>=19.2.0",
"attrs>=22.2.0",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was pretty nervous about this, but checking download stats it seems likely to be fine 馃檪

"exceptiongroup>=1.0.0 ; python_version<'3.11'",
"sortedcontainers>=2.1.0,<3.0.0",
],
Expand Down
28 changes: 27 additions & 1 deletion hypothesis-python/src/hypothesis/strategies/_internal/attrs.py
Expand Up @@ -21,12 +21,38 @@
from hypothesis.utils.conventions import infer


def get_attribute_by_alias(fields, alias, *, target=None):
"""
Get an attrs attribute by its alias, rather than its name (compare
getattr(fields, name)).

``target`` is used only to provide a nicer error message, and can be safely
omitted.
"""
# attrs supports defining an alias for a field, which is the name used when
# defining __init__. The init args are what we pull from when determining
# what parameters we need to supply to the class, so it's what we need to
# match against as well, rather than the class-level attribute name.
matched_fields = [f for f in fields if f.alias == alias]
if not matched_fields:
raise TypeError(
f"Unexpected keyword argument {alias} for attrs class"
f"{f' {target}' if target else ''}. Expected one of "
f"{[f.name for f in fields]}"
)
# alias is used as an arg in __init__, so it is guaranteed to be unique, if
# it exists.
assert len(matched_fields) == 1
return matched_fields[0]


def from_attrs(target, args, kwargs, to_infer):
"""An internal version of builds(), specialised for Attrs classes."""
fields = attr.fields(target)
kwargs = {k: v for k, v in kwargs.items() if v is not infer}
for name in to_infer:
kwargs[name] = from_attrs_attribute(getattr(fields, name), target)
attrib = get_attribute_by_alias(fields, name, target=target)
kwargs[name] = from_attrs_attribute(attrib, target)
# We might make this strategy more efficient if we added a layer here that
# retries drawing if validation fails, for improved composition.
# The treatment of timezones in datetimes() provides a precedent.
Expand Down
34 changes: 34 additions & 0 deletions hypothesis-python/tests/cover/test_attrs_inference.py
Expand Up @@ -89,3 +89,37 @@ def test_cannot_infer(c):
def test_cannot_infer_takes_self():
with pytest.raises(ResolutionFailed):
st.builds(Inferrables, has_default_factory_takes_self=...).example()


@attr.s
class HasPrivateAttribute:
_x: int = attr.ib()


@pytest.mark.parametrize("s", [st.just(42), ...])
def test_private_attribute(s):
st.builds(HasPrivateAttribute, x=s).example()


def test_private_attribute_underscore_fails():
with pytest.raises(TypeError, match="unexpected keyword argument '_x'"):
st.builds(HasPrivateAttribute, _x=st.just(42)).example()


def test_private_attribute_underscore_infer_fails():
# this has a slightly different failure case, because it goes through
# attrs-specific resolution logic.
with pytest.raises(
TypeError, match="Unexpected keyword argument _x for attrs class"
):
st.builds(HasPrivateAttribute, _x=...).example()


@attr.s
class HasAliasedAttribute:
x: int = attr.ib(alias="crazyname")


@pytest.mark.parametrize("s", [st.just(42), ...])
def test_aliased_attribute(s):
st.builds(HasAliasedAttribute, crazyname=s).example()