import os from os.path import join as pjoin import sys import platform import textwrap import re import subprocess import difflib import time import types import shutil import SCons.Errors class DefineDict(object): """ A dictionary-like object which generates appropriate preprocessor define statements from its dict of variable / value pairs. Variables whose value is None or that are not in the dict are left undefined. """ def __init__(self, data): self.data = data self.undefined = set() def __getitem__(self, key): if key not in self.data: self.undefined.add(key) return '/* #undef %s */' % key elif self.data[key] is None: return '/* #undef %s */' % key else: return '#define %s %s' % (key, self.data[key]) class ConfigBuilder(object): """ Used along with DefineDict to generate a customized config.h file from a config.h.in file using the variables given in 'defines'. """ def __init__(self, defines): self.defines = DefineDict(defines) def __call__(self, source, target, env): for s, t in zip(source, target): config_h_in = file(str(s), "r") config_h = file(str(t), "w") config_h.write(config_h_in.read() % self.defines) config_h_in.close() config_h.close() self.print_config(str(t)) def print_config(self, filename): print 'Generating %s with the following settings:' % filename for key, val in sorted(self.defines.data.iteritems()): if val is not None: print " %-35s %s" % (key, val) for key in sorted(self.defines.undefined): print " %-35s %s" % (key, '*undefined*') class TestResults(object): """ A class that stores information about all the regression tests that are defined and which ones have passed / failed in order to print a summary at the end of the build process. """ def __init__(self): self.tests = {} self.passed = {} self.failed = {} def printReport(self, target, source, env): total = len(self.passed) + len(self.failed) print """ ********************************** *** Regression Testing Summary *** ********************************** Tests passed: %(passed)s Tests failed: %(failed)s Up-to-date tests skipped: %(skipped)s **********************************""" % dict( passed=len(self.passed), failed=len(self.failed), skipped=len(self.tests)) if self.failed: raise SCons.Errors.BuildError(self, 'One or more tests failed.') testResults = TestResults() def regression_test(target, source, env): """ Run a regression test comparing the output of a test program with existing "blessed" output files. target - The name of an output file that will be generated if the test is successful. source - A list containing the name of the program to run and (optionally) a list of file names to be passed as command line arguments. The test also relies on several parameters that are passed in via variables in the SCons environment: env['test_command_options'] - non-file command line options to be passed to to the test program """ # unpack: program = source[0] if len(source) > 1: clargs = [s.name for s in source[1:]] else: clargs = [] # Name to use for the output file blessedName = env['test_blessed_file'] if blessedName is not None and 'blessed' in blessedName: outputName = blessedName.replace('blessed', 'output') else: outputName = 'test_output.txt' # command line options clopts = env['test_command_options'].split() # Run the test program dir = str(target[0].dir.abspath) with open(pjoin(dir,outputName), 'w') as outfile: code = subprocess.call([program.abspath] + clopts + clargs, stdout=outfile, stderr=outfile, cwd=dir) diff = 0 # Compare output files comparisons = env['test_comparisons'] if blessedName is not None: comparisons.append((blessedName,outputName)) for blessed,output in comparisons: print """Comparing '%s' with '%s'""" % (blessed, output) diff |= compareFiles(env, pjoin(dir, blessed), pjoin(dir, output)) del testResults.tests[env['active_test_name']] if diff or code: print 'FAILED' if os.path.exists(target[0].abspath): os.path.unlink(target[0].abspath) testResults.failed[env['active_test_name']] = 1 else: print 'PASSED' open(target[0].path, 'w').write(time.asctime()+'\n') testResults.passed[env['active_test_name']] = 1 def compareFiles(env, file1, file2): """ Compare the contents of two files, using a method chosen based on their file extensions. """ if file1.endswith('csv') and file2.endswith('csv'): return compareCsvFiles(env, file1, file2) else: return compareTextFiles(env, file1, file2) def compareTextFiles(env, file1, file2): """ Compare the contents of two text files while: - ignoring trailing whitespace - ignoring any lines starting with strings specified in the variable env['test_ignoreLines']. - comparing floating point numbers only up to the printed precision """ text1 = [line.rstrip() for line in open(file1).readlines() if not line.startswith(tuple(env['test_ignoreLines']))] text2 = [line.rstrip() for line in open(file2).readlines() if not line.startswith(tuple(env['test_ignoreLines']))] # Try to compare the files without testing the floating point numbers diff = list(difflib.unified_diff(text1, text2)) if not diff: return 0 # Replace nearly-equal floating point numbers with exactly equivalent # representations to avoid confusing difflib reFloat = re.compile(r'(\s*)([+-]{0,1}\d+\.{0,1}\d*[eE]{0,1}[+-]{0,1}\d*)') for i in range(min(len(text1), len(text2))): line1 = text1[i] line2 = text2[i] if line1 == line2: continue # group(1) is the left space padding # group(2) is the number floats1 = [(m.group(1),m.group(2)) for m in list(reFloat.finditer(line1))] floats2 = [(m.group(1),m.group(2)) for m in list(reFloat.finditer(line2))] # If the lines don't contain the same number of numbers, # we're not going to pass the diff comparison no matter what if len(floats1) != len(floats2): continue allMatch = True for j in range(len(floats1)): if floats1[j] == floats2[j]: # String representations match, so replacement is unnecessary continue delta = max(getPrecision(floats1[j][1]), getPrecision(floats2[j][1])) num1 = float(floats1[j][1]) num2 = float(floats2[j][1]) if not num1 - 1.1*delta < num2 < num1 + 1.1*delta: allMatch = False break # All the values are sufficiently close, so replace the string # so that the diff of this line will succeed text2[i] = line1 # Try the comparison again diff = list(difflib.unified_diff(text1, text2)) if diff: print 'Found differences between %s and %s:' % (file1, file2) print '>>>' print '\n'.join(diff) print '<<<' return 1 return 0 def getPrecision(x): """ Return the number corresponding to the least significant digit of the number represented by the string 'x'. """ x = x.lower() decimalPt = x.find('.') if decimalPt == -1: decimalPt = 0 if x.find('e') != -1: precision = decimalPt - x.find('e') + 1 precision += int(x[x.find('e')+1:]) else: precision = decimalPt - len(x) + 1 return 10**precision def compareCsvFiles(env, file1, file2): """ Compare the contents of two .csv file to see if they are similar. Similarity is defined according to tolerances stored in the environment as: env['test_csv_threshold'] env['test_csv_tolerance'] The comparison for each variable is: |a-b|/(max(|a|,|b|) + threshold) < tolerance Returns 0 if all variables in the files are similar and 1 if the files are dissimilar. Lines containing non-numeric data are automatically ignored. """ try: import numpy as np except ImportError: print 'WARNING: skipping .csv diff because numpy is not installed' return 0 # decide how many header lines to skip f = open(file1) headerRows = 0 goodChars = set('0123456789.+-eE, ') for line in f: if not set(line[:-1]).issubset(goodChars): headerRows += 1 else: break try: data1 = np.genfromtxt(file1, skiprows=headerRows, delimiter=',') data2 = np.genfromtxt(file2, skiprows=headerRows, delimiter=',') except (IOError, StopIteration) as e: print e return 1 relerror = (np.abs(data2-data1) / (np.maximum(np.abs(data2), np.abs(data1)) + env['test_csv_threshold'])) maxerror = np.nanmax(relerror.flat) tol = env['test_csv_tolerance'] if maxerror > tol: # Threshold based on printing 6 digits in the CSV file print ("Files differ. %i / %i elements above specified tolerance" % (np.sum(relerror > tol), relerror.size)) return 1 else: return 0 def regression_test_message(target, source, env): """ Determines the message printed by SCons while building a RegressionTest target. """ return """* Running test '%s'...""" % env['active_test_name'] def add_RegressionTest(env): """ Add "RegressionTest" as a Builder in the specified Scons Environment. """ env['BUILDERS']['RegressionTest'] = env.Builder( action=env.Action(regression_test, regression_test_message)) def quoted(s): """ Returns the given string wrapped in double quotes.""" return '"%s"' % s def mglob(env, subdir, *args): """ Each arg in args is assumed to be file extension, unless the arg starts with a '^', in which case the remainder of the arg is taken to be a complete pattern. """ matches = [] for ext in args: if ext.startswith('^'): matches += env.Glob(pjoin(subdir, ext[1:])) else: matches += env.Glob(pjoin(subdir, '*.%s' % ext)) return matches def psplit(s): """ Split a path given as a string into a list. This is the inverse of os.path.join. """ s = s.strip('/\\') head, tail = os.path.split(s) path = [tail] while head: head, tail = os.path.split(head) path.append(tail) path.reverse() return path def stripDrive(s): """ Remove a Windows drive letter specification from a path. """ if len(s) > 1 and s[1] == ':': return s[2:] else: return s def which(program): """ Replicates the functionality of the 'which' shell command """ def is_exe(fpath): for ext in ('', '.exe', '.bat'): if os.path.exists(fpath + ext): return os.access(fpath + ext, os.X_OK) return False fpath, fname = os.path.split(program) if fpath: if is_exe(program): return program else: for path in os.environ["PATH"].split(os.pathsep): exe_file = os.path.join(path, program) if is_exe(exe_file): return exe_file return None optionWrapper = textwrap.TextWrapper(initial_indent=' ', subsequent_indent=' ', width=72) def formatOption(env, opt): """ Print a nicely formatted description of a SCons configuration option, its permitted values, default value, and current value if different from the default. """ # Extract the help description from the permitted values. Original format # is in the format: "Help text ( value1|value2 )" or "Help text" if opt.help.endswith(')'): parts = opt.help.split('(') help = '('.join(parts[:-1]) values = parts[-1][:-1].strip().replace('|', ' | ') if values == '': values = 'string' else: help = opt.help values = 'string' # Fix the representation of boolean options, which are stored as # Python bools, but need to be passed by the user as strings default = opt.default if default is True: default = 'yes' elif default is False: default = 'no' # First line: "* option-name: [ choice1 | choice2 ]" lines = ['* %s: [ %s ]' % (opt.key, values)] # Help text, wrapped and idented 4 spaces lines.extend(optionWrapper.wrap(re.sub(r'\s+', ' ',help))) # Default value lines.append(' - default: %r' % default) # Get the actual value in the current environment if opt.key in env: actual = env.subst('${%s}' % opt.key) else: actual = None # Fix the representation of boolean options if actual == 'True': actual = 'yes' elif actual == 'False': actual = 'no' # Print the value if it differs from the default if actual != default: lines.append(' - actual: %r' % actual) lines.append('') return lines def listify(value): """ Convert an option specified as a string to a list. Allow both comma and space as delimiters. Passes lists transparently. """ if isinstance(value, types.StringTypes): return value.replace(',', ' ').split() else: # Already a sequence. Return as a list return list(value) def removeFile(name): """ Remove file (if it exists) and print a log message """ if os.path.exists(name): print 'Removing file "%s"' % name os.remove(name) def removeDirectory(name): """ Remove directory recursively and print a log message """ if os.path.exists(name): print 'Removing directory "%s"' % name shutil.rmtree(name) def ipdb(): """ Break execution and drop into an IPython debug shell at the point where this function is called. """ from IPython.core.debugger import Pdb from IPython.core import ipapi ip = ipapi.get() def_colors = ip.colors Pdb(def_colors).set_trace(sys._getframe().f_back) def getSpawn(env): """ A replacement for env['SPAWN'] on Windows that can deal with very long commands, namely those generated when linking. This is only used when compiling with MinGW, as SCons automatically uses a tempfile for the MSVC link command. Pass the return value of this function as the SPAWN keyword argument to the Library target, e.g.: env.SharedLibrary(..., SPAWN=getSpawn(env)) Adapted from http://www.scons.org/wiki/LongCmdLinesOnWin32 """ if sys.platform != 'win32' or env['toolchain'] != 'mingw': return env['SPAWN'] try: useShowWindow = subprocess.STARTF_USESHOWWINDOW except AttributeError: useShowWindow = subprocess._subprocess.STARTF_USESHOWWINDOW def ourSpawn(sh, escape, cmd, args, environ): newargs = ' '.join(args[1:]) cmdline = cmd + " " + newargs startupinfo = subprocess.STARTUPINFO() startupinfo.dwFlags |= useShowWindow proc = subprocess.Popen(cmdline, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, startupinfo=startupinfo, shell=False, env=environ) data, err = proc.communicate() rv = proc.wait() if rv: print "=====" print err print "=====" return rv return ourSpawn