HypothesisWorks / hypothesis

Hypothesis is a powerful, flexible, and easy to use library for property-based testing.
https://hypothesis.works
Other
7.56k stars 587 forks source link

Document how to make a Strategy for enums #2693

Closed ysangkok closed 3 years ago

ysangkok commented 3 years ago

If one tries builds(Enum), it fails horribly:

>>> from hypothesis.strategies import builds
>>> from enum import Enum
>>> builds(Enum("One",[chr(x) for x in range(ord('A'),ord('C'))])).example()
Traceback:
  File "/home/janus/.pyenv/versions/3.9.0rc2/lib/python3.9/site-packages/hypothesis/strategies/_internal/core.py", line 1318, in <lambda>
    lambda value: target(*value[0], **value[1])  # type: ignore
TypeError: __call__() missing 1 required positional argument: 'value'

It would be nice if the right way was documented, or if builds detected that it was being called on an enum. It is a standard library class.

sampled_from(list(Enum)) does work, but it may not be obvious to everybody.

This was tested with version 5.41.5 on Python 3.9.0rc2.

Zac-HD commented 3 years ago

As the docs note, sampled_from(Enum) works directly, and even has special handling for Flag enums to generate combinations of options.

I actually don't think we should change the semantics of builds() away from "calls the target with arguments", even when there's a clear alternative interpretation (though it's not that clear given that enums are actually callable!). It's not a bad API style for interactive tools, but as a testing framework we prioritize clear and simple mental models to minimize the risk of surprising test behaviour.

However, I'd be happy to detect this particular problem and raise an error with a helpful message explaining what to do instead.

ysangkok commented 3 years ago

Ok! The confusion mainly stems from the fact that I can build dataclasses using builds, even if they contain Enums. It seemed odd to me that I would call a different function based on whether the outermost type is a sum type or a product type. I would very much appreciate a more helpful error message! Thanks.

Zac-HD commented 3 years ago

It seemed odd to me that I would call a different function based on whether the outermost type is a sum type or a product type.

Ah - the distinction is actually "builds(target) calls the target, sampled_from(target) returns elements from the target sequence". Sum-type vs product-type is a nice mental model to have, but not quite applicable here.

Knowing that's the distinction you had in mind will help me write the new error message though - thanks!

ysangkok commented 3 years ago

Thanks a lot @Zac-HD , I appreciate it a lot!

ThatXliner commented 3 years ago

But how do we generate enums, though? I've tried

@st.composite
def generate_enum(draw):
    return draw(
        st.builds(
            Enum,
            st.from_regex(r"(?a)[_a-zA-Z][_a-zA-Z0-9]*"),
            st.from_regex(r"(?a)[_a-zA-Z][_a-zA-Z0-9]*( [_a-zA-Z][_a-zA-Z0-9]*)*"),
        )
    )

But got

TypeError: Attempted to reuse key: 'A'

and

ValueError: type name must not contain null characters

I also saw #2923 . Maybe I'm just dumb. Because what I'm trying is not working

Zac-HD commented 3 years ago

This issue is about generating instances of a particular Enum subclass. To generate arbitrary Enum types, you could

from enum import Enum
from hypothesis import strategies as st 

def enums():
    names = st.text().filter(str.isidentifier)  # or st.from_regex(), etc.
    values = st.lists(names, min_size=1, unique=True).map(" ".join)
    return st.builds(Enum, names, values)

though a more efficient strategy for Python identifiers can be found here. You could also be more general about the values with st.lists(st.tuples(names, st.from_type(Hashable)), min_size=1, unique_by=itemgetter(0)) but it's unclear whether that's worth the trouble.

ThatXliner commented 3 years ago

Funny, because I also tried a little harder with making the enum strategy and I got (where variable_names() would be the identifier-generating strategy)

@st.composite
def generate_enum(draw):
    return draw(
        st.builds(
            Enum,
            variable_names(),
            st.dictionaries(variable_names(), st.text()),
        ),
    )

I think you guys should really add two new strategies: Enums and/or identifiers

rsokl commented 3 years ago

I think you guys should really add two new strategies: Enums and/or identifiers

What is insufficient about the strategy that you and @Zac-HD both posted? It makes standard use of composite and builds, and it is concise and easy to reason about.

ThatXliner commented 3 years ago

I think you guys should really add two new strategies: Enums and/or identifiers

What is insufficient about the strategy that you and @Zac-HD both posted? It makes standard use of composite and builds, and it is concise and easy to reason about.

Hmm, true. Though it did take me a while to figure. I guess we shouldn't add too much extra strategies

ThatXliner commented 3 years ago

As the docs note, sampled_from(Enum) works directly, and even has special handling for Flag enums to generate combinations of options.

I get

 hypothesis.errors.InvalidArgument: Cannot sample from generate_enum(), not an ordered collection.
Zac-HD commented 3 years ago

Because generate_enum() returns a strategy, not an ordered collection.