diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index de6fa7de..ff4ee563 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,7 +21,7 @@ jobs: python-version: '3.13' - name: Setup Graphviz - uses: ts-graphviz/setup-graphviz@v1 + uses: ts-graphviz/setup-graphviz@v2 - name: Install uv uses: astral-sh/setup-uv@v3 diff --git a/README.md b/README.md index 66d35b05..eb93ce9f 100644 --- a/README.md +++ b/README.md @@ -168,7 +168,7 @@ Easily iterate over all states: ```py >>> [s.id for s in sm.states] -['green', 'red', 'yellow'] +['green', 'yellow', 'red'] ``` diff --git a/statemachine/callbacks.py b/statemachine/callbacks.py index 9c1b49db..0d2cd688 100644 --- a/statemachine/callbacks.py +++ b/statemachine/callbacks.py @@ -71,6 +71,7 @@ def __init__( func, group: CallbackGroup, is_convention=False, + is_event: bool = False, cond=None, priority: CallbackPriority = CallbackPriority.NAMING, expected_value=None, @@ -78,6 +79,7 @@ def __init__( self.func = func self.group = group self.is_convention = is_convention + self.is_event = is_event self.cond = cond self.expected_value = expected_value self.priority = priority @@ -88,7 +90,12 @@ def __init__( elif callable(func): self.reference = SpecReference.CALLABLE self.is_bounded = hasattr(func, "__self__") - self.attr_name = func.__name__ + self.attr_name = ( + func.__name__ if not self.is_event or self.is_bounded else f"_{func.__name__}_" + ) + if not self.is_bounded: + func.attr_name = self.attr_name + func.is_event = is_event else: self.reference = SpecReference.NAME self.attr_name = func @@ -114,11 +121,6 @@ def __eq__(self, other): def __hash__(self): return id(self) - def _update_func(self, func: Callable, attr_name: str): - self.func = func - self.reference = SpecReference.CALLABLE - self.attr_name = attr_name - class SpecListGrouper: def __init__(self, list: "CallbackSpecList", group: CallbackGroup) -> None: @@ -158,7 +160,7 @@ def __init__(self, factory=CallbackSpec): def __repr__(self): return f"{type(self).__name__}({self.items!r}, factory={self.factory!r})" - def _add_unbounded_callback(self, func, is_event=False, transitions=None, **kwargs): + def _add_unbounded_callback(self, func, transitions=None, **kwargs): """This list was a target for adding a func using decorator `@[.on|before|after|enter|exit]` syntax. @@ -181,11 +183,7 @@ def _add_unbounded_callback(self, func, is_event=False, transitions=None, **kwar event. """ - spec = self._add(func, **kwargs) - if not getattr(func, "_specs_to_update", None): - func._specs_to_update = set() - if is_event: - func._specs_to_update.add(spec._update_func) + self._add(func, **kwargs) func._transitions = transitions return func @@ -202,7 +200,10 @@ def grouper(self, group: CallbackGroup) -> SpecListGrouper: return self._groupers[group] def _add(self, func, group: CallbackGroup, **kwargs): - spec = self.factory(func, group, **kwargs) + if isinstance(func, CallbackSpec): + spec = func + else: + spec = self.factory(func, group, **kwargs) if spec in self.items: return diff --git a/statemachine/engines/async_.py b/statemachine/engines/async_.py index c80d420d..3425e437 100644 --- a/statemachine/engines/async_.py +++ b/statemachine/engines/async_.py @@ -7,6 +7,7 @@ from ..exceptions import InvalidDefinition from ..exceptions import TransitionNotAllowed from ..i18n import _ +from ..state import State from ..transition import Transition if TYPE_CHECKING: @@ -82,7 +83,7 @@ async def processing_loop(self): async def _trigger(self, trigger_data: TriggerData): event_data = None if trigger_data.event == "__initial__": - transition = Transition(None, self.sm._get_initial_state(), event="__initial__") + transition = Transition(State(), self.sm._get_initial_state(), event="__initial__") transition._specs.clear() event_data = EventData(trigger_data=trigger_data, transition=transition) await self._activate(event_data) diff --git a/statemachine/engines/sync.py b/statemachine/engines/sync.py index fdbaed4b..32e00bf2 100644 --- a/statemachine/engines/sync.py +++ b/statemachine/engines/sync.py @@ -5,6 +5,7 @@ from ..event_data import EventData from ..event_data import TriggerData from ..exceptions import TransitionNotAllowed +from ..state import State from ..transition import Transition if TYPE_CHECKING: @@ -85,7 +86,7 @@ def processing_loop(self): def _trigger(self, trigger_data: TriggerData): event_data = None if trigger_data.event == "__initial__": - transition = Transition(None, self.sm._get_initial_state(), event="__initial__") + transition = Transition(State(), self.sm._get_initial_state(), event="__initial__") transition._specs.clear() event_data = EventData(trigger_data=trigger_data, transition=transition) self._activate(event_data) diff --git a/statemachine/factory.py b/statemachine/factory.py index f8e4fcac..e5428e4c 100644 --- a/statemachine/factory.py +++ b/statemachine/factory.py @@ -4,7 +4,6 @@ from typing import Dict from typing import List from typing import Tuple -from uuid import uuid4 from . import registry from .event import Event @@ -179,7 +178,7 @@ def add_inherited(cls, bases): cls.add_event(event=Event(id=event.id, name=event.name)) def add_from_attributes(cls, attrs): # noqa: C901 - for key, value in sorted(attrs.items(), key=lambda pair: pair[0]): + for key, value in attrs.items(): if isinstance(value, States): cls._add_states_from_dict(value) if isinstance(value, State): @@ -195,7 +194,7 @@ def add_from_attributes(cls, attrs): # noqa: C901 ), old_event=value, ) - elif getattr(value, "_specs_to_update", None): + elif getattr(value, "attr_name", None): cls._add_unbounded_callback(key, value) def _add_states_from_dict(cls, states): @@ -205,13 +204,10 @@ def _add_states_from_dict(cls, states): def _add_unbounded_callback(cls, attr_name, func): # if func is an event, the `attr_name` will be replaced by an event trigger, # so we'll also give the ``func`` a new unique name to be used by the callback - # machinery. - cls.add_event(event=Event(func._transitions, id=attr_name, name=attr_name)) - attr_name = f"_{attr_name}_{uuid4().hex}" - setattr(cls, attr_name, func) - - for ref in func._specs_to_update: - ref(getattr(cls, attr_name), attr_name) + # machinery that is stored at ``func.attr_name`` + setattr(cls, func.attr_name, func) + if func.is_event: + cls.add_event(event=Event(func._transitions, id=attr_name, name=attr_name)) def add_state(cls, id, state: State): state._set_id(id) @@ -236,7 +232,7 @@ def add_event( transitions = event._transitions if transitions is not None: - transitions.add_event(event) + transitions._on_event_defined(event=event, states=list(cls.states)) if event not in cls._events: cls._events[event] = None diff --git a/statemachine/state.py b/statemachine/state.py index 076c11d3..65ac7221 100644 --- a/statemachine/state.py +++ b/statemachine/state.py @@ -1,6 +1,7 @@ from typing import TYPE_CHECKING from typing import Any from typing import Dict +from typing import List from weakref import ref from .callbacks import CallbackGroup @@ -15,6 +16,37 @@ from .statemachine import StateMachine +class _TransitionBuilder: + def __init__(self, state: "State"): + self._state = state + + def itself(self, **kwargs): + return self.__call__(self._state, **kwargs) + + def __call__(self, *states: "State", **kwargs): + raise NotImplementedError + + +class _ToState(_TransitionBuilder): + def __call__(self, *states: "State", **kwargs): + transitions = TransitionList(Transition(self._state, state, **kwargs) for state in states) + self._state.transitions.add_transitions(transitions) + return transitions + + +class _FromState(_TransitionBuilder): + def any(self, **kwargs): + return self.__call__(AnyState(), **kwargs) + + def __call__(self, *states: "State", **kwargs): + transitions = TransitionList() + for origin in states: + transition = Transition(origin, self._state, **kwargs) + origin.transitions.add_transitions(transition) + transitions.add_transitions(transition) + return transitions + + class State: """ A State in a :ref:`StateMachine` describes a particular behavior of the machine. @@ -136,6 +168,12 @@ def _setup(self): self.exit.add("on_exit_state", priority=CallbackPriority.GENERIC, is_convention=True) self.exit.add(f"on_exit_{self.id}", priority=CallbackPriority.NAMING, is_convention=True) + def _on_event_defined(self, event: str, transition: Transition, states: List["State"]): + """Called by statemachine factory when an event is defined having a transition + starting from this state. + """ + pass + def __repr__(self): return ( f"{type(self).__name__}({self.name!r}, id={self.id!r}, value={self.value!r}, " @@ -172,38 +210,15 @@ def _set_id(self, id: str): if not self.name: self.name = self._id.replace("_", " ").capitalize() - def _to_(self, *states: "State", **kwargs): - transitions = TransitionList(Transition(self, state, **kwargs) for state in states) - self.transitions.add_transitions(transitions) - return transitions - - def _from_(self, *states: "State", **kwargs): - transitions = TransitionList() - for origin in states: - transition = Transition(origin, self, **kwargs) - origin.transitions.add_transitions(transition) - transitions.add_transitions(transition) - return transitions - - def _get_proxy_method_to_itself(self, method): - def proxy(*states: "State", **kwargs): - return method(*states, **kwargs) - - def proxy_to_itself(**kwargs): - return proxy(self, **kwargs) - - proxy.itself = proxy_to_itself - return proxy - @property - def to(self): + def to(self) -> _ToState: """Create transitions to the given target states.""" - return self._get_proxy_method_to_itself(self._to_) + return _ToState(self) @property - def from_(self): + def from_(self) -> _FromState: """Create transitions from the given target states (reversed).""" - return self._get_proxy_method_to_itself(self._from_) + return _FromState(self) @property def initial(self): @@ -269,3 +284,19 @@ def id(self) -> str: @property def is_active(self): return self._machine().current_state == self + + +class AnyState(State): + """A special state that works as a "ANY" placeholder. + + It is used as the "From" state of a transtion, + until the state machine class is evaluated. + """ + + def _on_event_defined(self, event: str, transition: Transition, states: List[State]): + for state in states: + if state.final: + continue + new_transition = transition._copy_with_args(source=state, event=event) + + state.transitions.add_transitions(new_transition) diff --git a/statemachine/transition.py b/statemachine/transition.py index dc5260d6..a9044f0f 100644 --- a/statemachine/transition.py +++ b/statemachine/transition.py @@ -1,9 +1,15 @@ +from copy import deepcopy +from typing import TYPE_CHECKING + from .callbacks import CallbackGroup from .callbacks import CallbackPriority from .callbacks import CallbackSpecList from .events import Events from .exceptions import InvalidDefinition +if TYPE_CHECKING: + from .statemachine import State + class Transition: """A transition holds reference to the source and target state. @@ -32,8 +38,8 @@ class Transition: def __init__( self, - source, - target, + source: "State", + target: "State", event=None, internal=False, validators=None, @@ -125,3 +131,17 @@ def events(self): def add_event(self, value): self._events.add(value) + + def _copy_with_args(self, **kwargs): + source = kwargs.pop("source", self.source) + target = kwargs.pop("target", self.target) + event = kwargs.pop("event", self.event) + internal = kwargs.pop("internal", self.internal) + new_transition = Transition( + source=source, target=target, event=event, internal=internal, **kwargs + ) + for spec in self._specs: + new_spec = deepcopy(spec) + new_transition._specs.add(new_spec, new_spec.group) + + return new_transition diff --git a/statemachine/transition_list.py b/statemachine/transition_list.py index 0557764d..265a7b6c 100644 --- a/statemachine/transition_list.py +++ b/statemachine/transition_list.py @@ -3,11 +3,13 @@ from typing import Iterable from typing import List +from .callbacks import CallbackGroup from .transition import Transition from .utils import ensure_iterable if TYPE_CHECKING: from .events import Event + from .state import State class TransitionList: @@ -40,6 +42,12 @@ def __or__(self, other: "TransitionList | Iterable"): """ return TransitionList(self.transitions).add_transitions(other) + def _on_event_defined(self, event: str, states: List["State"]): + self.add_event(event) + + for transition in self.transitions: + transition.source._on_event_defined(event=event, transition=transition, states=states) + def add_transitions(self, transition: "Transition | TransitionList | Iterable"): """Adds one or more transitions to the :ref:`TransitionList` instance. @@ -78,9 +86,9 @@ def __len__(self): """ return len(self.transitions) - def _add_callback(self, callback, name, is_event=False, **kwargs): + def _add_callback(self, callback, grouper: CallbackGroup, is_event=False, **kwargs): for transition in self.transitions: - list_obj = getattr(transition, name) + list_obj = transition._specs.grouper(grouper) list_obj._add_unbounded_callback( callback, is_event=is_event, @@ -90,7 +98,7 @@ def _add_callback(self, callback, name, is_event=False, **kwargs): return callback def __call__(self, f): - return self._add_callback(f, "on", is_event=True) + return self._add_callback(f, CallbackGroup.ON, is_event=True) def before(self, f: Callable): """Adds a ``before`` :ref:`transition actions` callback to every :ref:`transition` in the @@ -102,7 +110,7 @@ def before(self, f: Callable): Returns: The `f` callable. """ - return self._add_callback(f, "before") + return self._add_callback(f, CallbackGroup.BEFORE) def after(self, f: Callable): """Adds a ``after`` :ref:`transition actions` callback to every :ref:`transition` in the @@ -114,7 +122,7 @@ def after(self, f: Callable): Returns: The `f` callable. """ - return self._add_callback(f, "after") + return self._add_callback(f, CallbackGroup.AFTER) def on(self, f: Callable): """Adds a ``on`` :ref:`transition actions` callback to every :ref:`transition` in the @@ -126,7 +134,7 @@ def on(self, f: Callable): Returns: The `f` callable. """ - return self._add_callback(f, "on") + return self._add_callback(f, CallbackGroup.ON) def cond(self, f: Callable): """Adds a ``cond`` :ref:`guards` callback to every :ref:`transition` in the @@ -138,7 +146,7 @@ def cond(self, f: Callable): Returns: The `f` callable. """ - return self._add_callback(f, "cond", expected_value=True) + return self._add_callback(f, CallbackGroup.COND, expected_value=True) def unless(self, f: Callable): """Adds a ``unless`` :ref:`guards` callback with expected value ``False`` to every @@ -150,7 +158,7 @@ def unless(self, f: Callable): Returns: The `f` callable. """ - return self._add_callback(f, "cond", expected_value=False) + return self._add_callback(f, CallbackGroup.COND, expected_value=False) def validators(self, f: Callable): """Adds a :ref:`validators` callback to the :ref:`TransitionList` instance. @@ -161,7 +169,7 @@ def validators(self, f: Callable): The callback function. """ - return self._add_callback(f, "validators") + return self._add_callback(f, CallbackGroup.VALIDATOR) def add_event(self, event: str): """ diff --git a/tests/examples/order_control_rich_model_machine.py b/tests/examples/order_control_rich_model_machine.py index 35be54fe..ad0a4e34 100644 --- a/tests/examples/order_control_rich_model_machine.py +++ b/tests/examples/order_control_rich_model_machine.py @@ -63,8 +63,8 @@ class OrderControl(StateMachine): assert ( # noqa: PT017 str(e) == ( - "Error on transition process_order from Processing to Shipping when resolving " - "callbacks: Did not found name 'payment_received' from model or statemachine" + "Error on Waiting for payment when resolving callbacks: " + "Did not found name 'wait_for_payment' from model or statemachine" ) ) diff --git a/tests/test_callbacks.py b/tests/test_callbacks.py index 6c5ce05d..0c65984f 100644 --- a/tests/test_callbacks.py +++ b/tests/test_callbacks.py @@ -209,3 +209,140 @@ def refuse_call(self, reason): mock.call("enter_ordinary_world"), mock.call("refuse_call", "Not prepared yet"), ] + + +class TestIssue406: + """ + A StateMachine that exercises the example given on issue + #[406](https://github.com/fgmacedo/python-statemachine/issues/406). + + In this example, the event callback must be registered only once. + """ + + def test_issue_406(self, mocker): + mock = mocker.Mock() + + class ExampleStateMachine(StateMachine, strict_states=False): + created = State(initial=True) + inited = State(final=True) + + initialize = created.to(inited) + + @initialize.before + def before_initialize(self): + mock("before init") + + @initialize.on + def on_initialize(self): + mock("on init") + + sm = ExampleStateMachine() + sm.initialize() + + assert mock.call_args_list == [ + mocker.call("before init"), + mocker.call("on init"), + ] + + +class TestIssue417: + """ + A StateMachine that exercises the example given on issue + #[417](https://github.com/fgmacedo/python-statemachine/issues/417). + """ + + @pytest.fixture() + def mock_calls(self, mocker): + return mocker.Mock() + + @pytest.fixture() + def model_class(self): + class Model: + def __init__(self, counter: int = 0): + self.state = None + self.counter = counter + + def can_be_started_on_model(self) -> bool: + return self.counter > 0 + + @property + def can_be_started_as_property_on_model(self) -> bool: + return self.counter > 1 + + @property + def can_be_started_as_property_str_on_model(self) -> bool: + return self.counter > 2 + + return Model + + @pytest.fixture() + def sm_class(self, model_class, mock_calls): + class ExampleStateMachine(StateMachine): + created = State(initial=True) + started = State(final=True) + + def can_be_started(self) -> bool: + return self.counter > 0 + + @property + def can_be_started_as_property(self) -> bool: + return self.counter > 1 + + @property + def can_be_started_as_property_str(self) -> bool: + return self.counter > 2 + + start = created.to( + started, + cond=[ + can_be_started, + can_be_started_as_property, + "can_be_started_as_property_str", + model_class.can_be_started_on_model, + model_class.can_be_started_as_property_on_model, + "can_be_started_as_property_str_on_model", + ], + ) + + def __init__(self, model=None, counter: int = 0): + self.counter = counter + super().__init__(model=model) + + def on_start(self): + mock_calls("started") + + return ExampleStateMachine + + def test_issue_417_cannot_start(self, model_class, sm_class, mock_calls): + model = model_class(0) + sm = sm_class(model, 0) + with pytest.raises(sm.TransitionNotAllowed, match="Can't start when in Created"): + sm.start() + + mock_calls.assert_not_called() + + def test_issue_417_can_start(self, model_class, sm_class, mock_calls, mocker): + model = model_class(3) + sm = sm_class(model, 3) + sm.start() + + assert mock_calls.call_args_list == [ + mocker.call("started"), + ] + + def test_raise_exception_if_property_is_not_found(self): + class StrangeObject: + @property + def this_cannot_resolve(self) -> bool: + return True + + class ExampleStateMachine(StateMachine): + created = State(initial=True) + started = State(final=True) + start = created.to(started, cond=[StrangeObject.this_cannot_resolve]) + + with pytest.raises( + InvalidDefinition, + match="Error on transition start from Created to Started when resolving callbacks", + ): + ExampleStateMachine() diff --git a/tests/test_rtc.py b/tests/test_rtc.py index 30af9347..05515f9a 100644 --- a/tests/test_rtc.py +++ b/tests/test_rtc.py @@ -93,7 +93,7 @@ class TestChainedTransition: ( False, [ - mock.call("on_enter_state", state="a", source=None, value=0), + mock.call("on_enter_state", state="a", source="", value=0), mock.call("before_t1", source="a", value=42), mock.call("on_exit_state", state="a", source="a", value=42), mock.call("on_t1", source="a", value=42), @@ -109,7 +109,7 @@ class TestChainedTransition: ( True, [ - mock.call("on_enter_state", state="a", source=None, value=0), + mock.call("on_enter_state", state="a", source="", value=0), mock.call("before_t1", source="a", value=42), mock.call("on_exit_state", state="a", source="a", value=42), mock.call("on_t1", source="a", value=42), diff --git a/tests/test_statemachine.py b/tests/test_statemachine.py index 2ebf194e..9337e025 100644 --- a/tests/test_statemachine.py +++ b/tests/test_statemachine.py @@ -20,14 +20,14 @@ def test_machine_should_be_at_start_state(campaign_machine): machine = campaign_machine(model) assert [s.value for s in campaign_machine.states] == [ - "closed", "draft", "producing", + "closed", ] assert [t.name for t in campaign_machine.events] == [ "add_job", - "deliver", "produce", + "deliver", ] assert model.state == "draft" diff --git a/tests/test_transitions.py b/tests/test_transitions.py index 3b6b52aa..3f2c8150 100644 --- a/tests/test_transitions.py +++ b/tests/test_transitions.py @@ -21,7 +21,7 @@ def test_transition_representation(campaign_machine): def test_list_machine_events(classic_traffic_light_machine): machine = classic_traffic_light_machine() transitions = [t.name for t in machine.events] - assert transitions == ["go", "slowdown", "stop"] + assert transitions == ["slowdown", "stop", "go"] def test_list_state_transitions(classic_traffic_light_machine): @@ -296,3 +296,78 @@ def test_send_not_valid_for_the_current_state_event(self, classic_traffic_light_ assert sm.green.is_active sm.stop() assert sm.green.is_active + + +class TestTransitionFromAny: + @pytest.fixture() + def account_sm(self): + class AccountStateMachine(StateMachine): + active = State("Active", initial=True) + suspended = State("Suspended") + overdrawn = State("Overdrawn") + closed = State("Closed", final=True) + + # Define transitions between states + suspend = active.to(suspended) + activate = suspended.to(active) + overdraft = active.to(overdrawn) + resolve_overdraft = overdrawn.to(active) + + close_account = closed.from_.any(cond="can_close_account") + + can_close_account: bool = True + + # Actions performed during transitions + def on_close_account(self): + print("Account has been closed.") + + return AccountStateMachine + + def test_transition_from_any(self, account_sm): + sm = account_sm() + sm.close_account() + assert sm.closed.is_active + + def test_can_close_from_every_state(self, account_sm): + sm = account_sm() + states_can_close = {} + for state in sm.states: + for transition in state.transitions: + print(f"{state.id} -({transition.event})-> {transition.target.id}") + if transition.target == sm.closed: + states_can_close[state.id] = state + + assert list(states_can_close) == ["active", "suspended", "overdrawn"] + + def test_transition_from_any_with_cond(self, account_sm): + sm = account_sm() + sm.can_close_account = False + with pytest.raises(sm.TransitionNotAllowed): + sm.close_account() + assert sm.active.is_active + + def test_any_can_be_used_as_decorator(self): + class AccountStateMachine(StateMachine): + active = State("Active", initial=True) + suspended = State("Suspended") + overdrawn = State("Overdrawn") + closed = State("Closed", final=True) + + # Define transitions between states + suspend = active.to(suspended) + activate = suspended.to(active) + overdraft = active.to(overdrawn) + resolve_overdraft = overdrawn.to(active) + + close_account = closed.from_.any() + + flag_for_debug: bool = False + + @close_account.on + def do_close_account(self): + self.flag_for_debug = True + + sm = AccountStateMachine() + sm.close_account() + assert sm.closed.is_active + assert sm.flag_for_debug is True diff --git a/tests/testcases/issue406.md b/tests/testcases/issue406.md deleted file mode 100644 index 0c446ca7..00000000 --- a/tests/testcases/issue406.md +++ /dev/null @@ -1,39 +0,0 @@ -### Issue 406 - -A StateMachine that exercises the example given on issue -#[406](https://github.com/fgmacedo/python-statemachine/issues/406). - -In this example, the event callback must be registered only once. - -```py ->>> from statemachine import State ->>> from statemachine import StateMachine - ->>> class ExampleStateMachine(StateMachine, strict_states=False): -... Created = State(initial=True) -... Inited = State(final=True) -... -... initialize = Created.to(Inited) -... -... @initialize.before -... def before_initialize(self): -... print("before init") -... -... @initialize.on -... def on_initialize(self): -... print("on init") - ->>> def test_sm(): -... sm = ExampleStateMachine() -... sm.initialize() - -``` - -Expected output: - -```py ->>> test_sm() -before init -on init - -``` diff --git a/tests/testcases/issue417.md b/tests/testcases/issue417.md deleted file mode 100644 index ff2c89e1..00000000 --- a/tests/testcases/issue417.md +++ /dev/null @@ -1,118 +0,0 @@ -### Issue 417 - -A StateMachine that exercises the derived example given on issue -#[417](https://github.com/fgmacedo/python-statemachine/issues/417). - -In this example, the condition callback must be registered using a method by reference, not by it's name. -Just to be sure, we've added a lot of variations. - -```py ->>> from statemachine import State ->>> from statemachine import StateMachine - ->>> class Model: -... def __init__(self, counter: int = 0): -... self.state = None -... self.counter = counter -... -... def can_be_started_on_model(self) -> bool: -... return self.counter > 0 -... -... @property -... def can_be_started_as_property_on_model(self) -> bool: -... return self.counter > 1 -... -... @property -... def can_be_started_as_property_str_on_model(self) -> bool: -... return self.counter > 2 - ->>> class ExampleStateMachine(StateMachine): -... created = State(initial=True) -... started = State(final=True) -... -... def can_be_started(self) -> bool: -... return self.counter > 0 -... -... @property -... def can_be_started_as_property(self) -> bool: -... return self.counter > 1 -... -... @property -... def can_be_started_as_property_str(self) -> bool: -... return self.counter > 2 -... -... start = created.to( -... started, -... cond=[ -... can_be_started, can_be_started_as_property, "can_be_started_as_property_str", -... Model.can_be_started_on_model, Model.can_be_started_as_property_on_model, -... "can_be_started_as_property_str_on_model" -... ] -... ) -... -... def __init__(self, model=None, counter: int = 0): -... self.counter = counter -... super().__init__(model=model) -... -... def on_start(self): -... print("started") -... - ->>> def test_machine(counter): -... model = Model(counter) -... sm = ExampleStateMachine(model, counter) -... sm.start() - -``` - -Expected output: - -```py ->>> test_machine(0) -Traceback (most recent call last): -... -statemachine.exceptions.TransitionNotAllowed: Can't start when in Created. - ->>> test_machine(3) -started - -``` - -## Invalid scenarios - -Should raise an exception if the property is not found on the correct objects: - - -```py - ->>> class StrangeObject: -... @property -... def this_cannot_resolve(self) -> bool: -... return True - - - ->>> class ExampleStateMachine(StateMachine): -... created = State(initial=True) -... started = State(final=True) -... -... start = created.to( -... started, -... cond=[StrangeObject.this_cannot_resolve] -... ) -... - ->>> def test_machine(): -... sm = ExampleStateMachine() -... sm.start() - -``` - -Expected output: - -```py ->>> test_machine() -Traceback (most recent call last): -... -statemachine.exceptions.InvalidDefinition: Error on transition start from Created to Started when resolving callbacks: Did not found name ... from model or statemachine -```