Upstream version 10.39.225.0
[platform/framework/web/crosswalk.git] / src / native_client / toolchain_build / command.py
1 #!/usr/bin/python
2 # Copyright (c) 2012 The Native Client Authors. All rights reserved.
3 # Use of this source code is governed by a BSD-style license that can be
4 # found in the LICENSE file.
5
6 """Class capturing a command invocation as data."""
7
8 import inspect
9 import glob
10 import hashlib
11 import logging
12 import os
13 import shutil
14 import sys
15
16 sys.path.append(os.path.join(os.path.dirname(__file__), '..'))
17 import pynacl.file_tools
18 import pynacl.log_tools
19 import pynacl.repo_tools
20
21 import substituter
22
23
24 # MSYS tools do not always work with combinations of Windows and MSYS
25 # path conventions, e.g. '../foo\\bar' doesn't find '../foo/bar'.
26 # Since we convert all the directory names to MSYS conventions, we
27 # should not be using Windows separators with those directory names.
28 # As this is really an implementation detail of this module, we export
29 # 'command.path' to use in place of 'os.path', rather than having
30 # users of the module know which flavor to use.
31 import posixpath
32 path = posixpath
33
34
35 SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
36 NACL_DIR = os.path.dirname(SCRIPT_DIR)
37
38 COMMAND_CODE_FILES = [os.path.join(SCRIPT_DIR, f)
39                       for f in ('command.py', 'once.py', 'substituter.py',
40                                 'pnacl_commands.py', 'toolchain_main.py',)]
41 COMMAND_CODE_FILES += [os.path.join(NACL_DIR, 'pynacl', f)
42                        for f in ('platform.py','directory_storage.py',
43                                  'file_tools.py', 'gsd_storage.py',
44                                  'hashing_tools.py', 'local_storage_cache.py',
45                                  'log_tools.py', 'repo_tools.py',)]
46
47 def HashBuildSystemSources():
48   """Read the build source files to use in hashes for Callbacks."""
49   global FILE_CONTENTS_HASH
50   h = hashlib.sha1()
51   for filename in COMMAND_CODE_FILES:
52     with open(filename) as f:
53       h.update(f.read())
54   FILE_CONTENTS_HASH = h.hexdigest()
55
56 HashBuildSystemSources()
57
58
59 def PlatformEnvironment(extra_paths):
60   """Select the environment variables to run commands with.
61
62   Args:
63     extra_paths: Extra paths to add to the PATH variable.
64   Returns:
65     A dict to be passed as env to subprocess.
66   """
67   env = os.environ.copy()
68   paths = []
69   if sys.platform == 'win32':
70     # TODO(bradnelson): switch to something hermetic.
71     mingw = os.environ.get('MINGW', r'c:\mingw')
72     msys = os.path.join(mingw, 'msys', '1.0')
73     if not os.path.exists(msys):
74       msys = os.path.join(mingw, 'msys')
75     # We need both msys (posix like build environment) and MinGW (windows
76     # build of tools like gcc). We add <MINGW>/msys/[1.0/]bin to the path to
77     # get sh.exe. We add <MINGW>/bin to allow direct invocation on MinGW
78     # tools. We also add an msys style path (/mingw/bin) to get things like
79     # gcc from inside msys.
80     paths = [
81         '/mingw/bin',
82         os.path.join(mingw, 'bin'),
83         os.path.join(msys, 'bin'),
84     ]
85   env['PATH'] = os.pathsep.join(
86       paths + extra_paths + env.get('PATH', '').split(os.pathsep))
87   return env
88
89
90 class Runnable(object):
91   """An object representing a single command."""
92   def __init__(self, run_cond, func, *args, **kwargs):
93     """Construct a runnable which will call 'func' with 'args' and 'kwargs'.
94
95     Args:
96       run_cond: If not None, expects a function which takes a CommandOptions
97                 object and returns whether or not to run the command.
98       func: Function which will be called by Invoke
99       args: Positional arguments to be passed to func
100       kwargs: Keyword arguments to be passed to func
101
102       RUNNABLES SHOULD ONLY BE IMPLEMENTED IN THIS FILE, because their
103       string representation (which is used to calculate whether targets should
104       be rebuilt) is based on this file's hash and does not attempt to capture
105       the code or bound variables of the function itself (the one exception is
106       once_test.py which injects its own callbacks to verify its expectations).
107
108       When 'func' is called, its first argument will be a substitution object
109       which it can use to substitute %-templates in its arguments.
110     """
111     self._run_cond = run_cond
112     self._func = func
113     self._args = args or []
114     self._kwargs = kwargs or {}
115
116   def __str__(self):
117     values = []
118
119     sourcefile = inspect.getsourcefile(self._func)
120     # Check that the code for the runnable is implemented in one of the known
121     # source files of the build system (which are included in its hash). This
122     # isn't a perfect test because it could still import code from an outside
123     # module, so we should be sure to add any new build system files to the list
124     found_match = (os.path.basename(sourcefile) in
125                    [os.path.basename(f) for f in
126                     COMMAND_CODE_FILES + ['once_test.py']])
127     if not found_match:
128       print 'Function', self._func.func_name, 'in', sourcefile
129       raise Exception('Python Runnable objects must be implemented in one of' +
130                       'the following files: ' + str(COMMAND_CODE_FILES))
131
132     # Like repr(datum), but do something stable for dictionaries.
133     # This only properly handles dictionaries that use simple types
134     # as keys.
135     def ReprForHash(datum):
136       if isinstance(datum, dict):
137         # The order of a dictionary's items is unpredictable.
138         # Manually make a string in dict syntax, but sorted on keys.
139         return ('{' +
140                 ', '.join(repr(key) + ': ' + ReprForHash(value)
141                           for key, value in sorted(datum.iteritems(),
142                                                    key=lambda t: t[0])) +
143                 '}')
144       elif isinstance(datum, list):
145         # A list is already ordered, but its items might be dictionaries.
146         return ('[' +
147                 ', '.join(ReprForHash(value) for value in datum) +
148                 ']')
149       else:
150         return repr(datum)
151
152     for v in self._args:
153       values += [ReprForHash(v)]
154     # The order of a dictionary's items is unpredictable.
155     # Sort by key for hashing purposes.
156     for k, v in sorted(self._kwargs.iteritems(), key=lambda t: t[0]):
157       values += [repr(k), ReprForHash(v)]
158     values += [FILE_CONTENTS_HASH]
159
160     return '\n'.join(values)
161
162   def CheckRunCond(self, cmd_options):
163     if self._run_cond and not self._run_cond(cmd_options):
164       return False
165     return True
166
167   def Invoke(self, logger, subst):
168     return self._func(logger, subst, *self._args, **self._kwargs)
169
170
171 def Command(command, stdout=None, run_cond=None, **kwargs):
172   """Return a Runnable which invokes 'command' with check_call.
173
174   Args:
175     command: List or string with a command suitable for check_call
176     stdout (optional): File name to redirect command's stdout to
177     kwargs: Keyword arguments suitable for check_call (or 'cwd' or 'path_dirs')
178
179   The command will be %-substituted and paths will be assumed to be relative to
180   the cwd given by Invoke. If kwargs contains 'cwd' it will be appended to the
181   cwd given by Invoke and used as the cwd for the call. If kwargs contains
182   'path_dirs', the directories therein will be added to the paths searched for
183   the command. Any other kwargs will be passed to check_call.
184   """
185   def runcmd(logger, subst, command, stdout, **kwargs):
186     check_call_kwargs = kwargs.copy()
187     command = command[:]
188
189     cwd = subst.SubstituteAbsPaths(check_call_kwargs.get('cwd', '.'))
190     subst.SetCwd(cwd)
191     check_call_kwargs['cwd'] = cwd
192
193     # Extract paths from kwargs and add to the command environment.
194     path_dirs = []
195     if 'path_dirs' in check_call_kwargs:
196       path_dirs = [subst.Substitute(dirname) for dirname
197                    in check_call_kwargs['path_dirs']]
198       del check_call_kwargs['path_dirs']
199     check_call_kwargs['env'] = PlatformEnvironment(path_dirs)
200
201     if isinstance(command, str):
202       command = subst.Substitute(command)
203     else:
204       command = [subst.Substitute(arg) for arg in command]
205       paths = check_call_kwargs['env']['PATH'].split(os.pathsep)
206       command[0] = pynacl.file_tools.Which(command[0], paths=paths)
207
208     if stdout is not None:
209       stdout = subst.SubstituteAbsPaths(stdout)
210
211     pynacl.log_tools.CheckCall(command, stdout=stdout, logger=logger,
212                                **check_call_kwargs)
213
214   return Runnable(run_cond, runcmd, command, stdout, **kwargs)
215
216
217 def SkipForIncrementalCommand(command, run_cond=None, **kwargs):
218   """Return a command which gets skipped for incremental builds.
219
220   Incremental builds are defined to be when the clobber flag is not on and
221   the working directory is not empty.
222   """
223   def SkipForIncrementalCondition(cmd_opts):
224     # Check if caller passed their own run_cond.
225     if run_cond and not run_cond(cmd_opts):
226       return False
227
228     # Only run when clobbering working directory or working directory is empty.
229     return (cmd_opts.IsClobberWorking() or
230             not os.path.isdir(cmd_opts.GetWorkDir()) or
231             len(os.listdir(cmd_opts.GetWorkDir())) == 0)
232
233   return Command(command, run_cond=SkipForIncrementalCondition, **kwargs)
234
235
236 def Mkdir(path, parents=False, run_cond=None):
237   """Convenience method for generating mkdir commands."""
238   def mkdir(logger, subst, path):
239     path = subst.SubstituteAbsPaths(path)
240     if os.path.isdir(path):
241       return
242     logger.debug('Making Directory: %s', path)
243     if parents:
244       os.makedirs(path)
245     else:
246       os.mkdir(path)
247   return Runnable(run_cond, mkdir, path)
248
249
250 def Copy(src, dst, run_cond=None):
251   """Convenience method for generating cp commands."""
252   def copy(logger, subst, src, dst):
253     src = subst.SubstituteAbsPaths(src)
254     dst = subst.SubstituteAbsPaths(dst)
255     logger.debug('Copying: %s -> %s', src, dst)
256     shutil.copyfile(src, dst)
257
258   return Runnable(run_cond, copy, src, dst)
259
260
261 def CopyRecursive(src, dst, run_cond=None):
262   """Recursively copy items in a directory tree.
263
264      If src is a file, the semantics are like shutil.copyfile+copymode.
265      If src is a directory, the semantics are like shutil.copytree, except
266      that the destination may exist (it must be a directory) and will not be
267      clobbered. There must be no files in dst which have names/subpaths which
268      match files in src.
269   """
270   def rcopy(logger, subst, src, dst):
271     src = subst.SubstituteAbsPaths(src)
272     dst = subst.SubstituteAbsPaths(dst)
273     if os.path.isfile(src):
274       shutil.copyfile(src, dst)
275       shutil.copymode(src, dst)
276     elif os.path.isdir(src):
277       logger.debug('Copying directory: %s -> %s', src, dst)
278       pynacl.file_tools.MakeDirectoryIfAbsent(dst)
279       for item in os.listdir(src):
280         rcopy(logger, subst, os.path.join(src, item), os.path.join(dst, item))
281   return Runnable(run_cond, rcopy, src, dst)
282
283 def CopyTree(src, dst, exclude=[], run_cond=None):
284   """Copy a directory tree, excluding a list of top-level entries.
285
286      The destination directory will be clobbered if it exists.
287   """
288   def copyTree(logger, subst, src, dst, exclude):
289     src = subst.SubstituteAbsPaths(src)
290     dst = subst.SubstituteAbsPaths(dst)
291     def ignoreExcludes(dir, files):
292       if dir == src:
293         return exclude
294       else:
295         return []
296     logger.debug('Copying Tree: %s -> %s', src, dst)
297     pynacl.file_tools.RemoveDirectoryIfPresent(dst)
298     shutil.copytree(src, dst, symlinks=True, ignore=ignoreExcludes)
299   return Runnable(run_cond, copyTree, src, dst, exclude)
300
301
302 def RemoveDirectory(path, run_cond=None):
303   """Convenience method for generating a command to remove a directory tree."""
304   def remove(logger, subst, path):
305     path = subst.SubstituteAbsPaths(path)
306     logger.debug('Removing Directory: %s', path)
307     pynacl.file_tools.RemoveDirectoryIfPresent(path)
308   return Runnable(run_cond, remove, path)
309
310
311 def Remove(*args):
312   """Convenience method for generating a command to remove files."""
313   def remove(logger, subst, *args):
314     for arg in args:
315       path = subst.SubstituteAbsPaths(arg)
316       logger.debug('Removing Pattern: %s', path)
317       expanded = glob.glob(path)
318       if len(expanded) == 0:
319         logger.debug('command.Remove: argument %s (substituted from %s) '
320                      'does not match any file' %
321                       (path, arg))
322       for f in expanded:
323         logger.debug('Removing File: %s', f)
324         os.remove(f)
325   return Runnable(None, remove, *args)
326
327
328 def Rename(src, dst, run_cond=None):
329   """Convenience method for generating a command to rename a file."""
330   def rename(logger, subst, src, dst):
331     src = subst.SubstituteAbsPaths(src)
332     dst = subst.SubstituteAbsPaths(dst)
333     logger.debug('Renaming: %s -> %s', src, dst)
334     os.rename(src, dst)
335   return Runnable(run_cond, rename, src, dst)
336
337
338 def WriteData(data, dst, run_cond=None):
339   """Convenience method to write a file with fixed contents."""
340   def writedata(logger, subst, dst, data):
341     dst = subst.SubstituteAbsPaths(dst)
342     logger.debug('Writing Data to File: %s', dst)
343     with open(subst.SubstituteAbsPaths(dst), 'wb') as f:
344       f.write(data)
345   return Runnable(run_cond, writedata, dst, data)
346
347
348 def SyncGitRepoCmds(url, destination, revision, clobber_invalid_repo=False,
349                     reclone=False, clean=False, pathspec=None, git_cache=None,
350                     push_url=None, known_mirrors=[], push_mirrors=[],
351                     run_cond=None):
352   """Returns a list of commands to sync and validate a git repo.
353
354   Args:
355     url: Git repo URL to sync from.
356     destination: Local git repo directory to sync to.
357     revision: If not None, will sync the git repository to this revision.
358     clobber_invalid_repo: Always True for bots, but can be forced for users.
359     reclone: If True, delete the destination directory and re-clone the repo.
360     clean: If True, discard local changes and untracked files.
361            Otherwise the checkout will fail if there are uncommitted changes.
362     pathspec: If not None, add the path to the git checkout command, which
363               causes it to just update the working tree without switching
364               branches.
365     known_mirrors: List of tuples specifying known mirrors for a subset of the
366                    git URL. IE: [('http://mirror.com/mirror', 'http://git.com')]
367     push_mirrors: List of tuples specifying known push mirrors, see
368                   known_mirrors argument for the format.
369     git_cache: If not None, will use git_cache directory as a cache for the git
370                repository and share the objects with any other destination with
371                the same URL.
372     push_url: If not None, specifies what the push URL should be set to.
373     run_cond: Run condition for when to sync the git repo.
374
375   Returns:
376     List of commands, this is a little different from the other command funcs.
377   """
378   def update_valid_mirrors(logger, subst, url, push_url, directory,
379                            known_mirrors, push_mirrors):
380     if push_url is None:
381       push_url = url
382
383     abs_dir = subst.SubstituteAbsPaths(directory)
384     git_dir = os.path.join(abs_dir, '.git')
385     if os.path.exists(git_dir):
386       fetch_list = pynacl.repo_tools.GitRemoteRepoList(abs_dir,
387                                                        include_fetch=True,
388                                                        include_push=False,
389                                                        logger=logger)
390       tracked_fetch_url = dict(fetch_list).get('origin', 'None')
391
392       push_list = pynacl.repo_tools.GitRemoteRepoList(abs_dir,
393                                                       include_fetch=False,
394                                                       include_push=True,
395                                                       logger=logger)
396       tracked_push_url = dict(push_list).get('origin', 'None')
397
398       if ((known_mirrors and tracked_fetch_url != url) or
399           (push_mirrors and tracked_push_url != push_url)):
400         updated_fetch_url = tracked_fetch_url
401         for mirror, url_subset in known_mirrors:
402           if mirror in updated_fetch_url:
403             updated_fetch_url = updated_fetch_url.replace(mirror, url_subset)
404
405         updated_push_url = tracked_push_url
406         for mirror, url_subset in push_mirrors:
407           if mirror in updated_push_url:
408             updated_push_url = updated_push_url.replace(mirror, url_subset)
409
410         if ((updated_fetch_url != tracked_fetch_url) or
411             (updated_push_url != tracked_push_url)):
412           logger.warn('Your git repo is using an old mirror: %s', abs_dir)
413           logger.warn('Updating git repo using known mirror:')
414           logger.warn('  [FETCH] %s -> %s',
415                       tracked_fetch_url, updated_fetch_url)
416           logger.warn('  [PUSH] %s -> %s',
417                       tracked_push_url, updated_push_url)
418           pynacl.repo_tools.GitSetRemoteRepo(updated_fetch_url, abs_dir,
419                                              push_url=updated_push_url,
420                                              logger=logger)
421
422   def populate_cache(logger, subst, git_cache, url):
423     if git_cache:
424       abs_git_cache = subst.SubstituteAbsPaths(git_cache)
425       logger.debug('Populating Cache: %s [%s]', abs_git_cache, url)
426       if abs_git_cache:
427         pynacl.repo_tools.PopulateGitCache(abs_git_cache, [url],
428                                            logger=logger)
429
430   def validate(logger, subst, url, directory):
431     abs_dir = subst.SubstituteAbsPaths(directory)
432     logger.debug('Validating Repo: %s [%s]', abs_dir, url)
433     pynacl.repo_tools.ValidateGitRepo(url,
434                                       subst.SubstituteAbsPaths(directory),
435                                       clobber_mismatch=True,
436                                       logger=logger)
437
438   def sync(logger, subst, url, dest, revision, reclone, clean, pathspec,
439            git_cache, push_url):
440     abs_dest = subst.SubstituteAbsPaths(dest)
441     if git_cache:
442       git_cache = subst.SubstituteAbsPaths(git_cache)
443
444     logger.debug('Syncing Git Repo: %s [%s]', abs_dest, url)
445     try:
446       pynacl.repo_tools.SyncGitRepo(url, abs_dest, revision,
447                                     reclone=reclone, clean=clean,
448                                     pathspec=pathspec, git_cache=git_cache,
449                                     push_url=push_url, logger=logger)
450     except pynacl.repo_tools.InvalidRepoException, e:
451       remote_repos = dict(pynacl.repo_tools.GitRemoteRepoList(abs_dest,
452                                                               logger=logger))
453       tracked_url = remote_repos.get('origin', 'None')
454       logger.error('Invalid Git Repo: %s' % e)
455       logger.error('Destination Directory: %s', abs_dest)
456       logger.error('Currently Tracked Repo: %s', tracked_url)
457       logger.error('Expected Repo: %s', e.expected_repo)
458       logger.warn('Possible solutions:')
459       logger.warn('  1. The simplest way if you have no local changes is to'
460                   ' simply delete the directory and let the tool resync.')
461       logger.warn('  2. If the tracked repo is merely a mirror, simply go to'
462                   ' the directory and run "git remote set-url origin %s"',
463                   e.expected_repo)
464       raise Exception('Could not validate local git repository.')
465
466   def ClobberInvalidRepoCondition(cmd_opts):
467     # Check if caller passed their own run_cond
468     if run_cond and not run_cond(cmd_opts):
469       return False
470     elif clobber_invalid_repo:
471       return True
472     return cmd_opts.IsBot()
473
474   commands = []
475   if git_cache:
476     commands.append(Runnable(run_cond, populate_cache, git_cache, url))
477
478   commands.extend([Runnable(run_cond, update_valid_mirrors, url, push_url,
479                             destination, known_mirrors, push_mirrors),
480                    Runnable(ClobberInvalidRepoCondition, validate, url,
481                             destination),
482                    Runnable(run_cond, sync, url, destination, revision, reclone,
483                             clean, pathspec, git_cache, push_url)])
484   return commands
485
486
487 def CleanGitWorkingDir(directory, path, run_cond=None):
488   """Clean a path in a git checkout, if the checkout directory exists."""
489   def clean(logger, subst, directory, path):
490     directory = subst.SubstituteAbsPaths(directory)
491     logger.debug('Cleaning Git Working Directory: %s', directory)
492     if os.path.exists(directory) and len(os.listdir(directory)) > 0:
493       pynacl.repo_tools.CleanGitWorkingDir(directory, path, logger=logger)
494   return Runnable(run_cond, clean, directory, path)
495
496
497 def GenerateGitPatches(git_dir, info, run_cond=None):
498   """Generate patches from a Git repository.
499
500   Args:
501     git_dir: bare git repository directory to examine (.../.git)
502     info: dictionary containing:
503       'rev': commit that we build
504       'upstream-name': basename of the upstream baseline release
505         (i.e. what the release tarball would be called before ".tar")
506       'upstream-base': commit corresponding to upstream-name
507       'upstream-branch': tracking branch used for upstream merges
508
509   This will produce between zero and two patch files (in %(output)s/):
510     <upstream-name>-g<commit-abbrev>.patch: From 'upstream-base' to the common
511       ancestor (merge base) of 'rev' and 'upstream-branch'.  Omitted if none.
512     <upstream-name>[-g<commit-abbrev>]-nacl.patch: From the result of that
513       (or from 'upstream-base' if none above) to 'rev'.
514   """
515   def generatePatches(logger, subst, git_dir, info, run_cond=None):
516     git_dir = subst.SubstituteAbsPaths(git_dir)
517     git_dir_flag = '--git-dir=' + git_dir
518     basename = info['upstream-name']
519     logger.debug('Generating Git Patches: %s', git_dir)
520
521     patch_files = []
522
523     def generatePatch(description, src_rev, dst_rev, suffix):
524       src_prefix = '--src-prefix=' + basename + '/'
525       dst_prefix = '--dst-prefix=' + basename + suffix + '/'
526       patch_name = basename + suffix + '.patch'
527       patch_file = subst.SubstituteAbsPaths(path.join('%(output)s', patch_name))
528       git_args = [git_dir_flag, 'diff',
529                   '--patch-with-stat', '--ignore-space-at-eol', '--full-index',
530                   '--no-ext-diff', '--no-color', '--no-renames',
531                   '--no-textconv', '--text', src_prefix, dst_prefix,
532                   src_rev, dst_rev]
533       pynacl.log_tools.CheckCall(
534           pynacl.repo_tools.GitCmd() + git_args,
535           stdout=patch_file,
536           logger=logger,
537       )
538       patch_files.append((description, patch_name))
539
540     def revParse(args):
541       output = pynacl.repo_tools.CheckGitOutput([git_dir_flag] + args)
542       lines = output.splitlines()
543       if len(lines) != 1:
544         raise Exception('"git %s" did not yield a single commit' %
545                         ' '.join(args))
546       return lines[0]
547
548     rev = revParse(['rev-parse', info['rev']])
549     upstream_base = revParse(['rev-parse', info['upstream-base']])
550     upstream_branch = revParse(['rev-parse',
551                                 'refs/remotes/origin/' +
552                                 info['upstream-branch']])
553     upstream_snapshot = revParse(['merge-base', rev, upstream_branch])
554
555     if rev == upstream_base:
556       # We're building a stock upstream release.  Nothing to do!
557       return
558
559     if upstream_snapshot == upstream_base:
560       # We've forked directly from the upstream baseline release.
561       suffix = ''
562     else:
563       # We're using an upstream baseline snapshot past the baseline
564       # release, so generate a snapshot patch.  The leading seven
565       # hex digits of the commit ID is what Git usually produces
566       # for --abbrev-commit behavior, 'git describe', etc.
567       suffix = '-g' + upstream_snapshot[:7]
568       generatePatch('Patch the release up to the upstream snapshot version.',
569                     upstream_base, upstream_snapshot, suffix)
570
571     if rev != upstream_snapshot:
572       # We're using local changes, so generate a patch of those.
573       generatePatch('Apply NaCl-specific changes.',
574                     upstream_snapshot, rev, suffix + '-nacl')
575
576     with open(subst.SubstituteAbsPaths(path.join('%(output)s',
577                                                  info['name'] + '.series')),
578               'w') as f:
579       f.write("""\
580 # This is a "series file" in the style used by the "quilt" tool.
581 # It describes how to unpack and apply patches to produce the source
582 # tree of the %(name)s component of a toolchain targetting Native Client.
583
584 # Source: %(upstream-name)s.tar
585 """
586               % info)
587       for patch in patch_files:
588         f.write('\n# %s\n%s\n' % patch)
589
590   return Runnable(run_cond, generatePatches, git_dir, info)