Chromium Code Reviews
chromiumcodereview-hr@appspot.gserviceaccount.com (chromiumcodereview-hr) | Please choose your nickname with Settings | Help | Chromium Project | Gerrit Changes | Sign out
(86)

Side by Side Diff: third_party/recipe_engine/package.py

Issue 1241323004: Cross-repo recipe package system. (Closed) Base URL: svn://svn.chromium.org/chrome/trunk/tools/build
Patch Set: Moved show_me_the_modules into recipe_engine Created 5 years, 4 months ago
Use n/p to move between diff chunks; N/P to move between comments. Draft comments are only viewable by you.
Jump to:
View unified diff | Download patch | Annotate | Revision Log
OLDNEW
(Empty)
1 import ast
2 import collections
3 import contextlib
4 import copy
5 import functools
6 import itertools
7 import logging
8 import os
9 import subprocess
10 import sys
11 import tempfile
12
13 sys.path.append(
luqui 2015/08/07 19:30:36 import third_party.google instead
luqui 2015/08/20 22:45:24 Done
14 os.path.join(os.path.dirname(os.path.abspath(__file__)), 'third_party'))
15
16 import dateutil.parser
17
18 from google import protobuf
19 from recipe_engine import package_pb2
20
21 class UncleanFilesystemError(Exception):
22 pass
23
24
25 class InconsistentDependencyGraphError(Exception):
26 pass
27
28
29 class ProtoFile(object):
iannucci 2015/08/06 23:57:12 document why an object and not just a collection o
luqui 2015/08/20 22:45:24 Done
30 def __init__(self, path):
31 self._path = path
32
33 @property
34 def path(self):
35 return os.path.realpath(self._path)
36
37 def read_text(self):
38 with open(self._path, 'r') as fh:
39 return fh.read()
40
41 def read(self):
42 text = self.read_text()
43 buf = package_pb2.Package()
44 protobuf.text_format.Merge(text, buf)
45 return buf
46
47 def to_text(self, buf):
48 return protobuf.text_format.MessageToString(buf)
49
50 def write(self, buf):
51 with open(self._path, 'w') as fh:
52 fh.write(self.to_text(buf))
53
54
55 class PackageContext(object):
56 """Contains information about where the root package and its dependency
57 checkouts live.
58
59 - recipes_dir is the location of recipes/ and recipe_modules/ which contain
60 the actual recipes of the root package.
61 - package_dir is where dependency checkouts live, e.g.
62 package_dir/recipe_engine/recipes/...
63 - repo_root is the root of the repository containing the root package.
64 """
65
66 def __init__(self, recipes_dir, package_dir, repo_root):
67 self.recipes_dir = recipes_dir
68 self.package_dir = package_dir
69 self.repo_root = repo_root
70
71 @classmethod
72 def from_proto_file(cls, proto_file):
73 if isinstance(proto_file, basestring):
74 proto_file = ProtoFile(proto_file)
iannucci 2015/08/06 23:57:12 nuke
luqui 2015/08/20 22:45:24 Done
75 proto_path = proto_file.path
76
77 repo_root = os.path.dirname(os.path.dirname(os.path.dirname(proto_path)))
78 expected_path = os.path.join(repo_root, 'infra', 'config', 'recipes.cfg')
79 assert proto_path == expected_path, (
80 'Recipes config must be in infra/config/recipes.cfg from root of repo\n'
iannucci 2015/08/06 23:57:12 maybe need to talk to luci-cfg to see if this is t
luqui 2015/08/20 22:45:24 Ugh, it is configurable. Since we don't need this
81 ' Expected location: %s\n'
82 ' Actual location: %s\n'
83 % (expected_path, proto_path))
iannucci 2015/08/06 23:57:13 can we validate this higher in the stack? like mai
luqui 2015/08/20 22:45:24 Done.
84
85 buf = proto_file.read()
86
87 recipes_path = buf.recipes_path
88 if sys.platform.startswith('win'):
89 recipes_path.replace('/', '\\')
iannucci 2015/08/06 23:57:12 os.sep instead of '\\' recipes_path = buf.recip
luqui 2015/08/20 22:45:24 Done
90
91 return cls(os.path.join(repo_root, recipes_path),
92 os.path.join(repo_root, recipes_path, '.recipe_deps'),
93 repo_root)
94
95
96 @functools.total_ordering
97 class RepoUpdate(object):
98 def __init__(self, spec):
99 self.spec = spec
100
101 @property
102 def id(self):
103 return self.spec.id
104
105 def __eq__(self, other):
106 return (self.id, self.spec.revision) == (other.id, other.spec.revision)
107
108 def __le__(self, other):
109 return (self.id, self.spec.revision) <= (other.id, other.spec.revision)
110
111
112 class RepoSpec(object):
113 def checkout(self, context):
iannucci 2015/08/06 23:57:12 need check_checkout (and whatever other methods th
luqui 2015/08/20 22:45:24 Done
114 """Fetches the specified package and returns the path of the package root
115 (the directory that contains recipes and recipe_modules).
116 """
117 raise NotImplementedError()
118
119
120 class GitRepoSpec(RepoSpec):
121 def __init__(self, id, repo, branch, revision, path):
122 self.id = id
123 self.repo = repo
124 self.branch = branch
125 self.revision = revision
126 self.path = path
127
128 def checkout(self, context):
129 package_dir = context.package_dir
130 dep_dir = os.path.join(package_dir, self.id)
131 logging.info('Freshening repository %s' % dep_dir)
132
133 if not os.path.isdir(dep_dir):
134 _run_cmd(['git', 'clone', self.repo, dep_dir])
135 elif not os.path.isdir(os.path.join(dep_dir, '.git')):
136 raise UncleanFilesystemError('%s exists but is not a git repo' % dep_dir)
137
138 with _in_directory(dep_dir):
139 _run_cmd(['git', 'fetch'])
iannucci 2015/08/06 23:57:13 mebbeh use cwd=
luqui 2015/08/07 19:30:36 check & fetch, auto-fetch.
luqui 2015/08/20 22:45:24 Done
140 _run_cmd(['git', 'reset', '--hard', self.revision])
141
142 def check_checkout(self, context):
143 dep_dir = os.path.join(context.package_dir, self.id)
144 if not os.path.isdir(dep_dir):
145 raise UncleanFilesystemError('Dependency %s does not exist' %
146 dep_dir)
147 elif not os.path.isdir(os.path.join(dep_dir, '.git')):
148 raise UncleanFilesystemError('Dependency %s is not a git repo' %
149 dep_dir)
150
151 with _in_directory(dep_dir):
152 git_status_command = ['git', 'status', '--porcelain']
153 logging.info('%s', git_status_command)
154 output = subprocess.check_output(git_status_command)
155 if output:
156 raise UncleanFilesystemError('Dependency %s is unclean:\n%s' %
157 (dep_dir, output))
158
159
160 def repo_root(self, context):
161 return os.path.join(context.package_dir, self.id, self.path)
162
163 def dump(self):
164 buf = package_pb2.DepSpec(
165 project_id=self.id,
166 url=self.repo,
167 branch=self.branch,
168 revision=self.revision)
169 if self.path:
170 buf.path_override = self.path
171 return buf
172
173 def updates(self, context):
174 """Returns a list of all updates to the branch since the revision this
175 repo spec refers to, paired with their commit timestamps; i.e.
176 (timestamp, GitRepoSpec).
177
178 Although timestamps are not completely reliable, they are the best tool we
179 have to approximate global coherence.
180 """
181 lines = filter(bool, self._raw_updates(context).strip().split('\n'))
182 return [ RepoUpdate(
183 GitRepoSpec(self.id, self.repo, self.branch, rev, self.path))
184 for rev in lines ]
185
186 def _raw_updates(self, context):
187 self.checkout(context)
188 # XXX(luqui): Should this just focus on the recipes subtree rather than
189 # the whole repo?
190 git = subprocess.Popen(['git', 'log',
191 '%s..origin/%s' % (self.revision, self.branch),
iannucci 2015/08/06 23:57:12 'origin' is an assumption? or is this a repo manag
luqui 2015/08/20 22:45:24 This repo is managed by us.
192 '--pretty=%H',
193 '--reverse'],
194 stdout=subprocess.PIPE,
195 cwd=os.path.join(context.package_dir, self.id))
196 (stdout, _) = git.communicate()
197 return stdout
198
199 def _components(self):
200 return (self.id, self.repo, self.revision, self.path)
201
202 def __eq__(self, other):
203 return self._components() == other._components()
204
205 def __ne__(self, other):
206 return not self.__eq__(other)
207
208
209 class RootRepoSpec(RepoSpec):
210 def __init__(self):
211 pass
212
213 def checkout(self, context):
214 # We assume this is already checked out.
215 pass
216
217 def check_checkout(self, context):
218 pass
219
220 def repo_root(self, context):
221 return context.repo_root
222
223
224 class Package(object):
225 def __init__(self, repo, deps, recipes_dir):
226 self.repo = repo
iannucci 2015/08/06 23:57:12 s/repo/repo_spec doc type of deps doc that repo_
luqui 2015/08/20 22:45:24 Done
227 self.deps = deps
228 self.recipes_dir = recipes_dir
229
230 @property
231 def recipe_dirs(self):
232 return [os.path.join(self.recipes_dir, 'recipes')]
233
234 @property
235 def module_dirs(self):
236 return [os.path.join(self.recipes_dir, 'recipe_modules')]
237
238 def find_dep(self, dep_name):
239 return self.deps[dep_name]
240
241 def module_path(self, module_name):
242 return os.path.join(self.recipes_dir, 'recipe_modules', module_name)
243
244
245 class PackageSpec(object):
246 API_VERSION = 1
247
248 def __init__(self, project_id, recipes_path, deps):
249 self._project_id = project_id
250 self._recipes_path = recipes_path
251 self._deps = deps
252
253 @classmethod
254 def load_proto(cls, proto_file):
255 buf = proto_file.read()
256 assert buf.api_version == cls.API_VERSION
257
258 deps = { dep.project_id: GitRepoSpec(dep.project_id,
259 dep.url,
260 dep.branch,
261 dep.revision,
262 dep.path_override)
263 for dep in buf.deps }
264 return cls(buf.project_id, buf.recipes_path, deps)
265
266 @property
267 def project_id(self):
268 return self._project_id
269
270 @property
271 def recipes_path(self):
272 return self._recipes_path
273
274 @property
275 def deps(self):
276 return self._deps
277
278 def dump(self):
279 return package_pb2.Package(
280 api_version=self.API_VERSION,
281 project_id=self._project_id,
282 recipes_path=self._recipes_path,
283 deps=[ self._deps[dep].dump() for dep in sorted(self._deps.keys()) ])
284
285 def updates(self, context):
iannucci 2015/08/06 23:57:12 need docstrings
luqui 2015/08/20 22:45:24 Done
286 dep_updates = _merge([
287 self._deps[dep].updates(context) for dep in sorted(self._deps.keys()) ])
288
289 deps_so_far = self._deps
290 ret_updates = []
291 for update in dep_updates:
292 deps_so_far = _updated(deps_so_far, { update.id: update.spec })
293 ret_updates.append(RepoUpdate(PackageSpec(
iannucci 2015/08/06 23:57:12 maybe don't need RepoUpdate because it's only used
luqui 2015/08/07 19:30:36 Document how rolling work. Document global coheren
luqui 2015/08/20 22:45:24 Done
294 self._project_id, self._recipes_path, deps_so_far)))
295 return ret_updates
296
297 def iterate_consistent_updates(self, context):
298 root_spec = RootRepoSpec()
299 for update in self.updates(context):
300 consistent_spec = True
301 try:
302 package_deps = PackageDeps(context)
303 package_deps._create_from_spec(root_spec, update.spec, fetch=True)
304 except InconsistentDependencyGraphError:
305 # Skip inconsistent graphs, which are blocked on dependency rolls
306 consistent_spec = False
307 if consistent_spec:
308 yield update
iannucci 2015/08/06 23:57:12 remove consistent_spec try: ... yield update
luqui 2015/08/20 22:45:24 Done
309
310 def __eq__(self, other):
311 return (
312 self._project_id == other._project_id and
313 self._recipes_path == other._recipes_path and
314 self._deps == other._deps)
315
316 def __ne__(self, other):
317 return not self.__eq__(other)
318
319
320 class PackageDeps(object):
321 """An object containing all the transitive dependencies of the root package.
322 """
323 def __init__(self, context):
324 self._context = context
325 self._repos = {}
326
327 @classmethod
328 def create(cls, proto_file, fetch=False):
iannucci 2015/08/06 23:57:13 doc: proto_file is the root-repo's `infra/config/r
luqui 2015/08/20 22:45:24 Done
329 if isinstance(proto_file, basestring):
330 proto_file = ProtoFile(proto_file)
iannucci 2015/08/06 23:57:12 nuke2faic (always require ProtoFile)
luqui 2015/08/20 22:45:24 Done
331 context = PackageContext.from_proto_file(proto_file)
332 package_deps = cls(context)
333
334 root_package = package_deps._create_package(RootRepoSpec(), fetch)
iannucci 2015/08/06 23:57:12 maybe name 'allowFetch'? just call _create_from_s
luqui 2015/08/20 22:45:24 allow_fetch, Done.
335 return package_deps
336
337 def _create_package(self, repo_spec, fetch):
338 if fetch:
339 repo_spec.checkout(self._context)
340 else:
341 try:
342 repo_spec.check_checkout(self._context)
343 except UncleanFilesystemError as e:
344 logging.warn(
345 'Unclean environment. You probably need to run "recipes.py fetch"\n'
346 '%s' % e.message)
347
348 proto_path = os.path.join(repo_spec.repo_root(self._context),
349 'infra', 'config', 'recipes.cfg')
iannucci 2015/08/06 23:57:12 can this 'infra' 'config' join concept be abstract
luqui 2015/08/20 22:45:24 Done mostly
350 package_spec = PackageSpec.load_proto(ProtoFile(proto_path))
351
352 return self._create_from_spec(repo_spec, package_spec, fetch)
353
354 def _create_from_spec(self, repo_spec, package_spec, fetch):
iannucci 2015/08/06 23:57:12 I have sneaking suspicion that repo_spec is only r
355 deps = {}
356 for dep, dep_repo in sorted(package_spec.deps.items()):
357 deps[dep] = self._create_package(dep_repo, fetch)
358
359 if (package_spec.project_id in self._repos and
360 not repo_spec == self._repos[package_spec.project_id].repo):
iannucci 2015/08/06 23:57:12 != possibly?
luqui 2015/08/20 22:45:24 Done .
361 raise InconsistentDependencyGraphError(
362 'Package specs do not match: %s vs %s' %
363 (repo_spec, self._repos[package_spec.project_id].repo))
364
365 package = Package(
366 repo_spec, deps,
367 os.path.join(repo_spec.repo_root(self._context),
368 package_spec.recipes_path))
369
370 self._repos[package_spec.project_id] = package
iannucci 2015/08/06 23:57:12 need a cycle breaker "currently loading" object pe
luqui 2015/08/20 22:45:24 Done
371 return package
372
373 # TODO(luqui): Remove this, so all accesses to packages are done
374 # via other packages with properly scoped deps.
375 def get_package(self, package_id):
376 return self._repos[package_id]
377
378 @property
379 def all_recipe_dirs(self):
380 for repo in self._repos.values():
381 for subdir in repo.recipe_dirs:
382 yield str(subdir)
383
384 @property
385 def all_module_dirs(self):
386 for repo in self._repos.values():
387 for subdir in repo.module_dirs:
388 yield str(subdir)
389
390
391 def _run_cmd(cmd):
392 logging.info('%s', cmd)
393 subprocess.check_call(cmd)
394
395
396 def _parse_date(datestr):
iannucci 2015/08/06 23:57:13 don't need this :P
luqui 2015/08/20 22:45:24 Done
397 """Parses an ISO-8601 date string into a datetime object.
398
399 >>> ( _parse_date('2015-06-30T10:15:20-00:00')
400 ... <= _parse_date('2015-06-30T11:20:31-00:00'))
401 True
402 >>> ( _parse_date('2015-06-30T11:33:52-07:00')
403 ... <= _parse_date('2015-06-30T11:33:52-08:00'))
404 True
405 >>> ( _parse_date('2015-06-30T11:33:52-07:00')
406 ... <= _parse_date('2015-06-30T11:33:52-06:00'))
407 False
408 """
409 return dateutil.parser.parse(datestr)
410
411
412 def _merge2(xs, ys, compare=lambda x, y: x <= y):
iannucci 2015/08/06 23:57:13 maybe just return sorted(xs + ys)
luqui 2015/08/20 22:45:24 Will not work, since we need the order in xs and y
413 """Merges two sorted iterables, preserving sort order.
414
415 >>> list(_merge2([1, 3, 6], [2, 4, 5]))
416 [1, 2, 3, 4, 5, 6]
417 >>> list(_merge2([1, 2, 3], []))
418 [1, 2, 3]
419 >>> list(_merge2([], [4, 5, 6]))
420 [4, 5, 6]
421 >>> list(_merge2([], []))
422 []
423 >>> list(_merge2([4, 2], [3, 1], compare=lambda x, y: x >= y))
424 [4, 3, 2, 1]
425
426 The merge is left-biased and preserves order within each argument.
427
428 >>> list(_merge2([1, 4], [3, 2], compare=lambda x, y: True))
429 [1, 4, 3, 2]
430 """
431
iannucci 2015/08/06 23:57:12 not_a_thing = object()
luqui 2015/08/20 22:45:24 Done
432 xs = iter(xs)
433 ys = iter(ys)
434 x = None
435 y = None
436 try:
437 x = (xs.next(),)
438 y = (ys.next(),)
439
440 while True:
441 if compare(x[0], y[0]):
442 yield x[0]
443 x = None
444 x = (xs.next(),)
445 else:
446 yield y[0]
447 y = None
448 y = (ys.next(),)
449 except StopIteration:
450 if x: yield x[0]
451 for x in xs: yield x
452 if y: yield y[0]
453 for y in ys: yield y
454
455
456 def _merge(xss, compare=lambda x, y: x <= y):
457 """Merges a sequence of sorted iterables in sorted order.
458
459 >>> list(_merge([ [1,5], [2,5,6], [], [0,7] ]))
460 [0, 1, 2, 5, 5, 6, 7]
461 >>> list(_merge([ [1,2,3] ]))
462 [1, 2, 3]
463 >>> list(_merge([]))
464 []
465 """
466 return reduce(lambda xs, ys: _merge2(xs, ys, compare=compare), xss, [])
467
468
469 def _updated(d, updates):
470 """Updates a dictionary without mutation.
471
472 >>> d = { 'x': 1, 'y': 2 }
473 >>> sorted(_updated(d, { 'y': 3, 'z': 4 }).items())
474 [('x', 1), ('y', 3), ('z', 4)]
475 >>> sorted(d.items())
476 [('x', 1), ('y', 2)]
477 """
478
479 d = copy.copy(d)
480 d.update(updates)
481 return d
482
483
484 @contextlib.contextmanager
485 def _in_directory(target_dir):
486 cwd = os.getcwd()
487 os.chdir(target_dir)
488 try:
489 yield
490 finally:
491 os.chdir(cwd)
OLDNEW

Powered by Google App Engine
This is Rietveld 408576698