pytransitions / transitions

A lightweight, object-oriented finite state machine implementation in Python with many extensions
MIT License
5.49k stars 524 forks source link

Parent callbacks take precendence over child callbacks #523

Closed edenworky closed 3 years ago

edenworky commented 3 years ago

Possibly related to #501, but that discussion seems mostly to be about updating the docs so I'm opening a new issue.

Basically what the title says, as illustrated here:

from transitions.extensions.nesting import (
  HierarchicalMachine as Machine,
  NestedState as State,
)

class Base():
    def __init__(self):
        self.machine = Machine(self, **self._make_definition())

    def _make_definition(self): raise NotImplementedError

start_trigger = {'trigger': 'start', 'source': 'idle', 'dest': 'started', 'before': '_start'}

class Parent(Base):
    def _make_definition(self):
        return {
            'initial': 'idle',
            'states': ['idle', 'started', {'name': 'child', 'children': Child().machine}],
            'transitions': [
                start_trigger,
            ]
        }

    def _start(self):
        print('Parent started')

class Child(Base):
    def _make_definition(self):
        return {
            'initial': 'idle',
            'states': ['idle', 'started'],
            'transitions': [
                start_trigger,
            ]
        }

    def _start(self):
        print('Child started')

p = Parent()
assert p.is_idle()
p.start() # Parent started
assert p.is_started()
p.to_child()
assert p.is_child_idle()
p.start() # !! Parent started !! even though child started
assert p.is_child_started()

I'll see if I can get it to work with state callbacks rather than trigger callbacks, but this still doesn't seem like intended behavior. It also means you can't pass data into a child state without a unique trigger to my understanding, can this be?

EDIT: The same problem happens with state callbacks, as illustrated here:

from transitions.extensions.nesting import (
  HierarchicalMachine as Machine,
  NestedState as State,
)

class Base():
    def __init__(self):
        self.machine = Machine(self, **self._make_definition())

    def _make_definition(self): raise NotImplementedError

class Parent(Base):
    def _make_definition(self):
        return {
            'initial': 'idle',
            'states': [
                'idle',
                State('starting', on_enter='_start'),
                'started',
                {'name': 'child', 'children': Child().machine}
            ],
            'transitions': [
                ['start', 'idle', 'starting']
            ]
        }

    def _start(self):
        print('Parent started')
        self.to_started()

class Child(Base):
    def _make_definition(self):
        return {
            'initial': 'idle',
            'states': ['idle', State('starting', on_enter='_start'), 'started'],
            'transitions': [
                ['start', 'idle', 'starting']
            ]
        }

    def _start(self):
        print('Child started')
        self.to_started()

p = Parent()
assert p.is_idle()
p.start() # Parent started
assert p.is_started()
p.to_child()
assert p.is_child_idle()
p.start() # !! Parent started !! even though child started
assert p.is_child_started()
aleneum commented 3 years ago

Hello @edenworky,

the link just opens a notebook at the project root. Could you provide a minimal example that reproduces your issue?

edenworky commented 3 years ago

Sorry, I'm apparently confused about jupyter stuff. Edited with code.

aleneum commented 3 years ago

You pass your callback as a string (_start). The ReadMe states:

The ReadMe states here:

As you have probably already realized, the standard way of passing callables to states, conditions and transitions is by name. When processing callbacks and conditions, transitions will use their name to retrieve the related callable from the model.

Callbacks are evaluated 'lazy' as in "when the trigger is called.". So the confusion here is: What is the model? The answer is: It is your parent object! When you pass Child.machine to Parent, the state machine will reference the mentioned states but it will not use the child object as a model. More precisely, your p has no knowledge about the created Child instance.

If you want to reference methods on a specific object, you need to use references:

class Child(Base):
    def _make_definition(self):
        return {
            'initial': 'idle',
            'states': ['idle', 'started'],
            'transitions': [
                {'trigger': 'start', 'source': 'idle', 'dest': 'started', 'before': self._start},
            ]
        }

    def _start(self):
        print('Child started')

You could also use module functions as described in the above mentioned section if you want to stick with strings.

edenworky commented 3 years ago

That's brilliant! Thanks for the clarification