diff --git a/routemaster/cli.py b/routemaster/cli.py index b194f1d1..4f5bf316 100644 --- a/routemaster/cli.py +++ b/routemaster/cli.py @@ -9,7 +9,6 @@ from routemaster.config import ConfigError, load_config from routemaster.server import server from routemaster.validation import ValidationError, validate_config -from routemaster.record_states import record_state_machines from routemaster.gunicorn_application import GunicornWSGIApplication logger = logging.getLogger(__name__) @@ -92,8 +91,6 @@ def serve(ctx, bind, debug): # pragma: no cover if debug: server.config['DEBUG'] = True - record_state_machines(app, app.config.state_machines.values()) - cron_thread = CronThread(app) cron_thread.start() diff --git a/routemaster/db/__init__.py b/routemaster/db/__init__.py index 13d4feb9..ca14fa78 100644 --- a/routemaster/db/__init__.py +++ b/routemaster/db/__init__.py @@ -1,21 +1,11 @@ """Public Database interface.""" -from routemaster.db.model import ( - edges, - labels, - states, - history, - metadata, - state_machines, -) +from routemaster.db.model import labels, history, metadata from routemaster.db.initialisation import initialise_db __all__ = ( - 'edges', 'labels', - 'states', 'history', 'metadata', - 'state_machines', 'initialise_db', ) diff --git a/routemaster/db/model.py b/routemaster/db/model.py index 8ae80cc7..0dde9cd2 100644 --- a/routemaster/db/model.py +++ b/routemaster/db/model.py @@ -12,7 +12,6 @@ Integer, DateTime, MetaData, - ForeignKey, FetchedValue, ForeignKeyConstraint, func, @@ -93,57 +92,3 @@ # Null indicates being deleted from a state machine NullableColumn('new_state', String), ) - - -""" -Represents a state machine. - -We serialise versions of the configuration into the database so that the -structure of the state machines can be exported to a data warehouse. -""" -state_machines = Table( - 'state_machines', - metadata, - - Column('name', String, primary_key=True), - Column('updated', DateTime), -) - - -"""Represents a state in a state machine.""" -states = Table( - 'states', - metadata, - Column('name', String, primary_key=True), - Column( - 'state_machine', - String, - ForeignKey('state_machines.name'), - primary_key=True, - ), - - # `deprecated = True` represents a state that is no longer accessible. - Column('deprecated', Boolean, default=False), - - Column('updated', DateTime), -) - - -"""Represents an edge between states in a state machine.""" -edges = Table( - 'edges', - metadata, - Column('state_machine', String, primary_key=True), - Column('from_state', String, primary_key=True), - Column('to_state', String, primary_key=True), - Column('deprecated', Boolean, default=False), - Column('updated', DateTime), - ForeignKeyConstraint( - columns=('state_machine', 'from_state'), - refcolumns=(states.c.state_machine, states.c.name), - ), - ForeignKeyConstraint( - columns=('state_machine', 'to_state'), - refcolumns=(states.c.state_machine, states.c.name), - ), -) diff --git a/routemaster/migrations/versions/9871e5c166b4_drop_data_warehouse_models_for_now.py b/routemaster/migrations/versions/9871e5c166b4_drop_data_warehouse_models_for_now.py new file mode 100644 index 00000000..da2626a1 --- /dev/null +++ b/routemaster/migrations/versions/9871e5c166b4_drop_data_warehouse_models_for_now.py @@ -0,0 +1,119 @@ +""" +drop data warehouse models for now + +Revision ID: 9871e5c166b4 +Revises: 3eb4f3b419c6 +""" +import sqlalchemy as sa + +from alembic import op + +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '9871e5c166b4' +down_revision = '3eb4f3b419c6' +branch_labels = None +depends_on = None + + +def upgrade(): + op.drop_table('edges') + op.drop_table('state_machines') + op.drop_table('states') + + +def downgrade(): + op.create_table( + 'states', + sa.Column('name', sa.VARCHAR(), autoincrement=False, nullable=False), + sa.Column( + 'state_machine', + sa.VARCHAR(), + autoincrement=False, + nullable=False, + ), + sa.Column( + 'deprecated', + sa.BOOLEAN(), + server_default=sa.text('false'), + autoincrement=False, + nullable=False, + ), + sa.Column( + 'updated', + postgresql.TIMESTAMP(), + server_default=sa.text('now()'), + autoincrement=False, + nullable=False, + ), + sa.ForeignKeyConstraint( + ['state_machine'], + ['state_machines.name'], + name='states_state_machine_fkey', + ), + sa.PrimaryKeyConstraint('name', 'state_machine', name='states_pkey'), + postgresql_ignore_search_path=False, + ) + op.create_table( + 'state_machines', + sa.Column('name', sa.VARCHAR(), autoincrement=False, nullable=False), + sa.Column( + 'updated', + postgresql.TIMESTAMP(), + server_default=sa.text('now()'), + autoincrement=False, + nullable=False, + ), + sa.PrimaryKeyConstraint('name', name='state_machines_pkey'), + postgresql_ignore_search_path=False, + ) + op.create_table( + 'edges', + sa.Column( + 'state_machine', + sa.VARCHAR(), + autoincrement=False, + nullable=False, + ), + sa.Column( + 'from_state', + sa.VARCHAR(), + autoincrement=False, + nullable=False, + ), + sa.Column( + 'to_state', + sa.VARCHAR(), + autoincrement=False, + nullable=False, + ), + sa.Column( + 'deprecated', + sa.BOOLEAN(), + autoincrement=False, + nullable=False, + ), + sa.Column( + 'updated', + postgresql.TIMESTAMP(), + autoincrement=False, + nullable=False, + ), + sa.ForeignKeyConstraint( + ['state_machine', 'from_state'], + ['states.state_machine', 'states.name'], + name='edges_state_machine_fkey', + ), + sa.ForeignKeyConstraint( + ['state_machine', 'to_state'], + ['states.state_machine', 'states.name'], + name='edges_state_machine_fkey1', + ), + sa.PrimaryKeyConstraint( + 'state_machine', + 'from_state', + 'to_state', + name='edges_pkey', + ), + ) diff --git a/routemaster/record_states/__init__.py b/routemaster/record_states/__init__.py deleted file mode 100644 index 727a529b..00000000 --- a/routemaster/record_states/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -"""Mechanism for recording states and state machines in the DB.""" - -from routemaster.record_states.api import record_state_machines - -__all__ = ( - 'record_state_machines', -) diff --git a/routemaster/record_states/api.py b/routemaster/record_states/api.py deleted file mode 100644 index cb8a6be1..00000000 --- a/routemaster/record_states/api.py +++ /dev/null @@ -1,63 +0,0 @@ -"""Public API for state recording subsystem.""" - -from typing import Iterable - -from sqlalchemy import func, select - -from routemaster.db import state_machines -from routemaster.app import App -from routemaster.config import StateMachine -from routemaster.record_states.utils import ( - resync_state_machine_names, - resync_states_on_state_machine, -) - - -def record_state_machines( - app: App, - machines: Iterable[StateMachine], -) -> None: - """ - Record the new state as being one set of state machines. - - A ValueError is raised in case of incompatibility between the old and new - configurations. - """ - machines = list(machines) - machines_by_name = {x.name: x for x in machines} - - with app.db.begin() as conn: - old_machine_names = set( - x.name - for x in conn.execute( - select(( - state_machines.c.name, - )), - ).fetchall() - ) - - resync_machines = resync_state_machine_names( - conn, - old_machine_names, - machines, - ) - - updated_state_machine_names = set() - - for machine_name in resync_machines: - machine = machines_by_name[machine_name] - - any_changes = resync_states_on_state_machine(conn, machine) - if any_changes: - updated_state_machine_names.add(machine.name) - - updated_state_machine_names &= old_machine_names - - if updated_state_machine_names: - conn.execute( - state_machines.update().where( - state_machines.c.name.in_(updated_state_machine_names), - ).values( - updated=func.now(), - ), - ) diff --git a/routemaster/record_states/tests/test_record_states.py b/routemaster/record_states/tests/test_record_states.py deleted file mode 100644 index 05e3d69c..00000000 --- a/routemaster/record_states/tests/test_record_states.py +++ /dev/null @@ -1,416 +0,0 @@ -import pytest -from sqlalchemy import func, select - -from routemaster.db import edges, states, state_machines -from routemaster.config import ( - Gate, - NoNextStates, - StateMachine, - ConstantNextState, -) -from routemaster.record_states import record_state_machines -from routemaster.exit_conditions import ExitConditionProgram - - -def test_record_no_states_has_no_effect(app_config): - record_state_machines(app_config, []) - - with app_config.db.begin() as conn: - num_machines = conn.scalar(state_machines.count()) - - assert num_machines == 0 - - -def test_record_single_trivial_machine(app_config): - record_state_machines(app_config, [ - StateMachine( - name='machine', - feeds=[], - webhooks=[], - states=[ - Gate( - name='state', - exit_condition=ExitConditionProgram('false'), - next_states=NoNextStates(), - triggers=[], - ), - ], - ) - ]) - - with app_config.db.begin() as conn: - num_machines = conn.scalar(state_machines.count()) - assert num_machines == 1 - - machine_definition = conn.execute(state_machines.select()).fetchone() - assert machine_definition.name == 'machine' - - num_states = conn.scalar(states.count()) - assert num_states == 1 - - state_definition = conn.execute(states.select()).fetchone() - assert state_definition.name == 'state' - assert not state_definition.deprecated - - num_edges = conn.scalar(edges.count()) - assert num_edges == 0 - - -def test_record_single_trivial_machine_twice(app_config): - state_machines_config = [ - StateMachine( - name='machine', - feeds=[], - webhooks=[], - states=[ - Gate( - name='state', - exit_condition=ExitConditionProgram('false'), - next_states=NoNextStates(), - triggers=[], - ), - ], - ) - ] - - record_state_machines(app_config, state_machines_config) - record_state_machines(app_config, state_machines_config) - - with app_config.db.begin() as conn: - num_machines = conn.scalar(state_machines.count()) - assert num_machines == 1 - - machine_definition = conn.execute(state_machines.select()).fetchone() - assert machine_definition.name == 'machine' - - num_states = conn.scalar(states.count()) - assert num_states == 1 - - state_definition = conn.execute(states.select()).fetchone() - assert state_definition.name == 'state' - assert not state_definition.deprecated - - num_edges = conn.scalar(edges.count()) - assert num_edges == 0 - - -@pytest.mark.xfail( - reason="removed while we investigate implementation options", -) -def test_delete_single_trivial_machine(app_config): - record_state_machines(app_config, [ - StateMachine( - name='machine', - feeds=[], - webhooks=[], - states=[ - Gate( - name='state', - exit_condition=ExitConditionProgram('false'), - next_states=NoNextStates(), - triggers=[], - ), - ], - ) - ]) - - record_state_machines(app_config, []) - - with app_config.db.begin() as conn: - num_machines = conn.scalar(state_machines.count()) - assert num_machines == 0 - - -def test_deprecate_state_in_state_machine(app_config): - record_state_machines(app_config, [ - StateMachine( - name='machine', - feeds=[], - webhooks=[], - states=[ - Gate( - name='state_old', - exit_condition=ExitConditionProgram('false'), - next_states=NoNextStates(), - triggers=[], - ), - ], - ) - ]) - - record_state_machines(app_config, [ - StateMachine( - name='machine', - feeds=[], - webhooks=[], - states=[ - Gate( - name='state_new', - exit_condition=ExitConditionProgram('false'), - next_states=NoNextStates(), - triggers=[], - ), - ], - ) - ]) - - with app_config.db.begin() as conn: - state_deprecations = { - x.name: x.deprecated - for x in conn.execute( - select(( - states.c.name, - states.c.deprecated, - )), - ) - } - - assert state_deprecations == { - 'state_old': True, - 'state_new': False, - } - - -def test_undeprecate_state_in_state_machine(app_config): - record_state_machines(app_config, [ - StateMachine( - name='machine', - feeds=[], - webhooks=[], - states=[ - Gate( - name='state_old', - exit_condition=ExitConditionProgram('false'), - next_states=NoNextStates(), - triggers=[], - ), - ], - ) - ]) - - record_state_machines(app_config, [ - StateMachine( - name='machine', - feeds=[], - webhooks=[], - states=[ - Gate( - name='state_new', - exit_condition=ExitConditionProgram('false'), - next_states=NoNextStates(), - triggers=[], - ), - ], - ) - ]) - - record_state_machines(app_config, [ - StateMachine( - name='machine', - feeds=[], - webhooks=[], - states=[ - Gate( - name='state_old', - exit_condition=ExitConditionProgram('false'), - next_states=NoNextStates(), - triggers=[], - ), - ], - ) - ]) - - with app_config.db.begin() as conn: - state_deprecations = { - x.name: x.deprecated - for x in conn.execute( - select(( - states.c.name, - states.c.deprecated, - )), - ) - } - - assert state_deprecations == { - 'state_old': False, - 'state_new': True, - } - - -def test_record_edges(app_config): - record_state_machines(app_config, [ - StateMachine( - name='machine', - feeds=[], - webhooks=[], - states=[ - Gate( - name='state1', - exit_condition=ExitConditionProgram('true'), - next_states=ConstantNextState('state2'), - triggers=[], - ), - Gate( - name='state2', - exit_condition=ExitConditionProgram('false'), - next_states=NoNextStates(), - triggers=[], - ), - ], - ) - ]) - - with app_config.db.begin() as conn: - num_states = conn.scalar(states.count()) - assert num_states == 2 - - num_edges = conn.scalar(edges.count()) - assert num_edges == 1 - - edge_definition = conn.execute(edges.select()).fetchone() - assert edge_definition.from_state == 'state1' - assert edge_definition.to_state == 'state2' - assert not edge_definition.deprecated - - -def test_edges_are_deprecated_when_removed(app_config): - record_state_machines(app_config, [ - StateMachine( - name='machine', - feeds=[], - webhooks=[], - states=[ - Gate( - name='state1', - exit_condition=ExitConditionProgram('true'), - next_states=ConstantNextState('state2'), - triggers=[], - ), - Gate( - name='state2', - exit_condition=ExitConditionProgram('false'), - next_states=NoNextStates(), - triggers=[], - ), - ], - ) - ]) - - record_state_machines(app_config, [ - StateMachine( - name='machine', - feeds=[], - webhooks=[], - states=[ - Gate( - name='state1', - exit_condition=ExitConditionProgram('true'), - next_states=ConstantNextState('state3'), - triggers=[], - ), - Gate( - name='state3', - exit_condition=ExitConditionProgram('false'), - next_states=NoNextStates(), - triggers=[], - ), - ], - ) - ]) - - with app_config.db.begin() as conn: - num_edges = conn.scalar(edges.count()) - assert num_edges == 2 - - edge_deprecations = { - (x.from_state, x.to_state): x.deprecated - for x in conn.execute( - select(( - edges.c.from_state, - edges.c.to_state, - edges.c.deprecated, - )).where( - edges.c.state_machine == 'machine' - ) - ) - } - assert edge_deprecations == { - ('state1', 'state2'): True, - ('state1', 'state3'): False, - } - - -def test_edges_are_undeprecated_when_readded(app_config): - record_state_machines(app_config, [ - StateMachine( - name='machine', - feeds=[], - webhooks=[], - states=[ - Gate( - name='state1', - exit_condition=ExitConditionProgram('true'), - next_states=ConstantNextState('state2'), - triggers=[], - ), - Gate( - name='state2', - exit_condition=ExitConditionProgram('false'), - next_states=NoNextStates(), - triggers=[], - ), - ], - ), - ]) - - record_state_machines(app_config, [ - StateMachine( - name='machine', - feeds=[], - webhooks=[], - states=[ - Gate( - name='state1', - exit_condition=ExitConditionProgram('false'), - next_states=NoNextStates(), - triggers=[], - ), - ], - ), - ]) - - record_state_machines(app_config, [ - StateMachine( - name='machine', - feeds=[], - webhooks=[], - states=[ - Gate( - name='state1', - exit_condition=ExitConditionProgram('true'), - next_states=ConstantNextState('state2'), - triggers=[], - ), - Gate( - name='state2', - exit_condition=ExitConditionProgram('false'), - next_states=NoNextStates(), - triggers=[], - ), - ], - ), - ]) - - with app_config.db.begin() as conn: - num_edges = conn.scalar(edges.count()) - assert num_edges == 1 - - any_deprecated = conn.scalar( - select(( - func.bool_or( - edges.c.deprecated, - ), - )), - ) - - assert not any_deprecated diff --git a/routemaster/record_states/utils.py b/routemaster/record_states/utils.py deleted file mode 100644 index ebea9a4f..00000000 --- a/routemaster/record_states/utils.py +++ /dev/null @@ -1,237 +0,0 @@ -"""Utility methods for state resyncing.""" - -from typing import Set, Tuple, Iterable - -from sqlalchemy import and_, func, not_, select, tuple_ - -from routemaster.db import edges, states, state_machines -from routemaster.config import StateMachine - - -def resync_state_machine_names( - conn, - old_machine_names: Set[str], - machines: Iterable[StateMachine], -) -> Set[str]: - """ - Resync the state machines by name. - - Return a collection of the name of the machines which were either updated - or created. - """ - - new_machine_names = set(x.name for x in machines) - - insertions = new_machine_names - old_machine_names - deletions = old_machine_names - new_machine_names - updates = new_machine_names & old_machine_names - - if deletions: - raise ValueError("Deleting a state machine is unsupported.") - - if insertions: - conn.execute( - state_machines.insert().values(updated=func.now()), - [ - { - 'name': new_machine, - } - for new_machine in insertions - ], - ) - - return updates | insertions - - -def resync_states_on_state_machine(conn, machine: StateMachine) -> bool: - """ - Resync all states for the given state machine. - - Return whether any work was done. - """ - - old_states = set( - x.name - for x in conn.execute( - select(( - states.c.name, - )).where( - and_( - states.c.state_machine == machine.name, - not_(states.c.deprecated), - ), - ), - ) - ) - new_states = set( - x.name - for x in machine.states - ) - - deleted_states = old_states - new_states - created_states = new_states - old_states - - if deleted_states: - deprecate_states(conn, machine, deleted_states) - - if created_states: - create_or_undeprecate_states(conn, machine, created_states) - - all_links = set( - (x.name, y) - for x in machine.states - for y in x.next_states.all_destinations() - ) - - changed_links = resync_links( - conn, - machine, - all_links, - ) - - any_changes = bool(changed_links or deleted_states or created_states) - return any_changes - - -def resync_links( - conn, - machine: StateMachine, - links: Set[Tuple[str, str]], -) -> bool: - """Synchronise the links table for this machine.""" - anything_done = False - - # Deprecate all links not in this set, which are not already deprecated - result = conn.execute( - edges.update().where( - and_( - edges.c.state_machine == machine.name, - not_( - tuple_( - edges.c.from_state, - edges.c.to_state, - ).in_(list(links)), - ), - not_(edges.c.deprecated), - ), - ).values( - deprecated=True, - updated=func.now(), - ), - ) - if result.rowcount > 0: - anything_done = True - - previous_data = { - (x.from_state, x.to_state): x.deprecated - for x in conn.execute( - select(( - edges.c.from_state, - edges.c.to_state, - edges.c.deprecated, - )).where( - edges.c.state_machine == machine.name, - ), - ) - } - new_inserts = links - set(previous_data) - undeprecations = links & set( - link - for link, deprecated in previous_data.items() - if deprecated - ) - - if new_inserts: - conn.execute( - edges.insert().values( - state_machine=machine.name, - deprecated=False, - updated=func.now(), - ), - [ - { - 'from_state': from_state, - 'to_state': to_state, - } - for from_state, to_state in new_inserts - ], - ) - anything_done = True - - if undeprecations: - conn.execute( - edges.update().values( - deprecated=False, - updated=func.now(), - ).where( - tuple_( - edges.c.from_state, - edges.c.to_state, - ).in_(list(undeprecations)), - ), - ) - anything_done = True - - return anything_done - - -def create_or_undeprecate_states( - conn, - machine: StateMachine, - created_states: Set[str], -) -> None: - """Create some new states, or mark them as undeprecated.""" - # If any of these are old states which have been reanimated, we - # just set the deprecated flag back to False and flag them - # updated. - undeprecated_names = [ - x.name - for x in conn.execute( - states.update().where( - and_( - states.c.state_machine == machine.name, - states.c.name.in_(list(created_states)), - states.c.deprecated, - ), - ).values( - deprecated=False, - updated=func.now(), - ).returning( - states.c.name, - ), - ) - ] - - newly_inserted_rows = [ - { - 'name': state.name, - } - for state in machine.states - if state.name in created_states and - state.name not in undeprecated_names - ] - - if newly_inserted_rows: - conn.execute(states.insert().values( - state_machine=machine.name, - updated=func.now(), - ), newly_inserted_rows) - - -def deprecate_states( - conn, - machine: StateMachine, - deleted_states: Set[str], -) -> None: - """Mark states as deprecated.""" - conn.execute( - states.update().where( - and_( - states.c.state_machine == machine.name, - states.c.name.in_(list(deleted_states)), - ), - ).values( - deprecated=True, - updated=func.now(), - ), - ) diff --git a/routemaster/tests/test_layering.py b/routemaster/tests/test_layering.py index 4cf57399..1e0f1afe 100644 --- a/routemaster/tests/test_layering.py +++ b/routemaster/tests/test_layering.py @@ -20,7 +20,6 @@ ('cli', 'server'), ('cli', 'app'), ('cli', 'gunicorn_application'), - ('cli', 'record_states'), ('cli', 'validation'), ('exit_conditions', 'utils'), @@ -51,9 +50,6 @@ ('state_machine', 'context'), ('state_machine', 'webhooks'), - ('record_states', 'app'), - ('record_states', 'config'), - ('feeds', 'config'), ('webhooks', 'config'), diff --git a/routemaster/tests/test_validation.py b/routemaster/tests/test_validation.py index a7b963f8..bf6cba6a 100644 --- a/routemaster/tests/test_validation.py +++ b/routemaster/tests/test_validation.py @@ -2,19 +2,13 @@ from routemaster.config import ( Gate, - Config, NoNextStates, StateMachine, ConstantNextState, ContextNextStates, ContextNextStatesOption, ) -from routemaster.validation import ( - ValidationError, - _validate_state_machine, - _validate_no_deleted_state_machines, -) -from routemaster.record_states import record_state_machines +from routemaster.validation import ValidationError, _validate_state_machine from routemaster.exit_conditions import ExitConditionProgram @@ -167,42 +161,3 @@ def test_label_in_deleted_state_on_per_state_machine_basis( ) with pytest.raises(ValidationError): _validate_state_machine(app_config, state_machine) - - -def test_deleted_state_machine_invalid(app_config): - record_state_machines(app_config, [ - StateMachine( - name='machine_1', - feeds=[], - webhooks=[], - states=[ - Gate( - name='start', - triggers=[], - next_states=NoNextStates(), - exit_condition=ExitConditionProgram('false'), - ), - ] - ) - ]) - - state_machine = Config( - state_machines={ - 'machine_2': StateMachine( - name='machine_2', - feeds=[], - webhooks=[], - states=[ - Gate( - name='start', - triggers=[], - next_states=NoNextStates(), - exit_condition=ExitConditionProgram('false'), - ), - ] - ) - }, - database=None, - ) - with pytest.raises(ValidationError): - _validate_no_deleted_state_machines(app_config, state_machine) diff --git a/routemaster/validation.py b/routemaster/validation.py index e29f59b0..431cbba9 100644 --- a/routemaster/validation.py +++ b/routemaster/validation.py @@ -2,7 +2,7 @@ import networkx from sqlalchemy import and_, func, false, select -from routemaster.db import labels, history, state_machines +from routemaster.db import labels, history from routemaster.app import App from routemaster.config import Config, StateMachine @@ -17,8 +17,6 @@ def validate_config(app: App, config: Config): for state_machine in config.state_machines.values(): _validate_state_machine(app, state_machine) - _validate_no_deleted_state_machines(app, config) - def _validate_state_machine(app: App, state_machine: StateMachine): """Validate that a given state machine is internally consistent.""" @@ -103,29 +101,3 @@ def _validate_no_labels_in_nonexistent_states(state_machine, app): f"Labels currently in states that no longer exist: " f"{', '.join(inhabited)}", ) - - -def _validate_no_deleted_state_machines(app: App, config: Config): - """ - Validate that no state machines already recorded in the DB are gone. - - Currently we do not support deleting a state machine. This validation check - should be removed once we do safely support deletion. - """ - new_machine_names = set(config.state_machines.keys()) - with app.db.begin() as conn: - old_machine_names = set( - x.name - for x in conn.execute( - select(( - state_machines.c.name, - )), - ).fetchall() - ) - deleted_machine_names = old_machine_names - new_machine_names - if deleted_machine_names: - raise ValidationError( - f"State machines '{', '.join(deleted_machine_names)}' have " - f"been removed from the config, but state machine deletion is " - f"not yet supported.", - )