Repository: HypothesisWorks/hypothesis
Pull request: Rule bundle printing
Closes #4361 by improving printing of the steps (going into exception notes).

There are some matters of taste wrt exactly how the bundle variables should look, which I'll address in comments below.

Problem statement:
return symbols in reproduce statement undefined with targets in stateful test.
I write a simple stateful test to reproduce this issue: 

```python 
from hypothesis.stateful import RuleBasedStateMachine, Bundle, rule, initialize
from hypothesis import settings, Verbosity
from hypothesis import strategies as st
import random

@settings(max_examples=100, verbosity=Verbosity.verbose)
class JuicefsMachine(RuleBasedStateMachine):
    Numbers = Bundle("numbers")  
    DoubledNumbers = Bundle("doubled_numbers")  

    def __init__(self):
        super(JuicefsMachine, self).__init__()
        print('Init')

    @initialize(targets=(Numbers, DoubledNumbers))
    def init_number(self):
        return (0, 0)  

    @rule(targets=(Numbers, DoubledNumbers), 
          number=st.integers(min_value=1, max_value=100))
    def generate_and_double(self, number):
        doubled = number * 2
        print(f"Generated number: {number}, Doubled number: {doubled}")
        return number, doubled

    @rule(number=Numbers)
    def use_number(self, number):
        number = number [0]
        print(f"Using number: {number}")
        assert number < 90, "number should be less than 100"

    # @rule(doubled_number=DoubledNumbers)
    # def use_doubled_number(self, doubled_number):
    #     print(f"Using doubled number: {doubled_number}")
    #     assert doubled_number % 2 == 0, "Doubled number should be even"

    def teardown(self):
        print("Teardown.")

if __name__ == '__main__':
    juicefs_machine = JuicefsMachine.TestCase()
    juicefs_machine.runTest()
```

This is the log message: 
```
Init
Generated number: 90, Doubled number: 180
Using number: 90
Teardown.
Traceback (most recent call last):
  File "/root/jfs/.github/scripts/hypo/number.py", line 42, in <module>
    juicefs_machine.runTest()
  File "/root/jfs/venv/lib/python3.12/site-packages/hypothesis/stateful.py", line 460, in runTest
    run_state_machine_as_test(cls, settings=self.settings)
  File "/root/jfs/venv/lib/python3.12/site-packages/hypothesis/stateful.py", line 261, in run_state_machine_as_test
    state_machine_test(state_machine_factory)
  File "/root/jfs/venv/lib/python3.12/site-packages/hypothesis/stateful.py", line 115, in run_state_machine
    @given(st.data())
               ^^^^^^^
  File "/root/jfs/venv/lib/python3.12/site-packages/hypothesis/core.py", line 1837, in wrapped_test
    raise the_error_hypothesis_found
  File "/root/jfs/.github/scripts/hypo/number.py", line 30, in use_number
    assert number < 90, "number should be less than 100"
           ^^^^^^^^^^^
AssertionError: number should be less than 100
Falsifying example:
state = JuicefsMachine()
doubled_numbers_0 = state.init_number()
doubled_numbers_1 = state.generate_and_double(number=90)
state.use_number(number=numbers_1)
state.teardown()
Explanation:
    These lines were always and only run by failing examples:
        /root/jfs/.github/scripts/hypo/number.py:28
```

in Falsifying example above,  parameter numbers_1 in  state.use_number(number=numbers_1) is not defined before. I think it is because I use targets in generate_and_double and hypothesis framework can't handle it well.

Hints from issue discussion:
The `targets=` argument to `@rule` is for replicating the same value to multiple bundles - see https://hypothesis.readthedocs.io/en/latest/reference/api.html#hypothesis.stateful.rule. So, in this case, the tuple return value is added to both bundles. To get the desired result, you will need to either define one rule per bundle -- or have a single bundle containing (number, doubled) pairs.

But you're right that the output is confusing,
```
doubled_numbers_0 = state.init_number()
doubled_numbers_1 = state.generate_and_double(number=90)
```
should be something like this, given that there are multiple targets,
```
numbers_0 = doubled_numbers_0 = state.init_number()
numbers_1 = doubled_numbers_1 = state.generate_and_double(number=90)
```

Commit message: Special-case 1-target multi-result

Patch to review:
diff --git a/hypothesis-python/src/hypothesis/stateful.py b/hypothesis-python/src/hypothesis/stateful.py
index 2ade095dbe..46aeb6eba9 100644
--- a/hypothesis-python/src/hypothesis/stateful.py
+++ b/hypothesis-python/src/hypothesis/stateful.py
@@ -21,6 +21,7 @@
 from copy import copy
 from functools import lru_cache
 from io import StringIO
+from itertools import chain
 from time import perf_counter
 from typing import Any, Callable, ClassVar, Optional, TypeVar, Union, overload
 from unittest import TestCase
@@ -390,15 +391,34 @@ def invariants(cls):
     def _repr_step(self, rule, data, result):
         output_assignment = ""
         if rule.targets:
+            number_of_results = (
+                len(result.values) if isinstance(result, MultipleResults) else 1
+            )
+            number_of_last_names = len(rule.targets) * number_of_results
+            last_names = self._last_names(number_of_last_names)
+            names_per_target = [
+                last_names[i :: len(rule.targets)] for i in range(len(rule.targets))
+            ]
             if isinstance(result, MultipleResults):
                 if len(result.values) == 1:
-                    output_assignment = f"({self._last_names(1)[0]},) = "
+                    output_names = [f"({name},)" for name in chain(*names_per_target)]
+                    output_assignment = " = ".join(output_names) + " = "
                 elif result.values:
-                    number_of_last_names = len(rule.targets) * len(result.values)
-                    output_names = self._last_names(number_of_last_names)
-                    output_assignment = ", ".join(output_names) + " = "
+                    # We prefer the per-result ordering - otherwise we get interleaved
+                    # ordering for duplicate targets when there are >1 results.
+                    # Note: "per-target" and "per-result" is only true in the abstract,
+                    # the actual value ordering may differ.
+                    names_per_result = list(zip(*names_per_target))  # transposed
+                    output_names = [", ".join(names) for names in names_per_result]
+                    if len(rule.targets) == 1:
+                        output_assignment = ", ".join(output_names) + " = "
+                    else:
+                        # Note special curly-bracket notation, it is meant to signal that this case
+                        # (only) does not have strict ordering of the result-to-target mapping
+                        output_assignment = "{" + "; ".join(output_names) + "} = "
             else:
-                output_assignment = self._last_names(1)[0] + " = "
+                output_names = chain(*names_per_target)
+                output_assignment = " = ".join(output_names) + " = "
         args = ", ".join("%s=%s" % kv for kv in data.items())
         return f"{output_assignment}state.{rule.function.__name__}({args})"
 
diff --git a/hypothesis-python/tests/cover/test_stateful.py b/hypothesis-python/tests/cover/test_stateful.py
index 74c7bd1d0e..fc4859ed14 100644
--- a/hypothesis-python/tests/cover/test_stateful.py
+++ b/hypothesis-python/tests/cover/test_stateful.py
@@ -1248,6 +1248,83 @@ def fail_fast(self, param):
     )
 
 
+@pytest.mark.parametrize(
+    "initial,repr_",
+    [
+        ("ret1", "a_0 = b_0 = a_1 = "),
+        (multiple(), ""),
+        (multiple("ret1"), "(a_0,) = (b_0,) = (a_1,) = "),
+        (multiple("ret1", "ret2"), "{a_0, b_0, a_1; a_2, b_1, a_3} = "),
+    ],
+)
+def test_multiple_targets_repr(initial, repr_):
+    class Machine(RuleBasedStateMachine):
+        a = Bundle("a")
+        b = Bundle("b")
+
+        @initialize(targets=(a, b, a))
+        def initialize(self):
+            return initial
+
+        @rule()
+        def fail_fast(self):
+            raise AssertionError
+
+    Machine.TestCase.settings = NO_BLOB_SETTINGS
+    with pytest.raises(AssertionError) as err:
+        run_state_machine_as_test(Machine)
+
+    result = "\n".join(err.value.__notes__)
+    assert (
+        result
+        == f"""
+Falsifying example:
+state = Machine()
+{repr_}state.initialize()
+state.fail_fast()
+state.teardown()
+""".strip()
+    )
+
+
+@pytest.mark.parametrize(
+    "initial,repr_",
+    [
+        ("ret1", "a_0 = "),
+        (multiple(), ""),
+        (multiple("ret1"), "(a_0,) = "),
+        (multiple("ret1", "ret2"), "a_0, a_1 = "),
+    ],
+)
+def test_single_target_repr(initial, repr_):
+    class Machine(RuleBasedStateMachine):
+        a = Bundle("a")
+
+        @initialize(target=a)
+        def initialize(self):
+            return initial
+
+        @rule()
+        def fail_fast(self):
+            raise AssertionError
+
+    Machine.TestCase.settings = NO_BLOB_SETTINGS
+    with pytest.raises(AssertionError) as err:
+        run_state_machine_as_test(Machine)
+
+    result = "\n".join(err.value.__notes__)
+    assert (
+        result
+        == f"""
+Falsifying example:
+state = Machine()
+{repr_}state.initialize()
+state.fail_fast()
+state.teardown()
+""".strip()
+    )
+
+
 def test_multiple_targets():
     class Machine(RuleBasedStateMachine):
         a = Bundle("a")
@@ -1278,7 +1355,7 @@ def fail_fast(self, a1, a2, a3, b1, b2, b3):
         == """
 Falsifying example:
 state = Machine()
-a_0, b_0, a_1, b_1, a_2, b_2 = state.initialize()
+{a_0, b_0; a_1, b_1; a_2, b_2} = state.initialize()
 state.fail_fast(a1=a_2, a2=a_1, a3=a_0, b1=b_2, b2=b_1, b3=b_0)
 state.teardown()
 """.strip()
@@ -1318,7 +1395,7 @@ def fail_fast(self, a1, a2, a3, a4, a5, a6, b1, b2, b3):
         == """
 Falsifying example:
 state = Machine()
-a_0, b_0, a_1, a_2, b_1, a_3, a_4, b_2, a_5 = state.initialize()
+{a_0, b_0, a_1; a_2, b_1, a_3; a_4, b_2, a_5} = state.initialize()
 state.fail_fast(a1=a_5, a2=a_4, a3=a_3, a4=a_2, a5=a_1, a6=a_0, b1=b_2, b2=b_1, b3=b_0)
 state.teardown()
 """.strip()


Code review comments:
diff_hunk: @@ -1248,6 +1248,83 @@ def fail_fast(self, param):
     )
 
 
+@pytest.mark.parametrize(
+    "initial,repr_",
+    [
+        ("ret1", "a_0 = b_0 = a_1 = "),
+        (multiple(), ""),
+        (multiple("ret1"), "(a_0,) = (b_0,) = (a_1,) = "),
+        (multiple("ret1", "ret2"), "{a_0, b_0, a_1; a_2, b_1, a_3} = "),
+    ],
+)
+def test_multiple_targets_repr(initial, repr_):
+    class Machine(RuleBasedStateMachine):
+        a = Bundle("a")
+        b = Bundle("b")
+
+        @initialize(targets=(a, b, a))
+        def initialize(self):
+            return initial
+
+        @rule()
+        def fail_fast(self):
+            raise AssertionError
+
+    Machine.TestCase.settings = NO_BLOB_SETTINGS
+    with pytest.raises(AssertionError) as err:
+        run_state_machine_as_test(Machine)
+
+    result = "\n".join(err.value.__notes__)
+    assert (
+        result
+        == f"""
+Falsifying example:
+state = Machine()
+{repr_}state.initialize()
+state.fail_fast()
+state.teardown()
+""".strip()
+    )
+
+
+@pytest.mark.parametrize(
+    "initial,repr_",
+    [
+        ("ret1", "a_0 = "),
+        (multiple(), ""),
+        (multiple("ret1"), "(a_0,) = "),
+        (multiple("ret1", "ret2"), "a_0, a_1 = "),
path: hypothesis-python/tests/cover/test_stateful.py
text: @author:
or `"(a_0, a_1) = "` ?

diff_hunk: @@ -390,15 +391,34 @@ def invariants(cls):
     def _repr_step(self, rule, data, result):
         output_assignment = ""
         if rule.targets:
+            number_of_results = (
+                len(result.values) if isinstance(result, MultipleResults) else 1
+            )
+            number_of_last_names = len(rule.targets) * number_of_results
+            last_names = self._last_names(number_of_last_names)
+            names_per_target = [
+                last_names[i :: len(rule.targets)] for i in range(len(rule.targets))
+            ]
             if isinstance(result, MultipleResults):
                 if len(result.values) == 1:
-                    output_assignment = f"({self._last_names(1)[0]},) = "
+                    output_names = [f"({name},)" for name in chain(*names_per_target)]
+                    output_assignment = " = ".join(output_names) + " = "
                 elif result.values:
-                    number_of_last_names = len(rule.targets) * len(result.values)
-                    output_names = self._last_names(number_of_last_names)
-                    output_assignment = ", ".join(output_names) + " = "
+                    # We prefer the per-result ordering - otherwise we get interleaved
+                    # ordering for duplicate targets when there are >1 results.
+                    # Note: "per-target" and "per-result" is only true in the abstract,
+                    # the actual value ordering may differ.
+                    names_per_result = list(zip(*names_per_target))  # transposed
+                    output_names = [", ".join(names) for names in names_per_result]
+                    if len(rule.targets) == 1:
+                        output_assignment = ", ".join(output_names) + " = "
+                    else:
+                        # Note special curly-bracket notation, it is meant to signal that this case
+                        # (only) does not have strict ordering of the result-to-target mapping
+                        output_assignment = "{" + "; ".join(output_names) + "} = "
path: hypothesis-python/src/hypothesis/stateful.py
text: @user1:
I'd prefer to keep the output as valid syntax, if we can.

@author:
Sure! But I feel the "= ... = ... =" syntax used in other cases is misleading here, so perhaps just a comma-separated list (tuple) of all variables?

[edit] Or leave the curlies in place, just `;`→`,`

@user1:
I should really be sleeping at this point, but: if we take out the randomness, it seems like we have some cases for multiple-assignment (`a1 = b1 = state.whatever()`), some for tuple-unpacking-assignment, and some where there might be both.  In the "both" case, I think we really want to do that in multiple statements, so we have `a1, a2 = ...; b1, b2 = a1, a2`, rather than trying to cram it into a single line.

@author:
Sounds like a plan :grin: For ease of later discussion, this is the situation at PR current state:

|  Tgt↓\Res→ | Single | Multiple-1 | Multiple-2
| -------- | ------- | -------- | ------- | 
|`a` | `a_0 = state.init()` | `a_0, = state.init()` | `a_0, a_1 = state.init()` |
|`a, b, a` | `a_0 = b_0 = a_1 = state.init()` | `a_0, = b_0, = a_1, = state.init()` | `a_0, a_1 = state.init()`<br>`b_0, b_1 = a_0, a_1`<br>`a_2, a_3 = a_0, a_1` |

Feel free to adjust the table to the desired state. ~Apart from parentheses-or-not quibbles I think it looks good _except_ the lower right cell. (oh, and the bundle names are usually longer I guess)~

[edit: Table updated after pushing multiple-lines commit]
[edit: Table updated after changing assignment order from per-result to per-target]

@author:
Ok, I looked into the code and it looks like there is no randomness in assigning values to bundles. I misinterpreted the random-draw side when a target is duplicated. Updated code to line-split the multi-multi case.

@author:
@user2-HD Is the table above what you intended? If so, I'll merge (unless you beat me to it)

@user1:
Yes, that's what I intended, except that I think single-element tuples should be parenthesized.  It's just too easy to miss the trailing comma alone - see e.g. [ruff docs](https://docs.astral.sh/ruff/formatter/black/#single-element-tuples-are-always-parenthesized) or https://github.com/psf/black/issues/897.

(we'll also need to add a changelog entry, and wait for the current merge to finish)

@author:
Oops, I'd forgotten to git-add RELEASE.rst