Repository: pytransitions/transitions

Issue:
Ability to manage multiple states per Model;Custom state field for Model
First of all, thanks for this powerful lib :)

Currently the library makes an assumption that all Models will track the "state" using the `state` attribute. This assumption leads to two problems:

1. No ability to track the state changes in some other attribute/field.
2. A Model cannot have multiple states.

For example, If a use-case were to have two different fields on the Model. Both of them would represent different states and would be controlled by different Machines.

Something like this:
```python
lump = Matter()

lump.global_state
lump.pipeline_state

global_machine = Machine(lump, model_field='global_state', **kwargs)
pipeline_machine = Machine(lump, model_field='pipeline_state', **kwargs)
```

Is this out of scope of this library?

Discussion:
v1k45: @aleneum Thanks for the review, please take another look when you get time. :)

Patch:
diff --git a/README.md b/README.md
index 850ffba..0b35862 100644
--- a/README.md
+++ b/README.md
@@ -319,6 +319,8 @@ machine = Machine(lump, states=['A', 'B', 'C'])
 
 Now, any time `lump` transitions to state `A`, the `on_enter_A()` method defined in the `Matter` class will fire.
 
+#### <a name="checking-state"></a>Checking state
+
 You can always check the current state of the model by either:
 
 - inspecting the `.state` attribute, or
@@ -337,6 +339,15 @@ machine.get_state(lump.state).name
 >>> 'solid'
 ```
 
+If you'd like you track it using a different attribute, you could do that using the `model_attribute` argument while initializing the `Machine`.
+
+```python
+lump = Matter()
+machine = Machine(lump, states=['solid', 'liquid', 'gas'],  model='matter_state', initial='solid')
+lump.matter_state
+>>> 'solid'
+```
+
 #### <a name="enum-state"></a>Enumerations
 
 So far we have seen how we can give state names and use these names to work with our state machine. If you favour stricter typing and more IDE code completion (or you just can't type 'sesquipedalophobia' any longer because the word scares you) using [Enumerations](https://docs.python.org/3/library/enum.html) might be what you are looking for:
@@ -913,6 +924,20 @@ machine.add_model(Matter())
 machine.add_model(Matter(), initial='liquid')
 ```
 
+Models with multiple states could attach multiple machines using different `model_attribute` values:
+
+```python
+lump = Matter()
+
+lump.state
+>>> 'solid'
+lump.shipping_state
+>>> 'delivered'
+
+matter_machine = Machine(lump, model_attribute='state', **kwargs)
+shipment_machine = Machine(lump, model_attribute='shipping_state', **kwargs)
+```
+
 ### Logging
 
 Transitions includes very rudimentary logging capabilities. A number of events – namely, state changes, transition triggers, and conditional checks – are logged as INFO-level events using the standard Python `logging` module. This means you can easily configure logging to standard output in a script:
diff --git a/transitions/core.py b/transitions/core.py
index fb29a81..6fdb506 100644
--- a/transitions/core.py
+++ b/transitions/core.py
@@ -290,7 +290,7 @@ class Transition(object):
     def _change_state(self, event_data):
         event_data.machine.get_state(self.source).exit(event_data)
         event_data.machine.set_state(self.dest, event_data.model)
-        event_data.update(event_data.model.state)
+        event_data.update(getattr(event_data.model, event_data.machine.model_attribute))
         event_data.machine.get_state(self.dest).enter(event_data)
 
     def add_callback(self, trigger, func):
@@ -408,7 +408,7 @@ class Event(object):
         """ Internal trigger function called by the ``Machine`` instance. This should not
         be called directly but via the public method ``Machine.trigger``.
         """
-        state = self.machine.get_state(model.state)
+        state = self.machine.get_model_state(model)
         if state.name not in self.transitions:
             msg = "%sCan't trigger event %s from state %s!" % (self.machine.name, self.name,
                                                                state.name)
@@ -496,7 +496,7 @@ class Machine(object):
                  send_event=False, auto_transitions=True,
                  ordered_transitions=False, ignore_invalid_triggers=None,
                  before_state_change=None, after_state_change=None, name=None,
-                 queued=False, prepare_event=None, finalize_event=None, **kwargs):
+                 queued=False, prepare_event=None, finalize_event=None, model_attribute='state', **kwargs):
         """
         Args:
             model (object or list): The object(s) whose states we want to manage. If 'self',
@@ -570,6 +570,7 @@ class Machine(object):
         self.after_state_change = after_state_change
         self.finalize_event = finalize_event
         self.name = name + ": " if name is not None else ""
+        self.model_attribute = model_attribute
 
         self.models = []
 
@@ -728,7 +729,10 @@ class Machine(object):
         Returns:
             bool: Whether the model's current state is state.
         """
-        return model.state == state
+        return getattr(model, self.model_attribute) == state
+
+    def get_model_state(self, model):
+        return self.get_state(getattr(model, self.model_attribute))
 
     def set_state(self, state, model=None):
         """
@@ -740,7 +744,7 @@ class Machine(object):
         models = self.models if model is None else listify(model)
 
         for mod in models:
-            mod.state = state.value
+            setattr(mod, self.model_attribute, state.value)
 
     def add_state(self, *args, **kwargs):
         """ Alias for add_states. """
@@ -853,6 +857,8 @@ class Machine(object):
             **kwargs: Additional arguments which can be passed to the created transition.
                 This is useful if you plan to extend Machine.Transition and require more parameters.
         """
+        if trigger == self.model_attribute:
+            raise ValueError("Trigger name cannot be same as model attribute name.")
         if trigger not in self.events:
             self.events[trigger] = self._create_event(trigger, self)
             for model in self.models:
diff --git a/transitions/extensions/diagrams.py b/transitions/extensions/diagrams.py
index aad0fbc..c73828f 100644
--- a/transitions/extensions/diagrams.py
+++ b/transitions/extensions/diagrams.py
@@ -164,7 +164,7 @@ class GraphMachine(MarkupMachine):
             grph = self.graph_cls(self, title=title if title is not None else self.title)
             self.model_graphs[model] = grph
             try:
-                self.model_graphs[model].set_node_style(model.state, 'active')
+                self.model_graphs[model].set_node_style(getattr(model, self.model_attribute), 'active')
             except AttributeError:
                 _LOGGER.info("Could not set active state of diagram")
         try:
@@ -172,7 +172,7 @@ class GraphMachine(MarkupMachine):
         except KeyError:
             _ = self._get_graph(model, title, force_new=True)
             m = self.model_graphs[model]
-        m.roi_state = model.state if show_roi else None
+        m.roi_state = getattr(model, self.model_attribute) if show_roi else None
         return m.get_graph(title=title)
 
     def get_combined_graph(self, title=None, force_new=False, show_roi=False):
diff --git a/transitions/extensions/markup.py b/transitions/extensions/markup.py
index de1e0c8..1a7f347 100644
--- a/transitions/extensions/markup.py
+++ b/transitions/extensions/markup.py
@@ -105,10 +105,10 @@ class MarkupMachine(Machine):
 
     def _convert_models(self):
         models = []
-        for m in self.models:
-            model_def = dict(state=m.state)
-            model_def['name'] = m.name if hasattr(m, 'name') else str(id(m))
-            model_def['class-name'] = 'self' if m == self else m.__module__ + "." + m.__class__.__name__
+        for model in self.models:
+            model_def = dict(state=getattr(model, self.model_attribute))
+            model_def['name'] = model.name if hasattr(model, 'name') else str(id(model))
+            model_def['class-name'] = 'self' if model == self else model.__module__ + "." + model.__class__.__name__
             models.append(model_def)
         return models
 
diff --git a/transitions/extensions/nesting.py b/transitions/extensions/nesting.py
index dc2f26d..f014d49 100644
--- a/transitions/extensions/nesting.py
+++ b/transitions/extensions/nesting.py
@@ -220,7 +220,7 @@ class NestedTransition(Transition):
         machine = event_data.machine
         model = event_data.model
         dest_state = machine.get_state(self.dest)
-        source_state = machine.get_state(model.state)
+        source_state = machine.get_model_state(model)
         lvl = source_state.exit_nested(event_data, dest_state)
         event_data.machine.set_state(self.dest, model)
         event_data.update(dest_state)
@@ -245,13 +245,13 @@ class NestedEvent(Event):
     """ An event type to work with nested states. """
 
     def _trigger(self, model, *args, **kwargs):
-        state = self.machine.get_state(model.state)
+        state = self.machine.get_model_state(model)
         while state.parent and state.name not in self.transitions:
             state = state.parent
         if state.name not in self.transitions:
             msg = "%sCan't trigger event %s from state %s!" % (self.machine.name, self.name,
-                                                               model.state)
-            if self.machine.get_state(model.state).ignore_invalid_triggers:
+                                                               self.machine.get_model_state(model))
+            if self.machine.get_model_state(model).ignore_invalid_triggers:
                 _LOGGER.warning(msg)
             else:
                 raise MachineError(msg)
@@ -319,9 +319,9 @@ class HierarchicalMachine(Machine):
 
         """
         if not allow_substates:
-            return model.state == state_name
+            return getattr(model, self.model_attribute) == state_name
 
-        return self.get_state(model.state).is_substate_of(state_name)
+        return self.get_model_state(model).is_substate_of(state_name)
 
     def _traverse(self, states, on_enter=None, on_exit=None,
                   ignore_invalid_triggers=None, parent=None, remap=None):
@@ -515,6 +515,6 @@ class HierarchicalMachine(Machine):
             state_name (str): Name of the destination state.
         """
 
-        event = EventData(self.get_state(model.state), Event('to', self), self,
+        event = EventData(self.get_model_state(model), Event('to', self), self,
                           model, args=args, kwargs=kwargs)
-        self._create_transition(model.state, state_name).execute(event)
+        self._create_transition(getattr(model, self.model_attribute), state_name).execute(event)


Test patch:
diff --git a/tests/test_core.py b/tests/test_core.py
index d6b726c..c4551c3 100644
--- a/tests/test_core.py
+++ b/tests/test_core.py
@@ -1016,6 +1016,45 @@ class TestTransitions(TestCase):
         self.assertEqual(m.model.state, 'A')
         self.assertEqual(m.model.level, 2)
 
+    def test_dynamic_model_state_attribute(self):
+        class Model:
+            def __init__(self):
+                self.status = None
+                self.state = 'some_value'
+
+        m = Machine(Model(), states=['A', 'B'], initial='A', model_attribute='status')
+        self.assertEqual(m.model.status, 'A')
+        self.assertEqual(m.model.state, 'some_value')
+
+        m.add_transition('move', 'A', 'B')
+        m.model.move()
+
+        self.assertEqual(m.model.status, 'B')
+        self.assertEqual(m.model.state, 'some_value')
+
+    def test_multiple_machines_per_model(self):
+        class Model:
+            def __init__(self):
+                self.state_a = None
+                self.state_b = None
+
+        instance = Model()
+        machine_a = Machine(instance, states=['A', 'B'], initial='A', model_attribute='state_a')
+        machine_a.add_transition('melt', 'A', 'B')
+        machine_b = Machine(instance, states=['A', 'B'], initial='B', model_attribute='state_b')
+        machine_b.add_transition('freeze', 'B', 'A')
+
+        self.assertEqual(instance.state_a, 'A')
+        self.assertEqual(instance.state_b, 'B')
+
+        instance.melt()
+        self.assertEqual(instance.state_a, 'B')
+        self.assertEqual(instance.state_b, 'B')
+
+        instance.freeze()
+        self.assertEqual(instance.state_b, 'A')
+        self.assertEqual(instance.state_a, 'B')
+
 
 class TestWarnings(TestCase):
     def test_warning(self):