You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

489 lines
18 KiB

# License: Apache 2.0. See LICENSE file in root directory.
# Copyright(c) 2021 Intel Corporation. All Rights Reserved.
import re, os, subprocess, time, sys, platform
from abc import ABC, abstractmethod
from rspy import log, file
# this script is in unit-test/py/rspy
unit_tests_dir = os.path.dirname( os.path.dirname( os.path.dirname( os.path.abspath( __file__ ) ) ) )
# the full path to the directory that should hold the unit-tests logs. It is updated in run-unit-tests when we know
# the target directory. If None we assume the output should go to stdout
logdir = None
# LibCI needs a directory in which to look for a configuration and/or collaterals: we call that our "home"
# This is always a directory called "LibCI", but may be in different locations, in this order of priority:
# 1. C:\LibCI (on the LibCI machine)
# 2. ~/LibCI (in Windows, ~ is likely C:\Users\<username>)
if platform.system() == 'Linux':
home = '/usr/local/lib/ci'
else:
home = 'C:\\LibCI'
if not os.path.isdir( home ):
home = os.path.normpath( os.path.expanduser( '~/LibCI' ))
#
# Configuration (git config format) is kept in this file:
configfile = home + os.sep + 'configfile'
#
# And exceptions for configuration specs are stored here:
exceptionsfile = home + os.sep + 'exceptions.specs'
def run( cmd, stdout = None, timeout = 200, append = False ):
"""
Wrapper function for subprocess.run.
If the child process times out or ends with a non-zero exit status an exception is raised!
:param cmd: the command and argument for the child process, as a list
:param stdout: path of file to direct the output of the process to (None to disable)
:param timeout: number of seconds to give the process before forcefully ending it (None to disable)
:param append: if True and stdout is not None, the log of the test will be appended to the file instead of
overwriting it
:return: the output written by the child, if stdout is None -- otherwise N/A
"""
log.d( 'running:', cmd )
handle = None
start_time = time.time()
try:
log.debug_indent()
if stdout is None:
sys.stdout.flush()
elif stdout and stdout != subprocess.PIPE:
if append:
handle = open( stdout, "a" )
handle.write(
"\n----------TEST-SEPARATOR----------\n\n" )
handle.flush()
else:
handle = open( stdout, "w" )
stdout = handle
rv = subprocess.run( cmd,
stdout=stdout,
stderr=subprocess.STDOUT,
universal_newlines=True,
timeout=timeout,
check=True )
result = rv.stdout
if not result:
result = []
else:
result = result.split( '\n' )
return result
finally:
if handle:
handle.close()
log.debug_unindent()
run_time = time.time() - start_time
log.d( "test took", run_time, "seconds" )
class TestConfig( ABC ): # Abstract Base Class
"""
Configuration for a test, encompassing any metadata needed to control its run, like retries etc.
"""
def __init__( self, context ):
self._configurations = list()
self._priority = 1000
self._tags = set()
self._flags = set()
self._timeout = 200
self._retries = 0
self._context = context
self._donotrun = False
def debug_dump( self ):
if self._donotrun:
log.d( 'THIS TEST WILL BE SKIPPED (donotrun specified)' )
if self._priority != 1000:
log.d( 'priority:', self._priority )
if self._timeout != 200:
log.d( 'timeout:', self._timeout )
if self._retries != 0:
log.d( 'retries:', self._retries )
if len( self._tags ) > 1:
log.d( 'tags:', self._tags )
if self._flags:
log.d( 'flags:', self._flags )
if len( self._configurations ) > 1:
log.d( len( self._configurations ), 'configurations' )
# don't show them... they are output separately
@property
def configurations( self ):
return self._configurations
@property
def priority( self ):
return self._priority
@property
def timeout( self ):
return self._timeout
@property
def retries( self ):
return self._retries
@property
def tags( self ):
return self._tags
@property
def flags( self ):
return self._flags
@property
def context( self ):
return self._context
@property
def donotrun( self ):
return self._donotrun
class TestConfigFromText( TestConfig ):
"""
Configuration for a test -- from any text-based syntax with a given prefix, e.g. for python:
#test:usb2
#test:device L500* D400*
#test:retries 3
#test:priority 0
And, for C++ the prefix could be:
//#test:...
"""
def __init__( self, source, line_prefix, context ):
"""
:param source: The absolute path to the text file
:param line_prefix: A regex to denote a directive (must be first thing in a line), which will
be immediately followed by the directive itself and optional arguments
:param context: context in which to configure the test
"""
TestConfig.__init__( self, context )
self.derive_config_from_text( source, line_prefix )
self.derive_tags_from_path( source )
def derive_config_from_text( self, source, line_prefix ):
# Configuration is made up of directives:
# #test:<directive>[:[!]<context>] <param>*
# If a context is not specified, the directive always applies. Any directive with a context
# will only get applied if we're running under the context it specifies (! means not, so
# !nightly means when not under nightly).
regex = r'^' + line_prefix
regex += r'([^\s:]+)' # 1: directive
regex += r'(?::(\S+))?' # 2: optional context
regex += r'((?:\s+\S+)*?)' # 3: params
regex += r'\s*(?:#\s*(.*))?$' # 4: optional comment
for line in file.grep( regex, source ):
match = line['match']
directive = match.group( 1 )
directive_context = match.group( 2 )
text_params = match.group( 3 ).strip()
params = [s for s in text_params.split()]
comment = match.group( 4 )
if directive_context:
not_context = directive_context.startswith('!')
if not_context:
directive_context = directive_context[1:]
# not_context | directive_ctx in context | RESULT
# ----------- | ------------------------ | ------
# 0 | 0 | IGNORE
# 0 | 1 | USE
# 1 | 0 | USE
# 1 | 1 | IGNORE
if not_context == (self._context and directive_context in self._context):
# log.d( "directive", line['line'], "ignored because of context mismatch with running context",
# self._context)
continue
if directive == 'device':
# log.d( ' configuration:', params )
params_lower_list = text_params.lower().split()
if not params:
log.e( source + '+' + str( line['index'] ) + ': device directive with no devices listed' )
elif sum(s.startswith('each(') for s in params_lower_list) > 1:
log.e( source + '+' + str(
line['index'] ) + ': each() cannot be used multiple times in same line', params )
elif params_lower_list[0].startswith('each('):
if not re.fullmatch( r'each\(.+\)', params_lower_list[0], re.IGNORECASE ):
log.e( source + '+' + str( line['index'] ) + ': invalid \'each\' syntax:', params )
else:
for param in params_lower_list[1:]:
if not param.startswith("!"):
log.e(source + '+' + str(line['index']) + ': invalid syntax:', params,
'. All device names after \'' + params[0] +
'\' must start with \'!\' in order to skip them')
break
else:
self._configurations.append( params )
else:
self._configurations.append( params )
elif directive == 'priority':
if len( params ) == 1 and params[0].isdigit():
self._priority = int( params[0] )
else:
log.e( source + '+' + str( line['index'] ) + ': priority directive with invalid parameters:',
params )
elif directive == 'timeout':
if len( params ) == 1 and params[0].isdigit():
self._timeout = int( params[0] )
else:
log.e( source + '+' + str( line['index'] ) + ': timeout directive with invalid parameters:',
params )
elif directive == 'retries':
if len( params ) == 1 and params[0].isdigit():
self._retries = int( params[0] )
else:
log.e( source + '+' + str( line['index'] ) + ': timeout directive with invalid parameters:',
params )
elif directive == 'tag':
self._tags.update( map( str.lower, params )) # tags are case-insensitive
elif directive == 'flag':
self._flags.update( params )
elif directive == 'donotrun':
if params:
log.e( source + '+' + str( line['index'] ) + ': donotrun directive should not have parameters:',
params )
self._donotrun = True
else:
log.e( source + '+' + str( line['index'] ) + ': invalid directive "' + directive + '"; ignoring' )
def derive_tags_from_path( self, source ):
# we need the relative path starting at the unit-tests directory
relative_path = re.split( r"[/\\]unit-tests[/\\]", source )[-1]
sub_dirs = re.split( r"[/\\]", relative_path )[:-1] # last element will be the name of the test
self._tags.update( sub_dirs )
class TestConfigFromCpp( TestConfigFromText ):
def __init__( self, source, context ):
TestConfigFromText.__init__( self, source, r'//#\s*test:', context )
self._tags.add( 'exe' )
class TestConfigFromPy( TestConfigFromText ):
def __init__( self, source, context ):
TestConfigFromText.__init__( self, source, r'#\s*test:', context )
self._tags.add( 'py' )
class Test( ABC ): # Abstract Base Class
"""
Abstract class for a test. Holds the name of the test
"""
def __init__( self, testname ):
# log.d( 'found', testname )
self._name = testname
self._config = None
self._ran = False
@abstractmethod
def run_test( self, configuration = None, log_path = None, opts = set() ):
pass
def debug_dump( self ):
if self._config:
self._config.debug_dump()
@property
def config( self ):
return self._config
@property
def name( self ):
return self._name
@property
def ran( self ):
return self._ran
def get_log( self ):
global logdir
if not logdir:
path = None
else:
path = logdir + os.sep + self.name + ".log"
return path
def is_live( self ):
"""
Returns True if the test configurations specify devices (test has a 'device' directive)
"""
return self._config and len( self._config.configurations ) > 0
def find_source_path( self ):
"""
:return: The relative path from unit-tests directory to the test's source file (cpp or py). If the source
file is not found None will be returned
"""
# TODO: this is limited to a structure in which .cpp files and directories do not share names
# For example:
# unit-tests/
# func/
# ...
# test-func.cpp
# test-func.cpp will not be found!
global unit_tests_dir
split_testname = self.name.split( '-' )
path = unit_tests_dir
relative_path = ""
found_test_dir = False
while not found_test_dir:
# index 0 should be 'test' as tests always start with it
found_test_dir = True
for i in range( 2,
len( split_testname ) ): # Checking if the next part of the test name is a sub-directory
possible_sub_dir = '-'.join( split_testname[1:i] ) # The next sub-directory could have several words
sub_dir_path = path + os.sep + possible_sub_dir
if os.path.isdir( sub_dir_path ):
path = sub_dir_path
relative_path += possible_sub_dir + os.sep
del split_testname[1:i]
found_test_dir = False
break
path += os.sep + '-'.join( split_testname )
relative_path += '-'.join( split_testname )
if os.path.isfile( path + ".cpp" ):
relative_path += ".cpp"
elif os.path.isfile( path + ".py" ):
relative_path += ".py"
else:
log.w( log.red + self.name + log.reset + ':',
'No matching .cpp or .py file was found; no configuration will be used!' )
return None
return relative_path
class PyTest( Test ):
"""
Class for python tests. Hold the path to the script of the test
"""
def __init__( self, testname, path_to_test, context = None ):
"""
:param testname: name of the test
:param path_to_test: the relative path from the current directory to the path
:param context: context in which the test will run
"""
global unit_tests_dir
Test.__init__( self, testname )
self.path_to_script = unit_tests_dir + os.sep + path_to_test
self._config = TestConfigFromPy( self.path_to_script, context )
def debug_dump( self ):
log.d( 'script:', self.path_to_script )
Test.debug_dump( self )
def command( self, to_file ):
"""
:param to_file: True if stdout is redirected to a file (so colors make no sense)
"""
cmd = [sys.executable]
#
# PYTHON FLAGS
#
# -u : force the stdout and stderr streams to be unbuffered; same as PYTHONUNBUFFERED=1
# With buffering we may end up losing output in case of crashes! (in Python 3.7 the text layer of the
# streams is unbuffered, but we assume 3.6)
cmd += ['-u']
#
if sys.flags.verbose:
cmd += ["-v"]
#
cmd += [self.path_to_script]
#
# SCRIPT FLAGS
#
# If the script has a custom-arguments flag, then we don't pass any of the standard options
if 'custom-args' not in self.config.flags:
#
if log.is_debug_on():
cmd += ['--debug']
#
if to_file:
pass
elif log.is_color_on():
cmd += ['--color']
elif log.is_color_disabled():
cmd += ['--no-color']
#
if self.config.context:
cmd += ['--context', ' '.join(self.config.context)]
return cmd
def run_test( self, configuration = None, log_path = None, opts = set() ):
try:
cmd = self.command( to_file = log_path and log_path != subprocess.PIPE )
if opts:
cmd += [opt for opt in opts]
run( cmd, stdout=log_path, append=self.ran, timeout=self.config.timeout )
finally:
self._ran = True
class ExeTest( Test ):
"""
Class for c/cpp tests. Hold the path to the executable for the test
"""
def __init__( self, testname, exe = None, context = None ):
"""
:param testname: name of the test
:param exe: full path to executable
:param context: context in which the test will run
"""
global unit_tests_dir
Test.__init__( self, testname )
self.exe = exe
relative_test_path = self.find_source_path()
if relative_test_path:
self._config = TestConfigFromCpp( unit_tests_dir + os.sep + relative_test_path, context )
else:
self._config = TestConfig(context)
self._config.tags.add( 'exe' )
def debug_dump( self ):
if self.exe:
if not os.path.isfile( self.exe ):
log.d( "exe does not exist: " + self.exe )
else:
log.d( 'exe:', self.exe )
Test.debug_dump( self )
def command( self, to_file ):
"""
:param to_file: True if stdout is redirected to a file (so colors make no sense)
"""
cmd = [self.exe]
if 'custom-args' not in self.config.flags:
# Assume we're a Catch2 exe, so:
# if sys.flags.verbose:
# cmd +=
if log.is_debug_on():
cmd += ['-d', 'yes'] # show durations for each test-case
# cmd += ['--success'] # show successful assertions in output
cmd += ['--debug']
# if not to_file and log.is_color_on():
# cmd += ['--use-colour', 'yes']
if self.config.context:
cmd += ['--context', ' '.join(self.config.context)]
return cmd
def run_test( self, configuration = None, log_path = None, opts = set() ):
if not self.exe:
raise RuntimeError("Tried to run test " + self.name + " with no exe file provided")
try:
cmd = self.command( to_file = log_path and log_path != subprocess.PIPE )
if opts:
cmd += [opt for opt in opts]
run( cmd, stdout=log_path, append=self.ran, timeout=self.config.timeout )
finally:
self._ran = True