mirror of
				https://github.com/avatao-content/baseimage-tutorial-framework
				synced 2025-11-04 09:42:54 +00:00 
			
		
		
		
	Simplify package structure
This commit is contained in:
		
							
								
								
									
										3
									
								
								tfw/components/terminal/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								tfw/components/terminal/__init__.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,3 @@
 | 
			
		||||
from .terminal_handler import TerminalHandler
 | 
			
		||||
from .terminal_commands_handler import TerminalCommandsHandler
 | 
			
		||||
from .commands_equal import CommandsEqual
 | 
			
		||||
							
								
								
									
										107
									
								
								tfw/components/terminal/commands_equal.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										107
									
								
								tfw/components/terminal/commands_equal.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,107 @@
 | 
			
		||||
from shlex import split
 | 
			
		||||
from re import search
 | 
			
		||||
 | 
			
		||||
from tfw.internals.lazy import lazy_property
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class CommandsEqual:
 | 
			
		||||
    # pylint: disable=too-many-arguments
 | 
			
		||||
    """
 | 
			
		||||
    This class is useful for comparing executed commands with
 | 
			
		||||
    excepted commands (i.e. when triggering a state change when
 | 
			
		||||
    the correct command is executed).
 | 
			
		||||
 | 
			
		||||
    Note that in most cases you should test the changes
 | 
			
		||||
    caused by the commands instead of just checking command history
 | 
			
		||||
    (stuff can be done in countless ways and preparing for every
 | 
			
		||||
    single case is impossible). This should only be used when
 | 
			
		||||
    testing the changes would be very difficult, like when
 | 
			
		||||
    explaining stuff with cli tools and such.
 | 
			
		||||
 | 
			
		||||
    This class implicitly converts to bool, use it like
 | 
			
		||||
    if CommandsEqual(...): ...
 | 
			
		||||
 | 
			
		||||
    It tries detecting differing command parameter orders with similar
 | 
			
		||||
    semantics and provides fuzzy logic options.
 | 
			
		||||
    The rationale behind this is that a few false positives
 | 
			
		||||
    are better than only accepting a single version of a command
 | 
			
		||||
    (i.e. using ==).
 | 
			
		||||
    """
 | 
			
		||||
    def __init__(
 | 
			
		||||
            self, command_1, command_2,
 | 
			
		||||
            fuzzyness=1, begin_similarly=True,
 | 
			
		||||
            include_patterns=None, exclude_patterns=None
 | 
			
		||||
    ):
 | 
			
		||||
        """
 | 
			
		||||
        :param command_1: Compared command 1
 | 
			
		||||
        :param command_2: Compared command 2
 | 
			
		||||
        :param fuzzyness: float between 0 and 1.
 | 
			
		||||
                          the percentage of arguments required to
 | 
			
		||||
                          match between commands to result in True.
 | 
			
		||||
                          i.e 1 means 100% - all arguments need to be
 | 
			
		||||
                          present in both commands, while 0.75
 | 
			
		||||
                          would mean 75% - in case of 4 arguments
 | 
			
		||||
                          1 could differ between the commands.
 | 
			
		||||
        :param begin_similarly: bool, the first word of the commands
 | 
			
		||||
                                must match
 | 
			
		||||
        :param include_patterns: list of regex patterns the commands
 | 
			
		||||
                                 must include
 | 
			
		||||
        :param exclude_patterns: list of regex patterns the commands
 | 
			
		||||
                                 must exclude
 | 
			
		||||
        """
 | 
			
		||||
        self.command_1 = split(command_1)
 | 
			
		||||
        self.command_2 = split(command_2)
 | 
			
		||||
        self.fuzzyness = fuzzyness
 | 
			
		||||
        self.begin_similarly = begin_similarly
 | 
			
		||||
        self.include_patterns = include_patterns
 | 
			
		||||
        self.exclude_patterns = exclude_patterns
 | 
			
		||||
 | 
			
		||||
    def __bool__(self):
 | 
			
		||||
        if self.begin_similarly:
 | 
			
		||||
            if not self.beginnings_are_equal:
 | 
			
		||||
                return False
 | 
			
		||||
 | 
			
		||||
        if self.include_patterns is not None:
 | 
			
		||||
            if not self.commands_contain_include_patterns:
 | 
			
		||||
                return False
 | 
			
		||||
 | 
			
		||||
        if self.exclude_patterns is not None:
 | 
			
		||||
            if not self.commands_contain_no_exclude_patterns:
 | 
			
		||||
                return False
 | 
			
		||||
 | 
			
		||||
        return self.similarity >= self.fuzzyness
 | 
			
		||||
 | 
			
		||||
    @lazy_property
 | 
			
		||||
    def beginnings_are_equal(self):
 | 
			
		||||
        return self.command_1[0] == self.command_2[0]
 | 
			
		||||
 | 
			
		||||
    @lazy_property
 | 
			
		||||
    def commands_contain_include_patterns(self):
 | 
			
		||||
        return all((
 | 
			
		||||
            self.contains_regex_patterns(self.command_1, self.include_patterns),
 | 
			
		||||
            self.contains_regex_patterns(self.command_2, self.include_patterns)
 | 
			
		||||
        ))
 | 
			
		||||
 | 
			
		||||
    @lazy_property
 | 
			
		||||
    def commands_contain_no_exclude_patterns(self):
 | 
			
		||||
        return all((
 | 
			
		||||
            not self.contains_regex_patterns(self.command_1, self.exclude_patterns),
 | 
			
		||||
            not self.contains_regex_patterns(self.command_2, self.exclude_patterns)
 | 
			
		||||
        ))
 | 
			
		||||
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    def contains_regex_patterns(command, regex_parts):
 | 
			
		||||
        command = ' '.join(command)
 | 
			
		||||
        for pattern in regex_parts:
 | 
			
		||||
            if not search(pattern, command):
 | 
			
		||||
                return False
 | 
			
		||||
        return True
 | 
			
		||||
 | 
			
		||||
    @lazy_property
 | 
			
		||||
    def similarity(self):
 | 
			
		||||
        parts_1 = set(self.command_1)
 | 
			
		||||
        parts_2 = set(self.command_2)
 | 
			
		||||
 | 
			
		||||
        difference = parts_1 - parts_2
 | 
			
		||||
        deviance = len(difference) / len(max(parts_1, parts_2))
 | 
			
		||||
        return 1 - deviance
 | 
			
		||||
							
								
								
									
										97
									
								
								tfw/components/terminal/history_monitor.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										97
									
								
								tfw/components/terminal/history_monitor.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,97 @@
 | 
			
		||||
from re import findall
 | 
			
		||||
from re import compile as compileregex
 | 
			
		||||
from abc import ABC, abstractmethod
 | 
			
		||||
 | 
			
		||||
from tfw.internals.inotify import InotifyObserver
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class HistoryMonitor(ABC, InotifyObserver):
 | 
			
		||||
    """
 | 
			
		||||
    Abstract class capable of monitoring and parsing a history file such as
 | 
			
		||||
    bash HISTFILEs. Monitoring means detecting when the file was changed and
 | 
			
		||||
    notifying subscribers about new content in the file.
 | 
			
		||||
 | 
			
		||||
    This is useful for monitoring CLI sessions.
 | 
			
		||||
 | 
			
		||||
    To specify a custom HistoryMonitor inherit from this class and override the
 | 
			
		||||
    command pattern property and optionally the sanitize_command method.
 | 
			
		||||
    See examples below.
 | 
			
		||||
    """
 | 
			
		||||
    def __init__(self, uplink, histfile):
 | 
			
		||||
        self.histfile = histfile
 | 
			
		||||
        self.history = []
 | 
			
		||||
        self._last_length = len(self.history)
 | 
			
		||||
        self.uplink = uplink
 | 
			
		||||
        super().__init__(self.histfile)
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    @abstractmethod
 | 
			
		||||
    def domain(self):
 | 
			
		||||
        raise NotImplementedError()
 | 
			
		||||
 | 
			
		||||
    def on_modified(self, event):
 | 
			
		||||
        self._fetch_history()
 | 
			
		||||
        if self._last_length < len(self.history):
 | 
			
		||||
            for command in self.history[self._last_length:]:
 | 
			
		||||
                self.send_message(command)
 | 
			
		||||
 | 
			
		||||
    def _fetch_history(self):
 | 
			
		||||
        self._last_length = len(self.history)
 | 
			
		||||
        with open(self.histfile, 'r') as ifile:
 | 
			
		||||
            pattern = compileregex(self.command_pattern)
 | 
			
		||||
            data = ifile.read()
 | 
			
		||||
            self.history = [
 | 
			
		||||
                self.sanitize_command(command)
 | 
			
		||||
                for command in findall(pattern, data)
 | 
			
		||||
            ]
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    @abstractmethod
 | 
			
		||||
    def command_pattern(self):
 | 
			
		||||
        raise NotImplementedError
 | 
			
		||||
 | 
			
		||||
    def sanitize_command(self, command):
 | 
			
		||||
        # pylint: disable=no-self-use
 | 
			
		||||
        return command
 | 
			
		||||
 | 
			
		||||
    def send_message(self, command):
 | 
			
		||||
        self.uplink.send_message({
 | 
			
		||||
            'key': f'history.{self.domain}',
 | 
			
		||||
            'value': command
 | 
			
		||||
        })
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class BashMonitor(HistoryMonitor):
 | 
			
		||||
    """
 | 
			
		||||
    HistoryMonitor for monitoring bash CLI sessions.
 | 
			
		||||
    This requires the following to be set in bash
 | 
			
		||||
    (note that this is done automatically by TFW):
 | 
			
		||||
    PROMPT_COMMAND="history -a"
 | 
			
		||||
    shopt -s cmdhist
 | 
			
		||||
    shopt -s histappend
 | 
			
		||||
    unset HISTCONTROL
 | 
			
		||||
    """
 | 
			
		||||
    @property
 | 
			
		||||
    def domain(self):
 | 
			
		||||
        return 'bash'
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def command_pattern(self):
 | 
			
		||||
        return r'.+'
 | 
			
		||||
 | 
			
		||||
    def sanitize_command(self, command):
 | 
			
		||||
        return command.strip()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class GDBMonitor(HistoryMonitor):
 | 
			
		||||
    """
 | 
			
		||||
    HistoryMonitor to monitor GDB sessions.
 | 
			
		||||
    For this to work "set trace-commands on" must be set in GDB.
 | 
			
		||||
    """
 | 
			
		||||
    @property
 | 
			
		||||
    def domain(self):
 | 
			
		||||
        return 'gdb'
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def command_pattern(self):
 | 
			
		||||
        return r'(?<=\n)\+(.+)\n'
 | 
			
		||||
							
								
								
									
										44
									
								
								tfw/components/terminal/terminado_mini_server.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								tfw/components/terminal/terminado_mini_server.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,44 @@
 | 
			
		||||
import logging
 | 
			
		||||
 | 
			
		||||
from tornado.web import Application
 | 
			
		||||
from terminado import TermSocket, SingleTermManager
 | 
			
		||||
 | 
			
		||||
LOG = logging.getLogger(__name__)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TerminadoMiniServer:
 | 
			
		||||
    def __init__(self, url, port, workdir, shellcmd):
 | 
			
		||||
        self.port = port
 | 
			
		||||
        self._term_manager = SingleTermManager(
 | 
			
		||||
            shell_command=shellcmd,
 | 
			
		||||
            term_settings={'cwd': workdir}
 | 
			
		||||
        )
 | 
			
		||||
        self.application = Application([(
 | 
			
		||||
            url,
 | 
			
		||||
            TerminadoMiniServer.ResetterTermSocket,
 | 
			
		||||
            {'term_manager': self._term_manager}
 | 
			
		||||
        )])
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def term_manager(self):
 | 
			
		||||
        return self._term_manager
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def pty(self):
 | 
			
		||||
        if self.term_manager.terminal is None:
 | 
			
		||||
            self.term_manager.get_terminal()
 | 
			
		||||
        return self.term_manager.terminal.ptyproc
 | 
			
		||||
 | 
			
		||||
    class ResetterTermSocket(TermSocket): # pylint: disable=abstract-method
 | 
			
		||||
        def check_origin(self, origin):
 | 
			
		||||
            return True
 | 
			
		||||
 | 
			
		||||
        def on_close(self):
 | 
			
		||||
            self.term_manager.terminal = None
 | 
			
		||||
            self.term_manager.get_terminal()
 | 
			
		||||
 | 
			
		||||
    def listen(self):
 | 
			
		||||
        self.application.listen(self.port)
 | 
			
		||||
 | 
			
		||||
    def stop(self):
 | 
			
		||||
        self.term_manager.shutdown()
 | 
			
		||||
							
								
								
									
										68
									
								
								tfw/components/terminal/terminal_commands.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										68
									
								
								tfw/components/terminal/terminal_commands.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,68 @@
 | 
			
		||||
import logging
 | 
			
		||||
from abc import ABC
 | 
			
		||||
from re import match
 | 
			
		||||
from shlex import split
 | 
			
		||||
 | 
			
		||||
LOG = logging.getLogger(__name__)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TerminalCommands(ABC):
 | 
			
		||||
    # pylint: disable=anomalous-backslash-in-string
 | 
			
		||||
    """
 | 
			
		||||
    A class you can use to define hooks for terminal commands. This means that you can
 | 
			
		||||
    have python code executed when the user enters a specific command to the terminal on
 | 
			
		||||
    our frontend.
 | 
			
		||||
 | 
			
		||||
    To receive events you need to subscribe TerminalCommand.callback to a HistoryMonitor
 | 
			
		||||
    instance.
 | 
			
		||||
 | 
			
		||||
    Inherit from this class and define methods which start with "command\_". When the user
 | 
			
		||||
    executes the command specified after the underscore, your method will be invoked. All
 | 
			
		||||
    such commands must expect the parameter \*args which will contain the arguments of the
 | 
			
		||||
    command.
 | 
			
		||||
 | 
			
		||||
    For example to define a method that runs when someone starts vim in the terminal
 | 
			
		||||
    you have to define a method like: "def command_vim(self, \*args)"
 | 
			
		||||
 | 
			
		||||
    You can also use this class to create new commands similarly.
 | 
			
		||||
    """
 | 
			
		||||
    def __init__(self, bashrc):
 | 
			
		||||
        self._command_method_regex = r'^command_(.+)$'
 | 
			
		||||
        self.command_implemetations = self._build_command_to_implementation_dict()
 | 
			
		||||
        if bashrc is not None:
 | 
			
		||||
            self._setup_bashrc_aliases(bashrc)
 | 
			
		||||
 | 
			
		||||
    def _build_command_to_implementation_dict(self):
 | 
			
		||||
        return {
 | 
			
		||||
            self._parse_command_name(fun): getattr(self, fun)
 | 
			
		||||
            for fun in dir(self)
 | 
			
		||||
            if callable(getattr(self, fun))
 | 
			
		||||
            and self._is_command_implementation(fun)
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
    def _setup_bashrc_aliases(self, bashrc):
 | 
			
		||||
        with open(bashrc, 'a') as ofile:
 | 
			
		||||
            alias_template = 'type {0} &> /dev/null || alias {0}="{0} &> /dev/null"\n'
 | 
			
		||||
            for command in self.command_implemetations.keys():
 | 
			
		||||
                ofile.write(alias_template.format(command))
 | 
			
		||||
 | 
			
		||||
    def _is_command_implementation(self, method_name):
 | 
			
		||||
        return bool(self._match_command_regex(method_name))
 | 
			
		||||
 | 
			
		||||
    def _parse_command_name(self, method_name):
 | 
			
		||||
        try:
 | 
			
		||||
            return self._match_command_regex(method_name).groups()[0]
 | 
			
		||||
        except AttributeError:
 | 
			
		||||
            return ''
 | 
			
		||||
 | 
			
		||||
    def _match_command_regex(self, string):
 | 
			
		||||
        return match(self._command_method_regex, string)
 | 
			
		||||
 | 
			
		||||
    def callback(self, command):
 | 
			
		||||
        parts = split(command)
 | 
			
		||||
        command = parts[0]
 | 
			
		||||
        if command in self.command_implemetations.keys():
 | 
			
		||||
            try:
 | 
			
		||||
                self.command_implemetations[command](*parts[1:])
 | 
			
		||||
            except Exception: # pylint: disable=broad-except
 | 
			
		||||
                LOG.exception('Command "%s" failed:', command)
 | 
			
		||||
							
								
								
									
										9
									
								
								tfw/components/terminal/terminal_commands_handler.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								tfw/components/terminal/terminal_commands_handler.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,9 @@
 | 
			
		||||
from .terminal_commands import TerminalCommands
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TerminalCommandsHandler(TerminalCommands):
 | 
			
		||||
    keys = ['history.bash']
 | 
			
		||||
 | 
			
		||||
    def handle_event(self, message, _):
 | 
			
		||||
        command = message['value']
 | 
			
		||||
        self.callback(command)
 | 
			
		||||
							
								
								
									
										86
									
								
								tfw/components/terminal/terminal_handler.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										86
									
								
								tfw/components/terminal/terminal_handler.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,86 @@
 | 
			
		||||
import logging
 | 
			
		||||
 | 
			
		||||
from .history_monitor import BashMonitor
 | 
			
		||||
from .terminado_mini_server import TerminadoMiniServer
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
LOG = logging.getLogger(__name__)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TerminalHandler:
 | 
			
		||||
    keys = ['shell']
 | 
			
		||||
    """
 | 
			
		||||
    Event handler responsible for managing terminal sessions for frontend xterm
 | 
			
		||||
    sessions to connect to. You need to instanciate this in order for frontend
 | 
			
		||||
    terminals to work.
 | 
			
		||||
 | 
			
		||||
    This EventHandler accepts messages that have a data['command'] key specifying
 | 
			
		||||
    a command to be executed.
 | 
			
		||||
    The API of each command is documented in their respective handler.
 | 
			
		||||
    """
 | 
			
		||||
    def __init__(self, *, port, user, workind_directory, histfile):
 | 
			
		||||
        """
 | 
			
		||||
        :param key: key this EventHandler listens to
 | 
			
		||||
        :param monitor: tfw.components.HistoryMonitor instance to read command history from
 | 
			
		||||
        """
 | 
			
		||||
        self.server_connector = None
 | 
			
		||||
        self._histfile = histfile
 | 
			
		||||
        self._historymonitor = None
 | 
			
		||||
        bash_as_user_cmd = ['sudo', '-u', user, 'bash']
 | 
			
		||||
 | 
			
		||||
        self.terminado_server = TerminadoMiniServer(
 | 
			
		||||
            '/terminal',
 | 
			
		||||
            port,
 | 
			
		||||
            workind_directory,
 | 
			
		||||
            bash_as_user_cmd
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        self.commands = {
 | 
			
		||||
            'write': self.write,
 | 
			
		||||
            'read': self.read
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        self.terminado_server.listen()
 | 
			
		||||
 | 
			
		||||
    def start(self):
 | 
			
		||||
        self._historymonitor = BashMonitor(self.server_connector, self._histfile)
 | 
			
		||||
        self._historymonitor.start()
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def historymonitor(self):
 | 
			
		||||
        return self._historymonitor
 | 
			
		||||
 | 
			
		||||
    def handle_event(self, message, _):
 | 
			
		||||
        try:
 | 
			
		||||
            data = message['data']
 | 
			
		||||
            message['data'] = self.commands[data['command']](data)
 | 
			
		||||
        except KeyError:
 | 
			
		||||
            LOG.error('IGNORING MESSAGE: Invalid message received: %s', message)
 | 
			
		||||
 | 
			
		||||
    def write(self, data):
 | 
			
		||||
        """
 | 
			
		||||
        Writes a string to the terminal session (on the pty level).
 | 
			
		||||
        Useful for pre-typing and executing commands for the user.
 | 
			
		||||
 | 
			
		||||
        :param data: TFW message data containing 'value'
 | 
			
		||||
                     (command to be written to the pty)
 | 
			
		||||
        """
 | 
			
		||||
        self.terminado_server.pty.write(data['value'])
 | 
			
		||||
        return data
 | 
			
		||||
 | 
			
		||||
    def read(self, data):
 | 
			
		||||
        """
 | 
			
		||||
        Reads the history of commands executed.
 | 
			
		||||
 | 
			
		||||
        :param data: TFW message data containing 'count'
 | 
			
		||||
                     (the number of history elements to return)
 | 
			
		||||
        :return dict: message with list of commands in data['history']
 | 
			
		||||
        """
 | 
			
		||||
        data['count'] = int(data.get('count', 1))
 | 
			
		||||
        if self.historymonitor:
 | 
			
		||||
            data['history'] = self.historymonitor.history[-data['count']:]
 | 
			
		||||
        return data
 | 
			
		||||
 | 
			
		||||
    def cleanup(self):
 | 
			
		||||
        self.terminado_server.stop()
 | 
			
		||||
        self.historymonitor.stop()
 | 
			
		||||
		Reference in New Issue
	
	Block a user