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