1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458
|
#!/usr/bin/env python3
"""Helper code for app tests.
This module contains helper functions and classes for making app tests easy.
The advantage of using Python for this is easier portability instead of relying
on Unix tools such as bash and diff which are harder to install on Windows than
Python.
App tests are performed by executing the programs on test data and comparing
their output to previously generated "golden" output files.
Classes/Functions:
class TestConf -- stores configuration of a test.
class TestPathHelper -- helps with constructing paths.
function runTest -- runs a test configured by a TestConf object.
function autolocateBinary -- locates a binary, possibly in an intermediary
directory.
"""
__author__ = 'Manuel Holtgrewe <manuel.holtgrewe@fu-berlin.de>'
import difflib
import hashlib
import logging
import optparse
import os
import os.path
import re
import subprocess
import shutil
import sys
import tempfile
import gzip
def md5ForFile(f, block_size=2**20):
"""Compute MD5 of a file.
Taken from http://stackoverflow.com/a/1131255/84349.
"""
md5 = hashlib.md5()
while True:
data = f.read(block_size)
if not data:
break
md5.update(data)
return md5.hexdigest()
# Valgrind flags, taken from CMake output, ideally given to test script by CMake?
SUPPRESSIONS = '--suppressions=' + os.path.join(os.path.dirname(__file__), '..', '..', '..', 'misc', 'seqan.supp')
VALGRIND_FLAGS = [SUPPRESSIONS] + '--error-exitcode=1 -q --tool=memcheck --leak-check=yes --show-reachable=yes --workaround-gcc296-bugs=yes --num-callers=50 --'.split()
VALGRIND_PATH = '/usr/bin/valgrind'
class BadResultException(Exception):
pass
class TestConf(object):
"""Configuration for one tests.
A test configuration consists of the parameters to give to the
program and the expected result.
Attrs:
program -- string, path to binary to execute.
args -- list of strings with arguments to the program.
to_diff -- optional list of pairs with (output-file, expected-file) paths
diff, the contents of output-file should be equal to the
contents of expected-file.
name -- optional string, name of the test.
redir_stdout -- optional string that gives the path to redirect stdout to
if the variable is not None.
redir_stderr -- optional string that gives the path to redirect stderr to
if the variable is not None.
check_callback -- callable throwing an exception on erorrs.
"""
def __init__(self, program, args, to_diff=[], name=None,
redir_stdout=None, redir_stderr=None,
check_callback=None):
"""Constructor, args correspond to attrs."""
self.program = program
self.args = args
self.to_diff = to_diff
self.name = name
self.redir_stdout = redir_stdout
self.redir_stderr = redir_stderr
if not hasattr(TestConf, 'valgrind'):
self.valgrind = False
else:
self.valgrind = TestConf.valgrind
self.check_callback = check_callback
def __str__(self):
fmt = 'TestConf(%s, %s, %s, %s, %s, %s)'
return fmt % (repr(self.program), self.args, self.to_diff, self.name,
self.redir_stdout, self.redir_stderr)
def commandLineArgs(self):
"""Returns the command line."""
args = [x for x in self.args if x != '']
args = [self.program] + args
if self.valgrind:
args = [VALGRIND_PATH] + VALGRIND_FLAGS + args
return args
class TestPathHelper(object):
"""Helper class for paths.
TestPathHelper objects are configured with the appropriate paths. The
provide functions to construct when executing tests.
"""
def __init__(self, source_base_path, binary_base_path,
tests_dir):
self.temp_dir = None
self.source_base_path = source_base_path
self.binary_base_path = binary_base_path
self.tests_dir = tests_dir
self.created_paths = []
def inFile(self, path):
"""Convert the path of a test file.
The given path, relative to the test directory, will be transformed into an
absolute path to the file.
Args:
path -- relative path to a file in the test directory.
Returns:
Absolute to the file.
"""
result = os.path.join(self.source_base_path, self.tests_dir, path)
logging.debug('inFile(%s) = %s', path, result)
return result
def outFile(self, path, subdir=None):
"""Convert the path of an output file.
The given path will be converted to a path to a temporary file. The path
to this file will be created.
If subdir is set then a subdirectory with this name will be created and
the output will be relative to subdir.
"""
if not self.temp_dir:
self.temp_dir = tempfile.mkdtemp()
if not os.path.isdir(self.temp_dir):
self.created_paths.append(self.temp_dir)
os.makedirs(self.temp_dir)
target_dir = self.temp_dir
if subdir:
target_dir = os.path.join(self.temp_dir, subdir)
if not os.path.isdir(target_dir):
self.created_paths.append(target_dir)
os.makedirs(target_dir)
logging.debug('outFile(%s, %s) = %s', path, subdir, self.temp_dir)
res = os.path.join(target_dir, path)
self.created_paths.append(res)
return res
def deleteTempDir(self):
"""Remove the temporary directory created earlier and all files below."""
print('DELETING TEMP DIR', self.temp_dir, file=sys.stderr)
if self.temp_dir:
shutil.rmtree(self.temp_dir)
def autolocateBinary(base_path, relative_path, binary_name):
"""Autolocates a binary, possibly in an intermediary path.
When building applications with CMake, they do not always have the same
relative path from the binary build directory. For Unix Makefiles, the path
could be 'apps/tree_recon' whereas for Visual Studio, it could be
'apps/Release/tree_recon'.
Also, it searches for the binary name in "${base_path}/bin".
This function tries to automatically guess the name of the file and return
the first one it finds.
"""
# Names of intermediary directories and possible file extensions.
intermediary_dir_names = ['', 'Debug', 'Release']
extensions = ['', '.exe']
paths = [os.path.join(base_path, 'bin', binary_name),
os.path.join(base_path, 'bin', 'Debug', binary_name),
os.path.join(base_path, 'bin', 'Release', binary_name),
os.path.join(base_path, 'bin', binary_name + '.exe'),
os.path.join(base_path, 'bin', 'Debug', binary_name + '.exe'),
os.path.join(base_path, 'bin', 'Release', binary_name + '.exe')]
# Try all possible paths.
for dir_name in intermediary_dir_names:
for ext in extensions:
# With CMAKE_BINARY_DIR not set to "bin".
res_list = [base_path, relative_path, dir_name, binary_name + ext]
filtered_list = [x for x in res_list if x] # Filter out empty strings.
paths.append(os.path.join(*filtered_list))
if dir_name:
paths.append('/'.join([base_path] + relative_path.split('/')[:-1] +
[dir_name] + relative_path.split('/')[-1:] +
[binary_name]))
for path in paths:
logging.debug('Trying path %s', path)
if os.path.isfile(path):
logging.debug(' Found binary %s', path)
return path
# Fall back ot Unix default.
return os.path.join(base_path, relative_path, binary_name)
def runTest(test_conf):
"""Run the test configured in test_conf.
Args:
test_conf -- TestConf object to run test for.
Returns:
True on success, False on any errors.
Side Effects:
Errors are printed to stderr.
"""
# Execute the program.
logging.debug('runTest(%s)', test_conf)
logging.debug('Executing "%s"', ' '.join(test_conf.commandLineArgs()))
stdout_file = subprocess.PIPE
if test_conf.redir_stdout:
logging.debug(' Redirecting stdout to "%s".' % test_conf.redir_stdout)
stdout_file = open(test_conf.redir_stdout, 'w+')
stderr_file = subprocess.PIPE
if test_conf.redir_stderr:
logging.debug(' Redirecting stderr to "%s".' % test_conf.redir_stderr)
stderr_file = open(test_conf.redir_stderr, 'w+')
try:
process = subprocess.Popen(test_conf.commandLineArgs(), stdout=stdout_file,
stderr=stderr_file)
retcode = process.wait()
logging.debug(' return code is %d', retcode)
if retcode != 0:
fmt = 'Return code of command "%s" was %d.'
print('--- stdout begin --', file=sys.stderr)
print(fmt % (' '.join(test_conf.commandLineArgs()), retcode), file=sys.stderr)
print(stdout_file.read(), file=sys.stderr)
print('--- stdout end --', file=sys.stderr)
stdout_file.close()
if process.stderr:
stderr_contents = process.stderr.read()
else:
stderr_contents = ''
print('-- stderr begin --', file=sys.stderr)
print(stderr_contents, file=sys.stderr)
print('-- stderr end --', file=sys.stderr)
return False
except Exception as e:
# Print traceback.
import traceback
exc_type, exc_value, exc_traceback = sys.exc_info()
traceback.print_exception(exc_type, exc_value, exc_traceback)
fmt = 'ERROR (when executing "%s"): %s'
if stdout_file is not subprocess.PIPE:
stdout_file.close()
print(fmt % (' '.join(test_conf.commandLineArgs()), e), file=sys.stderr)
return False
# Handle error of program, indicated by return code != 0.
if retcode != 0:
print('Error when executing "%s".' % ' '.join(test_conf.commandLineArgs()), file=sys.stderr)
print('Return code is %d' % retcode, file=sys.stderr)
if stdout_file is not subprocess.PIPE:
stdout_file.seek(0)
stdout_contents = process.stdout.read()
if stdout_contents:
print('-- stdout begin --', file=sys.stderr)
print(stdout_contents, file=sys.stderr)
print('-- stdout end --', file=sys.stderr)
else:
print('-- stdout is empty --', file=sys.stderr)
stderr_contents = process.stderr.read()
if stderr_contents:
print('-- stderr begin --', file=sys.stderr)
print(stderr_contents, file=sys.stderr)
print('-- stderr end --', file=sys.stderr)
else:
print('-- stderr is empty --', file=sys.stderr)
# Close standard out file if necessary.
if stdout_file is not subprocess.PIPE:
stdout_file.close()
# Compare results with expected results, if the expected and actual result
# are not equal then print diffs.
result = True
for tuple_ in test_conf.to_diff:
expected_path, result_path = tuple_[:2]
binary = False
gunzip = False
transforms = [NormalizeLineEndingsTransform()]
if len(tuple_) >= 3:
if tuple_[2] == 'md5':
binary = True
elif tuple_[2] == 'gunzip':
binary = True
gunzip = True
else:
transforms += tuple_[2]
try:
if gunzip:
f = gzip.open(expected_path, 'rb')
expected_md5 = md5ForFile(f)
f.close()
f = gzip.open(result_path, 'rb')
result_md5 = md5ForFile(f)
f.close()
if expected_md5 == result_md5:
continue
else:
tpl = (expected_path, expected_md5, result_md5, result_path)
print('md5(gunzip(%s)) == %s != %s == md5(gunzip(%s))' % tpl, file=sys.stderr)
result = False
if binary:
with open(expected_path, 'rb') as f:
expected_md5 = md5ForFile(f)
with open(result_path, 'rb') as f:
result_md5 = md5ForFile(f)
if expected_md5 == result_md5:
continue
else:
tpl = (expected_path, expected_md5, result_md5, result_path)
print('md5(%s) == %s != %s == md5(%s)' % tpl, file=sys.stderr)
result = False
else:
with open(expected_path, 'rb') as f:
expected_str = f.read().decode('utf8')
for t in transforms:
expected_str = t.apply(expected_str, True)
with open(result_path, 'rb') as f:
result_str = f.read().decode('utf8')
for t in transforms:
result_str = t.apply(result_str, False)
if expected_str == result_str:
continue
fmt = 'Comparing %s against %s'
print(fmt % (expected_path, result_path), file=sys.stderr)
diff = difflib.unified_diff(expected_str.splitlines(),
result_str.splitlines())
for line in diff:
print(line, file=sys.stderr)
result = False
except Exception as e:
fmt = 'Error when trying to compare %s to %s: %s ' + str(type(e))
print(fmt % (expected_path, result_path, e), file=sys.stderr)
result = False
# Call check callable.
if test_conf.check_callback:
try:
test_conf.check_callback()
except BadResultException as e:
print('Bad result: ' + str(e), file=sys.stderr)
result = False
except Exception as e:
print('Error in checker: ' + str(type(e)) + ' ' + str(e), file=sys.stderr)
result = False
return result
class ReplaceTransform(object):
"""Transformation on left and/or right files to diff."""
def __init__(self, needle, replacement, left=True, right=True):
self.needle = needle
self.replacement = replacement
self.left = left
self.right = right
def apply(self, text, is_left):
if (is_left and not self.left) or (not is_left and not self.right):
return text # Skip if no transform is to be applied.
return text.replace(self.needle, self.replacement)
class NormalizeLineEndingsTransform(object):
"""Normalizes line endings to '\n'."""
def __init__(self, left=True, right=True):
self.left = left
self.right = right
def apply(self, text, is_left):
if (is_left and not self.left) or (not is_left and not self.right):
return text # Skip if no transform is to be applied.
return text.replace('\r\n', '\n')
class NormalizeScientificExponentsTransform(object):
"""Transformation that normalized scientific notation exponents.
On Windows, scientific numbers are printed with an exponent padded to
a width of three with zeros, e.g. 1e003 instead of 1e03 as on Unix.
This transform normalizes to Unix or Windows.
"""
def __init__(self, normalize_to_unix=True):
self.normalize_to_unix = normalize_to_unix
def apply(self, text, is_left):
"""Apply the transform."""
if self.normalize_to_unix:
return re.sub(r'([-+]?(?:[0-9]*\.)?[0-9]+[eE][\-+]?)0([0-9]{2})', r'\1\2', text)
else:
return re.sub(r'([-+]?(?:[0-9]*\.)?[0-9]+[eE][\-+]?)([0-9]{2})', r'\10\2', text)
class RegexpReplaceTransform(object):
"""Transformation that applies regular expression replacement."""
def __init__(self, needle, replacement, left=True, right=True):
self.needle = needle
self.replacement = replacement
self.left = left
self.right = right
def apply(self, text, is_left):
"""Apply the transform."""
if (is_left and not self.left) or (not is_left and not self.right):
return text # Skip if no transform is to be applied.
return re.sub(self.needle, self.replacement, text)
class UniqueTransform(object):
"""Unique sort transformation on left and/or right files to diff."""
def __init__(self, left=True, right=True):
self.left = left
self.right = right
def apply(self, text, is_left):
if (is_left and not self.left) or (not is_left and not self.right):
return text # Skip if no transform is to be applied.
return ''.join(sorted(set(text.splitlines(True))))
def main(main_func, **kwargs):
"""Run main_func with the first and second positional parameter."""
parser = optparse.OptionParser("usage: run_tests [options] SOURCE_ROOT_PATH BINARY_ROOT_PATH")
parser.add_option('-v', '--verbose', dest='verbose', action='store_true')
parser.add_option('--valgrind', dest='valgrind', action='store_true')
(options, args) = parser.parse_args()
if len(args) != 2:
parser.error('Incorrect number of arguments!')
return 2
if options.verbose:
logging.root.setLevel(logging.DEBUG)
if options.valgrind:
TestConf.valgrind = True
return main_func(args[0], args[1], **kwargs)
|