mirror of
https://github.com/avatao-content/baseimage-tutorial-framework
synced 2025-06-28 10:55:12 +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