mirror of
				https://github.com/avatao-content/baseimage-tutorial-framework
				synced 2025-11-04 07:52:55 +00:00 
			
		
		
		
	Simplify package structure
This commit is contained in:
		
							
								
								
									
										3
									
								
								tfw/fsm/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								tfw/fsm/__init__.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,3 @@
 | 
			
		||||
from .fsm_base import FSMBase
 | 
			
		||||
from .linear_fsm import LinearFSM
 | 
			
		||||
from .yaml_fsm import YamlFSM
 | 
			
		||||
							
								
								
									
										84
									
								
								tfw/fsm/fsm_base.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										84
									
								
								tfw/fsm/fsm_base.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,84 @@
 | 
			
		||||
import logging
 | 
			
		||||
from collections import defaultdict
 | 
			
		||||
from datetime import datetime
 | 
			
		||||
 | 
			
		||||
from transitions import Machine, MachineError
 | 
			
		||||
 | 
			
		||||
from tfw.internals.callback_mixin import CallbackMixin
 | 
			
		||||
 | 
			
		||||
LOG = logging.getLogger(__name__)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class FSMBase(Machine, CallbackMixin):
 | 
			
		||||
    """
 | 
			
		||||
    A general FSM base class you can inherit from to track user progress.
 | 
			
		||||
    See linear_fsm.py for an example use-case.
 | 
			
		||||
    TFW uses the transitions library for state machines, please refer to their
 | 
			
		||||
    documentation for more information on creating your own machines:
 | 
			
		||||
    https://github.com/pytransitions/transitions
 | 
			
		||||
    """
 | 
			
		||||
    states, transitions = [], []
 | 
			
		||||
 | 
			
		||||
    def __init__(self, initial=None, accepted_states=None):
 | 
			
		||||
        """
 | 
			
		||||
        :param initial: which state to begin with, defaults to the last one
 | 
			
		||||
        :param accepted_states: list of states in which the challenge should be
 | 
			
		||||
                                considered successfully completed
 | 
			
		||||
        """
 | 
			
		||||
        self.accepted_states = accepted_states or [self.states[-1].name]
 | 
			
		||||
        self.trigger_predicates = defaultdict(list)
 | 
			
		||||
        self.event_log = []
 | 
			
		||||
 | 
			
		||||
        Machine.__init__(
 | 
			
		||||
            self,
 | 
			
		||||
            states=self.states,
 | 
			
		||||
            transitions=self.transitions,
 | 
			
		||||
            initial=initial or self.states[0],
 | 
			
		||||
            send_event=True,
 | 
			
		||||
            ignore_invalid_triggers=True,
 | 
			
		||||
            after_state_change='execute_callbacks'
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def execute_callbacks(self, event_data):
 | 
			
		||||
        self._execute_callbacks(event_data.kwargs)
 | 
			
		||||
 | 
			
		||||
    def is_solved(self):
 | 
			
		||||
        return self.state in self.accepted_states  # pylint: disable=no-member
 | 
			
		||||
 | 
			
		||||
    def subscribe_predicate(self, trigger, *predicates):
 | 
			
		||||
        self.trigger_predicates[trigger].extend(predicates)
 | 
			
		||||
 | 
			
		||||
    def unsubscribe_predicate(self, trigger, *predicates):
 | 
			
		||||
        self.trigger_predicates[trigger] = [
 | 
			
		||||
            predicate
 | 
			
		||||
            for predicate in self.trigger_predicates[trigger]
 | 
			
		||||
            not in predicates
 | 
			
		||||
        ]
 | 
			
		||||
 | 
			
		||||
    def step(self, trigger):
 | 
			
		||||
        predicate_results = (
 | 
			
		||||
            predicate()
 | 
			
		||||
            for predicate in self.trigger_predicates[trigger]
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        if all(predicate_results):
 | 
			
		||||
            try:
 | 
			
		||||
                from_state = self.state
 | 
			
		||||
                self.trigger(trigger)
 | 
			
		||||
                self.update_event_log(from_state, trigger)
 | 
			
		||||
                return True
 | 
			
		||||
            except (AttributeError, MachineError):
 | 
			
		||||
                LOG.debug('FSM failed to execute nonexistent trigger: "%s"', trigger)
 | 
			
		||||
        return False
 | 
			
		||||
 | 
			
		||||
    def update_event_log(self, from_state, trigger):
 | 
			
		||||
        self.event_log.append({
 | 
			
		||||
            'from_state': from_state,
 | 
			
		||||
            'to_state': self.state,
 | 
			
		||||
            'trigger': trigger,
 | 
			
		||||
            'timestamp': datetime.utcnow()
 | 
			
		||||
        })
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def in_accepted_state(self):
 | 
			
		||||
        return self.state in self.accepted_states
 | 
			
		||||
							
								
								
									
										32
									
								
								tfw/fsm/linear_fsm.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								tfw/fsm/linear_fsm.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,32 @@
 | 
			
		||||
from transitions import State
 | 
			
		||||
 | 
			
		||||
from .fsm_base import FSMBase
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class LinearFSM(FSMBase):
 | 
			
		||||
    # pylint: disable=anomalous-backslash-in-string
 | 
			
		||||
    """
 | 
			
		||||
    This is a state machine for challenges with linear progression, consisting of
 | 
			
		||||
    a number of steps specified in the constructor. It automatically sets up 2
 | 
			
		||||
    actions (triggers) between states as such:
 | 
			
		||||
    (0) --  step_1     --> (1) --  step_2     --> (2) --  step_3     --> (3) ...
 | 
			
		||||
    (0) --  step_next  --> (1) --  step_next  --> (2) --  step_next  --> (3) ...
 | 
			
		||||
    """
 | 
			
		||||
    def __init__(self, number_of_steps):
 | 
			
		||||
        """
 | 
			
		||||
        :param number_of_steps: how many states this FSM should have
 | 
			
		||||
        """
 | 
			
		||||
        self.states = [State(name=str(index)) for index in range(number_of_steps)]
 | 
			
		||||
        self.transitions = []
 | 
			
		||||
        for state in self.states[:-1]:
 | 
			
		||||
            self.transitions.append({
 | 
			
		||||
                'trigger': f'step_{int(state.name)+1}',
 | 
			
		||||
                'source': state.name,
 | 
			
		||||
                'dest': str(int(state.name)+1)
 | 
			
		||||
            })
 | 
			
		||||
            self.transitions.append({
 | 
			
		||||
                'trigger': 'step_next',
 | 
			
		||||
                'source': state.name,
 | 
			
		||||
                'dest': str(int(state.name)+1)
 | 
			
		||||
            })
 | 
			
		||||
        super(LinearFSM, self).__init__()
 | 
			
		||||
							
								
								
									
										105
									
								
								tfw/fsm/yaml_fsm.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										105
									
								
								tfw/fsm/yaml_fsm.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,105 @@
 | 
			
		||||
from subprocess import Popen, run
 | 
			
		||||
from functools import partial, singledispatch
 | 
			
		||||
from contextlib import suppress
 | 
			
		||||
 | 
			
		||||
import yaml
 | 
			
		||||
import jinja2
 | 
			
		||||
from transitions import State
 | 
			
		||||
 | 
			
		||||
from .fsm_base import FSMBase
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class YamlFSM(FSMBase):
 | 
			
		||||
    """
 | 
			
		||||
    This is a state machine capable of building itself from a YAML config file.
 | 
			
		||||
    """
 | 
			
		||||
    def __init__(self, config_file, jinja2_variables=None):
 | 
			
		||||
        """
 | 
			
		||||
        :param config_file: path of the YAML file
 | 
			
		||||
        :param jinja2_variables: dict containing jinja2 variables
 | 
			
		||||
                                 or str with filename of YAML file to
 | 
			
		||||
                                 parse and use as dict.
 | 
			
		||||
                                 jinja2 support is disabled if this is None
 | 
			
		||||
        """
 | 
			
		||||
        self.config = ConfigParser(config_file, jinja2_variables).config
 | 
			
		||||
        self.setup_states()
 | 
			
		||||
        super().__init__()  # FSMBase.__init__() requires states
 | 
			
		||||
        self.setup_transitions()
 | 
			
		||||
 | 
			
		||||
    def setup_states(self):
 | 
			
		||||
        self.for_config_states_and_transitions_do(self.wrap_callbacks_with_subprocess_call)
 | 
			
		||||
        self.states = [State(**state) for state in self.config['states']]
 | 
			
		||||
 | 
			
		||||
    def setup_transitions(self):
 | 
			
		||||
        self.for_config_states_and_transitions_do(self.subscribe_and_remove_predicates)
 | 
			
		||||
        for transition in self.config['transitions']:
 | 
			
		||||
            self.add_transition(**transition)
 | 
			
		||||
 | 
			
		||||
    def for_config_states_and_transitions_do(self, what):
 | 
			
		||||
        for array in ('states', 'transitions'):
 | 
			
		||||
            for json_obj in self.config[array]:
 | 
			
		||||
                what(json_obj)
 | 
			
		||||
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    def wrap_callbacks_with_subprocess_call(json_obj):
 | 
			
		||||
        topatch = ('on_enter', 'on_exit', 'prepare', 'before', 'after')
 | 
			
		||||
        for key in json_obj:
 | 
			
		||||
            if key in topatch:
 | 
			
		||||
                json_obj[key] = partial(run_command_async, json_obj[key])
 | 
			
		||||
 | 
			
		||||
    def subscribe_and_remove_predicates(self, json_obj):
 | 
			
		||||
        if 'predicates' in json_obj:
 | 
			
		||||
            for predicate in json_obj['predicates']:
 | 
			
		||||
                self.subscribe_predicate(
 | 
			
		||||
                    json_obj['trigger'],
 | 
			
		||||
                    partial(
 | 
			
		||||
                        command_statuscode_is_zero,
 | 
			
		||||
                        predicate
 | 
			
		||||
                    )
 | 
			
		||||
                )
 | 
			
		||||
 | 
			
		||||
        with suppress(KeyError):
 | 
			
		||||
            json_obj.pop('predicates')
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def run_command_async(command, _):
 | 
			
		||||
    Popen(command, shell=True)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def command_statuscode_is_zero(command):
 | 
			
		||||
    return run(command, shell=True).returncode == 0
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ConfigParser:
 | 
			
		||||
    def __init__(self, config_file, jinja2_variables):
 | 
			
		||||
        self.read_variables = singledispatch(self._read_variables)
 | 
			
		||||
        self.read_variables.register(dict, self._read_variables_dict)
 | 
			
		||||
        self.read_variables.register(str, self._read_variables_str)
 | 
			
		||||
 | 
			
		||||
        self.config = self.parse_config(config_file, jinja2_variables)
 | 
			
		||||
 | 
			
		||||
    def parse_config(self, config_file, jinja2_variables):
 | 
			
		||||
        config_string = self.read_file(config_file)
 | 
			
		||||
        if jinja2_variables is not None:
 | 
			
		||||
            variables = self.read_variables(jinja2_variables)
 | 
			
		||||
            template = jinja2.Environment(loader=jinja2.BaseLoader).from_string(config_string)
 | 
			
		||||
            config_string = template.render(**variables)
 | 
			
		||||
        return yaml.safe_load(config_string)
 | 
			
		||||
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    def read_file(filename):
 | 
			
		||||
        with open(filename, 'r') as ifile:
 | 
			
		||||
            return ifile.read()
 | 
			
		||||
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    def _read_variables(variables):
 | 
			
		||||
        raise TypeError(f'Invalid variables type {type(variables)}')
 | 
			
		||||
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    def _read_variables_str(variables):
 | 
			
		||||
        with open(variables, 'r') as ifile:
 | 
			
		||||
            return yaml.safe_load(ifile)
 | 
			
		||||
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    def _read_variables_dict(variables):
 | 
			
		||||
        return variables
 | 
			
		||||
		Reference in New Issue
	
	Block a user