78d26e542fea95852c66fb206a31ebacbbc04437
[scons.git] / bin / time-scons.py
1 #!/usr/bin/env python
2 #
3 # time-scons.py:  a wrapper script for running SCons timings
4 #
5 # This script exists to:
6 #
7 #     1)  Wrap the invocation of runtest.py to run the actual TimeSCons
8 #         timings consistently.  It does this specifically by building
9 #         SCons first, so .pyc compilation is not part of the timing.
10 #
11 #     2)  Provide an interface for running TimeSCons timings against
12 #         earlier revisions, before the whole TimeSCons infrastructure
13 #         was "frozen" to provide consistent timings.  This is done
14 #         by updating the specific pieces containing the TimeSCons
15 #         infrastructure to the earliest revision at which those pieces
16 #         were "stable enough."
17 #
18 # By encapsulating all the logic in this script, our Buildbot
19 # infrastructure only needs to call this script, and we should be able
20 # to change what we need to in this script and have it affect the build
21 # automatically when the source code is updated, without having to
22 # restart either master or slave.
23
24 import optparse
25 import os
26 import shutil
27 import subprocess
28 import sys
29 import tempfile
30 import xml.sax.handler
31
32
33 SubversionURL = 'http://scons.tigris.org/svn/scons'
34
35
36 # This is the baseline revision when the TimeSCons scripts first
37 # stabilized and collected "real," consistent timings.  If we're timing
38 # a revision prior to this, we'll forcibly update the TimeSCons pieces
39 # of the tree to this revision to collect consistent timings for earlier
40 # revisions.
41 TimeSCons_revision = 4569
42
43 # The pieces of the TimeSCons infrastructure that are necessary to
44 # produce consistent timings, even when the rest of the tree is from
45 # an earlier revision that doesn't have these pieces.
46 TimeSCons_pieces = ['QMTest', 'timings', 'runtest.py']
47
48
49 class CommandRunner:
50     """
51     Executor class for commands, including "commands" implemented by
52     Python functions.
53     """
54     verbose = True
55     active = True
56
57     def __init__(self, dictionary={}):
58         self.subst_dictionary(dictionary)
59
60     def subst_dictionary(self, dictionary):
61         self._subst_dictionary = dictionary
62
63     def subst(self, string, dictionary=None):
64         """
65         Substitutes (via the format operator) the values in the specified
66         dictionary into the specified command.
67
68         The command can be an (action, string) tuple.    In all cases, we
69         perform substitution on strings and don't worry if something isn't
70         a string.    (It's probably a Python function to be executed.)
71         """
72         if dictionary is None:
73             dictionary = self._subst_dictionary
74         if dictionary:
75             try:
76                 string = string % dictionary
77             except TypeError:
78                 pass
79         return string
80
81     def display(self, command, stdout=None, stderr=None):
82         if not self.verbose:
83             return
84         if type(command) == type(()):
85             func = command[0]
86             args = command[1:]
87             s = '%s(%s)' % (func.__name__, ', '.join(map(repr, args)))
88         if type(command) == type([]):
89             # TODO:    quote arguments containing spaces
90             # TODO:    handle meta characters?
91             s = ' '.join(command)
92         else:
93             s = self.subst(command)
94         if not s.endswith('\n'):
95             s += '\n'
96         sys.stdout.write(s)
97         sys.stdout.flush()
98
99     def execute(self, command, stdout=None, stderr=None):
100         """
101         Executes a single command.
102         """
103         if not self.active:
104             return 0
105         if type(command) == type(''):
106             command = self.subst(command)
107             cmdargs = shlex.split(command)
108             if cmdargs[0] == 'cd':
109                  command = (os.chdir,) + tuple(cmdargs[1:])
110         if type(command) == type(()):
111             func = command[0]
112             args = command[1:]
113             return func(*args)
114         else:
115             if stdout is sys.stdout:
116                 # Same as passing sys.stdout, except works with python2.4.
117                 subout = None
118             elif stdout is None:
119                 # Open pipe for anything else so Popen works on python2.4.
120                 subout = subprocess.PIPE
121             else:
122                 subout = stdout
123             if stderr is sys.stderr:
124                 # Same as passing sys.stdout, except works with python2.4.
125                 suberr = None
126             elif stderr is None:
127                 # Merge with stdout if stderr isn't specified.
128                 suberr = subprocess.STDOUT
129             else:
130                 suberr = stderr
131             p = subprocess.Popen(command,
132                                  shell=(sys.platform == 'win32'),
133                                  stdout=subout,
134                                  stderr=suberr)
135             p.wait()
136             return p.returncode
137
138     def run(self, command, display=None, stdout=None, stderr=None):
139         """
140         Runs a single command, displaying it first.
141         """
142         if display is None:
143             display = command
144         self.display(display)
145         return self.execute(command, stdout, stderr)
146
147     def run_list(self, command_list, **kw):
148         """
149         Runs a list of commands, stopping with the first error.
150
151         Returns the exit status of the first failed command, or 0 on success.
152         """
153         status = 0
154         for command in command_list:
155             s = self.run(command, **kw)
156             if s and status == 0:
157                 status = s
158         return 0
159
160
161 def get_svn_revisions(branch, revisions=None):
162     """
163     Fetch the actual SVN revisions for the given branch querying
164     "svn log."  A string specifying a range of revisions can be
165     supplied to restrict the output to a subset of the entire log.
166     """
167     command = ['svn', 'log', '--xml']
168     if revisions:
169         command.extend(['-r', revisions])
170     command.append(branch)
171     p = subprocess.Popen(command, stdout=subprocess.PIPE)
172
173     class SVNLogHandler(xml.sax.handler.ContentHandler):
174         def __init__(self):
175             self.revisions = []
176         def startElement(self, name, attributes):
177             if name == 'logentry':
178                 self.revisions.append(int(attributes['revision']))
179
180     parser = xml.sax.make_parser()
181     handler = SVNLogHandler()
182     parser.setContentHandler(handler)
183     parser.parse(p.stdout)
184     return sorted(handler.revisions)
185
186
187 def prepare_commands():
188     """
189     Returns a list of the commands to be executed to prepare the tree
190     for testing.  This involves building SCons, specifically the
191     build/scons subdirectory where our packaging build is staged,
192     and then running setup.py to create a local installed copy
193     with compiled *.pyc files.  The build directory gets removed
194     first.
195     """
196     commands = []
197     if os.path.exists('build'):
198         commands.extend([
199             ['mv', 'build', 'build.OLD'],
200             ['rm', '-rf', 'build.OLD'],
201         ])
202     commands.append([sys.executable, 'bootstrap.py', 'build/scons'])
203     commands.append([sys.executable,
204                      'build/scons/setup.py',
205                      'install',
206                      '--prefix=' + os.path.abspath('build/usr')])
207     return commands
208
209 def script_command(script):
210     """Returns the command to actually invoke the specified timing
211     script using our "built" scons."""
212     return [sys.executable, 'runtest.py', '-x', 'build/usr/bin/scons', script]
213
214 def do_revisions(cr, opts, branch, revisions, scripts):
215     """
216     Time the SCons branch specified scripts through a list of revisions.
217
218     We assume we're in a (temporary) directory in which we can check
219     out the source for the specified revisions.
220     """
221     stdout = sys.stdout
222     stderr = sys.stderr
223
224     status = 0
225
226     if opts.logsdir and not opts.no_exec and len(scripts) > 1:
227         for script in scripts:
228             subdir = os.path.basename(os.path.dirname(script))
229             logsubdir = os.path.join(opts.origin, opts.logsdir, subdir)
230             if not os.path.exists(logsubdir):
231                 os.makedirs(logsubdir)
232
233     for this_revision in revisions:
234
235         if opts.logsdir and not opts.no_exec:
236             log_name = '%s.log' % this_revision
237             log_file = os.path.join(opts.origin, opts.logsdir, log_name)
238             stdout = open(log_file, 'w')
239             stderr = None
240
241         commands = [
242             ['svn', 'co', '-q', '-r', str(this_revision), branch, '.'],
243         ]
244
245         if int(this_revision) < int(TimeSCons_revision):
246             commands.append(['svn', 'up', '-q', '-r', str(TimeSCons_revision)]
247                             + TimeSCons_pieces)
248
249         commands.extend(prepare_commands())
250
251         s = cr.run_list(commands, stdout=stdout, stderr=stderr)
252         if s:
253             if status == 0:
254                 status = s
255             continue
256
257         for script in scripts:
258             if opts.logsdir and not opts.no_exec and len(scripts) > 1:
259                 subdir = os.path.basename(os.path.dirname(script))
260                 lf = os.path.join(opts.origin, opts.logsdir, subdir, log_name)
261                 out = open(lf, 'w')
262                 err = None
263             else:
264                 out = stdout
265                 err = stderr
266             s = cr.run(script_command(script), stdout=out, stderr=err)
267             if s and status == 0:
268                 status = s
269             if out not in (sys.stdout, None):
270                 out.close()
271                 out = None
272
273         if int(this_revision) < int(TimeSCons_revision):
274             # "Revert" the pieces that we previously updated to the
275             # TimeSCons_revision, so the update to the next revision
276             # works cleanly.
277             command = (['svn', 'up', '-q', '-r', str(this_revision)]
278                        + TimeSCons_pieces)
279             s = cr.run(command, stdout=stdout, stderr=stderr)
280             if s:
281                 if status == 0:
282                     status = s
283                 continue
284
285         if stdout not in (sys.stdout, None):
286             stdout.close()
287             stdout = None
288
289     return status
290
291 Usage = """\
292 time-scons.py [-hnq] [-r REVISION ...] [--branch BRANCH]
293               [--logsdir DIR] [--svn] SCRIPT ..."""
294
295 def main(argv=None):
296     if argv is None:
297         argv = sys.argv
298
299     parser = optparse.OptionParser(usage=Usage)
300     parser.add_option("--branch", metavar="BRANCH", default="trunk",
301                       help="time revision on BRANCH")
302     parser.add_option("--logsdir", metavar="DIR", default='.',
303                       help="generate separate log files for each revision")
304     parser.add_option("-n", "--no-exec", action="store_true",
305                       help="no execute, just print the command line")
306     parser.add_option("-q", "--quiet", action="store_true",
307                       help="quiet, don't print the command line")
308     parser.add_option("-r", "--revision", metavar="REVISION",
309                       help="time specified revisions")
310     parser.add_option("--svn", action="store_true",
311                       help="fetch actual revisions for BRANCH")
312     opts, scripts = parser.parse_args(argv[1:])
313
314     if not scripts:
315         sys.stderr.write('No scripts specified.\n')
316         sys.exit(1)
317
318     CommandRunner.verbose = not opts.quiet
319     CommandRunner.active = not opts.no_exec
320     cr = CommandRunner()
321
322     os.environ['TESTSCONS_SCONSFLAGS'] = ''
323
324     branch = SubversionURL + '/' + opts.branch
325
326     if opts.svn:
327         revisions = get_svn_revisions(branch, opts.revision)
328     elif opts.revision:
329         # TODO(sgk):  parse this for SVN-style revision strings
330         revisions = [opts.revision]
331     else:
332         revisions = None
333
334     if opts.logsdir and not os.path.exists(opts.logsdir):
335         os.makedirs(opts.logsdir)
336
337     if revisions:
338         opts.origin = os.getcwd()
339         tempdir = tempfile.mkdtemp(prefix='time-scons-')
340         try:
341             os.chdir(tempdir)
342             status = do_revisions(cr, opts, branch, revisions, scripts)
343         finally:
344             os.chdir(opts.origin)
345             shutil.rmtree(tempdir)
346     else:
347         commands = prepare_commands()
348         commands.extend([ script_command(script) for script in scripts ])
349         status = cr.run_list(commands, stdout=sys.stdout, stderr=sys.stderr)
350
351     return status
352
353
354 if __name__ == "__main__":
355     sys.exit(main())