From e3877e870f791ed1ab3f655be4e26472d87acac0 Mon Sep 17 00:00:00 2001 From: Martine Lenders Date: Mon, 25 Jan 2021 14:41:22 +0100 Subject: [PATCH] Initial import of MultiRIOTCtrl --- riotctrl/ctrl.py | 13 +- riotctrl/multictrl/__init__.py | 0 riotctrl/multictrl/ctrl.py | 95 ++++++++++++ riotctrl/multictrl/shell.py | 67 ++++++++ riotctrl/multictrl/utils.py | 70 +++++++++ riotctrl/shell/__init__.py | 11 +- riotctrl/tests/multictrl_test.py | 255 +++++++++++++++++++++++++++++++ 7 files changed, 507 insertions(+), 4 deletions(-) create mode 100644 riotctrl/multictrl/__init__.py create mode 100644 riotctrl/multictrl/ctrl.py create mode 100644 riotctrl/multictrl/shell.py create mode 100644 riotctrl/multictrl/utils.py create mode 100644 riotctrl/tests/multictrl_test.py diff --git a/riotctrl/ctrl.py b/riotctrl/ctrl.py index dc5dea1..7af0857 100644 --- a/riotctrl/ctrl.py +++ b/riotctrl/ctrl.py @@ -133,15 +133,22 @@ def start_term(self, **spawnkwargs): The function is blocking until it is ready. It waits some time until the terminal is ready and resets the ctrl. """ + self.start_term_wo_sleep(**spawnkwargs) + # on many platforms, the termprog needs a short while to be ready + time.sleep(self.TERM_STARTED_DELAY) + + def start_term_wo_sleep(self, **spawnkwargs): + """Start the terminal. + + The function is blocking until it is ready. + It does not wait until the terminal is ready and resets the ctrl. + """ self.stop_term() term_cmd = self.make_command(self.TERM_TARGETS) self.term = self.TERM_SPAWN_CLASS(term_cmd[0], args=term_cmd[1:], env=self.env, **spawnkwargs) - # on many platforms, the termprog needs a short while to be ready - time.sleep(self.TERM_STARTED_DELAY) - def _term_pid(self): """Terminal pid or None.""" return getattr(self.term, 'pid', None) diff --git a/riotctrl/multictrl/__init__.py b/riotctrl/multictrl/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/riotctrl/multictrl/ctrl.py b/riotctrl/multictrl/ctrl.py new file mode 100644 index 0000000..7f14f99 --- /dev/null +++ b/riotctrl/multictrl/ctrl.py @@ -0,0 +1,95 @@ +"""Abstraction for multiple RIOTCtrl objects + +Defines a class to abstract access to multiple RIOTCtrls. +""" + +import riotctrl.ctrl + +from riotctrl.multictrl.utils import MultiKeyDict + + +# Intentionally do not inherit from TermSpawn so we do not have to rewrite +# all of `pexpect` ;-) +# pylint: disable=too-many-ancestors +class MultiTermSpawn(MultiKeyDict): + """Allows for access and control of multiple RIOTCtrl objects + """ + def expect(self, pattern, *args, **kwargs): + """ + mirroring riotctrl.ctrl.TermSpawn.expect() + """ + return {k: v.expect(pattern, *args, **kwargs) + for k, v in self.items()} + + def expect_exact(self, pattern, *args, **kwargs): + """ + mirroring riotctrl.ctrl.TermSpawn.expect() + """ + return {k: v.expect_exact(pattern, *args, **kwargs) + for k, v in self.items()} + + def sendline(self, *args, **kwargs): + """ + mirroring riotctrl.ctrl.TermSpawn.expect() + """ + return {k: v.sendline(*args, **kwargs) + for k, v in self.items()} + + +# pylint: disable=too-many-ancestors +class MultiRIOTCtrl(MultiKeyDict, riotctrl.ctrl.RIOTCtrl): + """Allows for access and control of multiple RIOTCtrl objects + + >>> ctrl = MultiRIOTCtrl({'a': riotctrl.ctrl.RIOTCtrl(env={'BOARD': 'A'}), + ... 'b': riotctrl.ctrl.RIOTCtrl(env={'BOARD': 'B'})}) + >>> ctrl.board() + {'a': 'A', 'b': 'B'} + >>> ctrl['a','b'].board() + {'a': 'A', 'b': 'B'} + >>> ctrl['a'].board() + 'A' + """ + TERM_SPAWN_CLASS = MultiTermSpawn + + def __init__(self, ctrls=None): + super().__init__(ctrls) + self.term = None # type: MultiRIOTCtrl.TERM_SPAWN_CLASS + + @property + def application_directory(self): + """Absolute path to the containing RIOTCtrls current directory as + dictionary.""" + return {k: ctrl.application_directory for k, ctrl in self.items()} + + def board(self): + """Return board type of containing RIOTCtrls as dictionary.""" + return {k: ctrl.board() for k, ctrl in self.items()} + + def start_term_wo_sleep(self, **spawnkwargs): + """Start the terminal (without waiting) for containing ctrls. + """ + res = {} + for k, ctrl in self.items(): + ctrl.start_term_wo_sleep(**spawnkwargs) + res[k] = ctrl.term + self.term = self.TERM_SPAWN_CLASS(res) + + def make_run(self, targets, *runargs, **runkwargs): + """Call make `targets` for containing RIOTctrl contexts. + + It is using `subprocess.run` internally. + + :param targets: make targets + :param *runargs: args passed to subprocess.run + :param *runkwargs: kwargs passed to subprocess.run + :return: subprocess.CompletedProcess object + """ + return {k: ctrl.make_run(targets, *runargs, **runkwargs) + for k, ctrl in self.items()} + + def make_command(self, targets): + """Dictionary of make command for context of containing RIOTctrls. + + :return: list of command arguments (for example for subprocess) + """ + return {k: ctrl.make_command(targets) for k, ctrl in self.items()} diff --git a/riotctrl/multictrl/shell.py b/riotctrl/multictrl/shell.py new file mode 100644 index 0000000..2d6c04f --- /dev/null +++ b/riotctrl/multictrl/shell.py @@ -0,0 +1,67 @@ +""" +Shell interaction extenison for riotctrl.multictrl + +Defines classes to abstract interactions with RIOT shell commands using +riotctrl.multictrl.MultiRIOTCtrl objects +""" + +import pexpect + +import riotctrl.shell +import riotctrl.multictrl.ctrl + +from riotctrl.multictrl.ctrl import MultiKeyDict + + +class MultiShellInteractionMixin(riotctrl.shell.ShellInteraction): + """ + Mixin class for shell interactions of riotctrl.multictrl.ctrl.MultiRIOTCtrl + + :param ctrl: a MultiRIOTCtrl object + """ + # pylint: disable=super-init-not-called + # intentionally do not call super-init, to not cause TypeError + def __init__(self, ctrl): + # used in __del__, so initialize before raising exception + self.term_was_started = False + if not isinstance(ctrl, riotctrl.multictrl.ctrl.MultiRIOTCtrl): + raise TypeError( + "{} not compatible with non multi-RIOTCtrl {}. Use {} instead." + .format(type(self), type(ctrl), + riotctrl.shell.ShellInteraction) + ) + self.riotctrl = ctrl + self.replwrap = MultiKeyDict() + + def _start_replwrap(self): + if not self.replwrap or \ + (any(key not in self.replwrap for key in self.riotctrl) and + any(self.replwrap[key].child != self.riotctrl[key].term + for key in self.riotctrl)): + for key, ctrl in self.riotctrl.items(): + # consume potentially shown prompt to be on the same ground as + # if it is not shown + ctrl.term.expect_exact(["> ", pexpect.TIMEOUT], timeout=.1) + # enforce prompt to be shown by sending newline + ctrl.term.sendline("") + self.replwrap[key] = pexpect.replwrap.REPLWrapper( + ctrl.term, orig_prompt="> ", prompt_change=None, + ) + + # pylint: disable=arguments-differ + def cmd(self, cmd, timeout=-1, async_=False, ctrls=None): + """ + Sends a command via the MultiShellInteractionMixin `riotctrl`s + + :param cmd: A shell command as string. + :param ctrls: ctrls to run command on + + :return: Output of the command as a string + """ + self._start_replwrap() + if ctrls is None: + ctrls = self.riotctrl.keys() + elif not isinstance(ctrls, (tuple, list)): + ctrls = (ctrls,) + return {k: rw.run_command(cmd, timeout=timeout, async_=async_) + for k, rw in self.replwrap.items() if k in ctrls} diff --git a/riotctrl/multictrl/utils.py b/riotctrl/multictrl/utils.py new file mode 100644 index 0000000..c80951f --- /dev/null +++ b/riotctrl/multictrl/utils.py @@ -0,0 +1,70 @@ +"""Helper utilities. +""" + +import collections.abc + + +class MultiKeyDict(collections.abc.MutableMapping): + """Works like a dict, but returns another dict, when used with tuples + as a key: + + >>> a = MultiKeyDict({0: 'zero', 1: 'one', 2: 'two'}) + >>> len(a) + 3 + >>> a[0] + 'zero' + >>> a[1] + 'one' + >>> a[2] + 'two' + >>> a[0,1] + {0: 'zero', 1: 'one'} + >>> a[3] = 'three' + >>> a[0,1] = 'foobar' + >>> a + {0: 'foobar', 1: 'foobar', 2: 'two', 3: 'three'} + >>> del a[1,2] + >>> a + {0: 'foobar', 3: 'three'} + >>> del a[0] + >>> a + {3: 'three'} + """ + def __init__(self, dictionary=None): + if dictionary is None: + self._dict = {} + else: + self._dict = dict(dictionary) + + def __str__(self): + return str(self._dict) + + def __repr__(self): + return str(self) + + def __getitem__(self, key): + if isinstance(key, tuple): + return type(self)( + {k: self._dict[k] for k in key} + ) + return self._dict[key] + + def __setitem__(self, key, value): + if isinstance(key, tuple): + for k in key: + self._dict[k] = value + else: + self._dict[key] = value + + def __delitem__(self, key): + if isinstance(key, tuple): + for k in key: + del self._dict[k] + else: + del self._dict[key] + + def __iter__(self): + return iter(self._dict) + + def __len__(self): + return len(self._dict) diff --git a/riotctrl/shell/__init__.py b/riotctrl/shell/__init__.py index 74a4fab..04c1983 100644 --- a/riotctrl/shell/__init__.py +++ b/riotctrl/shell/__init__.py @@ -9,6 +9,8 @@ import pexpect import pexpect.replwrap +import riotctrl.multictrl.ctrl as multictrl + # pylint: disable=R0903 class ShellInteractionParser(abc.ABC): @@ -33,10 +35,17 @@ class ShellInteraction(): PROMPT_TIMEOUT = .5 def __init__(self, riotctrl, prompt='> '): + # used in __del__, so initialize before raising exception + self.term_was_started = False + if isinstance(riotctrl, multictrl.MultiRIOTCtrl): + raise TypeError( + "{} not compatible with multi-RIOTCtrl {}. Use {} instead." + .format(type(self), type(riotctrl), + 'riotctrl.multictrl.shell.MultiShellInteractionMixin') + ) self.riotctrl = riotctrl self.prompt = prompt self.replwrap = None - self.term_was_started = False def __del__(self): if self.term_was_started: diff --git a/riotctrl/tests/multictrl_test.py b/riotctrl/tests/multictrl_test.py new file mode 100644 index 0000000..f8a1a2b --- /dev/null +++ b/riotctrl/tests/multictrl_test.py @@ -0,0 +1,255 @@ +"""riotctrl.multictrl test module.""" + +import os +import sys +import tempfile + +import pexpect +import pytest + +import riotctrl.ctrl +import riotctrl.multictrl.ctrl +import riotctrl.multictrl.shell +import riotctrl.shell + +CURDIR = os.path.dirname(__file__) +APPLICATIONS_DIR = os.path.join(CURDIR, 'utils', 'application') + + +@pytest.fixture(name='app_pidfile_env') +def fixture_app_pidfile_envs(): + """Environment to use application pidfile""" + with tempfile.NamedTemporaryFile() as tmpfile1: + with tempfile.NamedTemporaryFile() as tmpfile2: + yield [{'PIDFILE': tmpfile1.name}, {'PIDFILE': tmpfile2.name}] + + +@pytest.fixture(name='skip_first_prompt') +def fixture_skip_first_prompt(request): + """ + Configures if first prompt should be skipped in the ctrls fixture + """ + return getattr(request, "param", True) + + +@pytest.fixture(name='ctrls') +def fixture_ctrls(app_pidfile_env, skip_first_prompt): + """Initializes RIOTCtrl for MultiShellInteractionMixin tests""" + env1 = { + 'QUIET': '1', # pipe > in command interferes with test + 'BOARD': 'board1', + 'APPLICATION': './shell.py' + + (' 1' if skip_first_prompt else ''), + } + env2 = {} + env2.update(env1) + env2['BOARD'] = 'board2' + env1.update(app_pidfile_env[0]) + env2.update(app_pidfile_env[1]) + + ctrls = riotctrl.multictrl.ctrl.MultiRIOTCtrl({ + 'one': riotctrl.ctrl.RIOTCtrl(APPLICATIONS_DIR, env1), + 'two': riotctrl.ctrl.RIOTCtrl(APPLICATIONS_DIR, env2), + }) + yield ctrls + + +def test_multiriotctrl_init(): + """Test typing for MultiRIOTCtrl initialization""" + riotctrl.multictrl.ctrl.MultiRIOTCtrl() + riotctrl.multictrl.ctrl.MultiRIOTCtrl({'test': riotctrl.ctrl.RIOTCtrl()}) + riotctrl.multictrl.ctrl.MultiRIOTCtrl({0: riotctrl.ctrl.RIOTCtrl()}) + riotctrl.multictrl.ctrl.MultiRIOTCtrl([(0, riotctrl.ctrl.RIOTCtrl())]) + riotctrl.multictrl.ctrl.MultiRIOTCtrl(((0, riotctrl.ctrl.RIOTCtrl()),)) + riotctrl.multictrl.ctrl.MultiRIOTCtrl( + riotctrl.multictrl.ctrl.MultiRIOTCtrl( + {'test': riotctrl.ctrl.RIOTCtrl()} + ) + ) + + +def test_multiriotctrl_application_dir(): + """Test if a freshly initialized MultiRIOTCtrl contains the keys the + RIOTCtrl was initialized with + """ + appbase = os.path.abspath(os.environ['APPBASE']) + application1 = os.path.join(appbase, 'application') + application2 = APPLICATIONS_DIR + board1 = 'native' + board2 = 'iotlab-m3' + + env1 = {'BOARD': board1} + env2 = {'BOARD': board2} + ctrl = riotctrl.multictrl.ctrl.MultiRIOTCtrl({ + 'one': riotctrl.ctrl.RIOTCtrl(application1, env1), + 'two': riotctrl.ctrl.RIOTCtrl(application2, env2), + }) + assert ctrl.application_directory == { + 'one': application1, + 'two': application2, + } + assert ctrl.board() == { + 'one': board1, + 'two': board2, + } + + clean_cmd1 = ['make', '--no-print-directory', '-C', application1, 'clean'] + clean_cmd2 = ['make', '--no-print-directory', '-C', application2, 'clean'] + assert ctrl.make_command(['clean']) == { + 'one': clean_cmd1, + 'two': clean_cmd2, + } + + +def test_multiriotctrl_running_term_with_reset(app_pidfile_env): + """Test that MultiRIOTCtrl resets on run_term and expect behavior.""" + env1 = {'BOARD': 'board'} + env1.update(app_pidfile_env[0]) + env1['APPLICATION'] = './hello.py' + env2 = {} + env2.update(env1) + env2.update(app_pidfile_env[1]) + + ctrl = riotctrl.multictrl.ctrl.MultiRIOTCtrl({ + 'one': riotctrl.ctrl.RIOTCtrl(APPLICATIONS_DIR, env1), + 'two': riotctrl.ctrl.RIOTCtrl(APPLICATIONS_DIR, env2), + }) + ctrl.TERM_STARTED_DELAY = 1 + + assert ctrl['one'].env["PIDFILE"] == app_pidfile_env[0]["PIDFILE"] + assert ctrl['two'].env["PIDFILE"] == app_pidfile_env[1]["PIDFILE"] + + with ctrl.run_term(logfile=sys.stdout) as child: + # Firmware should have started twice on both boards + res = child.expect_exact('Starting RIOT Ctrl') + assert {'one': 0, 'two': 0} == res + res = child.expect_exact('Hello World') + assert {'one': 0, 'two': 0} == res + res = child.expect([ + 'This is not what we expect', + 'Starting RIOT Ctrl' + ]) + assert {'one': 1, 'two': 1} == res + res = child.expect_exact('Hello World') + assert {'one': 0, 'two': 0} == res + + +def test_multiriotctrl_running_error_cases(app_pidfile_env): + """Test basic functionalities with the 'echo' application for + MultiRIOTCtrl. + + This tests: + * stopping already stopped child + """ + # Use only 'echo' as process to exit directly + env = {'BOARD': 'board', + 'NODE_WRAPPER': 'echo', 'APPLICATION': 'Starting RIOT Ctrl'} + env.update(app_pidfile_env[0]) + + ctrl = riotctrl.multictrl.ctrl.MultiRIOTCtrl({ + 'one': riotctrl.ctrl.RIOTCtrl(APPLICATIONS_DIR, env), + }) + ctrl.TERM_STARTED_DELAY = 1 + + with ctrl.run_term(logfile=sys.stdout) as child: + res = child.expect_exact('Starting RIOT Ctrl') + assert {'one': 0} == res + + # Term is already finished and expect should trigger EOF + with pytest.raises(pexpect.EOF): + child.expect('this should eof') + + # Exiting the context manager should not crash when ctrl is killed + + +def test_multiriotctrl_echo_application(app_pidfile_env): + """Test basic functionalities of MultiRIOTCtrl with the 'echo' application. + """ + env = {'BOARD': 'board', 'APPLICATION': './echo.py'} + env.update(app_pidfile_env[0]) + + ctrls = riotctrl.multictrl.ctrl.MultiRIOTCtrl({ + 'one': riotctrl.ctrl.RIOTCtrl(APPLICATIONS_DIR, env), + 'two': riotctrl.ctrl.RIOTCtrl(APPLICATIONS_DIR, env), + }) + ctrls.TERM_STARTED_DELAY = 1 + + with ctrls.run_term(logfile=sys.stdout) as child: + res = child.expect_exact('Starting RIOT Ctrl') + assert {'one': 0, 'two': 0} == res + + # Test multiple echo + for i in range(16): + child.sendline('Hello Test {}'.format(i)) + res = child.expect(r'Hello Test (\d+)', timeout=1) + assert {'one': 0, 'two': 0} == res + num = int(child['one'].match.group(1)) + assert i == num + num = int(child['two'].match.group(1)) + assert i == num + + for key in ctrls: + child[key].sendline('Pinging {}'.format(key)) + res = child.expect(r'Pinging (one|two)', timeout=1) + assert {'one': 0, 'two': 0} == res + assert child['one'].match.group(1) == 'one' + assert child['two'].match.group(1) == 'two' + + +def test_multiriotctrl_shell_interaction_typeerror(): + """Tests if ShellInteraction throws a type error when initialized with a + MultiRIOTCtrl + """ + ctrls = riotctrl.multictrl.ctrl.MultiRIOTCtrl({ + 'one': riotctrl.ctrl.RIOTCtrl(APPLICATIONS_DIR), + 'two': riotctrl.ctrl.RIOTCtrl(APPLICATIONS_DIR), + }) + with pytest.raises(TypeError): + riotctrl.shell.ShellInteraction(ctrls) + + +def test_multiriotctrl_multi_shell_interaction_typeerror(): + """Tests if MultiShellInteractionMixin ShellInteraction throws a type error + RIOTCtrl + """ + ctrl = riotctrl.ctrl.RIOTCtrl(APPLICATIONS_DIR) + with pytest.raises(TypeError): + riotctrl.multictrl.shell.MultiShellInteractionMixin(ctrl) + + +@pytest.mark.parametrize('skip_first_prompt', [False, True], + indirect=['skip_first_prompt']) +def test_multiriotctrl_multi_shell_interaction_cmd(ctrls): + """Test basic functionalities with the 'shell' application with and without + first prompt missing.""" + with ctrls.run_term(logfile=sys.stdout, reset=False): + shell = riotctrl.multictrl.shell.MultiShellInteractionMixin(ctrls) + res = shell.cmd('foobar') + assert 'one' in res and 'two' in res + assert 'foobar' in res['one'] and 'foobar' in res['two'] + res = shell.cmd('snafoo', ctrls='one') + assert 'one' in res and 'two' not in res + assert 'snafoo' in res['one'] + res = shell.cmd('test', ctrls=['two']) + assert 'two' in res and 'one' not in res + assert 'test' in res['two'] + + +class Snafoo(riotctrl.multictrl.shell.MultiShellInteractionMixin, + riotctrl.shell.ShellInteraction): + """Test inheritance class to test check_term decorator""" + @riotctrl.multictrl.shell.MultiShellInteractionMixin.check_term + def snafoo(self, ctrls=None): + """snafoo pseudo command""" + return self.cmd('snafoo', ctrls=ctrls) + + +def test_multiriotctrl_multi_shell_interaction_check_term(ctrls): + """Tests the check_term decorator""" + shell = Snafoo(ctrls) + res = shell.snafoo() + assert 'one' in res and 'two' in res + assert 'snafoo' in res['one'] and 'snafoo' in res['two'] + res = shell.snafoo(ctrls=['one']) + assert 'one' in res and 'two' not in res + assert 'snafoo' in res['one']