diff --git a/.github/workflows/pull_request.yaml b/.github/workflows/pull_request.yaml index 7bca8de..329a6dd 100644 --- a/.github/workflows/pull_request.yaml +++ b/.github/workflows/pull_request.yaml @@ -10,7 +10,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: [3.6, 3.7, 3.8, 3.9] + python-version: [3.7, 3.8, 3.9] poetry-version: [1.1.6] os: [ubuntu-latest] runs-on: ${{ matrix.os }} diff --git a/poetry.lock b/poetry.lock index b5c10ca..52bbd38 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,3 +1,18 @@ +[[package]] +name = "anytree" +version = "2.8.0" +description = "Powerful and Lightweight Python Tree Data Structure.." +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +six = ">=1.9.0" + +[package.extras] +dev = ["check-manifest"] +test = ["coverage"] + [[package]] name = "appdirs" version = "1.4.4" @@ -374,9 +389,13 @@ testing = ["pytest (>=4.6)", "pytest-checkdocs (>=1.2.3)", "pytest-flake8", "pyt [metadata] lock-version = "1.1" python-versions = "^3.6.2" -content-hash = "fb41e670a8523bc3b4e0f68dbaf2d3cc6cbdf975d05b44ec4854af257151815a" +content-hash = "0341b269d1f385c915f5ad9c99285f68a075a986f049d64dfab7a5b13e2033ea" [metadata.files] +anytree = [ + {file = "anytree-2.8.0-py2.py3-none-any.whl", hash = "sha256:14c55ac77492b11532395049a03b773d14c7e30b22aa012e337b1e983de31521"}, + {file = "anytree-2.8.0.tar.gz", hash = "sha256:3f0f93f355a91bc3e6245319bf4c1d50e3416cc7a35cc1133c1ff38306bbccab"}, +] appdirs = [ {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"}, {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"}, diff --git a/pyproject.toml b/pyproject.toml index 34716b3..2221622 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,6 @@ keywords = ["Answer Set Programming", "wrapper", "clingo"] classifiers = [ "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9" @@ -22,8 +21,9 @@ packages = [ docs = "https://github.com/davidkpiano/xstate-python" [tool.poetry.dependencies] -python = "^3.6.2" +python = "^3.7.0" Js2Py = "^0.71" +anytree = "^2.8.0" [tool.poetry.dev-dependencies] pytest-cov = "^2.12.0" diff --git a/tests/test_algorithm.py b/tests/test_algorithm.py index 05f9930..d939757 100644 --- a/tests/test_algorithm.py +++ b/tests/test_algorithm.py @@ -1,7 +1,174 @@ -from xstate.algorithm import is_parallel_state +"""Tests for algorithms + +These tests use either machines coded as python `dict` +or accept strings representing xstate javascript/typescript which are +then converted to a python `dict` by the module `js2py` + +The following tests exist + * test_is_parallel_state - returns the column headers of the file + * test_is_not_parallel_state - the main function of the script +""" + +import pytest +from .utils_for_tests import pytest_func_docstring_summary + + +from xstate.algorithm import is_parallel_state, to_state_path, get_configuration from xstate.machine import Machine +class TestAlgorithms: + + test_machine = Machine( + """ { + id: 'a', + initial: 'b1', + states: { + b1: { + id: 'b1', + type: 'parallel', + states: { + c1: { + id: 'c1', + initial: 'd1', + states: { + d1: { id: 'd1' }, + d2: { + id: 'd2', + initial: 'e1', + states: { + e1: { id: 'e1' }, + e2: { id: 'e2' } + } + } + } + }, + c2: { id: 'c2' }, + c3: { + id: 'c3', + initial: 'd3', + states: { + d3: { id: 'd3' }, + d4: { + id: 'd4', + initial: 'e3', + states: { + e3: { id: 'e3' }, + e4: { id: 'e4' } + } + } + } + } + } + }, + b2: { + id: 'b2', + initial: 'c4', + states: { + c4: { id: 'c4' } + } + }, + b3: { + id: 'b3', + initial: 'c5', + states: { + c5: { id: 'c5' }, + c6: { + id: 'c6', + type: 'parallel', + states: { + d5: { id: 'd5' }, + d6: { id: 'd6' }, + d7: { id: 'd7' } + } + } + } + } + } + } + """ + ) + + def test_algorithim_get_configuration(self, request): + """1 - getConfiguration + + ref: https://github.com/statelyai/xstate/blob/main/packages/core/test/algorithm.test.ts + """ + prev_nodes = self.test_machine.root.get_state_nodes( + # TODO: Should this be a config snippet `StateNode.get_state_nodes` does not decode dict + """ { + b1: { + c1: 'd1', + c2: {}, + c3: 'd3' + } + } + """ + ) + nodes = [self.test_machine.root.get_state_node_by_id(id) for id in ["c1", "d4"]] + + c = get_configuration(prev_nodes, nodes) + + assert sorted([sn.id for sn in c]) == [ + "a", + "b1", + "c1", + "c2", + "c3", + "d1", + "d4", + "e3", + ], pytest_func_docstring_summary(request) + + +def test_machine_config_translate(): + + xstate_js_config = """{ + id: 'fan', + initial: 'off', + states: { + off: { + on: { + POWER:'on.hist', + H_POWER:'on.H', + SWITCH:'on.first' + } + + }, + on: { + initial: 'first', + states: { + first: { + on: { + SWITCH:'second' + } + }, + second: { + on: { + SWITCH: 'third' + } + }, + third: {}, + H: { + type: 'history' + }, + hist: { + type: 'history', + history:'shallow' + }, + } + } + } + } + """ + # xstate_python_config=machine_config_translate(xstate_js_config) + # assert isinstance(xstate_python_config,dict) + try: + machine = Machine(xstate_js_config) + except Exception as e: + assert False, f"Machine config is not valid, Exception:{e}" + + def test_is_parallel_state(): machine = Machine( {"id": "test", "initial": "foo", "states": {"foo": {"type": "parallel"}}} @@ -20,3 +187,19 @@ def test_is_not_parallel_state(): foo_state_node = machine._get_by_id("test.foo") assert is_parallel_state(foo_state_node) is False + + +class TestStatePaths: + def test__to_state_path(self, request): + """should have valid results for converstion to state paths list""" + assert to_state_path("one.two.three") == ["one", "two", "three"] + assert to_state_path("one/two/three", delimiter="/") == ["one", "two", "three"] + assert to_state_path(["one", "two", "three"]) == ["one", "two", "three"] + try: + state_id = {"values": ["one", "two", "three"]} + assert to_state_path(state_id) == ["one", "two", "three"] + except Exception as e: + assert ( + e.args[0] + == "{'values': ['one', 'two', 'three']} is not a valid state path" + ) diff --git a/tests/test_history.py b/tests/test_history.py new file mode 100644 index 0000000..83e76a3 --- /dev/null +++ b/tests/test_history.py @@ -0,0 +1,1213 @@ +"""Tests for history + +Based on : xstate/packages/core/test/history.test.ts +These tests test history + +These tests use either machines coded as python `dict` +or accept strings representing xstate javascript/typescript which are +then converted to a python `dict` by the module `js2py` + +The following tests exist + * xxx - returns .... + * xxx - the .... +""" +import pytest + + +from .utils_for_tests import JSstyleTest, pytest_func_docstring_summary + +from xstate.algorithm import get_configuration_from_js + +# from xstate.algorithm import is_parallel_state + +from xstate.machine import Machine +from xstate.state import State + +# import { +# Machine, +# State, +# StateFrom, +# interpret, +# createMachine, +# spawn +# } from '../src/index'; +# import { initEvent, assign } from '../src/actions'; +# import { toSCXMLEvent } from '../src/utils'; + +import sys +import importlib + +# importlib.reload(sys.modules['xstate.state']) + +from .utils_for_tests import pytest_func_docstring_summary + + +history_machine_xstate_js_config = """{ + key: 'history', + initial: 'off', + states: { + off: { + on: { POWER: 'on.hist', H_POWER: 'on.H' } + }, + on: { + initial: 'first', + states: { + first: { + on: { SWITCH: 'second' } + }, + second: { + on: { SWITCH: 'third' } + }, + third: {}, + H: { + type: 'history' + }, + hist: { + type: 'history', + history: 'shallow' + } + }, + on: { + POWER: 'off', + H_POWER: 'off' + } + } + } + }""" +xstate_python_config = get_configuration_from_js(history_machine_xstate_js_config) + +# Example of tweaking model for test development +# del xstate_python_config['states']['one']['on']['INTERNAL']['actions'] + +# xstate_python_config['states']['one']['on']['INTERNAL']['actions']=['doSomething'] +# del xstate_python_config['states']['one']['entry'] +# xstate_python_config['states']['one']['entry'] =['enter'] +history_machine = Machine(xstate_python_config) + + +""" +import { Machine, createMachine, interpret } from '../src/index'; + +describe('history states', () => { + const historyMachine = createMachine({ + key: 'history', + initial: 'off', + states: { + off: { + on: { POWER: 'on.hist', H_POWER: 'on.H' } + }, + on: { + initial: 'first', + states: { + first: { + on: { SWITCH: 'second' } + }, + second: { + on: { SWITCH: 'third' } + }, + third: {}, + H: { + type: 'history' + }, + hist: { + type: 'history', + history: 'shallow' + } + }, + on: { + POWER: 'off', + H_POWER: 'off' + } + } + } + }); +""" + + +class TestHistoryInitial: + """An initial set of unit tests of History capability""" + + def test_history_should_go_to_the_most_recently_visited_state(self, request): + """should go to the most recently visited state""" + # it('should go to the most recently visited state', () => { + def test_procedure(): + # const onSecondState = historyMachine.transition('on', 'SWITCH'); + # const offState = historyMachine.transition(onSecondState, 'POWER'); + on_second_state = history_machine.transition("on", "SWITCH") + off_state = history_machine.transition(on_second_state, "POWER") + # expect(historyMachine.transition(offState, 'POWER').value).toEqual({ + return history_machine.transition(off_state, "POWER").value + + test = JSstyleTest() + test.it(pytest_func_docstring_summary(request)).expect( + test_procedure() + ).toEqual( + # expect(historyMachine.transition(offState, 'POWER').value).toEqual({ + # on: 'second' + {"on": "second"} + ) + # """ + + # it('should go to the most recently visited state', () => { + # const onSecondState = historyMachine.transition('on', 'SWITCH'); + # const offState = historyMachine.transition(onSecondState, 'POWER'); + + # expect(historyMachine.transition(offState, 'POWER').value).toEqual({ + # on: 'second' + # }); + # }); + # """ + + def test_history_should_go_to_the_most_recently_visited_state_explicit( + self, request + ): + """should go to the most recently visited state (explicit)""" + + def test_procedure(): + on_second_state = history_machine.transition("on", "SWITCH") + off_state = history_machine.transition(on_second_state, "H_POWER") + return history_machine.transition(off_state, "H_POWER").value + + test = JSstyleTest() + test.it(pytest_func_docstring_summary(request)).expect( + test_procedure() + ).toEqual( + # expect(historyMachine.transition(offState, 'POWER').value).toEqual({ + # on: 'second' + {"on": "second"} + ) + + # it('should go to the most recently visited state (explicit)', () => { + # const onSecondState = historyMachine.transition('on', 'SWITCH'); + # const offState = historyMachine.transition(onSecondState, 'H_POWER'); + + # expect(historyMachine.transition(offState, 'H_POWER').value).toEqual({ + # on: 'second' + # }); + # }); + + def test_history_should_go_to_the_initial_state_when_no_history_present( + self, request + ): + """should go to the initial state when no history present""" + test = JSstyleTest() + test.it(pytest_func_docstring_summary(request)).expect( + history_machine.transition("off", "POWER").value + ).toEqual({"on": "first"}) + + # it('should go to the initial state when no history present', () => { + # expect(historyMachine.transition('off', 'POWER').value).toEqual({ + # on: 'first' + # }); + # }); + + def test_history_should_go_to_the_initial_state_when_no_history_present_explicit( + self, request + ): + """should go to the initial state when no history present (explicit)""" + test = JSstyleTest() + test.it(pytest_func_docstring_summary(request)).expect( + history_machine.transition("off", "H_POWER").value + ).toEqual({"on": "first"}) + + # it('should go to the initial state when no history present (explicit)', () => { + # expect(historyMachine.transition('off', 'H_POWER').value).toEqual({ + # on: 'first' + # }); + # }); + + def test_history_should_dispose_of_previous_histories(self, request): + """should dispose of previous histories""" + + def test_procedure(): + + onSecondState = history_machine.transition("on", "SWITCH") + offState = history_machine.transition(onSecondState, "H_POWER") + onState = history_machine.transition(offState, "H_POWER") + nextState = history_machine.transition(onState, "H_POWER") + test_result = nextState.history.history is None + return test_result + + test = JSstyleTest() + test.it(pytest_func_docstring_summary(request)).expect( + test_procedure() + ).toEqual(True) + + # it('should dispose of previous histories', () => { + # const onSecondState = historyMachine.transition('on', 'SWITCH'); + # const offState = historyMachine.transition(onSecondState, 'H_POWER'); + # const onState = historyMachine.transition(offState, 'H_POWER'); + # const nextState = historyMachine.transition(onState, 'H_POWER'); + # expect(nextState.history!.history).not.toBeDefined(); + # }); + @pytest.mark.skip(reason="Eventless Transition `Always`, not yet implemented") + def test_history_should_go_to_the_most_recently_visited_state_by_a_transient_transition_non_interpreter( + self, request + ): + """should go to the most recently visited state by a transient transition + + The on event `DESTROY` the state `destroy` should automatically ie `always` proceed to `idle.absent` + """ + + def test_procedure(): + machine = Machine( + """ + { + initial: 'idle', + states: { + idle: { + id: 'idle', + initial: 'absent', + states: { + absent: { + on: { + DEPLOY: '#deploy' + } + }, + present: { + on: { + DEPLOY: '#deploy', + DESTROY: '#destroy' + } + }, + hist: { + type: 'history' + } + } + }, + deploy: { + id: 'deploy', + on: { + SUCCESS: 'idle.present', + FAILURE: 'idle.hist' + } + }, + destroy: { + id: 'destroy', + always: [{ target: 'idle.absent' }] + } + } + } + """ + ) + initial_state = machine.initial_state + # service.send('DEPLOY'); + next_state = machine.transition(initial_state, "DEPLOY") + # service.send('SUCCESS'); + next_state = machine.transition(next_state, "SUCCESS") + # service.send('DESTROY'); + next_state = machine.transition(next_state, "DESTROY") + # service.send('DEPLOY'); + next_state = machine.transition(next_state, "DEPLOY") + # service.send('FAILURE'); + next_state = machine.transition(next_state, "FAILURE") + test_result = next_state.state.value + return test_result + + test = JSstyleTest() + test.it(pytest_func_docstring_summary(request)).expect( + test_procedure() + ).toEqual({"idle": "absent"}) + + @pytest.mark.skip(reason="interpreter, not yet implemented") + def test_history_should_go_to_the_most_recently_visited_state_by_a_transient_transition( + self, request + ): + """should go to the most recently visited state by a transient transition""" + + def test_procedure(): + machine = Machine( + """ + { + initial: 'idle', + states: { + idle: { + id: 'idle', + initial: 'absent', + states: { + absent: { + on: { + DEPLOY: '#deploy' + } + }, + present: { + on: { + DEPLOY: '#deploy', + DESTROY: '#destroy' + } + }, + hist: { + type: 'history' + } + } + }, + deploy: { + id: 'deploy', + on: { + SUCCESS: 'idle.present', + FAILURE: 'idle.hist' + } + }, + destroy: { + id: 'destroy', + always: [{ target: 'idle.absent' }] + } + } + } + """ + ) + service = interpret(machine).start() + + service.send("DEPLOY") + service.send("SUCCESS") + service.send("DESTROY") + service.send("DEPLOY") + service.send("FAILURE") + test_result = service.state.value + return test_result + + test = JSstyleTest() + test.it(pytest_func_docstring_summary(request)).expect( + test_procedure() + ).toEqual({"idle": "absent"}) + # XStateJS + # it('should go to the most recently visited state by a transient transition', () => { + """ + # const machine = createMachine({ + # initial: 'idle', + # states: { + # idle: { + # id: 'idle', + # initial: 'absent', + # states: { + # absent: { + # on: { + # DEPLOY: '#deploy' + # } + # }, + # present: { + # on: { + # DEPLOY: '#deploy', + # DESTROY: '#destroy' + # } + # }, + # hist: { + # type: 'history' + # } + # } + # }, + # deploy: { + # id: 'deploy', + # on: { + # SUCCESS: 'idle.present', + # FAILURE: 'idle.hist' + # } + # }, + # destroy: { + # id: 'destroy', + # always: [{ target: 'idle.absent' }] + # } + # } + # }); + + # const service = interpret(machine).start(); + + # service.send('DEPLOY'); + # service.send('SUCCESS'); + # service.send('DESTROY'); + # service.send('DEPLOY'); + # service.send('FAILURE'); + + # expect(service.state.value).toEqual({ idle: 'absent' }); + # }); + # }); + """ + + +class TestHistoryDeepStates: + """A set of unit tests of Deep History States""" + + history_machine = Machine( + """ + { + key: 'history', + initial: 'off', + states: { + off: { + on: { + POWER: 'on.history', + DEEP_POWER: 'on.deepHistory' + } + }, + on: { + initial: 'first', + states: { + first: { + on: { SWITCH: 'second' } + }, + second: { + initial: 'A', + states: { + A: { + on: { INNER: 'B' } + }, + B: { + initial: 'P', + states: { + P: { + on: { INNER: 'Q' } + }, + Q: {} + } + } + } + }, + history: { history: 'shallow' }, + deepHistory: {type: 'history', + history: 'deep' + } + }, + on: { + POWER: 'off' + } + } + } + } + """ + ) + # XStateJS + # describe('deep history states', () => { + # const historyMachine = Machine({ + # key: 'history', + # initial: 'off', + # states: { + # off: { + # on: { + # POWER: 'on.history', + # DEEP_POWER: 'on.deepHistory' + # } + # }, + # on: { + # initial: 'first', + # states: { + # first: { + # on: { SWITCH: 'second' } + # }, + # second: { + # initial: 'A', + # states: { + # A: { + # on: { INNER: 'B' } + # }, + # B: { + # initial: 'P', + # states: { + # P: { + # on: { INNER: 'Q' } + # }, + # Q: {} + # } + # } + # } + # }, + # history: { history: 'shallow' }, + # deepHistory: { + # history: 'deep' + # } + # }, + # on: { + # POWER: 'off' + # } + # } + # } + # }); + + +class TestHistoryDeepStatesHistory: + # on.first -> on.second.A + state2A = TestHistoryDeepStates().history_machine.transition( + # { 'on': 'first' }, + "on.first", + "SWITCH", + ) + # on.second.A -> on.second.B.P + state2BP = TestHistoryDeepStates().history_machine.transition(state2A, "INNER") + # on.second.B.P -> on.second.B.Q + state2BQ = TestHistoryDeepStates().history_machine.transition(state2BP, "INNER") + + assert state2BP.history_value.states["on"].current == { + "second": {"B": "P"} + }, "state2BP should stay at 2BP and not be affected by 2BP->2BQ" + # XStateJS + # describe('history', () => { + # // on.first -> on.second.A + # const state2A = historyMachine.transition({ on: 'first' }, 'SWITCH'); + # // on.second.A -> on.second.B.P + # const state2BP = historyMachine.transition(state2A, 'INNER'); + # // on.second.B.P -> on.second.B.Q + # const state2BQ = historyMachine.transition(state2BP, 'INNER'); + + # @pytest.mark.skip(reason="") + def test_history_should_go_to_the_shallow_history(self, request): + """should go to the shallow history""" + + def test_procedure(self): + # on.second.B.P -> off + stateOff = TestHistoryDeepStates.history_machine.transition( + self.state2BP, "POWER" + ) + test_result = TestHistoryDeepStates.history_machine.transition( + stateOff, "POWER" + ).value + return test_result + + test = JSstyleTest() + test.it(pytest_func_docstring_summary(request)).expect( + test_procedure(self) + ).toEqual({"on": {"second": "A"}}) + # XStateJS + # it('should go to the shallow history', () => { + # // on.second.B.P -> off + # const stateOff = historyMachine.transition(state2BP, 'POWER'); + # expect(historyMachine.transition(stateOff, 'POWER').value).toEqual({ + # on: { second: 'A' } + + # @pytest.mark.skip(reason="") + def test_history_should_go_to_the_deep_history_explicit(self, request): + """should go to the deep history (explicit)""" + + def test_procedure(self): + # on.second.B.P -> off + stateOff = TestHistoryDeepStates.history_machine.transition( + self.state2BP, "POWER" + ) + test_result = TestHistoryDeepStates.history_machine.transition( + stateOff, "DEEP_POWER" + ).value + return test_result + + test = JSstyleTest() + test.it(pytest_func_docstring_summary(request)).expect( + test_procedure(self) + ).toEqual({"on": {"second": {"B": "P"}}}) + # XStateJS + # it('should go to the deep history (explicit)', () => { + # // on.second.B.P -> off + # const stateOff = historyMachine.transition(state2BP, 'POWER'); + # expect(historyMachine.transition(stateOff, 'DEEP_POWER').value).toEqual({ + # on: { second: { B: 'P' } } + + # @pytest.mark.skip(reason="") + def test_history_should_go_to_the_deepest_history(self, request): + """should go to the deepest history""" + + def test_procedure(self): + # on.second.B.Q -> off + stateOff = TestHistoryDeepStates.history_machine.transition( + self.state2BQ, "POWER" + ) + test_result = TestHistoryDeepStates.history_machine.transition( + stateOff, "DEEP_POWER" + ).value + return test_result + + test = JSstyleTest() + test.it(pytest_func_docstring_summary(request)).expect( + test_procedure(self) + ).toEqual({"on": {"second": {"B": "Q"}}}) + # XStateJS + # it('should go to the deepest history', () => { + # // on.second.B.Q -> off + # const stateOff = historyMachine.transition(state2BQ, 'POWER'); + # expect(historyMachine.transition(stateOff, 'DEEP_POWER').value).toEqual({ + # on: { second: { B: 'Q' } } + + +class TestParallelHistoryStates: + """A set of unit tests for Parallel History States""" + + history_machine = Machine( + """ + { + key: 'parallelhistory', + initial: 'off', + states: { + off: { + on: { + SWITCH: 'on', /* go to the initial states */ + POWER: 'on.hist', + DEEP_POWER: 'on.deepHistory', + PARALLEL_HISTORY: [{ target: ['on.A.hist', 'on.K.hist'] }], + PARALLEL_SOME_HISTORY: [{ target: ['on.A.C', 'on.K.hist'] }], + PARALLEL_DEEP_HISTORY: [ + { target: ['on.A.deepHistory', 'on.K.deepHistory'] } + ] + } + }, + on: { + type: 'parallel', + states: { + A: { + initial: 'B', + states: { + B: { + on: { INNER_A: 'C' } + }, + C: { + initial: 'D', + states: { + D: { + on: { INNER_A: 'E' } + }, + E: {} + } + }, + hist: { history: true }, + deepHistory: { + history: 'deep' + } + } + }, + K: { + initial: 'L', + states: { + L: { + on: { INNER_K: 'M' } + }, + M: { + initial: 'N', + states: { + N: { + on: { INNER_K: 'O' } + }, + O: {} + } + }, + hist: { history: true }, + deepHistory: { + history: 'deep' + } + } + }, + hist: { + history: true + }, + shallowHistory: { + history: 'shallow' + }, + deepHistory: { + history: 'deep' + } + }, + on: { + POWER: 'off' + } + } + } + } + """ + ) + # XStateJS + # describe('parallel history states', () => { + # const historyMachine = Machine({ + # key: 'parallelhistory', + # initial: 'off', + # states: { + # off: { + # on: { + # SWITCH: 'on', // go to the initial states + # POWER: 'on.hist', + # DEEP_POWER: 'on.deepHistory', + # PARALLEL_HISTORY: [{ target: ['on.A.hist', 'on.K.hist'] }], + # PARALLEL_SOME_HISTORY: [{ target: ['on.A.C', 'on.K.hist'] }], + # PARALLEL_DEEP_HISTORY: [ + # { target: ['on.A.deepHistory', 'on.K.deepHistory'] } + # ] + # } + # }, + # on: { + # #type: 'parallel', + # states: { + # A: { + # initial: 'B', + # states: { + # B: { + # on: { INNER_A: 'C' } + # }, + # C: { + # initial: 'D', + # states: { + # D: { + # on: { INNER_A: 'E' } + # }, + # E: {} + # } + # }, + # hist: { history: true }, + # deepHistory: { + # history: 'deep' + # } + # } + # }, + # K: { + # initial: 'L', + # states: { + # L: { + # on: { INNER_K: 'M' } + # }, + # M: { + # initial: 'N', + # states: { + # N: { + # on: { INNER_K: 'O' } + # }, + # O: {} + # } + # }, + # hist: { history: true }, + # deepHistory: { + # history: 'deep' + # } + # } + # }, + # hist: { + # history: true + # }, + # shallowHistory: { + # history: 'shallow' + # }, + # deepHistory: { + # history: 'deep' + # } + # }, + # on: { + # POWER: 'off' + # } + # } + # } + # }); + + +class TestParallelHistoryStatesHistory: + + # on.first -> on.second.A + stateABKL = TestParallelHistoryStates().history_machine.transition( + TestParallelHistoryStates().history_machine.initial_state, "SWITCH" + ) + # INNER_A twice + stateACDKL = TestParallelHistoryStates().history_machine.transition( + stateABKL, "INNER_A" + ) + stateACEKL = TestParallelHistoryStates().history_machine.transition( + stateACDKL, "INNER_A" + ) + + # INNER_K twice + stateACEKMN = TestParallelHistoryStates().history_machine.transition( + stateACEKL, "INNER_K" + ) + stateACEKMO = TestParallelHistoryStates().history_machine.transition( + stateACEKMN, "INNER_K" + ) + + # XStateJS + # describe('history', () => { + # // on.first -> on.second.A + # const stateABKL = historyMachine.transition( + # historyMachine.initialState, + # 'SWITCH' + # ); + # // INNER_A twice + # const stateACDKL = historyMachine.transition(stateABKL, 'INNER_A'); + # const stateACEKL = historyMachine.transition(stateACDKL, 'INNER_A'); + + # // INNER_K twice + # const stateACEKMN = historyMachine.transition(stateACEKL, 'INNER_K'); + # const stateACEKMO = historyMachine.transition(stateACEKMN, 'INNER_K'); + + # @pytest.mark.skip(reason="") + def test_should_ignore_parallel_state_history(self, request): + """should ignore parallel state history""" + + def test_procedure(self): + # on.second.B.P -> off + + stateOff = TestParallelHistoryStates().history_machine.transition( + self.stateACDKL, "POWER" + ) + test_result = ( + TestParallelHistoryStates() + .history_machine.transition(stateOff, "POWER") + .value + ) + + return test_result + + test = JSstyleTest() + test.it(pytest_func_docstring_summary(request)).expect( + test_procedure(self) + ).toEqual({"on": {"A": "B", "K": "L"}}) + # XStateJS + # it('should ignore parallel state history', () => { + # const stateOff = historyMachine.transition(stateACDKL, 'POWER'); + # expect(historyMachine.transition(stateOff, 'POWER').value).toEqual({ + # on: { A: 'B', K: 'L' } + # }); + # }); + + # @pytest.mark.skip(reason="") + def test_should_remember_first_level_state_history(self, request): + """should remember first level state history""" + + def test_procedure(self): + stateOff = TestParallelHistoryStates().history_machine.transition( + self.stateACDKL, "POWER" + ) + transition_result = TestParallelHistoryStates().history_machine.transition( + stateOff, "DEEP_POWER" + ) + test_result = transition_result.value + return test_result + + test = JSstyleTest() + test.it(pytest_func_docstring_summary(request)).expect( + test_procedure(self) + ).toEqual({"on": {"A": {"C": "D"}, "K": "L"}}) + # XStateJS + # it('should remember first level state history', () => { + # const stateOff = historyMachine.transition(stateACDKL, 'POWER'); + # expect(historyMachine.transition(stateOff, 'DEEP_POWER').value).toEqual({ + # on: { A: { C: 'D' }, K: 'L' } + # }); + # }); + + # @pytest.mark.skip(reason="") + def test_should_re_enter_each_regions_of_parallel_state_correctly(self, request): + """should re-enter each regions of parallel state correctly""" + + def test_procedure(self): + stateOff = TestParallelHistoryStates().history_machine.transition( + self.stateACEKMO, "POWER" + ) + transition_result = TestParallelHistoryStates().history_machine.transition( + stateOff, "DEEP_POWER" + ) + test_result = transition_result.value + return test_result + + test = JSstyleTest() + test.it(pytest_func_docstring_summary(request)).expect( + test_procedure(self) + ).toEqual({"on": {"A": {"C": "E"}, "K": {"M": "O"}}}) + # XStateJS + # it('should re-enter each regions of parallel state correctly', () => { + # const stateOff = historyMachine.transition(stateACEKMO, 'POWER'); + # expect(historyMachine.transition(stateOff, 'DEEP_POWER').value).toEqual({ + # on: { A: { C: 'E' }, K: { M: 'O' } } + # }); + # }); + + def test_should_re_enter_multiple_history_states(self, request): + """should re-enter multiple history states""" + + def test_procedure(self): + stateOff = TestParallelHistoryStates().history_machine.transition( + self.stateACEKMO, "POWER" + ) + transition_result = TestParallelHistoryStates().history_machine.transition( + stateOff, "PARALLEL_HISTORY" + ) + test_result = transition_result.value + return test_result + + test = JSstyleTest() + test.it(pytest_func_docstring_summary(request)).expect( + test_procedure(self) + ).toEqual({"on": {"A": {"C": "D"}, "K": {"M": "N"}}}) + # XStateJS + # it('should re-enter multiple history states', () => { + # const stateOff = historyMachine.transition(stateACEKMO, 'POWER'); + # expect( + # historyMachine.transition(stateOff, 'PARALLEL_HISTORY').value + # ).toEqual({ + # on: { A: { C: 'D' }, K: { M: 'N' } } + # }); + # }); + + def test_should_re_enter_a_parallel_with_partial_history(self, request): + """should re-enter a parallel with partial history""" + + def test_procedure(self): + stateOff = TestParallelHistoryStates().history_machine.transition( + self.stateACEKMO, "POWER" + ) + transition_result = TestParallelHistoryStates().history_machine.transition( + stateOff, "PARALLEL_SOME_HISTORY" + ) + test_result = transition_result.value + return test_result + + test = JSstyleTest() + test.it(pytest_func_docstring_summary(request)).expect( + test_procedure(self) + ).toEqual({"on": {"A": {"C": "D"}, "K": {"M": "N"}}}) + # XStateJS + # it('should re-enter a parallel with partial history', () => { + # const stateOff = historyMachine.transition(stateACEKMO, 'POWER'); + # expect( + # historyMachine.transition(stateOff, 'PARALLEL_SOME_HISTORY').value + # ).toEqual({ + # on: { A: { C: 'D' }, K: { M: 'N' } } + # }); + # }); + + def test_should_re_enter_a_parallel_with_full_history(self, request): + """should re-enter a parallel with full history""" + + def test_procedure(self): + stateOff = TestParallelHistoryStates().history_machine.transition( + self.stateACEKMO, "POWER" + ) + transition_result = TestParallelHistoryStates().history_machine.transition( + stateOff, "PARALLEL_DEEP_HISTORY" + ) + test_result = transition_result.value + return test_result + + test = JSstyleTest() + test.it(pytest_func_docstring_summary(request)).expect( + test_procedure(self) + ).toEqual({"on": {"A": {"C": "E"}, "K": {"M": "O"}}}) + # XStateJS + # it('should re-enter a parallel with full history', () => { + # const stateOff = historyMachine.transition(stateACEKMO, 'POWER'); + # expect( + # historyMachine.transition(stateOff, 'PARALLEL_DEEP_HISTORY').value + # ).toEqual({ + # on: { A: { C: 'E' }, K: { M: 'O' } } + # }); + # }); + + +class TestTransientHistory: + + transientMachine = Machine( + # TODO: uncomment `always` when implemented + """ + { + initial: 'A', + states: { + A: { + on: { EVENT: 'B' } + }, + B: { + /* eventless transition + always: 'C'*/ + }, + C: {} + } + } + """ + ) + + # const transientMachine = Machine({ + # initial: 'A', + # states: { + # A: { + # on: { EVENT: 'B' } + # }, + # B: { + # // eventless transition + # always: 'C' + # }, + # C: {} + # } + # }); + + @pytest.mark.skip(reason="Transient `always` not implemented yet") + def test_should_have_history_on_transient_transitions(self, request): + """should have history on transient transitions""" + + def test_procedure(self): + nextState = self.transientMachine.transition("A", "EVENT") + test_result = nextState.value == "C" and nextState.history is not None + + return test_result + + test = JSstyleTest() + test.it(pytest_func_docstring_summary(request)).expect( + test_procedure(self) + ).toEqual(True) + # XStateJS + # it('should have history on transient transitions', () => { + # const nextState = transientMachine.transition('A', 'EVENT'); + # expect(nextState.value).toEqual('C'); + # expect(nextState.history).toBeDefined(); + # }); + # }); + + +class TestInternalTransitionWithHistory: + + machine = Machine( + """ + { + key: 'test', + initial: 'first', + states: { + first: { + initial: 'foo', + states: { + foo: {} + }, + on: { + NEXT: 'second.other' + } + }, + second: { + initial: 'nested', + states: { + nested: {}, + other: {}, + hist: { + history: true + } + }, + on: { + NEXT: [ + { + target: '.hist' + } + ] + } + } + } + } + """ + ) + + # @pytest.mark.skip(reason="") + def test_should_transition_internally_to_the_most_recently_visited_state( + self, request + ): + """should transition internally to the most recently visited state""" + + def test_procedure(self): + state2 = self.machine.transition(self.machine.root.initial, "NEXT") + state3 = self.machine.transition(state2, "NEXT") + test_result = state3.value + + return test_result + + test = JSstyleTest() + test.it(pytest_func_docstring_summary(request)).expect( + test_procedure(self) + ).toEqual({"second": "other"}) + # XStateJS + # it('should transition internally to the most recently visited state', () => { + # // { + # // $current: 'first', + # // first: undefined, + # // second: { + # // $current: 'nested', + # // nested: undefined, + # // other: undefined + # // } + # // } + # const state2 = machine.transition(machine.initialState, 'NEXT'); + # // { + # // $current: 'second', + # // first: undefined, + # // second: { + # // $current: 'other', + # // nested: undefined, + # // other: undefined + # // } + # // } + # const state3 = machine.transition(state2, 'NEXT'); + # // { + # // $current: 'second', + # // first: undefined, + # // second: { + # // $current: 'other', + # // nested: undefined, + # // other: undefined + # // } + # // } + + # expect(state3.value).toEqual({ second: 'other' }); + # }); + # }); + + +class TestMultistageHistoryStates: + + pcWithTurboButtonMachine = Machine( + """ + { + key: 'pc-with-turbo-button', + initial: 'off', + states: { + off: { + on: { POWER: 'starting' } + }, + starting: { + on: { STARTED: 'running.H' } + }, + running: { + initial: 'normal', + states: { + normal: { + on: { SWITCH_TURBO: 'turbo' } + }, + turbo: { + on: { SWITCH_TURBO: 'normal' } + }, + H: { + history: true + } + }, + on: { + POWER: 'off' + } + } + } + } + """ + ) + + # @pytest.mark.skip(reason="") + def test_should_go_to_the_most_recently_visited_state(self, request): + """should go to the most recently visited state""" + + def test_procedure(self): + onTurboState = self.pcWithTurboButtonMachine.transition( + "running", "SWITCH_TURBO" + ) + offState = self.pcWithTurboButtonMachine.transition(onTurboState, "POWER") + loadingState = self.pcWithTurboButtonMachine.transition(offState, "POWER") + finalState = self.pcWithTurboButtonMachine.transition( + loadingState, "STARTED" + ) + test_result = finalState.value + + return test_result + + test = JSstyleTest() + test.it(pytest_func_docstring_summary(request)).expect( + test_procedure(self) + ).toEqual({"running": "turbo"}) + # XStateJS + # it('should go to the most recently visited state', () => { + # const onTurboState = pcWithTurboButtonMachine.transition( + # 'running', + # 'SWITCH_TURBO' + # ); + # const offState = pcWithTurboButtonMachine.transition(onTurboState, 'POWER'); + # const loadingState = pcWithTurboButtonMachine.transition(offState, 'POWER'); + + # expect( + # pcWithTurboButtonMachine.transition(loadingState, 'STARTED').value + # ).toEqual({ running: 'turbo' }); + # }); + # }); diff --git a/tests/test_machine.py b/tests/test_machine.py index 095ba84..8074faa 100644 --- a/tests/test_machine.py +++ b/tests/test_machine.py @@ -45,54 +45,6 @@ def test_final_state(): assert red_timeout_state.value == "green" - -fan = Machine( - { - "id": "fan", - "initial": "fanOff", - "states": { - "fanOff": { - "on": { - "POWER": "#fan.fanOn.hist", - "HIGH_POWER": "fanOn.highPowerHist", - }, - }, - "fanOn": { - "initial": "first", - "states": { - "first": {"on": {"SWITCH": "second"}}, - "second": {"on": {"SWITCH": "third"}}, - "third": {}, - "hist": {"type": "history", "history": "shallow"}, - "highPowerHist": {"type": "history", "target": "third"}, - }, - "on": {"POWER": "fanOff"}, - }, - }, - } -) - - -def test_history_state(): - on_state = fan.transition(fan.initial_state, "POWER") - - assert on_state.value == "fanOn.first" - - on_second_state = fan.transition(on_state, "SWITCH") - - assert on_second_state.value == "fanOn.second" - - off_state = fan.transition(on_second_state, "POWER") - - assert off_state.value == "fanOff" - - on_second_state = fan.transition(off_state, "POWER") - - assert on_second_state.value == "fanOn.second" - - - - def test_top_level_final(): final = Machine( { @@ -104,7 +56,7 @@ def test_top_level_final(): }, } ) - + end_state = final.transition(final.initial_state, "FINISH") assert end_state.value == "end" diff --git a/tests/test_state.py b/tests/test_state.py new file mode 100644 index 0000000..94044b6 --- /dev/null +++ b/tests/test_state.py @@ -0,0 +1,1085 @@ +"""Tests for states + +Based on : xstate/packages/core/test/state.test.ts + +These tests use either machines coded as python `dict` +or accept strings representing xstate javascript/typescript which are +then converted to a python `dict` by the module `js2py` + +The following tests exist + * xxx - returns .... + * xxx - the .... +""" +import pytest +from xstate.algorithm import get_configuration_from_js + +# from xstate.algorithm import is_parallel_state + +from xstate.machine import Machine +from xstate.state import State + +# import { +# Machine, +# State, +# StateFrom, +# interpret, +# createMachine, +# spawn +# } from '../src/index'; +# import { initEvent, assign } from '../src/actions'; +# import { toSCXMLEvent } from '../src/utils'; + +import sys +import importlib + +# importlib.reload(sys.modules['xstate.state']) + +from .utils_for_tests import pytest_func_docstring_summary + +machine_xstate_js_config = """{ + initial: 'one', + states: { + one: { + entry: ['enter'], + on: { + EXTERNAL: { + target: 'one', + internal: false + }, + INERT: { + target: 'one', + internal: true + }, + INTERNAL: { + target: 'one', + internal: true, + actions: ['doSomething'] + }, + TO_TWO: 'two', + TO_TWO_MAYBE: { + target: 'two', + /* cond: function maybe() { + return true; + }*/ + cond: function maybe() { + return true; + } + }, + TO_THREE: 'three', + FORBIDDEN_EVENT: undefined, + TO_FINAL: 'success' + } + }, + two: { + initial: 'deep', + states: { + deep: { + initial: 'foo', + states: { + foo: { + on: { + FOO_EVENT: 'bar', + FORBIDDEN_EVENT: undefined + } + }, + bar: { + on: { + BAR_EVENT: 'foo' + } + } + } + } + }, + on: { + DEEP_EVENT: '.' + } + }, + three: { + type: 'parallel', + states: { + first: { + initial: 'p31', + states: { + p31: { + on: { P31: '.' } + } + } + }, + second: { + initial: 'p32', + states: { + p32: { + on: { P32: '.' } + } + } + } + }, + on: { + THREE_EVENT: '.' + } + }, + success: { + type: 'final' + } + }, + on: { + MACHINE_EVENT: '.two' + } +}""" +xstate_python_config = get_configuration_from_js(machine_xstate_js_config) + + +# Example of Workaround for issues with state machine config +# del xstate_python_config['states']['one']['on']['INTERNAL']['actions'] + +# xstate_python_config['states']['one']['on']['INTERNAL']['actions']=['doSomething'] +# del xstate_python_config['states']['one']['entry'] +# xstate_python_config['states']['one']['entry'] =['enter'] +machine = Machine(xstate_python_config) + + +# type Events = +# | { type: 'BAR_EVENT' } +# | { type: 'DEEP_EVENT' } +# | { type: 'EXTERNAL' } +# | { type: 'FOO_EVENT' } +# | { type: 'FORBIDDEN_EVENT' } +# | { type: 'INERT' } +# | { type: 'INTERNAL' } +# | { type: 'MACHINE_EVENT' } +# | { type: 'P31' } +# | { type: 'P32' } +# | { type: 'THREE_EVENT' } +# | { type: 'TO_THREE' } +# | { type: 'TO_TWO'; foo: string } +# | { type: 'TO_TWO_MAYBE' } +# | { type: 'TO_FINAL' }; + + +class TestState_changed: + """describe('State .changed ', () => { + describe('.changed', () => { + """ + + @pytest.mark.skip(reason="Not implemented yet") + def test_not_changed_if_initial_state(self, request): + """1 - should indicate that it is not changed if initial state + + ref: https://github.com/statelyai/xstate/blob/main/packages/core/test/state.test.ts + + it('should indicate that it is not changed if initial state', () => { + expect(machine.initialState.changed).not.toBeDefined(); + }); + """ + assert machine.initial_state.changed == None, pytest_func_docstring_summary( + request + ) + + @pytest.mark.skip(reason="Not implemented yet") + def test_external_transitions_with_entry_actions_should_be_changed(self, request): + """2 - states from external transitions with entry actions should be changed + + ref: https://github.com/statelyai/xstate/blob/main/packages/core/test/state.test.ts + + it('states from external transitions with entry actions should be changed', () => { + const changedState = machine.transition(machine.initialState, 'EXTERNAL'); + expect(changedState.changed).toBe(true); + }); + """ + changed_state = machine.transition(machine.initial_state, "EXTERNAL") + assert changed_state.changed == True, pytest_func_docstring_summary(request) + + @pytest.mark.skip(reason="Not implemented yet") + def test_not_yet_implemented(self, request): + """UNimplemented Tests + + ref: https://github.com/statelyai/xstate/blob/main/packages/core/test/state.test.ts + + # it('states from internal transitions with no actions should be unchanged', () => { + # const changedState = machine.transition(machine.initialState, 'EXTERNAL'); + # const unchangedState = machine.transition(changedState, 'INERT'); + # expect(unchangedState.changed).toBe(false); + # }); + + # it('states from internal transitions with actions should be changed', () => { + # const changedState = machine.transition(machine.initialState, 'INTERNAL'); + # expect(changedState.changed).toBe(true); + # }); + + # it('normal state transitions should be changed (initial state)', () => { + # const changedState = machine.transition(machine.initialState, 'TO_TWO'); + # expect(changedState.changed).toBe(true); + # }); + + # it('normal state transitions should be changed', () => { + # const twoState = machine.transition(machine.initialState, 'TO_TWO'); + # const changedState = machine.transition(twoState, 'FOO_EVENT'); + # expect(changedState.changed).toBe(true); + # }); + + # it('normal state transitions with unknown event should be unchanged', () => { + # const twoState = machine.transition(machine.initialState, 'TO_TWO'); + # const changedState = machine.transition(twoState, 'UNKNOWN_EVENT' as any); + # expect(changedState.changed).toBe(false); + # }); + + # it('should report entering a final state as changed', () => { + # const finalMachine = Machine({ + # id: 'final', + # initial: 'one', + # states: { + # one: { + # on: { + # DONE: 'two' + # } + # }, + + # two: { + # type: 'final' + # } + # } + # }); + + # const twoState = finalMachine.transition('one', 'DONE'); + + # expect(twoState.changed).toBe(true); + # }); + + # it('should report any internal transition assignments as changed', () => { + # const assignMachine = Machine<{ count: number }>({ + # id: 'assign', + # initial: 'same', + # context: { + # count: 0 + # }, + # states: { + # same: { + # on: { + # EVENT: { + # actions: assign({ count: (ctx) => ctx.count + 1 }) + # } + # } + # } + # } + # }); + + # const { initialState } = assignMachine; + # const changedState = assignMachine.transition(initialState, 'EVENT'); + # expect(changedState.changed).toBe(true); + # expect(initialState.value).toEqual(changedState.value); + # }); + + # it('should not escape targetless child state nodes', () => { + # interface Ctx { + # value: string; + # } + # type ToggleEvents = + # | { + # type: 'CHANGE'; + # value: string; + # } + # | { + # type: 'SAVE'; + # }; + # const toggleMachine = Machine({ + # id: 'input', + # context: { + # value: '' + # }, + # type: 'parallel', + # states: { + # edit: { + # on: { + # CHANGE: { + # actions: assign({ + # value: (_, e) => { + # return e.value; + # } + # }) + # } + # } + # }, + # validity: { + # initial: 'invalid', + # states: { + # invalid: {}, + # valid: {} + # }, + # on: { + # CHANGE: [ + # { + # target: '.valid', + # cond: () => true + # }, + # { + # target: '.invalid' + # } + # ] + # } + # } + # } + # }); + + # const nextState = toggleMachine.transition(toggleMachine.initialState, { + # type: 'CHANGE', + # value: 'whatever' + # }); + + # expect(nextState.changed).toBe(true); + # expect(nextState.value).toEqual({ + # edit: {}, + # validity: 'valid' + # }); + # }); + # }); + + # describe('.nextEvents', () => { + # it('returns the next possible events for the current state', () => { + # expect(machine.initialState.nextEvents.sort()).toEqual([ + # 'EXTERNAL', + # 'INERT', + # 'INTERNAL', + # 'MACHINE_EVENT', + # 'TO_FINAL', + # 'TO_THREE', + # 'TO_TWO', + # 'TO_TWO_MAYBE' + # ]); + + # expect( + # machine.transition(machine.initialState, 'TO_TWO').nextEvents.sort() + # ).toEqual(['DEEP_EVENT', 'FOO_EVENT', 'MACHINE_EVENT']); + + # expect( + # machine.transition(machine.initialState, 'TO_THREE').nextEvents.sort() + # ).toEqual(['MACHINE_EVENT', 'P31', 'P32', 'THREE_EVENT']); + # }); + + # it('returns events when transitioned from StateValue', () => { + # const A = machine.transition(machine.initialState, 'TO_THREE'); + # const B = machine.transition(A.value, 'TO_THREE'); + + # expect(B.nextEvents.sort()).toEqual([ + # 'MACHINE_EVENT', + # 'P31', + # 'P32', + # 'THREE_EVENT' + # ]); + # }); + + # it('returns no next events if there are none', () => { + # const noEventsMachine = Machine({ + # id: 'no-events', + # initial: 'idle', + # states: { + # idle: { + # on: {} + # } + # } + # }); + + # expect(noEventsMachine.initialState.nextEvents).toEqual([]); + # }); + # }); + + # describe('State.create()', () => { + # it('should be able to create a state from a JSON config', () => { + # const { initialState } = machine; + # const jsonInitialState = JSON.parse(JSON.stringify(initialState)); + + # const stateFromConfig = State.create(jsonInitialState) as StateFrom< + # typeof machine + # >; + + # expect(machine.transition(stateFromConfig, 'TO_TWO').value).toEqual({ + # two: { deep: 'foo' } + # }); + # }); + + # it('should preserve state.nextEvents using machine.resolveState', () => { + # const { initialState } = machine; + # const { nextEvents } = initialState; + # const jsonInitialState = JSON.parse(JSON.stringify(initialState)); + + # const stateFromConfig = State.create(jsonInitialState) as StateFrom< + # typeof machine + # >; + + # expect(machine.resolveState(stateFromConfig).nextEvents.sort()).toEqual( + # nextEvents.sort() + # ); + # }); + # }); + + # describe('State.inert()', () => { + # it('should create an inert instance of the given State', () => { + # const { initialState } = machine; + + # expect(State.inert(initialState, undefined).actions).toEqual([]); + # }); + + # it('should create an inert instance of the given stateValue and context', () => { + # const { initialState } = machine; + # const inertState = State.inert(initialState.value, { foo: 'bar' }); + + # expect(inertState.actions).toEqual([]); + # expect(inertState.context).toEqual({ foo: 'bar' }); + # }); + + # it('should preserve the given State if there are no actions', () => { + # const naturallyInertState = State.from('foo'); + + # expect(State.inert(naturallyInertState, undefined)).toEqual( + # naturallyInertState + # ); + # }); + # }); + + # describe('.event', () => { + # it('the .event prop should be the event (string) that caused the transition', () => { + # const { initialState } = machine; + + # const nextState = machine.transition(initialState, 'TO_TWO'); + + # expect(nextState.event).toEqual({ type: 'TO_TWO' }); + # }); + + # it('the .event prop should be the event (object) that caused the transition', () => { + # const { initialState } = machine; + + # const nextState = machine.transition(initialState, { + # type: 'TO_TWO', + # foo: 'bar' + # }); + + # expect(nextState.event).toEqual({ type: 'TO_TWO', foo: 'bar' }); + # }); + + # it('the ._event prop should be the initial event for the initial state', () => { + # const { initialState } = machine; + + # expect(initialState._event).toEqual(initEvent); + # }); + # }); + + # describe('._event', () => { + # it('the ._event prop should be the SCXML event (string) that caused the transition', () => { + # const { initialState } = machine; + + # const nextState = machine.transition(initialState, 'TO_TWO'); + + # expect(nextState._event).toEqual(toSCXMLEvent('TO_TWO')); + # }); + + # it('the ._event prop should be the SCXML event (object) that caused the transition', () => { + # const { initialState } = machine; + + # const nextState = machine.transition(initialState, { + # type: 'TO_TWO', + # foo: 'bar' + # }); + + # expect(nextState._event).toEqual( + # toSCXMLEvent({ type: 'TO_TWO', foo: 'bar' }) + # ); + # }); + + # it('the ._event prop should be the initial SCXML event for the initial state', () => { + # const { initialState } = machine; + + # expect(initialState._event).toEqual(toSCXMLEvent(initEvent)); + # }); + + # it('the ._event prop should be the SCXML event (SCXML metadata) that caused the transition', () => { + # const { initialState } = machine; + + # const nextState = machine.transition( + # initialState, + # toSCXMLEvent( + # { + # type: 'TO_TWO', + # foo: 'bar' + # }, + # { + # sendid: 'test' + # } + # ) + # ); + + # expect(nextState._event).toEqual( + # toSCXMLEvent( + # { type: 'TO_TWO', foo: 'bar' }, + # { + # sendid: 'test' + # } + # ) + # ); + # }); + + # describe('_sessionid', () => { + # it('_sessionid should be null for non-invoked machines', () => { + # const testMachine = Machine({ + # initial: 'active', + # states: { + # active: {} + # } + # }); + + # expect(testMachine.initialState._sessionid).toBeNull(); + # }); + + # it('_sessionid should be the service sessionId for invoked machines', (done) => { + # const testMachine = Machine({ + # initial: 'active', + # states: { + # active: { + # on: { + # TOGGLE: 'inactive' + # } + # }, + # inactive: { + # type: 'final' + # } + # } + # }); + + # const service = interpret(testMachine); + + # service + # .onTransition((state) => { + # expect(state._sessionid).toEqual(service.sessionId); + # }) + # .onDone(() => { + # done(); + # }) + # .start(); + + # service.send('TOGGLE'); + # }); + + # it('_sessionid should persist through states (manual)', () => { + # const testMachine = Machine({ + # initial: 'active', + # states: { + # active: { + # on: { + # TOGGLE: 'inactive' + # } + # }, + # inactive: { + # type: 'final' + # } + # } + # }); + + # const { initialState } = testMachine; + + # initialState._sessionid = 'somesessionid'; + + # const nextState = testMachine.transition(initialState, 'TOGGLE'); + + # expect(nextState._sessionid).toEqual('somesessionid'); + # }); + # }); + # }); + it('states from external transitions with entry actions should be changed', () => { + const changedState = machine.transition(machine.initialState, 'EXTERNAL'); + expect(changedState.changed).toBe(true); + }); + """ + changed_state = machine.transition(machine.initial_state, "EXTERNAL") + assert changed_state.changed == True, pytest_func_docstring_summary(request) + + +class TestState_transitions: + """ + describe('.transitions', () => { + 1 - should have no transitions for the initial state + 2 - should have transitions for the sent event + 3 - should have condition in the transition + 4 - should have python callable condition in the transition + """ + + initial_state = machine.initial_state + + def test_state_transitions_1(self, request): + """1 - should have no transitions for the initial state + + ref: https://github.com/statelyai/xstate/blob/main/packages/core/test/state.test.ts + + it('should have no transitions for the initial state', () => { + expect(initialState.transitions).toHaveLength(0); + }); + + """ + assert len(self.initial_state.transitions) == 0, pytest_func_docstring_summary( + request + ) + + def test_state_transitions_2(self, request): + """2 - should have transitions for the sent event + + ref: https://github.com/statelyai/xstate/blob/main/packages/core/test/state.test.ts + + it('should have transitions for the sent event', () => { + expect( + machine.transition(initialState, 'TO_TWO').transitions + ).toContainEqual(expect.objectContaining({ eventType: 'TO_TWO' })); + }); + """ + # xstate_python_config['id']="test_states" # TODO REMOVE ME after debug + new_state_transitions = machine.transition( + self.initial_state, "TO_TWO" + ).transitions + + # TODO WIP 21w38 not sure if events are supported + assert new_state_transitions != set() and all( + [transition.event == "TO_TWO" for transition in new_state_transitions] + ), pytest_func_docstring_summary(request) + + def test_state_transitions_3(self, request): + """3 - should have condition in the transition + + ref: https://github.com/statelyai/xstate/blob/main/packages/core/test/state.test.ts + + it('should have condition in the transition', () => { + expect( + machine.transition(initialState, 'TO_TWO_MAYBE').transitions + ).toContainEqual( + expect.objectContaining({ + eventType: 'TO_TWO_MAYBE', + cond: expect.objectContaining({ name: 'maybe' }) + """ + + # new_state_transitions = machine.transition(self.initial_state, 'TO_TWO_MAYBE').transitions + initial_state = machine.initial_state + new_state = machine.transition(initial_state, "TO_TWO_MAYBE") + new_state_transitions = new_state.transitions + assert ( + new_state_transitions != set() + and all( + [ + transition.event == "TO_TWO_MAYBE" + for transition in new_state_transitions + ] + ) + and list(new_state_transitions)[0].cond + and repr(list(new_state_transitions)[0].cond) + == "'function maybe() { [python code] }'" + and list(new_state_transitions)[0].cond() + ), pytest_func_docstring_summary(request) + + @pytest.mark.skip(reason="Not implemented yet") + def test_state_transitions_4(self, request): + """4 - should have python callable condition in the transition""" + # TODO: Implement and Test Python callable Transition condition + assert "IMPLEMENTED" == "NOT YET", pytest_func_docstring_summary(request) + + +class TestState_State_Protoypes: + """Test: describe('State.prototype.matches""" + + initial_state = machine.initial_state + + @pytest.mark.skip(reason="Not implemented yet") + def test_state_prototype_matches(self, request): + """1 - should keep reference to state instance after destructuring + + ref: https://github.com/statelyai/xstate/blob/main/packages/core/test/state.test.ts + it('should keep reference to state instance after destructuring', () => { + const { initialState } = machine; + const { matches } = initialState; + expect(matches('one')).toBe(true); + }); + """ + assert ( + "IMPLEMENTED" == "NOT YET" + ), "1 - should keep reference to state instance after destructuring" + + +class TestState_State_Protoypes_To_String: + """Test: describe('State.prototype.toStrings' + * 1 - should return all state paths as strings' + * 2 - should respect `delimiter` option for deeply nested states + * 3 - should keep reference to state instance after destructuring + """ + + def test_state_prototype_to_strings_1(self, request): + """1 - should return all state paths as strings + + ref: https://github.com/statelyai/xstate/blob/main/packages/core/test/state.test.ts + + it('should return all state paths as strings', () => { + const twoState = machine.transition('one', 'TO_TWO'); + expect(twoState.toStrings()).toEqual(['two', 'two.deep', 'two.deep.foo']); + }); + """ + two_state = machine.transition("one", "TO_TWO") + assert repr( + two_state + ) == "" and str( + two_state + ) == repr( + ["two", "two.deep", "two.deep.foo"] + ), pytest_func_docstring_summary( + request + ) + + @pytest.mark.skip(reason="Not implemented yet") + def test_state_prototype_to_strings_2(self, request): + """2 - should respect `delimiter` option for deeply nested states' + + ref: https://github.com/statelyai/xstate/blob/main/packages/core/test/state.test.ts + + it('should respect `delimiter` option for deeply nested states', () => { + const twoState = machine.transition('one', 'TO_TWO'); + expect(twoState.toStrings(undefined, ':')).toEqual([ + 'two', + 'two:deep', + 'two:deep:foo' + ]); + """ + two_state = machine.transition("one", "TO_TWO") + assert ( + "IMPLEMENTED" == "NOT YET - possibly requires a formatter" + ), pytest_func_docstring_summary(request) + + def test_state_prototype_to_strings_3(self, request): + """3 - should keep reference to state instance after destructuring + + ref: https://github.com/statelyai/xstate/blob/main/packages/core/test/state.test.ts + + it('should keep reference to state instance after destructuring', () => { + const { initialState } = machine; + const { toStrings } = initialState; + + expect(toStrings()).toEqual(['one']); + """ + + initial_state = machine.initial_state + # const { toStrings } = initialState; + assert repr( + initial_state + ) == "" and str( + initial_state + ) == repr( + ["one"] + ), pytest_func_docstring_summary( + request + ) + + +class TestState_State_Done: + """Test: describe('.done', + + 1 - should keep reference to state instance after destructuring + 2 - should show that a machine has reached its final state + + """ + + initial_state = machine.initial_state + + @pytest.mark.skip(reason="Not implemented yet") + def test_state_done_1(self, request): + """1 - should keep reference to state instance after destructuring + + ref: https://github.com/statelyai/xstate/blob/main/packages/core/test/state.test.ts + it('should show that a machine has not reached its final state', () => { + expect(machine.initialState.done).toBeFalsy(); + }); + """ + assert "IMPLEMENTED" == "NOT YET", pytest_func_docstring_summary(request) + + @pytest.mark.skip(reason="Not implemented yet") + def test_state_done_2(self, request): + """2 - should show that a machine has reached its final state + + ref: https://github.com/statelyai/xstate/blob/main/packages/core/test/state.test.ts + it('should show that a machine has reached its final state', () => { + expect(machine.transition(undefined, 'TO_FINAL').done).toBeTruthy(); + }); + """ + assert "IMPLEMENTED" == "NOT YET", pytest_func_docstring_summary(request) + + +class TestState_State_Can: + """Test: describe('.can', + + .can is not yet implemented in python + 1 - ??????????????????? + .... + n -- ????????????????? + """ + + initial_state = machine.initial_state + + @pytest.mark.skip(reason="Not implemented yet") + def test_state_can_1(self, request): + """1 - should keep reference to state instance after destructuring + + ref: https://github.com/statelyai/xstate/blob/main/packages/core/test/state.test.ts + describe('.can', () => { + it('should return true for a simple event that results in a transition to a different state', () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { + NEXT: 'b' + } + }, + b: {} + } + }); + + expect(machine.initialState.can('NEXT')).toBe(true); + }); + + it('should return true for an event object that results in a transition to a different state', () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { + NEXT: 'b' + } + }, + b: {} + } + }); + + expect(machine.initialState.can({ type: 'NEXT' })).toBe(true); + }); + + it('should return true for an event object that results in a new action', () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { + NEXT: { + actions: 'newAction' + } + } + } + } + }); + + expect(machine.initialState.can({ type: 'NEXT' })).toBe(true); + }); + + it('should return true for an event object that results in a context change', () => { + const machine = createMachine({ + initial: 'a', + context: { count: 0 }, + states: { + a: { + on: { + NEXT: { + actions: assign({ count: 1 }) + } + } + } + } + }); + + expect(machine.initialState.can({ type: 'NEXT' })).toBe(true); + }); + + it('should return false for an external self-transition without actions', () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { + EV: 'a' + } + } + } + }); + + expect(machine.initialState.can({ type: 'EV' })).toBe(false); + }); + + it('should return true for an external self-transition with reentry action', () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + entry: () => {}, + on: { + EV: 'a' + } + } + } + }); + + expect(machine.initialState.can({ type: 'EV' })).toBe(true); + }); + + it('should return true for an external self-transition with transition action', () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { + EV: { + target: 'a', + actions: () => {} + } + } + } + } + }); + + expect(machine.initialState.can({ type: 'EV' })).toBe(true); + }); + + it('should return true for a targetless transition with actions', () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { + EV: { + actions: () => {} + } + } + } + } + }); + + expect(machine.initialState.can({ type: 'EV' })).toBe(true); + }); + + it('should return false for a forbidden transition', () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { + EV: undefined + } + } + } + }); + + expect(machine.initialState.can({ type: 'EV' })).toBe(false); + }); + + it('should return false for an unknown event', () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { + NEXT: 'b' + } + }, + b: {} + } + }); + + expect(machine.initialState.can({ type: 'UNKNOWN' })).toBe(false); + }); + + it('should return true when a guarded transition allows the transition', () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { + CHECK: { + target: 'b', + cond: () => true + } + } + }, + b: {} + } + }); + + expect( + machine.initialState.can({ + type: 'CHECK' + }) + ).toBe(true); + }); + + it('should return false when a guarded transition disallows the transition', () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { + CHECK: { + target: 'b', + cond: () => false + } + } + }, + b: {} + } + }); + + expect( + machine.initialState.can({ + type: 'CHECK' + }) + ).toBe(false); + }); + + it('should not spawn actors when determining if an event is accepted', () => { + let spawned = false; + const machine = createMachine({ + context: {}, + initial: 'a', + states: { + a: { + on: { + SPAWN: { + actions: assign(() => ({ + ref: spawn(() => { + spawned = true; + }) + })) + } + } + }, + b: {} + } + }); + + const service = interpret(machine).start(); + service.state.can('SPAWN'); + expect(spawned).toBe(false); + }); + + it('should return false for states created without a machine', () => { + const state = State.from('test'); + + expect(state.can({ type: 'ANY_EVENT' })).toEqual(false); + }); + + it('should allow errors to propagate', () => { + const machine = createMachine({ + context: {}, + on: { + DO_SOMETHING_BAD: { + actions: assign(() => { + throw new Error('expected error'); + }) + } + } + }); + + expect(() => { + const { initialState } = machine; + + initialState.can('DO_SOMETHING_BAD'); + }).toThrowError(/expected error/); + }); + }); + }); + """ + assert "IMPLEMENTED" == "NOT YET", pytest_func_docstring_summary(request) diff --git a/tests/test_state_in.py b/tests/test_state_in.py new file mode 100644 index 0000000..d20be8d --- /dev/null +++ b/tests/test_state_in.py @@ -0,0 +1,410 @@ +"""Tests for statesin + +These tests test transitions, some of the tests test the transition guard capabilities +ie only transition if state is in a particular state `in State` +see https://xstate.js.org/docs/guides/guards.html#in-state-guards + +Based on : xstate/packages/core/test/statein.test.ts + +These tests use either machines coded as python `dict` +or accept strings representing xstate javascript/typescript which are +then converted to a python `dict` by the module `js2py` + +The following tests exist + * xxx - returns .... + * xxx - the .... +""" +# from xstate.algorithm import is_parallel_state +from webbrowser import get +from xstate.machine import Machine +from xstate.algorithm import get_configuration_from_js + +# from xstate.state import State +# import { +# Machine, +# State, +# StateFrom, +# interpret, +# createMachine, +# spawn +# } from '../src/index'; +# import { initEvent, assign } from '../src/actions'; +# import { toSCXMLEvent } from '../src/utils'; + + +import pytest +from .utils_for_tests import JSstyleTest, pytest_func_docstring_summary + +machine_xstate_js_config = """{ + type: 'parallel', + states: { + a: { + initial: 'a1', + states: { + a1: { + on: { + EVENT1: { + target: 'a2', + in: 'b.b2' + }, + EVENT2: { + target: 'a2', + in: { b: 'b2' } + }, + EVENT3: { + target: 'a2', + in: '#b_b2' + } + } + }, + a2: {} + } + }, + b: { + initial: 'b1', + states: { + b1: { + on: { + EVENT: { + target: 'b2', + in: 'a.a2' + } + } + }, + b2: { + id: 'b_b2', + type: 'parallel', + states: { + foo: { + initial: 'foo1', + states: { + foo1: { + on: { + EVENT_DEEP: { target: 'foo2', in: 'bar.bar1' } + } + }, + foo2: {} + } + }, + bar: { + initial: 'bar1', + states: { + bar1: {}, + bar2: {} + } + } + } + } + } + } + } +}""" + +machine = Machine(machine_xstate_js_config) + + +light_machine_xstate_js_config = """{ + id: 'light', + initial: 'green', + states: { + green: { on: { TIMER: 'yellow' } }, + yellow: { on: { TIMER: 'red' } }, + red: { + initial: 'walk', + states: { + walk: {}, + wait: {}, + stop: {} + }, + on: { + TIMER: [ + { + target: 'green', + in: { red: 'stop' } + } + ] + } + } + } +}""" + +light_machine = Machine(light_machine_xstate_js_config) + + +# type Events = +# | { type: 'BAR_EVENT' } +# | { type: 'DEEP_EVENT' } +# | { type: 'EXTERNAL' } +# | { type: 'FOO_EVENT' } +# | { type: 'FORBIDDEN_EVENT' } +# | { type: 'INERT' } +# | { type: 'INTERNAL' } +# | { type: 'MACHINE_EVENT' } +# | { type: 'P31' } +# | { type: 'P32' } +# | { type: 'THREE_EVENT' } +# | { type: 'TO_THREE' } +# | { type: 'TO_TWO'; foo: string } +# | { type: 'TO_TWO_MAYBE' } +# | { type: 'TO_FINAL' }; + + +class TestStateIn_transition: + """describe('transition "in" check', () => {""" + + def test_transition_if_string_state_path_matches_current_state_value(self, request): + """should transition if string state path matches current state value""" + test = JSstyleTest() + test.it(pytest_func_docstring_summary(request)).expect( + machine.transition( + get_configuration_from_js( + """ + { + a: 'a1', + b: { + b2: { + foo: 'foo2', + bar: 'bar1' + } + } + } + """ + ), + "EVENT1", + ).value + ).toEqual( + get_configuration_from_js( + """ + { + a: 'a2', + b: { + b2: { + foo: 'foo2', + bar: 'bar1' + } + } + } + """ + ) + ) + + def test_should_transition_if_state_node_id_matches_current_state_value( + self, request + ): + """should transition if state node ID matches current state value""" + test = JSstyleTest() + test.it(pytest_func_docstring_summary(request)).expect( + machine.transition( + get_configuration_from_js( + """ + { + a: 'a1', + b: { + b2: { + foo: 'foo2', + bar: 'bar1' + } + } + } + """ + ), + "EVENT3", + ).value + ).toEqual( + get_configuration_from_js( + """ + { + a: 'a2', + b: { + b2: { + foo: 'foo2', + bar: 'bar1' + } + } + } + """ + ) + ) + + # it('should not transition if string state path does not match current state value', () => { + @pytest.mark.skip( + reason="Transition based on Orthogonal/Parallel or Hierachical relative states, not yet implemented" + ) + def test_should_not_transition_if_string_state_path_does_not_match_current_state_value( + self, request + ): + """should not transition if string state path does not match current state value + ie in the case of a parallel referenced state the transition should not happend""" + test = JSstyleTest() + test.it(pytest_func_docstring_summary(request)).expect( + machine.transition( + get_configuration_from_js( + """ + { + a: 'a1', + b: 'b1' + } + """ + ), + "EVENT1", + ).value + ).toEqual( + get_configuration_from_js( + """ + { + a: 'a1', + b: 'b1' + } + """ + ) + ) + + # it('should not transition if state value matches current state value', () => { + def test_should_not_transition_if_state_value_matches_current_state_value( + self, request + ): + """should not transition if state value matches current state value""" + test = JSstyleTest() + test.it(pytest_func_docstring_summary(request)).expect( + machine.transition( + get_configuration_from_js( + """ + { + a: 'a1', + b: { + b2: { + foo: 'foo2', + bar: 'bar1' + } + } + } + + """ + ), + "EVENT2", + ).value + ).toEqual( + get_configuration_from_js( + """ + { + a: 'a2', + b: { + b2: { + foo: 'foo2', + bar: 'bar1' + } + } + } + + """ + ) + ) + + # it('matching should be relative to grandparent (match)', () => { + def test_matching_should_be_relative_to_grandparent_match(self, request): + """matching should be relative to grandparent (match)""" + test = JSstyleTest() + test.it(pytest_func_docstring_summary(request)).expect( + machine.transition( + get_configuration_from_js( + """ + { + a: 'a1', + b: { + b2: + { + foo: 'foo1', + bar: 'bar1' + } + } + } + + """ + ), + "EVENT_DEEP", + ).value + ).toEqual( + get_configuration_from_js( + """ + { + a: 'a1', + b: { + b2: { + foo: 'foo2', + bar: 'bar1' + } + } + } + + """ + ) + ) + + # it('matching should be relative to grandparent (no match)', () => { + @pytest.mark.skip(reason="Transition Guards, not yet implemented") + def test_matching_should_be_relative_to_grandparent_no_match(self, request): + """matching should be relative to grandparent (no match) + ie. transitioning a state dependent on a relative state + only transition `foo.foo2` if a relative has current state as `bar.bar1` + see https://xstate.js.org/docs/guides/guards.html#in-state-guards + """ + test = JSstyleTest() + test.it(pytest_func_docstring_summary(request)).expect( + machine.transition( + get_configuration_from_js( + """ + { + a: 'a1', + b: { + b2: + { + foo: 'foo1', + bar: 'bar2' + } + } + } + """ + ), + "EVENT_DEEP", + ).value + ).toEqual( + get_configuration_from_js( + """ + { + a: 'a1', + b: { + b2: { + foo: 'foo1', + bar: 'bar2' + } + } + } + """ + ) + ) + + # it('should work to forbid events', () => { + @pytest.mark.skip(reason="Transition Guards, not yet implemented") + def test_should_work_to_forbid_events(self, request): + """should work to forbid events + ie. transitioning a state dependent on being in a particular state + only transition to `#light.green` if `#light.red` substate is `red.stop` + see https://xstate.js.org/docs/guides/guards.html#in-state-guards + + """ + test = JSstyleTest() + test.it(pytest_func_docstring_summary(request)).do( + walkState=light_machine.transition("red.walk", "TIMER") + ).expect("walkState.value").toEqual({"red": "walk"}).do( + waitState=light_machine.transition("red.wait", "TIMER") + ).expect( + "waitState.value" + ).toEqual( + {"red": "wait"} + ).do( + stopState=light_machine.transition("red.stop", "TIMER") + ).expect( + "stopState.value" + ).toEqual( + "green" + ) diff --git a/tests/utils_for_tests.py b/tests/utils_for_tests.py new file mode 100644 index 0000000..1482219 --- /dev/null +++ b/tests/utils_for_tests.py @@ -0,0 +1,87 @@ +import pytest + + +def pytest_func_docstring_summary(request: pytest.FixtureRequest) -> str: + """Retrieve the Summary line of the tests docstring + + Args: + request ([type]): a pytest fixture request + + Returns: + str: the top summary line of the Test Doc String + """ + return request.node.function.__doc__.split("\n")[0] + + +class JSstyleTest: + '''Implements a functional JS style test + + example: + ``` + test = JSstyleTest() + test.it( + "should transition if string state path matches current state value" + ).expect( + machine.transition( + get_configuration_from_js( + """ + { + a: 'a1', + b: { + b2: { + foo: 'foo2', + bar: 'bar1' + } + } + } + """ + ), + "EVENT1", + ).value + ).toEqual( + get_configuration_from_js( + """ + { + a: 'a2', + b: { + b2: { + foo: 'foo2', + bar: 'bar1' + } + } + } + """ + ) + ) + + ``` + ''' + + def __init__(self): + pass + + def it(self, message): + self._definitions = None + self.message = message + + return self + + def do(self, **kwargs): + self._definitions = kwargs + return self + + def expect(self, operation): + self.operation = operation + if isinstance(self.operation, str) and self._definitions is not None: + for d in self._definitions.keys(): + if str(d) in operation: + self.operation = eval(operation, {}, self._definitions) + + return self + + def toEqual(self, test): + self.result = self.operation == test + assert ( + self.result + ), f"{self.message}, test value:{self.operation}, should be:{test}" + return self diff --git a/viz.py b/viz.py index 8913755..771abf1 100644 --- a/viz.py +++ b/viz.py @@ -35,7 +35,7 @@ def state_node_to_viz(state_node): """ if state_node.initial: - initial_state = state_node.initial.target[0].id + initial_state = state_node.initial_transition.target[0].id initial_string = f"[*] --> {initial_state}\n" result += initial_string diff --git a/xstate/__init__.py b/xstate/__init__.py index fbaa702..f4adc0f 100644 --- a/xstate/__init__.py +++ b/xstate/__init__.py @@ -1 +1,5 @@ -from xstate.machine import Machine # noqa +from __future__ import ( + annotations, +) # PEP 563:__future__.annotations will become the default in Python 3.11 + +# from xstate.machine import Machine # noqa diff --git a/xstate/action.py b/xstate/action.py index 6d6e5f4..92c6898 100644 --- a/xstate/action.py +++ b/xstate/action.py @@ -1,10 +1,95 @@ -from typing import Any, Callable, Dict, Optional +from __future__ import ( + annotations, +) # PEP 563:__future__.annotations will become the default in Python 3.11 +from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, List, Union + +if TYPE_CHECKING: + from xstate.action import Action + from xstate.state_node import StateNode +import logging + +logger = logging.getLogger(__name__) +from xstate.event import Event + +# from xstate.action_types import DoneInvoke +from xstate.types import ( + ActionFunction, + ActionFunctionMap, + ActionType, + ActionTypes, + ActionObject, +) + +from xstate.algorithm import ( + to_scxml_event, +) +import xstate.action_types as actionTypes def not_implemented(): + logger.warning("Action function: not implemented") pass +class DoneEvent(Event): + pass + + +init_event = to_scxml_event({"type": actionTypes.init}) + +# /** +# * Returns an event that represents that an invoked service has terminated. +# * +# * An invoked service is terminated when it has reached a top-level final state node, +# * but not when it is canceled. +# * +# * @param id The final state node ID +# * @param data The data to pass into the event +# */ +# export function doneInvoke(id: string, data?: any): DoneEvent { +# const type = `${ActionTypes.DoneInvoke}.${id}`; +# const eventObject = { +# type, +# data +# }; + +# eventObject.toString = () => type; + +# return eventObject as DoneEvent; +# } +def done_invoke(id: str, data: Any) -> DoneEvent: + """Returns an event that represents that an invoked service has terminated. + + * An invoked service is terminated when it has reached a top-level final state node, + * but not when it is canceled. + + Args: + id (str): The final state node ID + data (Any): The data to pass into the event + + Returns: + DoneEvent: an event that represents that an invoked service has terminated + """ + type = f"{ActionTypes.DoneInvoke}.{id}" + return DoneEvent(type, data) + + +# export const toActionObjects = ( +# action?: SingleOrArray> | undefined, +# actionFunctionMap?: ActionFunctionMap +# ): Array> => { +# if (!action) { +# return []; +# } + +# const actions = isArray(action) ? action : [action]; + +# return actions.map((subAction) => +# toActionObject(subAction, actionFunctionMap) +# ); +# }; + + class Action: type: str exec: Callable[[], None] @@ -22,3 +107,133 @@ def __init__( def __repr__(self): return repr({"type": self.type}) + + +# export function getActionFunction( +# actionType: ActionType, +# actionFunctionMap?: ActionFunctionMap +# ): +def get_action_function( + action_type: ActionType, action_function_map: ActionFunctionMap +) -> Union[ActionObject, ActionFunction, None]: + # | ActionObject + # | ActionFunction + # | undefined { + + # return actionFunctionMap + # ? actionFunctionMap[actionType] || undefined + # : undefined; + return action_function_map[action_type] or None if action_function_map else None + + +# export function toActionObject( +# action: Action, +# actionFunctionMap?: ActionFunctionMap +# ): ActionObject { +def to_action_object(action: Action, action_function_map: ActionFunctionMap): + # let actionObject: ActionObject; + action_object: ActionObject = {} + + # if (isString(action) || typeof action === 'number') { + if isinstance(action, str) or type(action) == "number": + # const exec = getActionFunction(action, actionFunctionMap); + exec = get_action_function(action, action_function_map) + + # if (isFunction(exec)) { + if callable(exec): + # action_object = { + # "type": action, + # exec + # } + action_object = { + "type": action, + "exec": exec, + } + # } else if (exec) { + elif exec: + # actionObject = exec; + action_object = exec + # } else { + else: + # actionObject = { type : action, exec: undefined }; + action_object = {"type": action, "exec": None} + # } + # } else if (isFunction(action)) { + elif callable(action): + # actionObject = { + # // Convert action to string if unnamed + # type : action.name || action.toString(), + # exec: action + # }; + action_object = { + # // Convert action to string if unnamed + # "type": action.__qualname__ or str(action), + "type": str(ActionTypes.Raise), # "xstate:raise + "exec": action, + } + action_object = {**action_object, "data": action_object.copy()} + # } else { + elif isinstance(action, dict): + action_object = { + "type": action.get("type", ActionTypes.Raise), + "exec": action.get("exec", None), + } + action_object["data"] = action if ("event" in action) else {} + else: + # TODO TD, some validation required that object has a type and exec attribute + # const exec = getActionFunction(action.type, actionFunctionMap); + exec = get_action_function(action.type, action_function_map) + # if (isFunction(exec)) { + if callable(exec): + # actionObject = { + # ...action, + # exec + # }; + action_object = {**action, **{"exec": exec}} + # } else if (exec) { + elif exec: + # const actionType = exec.type || action.type; + action_type = exec.type or action.type + # actionObject = { + # ...exec, + # ...action, + # type : actionType + # } as ActionObject; + action_object = {**exec, **action, **{"type": action_type}} + + # } else { + else: + # actionObject = action as ActionObject; + # } + # } + action_object = action + + # return actionObject; + return Action(**action_object) + + +# export const toActionObjects = ( +# action?: SingleOrArray> | undefined, +# actionFunctionMap?: ActionFunctionMap +# ): Array> => { +# if (!action) { +# return []; +# } + +# const actions = isArray(action) ? action : [action]; + +# return actions.map((subAction) => +# toActionObject(subAction, actionFunctionMap) +# ); +# }; + + +def to_action_objects( + action: Union[Action, List[Action]], + action_function_map: Any, # TODO: define types: ActionFunctionMap +) -> List[ActionObject]: + if not action: + return [] + actions = action if isinstance(action, List) else [action] + + return [to_action_object(sub_action, action_function_map) for sub_action in actions] diff --git a/xstate/action_types.py b/xstate/action_types.py new file mode 100644 index 0000000..4081f60 --- /dev/null +++ b/xstate/action_types.py @@ -0,0 +1,49 @@ +from __future__ import ( + annotations, +) # PEP 563:__future__.annotations will become the default in Python 3.11 +from typing import TYPE_CHECKING, Dict, List, Optional, Union +import logging + +logger = logging.getLogger(__name__) # import { ActionTypes } from './types'; +from xstate.types import ActionTypes + +# // xstate-specific action types +# export const start = ActionTypes.Start; +# export const stop = ActionTypes.Stop; +# export const raise = ActionTypes.Raise; +# export const send = ActionTypes.Send; +# export const cancel = ActionTypes.Cancel; +# export const nullEvent = ActionTypes.NullEvent; +# export const assign = ActionTypes.Assign; +# export const after = ActionTypes.After; +# export const doneState = ActionTypes.DoneState; +# export const log = ActionTypes.Log; +# export const init = ActionTypes.Init; +# export const invoke = ActionTypes.Invoke; +# export const errorExecution = ActionTypes.ErrorExecution; +# export const errorPlatform = ActionTypes.ErrorPlatform; +# export const error = ActionTypes.ErrorCustom; +# export const update = ActionTypes.Update; +# export const choose = ActionTypes.Choose; +# export const pure = ActionTypes.Pure; + + +# xstate-specific action types +start = ActionTypes.Start +stop = ActionTypes.Stop +action_raise = ActionTypes.Raise # raise not permitted as is python keyword +send = ActionTypes.Send +cancel = ActionTypes.Cancel +nullEvent = ActionTypes.NullEvent +assign = ActionTypes.Assign +after = ActionTypes.After +doneState = ActionTypes.DoneState +log = ActionTypes.Log +init = ActionTypes.Init +invoke = ActionTypes.Invoke +errorExecution = ActionTypes.ErrorExecution +errorPlatform = ActionTypes.ErrorPlatform +error = ActionTypes.ErrorCustom +update = ActionTypes.Update +choose = ActionTypes.Choose +pure = ActionTypes.Pure diff --git a/xstate/algorithm.py b/xstate/algorithm.py index 67af1d9..dae27d2 100644 --- a/xstate/algorithm.py +++ b/xstate/algorithm.py @@ -1,11 +1,69 @@ -from typing import Dict, List, Optional, Set, Tuple, Union +from __future__ import annotations +from multiprocessing import ( + Condition, +) # PEP 563:__future__.annotations will become the default in Python 3.11 +import logging +from typing_extensions import get_args + +logger = logging.getLogger(__name__) + +from typing import ( + TYPE_CHECKING, + Any, + Callable, + Dict, + List, + Optional, + Set, + Tuple, + Union, + Iterable, +) + +# from xstate.state import StateValue # , StateLike + +# from xstate.types import StateLike + +# TODO: why does this cause pytest to fail, ImportError: cannot import name 'get_state_value' from 'xstate.algorithm' +# Workaround: supress import and in `get_configuration_from_state` put state: [Dict,str] +# from xstate.state import StateType +from xstate.environment import IS_PRODUCTION, WILDCARD, STATE_IDENTIFIER, NULL_EVENT + +from xstate.constants import ( + STATE_DELIMITER, + TARGETLESS_KEY, + DEFAULT_GUARD_TYPE, +) + +if TYPE_CHECKING: + from xstate.types import ( + Record, + Guard, + DoneEventObject, + StateLike, + StateValue, + Configuration, + AdjList, + SCXML, + # HistoryValue + ) + from xstate.action import Action + from xstate.transition import Transition + from xstate.state_node import StateNode + from xstate.state import StateType + from xstate.event import Event + + from xstate.state import State + + # HistoryValue = Dict[str, Set[StateNode]] +from xstate.types import HistoryValue + -from xstate.action import Action from xstate.event import Event -from xstate.state_node import StateNode -from xstate.transition import Transition +from xstate.action_types import ActionTypes + -HistoryValue = Dict[str, Set[StateNode]] +import js2py def compute_entry_set( @@ -14,9 +72,10 @@ def compute_entry_set( states_for_default_entry: Set[StateNode], default_history_content: Dict, history_value: HistoryValue, + current_state: StateValue, ): for t in transitions: - for s in t.target: + for s in t.target_consider_history(history_value=history_value): add_descendent_states_to_enter( s, states_to_enter=states_to_enter, @@ -25,15 +84,48 @@ def compute_entry_set( history_value=history_value, ) ancestor = get_transition_domain(t, history_value=history_value) - for s in get_effective_target_states(t, history_value=history_value): - add_ancestor_states_to_enter( - s, - ancestor=ancestor, - states_to_enter=states_to_enter, - states_for_default_entry=states_for_default_entry, - default_history_content=default_history_content, - history_value=history_value, + # When processing history the search for states to enter from ancestors must be restricted + if ( + ancestor is None + # depenmded on format of t.config, str or dict + or not ( + # str + ( + isinstance(t.config, str) + and all( + [ + node.type == "history" and node.history == "deep" + for node in ancestor.get_from_relative_path( + to_state_path(t.config) + ) + ] + ) + ) + # dict + or ( + isinstance(t.config, dict) + and any( + [ + node.type == "history" + for tc in t.target + for node in ancestor.machine.root.get_from_relative_path( + tc.path + ) + ] + ) + ) ) + or history_value is None + ): + for s in get_effective_target_states(t, history_value=history_value): + add_ancestor_states_to_enter( + s, + ancestor=ancestor, + states_to_enter=states_to_enter, + states_for_default_entry=states_for_default_entry, + default_history_content=default_history_content, + history_value=history_value, + ) def add_descendent_states_to_enter( # noqa C901 too complex. TODO: simplify function @@ -44,26 +136,28 @@ def add_descendent_states_to_enter( # noqa C901 too complex. TODO: simplify fun history_value: HistoryValue, ): if is_history_state(state): - if history_value.get(state.id): - for s in history_value.get(state.id): + if history_value.states: + for s in history_value.states[state.parent.key].states: add_descendent_states_to_enter( - s, + state.parent.get_state_node(s), states_to_enter=states_to_enter, states_for_default_entry=states_for_default_entry, default_history_content=default_history_content, history_value=history_value, ) - for s in history_value.get(state.id): + for s in history_value.states[state.parent.key].states: add_ancestor_states_to_enter( - s, - ancestor=s.parent, + state.parent.get_state_node(s), + ancestor=state.parent, states_to_enter=states_to_enter, states_for_default_entry=states_for_default_entry, default_history_content=default_history_content, history_value=history_value, ) else: - default_history_content[state.parent.id] = state.transition.content + # default_history_content[state.parent.id] = state.transition.content + # TODO: WIP -histoy This code does not get touched by current history tests + default_history_content[state.parent.id] = None # for s in state.transition.target: # add_descendent_states_to_enter( # s, @@ -85,7 +179,7 @@ def add_descendent_states_to_enter( # noqa C901 too complex. TODO: simplify fun states_to_enter.add(state) if is_compound_state(state): states_for_default_entry.add(state) - for s in state.initial.target: + for s in state.initial_transition.target: add_descendent_states_to_enter( s, states_to_enter=states_to_enter, @@ -93,7 +187,7 @@ def add_descendent_states_to_enter( # noqa C901 too complex. TODO: simplify fun default_history_content=default_history_content, history_value=history_value, ) - for s in state.initial.target: + for s in state.initial_transition.target: add_ancestor_states_to_enter( s, ancestor=s.parent, @@ -165,7 +259,7 @@ def get_effective_target_states( ) -> Set[StateNode]: targets: Set[StateNode] = set() - for s in transition.target: + for s in transition.target_consider_history(history_value=history_value): if is_history_state(s): if history_value.get(s.id): targets.update(history_value.get(s.id)) @@ -215,6 +309,26 @@ def get_proper_ancestors( return ancestors +# export function getChildren( +# stateNode: StateNode +# ): Array> { +# return keys(stateNode.states).map((key) => stateNode.states[key]); +# } +def get_children(state_node: StateNode) -> List[StateNode]: + return [state_node.states[key] for key, state in state_node.states.items()] + # return state_node.states.keys() + + +# export const isLeafNode = (stateNode: StateNode) => +# stateNode.type === 'atomic' || stateNode.type === 'final'; +def is_state_id(state_id: str) -> bool: + return state_id[0] == STATE_IDENTIFIER + + +def is_leaf_node(state_node: StateNode) -> bool: + return state_node.type == "atomic" or state_node.type == "final" + + def is_final_state(state_node: StateNode) -> bool: return state_node.type == "final" @@ -231,7 +345,10 @@ def get_child_states(state_node: StateNode) -> List[StateNode]: return [state_node.states.get(key) for key in state_node.states.keys()] -def is_in_final_state(state: StateNode, configuration: Set[StateNode]) -> bool: +def is_in_final_state( + configuration: Set[StateNode], + state: StateNode, +) -> bool: if is_compound_state(state): return any( [ @@ -240,11 +357,47 @@ def is_in_final_state(state: StateNode, configuration: Set[StateNode]) -> bool: ] ) elif is_parallel_state(state): - return all(is_in_final_state(s, configuration) for s in get_child_states(state)) + return all(is_in_final_state(configuration, s) for s in get_child_states(state)) else: return False +# /** +# * Returns an event that represents that a final state node +# * has been reached in the parent state node. +# * +# * @param id The final state node's parent state node `id` +# * @param data The data to pass into the event +# */ +# export function done(id: string, data?: any): DoneEventObject { +def done(id: str, data: Any) -> DoneEventObject: + """Returns an event that represents that a final state node + has been reached in the parent state node. + + Args: + id (str): The final state node's parent state node `id` + data (Any): The data to pass into the event + + Returns: + DoneEventObject: an event that represents that a final state node + has been reached in the parent state node. + """ + # const type = `${ActionTypes.DoneState}.${id}`; + type = f"{ActionTypes.DoneState}.{id}" + # const eventObject = { + # type, + # data + # }; + event_object = {"type": type, "data": data} + + # TODO: implement this, No Unit Test yet + # eventObject.toString = () => type; + + # return eventObject as DoneEvent; + return event_object + # } + + def enter_states( enabled_transitions: List[Transition], configuration: Set[StateNode], @@ -252,6 +405,8 @@ def enter_states( history_value: HistoryValue, actions: List[Action], internal_queue: List[Event], + transitions: List[Transition], + current_state: State, ) -> Tuple[Set[StateNode], List[Action], List[Event]]: states_to_enter: Set[StateNode] = set() states_for_default_entry: Set[StateNode] = set() @@ -264,6 +419,7 @@ def enter_states( states_for_default_entry=states_for_default_entry, default_history_content=default_history_content, history_value=history_value, + current_state=current_state, ) # TODO: sort @@ -288,15 +444,42 @@ def enter_states( parent = s.parent grandparent = parent.parent internal_queue.append(Event(f"done.state.{parent.id}", s.donedata)) + # transitions.add("TRANSITION") if grandparent and is_parallel_state(grandparent): if all( - is_in_final_state(parent_state, configuration) + is_in_final_state(configuration, parent_state) for parent_state in get_child_states(grandparent) ): internal_queue.append(Event(f"done.state.{grandparent.id}")) - - return (configuration, actions, internal_queue) + # transitions.add("TRANSITION") + + # const historyValue = currentState + # ? currentState.historyValue + + # ? currentState.historyValue + + # : stateTransition.source + # ? (this.machine.historyValue(currentState.value) as HistoryValue) + # : undefined + + # : undefined; + hv = ( + current_state.history_value + if current_state and current_state.history_value + else ( + s.machine.root._history_value( + current_state.value + if str(type(current_state)) == "" + else None + ) + if list(enabled_transitions)[0].source + else (None if s else None) + ) + ) + if hv: + history_value = HistoryValue(**hv.__dict__) + return (configuration, actions, internal_queue, transitions, history_value) def exit_states( @@ -306,9 +489,13 @@ def exit_states( history_value: HistoryValue, actions: List[Action], internal_queue: List[Event], + current_state: State, ): states_to_exit = compute_exit_set( - enabled_transitions, configuration=configuration, history_value=history_value + enabled_transitions, + configuration=configuration, + history_value=history_value, + # current_state=current_state, ) for s in states_to_exit: states_to_invoke.discard(s) @@ -335,7 +522,7 @@ def compute_exit_set( ) -> Set[StateNode]: states_to_exit: Set[StateNode] = set() for t in enabled_transitions: - if t.target: + if t.target_consider_history(history_value=history_value): domain = get_transition_domain(t, history_value=history_value) for s in configuration: if is_descendent(s, state2=domain): @@ -366,7 +553,7 @@ def select_transitions(event: Event, configuration: Set[StateNode]): break_loop = True enabled_transitions = remove_conflicting_transitions( - enabled_transitions, configuration=configuration, history_value={} # TODO + enabled_transitions, configuration=configuration, history_value=HistoryValue() ) return enabled_transitions @@ -389,7 +576,7 @@ def select_eventless_transitions(configuration: Set[StateNode]): enabled_transitions = remove_conflicting_transitions( enabled_transitions=enabled_transitions, configuration=configuration, - history_value={}, # TODO + history_value=HistoryValue(), ) return enabled_transitions @@ -433,28 +620,43 @@ def remove_conflicting_transitions( def main_event_loop( - configuration: Set[StateNode], event: Event + configuration: Set[StateNode], event: Event, current_state: State ) -> Tuple[Set[StateNode], List[Action]]: states_to_invoke: Set[StateNode] = set() - history_value = {} + history_value = ( + current_state.history_value if current_state.history_value else HistoryValue() + ) + transitions = set() enabled_transitions = select_transitions(event=event, configuration=configuration) - - (configuration, actions, internal_queue) = microstep( + transitions = transitions.union(enabled_transitions) + (configuration, actions, internal_queue, transitions, history_value) = microstep( enabled_transitions, configuration=configuration, states_to_invoke=states_to_invoke, history_value=history_value, + transitions=transitions, + current_state=current_state, ) - (configuration, actions) = macrostep( - configuration=configuration, actions=actions, internal_queue=internal_queue + (configuration, actions, transitions, history_value) = macrostep( + configuration=configuration, + actions=actions, + internal_queue=internal_queue, + transitions=transitions, + current_state=current_state, + history_value=history_value, ) - return (configuration, actions) + return (configuration, actions, transitions, history_value) def macrostep( - configuration: Set[StateNode], actions: List[Action], internal_queue: List[Event] + configuration: Set[StateNode], + actions: List[Action], + internal_queue: List[Event], + transitions: List[Transition], + current_state: State = None, + history_value: HistoryValue = HistoryValue(), ) -> Tuple[Set[StateNode], List[Action]]: enabled_transitions = set() macrostep_done = False @@ -472,14 +674,22 @@ def macrostep( configuration=configuration, ) if enabled_transitions: - (configuration, actions, internal_queue) = microstep( + ( + configuration, + actions, + internal_queue, + transitions, + history_value, + ) = microstep( enabled_transitions=enabled_transitions, configuration=configuration, - states_to_invoke=set(), # TODO - history_value={}, # TODO + states_to_invoke=set(), + history_value=history_value, + transitions=transitions, + current_state=current_state, ) - return (configuration, actions) + return (configuration, actions, transitions, history_value) def execute_transition_content( @@ -493,17 +703,19 @@ def execute_transition_content( def execute_content(action: Action, actions: List[Action], internal_queue: List[Event]): - if action.type == "xstate:raise": - internal_queue.append(Event(action.data.get("event"))) + if action.type == ActionTypes.Raise or action.type == "xstate:raise": + internal_queue.append(Event(name=action.data.get("event"))) else: actions.append(action) def microstep( enabled_transitions: List[Transition], + transitions: List[Transition], configuration: Set[StateNode], states_to_invoke: Set[StateNode], history_value: HistoryValue, + current_state: State, ) -> Tuple[Set[StateNode], List[Action], List[Event]]: actions: List[Action] = [] internal_queue: List[Event] = [] @@ -515,32 +727,291 @@ def microstep( history_value=history_value, actions=actions, internal_queue=internal_queue, + current_state=current_state, ) execute_transition_content( enabled_transitions, actions=actions, internal_queue=internal_queue ) - enter_states( + _, _, _, _, history_value = enter_states( enabled_transitions, configuration=configuration, states_to_invoke=states_to_invoke, history_value=history_value, actions=actions, internal_queue=internal_queue, + transitions=transitions, + current_state=current_state, ) - return (configuration, actions, internal_queue) + return (configuration, actions, internal_queue, transitions, history_value) + + +def is_machine(value): + try: + return "__xstatenode" in value + except: + return False + + +# export function toGuard( +# condition?: Condition, +# guardMap?: Record> +# ): Guard | undefined { + + +def to_guard(condition: Condition, guardMap: Record) -> Guard: + # if (!condition) { + # return undefined; + # } + if condition == None: + return None + + # if (isString(condition)) { + # return { + # type : DEFAULT_GUARD_TYPE, + # name: condition, + # predicate: guardMap ? guardMap[condition] : undefined + # }; + # } + + if isinstance(condition, str): + return { + "type": DEFAULT_GUARD_TYPE, + "name": condition, + "predicate": guardMap[condition] if guardMap else None, + } + + # if (isFunction(condition)) { + # return { + # type : DEFAULT_GUARD_TYPE, + # name: condition.name, + # predicate: condition + # }; + # } + + if callable(condition): + return { + "type": DEFAULT_GUARD_TYPE, + "name": condition["name"], + "predicate": condition, + } + + # return condition; + return condition + + +def to_array_strict(value: Any) -> List: + if isinstance(value, List): + return value + return [value] + + +# export function toArray(value: T[] | T | undefined): T[] { +# if (value === undefined) { +# return []; +# } +# return toArrayStrict(value); +# } +def to_array(value: Union[str, List, None]) -> List: + if not value: + return [] + + return to_array_strict(value) # =================== +def to_transition_config_array(event, configLike) -> List: + transitions = { + {"target": transition_like, "event": event} + for transition_like in to_array_strict(configLike) + if ( + # isinstance(transition_like,'undefined') or + isinstance(transition_like, str) + or is_machine(transition_like) + ) + } + return transitions + + +# export function normalizeTarget( +# target: SingleOrArray> | undefined +# ): Array> | undefined { +# if (target === undefined || target === TARGETLESS_KEY) { +# return undefined; +# } +# return toArray(target); +# } + + +def normalize_target( + target: Union[List[str], StateNode] +) -> Union[List[str], StateNode]: + + if not target or target == TARGETLESS_KEY: + return None + + return to_array(target) + + +def flatten(t: List) -> List: + return [item for sublist in t for item in sublist] + + +def get_configuration( + prev_state_nodes: Iterable[StateNode], state_nodes: Iterable[StateNode] +) -> Iterable[StateNode]: + + # export function getConfiguration( + # prevStateNodes: Iterable>, + # stateNodes: Iterable> + # ): Iterable> { + + # const prevConfiguration = new Set(prevStateNodes); + # const prevAdjList = getAdjList(prevConfiguration); + prev_configuration = set(prev_state_nodes) + prev_adj_list = get_adj_list(prev_configuration) + + configuration = set(state_nodes) + + # // add all ancestors + # for (const s of configuration) { + # let m = s.parent; + + # while (m && !configuration.has(m)) { + # configuration.add(m); + # m = m.parent; + # } + # } + + # for s in configuration: + current_configuration = configuration.copy() + for s in current_configuration: + m = s.parent + while m and m not in current_configuration: + configuration.add(m) + m = m.parent + + # const adjList = getAdjList(configuration); + adjList = get_adj_list(configuration) + + # // add descendants + # for (const s of configuration) { + current_configuration = configuration.copy() + for s in current_configuration: + + # // if previously active, add existing child nodes + # if (s.type === 'compound' && (!adjList.get(s) || !adjList.get(s)!.length)) { + if s.type == "compound" and (s.id not in adjList or len(adjList[s.id]) == 0): + + # if (prevAdjList.get(s)) { + if prev_adj_list.get(s.id, None): + # prevAdjList.get(s)!.forEach((sn) => configuration.add(sn)); + [configuration.add(sn) for sn in prev_adj_list.get(s.id, None)] + # } else { + else: + # s.initialStateNodes.forEach((sn) => configuration.add(sn)); + [configuration.add(sn) for sn in s.initial_state_nodes] + # } + # } else { + else: + # if (s.type === 'parallel') { + if s.type == "parallel": + # for (const child of getChildren(s)) { + for child in get_children(s): + # if (child.type === 'history') { + if child.type == "history": + # continue; + continue + # } + + # if (!configuration.has(child)) { + if child not in current_configuration: + # configuration.add(child); + configuration.add(child) + + # if (prevAdjList.get(child)) { + if child in prev_adj_list: + # prevAdjList.get(child)!.forEach((sn) => configuration.add(sn)); + [configuration.add(sn) for sn in prev_adj_list[child]] + # } else { + else: + # child.initialStateNodes.forEach((sn) => configuration.add(sn)); + [configuration.add(sn) for sn in child.initial_state_nodes] + # } + # } + # } + # } + # } + # } + + # // add all ancestors + # for (const s of configuration) { + current_configuration = configuration + for s in current_configuration: + + # let m = s.parent; + m = s.parent + + # while (m && !configuration.has(m)) { + # configuration.add(m); + # m = m.parent; + while m and m not in current_configuration: + configuration.add(m) + m = m.parent + + # } + # } + + # return configuration; + return configuration + + +# } + + +def is_possible_js_config_snippet(config: str): + return ( + isinstance(config, str) + and config.lstrip()[0] == "{" + and config.rstrip()[-1] == "}" + ) + + +def get_configuration_from_js(config: str) -> dict: + """Translates a JS config to a xstate_python configuration dict + config: str a valid javascript snippet of an xstate machine + Example + get_configuration_from_js( + config= + ``` + { + a: 'a2', + b: { + b2: { + foo: 'foo2', + bar: 'bar1' + } + } + } + ```) + ) + """ + # return js2py.eval_js(f"config = {config.replace(chr(10),'').replace(' ','')}").to_dict() + return js2py.eval_js(f"config = {config.replace(chr(10),'')}").to_dict() def get_configuration_from_state( from_node: StateNode, - state_value: Union[Dict, str], + state: Union[State, Dict, str], partial_configuration: Set[StateNode], ) -> Set[StateNode]: + # TODO TD: isinstance(state,State) requires import which generates circular dependencie issues + if str(type(state)) == "": + state_value = state.value + else: + state_value = state if isinstance(state_value, str): partial_configuration.add(from_node.states.get(state_value)) else: @@ -574,11 +1045,252 @@ def get_state_value(state_node: StateNode, configuration: Set[StateNode]): return get_value_from_adj(state_node, get_adj_list(configuration)) +def path_to_state_value(state_path: List[str]) -> StateValue: + # export function pathToStateValue(statePath: string[]): StateValue { + # if (statePath.length === 1) { + # return statePath[0]; + # } + if len(state_path) == 1: + return state_path[0] + + # const value = {}; + # let marker = value; + value = {} + marker = value + + # for (let i = 0; i < statePath.length - 1; i++) { + for i, element in enumerate(state_path[:-1]): + # if (i === statePath.length - 2) { + if i == len(state_path) - 2: + # marker[statePath[i]] = statePath[i + 1]; + marker[element] = state_path[i + 1] + # } else { + else: + # marker[statePath[i]] = {}; + marker[element] = {} + # marker = marker[statePath[i]]; + marker = marker[element] + return value + + +def to_state_value( + state_value: Union[StateLike, StateValue, str], + # state_value: Union[StateValue, str], + delimiter, +) -> StateValue: + # export function toStateValue( + # stateValue: StateLike | StateValue | string[], + # delimiter: string + # ): StateValue { + + # if (isStateLike(stateValue)) { + # return stateValue.value; + # } + if is_state_like(state_value): + return state_value.value + # if (isArray(stateValue)) { + # return pathToStateValue(stateValue); + # } + if isinstance(state_value, List): + return path_to_state_value(to_state_value) + + # if (typeof stateValue !== 'string') { + # return stateValue; + # } + if not isinstance(state_value, str): + return state_value + + # for a js config snippet + if isinstance(state_value, str): + # TODO: do we really have to process js snippets + if is_possible_js_config_snippet(state_value): + # state_value = repr(get_configuration_from_js(state_value)) + state_value = get_configuration_from_js(state_value) + return state_value + else: + # const statePath = toStatePath(stateValue as string, delimiter); + state_path = to_state_path(state_value, delimiter) + # return pathToStateValue(statePath); + return path_to_state_value(state_path) + # } + + +def to_state_path(state_id: str, delimiter: str = ".") -> List[str]: + try: + if isinstance(state_id, List): + return state_id + + return state_id.split(delimiter) + except Exception as e: + raise Exception(f"{state_id} is not a valid state path") + + +# export function toStatePaths(stateValue: StateValue | undefined): string[][] { +def to_state_paths(state_value: Union[StateValue, None]) -> List[str]: + + # if (!stateValue) { + # return [[]]; + # } + if state_value is None: + return [[]] + + # if (isString(stateValue)) { + # return [[stateValue]]; + # } + if isinstance(state_value, str): + return [[state_value]] + + # const result = flatten( + # keys(stateValue).map((key) => { + # const subStateValue = stateValue[key]; + + def map_function(key): + # const subStateValue = stateValue[key]; + sub_state_value = state_value[key] + # if ( + # typeof subStateValue !== 'string' && + # (!subStateValue || !Object.keys(subStateValue).length) + # ) { + # return [[key]]; + # } + if not isinstance(sub_state_value, str) and ( + sub_state_value is None or not len(sub_state_value) > 0 + ): + return [[key]] + + # return toStatePaths(stateValue[key]).map((subPath) => { + # return [key].concat(subPath); + # }); + return [[key] + sub_path for sub_path in to_state_paths(state_value[key])] + # }) + # ); + + result = flatten([map_function(key) for key in state_value.keys()]) + + # return result; + return result + # return flatten(result) + # } + + +# export function toEventObject( +# event: Event, +# payload?: EventData +# // id?: TEvent['type'] +# ): TEvent { +def to_event_object(event: Event, **kwargs) -> Event: + + # if (isString(event) || typeof event === 'number') { + # return { type: event, ...payload } as TEvent; + # } + + if isinstance(event, str) or isinstance(event, int): + return {"type": event, **kwargs} + # } + + # return event; + return event + + +# } + +# export function toSCXMLEvent( +# event: Event | SCXML.Event, +# scxmlEvent?: Partial> +# ): SCXML.Event { +def to_scxml_event( + event: Event, +) -> SCXML.Event: + + # if (!isString(event) && '$$type' in event && event.$$type === 'scxml') { + # return event as SCXML.Event; + # } + if not isinstance(event, str) and "__type" in event and event.__type == "scxml": + return event + + # const eventObject = toEventObject(event as Event); + event_object = to_event_object(event) + + # return { + # name: eventObject.type, + # data: eventObject, + # $$type: 'scxml', + # #type: 'external', + # ...scxmlEvent + # }; + return { + "name": event_object["type"].value[0], + "data": event_object, + "__type": "scxml", + "_type": "external", + # ...scxmlEvent + } + + +# } + + +def is_state_like(state: any) -> bool: + return ( + isinstance(state, object) + and " | StateValue | string[], +# delimiter: string +# ): StateValue { +# if (isStateLike(stateValue)) { +# return stateValue.value; +# } + +# if (isArray(stateValue)) { +# return pathToStateValue(stateValue); +# } + +# if (typeof stateValue !== 'string') { +# return stateValue as StateValue; +# } + +# const statePath = toStatePath(stateValue as string, delimiter); + +# return pathToStateValue(statePath); +# } + +# export function pathToStateValue(statePath: string[]): StateValue { +# if (statePath.length === 1) { +# return statePath[0]; +# } + +# const value = {}; +# let marker = value; + +# for (let i = 0; i < statePath.length - 1; i++) { +# if (i === statePath.length - 2) { +# marker[statePath[i]] = statePath[i + 1]; +# } else { +# marker[statePath[i]] = {}; +# marker = marker[statePath[i]]; +# } +# } + +# return value; +# } + + def get_value_from_adj(state_node: StateNode, adj_list: Dict[str, Set[StateNode]]): child_state_nodes = adj_list.get(state_node.id) if is_compound_state(state_node): - child_state_node = list(child_state_nodes)[0] + child_state_node = ( + list(child_state_nodes)[0] if child_state_nodes != set() else None + ) if child_state_node: if is_atomic_state(child_state_node): @@ -592,3 +1304,184 @@ def get_value_from_adj(state_node: StateNode, adj_list: Dict[str, Set[StateNode] state_value[s.key] = get_value_from_adj(s, adj_list) return state_value + + +# export function getValue( +# rootNode: StateNode, +# configuration: Configuration +# ): StateValue { +# const config = getConfiguration([rootNode], configuration); + +# return getValueFromAdj(rootNode, getAdjList(config)); +# } + + +def get_value(root_node: StateNode, configuration: Configuration) -> StateValue: + config = get_configuration([root_node], configuration) + + return get_value_from_adj(root_node, get_adj_list(config)) + + +def map_values(collection: Dict[str, Any], iteratee: Callable): + result = {} + collection_keys = collection.keys() + + for i, key in enumerate(collection_keys): + args = (collection[key], key, collection, i) + result[key] = iteratee(*args) + + return result + + +# export function mapFilterValues( +# collection: { [key: string]: T }, +# iteratee: (item: T, key: string, collection: { [key: string]: T }) => P, +# predicate: (item: T) => boolean +# ): { [key: string]: P } { + + +def map_filter_values( + collection: Dict, + # TODO: define valid types + iteratee: Any, # (item: T, key: string, collection: { [key: string]: T }) => P, + predicate: Any, # (item: T) => boolean +) -> Dict: + + # const result: { [key: string]: P } = {}; + result = {} + + # for (const key of keys(collection)) { + for key, item in collection.items(): + + # const item = collection[key]; + pass + + # if (!predicate(item)) { + # continue; + # } + if not predicate(item): + continue + + # result[key] = iteratee(item, key, collection); + result[key] = iteratee(item, key, collection) + + # return result; + return result + + +# /** +# * Retrieves a value at the given path via the nested accessor prop. +# * @param props The deep path to the prop of the desired value +# */ +# export function nestedPath>( +# props: string[], +# accessorProp: keyof T +# ): (object: T) => T { +def nested_path(props: List[str], accessorProp: str) -> Callable: + # return (object) => { + # let result: T = object; + def func_nested_path(object): + result = object + + for prop in props: + result = ( + result[accessorProp].get(prop, None).__dict__ + if result[accessorProp] + else None + ) + + return result + + return func_nested_path + + +# for (const prop of props) { +# result = result[accessorProp][prop]; +# } + +# return result; +# }; +# } + +# /** +# * Retrieves a value at the given path via the nested accessor prop. +# * @param props The deep path to the prop of the desired value +# */ +# export function nestedPath>( +# props: string[], +# accessorProp: keyof T +# ): (object: T) => T { +# return (object) => { +# let result: T = object; + +# for (const prop of props) { +# result = result[accessorProp][prop]; +# } + +# return result; +# }; +# } + + +# export function updateHistoryStates( +# hist: HistoryValue, +# stateValue: StateValue +# ): Record { +# return mapValues(hist.states, (subHist, key) => { +# if (!subHist) { +# return undefined; +# } +# const subStateValue = +# (isString(stateValue) ? undefined : stateValue[key]) || +# (subHist ? subHist.current : undefined); + +# if (!subStateValue) { +# return undefined; +# } + +# return { +# current: subStateValue, +# states: updateHistoryStates(subHist, subStateValue) +# }; +# }); +# } + + +def update_history_states(hist: HistoryValue, state_value) -> Dict: + def lambda_function(*args): + sub_hist, key = args[0:2] + if not sub_hist: + return None + + sub_state_value = ( + None if isinstance(state_value, str) else (state_value.get(key, None)) + ) or (sub_hist.current if sub_hist else None) + + if not sub_state_value: + return None + + return HistoryValue( + **{ + "current": sub_state_value, + "states": update_history_states(sub_hist, sub_state_value), + } + ) + + return map_values(hist.states, lambda_function) + + +# export function updateHistoryValue( +# hist: HistoryValue, +# stateValue: StateValue +# ): HistoryValue { +# return { +# current: stateValue, +# states: updateHistoryStates(hist, stateValue) +# }; +# } + + +def update_history_value(hist, state_value): + return HistoryValue( + **{"current": state_value, "states": update_history_states(hist, state_value)} + ) diff --git a/xstate/constants.py b/xstate/constants.py new file mode 100644 index 0000000..00771a8 --- /dev/null +++ b/xstate/constants.py @@ -0,0 +1,18 @@ +from __future__ import ( + annotations, +) # PEP 563:__future__.annotations will become the default in Python 3.11 + +# import { ActivityMap, DefaultGuardType } from './types'; + +# export const STATE_DELIMITER = '.'; +STATE_DELIMITER = "." +# export const EMPTY_ACTIVITY_MAP: ActivityMap = {}; + +# TODO TD check implementation +# export const DEFAULT_GUARD_TYPE: DefaultGuardType = 'xstate.guard'; +DEFAULT_GUARD_TYPE = "xstate.guard" + +# export const TARGETLESS_KEY = ''; +TARGETLESS_KEY = "" + +UNSUPPORTED_EVENTS = ["always"] diff --git a/xstate/environment.py b/xstate/environment.py new file mode 100644 index 0000000..75ad0d3 --- /dev/null +++ b/xstate/environment.py @@ -0,0 +1,15 @@ +from __future__ import ( + annotations, +) # PEP 563:__future__.annotations will become the default in Python 3.11 + +import os + +IS_PRODUCTION = True if os.getenv("IS_PRODUCTON", None) else False +WILDCARD = os.getenv("WILDCARD", "*") + +NULL_EVENT = "" +STATE_IDENTIFIER = os.getenv("STATE_IDENTIFIER", "#") + + +def PYTEST_CURRENT_TEST(): + return os.getenv("PYTEST_CURRENT_TEST", False) diff --git a/xstate/event.py b/xstate/event.py index 83ad1cc..dda6080 100644 --- a/xstate/event.py +++ b/xstate/event.py @@ -1,3 +1,6 @@ +from __future__ import ( + annotations, +) # PEP 563:__future__.annotations will become the default in Python 3.11 from typing import Dict, Optional diff --git a/xstate/interpreter.py b/xstate/interpreter.py index 43ad886..eaddea4 100644 --- a/xstate/interpreter.py +++ b/xstate/interpreter.py @@ -1,3 +1,6 @@ +from __future__ import ( + annotations, +) # PEP 563:__future__.annotations will become the default in Python 3.11 from xstate.machine import Machine from xstate.state import State diff --git a/xstate/machine.py b/xstate/machine.py index 50ccf49..0ccaabe 100644 --- a/xstate/machine.py +++ b/xstate/machine.py @@ -1,17 +1,45 @@ -from typing import Dict, List +from __future__ import ( + annotations, +) # PEP 563:__future__.annotations will become the default in Python 3.11 +from typing import TYPE_CHECKING, Dict, List, Union +import logging +from xstate.types import HistoryValue + +logger = logging.getLogger(__name__) + +# from xstate import transition from xstate.algorithm import ( enter_states, + path_to_state_value, get_configuration_from_state, macrostep, main_event_loop, + get_configuration_from_js, + update_history_value, + get_value, ) + +if TYPE_CHECKING: + from xstate.state import State + from xstate.state import StateType + from xstate.event import Event from xstate.state import State from xstate.state_node import StateNode class Machine: + """[summary] + + Raises: + f: [description] + ValueError: [description] + + Returns: + [type]: [description] + """ + id: str root: StateNode _id_map: Dict[str, StateNode] @@ -19,27 +47,144 @@ class Machine: states: Dict[str, StateNode] actions: List[lambda: None] - def __init__(self, config: object, actions={}): - self.id = config["id"] + def __init__(self, config: Union[Dict, str], actions={}): + """[summary] + + Args: + config ( Union[Dict,str]): A machine configuration either str snippet in Javascript or dict + actions (dict, optional): A set of Actions. Defaults to {}. + + Raises: + Exception: Invalid snippet of Javascript for Machine configuration + """ + if isinstance(config, str): + try: + config = get_configuration_from_js(config) + except Exception as e: + raise f"Invalid snippet of Javascript for Machine configuration, Exception:{e}" + + self.id = config.get("id", "(machine)") self._id_map = {} - self.root = StateNode( - config, machine=self, key=config.get("id", "(machine)"), parent=None - ) + self.root = StateNode(config, machine=self, parent=None) self.states = self.root.states self.config = config self.actions = actions - def transition(self, state: State, event: str): + def transition(self, state: StateType, event: str): + + # if (state instanceof State_1.State) { + if isinstance(state, State): + # currentState = + # context === undefined + # ? state + # : this.resolveState(State_1.State.from(state, context)); + # TODO implement context + # currentState = state if context is None else self.resolve_state(State.from(state, context) + current_state = state + elif isinstance(state, dict): + # resolved_context = context if context else self.machine.context + current_state = self.root.resolve_state( + State._from( + state, + # resolved_context, + ) + ) + # else { + else: + # var resolvedStateValue = utils_1.isString(state) + # ? this.resolve(utils_1.pathToStateValue(this.getResolvedPath(state))) + # : this.resolve(state); + resolved_state_value = ( + self.root.resolve( + path_to_state_value(self.root._get_resolved_path(state)) + ) + if isinstance(state, str) + else self.root.resolve(state) + ) + + # var resolvedContext = context !== null && context !== void 0 ? context : this.machine.context; + # currentState = this.resolveState(State_1.State.from(resolvedStateValue, resolvedContext)); + + # TODO implement context + # resolved_context = context if context is not None and && context !== void 0 ? : this.machine.context; + current_state = self.root.resolve_state( + State._from( + state_value=resolved_state_value, + # TODO implement context + context=None, # resolvedContext + ) + ) + configuration = get_configuration_from_state( - from_node=self.root, state_value=state.value, partial_configuration=set() + from_node=self.root, state=current_state, partial_configuration=set() + ) + + possible_transitions = [ + transition + for statenode in configuration + for transition in statenode.transitions + ] + (configuration, _actions, transitions, history_value) = main_event_loop( + configuration, Event(event), current_state ) - (configuration, _actions) = main_event_loop(configuration, Event(event)) actions, warnings = self._get_actions(_actions) for w in warnings: - print(w) + logger.warning(w) + + # const willTransition = + # !currentState || stateTransition.transitions.length > 0; + # const resolvedStateValue = willTransition + # ? getValue(this.machine, configuration) + # : undefined; + + will_transition = not current_state or len(transitions) > 0 + resolved_state_value = ( + get_value(self.root, configuration) if will_transition else None + ) + assert ( + len(transitions) >= 1 + ), f"found {len(transitions)} transitions: {transitions}" - return State(configuration=configuration, context={}, actions=actions) + next_state = State( + configuration=configuration, + context={}, + actions=actions, + transitions=transitions, + value=resolved_state_value, + # historyValue: resolvedStateValue + # ? historyValue + # ? updateHistoryValue(historyValue, resolvedStateValue) + # : undefined + # : currentState + # ? currentState.historyValue + # : undefined, + # history: + # !resolvedStateValue || stateTransition.source + # ? currentState + # : undefined, ) + history_value=( + ( + update_history_value(history_value, resolved_state_value) + if history_value + else None + ) + if resolved_state_value + else (current_state.history_value if current_state else None) + ), + history=( + current_state + if (not resolved_state_value or list(transitions)[0].source) + else None + ), + ) + # Dispose of penultimate histories to prevent memory leaks + if ( + next_state.history + and str(type(next_state.history)) == "" + ): + next_state.history.history = None + return next_state def _get_actions(self, actions) -> List[lambda: None]: result = [] @@ -49,6 +194,8 @@ def _get_actions(self, actions) -> List[lambda: None]: result.append(self.actions[action.type]) elif callable(action.type): result.append(action.type) + elif callable(action.exec): + result.append(action.exec) else: errors.append("No '{}' action".format(action.type)) return result, errors @@ -91,21 +238,39 @@ def _get_configuration(self, state_value, parent=None) -> List[StateNode]: @property def initial_state(self) -> State: - (configuration, _actions, internal_queue) = enter_states( - [self.root.initial], + ( + configuration, + _actions, + internal_queue, + transitions, + history_value, + ) = enter_states( + enabled_transitions=[self.root.initial_transition], configuration=set(), states_to_invoke=set(), - history_value={}, + history_value=HistoryValue(), actions=[], internal_queue=[], + transitions=[], + current_state=None, ) - (configuration, _actions) = macrostep( - configuration=configuration, actions=_actions, internal_queue=internal_queue + (configuration, _actions, transitions, history_value) = macrostep( + configuration=configuration, + actions=_actions, + internal_queue=internal_queue, + transitions=transitions, + history_value=history_value, ) actions, warnings = self._get_actions(_actions) for w in warnings: print(w) - return State(configuration=configuration, context={}, actions=actions) + return State( + configuration=configuration, + context={}, + actions=actions, + transitions=transitions, + history_value=history_value, + ) diff --git a/xstate/scxml.py b/xstate/scxml.py index 3724068..94b5004 100644 --- a/xstate/scxml.py +++ b/xstate/scxml.py @@ -1,3 +1,6 @@ +from __future__ import ( + annotations, +) # PEP 563:__future__.annotations will become the default in Python 3.11 import xml.etree.ElementTree as ET from typing import Optional diff --git a/xstate/state.py b/xstate/state.py index ffcdd1c..aaf462c 100644 --- a/xstate/state.py +++ b/xstate/state.py @@ -1,10 +1,29 @@ -from typing import TYPE_CHECKING, Any, Dict, List, Set +from __future__ import ( + annotations, +) # PEP 563:__future__.annotations will become the default in Python 3.11 +import logging -from xstate.algorithm import get_state_value +logger = logging.getLogger(__name__) + +from typing import TYPE_CHECKING, Any, Dict, List, Set, Union +from xstate.transition import Transition + +# from xstate.algorithm import get_state_value +import xstate.algorithm as algorithm +from xstate.action import init_event +from contextvars import Context +from dataclasses import dataclass if TYPE_CHECKING: from xstate.action import Action from xstate.state_node import StateNode + from xstate.types import ( + StateValue, + SCXML, + ) + + +from anytree import Node, RenderTree, LevelOrderIter class State: @@ -12,24 +31,214 @@ class State: value: str context: Dict[str, Any] actions: List["Action"] + # TODO WIP (history) - fix types + history_value: List[ + Any + ] # List["History"] #The tree representing historical values of the state nodes + history: Any # State #The previous state + transitions: List["Transition"] def __init__( self, configuration: Set["StateNode"], - context: Dict[str, Any], + context: Dict[str, Any] = {}, actions: List["Action"] = [], + **kwargs, ): - root = next(iter(configuration)).machine.root + # root = next(iter(configuration)).machine.root self.configuration = configuration - self.value = get_state_value(root, configuration) + if kwargs.get("value", None) is None: + if self.configuration == []: + msg = f"A valid configuration must exist to facilitate state: obtaining state as no value given" + logger.error(msg) + raise Exception(msg) + else: + self.value = algorithm.get_state_value( + next(iter(configuration)).machine.root, configuration + ) + + else: + self.value = kwargs.get("value", None) self.context = context self.actions = actions + self.history_value = kwargs.get("history_value", None) + self.history = kwargs.get("history", None) + self.transitions = kwargs.get("transitions", []) + + # TODO: __repr__ and __str__ should be swapped, __repr__ should be able to instantiate an instance + # def __repr__(self): + # return repr( + # { + # "value": self.value, + # "context": self.context, + # "actions": self.actions, + # } + # ) def __repr__(self): - return repr( + return "" % repr( { "value": self.value, "context": self.context, "actions": self.actions, } ) + + def __str__(self): + # configuration is a set, we need an algorithm to walk the set and produce an ordered list + # Why: [state.id for state in self.configuration] # produces unsorted with machine prefix `['test_states.two.deep', 'test_states.two', 'test_states.two.deep.foo']` + + # build dict of child:parent + relationships = { + state.id: state.parent.id if state.parent else None + for state in self.configuration + } + # find root node, ie has parent and without a '.' in the parent id + roots = [ + (child, parent) + for child, parent in relationships.items() + if parent and "." not in parent + ] + assert ( + len(roots) == 1 + ), "Invalid Root, can only be 1 root and must be at least 1 root" + + relatives = { + child: parent + for child, parent in relationships.items() + if parent and "." in parent + } + child, parent = roots[0] + root_node = Node(parent) + added = {} + added[child] = Node(child, parent=root_node) + while relatives: + for child, parent in relatives.items(): + if parent in added: + + added[child] = Node(child, parent=added[parent]) + relatives.pop(child) + break + # Should have no parent as None, because we have already determined the root + # TODO: possible should change to this as more general + # elif parent is None: + # tree.create_node(key, key) + # added.add(key) + # relatives.pop(key) + # break + # Render in ascii the tree + # for pre, fill, node in RenderTree(root_node): + # print("%s%s" % (pre, node.name)) + + states_ordered = [node.name for node in LevelOrderIter(root_node)] + root_state = states_ordered[0] + "." + # ignore the root state + states_ordered = [state.replace(root_state, "") for state in states_ordered[1:]] + return repr(states_ordered) + + # return f"""{self.__class__.__name__}(configuration={''}, context={self.context} , actions={self.actions})""" + + # public static from( + # stateValue: State | StateValue, + # context?: TC | undefined + # ): State { + def _from(state_value: Union[State, StateValue], context: Any = None) -> State: + """Creates a new State instance for the given `stateValue` and `context`. + + Args: + state_value (Union[State, StateValue]): [description] + context (Any): [description] + + Returns: + State: [description] + """ + # /** + # * Creates a new State instance for the given `stateValue` and `context`. + # * @param stateValue + # * @param context + # */ + + # if (stateValue instanceof State) { + if isinstance(state_value, State): + # if (stateValue.context !== context) { + if state_value.context != context: + # return new State({ + # value: stateValue.value, + # context: context as TC, + # _event: stateValue._event, + # _sessionid: null, + # historyValue: stateValue.historyValue, + # history: stateValue.history, + # actions: [], + # activities: stateValue.activities, + # meta: {}, + # events: [], + # configuration: [], // TODO: fix, + # transitions: [], + # children: {} + # }); + # } + return State( + **{ + "value": state_value.value, + "context": context, + "_event": state_value._event, + "_sessionid": None, + "historyValue": state_value.history_value, + "history": state_value.history, + "actions": [], + "activities": state_value.activities, + "meta": {}, + "events": [], + "configuration": [], # TODO: fix, ( oriiginal comment in JS) look into thoughts in JS + "transitions": [], + "children": {}, + } + ) + # } + + # return stateValue; + return state_value + # } + + # const _event = initEvent as SCXML.Event; + # TODO capture SCXML.Event + _event: SCXML.Event = init_event + + # return new State({ + # value: stateValue, + # context: context as TC, + # _event, + # _sessionid: null, + # historyValue: undefined, + # history: undefined, + # actions: [], + # activities: undefined, + # meta: undefined, + # events: [], + # configuration: [], + # transitions: [], + # children: {} + return State( + **{ + "value": state_value, + "context": context, + "_event": None, + "_sessionid": None, + "historyValue": None, + "history": None, + "actions": [], + "activities": None, + "meta": None, + "events": [], + "configuration": [], + "transitions": [], + "children": {}, + } + ) + + # } + + +# StateType = Union[str, State] +# StateValue = str diff --git a/xstate/state_node.py b/xstate/state_node.py index 87da4c3..afd41fd 100644 --- a/xstate/state_node.py +++ b/xstate/state_node.py @@ -1,17 +1,101 @@ -from typing import TYPE_CHECKING, Dict, List, Optional, Union +from __future__ import ( + annotations, +) # PEP 563:__future__.annotations will become the default in Python 3.11 +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union +import logging -from xstate.action import Action +logger = logging.getLogger(__name__) +from functools import reduce + +from xstate import transition + +from xstate.constants import ( + STATE_DELIMITER, + TARGETLESS_KEY, + UNSUPPORTED_EVENTS, +) +from xstate.types import ( + TransitionConfig, + TransitionDefinition, + HistoryValue, + HistoryStateNodeConfig, +) # , StateLike + +from xstate.action import Action, to_action_objects, to_action_object from xstate.transition import Transition +from xstate.algorithm import ( + to_transition_config_array, + to_state_path, + path_to_state_value, + flatten, + map_values, + map_filter_values, + nested_path, + normalize_target, + to_array_strict, + to_state_value, + to_state_paths, + is_state_id, + is_machine, + is_leaf_node, + is_in_final_state, + to_array, + to_guard, + done, + get_configuration, +) + +from xstate.action import done_invoke, to_action_objects + +from xstate.environment import ( + IS_PRODUCTION, + WILDCARD, + STATE_IDENTIFIER, + NULL_EVENT, + PYTEST_CURRENT_TEST, +) if TYPE_CHECKING: from xstate.machine import Machine + from xstate.types import State, StateValue, StateLike, EMPTY_OBJECT + + +from xstate.state import State + + +# TODO TD implement __cache possibly in dataclass +# private __cache = { +# events: undefined as Array | undefined, +# relativeValue: new Map() as Map, StateValue>, +# initialStateValue: undefined as StateValue | undefined, +# initialState: undefined as State | undefined, +# on: undefined as TransitionDefinitionMap | undefined, +# transitions: undefined as +# | Array> +# | undefined, +# candidates: {} as { +# [K in TEvent['type'] | NullEvent['type'] | '*']: +# | Array< +# TransitionDefinition< +# TContext, +# K extends TEvent['type'] +# ? Extract +# : EventObject +# > +# > +# | undefined; +# }, +# delayedTransitions: undefined as +# | Array> +# | undefined +# }; class StateNode: on: Dict[str, List[Transition]] machine: "Machine" parent: Optional["StateNode"] - initial: Optional[Transition] + initial: Union[StateValue, str] entry: List[Action] exit: List[Action] donedata: Optional[Dict] @@ -19,28 +103,239 @@ class StateNode: transitions: List[Transition] id: str key: str + path: List[str] = [] states: Dict[str, "StateNode"] + delimiter: str = STATE_DELIMITER + __cache: Any # TODO TD see above JS and TODO for implement __cache def get_actions(self, action): - if callable(action): - return Action(action) - else: - return Action(action.get("type"), exec=None, data=action) + """get_actions ( requires migration to newer implementation""" + logger.warning( + "The function `get_actions` in `StateNode` , requires migration to newer `xstate.core` implementation" + ) + return to_action_object(action, action_function_map=None) + + # TODO migrate get_actions to current xstate.core equivilance + + # private getActions( + # transition: StateTransition, + # currentContext: TContext, + # _event: SCXML.Event, + # prevState?: State + # ): Array> { + # const prevConfig = getConfiguration( + # [], + # prevState ? this.getStateNodes(prevState.value) : [this] + # ); + # const resolvedConfig = transition.configuration.length + # ? getConfiguration(prevConfig, transition.configuration) + # : prevConfig; + + # for (const sn of resolvedConfig) { + # if (!has(prevConfig, sn)) { + # transition.entrySet.push(sn); + # } + # } + # for (const sn of prevConfig) { + # if (!has(resolvedConfig, sn) || has(transition.exitSet, sn.parent)) { + # transition.exitSet.push(sn); + # } + # } + + # if (!transition.source) { + # transition.exitSet = []; + + # // Ensure that root StateNode (machine) is entered + # transition.entrySet.push(this); + # } + + # const doneEvents = flatten( + # transition.entrySet.map((sn) => { + # const events: DoneEventObject[] = []; + + # if (sn.type !== 'final') { + # return events; + # } + + # const parent = sn.parent!; + + # if (!parent.parent) { + # return events; + # } + + # events.push( + # done(sn.id, sn.doneData), // TODO: deprecate - final states should not emit done events for their own state. + # done( + # parent.id, + # sn.doneData + # ? mapContext(sn.doneData, currentContext, _event) + # : undefined + # ) + # ); + + # const grandparent = parent.parent!; + + # if (grandparent.type === 'parallel') { + # if ( + # getChildren(grandparent).every((parentNode) => + # isInFinalState(transition.configuration, parentNode) + # ) + # ) { + # events.push(done(grandparent.id)); + # } + # } + + # return events; + # }) + # ); + + # transition.exitSet.sort((a, b) => b.order - a.order); + # transition.entrySet.sort((a, b) => a.order - b.order); + + # const entryStates = new Set(transition.entrySet); + # const exitStates = new Set(transition.exitSet); + + # const [entryActions, exitActions] = [ + # flatten( + # Array.from(entryStates).map((stateNode) => { + # return [ + # ...stateNode.activities.map((activity) => start(activity)), + # ...stateNode.onEntry + # ]; + # }) + # ).concat(doneEvents.map(raise) as Array>), + # flatten( + # Array.from(exitStates).map((stateNode) => [ + # ...stateNode.onExit, + # ...stateNode.activities.map((activity) => stop(activity)) + # ]) + # ) + # ]; + + # const actions = toActionObjects( + # exitActions.concat(transition.actions).concat(entryActions), + # this.machine.options.actions + # ) as Array>; + + # return actions; + # } + + # def __init__( + # self, + # # { "type": "compound", "states": { ... } } + # config, + # machine: "Machine", + # parent: Union["StateNode", "Machine"] = None, + # key: str = None, + + # ): + # self.config = config + # self.parent = parent + # self.id = ( + # config.get("id", parent.id + (("." + key) if key else "")) + # if parent + # else config.get("id", machine.id + (("." + key) if key else "")) + # ) + # self.entry = ( + # [self.get_actions(entry_action) for entry_action in config.get("entry")] + # if config.get("entry") + # else [] + # ) + + # self.exit = ( + # [self.get_actions(exit_action) for exit_action in config.get("exit")] + # if config.get("exit") + # else [] + # ) + + # self.key = key + # self.states = { + # k: StateNode(v, machine=machine, parent=self, key=k) + # for k, v in config.get("states", {}).items() + # } + # self.on = {} + # self.transitions = [] + # for k, v in config.get("on", {}).items(): + # self.on[k] = [] + # transition_configs = v if isinstance(v, list) else [v] + + # for transition_config in transition_configs: + # transition = Transition( + # transition_config, + # source=self, + # event=k, + # order=len(self.transitions), + # ) + # self.on[k].append(transition) + # self.transitions.append(transition) + + # self.type = config.get("type") + + # if self.type is None: + # self.type = "atomic" if not self.states else "compound" + + # if self.type == "final": + # self.donedata = config.get("data") + + # if config.get("onDone"): + # done_event = f"done.state.{self.id}" + # done_transition = Transition( + # config.get("onDone"), + # source=self, + # event=done_event, + # order=len(self.transitions), + # ) + # self.on[done_event] = done_transition + # self.transitions.append(done_transition) + + # machine._register(self) + + # @property + # def initial(self): + # initial_key = self.config.get("initial") + + # if not initial_key: + # if self.type == "compound": + # return Transition( + # next(iter(self.states.values())), source=self, event=None, order=-1 + # ) + # else: + # return Transition( + # self.states.get(initial_key), source=self, event=None, order=-1 + # ) + + # def _get_relative(self, target: str) -> "StateNode": + # if target.startswith("#"): + # return self.machine._get_by_id(target[1:]) + + # state_node = self.parent.states.get(target) + + # if not state_node: + # raise ValueError( + # f"Relative state node '{target}' does not exist on state node '#{self.id}'" # noqa + # ) + + # return state_node + # def __init__( self, # { "type": "compound", "states": { ... } } config, machine: "Machine", - key: str, - parent: Union["StateNode", "Machine"] = None, + parent: Union["StateNode", "Machine", None] = None, + key: str = None, ): self.config = config + # TODO: validate this change, initial was showing up as an event, but xstate.core has it initialized to config.initial + # {'event': None, 'source': 'd4', 'target': ['#e3'], 'cond': None, 'actions': [], 'type': 'external', 'order': -1} + self.history = self.config.get("history", None) + self.initial = self.config.get("initial", None) self.parent = parent self.id = ( - config.get("id", parent.id + "." + key) + config.get("id", parent.id + (("." + key) if key else "")) if parent - else config.get("id", machine.id + "." + key) + else config.get("id", machine.id + (("." + key) if key else "")) ) self.entry = ( [self.get_actions(entry_action) for entry_action in config.get("entry")] @@ -54,13 +349,30 @@ def __init__( else [] ) - self.key = key + self.key = ( + key + or self.config.get("key", None) + # or self.options._key or + or self.config.get("id", None) + or "(machine)" + ) + self.path = self.parent.path + [self.key] if self.parent else [] self.states = { k: StateNode(v, machine=machine, parent=self, key=k) for k, v in config.get("states", {}).items() } self.on = {} self.transitions = [] + + # TODO: add support for events such as `always` + unsupported_events = set(UNSUPPORTED_EVENTS).intersection(set(config.keys())) + if unsupported_events != set(): + msg = f"XState, unsupported Event/s:{unsupported_events} found in config for StateNode ID:{self.id}" + logger.error(msg) + # TODO remove `True` PYTEST_CURRENT_TEST() not always working + if IS_PRODUCTION or PYTEST_CURRENT_TEST() or True: + raise Exception(msg) + for k, v in config.get("on", {}).items(): self.on[k] = [] transition_configs = v if isinstance(v, list) else [v] @@ -74,8 +386,8 @@ def __init__( ) self.on[k].append(transition) self.transitions.append(transition) - - self.type = config.get("type") + # handle the case where a history node often does not have a type specified + self.type = config.get("type") if not self.history else "history" if self.type is None: self.type = "atomic" if not self.states else "compound" @@ -96,8 +408,228 @@ def __init__( machine._register(self) + # public resolve(stateValue: StateValue): StateValue { + def resolve(self, state_value: StateValue) -> StateValue: + """Resolves a partial state value with its full representation in this machine. + + Args: + state_value (StateValue): The partial state value to resolve. + + Raises: + Exception: [description] + Exception: [description] + ValueError: [description] + Exception: [description] + Exception: [description] + Exception: [description] + Exception: [description] + + Returns: + StateValue: Full representation of a state in this machine. + """ + # if (!stateValue) { + # return this.initialStateValue || EMPTY_OBJECT; // TODO: type-specific properties + # } + + if state_value is None: + return ( + self.initial_state_value or EMPTY_OBJECT + ) # // TODO: type-specific properties + + # switch (this.type) { + # case 'parallel': + if self.type == "parallel": + # return map_values( + # this.initialStateValue as Record, + # self.initial_state_value, + # (subStateValue, subStateKey) => { + # return subStateValue + # ? this.getStateNode(subStateKey).resolve( + # stateValue[subStateKey] || subStateValue + # ) + # : EMPTY_OBJECT; + # } + # ); + + def func1(sub_state_value, sub_state_key, *args): + return ( + self.get_state_node(sub_state_key).resolve( + state_value.get(sub_state_key, sub_state_value) + ) + if (sub_state_value is not None) + else EMPTY_OBJECT + ) + + return map_values(self.initial_state_value, func1) + + # case 'compound': + elif self.type == "compound": + # if (isString(stateValue)) { + # const subStateNode = this.getStateNode(stateValue); + + if isinstance(state_value, str): + sub_state_node = self.get_state_node(state_value) + # if ( + # subStateNode.type === 'parallel' || + # subStateNode.type === 'compound' + # ) { + # return { [stateValue]: subStateNode.initialStateValue! }; + # } + + # return stateValue; + # } + + if ( + sub_state_node.type == "parallel" + or sub_state_node.type == "compound" + ): + return {state_value: sub_state_node.initial_state_value} + + return state_value + + # if (!keys(stateValue).length) { + # return this.initialStateValue || {}; + # } + + if len(state_value.keys()) == 0: + return self.initial_state_value if self.initial_state_value else {} + + # return mapValues(stateValue, (subStateValue, subStateKey) => { + # return subStateValue + # ? this.getStateNode(subStateKey as string).resolve(subStateValue) + # : EMPTY_OBJECT; + # }); + def func(*args): + sub_state_value, sub_state_key = args[0:2] + return ( + self.get_state_node(sub_state_key).resolve(sub_state_value) + if (sub_state_value is not None) + else EMPTY_OBJECT + ) + + return map_values(state_value, func) + + # default: + # return stateValue || EMPTY_OBJECT; + else: + return state_value if state_value is not None else EMPTY_OBJECT + # } + # } + + # } + + # } + + # public resolveState( + # state: State + # ): State { + + def resolve_state(self, state: State) -> State: + """Resolves the given `state` to a new `State` instance relative to this machine. + + This ensures that `.events` and `.nextEvents` represent the correct values. + + Args: + state (State): The state to resolve + + Returns: + State: a new `State` instance relative to this machine + """ + + # const configuration = Array.from( + # getConfiguration([], this.getStateNodes(state.value)) + # ); + + configuration = list(get_configuration([], self.get_state_nodes(state.value))) + + # return new State({ + # ...state, + # value: this.resolve(state.value), + # configuration, + # done: isInFinalState(configuration, this) + # }); + # } + return State( + **{ + **vars(state), + **{ + "value": self.resolve(state.value), + "configuration": configuration, + "done": is_in_final_state(configuration, self), + }, + } + ) + # } + + # StateNode.prototype.getStateNodeById = function (stateId) { + def get_state_node_by_id(self, state_id: str): + """Returns the state node with the given `state_id`, or raises exception. + + Args: + state_id (str): The state ID. The prefix "#" is removed. + + Raises: + Exception: [description] + + Returns: + StateNode: the state node with the given `state_id`, or raises exception. + + """ + # TODO: P1, History, what happpens if this node is type 'history` , `resolve_history` should be called just as in + # var resolvedStateId = isStateId(stateId) ? stateId.slice(STATE_IDENTIFIER.length) : stateId; + resolved_state_id = ( + state_id[len(STATE_IDENTIFIER) :] if is_state_id(state_id) else state_id + ) + + # if (resolvedStateId === this.id) { + # return this; + # } + if resolved_state_id == self.id: + return self + + # var stateNode = this.machine.idMap[resolvedStateId]; + state_node = self.machine._id_map[resolved_state_id] + + # if (!stateNode) { + # throw new Error("Child state node '#" + resolvedStateId + "' does not exist on machine '" + this.id + "'"); + # } + if not state_node: + msg = f"Child state node '#{resolved_state_id}' does not exist on machine '{self.id}'" + logger.error(msg) + raise Exception(msg) + + # return stateNode; + return state_node + + # }; + + # private getResolvedPath(stateIdentifier: string): string[] { + def _get_resolved_path(self, state_identifier: str) -> List[str]: + # if (isStateId(stateIdentifier)) { + if is_state_id(state_identifier): + # const stateNode = this.machine.idMap[ + # stateIdentifier.slice(STATE_IDENTIFIER.length) + # ]; + state_node = self.machine.id_map[state_identifier[len(STATE_IDENTIFIER) :]] + + if state_node is None: + # throw new Error(`Unable to find state node '${stateIdentifier}'`); + msg = f"Unable to find state node '{state_identifier}'" + logger.error(msg) + raise Exception(msg) + # return stateNode.path; + return state_node.path + # return toStatePath(stateIdentifier, this.delimiter); + return to_state_path(state_identifier, self.delimiter) + + # } + + # private resolveTarget( + # _target: Array> | undefined + # ): Array> | undefined { + @property - def initial(self): + def initial_transition(self): initial_key = self.config.get("initial") if not initial_key: @@ -110,12 +642,351 @@ def initial(self): self.states.get(initial_key), source=self, event=None, order=-1 ) - def _get_relative(self, target: str) -> "StateNode": + # public get initialStateNodes(): Array> { + @property + def initial_state_nodes(self) -> List[StateNode]: + + # if (isLeafNode(this)) { + # return [this]; + # } + if is_leaf_node(self): + return [self] + + # // Case when state node is compound but no initial state is defined + # if (this.type === 'compound' && !this.initial) { + # if (!IS_PRODUCTION) { + # warn(false, `Compound state node '${this.id}' has no initial state.`); + # } + # return [this]; + # } + + # Case when state node is compound but no initial state is defined + if self.type == "compound" and not self.initial: + if not IS_PRODUCTION: + logger.warning( + f"Compound state node '${self.id}' has no initial state." + ) + return [self] + + # const initialStateNodePaths = toStatePaths(this.initialStateValue!); + # return flatten( + # initialStateNodePaths.map((initialPath) => + # this.getFromRelativePath(initialPath) + # ) + # ); + # } + + initial_state_node_paths = to_state_paths(self.initial_state_value) + return flatten( + [ + self.get_from_relative_path(initial_path) + for initial_path in initial_state_node_paths + ] + ) + + # private get initialStateValue(): StateValue | undefined { + @property + def initial_state_value(self) -> Union[StateValue, None]: + # if (this.__cache.initialStateValue) { + # return this.__cache.initialStateValue; + # } + + # TODO: implement cache + # if self.__cache.initial_state_value is not None: + # return self.__cache.initial_state_value + + # let initialStateValue: StateValue | undefined; + initial_state_value: Union[StateValue, None] = None + + # if (this.type === 'parallel') { + # initialStateValue = mapFilterValues( + # this.states as Record>, + # (state) => state.initialStateValue || EMPTY_OBJECT, + # (stateNode) => !(stateNode.type === 'history') + # ); + if self.type == "parallel": + + initial_state_value = { + key: state.initial_state_value + if state.initial_state_value is not None + else EMPTY_OBJECT + for key, state in self.states.items() + # for state in ( + # self.states + # if not isinstance(self.states, dict) + # else [state_node for key, state_node in self.states.items()] + # ) + if state.type != "history" + } + + # } else if (this.initial !== undefined) { + # if (!this.states[this.initial]) { + # throw new Error( + # `Initial state '${this.initial}' not found on '${this.key}'` + # ); + # } + elif self.initial is not None: + if self.states.get(self.initial, None) is None: + msg = f"Initial state '{self.initial}' not found on '{self.key}'" + logger.error(msg) + raise Exception(msg) + # initialStateValue = (isLeafNode(this.states[this.initial]) + # ? this.initial + # : { + # [this.initial]: this.states[this.initial].initialStateValue + # }) as StateValue; + initial_state_value = ( + self.initial + if is_leaf_node(self.states[self.initial]) + else {self.initial: self.states[self.initial].initial_state_value} + ) # StateValue + + # } else { + # // The finite state value of a machine without child states is just an empty object + # initialStateValue = {}; + # } + else: + # The finite state value of a machine without child states is just an empty object + initial_state_value = {} + + # this.__cache.initialStateValue = initialStateValue; + # TODO TD implement cache + # self.__cache.initial_state_value = initial_state_value + + # return this.__cache.initialStateValue; + # TODO TD implement cache + # return self.__cache.initial_state_value + return initial_state_value + # } + + def _history_value( + self, relative_state_value: Union[StateValue, None] = None + ) -> Union[HistoryValue, None]: + + # private historyValue( + # relativeStateValue?: StateValue | undefined + # ): HistoryValue | undefined { + + # if (!keys(this.states).length) { + # return undefined; + # } + + if len(self.states.keys()) == 0: + return None + + # return { + # current: relativeStateValue || this.initialStateValue, + # states: mapFilterValues< + # StateNode, + # HistoryValue | undefined + # >( + + def f_iteratee(state_node: StateNode, key: str, collection: Dict = None): + if not relative_state_value: + return state_node._history_value() + + sub_state_value = ( + None + if isinstance(relative_state_value, str) + else relative_state_value.get(key, None) + ) + return state_node._history_value( + sub_state_value or state_node.initial_state_value + ) + + def f_predicate(state_node: StateNode): + return state_node.history == None + + return HistoryValue( + **{ + "current": relative_state_value or self.initial_state_value, + "states": map_filter_values( + # this.states, + collection=self.states, + iteratee=f_iteratee, + predicate=f_predicate, + ), + } + ) + # (stateNode, key) => { + # if (!relativeStateValue) { + # return stateNode.historyValue(); + # } + + # const subStateValue = isString(relativeStateValue) + # ? undefined + # : relativeStateValue[key]; + + # return stateNode.historyValue( + # subStateValue || stateNode.initialStateValue + # ); + # }, + # (stateNode) => !stateNode.history + # ) + # }; + # } + + @property + def target(self) -> Union[StateValue, None]: + """The target state value of the history state node, if it exists. This represents the + default state value to transition to if no history value exists yet. + + Returns: + Union[ StateValue , None ]: The target state value of the history state node + """ + + # /** + # * The target state value of the history state node, if it exists. This represents the + # * default state value to transition to if no history value exists yet. + # */ + # public get target(): StateValue | undefined { + + # let target; + target = None + # if (this.type === 'history') { + if self.type == "history": + # const historyConfig = this.config as HistoryStateNodeConfig< + # TContext, + # TEvent + # >; + history_config = HistoryStateNodeConfig( + **self.config + ) # as HistoryStateNodeConfig< + # if (isString(historyConfig.target)) { + if isinstance(history_config.target, str): + # target = isStateId(historyConfig.target) + # ? pathToStateValue( + # this.machine + # .getStateNodeById(historyConfig.target) + # .path.slice(this.path.length - 1) + # ) + # : historyConfig.target; + target = ( + path_to_state_value( + ( + self.machine.root.get_state_node_by_id( + history_config.target + ).path[(self.path.length - 1)] + ) + ) + if is_state_id(history_config.target) + else history_config.target + ) + # } else { + else: + target = history_config.target + + # return target; + return target + # } + + def resolve_history(self, history_value: HistoryValue = None) -> List[StateNode]: + """Resolves to the historical value(s) of the parent state node, + represented by state nodes. + + Args: + history_value (HistoryValue): the value to resolve + + Returns: + List[StateNode]: historical value(s) of the parent state node + """ + + # /** + # * Resolves to the historical value(s) of the parent state node, + # * represented by state nodes. + # * + # * @param historyValue + # */ + # private resolveHistory( + # historyValue?: HistoryValue + # ): Array> { + # if (this.type !== 'history') { + # return [this]; + # } + + # const parent = this.parent!; + parent = self.parent # .copy() + + # if (!historyValue) { + # const historyTarget = this.target; + # return historyTarget + # ? flatten( + # toStatePaths(historyTarget).map((relativeChildPath) => + # parent.getFromRelativePath(relativeChildPath) + # ) + # ) + # : parent.initialStateNodes; + # } + if not history_value or history_value == HistoryValue(): + history_target = self.target + return ( + flatten( + [ + parent.get_from_relative_path(relative_child_path) + for relative_child_path in to_state_paths(history_target) + ] + ) + if history_target + else parent.initial_state_nodes + ) + + # const subHistoryValue = nestedPath( + # parent.path, + # 'states' + # )(historyValue).current; + sub_history_object = nested_path(parent.path, "states")(history_value.__dict__) + sub_history_value = ( + sub_history_object.get("current", None) if sub_history_object else None + ) + + # if (isString(subHistoryValue)) { + # return [parent.getStateNode(subHistoryValue)]; + # } + + if isinstance(sub_history_value, str): + return [parent.get_state_node(sub_history_value)] + + # return flatten( + # toStatePaths(subHistoryValue!).map((subStatePath) => { + # return this.history === 'deep' + # ? parent.getFromRelativePath(subStatePath) + # : [parent.states[subStatePath[0]]]; + # }) + # ); + # } + + return flatten( + [ + parent.get_from_relative_path(sub_state_path) + if self.history == "deep" + else [parent.states[sub_state_path[0]]] + for sub_state_path in to_state_paths(sub_history_value) + ] + ) + + def _get_relative( + self, target: str, history_value: HistoryValue = None + ) -> "StateNode": if target.startswith("#"): + # TODO: P1, History, handle `history_value` for id, construct a test case return self.machine._get_by_id(target[1:]) - state_node = self.parent.states.get(target) + # state_node = self.parent.states.get(target) + target_path = to_state_path(target) + if target_path[0] == "": # a relative path to self + state_node = self.get_from_relative_path( + to_state_path(target)[1:], history_value + ) + else: # presume relative to parent + state_node = self.parent.get_from_relative_path( + to_state_path(target), history_value + ) + # TODO: assume only 1 item in list + assert ( + len(state_node) == 1 + ), f"Not handling {len(state_node)} targets matching, {target}" + state_node = state_node[0] if not state_node: raise ValueError( f"Relative state node '{target}' does not exist on state node '#{self.id}'" # noqa @@ -123,5 +994,762 @@ def _get_relative(self, target: str) -> "StateNode": return state_node + # /** + # * Retrieves state nodes from a relative path to this state node. + # * + # * @param relativePath The relative path from this state node + # * @param historyValue + # */ + # public getFromRelativePath( + # relativePath: string[] + # ): Array> { + + def get_relative_state_nodes( + self, + relative_state_id: StateNode, + history_value: HistoryValue = None, + resolve: bool = True, + ) -> List[StateNode]: + """Returns the leaf nodes from a state path relative to this state node. + + Args: + relative_state_id (StateNode): The relative state path to retrieve the state nodes + history_value (HistoryValue, optional):The previous state to retrieve history. Defaults to None. + resolve (bool, optional): Whether state nodes should resolve to initial child state nodes. Defaults to True. + + Raises: + Exception: [description] + Exception: [description] + Exception: [description] + Exception: [description] + + Returns: + List[StateNode]: leaf nodes from a state path relative to this state node + """ + # /** + # * Returns the leaf nodes from a state path relative to this state node. + # * + # * @param relativeStateId The relative state path to retrieve the state nodes + # * @param history The previous state to retrieve history + # * @param resolve Whether state nodes should resolve to initial child state nodes + # */ + # public getRelativeStateNodes( + # relativeStateId: StateNode, + # historyValue?: HistoryValue, + # resolve: boolean = true + # ): Array> { + + # return resolve + # ? relativeStateId.type === 'history' + # ? relativeStateId.resolveHistory(historyValue) + # : relativeStateId.initialStateNodes + # : [relativeStateId]; + # } + + return ( + ( + relative_state_id.resolve_history(history_value) + if relative_state_id.type == "history" + else relative_state_id.initial_state_nodes + ) + if resolve + else [relative_state_id] + ) + + def get_from_relative_path( + self, relative_path: Union[str, List(str)], history_value: HistoryValue = None + ) -> List[StateNode]: + + # if (!relativePath.length) { + # return [this]; + # } + if isinstance(relative_path, List) and len(relative_path) == 0: + return [self] + + # const [stateKey, ...childStatePath] = relativePath; + if is_state_id(relative_path[0]): + state_key = STATE_DELIMITER.join(relative_path) + child_state_path = [] + else: + state_key, *child_state_path = relative_path + + # if (!this.states) { + # throw new Error( + # `Cannot retrieve subPath '${stateKey}' from node with no states` + # ); + # } + if self.states is None: + msg = f"Cannot retrieve subPath '{state_key}' from node with no states" + logger.error(msg) + raise Exception(msg) + + # const childStateNode = this.getStateNode(stateKey); + if is_state_id(state_key): + child_state_node = self.get_state_node_by_id(state_key) + # state_key = state_key.split(STATE_IDENTIFIER)[1] + else: + child_state_node = self.get_state_node(state_key) + + # if (childStateNode.type === 'history') { + # return childStateNode.resolveHistory(); + # } + if child_state_node.type == "history" and history_value: + return child_state_node.resolve_history(history_value) + + # if (!this.states[stateKey]) { + # throw new Error( + # `Child state '${stateKey}' does not exist on '${this.id}'` + # ); + # } + + if ( + self.get_state_node_by_id(state_key) + if is_state_id(state_key) + else self.states.get(state_key, None) + ) is None: + msg = f"Child state '{state_key}' does not exist on '{self.id}'" + logger.error(msg) + raise Exception(msg) + + # return this.states[stateKey].getFromRelativePath(childStatePath); + if is_state_id(state_key): + return self.get_state_node_by_id(state_key).get_from_relative_path( + child_state_path, history_value + ) + else: + return self.states[state_key].get_from_relative_path( + child_state_path, history_value + ) + + def get_state_node( + self, state_key: str, history_value: HistoryValue = None + ) -> StateNode: + # public getStateNode( + # stateKey: string + # ): StateNode { + # /** + # * Returns the child state node from its relative `stateKey`, or throws. + # */ + """Returns the child state node from its relative `stateKey`, or raises exception. + + Args: + state_key (str): [description] + + Returns: + StateNode: Returns the child state node from its relative `stateKey`, or throws. + """ + if history_value is None: + # TODO P1, fully implement what may be required to handle history, check all calls to get_state_node + logger.warning( + f"WIP implementing history - get_state_node is not handling history:{history_value}" + ) + # if (isStateId(stateKey)) { + if is_state_id(state_key): + return self.machine.root.get_state_node_by_id(state_key) + # } + + # if (!this.states) { + if not self.states: + # throw new Error( + # `Unable to retrieve child state '${stateKey}' from '${this.id}'; no child states exist.` + # ); + msg = f"Unable to retrieve child state '{state_key}' from '{self.id}'; no child states exist." + logger.error(msg) + raise Exception(msg) + + # const result = this.states[stateKey]; + result = self.states.get(state_key, None) + # if (!result) { + if not result: + # throw new Error( + # `Child state '${stateKey}' does not exist on '${this.id}'` + # ); + msg = f"Child state '{state_key}' does not exist on '{self.id}'" + logger.error(msg) + raise Exception(msg) + + # return result; + return result + + def get_state_nodes(self, state: StateValue) -> List[StateNode]: + # public getStateNodes( + # state: StateValue | State + # ): Array> { + # /** + # * Returns the state nodes represented by the current state value. + # * + # * @param state The state value or State instance + # */ + """Returns the state nodes represented by the current state value. + + Args: + state (StateValue): The state value or State instance + + Raises: + Exception: [description] + + Returns: + List[StateNode]: Returns the state nodes represented by the current state value. + """ + # if (!state) { + # return []; + # } + if state is None: + return [] + + # const stateValue = + # state instanceof State + # ? state.value + # : toStateValue(state, this.delimiter); + state_value = ( + state.value + if isinstance(state, State) + else to_state_value(state, self.delimiter) + ) + + # if (isString(stateValue)) { + if isinstance(state_value, str): + + # const initialStateValue = this.getStateNode(stateValue).initial; + initial_state_value = self.get_state_node(state_value).initial + + # return initialStateValue !== undefined + # ? this.getStateNodes({ [stateValue]: initialStateValue } as StateValue) + # : [this, this.states[stateValue]]; + # } + # TODO: TEST Check this - no unit test currently produces `initial_state_value is not None` + return ( + self.get_state_nodes({[state_value]: initial_state_value}) + if initial_state_value + else [self, self.states[state_value]] + ) + + # const subStateKeys = keys(stateValue); + sub_state_keys = state_value.keys() + + # const subStateNodes: Array< + # StateNode + # > = subStateKeys.map((subStateKey) => this.getStateNode(subStateKey)); + sub_state_nodes: List[StateNode] = [ + self.get_state_node(sub_state_key) for sub_state_key in sub_state_keys + ] + + # subStateNodes.push(this); + sub_state_nodes.append(self) + + # return subStateNodes.concat( + + # def full_union(input): + # """ Compute the union of a list of sets """ + # return reduce(set.union, input[1:], input[0]) + + # result = full_union(L) + + # subStateKeys.reduce((allSubStateNodes, subStateKey) => + # { + # const subStateNode = this.getStateNode(subStateKey).getStateNodes( + # stateValue[subStateKey] + # ); + def reduce_fx(all_sub_state_nodes, sub_state_key): + sub_state_node = self.get_state_node(sub_state_key).get_state_nodes( + state_value[sub_state_key] + ) + all_sub_state_nodes.extend(sub_state_node if sub_state_node else []) + return all_sub_state_nodes + + def substate_node_reduce(sub_state_keys): + initial_list = [] + result_list = reduce(reduce_fx, sub_state_keys, initial_list) + return result_list + + # }, [] as Array>) + # ); + reduce_results = substate_node_reduce(sub_state_keys) + sub_state_nodes.extend(reduce_results) + return sub_state_nodes + + # /** + # * Returns `true` if this state node explicitly handles the given event. + # * + # * @param event The event in question + # */ + # public handles(event: Event): boolean { + # const eventType = getEventType(event); + + # return this.events.includes(eventType); + # } + + # const validateArrayifiedTransitions = ( + # stateNode: StateNode, + # event: string, + # transitions: Array< + # TransitionConfig & { + # event: string; + # } + # > + # ) => { + def validate_arrayified_transitions( + state_node: StateNode, + event: str, + transitions: List[TransitionConfig], + # TContext, EventObject> & { + # event: string; + # } + # > + ): + + # const hasNonLastUnguardedTarget = transitions + # .slice(0, -1) + # .some( + # (transition) => + # !('cond' in transition) && + # !('in' in transition) && + # (isString(transition.target) || isMachine(transition.target)) + # ); + has_non_last_unguarded_target = any( + [ + ( + "cond" not in transition + and "in" not in transition + and ( + isinstance(transition.target, str) + or is_machine(transition.target) + ) + ) + for transition in transitions[0:-1] + ] + ) + + # .slice(0, -1) + # .some( + # (transition) => + # !('cond' in transition) && + # !('in' in transition) && + # (isString(transition.target) || isMachine(transition.target)) + # ); + + # const eventText = + # event === NULL_EVENT ? 'the transient event' : `event '${event}'`; + eventText = "the transient event" if event == NULL_EVENT else f"event '{event}'" + + # warn( + # !hasNonLastUnguardedTarget, + # `One or more transitions for ${eventText} on state '${stateNode.id}' are unreachable. ` + + # `Make sure that the default transition is the last one defined.` + # ); + if not has_non_last_unguarded_target: + logger.warning( + ( + f"One or more transitions for {eventText} on state '{state_node.id}' are unreachable. " + f"Make sure that the default transition is the last one defined." + ) + ) + + # /** + # * Returns the relative state node from the given `statePath`, or throws. + # * + # * @param statePath The string or string array relative path to the state node. + # */ + # public getStateNodeByPath( + # statePath: string | string[] + # ): StateNode { + + def get_state_node_by_path(self, state_path: str) -> StateNode: + """Returns the relative state node from the given `statePath`, or throws. + + Args: + statePath (string):The string or string array relative path to the state node. + + Raises: + Exception: [??????] + + Returns: + StateNode: the relative state node from the given `statePath`, or throws. + """ + + # if (typeof statePath === 'string' && isStateId(statePath)) { + # try { + # return this.getStateNodeById(statePath.slice(1)); + # } catch (e) { + # // try individual paths + # // throw e; + # } + # } + + if isinstance(state_path, str) and is_state_id(state_path): + try: + return self.get_state_node_by_id(state_path[1:].copy()) + except Exception as e: + # // try individual paths + # // throw e; + pass + + # const arrayStatePath = toStatePath(statePath, this.delimiter).slice(); + array_state_path = to_state_path(state_path, self.delimiter)[:].copy() + # let currentStateNode: StateNode = this; + current_state_node = self + + # while (arrayStatePath.length) { + while len(array_state_path) > 0: + # const key = arrayStatePath.shift()!; + key = array_state_path.pop() + # if (!key.length) { + # break; + # } + + if len(key) == 0: + break + + # currentStateNode = currentStateNode.getStateNode(key); + current_state_node = current_state_node.get_state_node(key) + + # return currentStateNode; + return current_state_node + + # private resolveTarget( + # _target: Array> | undefined + # ): Array> | undefined { + + def resolve_target(_target: List[StateNode]) -> List[StateNode]: + + # if (_target === undefined) { + # // an undefined target signals that the state node should not transition from that state when receiving that event + # return undefined; + # } + if not _target: + # an undefined target signals that the state node should not transition from that state when receiving that event + return None + + # return _target.map((target) => + def function(self, target): + # if (!isString(target)) { + # return target; + # } + if isinstance(target, str): + return target + + # const isInternalTarget = target[0] === this.delimiter; + is_internal_target = target[0] == self.delimiter + + # // If internal target is defined on machine, + # // do not include machine key on target + # if (isInternalTarget && !this.parent) { + # return this.getStateNodeByPath(target.slice(1)); + # } + # If internal target is defined on machine, + # do not include machine key on target + if is_internal_target and not self.parent: + return self.get_state_node_by_path(target.slice(1)) + + # const resolvedTarget = isInternalTarget ? this.key + target : target; + resolved_target = self.key + target if is_internal_target else target + + # if (this.parent) { + # try { + # const targetStateNode = this.parent.getStateNodeByPath( + # resolvedTarget + # ); + # return targetStateNode; + # } catch (err) { + # throw new Error( + # `Invalid transition definition for state node '${this.id}':\n${err.message}` + # ); + # } + + if self.parent: + try: + target_state_node = self.parent.get_state_node_by_path( + resolved_target + ) + return target_state_node + except Exception as e: + msg = f"Invalid transition definition for state node '{self.id}':\n{e}" + logger.error(msg) + raise Exception(msg) + # } else { + # return this.getStateNodeByPath(resolvedTarget); + else: + return self.get_state_node_by_path(resolved_target) + + # private formatTransition( + # transitionConfig: TransitionConfig & { + # event: TEvent['type'] | NullEvent['type'] | '*'; + # } + # ): TransitionDefinition { + + def format_transition( + self, transition_config: TransitionConfig + ) -> TransitionDefinition: + + # const normalizedTarget = normalizeTarget(transitionConfig.target); + normalized_target = normalize_target(transition_config["target"]) + + # const internal = + # 'internal' in transitionConfig + # ? transitionConfig.internal + # : normalizedTarget + # ? normalizedTarget.some( + # (_target) => isString(_target) && _target[0] === this.delimiter + # ) + # : true; + internal = ( + transition_config["internal"] + if "internal" in transition_config + else any( + isinstance(_target, str) and _target[0] == self.delimiter + for _target in normalized_target + ) + if normalized_target + else True + ) + + # const { guards } = this.machine.options; + guards = self.machine.options["guards"] + # const target = this.resolveTarget(normalizedTarget); + target = self.resolve_target(normalized_target) + + # const transition = { + # ...transitionConfig, + # actions: toActionObjects(toArray(transitionConfig.actions)), + # cond: toGuard(transitionConfig.cond, guards), + # target, + # source: this as any, + # internal, + # eventType: transitionConfig.event, + # toJSON: () => ({ + # ...transition, + # target: transition.target + # ? transition.target.map((t) => `#${t.id}`) + # : undefined, + # source: `#${this.id}` + # }) + # }; + transition = { + **transition_config, + **{ + "actions": to_action_objects(to_array(transition_config["actions"])), + "cond": to_guard(transition_config.cond, guards), + **target, + "source": self, + **internal, + "eventType": transition_config.event, + "toJSON": None, + }, + } + transition = { + **transition, + **{ + "target": ["#{t.id}" for t in transition.target] + if transition.target + else None, + "source": "#{self.id}", + }, + } + + # return transition; + return transition + + def format_transitions(self) -> List: + # StateNode.prototype.formatTransitions = function () { + # var e_9, _a; + + # var _this = this; + _self = self + + onConfig = None + + # if (!this.config.on) { + # onConfig = []; + if "on" not in self.config: + onConfig = {} + + # } else if (Array.isArray(this.config.on)) { + # onConfig = this.config.on; + elif isinstance(self.config, dict): + onConfig = self.config["on"] + # } else { + else: + # TODO: TD implement WILDCARD + # var _b = this.config.on, + # _c = WILDCARD, + # _d = _b[_c], + # wildcardConfigs = _d === void 0 ? [] : _d, + wildcard_configs = [] # Workaround for #TODO: TD implement WILDCARD + # strictTransitionConfigs_1 = _tslib.__rest(_b, [typeof _c === "symbol" ? _c : _c + ""]); + # strictTransitionConfigs_1 = _tslib.__rest(_b, [typeof _c === "symbol" ? _c : _c + ""]) + strict_transition_configs_1 = self.config["on"] + + # if (!environment.IS_PRODUCTION && key === NULL_EVENT) { + def function(key): + # function (key) { + # if (!environment.IS_PRODUCTION && key === NULL_EVENT) { + # utils.warn(false, "Empty string transition configs (e.g., `{ on: { '': ... }}`) for transient transitions are deprecated. Specify the transition in the `{ always: ... }` property instead. " + ("Please check the `on` configuration for \"#" + _this.id + "\".")); + # } + + # var transitionConfigArray = utils.toTransitionConfigArray(key, strictTransitionConfigs_1[key]); + + # if (!environment.IS_PRODUCTION) { + # validateArrayifiedTransitions(_this, key, transitionConfigArray); + # } + + # return transitionConfigArray; + # } if IS_PRODUCTION and not key: + logger.warning( + ( + f"Empty string transition configs (e.g., `{{ on: {{ '': ... }}}}`) for transient transitions are deprecated. " + f"Specify the transition in the `{{ always: ... }}` property instead. " + f'Please check the `on` configuration for "#{self.id}".' + ) + ) + transition_config_array = to_transition_config_array( + key, strict_transition_configs_1[key] + ) + + if not IS_PRODUCTION: + self.validate_arrayified_transitions( + self, key, transition_config_array + ) + + return transition_config_array + + # onConfig = utils.flatten(utils.keys(strictTransitionConfigs_1).map( + # ).concat(utils.toTransitionConfigArray(WILDCARD, wildcardConfigs))); + # } + + on_config = flatten( + [function(key) for key in strict_transition_configs_1.keys()].append( + to_transition_config_array(WILDCARD, wildcard_configs) + ) + ) + + # var eventlessConfig = this.config.always ? utils.toTransitionConfigArray('', this.config.always) : []; + eventless_config = ( + to_transition_config_array("", self.config["always"]) + if "always" in self.config + else [] + ) + + # var doneConfig = this.config.onDone ? utils.toTransitionConfigArray(String(actions.done(this.id)), this.config.onDone) : []; + done_config = ( + to_transition_config_array(str(done(self.id)), self.config["onDone"]) + if "onDone" in self.config + else [] + ) + + # if (!environment.IS_PRODUCTION) { + # utils.warn(!(this.config.onDone && !this.parent), "Root nodes cannot have an \".onDone\" transition. Please check the config of \"" + this.id + "\"."); + # } + if IS_PRODUCTION and not ("onDone" in self.config and not self.parent): + + logger.warning( + f'Root nodes cannot have an ".onDone" transition. Please check the config of "{self.id}".' + ) + + def function(invoke_def): + # const settleTransitions: any[] = []; + settle_transitions = [] + # if (invokeDef.onDone) { + if "onDone" in invoke_def: + # settleTransitions.push( + # ...toTransitionConfigArray( + # String(doneInvoke(invokeDef.id)), + # invokeDef.onDone + # ) + # ); + # } + + settle_transitions.append( + to_transition_config_array( + str(done_invoke(invoke_def.id)), invoke_def["onDone"] + ) + ) + # if (invokeDef.onError) { + if "onError" in invoke_def: + # settleTransitions.push( + # ...toTransitionConfigArray( + # String(error(invokeDef.id)), + # invokeDef.onError + # ) + # ); + # } + settle_transitions.append( + to_transition_config_array( + str(done_invoke(invoke_def.id)), invoke_def["onError"] + ) + ) + # return settleTransitions; + return settle_transitions + + # const invokeConfig = flatten( + # this.invoke.map((invokeDef) => { + invoke_config = flatten([function(element) for element in self.invoke]) + + # var delayedTransitions = this.after; + delayed_transitions = self.after + + # const formattedTransitions = flatten( + # [...doneConfig, ...invokeConfig, ...onConfig, ...eventlessConfig].map( + formatted_transitions = flatten( + [ + # toArray(transitionConfig).map((transition) => + # this.formatTransition(transition) + self.format_transition(transition) + for transition in [ + transition_config + # ( + # transitionConfig: TransitionConfig & { + # event: TEvent['type'] | NullEvent['type'] | '*'; + # } + # ) => + for transition_config in [ + done_config, + invoke_config, + on_config, + eventless_config, + ] + ] + ] + ) + + # for (const delayedTransition of delayedTransitions) { + # formattedTransitions.push(delayedTransition as any); + # } + for delayed_transition in delayed_transitions: + formatted_transitions.append(delayed_transition) + + [ + formatted_transitions.append(delayed_transition) + for delayed_transition in delayed_transitions + ] + + return formatted_transitions + + # Object.defineProperty(StateNode.prototype, "transitions", { + # TODO: this clashes with the attribute `transition` so relabelled to `get_transitions` need to understand this property in relation to the attribute, how does format_transitions work + @property + def get_transitions(self) -> List: + # /** + # * All the transitions that can be taken from this state node. + # */ + # get: function () { + # return this.__cache.transitions || (this.__cache.transitions = this.formatTransitions(), this.__cache.transitions); + # }, + if not self.__cache.transitions: + self.__cache.transitions = self.format_transitions() + return self.__cache.transitions + + # enumerable: false, + # configurable: true + # }); + + # TODO: __repr__ and __str__ should be swapped, __repr__ should be able to instantiate an instance + # def __repr__(self) -> str: + # return "" % repr({"id": self.id}) def __repr__(self) -> str: - return "" % repr({"id": self.id}) + return "" % repr({"id": self.id, "parent": self.parent}) + + def __str__(self) -> str: + return ( + f"""{self.__class__.__name__}(config={''}, """ + f"""machine={self.machine}, id={self.id}, parent={self.parent})""" + ) diff --git a/xstate/transition.py b/xstate/transition.py index 45fa354..db5e395 100644 --- a/xstate/transition.py +++ b/xstate/transition.py @@ -1,9 +1,17 @@ +from __future__ import ( + annotations, +) # PEP 563:__future__.annotations will become the default in Python 3.11 from typing import TYPE_CHECKING, Any, Callable, List, NamedTuple, Optional, Union -from xstate.action import Action +import xstate.algorithm as algorithm + + +from xstate.action import to_action_objects from xstate.event import Event +from xstate.types import HistoryValue, StateValue if TYPE_CHECKING: + from xstate.action import Action from xstate.state_node import StateNode CondFunction = Callable[[Any, Event], bool] @@ -15,6 +23,7 @@ class TransitionConfig(NamedTuple): class Transition: event: str + # _event: SCXML.Event #TODO Implement source: "StateNode" config: Union[str, "StateNode", TransitionConfig] actions: List[Action] @@ -31,7 +40,19 @@ def __init__( order: int, cond: Optional[CondFunction] = None, ): + if ( + # Test for possible snippet of JS being the configuration + isinstance(config, str) + and config.lstrip()[0] == "{" + and config.rstrip()[-1] == "}" + ): + try: + config = algorithm.get_configuration_from_js(config) + except Exception as e: + raise f"Invalid snippet of Javascript for Machine configuration, Exception:{e}" + self.event = event + # self._event = utils.toSCXMLEvent(event) #TODO Implement self.config = config self.source = source self.type = "external" @@ -39,20 +60,20 @@ def __init__( self.order = order self.actions = ( - ( - [ - Action(type=action.get("type"), data=action) - for action in config.get("actions", []) - ] - ) + to_action_objects(config.get("actions", []), action_function_map=None) if isinstance(config, dict) else [] ) @property def target(self) -> List["StateNode"]: - if isinstance(self.config, str): - return [self.source._get_relative(self.config)] + if isinstance(self.config, str) and not algorithm.is_state_id(self.config): + return self.source.parent.get_from_relative_path( + algorithm.to_state_path(self.config) + ) + # return [self.source._get_relative(self.config)] + elif isinstance(self.config, str) and algorithm.is_state_id(self.config): + return [self.source.machine.root.get_state_node(self.config)] elif isinstance(self.config, dict): if isinstance(self.config["target"], str): return [self.source._get_relative(self.config["target"])] @@ -61,6 +82,33 @@ def target(self) -> List["StateNode"]: else: return [self.config] if self.config else [] + def target_consider_history(self, history_value: HistoryValue) -> List["StateNode"]: + # TODO: P2, would be desirable to amalgate `target` and `target_consider_history` however the history_value would need to be an attribute of `transition` or possibly `source` ? + # if isinstance(self.config, str): + # return self.source.parent.get_from_relative_path( + # algorithm.to_state_path(self.config), current_state.history_value + # ) + if isinstance(self.config, str) and not algorithm.is_state_id(self.config): + return self.source.parent.get_from_relative_path( + algorithm.to_state_path(self.config), history_value + ) + # return [self.source._get_relative(self.config)] + elif isinstance(self.config, str) and algorithm.is_state_id(self.config): + return [self.source.machine.root.get_state_node(self.config, history_value)] + elif isinstance(self.config, dict): + if isinstance(self.config["target"], str): + return [self.source._get_relative(self.config["target"], history_value)] + + return [ + self.source._get_relative( + v, + history_value, + ) + for v in self.config["target"] + ] + else: + return [self.config] if self.config else [] + def __repr__(self) -> str: return repr( { diff --git a/xstate/types.py b/xstate/types.py new file mode 100644 index 0000000..995e268 --- /dev/null +++ b/xstate/types.py @@ -0,0 +1,1816 @@ +from __future__ import annotations +from contextvars import Context +from typing import ( + TYPE_CHECKING, + Callable, + Iterable, + TypeVar, + Generic, + Union, + Dict, + Any, + List, + Callable, +) + +# T = TypeVar('T') + +from enum import Enum +from dataclasses import dataclass + +if TYPE_CHECKING: + from xstate.action import Action + from xstate.state import State + + # from xstate.state_node import StateNode + from xstate.types import StateValueMap + + +# TODO: TD workarounds for Circular refs in State and StateNode +# from xstate.state import State +State = Any +# from xstate.state_node import StateNode +StateNode = Any + +EMPTY_OBJECT = {} +""" +//from: xstate/packages/core/src/types.ts + +import { StateNode } from './StateNode'; +import { State } from './State'; +import { Interpreter, Clock } from './interpreter'; +import { Model } from './model.types'; + +type AnyFunction = (...args: any[]) => any; +type ReturnTypeOrValue = T extends AnyFunction ? ReturnType : T; +""" +Record = Dict +# export type EventType = string; +EventType = str +# export type ActionType = string; +ActionType = str +# export type MetaObject = Record; +MetaObject = Record[str, Any] + +# /** +# * The full definition of an event, with a string `type`. +# */ +# export interface EventObject { +# /** +# * The type of event that is sent. +# */ +# type : string; +# } + +# type Configuration = Iterable< +# StateNode +# >; +Configuration = Iterable[StateNode] + +# type AdjList = Map< +# StateNode, +# Array> +# >; +# TODO: TD python equivilance for Map rather than List +AdjList = Dict # List[StateNode] + + +@dataclass +class EventObject: + """The full definition of an event, with a string `type`. + + Args: + type (str): The type of event that is sent. + + """ + + type: str = "" + + def update(self, new): + for key, value in new.items(): + if hasattr(self, key): + setattr(self, key, value) + return self + + +""" +export interface AnyEventObject extends EventObject { + [key: string]: any; +} +""" + +# export interface BaseActionObject { +# /** +# * The type of action that is executed. +# */ +# type : string; +# [other: string]: any; +# } +@dataclass +class BaseActionObject: + type: str + other: Any + + +# /** +# * The full definition of an action, with a string `type` and an +# * `exec` implementation function. +# */ + +# export interface ActionObject +# extends BaseActionObject { +# /** +# * The implementation for executing the action. +# */ +# exec?: ActionFunction; +# } + + +@dataclass +class ActionObject(BaseActionObject): + exec: ActionFunction + + +""" +export type DefaultContext = Record | undefined; + +export type EventData = Record & { type?: never }; + +/** + * The specified string event types or the specified event objects. + */ +export type Event = TEvent['type'] | TEvent; + +export interface ActionMeta< + TContext, + TEvent extends EventObject, + TAction extends ActionObject = ActionObject< + TContext, + TEvent + > +> extends StateMeta { + action: TAction; + _event: SCXML.Event; +} + +export interface AssignMeta { + state?: State; + action: AssignAction; + _event: SCXML.Event; +} +""" +# export type ActionFunction< +# TContext, +# TEvent extends EventObject, +# TAction extends ActionObject = ActionObject< +# TContext, +# TEvent +# > +# > = ( +# context: TContext, +# event: TEvent, +# meta: ActionMeta +# ) => void; + +# TODO: implement properly +ActionFunction = Callable + +""" +export interface ChooseConditon { + cond?: Condition; + actions: Actions; +} +""" +# export type Action = +# | ActionType +# | ActionObject +# | ActionFunction; +Action = Union[ActionFunction, ActionObject] +""" +/** + * Extracts action objects that have no extra properties. + */ +type SimpleActionsOf = ActionObject< + any, + any +> extends T + ? T // If actions are unspecified, all action types are allowed (unsafe) + : ExtractWithSimpleSupport; + +/** + * Events that do not require payload + */ +export type SimpleEventsOf< + TEvent extends EventObject +> = ExtractWithSimpleSupport; + +export type BaseAction< + TContext, + TEvent extends EventObject, + TAction extends BaseActionObject +> = + | SimpleActionsOf['type'] + | TAction + | RaiseAction + | SendAction + | AssignAction + | LogAction + | CancelAction + | StopAction + | ChooseAction + | ActionFunction; + +export type BaseActions< + TContext, + TEvent extends EventObject, + TAction extends BaseActionObject +> = SingleOrArray>; +""" +# export type Actions = SingleOrArray< +# Action +# >; +Actions = Union[Action, List[Action]] + +# export type StateKey = string | State; +StateKey = Union[str, State] + + +""" +/** + * The string or object representing the state value relative to the parent state node. + * + * - For a child atomic state node, this is a string, e.g., `"pending"`. + * - For complex state nodes, this is an object, e.g., `{ success: "someChildState" }`. + */ +""" +# export interface StateValueMap { +# [key: string]: StateValue; +# } +# StateValueMap = Dict[str,StateValue] # TODO TD Workaround for circular StateValue with StateValueMap +StateValueMap = Dict[str, Any] + + +# export type StateValue = string | StateValueMap; +StateValue = Union[str, StateValueMap] + + +""" +type KeysWithStates< + TStates extends Record | undefined +> = TStates extends object + ? { + [K in keyof TStates]-?: TStates[K] extends { states: object } ? K : never; + }[keyof TStates] + : never; + +export type ExtractStateValue< + TSchema extends Required, 'states'>> +> = + | keyof TSchema['states'] + | (KeysWithStates extends never + ? never + : { + [K in KeysWithStates]?: ExtractStateValue< + TSchema['states'][K] + >; + }); +""" + +# export interface HistoryValue { +# states: Record; +# current: StateValue | undefined; +# } +@dataclass +class HistoryValue(EventObject): + states: Union[ + Record, HistoryValue, None + ] = None # ; + current: Union[StateValue, None] = None + + +""" +export type ConditionPredicate = ( + context: TContext, + event: TEvent, + meta: GuardMeta +) => boolean; + +export type DefaultGuardType = 'xstate.guard'; + +export interface GuardPredicate { + type: DefaultGuardType; + name: string | undefined; + predicate: ConditionPredicate; +} + +export type Guard = + | GuardPredicate + | (Record & { + type: string; + }); + +export interface GuardMeta + extends StateMeta { + cond: Guard; +} + +export type Condition = + | string + | ConditionPredicate + | Guard; + """ +Condition = EventObject +""" +export type TransitionTarget< + TContext, + TEvent extends EventObject +> = SingleOrArray>; +""" +TransitionTarget = EventObject + +""" +export type TransitionTargets = Array< + string | StateNode +>; + +export interface TransitionConfig { + cond?: Condition; + actions?: Actions; + in?: StateValue; + internal?: boolean; + target?: TransitionTarget; + meta?: Record; +} +""" + + +@dataclass +class TransitionConfig(EventObject): + cond: Condition = None + actions: Actions = None + _in: StateValue = None + internal: bool = None + target: TransitionTarget = None + meta: Record = None + + +""" +export interface TargetTransitionConfig + extends TransitionConfig { + target: TransitionTarget; // TODO: just make this non-optional +} + +export type ConditionalTransitionConfig< + TContext, + TEvent extends EventObject = EventObject +> = Array>; + +export type Transition = + | string + | TransitionConfig + | ConditionalTransitionConfig; + +export type DisposeActivityFunction = () => void; + +export type ActivityConfig = ( + ctx: TContext, + activity: ActivityDefinition +) => DisposeActivityFunction | void; + +export type Activity = + | string + | ActivityDefinition; + +export interface ActivityDefinition + extends ActionObject { + id: string; + type: string; +} + +export type Sender = (event: Event) => void; + +type ExcludeType = { [K in Exclude]: A[K] }; + +type ExtractExtraParameters = A extends { type: T } + ? ExcludeType + : never; + +type ExtractWithSimpleSupport = T extends any + ? { type: T['type'] } extends T + ? T + : never + : never; + +type NeverIfEmpty = {} extends T ? never : T; + +export interface PayloadSender { + /** + * Send an event object or just the event type, if the event has no other payload + */ + (event: TEvent | ExtractWithSimpleSupport['type']): void; + /** + * Send an event type and its payload + */ + ( + eventType: K, + payload: NeverIfEmpty> + ): void; +} + +export type Receiver = ( + listener: (event: TEvent) => void +) => void; + +export type InvokeCallback< + TEvent extends EventObject = AnyEventObject, + TSentEvent extends EventObject = AnyEventObject +> = ( + callback: Sender, + onReceive: Receiver +) => (() => void) | Promise | void; + +export interface InvokeMeta { + data: any; + src: InvokeSourceDefinition; +} + +/** + * Returns either a Promises or a callback handler (for streams of events) given the + * machine's current `context` and `event` that invoked the service. + * + * For Promises, the only events emitted to the parent will be: + * - `done.invoke.` with the `data` containing the resolved payload when the promise resolves, or: + * - `error.platform.` with the `data` containing the caught error, and `src` containing the service `id`. + * + * For callback handlers, the `callback` will be provided, which will send events to the parent service. + * + * @param context The current machine `context` + * @param event The event that invoked the service + */ +export type InvokeCreator< + TContext, + TEvent extends EventObject, + TFinalContext = any +> = ( + context: TContext, + event: TEvent, + meta: InvokeMeta +) => + | PromiseLike + | StateMachine + | Subscribable + | InvokeCallback + | Behavior; + +export interface InvokeDefinition + extends ActivityDefinition { + /** + * The source of the machine to be invoked, or the machine itself. + */ + src: string | InvokeSourceDefinition; // TODO: deprecate string (breaking change for V4) + /** + * If `true`, events sent to the parent service will be forwarded to the invoked service. + * + * Default: `false` + */ + autoForward?: boolean; + /** + * @deprecated + * + * Use `autoForward` property instead of `forward`. Support for `forward` will get removed in the future. + */ + forward?: boolean; + /** + * Data from the parent machine's context to set as the (partial or full) context + * for the invoked child machine. + * + * Data should be mapped to match the child machine's context shape. + */ + data?: Mapper | PropertyMapper; +} + +export interface Delay { + id: string; + /** + * The time to delay the event, in milliseconds. + */ + delay: number; +} + +export type DelayedTransitions = + | Record< + string | number, + string | SingleOrArray> + > + | Array< + TransitionConfig & { + delay: number | string | Expr; + } + >; + +export type StateTypes = + | 'atomic' + | 'compound' + | 'parallel' + | 'final' + | 'history' + | string; // TODO: remove once TS fixes this type-widening issue + +export type SingleOrArray = T[] | T; + +export type StateNodesConfig< + TContext, + TStateSchema extends StateSchema, + TEvent extends EventObject +> = { + [K in keyof TStateSchema['states']]: StateNode< + TContext, + TStateSchema['states'][K], + TEvent + >; +}; + +export type StatesConfig< + TContext, + TStateSchema extends StateSchema, + TEvent extends EventObject, + TAction extends BaseActionObject = BaseActionObject +> = { + [K in keyof TStateSchema['states']]: StateNodeConfig< + TContext, + TStateSchema['states'][K], + TEvent, + TAction + >; +}; + +export type StatesDefinition< + TContext, + TStateSchema extends StateSchema, + TEvent extends EventObject +> = { + [K in keyof TStateSchema['states']]: StateNodeDefinition< + TContext, + TStateSchema['states'][K], + TEvent + >; +}; + +export type TransitionConfigTarget = + | string + | undefined + | StateNode; + +export type TransitionConfigOrTarget< + TContext, + TEvent extends EventObject +> = SingleOrArray< + TransitionConfigTarget | TransitionConfig +>; + +export type TransitionsConfigMap = { + [K in TEvent['type']]?: TransitionConfigOrTarget< + TContext, + TEvent extends { type: K } ? TEvent : never + >; +} & { + ''?: TransitionConfigOrTarget; +} & { + '*'?: TransitionConfigOrTarget; +}; + +type TransitionsConfigArray = Array< + // distribute the union + | (TEvent extends EventObject + ? TransitionConfig & { event: TEvent['type'] } + : never) + | (TransitionConfig & { event: '' }) + | (TransitionConfig & { event: '*' }) +>; + +export type TransitionsConfig = + | TransitionsConfigMap + | TransitionsConfigArray; + +export interface InvokeSourceDefinition { + [key: string]: any; + type: string; +} + +export interface InvokeConfig { + /** + * The unique identifier for the invoked machine. If not specified, this + * will be the machine's own `id`, or the URL (from `src`). + */ + id?: string; + /** + * The source of the machine to be invoked, or the machine itself. + */ + src: + | string + | InvokeSourceDefinition + | StateMachine + | InvokeCreator; + /** + * If `true`, events sent to the parent service will be forwarded to the invoked service. + * + * Default: `false` + */ + autoForward?: boolean; + /** + * @deprecated + * + * Use `autoForward` property instead of `forward`. Support for `forward` will get removed in the future. + */ + forward?: boolean; + /** + * Data from the parent machine's context to set as the (partial or full) context + * for the invoked child machine. + * + * Data should be mapped to match the child machine's context shape. + */ + data?: Mapper | PropertyMapper; + /** + * The transition to take upon the invoked child machine reaching its final top-level state. + */ + onDone?: + | string + | SingleOrArray>>; + /** + * The transition to take upon the invoked child machine sending an error event. + */ + onError?: + | string + | SingleOrArray>>; +} + +export interface StateNodeConfig< + TContext, + TStateSchema extends StateSchema, + TEvent extends EventObject, + TAction extends BaseActionObject = BaseActionObject +> { + /** + * The relative key of the state node, which represents its location in the overall state value. + * This is automatically determined by the configuration shape via the key where it was defined. + */ + key?: string; + /** + * The initial state node key. + */ + initial?: keyof TStateSchema['states'] | undefined; + /** + * @deprecated + */ + parallel?: boolean | undefined; + /** + * The type of this state node: + * + * - `'atomic'` - no child state nodes + * - `'compound'` - nested child state nodes (XOR) + * - `'parallel'` - orthogonal nested child state nodes (AND) + * - `'history'` - history state node + * - `'final'` - final state node + */ + type?: 'atomic' | 'compound' | 'parallel' | 'final' | 'history'; + /** + * Indicates whether the state node is a history state node, and what + * type of history: + * shallow, deep, true (shallow), false (none), undefined (none) + */ + history?: 'shallow' | 'deep' | boolean | undefined; + /** + * The mapping of state node keys to their state node configurations (recursive). + */ + states?: StatesConfig | undefined; + /** + * The services to invoke upon entering this state node. These services will be stopped upon exiting this state node. + */ + invoke?: SingleOrArray< + InvokeConfig | StateMachine + >; + /** + * The mapping of event types to their potential transition(s). + */ + on?: TransitionsConfig; + /** + * The action(s) to be executed upon entering the state node. + * + * @deprecated Use `entry` instead. + */ + onEntry?: Actions; // TODO: deprecate + /** + * The action(s) to be executed upon entering the state node. + */ + entry?: BaseActions; + /** + * The action(s) to be executed upon exiting the state node. + * + * @deprecated Use `exit` instead. + */ + onExit?: Actions; // TODO: deprecate + /** + * The action(s) to be executed upon exiting the state node. + */ + exit?: BaseActions; + /** + * The potential transition(s) to be taken upon reaching a final child state node. + * + * This is equivalent to defining a `[done(id)]` transition on this state node's `on` property. + */ + onDone?: string | SingleOrArray>; + /** + * The mapping (or array) of delays (in milliseconds) to their potential transition(s). + * The delayed transitions are taken after the specified delay in an interpreter. + */ + after?: DelayedTransitions; + + /** + * An eventless transition that is always taken when this state node is active. + * Equivalent to a transition specified as an empty `''`' string in the `on` property. + */ + always?: TransitionConfigOrTarget; + /** + * The activities to be started upon entering the state node, + * and stopped upon exiting the state node. + * + * @deprecated Use `invoke` instead. + */ + activities?: SingleOrArray>; + /** + * @private + */ + parent?: StateNode; + strict?: boolean | undefined; + /** + * The meta data associated with this state node, which will be returned in State instances. + */ + meta?: TStateSchema extends { meta: infer D } ? D : any; + /** + * The data sent with the "done.state._id_" event if this is a final state node. + * + * The data will be evaluated with the current `context` and placed on the `.data` property + * of the event. + */ + data?: Mapper | PropertyMapper; + /** + * The unique ID of the state node, which can be referenced as a transition target via the + * `#id` syntax. + */ + id?: string | undefined; + /** + * The string delimiter for serializing the path to a string. The default is "." + */ + delimiter?: string; + /** + * The order this state node appears. Corresponds to the implicit SCXML document order. + */ + order?: number; + + /** + * The tags for this state node, which are accumulated into the `state.tags` property. + */ + tags?: SingleOrArray; + /** + * Whether actions should be called in order. + * When `false` (default), `assign(...)` actions are prioritized before other actions. + * + * @default false + */ + preserveActionOrder?: boolean; +} + +export interface StateNodeDefinition< + TContext, + TStateSchema extends StateSchema, + TEvent extends EventObject +> { + id: string; + version: string | undefined; + key: string; + context: TContext; + type: 'atomic' | 'compound' | 'parallel' | 'final' | 'history'; + initial: StateNodeConfig['initial']; + history: boolean | 'shallow' | 'deep' | undefined; + states: StatesDefinition; + on: TransitionDefinitionMap; + transitions: Array>; + entry: Array>; + exit: Array>; + /** + * @deprecated + */ + activities: Array>; + meta: any; + order: number; + data?: FinalStateNodeConfig['data']; + invoke: Array>; +} + +export type AnyStateNodeDefinition = StateNodeDefinition; +export interface AtomicStateNodeConfig + extends StateNodeConfig { + initial?: undefined; + parallel?: false | undefined; + states?: undefined; + onDone?: undefined; +} + +export interface HistoryStateNodeConfig + extends AtomicStateNodeConfig { + history: 'shallow' | 'deep' | true; + target: StateValue | undefined; +} +""" + + +@dataclass +class HistoryStateNodeConfig(EventObject): + history: str = False + type: str = None + target: Union[StateValue, None] = None + + +""" +export interface FinalStateNodeConfig + extends AtomicStateNodeConfig { + type: 'final'; + /** + * The data to be sent with the "done.state." event. The data can be + * static or dynamic (based on assigners). + */ + data?: Mapper | PropertyMapper; +} + +export type SimpleOrStateNodeConfig< + TContext, + TStateSchema extends StateSchema, + TEvent extends EventObject +> = + | AtomicStateNodeConfig + | StateNodeConfig; +""" +# export type ActionFunctionMap< +# TContext, +# TEvent extends EventObject, +# TAction extends ActionObject = ActionObject< +# TContext, +# TEvent +# > +# > = { +# [K in TAction['type']]?: +# | ActionObject +# | ActionFunction< +# TContext, +# TEvent, +# TAction extends { type : K } ? TAction : never +# >; +# }; + +ActionFunctionMap = ActionObject +# TODO: need to implement the following for ActionFunctionMap +# TContext, +# TEvent extends EventObject, +# TAction extends ActionObject = ActionObject< +# TContext, +# TEvent +# > +# > = { +# [K in TAction['type']]?: +# | ActionObject +# | ActionFunction< +# TContext, +# TEvent, +# TAction extends { type : K } ? TAction : never +# >; +# }; + + +""" +export type DelayFunctionMap = Record< + string, + DelayConfig +>; + +export type ServiceConfig< + TContext, + TEvent extends EventObject = AnyEventObject +> = string | StateMachine | InvokeCreator; + +export type DelayConfig = + | number + | DelayExpr; + +export interface MachineOptions< + TContext, + TEvent extends EventObject, + TAction extends ActionObject = ActionObject< + TContext, + TEvent + > +> { + guards: Record>; + actions: ActionFunctionMap; + /** + * @deprecated Use `services` instead. + */ + activities: Record>; + services: Record>; + delays: DelayFunctionMap; + /** + * @private + */ + _parent?: StateNode; + /** + * @private + */ + _key?: string; +} +export interface MachineConfig< + TContext, + TStateSchema extends StateSchema, + TEvent extends EventObject, + TAction extends BaseActionObject = ActionObject +> extends StateNodeConfig { + /** + * The initial context (extended state) + */ + context?: TContext | (() => TContext); + /** + * The machine's own version. + */ + version?: string; + schema?: MachineSchema; +} + +export interface MachineSchema { + context?: TContext; + events?: TEvent; + actions?: { type: string; [key: string]: any }; + guards?: { type: string; [key: string]: any }; + services?: { type: string; [key: string]: any }; +} + +export interface StandardMachineConfig< + TContext, + TStateSchema extends StateSchema, + TEvent extends EventObject +> extends StateNodeConfig {} + +export interface ParallelMachineConfig< + TContext, + TStateSchema extends StateSchema, + TEvent extends EventObject +> extends StateNodeConfig { + initial?: undefined; + type?: 'parallel'; +} + +export interface EntryExitEffectMap { + entry: Array>; + exit: Array>; +} + +export interface HistoryStateNode extends StateNode { + history: 'shallow' | 'deep'; + target: StateValue | undefined; +} + +export interface StateMachine< + TContext, + TStateSchema extends StateSchema, + TEvent extends EventObject, + TTypestate extends Typestate = { value: any; context: TContext }, + _TAction extends ActionObject = ActionObject< + TContext, + TEvent + > +> extends StateNode { + id: string; + states: StateNode['states']; + + withConfig( + options: Partial>, + context?: TContext | (() => TContext) + ): StateMachine; + + withContext( + context: TContext | (() => TContext) + ): StateMachine; +} + +export type StateFrom< + T extends + | StateMachine + | ((...args: any[]) => StateMachine) +> = T extends StateMachine + ? ReturnType + : T extends (...args: any[]) => StateMachine + ? ReturnType['transition']> + : never; + +export interface ActionMap { + onEntry: Array>; + actions: Array>; + onExit: Array>; +} + +export interface EntryExitStates { + entry: Set>; + exit: Set>; +} + +export interface EntryExitStateArrays { + entry: Array>; + exit: Array>; +} + +export interface ActivityMap { + [activityKey: string]: ActivityDefinition | false; +} + +// tslint:disable-next-line:class-name +export interface StateTransition { + transitions: Array>; + configuration: Array>; + entrySet: Array>; + exitSet: Array>; + /** + * The source state that preceded the transition. + */ + source: State | undefined; + actions: Array>; +} + +export interface TransitionData { + value: StateValue | undefined; + actions: ActionMap; + activities?: ActivityMap; +} +""" + +# export enum ActionTypes { +# Start = 'xstate.start', +# Stop = 'xstate.stop', +# Raise = 'xstate.raise', +# Send = 'xstate.send', +# Cancel = 'xstate.cancel', +# NullEvent = '', +# Assign = 'xstate.assign', +# After = 'xstate.after', +# DoneState = 'done.state', +# DoneInvoke = 'done.invoke', +# Log = 'xstate.log', +# Init = 'xstate.init', +# Invoke = 'xstate.invoke', +# ErrorExecution = 'error.execution', +# ErrorCommunication = 'error.communication', +# ErrorPlatform = 'error.platform', +# ErrorCustom = 'xstate.error', +# Update = 'xstate.update', +# Pure = 'xstate.pure', +# Choose = 'xstate.choose' +# } + + +class ActionTypes(Enum): + Start = ("xstate.start",) + Stop = ("xstate.stop",) + Raise = ("xstate.raise",) + Send = ("xstate.send",) + Cancel = ("xstate.cancel",) + NullEvent = ("",) + Assign = ("xstate.assign",) + After = ("xstate.after",) + DoneState = ("done.state",) + DoneInvoke = ("done.invoke",) + Log = ("xstate.log",) + Init = ("xstate.init",) + Invoke = ("xstate.invoke",) + ErrorExecution = ("error.execution",) + ErrorCommunication = ("error.communication",) + ErrorPlatform = ("error.platform",) + ErrorCustom = ("xstate.error",) + Update = ("xstate.update",) + Pure = ("xstate.pure",) + Choose = "xstate.choose" + + +""" +export interface RaiseAction { + type: ActionTypes.Raise; + event: TEvent['type']; +} + +export interface RaiseActionObject { + type: ActionTypes.Raise; + _event: SCXML.Event; +} + +export interface DoneInvokeEvent extends EventObject { + data: TData; +} + +export interface ErrorExecutionEvent extends EventObject { + src: string; + type: ActionTypes.ErrorExecution; + data: any; +} + +export interface ErrorPlatformEvent extends EventObject { + data: any; +} + +export interface DoneEventObject extends EventObject { + data?: any; + toString(): string; +} + +export interface UpdateObject extends EventObject { + id: string | number; + state: State; +} + +export type DoneEvent = DoneEventObject & string; + +export interface NullEvent { + type: ActionTypes.NullEvent; +} + +export interface ActivityActionObject + extends ActionObject { + type: ActionTypes.Start | ActionTypes.Stop; + activity: ActivityDefinition | undefined; + exec: ActionFunction | undefined; +} + +export interface InvokeActionObject + extends ActivityActionObject { + activity: InvokeDefinition; +} + +export type DelayExpr = ExprWithMeta< + TContext, + TEvent, + number +>; + +export type LogExpr = ExprWithMeta< + TContext, + TEvent, + any +>; + +export interface LogAction + extends ActionObject { + label: string | undefined; + expr: string | LogExpr; +} + +export interface LogActionObject + extends LogAction { + value: any; +} + +export interface SendAction< + TContext, + TEvent extends EventObject, + TSentEvent extends EventObject +> extends ActionObject { + to: + | string + | number + | ActorRef + | ExprWithMeta> + | undefined; + event: TSentEvent | SendExpr; + delay?: number | string | DelayExpr; + id: string | number; +} + +export interface SendActionObject< + TContext, + TEvent extends EventObject, + TSentEvent extends EventObject = AnyEventObject +> extends SendAction { + to: string | number | ActorRef | undefined; + _event: SCXML.Event; + event: TSentEvent; + delay?: number; + id: string | number; +} + +export interface StopAction + extends ActionObject { + type: ActionTypes.Stop; + activity: + | string + | { id: string } + | Expr; +} + +export interface StopActionObject { + type: ActionTypes.Stop; + activity: { id: string }; +} + +export type Expr = ( + context: TContext, + event: TEvent +) => T; + +export type ExprWithMeta = ( + context: TContext, + event: TEvent, + meta: SCXMLEventMeta +) => T; + +export type SendExpr< + TContext, + TEvent extends EventObject, + TSentEvent extends EventObject = AnyEventObject +> = ExprWithMeta; + +export enum SpecialTargets { + Parent = '#_parent', + Internal = '#_internal' +} + +export interface SendActionOptions { + id?: string | number; + delay?: number | string | DelayExpr; + to?: string | ExprWithMeta>; +} + +export interface CancelAction extends ActionObject { + sendId: string | number; +} + +export type Assigner = ( + context: TContext, + event: TEvent, + meta: AssignMeta +) => Partial; + +export type PartialAssigner< + TContext, + TEvent extends EventObject, + TKey extends keyof TContext +> = ( + context: TContext, + event: TEvent, + meta: AssignMeta +) => TContext[TKey]; + +export type PropertyAssigner = { + [K in keyof TContext]?: PartialAssigner | TContext[K]; +}; + +export type Mapper = ( + context: TContext, + event: TEvent +) => TParams; + +export type PropertyMapper< + TContext, + TEvent extends EventObject, + TParams extends {} +> = { + [K in keyof TParams]?: + | ((context: TContext, event: TEvent) => TParams[K]) + | TParams[K]; +}; + +export interface AnyAssignAction + extends ActionObject { + type: ActionTypes.Assign; + assignment: any; +} + +export interface AssignAction + extends ActionObject { + type: ActionTypes.Assign; + assignment: Assigner | PropertyAssigner; +} + +export interface PureAction + extends ActionObject { + type: ActionTypes.Pure; + get: ( + context: TContext, + event: TEvent + ) => SingleOrArray> | undefined; +} + +export interface ChooseAction + extends ActionObject { + type: ActionTypes.Choose; + conds: Array>; +} +""" + +# export interface TransitionDefinition +# extends TransitionConfig { +# target: Array> | undefined; +# source: StateNode; +# actions: Array>; +# cond?: Guard; +# eventType: TEvent['type'] | NullEvent['type'] | '*'; +# toJSON: () => { +# target: string[] | undefined; +# source: string; +# actions: Array>; +# cond?: Guard; +# eventType: TEvent['type'] | NullEvent['type'] | '*'; +# meta?: Record; +# }; +# } + + +@dataclass +class TransitionDefinition(TransitionConfig): + source: str = None + + +""" +export type TransitionDefinitionMap = { + [K in TEvent['type'] | NullEvent['type'] | '*']: Array< + TransitionDefinition< + TContext, + K extends TEvent['type'] ? Extract : EventObject + > + >; +}; + +export interface DelayedTransitionDefinition< + TContext, + TEvent extends EventObject +> extends TransitionDefinition { + delay: number | string | DelayExpr; +} + +export interface Edge< + TContext, + TEvent extends EventObject, + TEventType extends TEvent['type'] = string +> { + event: TEventType; + source: StateNode; + target: StateNode; + cond?: Condition; + actions: Array>; + meta?: MetaObject; + transition: TransitionDefinition; +} +export interface NodesAndEdges { + nodes: StateNode[]; + edges: Array>; +} + +export interface Segment { + /** + * From state. + */ + state: State; + /** + * Event from state. + */ + event: TEvent; +} + +export interface PathItem { + state: State; + path: Array>; + weight?: number; +} + +export interface PathMap { + [key: string]: PathItem; +} + +export interface PathsItem { + state: State; + paths: Array>>; +} + +export interface PathsMap { + [key: string]: PathsItem; +} + +export interface TransitionMap { + state: StateValue | undefined; +} + +export interface AdjacencyMap { + [stateId: string]: Record; +} + +export interface ValueAdjacencyMap { + [stateId: string]: Record>; +} + +export interface SCXMLEventMeta { + _event: SCXML.Event; +} + +export interface StateMeta { + state: State; + _event: SCXML.Event; +} + +export interface Typestate { + value: StateValue; + context: TContext; +} + +""" + + +@dataclass +class StateLike: + value: StateValue + context: Context + event: EventObject + _event: Any # TODO change to SCXML.Event + + +# export interface StateLike { +# value: StateValue; +# context: TContext; +# event: EventObject; +# _event: SCXML.Event; +# } + + +""" +export interface StateConfig { + value: StateValue; + context: TContext; + _event: SCXML.Event; + _sessionid: string | null; + historyValue?: HistoryValue | undefined; + history?: State; + actions?: Array>; + /** + * @deprecated + */ + activities?: ActivityMap; + meta?: any; + events?: TEvent[]; + configuration: Array>; + transitions: Array>; + children: Record>; + done?: boolean; + tags?: Set; + machine?: StateMachine; +} + +export interface StateSchema { + meta?: any; + context?: Partial; + states?: { + [key: string]: StateSchema; + }; +} + +export interface InterpreterOptions { + /** + * Whether state actions should be executed immediately upon transition. Defaults to `true`. + */ + execute: boolean; + clock: Clock; + logger: (...args: any[]) => void; + parent?: AnyInterpreter; + /** + * If `true`, defers processing of sent events until the service + * is initialized (`.start()`). Otherwise, an error will be thrown + * for events sent to an uninitialized service. + * + * Default: `true` + */ + deferEvents: boolean; + /** + * The custom `id` for referencing this service. + */ + id?: string; + /** + * If `true`, states and events will be logged to Redux DevTools. + * + * Default: `false` + */ + devTools: boolean | object; // TODO: add enhancer options + [option: string]: any; +} + +export namespace SCXML { + // tslint:disable-next-line:no-shadowed-variable + export interface Event { + /** + * This is a character string giving the name of the event. + * The SCXML Processor must set the name field to the name of this event. + * It is what is matched against the 'event' attribute of . + * Note that transitions can do additional tests by using the value of this field + * inside boolean expressions in the 'cond' attribute. + */ + name: string; + /** + * This field describes the event type. + * The SCXML Processor must set it to: "platform" (for events raised by the platform itself, such as error events), + * "internal" (for events raised by and with target '_internal') + * or "external" (for all other events). + */ + type: 'platform' | 'internal' | 'external'; + /** + * If the sending entity has specified a value for this, the Processor must set this field to that value + * (see C Event I/O Processors for details). + * Otherwise, in the case of error events triggered by a failed attempt to send an event, + * the Processor must set this field to the send id of the triggering element. + * Otherwise it must leave it blank. + */ + sendid?: string; + /** + * This is a URI, equivalent to the 'target' attribute on the element. + * For external events, the SCXML Processor should set this field to a value which, + * when used as the value of 'target', will allow the receiver of the event to + * a response back to the originating entity via the Event I/O Processor specified in 'origintype'. + * For internal and platform events, the Processor must leave this field blank. + */ + origin?: string; + /** + * This is equivalent to the 'type' field on the element. + * For external events, the SCXML Processor should set this field to a value which, + * when used as the value of 'type', will allow the receiver of the event to + * a response back to the originating entity at the URI specified by 'origin'. + * For internal and platform events, the Processor must leave this field blank. + */ + origintype?: string; + /** + * If this event is generated from an invoked child process, the SCXML Processor + * must set this field to the invoke id of the invocation that triggered the child process. + * Otherwise it must leave it blank. + */ + invokeid?: string; + /** + * This field contains whatever data the sending entity chose to include in this event. + * The receiving SCXML Processor should reformat this data to match its data model, + * but must not otherwise modify it. + * + * If the conversion is not possible, the Processor must leave the field blank + * and must place an error 'error.execution' in the internal event queue. + */ + data: TEvent; + /** + * @private + */ + $$type: 'scxml'; + } +} + +// Taken from RxJS +export interface Observer { + next: (value: T) => void; + error: (err: any) => void; + complete: () => void; +} + +export interface Subscription { + unsubscribe(): void; +} + +export interface Subscribable { + subscribe( + next: (value: T) => void, + error?: (error: any) => void, + complete?: () => void + ): Subscription; + subscribe(observer: Observer): Subscription; +} + +export type Spawnable = + | StateMachine + | PromiseLike + | InvokeCallback + | Subscribable + | Behavior; + +export type ExtractEvent< + TEvent extends EventObject, + TEventType extends TEvent['type'] +> = TEvent extends { type: TEventType } ? TEvent : never; + +export interface BaseActorRef { + send: (event: TEvent) => void; +} + +export interface ActorRef + extends Subscribable { + send: Sender; // TODO: this should just be TEvent + id: string; + getSnapshot: () => TEmitted | undefined; + stop?: () => void; + toJSON?: () => any; +} + +/** + * @deprecated Use `ActorRef` instead. + */ +export type SpawnedActorRef< + TEvent extends EventObject, + TEmitted = any +> = ActorRef; + +export type ActorRefWithDeprecatedState< + TContext, + TEvent extends EventObject, + TTypestate extends Typestate +> = ActorRef> & { + /** + * @deprecated Use `.getSnapshot()` instead. + */ + state: State; +}; + +export type ActorRefFrom = T extends StateMachine< + infer TContext, + any, + infer TEvent, + infer TTypestate +> + ? ActorRefWithDeprecatedState + : T extends ( + ...args: any[] + ) => StateMachine + ? ActorRefWithDeprecatedState + : T extends Promise + ? ActorRef + : T extends Behavior + ? ActorRef + : T extends (...args: any[]) => Behavior + ? ActorRef + : never; + +export type AnyInterpreter = Interpreter; + +export type InterpreterFrom< + T extends + | StateMachine + | ((...args: any[]) => StateMachine) +> = T extends StateMachine< + infer TContext, + infer TStateSchema, + infer TEvent, + infer TTypestate +> + ? Interpreter + : T extends ( + ...args: any[] + ) => StateMachine< + infer TContext, + infer TStateSchema, + infer TEvent, + infer TTypestate + > + ? Interpreter + : never; + +export interface ActorContext { + parent?: ActorRef; + self: ActorRef; + id: string; + observers: Set>; +} + +export interface Behavior { + transition: ( + state: TEmitted, + event: TEvent, + actorCtx: ActorContext + ) => TEmitted; + initialState: TEmitted; + start?: (actorCtx: ActorContext) => TEmitted; +} + +export type EmittedFrom = ReturnTypeOrValue extends infer R + ? R extends ActorRef + ? TEmitted + : R extends Behavior + ? TEmitted + : R extends ActorContext + ? TEmitted + : never + : never; + +export type EventFrom = ReturnTypeOrValue extends infer R + ? R extends StateMachine + ? TEvent + : R extends Model + ? TEvent + : R extends State + ? TEvent + : R extends Interpreter + ? TEvent + : never + : never; + +export type ContextFrom = ReturnTypeOrValue extends infer R + ? R extends StateMachine + ? TContext + : R extends Model + ? TContext + : R extends State + ? TContext + : R extends Interpreter + ? TContext + : never + : never; +""" + + +# export namespace SCXML { +class SCXML: + pass + + class Event: + """[summary] + + name: str + # /** + # * This is a character string giving the name of the event. + # * The SCXML Processor must set the name field to the name of this event. + # * It is what is matched against the 'event' attribute of . + # * Note that transitions can do additional tests by using the value of this field + # * inside boolean expressions in the 'cond' attribute. + # */ + type: str + # /** + # * This field describes the event type. + # * The SCXML Processor must set it to: "platform" (for events raised by the platform itself, such as error events), + # * "internal" (for events raised by and with target '_internal') + # * or "external" (for all other events). + # */ + + + """ + + # // tslint:disable-next-line:no-shadowed-variable + # export interface Event { + # name: string; + name: str + # /** + # * This field describes the event type. + # * The SCXML Processor must set it to: "platform" (for events raised by the platform itself, such as error events), + # * "internal" (for events raised by and with target '_internal') + # * or "external" (for all other events). + # */ + # #type: 'platform' | 'internal' | 'external'; + event_type: str # 'platform' | 'internal' | 'external' + + # /** + # * If the sending entity has specified a value for this, the Processor must set this field to that value + # * (see C Event I/O Processors for details). + # * Otherwise, in the case of error events triggered by a failed attempt to send an event, + # * the Processor must set this field to the send id of the triggering element. + # * Otherwise it must leave it blank. + # */ + # sendid?: string; + # /** + # * This is a URI, equivalent to the 'target' attribute on the element. + # * For external events, the SCXML Processor should set this field to a value which, + # * when used as the value of 'target', will allow the receiver of the event to + # * a response back to the originating entity via the Event I/O Processor specified in 'origintype'. + # * For internal and platform events, the Processor must leave this field blank. + # */ + # origin?: string; + # /** + # * This is equivalent to the 'type' field on the element. + # * For external events, the SCXML Processor should set this field to a value which, + # * when used as the value of 'type', will allow the receiver of the event to + # * a response back to the originating entity at the URI specified by 'origin'. + # * For internal and platform events, the Processor must leave this field blank. + # */ + # origintype?: string; + # /** + # * If this event is generated from an invoked child process, the SCXML Processor + # * must set this field to the invoke id of the invocation that triggered the child process. + # * Otherwise it must leave it blank. + # */ + # invokeid?: string; + # /** + # * This field contains whatever data the sending entity chose to include in this event. + # * The receiving SCXML Processor should reformat this data to match its data model, + # * but must not otherwise modify it. + # * + # * If the conversion is not possible, the Processor must leave the field blank + # * and must place an error 'error.execution' in the internal event queue. + # */ + # data: TEvent; + + # /** + # * @private + # */ + # $$type: 'scxml'; + __type: str = "scxml" + + +# } +# }