Skip to content

Commit

Permalink
Merge pull request #3807 from tybug/attrs-private-bug
Browse files Browse the repository at this point in the history
  • Loading branch information
Zac-HD authored Dec 8, 2023
2 parents ff22890 + 1c24f5f commit 650adc9
Show file tree
Hide file tree
Showing 4 changed files with 69 additions and 2 deletions.
7 changes: 7 additions & 0 deletions hypothesis-python/RELEASE.rst
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
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",
"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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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()

0 comments on commit 650adc9

Please sign in to comment.