2 TestCmd.py: a testing framework for commands and scripts.
4 The TestCmd module provides a framework for portable automated testing of
5 executable commands and scripts (in any language, not just Python), especially
6 commands and scripts that require file system interaction.
8 In addition to running tests and evaluating conditions, the TestCmd module
9 manages and cleans up one or more temporary workspace directories, and provides
10 methods for creating files and directories in those workspace directories from
11 in-line data, here-documents), allowing tests to be completely self-contained.
13 A TestCmd environment object is created via the usual invocation:
17 The TestCmd module provides pass_test(), fail_test(), and no_result() unbound
18 methods that report test results for use with the Aegis change management
19 system. These methods terminate the test immediately, reporting PASSED, FAILED
20 or NO RESULT respectively and exiting with status 0 (success), 1 or 2
21 respectively. This allows for a distinction between an actual failed test and a
22 test that could not be properly evaluated because of an external condition (such
23 as a full file system or incorrect permissions).
26 # Copyright 2000 Steven Knight
27 # This module is free software, and you may redistribute it and/or modify
28 # it under the same terms as Python itself, so long as this copyright message
29 # and disclaimer are retained in their original form.
31 # IN NO EVENT SHALL THE AUTHOR BE LIABLE TO ANY PARTY FOR DIRECT, INDIRECT,
32 # SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OF
33 # THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH
36 # THE AUTHOR SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, BUT NOT
37 # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
38 # PARTICULAR PURPOSE. THE CODE PROVIDED HEREUNDER IS ON AN "AS IS" BASIS,
39 # AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
40 # SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
42 # Copyright 2002-2003 Vladimir Prus.
43 # Copyright 2002-2003 Dave Abrahams.
44 # Copyright 2006 Rene Rivera.
45 # Distributed under the Boost Software License, Version 1.0.
46 # (See accompanying file LICENSE_1_0.txt or copy at
47 # http://www.boost.org/LICENSE_1_0.txt)
50 from string import join, split
52 __author__ = "Steven Knight <knight@baldmt.com>"
53 __revision__ = "TestCmd.py 0.D002 2001/08/31 14:56:12 software"
69 tempfile.template = 'testcmd.'
84 def caller(tblist, skip):
87 for file, line, name, text in tblist:
88 if file[-10:] == "TestCmd.py":
90 arr = [(file, line, name, text)] + arr
92 for file, line, name, text in arr[skip:]:
96 name = " (" + name + ")"
97 string = string + ("%s line %d of %s%s\n" % (atfrom, line, file, name))
102 def fail_test(self=None, condition=True, function=None, skip=0):
103 """Cause the test to fail.
105 By default, the fail_test() method reports that the test FAILED and exits
106 with a status of 1. If a condition argument is supplied, the test fails only
107 if the condition is true.
111 if not function is None:
118 of = " of " + join(self.program, " ")
121 desc = " [" + self.description + "]"
124 at = caller(traceback.extract_stack(), skip)
126 sys.stderr.write("FAILED test" + of + desc + sep + at + """
127 in directory: """ + os.getcwd() )
131 def no_result(self=None, condition=True, function=None, skip=0):
132 """Causes a test to exit with no valid result.
134 By default, the no_result() method reports NO RESULT for the test and exits
135 with a status of 2. If a condition argument is supplied, the test fails only
136 if the condition is true.
140 if not function is None:
147 of = " of " + self.program
150 desc = " [" + self.description + "]"
153 at = caller(traceback.extract_stack(), skip)
154 sys.stderr.write("NO RESULT for test" + of + desc + sep + at)
158 def pass_test(self=None, condition=True, function=None):
159 """Causes a test to pass.
161 By default, the pass_test() method reports PASSED for the test and exits
162 with a status of 0. If a condition argument is supplied, the test passes
163 only if the condition is true.
167 if not function is None:
169 sys.stderr.write("PASSED\n")
173 def match_exact(lines=None, matches=None):
174 """Returns whether the given lists or strings containing lines separated
175 using newline characters contain exactly the same data.
177 if not type(lines) is ListType:
178 lines = split(lines, "\n")
179 if not type(matches) is ListType:
180 matches = split(matches, "\n")
181 if len(lines) != len(matches):
183 for i in range(len(lines)):
184 if lines[i] != matches[i]:
189 def match_re(lines=None, res=None):
190 """Given lists or strings contain lines separated using newline characters.
191 This function matches those lines one by one, interpreting the lines in the
192 res parameter as regular expressions.
194 if not type(lines) is ListType:
195 lines = split(lines, "\n")
196 if not type(res) is ListType:
197 res = split(res, "\n")
198 if len(lines) != len(res):
200 for i in range(len(lines)):
201 if not re.compile("^" + res[i] + "$").search(lines[i]):
210 def __init__(self, description=None, program=None, workdir=None,
211 subdir=None, verbose=False, match=None, inpath=None):
213 self._cwd = os.getcwd()
214 self.description_set(description)
216 self.program = program
218 self.program_set(program)
219 self.verbose_set(verbose)
220 if not match is None:
221 self.match_func = match
223 self.match_func = match_re
225 self._preserve = {'pass_test': 0, 'fail_test': 0, 'no_result': 0}
226 if os.environ.has_key('PRESERVE') and not os.environ['PRESERVE'] is '':
227 self._preserve['pass_test'] = os.environ['PRESERVE']
228 self._preserve['fail_test'] = os.environ['PRESERVE']
229 self._preserve['no_result'] = os.environ['PRESERVE']
232 self._preserve['pass_test'] = os.environ['PRESERVE_PASS']
236 self._preserve['fail_test'] = os.environ['PRESERVE_FAIL']
240 self._preserve['no_result'] = os.environ['PRESERVE_NO_RESULT']
246 self.condition = 'no_result'
247 self.workdir_set(workdir)
254 return "%x" % id(self)
256 def cleanup(self, condition=None):
257 """Removes any temporary working directories for the specified TestCmd
258 environment. If the environment variable PRESERVE was set when the
259 TestCmd environment was created, temporary working directories are not
260 removed. If any of the environment variables PRESERVE_PASS,
261 PRESERVE_FAIL or PRESERVE_NO_RESULT were set when the TestCmd
262 environment was created, then temporary working directories are not
263 removed if the test passed, failed or had no result, respectively.
264 Temporary working directories are also preserved for conditions
265 specified via the preserve method.
267 Typically, this method is not called directly, but is used when the
268 script exits to clean up temporary working directories as appropriate
271 if not self._dirlist:
273 if condition is None:
274 condition = self.condition
275 if self._preserve[condition]:
276 for dir in self._dirlist:
277 print "Preserved directory", dir
279 list = self._dirlist[:]
282 self.writable(dir, 1)
283 shutil.rmtree(dir, ignore_errors = 1)
290 _Cleanup.remove(self)
291 except (AttributeError, ValueError):
294 def description_set(self, description):
295 """Set the description of the functionality being tested.
297 self.description = description
299 def fail_test(self, condition=True, function=None, skip=0):
300 """Cause the test to fail.
304 self.condition = 'fail_test'
305 fail_test(self = self,
306 condition = condition,
310 def match(self, lines, matches):
311 """Compare actual and expected file contents.
313 return self.match_func(lines, matches)
315 def match_exact(self, lines, matches):
316 """Compare actual and expected file contents.
318 return match_exact(lines, matches)
320 def match_re(self, lines, res):
321 """Compare actual and expected file contents.
323 return match_re(lines, res)
325 def no_result(self, condition=True, function=None, skip=0):
326 """Report that the test could not be run.
330 self.condition = 'no_result'
331 no_result(self = self,
332 condition = condition,
336 def pass_test(self, condition=True, function=None):
337 """Cause the test to pass.
341 self.condition = 'pass_test'
342 pass_test(self = self, condition = condition, function = function)
344 def preserve(self, *conditions):
345 """Arrange for the temporary working directories for the specified
346 TestCmd environment to be preserved for one or more conditions. If no
347 conditions are specified, arranges for the temporary working directories
348 to be preserved for all conditions.
351 conditions = ('pass_test', 'fail_test', 'no_result')
352 for cond in conditions:
353 self._preserve[cond] = 1
355 def program_set(self, program):
356 """Set the executable program or script to be tested.
358 if program and program[0] and not os.path.isabs(program[0]):
359 program[0] = os.path.join(self._cwd, program[0])
360 self.program = program
362 def read(self, file, mode='rb'):
363 """Reads and returns the contents of the specified file name. The file
364 name may be a list, in which case the elements are concatenated with the
365 os.path.join() method. The file is assumed to be under the temporary
366 working directory unless it is an absolute path name. The I/O mode for
367 the file may be specified; it must begin with an 'r'. The default is
370 if type(file) is ListType:
371 file = apply(os.path.join, tuple(file))
372 if not os.path.isabs(file):
373 file = os.path.join(self.workdir, file)
375 raise ValueError, "mode must begin with 'r'"
376 return open(file, mode).read()
378 def run(self, program=None, arguments=None, chdir=None, stdin=None):
379 """Runs a test of the program or script for the test environment.
380 Standard output and error output are saved for future retrieval via the
381 stdout() and stderr() methods.
385 if not os.path.isabs(chdir):
386 chdir = os.path.join(self.workpath(chdir))
388 sys.stderr.write("chdir(" + chdir + ")\n")
391 if program and program[0]:
392 if program[0] != self.program[0] and not os.path.isabs(program[0]):
393 program[0] = os.path.join(self._cwd, program[0])
398 cmd += arguments.split(" ")
400 sys.stderr.write(join(cmd, " ") + "\n")
402 p = popen2.Popen3(cmd, 1)
403 except AttributeError:
404 # We end up here in case the popen2.Popen3 class is not available
405 # (e.g. on Windows). We will be using the os.popen3() Python API
406 # which takes a string parameter and so needs its executable quoted
407 # in case its name contains spaces.
408 cmd[0] = '"' + cmd[0] + '"'
409 command_string = join(cmd, " ")
410 if ( os.name == 'nt' ):
411 # This is a workaround for a longstanding Python bug on Windows
412 # when using os.popen(), os.system() and similar functions to
413 # execute a command containing quote characters. The bug seems
414 # to be related to the quote stripping functionality used by the
415 # Windows cmd.exe interpreter when its /S is not specified.
417 # Cleaned up quote from the cmd.exe help screen as displayed on
420 # 1. If all of the following conditions are met, then quote
421 # characters on the command line are preserved:
424 # - exactly two quote characters
425 # - no special characters between the two quote
426 # characters, where special is one of: &<>()@^|
427 # - there are one or more whitespace characters between
428 # the two quote characters
429 # - the string between the two quote characters is the
430 # name of an executable file.
432 # 2. Otherwise, old behavior is to see if the first character
433 # is a quote character and if so, strip the leading
434 # character and remove the last quote character on the
435 # command line, preserving any text after the last quote
438 # This causes some commands containing quotes not to be executed
439 # correctly. For example:
441 # "\Long folder name\aaa.exe" --name="Jurko" --no-surname
443 # would get its outermost quotes stripped and would be executed
446 # \Long folder name\aaa.exe" --name="Jurko --no-surname
448 # which would report an error about '\Long' not being a valid
451 # cmd.exe help seems to indicate it would be enough to add an
452 # extra space character in front of the command to avoid this
453 # but this does not work, most likely due to the shell first
454 # stripping all leading whitespace characters from the command.
456 # Solution implemented here is to quote the whole command in
457 # case it contains any quote characters. Note thought this will
458 # not work correctly should Python ever fix this bug.
459 # (01.05.2008.) (Jurko)
460 if command_string.find('"') != -1:
461 command_string = '"' + command_string + '"'
462 (tochild, fromchild, childerr) = os.popen3(command_string)
464 if type(stdin) is ListType:
470 self._stdout.append(fromchild.read())
471 self._stderr.append(childerr.read())
473 self.status = childerr.close()
480 if type(stdin) is ListType:
482 p.tochild.write(line)
484 p.tochild.write(stdin)
486 self._stdout.append(p.fromchild.read())
487 self._stderr.append(p.childerr.read())
488 self.status = p.wait()
491 sys.stdout.write(self._stdout[-1])
492 sys.stderr.write(self._stderr[-1])
497 def stderr(self, run=None):
498 """Returns the error output from the specified run number. If there is
499 no specified run number, then returns the error output of the last run.
500 If the run number is less than zero, then returns the error output from
501 that many runs back from the current run.
504 run = len(self._stderr)
506 run = len(self._stderr) + run
510 return self._stderr[run]
512 def stdout(self, run=None):
513 """Returns the standard output from the specified run number. If there
514 is no specified run number, then returns the standard output of the last
515 run. If the run number is less than zero, then returns the standard
516 output from that many runs back from the current run.
519 run = len(self._stdout)
521 run = len(self._stdout) + run
525 return self._stdout[run]
527 def subdir(self, *subdirs):
528 """Create new subdirectories under the temporary working directory, one
529 for each argument. An argument may be a list, in which case the list
530 elements are concatenated using the os.path.join() method.
531 Subdirectories multiple levels deep must be created using a separate
532 argument for each level:
534 test.subdir('sub', ['sub', 'dir'], ['sub', 'dir', 'ectory'])
536 Returns the number of subdirectories actually created.
542 if type(sub) is ListType:
543 sub = apply(os.path.join, tuple(sub))
544 new = os.path.join(self.workdir, sub)
553 def unlink (self, file):
554 """Unlinks the specified file name. The file name may be a list, in
555 which case the elements are concatenated using the os.path.join()
556 method. The file is assumed to be under the temporary working directory
557 unless it is an absolute path name.
559 if type(file) is ListType:
560 file = apply(os.path.join, tuple(file))
561 if not os.path.isabs(file):
562 file = os.path.join(self.workdir, file)
565 def verbose_set(self, verbose):
566 """Set the verbose level.
568 self.verbose = verbose
570 def workdir_set(self, path):
571 """Creates a temporary working directory with the specified path name.
572 If the path is a null string (''), a unique directory name is created.
575 if os.path.isabs(path):
580 path = tempfile.mktemp()
583 self._dirlist.append(path)
588 _Cleanup.append(self)
589 # We'd like to set self.workdir like this:
590 # self.workdir = path
591 # But symlinks in the path will report things differently from
592 # os.getcwd(), so chdir there and back to fetch the canonical
596 self.workdir = os.getcwd()
601 def workpath(self, *args):
602 """Returns the absolute path name to a subdirectory or file within the
603 current temporary working directory. Concatenates the temporary working
604 directory name with the specified arguments using the os.path.join()
607 return apply(os.path.join, (self.workdir,) + tuple(args))
609 def writable(self, top, write):
610 """Make the specified directory tree writable (write == 1) or not
614 def _walk_chmod(arg, dirname, names):
615 st = os.stat(dirname)
616 os.chmod(dirname, arg(st[stat.ST_MODE]))
618 n = os.path.join(dirname, name)
620 os.chmod(n, arg(st[stat.ST_MODE]))
622 def _mode_writable(mode):
623 return stat.S_IMODE(mode|0200)
625 def _mode_non_writable(mode):
626 return stat.S_IMODE(mode&~0200)
631 f = _mode_non_writable
633 os.path.walk(top, _walk_chmod, f)
635 pass # Ignore any problems changing modes.
637 def write(self, file, content, mode='wb'):
638 """Writes the specified content text (second argument) to the specified
639 file name (first argument). The file name may be a list, in which case
640 the elements are concatenated using the os.path.join() method. The file
641 is created under the temporary working directory. Any subdirectories in
642 the path must already exist. The I/O mode for the file may be specified;
643 it must begin with a 'w'. The default is 'wb' (binary write).
645 if type(file) is ListType:
646 file = apply(os.path.join, tuple(file))
647 if not os.path.isabs(file):
648 file = os.path.join(self.workdir, file)
650 raise ValueError, "mode must begin with 'w'"
651 open(file, mode).write(content)