Repository: pytransitions/transitions

Issue:
GraphMachine markup should update if machine structure changes
I am experimenting with modifying HierarchicalGraphMachine for my particular use case: using `remove_transition` and/or removing states. I noticed that the GraphMachine family does not update its markup when structural changes occur.

Here is some example code I was messing with...

```python
from transitions.extensions import HierarchicalGraphMachine as HGM
states = ['a','b','c']
initial = 'a'
machine = HGM(states=states, initial='a', auto_transitions=False)
machine.add_transition(trigger='t_ab', source='a',dest='b')
machine.add_transition(trigger='t_bc', source='b',dest='c')
machine.add_transition(trigger='t_ca', source='c',dest='a')
machine.get_graph().draw('machine1.png', format="png", prog='dot')
```
And this produces:
![machine1](https://user-images.githubusercontent.com/1869285/210273838-96cb7c18-febe-41c6-bba9-3a28d8bab30b.png)

Then I take a couple steps:
```python
machine.t_ab()
machine.t_bc()
```
![machine3](https://user-images.githubusercontent.com/1869285/210273906-79376a45-da20-4c55-96fc-932dfbe5ae0b.png)
Now I remove a transition for fun:
```python
machine.remove_transition(trigger='t_ca')
```
The transition is removed correctly (no more `t_ca`), but the markup still has transition as evidenced in this final graph drawing:

![machine4](https://user-images.githubusercontent.com/1869285/210274121-eb9e06a0-a1b7-41e2-8b88-4f16843d6116.png)
and by looking at the markup:
```python
machine.markup['transitions']
```
`[{'source': 'a', 'dest': 'b', 'trigger': 't_ab'}, {'source': 'b', 'dest': 'c', 'trigger': 't_bc'}, {'source': 'c', 'dest': 'a', 'trigger': 't_ca'}]`

It could be I am doing this wrong, but I would expect that removing a transition or state should update the markup that is used to drive the graph output. If this seems reasonable, where would such a feature be implemented? Thanks!


Discussion:

Patch:
diff --git a/Changelog.md b/Changelog.md
index 48b68a2..348fe5a 100644
--- a/Changelog.md
+++ b/Changelog.md
@@ -11,6 +11,7 @@
 - PR #633: Remove surrounding whitespace from docstrings (thanks @artofhuman)
 - PR #665: Add `on_final` to `Machine` and `NestedState` constructor and `final` to `State`. Callbacks passed to `on_final` will be executed when a State with `final=True` is entered or all children of a parallel state are final.
 - Bug #626: Process exceptions with `Machine.on_exception` in may_<trigger> as well (thanks @match1)
+- PR #666: Improved handling of removing transitions for `GraphMachine` and `HiearachicalMachine` (thanks @drpjm)
 - Typing:
   + Made state, event and machine property mandatory in (Nested)EventData
   + Transition.dest may be None
diff --git a/transitions/extensions/diagrams.py b/transitions/extensions/diagrams.py
index cbee76e..28badbd 100644
--- a/transitions/extensions/diagrams.py
+++ b/transitions/extensions/diagrams.py
@@ -260,8 +260,9 @@ class GraphMachine(MarkupMachine):
 
     def remove_transition(self, trigger, source="*", dest="*"):
         super(GraphMachine, self).remove_transition(trigger, source, dest)
+        # update all model graphs since some transitions might be gone
         for model in self.models:
-            model.get_graph(force_new=True)
+            _ = model.get_graph(force_new=True)
 
 
 class NestedGraphTransition(TransitionGraphSupport, NestedTransition):
diff --git a/transitions/extensions/markup.pyi b/transitions/extensions/markup.pyi
index 080c000..c4ea6ef 100644
--- a/transitions/extensions/markup.pyi
+++ b/transitions/extensions/markup.pyi
@@ -8,7 +8,7 @@ from enum import Enum
 
 # mypy does not support recursive definitions (yet), we need to use Any instead of 'MarkupConfig'
 class MarkupConfig(TypedDict):
-    transitions: List[TransitionConfig]
+    transitions: List[Dict[str, Any]]
 
 class MarkupMachine(Machine):
     state_attributes: List[str]
diff --git a/transitions/extensions/nesting.py b/transitions/extensions/nesting.py
index d060ed1..06fe3ed 100644
--- a/transitions/extensions/nesting.py
+++ b/transitions/extensions/nesting.py
@@ -705,12 +705,57 @@ class HierarchicalMachine(Machine):
                 return matches
             source_path.pop()
             while source_path:
-                matches.extend(self.get_transitions(trigger,
-                                                    source=self.state_cls.separator.join(source_path),
-                                                    dest=dest))
+                matches.extend(self.get_nested_transitions(trigger,
+                                                           src_path=source_path,
+                                                           dest_path=dest_path))
                 source_path.pop()
             return matches
 
+    def _remove_nested_transitions(self, trigger, src_path, dest_path):
+        """Remove transitions from nested states.
+        Args:
+            trigger (str): Trigger name of the transition to be removed.
+            src_path (list(str)): If empty, all transitions that match dest_path and trigger will be removed.
+            dest_path (list(str)): If empty, all transitions that match src_path and trigger will be removed.
+        """
+        cur_src = self.state_cls.separator.join(src_path)
+        cur_dst = self.state_cls.separator.join(dest_path)
+        if trigger in self.scoped.events:
+            evt = self.scoped.events[trigger]
+            for src, transitions in evt.transitions.items():
+                evt.transitions[src] = [trans for trans in transitions
+                                        if (src_path and trans.source != cur_src) or (cur_dst and trans.dest != cur_dst)]
+        for state_name in self.scoped.states:
+            with self(state_name):
+                if state_name in [cur_src, cur_dst]:
+                    continue
+                self._remove_nested_transitions(trigger,
+                                                src_path if not src_path or state_name != src_path[0] else src_path[1:],
+                                                dest_path if not dest_path or state_name != dest_path[0] else dest_path[1:])
+
+    def remove_transition(self, trigger, source="*", dest="*"):
+        """Removes transitions matching the passed criteria.
+        Args:
+            trigger (str): Trigger name of the transition.
+            source (str, State or Enum): Limits list to transitions from a certain state.
+            dest (str, State or Enum): Limits list to transitions to a certain state.
+        """
+        with self():
+            source_path = [] if source == "*" \
+                else source.split(self.state_cls.separator) if isinstance(source, string_types) \
+                else self._get_enum_path(source) if isinstance(source, Enum) \
+                else self._get_state_path(source)
+            dest_path = [] if dest == "*" \
+                else dest.split(self.state_cls.separator) if isinstance(dest, string_types) \
+                else self._get_enum_path(dest) if isinstance(dest, Enum) \
+                else self._get_state_path(dest)
+            self._remove_nested_transitions(trigger, source_path, dest_path)
+
+        # remove trigger from models if no transition is left for trigger
+        if not self.get_transitions(trigger):
+            for model in self.models:
+                delattr(model, trigger)
+
     def _can_trigger(self, model, trigger, *args, **kwargs):
         state_tree = self.build_state_tree(getattr(model, self.model_attribute), self.state_cls.separator)
         ordered_states = resolve_order(state_tree)


Test patch:
diff --git a/tests/test_graphviz.py b/tests/test_graphviz.py
index 3244a79..c1b4564 100644
--- a/tests/test_graphviz.py
+++ b/tests/test_graphviz.py
@@ -289,6 +289,16 @@ class TestDiagrams(TestTransitions):
         _, nodes, edges = self.parse_dot(m.get_graph())
         self.assertIn("[is_state(A", edges[0])
 
+    def test_update_on_remove_transition(self):
+        m = self.machine_cls(states=self.states, transitions=self.transitions, initial='A',
+                             use_pygraphviz=self.use_pygraphviz, show_state_attributes=True)
+        _, _, edges = self.parse_dot(m.get_graph())
+        assert "[label=walk]" in edges
+        m.remove_transition(trigger="walk", source="A", dest="B")
+        _, _, edges = self.parse_dot(m.get_graph())
+        assert not any("walk" == t["trigger"] for t in m.markup["transitions"])
+        assert "[label=walk]" not in edges
+
 
 @skipIf(pgv is None, 'Graph diagram test requires graphviz')
 class TestDiagramsLocked(TestDiagrams):
diff --git a/tests/test_nesting.py b/tests/test_nesting.py
index 62001ed..4ce2552 100644
--- a/tests/test_nesting.py
+++ b/tests/test_nesting.py
@@ -973,6 +973,25 @@ class TestSeparatorsBase(TestCase):
         assert m.may_sprint()
         m.run_fast()
 
+    def test_remove_nested_transition(self):
+        separator = self.state_cls.separator
+        state = {
+            'name': 'B',
+            'children': ['1', '2'],
+            'transitions': [['jo', '1', '2']],
+            'initial': '2'
+        }
+        m = self.stuff.machine_cls(initial='A', states=['A', state],
+                                   transitions=[['go', 'A', 'B'], ['go', 'B{0}2'.format(separator),
+                                                                   'B{0}1'.format(separator)]])
+        m.remove_transition("go", "A", "B")
+        assert m.get_transitions("go")
+        m.remove_transition("go")
+        assert not m.get_transitions("go")
+        assert m.get_transitions("jo")
+        m.remove_transition("jo")
+        assert not m.get_transitions("jo")
+
 
 class TestSeparatorsSlash(TestSeparatorsBase):
     separator = '/'
diff --git a/tests/test_pygraphviz.py b/tests/test_pygraphviz.py
index da7984d..051a157 100644
--- a/tests/test_pygraphviz.py
+++ b/tests/test_pygraphviz.py
@@ -97,7 +97,20 @@ class PygraphvizTest(TestDiagrams):
         self.states[0] = {'name': 'A', 'tags': ['new', 'polling'], 'timeout': 5, 'on_enter': 'say_hello',
                           'on_exit': 'say_goodbye', 'on_timeout': 'do_something'}
         m = CustomMachine(states=self.states, transitions=self.transitions, initial='A', show_state_attributes=True)
-        g = m.get_graph(show_roi=True)
+        _ = m.get_graph(show_roi=True)
+
+    def test_update_on_remove_transition(self):
+
+        m = self.machine_cls(states=self.states, transitions=self.transitions, initial='A', show_state_attributes=True)
+        g = m.get_graph()
+        e = g.get_edge("A", "B")
+        assert e in g.edges()
+        m.remove_transition(trigger="walk", source="A", dest="B")
+        assert not any("walk" == t["trigger"] for t in m.markup["transitions"])
+        g = m.get_graph()
+        with self.assertRaises(KeyError):
+            _ = g.get_edge("A", "B")
+        assert e not in g.edges()
 
 
 @skipIf(pgv is None, 'NestedGraph diagram requires pygraphviz')