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.
6 """Class capturing a command invocation as data."""
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
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.
35 SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
36 NACL_DIR = os.path.dirname(SCRIPT_DIR)
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',)]
47 def HashBuildSystemSources():
48 """Read the build source files to use in hashes for Callbacks."""
49 global FILE_CONTENTS_HASH
51 for filename in COMMAND_CODE_FILES:
52 with open(filename) as f:
54 FILE_CONTENTS_HASH = h.hexdigest()
56 HashBuildSystemSources()
59 def PlatformEnvironment(extra_paths):
60 """Select the environment variables to run commands with.
63 extra_paths: Extra paths to add to the PATH variable.
65 A dict to be passed as env to subprocess.
67 env = os.environ.copy()
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.
82 os.path.join(mingw, 'bin'),
83 os.path.join(msys, 'bin'),
85 env['PATH'] = os.pathsep.join(
86 paths + extra_paths + env.get('PATH', '').split(os.pathsep))
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'.
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
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).
108 When 'func' is called, its first argument will be a substitution object
109 which it can use to substitute %-templates in its arguments.
111 self._run_cond = run_cond
113 self._args = args or []
114 self._kwargs = kwargs or {}
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']])
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))
132 # Like repr(datum), but do something stable for dictionaries.
133 # This only properly handles dictionaries that use simple types
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.
140 ', '.join(repr(key) + ': ' + ReprForHash(value)
141 for key, value in sorted(datum.iteritems(),
142 key=lambda t: t[0])) +
144 elif isinstance(datum, list):
145 # A list is already ordered, but its items might be dictionaries.
147 ', '.join(ReprForHash(value) for value in datum) +
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]
160 return '\n'.join(values)
162 def CheckRunCond(self, cmd_options):
163 if self._run_cond and not self._run_cond(cmd_options):
167 def Invoke(self, logger, subst):
168 return self._func(logger, subst, *self._args, **self._kwargs)
171 def Command(command, stdout=None, run_cond=None, **kwargs):
172 """Return a Runnable which invokes 'command' with check_call.
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')
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.
185 def runcmd(logger, subst, command, stdout, **kwargs):
186 check_call_kwargs = kwargs.copy()
189 cwd = subst.SubstituteAbsPaths(check_call_kwargs.get('cwd', '.'))
191 check_call_kwargs['cwd'] = cwd
193 # Extract paths from kwargs and add to the command environment.
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)
201 if isinstance(command, str):
202 command = subst.Substitute(command)
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)
208 if stdout is not None:
209 stdout = subst.SubstituteAbsPaths(stdout)
211 pynacl.log_tools.CheckCall(command, stdout=stdout, logger=logger,
214 return Runnable(run_cond, runcmd, command, stdout, **kwargs)
217 def SkipForIncrementalCommand(command, run_cond=None, **kwargs):
218 """Return a command which gets skipped for incremental builds.
220 Incremental builds are defined to be when the clobber flag is not on and
221 the working directory is not empty.
223 def SkipForIncrementalCondition(cmd_opts):
224 # Check if caller passed their own run_cond.
225 if run_cond and not run_cond(cmd_opts):
228 dir_list = os.listdir(cmd_opts.GetWorkDir())
229 # Only run when clobbering working directory or working directory is empty.
230 return (cmd_opts.IsClobberWorking() or
231 not os.path.isdir(cmd_opts.GetWorkDir()) or
232 len(dir_list) == 0 or
233 (len(dir_list) == 1 and dir_list[0].endswith('.log')))
235 return Command(command, run_cond=SkipForIncrementalCondition, **kwargs)
238 def Mkdir(path, parents=False, run_cond=None):
239 """Convenience method for generating mkdir commands."""
240 def mkdir(logger, subst, path):
241 path = subst.SubstituteAbsPaths(path)
242 if os.path.isdir(path):
244 logger.debug('Making Directory: %s', path)
249 return Runnable(run_cond, mkdir, path)
252 def Copy(src, dst, run_cond=None):
253 """Convenience method for generating cp commands."""
254 def copy(logger, subst, src, dst):
255 src = subst.SubstituteAbsPaths(src)
256 dst = subst.SubstituteAbsPaths(dst)
257 logger.debug('Copying: %s -> %s', src, dst)
258 shutil.copyfile(src, dst)
260 return Runnable(run_cond, copy, src, dst)
263 def CopyRecursive(src, dst, run_cond=None):
264 """Recursively copy items in a directory tree.
266 If src is a file, the semantics are like shutil.copyfile+copymode.
267 If src is a directory, the semantics are like shutil.copytree, except
268 that the destination may exist (it must be a directory) and will not be
269 clobbered. There must be no files in dst which have names/subpaths which
272 def rcopy(logger, subst, src, dst):
273 src = subst.SubstituteAbsPaths(src)
274 dst = subst.SubstituteAbsPaths(dst)
275 if os.path.isfile(src):
276 shutil.copyfile(src, dst)
277 shutil.copymode(src, dst)
278 elif os.path.isdir(src):
279 logger.debug('Copying directory: %s -> %s', src, dst)
280 pynacl.file_tools.MakeDirectoryIfAbsent(dst)
281 for item in os.listdir(src):
282 rcopy(logger, subst, os.path.join(src, item), os.path.join(dst, item))
283 return Runnable(run_cond, rcopy, src, dst)
285 def CopyTree(src, dst, exclude=[], run_cond=None):
286 """Copy a directory tree, excluding a list of top-level entries.
288 The destination directory will be clobbered if it exists.
290 def copyTree(logger, subst, src, dst, exclude):
291 src = subst.SubstituteAbsPaths(src)
292 dst = subst.SubstituteAbsPaths(dst)
293 def ignoreExcludes(dir, files):
298 logger.debug('Copying Tree: %s -> %s', src, dst)
299 pynacl.file_tools.RemoveDirectoryIfPresent(dst)
300 shutil.copytree(src, dst, symlinks=True, ignore=ignoreExcludes)
301 return Runnable(run_cond, copyTree, src, dst, exclude)
304 def RemoveDirectory(path, run_cond=None):
305 """Convenience method for generating a command to remove a directory tree."""
306 def remove(logger, subst, path):
307 path = subst.SubstituteAbsPaths(path)
308 logger.debug('Removing Directory: %s', path)
309 pynacl.file_tools.RemoveDirectoryIfPresent(path)
310 return Runnable(run_cond, remove, path)
314 """Convenience method for generating a command to remove files."""
315 def remove(logger, subst, *args):
317 path = subst.SubstituteAbsPaths(arg)
318 logger.debug('Removing Pattern: %s', path)
319 expanded = glob.glob(path)
320 if len(expanded) == 0:
321 logger.debug('command.Remove: argument %s (substituted from %s) '
322 'does not match any file' %
325 logger.debug('Removing File: %s', f)
327 return Runnable(None, remove, *args)
330 def Rename(src, dst, run_cond=None):
331 """Convenience method for generating a command to rename a file."""
332 def rename(logger, subst, src, dst):
333 src = subst.SubstituteAbsPaths(src)
334 dst = subst.SubstituteAbsPaths(dst)
335 logger.debug('Renaming: %s -> %s', src, dst)
337 return Runnable(run_cond, rename, src, dst)
340 def WriteData(data, dst, run_cond=None):
341 """Convenience method to write a file with fixed contents."""
342 def writedata(logger, subst, dst, data):
343 dst = subst.SubstituteAbsPaths(dst)
344 logger.debug('Writing Data to File: %s', dst)
345 with open(subst.SubstituteAbsPaths(dst), 'wb') as f:
347 return Runnable(run_cond, writedata, dst, data)
350 def SyncGitRepoCmds(url, destination, revision, clobber_invalid_repo=False,
351 reclone=False, pathspec=None, git_cache=None, push_url=None,
352 known_mirrors=[], push_mirrors=[],
354 """Returns a list of commands to sync and validate a git repo.
357 url: Git repo URL to sync from.
358 destination: Local git repo directory to sync to.
359 revision: If not None, will sync the git repository to this revision.
360 clobber_invalid_repo: Always True for bots, but can be forced for users.
361 reclone: If True, delete the destination directory and re-clone the repo.
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
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
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.
376 List of commands, this is a little different from the other command funcs.
378 def update_valid_mirrors(logger, subst, url, push_url, directory,
379 known_mirrors, push_mirrors):
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,
390 tracked_fetch_url = dict(fetch_list).get('origin', 'None')
392 push_list = pynacl.repo_tools.GitRemoteRepoList(abs_dir,
396 tracked_push_url = dict(push_list).get('origin', 'None')
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)
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)
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,
422 def populate_cache(logger, subst, git_cache, url):
424 abs_git_cache = subst.SubstituteAbsPaths(git_cache)
425 logger.debug('Populating Cache: %s [%s]', abs_git_cache, url)
427 pynacl.repo_tools.PopulateGitCache(abs_git_cache, [url],
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,
438 def sync(logger, subst, url, dest, revision, reclone, pathspec, git_cache,
440 abs_dest = subst.SubstituteAbsPaths(dest)
442 git_cache = subst.SubstituteAbsPaths(git_cache)
444 logger.debug('Syncing Git Repo: %s [%s]', abs_dest, url)
446 pynacl.repo_tools.SyncGitRepo(url, abs_dest, revision,
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,
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"',
464 raise Exception('Could not validate local git repository.')
466 def ClobberInvalidRepoCondition(cmd_opts):
467 # Check if caller passed their own run_cond
468 if run_cond and not run_cond(cmd_opts):
470 elif clobber_invalid_repo:
472 return cmd_opts.IsBot()
474 def CleanOnBotCondition(cmd_opts):
475 # Check if caller passed their own run_cond
476 if run_cond and not run_cond(cmd_opts):
478 return cmd_opts.IsBot()
480 commands = [CleanGitWorkingDir(destination, reset=True, path=None,
481 run_cond=CleanOnBotCondition)]
483 commands.append(Runnable(run_cond, populate_cache, git_cache, url))
485 commands.extend([Runnable(run_cond, update_valid_mirrors, url, push_url,
486 destination, known_mirrors, push_mirrors),
487 Runnable(ClobberInvalidRepoCondition, validate, url,
489 Runnable(run_cond, sync, url, destination, revision, reclone,
490 pathspec, git_cache, push_url)])
494 def CleanGitWorkingDir(directory, reset=False, path=None, run_cond=None):
495 """Clean a path in a git checkout, if the checkout directory exists."""
496 def clean(logger, subst, directory, reset, path):
497 directory = subst.SubstituteAbsPaths(directory)
498 logger.debug('Cleaning Git Working Directory: %s', directory)
499 if os.path.exists(directory) and len(os.listdir(directory)) > 0:
500 pynacl.repo_tools.CleanGitWorkingDir(directory, reset, path,logger=logger)
501 return Runnable(run_cond, clean, directory, reset, path)
504 def GenerateGitPatches(git_dir, info, run_cond=None):
505 """Generate patches from a Git repository.
508 git_dir: bare git repository directory to examine (.../.git)
509 info: dictionary containing:
510 'rev': commit that we build
511 'upstream-name': basename of the upstream baseline release
512 (i.e. what the release tarball would be called before ".tar")
513 'upstream-base': commit corresponding to upstream-name
514 'upstream-branch': tracking branch used for upstream merges
516 This will produce between zero and two patch files (in %(output)s/):
517 <upstream-name>-g<commit-abbrev>.patch: From 'upstream-base' to the common
518 ancestor (merge base) of 'rev' and 'upstream-branch'. Omitted if none.
519 <upstream-name>[-g<commit-abbrev>]-nacl.patch: From the result of that
520 (or from 'upstream-base' if none above) to 'rev'.
522 def generatePatches(logger, subst, git_dir, info, run_cond=None):
523 git_dir = subst.SubstituteAbsPaths(git_dir)
524 git_dir_flag = '--git-dir=' + git_dir
525 basename = info['upstream-name']
526 logger.debug('Generating Git Patches: %s', git_dir)
530 def generatePatch(description, src_rev, dst_rev, suffix):
531 src_prefix = '--src-prefix=' + basename + '/'
532 dst_prefix = '--dst-prefix=' + basename + suffix + '/'
533 patch_name = basename + suffix + '.patch'
534 patch_file = subst.SubstituteAbsPaths(path.join('%(output)s', patch_name))
535 git_args = [git_dir_flag, 'diff',
536 '--patch-with-stat', '--ignore-space-at-eol', '--full-index',
537 '--no-ext-diff', '--no-color', '--no-renames',
538 '--no-textconv', '--text', src_prefix, dst_prefix,
540 pynacl.log_tools.CheckCall(
541 pynacl.repo_tools.GitCmd() + git_args,
545 patch_files.append((description, patch_name))
548 output = pynacl.repo_tools.CheckGitOutput([git_dir_flag] + args)
549 lines = output.splitlines()
551 raise Exception('"git %s" did not yield a single commit' %
555 rev = revParse(['rev-parse', info['rev']])
556 upstream_base = revParse(['rev-parse', info['upstream-base']])
557 upstream_branch = revParse(['rev-parse',
558 'refs/remotes/origin/' +
559 info['upstream-branch']])
560 upstream_snapshot = revParse(['merge-base', rev, upstream_branch])
562 if rev == upstream_base:
563 # We're building a stock upstream release. Nothing to do!
566 if upstream_snapshot == upstream_base:
567 # We've forked directly from the upstream baseline release.
570 # We're using an upstream baseline snapshot past the baseline
571 # release, so generate a snapshot patch. The leading seven
572 # hex digits of the commit ID is what Git usually produces
573 # for --abbrev-commit behavior, 'git describe', etc.
574 suffix = '-g' + upstream_snapshot[:7]
575 generatePatch('Patch the release up to the upstream snapshot version.',
576 upstream_base, upstream_snapshot, suffix)
578 if rev != upstream_snapshot:
579 # We're using local changes, so generate a patch of those.
580 generatePatch('Apply NaCl-specific changes.',
581 upstream_snapshot, rev, suffix + '-nacl')
583 with open(subst.SubstituteAbsPaths(path.join('%(output)s',
584 info['name'] + '.series')),
587 # This is a "series file" in the style used by the "quilt" tool.
588 # It describes how to unpack and apply patches to produce the source
589 # tree of the %(name)s component of a toolchain targetting Native Client.
591 # Source: %(upstream-name)s.tar
594 for patch in patch_files:
595 f.write('\n# %s\n%s\n' % patch)
597 return Runnable(run_cond, generatePatches, git_dir, info)