Repository: HypothesisWorks/hypothesis

Issue:
Strategies deduplication does not working for `st.none` as expected in `OneOfStrategy.element_strategies`
Hello!

Some time ago I created an issue - https://github.com/HypothesisWorks/hypothesis/issues/2087
 and recently got back to the investigations regarding the cause of the observed behavior. A took this case:

`one_of(none(), none(), booleans())` 

That was generating 85 examples, while there are only possible 3 cases (`None`, `True`, `False`).

I found this code in `OneOfStrategy` that should remove duplicates (I assume):

```
pruned = []
seen = set()
for s in strategies:
    if s is self:
        continue
    if s in seen:
        continue
    seen.add(s)
    pruned.append(s)
```

But `if s in seen` doesn't work for `st.none()`:

```
In [3]: seen = set()                                                                                                                                                                                                                                                                                                    

In [4]: seen.add(st.none())                                                                                                                                                                                                                                                                                             

In [5]: seen.add(st.none())                                                                                                                                                                                                                                                                                             

In [6]: seen                                                                                                                                                                                                                                                                                                            
Out[6]: {none(), none()}
```

And for `just` (which is used in `none`). I assume that "just" strategy instances can be deduplicated at least in some cases (for `just(None)` for example). What do you think? Could you, please, share some information about this behavior?



Discussion:
See #2291, especially:

> https://github.com/HypothesisWorks/hypothesis/compare/master...Zac-HD:hashable-strategies shows a current draft... which doesn't quite work. Hashing really doesn't play nicely with lazy instantiation and instances which are distinguished by mutable attributes... and we have those everywhere. So it's easy to make it work a little, but intractable to make it worthwhile.
>
> Given that this is a minor performance issue but doesn't affect correctness at all, I'm inclined to leave things as they are :confused:

(and for the 84 examples, see #2030/#2185/#2290 I think - TLDR nothing to worry about, it's an artifact of a bit-level representation on non-power-of-two numbers of alternatives that only really manifests on small examples)

I think it would be reasonable to add `__hash__` methods to our strategies for hashable scalars, but I still don't think making all strategies hashable is worth the trouble.  I should add a comment block explaining this to the `OneOfStrategy` implementation, though...
Aha, thank you for the insights! :)

Resolving patch:
diff --git a/hypothesis-python/src/hypothesis/strategies/_internal/collections.py b/hypothesis-python/src/hypothesis/strategies/_internal/collections.py
--- a/hypothesis-python/src/hypothesis/strategies/_internal/collections.py
+++ b/hypothesis-python/src/hypothesis/strategies/_internal/collections.py
@@ -167,11 +167,7 @@ def do_draw(self, data):
         return result
 
 
-class UniqueSampledListStrategy(ListStrategy):
-    def __init__(self, elements, min_size, max_size, keys):
-        super().__init__(elements, min_size, max_size)
-        self.keys = keys
-
+class UniqueSampledListStrategy(UniqueListStrategy):
     def do_draw(self, data):
         should_draw = cu.many(
             data,
diff --git a/hypothesis-python/src/hypothesis/strategies/_internal/core.py b/hypothesis-python/src/hypothesis/strategies/_internal/core.py
--- a/hypothesis-python/src/hypothesis/strategies/_internal/core.py
+++ b/hypothesis-python/src/hypothesis/strategies/_internal/core.py
@@ -1799,9 +1799,8 @@ def timedeltas(
 
 
 class CompositeStrategy(SearchStrategy):
-    def __init__(self, definition, label, args, kwargs):
+    def __init__(self, definition, args, kwargs):
         self.definition = definition
-        self.__label = label
         self.args = args
         self.kwargs = kwargs
 
@@ -1809,7 +1808,7 @@ def do_draw(self, data):
         return self.definition(data.draw, *self.args, **self.kwargs)
 
     def calc_label(self):
-        return self.__label
+        return calc_label_from_cls(self.definition)
 
 
 @cacheable
@@ -1841,12 +1840,10 @@ def composite(f: Callable[..., Ex]) -> Callable[..., SearchStrategy[Ex]]:
     }
     new_argspec = argspec._replace(args=argspec.args[1:], annotations=annots)
 
-    label = calc_label_from_cls(f)
-
     @defines_strategy
     @define_function_signature(f.__name__, f.__doc__, new_argspec)
     def accept(*args, **kwargs):
-        return CompositeStrategy(f, label, args, kwargs)
+        return CompositeStrategy(f, args, kwargs)
 
     accept.__module__ = f.__module__
     return accept
diff --git a/hypothesis-python/src/hypothesis/strategies/_internal/strategies.py b/hypothesis-python/src/hypothesis/strategies/_internal/strategies.py
--- a/hypothesis-python/src/hypothesis/strategies/_internal/strategies.py
+++ b/hypothesis-python/src/hypothesis/strategies/_internal/strategies.py
@@ -547,21 +547,31 @@ def calc_is_cacheable(self, recur):
     @property
     def element_strategies(self):
         if self.__element_strategies is None:
+            # While strategies are hashable, they use object.__hash__ and are
+            # therefore distinguished only by identity.
+            #
+            # In principle we could "just" define a __hash__ method
+            # (and __eq__, but that's easy in terms of type() and hash())
+            # to make this more powerful, but this is harder than it sounds:
+            #
+            # 1. Strategies are often distinguished by non-hashable attributes,
+            #    or by attributes that have the same hash value ("^.+" / b"^.+").
+            # 2. LazyStrategy: can't reify the wrapped strategy without breaking
+            #    laziness, so there's a hash each for the lazy and the nonlazy.
+            #
+            # Having made several attempts, the minor benefits of making strategies
+            # hashable are simply not worth the engineering effort it would take.
+            # See also issues #2291 and #2327.
+            seen = {self}
             strategies = []
             for arg in self.original_strategies:
                 check_strategy(arg)
                 if not arg.is_empty:
-                    strategies.extend([s for s in arg.branches if not s.is_empty])
-            pruned = []
-            seen = set()
-            for s in strategies:
-                if s is self:
-                    continue
-                if s in seen:
-                    continue
-                seen.add(s)
-                pruned.append(s)
-            self.__element_strategies = pruned
+                    for s in arg.branches:
+                        if s not in seen and not s.is_empty:
+                            seen.add(s)
+                            strategies.append(s)
+            self.__element_strategies = strategies
         return self.__element_strategies
 
     def calc_label(self):


Test patch:
diff --git a/requirements/test.in b/requirements/test.in
--- a/requirements/test.in
+++ b/requirements/test.in
@@ -3,3 +3,4 @@ sortedcontainers
 pexpect
 pytest
 pytest-xdist
+zipp < 2  # dropped support for 3.5; required via pytest via importlib-metadata
diff --git a/requirements/test.txt b/requirements/test.txt
--- a/requirements/test.txt
+++ b/requirements/test.txt
@@ -8,10 +8,10 @@ apipkg==1.5               # via execnet
 atomicwrites==1.3.0       # via pytest
 attrs==19.3.0
 execnet==1.7.1            # via pytest-xdist
-importlib-metadata==1.3.0  # via pluggy, pytest
-more-itertools==8.0.2     # via pytest, zipp
+importlib-metadata==1.4.0  # via pluggy, pytest
+more-itertools==8.1.0     # via pytest, zipp
 packaging==20.0           # via pytest
-pexpect==4.7.0
+pexpect==4.8.0
 pluggy==0.13.1            # via pytest
 ptyprocess==0.6.0         # via pexpect
 py==1.8.1                 # via pytest
@@ -19,7 +19,7 @@ pyparsing==2.4.6          # via packaging
 pytest-forked==1.1.3      # via pytest-xdist
 pytest-xdist==1.31.0
 pytest==5.3.2
-six==1.13.0               # via packaging, pytest-xdist
+six==1.14.0               # via packaging, pytest-xdist
 sortedcontainers==2.1.0
 wcwidth==0.1.8            # via pytest
-zipp==0.6.0               # via importlib-metadata
+zipp==1.0.0