Skip to content

Commit

Permalink
move everything to utils, add documentation about base model generati…
Browse files Browse the repository at this point in the history
…on and model_override
  • Loading branch information
aleneum committed Jun 13, 2024
1 parent 05ec3ed commit dfef212
Show file tree
Hide file tree
Showing 8 changed files with 166 additions and 59 deletions.
74 changes: 74 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ A lightweight, object-oriented state machine implementation in Python with many
- [Alternative initialization patterns](#alternative-initialization-patterns)
- [Logging](#logging)
- [(Re-)Storing machine instances](#restoring)
- [Typing support](#typing-support)
- [Extensions](#extensions)
- [Diagrams](#diagrams)
- [Hierarchical State Machine](#hsm)
Expand Down Expand Up @@ -1226,6 +1227,79 @@ m2.states.keys()
>>> ['A', 'B', 'C']
```

### <a name="typing-support"></a> Typing support

As you probably noticed, `transitions` uses some of Python's dynamic features to give you handy ways to handle models. However, static type checkers don't like model attributes and methods not being known before runtime. Historically, `transitions` also didn't assign convenience methods already defined on models to prevent accidental overrides.

But don't worry! You can use the machine constructor parameter `model_override` to change how models are decorated. If you set `model_override=True`, `transitions` will only override already defined methods. This prevents new methods from showing up at runtime and also allows you to define which helper methods you want to use.

```python
from transitions import Machine

# Dynamic assignment
class Model:
pass

model = Model()
default_machine = Machine(model, states=["A", "B"], transitions=[["go", "A", "B"]], initial="A")
print(model.__dict__.keys()) # all convenience functions have been assigned
# >> dict_keys(['trigger', 'to_A', 'may_to_A', 'to_B', 'may_to_B', 'go', 'may_go', 'is_A', 'is_B', 'state'])
assert model.is_A() # Unresolved attribute reference 'is_A' for class 'Model'


# Predefined assigment: We are just interested in calling our 'go' event and will trigger the other events by name
class PredefinedModel:
# state (or another parameter if you set 'model_attribute') will be assigned anyway
# because we need to keep track of the model's state
state: str

def go(self) -> bool:
raise RuntimeError("Should be overridden!")

def trigger(self, trigger_name: str) -> bool:
raise RuntimeError("Should be overridden!")


model = PredefinedModel()
override_machine = Machine(model, states=["A", "B"], transitions=[["go", "A", "B"]], initial="A", model_override=True)
print(model.__dict__.keys())
# >> dict_keys(['trigger', 'go', 'state'])
model.trigger("to_B")
assert model.state == "B"
```

If you want to use all the convenience functions and throw some callbacks into the mix, defining a model can get pretty complicated when you have a lot of states and transitions defined.
The method `generate_base_model` in `transitions` can generate a base model from a machine configuration to help you out with that.

```python
from transitions.experimental.utils import generate_base_model
simple_config = {
"states": ["A", "B"],
"transitions": [
["go", "A", "B"],
],
"initial": "A",
"before_state_change": "call_this",
"model_override": True,
}

class_definition = generate_base_model(simple_config)
with open("base_model.py", "w") as f:
f.write(class_definition)

# ... in another file
from transitions import Machine
from base_model import BaseModel

class Model(BaseModel): # call_this will be an abstract method in BaseModel

def call_this(self) -> None:
# do something

model = Model()
machine = Machine(model, **simple_config)
```

### <a name="extensions"></a> Extensions

Even though the core of transitions is kept lightweight, there are a variety of MixIns to extend its functionality. Currently supported are:
Expand Down
15 changes: 7 additions & 8 deletions tests/test_experimental.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,7 @@
from unittest.mock import MagicMock

from transitions import Machine
from transitions.experimental.typing import generate_base_model
from transitions.experimental.decoration import trigger, with_trigger_decorator
from transitions.experimental.utils import generate_base_model, trigger_decorator, with_trigger_decorator
from transitions.extensions import HierarchicalMachine

from .utils import Stuff
Expand Down Expand Up @@ -137,8 +136,8 @@ class Model:
def is_B(self) -> bool:
return False

@trigger(source="A", dest="B")
@trigger(source=["A", "B"], dest="C")
@trigger_decorator(source="A", dest="B")
@trigger_decorator(source=["A", "B"], dest="C")
def go(self) -> bool:
raise RuntimeError("Should be overridden!")

Expand All @@ -164,13 +163,13 @@ class Model:
def check_param(self, param: bool) -> bool:
return param

@trigger(source="A", dest="B")
@trigger(source="B", dest="C", unless=Stuff.this_passes)
@trigger(source="B", dest="A", conditions=Stuff.this_passes, unless=Stuff.this_fails)
@trigger_decorator(source="A", dest="B")
@trigger_decorator(source="B", dest="C", unless=Stuff.this_passes)
@trigger_decorator(source="B", dest="A", conditions=Stuff.this_passes, unless=Stuff.this_fails)
def go(self) -> bool:
raise RuntimeError("Should be overridden")

@trigger(source="A", dest="B", conditions="check_param")
@trigger_decorator(source="A", dest="B", conditions="check_param")
def event(self, param) -> bool:
raise RuntimeError("Should be overridden")

Expand Down
2 changes: 2 additions & 0 deletions transitions/core.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ class MachineConfig(TypedDict, total=False):
transitions: List[TransitionConfig]
initial: str
auto_transitions: bool
ordered_transitions: bool
send_event: bool
ignore_invalid_triggers: bool
before_state_change: CallbacksArg
Expand All @@ -33,6 +34,7 @@ class MachineConfig(TypedDict, total=False):
model_override: bool
model_attribute: str
on_exception: CallbacksArg
on_final: CallbacksArg


def listify(obj: Union[None, List[Any], Tuple[Any], EnumMeta, Any]) -> Union[List[Any], Tuple[Any], EnumMeta]: ...
Expand Down
33 changes: 0 additions & 33 deletions transitions/experimental/decoration.py

This file was deleted.

11 changes: 0 additions & 11 deletions transitions/experimental/decoration.pyi

This file was deleted.

7 changes: 0 additions & 7 deletions transitions/experimental/typing.pyi

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
from collections import deque, defaultdict
from typing import Iterable, Optional, TYPE_CHECKING

from transitions.core import listify
from transitions.extensions.markup import HierarchicalMarkupMachine

if TYPE_CHECKING:
from transitions.core import TransitionConfig


_placeholder_body = "raise RuntimeError('This should be overridden')"


Expand Down Expand Up @@ -85,3 +93,55 @@ def trigger(self, name: str) -> bool: {_placeholder_body}
{callback_block}"""

return template


def with_trigger_decorator(cls):
add_model = getattr(cls, "add_model")

def add_model_override(self, model, initial=None) -> None:
self.model_override = True
for model in listify(model):
model = self if model == "self" else model
for name, specs in TriggerPlaceholder.configurations.get(model.__class__.__name__, {}).items():
for spec in specs:
self.add_transition(name, **spec)
add_model(self, model, initial)

setattr(cls, 'add_model', add_model_override)
return cls


class TriggerPlaceholder:
configurations = defaultdict(lambda: defaultdict(deque))

def __init__(self, configs: Iterable["TransitionConfig"]):
self.configs = configs

def __set_name__(self, owner, name):
for config in self.configs:
TriggerPlaceholder.configurations[owner.__name__][name].append(config)

def __call__(self, *args, **kwargs) -> Optional[bool]:
raise RuntimeError("Trigger was not initialized correctly!")


def transition(source, dest=None, conditions=None, unless=None, before=None, after=None, prepare=None):
return {"source": source, "dest": dest, "conditions": conditions, "unless": unless, "before": before,
"after": after, "prepare": prepare}


def trigger(*configs: "TransitionConfig"):
return TriggerPlaceholder(configs)


def trigger_decorator(source, dest=None, conditions=None, unless=None, before=None, after=None, prepare=None):
def _outer(trigger_func):
trigger_name = trigger_func.__name__
class_name = trigger_func.__qualname__.split('<locals>.', 1)[1].rsplit('.', 1)[0]
TriggerPlaceholder.configurations[class_name][trigger_name].appendleft({
"source": source, "dest": dest, "conditions": conditions, "unless": unless, "before": before,
"after": after, "prepare": prepare
})
return trigger_func

return _outer
23 changes: 23 additions & 0 deletions transitions/experimental/utils.pyi
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
from typing import Union, Type, Callable, List, Optional
from transitions.core import StateIdentifier, CallbacksArg, CallbackFunc, Machine, TransitionConfig, MachineConfig
from transitions.extensions.markup import MarkupConfig

_placeholder_body: str

def generate_base_model(config: Union[MachineConfig, MarkupConfig]) -> str: ...

def with_trigger_decorator(cls: Type[Machine]) -> Type[Machine]: ...

def trigger_decorator(source: Union[StateIdentifier, List[StateIdentifier]],
dest: Optional[StateIdentifier] = ...,
conditions: CallbacksArg = ..., unless: CallbacksArg = ...,
before: CallbacksArg = ..., after: CallbacksArg = ...,
prepare: CallbacksArg = ...) -> Callable[[CallbackFunc], CallbackFunc]: ...

def transition(source: Union[StateIdentifier, List[StateIdentifier]],
dest: Optional[StateIdentifier] = ...,
conditions: CallbacksArg = ..., unless: CallbacksArg = ...,
before: CallbacksArg = ..., after: CallbacksArg = ...,
prepare: CallbacksArg = ...) -> TransitionConfig: ...

def trigger(*configs: TransitionConfig) -> CallbackFunc: ...

0 comments on commit dfef212

Please sign in to comment.