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 }} + + + + +
+
+
action
+
gate
+
+
+ +
+
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?)"