From 08aa5d9f5ec0b7dc40779944b30d962fe002df3c Mon Sep 17 00:00:00 2001 From: Alexander Neumann Date: Thu, 3 Jul 2025 12:37:50 +0200 Subject: [PATCH 01/18] fix: set compound to True to fix edges on subgraphs --- transitions/extensions/diagrams.py | 1 + 1 file changed, 1 insertion(+) diff --git a/transitions/extensions/diagrams.py b/transitions/extensions/diagrams.py index 7538a900..325575ee 100644 --- a/transitions/extensions/diagrams.py +++ b/transitions/extensions/diagrams.py @@ -66,6 +66,7 @@ class GraphMachine(MarkupMachine): "directed": "true", "strict": "false", "rankdir": "LR", + "compound": "true", } style_attributes = { From 7aa16c0fa4665077ce4d3ceb0eb1569a2c138181 Mon Sep 17 00:00:00 2001 From: Alexander Neumann Date: Thu, 3 Jul 2025 12:38:44 +0200 Subject: [PATCH 02/18] fix: allow empty labels in mermaid --- transitions/extensions/diagrams_mermaid.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/transitions/extensions/diagrams_mermaid.py b/transitions/extensions/diagrams_mermaid.py index 9df99ad3..48b3c36f 100644 --- a/transitions/extensions/diagrams_mermaid.py +++ b/transitions/extensions/diagrams_mermaid.py @@ -214,9 +214,10 @@ def _add_edges(self, transitions, container): for src, dests in edges_attr.items(): for dst, attr in dests.items(): - if not attr["label"]: - continue - container.append("{source} --> {dest}: {label}".format(**attr)) + t = "{source} --> {dest}".format(**attr) + if attr["label"]: + t += ": {}".format(attr["label"]) + container.append(t) def _create_edge_attr(self, src, dst, transition): return {"source": src, "dest": dst, "label": self._transition_label(transition)} From ad1911483e1f82a397327aa937be4527859ef9ae Mon Sep 17 00:00:00 2001 From: Alexander Neumann Date: Thu, 3 Jul 2025 13:03:05 +0200 Subject: [PATCH 03/18] feat(diagrams): adjust state label alignment --- transitions/extensions/diagrams_base.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/transitions/extensions/diagrams_base.py b/transitions/extensions/diagrams_base.py index e97bd06d..1738f6b9 100644 --- a/transitions/extensions/diagrams_base.py +++ b/transitions/extensions/diagrams_base.py @@ -63,18 +63,21 @@ def get_graph(self, title=None, roi_state=None): """ def _convert_state_attributes(self, state): - label = state.get("label", state["name"]) + label = state.get("label", state["name"]) + "\\n" if self.machine.show_state_attributes: if "tags" in state: - label += " [" + ", ".join(state["tags"]) + "]" + label += "[" + ", ".join(state["tags"]) + "]\\n" if "on_enter" in state: - label += r"\l- enter:\l + " + r"\l + ".join(state["on_enter"]) + label += "- enter:\\l" + for action in state["on_enter"]: + label += " + " + action + "\\l" if "on_exit" in state: - label += r"\l- exit:\l + " + r"\l + ".join(state["on_exit"]) + label += "- exit:\\l" + for action in state["on_exit"]: + label += " + " + action + "\\l" if "timeout" in state: - label += r'\l- timeout(' + state['timeout'] + 's) -> (' + ', '.join(state['on_timeout']) + ')' - # end each label with a left-aligned newline - return label + r"\l" + label += '- timeout(' + state['timeout'] + 's) -> (' + ', '.join(state['on_timeout']) + ')' + return label def _get_state_names(self, state): if isinstance(state, (list, tuple, set)): From 9e1aa3a8db8114745aece97f52a8dffb9be39a51 Mon Sep 17 00:00:00 2001 From: Alexander Neumann Date: Thu, 3 Jul 2025 13:06:17 +0200 Subject: [PATCH 04/18] chore: update changelog feat 706 --- Changelog.md | 1 + 1 file changed, 1 insertion(+) diff --git a/Changelog.md b/Changelog.md index 59c6ce17..1b3c8326 100644 --- a/Changelog.md +++ b/Changelog.md @@ -9,6 +9,7 @@ - Feature #710: `experimental.utils.generate_base_model` can now be called with an instance of MarkupMachine directly (thanks @patrickwolf) - Bug #715: `HierarchicalMachine._final_check` wrongly determined a parallel state final when the last child was final (thanks @DenizKucukozturk) - Bug #716: `HierarchicalMachine` caused an `AssertionError` when `model_override` was `True` and `NestedSeperator` differed from `_` (thanks @pritam-dey3) +- Feat #706: Instroduce completion transitions which will be executed after a transition has been conducted (thanks @oEscal) ## 0.9.3 (July 2024) From 3ed4d64c05b5061a318315dc59aa5628ea32d9d2 Mon Sep 17 00:00:00 2001 From: Alexander Neumann Date: Thu, 3 Jul 2025 13:42:12 +0200 Subject: [PATCH 05/18] test: adjust diagram tests to new format --- tests/test_graphviz.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/test_graphviz.py b/tests/test_graphviz.py index 54dc95ad..757e542f 100644 --- a/tests/test_graphviz.py +++ b/tests/test_graphviz.py @@ -204,7 +204,7 @@ def test_roi(self): g1 = m.get_graph(show_roi=True) dot, nodes, edges = self.parse_dot(g1) self.assertEqual(0, len(edges)) - self.assertIn(r'label="A\l"', dot) + self.assertIn('label="A\\n"', dot) # make sure that generating a graph without ROI has not influence on the later generated graph # this has to be checked since graph.custom_style is a class property and is persistent for multiple # calls of graph.generate() @@ -213,7 +213,7 @@ def test_roi(self): _ = m.get_graph() g2 = m.get_graph(show_roi=True) dot, _, _ = self.parse_dot(g2) - self.assertNotIn(r'label="A\l"', dot) + self.assertNotIn('label="A\\n"', dot) m.to_B() g3 = m.get_graph(show_roi=True) _, nodes, edges = self.parse_dot(g3) @@ -247,10 +247,10 @@ class CustomMachine(self.machine_cls): # type: ignore transitions=[{'trigger': 'event', 'source': 'A', 'dest': 'B', 'label': 'LabelEvent'}], initial='A', graph_engine=self.graph_engine) dot, _, _ = self.parse_dot(m.get_graph()) - self.assertIn(r'label="LabelA\l"', dot) - self.assertIn(r'label="NotLabelA\l"', dot) + self.assertIn('label="LabelA\\n"', dot) + self.assertIn('label="NotLabelA\\n"', dot) self.assertIn("label=LabelEvent", dot) - self.assertNotIn(r'label="A\l"', dot) + self.assertNotIn('label="A\\n"', dot) self.assertNotIn("label=event", dot) def test_binary_stream(self): From 13a2d4e3e614c7b38145f373ff1534e4088ad99e Mon Sep 17 00:00:00 2001 From: Alexander Neumann Date: Thu, 3 Jul 2025 14:32:42 +0200 Subject: [PATCH 06/18] test: add completion tests --- tests/test_async.py | 10 ++++++++++ tests/test_core.py | 13 +++++++++++++ 2 files changed, 23 insertions(+) diff --git a/tests/test_async.py b/tests/test_async.py index db4d67b4..e2c54efd 100644 --- a/tests/test_async.py +++ b/tests/test_async.py @@ -740,6 +740,16 @@ async def run(): # should use cancel_running_transitions instead await machine.switch_model_context(self) self.assertEqual(len(w), 1) + + def test_completion_transition(self): + states = ['A', 'B', 'C'] + m = self.machine_cls(states=states, initial='A', auto_transitions=False) + m.add_transition('walk', 'A', 'B') + m.add_transition('', 'B', 'C') + + async def run(): + assert await m.walk() + assert m.is_C() asyncio.run(run()) diff --git a/tests/test_core.py b/tests/test_core.py index c9a12136..d70a9c54 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1418,3 +1418,16 @@ class MyMachine(self.machine_cls): # type: ignore assert trans[0].my_int == 23 assert trans[0].my_dict == {"baz": "bar"} assert trans[0].my_none is None + + def test_completion_transition(self): + states = ['A', 'B', 'C'] + transitions = [ + ['walk', 'A', 'B'], + ['', 'B', 'C'], + ['complete', 'C', 'A'] + ] + + m = self.machine_cls(states=states, transitions=transitions, initial='A', auto_transitions=False) + self.assertTrue(m.is_A()) + m.walk() + self.assertTrue(m.is_C()) From 86d3338ddb2d1266372cb92b6ea58e46646eca1d Mon Sep 17 00:00:00 2001 From: Alexander Neumann Date: Thu, 3 Jul 2025 14:37:14 +0200 Subject: [PATCH 07/18] feat: add completion transition to core --- transitions/core.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/transitions/core.py b/transitions/core.py index 3d469ac5..e166baa1 100644 --- a/transitions/core.py +++ b/transitions/core.py @@ -403,7 +403,12 @@ def trigger(self, model, *args, **kwargs): # noinspection PyProtectedMember # Machine._process should not be called somewhere else. That's why it should not be exposed # to Machine users. - return self.machine._process(func) + res = self.machine._process(func) + if res and self.machine._can_trigger(model, "", *args, **kwargs): + _LOGGER.debug("%sTriggering completion event", self.machine.name) + # Trigger the completion event if the machine allows it + res = self.machine.events[""].trigger(model, *args, **kwargs) + return res def _trigger(self, event_data): """Internal trigger function called by the ``Machine`` instance. This should not @@ -925,8 +930,9 @@ def _add_may_transition_func_for_trigger(self, trigger, model): self._checked_assignment(model, "may_%s" % trigger, partial(self._can_trigger, model, trigger)) def _add_trigger_to_model(self, trigger, model): - self._checked_assignment(model, trigger, partial(self.events[trigger].trigger, model)) - self._add_may_transition_func_for_trigger(trigger, model) + if trigger: + self._checked_assignment(model, trigger, partial(self.events[trigger].trigger, model)) + self._add_may_transition_func_for_trigger(trigger, model) def _get_trigger(self, model, trigger_name, *args, **kwargs): """Convenience function added to the model to trigger events by name. From 7341f745c7e448b6b73ff507ca630f0dbc9a6020 Mon Sep 17 00:00:00 2001 From: Alexander Neumann Date: Thu, 3 Jul 2025 14:37:25 +0200 Subject: [PATCH 08/18] feat: add completion transition to asyncio --- transitions/extensions/asyncio.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/transitions/extensions/asyncio.py b/transitions/extensions/asyncio.py index d2525e53..cb8b098b 100644 --- a/transitions/extensions/asyncio.py +++ b/transitions/extensions/asyncio.py @@ -187,7 +187,11 @@ async def trigger(self, model, *args, **kwargs): successfully executed (True if successful, False if not). """ func = partial(self._trigger, EventData(None, self, self.machine, model, args=args, kwargs=kwargs)) - return await self.machine.process_context(func, model) + res = await self.machine.process_context(func, model) + if res and await self.machine._can_trigger(model, "", *args, **kwargs): + _LOGGER.debug("%sTriggering completion event", self.machine.name) + res = await self.machine.events[""].trigger(model, *args, **kwargs) + return res async def _trigger(self, event_data): event_data.state = self.machine.get_state(getattr(event_data.model, self.machine.model_attribute)) @@ -256,6 +260,9 @@ async def trigger_nested(self, event_data): while elems: done.add(machine.state_cls.separator.join(elems)) elems.pop() + if event_data.result and await self.machine._can_trigger(model, "", *event_data.args, **event_data.kwargs): + _LOGGER.debug("%sTriggering completion event", self.machine.name) + event_data.result = await self.machine.events[""].trigger_nested(event_data) return event_data.result async def _process(self, event_data): From 978aafb3ebb088467641c4f2f064af0fd0ffe903 Mon Sep 17 00:00:00 2001 From: Alexander Neumann Date: Thu, 3 Jul 2025 14:37:38 +0200 Subject: [PATCH 09/18] feat: add completion transition to nesting --- transitions/extensions/nesting.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/transitions/extensions/nesting.py b/transitions/extensions/nesting.py index a392482a..5516a914 100644 --- a/transitions/extensions/nesting.py +++ b/transitions/extensions/nesting.py @@ -140,6 +140,9 @@ def trigger_nested(self, event_data): while elems: done.add(machine.state_cls.separator.join(elems)) elems.pop() + if event_data.result and self.machine._can_trigger(model, "", *event_data.args, **event_data.kwargs): + _LOGGER.debug("%sTriggering completion event", machine.name) + event_data.result = machine.events[""].trigger_nested(event_data) return event_data.result def _process(self, event_data): @@ -793,9 +796,9 @@ def _can_trigger_nested(self, model, trigger, path, *args, **kwargs): else: raise source_path.pop(-1) - if path: - with self(path.pop(0)): - return self._can_trigger_nested(model, trigger, path, *args, **kwargs) + if path and path[0] in self.states: + with self(path[0]): + return self._can_trigger_nested(model, trigger, path[1:], *args, **kwargs) return False def get_triggers(self, *args): From a216f90591540073923a4fed1ae676cf1ef90fec Mon Sep 17 00:00:00 2001 From: Alexander Neumann Date: Thu, 3 Jul 2025 15:27:06 +0200 Subject: [PATCH 10/18] fix: escape special chars for mermaid ids this fixes #708 --- transitions/extensions/diagrams_mermaid.py | 26 ++++++++++++++-------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/transitions/extensions/diagrams_mermaid.py b/transitions/extensions/diagrams_mermaid.py index 48b3c36f..07598346 100644 --- a/transitions/extensions/diagrams_mermaid.py +++ b/transitions/extensions/diagrams_mermaid.py @@ -42,8 +42,9 @@ def reset_styling(self): def _add_nodes(self, states, container): for state in states: - container.append("state \"{}\" as {}".format(self._convert_state_attributes(state), state["name"])) - container.append("Class {} s_{}".format(state["name"], + state_id = self._name_to_id(state["name"]) + container.append("state \"{}\" as {}".format(self._convert_state_attributes(state), state_id)) + container.append("Class {} s_{}".format(state_id, self.custom_styles["node"][state["name"]] or "default")) def _add_edges(self, transitions, container): @@ -102,7 +103,7 @@ def get_graph(self, title=None, roi_state=None): fsm_graph.append("") self._add_edges(transitions, fsm_graph) if self.machine.initial and (roi_state is None or roi_state == self.machine.initial): - fsm_graph.append("[*] --> {}".format(self.machine.initial)) + fsm_graph.append("[*] --> {}".format(self._name_to_id(self.machine.initial))) indent = 0 for i in range(len(fsm_graph)): @@ -130,6 +131,10 @@ def _convert_state_attributes(self, state): label += r'\n- timeout(' + state['timeout'] + 's) -> (' + ', '.join(state['on_timeout']) + ')' # end each label with a left-aligned newline return label + + def _name_to_id(self, name): + """Convert a state name to a valid identifier.""" + return name.replace(" ", "___").replace("-", "___").replace(".", "___").replace(":", "___") class NestedGraph(Graph): @@ -152,7 +157,8 @@ def _add_nodes(self, states, container): def _add_nested_nodes(self, states, container, prefix, default_style): for state in states: - name = prefix + state["name"] + state_id = self._name_to_id(state["name"]) + name = prefix + state_id container.append("state \"{}\" as {}".format(self._convert_state_attributes(state), name)) if state.get("final", False): container.append("{} --> [*]".format(name)) @@ -171,7 +177,7 @@ def _add_nested_nodes(self, states, container, prefix, default_style): [child], container, default_style="parallel", - prefix=prefix + state["name"] + self.machine.state_cls.separator, + prefix=prefix + state_id + self.machine.state_cls.separator, ) container.append("--") if state["children"]: @@ -179,12 +185,12 @@ def _add_nested_nodes(self, states, container, prefix, default_style): else: if initial: container.append("[*] --> {}".format( - prefix + state["name"] + self.machine.state_cls.separator + initial)) + prefix + state_id + self.machine.state_cls.separator + initial)) self._add_nested_nodes( state["children"], container, default_style="default", - prefix=prefix + state["name"] + self.machine.state_cls.separator, + prefix=prefix + state_id + self.machine.state_cls.separator, ) container.append("}") @@ -213,8 +219,10 @@ def _add_edges(self, transitions, container): ) for src, dests in edges_attr.items(): + source_id = self._name_to_id(src) for dst, attr in dests.items(): - t = "{source} --> {dest}".format(**attr) + dest_id = self._name_to_id(dst) + t = "{} --> {}".format(source_id, dest_id) if attr["label"]: t += ": {}".format(attr["label"]) container.append(t) @@ -253,7 +261,7 @@ def draw(self, filename, format=None, prog="dot", args=""): return None -invalid = {"style", "shape", "peripheries", "strict", "directed"} +invalid = {"style", "shape", "peripheries", "strict", "directed", "compound"} convertible = {"fillcolor": "fill", "rankdir": "direction"} From 0d418d1653d5ccfa0a9dbb7a36cfb6c62d9e697a Mon Sep 17 00:00:00 2001 From: Alexander Neumann Date: Thu, 3 Jul 2025 15:30:05 +0200 Subject: [PATCH 11/18] feat: replace all non-alphanumeric chars with triple underscore --- transitions/extensions/diagrams_mermaid.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/transitions/extensions/diagrams_mermaid.py b/transitions/extensions/diagrams_mermaid.py index 07598346..21626c7a 100644 --- a/transitions/extensions/diagrams_mermaid.py +++ b/transitions/extensions/diagrams_mermaid.py @@ -8,6 +8,7 @@ import copy import logging from collections import defaultdict +import re from .diagrams_graphviz import filter_states from .diagrams_base import BaseGraph @@ -134,7 +135,9 @@ def _convert_state_attributes(self, state): def _name_to_id(self, name): """Convert a state name to a valid identifier.""" - return name.replace(" ", "___").replace("-", "___").replace(".", "___").replace(":", "___") + # replace all non-alphanumeric characters with underscores + return re.sub(r'\W+', '___', name) + class NestedGraph(Graph): From eff7e1a2e0cead5d295eb1e353cc66a91105b6a5 Mon Sep 17 00:00:00 2001 From: Alexander Neumann Date: Thu, 3 Jul 2025 16:26:03 +0200 Subject: [PATCH 12/18] fix: set styles after nested states --- transitions/extensions/diagrams_mermaid.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/transitions/extensions/diagrams_mermaid.py b/transitions/extensions/diagrams_mermaid.py index 21626c7a..00cb5580 100644 --- a/transitions/extensions/diagrams_mermaid.py +++ b/transitions/extensions/diagrams_mermaid.py @@ -45,8 +45,7 @@ def _add_nodes(self, states, container): for state in states: state_id = self._name_to_id(state["name"]) container.append("state \"{}\" as {}".format(self._convert_state_attributes(state), state_id)) - container.append("Class {} s_{}".format(state_id, - self.custom_styles["node"][state["name"]] or "default")) + _ = self.custom_styles["node"][state_id] def _add_edges(self, transitions, container): edge_labels = defaultdict(lambda: defaultdict(list)) @@ -101,6 +100,7 @@ def get_graph(self, title=None, roi_state=None): active_states = active_states.union({k for k, style in self.custom_styles["node"].items() if style}) states = filter_states(copy.deepcopy(states), active_states, self.machine.state_cls) self._add_nodes(states, fsm_graph) + self._add_node_styles(fsm_graph) fsm_graph.append("") self._add_edges(transitions, fsm_graph) if self.machine.initial and (roi_state is None or roi_state == self.machine.initial): @@ -137,8 +137,13 @@ def _name_to_id(self, name): """Convert a state name to a valid identifier.""" # replace all non-alphanumeric characters with underscores return re.sub(r'\W+', '___', name) - - + + def _add_node_styles(self, container): + """Add styles to the graph.""" + for state_id, style_name in self.custom_styles["node"].items(): + container.append("{}:::s_{}".format( + state_id, style_name or "default") + ) class NestedGraph(Graph): """Graph creation support for transitions.extensions.nested.HierarchicalGraphMachine.""" @@ -149,7 +154,7 @@ def __init__(self, *args, **kwargs): def set_node_style(self, state, style): for state_name in self._get_state_names(state): - super(NestedGraph, self).set_node_style(state_name, style) + super(NestedGraph, self).set_node_style(self._name_to_id(state_name), style) def set_previous_transition(self, src, dst): self.custom_styles["edge"][src][dst] = "previous" @@ -165,9 +170,7 @@ def _add_nested_nodes(self, states, container, prefix, default_style): container.append("state \"{}\" as {}".format(self._convert_state_attributes(state), name)) if state.get("final", False): container.append("{} --> [*]".format(name)) - if not prefix: - container.append("Class {} s_{}".format(name.replace(" ", ""), - self.custom_styles["node"][name] or default_style)) + _ = self.custom_styles["node"][name] if state.get("children", None) is not None: container.append("state {} {{".format(name)) self._cluster_states.append(name) From 562e563fdd6e7895ed96239e21adc55cf8ab374f Mon Sep 17 00:00:00 2001 From: Alexander Neumann Date: Thu, 3 Jul 2025 16:50:27 +0200 Subject: [PATCH 13/18] feat: condense style assignment --- transitions/extensions/diagrams_mermaid.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/transitions/extensions/diagrams_mermaid.py b/transitions/extensions/diagrams_mermaid.py index 00cb5580..3b65aff7 100644 --- a/transitions/extensions/diagrams_mermaid.py +++ b/transitions/extensions/diagrams_mermaid.py @@ -45,7 +45,7 @@ def _add_nodes(self, states, container): for state in states: state_id = self._name_to_id(state["name"]) container.append("state \"{}\" as {}".format(self._convert_state_attributes(state), state_id)) - _ = self.custom_styles["node"][state_id] + self.custom_styles["node"][state_id] = self.custom_styles["node"][state_id] or "default" def _add_edges(self, transitions, container): edge_labels = defaultdict(lambda: defaultdict(list)) @@ -140,10 +140,11 @@ def _name_to_id(self, name): def _add_node_styles(self, container): """Add styles to the graph.""" + collection = defaultdict(set) for state_id, style_name in self.custom_styles["node"].items(): - container.append("{}:::s_{}".format( - state_id, style_name or "default") - ) + collection[style_name or "default"].add(state_id) + for style_name, state_ids in collection.items(): + container.append("class {} {}".format(", ".join(state_ids), "s_" + style_name)) class NestedGraph(Graph): """Graph creation support for transitions.extensions.nested.HierarchicalGraphMachine.""" @@ -170,7 +171,7 @@ def _add_nested_nodes(self, states, container, prefix, default_style): container.append("state \"{}\" as {}".format(self._convert_state_attributes(state), name)) if state.get("final", False): container.append("{} --> [*]".format(name)) - _ = self.custom_styles["node"][name] + self.custom_styles["node"][name] = self.custom_styles["node"][name] or default_style if state.get("children", None) is not None: container.append("state {} {{".format(name)) self._cluster_states.append(name) From d8cdedae9b081cb822e298eed7dc739d571ceac8 Mon Sep 17 00:00:00 2001 From: Alexander Neumann Date: Wed, 10 Sep 2025 11:22:21 +0200 Subject: [PATCH 14/18] fix: styling in test_async --- tests/test_async.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_async.py b/tests/test_async.py index e2c54efd..50cb8ba0 100644 --- a/tests/test_async.py +++ b/tests/test_async.py @@ -740,7 +740,7 @@ async def run(): # should use cancel_running_transitions instead await machine.switch_model_context(self) self.assertEqual(len(w), 1) - + def test_completion_transition(self): states = ['A', 'B', 'C'] m = self.machine_cls(states=states, initial='A', auto_transitions=False) From 5622953490de18442ba7a91225188f52db3bd9f7 Mon Sep 17 00:00:00 2001 From: Alexander Neumann Date: Wed, 10 Sep 2025 11:22:38 +0200 Subject: [PATCH 15/18] refactor: remove test print in graphviz --- tests/test_graphviz.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_graphviz.py b/tests/test_graphviz.py index 757e542f..5942afdc 100644 --- a/tests/test_graphviz.py +++ b/tests/test_graphviz.py @@ -389,7 +389,6 @@ def is_fast(*args, **kwargs): g1 = model.get_graph(show_roi=True) _, nodes, edges = self.parse_dot(g1) self.assertEqual(len(edges), 2) # reset and walk - print(nodes) self.assertEqual(len(nodes), 4) model.walk() model.run() From c0edd550094f9b7f2cceae5637114875a040ffad Mon Sep 17 00:00:00 2001 From: Alexander Neumann Date: Wed, 10 Sep 2025 11:22:54 +0200 Subject: [PATCH 16/18] refactor: remove ununsed imports in test mermaid --- tests/test_mermaid.py | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/tests/test_mermaid.py b/tests/test_mermaid.py index 589a1003..c80049de 100644 --- a/tests/test_mermaid.py +++ b/tests/test_mermaid.py @@ -1,26 +1,11 @@ from .test_graphviz import TestDiagrams, TestDiagramsNested -from .utils import Stuff, DummyModel -from .test_core import TestTransitions, TYPE_CHECKING -from transitions.extensions import ( - LockedGraphMachine, GraphMachine, HierarchicalGraphMachine, LockedHierarchicalGraphMachine -) -from transitions.extensions.states import add_state_features, Timeout, Tags +from transitions.extensions import HierarchicalGraphMachine, LockedHierarchicalGraphMachine from unittest import skipIf import tempfile import os import re import sys -from unittest import TestCase - -try: - # Just to skip tests if graphviz not installed - import graphviz as pgv # @UnresolvedImport -except ImportError: # pragma: no cover - pgv = None - -if TYPE_CHECKING: - from typing import Type, List, Collection, Union, Literal class TestMermaidDiagrams(TestDiagrams): From c7d760893f45e105f1d68259ca04a7d32678bd60 Mon Sep 17 00:00:00 2001 From: Alexander Neumann Date: Wed, 10 Sep 2025 11:23:27 +0200 Subject: [PATCH 17/18] fix: use empty string as default for mermaid style just like graphviz --- transitions/extensions/diagrams_mermaid.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/transitions/extensions/diagrams_mermaid.py b/transitions/extensions/diagrams_mermaid.py index 3b65aff7..fbe0f8f8 100644 --- a/transitions/extensions/diagrams_mermaid.py +++ b/transitions/extensions/diagrams_mermaid.py @@ -45,7 +45,6 @@ def _add_nodes(self, states, container): for state in states: state_id = self._name_to_id(state["name"]) container.append("state \"{}\" as {}".format(self._convert_state_attributes(state), state_id)) - self.custom_styles["node"][state_id] = self.custom_styles["node"][state_id] or "default" def _add_edges(self, transitions, container): edge_labels = defaultdict(lambda: defaultdict(list)) @@ -130,21 +129,21 @@ def _convert_state_attributes(self, state): label += r"\n- exit:\n + " + r"\n + ".join(state["on_exit"]) if "timeout" in state: label += r'\n- timeout(' + state['timeout'] + 's) -> (' + ', '.join(state['on_timeout']) + ')' - # end each label with a left-aligned newline return label - + def _name_to_id(self, name): """Convert a state name to a valid identifier.""" # replace all non-alphanumeric characters with underscores return re.sub(r'\W+', '___', name) - + def _add_node_styles(self, container): """Add styles to the graph.""" collection = defaultdict(set) for state_id, style_name in self.custom_styles["node"].items(): collection[style_name or "default"].add(state_id) for style_name, state_ids in collection.items(): - container.append("class {} {}".format(", ".join(state_ids), "s_" + style_name)) + container.append("class {} {}".format(", ".join(state_ids), "s_" + (style_name or 'default'))) + class NestedGraph(Graph): """Graph creation support for transitions.extensions.nested.HierarchicalGraphMachine.""" @@ -171,11 +170,9 @@ def _add_nested_nodes(self, states, container, prefix, default_style): container.append("state \"{}\" as {}".format(self._convert_state_attributes(state), name)) if state.get("final", False): container.append("{} --> [*]".format(name)) - self.custom_styles["node"][name] = self.custom_styles["node"][name] or default_style if state.get("children", None) is not None: container.append("state {} {{".format(name)) self._cluster_states.append(name) - # with container.subgraph(name=cluster_name, graph_attr=attr) as sub: initial = state.get("initial", "") is_parallel = isinstance(initial, list) if is_parallel: From cdc4d51b74ccea41c2a4bb7344099d556d768eff Mon Sep 17 00:00:00 2001 From: Alexander Neumann Date: Wed, 10 Sep 2025 11:44:04 +0200 Subject: [PATCH 18/18] fix: unset style consistenlty for mermaid --- transitions/extensions/diagrams_mermaid.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/transitions/extensions/diagrams_mermaid.py b/transitions/extensions/diagrams_mermaid.py index fbe0f8f8..b06bcbab 100644 --- a/transitions/extensions/diagrams_mermaid.py +++ b/transitions/extensions/diagrams_mermaid.py @@ -44,6 +44,7 @@ def reset_styling(self): def _add_nodes(self, states, container): for state in states: state_id = self._name_to_id(state["name"]) + self.custom_styles["node"][state_id] = self.custom_styles["node"][state_id] or "" container.append("state \"{}\" as {}".format(self._convert_state_attributes(state), state_id)) def _add_edges(self, transitions, container): @@ -142,7 +143,7 @@ def _add_node_styles(self, container): for state_id, style_name in self.custom_styles["node"].items(): collection[style_name or "default"].add(state_id) for style_name, state_ids in collection.items(): - container.append("class {} {}".format(", ".join(state_ids), "s_" + (style_name or 'default'))) + container.append("class {} {}".format(", ".join(state_ids), "s_" + style_name)) class NestedGraph(Graph): @@ -161,7 +162,7 @@ def set_previous_transition(self, src, dst): self.set_node_style(src, "previous") def _add_nodes(self, states, container): - self._add_nested_nodes(states, container, prefix="", default_style="default") + self._add_nested_nodes(states, container, prefix="", default_style="") def _add_nested_nodes(self, states, container, prefix, default_style): for state in states: @@ -170,6 +171,7 @@ def _add_nested_nodes(self, states, container, prefix, default_style): container.append("state \"{}\" as {}".format(self._convert_state_attributes(state), name)) if state.get("final", False): container.append("{} --> [*]".format(name)) + self.custom_styles["node"][name] = self.custom_styles["node"][name] or default_style if state.get("children", None) is not None: container.append("state {} {{".format(name)) self._cluster_states.append(name) @@ -193,7 +195,7 @@ def _add_nested_nodes(self, states, container, prefix, default_style): self._add_nested_nodes( state["children"], container, - default_style="default", + default_style="", prefix=prefix + state_id + self.machine.state_cls.separator, ) container.append("}")