Skip to content

Commit

Permalink
update changelog; fix markup for enum;
Browse files Browse the repository at this point in the history
make diagram use graphviz when pygraphviz cannot be imported; extend changelog
  • Loading branch information
aleneum committed Jan 10, 2020
1 parent bbba36e commit d28e8c2
Show file tree
Hide file tree
Showing 11 changed files with 119 additions and 81 deletions.
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,4 @@ examples/state.png
MANIFEST
*.bak
pylint.log
sandbox*.py
sandbox*
9 changes: 9 additions & 0 deletions Changelog.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# Changelog

## 0.8.0 (January 2020)

Release 0.8.0 is a major release and introduces asyncio support for Python 3.7+ and some bugfixes

- Feature: Introduced `AsyncMachine` (see discussion #259)
- Bugfix: Auto transitions are added multiple times when add_states is called more than once
- Bugfix: Convert state._name from `Enum` into strings in `MarkupMachine` when necessary
- `GraphMachine` now attempts to fall back to `graphviz` when importing `pygraphviz` fails

## 0.7.2 (January 2020)

Release 0.7.2 is a minor release and contains bugfixes and and a new feature
Expand Down
8 changes: 8 additions & 0 deletions tests/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,14 @@ def test_transition_definitions(self):
m.sprint()
self.assertEqual(m.state, 'D')

def test_add_states(self):
s = self.stuff
s.machine.add_state('X')
s.machine.add_state('Y')
s.machine.add_state('Z')
event = s.machine.events['to_{0}'.format(s.state)]
self.assertEqual(1, len(event.transitions['X']))

def test_transitioning(self):
s = self.stuff
s.machine.add_transition('advance', 'A', 'B')
Expand Down
1 change: 0 additions & 1 deletion tests/test_graphviz.py
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,6 @@ def is_fast(self, *args, **kwargs):
self.assertEqual(len(nodes), 3)

def test_internal(self):
self.use_pygraphviz = True
states = ['A', 'B']
transitions = [['go', 'A', 'B'],
dict(trigger='fail', source='A', dest=None, conditions=['failed']),
Expand Down
35 changes: 31 additions & 4 deletions tests/test_markup.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,25 @@
except ImportError:
pass

from transitions.core import Enum
from transitions.extensions.markup import MarkupMachine, rep
from transitions.extensions.factory import HierarchicalMarkupMachine
from .utils import Stuff
from functools import partial


from unittest import TestCase
from unittest import TestCase, skipIf

try:
from unittest.mock import MagicMock
except ImportError:
from mock import MagicMock

try:
import enum
except ImportError:
enum = None


class SimpleModel(object):

Expand Down Expand Up @@ -94,9 +100,8 @@ def setUp(self):
def test_markup_self(self):
m1 = self.machine_cls(states=self.states, transitions=self.transitions, initial='A')
m1.walk()
# print(m1.markup)
m2 = self.machine_cls(markup=m1.markup)
self.assertEqual(m1.state, m2.state)
self.assertTrue(m1.state == m2.state or m1.state.name == m2.state)
self.assertEqual(len(m1.models), len(m2.models))
self.assertEqual(sorted(m1.states.keys()), sorted(m2.states.keys()))
self.assertEqual(sorted(m1.events.keys()), sorted(m2.events.keys()))
Expand All @@ -108,11 +113,12 @@ def test_markup_model(self):
model1 = SimpleModel()
m1 = self.machine_cls(model1, states=self.states, transitions=self.transitions, initial='A')
model1.walk()
print(m1.markup)
m2 = self.machine_cls(markup=m1.markup)
model2 = m2.models[0]
self.assertIsInstance(model2, SimpleModel)
self.assertEqual(len(m1.models), len(m2.models))
self.assertEqual(model1.state, model2.state)
self.assertTrue(model1.state == model2.state or model1.state.name == model2.state)
self.assertEqual(sorted(m1.states.keys()), sorted(m2.states.keys()))
self.assertEqual(sorted(m1.events.keys()), sorted(m2.events.keys()))

Expand Down Expand Up @@ -158,3 +164,24 @@ def setUp(self):
self.machine_cls = HierarchicalMarkupMachine
self.num_trans = len(self.transitions)
self.num_auto = self.num_trans + 9**2


@skipIf(enum is None, "enum is not available")
class TestMarkupMachineEnum(TestMarkupMachine):

class States(Enum):
A = 1
B = 2
C = 3
D = 4

def setUp(self):
self.machine_cls = MarkupMachine
self.states = TestMarkupMachineEnum.States
self.transitions = [
{'trigger': 'walk', 'source': self.states.A, 'dest': self.states.B},
{'trigger': 'run', 'source': self.states.B, 'dest': self.states.C},
{'trigger': 'sprint', 'source': self.states.C, 'dest': self.states.D}
]
self.num_trans = len(self.transitions)
self.num_auto = self.num_trans + len(self.states)**2
4 changes: 2 additions & 2 deletions tests/test_nesting.py
Original file line number Diff line number Diff line change
Expand Up @@ -541,11 +541,11 @@ def test_ordered_with_graph(self):
State.separator = '/'
machine = GraphMachine('self', states, initial='A',
auto_transitions=False,
ignore_invalid_triggers=True)
ignore_invalid_triggers=True, use_pygraphviz=False)
machine.add_ordered_transitions(trigger='next_state')
machine.next_state()
self.assertEqual(machine.state, 'B')
target = tempfile.NamedTemporaryFile()
target = tempfile.NamedTemporaryFile(suffix='.png')
machine.get_graph().draw(target.name, prog='dot')
self.assertTrue(getsize(target.name) > 0)
target.close()
12 changes: 8 additions & 4 deletions transitions/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -791,10 +791,14 @@ def add_states(self, states, on_enter=None, on_exit=None,
self.states[state.name] = state
for model in self.models:
self._add_model_to_state(state, model)
# Add automatic transitions after all states have been created
if self.auto_transitions:
for state in self.states.keys():
self.add_transition('to_%s' % state, self.wildcard_all, state)
if self.auto_transitions:
for a_state in self.states.keys():
# add all states as sources to auto transitions 'to_<state>' with dest <state>
if a_state == state.name:
self.add_transition('to_%s' % a_state, self.wildcard_all, a_state)
# add auto transition with source <state> to <a_state>
else:
self.add_transition('to_%s' % a_state, state.name, a_state)

def _add_model_to_state(self, state, model):
self._checked_assignment(model, 'is_%s' % state.name, partial(self.is_state, state.value, model))
Expand Down
44 changes: 43 additions & 1 deletion transitions/extensions/diagrams.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,15 @@ def __init__(self, *args, **kwargs):
# keep 'auto_transitions_markup' for backwards compatibility
kwargs['auto_transitions_markup'] = kwargs.pop('show_auto_transitions', False)
self.model_graphs = {}
self.graph_cls = self._init_graphviz_engine(kwargs.pop('use_pygraphviz', True))

# determine graph engine; if pygraphviz cannot be imported, fall back to graphviz
use_pygraphviz = kwargs.pop('use_pygraphviz', True)
if use_pygraphviz:
try:
import pygraphviz
except ImportError:
use_pygraphviz = False
self.graph_cls = self._init_graphviz_engine(use_pygraphviz)

_LOGGER.debug("Using graph engine %s", self.graph_cls)
_super(GraphMachine, self).__init__(*args, **kwargs)
Expand Down Expand Up @@ -205,3 +213,37 @@ def add_transition(self, trigger, source, dest, conditions=None,
before=before, after=after, prepare=prepare, **kwargs)
for model in self.models:
model.get_graph(force_new=True)


class BaseGraph(object):

def __init__(self, machine, title=None):
self.machine = machine
self.fsm_graph = None
self.roi_state = None
self.generate(title)

def _convert_state_attributes(self, state):
label = state.get('label', state['name'])
if self.machine.show_state_attributes:
if 'tags' in state:
label += ' [' + ', '.join(state['tags']) + ']'
if 'on_enter' in state:
label += '\l- enter:\l + ' + '\l + '.join(state['on_enter'])
if 'on_exit' in state:
label += '\l- exit:\l + ' + '\l + '.join(state['on_exit'])
if 'timeout' in state:
label += '\l- timeout(' + state['timeout'] + 's) -> (' + ', '.join(state['on_timeout']) + ')'
return label

def _transition_label(self, tran):
edge_label = tran.get('label', tran['trigger'])
if 'dest' not in tran:
edge_label += " [internal]"
if self.machine.show_conditions and any(prop in tran for prop in ['conditions', 'unless']):
x = '{edge_label} [{conditions}]'.format(
edge_label=edge_label,
conditions=' & '.join(tran.get('conditions', []) + ['!' + u for u in tran.get('unless', [])]),
)
return x
return edge_label
35 changes: 4 additions & 31 deletions transitions/extensions/diagrams_graphviz.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from os.path import splitext

from .nesting import NestedState
from .diagrams import BaseGraph
try:
import graphviz as pgv
except ImportError: # pragma: no cover
Expand All @@ -26,18 +27,15 @@
_super = super


class Graph(object):
class Graph(BaseGraph):
""" Graph creation for transitions.core.Machine.
Attributes:
machine (object): Reference to the related machine.
"""

def __init__(self, machine, title=None):
self.machine = machine
self.roi_state = None
self.custom_styles = None
self.reset_styling()
self.generate(title)
_super(Graph, self).__init__(machine, title)

def set_previous_transition(self, src, dst):
self.custom_styles['edge'][src][dst] = 'previous'
Expand Down Expand Up @@ -70,18 +68,6 @@ def _add_edges(self, transitions, container):
style = self.custom_styles['edge'][src][dst]
container.edge(src, dst, label=' | '.join(labels), **self.machine.style_attributes['edge'][style])

def _transition_label(self, tran):
edge_label = tran.get('label', tran['trigger'])
if 'dest' not in tran:
edge_label += " [internal]"
if self.machine.show_conditions and any(prop in tran for prop in ['conditions', 'unless']):
x = '{edge_label} [{conditions}]'.format(
edge_label=edge_label,
conditions=' & '.join(tran.get('conditions', []) + ['!' + u for u in tran.get('unless', [])]),
)
return x
return edge_label

def generate(self, title=None, roi_state=None):
""" Generate a DOT graph with graphviz
Args:
Expand Down Expand Up @@ -133,22 +119,9 @@ def draw(graph, filename, format=None, prog='dot', args=''):
graph.render(filename, format=format if format else 'png', cleanup=True)
except TypeError:
if format is None:
raise ValueError("Paramter 'format' must not be None when filename is no valid file path.")
raise ValueError("Parameter 'format' must not be None when filename is no valid file path.")
filename.write(graph.pipe(format))

def _convert_state_attributes(self, state):
label = state.get('label', state['name'])
if self.machine.show_state_attributes:
if 'tags' in state:
label += ' [' + ', '.join(state['tags']) + ']'
if 'on_enter' in state:
label += '\l- enter:\l + ' + '\l + '.join(state['on_enter'])
if 'on_exit' in state:
label += '\l- exit:\l + ' + '\l + '.join(state['on_exit'])
if 'timeout' in state:
label += '\l- timeout(' + state['timeout'] + 's) -> (' + ', '.join(state['on_timeout']) + ')'
return label


class NestedGraph(Graph):
""" Graph creation support for transitions.extensions.nested.HierarchicalGraphMachine. """
Expand Down
36 changes: 4 additions & 32 deletions transitions/extensions/diagrams_pygraphviz.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@

import logging
from .nesting import NestedState
from .diagrams import BaseGraph

try:
import pygraphviz as pgv
except ImportError: # pragma: no cover
Expand All @@ -22,18 +24,12 @@
_super = super


class Graph(object):
class Graph(BaseGraph):
""" Graph creation for transitions.core.Machine.
Attributes:
machine (object): Reference to the related machine.
"""

def __init__(self, machine, title=None):
self.machine = machine
self.fsm_graph = None
self.roi_state = None
self.generate(title)

def _add_nodes(self, states, container):
for state in states:
shape = self.machine.style_attributes['node']['default']['shape']
Expand All @@ -53,21 +49,10 @@ def _add_edges(self, transitions, container):
else:
container.add_edge(src, dst, **edge_attr)

def _transition_label(self, tran):
edge_label = tran.get('label', tran['trigger'])
if 'dest' not in tran:
edge_label += " [internal]"
if self.machine.show_conditions and any(prop in tran for prop in ['conditions', 'unless']):
x = '{edge_label} [{conditions}]'.format(
edge_label=edge_label,
conditions=' & '.join(tran.get('conditions', []) + ['!' + u for u in tran.get('unless', [])]),
)
return x
return edge_label

def generate(self, title=None):
""" Generate a DOT graph with pygraphviz, returns an AGraph object """
if not pgv: # pragma: no cover
import pygraphviz
raise Exception('AGraph diagram requires pygraphviz')

title = '' if not title else title
Expand Down Expand Up @@ -117,19 +102,6 @@ def get_graph(self, title=None):
else:
return self.fsm_graph

def _convert_state_attributes(self, state):
label = state.get('label', state['name'])
if self.machine.show_state_attributes:
if 'tags' in state:
label += ' [' + ', '.join(state['tags']) + ']'
if 'on_enter' in state:
label += '\l- enter:\l + ' + '\l + '.join(state['on_enter'])
if 'on_exit' in state:
label += '\l- exit:\l + ' + '\l + '.join(state['on_exit'])
if 'timeout' in state:
label += '\l- timeout(' + state['timeout'] + 's) -> (' + ', '.join(state['on_timeout']) + ')'
return label

def set_node_style(self, state, style):
node = self.fsm_graph.get_node(state)
style_attr = self.fsm_graph.style_attributes.get('node', {}).get(style)
Expand Down
14 changes: 9 additions & 5 deletions transitions/extensions/markup.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,8 @@
from functools import partial
import itertools
import importlib
from collections import defaultdict

from ..core import Machine
from ..core import Machine, Enum
import numbers


Expand Down Expand Up @@ -67,8 +66,12 @@ def _convert_states(self, states):
markup_states = []
for state in states:
s_def = _convert(state, self.state_attributes, self.skip_references)
s_def['name'] = getattr(state, '_name', state.name)
if getattr(state, 'children', False):
state_name = state._name
if isinstance(state_name, Enum):
s_def['name'] = state_name.name
else:
s_def['name'] = state_name
if getattr(state, 'children', []):
s_def['children'] = self._convert_states(state.children)
markup_states.append(s_def)
return markup_states
Expand Down Expand Up @@ -106,7 +109,8 @@ def _add_markup_model(self, markup):
def _convert_models(self):
models = []
for model in self.models:
model_def = dict(state=getattr(model, self.model_attribute))
state = getattr(model, self.model_attribute)
model_def = dict(state=state.name if isinstance(state, Enum) else state)
model_def['name'] = model.name if hasattr(model, 'name') else str(id(model))
model_def['class-name'] = 'self' if model == self else model.__module__ + "." + model.__class__.__name__
models.append(model_def)
Expand Down

0 comments on commit d28e8c2

Please sign in to comment.