states.py 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181
  1. """
  2. transitions.extensions.states
  3. -----------------------------
  4. This module contains mix ins which can be used to extend state functionality.
  5. """
  6. from threading import Timer
  7. import logging
  8. import inspect
  9. from ..core import MachineError, listify, State
  10. _LOGGER = logging.getLogger(__name__)
  11. _LOGGER.addHandler(logging.NullHandler())
  12. class Tags(State):
  13. """ Allows states to be tagged.
  14. Attributes:
  15. tags (list): A list of tag strings. `State.is_<tag>` may be used
  16. to check if <tag> is in the list.
  17. """
  18. def __init__(self, *args, **kwargs):
  19. """
  20. Args:
  21. **kwargs: If kwargs contains `tags`, assign them to the attribute.
  22. """
  23. self.tags = kwargs.pop('tags', [])
  24. super(Tags, self).__init__(*args, **kwargs)
  25. def __getattr__(self, item):
  26. if item.startswith('is_'):
  27. return item[3:] in self.tags
  28. return super(Tags, self).__getattribute__(item)
  29. class Error(Tags):
  30. """ This mix in builds upon tag and should be used INSTEAD of Tags if final states that have
  31. not been tagged with 'accepted' should throw an `MachineError`.
  32. """
  33. def __init__(self, *args, **kwargs):
  34. """
  35. Args:
  36. **kwargs: If kwargs contains the keywork `accepted` add the 'accepted' tag to a tag list
  37. which will be forwarded to the Tags constructor.
  38. """
  39. tags = kwargs.get('tags', [])
  40. accepted = kwargs.pop('accepted', False)
  41. if accepted:
  42. tags.append('accepted')
  43. kwargs['tags'] = tags
  44. super(Error, self).__init__(*args, **kwargs)
  45. def enter(self, event_data):
  46. """ Extends transitions.core.State.enter. Throws a `MachineError` if there is
  47. no leaving transition from this state and 'accepted' is not in self.tags.
  48. """
  49. if not event_data.machine.get_triggers(self.name) and not self.is_accepted:
  50. raise MachineError("Error state '{0}' reached!".format(self.name))
  51. super(Error, self).enter(event_data)
  52. class Timeout(State):
  53. """ Adds timeout functionality to a state. Timeouts are handled model-specific.
  54. Attributes:
  55. timeout (float): Seconds after which a timeout function should be called.
  56. on_timeout (list): Functions to call when a timeout is triggered.
  57. """
  58. dynamic_methods = ['on_timeout']
  59. def __init__(self, *args, **kwargs):
  60. """
  61. Args:
  62. **kwargs: If kwargs contain 'timeout', assign the float value to self.timeout. If timeout
  63. is set, 'on_timeout' needs to be passed with kwargs as well or an AttributeError will
  64. be thrown. If timeout is not passed or equal 0.
  65. """
  66. self.timeout = kwargs.pop('timeout', 0)
  67. self._on_timeout = None
  68. if self.timeout > 0:
  69. try:
  70. self.on_timeout = kwargs.pop('on_timeout')
  71. except KeyError:
  72. raise AttributeError("Timeout state requires 'on_timeout' when timeout is set.")
  73. else:
  74. self._on_timeout = kwargs.pop('on_timeout', [])
  75. self.runner = {}
  76. super(Timeout, self).__init__(*args, **kwargs)
  77. def enter(self, event_data):
  78. """ Extends `transitions.core.State.enter` by starting a timeout timer for the current model
  79. when the state is entered and self.timeout is larger than 0.
  80. """
  81. if self.timeout > 0:
  82. timer = Timer(self.timeout, self._process_timeout, args=(event_data,))
  83. timer.setDaemon(True)
  84. timer.start()
  85. self.runner[id(event_data.model)] = timer
  86. return super(Timeout, self).enter(event_data)
  87. def exit(self, event_data):
  88. """ Extends `transitions.core.State.exit` by canceling a timer for the current model. """
  89. timer = self.runner.get(id(event_data.model), None)
  90. if timer is not None and timer.is_alive():
  91. timer.cancel()
  92. return super(Timeout, self).exit(event_data)
  93. def _process_timeout(self, event_data):
  94. _LOGGER.debug("%sTimeout state %s. Processing callbacks...", event_data.machine.name, self.name)
  95. for callback in self.on_timeout:
  96. event_data.machine.callback(callback, event_data)
  97. _LOGGER.info("%sTimeout state %s processed.", event_data.machine.name, self.name)
  98. @property
  99. def on_timeout(self):
  100. """ List of strings and callables to be called when the state timeouts. """
  101. return self._on_timeout
  102. @on_timeout.setter
  103. def on_timeout(self, value):
  104. """ Listifies passed values and assigns them to on_timeout."""
  105. self._on_timeout = listify(value)
  106. class Volatile(State):
  107. """ Adds scopes/temporal variables to the otherwise persistent state objects.
  108. Attributes:
  109. volatile_cls (cls): Class of the temporal object to be initiated.
  110. volatile_hook (str): Model attribute name which will contain the volatile instance.
  111. """
  112. def __init__(self, *args, **kwargs):
  113. """
  114. Args:
  115. **kwargs: If kwargs contains `volatile`, always create an instance of the passed class
  116. whenever the state is entered. The instance is assigned to a model attribute which
  117. can be passed with the kwargs keyword `hook`. If hook is not passed, the instance will
  118. be assigned to the 'attribute' scope. If `volatile` is not passed, an empty object will
  119. be assigned to the model's hook.
  120. """
  121. self.volatile_cls = kwargs.pop('volatile', VolatileObject)
  122. self.volatile_hook = kwargs.pop('hook', 'scope')
  123. super(Volatile, self).__init__(*args, **kwargs)
  124. self.initialized = True
  125. def enter(self, event_data):
  126. """ Extends `transitions.core.State.enter` by creating a volatile object and assign it to
  127. the current model's hook. """
  128. setattr(event_data.model, self.volatile_hook, self.volatile_cls())
  129. super(Volatile, self).enter(event_data)
  130. def exit(self, event_data):
  131. """ Extends `transitions.core.State.exit` by deleting the temporal object from the model. """
  132. super(Volatile, self).exit(event_data)
  133. try:
  134. delattr(event_data.model, self.volatile_hook)
  135. except AttributeError:
  136. pass
  137. def add_state_features(*args):
  138. """ State feature decorator. Should be used in conjunction with a custom Machine class. """
  139. def _class_decorator(cls):
  140. class CustomState(type('CustomState', args, {}), cls.state_cls):
  141. """ The decorated State. It is based on the State class used by the decorated Machine. """
  142. pass
  143. method_list = sum([c.dynamic_methods for c in inspect.getmro(CustomState) if hasattr(c, 'dynamic_methods')], [])
  144. CustomState.dynamic_methods = list(set(method_list))
  145. cls.state_cls = CustomState
  146. return cls
  147. return _class_decorator
  148. class VolatileObject(object):
  149. """ Empty Python object which can be used to assign attributes to."""
  150. pass