Repository: HypothesisWorks/hypothesis

Issue:
Add API to remove items from `stateful.Bundle`
Hey, I'm testing an API that has add and remove functionality using `RuleBasedStateMachine`. 

E.g.

```python
class OrganisationAPI(RuleBasedStateMachine):
    orgs = Bundle('Organisation')
    users = Bundle('User')

    @rule(target=orgs, user=users, name=st.text(average_size=5))
    def create_org(self, user, name):
        return api.organisation_create(name, user)

    @rule(org=orgs, user=users)
    def delete_org(self, org, user):
        return api.organisation_delete(org, user)
```

Ideally the org bundle would not re-used deleted orgs. Filing as a reminder to myself, will investigate how to implement.



Discussion:
Yep. I definitely agree this is a good idea. It's been vaguely on my TODO list for a while - implementation wise it should be pretty straightforward (behind the scenes there's just a set of live variable names in each bundle) but I didn't have an API I really liked for this. Very happy to take suggestions.

I'm going to throw out a few ideas but I'm not a fan of any of them.
1. Add `@rule(remove_from=orgs, ...)`
2. `self.orgs.remove(...)` - but I guess this prevents the machine's way to reason about where data enters and leaves the system?
3. When we return a value for `target=` we actually return a wrapper with a `.remove()` and a`.get_value()` method.

No 3 could fix a related problem I often have: Depending on the state of my model I don't always have a valid target to return. I could return `NotValid()` to signal hypothesis to not store the returned value.

But no 3 is also much uglier and makes everything much less nice (implementation and interface).

The API sketch I had that I was most happy with was to provide a consumes() function so you could do something like rule(source=consumes(org)).

In particular you can't just remove a value. It might be assigned to multiple variables.

One thing to consider: The API I would like to move towards for the rule based state machine allows bundles to be used as if they were strategies. so e.g. you should be able to have x=lists(orgs). It would be nice to have an API which would not get in the way when that comes around. The consumes() API does have the advantage of probably being compatible with this, though it may complicate the implementation significantly.

I have not yet figured out the details of this and supporting it is definitely not a requirement, but it's something to bear in mind as needing to not block.

RE the related issue: The way I usually deal with those is with assume(). So you can do assume(can return valid result); return (valid result)

To clarify: `consumes(org)` means that whatever org instance is passed in will be no longer in the Bundle after the function has been executed?

Thanks for `assume` I didn't expect it to work in the state machine context. Nice design!

More specifically it means the current name for the org instance would not be reused. In particular if you had something like 

``` python
@rule(target=orgs, source=orgs)
def copy_org(self, o):
    return o
```

Then a single organisation would appear in the list of available orgs multiple times and only one of those would be removed from the list.

Hi @Zac-HD, I see you removed the help-wanted label on this issue but is it still open to external contributors? I would love to have this feature in Hypothesis for my work and would have time to give it a try during the next weeks.
Absolutely! We removed the `help-wanted` label because we'd love help with any issue, so it wasn't much use as a classifier :wink:

My main tip for this is that we will want to make sure that the API is flexible enough to stick with long-term. Adding it to Hypothesis might be substantially more work than getting it to "useful for me", so we have a standing offer that you can hand an in-progress PR over for maintainers to finish if you're sick of it. 

So go ahead, good luck, thanks, and ask for tips if you need some! 

Resolving patch:
diff --git a/hypothesis-python/src/hypothesis/stateful.py b/hypothesis-python/src/hypothesis/stateful.py
--- a/hypothesis-python/src/hypothesis/stateful.py
+++ b/hypothesis-python/src/hypothesis/stateful.py
@@ -266,7 +266,8 @@ def __attrs_post_init__(self):
             assert not isinstance(v, BundleReferenceStrategy)
             if isinstance(v, Bundle):
                 bundles.append(v)
-                arguments[k] = BundleReferenceStrategy(v.name)
+                consume = isinstance(v, BundleConsumer)
+                arguments[k] = BundleReferenceStrategy(v.name, consume)
             else:
                 arguments[k] = v
         self.bundles = tuple(bundles)
@@ -277,8 +278,9 @@ def __attrs_post_init__(self):
 
 
 class BundleReferenceStrategy(SearchStrategy):
-    def __init__(self, name):
+    def __init__(self, name, consume=False):
         self.name = name
+        self.consume = consume
 
     def do_draw(self, data):
         machine = data.draw(self_strategy)
@@ -288,13 +290,17 @@ def do_draw(self, data):
         # Shrink towards the right rather than the left. This makes it easier
         # to delete data generated earlier, as when the error is towards the
         # end there can be a lot of hard to remove padding.
-        return bundle[cu.integer_range(data, 0, len(bundle) - 1, center=len(bundle))]
+        position = cu.integer_range(data, 0, len(bundle) - 1, center=len(bundle))
+        if self.consume:
+            return bundle.pop(position)
+        else:
+            return bundle[position]
 
 
 class Bundle(SearchStrategy):
-    def __init__(self, name):
+    def __init__(self, name, consume=False):
         self.name = name
-        self.__reference_strategy = BundleReferenceStrategy(name)
+        self.__reference_strategy = BundleReferenceStrategy(name, consume)
 
     def do_draw(self, data):
         machine = data.draw(self_strategy)
@@ -302,6 +308,29 @@ def do_draw(self, data):
         return machine.names_to_values[reference.name]
 
 
+class BundleConsumer(Bundle):
+    def __init__(self, bundle):
+        super(BundleConsumer, self).__init__(bundle.name, consume=True)
+
+
+def consumes(bundle):
+    """When introducing a rule in a RuleBasedStateMachine, this function can
+    be used to mark bundles from which each value used in a step with the
+    given rule should be removed. This function returns a strategy object
+    that can be manipulated and combined like any other.
+
+    For example, a rule declared with
+
+    ``@rule(value1=b1, value2=consumes(b2), value3=lists(consumes(b3)))``
+
+    will consume a value from Bundle ``b2`` and several values from Bundle
+    ``b3`` to populate ``value2`` and ``value3`` each time it is executed.
+    """
+    if not isinstance(bundle, Bundle):
+        raise TypeError("Argument to be consumed must be a bundle.")
+    return BundleConsumer(bundle)
+
+
 def _convert_targets(targets, target):
     """Single validator and convertor for target arguments."""
     if target is not None:
@@ -361,9 +390,13 @@ def rule(targets=(), target=None, **kwargs):
     each case.
 
     kwargs then define the arguments that will be passed to the function
-    invocation. If their value is a Bundle then values that have previously
-    been produced for that bundle will be provided, if they are anything else
-    it will be turned into a strategy and values from that will be provided.
+    invocation. If their value is a Bundle, or if it is ``consumes(b)``
+    where ``b`` is a Bundle, then values that have previously been produced
+    for that bundle will be provided. If ``consumes`` is used, the value
+    will also be removed from the bundle.
+
+    Any other kwargs should be strategies and values from them will be
+    provided.
     """
     converted_targets = _convert_targets(targets, target)
 


Test patch:
diff --git a/hypothesis-python/tests/cover/test_stateful.py b/hypothesis-python/tests/cover/test_stateful.py
--- a/hypothesis-python/tests/cover/test_stateful.py
+++ b/hypothesis-python/tests/cover/test_stateful.py
@@ -39,6 +39,7 @@
     Bundle,
     GenericStateMachine,
     RuleBasedStateMachine,
+    consumes,
     initialize,
     invariant,
     precondition,
@@ -352,6 +353,46 @@ def execute_step(self, step):
         assert False
 
 
+class MachineWithConsumingRule(RuleBasedStateMachine):
+    b1 = Bundle("b1")
+    b2 = Bundle("b2")
+
+    def __init__(self):
+        self.created_counter = 0
+        self.consumed_counter = 0
+        super(MachineWithConsumingRule, self).__init__()
+
+    @invariant()
+    def bundle_length(self):
+        assert len(self.bundle("b1")) == self.created_counter - self.consumed_counter
+
+    @rule(target=b1)
+    def populate_b1(self):
+        self.created_counter += 1
+        return self.created_counter
+
+    @rule(target=b2, consumed=consumes(b1))
+    def depopulate_b1(self, consumed):
+        self.consumed_counter += 1
+        return consumed
+
+    @rule(consumed=lists(consumes(b1)))
+    def depopulate_b1_multiple(self, consumed):
+        self.consumed_counter += len(consumed)
+
+    @rule(value1=b1, value2=b2)
+    def check(self, value1, value2):
+        assert value1 != value2
+
+
+TestMachineWithConsumingRule = MachineWithConsumingRule.TestCase
+
+
+def test_consumes_typecheck():
+    with pytest.raises(TypeError):
+        consumes(integers())
+
+
 def test_ratchetting_raises_flaky():
     with raises(Flaky):
         FlakyRatchettingMachine.TestCase().runTest()