diff --git a/MANIFEST.in b/MANIFEST.in
index 62d62da6..b86e859a 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -1,2 +1,3 @@
include version.py
include routemaster/config/schema.yaml
+include routemaster/config/visualisation.jinja
diff --git a/routemaster/config/model.py b/routemaster/config/model.py
index ae0ba8fa..847a4b59 100644
--- a/routemaster/config/model.py
+++ b/routemaster/config/model.py
@@ -1,6 +1,7 @@
"""Loading and validation of config files."""
import datetime
+import collections
from typing import (
TYPE_CHECKING,
Any,
@@ -11,6 +12,7 @@
Pattern,
Iterable,
Sequence,
+ Collection,
NamedTuple,
)
from dataclasses import dataclass
@@ -98,10 +100,14 @@ def next_state_for_label(self, label_context: Any) -> str:
"""Returns the constant next state."""
return self.state
- def all_destinations(self) -> Iterable[str]:
+ def all_destinations(self) -> Collection[str]:
"""Returns the constant next state."""
return [self.state]
+ def destinations_for_render(self) -> Mapping[str, str]:
+ """Returns the constant next state."""
+ return {self.state: ""}
+
class ContextNextStatesOption(NamedTuple):
"""Represents an option for a context conditional next state."""
@@ -123,10 +129,34 @@ def next_state_for_label(self, label_context: 'Context') -> str:
return destination.state
return self.default
- def all_destinations(self) -> Iterable[str]:
+ def all_destinations(self) -> Collection[str]:
"""Returns all possible destination states."""
return [x.state for x in self.destinations] + [self.default]
+ def destinations_for_render(self) -> Mapping[str, str]:
+ """
+ Returns destination states and a summary of how each might be reached.
+
+ This is intended for use in visualisations, so while the description of
+ how to reach each state is somewhat Pythonic its focus is being
+ human-readable.
+ """
+ destination_reasons = [
+ (x.state, f"{self.path} == {x.value}")
+ for x in self.destinations
+ ] + [
+ (self.default, "default"),
+ ]
+
+ collected = collections.defaultdict(list)
+ for destination, raeson in destination_reasons:
+ collected[destination].append(raeson)
+
+ return {
+ destination: " or ".join(reasons)
+ for destination, reasons in collected.items()
+ }
+
class NoNextStates(NamedTuple):
"""Represents the lack of a next state to progress to."""
@@ -137,10 +167,14 @@ def next_state_for_label(self, label_context: Any) -> str:
"Attempted to progress from a state with no next state",
)
- def all_destinations(self) -> Iterable[str]:
+ def all_destinations(self) -> Collection[str]:
"""Returns no states."""
return []
+ def destinations_for_render(self) -> Mapping[str, str]:
+ """Returns no states."""
+ return {}
+
NextStates = Union[ConstantNextState, ContextNextStates, NoNextStates]
diff --git a/routemaster/config/tests/test_loading.py b/routemaster/config/tests/test_loading.py
index 7ff2b180..1c55b04c 100644
--- a/routemaster/config/tests/test_loading.py
+++ b/routemaster/config/tests/test_loading.py
@@ -2,7 +2,6 @@
import re
import datetime
import contextlib
-from pathlib import Path
from unittest import mock
import pytest
@@ -415,7 +414,7 @@ def test_multiple_feeds_same_name_invalid():
load_config(yaml_data('multiple_feeds_same_name_invalid'))
-def test_example_config_loads():
+def test_example_config_loads(repo_root):
"""
Test that the example.yaml in this repo can be loaded.
@@ -423,7 +422,6 @@ def test_example_config_loads():
test of the system.
"""
- repo_root = Path(__file__).parent.parent.parent.parent
example_yaml = repo_root / 'example.yaml'
assert example_yaml.exists(), "Example file is missing! (is this test set up correctly?)"
diff --git a/routemaster/config/tests/test_next_states.py b/routemaster/config/tests/test_next_states.py
index 7c2f9736..3875d3a3 100644
--- a/routemaster/config/tests/test_next_states.py
+++ b/routemaster/config/tests/test_next_states.py
@@ -11,12 +11,14 @@
def test_constant_next_state():
next_states = ConstantNextState(state='foo')
assert next_states.all_destinations() == ['foo']
+ assert next_states.destinations_for_render() == {'foo': ""}
assert next_states.next_state_for_label(None) == 'foo'
def test_no_next_states_must_not_be_called():
next_states = NoNextStates()
assert next_states.all_destinations() == []
+ assert next_states.destinations_for_render() == {}
with pytest.raises(RuntimeError):
next_states.next_state_for_label(None)
@@ -34,6 +36,11 @@ def test_context_next_states(make_context):
context = make_context(label='label1', metadata={'foo': True})
assert next_states.all_destinations() == ['1', '2', '3']
+ assert next_states.destinations_for_render() == {
+ '1': "metadata.foo == True",
+ '2': "metadata.foo == False",
+ '3': "default",
+ }
assert next_states.next_state_for_label(context) == '1'
diff --git a/routemaster/config/visualisation.jinja b/routemaster/config/visualisation.jinja
new file mode 100644
index 00000000..b4376f99
--- /dev/null
+++ b/routemaster/config/visualisation.jinja
@@ -0,0 +1,174 @@
+
+
+
+
+
+ Visualisation of state machine {{ state_machine_name }}
+
+
+
+
+
+
+
+
+
+
Click and drag to move the graph; scroll to zoom
+
+
+
+
+
diff --git a/routemaster/conftest.py b/routemaster/conftest.py
index 050b9969..b2181270 100644
--- a/routemaster/conftest.py
+++ b/routemaster/conftest.py
@@ -9,6 +9,7 @@
import contextlib
import subprocess
from typing import Any, Dict
+from pathlib import Path
from unittest import mock
import pytest
@@ -499,6 +500,12 @@ def version():
return 'development'
+@pytest.fixture()
+def repo_root():
+ """Return root path of the repo."""
+ return Path(__file__).parent.parent
+
+
@pytest.fixture()
def current_state(app):
"""Get the current state of a label."""
diff --git a/routemaster/server/endpoints.py b/routemaster/server/endpoints.py
index 9d3038ba..0cc643ba 100644
--- a/routemaster/server/endpoints.py
+++ b/routemaster/server/endpoints.py
@@ -2,7 +2,7 @@
import sqlalchemy
import pkg_resources
-from flask import Flask, abort, jsonify, request
+from flask import Flask, abort, jsonify, request, render_template_string
from routemaster import state_machine
from routemaster.state_machine import (
@@ -10,6 +10,7 @@
UnknownLabel,
LabelAlreadyExists,
UnknownStateMachine,
+ nodes_for_cytoscape,
)
server = Flask('routemaster')
@@ -59,6 +60,7 @@ def get_state_machines():
'state-machines': [
{
'name': x.name,
+ 'view': f'/state-machines/{x.name}/view',
'labels': f'/state-machines/{x.name}/labels',
}
for x in server.config.app.config.state_machines.values()
@@ -66,6 +68,35 @@ def get_state_machines():
})
+@server.route('/state-machines//view', methods=['GET'])
+def view_state_machine(state_machine_name):
+ """
+ Render an image of a state machine.
+
+ Returns:
+ - 200 Ok, HTML: if the state machine exists.
+ - 404 Not Found: if the state machine does not exist.
+ """
+ app = server.config.app
+
+ try:
+ state_machine = app.config.state_machines[state_machine_name]
+ except KeyError:
+ msg = f"State machine '{state_machine_name}' does not exist"
+ abort(404, msg)
+
+ template_string = pkg_resources.resource_string(
+ 'routemaster.config',
+ 'visualisation.jinja',
+ ).decode('utf-8')
+
+ return render_template_string(
+ template_string,
+ state_machine_name=state_machine_name,
+ state_machine_config=nodes_for_cytoscape(state_machine),
+ )
+
+
@server.route(
'/state-machines//labels',
methods=['GET'],
diff --git a/routemaster/server/tests/test_endpoints.py b/routemaster/server/tests/test_endpoints.py
index d4857344..e7b909e9 100644
--- a/routemaster/server/tests/test_endpoints.py
+++ b/routemaster/server/tests/test_endpoints.py
@@ -34,6 +34,7 @@ def test_enumerate_state_machines(client, app):
{
'name': state_machine.name,
'labels': f'/state-machines/{state_machine.name}/labels',
+ 'view': f'/state-machines/{state_machine.name}/view',
}
for state_machine in app.config.state_machines.values()
]}
@@ -339,3 +340,15 @@ def test_update_label_410_for_deleted_label(
content_type='application/json',
)
assert response.status_code == 410
+
+
+def test_get_visualisation(client):
+ response = client.get('/state-machines/test_machine/view')
+ assert response.status_code == 200
+ assert 'text/html' in response.headers['Content-Type']
+
+
+def test_get_visualisation_404_for_not_found(client):
+ response = client.get('/state-machines/no_such_machine/view')
+ assert response.status_code == 404
+ assert 'text/html' in response.headers['Content-Type']
diff --git a/routemaster/state_machine/__init__.py b/routemaster/state_machine/__init__.py
index 88f9f288..5264502c 100644
--- a/routemaster/state_machine/__init__.py
+++ b/routemaster/state_machine/__init__.py
@@ -25,6 +25,7 @@
LabelAlreadyExists,
UnknownStateMachine,
)
+from routemaster.state_machine.visualisation import nodes_for_cytoscape
__all__ = (
'LabelRef',
@@ -43,6 +44,7 @@
'LabelAlreadyExists',
'LabelStateProcessor',
'UnknownStateMachine',
+ 'nodes_for_cytoscape',
'update_metadata_for_label',
'labels_in_state_with_metadata',
'labels_needing_metadata_update_retry_in_gate',
diff --git a/routemaster/state_machine/tests/test_visualisation.py b/routemaster/state_machine/tests/test_visualisation.py
new file mode 100644
index 00000000..8e9a0d86
--- /dev/null
+++ b/routemaster/state_machine/tests/test_visualisation.py
@@ -0,0 +1,86 @@
+import layer_loader
+
+from routemaster.config import yaml_load, load_config
+from routemaster.state_machine import nodes_for_cytoscape
+
+TEST_MACHINE_STATE_AS_NETWORK = [
+ {
+ 'data': {'id': 'start'},
+ 'classes': 'gate',
+ },
+ {
+ 'data': {
+ 'source': 'start',
+ 'target': 'perform_action',
+ 'label': 'feeds.tests.should_do_alternate_action == False or default',
+ },
+ },
+ {
+ 'data': {
+ 'source': 'start',
+ 'target': 'perform_alternate_action',
+ 'label': 'feeds.tests.should_do_alternate_action == True',
+ },
+ },
+ {
+ 'data': {
+ 'id': 'perform_action',
+ },
+ 'classes': 'action',
+ },
+ {
+ 'data': {
+ 'source': 'perform_action',
+ 'target': 'end',
+ 'label': '',
+ },
+ },
+ {
+ 'data': {
+ 'id': 'perform_alternate_action',
+ },
+ 'classes': 'action',
+ },
+ {
+ 'data': {
+ 'source': 'perform_alternate_action',
+ 'target': 'end',
+ 'label': 'feeds.tests.should_loop == False or default',
+ },
+ },
+ {
+ 'data': {
+ 'source': 'perform_alternate_action',
+ 'target': 'start',
+ 'label': 'feeds.tests.should_loop == True',
+ },
+ },
+ {
+ 'data': {'id': 'end'},
+ 'classes': 'gate',
+ },
+]
+
+
+def test_nodes_for_cytoscape(app):
+ nodes = nodes_for_cytoscape(app.config.state_machines['test_machine'])
+
+ assert nodes == TEST_MACHINE_STATE_AS_NETWORK
+
+
+def test_convert_example_to_network(app, repo_root):
+ example_yaml = repo_root / 'example.yaml'
+
+ assert example_yaml.exists(), "Example file is missing! (is this test set up correctly?)"
+
+ example_config = load_config(
+ layer_loader.load_files(
+ [example_yaml],
+ loader=yaml_load,
+ ),
+ )
+
+ # quick check that we've loaded the config we expect
+ assert list(example_config.state_machines.keys()) == ['user_lifecycle']
+
+ nodes_for_cytoscape(example_config.state_machines['user_lifecycle'])
diff --git a/routemaster/state_machine/visualisation.py b/routemaster/state_machine/visualisation.py
new file mode 100644
index 00000000..3e199ec6
--- /dev/null
+++ b/routemaster/state_machine/visualisation.py
@@ -0,0 +1,31 @@
+"""Visualisation code for state machines."""
+
+from typing import Dict, List, Union
+
+from routemaster.config import Action, StateMachine
+
+CytoscapeData = List[Dict[str, Union[Dict[str, str], str]]]
+
+
+def nodes_for_cytoscape(
+ state_machine: StateMachine,
+) -> CytoscapeData:
+ """Produce an SVG drawing of a state machine."""
+ elements: CytoscapeData = []
+
+ for state in state_machine.states:
+ node_kind = 'action' if isinstance(state, Action) else 'gate'
+ elements.append({
+ 'data': {'id': state.name},
+ 'classes': node_kind,
+ })
+
+ destinations = state.next_states.destinations_for_render()
+ for destination_name, reason in destinations.items():
+ elements.append({'data': {
+ 'source': state.name,
+ 'target': destination_name,
+ 'label': reason,
+ }})
+
+ return elements
diff --git a/routemaster/tests/test_validation.py b/routemaster/tests/test_validation.py
index 9d9206d8..27753530 100644
--- a/routemaster/tests/test_validation.py
+++ b/routemaster/tests/test_validation.py
@@ -1,5 +1,3 @@
-from pathlib import Path
-
import pytest
import layer_loader
@@ -204,7 +202,7 @@ def test_label_in_deleted_state_on_per_state_machine_basis(
_validate_state_machine(app, state_machine)
-def test_example_config_is_valid(app):
+def test_example_config_is_valid(app, repo_root):
"""
Test that the example.yaml in this repo is valid.
@@ -212,7 +210,6 @@ def test_example_config_is_valid(app):
test of the system.
"""
- repo_root = Path(__file__).parent.parent.parent
example_yaml = repo_root / 'example.yaml'
assert example_yaml.exists(), "Example file is missing! (is this test set up correctly?)"