| Index: third_party/recipe_engine/package.py
|
| diff --git a/third_party/recipe_engine/package.py b/third_party/recipe_engine/package.py
|
| new file mode 100644
|
| index 0000000000000000000000000000000000000000..3291275b8b5726a04b59eeb4722a2efda257e387
|
| --- /dev/null
|
| +++ b/third_party/recipe_engine/package.py
|
| @@ -0,0 +1,372 @@
|
| +import ast
|
| +import collections
|
| +import contextlib
|
| +import copy
|
| +import itertools
|
| +import logging
|
| +import os
|
| +import subprocess
|
| +import sys
|
| +import tempfile
|
| +
|
| +sys.path.append(
|
| + os.path.join(os.path.dirname(os.path.abspath(__file__)), 'third_party'))
|
| +
|
| +import dateutil.parser
|
| +
|
| +class UncleanFilesystemError(Exception):
|
| + pass
|
| +
|
| +
|
| +class InconsistentDependencyGraphError(Exception):
|
| + pass
|
| +
|
| +
|
| +class PackageContext(object):
|
| + """Contains information about where the root package and its dependency
|
| + checkouts live."""
|
| +
|
| + def __init__(self, root_dir, package_dir):
|
| + self.root_dir = root_dir
|
| + self.package_dir = package_dir
|
| +
|
| + @classmethod
|
| + def from_pyl_path(cls, pyl_path):
|
| + root_dir = os.path.dirname(pyl_path)
|
| + return cls(root_dir, os.path.join(root_dir, '.recipe_deps'))
|
| +
|
| +
|
| +class RepoSpec(object):
|
| + def checkout(self, context):
|
| + """Fetches the specified package and returns the path of the package root
|
| + (the directory that contains recipe_package.pyl).
|
| + """
|
| + raise NotImplementedError()
|
| +
|
| +
|
| +class GitRepoSpec(RepoSpec):
|
| + def __init__(self, id, repo, branch, revision, path):
|
| + self.id = id
|
| + self.repo = repo
|
| + self.branch = branch
|
| + self.revision = revision
|
| + self.path = path
|
| +
|
| + def checkout(self, context):
|
| + package_dir = context.package_dir
|
| + dep_dir = os.path.join(package_dir, self.id)
|
| + logging.info('Freshening repository %s' % dep_dir)
|
| +
|
| + if os.path.exists(os.path.join(package_dir, '.dont_mess_with_this')):
|
| + logging.warn('Skipping checkout of %s because of .dont_mess_with_this'
|
| + % self.id)
|
| + return os.path.join(dep_dir, self.path)
|
| +
|
| + if not os.path.isdir(dep_dir):
|
| + _run_cmd(['git', 'clone', self.repo, dep_dir])
|
| + elif not os.path.isdir(os.path.join(dep_dir, '.git')):
|
| + raise UncleanFilesystemError('%s exists but is not a git repo' % dep_dir)
|
| +
|
| + with in_directory(dep_dir):
|
| + _run_cmd(['git', 'fetch'])
|
| + _run_cmd(['git', 'checkout', '-q', self.revision])
|
| +
|
| + return os.path.join(dep_dir, self.path)
|
| +
|
| + def dump(self):
|
| + return {
|
| + 'repo': self.repo,
|
| + 'branch': self.branch,
|
| + 'revision': self.revision,
|
| + 'path': self.path,
|
| + }
|
| +
|
| + def updates(self, context):
|
| + """Returns a list of all updates to the branch since the revision this
|
| + repo spec refers to, paired with their commit timestamps; i.e.
|
| + (timestamp, GitRepoSpec).
|
| +
|
| + Although timestamps are not completely reliable, they are the best tool we
|
| + have to approximate global coherence.
|
| + """
|
| + lines = filter(bool, self._raw_updates(context).strip().split('\n'))
|
| + return [ (_parse_date(date),
|
| + GitRepoSpec(self.id, self.repo, self.branch, rev, self.path))
|
| + for date,rev in map(str.split, lines) ]
|
| +
|
| + def root_dir(self, context):
|
| + return os.path.join(context.package_dir, self.id, self.path)
|
| +
|
| + def _raw_updates(self, context):
|
| + self.checkout(context)
|
| + git = subprocess.Popen(['git', 'log',
|
| + '%s..origin/%s' % (self.revision, self.branch),
|
| + '--pretty=%aI %H',
|
| + '--reverse'],
|
| + stdout=subprocess.PIPE,
|
| + cwd=os.path.join(context.package_dir, self.id))
|
| + (stdout, _) = git.communicate()
|
| + return stdout
|
| +
|
| + def _components(self):
|
| + return (self.id, self.repo, self.revision, self.path)
|
| +
|
| + def __eq__(self, other):
|
| + return self._components() == other._components()
|
| +
|
| + def __ne__(self, other):
|
| + return not self.__eq__(other)
|
| +
|
| +
|
| +class RootRepoSpec(RepoSpec):
|
| + def __init__(self, pyl_path):
|
| + self.pyl_path = pyl_path
|
| +
|
| + def checkout(self, context):
|
| + # We assume this is already checked out.
|
| + return context.root_dir
|
| +
|
| + def root_dir(self, context):
|
| + return context.root_dir
|
| +
|
| +
|
| +class Package(object):
|
| + def __init__(self, repo, deps, root_dir):
|
| + self.repo = repo
|
| + self.deps = deps
|
| + self.root_dir = root_dir
|
| +
|
| + @property
|
| + def recipe_dirs(self):
|
| + return [os.path.join(self.root_dir, 'recipes')]
|
| +
|
| + @property
|
| + def module_dirs(self):
|
| + return [os.path.join(self.root_dir, 'recipe_modules')]
|
| +
|
| + def find_dep(self, dep_name):
|
| + return self.deps[dep_name]
|
| +
|
| + def module_path(self, module_name):
|
| + return os.path.join(self.root_dir, 'recipe_modules', module_name)
|
| +
|
| +
|
| +class PackageSpec(object):
|
| + def __init__(self, id, deps):
|
| + self.id = id
|
| + self.deps = deps
|
| +
|
| + @classmethod
|
| + def load_pyl(cls, pyl_path):
|
| + with open(pyl_path, 'r') as fh:
|
| + pyl_spec = ast.literal_eval(fh.read())
|
| + return cls.load(pyl_spec)
|
| +
|
| + @classmethod
|
| + def load(cls, spec):
|
| + assert spec['api_version'] == 0
|
| + deps = { dep: GitRepoSpec(dep,
|
| + dep_dict['repo'],
|
| + dep_dict['branch'],
|
| + dep_dict['revision'],
|
| + dep_dict['path'])
|
| + for dep, dep_dict in spec['deps'].iteritems() }
|
| +
|
| + return cls(spec['id'], deps)
|
| +
|
| + def dump(self):
|
| + return {
|
| + 'api_version': 0,
|
| + 'id': self.id,
|
| + 'deps': { dep_id: dep.dump() for dep_id, dep in self.deps.iteritems() },
|
| + }
|
| +
|
| + def updates(self, context):
|
| + dep_updates = _merge([ [ (date, dep, update)
|
| + for date, update in repo.updates(context) ]
|
| + for dep, repo in self.deps.iteritems() ])
|
| +
|
| + deps_so_far = self.deps
|
| + ret_updates = []
|
| + for (date, dep_id, dep) in dep_updates:
|
| + deps_so_far = _updated(deps_so_far, { dep_id: dep })
|
| + ret_updates.append((date, PackageSpec(self.id, deps_so_far)))
|
| + return ret_updates
|
| +
|
| + def iterate_consistent_updates(self, context):
|
| + root_spec = RootRepoSpec(
|
| + os.path.join(context.root_dir, 'recipe_package.pyl'))
|
| + for date, spec in self.updates(context):
|
| + consistent_spec = True
|
| + try:
|
| + package_deps = PackageDeps(context)
|
| + package_deps._create_from_spec(root_spec, spec)
|
| + except InconsistentDependencyGraphError:
|
| + # Skip inconsistent graphs, which are blocked on dependency rolls
|
| + consistent_spec = False
|
| + if consistent_spec:
|
| + yield date, spec
|
| +
|
| + def __eq__(self, other):
|
| + return self.id == other.id and self.deps == other.deps
|
| +
|
| + def __ne__(self, other):
|
| + return not self.__eq__(other)
|
| +
|
| +
|
| +class PackageDeps(object):
|
| + """An object containing all the transitive dependencies of the root package.
|
| + """
|
| + def __init__(self, context):
|
| + self._context = context
|
| + self._repos = {}
|
| +
|
| + @classmethod
|
| + def create(cls, pyl_path):
|
| + context = PackageContext.from_pyl_path(pyl_path)
|
| +
|
| + package_deps = cls(context)
|
| + root_package = package_deps._create_package(RootRepoSpec(pyl_path))
|
| + return package_deps
|
| +
|
| + def _create_package(self, repo_spec):
|
| + package_root = repo_spec.checkout(self._context)
|
| + pyl_path = os.path.join(package_root, 'recipe_package.pyl')
|
| +
|
| + package_spec = PackageSpec.load_pyl(pyl_path)
|
| + self._create_from_spec(repo_spec, package_spec)
|
| +
|
| + def _create_from_spec(self, repo_spec, package_spec):
|
| + deps = {}
|
| + for dep, dep_repo in sorted(package_spec.deps.items()):
|
| + deps[dep] = self._create_package(dep_repo)
|
| +
|
| + if (package_spec.id in self._repos and
|
| + not repo_spec == self._repos[package_spec.id].repo):
|
| + raise InconsistentDependencyGraphError(
|
| + 'Package specs do not match: %s vs %s' %
|
| + (repo_spec, self._repos[package_spec.id].repo))
|
| +
|
| + package = Package(repo_spec, deps, repo_spec.root_dir(self._context))
|
| + self._repos[package_spec.id] = package
|
| + return package
|
| +
|
| + # TODO(luqui): Remove this, so all accesses to packages are done
|
| + # via other packages with properly scoped deps.
|
| + def get_package(self, package_id):
|
| + return self._repos[package_id]
|
| +
|
| + @property
|
| + def all_recipe_dirs(self):
|
| + for repo in self._repos.values():
|
| + for subdir in repo.recipe_dirs:
|
| + yield subdir
|
| +
|
| + @property
|
| + def all_module_dirs(self):
|
| + for repo in self._repos.values():
|
| + for subdir in repo.module_dirs:
|
| + yield subdir
|
| +
|
| +
|
| +def _run_cmd(cmd):
|
| + logging.info('%s', cmd)
|
| + subprocess.check_call(cmd)
|
| +
|
| +
|
| +def _parse_date(datestr):
|
| + """Parses an ISO-8601 date string into a datetime object.
|
| +
|
| + >>> ( _parse_date('2015-06-30T10:15:20-00:00')
|
| + ... <= _parse_date('2015-06-30T11:20:31-00:00'))
|
| + True
|
| + >>> ( _parse_date('2015-06-30T11:33:52-07:00')
|
| + ... <= _parse_date('2015-06-30T11:33:52-08:00'))
|
| + True
|
| + >>> ( _parse_date('2015-06-30T11:33:52-07:00')
|
| + ... <= _parse_date('2015-06-30T11:33:52-06:00'))
|
| + False
|
| + """
|
| + return dateutil.parser.parse(datestr)
|
| +
|
| +
|
| +def _merge2(xs, ys, compare=lambda x, y: x <= y):
|
| + """Merges two sorted iterables, preserving sort order.
|
| +
|
| + >>> list(_merge2([1, 3, 6], [2, 4, 5]))
|
| + [1, 2, 3, 4, 5, 6]
|
| + >>> list(_merge2([1, 2, 3], []))
|
| + [1, 2, 3]
|
| + >>> list(_merge2([], [4, 5, 6]))
|
| + [4, 5, 6]
|
| + >>> list(_merge2([], []))
|
| + []
|
| + >>> list(_merge2([4, 2], [3, 1], compare=lambda x, y: x >= y))
|
| + [4, 3, 2, 1]
|
| +
|
| + The merge is left-biased and preserves order within each argument.
|
| +
|
| + >>> list(_merge2([1, 4], [3, 2], compare=lambda x, y: True))
|
| + [1, 4, 3, 2]
|
| + """
|
| +
|
| + xs = iter(xs)
|
| + ys = iter(ys)
|
| + x = None
|
| + y = None
|
| + try:
|
| + x = (xs.next(),)
|
| + y = (ys.next(),)
|
| +
|
| + while True:
|
| + if compare(x[0], y[0]):
|
| + yield x[0]
|
| + x = None
|
| + x = (xs.next(),)
|
| + else:
|
| + yield y[0]
|
| + y = None
|
| + y = (ys.next(),)
|
| + except StopIteration:
|
| + if x: yield x[0]
|
| + for x in xs: yield x
|
| + if y: yield y[0]
|
| + for y in ys: yield y
|
| +
|
| +
|
| +def _merge(xss, compare=lambda x, y: x <= y):
|
| + """Merges a sequence of sorted iterables in sorted order.
|
| +
|
| + >>> list(_merge([ [1,5], [2,5,6], [], [0,7] ]))
|
| + [0, 1, 2, 5, 5, 6, 7]
|
| + >>> list(_merge([ [1,2,3] ]))
|
| + [1, 2, 3]
|
| + >>> list(_merge([]))
|
| + []
|
| + """
|
| + return reduce(lambda xs, ys: _merge2(xs, ys, compare=compare), xss, [])
|
| +
|
| +
|
| +def _updated(d, updates):
|
| + """Updates a dictionary without mutation.
|
| +
|
| + >>> d = { 'x': 1, 'y': 2 }
|
| + >>> sorted(_updated(d, { 'y': 3, 'z': 4 }).items())
|
| + [('x', 1), ('y', 3), ('z', 4)]
|
| + >>> sorted(d.items())
|
| + [('x', 1), ('y', 2)]
|
| + """
|
| +
|
| + d = copy.copy(d)
|
| + d.update(updates)
|
| + return d
|
| +
|
| +
|
| +@contextlib.contextmanager
|
| +def in_directory(target_dir):
|
| + cwd = os.getcwd()
|
| + os.chdir(target_dir)
|
| + try:
|
| + yield
|
| + finally:
|
| + os.chdir(cwd)
|
|
|