Repository: HypothesisWorks/hypothesis

Issue:
Error when `@precondition` is used without `@rule` (or `@invariant`)
[`@precondition`](https://hypothesis.readthedocs.io/en/latest/reference/api.html#hypothesis.stateful.precondition) only has an effect when decorating a function that is also decorated with [`@rule`](https://hypothesis.readthedocs.io/en/latest/reference/api.html#hypothesis.stateful.rule) (or [`@invariant`](https://hypothesis.readthedocs.io/en/latest/reference/api.html#hypothesis.stateful.invariant)). Since this indicates a misunderstanding from the user (it's me, I'm the user), we should error in this case.




Discussion:

Patch:
diff --git a/hypothesis-python/RELEASE.rst b/hypothesis-python/RELEASE.rst
new file mode 100644
index 0000000000..b1ae7dd38c
--- /dev/null
+++ b/hypothesis-python/RELEASE.rst
@@ -0,0 +1,3 @@
+RELEASE_TYPE: patch
+
+|@precondition| now errors if used without |@rule| or |@invariant|. Doing so has no effect and is indicative of a user error (:issue:`4413`).
diff --git a/hypothesis-python/docs/prolog.rst b/hypothesis-python/docs/prolog.rst
index b9853f42fc..88757c2ed7 100644
--- a/hypothesis-python/docs/prolog.rst
+++ b/hypothesis-python/docs/prolog.rst
@@ -137,6 +137,7 @@
 .. |@rule| replace:: :func:`@rule <hypothesis.stateful.rule>`
 .. |@precondition| replace:: :func:`@precondition <hypothesis.stateful.precondition>`
 .. |@initialize| replace:: :func:`@initialize <hypothesis.stateful.initialize>`
+.. |@invariant| replace:: :func:`@invariant <hypothesis.stateful.invariant>`
 .. |RuleBasedStateMachine| replace:: :class:`~hypothesis.stateful.RuleBasedStateMachine`
 .. |run_state_machine_as_test| replace:: :func:`~hypothesis.stateful.run_state_machine_as_test`
 
diff --git a/hypothesis-python/src/hypothesis/stateful.py b/hypothesis-python/src/hypothesis/stateful.py
index 5b1f6fc588..e10085dcf0 100644
--- a/hypothesis-python/src/hypothesis/stateful.py
+++ b/hypothesis-python/src/hypothesis/stateful.py
@@ -19,7 +19,6 @@
 import dataclasses
 import inspect
 from collections.abc import Iterable, Sequence
-from copy import copy
 from dataclasses import dataclass, field
 from functools import lru_cache
 from io import StringIO
@@ -115,7 +114,7 @@ def get_state_machine_test(state_machine_factory, *, settings=None, _min_steps=0
     @given(st.data())
     def run_state_machine(factory, data):
         cd = data.conjecture_data
-        machine = factory()
+        machine: RuleBasedStateMachine = factory()
         check_type(RuleBasedStateMachine, machine, "state_machine_factory()")
         cd.hypothesis_runner = machine
         machine._observability_predicates = cd._observability_predicates  # alias
@@ -270,6 +269,13 @@ def __setattr__(cls, name, value):
         return super().__setattr__(name, value)
 
 
+@dataclass
+class _SetupState:
+    rules: list["Rule"]
+    invariants: list["Invariant"]
+    initializers: list["Rule"]
+
+
 class RuleBasedStateMachine(metaclass=StateMachineMeta):
     """A RuleBasedStateMachine gives you a structured way to define state machines.
 
@@ -281,23 +287,14 @@ class RuleBasedStateMachine(metaclass=StateMachineMeta):
     At any given point a random applicable rule will be executed.
     """
 
-    _rules_per_class: ClassVar[dict[type, list["Rule"]]] = {}
-    _invariants_per_class: ClassVar[dict[type, list["Invariant"]]] = {}
-    _initializers_per_class: ClassVar[dict[type, list["Rule"]]] = {}
+    _setup_state_per_class: ClassVar[dict[type, _SetupState]] = {}
 
     def __init__(self) -> None:
-        if not self.rules():
-            raise InvalidDefinition(f"Type {type(self).__name__} defines no rules")
-        self.bundles: dict[str, list] = {}
-        self.names_counters: collections.Counter = collections.Counter()
-        self.names_list: list[str] = []
-        self.names_to_values: dict[str, Any] = {}
-        self.__stream = StringIO()
-        self.__printer = RepresentationPrinter(
-            self.__stream, context=_current_build_context.value
-        )
-        self._initialize_rules_to_run = copy(self.initialize_rules())
-        self._rules_strategy = RuleStrategy(self)
+        setup_state = self.setup_state()
+        if not setup_state.rules:
+            raise InvalidDefinition(
+                f"State machine {type(self).__name__} defines no rules"
+            )
 
         if isinstance(s := vars(type(self)).get("settings"), Settings):
             tname = type(self).__name__
@@ -308,6 +305,21 @@ def __init__(self) -> None:
                 f"on the {tname} class."
             )
 
+        self.rules = setup_state.rules
+        self.invariants = setup_state.invariants
+        # copy since we pop from this as we run initialize rules.
+        self._initialize_rules_to_run = setup_state.initializers.copy()
+
+        self.bundles: dict[str, list] = {}
+        self.names_counters: collections.Counter = collections.Counter()
+        self.names_list: list[str] = []
+        self.names_to_values: dict[str, Any] = {}
+        self.__stream = StringIO()
+        self.__printer = RepresentationPrinter(
+            self.__stream, context=_current_build_context.value
+        )
+        self._rules_strategy = RuleStrategy(self)
+
     def _pretty_print(self, value):
         if isinstance(value, VarReference):
             return value.name
@@ -342,49 +354,44 @@ def bundle(self, name):
         return self.bundles.setdefault(name, [])
 
     @classmethod
-    def initialize_rules(cls) -> list["Rule"]:
-        try:
-            return cls._initializers_per_class[cls]
-        except KeyError:
-            pass
-
-        initializers = []
-        for _, v in inspect.getmembers(cls):
-            r = getattr(v, INITIALIZE_RULE_MARKER, None)
-            if r is not None:
-                initializers.append(r)
-        cls._initializers_per_class[cls] = initializers
-        return initializers
-
-    @classmethod
-    def rules(cls) -> list["Rule"]:
+    def setup_state(cls):
         try:
-            return cls._rules_per_class[cls]
+            return cls._setup_state_per_class[cls]
         except KeyError:
             pass
 
-        rules = []
-        for _, v in inspect.getmembers(cls):
-            r = getattr(v, RULE_MARKER, None)
-            if r is not None:
-                rules.append(r)
-        cls._rules_per_class[cls] = rules
-        return rules
-
-    @classmethod
-    def invariants(cls) -> list["Invariant"]:
-        try:
-            return cls._invariants_per_class[cls]
-        except KeyError:
-            pass
-
-        invariants = []
-        for _, v in inspect.getmembers(cls):
-            invariant = getattr(v, INVARIANT_MARKER, None)
+        rules: list[Rule] = []
+        initializers: list[Rule] = []
+        invariants: list[Invariant] = []
+
+        for _name, f in inspect.getmembers(cls):
+            rule = getattr(f, RULE_MARKER, None)
+            initializer = getattr(f, INITIALIZE_RULE_MARKER, None)
+            invariant = getattr(f, INVARIANT_MARKER, None)
+            if rule is not None:
+                rules.append(rule)
+            if initializer is not None:
+                initializers.append(initializer)
             if invariant is not None:
                 invariants.append(invariant)
-        cls._invariants_per_class[cls] = invariants
-        return invariants
+
+            if (
+                getattr(f, PRECONDITIONS_MARKER, None) is not None
+                and rule is None
+                and invariant is None
+            ):
+                raise InvalidDefinition(
+                    f"{_rule_qualname(f)} has been decorated with @precondition, "
+                    "but not @rule (or @invariant), which is not allowed. A "
+                    "precondition must be combined with a rule or an invariant, "
+                    "since it has no effect alone."
+                )
+
+        state = _SetupState(
+            rules=rules, initializers=initializers, invariants=invariants
+        )
+        cls._setup_state_per_class[cls] = state
+        return state
 
     def _repr_step(self, rule: "Rule", data: Any, result: Any) -> str:
         output_assignment = ""
@@ -435,7 +442,7 @@ def printer(obj, p, cycle, name=name):
                 self.bundles.setdefault(target, []).append(VarReference(name))
 
     def check_invariants(self, settings, output, runtimes):
-        for invar in self.invariants():
+        for invar in self.invariants:
             if self._initialize_rules_to_run and not invar.check_during_init:
                 continue
             if not all(precond(self) for precond in invar.preconditions):
@@ -716,6 +723,16 @@ def _convert_targets(targets, target):
 _RuleWrapper = Callable[[_RuleType[Ex]], _RuleType[Ex]]
 
 
+def _rule_qualname(f: Any) -> str:
+    # we define rules / invariants / initializes inside of wrapper functions, which
+    # makes f.__qualname__ look like:
+    #   test_precondition.<locals>.BadStateMachine.has_precondition_but_no_rule
+    # which is not ideal. This function returns just
+    #   BadStateMachine.has_precondition_but_no_rule
+    # instead.
+    return f.__qualname__.rsplit("<locals>.")[-1]
+
+
 # We cannot exclude `target` or `targets` from any of these signatures because
 # otherwise they would be matched against the `kwargs`, either leading to
 # overlapping overloads of incompatible return types, or a concrete
@@ -800,15 +817,23 @@ def rule(
     def accept(f):
         if getattr(f, INVARIANT_MARKER, None):
             raise InvalidDefinition(
-                "A function cannot be used for both a rule and an invariant.",
-                Settings.default,
+                f"{_rule_qualname(f)} is used with both @rule and @invariant, "
+                "which is not allowed. A function may be either a rule or an "
+                "invariant, but not both."
             )
         existing_rule = getattr(f, RULE_MARKER, None)
         existing_initialize_rule = getattr(f, INITIALIZE_RULE_MARKER, None)
-        if existing_rule is not None or existing_initialize_rule is not None:
+        if existing_rule is not None:
             raise InvalidDefinition(
-                "A function cannot be used for two distinct rules. ", Settings.default
+                f"{_rule_qualname(f)} has been decorated with @rule twice, which is "
+                "not allowed."
             )
+        if existing_initialize_rule is not None:
+            raise InvalidDefinition(
+                f"{_rule_qualname(f)} has been decorated with both @rule and "
+                "@initialize, which is not allowed."
+            )
+
         preconditions = getattr(f, PRECONDITIONS_MARKER, ())
         rule = Rule(
             targets=converted_targets,
@@ -876,19 +901,28 @@ def initialize(
     def accept(f):
         if getattr(f, INVARIANT_MARKER, None):
             raise InvalidDefinition(
-                "A function cannot be used for both a rule and an invariant.",
-                Settings.default,
+                f"{_rule_qualname(f)} is used with both @initialize and @invariant, "
+                "which is not allowed. A function may be either an initialization "
+                "rule or an invariant, but not both."
             )
         existing_rule = getattr(f, RULE_MARKER, None)
         existing_initialize_rule = getattr(f, INITIALIZE_RULE_MARKER, None)
-        if existing_rule is not None or existing_initialize_rule is not None:
+        if existing_rule is not None:
+            raise InvalidDefinition(
+                f"{_rule_qualname(f)} has been decorated with both @rule and "
+                "@initialize, which is not allowed."
+            )
+        if existing_initialize_rule is not None:
             raise InvalidDefinition(
-                "A function cannot be used for two distinct rules. ", Settings.default
+                f"{_rule_qualname(f)} has been decorated with @initialize twice, "
+                "which is not allowed."
             )
         preconditions = getattr(f, PRECONDITIONS_MARKER, ())
         if preconditions:
             raise InvalidDefinition(
-                "An initialization rule cannot have a precondition. ", Settings.default
+                f"{_rule_qualname(f)} has been decorated with both @initialize and "
+                "@precondition, which is not allowed. An initialization rule "
+                "runs unconditionally and may not have a precondition."
             )
         rule = Rule(
             targets=converted_targets,
@@ -945,7 +979,9 @@ def precondition_wrapper(*args, **kwargs):
         existing_initialize_rule = getattr(f, INITIALIZE_RULE_MARKER, None)
         if existing_initialize_rule is not None:
             raise InvalidDefinition(
-                "An initialization rule cannot have a precondition. ", Settings.default
+                f"{_rule_qualname(f)} has been decorated with both @initialize and "
+                "@precondition, which is not allowed. An initialization rule "
+                "runs unconditionally and may not have a precondition."
             )
 
         rule = getattr(f, RULE_MARKER, None)
@@ -1013,14 +1049,14 @@ def is_nonzero(self):
     def accept(f):
         if getattr(f, RULE_MARKER, None) or getattr(f, INITIALIZE_RULE_MARKER, None):
             raise InvalidDefinition(
-                "A function cannot be used for both a rule and an invariant.",
-                Settings.default,
+                f"{_rule_qualname(f)} has been decorated with both @invariant and "
+                "@rule, which is not allowed."
             )
         existing_invariant = getattr(f, INVARIANT_MARKER, None)
         if existing_invariant is not None:
             raise InvalidDefinition(
-                "A function cannot be used for two distinct invariants.",
-                Settings.default,
+                f"{_rule_qualname(f)} has been decorated with @invariant twice, "
+                "which is not allowed."
             )
         preconditions = getattr(f, PRECONDITIONS_MARKER, ())
         invar = Invariant(
@@ -1043,10 +1079,10 @@ def invariant_wrapper(*args, **kwargs):
 
 
 class RuleStrategy(SearchStrategy):
-    def __init__(self, machine):
+    def __init__(self, machine: RuleBasedStateMachine) -> None:
         super().__init__()
         self.machine = machine
-        self.rules = list(machine.rules())
+        self.rules = machine.rules.copy()
 
         self.enabled_rules_strategy = st.shared(
             FeatureStrategy(at_least_one_of={r.function.__name__ for r in self.rules}),
diff --git a/hypothesis-python/src/hypothesis/strategies/_internal/featureflags.py b/hypothesis-python/src/hypothesis/strategies/_internal/featureflags.py
index f37ff421e9..915b9d7f18 100644
--- a/hypothesis-python/src/hypothesis/strategies/_internal/featureflags.py
+++ b/hypothesis-python/src/hypothesis/strategies/_internal/featureflags.py
@@ -124,7 +124,7 @@ def __repr__(self) -> str:
 
 
 class FeatureStrategy(SearchStrategy[FeatureFlags]):
-    def __init__(self, at_least_one_of: Sequence[Hashable] = ()):
+    def __init__(self, at_least_one_of: Iterable[Hashable] = ()):
         super().__init__()
         self._at_least_one_of = frozenset(at_least_one_of)
 


Test patch:
diff --git a/hypothesis-python/tests/cover/test_stateful.py b/hypothesis-python/tests/cover/test_stateful.py
index 27da3323c0..4b5395a33f 100644
--- a/hypothesis-python/tests/cover/test_stateful.py
+++ b/hypothesis-python/tests/cover/test_stateful.py
@@ -906,7 +906,13 @@ def fail_fast(self, param):
 
 
 def test_initialize_rule_dont_mix_with_precondition():
-    with pytest.raises(InvalidDefinition) as exc:
+    with pytest.raises(
+        InvalidDefinition,
+        match=(
+            "BadStateMachine.initialize has been decorated with both @initialize "
+            "and @precondition"
+        ),
+    ):
 
         class BadStateMachine(RuleBasedStateMachine):
             @precondition(lambda self: True)
@@ -914,11 +920,15 @@ class BadStateMachine(RuleBasedStateMachine):
             def initialize(self):
                 pass
 
-    assert "An initialization rule cannot have a precondition." in str(exc.value)
-
     # Also test decorator application in reverse order
 
-    with pytest.raises(InvalidDefinition) as exc:
+    with pytest.raises(
+        InvalidDefinition,
+        match=(
+            "BadStateMachineReverseOrder.initialize has been decorated with both "
+            "@initialize and @precondition"
+        ),
+    ):
 
         class BadStateMachineReverseOrder(RuleBasedStateMachine):
             @initialize()
@@ -926,11 +936,12 @@ class BadStateMachineReverseOrder(RuleBasedStateMachine):
             def initialize(self):
                 pass
 
-    assert "An initialization rule cannot have a precondition." in str(exc.value)
-
 
 def test_initialize_rule_dont_mix_with_regular_rule():
-    with pytest.raises(InvalidDefinition) as exc:
+    with pytest.raises(
+        InvalidDefinition,
+        match="BadStateMachine.initialize has been decorated with both @rule and @initialize",
+    ):
 
         class BadStateMachine(RuleBasedStateMachine):
             @rule()
@@ -938,11 +949,26 @@ class BadStateMachine(RuleBasedStateMachine):
             def initialize(self):
                 pass
 
-    assert "A function cannot be used for two distinct rules." in str(exc.value)
+    with pytest.raises(
+        InvalidDefinition,
+        match=(
+            "BadStateMachineReverseOrder.initialize has been decorated with both "
+            "@rule and @initialize"
+        ),
+    ):
+
+        class BadStateMachineReverseOrder(RuleBasedStateMachine):
+            @initialize()
+            @rule()
+            def initialize(self):
+                pass
 
 
 def test_initialize_rule_cannot_be_double_applied():
-    with pytest.raises(InvalidDefinition) as exc:
+    with pytest.raises(
+        InvalidDefinition,
+        match="BadStateMachine.initialize has been decorated with @initialize twice",
+    ):
 
         class BadStateMachine(RuleBasedStateMachine):
             @initialize()
@@ -950,8 +976,6 @@ class BadStateMachine(RuleBasedStateMachine):
             def initialize(self):
                 pass
 
-    assert "A function cannot be used for two distinct rules." in str(exc.value)
-
 
 def test_initialize_rule_in_state_machine_with_inheritance():
     class ParentStateMachine(RuleBasedStateMachine):
@@ -1440,3 +1464,23 @@ def check(self, instance):
 
     Machine.TestCase.settings = Settings(stateful_step_count=5, max_examples=10)
     run_state_machine_as_test(Machine)
+
+
+def test_precondition_cannot_be_used_without_rule():
+    class BadStateMachine(RuleBasedStateMachine):
+        @precondition(lambda: True)
+        def has_precondition_but_no_rule(self):
+            pass
+
+        @rule(n=st.integers())
+        def trivial(self, n):
+            pass
+
+    with pytest.raises(
+        InvalidDefinition,
+        match=(
+            "BadStateMachine.has_precondition_but_no_rule has been decorated "
+            "with @precondition, but not @rule"
+        ),
+    ):
+        BadStateMachine.TestCase().runTest()