From 78e2b4c919faa2ac1c5196cb6ad5245fe4de7ee5 Mon Sep 17 00:00:00 2001 From: Fernando Macedo Date: Sat, 1 Aug 2026 16:33:34 -0300 Subject: [PATCH 1/3] fix: Python 3.10-compatible add_note pollution test; drop em dashes from 3.2.1 notes (#642) --- docs/io/security.md | 12 ++++++------ docs/releases/3.2.1.md | 4 ++-- statemachine/spec_parser.py | 2 +- tests/io/test_security.py | 13 ++++++++----- 4 files changed, 17 insertions(+), 14 deletions(-) diff --git a/docs/io/security.md b/docs/io/security.md index a121fceb..8984b5bd 100644 --- a/docs/io/security.md +++ b/docs/io/security.md @@ -201,17 +201,17 @@ every format (SCXML, JSON and YAML). A set of follow-up advisories hardened the restricted mode further and prompted the confidentiality/integrity vs availability framing above: -- [GHSA-fj3w-533r-fvf6](https://github.com/fgmacedo/python-statemachine/security/advisories/GHSA-fj3w-533r-fvf6) - — `` and `` read local files during loading, regardless of +- [GHSA-fj3w-533r-fvf6](https://github.com/fgmacedo/python-statemachine/security/advisories/GHSA-fj3w-533r-fvf6): + `` and `` read local files during loading, regardless of `trusted`. Loading now rejects external `src` references unless `trusted=True`, and refuses ``/DTD to block XML entity-expansion bombs. - [GHSA-v3qq-3xvg-m77g](https://github.com/fgmacedo/python-statemachine/security/advisories/GHSA-v3qq-3xvg-m77g) - / [GHSA-4857-ggqc-p3jc](https://github.com/fgmacedo/python-statemachine/security/advisories/GHSA-4857-ggqc-p3jc) - — a document could write to a dunder/private/protected attribute (notably traversing + / [GHSA-4857-ggqc-p3jc](https://github.com/fgmacedo/python-statemachine/security/advisories/GHSA-4857-ggqc-p3jc): + a document could write to a dunder/private/protected attribute (notably traversing `__class__`) and corrupt the shared model class process-wide. Write targets are now confined to public model attributes on every path segment. -- [GHSA-r8gj-366q-cgvj](https://github.com/fgmacedo/python-statemachine/security/advisories/GHSA-r8gj-366q-cgvj) - — `**`/`*` in the restricted evaluator had no magnitude bound, so a tiny expression could +- [GHSA-r8gj-366q-cgvj](https://github.com/fgmacedo/python-statemachine/security/advisories/GHSA-r8gj-366q-cgvj): + `**`/`*` in the restricted evaluator had no magnitude bound, so a tiny expression could exhaust CPU or memory. They are now magnitude-capped. These were released together in 3.2.1. diff --git a/docs/releases/3.2.1.md b/docs/releases/3.2.1.md index ddd3c406..0ce4d923 100644 --- a/docs/releases/3.2.1.md +++ b/docs/releases/3.2.1.md @@ -15,9 +15,9 @@ See [](../io/security.md). ```{note} **Am I affected?** -- **Yes** — if you load documents you did **not** author (via `statemachine.io.load(...)` / +- **Yes**, if you load documents you did **not** author (via `statemachine.io.load(...)` / `build_processor(...)` / `SCXMLProcessor`) with the default `trusted=False`. -- **No** — if you define machines in Python, only load documents you wrote yourself, or already +- **No**, if you define machines in Python, only load documents you wrote yourself, or already load with `trusted=True` for fully controlled documents. **Affected versions:** `>= 3.2.0, < 3.2.1` (this attack surface shipped with the diff --git a/statemachine/spec_parser.py b/statemachine/spec_parser.py index 4cf4c285..1046a423 100644 --- a/statemachine/spec_parser.py +++ b/statemachine/spec_parser.py @@ -289,7 +289,7 @@ def recurse(child): op_type = type(node.op) if op_type not in binary_operators: # e.g. bitwise ``^``/``|``/``<<`` are outside the allowlist. (``**`` and ``*`` - # are allowed but magnitude-capped — see ``binary_operators``.) + # are allowed but magnitude-capped, see ``binary_operators``.) raise ValueError(f"Binary operator '{op_type.__name__}' is not allowed") return build_binop(binary_operators[op_type], recurse(node.left), recurse(node.right)) case ast.List(elts=elts) if allow_value_nodes: diff --git a/tests/io/test_security.py b/tests/io/test_security.py index 79370d23..dc33d16e 100644 --- a/tests/io/test_security.py +++ b/tests/io/test_security.py @@ -551,11 +551,14 @@ def test_shared_exception_class_not_corrupted(self, fmt): try: sm = _run_exec(scxml, native, fmt) assert "failed" in _config(sm) - # The shared class is intact: add_note is still the inherited method, not int 1, - # and a normal exception still constructs and carries a note. - assert callable(TransitionNotAllowed.add_note) - err = TransitionNotAllowed(None, set()) - err.add_note("still works") + # The shared class is intact: the exploit did not inject ``add_note = 1`` onto it. + # ``BaseException.add_note`` only exists on Python 3.11+, so assert on the injection + # site (the class ``__dict__``) rather than the inherited method, to stay + # version-agnostic. + assert "add_note" not in TransitionNotAllowed.__dict__ + assert getattr(TransitionNotAllowed, "add_note", None) != 1 + # A normal exception still constructs. + TransitionNotAllowed(None, set()) finally: # Defensive: if a regression ever mutated the shared class, restore it so the # rest of the suite is not corrupted. From 45d6ab139d8e3f6aa599922b22e3f67741b953ff Mon Sep 17 00:00:00 2001 From: Fernando Macedo Date: Sun, 13 Sep 2026 17:32:39 -0300 Subject: [PATCH 2/3] fix: support event declarations inside State.Compound bodies (#645) * fix: support event declarations inside State.Compound bodies A nested state class body only understood the assignment form of an event declaration. The `Event` class and the `@.to()` decorator both fell through to the generic callable branch, so the name was bound to a detached object and the transition it wrapped stayed eventless, firing as soon as its source state became active. Handle both forms in the nested class body scanner, which is extracted from `NestedStateFactory.__new__` into `_collect_nested_members`. Closes #643 Signed-off-by: Fernando Macedo * refactor: read statechart class bodies through one shared reader The two kinds of statechart class body, a StateChart subclass and a nested State.Compound / State.Parallel, accept the same declaration forms, but each had its own copy of the recognition table. That is how #643 happened: the nested copy never learned about `Event`, so an event declared there silently became an eventless transition. Recognition now lives once in `class_body.read`, which dispatches to a reader supplying only what each side does with a form. The reader interface is a Protocol, so a form added to one side and forgotten on the other is a type error rather than a silent gap. Drop the `error_` prefix expansion the previous commit gave to nested decorated events: it is not what the top-level path does, and the two must agree. Signed-off-by: Fernando Macedo * docs: drop the versionchanged note for the compound Event fix The docs describe the current behavior. The previous behavior was a bug, not a documented contract, and the release notes already carry the history. Signed-off-by: Fernando Macedo * refactor: declare nested events inline instead of through a shared reader The reader added a module, a seven-method Protocol under TYPE_CHECKING and two implementations, to share class body recognition between the statechart metaclass and NestedStateFactory. The two consumers need different amounts of it, which showed up as an aliased on_history on one side and an empty on_other on the other, and all three defects found in review lived in the nested implementation. Declare the Event and decorator forms with two branches in the loop that was already there. The event reaches the machine through the path that already exists: add_state walks the tree and collects state.transitions.unique_events. Nothing is placed in the callbacks dict, so the expanded id of an error_ prefix no longer overwrites the class attribute that add_event had bound correctly. Two differences from the top level 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 a transition-less Event is dropped. Signed-off-by: Fernando Macedo * fix: keep delay and internal when declaring 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. internal was dropped the same way. test_delayed_event_on_event_definition built its own BoundEvent(delay=50) instead of triggering the declared one, so it never exercised the bug. internal is preserved on the declaration but still has no effect at trigger time: Event.__get__ does not pass it to BoundEvent and send() does not read it. Signed-off-by: Fernando Macedo * docs: link the nested Event limitations to their issues The notes stated what does not work without pointing anywhere. #656 covers the missing attribute binding in a nested body, #655 the internal flag that is preserved on the declaration but ignored at trigger time. Signed-off-by: Fernando Macedo * docs: drop the compound Event section and the issue links Declaring an Event inside a nested body was always expected to work, so an example for it repeats what the section above already shows. Open limitations are tracked on GitHub, not in the docs. Signed-off-by: Fernando Macedo --------- Signed-off-by: Fernando Macedo --- docs/releases/3.2.2.md | 96 +++++++++++++++ docs/releases/index.md | 1 + statemachine/factory.py | 2 + statemachine/state.py | 20 ++- tests/test_events.py | 12 ++ tests/test_state.py | 9 ++ tests/test_statechart_compound.py | 194 +++++++++++++++++++++++++++++- tests/test_statechart_delayed.py | 3 +- 8 files changed, 331 insertions(+), 6 deletions(-) create mode 100644 docs/releases/3.2.2.md 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 From 525bcddcc5bb9793ce03d7b3e560f9c2ec0c5ee2 Mon Sep 17 00:00:00 2001 From: Fernando Macedo Date: Mon, 14 Sep 2026 09:58:06 -0300 Subject: [PATCH 3/3] chore(deps): upgrade all direct dependencies (#659) --- .github/workflows/pages.yml | 6 +- .github/workflows/python-package.yml | 8 +- .github/workflows/release.yml | 10 +- .pre-commit-config.yaml | 2 +- docs/diagram.md | 2 + docs/guards.md | 2 +- docs/how-to/coming_from_state_pattern.md | 9 +- docs/how-to/coming_from_transitions.md | 29 +- docs/integrations.md | 15 +- docs/releases/1.0.1.md | 32 +- docs/releases/2.0.0.md | 4 - docs/releases/2.2.0.md | 11 +- docs/releases/2.3.0.md | 5 +- docs/releases/2.4.0.md | 2 + docs/releases/2.5.0.md | 5 +- docs/releases/3.1.0.md | 6 +- docs/releases/3.2.0.md | 4 +- docs/releases/upgrade_2x_to_3.md | 42 +- pyproject.toml | 22 +- statemachine/registry.py | 7 +- uv.lock | 2186 +++++++++++++--------- 21 files changed, 1418 insertions(+), 991 deletions(-) diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 616fbf02..35912b19 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -31,7 +31,7 @@ jobs: name: github-pages url: ${{ steps.deployment.outputs.page_url }} steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Assemble site run: | mkdir -p _site/schemas/statechart @@ -44,8 +44,8 @@ jobs:

See the statechart JSON Schema or the documentation.

HTML - - uses: actions/upload-pages-artifact@v3 + - uses: actions/upload-pages-artifact@v5 with: path: _site - id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@v5 diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index ac417f8d..6f6dfe0c 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -21,16 +21,16 @@ jobs: python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - run: git fetch origin develop - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: ${{ matrix.python-version }} - name: Setup Graphviz uses: ts-graphviz/setup-graphviz@v2 - name: Install uv - uses: astral-sh/setup-uv@v8.1.0 + uses: astral-sh/setup-uv@v10.1.0 with: enable-cache: true cache-suffix: "python${{ matrix.python-version }}" @@ -55,7 +55,7 @@ jobs: # upload coverage #---------------------------------------------- - name: Upload coverage to Codecov - uses: codecov/codecov-action@v6 + uses: codecov/codecov-action@v7 if: matrix.python-version == 3.14 with: token: ${{ secrets.CODECOV_TOKEN }} # not required for public repos diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index baf3fd1e..c571da18 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,12 +11,12 @@ jobs: permissions: id-token: write steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - run: git fetch origin develop - name: Setup Python - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: '3.14' @@ -24,7 +24,7 @@ jobs: uses: ts-graphviz/setup-graphviz@v2 - name: Install uv - uses: astral-sh/setup-uv@v8.1.0 + uses: astral-sh/setup-uv@v10.1.0 with: enable-cache: true @@ -40,7 +40,7 @@ jobs: uv build - name: Upload dists - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: release-dists path: dist/ @@ -59,7 +59,7 @@ jobs: steps: - name: Retrieve release distributions - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: release-dists path: dist/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8b2119bf..9f96d446 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,7 +9,7 @@ repos: exclude: docs/auto_examples - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.15.17 + rev: v0.16.7 hooks: # Run the linter. - id: ruff diff --git a/docs/diagram.md b/docs/diagram.md index d48443d3..a6512a75 100644 --- a/docs/diagram.md +++ b/docs/diagram.md @@ -214,6 +214,7 @@ Sphinx directive): ```python from statemachine.contrib.diagram import formatter + @formatter.register_format("plantuml", "puml") def _render_plantuml(machine_or_class): # your PlantUML renderer here @@ -525,6 +526,7 @@ class CustomDiagram(DotGraphMachine): graph_rankdir = "TB" state_active_fillcolor = "lightyellow" + sm = OrderControl() sm.receive_payment(10) diff --git a/docs/guards.md b/docs/guards.md index 8ce5a0ec..35455604 100644 --- a/docs/guards.md +++ b/docs/guards.md @@ -254,7 +254,7 @@ listeners. They can point to properties, attributes, or methods: **Parentheses** control evaluation order: ```python -cond="(is_admin or is_moderator) and not is_banned" +cond = "(is_admin or is_moderator) and not is_banned" ``` #### Expression examples diff --git a/docs/how-to/coming_from_state_pattern.md b/docs/how-to/coming_from_state_pattern.md index eccc6688..e04296a7 100644 --- a/docs/how-to/coming_from_state_pattern.md +++ b/docs/how-to/coming_from_state_pattern.md @@ -28,16 +28,13 @@ class OrderState(ABC): """Abstract base for all order states.""" @abstractmethod - def confirm(self, order): - ... + def confirm(self, order): ... @abstractmethod - def ship(self, order): - ... + def ship(self, order): ... @abstractmethod - def deliver(self, order): - ... + def deliver(self, order): ... class DraftState(OrderState): diff --git a/docs/how-to/coming_from_transitions.md b/docs/how-to/coming_from_transitions.md index 30e8f1e2..791c4765 100644 --- a/docs/how-to/coming_from_transitions.md +++ b/docs/how-to/coming_from_transitions.md @@ -233,7 +233,7 @@ that reads SCXML, JSON and YAML *documents* straight into a `StateChart`: ```python from statemachine.io import load -Machine = load("traffic_light.scxml") # or .json / .yaml; format detected from the extension +Machine = load("traffic_light.scxml") # or .json / .yaml; format detected from the extension ``` Expressions in the document (guards, datamodel) are evaluated by a restricted allowlist — @@ -306,8 +306,8 @@ True In *transitions*, events are called as methods on the model: ```python -machine.produce() # triggers the "produce" event -machine.deliver() # triggers the "deliver" event +machine.produce() # triggers the "produce" event +machine.deliver() # triggers the "deliver" event ``` python-statemachine supports both styles: @@ -342,13 +342,15 @@ Callbacks are specified as strings (method names) or callables: ```python machine = Machine( states=states, - transitions=[{ - "trigger": "produce", - "source": "draft", - "dest": "producing", - "before": "validate_job", - "after": "notify_team", - }], + transitions=[ + { + "trigger": "produce", + "source": "draft", + "dest": "producing", + "before": "validate_job", + "after": "notify_team", + } + ], initial="draft", ) ``` @@ -454,7 +456,9 @@ In *transitions*: ```python machine.add_transition( - "produce", "draft", "producing", + "produce", + "draft", + "producing", conditions=["is_valid", "has_resources"], unless=["is_locked"], ) @@ -588,10 +592,12 @@ See {ref}`invoke` for full documentation. ```python from transitions.extensions import AsyncMachine + class AsyncModel: async def on_enter_producing(self): await some_async_operation() + machine = AsyncMachine(model=AsyncModel(), states=states, initial="draft") await machine.produce() ``` @@ -752,6 +758,7 @@ No class swapping, no feature matrices to consult — just `StateChart`. class MyModel: pass + model = MyModel() machine = Machine(model=model, states=states, transitions=transitions, initial="draft") model.produce() # events are added to the model diff --git a/docs/integrations.md b/docs/integrations.md index fd362cee..ed1ffa38 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -120,10 +120,11 @@ from statemachine import State class CampaignMachine(StateChart): "A workflow machine" - draft = State('Draft', initial=True, value=1) - producing = State('Being produced', value=2) - closed = State('Closed', value=3) - cancelled = State('Cancelled', value=4) + + draft = State("Draft", initial=True, value=1) + producing = State("Being produced", value=2) + closed = State("Closed", value=3) + cancelled = State("Cancelled", value=4) add_job = draft.to.itself() | producing.to.itself() produce = draft.to(producing) @@ -142,9 +143,9 @@ from statemachine.mixins import MachineMixin class Campaign(models.Model, MachineMixin): - state_machine_name = 'campaign.statemachines.CampaignMachine' - state_machine_attr = 'sm' - state_field_name = 'step' + state_machine_name = "campaign.statemachines.CampaignMachine" + state_machine_attr = "sm" + state_field_name = "step" name = models.CharField(max_length=30) step = models.IntegerField() diff --git a/docs/releases/1.0.1.md b/docs/releases/1.0.1.md index d32b6fcb..e497bf64 100644 --- a/docs/releases/1.0.1.md +++ b/docs/releases/1.0.1.md @@ -27,14 +27,15 @@ Transitions now support `cond` and `unless` parameters, to restrict the execution. ```python - class ApprovalMachine(StateMachine): - "A workflow machine" - requested = State("Requested", initial=True) - accepted = State("Accepted") - rejected = State("Rejected") - completed = State("Completed") +class ApprovalMachine(StateMachine): + "A workflow machine" + + requested = State("Requested", initial=True) + accepted = State("Accepted") + rejected = State("Rejected") + completed = State("Completed") - validate = requested.to(accepted, cond="is_ok") | requested.to(rejected) + validate = requested.to(accepted, cond="is_ok") | requested.to(rejected) ``` ```{seealso} @@ -114,9 +115,10 @@ So, the previous code (not valid anymore): ```py class ApprovalMachine(StateMachine): "A workflow machine" - requested = State('Requested', initial=True) - accepted = State('Accepted') - rejected = State('Rejected') + + requested = State("Requested", initial=True) + accepted = State("Accepted") + rejected = State("Rejected") validate = requested.to(accepted, rejected) @@ -133,6 +135,7 @@ Should be rewritten to use {ref}`guards`, like this: ``` py class ApprovalMachine(StateMachine): "A workflow machine" + requested = State("Requested", initial=True) accepted = State("Accepted") rejected = State("Rejected") @@ -168,14 +171,16 @@ This was the previous behavior, you only got an error when trying to instantiate ```py class CampaignMachine(StateMachine): "A workflow machine" - draft = State('Draft', initial=True) - producing = State('Being produced') - closed = State('Closed', initial=True) # Should raise an Exception when instantiated + + draft = State("Draft", initial=True) + producing = State("Being produced") + closed = State("Closed", initial=True) # Should raise an Exception when instantiated add_job = draft.to(draft) | producing.to(producing) produce = draft.to(producing) deliver = producing.to(closed) + with pytest.raises(exceptions.InvalidDefinition): CampaignMachine() ``` @@ -187,6 +192,7 @@ with pytest.raises(exceptions.InvalidDefinition): class CampaignMachine(StateMachine): "A workflow machine" + draft = State("Draft", initial=True) producing = State("Being produced") closed = State( diff --git a/docs/releases/2.0.0.md b/docs/releases/2.0.0.md index fbc433f1..0d2c48e3 100644 --- a/docs/releases/2.0.0.md +++ b/docs/releases/2.0.0.md @@ -291,7 +291,6 @@ from tests.examples.traffic_light_machine import TrafficLightMachine sm = TrafficLightMachine() sm.run("cycle") - ``` Should become: @@ -313,7 +312,6 @@ from tests.examples.traffic_light_machine import TrafficLightMachine sm = TrafficLightMachine() assert [t.name for t in sm.allowed_transitions] == ["cycle"] - ``` Should become: @@ -333,7 +331,6 @@ from tests.examples.traffic_light_machine import TrafficLightMachine sm = TrafficLightMachine() assert sm.is_green - ``` Should become: @@ -355,7 +352,6 @@ from tests.examples.traffic_light_machine import TrafficLightMachine sm = TrafficLightMachine() assert sm.current_state.identification == "green" - ``` Should become: diff --git a/docs/releases/2.2.0.md b/docs/releases/2.2.0.md index 9ac3e83a..84f22105 100644 --- a/docs/releases/2.2.0.md +++ b/docs/releases/2.2.0.md @@ -42,16 +42,19 @@ This will currently issue a warning, but can be turned into an exception by sett ```python from statemachine import StateMachine, State + class TrafficLightMachine(StateMachine, strict_states=True): "A workflow machine" - red = State('Red', initial=True, value=1) - green = State('Green', value=2) - orange = State('Orange', value=3) - hazard = State('Hazard', value=4) + + red = State("Red", initial=True, value=1) + green = State("Green", value=2) + orange = State("Orange", value=3) + hazard = State("Hazard", value=4) cycle = red.to(green) | green.to(orange) | orange.to(red) fault = red.to(hazard) | green.to(hazard) | orange.to(hazard) + # InvalidDefinition: All non-final states should have at least one outgoing transition. # These states have no outgoing transition: ['hazard'] ``` diff --git a/docs/releases/2.3.0.md b/docs/releases/2.3.0.md index 83bb0901..08ff10c9 100644 --- a/docs/releases/2.3.0.md +++ b/docs/releases/2.3.0.md @@ -28,8 +28,8 @@ async code with a state machine. ```python class AsyncStateMachine(StateMachine): - initial = State('Initial', initial=True) - final = State('Final', final=True) + initial = State("Initial", initial=True) + final = State("Final", final=True) advance = initial.to(final) @@ -42,6 +42,7 @@ async def run_sm(): res = await sm.advance() return (42, sm.current_state.name) + asyncio.run(run_sm()) # (42, 'Final') ``` diff --git a/docs/releases/2.4.0.md b/docs/releases/2.4.0.md index 8054af36..2979cc1d 100644 --- a/docs/releases/2.4.0.md +++ b/docs/releases/2.4.0.md @@ -19,6 +19,7 @@ Example (with a spoiler of the next highlight): ```python from statemachine import StateMachine, State, Event + class AnyConditionSM(StateMachine): start = State(initial=True) end = State(final=True) @@ -31,6 +32,7 @@ class AnyConditionSM(StateMachine): used_money: bool = False used_credit: bool = False + sm = AnyConditionSM() sm.submit() # TransitionNotAllowed: Can't finish order when in Start. diff --git a/docs/releases/2.5.0.md b/docs/releases/2.5.0.md index 8cfe5840..babede62 100644 --- a/docs/releases/2.5.0.md +++ b/docs/releases/2.5.0.md @@ -66,6 +66,7 @@ listing the current state allowed events and executing the simulated user choice ```python import random + random.seed("15") sm = AccountStateMachine() @@ -76,7 +77,7 @@ while not sm.current_state.final: for idx, event in enumerate(allowed_events): print(f"{idx} - {event.name}") - user_input = random.randint(0, len(allowed_events)-1) + user_input = random.randint(0, len(allowed_events) - 1) print(f"User input: {user_input}") event = allowed_events[user_input] @@ -104,6 +105,7 @@ Example: ```python from statemachine import StateMachine, State, Event + class AnyConditionSM(StateMachine): start = State(initial=True) end = State(final=True) @@ -115,6 +117,7 @@ class AnyConditionSM(StateMachine): order_value: float = 0 + sm = AnyConditionSM() sm.submit() # TransitionNotAllowed: Can't finish order when in Start. diff --git a/docs/releases/3.1.0.md b/docs/releases/3.1.0.md index 7e518612..8557268c 100644 --- a/docs/releases/3.1.0.md +++ b/docs/releases/3.1.0.md @@ -41,9 +41,9 @@ from statemachine.contrib.diagram import formatter formatter.render(sm, "mermaid") formatter.supported_formats() + @formatter.register_format("custom") -def _render_custom(machine_or_class): - ... +def _render_custom(machine_or_class): ... ``` See {ref}`formatter-api` for details. @@ -77,6 +77,7 @@ class TrafficLight(StateChart): {statechart:md} """ + green = State(initial=True) yellow = State() red = State() @@ -163,6 +164,7 @@ async def fetch_data(): resp = await session.get("https://api.example.com/data") return await resp.json() + class Loader(StateChart): loading = State(initial=True, invoke=fetch_data) ready = State(final=True) diff --git a/docs/releases/3.2.0.md b/docs/releases/3.2.0.md index 85f72e33..9b3cd135 100644 --- a/docs/releases/3.2.0.md +++ b/docs/releases/3.2.0.md @@ -68,7 +68,7 @@ conformance suite), opt back into full Python with `trusted=True`: ```python from statemachine.io.scxml.processor import SCXMLProcessor -SCXMLProcessor() # safe default: restricted evaluator,