Repository: HypothesisWorks/hypothesis

Issue:
Improve composability of @classmethod and @st.composite
I frequently use the following pattern:

```python
@dataclass
class FooBarTestCase:
    foo: T1
    bar: T2

    @classmethod
    @st.composite
    def sample_instance(draw, cls):
        ...

@given(FooBarTestCase.sample_instance())
def test_foo_bar(foo_bar_tc):
    ...
```

Please note that the order of `@classmethod` and `@st.composite` as well as the corresponding function arguments must be as above, i.e. of swapped order.

Unfortunately, now `mypy` deduces types to `draw: Type` and `cls: Callable[[st.SearchStrategy[...]], ...]`. If I use the arguments, I get type checking warnings. The only ways I know of to get around false positives is:

  1. to provide type hints (quite clumsy and has to be replicated in every project)
  2. suppress warnings by `# type: ignore`  (might suppress useful warnings later on)
  3. use `@staticmethod` (forces me to duplicate the class name)

I would be glad if there could be a better way to combine `@classmethod` and `@st.composite`. It would help if there could be a predefined type hint for `draw` I could use. For sure there might be even better ways I am not aware of.


Discussion:
Unfortunately we can't do much about this at the moment, but I've been watching [PEP 612](https://www.python.org/dev/peps/pep-0612/) and the preceeding discussions for several years now!

Once Mypy supports the PEP, we'll be able to express the way `@st.composite` removes the first argument (at least under Python >= 3.10, or with the `typing_extensions` package).  Until then, I'm afraid a `# type: ignore` comment is your best bet :confused: 
As a workaround specifically for `@classmethod`, we could probably work out some introspection to support 
```python
@st.composite
@classmethod
def f(cls, draw, ...):
```

Update: no, unfortunately we can't do anything about the order-of-arguments issue.
@MaxG87 - why not use 

```python
@dataclass
class FooBarTestCase:
    foo: T1
    bar: T2

@given(st.builds(FooBarTestCase))  # or st.from_type(FooBarTestCase)
def test_foo_bar(foo_bar_tc):
    ...
```

FWIW the type of `draw` is `Callable[[st.SearchStrategy[T]], T]`.  Unfortunately `@st.composite` cannot be properly type-annotated without [PEP 612](https://www.python.org/dev/peps/pep-0612/) (due in Python 3.10), but after that Mypy should 'just work'.
I use the `sample_instance(…)` pattern when a simple `st.builds` does not suffice. Most of the time I introduce TestCase classes for one of two reasons:

  * there are interdependencies in the generated data, e.g. a sampled range of permissive values (think: prices) and a list of elements that needs to be in the range.
  * providing expected values in complicated tests (e.g.: test case would be an entire HTML construct, reference is some value that needs to be extracted from the correct location)

In neither of these cases `st.builds(…)` can be used. A standalone sampling function however would lead to needlessly complicated function names, e.g `foo_bar_test_cases(…)`. Having `FooBarTestCase.sample_instance(…)` seems to be preferable to me.

However, I totally understand your reasons not to spoil the design of `st.composite(…)` for my very particular use case. I tried out using `@staticmethod` in the last few ways and its almost as good as using `@classmethod`. The drawbacks are so minor that they hardly count.

So, thank you for discussing this issue. I'll stick with `@staticmethod` for now.
Makes sense!  If you don't need to pass arguments (aside from the class) to your strategy, using `st.register_type_strategy(FooBarTestCase, FooBarTestCase.sample_instance(FooBarTestCase))` - or a standalone function - and then `st.from_type(FooBarTestCase)` might also be a nice pattern.

To be clear, I do intend to support this as best as we can, but as per #2634 that's just not going to be great before Python 3.10 :confused: 

Resolving patch:
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
@@ -1814,6 +1814,12 @@ def composite(f: Callable[..., Ex]) -> Callable[..., SearchStrategy[Ex]]:
     Examples from this strategy shrink by shrinking the output of each draw
     call.
     """
+    if isinstance(f, (classmethod, staticmethod)):
+        special_method = type(f)
+        f = f.__func__
+    else:
+        special_method = None
+
     argspec = getfullargspec(f)
 
     if argspec.defaults is not None and len(argspec.defaults) == len(argspec.args):
@@ -1837,6 +1843,8 @@ def accept(*args, **kwargs):
         return CompositeStrategy(f, args, kwargs)
 
     accept.__module__ = f.__module__
+    if special_method is not None:
+        return special_method(accept)
     return accept
 
 


Test patch:
diff --git a/hypothesis-python/tests/cover/test_composite.py b/hypothesis-python/tests/cover/test_composite.py
--- a/hypothesis-python/tests/cover/test_composite.py
+++ b/hypothesis-python/tests/cover/test_composite.py
@@ -130,3 +130,48 @@ def strat(draw, arg):
 
     ex = data.draw(strat(ls))
     assert ex is ls
+
+
+class ClsWithStrategyMethods:
+    @classmethod
+    @st.composite
+    def st_classmethod_then_composite(draw, cls):
+        return draw(st.integers(0, 10))
+
+    @st.composite
+    @classmethod
+    def st_composite_then_classmethod(draw, cls):
+        return draw(st.integers(0, 10))
+
+    @staticmethod
+    @st.composite
+    def st_staticmethod_then_composite(draw):
+        return draw(st.integers(0, 10))
+
+    @st.composite
+    @staticmethod
+    def st_composite_then_staticmethod(draw):
+        return draw(st.integers(0, 10))
+
+    @st.composite
+    def st_composite_method(draw, self):
+        return draw(st.integers(0, 10))
+
+
+@given(st.data())
+def test_applying_composite_decorator_to_methods(data):
+    instance = ClsWithStrategyMethods()
+    for strategy in [
+        ClsWithStrategyMethods.st_classmethod_then_composite(),
+        ClsWithStrategyMethods.st_composite_then_classmethod(),
+        ClsWithStrategyMethods.st_staticmethod_then_composite(),
+        ClsWithStrategyMethods.st_composite_then_staticmethod(),
+        instance.st_classmethod_then_composite(),
+        instance.st_composite_then_classmethod(),
+        instance.st_staticmethod_then_composite(),
+        instance.st_composite_then_staticmethod(),
+        instance.st_composite_method(),
+    ]:
+        x = data.draw(strategy)
+        assert isinstance(x, int)
+        assert 0 <= x <= 10
diff --git a/hypothesis-python/tests/cover/test_lookup.py b/hypothesis-python/tests/cover/test_lookup.py
--- a/hypothesis-python/tests/cover/test_lookup.py
+++ b/hypothesis-python/tests/cover/test_lookup.py
@@ -95,36 +95,36 @@ class Elem:
 
 
 @pytest.mark.parametrize(
-    "typ,coll_type,instance_of",
+    "typ,coll_type",
     [
-        (typing.Set[Elem], set, Elem),
-        (typing.FrozenSet[Elem], frozenset, Elem),
-        (typing.Dict[Elem, Elem], dict, Elem),
-        (typing.DefaultDict[Elem, Elem], collections.defaultdict, Elem),
-        (typing.KeysView[Elem], type({}.keys()), Elem),
-        (typing.ValuesView[Elem], type({}.values()), Elem),
-        (typing.List[Elem], list, Elem),
-        (typing.Tuple[Elem], tuple, Elem),
-        (typing.Tuple[Elem, ...], tuple, Elem),
-        (typing.Iterator[Elem], typing.Iterator, Elem),
-        (typing.Sequence[Elem], typing.Sequence, Elem),
-        (typing.Iterable[Elem], typing.Iterable, Elem),
-        (typing.Mapping[Elem, None], typing.Mapping, Elem),
-        (typing.Container[Elem], typing.Container, Elem),
-        (typing.NamedTuple("A_NamedTuple", (("elem", Elem),)), tuple, Elem),
+        (typing.Set[Elem], set),
+        (typing.FrozenSet[Elem], frozenset),
+        (typing.Dict[Elem, None], dict),
+        (typing.DefaultDict[Elem, None], collections.defaultdict),
+        (typing.KeysView[Elem], type({}.keys())),
+        (typing.ValuesView[Elem], type({}.values())),
+        (typing.List[Elem], list),
+        (typing.Tuple[Elem], tuple),
+        (typing.Tuple[Elem, ...], tuple),
+        (typing.Iterator[Elem], typing.Iterator),
+        (typing.Sequence[Elem], typing.Sequence),
+        (typing.Iterable[Elem], typing.Iterable),
+        (typing.Mapping[Elem, None], typing.Mapping),
+        (typing.Container[Elem], typing.Container),
+        (typing.NamedTuple("A_NamedTuple", (("elem", Elem),)), tuple),
     ],
     ids=repr,
 )
 @given(data=st.data())
-def test_specialised_collection_types(data, typ, coll_type, instance_of):
+def test_specialised_collection_types(data, typ, coll_type):
     ex = data.draw(from_type(typ))
     assert isinstance(ex, coll_type)
-    instances = [isinstance(elem, instance_of) for elem in ex]
+    instances = [isinstance(elem, Elem) for elem in ex]
     assert all(instances)
     assume(instances)  # non-empty collections without calling len(iterator)
 
 
-@given(from_type(typing.DefaultDict[Elem, Elem]).filter(len))
+@given(from_type(typing.DefaultDict[int, Elem]).filter(len))
 def test_defaultdict_values_type(ex):
     assert all(isinstance(elem, Elem) for elem in ex.values())