From 44cec996e15c40bc564ad6a1b8ec9cf1f105cb14 Mon Sep 17 00:00:00 2001 From: Fernando Macedo Date: Sun, 5 Mar 2023 01:18:43 -0300 Subject: [PATCH] feat: allow_event_without_transition --- README.md | 2 +- docs/actions.md | 14 ++-- docs/api.md | 66 +++++++++++++++++ docs/diagram.md | 6 +- docs/guards.md | 16 ++--- docs/index.md | 1 + docs/integrations.md | 8 ++- docs/mixins.md | 2 +- docs/models.md | 13 ++-- docs/releases/1.0.1.md | 2 +- docs/releases/1.0.3.md | 2 +- docs/releases/2.0.0.md | 49 ++++++++++--- docs/states.md | 15 ++-- docs/transitions.md | 9 +-- statemachine/event.py | 6 +- statemachine/event_data.py | 17 +++-- statemachine/exceptions.py | 2 +- statemachine/state.py | 2 +- statemachine/statemachine.py | 72 +++++++++++++++++-- .../order_control_rich_model_machine.py | 4 +- tests/examples/persistent_model_machine.py | 2 +- tests/test_transitions.py | 16 +++++ 22 files changed, 247 insertions(+), 79 deletions(-) create mode 100644 docs/api.md diff --git a/README.md b/README.md index 063c7647..8f0c572b 100644 --- a/README.md +++ b/README.md @@ -298,7 +298,7 @@ There's a lot more to cover, please take a look at our docs: https://python-statemachine.readthedocs.io. -## Contributing +## Contributing to the project * Star this project * Open an Issue diff --git a/docs/actions.md b/docs/actions.md index 7430a370..4f9d9f95 100644 --- a/docs/actions.md +++ b/docs/actions.md @@ -1,6 +1,6 @@ # Actions -Action is the way a StateMachine can cause things to happen in the +Action is the way a {ref}`StateMachine` can cause things to happen in the outside world, and indeed they are the main reason why they exist at all. The main point of introducing a state machine is for the @@ -26,7 +26,7 @@ when something changes and are not bounded to a specific state or event: - `after_transition()` -The follow example can get you an overview of the "generic" callbacks available: +The following example can get you an overview of the "generic" callbacks available: ```py >>> from statemachine import StateMachine, State @@ -162,14 +162,14 @@ It's also possible to use an event name as action. ## Transition actions -For each {ref}`event`, you can register `before`, `on` and `after` callbacks. +For each {ref}`event`, you can register `before`, `on`, and `after` callbacks. ### Declare transition actions by naming convention The action will be registered for every {ref}`transition` associated with the event. -Callbacks by naming convention will be searched on the StateMachine and on the -model, using the patterns: +Callbacks by naming convention will be searched on the StateMachine and the model, +using the patterns: - `before_()` @@ -337,7 +337,7 @@ Actions and Guards will be executed in the following order: (dynamic-dispatch)= ## Dynamic dispatch -`python-statemachine` implements a custom dispatch mechanism on all those available Actions and +{ref}`statemachine` implements a custom dispatch mechanism on all those available Actions and Guards. This means that you can declare an arbitrary number of `*args` and `**kwargs`, and the library will match your method signature of what's expected to receive with the provided arguments. @@ -359,7 +359,7 @@ For your convenience, all these parameters are available for you on any Action o : All keyword arguments provided on the {ref}`Event`. `event_data` -: A reference to `EventData` instance. +: A reference to {ref}`EventData` instance. `event` : The {ref}`Event` that was triggered. diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 00000000..2bf93950 --- /dev/null +++ b/docs/api.md @@ -0,0 +1,66 @@ +# API + +## StateMachine + +```{eval-rst} +.. autoclass:: statemachine.statemachine.StateMachine + :members: + :undoc-members: +``` + +## State + +```{seealso} +{ref}`States` reference. +``` + + +```{eval-rst} +.. autoclass:: statemachine.state.State + :members: +``` + +## Transition + +```{seealso} +{ref}`Transitions` reference. +``` + +```{eval-rst} +.. autoclass:: statemachine.transition.Transition + :members: +``` + +## TransitionList + +```{eval-rst} +.. autoclass:: statemachine.transition_list.TransitionList + :members: +``` + +## Model + +```{seealso} +{ref}`Domain models` reference. +``` + + +```{eval-rst} +.. autoclass:: statemachine.model.Model + :members: +``` + +## TriggerData + + +```{eval-rst} +.. autoclass:: statemachine.event_data.TriggerData + :members: +``` + +## EventData + +```{eval-rst} +.. autoclass:: statemachine.event_data.EventData + :members: +``` diff --git a/docs/diagram.md b/docs/diagram.md index ebf9c390..1b5070b9 100644 --- a/docs/diagram.md +++ b/docs/diagram.md @@ -1,10 +1,10 @@ # Diagrams -You can generate diagrams from your state machine. +You can generate diagrams from your {ref}`StateMachine`. ```{note} This functionality depends on [pydot](https://github.com/pydot/pydot), it means that you need to -have pydot installed on your system. pydot is a Python library that allows you to create and +have `pydot` installed on your system. pydot is a Python library that allows you to create and manipulate graphs in [Graphviz](https://graphviz.org/)'s [dot language](https://graphviz.org/doc/info/lang.html). @@ -59,7 +59,7 @@ As this one: ![OrderControl](images/order_control_machine_initial.png) -The current state is also highlighted: +The current {ref}`state` is also highlighted: ``` py diff --git a/docs/guards.md b/docs/guards.md index 04aceab2..9160126b 100644 --- a/docs/guards.md +++ b/docs/guards.md @@ -1,10 +1,10 @@ (validators-and-guards)= # Validators and guards -Validations and Guards are checked before an transition is started. They are meant to stop a +Validations and Guards are checked before a transition is started. They are meant to stop a transition to occur. -The main difference, is that {ref}`validators` raise exceptions to stop the flow, and {ref}`guards` +The main difference is that {ref}`validators` raise exceptions to stop the flow, and {ref}`guards` act like predicates that shall resolve to a ``boolean`` value. ```{seealso} @@ -16,16 +16,16 @@ for all the available callbacks, being validators and guards or {ref}`actions`. Also known as **Conditional transition**. -A guard is a condition that may be checked when a statemachine wants to handle -an {ref}`event`. A guard is declared on the {ref}`transition`, and when that transition +A guard is a condition that may be checked when a {ref}`statemachine` wants to handle +an {ref}`event`. A guard is declared on the {ref}`transition`, and when that {ref}`transition` would trigger, then the guard (if any) is checked. If the guard is `True` then the transition does happen. If the guard is `False`, the transition is ignored. -When transitions have guards, then it's possible to define two or more -transitions for the same event from the same {ref}`state`. When the event happens, then +When {ref}`transitions` have guards, then it's possible to define two or more +transitions for the same {ref}`event` from the same {ref}`state`. When the {ref}`event` happens, then the guarded transitions are checked, one by one, and the first transition -whose guard is true will be used, the others will be ignored. +whose guard is true will be used, and the others will be ignored. A guard is generally a boolean function or boolean variable and must not have any side effects. Side effects are reserved for {ref}`actions`. @@ -66,7 +66,7 @@ So, a condition `s1.to(s2, cond=lambda: [])` will evaluate as `False`, as an emp Are like {ref}`guards`, but instead of evaluating to boolean, they are expected to raise an -exception to stop the flow. It may be useful for imperative style programming when you don't +exception to stop the flow. It may be useful for imperative-style programming when you don't want to continue evaluating other possible transitions and exit immediately. * Single validator: `validators="validator"` diff --git a/docs/index.md b/docs/index.md index ee97c8f8..a3bc95cf 100644 --- a/docs/index.md +++ b/docs/index.md @@ -17,6 +17,7 @@ mixins integrations diagram processing_model +api auto_examples/index contributing authors diff --git a/docs/integrations.md b/docs/integrations.md index b50dbdfa..4eee2519 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -3,7 +3,8 @@ ## Django integration -When used in a Django App, this library implements an auto-discovery hook similar to how Django's built-in **admin** [autodiscover](https://docs.djangoproject.com/en/dev/ref/contrib/admin/#django.contrib.admin.autodiscover). +When used in a Django App, this library implements an auto-discovery hook similar to how Django's +built-in **admin** [autodiscover](https://docs.djangoproject.com/en/dev/ref/contrib/admin/#django.contrib.admin.autodiscover). > This library attempts to import an **statemachine** or **statemachines** module in each installed > application. Such modules are expected to register `StateMachine` classes to be used with @@ -11,11 +12,12 @@ When used in a Django App, this library implements an auto-discovery hook simila ```{hint} -When using `python-statemachine` to control the state of a Django model, we advise keeping the ``StateMachine`` definitions on their modules. +When using `python-statemachine` to control the state of a Django model, we advise keeping the +{ref}`StateMachine` definitions on their own modules. So as circular references may occur, and as a way to help you organize your code, if you put state machines on modules named as mentioned above inside installed -Django Apps, these `StateMachine` classes will be automatically +Django Apps, these {ref}`StateMachine` classes will be automatically imported and registered. This is only an advice, nothing stops you do declare your state machine alongside your models. diff --git a/docs/mixins.md b/docs/mixins.md index 88742490..f2aaa90b 100644 --- a/docs/mixins.md +++ b/docs/mixins.md @@ -1,7 +1,7 @@ # Mixins -Your model can be inherited from a custom mixin to auto-instantiate a state machine. +Your {ref}`domain models` can be inherited from a custom mixin to auto-instantiate a {ref}`statemachine`. ## MachineMixin diff --git a/docs/models.md b/docs/models.md index cb3791fd..3936630a 100644 --- a/docs/models.md +++ b/docs/models.md @@ -13,16 +13,15 @@ If you don't pass an explicit model instance, this simple `Model` will be used: :linenos: ``` -```{eval-rst} -.. autoclass:: statemachine.model.Model - :members: -``` ```{seealso} -See the {ref}`sphx_glr_auto_examples_order_control_rich_model_machine.py` as example of using a domain object to hold attributes and methods to be used on the `StateMachine` definition. +See the {ref}`sphx_glr_auto_examples_order_control_rich_model_machine.py` as example of using a +domain object to hold attributes and methods to be used on the `StateMachine` definition. ``` ```{hint} -Domain models are registered as {ref}`observers`, so you can have the same level of functionalities provided to the built-in `StateMachine`, such as implementing all callbacks and guards on your domain model and keeping only the definition of states and transitions on -the `StateMachine`. +Domain models are registered as {ref}`observers`, so you can have the same level of functionalities +provided to the built-in {ref}`StateMachine`, such as implementing all {ref}`actions` and +{ref}`guards` on your domain model and keeping only the definition of {ref}`states` and +{ref}`transitions` on the {ref}`StateMachine`. ``` diff --git a/docs/releases/1.0.1.md b/docs/releases/1.0.1.md index 23e4ea99..09da5bf8 100644 --- a/docs/releases/1.0.1.md +++ b/docs/releases/1.0.1.md @@ -21,7 +21,7 @@ This is the last release to support Python 2.7, 3.5, and 3.6. ## What's new in 1.0 -### Validators and Guards +### Added validators and Guards Transitions now support `cond` and `unless` parameters, to restrict the execution. diff --git a/docs/releases/1.0.3.md b/docs/releases/1.0.3.md index dbb17b9e..083b4d4e 100644 --- a/docs/releases/1.0.3.md +++ b/docs/releases/1.0.3.md @@ -8,7 +8,7 @@ references of callbacks when there were multiple concurrent instances of the sam class. -## Bugfixes +## Bugfixes in 1.0.3 - [#334](https://github.com/fgmacedo/python-statemachine/issues/334): Fixed a shared reference of callbacks when there were multiple concurrent instances of the same `StateMachine` class. diff --git a/docs/releases/2.0.0.md b/docs/releases/2.0.0.md index 86ac5e09..c7870b3a 100644 --- a/docs/releases/2.0.0.md +++ b/docs/releases/2.0.0.md @@ -32,12 +32,6 @@ Even if you trigger an event inside an action. See {ref}`processing model` for more details. ``` -### Added support for translations (i18n) - -Now the library messages can be translated into any language. - -See {ref}`Add a translation` on how to contribute with translations. - ### State names are now optional {ref}`State` names are now by default derived from the class variable that they are assigned to. @@ -47,7 +41,7 @@ when it is different than the one derived from its id. ```py >>> from statemachine import StateMachine, State ->>> class SM(StateMachine): +>>> class ApprovalMachine(StateMachine): ... pending = State(initial=True) ... waiting_approval = State() ... approved = State(final=True) @@ -56,13 +50,13 @@ when it is different than the one derived from its id. ... approve = waiting_approval.to(approved) ... ->>> SM.pending.name +>>> ApprovalMachine.pending.name 'Pending' ->>> SM.waiting_approval.name +>>> ApprovalMachine.waiting_approval.name 'Waiting approval' ->>> SM.approved.name +>>> ApprovalMachine.approved.name 'Approved' ``` @@ -86,6 +80,41 @@ are ever executed as a result of an internal transition. See {ref}`internal transition` for more details. ``` +### Added option to ignore unknown events + +You can now instantiate a {ref}`StateMachine` with `allow_event_without_transition=True`, +so the state machine will allow triggering events that may not lead to a state {ref}`transition`, +including tolerance to unknown {ref}`event` triggers. + +The default value is ``False``, that keeps the backward compatible behavior of when an +event does not result in a {ref}`transition`, an exception ``TransitionNotAllowed`` will be raised. + +```py +>>> sm = ApprovalMachine(allow_event_without_transition=True) + +>>> sm.send("unknow_event_name") + +>>> sm.pending.is_active +True + +>>> sm.send("approve") + +>>> sm.pending.is_active +True + +>>> sm.send("start") + +>>> sm.waiting_approval.is_active +True + +``` + +### Added support for translations (i18n) + +Now the library messages can be translated into any language. + +See {ref}`Add a translation` on how to contribute with translations. + ## Minor features in 2.0 diff --git a/docs/states.md b/docs/states.md index b7a50edd..e0910390 100644 --- a/docs/states.md +++ b/docs/states.md @@ -1,19 +1,12 @@ # States -State, as the name says, holds the representation of a state in a statemachine. +{ref}`State`, as the name says, holds the representation of a state in a {ref}`StateMachine`. -## State +## Initial state -```{eval-rst} -.. autoclass:: statemachine.state.State - :members: -``` - -### Initial state - -A StateMachine should have one and only one `initial` state. +A {ref}`StateMachine` should have one and only one `initial` {ref}`state`. The initial {ref}`state` is entered when the machine starts and the corresponding entering @@ -21,7 +14,7 @@ state {ref}`actions` are called if defined. (final-state)= -### Final state +## Final state You can explicitly set final states. diff --git a/docs/transitions.md b/docs/transitions.md index 978e6464..c4d5c24d 100644 --- a/docs/transitions.md +++ b/docs/transitions.md @@ -43,9 +43,9 @@ In line 18, you can say that this code defines three transitions: And these transitions are assigned to the {ref}`event` `cycle` defined at the class level. -## Transition +## Transitions -In an executing state machine, a transition is a transfer from one state to another. In a state machine, a transition tells us what happens when an {ref}`event` occurs. +In an executing state machine, a {ref}`transition` is a transfer from one state to another. In a {ref}`statemachine`, a {ref}`transition` tells us what happens when an {ref}`event` occurs. ```{tip} @@ -59,11 +59,6 @@ An action associated with an event (before, on, after), will be assigned to all bounded that uses the event as trigger. ``` -```{eval-rst} -.. autoclass:: statemachine.transition.Transition - :members: -``` - ```{hint} Usually you don't need to import and use a {ref}`transition` class directly in your code, one of the most powerful features of this library is how transitions and events can be expressed diff --git a/statemachine/event.py b/statemachine/event.py index aa1f5bc5..3bdb1386 100644 --- a/statemachine/event.py +++ b/statemachine/event.py @@ -29,6 +29,7 @@ def trigger_wrapper(): return machine._process(trigger_wrapper) def _trigger(self, trigger_data: TriggerData): + event_data = None state = trigger_data.machine.current_state for transition in state.transitions: if not transition.match(trigger_data.event): @@ -39,9 +40,10 @@ def _trigger(self, trigger_data: TriggerData): event_data.executed = True break else: - raise TransitionNotAllowed(trigger_data.event, state) + if not trigger_data.machine.allow_event_without_transition: + raise TransitionNotAllowed(trigger_data.event, state) - return event_data.result + return event_data.result if event_data else None def trigger_event_factory(event): diff --git a/statemachine/event_data.py b/statemachine/event_data.py index 429366b0..d985db40 100644 --- a/statemachine/event_data.py +++ b/statemachine/event_data.py @@ -12,17 +12,18 @@ @dataclass class TriggerData: machine: "StateMachine" + event: str """The Event that was triggered.""" model: Any = field(init=False) - """A reference to the underlying model that holds the current State.""" + """A reference to the underlying model that holds the current :ref:`State`.""" args: tuple = field(default_factory=tuple) - """All positional arguments provided on the Event.""" + """All positional arguments provided on the :ref:`Event`.""" kwargs: dict = field(default_factory=dict) - """All keyword arguments provided on the Event.""" + """All keyword arguments provided on the :ref:`Event`.""" def __post_init__(self): self.model = self.machine.model @@ -31,17 +32,19 @@ def __post_init__(self): @dataclass class EventData: trigger_data: TriggerData + """The :ref:`TriggerData` of the :ref:`event`.""" + transition: "Transition" - """The Transition instance that was activated by the Event.""" + """The :ref:`Transition` instance that was activated by the :ref:`Event`.""" state: "State" = field(init=False) - """The current State of the state machine.""" + """The current :ref:`State` of the :ref:`statemachine`.""" source: "State" = field(init=False) - """The State the state machine was in when the Event started.""" + """The :ref:`State` which :ref:`statemachine` was in when the Event started.""" target: "State" = field(init=False) - """The destination State of the transition.""" + """The destination :ref:`State` of the :ref:`transition`.""" result: "Any | None" = None executed: bool = False diff --git a/statemachine/exceptions.py b/statemachine/exceptions.py index 3a4887c4..f389b4f4 100644 --- a/statemachine/exceptions.py +++ b/statemachine/exceptions.py @@ -23,7 +23,7 @@ class AttrNotFound(InvalidDefinition): class TransitionNotAllowed(StateMachineError): - "The transition can't run from the current state." + "Raised when there's no transition that can run from the current :ref:`state`." def __init__(self, event, state): self.event = event diff --git a/statemachine/state.py b/statemachine/state.py index eea938f8..b0e4e7e0 100644 --- a/statemachine/state.py +++ b/statemachine/state.py @@ -10,7 +10,7 @@ class State: """ - A State in a state machine describes a particular behavior of the machine. + A State in a :ref:`StateMachine` describes a particular behavior of the machine. When we say that a machine is “in” a state, it means that the machine behaves in the way that state describes. diff --git a/statemachine/statemachine.py b/statemachine/statemachine.py index 8c7542ab..b5c66856 100644 --- a/statemachine/statemachine.py +++ b/statemachine/statemachine.py @@ -22,12 +22,45 @@ class StateMachine(metaclass=StateMachineMetaclass): + """ - TransitionNotAllowed = TransitionNotAllowed # shortcut for handling exceptions + Args: + model: An optional external object to store state. See :ref:`domain models`. + + state_field (str): The model's field which stores the current state. + Default: ``state``. + + start_value: An optional start state value if there's no current state assigned + on the :ref:`domain models`. Default: ``None``. + + rtc (bool): Controls the :ref:`processing model`. Defaults to ``True`` + that corresponds to a **run-to-completion** (RTC) model. + + allow_event_without_transition: If ``False`` when an event does not result in a transition, + an exception ``TransitionNotAllowed`` will be raised. + If ``True`` the state machine allows triggering events that may not lead to a state + :ref:`transition`, including tolerance to unknown :ref:`event` triggers. + Default: ``False``. + + """ + + TransitionNotAllowed = TransitionNotAllowed + """Shortcut for easy exception handling. + + Example:: + + try: + sm.send("an-inexistent-event") + except sm.TransitionNotAllowed: + pass + """ _events: Dict[Any, Any] = {} states: List["State"] = [] + """List of all state machine :ref:`states`.""" + states_map: Dict[Any, "State"] = {} + """Map of ``state.value`` to the corresponding :ref:`state`.""" def __init__( self, @@ -35,10 +68,12 @@ def __init__( state_field: str = "state", start_value: Any = None, rtc: bool = True, + allow_event_without_transition: bool = False, ): self.model = model if model else Model() self.state_field = state_field self.start_value = start_value + self.allow_event_without_transition = allow_event_without_transition self.__rtc = rtc self.__processing: bool = False self._external_queue: deque = deque() @@ -54,7 +89,7 @@ def __init__( self._activate_initial_state(initial_transition) def __repr__(self): - current_state_id = self.current_state.id if self.current_state else None + current_state_id = self.current_state.id if self.current_state_value else None return ( f"{type(self).__name__}(model={self.model!r}, state_field={self.state_field!r}, " f"current_state={current_state_id!r})" @@ -126,6 +161,16 @@ def _setup(self, initial_transition): self.add_observer(machine, model) def add_observer(self, *observers): + """Add an observer. + + Observers are a way to generically add behavior to a :ref:`StateMachine` without changing + its internal implementation. + + .. seealso:: + + :ref:`observers`. + """ + resolvers = [resolver_factory(ObjectConfig.from_obj(o)) for o in observers] self._visit_states_and_transitions(lambda x: x._add_observer(*resolvers)) return self @@ -143,6 +188,11 @@ def _graph(self): @property def current_state_value(self): + """Get/Set the current :ref:`state` value. + + This is a low level API, that can be used to assign any valid state value + completely bypassing all the hooks and validations. + """ value = getattr(self.model, self.state_field, None) return value @@ -153,8 +203,13 @@ def current_state_value(self, value): setattr(self.model, self.state_field, value) @property - def current_state(self): - return self.states_map.get(self.current_state_value, None) + def current_state(self) -> "State": + """Get/Set the current :ref:`state`. + + This is a low level API, that can be to assign any valid state + completely bypassing all the hooks and validations. + """ + return self.states_map[self.current_state_value] @current_state.setter def current_state(self, value): @@ -166,7 +221,7 @@ def events(self): @property def allowed_events(self): - "get the callable proxy of the current allowed events" + """List of the current allowed events.""" return [ getattr(self, event) for event in self.current_state.transitions.unique_events @@ -257,5 +312,12 @@ def _activate(self, event_data: EventData): return result def send(self, event, *args, **kwargs): + """Send an :ref:`Event` to the state machine. + + .. seealso:: + + See: :ref:`triggering events`. + + """ event = Event(event) return event.trigger(self, *args, **kwargs) diff --git a/tests/examples/order_control_rich_model_machine.py b/tests/examples/order_control_rich_model_machine.py index 167bb5c0..47a040e2 100644 --- a/tests/examples/order_control_rich_model_machine.py +++ b/tests/examples/order_control_rich_model_machine.py @@ -49,8 +49,8 @@ class OrderControl(StateMachine): # %% -# Testing -# ------- +# Testing OrderControl +# -------------------- # # Let's first try to create a statemachine instance, using the default dummy model that doesn't # have the needed methods to complete the state machine. Since the required methods will not be diff --git a/tests/examples/persistent_model_machine.py b/tests/examples/persistent_model_machine.py index 77f47285..ddf86f2f 100644 --- a/tests/examples/persistent_model_machine.py +++ b/tests/examples/persistent_model_machine.py @@ -81,7 +81,7 @@ def _write_state(self, value): # %% # FilePersistentModel: Concrete model strategy -# ----------------------- +# -------------------------------------------- # # A concrete implementation of the generic storage protocol above, that reads and writes to a file # in plain text. diff --git a/tests/test_transitions.py b/tests/test_transitions.py index 1ac7b97f..e553f900 100644 --- a/tests/test_transitions.py +++ b/tests/test_transitions.py @@ -231,3 +231,19 @@ class TestStateMachine(StateMachine): final = State(final=True) execute = initial.to(initial, final, internal=True) + + +class TestAllowEventWithoutTransition: + def test_send_unknown_event(self, classic_traffic_light_machine): + sm = classic_traffic_light_machine(allow_event_without_transition=True) + assert sm.green.is_active + sm.send("unknow_event") + assert sm.green.is_active + + def test_send_not_valid_for_the_current_state_event( + self, classic_traffic_light_machine + ): + sm = classic_traffic_light_machine(allow_event_without_transition=True) + assert sm.green.is_active + sm.stop() + assert sm.green.is_active