diff --git a/hypothesis-python/RELEASE.rst b/hypothesis-python/RELEASE.rst new file mode 100644 index 0000000000..b4a4e4d498 --- /dev/null +++ b/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. diff --git a/hypothesis-python/setup.py b/hypothesis-python/setup.py index 5f5e82d1f0..d4054fd5af 100644 --- a/hypothesis-python/setup.py +++ b/hypothesis-python/setup.py @@ -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", ], diff --git a/hypothesis-python/src/hypothesis/strategies/_internal/attrs.py b/hypothesis-python/src/hypothesis/strategies/_internal/attrs.py index d4f56a1f3a..3b08f3a43d 100644 --- a/hypothesis-python/src/hypothesis/strategies/_internal/attrs.py +++ b/hypothesis-python/src/hypothesis/strategies/_internal/attrs.py @@ -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. diff --git a/hypothesis-python/tests/cover/test_attrs_inference.py b/hypothesis-python/tests/cover/test_attrs_inference.py index 6dce6a4c00..c517cea883 100644 --- a/hypothesis-python/tests/cover/test_attrs_inference.py +++ b/hypothesis-python/tests/cover/test_attrs_inference.py @@ -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()