#!python3 # License: Apache 2.0. See LICENSE file in root directory. # Copyright(c) 2020 Intel Corporation. All Rights Reserved. # # Syntax: # unit-test-config.py # # Looks for possible single-file unit-testing targets (test-*) in $dir, and builds # a CMakeLists.txt in $builddir to compile them. # # Each target is compiled in its own project, so that each file ends up in a different # process and so individual tests cannot affect others except through hardware. # import sys, os, subprocess, locale, re, getopt from glob import glob current_dir = os.path.dirname( os.path.abspath( __file__ ) ) sys.path.append( current_dir + os.sep + "py" ) from rspy import file, repo, libci, log def usage(): ourname = os.path.basename(sys.argv[0]) print( 'Syntax: ' + ourname + ' [options] ' ) print( ' build unit-testing framework for the tree in $dir' ) print( ' -r, --regex configure all tests that fit the following regular expression' ) print( ' -t, --tag configure all tests with the following tag. If used multiple times runs all tests matching' ) print( ' all tags. e.g. -t tag1 -t tag2 will run tests who have both tag1 and tag2' ) print( ' tests automatically get tagged with \'exe\' or \'py\' and based on their location' ) print( ' inside unit-tests/, e.g. unit-tests/func/test-hdr.py gets [func, py]' ) print( ' --list-tags print out all available tags. This option will not run any tests' ) print( ' --list-tests print out all available tests. This option will not run any tests' ) print( ' if both list-tags and list-tests are specified each test will be printed along' ) print( ' with what tags it has' ) print( ' --context The context to use for test configuration' ) print( ' --live Only configure tests that are live (have test:device)' ) print( ' --not-live Only configure tests that are NOT live (do not have test:device)' ) sys.exit(2) regex = None required_tags = [] list_tags = False list_tests = False context = None live_only = False not_live_only = False # parse command-line: try: opts, args = getopt.getopt( sys.argv[1:], 'hr:t:', longopts=['help', 'regex=', 'tag=', 'list-tags', 'list-tests', 'context=', 'live', 'not-live'] ) except getopt.GetoptError as err: log.e( err ) # something like "option -a not recognized" usage() for opt, arg in opts: if opt in ('-h', '--help'): usage() elif opt in ('-r', '--regex'): regex = arg elif opt in ('-t', '--tag'): required_tags.append( arg ) elif opt == '--list-tags': list_tags = True elif opt == '--list-tests': list_tests = True elif opt == '--context': context = arg elif opt == '--live': if not_live_only: raise RuntimeError( '--live and --not-live are mutually exclusive' ) live_only = True elif opt == '--not-live': if live_only: raise RuntimeError( '--live and --not-live are mutually exclusive' ) not_live_only = True if len( args ) != 2: usage() dir=args[0] builddir=args[1] if not os.path.isdir( dir ) or not os.path.isdir( builddir ): usage() # We have to stick to Unix conventions because CMake on Windows is fubar... root = repo.root.replace( '\\' , '/' ) src = root + '/src' def add_slash_before_spaces(links): """ This function adds '\' char before spaces in string or list of strings. Because spaces in links can't been read properly from cmake files. """ if links and type(links) is str: return links.replace(' ', '\ ') if links and type(links) is list: # Build list comprehension of strings with backslash before spaces in case the link. # We can get a ${var} so, when we get this we return as is return [link.replace(' ', '\ ') if link[0] != '$' else link for link in links] else: raise TypeError def generate_cmake( builddir, testdir, testname, filelist, custom_main, dependencies ): makefile = builddir + '/' + testdir + '/CMakeLists.txt' log.d( ' creating:', makefile ) handle = open( makefile, 'w' ) #filelist = add_slash_before_spaces(filelist) directory = add_slash_before_spaces(dir) root_directory = add_slash_before_spaces(root) filelist = '\n '.join( filelist ) handle.write( ''' # This file is automatically generated!! # Do not modify or your changes will be lost! cmake_minimum_required( VERSION 3.10.0 ) project( ''' + testname + ''' ) set( SRC_FILES ''' + filelist + ''' ) add_executable( ${PROJECT_NAME} ${SRC_FILES} ) add_definitions( ''' + ' '.join( f'-DLIBCI_DEPENDENCY_{d}' for d in dependencies.split() ) + ''' ) source_group( "Common Files" FILES ''' + directory + '''/test.cpp''') if not custom_main: handle.write(' ' + directory + '/unit-test-default-main.cpp') handle.write( ''' ) target_link_libraries( ${PROJECT_NAME} ''' + dependencies + ''' Catch2 ) set_target_properties( ${PROJECT_NAME} PROPERTIES FOLDER "Unit-Tests/''' + os.path.dirname( testdir ) + '''" ) using_easyloggingpp( ${PROJECT_NAME} SHARED ) # Add the repo root directory (so includes into src/ will be specific: ) target_include_directories( ${PROJECT_NAME} PRIVATE ''' + root + ''') ''' ) handle.close() def find_include( include, relative_to ): """ Try to match the include to an existing file. :param include: the text within "" or <> from the include directive :param relative_to: the directory from which to start finding if include is non-absolute :return: the normalized & absolute file path, if found -- otherwise, None """ if include: if not os.path.isabs( include ): include = os.path.normpath( relative_to + '/' + include ) include = include.replace( '\\', '/' ) if os.path.exists( include ): return include def find_include_in_dirs( include, dirs ): """ Search for the given include in all the specified directories """ for include_dir in dirs: path = find_include( include, include_dir ) if path: return path def find_includes( filepath, filelist, dependencies ): """ Recursively searches a .cpp file for #include directives :param filelist: any previous includes already processed (pass an empty dict() if none) :param dependencies: set of dependencies :return: a dictionary (include->source) of includes found """ include_dirs = list() if 'realsense2' in dependencies: include_dirs.append( os.path.join( root, 'include' )) include_dirs.append( os.path.join( root, 'third-party', 'rsutils', 'include' )) include_dirs.append( root ) filedir = os.path.dirname(filepath) try: log.debug_indent() for include_line in file.grep( r'^\s*#\s*include\s+("(.*)"|<(.*)>)\s*$', filepath ): m = include_line['match'] index = include_line['index'] include = find_include( m.group(2), filedir ) or find_include_in_dirs( m.group(2), include_dirs ) or find_include_in_dirs( m.group(3), include_dirs ) if include: if include in filelist: log.d( m.group(0), '->', include, '(already processed)' ) else: log.d( m.group(0), '->', include ) filelist[include] = filepath filelist = find_includes( include, filelist, dependencies ) else: log.d( 'not found:', m.group(0) ) finally: log.debug_unindent() return filelist def process_cpp( dir, builddir ): global regex, required_tags, list_only, available_tags, tests_and_tags, live_only, not_live_only found = [] shareds = [] statics = [] if regex: pattern = re.compile( regex ) log.d( 'looking for C++ files in:', dir ) for f in file.find( dir, '(^|/)test-.*\.cpp$' ): testdir = os.path.splitext( f )[0] # "log/internal/test-all" <- "log/internal/test-all.cpp" testparent = os.path.dirname(testdir) # "log/internal" # We need the project name unique: keep the path but make it nicer: if testparent: testname = 'test-' + testparent.replace( '/', '-' ) + '-' + os.path.basename( testdir )[ 5:] # "test-log-internal-all" else: testname = testdir # no parent folder so we get "test-all" if regex and not pattern.search( testname ): continue log.d( '... found:', f ) log.debug_indent() try: config = libci.TestConfigFromCpp( dir + os.sep + f, context ) if required_tags or list_tags: if not all( tag in config.tags for tag in required_tags ): continue available_tags.update( config.tags ) if list_tests: tests_and_tags[ testname ] = config.tags if testname not in tests_and_tags: tests_and_tags[testname] = None if live_only: if not config.configurations: continue elif not_live_only: if config.configurations: continue if config.donotrun: continue # Build the list of files we want in the project: # At a minimum, we have the original file, plus any common files filelist = [ dir + '/' + f ] includes = dict() # Add any files explicitly listed in the .cpp itself, like this: # //#cmake:add-file # Any files listed are relative to $dir shared = False static = False custom_main = False dependencies = 'realsense2' for cmake_directive in file.grep( '^//#cmake:\s*', dir + '/' + f ): m = cmake_directive['match'] index = cmake_directive['index'] cmd, *rest = cmake_directive['line'][m.end():].split() if cmd == 'add-file': for additional_file in rest: files = additional_file if not os.path.isabs( additional_file ): files = dir + '/' + testparent + '/' + additional_file files = glob( files ) if not files: log.e( f + '+' + str(index) + ': no files match "' + additional_file + '"' ) for abs_file in files: abs_file = os.path.normpath( abs_file ) abs_file = abs_file.replace( '\\', '/' ) if not os.path.exists( abs_file ): log.e( f + '+' + str(index) + ': file not found "' + additional_file + '"' ) log.d( 'add file:', abs_file ) filelist.append( abs_file ) if( os.path.splitext( abs_file )[0] == 'cpp' ): # Add any "" includes specified in the .cpp that we can find includes = find_includes( abs_file, includes, dependencies ) elif cmd == 'static!': if len(rest): log.e( f + '+' + str(index) + ': unexpected arguments past \'' + cmd + '\'' ) elif shared: log.e( f + '+' + str(index) + ': \'' + cmd + '\' mutually exclusive with \'shared!\'' ) else: log.d( 'static!' ) static = True elif cmd == 'shared!': if len(rest): log.e( f + '+' + str(index) + ': unexpected arguments past \'' + cmd + '\'' ) elif static: log.e( f + '+' + str(index) + ': \'' + cmd + '\' mutually exclusive with \'static!\'' ) else: log.d( 'shared!' ) shared = True elif cmd == 'custom-main': custom_main = True elif cmd == 'dependencies': dependencies = ' '.join( rest ) else: log.e( f + '+' + str(index) + ': unknown cmd \'' + cmd + '\' (should be \'add-file\', \'static!\', or \'shared!\')' ) # Add any includes specified in the .cpp that we can find includes = find_includes( dir + '/' + f, includes, dependencies ) for include,source in includes.items(): filelist.append( f'"{include}" # {source}' ) # all tests use the common test.cpp file filelist.append( root + "/unit-tests/test.cpp" ) # 'cmake:custom-main' indicates that the test is defining its own main() function. # If not specified we use a default main() which lives in its own .cpp: if not custom_main: filelist.append( root + "/unit-tests/unit-test-default-main.cpp" ) if list_only: continue # Each CMakeLists.txt sits in its own directory os.makedirs( builddir + '/' + testdir, exist_ok=True ) # "build/log/internal/test-all" generate_cmake( builddir, testdir, testname, filelist, custom_main, dependencies ) if static: statics.append( testdir ) elif shared: shareds.append( testdir ) else: found.append( testdir ) finally: log.debug_unindent() return found, shareds, statics def process_py( dir, builddir ): # TODO return [],[],[] list_only = list_tags or list_tests available_tags = set() tests_and_tags = dict() normal_tests = [] shared_tests = [] static_tests = [] n,sh,st = process_cpp( dir, builddir ) if list_only: if list_tags and list_tests: for t in sorted( tests_and_tags.keys() ): print( t, "has tags:", ' '.join( tests_and_tags[t] ) ) # elif list_tags: for t in sorted( list( available_tags ) ): print( t ) # elif list_tests: for t in sorted( tests_and_tags.keys() ): print( t ) sys.exit( 0 ) normal_tests.extend( n ) shared_tests.extend( sh ) static_tests.extend( st ) n,sh,st = process_py( dir, builddir ) normal_tests.extend( n ) shared_tests.extend( sh ) static_tests.extend( st ) cmakefile = builddir + '/CMakeLists.txt' name = os.path.basename( os.path.realpath( dir )) log.d( 'Creating "' + name + '" project in', cmakefile ) handle = open( cmakefile, 'w' ) handle.write( ''' ''' ) n_tests = 0 for sdir in normal_tests: handle.write( 'add_subdirectory( ' + sdir + ' )\n' ) log.d( '... including:', sdir ) n_tests += 1 if len(shared_tests): handle.write( 'if(NOT ${BUILD_SHARED_LIBS})\n' ) handle.write( ' message( INFO " ' + str(len(shared_tests)) + ' shared lib unit-tests will be skipped. Check BUILD_SHARED_LIBS to run them..." )\n' ) handle.write( 'else()\n' ) for test in shared_tests: handle.write( ' add_subdirectory( ' + test + ' )\n' ) log.d( '... including:', sdir ) n_tests += 1 handle.write( 'endif()\n' ) if len(static_tests): handle.write( 'if(${BUILD_SHARED_LIBS})\n' ) handle.write( ' message( INFO " ' + str(len(static_tests)) + ' static lib unit-tests will be skipped. Uncheck BUILD_SHARED_LIBS to run them..." )\n' ) handle.write( 'else()\n' ) for test in static_tests: handle.write( ' add_subdirectory( ' + test + ' )\n' ) log.d( '... including:', sdir ) n_tests += 1 handle.write( 'endif()\n' ) handle.close() print( 'Generated ' + str(n_tests) + ' unit-tests' ) if log.n_errors(): sys.exit(1) sys.exit(0)