Repository: HypothesisWorks/hypothesis

Issue:
Provide SearchStrategy optionals
I noticed that I frequently write my own helper `optionals`:

```python
_T = TypeVar("_T")
def optionals(strategy: st.SearchStrategy[_T]) -> st.SearchStrategy[Optional[_T]]:
    return st.one_of(st.none(), strategy)
```
Obviously, I use it to generate values for variables that either take a value or are `None`.

I wanted to ask if this coud be part of the strategies submodule. Then I could stop to copy that implementation from project to project. I expect it to be helpful for others too.


Discussion:
Given that the shorthand

```python
st.none() | strategy  # equivalent to `st.one_of(st.none(), strategy)`
```
is already available to express this, I wouldn't think adding `optionals()` to the API is warranted.

That being said, I do notice that `st.none() | strategy`  doesn't have the desired type annotation associated with it - it gets resolved as `SearchStrategy[None]`.

@Zac-HD do you think adding an overload for the case of a two-argument invocation of `one_of` would be useful in terms of providing more accurate type annotation resolution for the `|` syntax?


(IGNORE ALL OF THE BELOW - MY IDE WASNT CONFIGURED CORRECTLY)

Also, an aside...

As I was playing with @MaxG87 's `optionals` implementation, I noticed some weird behavior from pylance...

![image](https://user-images.githubusercontent.com/29104956/105541852-6c964700-5cc6-11eb-9acf-ff7b4dfdde40.png)

Any idea why the above signature might produce this confused annotation, @Zac-HD ? Perhaps something for us to point out to the pyright folks.

The proposed shorthand is good for a single usage but cannot be reused. Having the `optionals` strategy would allow

    @given(st.optionals(st.text()), st.optionals(st.integers()))
    def test_can_handle_missing_info(name, age):

If I had to use the shorthand, I either would have to duplicate the logic or predefine things like `maybe_text = st.none() | st.text()` everywhere where needed. It also would not be very flexible. For example, I cannot control the length of `maybe_text` directly, let alone character strategies.

A very weak argument at last: The easiness of `st.just(None)` did not hinder the introduction of `st.nones()`.
> A very weak argument at last: The easiness of `st.just(None)` did not hinder the introduction of `st.nones()`

It *also* predates [our API design guide](https://github.com/HypothesisWorks/hypothesis/blob/master/guides/api-style.rst), so.  I'd still be inclined to accept it on the basis that `None` *really is* specially common, but there'd be an argument against now too.

> Why the confused annotations?

Looks like `SearchStrategy.__or__` is not annotated!  I'll open a PR with type hints for that and `st.one_of()` :grin: 

Resolving patch:
diff --git a/hypothesis-python/src/hypothesis/extra/ghostwriter.py b/hypothesis-python/src/hypothesis/extra/ghostwriter.py
--- a/hypothesis-python/src/hypothesis/extra/ghostwriter.py
+++ b/hypothesis-python/src/hypothesis/extra/ghostwriter.py
@@ -188,6 +188,7 @@ def _strategy_for(param: inspect.Parameter) -> Union[st.SearchStrategy, InferTyp
         return st.text()
     for strategy, names in _GUESS_STRATEGIES_BY_NAME:
         if param.name in names:
+            assert isinstance(strategy, st.SearchStrategy)
             return strategy
     # And if all that failed, we'll return nothing() - the user will have to
     # fill this in by hand, and we'll leave a comment to that effect later.
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
@@ -296,11 +296,55 @@ def none() -> SearchStrategy[None]:
     return just(None)
 
 
+T4 = TypeVar("T4")
+T5 = TypeVar("T5")
+
+
 @overload
 def one_of(args: Sequence[SearchStrategy[Any]]) -> SearchStrategy[Any]:
     raise NotImplementedError
 
 
+@overload  # noqa: F811
+def one_of(a1: SearchStrategy[Ex]) -> SearchStrategy[Ex]:
+    raise NotImplementedError
+
+
+@overload  # noqa: F811
+def one_of(
+    a1: SearchStrategy[Ex], a2: SearchStrategy[K]
+) -> SearchStrategy[Union[Ex, K]]:
+    raise NotImplementedError
+
+
+@overload  # noqa: F811
+def one_of(
+    a1: SearchStrategy[Ex], a2: SearchStrategy[K], a3: SearchStrategy[V]
+) -> SearchStrategy[Union[Ex, K, V]]:
+    raise NotImplementedError
+
+
+@overload  # noqa: F811
+def one_of(
+    a1: SearchStrategy[Ex],
+    a2: SearchStrategy[K],
+    a3: SearchStrategy[V],
+    a4: SearchStrategy[T4],
+) -> SearchStrategy[Union[Ex, K, V, T4]]:
+    raise NotImplementedError
+
+
+@overload  # noqa: F811
+def one_of(
+    a1: SearchStrategy[Ex],
+    a2: SearchStrategy[K],
+    a3: SearchStrategy[V],
+    a4: SearchStrategy[T4],
+    a5: SearchStrategy[T5],
+) -> SearchStrategy[Union[Ex, K, V, T4, T5]]:
+    raise NotImplementedError
+
+
 @overload  # noqa: F811
 def one_of(*args: SearchStrategy[Any]) -> SearchStrategy[Any]:
     raise NotImplementedError
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
@@ -17,7 +17,7 @@
 import warnings
 from collections import defaultdict
 from random import choice as random_choice
-from typing import Any, Callable, Generic, List, TypeVar
+from typing import Any, Callable, Generic, List, TypeVar, Union
 
 from hypothesis._settings import HealthCheck, Phase, Verbosity, settings
 from hypothesis.control import _current_build_context, assume
@@ -53,14 +53,6 @@
 )
 
 
-def one_of_strategies(xs):
-    """Helper function for unioning multiple strategies."""
-    xs = tuple(xs)
-    if not xs:
-        raise ValueError("Cannot join an empty list of strategies")
-    return OneOfStrategy(xs)
-
-
 def recursive_property(name, default):
     """Handle properties which may be mutually recursive among a set of
     strategies.
@@ -360,7 +352,7 @@ def do_filtered_draw(self, data, filter_strategy):
     def branches(self) -> List["SearchStrategy[Ex]"]:
         return [self]
 
-    def __or__(self, other):
+    def __or__(self, other: "SearchStrategy[T]") -> "SearchStrategy[Union[Ex, T]]":
         """Return a strategy which produces values by randomly drawing from one
         of this strategy or the other strategy.
 
@@ -368,7 +360,7 @@ def __or__(self, other):
         """
         if not isinstance(other, SearchStrategy):
             raise ValueError("Cannot | a SearchStrategy with %r" % (other,))
-        return one_of_strategies((self, other))
+        return OneOfStrategy((self, other))
 
     def validate(self) -> None:
         """Throw an exception if the strategy is not valid.


Test patch:
diff --git a/hypothesis-python/tests/cover/test_searchstrategy.py b/hypothesis-python/tests/cover/test_searchstrategy.py
--- a/hypothesis-python/tests/cover/test_searchstrategy.py
+++ b/hypothesis-python/tests/cover/test_searchstrategy.py
@@ -20,7 +20,6 @@
 
 from hypothesis.errors import InvalidArgument
 from hypothesis.strategies import booleans, integers, just, tuples
-from hypothesis.strategies._internal.strategies import one_of_strategies
 from tests.common.debug import assert_no_examples
 
 
@@ -30,11 +29,6 @@ def test_or_errors_when_given_non_strategy():
         bools | "foo"
 
 
-def test_joining_zero_strategies_fails():
-    with pytest.raises(ValueError):
-        one_of_strategies(())
-
-
 SomeNamedTuple = namedtuple("SomeNamedTuple", ("a", "b"))
 
 
diff --git a/whole-repo-tests/test_type_hints.py b/whole-repo-tests/test_type_hints.py
--- a/whole-repo-tests/test_type_hints.py
+++ b/whole-repo-tests/test_type_hints.py
@@ -59,11 +59,20 @@ def get_mypy_analysed_type(fname, val):
         ("lists(none())", "list[None]"),
         ("dictionaries(integers(), datetimes())", "dict[int, datetime.datetime]"),
         ("data()", "hypothesis.strategies._internal.core.DataObject"),
+        ("none() | integers()", "Union[None, int]"),
         # Ex`-1 stands for recursion in the whole type, i.e. Ex`0 == Union[...]
         ("recursive(integers(), lists)", "Union[list[Ex`-1], int]"),
-        # See https://github.com/python/mypy/issues/5269 - fix the hints on
-        # `one_of` and document the minimum Mypy version when the issue is fixed.
-        ("one_of(integers(), text())", "Any"),
+        # We have overloads for up to five types, then fall back to Any.
+        # (why five?  JSON atoms are None|bool|int|float|str and we do that a lot)
+        ("one_of(integers(), text())", "Union[int, str]"),
+        (
+            "one_of(integers(), text(), none(), binary(), builds(list))",
+            "Union[int, str, None, bytes, list[_T`1]]",
+        ),
+        (
+            "one_of(integers(), text(), none(), binary(), builds(list), builds(dict))",
+            "Any",
+        ),
     ],
 )
 def test_revealed_types(tmpdir, val, expect):