Repository: HypothesisWorks/hypothesis

Issue:
st.recursive with max_leaves=0 hangs
```
import hypothesis.strategies as st
st.recursive(st.none(), extend=st.tuples, max_leaves=0).example()
```
never returns.

I'd expect it to return just `st.none()` (no recursion, just base).

(-1 never returns either by the way, but that request doesn't make any sense)


Discussion:
Wow, it looks like we have _no_ input validation here at all!

We should check that the base strategy is an instance of strategy, that the extend argument is callable, and that max_leaves is at least one, and deprecate non-integer max_leaves. 
I have no skin in the.game, but it might be an esthetic advantage to support the degenerate leaves=0 case by returning the original strategy?
The degenerate case _is_ `max_leaves=1`, and... currently we always do

https://github.com/HypothesisWorks/hypothesis/blob/fc566bf820a4b418a6cf54a15defbe9b44aaca5d/hypothesis-python/src/hypothesis/strategies/_internal/recursive.py#L77-L83

so yeah, special-casing `max_leaves=1` would be nice 😅 
I see, or rather I don't see ;-) I thought I was just not understanding the nomenclature, and that `max_leaves` was another word for what I'd call `depth`. But now I'm even more confused, I recognize the `2 ** (len - 1)` pattern but don't see the leaves:
```
>>> for n in range(1,17):
>>>     ex = set()
>>>     strat = st.recursive(st.none(), st.tuples, max_leaves=n)
>>>     for i in range(200):
>>>         ex.add(strat.example())
>>>     print(n, sorted(ex, key=lambda v: str(v)))
      
1 [(None,), None]
2 [((None,),), (None,), None]
3 [((None,),), (None,), None]
4 [(((None,),),), ((None,),), (None,), None]
5 [(((None,),),), ((None,),), (None,), None]
6 [(((None,),),), ((None,),), (None,), None]
7 [(((None,),),), ((None,),), (None,), None]
8 [((((None,),),),), (((None,),),), ((None,),), (None,), None]
9 [((((None,),),),), (((None,),),), ((None,),), (None,), None]
10 [((((None,),),),), (((None,),),), ((None,),), (None,), None]
11 [((((None,),),),), (((None,),),), ((None,),), (None,), None]
12 [((((None,),),),), (((None,),),), ((None,),), (None,), None]
13 [((((None,),),),), (((None,),),), ((None,),), (None,), None]
14 [((((None,),),),), (((None,),),), ((None,),), (None,), None]
15 [((((None,),),),), (((None,),),), ((None,),), (None,), None]
16 [(((((None,),),),),), ((((None,),),),), (((None,),),), ((None,),), (None,), None]
```
To me, this looks like "single leaf" and `depth = ceil(sqrt(n+1)+1)`. But knowing little, I will totally accept it if you say it looks correct :-)
Note that the actually limitation on the number of leaves is set elsewhere:

https://github.com/HypothesisWorks/hypothesis/blob/fc566bf820a4b418a6cf54a15defbe9b44aaca5d/hypothesis-python/src/hypothesis/strategies/_internal/recursive.py#L105-L106

In this degenerate case our tightest upper-bound on the number of leaves is instead imposed by this structural limitation, but in general that's not the case (e.g. `st.recursive(st.none(), st.lists)`) and we're just using `log2(n)` as a reasonable (?) heuristic for "how deep should the recursion be allowed to go, given this max_leaves".
I see. Thanks for taking the time to explain!

Resolving patch:
diff --git a/hypothesis-python/src/hypothesis/strategies/_internal/recursive.py b/hypothesis-python/src/hypothesis/strategies/_internal/recursive.py
--- a/hypothesis-python/src/hypothesis/strategies/_internal/recursive.py
+++ b/hypothesis-python/src/hypothesis/strategies/_internal/recursive.py
@@ -11,8 +11,10 @@
 import threading
 from contextlib import contextmanager
 
+from hypothesis.errors import InvalidArgument
 from hypothesis.internal.lazyformat import lazyformat
 from hypothesis.internal.reflection import get_pretty_function_description
+from hypothesis.internal.validation import check_type
 from hypothesis.strategies._internal.strategies import (
     OneOfStrategy,
     SearchStrategy,
@@ -97,6 +99,11 @@ def do_validate(self):
         check_strategy(extended, f"extend({self.limited_base!r})")
         self.limited_base.validate()
         extended.validate()
+        check_type(int, self.max_leaves, "max_leaves")
+        if self.max_leaves <= 0:
+            raise InvalidArgument(
+                f"max_leaves={self.max_leaves!r} must be greater than zero"
+            )
 
     def do_draw(self, data):
         count = 0
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
@@ -978,6 +978,7 @@ def branches(self) -> List[SearchStrategy[Ex]]:
 
 @check_function
 def check_strategy(arg, name=""):
+    assert isinstance(name, str)
     if not isinstance(arg, SearchStrategy):
         hint = ""
         if isinstance(arg, (list, tuple)):


Test patch:
diff --git a/hypothesis-python/tests/cover/test_recursive.py b/hypothesis-python/tests/cover/test_recursive.py
--- a/hypothesis-python/tests/cover/test_recursive.py
+++ b/hypothesis-python/tests/cover/test_recursive.py
@@ -73,3 +73,18 @@ def test_can_exclude_branching_with_max_leaves(t):
 @given(st.recursive(st.none(), lambda x: st.one_of(x, x)))
 def test_issue_1502_regression(s):
     pass
+
+
+@pytest.mark.parametrize(
+    "s",
+    [
+        st.recursive(None, st.lists),
+        st.recursive(st.none(), lambda x: None),
+        st.recursive(st.none(), st.lists, max_leaves=-1),
+        st.recursive(st.none(), st.lists, max_leaves=0),
+        st.recursive(st.none(), st.lists, max_leaves=1.0),
+    ],
+)
+def test_invalid_args(s):
+    with pytest.raises(InvalidArgument):
+        s.example()