Enhance runtest.py and add a script for automated regression-test runs.
[scons.git] / runtest.py
1 #!/usr/bin/env python
2 #
3 # runtest.py - wrapper script for running SCons tests
4 #
5 # This script mainly exists to set PYTHONPATH to the right list of
6 # directories to test the SCons modules.
7 #
8 # By default, it directly uses the modules in the local tree:
9 # ./src/ (source files we ship) and ./etc/ (other modules we don't).
10 #
11 # HOWEVER, now that SCons has Repository support, we don't have
12 # Aegis copy all of the files into the local tree.  So if you're
13 # using Aegis and want to run tests by hand using this script, you
14 # must "aecp ." the entire source tree into your local directory
15 # structure.  When you're done with your change, you can then
16 # "aecpu -unch ." to un-copy any files that you haven't changed.
17 #
18 # When any -p option is specified, this script assumes it's in a
19 # directory in which a build has been performed, and sets PYTHONPATH
20 # so that it *only* references the modules that have unpacked from
21 # the specified built package, to test whether the packages are good.
22 #
23 # Options:
24 #
25 #       -a              Run all tests; does a virtual 'find' for
26 #                       all SCons tests under the current directory.
27 #
28 #       --aegis         Print test results to an output file (specified
29 #                       by the -o option) in the format expected by
30 #                       aetest(5).  This is intended for use in the
31 #                       batch_test_command field in the Aegis project
32 #                       config file.
33 #
34 #       -d              Debug.  Runs the script under the Python
35 #                       debugger (pdb.py) so you don't have to
36 #                       muck with PYTHONPATH yourself.
37 #
38 #       -f file         Only execute the tests listed in the specified
39 #                       file.
40 #
41 #       -h              Print the help and exit.
42 #
43 #       -o file         Print test results to the specified file.
44 #                       The --aegis and --xml options specify the
45 #                       output format.
46 #
47 #       -P Python       Use the specified Python interpreter.
48 #
49 #       -p package      Test against the specified package.
50 #
51 #       --passed        In the final summary, also report which tests
52 #                       passed.  The default is to only report tests
53 #                       which failed or returned NO RESULT.
54 #
55 #       -q              Quiet.  By default, runtest.py prints the
56 #                       command line it will execute before
57 #                       executing it.  This suppresses that print.
58 #
59 #       -X              The scons "script" is an executable; don't
60 #                       feed it to Python.
61 #
62 #       -x scons        The scons script to use for tests.
63 #
64 #       --xml           Print test results to an output file (specified
65 #                       by the -o option) in an SCons-specific XML format.
66 #                       This is (will be) used for reporting results back
67 #                       to a central SCons test monitoring infrastructure.
68 #
69 # (Note:  There used to be a -v option that specified the SCons
70 # version to be tested, when we were installing in a version-specific
71 # library directory.  If we ever resurrect that as the default, then
72 # you can find the appropriate code in the 0.04 version of this script,
73 # rather than reinventing that wheel.)
74 #
75
76 import getopt
77 import glob
78 import os
79 import os.path
80 import popen2
81 import re
82 import stat
83 import string
84 import sys
85
86 all = 0
87 debug = ''
88 format = None
89 tests = []
90 printcommand = 1
91 package = None
92 print_passed_summary = None
93 scons = None
94 scons_exec = None
95 outputfile = None
96 testlistfile = None
97 version = ''
98
99 if os.name == 'java':
100     python = os.path.join(sys.prefix, 'jython')
101 else:
102     python = sys.executable
103
104 cwd = os.getcwd()
105
106 if sys.platform == 'win32' or os.name == 'java':
107     lib_dir = os.path.join(sys.exec_prefix, "Lib")
108 else:
109     # The hard-coded "python" here is the directory name,
110     # not an executable, so it's all right.
111     lib_dir = os.path.join(sys.exec_prefix, "lib", "python" + sys.version[0:3])
112
113 helpstr = """\
114 Usage: runtest.py [OPTIONS] [TEST ...]
115 Options:
116   -a, --all                   Run all tests.
117   --aegis                     Print results in Aegis format.
118   -d, --debug                 Run test scripts under the Python debugger.
119   -f FILE, --file FILE        Run tests in specified FILE.
120   -h, --help                  Print this message and exit.
121   -o FILE, --output FILE      Print test results to FILE.
122   -P Python                   Use the specified Python interpreter.
123   -p PACKAGE, --package PACKAGE
124                               Test against the specified PACKAGE:
125                                 deb           Debian
126                                 local-tar-gz  .tar.gz standalone package
127                                 local-zip     .zip standalone package
128                                 rpm           Red Hat
129                                 src-tar-gz    .tar.gz source package
130                                 src-zip       .zip source package
131                                 tar-gz        .tar.gz distribution
132                                 zip           .zip distribution
133   --passed                    Summarize which tests passed.
134   -q, --quiet                 Don't print the test being executed.
135   -v version                  Specify the SCons version.
136   -X                          Test script is executable, don't feed to Python.
137   -x SCRIPT, --exec SCRIPT    Test SCRIPT.
138   --xml                       Print results in SCons XML format.
139 """
140
141 opts, args = getopt.getopt(sys.argv[1:], "adf:ho:P:p:qv:Xx:",
142                             ['all', 'aegis',
143                              'debug', 'file=', 'help', 'output=',
144                              'package=', 'passed', 'python=', 'quiet',
145                              'version=', 'exec=',
146                              'xml'])
147
148 for o, a in opts:
149     if o == '-a' or o == '--all':
150         all = 1
151     elif o == '-d' or o == '--debug':
152         debug = os.path.join(lib_dir, "pdb.py")
153     elif o == '-f' or o == '--file':
154         if not os.path.isabs(a):
155             a = os.path.join(cwd, a)
156         testlistfile = a
157     elif o == '-h' or o == '--help':
158         print helpstr
159         sys.exit(0)
160     elif o == '-o' or o == '--output':
161         if a != '-' and not os.path.isabs(a):
162             a = os.path.join(cwd, a)
163         outputfile = a
164     elif o == '-p' or o == '--package':
165         package = a
166     elif o == '--passed':
167         print_passed_summary = 1
168     elif o == '-P' or o == '--python':
169         python = a
170     elif o == '-q' or o == '--quiet':
171         printcommand = 0
172     elif o == '-v' or o == '--version':
173         version = a
174     elif o == '-X':
175         scons_exec = 1
176     elif o == '-x' or o == '--exec':
177         scons = a
178     elif o in ['--aegis', '--xml']:
179         format = o
180
181 def whereis(file):
182     for dir in string.split(os.environ['PATH'], os.pathsep):
183         f = os.path.join(dir, file)
184         if os.path.isfile(f):
185             try:
186                 st = os.stat(f)
187             except OSError:
188                 continue
189             if stat.S_IMODE(st[stat.ST_MODE]) & 0111:
190                 return f
191     return None
192
193 aegis = whereis('aegis')
194
195 sp = []
196
197 if aegis:
198     paths = os.popen("aesub '$sp' 2>/dev/null", "r").read()[:-1]
199     sp.extend(string.split(paths, os.pathsep))
200     spe = os.popen("aesub '$spe' 2>/dev/null", "r").read()[:-1]
201     spe = string.split(spe, os.pathsep)
202 else:
203     spe = []
204
205 sp.append(cwd)
206
207 class Base:
208     def __init__(self, path, spe=None):
209         self.path = path
210         self.abspath = os.path.abspath(path)
211         if spe:
212             for dir in spe:
213                 f = os.path.join(dir, path)
214                 if os.path.isfile(f):
215                     self.abspath = f
216                     break
217         self.status = None
218
219 class SystemExecutor(Base):
220     def execute(self):
221         s = os.system(self.command)
222         if s >= 256:
223             s = s / 256
224         self.status = s
225         if s < 0 or s > 2:
226             sys.stdout.write("Unexpected exit status %d\n" % s)
227
228 try:
229     popen2.Popen3
230 except AttributeError:
231     class PopenExecutor(Base):
232         def execute(self):
233             (tochild, fromchild, childerr) = os.popen3(self.command)
234             tochild.close()
235             self.stdout = fromchild.read()
236             self.stderr = childerr.read()
237             fromchild.close()
238             self.status = childerr.close()
239             if not self.status:
240                 self.status = 0
241 else:
242     class PopenExecutor(Base):
243         def execute(self):
244             p = popen2.Popen3(self.command, 1)
245             p.tochild.close()
246             self.stdout = p.fromchild.read()
247             self.stderr = p.childerr.read()
248             self.status = p.wait()
249
250 class Aegis(SystemExecutor):
251     def header(self, f):
252         f.write('test_result = [\n')
253     def write(self, f):
254         f.write('    { file_name = "%s";\n' % self.path)
255         f.write('      exit_status = %d; },\n' % self.status)
256     def footer(self, f):
257         f.write('];\n')
258
259 class XML(PopenExecutor):
260     def header(self, f):
261         f.write('  <results>\n')
262     def write(self, f):
263         f.write('    <test>\n')
264         f.write('      <file_name>%s</file_name>\n' % self.path)
265         f.write('      <command_line>%s</command_line>\n' % self.command)
266         f.write('      <exit_status>%s</exit_status>\n' % self.status)
267         f.write('      <stdout>%s</stdout>\n' % self.stdout)
268         f.write('      <stderr>%s</stderr>\n' % self.stderr)
269         f.write('    </test>\n')
270     def footer(self, f):
271         f.write('  </results>\n')
272
273 format_class = {
274     None        : SystemExecutor,
275     '--aegis'   : Aegis,
276     '--xml'     : XML,
277 }
278 Test = format_class[format]
279
280 if args:
281     if spe:
282         for a in args:
283             if os.path.isabs(a):
284                 for g in glob.glob(a):
285                     tests.append(Test(g))
286             else:
287                 for dir in spe:
288                     x = os.path.join(dir, a)
289                     globs = glob.glob(x)
290                     if globs:
291                         for g in globs:
292                             tests.append(Test(g))
293                         break
294     else:
295         for a in args:
296             for g in glob.glob(a):
297                 tests.append(Test(g))
298 elif all:
299     tdict = {}
300
301     def find_Test_py(arg, dirname, names, tdict=tdict):
302         for n in filter(lambda n: n[-8:] == "Tests.py", names):
303             t = os.path.join(dirname, n)
304             if not tdict.has_key(t):
305                 tdict[t] = Test(t)
306     os.path.walk('src', find_Test_py, 0)
307
308     def find_py(arg, dirname, names, tdict=tdict):
309         for n in filter(lambda n: n[-3:] == ".py", names):
310             t = os.path.join(dirname, n)
311             if not tdict.has_key(t):
312                 tdict[t] = Test(t)
313     os.path.walk('test', find_py, 0)
314
315     if aegis:
316         cmd = "aegis -list -unf pf 2>/dev/null"
317         for line in os.popen(cmd, "r").readlines():
318             a = string.split(line)
319             if a[0] == "test" and not tdict.has_key(a[-1]):
320                 tdict[a[-1]] = Test(a[-1], spe)
321         cmd = "aegis -list -unf cf 2>/dev/null"
322         for line in os.popen(cmd, "r").readlines():
323             a = string.split(line)
324             if a[0] == "test":
325                 if a[1] == "remove":
326                     del tdict[a[-1]]
327                 elif not tdict.has_key(a[-1]):
328                     tdict[a[-1]] = Test(a[-1], spe)
329
330     keys = tdict.keys()
331     keys.sort()
332     tests = map(tdict.get, keys)
333 elif testlistfile:
334     tests = map(Test, map(lambda x: x[:-1], open(testlistfile, 'r').readlines()))
335 else:
336     sys.stderr.write("""\
337 runtest.py:  No tests were specified on the command line.
338              List one or more tests, or use the -a option
339              to find and run all tests.
340 """)
341
342
343 if package:
344
345     dir = {
346         'deb'          : 'usr',
347         'local-tar-gz' : None,
348         'local-zip'    : None,
349         'rpm'          : 'usr',
350         'src-tar-gz'   : '',
351         'src-zip'      : '',
352         'tar-gz'       : '',
353         'zip'          : '',
354     }
355
356     # The hard-coded "python2.1" here is the library directory
357     # name on Debian systems, not an executable, so it's all right.
358     lib = {
359         'deb'        : os.path.join('python2.1', 'site-packages')
360     }
361
362     if not dir.has_key(package):
363         sys.stderr.write("Unknown package '%s'\n" % package)
364         sys.exit(2)
365
366     test_dir = os.path.join(cwd, 'build', 'test-%s' % package)
367
368     if dir[package] is None:
369         scons_script_dir = test_dir
370         globs = glob.glob(os.path.join(test_dir, 'scons-local-*'))
371         if not globs:
372             sys.stderr.write("No `scons-local-*' dir in `%s'\n" % test_dir)
373             sys.exit(2)
374         scons_lib_dir = None
375         pythonpath_dir = globs[len(globs)-1]
376     elif sys.platform == 'win32':
377         scons_script_dir = os.path.join(test_dir, dir[package], 'Scripts')
378         scons_lib_dir = os.path.join(test_dir, dir[package])
379         pythonpath_dir = scons_lib_dir
380     else:
381         scons_script_dir = os.path.join(test_dir, dir[package], 'bin')
382         l = lib.get(package, 'scons')
383         scons_lib_dir = os.path.join(test_dir, dir[package], 'lib', l)
384         pythonpath_dir = scons_lib_dir
385
386 else:
387     sd = None
388     ld = None
389
390     # XXX:  Logic like the following will be necessary once
391     # we fix runtest.py to run tests within an Aegis change
392     # without symlinks back to the baseline(s).
393     #
394     #if spe:
395     #    if not scons:
396     #        for dir in spe:
397     #            d = os.path.join(dir, 'src', 'script')
398     #            f = os.path.join(d, 'scons.py')
399     #            if os.path.isfile(f):
400     #                sd = d
401     #                scons = f
402     #    spe = map(lambda x: os.path.join(x, 'src', 'engine'), spe)
403     #    ld = string.join(spe, os.pathsep)
404
405     scons_script_dir = sd or os.path.join(cwd, 'src', 'script')
406
407     scons_lib_dir = ld or os.path.join(cwd, 'src', 'engine')
408
409     pythonpath_dir = scons_lib_dir
410
411 if scons:
412     # Let the version of SCons that the -x option pointed to find
413     # its own modules.
414     os.environ['SCONS'] = scons
415 elif scons_lib_dir:
416     # Because SCons is really aggressive about finding its modules,
417     # it sometimes finds SCons modules elsewhere on the system.
418     # This forces SCons to use the modules that are being tested.
419     os.environ['SCONS_LIB_DIR'] = scons_lib_dir
420
421 if scons_exec:
422     os.environ['SCONS_EXEC'] = '1'
423
424 os.environ['SCONS_SCRIPT_DIR'] = scons_script_dir
425 os.environ['SCONS_CWD'] = cwd
426
427 os.environ['SCONS_VERSION'] = version
428
429 old_pythonpath = os.environ.get('PYTHONPATH')
430
431 pythonpaths = [ pythonpath_dir ]
432 for p in sp:
433     pythonpaths.append(os.path.join(p, 'build', 'etc'))
434     pythonpaths.append(os.path.join(p, 'etc'))
435 os.environ['PYTHONPATH'] = string.join(pythonpaths, os.pathsep)
436
437 if old_pythonpath:
438     os.environ['PYTHONPATH'] = os.environ['PYTHONPATH'] + \
439                                os.pathsep + \
440                                old_pythonpath
441
442 os.chdir(scons_script_dir)
443
444 class Unbuffered:
445     def __init__(self, file):
446         self.file = file
447     def write(self, arg):
448         self.file.write(arg)
449         self.file.flush()
450     def __getattr__(self, attr):
451         return getattr(self.file, attr)
452
453 sys.stdout = Unbuffered(sys.stdout)
454
455 _ws = re.compile('\s')
456
457 def escape(s):
458     if _ws.search(s):
459         s = '"' + s + '"'
460     return s
461
462 for t in tests:
463     t.command = string.join(map(escape, [python, debug, t.abspath]), " ")
464     if printcommand:
465         sys.stdout.write(t.command + "\n")
466     t.execute()
467
468 passed = filter(lambda t: t.status == 0, tests)
469 fail = filter(lambda t: t.status == 1, tests)
470 no_result = filter(lambda t: t.status == 2, tests)
471
472 if len(tests) != 1:
473     if passed and print_passed_summary:
474         if len(passed) == 1:
475             sys.stdout.write("\nPassed the following test:\n")
476         else:
477             sys.stdout.write("\nPassed the following %d tests:\n" % len(passed))
478         paths = map(lambda x: x.path, passed)
479         sys.stdout.write("\t" + string.join(paths, "\n\t") + "\n")
480     if fail:
481         if len(fail) == 1:
482             sys.stdout.write("\nFailed the following test:\n")
483         else:
484             sys.stdout.write("\nFailed the following %d tests:\n" % len(fail))
485         paths = map(lambda x: x.path, fail)
486         sys.stdout.write("\t" + string.join(paths, "\n\t") + "\n")
487     if no_result:
488         if len(no_result) == 1:
489             sys.stdout.write("\nNO RESULT from the following test:\n")
490         else:
491             sys.stdout.write("\nNO RESULT from the following %d tests:\n" % len(no_result))
492         paths = map(lambda x: x.path, no_result)
493         sys.stdout.write("\t" + string.join(paths, "\n\t") + "\n")
494
495 if outputfile:
496     if outputfile == '-':
497         f = sys.stdout
498     else:
499         f = open(outputfile, 'w')
500     tests[0].header(f)
501     #f.write("test_result = [\n")
502     for t in tests:
503         t.write(f)
504     tests[0].footer(f)
505     #f.write("];\n")
506     if outputfile != '-':
507         f.close()
508
509 if format == '--aegis':
510     sys.exit(0)
511 elif len(fail):
512     sys.exit(1)
513 elif len(no_result):
514     sys.exit(2)
515 else:
516     sys.exit(0)