Skip to content

Commit

Permalink
TODO see inside REWORD ME riotnode: add node implementation and tests
Browse files Browse the repository at this point in the history
The abstraction is a basic class to replicate behavior that was in
`testrunner.

Implementation was sometime directly taken from `testrunner` without
being technically justified to keep backward compatibility. This is
described in `TODO.rst` and dedicated implementation.

This also adds a test `node` implementation with a Makefile.
It is currently an easy to use node as no output is lost and reset
correctly resets.

TODO maybe put this as a comment in the file!!
When pexpect 'close' a 'ptyprocess' it starts by 'SIGHUP'.
It is not handled by default to call 'atexit'.
To not do a specific handling for 'SIGHUP' just forward all signals to
the child. This wrapper should not do any specific things anyway.

TODO Split the 'safe_term_close' to utils with a test not using Make
term and so directly only wanting 'sigkill'
This should allow merging it before and using it in testrunner

TODO settle the `env` handling.
Get feedback on what to do here.

Disable local echo otherwise pexpect matches sent characters.

REMOVE ME: Include fix for RIOT-OS#10952
  • Loading branch information
cladmi authored and leandrolanzieri committed Mar 4, 2020
1 parent 95f23c3 commit 19d331d
Show file tree
Hide file tree
Showing 10 changed files with 444 additions and 1 deletion.
24 changes: 24 additions & 0 deletions dist/pythonlibs/riotnode/TODO.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
TODO list
=========

Some list of things I would like to do but not for first publication.


Legacy handling
---------------

Some handling was directly taken from ``testrunner``, without a justified/tested
reason. I just used it to not break existing setup for nothing.
I have more details in the code.

* Ignoring reset return value and error message
* Use killpg(SIGKILL) to kill terminal


Testing
-------

The current 'node' implementation is an ideal node where all output is captured
and reset directly resets. Having wilder implementations with output loss (maybe
as a deamon with a ``flash`` pre-requisite and sometime no ``reset`` would be
interesting.
Binary file added dist/pythonlibs/riotnode/out.pdf
Binary file not shown.
174 changes: 174 additions & 0 deletions dist/pythonlibs/riotnode/riotnode/node.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
"""RIOTNode abstraction.
Define class to abstract a node over the RIOT build system.
"""

import os
import time
import logging
import subprocess
import contextlib
import signal

import pexpect

DEVNULL = open(os.devnull, 'w')


class TermSpawn(pexpect.spawn):
"""Subclass to adapt the behaviour to our need.
* change default `__init__` values
* disable local 'echo' to not match send messages
* 'utf-8/replace' by default
* default timeout
"""

def __init__(self, # pylint:disable=too-many-arguments
command, timeout=10, echo=False,
encoding='utf-8', codec_errors='replace', **kwargs):
super().__init__(command, timeout=timeout, echo=echo,
encoding=encoding, codec_errors=codec_errors,
**kwargs)


class RIOTNode():
"""Class abstracting a RIOTNode in an application.
This should abstract the build system integration.
:param application_directory: relative directory to the application.
:param env: dictionary of environment variables that should be used.
These overwrites values coming from `os.environ` and can help
define factories where environment comes from a file or if the
script is not executed from the build system context.
Environment variable configuration
:environment BOARD: current RIOT board type.
:environment RIOT_TERM_START_DELAY: delay before `make term` is said to be
ready after calling.
"""

TERM_SPAWN_CLASS = TermSpawn
TERM_STOP_SIGNAL = signal.SIGKILL
TERM_STARTED_DELAY = int(os.environ.get('RIOT_TERM_START_DELAY') or 3)

MAKE_ARGS = ()
RESET_TARGETS = ('reset',)

def __init__(self, application_directory='.', env=None):
self._application_directory = application_directory

# TODO I am not satisfied by this, but would require changing all the
# environment handling, just put a note until I can fix it.
# I still want to show a PR before this
# I would prefer getting either no environment == os.environ or the
# full environment to use.
# It should also change the `TERM_STARTED_DELAY` thing.
self.env = os.environ.copy()
self.env.update(env or {})

self.term = None # type: pexpect.spawn

self.logger = logging.getLogger(__name__)

@property
def application_directory(self):
"""Absolute path to the current directory."""
return os.path.abspath(self._application_directory)

def board(self):
"""Return board type."""
return self.env['BOARD']

def reset(self):
"""Reset current node."""
# Ignoring 'reset' return value was taken from `testrunner`.
# For me it should not be done for all boards as it should be an error.
# I would rather fix it in the build system or have a per board
# configuration.

# Make reset yields error on some boards even if successful
# Ignore printed errors and returncode
self.make_run(self.RESET_TARGETS, stdout=DEVNULL, stderr=DEVNULL)

@contextlib.contextmanager
def run_term(self, **startkwargs):
"""Terminal context manager."""
try:
self.start_term(**startkwargs)
yield self.term
finally:
self.stop_term()

def start_term(self, **spawnkwargs):
"""Start the terminal.
The function is blocking until it is ready.
It waits some time until the terminal is ready and resets the node.
"""
self.stop_term()

term_cmd = self.make_command(['term'])
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)
self.reset()

def stop_term(self):
"""Stop the terminal."""
self._safe_term_close()

def _safe_term_close(self):
"""Safe 'term.close'.
Handles possible exceptions.
"""
try:
self._kill_term()
except AttributeError:
# Not initialized
return
except ProcessLookupError:
self.logger.warning('Process already stopped')

self.term.close()

def _kill_term(self):
"""Kill the current terminal."""
# killpg(SIGKILL) was taken from `testrunner`.
# I do not really like direct `SIGKILL` as it prevents script cleanup.
# I kept it as I do not want to break an edge case that rely on it.

# Using 'killpg' shows that our shell script do not correctly kill
# programs they started. So this is more a hack than a real solution.
os.killpg(os.getpgid(self.term.pid), self.TERM_STOP_SIGNAL)

def make_run(self, targets, *runargs, **runkwargs):
"""Call make `targets` for current RIOTNode context.
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
"""
command = self.make_command(targets)
return subprocess.run(command, env=self.env, *runargs, **runkwargs)

def make_command(self, targets):
"""Make command for current RIOTNode context.
:return: list of command arguments (for example for subprocess)
"""
command = ['make']
command.extend(self.MAKE_ARGS)
if self._application_directory != '.':
dir_cmd = '--no-print-directory', '-C', self.application_directory
command.extend(dir_cmd)
command.extend(targets)
return command
121 changes: 121 additions & 0 deletions dist/pythonlibs/riotnode/riotnode/tests/node_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
"""riotnode.node test module."""

import os
import sys
import tempfile

import pytest
import pexpect

import riotnode.node

CURDIR = os.path.dirname(__file__)
APPLICATIONS_DIR = os.path.join(CURDIR, 'utils', 'application')


def test_riotnode_application_dir():
"""Test the creation of a riotnode with an `application_dir`."""
riotbase = os.path.abspath(os.environ['RIOTBASE'])
application = os.path.join(riotbase, 'examples/hello-world')
board = 'native'

env = {'BOARD': board}
node = riotnode.node.RIOTNode(application, env)

assert node.application_directory == application
assert node.board() == board

clean_cmd = ['make', '--no-print-directory', '-C', application, 'clean']
assert node.make_command(['clean']) == clean_cmd


def test_riotnode_curdir():
"""Test the creation of a riotnode with current directory."""
riotbase = os.path.abspath(os.environ['RIOTBASE'])
application = os.path.join(riotbase, 'examples/hello-world')
board = 'native'

_curdir = os.getcwd()
_environ = os.environ.copy()
try:
os.environ['BOARD'] = board
os.chdir(application)

node = riotnode.node.RIOTNode()

assert node.application_directory == application
assert node.board() == board
assert node.make_command(['clean']) == ['make', 'clean']
finally:
os.chdir(_curdir)
os.environ.clear()
os.environ.update(_environ)


@pytest.fixture(name='app_pidfile_env')
def fixture_app_pidfile_env():
"""Environment to use application pidfile"""
with tempfile.NamedTemporaryFile() as tmpfile:
yield {'PIDFILE': tmpfile.name}


def test_running_echo_application(app_pidfile_env):
"""Test basic functionnalities with the 'echo' application."""
env = {'BOARD': 'board', 'APPLICATION': './echo.py'}
env.update(app_pidfile_env)

node = riotnode.node.RIOTNode(APPLICATIONS_DIR, env)
node.TERM_STARTED_DELAY = 1

with node.run_term(logfile=sys.stdout) as child:
child.expect_exact('Starting RIOT node')

# Test multiple echo
for i in range(16):
child.sendline('Hello Test {}'.format(i))
child.expect(r'Hello Test (\d+)', timeout=1)
num = int(child.match.group(1))
assert i == num


def test_running_error_cases(app_pidfile_env):
"""Test basic functionnalities with the 'echo' application.
This tests:
* stopping already stopped child
"""
# Use only 'echo' as process to exit directly
env = {'BOARD': 'board',
'NODE_WRAPPER': 'echo', 'APPLICATION': 'Starting RIOT node'}
env.update(app_pidfile_env)

node = riotnode.node.RIOTNode(APPLICATIONS_DIR, env)
node.TERM_STARTED_DELAY = 1

with node.run_term(logfile=sys.stdout) as child:
child.expect_exact('Starting RIOT node')

# 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 node is killed


def test_expect_not_matching_stdin(app_pidfile_env):
"""Test that expect does not match stdin."""
env = {'BOARD': 'board', 'APPLICATION': './hello.py'}
env.update(app_pidfile_env)

node = riotnode.node.RIOTNode(APPLICATIONS_DIR, env)
node.TERM_STARTED_DELAY = 1

with node.run_term(logfile=sys.stdout) as child:
child.expect_exact('Starting RIOT node')
child.expect_exact('Hello World')

msg = "This should not be matched as it is on stdin"
child.sendline(msg)
matched = child.expect_exact([pexpect.TIMEOUT, msg], timeout=1)
assert matched == 0
# This would have matched with `node.run_term(echo=True)`
16 changes: 16 additions & 0 deletions dist/pythonlibs/riotnode/riotnode/tests/utils/application/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
.PHONY: all flash reset term

PIDFILE ?= /tmp/riotnode_test_pid
NODEPID = $(shell cat $(firstword $(wildcard $(PIDFILE)) /dev/null))

NODE_WRAPPER ?= ./node.py
APPLICATION ?= ./echo.py

all:
flash:

reset:
kill -USR1 $(NODEPID) 2>/dev/null || true

term:
sh -c 'echo $$$$ > $(PIDFILE); exec $(NODE_WRAPPER) $(APPLICATION)'
16 changes: 16 additions & 0 deletions dist/pythonlibs/riotnode/riotnode/tests/utils/application/echo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
#! /usr/bin/env python3
"""Firmware implementing echoing line inputs."""

import sys


def main():
"""Print some header and echo the output."""
print('Starting RIOT node')
print('This example will echo')
while True:
print(input())


if __name__ == '__main__':
sys.exit(main())
17 changes: 17 additions & 0 deletions dist/pythonlibs/riotnode/riotnode/tests/utils/application/hello.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
#! /usr/bin/env python3
"""Firmware implementing a simple hello-world."""

import sys
import signal


def main():
"""Print some header and do nothing."""
print('Starting RIOT node')
print('Hello World')
while True:
signal.pause()


if __name__ == '__main__':
sys.exit(main())
Loading

0 comments on commit 19d331d

Please sign in to comment.