diff --git a/docs/releases/3.2.2.md b/docs/releases/3.2.2.md new file mode 100644 index 00000000..b0a6405e --- /dev/null +++ b/docs/releases/3.2.2.md @@ -0,0 +1,96 @@ +# StateChart 3.2.2 + +*Not released yet* + +## Bug fixes in 3.2.2 + +### Event declarations inside `State.Compound` + +A `State.Compound` (or `State.Parallel`) class body only understood the assignment form of an +event declaration (`visit_pub = bag_end.to(green_dragon)`). The two other documented forms were +silently dropped: the name was bound to a detached object and the transition it wrapped stayed +{ref}`eventless `, firing as soon as its source state became active. + +The `Event` class now declares an event inside a nested state body: + +```py +>>> from statemachine import Event, State, StateChart + +>>> class Journey(StateChart): +... class shire(State.Compound): +... bag_end = State(initial=True) +... green_dragon = State() +... +... visit_pub = Event(bag_end.to(green_dragon)) +... +... road = State(final=True) +... depart = Event(shire.to(road)) + +>>> sm = Journey() +>>> set(sm.configuration_values) == {"shire", "bag_end"} +True + +>>> sm.send("visit_pub") +>>> set(sm.configuration_values) == {"shire", "green_dragon"} +True + +``` + +Before this fix, `Journey` started already in `green_dragon` and `visit_pub` was not among its +events. The attribute name now becomes the event `id`, an explicit `id` takes precedence, and +the `error_` / `done_state_` / `done_invoke_` prefixes expand to their dotted form. + +The same applies to the `@.to()` decorator, which declares an event and its +inline action at once. Inside a compound body it registered no event and never ran its body: + +```py +>>> class Gate(StateChart): +... class gate(State.Compound): +... locked = State(initial=True) +... unlocked = State() +... +... push = unlocked.to(locked) +... +... @locked.to(unlocked) +... def coin(self): +... return "accepted" +... +... broken = State(final=True) +... smash = gate.to(broken) + +>>> sm = Gate() +>>> sm.send("coin") +'accepted' + +>>> set(sm.configuration_values) == {"gate", "unlocked"} +True + +``` + +Two differences from the top-level form remain, both because a nested body is evaluated before +the owning class exists: an explicit `id` that differs from the attribute name does not also +bind the attribute name, and an `Event` with no transitions is dropped instead of becoming a +class attribute. + +Reported by [@Dolecor](https://github.com/Dolecor). + +[#643](https://github.com/fgmacedo/python-statemachine/issues/643). + +### `delay` and `internal` dropped from an explicit `Event` + +`Event(dark.to(lit), delay=50)` rebuilt the event without its `delay`, so +`BeaconsOfGondor.light.delay` was `0` and the event fired immediately instead of being queued. +The same happened to `internal`. Both are now preserved, although `internal` still has no +effect at trigger time: + +```py +>>> class BeaconsOfGondor(StateChart): +... dark = State(initial=True) +... lit = State(final=True) +... +... light = Event(dark.to(lit), delay=50) + +>>> BeaconsOfGondor.light.delay +50 + +``` diff --git a/docs/releases/index.md b/docs/releases/index.md index 28929df3..f62455f7 100644 --- a/docs/releases/index.md +++ b/docs/releases/index.md @@ -16,6 +16,7 @@ Requires Python 3.10+. ```{toctree} :maxdepth: 2 +3.2.2 3.2.1 3.2.0 3.1.2 diff --git a/statemachine/factory.py b/statemachine/factory.py index 3bced620..33f66e28 100644 --- a/statemachine/factory.py +++ b/statemachine/factory.py @@ -316,6 +316,8 @@ def add_from_attributes(cls, attrs): # noqa: C901 transitions=value._transitions, id=event_id, name=value.name, + delay=value.delay, + internal=value.internal, ) cls.add_event(event=new_event, old_event=value) # Ensure the event is accessible by the Python attribute name diff --git a/statemachine/state.py b/statemachine/state.py index 065cc52a..02268083 100644 --- a/statemachine/state.py +++ b/statemachine/state.py @@ -8,6 +8,7 @@ from .callbacks import CallbackGroup from .callbacks import CallbackPriority from .callbacks import CallbackSpecList +from .event import Event from .event import _expand_event_id from .exceptions import InvalidDefinition from .i18n import _ @@ -56,7 +57,7 @@ def __call__(self, *states: "State | NestedStateFactory", **kwargs): class NestedStateFactory(type): - def __new__( # type: ignore [misc] + def __new__( # type: ignore [misc] # noqa: C901 cls, classname, bases, attrs, name="", **kwargs ) -> "State": if not bases: @@ -76,6 +77,8 @@ def __new__( # type: ignore [misc] states = [] history = [] callbacks = {} + # Order is significant: a ``HistoryState`` is a ``State``, and an ``Event`` is a + # callable ``str``, so both would be captured by a later branch. for key, value in attrs.items(): if isinstance(value, States): for state_id, state in value.items(): @@ -89,6 +92,21 @@ def __new__( # type: ignore [misc] states.append(value) elif isinstance(value, TransitionList): value.add_event(_expand_event_id(key)) + elif isinstance(value, Event): + if value._transitions is not None: + event_id = value.id if value._has_real_id else _expand_event_id(key) + value._transitions.add_event( + Event( + id=event_id, + name=value.name, + delay=value.delay, + internal=value.internal, + ) + ) + elif getattr(value, "attr_name", None): + if value.is_event: + value._transitions.add_event(key) + callbacks[value.attr_name] = value elif callable(value): callbacks[key] = value diff --git a/tests/test_events.py b/tests/test_events.py index b4ce48ab..fc00d675 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -59,6 +59,18 @@ class StartMachine(StateChart): assert [e.name for e in StartMachine.events] == ["Start the machine"] assert StartMachine.start.name == "Start the machine" + def test_accept_delay_and_internal(self): + class BeaconsOfGondor(StateChart): + dark = State(initial=True) + lit = State(final=True) + + light = Event(dark.to(lit), delay=50, internal=True) + + (registered,) = BeaconsOfGondor.events + assert (registered.delay, registered.internal) == (50, True) + assert BeaconsOfGondor.light.delay == 50 + assert BeaconsOfGondor().light.delay == 50 + def test_derive_name_from_id(self): class StartMachine(StateChart): created = State(initial=True) diff --git a/tests/test_state.py b/tests/test_state.py index 2e2d7f1c..e87cfc04 100644 --- a/tests/test_state.py +++ b/tests/test_state.py @@ -1,5 +1,6 @@ import pytest from statemachine.orderedset import OrderedSet +from statemachine.states import States from statemachine import State from statemachine import StateChart @@ -83,3 +84,11 @@ def test_ordered_set_union(): s1 = OrderedSet([1, 2]) result = s1.union([3, 4], [5, 6]) assert list(result) == [1, 2, 3, 4, 5, 6] + + +def test_states_getattr_unknown_name(): + """States exposes its members as attributes and rejects anything else.""" + states = States({"draft": State("Draft")}) + assert states.draft.name == "Draft" + with pytest.raises(AttributeError, match="published not found in States"): + _ = states.published diff --git a/tests/test_statechart_compound.py b/tests/test_statechart_compound.py index 1506e1f5..de1c52f3 100644 --- a/tests/test_statechart_compound.py +++ b/tests/test_statechart_compound.py @@ -13,6 +13,7 @@ import pytest from statemachine.states import States +from statemachine import Event from statemachine import State from statemachine import StateChart from tests.machines.compound.middle_earth_journey import MiddleEarthJourney @@ -235,7 +236,10 @@ class wrapper(State.Compound): await sm_runner.processing_loop(sm) assert {"done"} == set(sm.configuration_values) - async def test_error_execution_inside_compound(self, sm_runner): + @pytest.mark.parametrize( + "declare", [lambda transitions: transitions, Event], ids=["bare", "Event"] + ) + async def test_error_execution_inside_compound(self, sm_runner, declare): """error_execution inside a compound body registers error.execution event.""" def raise_error(): @@ -246,15 +250,19 @@ class active(State.Compound): ok = State(initial=True) failing = State() - trigger = ok.to(failing, on=raise_error) + trigger = declare(ok.to(failing, on=raise_error)) errored = State() - error_execution = failing.to(errored) + error_execution = declare(failing.to(errored)) done = State(final=True) finish = active.to(done) + assert "error.execution" in [event.id for event in ErrorInCompound.events] + sm = await sm_runner.start(ErrorInCompound) + assert "ok" in sm.configuration_values + await sm_runner.send(sm, "trigger") assert "errored" in sm.configuration_values @@ -302,3 +310,183 @@ class inner(State.Compound): await sm_runner.send(sm, "inner_to_baz_bar") assert {OuterStates.BAR} == set(sm.configuration_values) + + +@pytest.mark.timeout(5) +class TestEventClassInsideCompound: + """The ``Event`` class inside a ``State.Compound`` body (#643).""" + + async def test_event_class_declares_a_named_event(self, sm_runner): + """``Event()`` binds the event instead of leaving it eventless.""" + + class QuirkyJourney(StateChart): + class shire(State.Compound): + bag_end = State(initial=True) + green_dragon = State() + + visit_pub = Event(bag_end.to(green_dragon)) + + road = State(final=True) + depart = Event(shire.to(road)) + + assert [event.id for event in QuirkyJourney.events] == ["visit_pub", "depart"] + + sm = await sm_runner.start(QuirkyJourney) + assert {"shire", "bag_end"} == set(sm.configuration_values) + + await sm_runner.send(sm, "visit_pub") + assert {"shire", "green_dragon"} == set(sm.configuration_values) + + def test_name_delay_and_internal_are_preserved(self): + class NamedEvent(StateChart): + class shire(State.Compound): + bag_end = State(initial=True) + green_dragon = State(final=True) + + visit_pub = Event( + bag_end.to(green_dragon), name="Visit the pub", delay=50, internal=True + ) + + (registered,) = NamedEvent.events + assert (registered.id, registered.name) == ("visit_pub", "Visit the pub") + assert (registered.delay, registered.internal) == (50, True) + assert NamedEvent.visit_pub.name == "Visit the pub" + + def test_expanded_id_drops_the_declared_arguments(self): + """A space-separated id declares distinct events, so each one names itself. + + The ``error_`` prefix expands to ``"error_foo error.foo"``, which reuses that format + to mean two spellings of a single event. See + ``test_multiple_ids_from_the_same_event_will_be_converted_to_multiple_events``. + """ + + class ErrorEvent(StateChart): + class shire(State.Compound): + bag_end = State(initial=True) + green_dragon = State(final=True) + + error_foo = Event(bag_end.to(green_dragon), name="Boom", delay=50) + + assert [event.id for event in ErrorEvent.events] == ["error_foo", "error.foo"] + assert [event.name for event in ErrorEvent.events] == ["Error foo", "Error foo"] + assert [event.delay for event in ErrorEvent.events] == [0, 0] + + async def test_explicit_id_wins_over_the_attribute_name(self, sm_runner): + class ExplicitId(StateChart): + class shire(State.Compound): + bag_end = State(initial=True) + green_dragon = State(final=True) + + visit_pub = Event(bag_end.to(green_dragon), id="pub.visit") + + assert [event.id for event in ExplicitId.events] == ["pub.visit"] + + sm = await sm_runner.start(ExplicitId) + await sm_runner.send(sm, "pub.visit") + assert {"shire", "green_dragon"} == set(sm.configuration_values) + + async def test_combined_transitions(self, sm_runner): + class Wandering(StateChart): + class shire(State.Compound): + bag_end = State(initial=True) + green_dragon = State() + + wander = Event(bag_end.to(green_dragon) | green_dragon.to(bag_end)) + + road = State(final=True) + depart = Event(shire.to(road)) + + sm = await sm_runner.start(Wandering) + await sm_runner.send(sm, "wander") + assert "green_dragon" in sm.configuration_values + + await sm_runner.send(sm, "wander") + assert "bag_end" in sm.configuration_values + + async def test_event_inside_parallel_region(self, sm_runner): + class WarOfTheRing(StateChart): + class war(State.Parallel): + class quest(State.Compound): + start = State(initial=True) + end = State(final=True) + + go = Event(start.to(end)) + + class battle(State.Compound): + fighting = State(initial=True) + won = State(final=True) + + victory = Event(fighting.to(won)) + + sm = await sm_runner.start(WarOfTheRing) + assert {"war", "quest", "start", "battle", "fighting"} == set(sm.configuration_values) + + await sm_runner.send(sm, "go") + await sm_runner.send(sm, "victory") + assert {"war", "quest", "end", "battle", "won"} == set(sm.configuration_values) + + def test_transition_less_event_declares_nothing(self): + """A nested ``Event`` carries only its id, so with no transitions it is dropped.""" + + class Placeholder(StateChart): + class shire(State.Compound): + bag_end = State(initial=True) + green_dragon = State(final=True) + + visit_pub = bag_end.to(green_dragon) + knock = Event(name="Knock on the door") + + assert [event.id for event in Placeholder.events] == ["visit_pub"] + assert not hasattr(Placeholder, "knock") + + +@pytest.mark.timeout(5) +class TestDecoratorEventInsideCompound: + """The ``@.to()`` decorator inside a ``State.Compound`` body.""" + + async def test_decorator_declares_a_named_event(self, sm_runner): + """The decorated name becomes the event, and its body runs as the ``on`` action.""" + + class Gate(StateChart): + class gate(State.Compound): + locked = State(initial=True) + unlocked = State() + + push = unlocked.to(locked) + + @locked.to(unlocked) + def coin(self): + return "accepted" + + broken = State(final=True) + smash = gate.to(broken) + + assert "coin" in [event.id for event in Gate.events] + + sm = await sm_runner.start(Gate) + assert "locked" in sm.configuration_values + + assert await sm_runner.send(sm, "coin") == "accepted" + assert "unlocked" in sm.configuration_values + + async def test_decorated_callback_is_not_an_event(self, sm_runner): + """``@.on`` keeps declaring a plain callback, not a new event.""" + + log = [] + + class Gate(StateChart): + class gate(State.Compound): + locked = State(initial=True) + unlocked = State(final=True) + + coin = locked.to(unlocked) + + @coin.on + def clink(self): + log.append("clink") + + assert [event.id for event in Gate.events] == ["coin"] + + sm = await sm_runner.start(Gate) + await sm_runner.send(sm, "coin") + assert log == ["clink"] diff --git a/tests/test_statechart_delayed.py b/tests/test_statechart_delayed.py index 5451895c..f1f7c21b 100644 --- a/tests/test_statechart_delayed.py +++ b/tests/test_statechart_delayed.py @@ -89,8 +89,7 @@ class BeaconsOfGondor(StateChart): sm = await sm_runner.start(BeaconsOfGondor) # Queue via BoundEvent.put() to avoid blocking in processing_loop - event = BoundEvent(id="light", name="Light", delay=50, _sm=sm) - event.put() + sm.light.put() # Not yet processed assert "dark" in sm.configuration_values