diff --git a/docs/diagram.md b/docs/diagram.md index c6d68265..0ff10c2c 100644 --- a/docs/diagram.md +++ b/docs/diagram.md @@ -460,7 +460,8 @@ The directive supports the same layout options as the standard `image` and `:target:` *(URL or empty)* : Makes the diagram clickable. When set without a value, the raw SVG is saved as a file and linked so users can open it in a new tab for - full-resolution zooming — useful for large or complex diagrams. + full-resolution zooming, useful for large or complex diagrams. The link is relative to + the page, so it also works when the documentation is served below a URL prefix. `:class:` *(space-separated strings)* : Extra CSS classes for the wrapper element. @@ -486,6 +487,8 @@ JupyterLab cells — no extra code needed: ![Approval machine on JupyterLab](images/lab_approval_machine_accepted.png) +(diagram-graphviz-errors)= + ## When Graphviz fails Every path that runs `dot` (the `svg` format, `_repr_svg_()` in Jupyter, the command line @@ -496,10 +499,11 @@ what Graphviz wrote to stderr, such as `Error: : syntax error in line 25 near ','`. The Sphinx directive reports the same text as a build warning. -A missing program raises {class}`~statemachine.contrib.diagram.GraphvizNotFoundError`, a -subclass of both `GraphvizError` and {class}`FileNotFoundError`. Catch `GraphvizError` to -handle every case; code that skips diagrams with `except FileNotFoundError` when Graphviz is -not installed keeps working: +`GraphvizError` is a {class}`~statemachine.exceptions.StateMachineError`. A missing program +raises {class}`~statemachine.contrib.diagram.GraphvizNotFoundError`, a subclass of both +`GraphvizError` and {class}`FileNotFoundError`. Catch `GraphvizError` to handle every case; +code that skips diagrams with `except FileNotFoundError` when Graphviz is not installed keeps +working: ```py >>> from statemachine.contrib.diagram import GraphvizError diff --git a/docs/events.md b/docs/events.md index d9e50cbb..5ee73fba 100644 --- a/docs/events.md +++ b/docs/events.md @@ -71,6 +71,28 @@ human-readable display name, use the `Event` class explicitly: ``` +`Event` also takes a `delay`, in milliseconds, and `internal`. Both apply by default every +time the event is triggered, and an explicit argument to `send()` overrides either one +(`delay=0`, `internal=False`). A delayed event waits in the external queue until it is due (see +{ref}`delayed-events`), and an internal event triggered from a callback runs within the +current macrostep (see {ref}`processing model`). An event cannot be both delayed and +internal: declaring it so raises {class}`~statemachine.exceptions.InvalidDefinition`. + +```py +>>> class Lantern(StateChart): +... dark = State(initial=True) +... lit = State(final=True) +... +... light = Event(dark.to(lit), delay=50) + +>>> Lantern.light.delay +50 + +``` + +Each of these forms also declares an event inside a `State.Compound` or `State.Parallel` +body (see {ref}`compound-states`). + (event-identity)= diff --git a/docs/processing_model.md b/docs/processing_model.md index a272f79c..5758e255 100644 --- a/docs/processing_model.md +++ b/docs/processing_model.md @@ -71,7 +71,9 @@ Within a single macrostep, the engine repeats: 3. If neither step produced a transition, the macrostep is **done**. After the macrostep completes, the engine picks the next event from the -**external queue** (placed by `send()`) and starts a new macrostep. +**external queue** (placed by `send()`) and starts a new macrostep. Once the +machine reaches a top-level final state it stops, and the events left in the +external queue are discarded. ### Event queues diff --git a/docs/releases/3.2.2.md b/docs/releases/3.2.2.md index e750ca61..2ba5d087 100644 --- a/docs/releases/3.2.2.md +++ b/docs/releases/3.2.2.md @@ -1,15 +1,80 @@ -# StateChart 3.2.2 +# python-statemachine 3.2.2 *Not released yet* +python-statemachine 3.2.2 is a bugfix release that also adds Python 3.15 support. Most of its +fixes close gaps between what the documentation promised and what the engine did: events +declared inside a `State.Compound` body, `delay` and `internal` declared on an `Event`, internal +events and the order of the event queues, callbacks spread over nested state bodies, and the +errors Graphviz reports when it cannot render a diagram. + ## Python compatibility in 3.2.2 Added Python 3.15 on the test matrix. python-statemachine 3.2.2 supports Python 3.10, 3.11, 3.12, 3.13, 3.14 and 3.15. +[#676](https://github.com/fgmacedo/python-statemachine/pull/676). + +## Behavior changes to check when upgrading + +Each item below fixes behavior that did not match the documented contract, which is why they +ship in a patch release. Code that relied on the old behavior can still behave differently or +fail after upgrading: + +- A transition wrapped in `Event(...)` or declared with the `@source.to(target)` decorator + inside a `State.Compound` or `State.Parallel` body now fires only on its event. It used to be + eventless. See {ref}`release-3.2.2-compound-events`. +- A `delay` declared on an `Event(...)` now takes effect. On the sync engine the processing loop + blocks the calling thread until the delay elapses. See {ref}`release-3.2.2-delay-internal`. +- An event declared `Event(..., internal=True)` and triggered from a callback now runs within + the current macrostep, before any pending external event. If it enables no transition it is + ignored like any internal event; on the sync engine a `StateMachine` used to raise + `TransitionNotAllowed`. See {ref}`release-3.2.2-delay-internal`. +- A delayed internal event now raises `InvalidDefinition`: an `Event(...)` declared with both + `delay` and `internal=True`, `raise_()` or `send(..., internal=True)` called with a `delay` or + on an event declared with a `delay` (pass `delay=0` to override the declared one), + `send(..., delay=...)` on an event declared `internal=True` (pass `internal=False` to deliver + it later on the external queue), and an SCXML, JSON or YAML `` to `#_internal` with a + delay, rejected when the document is loaded. See {ref}`release-3.2.2-delay-internal`. +- `raise_()` and `send(..., internal=True)` called from outside a callback now return the + result like `send()` does, and on a `StateMachine` they raise `TransitionNotAllowed` when the + event enables no transition. See {ref}`release-3.2.2-internal-from-outside`. +- Internal events and eventless transitions enabled by an external event now run before the + next queued external event. They used to run after the whole batch of queued external + events, and eventless guards are now evaluated once per external event. See + {ref}`release-3.2.2-macrostep`. +- Events still queued when the machine reaches a top-level final state are dropped. On the + sync engine, a `StateMachine` used to raise `TransitionNotAllowed` for them. A coroutine + awaiting a delayed one, which was never answered, now gets `None`, or `TransitionNotAllowed` + on a `StateMachine`. See + {ref}`release-3.2.2-macrostep`. +- An attribute named with the `error_`, `done_state_` or `done_invoke_` prefix now registers + two events, so `events`, `allowed_events` and `enabled_events()` list both ids. See + {ref}`release-3.2.2-prefixed-events`. +- `TriggerData.execution_time` is now on the `time.monotonic()` clock instead of epoch seconds. + See {ref}`release-3.2.2-event-queues`. +- A callback name reached by name (a naming convention, a string reference or a plain method) + that is declared in two nested state bodies, or in a nested body and the class body, or that + shares its name with an event or another attribute of the class, now raises + `InvalidDefinition`. It used to replace the other declaration. See + {ref}`release-3.2.2-name-collisions`. +- A decorated callback declared under the same name in several nested bodies now runs in each + of them, unless the name is also referenced by name. Only the last body used to run. See + {ref}`release-3.2.2-name-collisions`. +- A state reached through its parent (`sm.door.shut`) now reports its real `is_active` and + `parent`. See {ref}`release-3.2.2-nested-proxies`. +- The diagram paths that run `dot` (the `svg` format, `_repr_svg_()`, the command line and the + Sphinx directive) raise `statemachine.contrib.diagram.GraphvizError` where they raised pydot's + `AssertionError` when `dot` failed. A missing `dot` raises `GraphvizNotFoundError`, which is + still a `FileNotFoundError`. See {ref}`release-3.2.2-graphviz-error`. + ## Bug fixes in 3.2.2 -### Event declarations inside `State.Compound` +### Event declarations and queues + +(release-3.2.2-compound-events)= + +#### 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 @@ -46,7 +111,8 @@ events. The attribute name now becomes the event `id`, an explicit `id` takes pr 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: +inline action at once. Inside a compound body its transition was eventless too, and sending +the event raised `AttributeError`: ```py >>> class Gate(StateChart): @@ -75,140 +141,226 @@ 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. +class attribute. Both are tracked by +[#656](https://github.com/fgmacedo/python-statemachine/issues/656). Reported by [@Dolecor](https://github.com/Dolecor). -[#643](https://github.com/fgmacedo/python-statemachine/issues/643). +[#643](https://github.com/fgmacedo/python-statemachine/issues/643), +[#645](https://github.com/fgmacedo/python-statemachine/pull/645). -### Callback names colliding between nested state bodies +(release-3.2.2-delay-internal)= -Callbacks declared inside a `State.Compound` or `State.Parallel` body are attached to the -statechart class under their own names, with no reference to the owning state. Two sibling -regions declaring the same callback name collided, and the last body silently won: with a -`@start.to(end) def go` in each region of a parallel state, `send("go")` moved both regions but -ran only the second body. The same happened to a plain callback such as `on_enter_` -declared in two bodies, and to a nested body overriding a method of the class body. +#### `delay` and `internal` declared on an `Event` -A callback attached through a decorator, or by passing the function itself (`cond=ready`), now -runs in every body that declares it: +`light = Event(dark.to(lit), delay=50)` rebuilt the event without its `delay`, so +`BeaconsOfGondor.light.delay` was `0` and the event fired immediately. `internal` was dropped +the same way. Both are now kept on the declaration: ```py ->>> from statemachine import State, StateChart ->>> from statemachine.exceptions import InvalidDefinition - ->>> class War(StateChart): -... class war(State.Parallel): -... class quest(State.Compound): -... start = State(initial=True) -... end = State(final=True) +>>> class BeaconsOfGondor(StateChart): +... dark = State(initial=True) +... lit = State(final=True) ... -... @start.to(end) -... def go(self): -... print("quest") +... light = Event(dark.to(lit), delay=50) + +>>> BeaconsOfGondor.light.delay +50 + +``` + +A declared `delay` now applies every time the event is triggered, so `sm.light()` and +`sm.send("light")` queue the event for 50 ms. On the sync engine the processing loop blocks the +calling thread until the delay elapses (see {ref}`delayed-events`). + +A declared `internal=True` is now honored when the event is triggered: `sm.go()` and +`sm.send("go")` from a callback put the event on the internal queue, so it runs within the +current macrostep, before any pending external event. It used to go to the external queue +and run after them: + +```py +>>> class Muster(StateChart): +... camp = State(initial=True) +... riding = State(final=True) +... besieged = State(final=True) ... -... class battle(State.Compound): -... fighting = State(initial=True) -... won = State(final=True) +... summon = camp.to.itself(on="call") +... ride = Event(camp.to(riding), internal=True) +... siege = camp.to(besieged) ... -... @fighting.to(won) -... def go(self): -... print("battle") +... def call(self): +... self.send("siege") +... self.ride() ->>> sm = War() ->>> _ = sm.send("go") -quest -battle +>>> sm = Muster() +>>> sm.send("summon") +>>> sm.riding.is_active +True ``` -A name reached by name, through a naming convention, a string reference or a plain method, -cannot tell the bodies apart. Such a name declared in two nested bodies, or in a nested body and -the class body, is now rejected at class creation: +A delay never reached the internal queue: `raise_("go", delay=50)` fired at once. A macrostep +takes no time, and SCXML forbids `delay` on a `` to `#_internal`, so a delayed internal +event is now an invalid definition, whether declared, sent, raised, or loaded from an SCXML, +JSON or YAML `` to `#_internal` (also rejected by the JSON Schema): ```py ->>> try: -... class War(StateChart): -... class war(State.Parallel): -... class quest(State.Compound): -... start = State(initial=True) -... end = State(final=True) -... go = start.to(end) -... -... def on_enter_end(self): -... print("quest") +>>> class DelayedBeacons(StateChart): # doctest: -IGNORE_EXCEPTION_DETAIL +... dark = State(initial=True) +... lit = State(final=True) ... -... class battle(State.Compound): -... fighting = State(initial=True) -... won = State(final=True) -... go = fighting.to(won) +... light = Event(dark.to(lit), delay=50, internal=True) +Traceback (most recent call last): ... -... def on_enter_end(self): -... print("battle") -... except InvalidDefinition as e: -... print(e) -Callback 'on_enter_end' is declared in both the 'quest' and the 'battle' state bodies. Nested state bodies share the class namespace. Rename one of them. +statemachine.exceptions.InvalidDefinition: Event 'light' cannot be both delayed and internal: ... ``` -Assigning the same callable under one name in several bodies is still allowed, and so is -overriding a method inherited from a base class. Giving -each nested body its own namespace for names, so that they can be reused across regions, is -tracked by [#657](https://github.com/fgmacedo/python-statemachine/issues/657). +The error names the event by its attribute name, or by its explicit `id`, inside a +`State.Compound` body too. The same applies to `raise_()` and `send(..., internal=True)` on an +event declared with a `delay`, which used to fire at once because the declared delay was lost, +and to `send(..., delay=...)` on an event declared `internal=True`, which used to be delivered +later because the declared `internal` was lost. -[#653](https://github.com/fgmacedo/python-statemachine/issues/653). +Drop `internal` to deliver the event later on the external queue, or drop `delay` to run it +within the current macrostep. An explicit argument to `send()` overrides the declaration: +`send("light", delay=50, internal=False)` delivers a declared-internal event later, and +`send("light", delay=0)` or `raise_("light", delay=0)` runs an event declared with a delay now. -### Subclassing a chart with callbacks in a `State.Compound` body +Based on the original fix by [@chiliec](https://github.com/chiliec) in +[#663](https://github.com/fgmacedo/python-statemachine/pull/663). -Subclassing a `StateChart` whose compound (or parallel) body declared any callback, as a plain -method or through the `@.on` / `@.cond` decorators, raised -`AttributeError: 'State' object has no attribute '_callbacks'` while the subclass was being -built. The nested `State` objects are shared with the subclass, and unpacking the callbacks of -the body deleted the attribute the subclass then read. The subclass now builds, inherits the -callbacks of the base and may override the naming-convention ones as it would at the top level: +[#643](https://github.com/fgmacedo/python-statemachine/issues/643), +[#645](https://github.com/fgmacedo/python-statemachine/pull/645), +[#655](https://github.com/fgmacedo/python-statemachine/issues/655), +[#668](https://github.com/fgmacedo/python-statemachine/pull/668), +[#679](https://github.com/fgmacedo/python-statemachine/issues/679), +[#683](https://github.com/fgmacedo/python-statemachine/pull/683). + +(release-3.2.2-internal-from-outside)= + +#### Internal events triggered from outside a callback + +The internal queue belongs to the macrostep in progress, but it also accepted triggers that no +running step had raised. `raise_()` and `send(..., internal=True)` called from outside a callback +returned `None` instead of the callback result, and a `StateMachine` raised no +`TransitionNotAllowed` for an event that enabled no transition. On the async engine, the same +calls on a fresh machine were lost: they returned `None` and the transition never ran. + +The internal queue now receives only what the machine raises while it runs a step. Triggered +from outside, `raise_()`, `send(..., internal=True)` and a declared-internal event go to the +external queue and give their caller what `send()` gives, the result, the exception or +`TransitionNotAllowed`: ```py ->>> class Journey(StateChart): -... class shire(State.Compound): -... bag_end = State(initial=True) -... green_dragon = State() +>>> class Beacons(StateChart): +... dark = State(initial=True) +... lit = State(final=True) ... -... visit_pub = bag_end.to(green_dragon) +... light = Event(dark.to(lit, on="kindle"), internal=True) ... -... def on_enter_green_dragon(self): -... print("A pint for Frodo") +... def kindle(self): +... return "kindled" + +>>> Beacons().light() +'kindled' + +>>> Beacons().raise_("light") +'kindled' + +``` + +Based on the original fix by [@chiliec](https://github.com/chiliec) in +[#663](https://github.com/fgmacedo/python-statemachine/pull/663). + +[#655](https://github.com/fgmacedo/python-statemachine/issues/655), +[#668](https://github.com/fgmacedo/python-statemachine/pull/668). + +(release-3.2.2-macrostep)= + +#### Each external event gets its own macrostep + +With several external events already queued, the engine took them one after the other and +only then ran the internal events and eventless transitions they had enabled. An internal +event raised while handling the first external event ran after the second, breaking the +run-to-completion order the {ref}`processing model ` describes: + +```py +>>> class Relay(StateChart): +... dark = State(initial=True) +... lit = State(final=True) +... besieged = State(final=True) ... -... road = State(final=True) -... depart = shire.to(road) +... signal = dark.to.itself(internal=True, on="relay") +... light = dark.to(lit) +... siege = dark.to(besieged) +... +... def relay(self): +... self.raise_("light") +... self.send("siege") ->>> class Hobbit(Journey): -... pass +>>> sm = Relay() +>>> sm.send("signal") +>>> sm.lit.is_active +True ->>> sm = Hobbit() ->>> sm.send("visit_pub") -A pint for Frodo +``` + +Each external event is now followed by its macrostep before the next one is taken, on both +engines. Eventless transitions are checked after every external event instead of once per +batch, so a burst of queued external events costs one guard evaluation each. + +Once the machine reaches a top-level final state it stops, and the external events still in +its queue are dropped. On the sync engine, a `StateMachine` used to process them against the +final state and raise `TransitionNotAllowed`: + +```py +>>> from statemachine import StateMachine + +>>> class Ferry(StateMachine): +... docked = State(initial=True) +... sunk = State(final=True) +... +... sail = docked.to.itself() +... storm = docked.to(sunk, on="flood") +... +... def flood(self): +... self.send("sail") + +>>> sm = Ferry() +>>> sm.send("storm") +>>> sm.is_terminated +True ``` -[#646](https://github.com/fgmacedo/python-statemachine/issues/646). +A coroutine awaiting the `send()` of a dropped event, delayed or not, gets the answer the +terminated machine gives to a new event: `None`, or `TransitionNotAllowed` for that event when +`allow_event_without_transition` is `False`, as on a `StateMachine`. This also applies when the +machine terminates because every region of a top-level parallel state reached a final state. +On 3.2.1, a coroutine awaiting a delayed event still pending when the machine terminated was +never answered. + +[#668](https://github.com/fgmacedo/python-statemachine/pull/668), +[#678](https://github.com/fgmacedo/python-statemachine/issues/678), +[#685](https://github.com/fgmacedo/python-statemachine/pull/685). + +(release-3.2.2-prefixed-events)= -### Prefixed events were registered under a glued id +#### Prefixed events were registered under a glued id The `error_` / `done_state_` / `done_invoke_` prefixes declare two event ids, the attribute name and its SCXML dotted form. Both reached the transition, so `send("error.execution")` worked, but the chart registered a single event whose id was the two ids joined by a space. `sm.error_execution()` raised `TypeError` on a bare transition and silently did nothing on an `Event(...)`, and `allowed_events` / `enabled_events()` raised `AttributeError` looking up an -attribute named `error.execution`. The expansion also dropped the `name`, `delay` and -`internal` declared on an `Event` inside a nested state body. +attribute named `error.execution`. Each id is now an event of its own, so both are listed, both are sendable, the attribute name is callable as a method, and the declared arguments are kept on both. The `@source.to(target)` decorator form, which registered only the attribute name, expands too: ```py ->>> from statemachine import Event, State, StateChart - >>> class ErrorLogger(StateChart): ... running = State(initial=True) ... failed = State(final=True) @@ -228,133 +380,203 @@ True ``` +Code that lists `events`, `allowed_events` or `enabled_events()` now sees both ids. To offer +events to the outside without the ones the engine raises, see {ref}`platform-events`. + Reported by [@Dolecor](https://github.com/Dolecor). [#644](https://github.com/fgmacedo/python-statemachine/issues/644), -[#654](https://github.com/fgmacedo/python-statemachine/issues/654). - -### Events due at the same moment kept their order only by chance - -Both event queues are priority queues ordered by each trigger's due time, and triggers due at -the same time came out in no particular order. A clock coarse enough to stamp two events alike -could run them out of the order they were sent or raised: twenty events raised in one callback, -all stamped with the same time, ran as `0, 2, 6, 14, 19, 18, ...`. Each trigger now also holds -the order it was created in, and triggers due at the same moment run in that order, on both -engines and both queues. +[#654](https://github.com/fgmacedo/python-statemachine/issues/654), +[#675](https://github.com/fgmacedo/python-statemachine/pull/675). + +(release-3.2.2-event-queues)= + +#### Ordering, timing and cancelling queued events + +Three fixes to how both engines order, time and cancel the triggers in their queues. + +**Events due at the same moment kept their order only by chance.** Both event queues are +priority queues ordered by each trigger's due time, and triggers due at the same time came out +in no particular order. A clock coarse enough to stamp two events alike could run them out of +the order they were sent or raised: twenty events raised in one callback, all stamped with the +same time, ran as `0, 2, 6, 14, 19, 18, ...`. Each trigger now also holds the order it was +created in, and triggers due at the same moment run in that order, on both engines and both +queues. + +**Cancelling a delayed event could disorder the others and strand its caller.** +`cancel_event()` removed a trigger by filtering the list behind the queue's heap, and a +filtered heap is not always a heap. When the loop reached them after they were due, after a +long callback or on a coarse clock, the triggers left could then run out of due order: of ten +delayed events due from 10 to 90 ms, cancelling the first left the one due at 50 ms running +before the one due at 40 ms. The trigger's future went with it, so on the async engine a +coroutine awaiting the cancelled event's `send()` was never answered. A cancelled trigger now +stays where it is, marked as cancelled, and is dropped when it reaches the head of the queue, +without the processing loop waiting for its due time. A coroutine awaiting it gets `None` as +soon as it is cancelled. A trigger waiting on its delay also stays in the queue while the loop +checks the clock, so a `cancel_event()` from another thread always finds it. + +**Delays were measured on the wall clock.** Every trigger was stamped with `time.time()` plus +its delay, and the processing loop held a trigger until `time.time()` reached its stamp. A +step of the wall clock, from a time sync or a change of the system time, moved every due time +with it: stepped back, it held even an event sent with no delay until the clock caught up; +stepped forward, it ran delayed events early. Delays are now measured on `time.monotonic()`, +which no setting of the wall clock moves, and `TriggerData.execution_time` holds a value of that +clock instead of epoch seconds. + +Contributed by [@JMerrill-Fairness](https://github.com/JMerrill-Fairness). [#665](https://github.com/fgmacedo/python-statemachine/pull/665). -### Cancelling a delayed event could disorder the others and strand its caller +### Nested state bodies and inheritance -`cancel_event()` removed a trigger by filtering the list behind the queue's heap, and a filtered -heap is not always a heap. The triggers left could then run out of due order: of ten delayed -events due from 10 to 90 ms, cancelling the first left the one due at 50 ms running before the -one due at 40 ms. The trigger's future went with it, so on the async engine a coroutine -awaiting the cancelled event's `send()` was never answered. +(release-3.2.2-name-collisions)= -A cancelled trigger now stays where it is, marked as cancelled, and is dropped when it reaches -the head of the queue, without the processing loop waiting for its due time. A coroutine -awaiting it gets `None` as soon as it is cancelled. A trigger waiting on its delay also stays -in the queue while the loop checks the clock, so a `cancel_event()` from another thread always -finds it. +#### Callback names colliding between nested state bodies -[#665](https://github.com/fgmacedo/python-statemachine/pull/665). +Callbacks declared inside a `State.Compound` or `State.Parallel` body are attached to the +statechart class under their own names, with no reference to the owning state. Two sibling +regions declaring the same callback name collided, and the last body silently won: with an +`@go.on def act` in each region of a parallel state, `send("go")` moved both regions but ran +only the second `act`. The same happened to a plain callback such as `on_enter_` +declared in two bodies, and to a nested body overriding a method of the class body. -### Delays measured on the wall clock +A callback attached through a decorator, or by passing the function itself (`cond=ready`), now +runs in every body that declares it: -Every trigger was stamped with `time.time()` plus its delay, and the processing loop held a -trigger until `time.time()` reached its stamp. A step of the wall clock, from a time sync or a -change of the system time, moved every due time with it: stepped back, it held even an event -sent with no delay until the clock caught up; stepped forward, it ran delayed events early. -Delays are now measured on `time.monotonic()`, which no setting of the wall clock moves. +```py +>>> from statemachine.exceptions import InvalidDefinition -[#665](https://github.com/fgmacedo/python-statemachine/pull/665). +>>> class War(StateChart): +... class war(State.Parallel): +... class quest(State.Compound): +... start = State(initial=True) +... end = State(final=True) +... go = start.to(end) +... +... @go.on +... def act(self): +... print("quest") +... +... class battle(State.Compound): +... fighting = State(initial=True) +... won = State(final=True) +... go = fighting.to(won) +... +... @go.on +... def act(self): +... print("battle") -### `delay` and `internal` dropped from an explicit `Event` +>>> sm = War() +>>> _ = sm.send("go") +quest +battle -`light = 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`. Each is now preserved: +``` + +A name reached by name, through a naming convention, a string reference or a plain method, +cannot tell the bodies apart. Such a name declared in two nested bodies, or in a nested body and +the class body, is now rejected at class creation: ```py ->>> class BeaconsOfGondor(StateChart): -... dark = State(initial=True) -... lit = State(final=True) +>>> try: +... class War(StateChart): +... class war(State.Parallel): +... class quest(State.Compound): +... start = State(initial=True) +... end = State(final=True) +... go = start.to(end) ... -... light = Event(dark.to(lit), delay=50) - ->>> BeaconsOfGondor.light.delay -50 +... def on_enter_end(self): +... print("quest") +... +... class battle(State.Compound): +... fighting = State(initial=True) +... won = State(final=True) +... go = fighting.to(won) +... +... def on_enter_end(self): +... print("battle") +... except InvalidDefinition as e: +... print(e) +Callback 'on_enter_end' is declared in both the 'quest' and the 'battle' state bodies. Nested state bodies share the class namespace. Rename one of them. ``` -### `internal` declared on an `Event` ignored at trigger time - -`go = Event(a.to(b), internal=True)` kept `internal` on the declaration, but neither `sm.go()` -nor `sm.send("go")` carried it to the queue, so the event always went to the external queue. Both -now honor the declaration: triggered from a callback, the event runs within the current -macrostep, before any pending external event. - -Honoring it exposed how the internal queue treated a trigger that no running step had raised. -On the async engine, the first `await sm.go()` on a fresh machine was lost before the initial -state was entered. The caller got `None` instead of the callback result, and no -`TransitionNotAllowed` on a `StateMachine`. The internal queue now receives only what the -machine raises while it runs a step. Triggered from outside, an internal event goes to the -external queue and returns its result, as `send()` does: +A plain method in a nested body named like an event or another attribute of the class used to +replace that attribute, so sending the event then failed with `AttributeError`. It is rejected +too: ```py ->>> class Beacons(StateChart): -... dark = State(initial=True) -... lit = State(final=True) +>>> try: +... class Quest(StateChart): +... class journey(State.Compound): +... start = State(initial=True) +... end = State(final=True) +... go = start.to(end) ... -... light = Event(dark.to(lit, on="kindle"), internal=True) +... def finish(self): +... return "done" ... -... def kindle(self): -... return "kindled" - ->>> Beacons().light() -'kindled' +... home = State(final=True) +... finish = journey.to(home) +... except InvalidDefinition as e: +... print(e) +Callback 'finish' in the 'journey' state body clashes with the event 'finish'. Nested state bodies share the class namespace. Rename one of them. ``` -A delay never reached the internal queue either: `raise_("go", delay=50)` fired at once. A -macrostep takes no time, and SCXML forbids `delay` on a `` to `#_internal`, so a delayed -internal event is now an invalid definition, whether declared, sent, raised, or loaded -from an SCXML, JSON or YAML `` to `#_internal` (also rejected by the JSON Schema): +Assigning the same callable under one name in several bodies is still allowed, and so is +overriding a method inherited from a base class. See {ref}`callback-name-collision`. Giving +each nested body its own namespace for names, so that they can be reused across regions, is +tracked by [#657](https://github.com/fgmacedo/python-statemachine/issues/657). + +[#653](https://github.com/fgmacedo/python-statemachine/issues/653), +[#674](https://github.com/fgmacedo/python-statemachine/pull/674). + +#### Subclassing a chart with callbacks in a `State.Compound` body + +Subclassing a `StateChart` whose compound (or parallel) body declared any callback, as a plain +method or through the `@.on` / `@.cond` decorators, raised +`AttributeError: 'State' object has no attribute '_callbacks'` while the subclass was being +built. The nested `State` objects are shared with the subclass, and unpacking the callbacks of +the body deleted the attribute the subclass then read. The subclass now builds, inherits the +callbacks of the base and may override the naming-convention ones as it would at the top level: ```py ->>> class DelayedBeacons(StateChart): -... dark = State(initial=True) -... lit = State(final=True) +>>> class Shire(StateChart): +... class shire(State.Compound): +... bag_end = State(initial=True) +... green_dragon = State() ... -... light = Event(dark.to(lit), delay=50, internal=True) -Traceback (most recent call last): +... visit_pub = bag_end.to(green_dragon) ... -InvalidDefinition: Event 'light' cannot be both delayed and internal: ... +... def on_enter_green_dragon(self): +... print("A pint for Frodo") +... +... road = State(final=True) +... depart = shire.to(road) -``` +>>> class Hobbit(Shire): +... pass -The error names the event by its attribute name, or by its explicit `id`, inside a -`State.Compound` body too. +>>> sm = Hobbit() +>>> sm.send("visit_pub") +A pint for Frodo -Drop `internal` to deliver the event later on the external queue, or drop `delay` to run it -within the current macrostep. An explicit argument to `send()` overrides the declaration: -`send("light", delay=50, internal=False)` delivers a declared-internal event later, and -`send("signal", delay=0)` or `raise_("signal", delay=0)` runs an event declared with a delay now. +``` -Contributed by [@chiliec](https://github.com/chiliec). +[#646](https://github.com/fgmacedo/python-statemachine/issues/646), +[#673](https://github.com/fgmacedo/python-statemachine/pull/673). -[#655](https://github.com/fgmacedo/python-statemachine/issues/655), -[#663](https://github.com/fgmacedo/python-statemachine/pull/663), -[#668](https://github.com/fgmacedo/python-statemachine/pull/668), -[#679](https://github.com/fgmacedo/python-statemachine/issues/679). +(release-3.2.2-nested-proxies)= -### `is_active` on a state nested in a parallel region +#### `is_active` on a state reached through its parent -Reaching a child state through its parent (`sm.door.shut`) returned the class-level `State`, +Reaching a nested state through its parent (`sm.door.shut`) returned the class-level `State`, whose `is_active` is always `False`, while `sm.shut.is_active` on the same machine was `True`. -The same happened to `parent`. A state reached through any attribute path now resolves to the -machine's per-instance proxy: +The same happened to `parent`. This held for any state nested in a compound or parallel +state. A state reached through any attribute path now resolves to the machine's per-instance +proxy: ```py >>> class Microwave(StateChart): @@ -380,68 +602,50 @@ Creating a machine also rebound the shared `parent` of every nested state to tha proxy, so with two instances alive, `parent` on the first answered for the second. Each instance now keeps its own proxies and the class-level states are never mutated. -Reported by [@MartinBernstorff](https://github.com/MartinBernstorff). +Reported and contributed by [@MartinBernstorff](https://github.com/MartinBernstorff). [#651](https://github.com/fgmacedo/python-statemachine/issues/651), [#652](https://github.com/fgmacedo/python-statemachine/pull/652). -### Each external event gets its own macrostep +### Diagrams and Sphinx -With several external events already queued, the engine took them one after the other and -only then ran the internal events and eventless transitions they had enabled. An internal -event raised while handling the first external event ran after the second, breaking the -run-to-completion order the {ref}`processing model ` describes: +(release-3.2.2-graphviz-error)= -```py ->>> class Beacons(StateChart): -... dark = State(initial=True) -... lit = State(final=True) -... besieged = State(final=True) -... -... signal = dark.to.itself(internal=True, on="relay") -... light = dark.to(lit) -... siege = dark.to(besieged) -... -... def relay(self): -... self.raise_("light") -... self.send("siege") - ->>> sm = Beacons() ->>> sm.send("signal") ->>> sm.lit.is_active -True - -``` - -Each external event is now followed by its macrostep before the next one is taken, on both -engines. Eventless transitions are now checked after every external event instead of once per -batch, so a burst of queued external events costs one guard evaluation each, and an event -queued behind one that reaches a final state is no longer processed. - -Such an event, delayed or not, is dropped when the machine terminates, and a coroutine awaiting -its `send()` gets the answer the terminated machine gives to a new event: `None`, or -`TransitionNotAllowed` for that event when `allow_event_without_transition` is `False`, as on a -`StateMachine`. This also applies when the machine terminates because every region of a -top-level parallel state reached a final state. On v3.2.1 a coroutine awaiting a delayed event -still pending when the machine terminated was never answered. - -[#668](https://github.com/fgmacedo/python-statemachine/pull/668), -[#678](https://github.com/fgmacedo/python-statemachine/issues/678). - -### Graphviz errors reported only the exit code +#### Graphviz errors reported only the exit code A diagram that `dot` refused to render (the `svg` format, `_repr_svg_()` in Jupyter, the command line or the Sphinx directive) failed with `"dot" with args [...] returned code: 1`, while the reason, such as `Error: : syntax error in line 25 near ','`, was printed to -stdout by pydot, apart from the exception and from the Sphinx warning. These paths now run `dot` directly and raise -`GraphvizError` with the format that was requested and what Graphviz wrote to stderr. The -Sphinx directive reports the same text in its warning. A missing `dot` says so, with a link -to the Graphviz download page, and raises `GraphvizNotFoundError`, a subclass of both -`GraphvizError` and `FileNotFoundError`, so `except FileNotFoundError` still catches it. +stdout by pydot, apart from the exception and from the Sphinx warning. These paths now run +`dot` directly and raise {class}`~statemachine.contrib.diagram.GraphvizError` with the format +that was requested and what Graphviz wrote to stderr. The Sphinx directive reports the same +text in its warning. + +`GraphvizError` is a subclass of `StateMachineError`. Code that caught pydot's +`AssertionError` around these paths when `dot` failed must catch `GraphvizError` instead. A +missing `dot` says so, with a link to the Graphviz download page, and raises +`GraphvizNotFoundError`, a subclass of both `GraphvizError` and `FileNotFoundError`, so +`except FileNotFoundError` still catches it. +Formats requested directly on the `pydot.Dot` object (`write_png()`, `create_svg()`, ...) are +still run by pydot and raise as before. See {ref}`diagram-graphviz-errors`. Reported by [@barnettwilliam](https://github.com/barnettwilliam). -[#649](https://github.com/fgmacedo/python-statemachine/issues/649). +[#649](https://github.com/fgmacedo/python-statemachine/issues/649), +[#672](https://github.com/fgmacedo/python-statemachine/pull/672), +[#680](https://github.com/fgmacedo/python-statemachine/pull/680). + +#### Sphinx diagram zoom links outside the documentation root + +The `statemachine-diagram` directive with an empty `:target:` writes the full SVG to the +build's `_images` directory and links the diagram to it. The link started with a slash +(`/_images/...`), so documentation served below a URL prefix, such as +`https://example.com/project/en/stable/`, linked outside its own deployment. The link is now +relative to the page that holds the diagram, for both the `html` and `dirhtml` builders. + +Contributed by [@rastagan-git](https://github.com/rastagan-git). + +[#650](https://github.com/fgmacedo/python-statemachine/pull/650). ## Documentation in 3.2.2 @@ -458,4 +662,5 @@ section links to it. No behavior changed. Reported by [@Dolecor](https://github.com/Dolecor). -[#644](https://github.com/fgmacedo/python-statemachine/issues/644). +[#644](https://github.com/fgmacedo/python-statemachine/issues/644), +[#671](https://github.com/fgmacedo/python-statemachine/pull/671). diff --git a/docs/statechart.md b/docs/statechart.md index a6c99776..ff625a45 100644 --- a/docs/statechart.md +++ b/docs/statechart.md @@ -133,13 +133,19 @@ sm.cancel_event("my_timeout") On the async engine, a coroutine awaiting the `send()` of a cancelled event gets `None`. -Only `send()` takes a delay. An internal event runs within the current macrostep, -which takes no time, so `raise_(..., delay=...)` and `send(..., delay=..., internal=True)` -raise {class}`~statemachine.exceptions.InvalidDefinition`. An explicit `delay=0` overrides a -delay declared on the event. +A delay declared on the event itself, `Event(..., delay=5000)`, applies every time the event +is triggered, whether by `send()` or by calling it as a method (see {ref}`events`). An explicit +`delay=0` overrides it. + +Only `send()` delivers an event later. An internal event runs within the current macrostep, +which takes no time, so any delay on an internal event, given or declared, raises +{class}`~statemachine.exceptions.InvalidDefinition`: `raise_(..., delay=...)`, +`send(..., delay=..., internal=True)`, `raise_()` on an event declared with a `delay`, and +`send(..., delay=...)` on an event declared `internal=True`. An explicit `delay=0` overrides a +declared delay. ```{note} -The delay is **blocking** in the sync engine — the processing loop sleeps until the +The delay is **blocking** in the sync engine: the processing loop sleeps until the delay elapses, holding the calling thread. In the async engine, delays are scheduled with `asyncio` and do not block the event loop. ```