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

Side by Side Diff: testing_support/git_test_utils.py

Issue 26109002: Add git-number script to calculate generation numbers for commits. (Closed) Base URL: svn://svn.chromium.org/chrome/trunk/tools/depot_tools
Patch Set: Now with tests! Created 7 years, 1 month 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 # Copyright (c) 2013 The Chromium Authors. All rights reserved.
2 # Use of this source code is governed by a BSD-style license that can be
3 # found in the LICENSE file.
4
5 import atexit
6 import collections
7 import copy
8 import datetime
9 import hashlib
10 import os
11 import shutil
12 import subprocess
13 import sys
14 import tempfile
15 import unittest
16
17
18 def git_hash_data(data, typ='blob'):
19 """Calculate the git-style SHA1 for some data.
20
21 Only supports 'blob' type data at the moment.
22 """
23 assert typ == 'blob', "Only support blobs for now"
24 return hashlib.sha1("blob %s\0%s" % (len(data), data)).hexdigest()
25
26
27 class OrderedSet(collections.MutableSet):
28 # from http://code.activestate.com/recipes/576694/
29 def __init__(self, iterable=None):
30 self.end = end = []
31 end += [None, end, end] # sentinel node for doubly linked list
32 self.data = {} # key --> [key, prev, next]
33 if iterable is not None:
34 self |= iterable
35
36 def __len__(self):
37 return len(self.data)
38
39 def __contains__(self, key):
40 return key in self.data
41
42 def add(self, key):
43 if key not in self.data:
44 end = self.end
45 curr = end[1]
46 curr[2] = end[1] = self.data[key] = [key, curr, end]
47
48 def discard(self, key):
49 if key in self.data:
50 key, prev, nxt = self.data.pop(key)
51 prev[2] = nxt
52 nxt[1] = prev
53
54 def difference_update(self, *others):
55 for other in others:
56 for i in other:
57 self.discard(i)
58
59 def __iter__(self):
M-A Ruel 2013/11/07 20:59:11 Group the __functions__ together, sort both subset
iannucci 2013/11/07 21:44:57 Done. I put __init__ at the top though.
60 end = self.end
61 curr = end[2]
62 while curr is not end:
63 yield curr[0]
64 curr = curr[2]
65
66 def __reversed__(self):
67 end = self.end
68 curr = end[1]
69 while curr is not end:
70 yield curr[0]
71 curr = curr[1]
72
73 def pop(self, last=True): # pylint: disable=W0221
74 if not self:
75 raise KeyError('set is empty')
76 key = self.end[1][0] if last else self.end[2][0]
77 self.discard(key)
78 return key
79
80 def __repr__(self):
81 if not self:
82 return '%s()' % (self.__class__.__name__,)
83 return '%s(%r)' % (self.__class__.__name__, list(self))
84
85 def __eq__(self, other):
86 if isinstance(other, OrderedSet):
87 return len(self) == len(other) and list(self) == list(other)
88 return set(self) == set(other)
89
90
91 class GitRepoSchema(object):
92 """A declarative git testing repo.
93
94 Pass a schema to __init__ in the form of:
95 A B C D
96 B E D
97
98 This is the repo
99
100 A - B - C - D
101 \ E /
102
103 Whitespace doesn't matter. Each line is a declaration of which commits come
104 before which other commits.
105
106 Every commit gets a tag 'tag_%(commit)s'
107 Every unique terminal commit gets a branch 'branch_%(commit)s'
108 Last commit in First line is the branch 'master'
109 Root commits get a ref 'root_%(commit)s'
110
111 Timestamps are in topo order, earlier commits (as indicated by their presence
112 in the schema) get earlier timestamps. Stamps start at the Unix Epoch, and
113 increment by 1 day each.
114 """
115 COMMIT = collections.namedtuple('COMMIT', 'name parents is_branch is_root')
116
117 def walk(self):
118 """Generator to walk the repo schema from roots to tips.
119
120 Generates GitRepoSchema.COMMIT objects for each commit.
121
122 Throws an AssertionError if it detects a cycle.
123 """
124 is_root = True
125 par_map = copy.deepcopy(self.par_map)
126 while par_map:
127 empty_keys = set(k for k, v in par_map.iteritems() if not v)
128 if empty_keys:
129 for k in sorted(empty_keys):
130 yield self.COMMIT(k, self.par_map[k],
131 not any(k in v for v in self.par_map.itervalues()),
132 is_root)
133 del par_map[k]
134 for v in par_map.itervalues():
135 v.difference_update(empty_keys)
136 is_root = False
137 else:
138 assert False, "Cycle detected! %s" % par_map
139
140 def add_commits(self, schema):
141 """Adds more commits from a schema into the existing Schema.
142
143 Args:
144 schema (str) - See class docstring for info on schema format.
145
146 Throws an AssertionError if it detects a cycle.
147 """
148 for commits in (l.split() for l in schema.splitlines() if l.strip()):
149 parent = None
150 for commit in commits:
151 if commit not in self.par_map:
152 self.par_map[commit] = OrderedSet()
153 if parent is not None:
154 self.par_map[commit].add(parent)
155 parent = commit
156 if parent and self.master is None:
157 self.master = parent
158 for _ in self.walk(): # This will throw if there are any cycles.
159 pass
160
161 def __init__(self, repo_schema="",
162 content_fn=lambda v: {v: {'data': v}}):
163 """Builds a new GitRepoSchema.
164
165 Args:
166 repo_schema (str) - Initial schema for this repo. See class docstring for
167 info on the schema format.
168 content_fn ((commit_name) -> commit_data) - A function which will be
169 lazily called to obtain data for each commit. The results of this
170 function are cached (i.e. it will never be called twice for the same
171 commit_name). See the docstring on the GitRepo class for the format of
172 the data returned by this function.
173 """
174 self.master = None
175 self.par_map = {}
176 self.data_cache = {}
177 self.content_fn = content_fn
178 self.add_commits(repo_schema)
179
180 def reify(self):
181 """Returns a real GitRepo for this GitRepoSchema"""
182 return GitRepo(self)
183
184 def data_for(self, commit):
185 """Method to obtain data for a commit.
186
187 See the docstring on the GitRepo class for the format of the returned data.
188
189 Caches the result on this GitRepoSchema instance.
190 """
191 if commit not in self.data_cache:
192 self.data_cache[commit] = self.content_fn(commit)
193 return self.data_cache[commit]
194
195
196 class GitRepo(object):
M-A Ruel 2013/11/07 20:59:11 This could likely be used to remove git support in
iannucci 2013/11/07 21:44:57 Yeah agreed
197 """Creates a real git repo for a GitRepoSchema.
198
199 Obtains schema and content information from the GitRepoSchema.
200
201 The format for the commit data supplied by GitRepoSchema.data_for is:
202 {
203 SPECIAL_KEY: special_value,
204 ...
205 "path/to/some/file": { 'data': "some data content for this file",
206 'mode': 0755 },
207 ...
208 }
209
210 The SPECIAL_KEYs are the following attribues of the GitRepo class:
211 * AUTHOR_NAME
212 * AUTHOR_EMAIL
213 * AUTHOR_DATE - must be a datetime.datetime instance
214 * COMMITTER_NAME
215 * COMMITTER_EMAIL
216 * COMMITTER_DATE - must be a datetime.datetime instance
217
218 For file content, if 'data' is None, then this commit will `git rm` that file.
219 """
220 BASE_TEMP_DIR = tempfile.mkdtemp(suffix='base', prefix='git_repo')
221 atexit.register(shutil.rmtree, BASE_TEMP_DIR)
222
223 # Singleton objects to specify specific data in a commit dictionary.
224 AUTHOR_NAME = object()
225 AUTHOR_EMAIL = object()
226 AUTHOR_DATE = object()
227 COMMITTER_NAME = object()
228 COMMITTER_EMAIL = object()
229 COMMITTER_DATE = object()
230
231 DEFAULT_AUTHOR_NAME = 'Author McAuthorly'
232 DEFAULT_AUTHOR_EMAIL = 'author@example.com'
233 DEFAULT_COMMITTER_NAME = 'Charles Committish'
234 DEFAULT_COMMITTER_EMAIL = 'commitish@example.com'
235
236 COMMAND_OUTPUT = collections.namedtuple('COMMAND_OUTPUT', 'retcode stdout')
237
238 def __init__(self, schema):
239 """Makes new GitRepo.
240
241 Automatically creates a temp folder under GitRepo.BASE_TEMP_DIR. It's
242 recommended that you clean this repo up by calling nuke() on it, but if not,
243 GitRepo will automatically clean up all allocated repos at the exit of the
244 program (assuming a normal exit like with sys.exit)
245
246 Args:
247 schema - An instance of GitRepoSchema
248 """
249 self.repo_path = tempfile.mkdtemp(dir=self.BASE_TEMP_DIR)
250 self.commit_map = {}
251 self._date = datetime.datetime(1970, 1, 1)
252
253 self.git('init')
254 for commit in schema.walk():
255 self._add_schema_commit(commit, schema.data_for(commit.name))
256 if schema.master:
257 self.git('update-ref', 'master', self[schema.master])
258
259 def __getitem__(self, commit_name):
260 """Allows you to get the hash of a commit by it's schema name.
261
262 >>> r = GitRepo(GitRepoSchema('A B C'))
263 >>> r['B']
264 '7381febe1da03b09da47f009963ab7998a974935'
265 """
266 return self.commit_map[commit_name]
267
268 def _add_schema_commit(self, commit, data):
269 data = data or {}
270
271 if commit.parents:
272 parents = list(commit.parents)
273 self.git('checkout', '--detach', '-q', self[parents[0]])
274 if len(parents) > 1:
275 self.git('merge', '--no-commit', '-q', *[self[x] for x in parents[1:]])
276 else:
277 self.git('checkout', '--orphan', 'root_%s' % commit.name)
278 self.git('rm', '-rf', '.')
279
280 env = {}
281 for prefix in ('AUTHOR', 'COMMITTER'):
282 for suffix in ('NAME', 'EMAIL', 'DATE'):
283 singleton = '%s_%s' % (prefix, suffix)
284 key = getattr(self, singleton)
285 if key in data:
286 val = data[key]
287 else:
288 if suffix == 'DATE':
289 val = self._date
290 self._date += datetime.timedelta(days=1)
291 else:
292 val = getattr(self, 'DEFAULT_%s' % singleton)
293 env['GIT_%s' % singleton] = str(val)
294
295 for fname, file_data in data.iteritems():
296 deleted = False
297 if 'data' in file_data:
298 data = file_data.get('data')
299 if data is None:
300 deleted = True
301 self.git('rm', fname)
302 else:
303 path = os.path.join(self.repo_path, fname)
304 pardir = os.path.dirname(path)
305 if not os.path.exists(pardir):
306 os.makedirs(pardir)
307 with open(path, 'wb') as f:
308 f.write(data)
309
310 mode = file_data.get('mode')
311 if mode and not deleted:
312 os.chmod(path, mode)
313
314 self.git('add', fname)
315
316 rslt = self.git('commit', '--allow-empty', '-m', commit.name, env=env)
317 assert rslt.retcode == 0, 'Failed to commit %s' % str(commit)
318 self.commit_map[commit.name] = self.git('rev-parse', 'HEAD').stdout.strip()
319 self.git('tag', 'tag_%s' % commit.name, self[commit.name])
320 if commit.is_branch:
321 self.git('update-ref', 'branch_%s' % commit.name, self[commit.name])
322
323 def git(self, *args, **kwargs):
324 """Runs a git command specified by |args| in this repo."""
325 assert self.repo_path is not None
326 try:
327 with open(os.devnull, 'wb') as devnull:
328 output = subprocess.check_output(
329 ('git',) + args, cwd=self.repo_path, stderr=devnull, **kwargs)
330 return self.COMMAND_OUTPUT(0, output)
331 except subprocess.CalledProcessError as e:
332 return self.COMMAND_OUTPUT(e.returncode, e.output)
333
334 def nuke(self):
335 """Obliterates the git repo on disk.
336
337 Causes this GitRepo to be unusable.
338 """
339 shutil.rmtree(self.repo_path)
340 self.repo_path = None
341
342 def run(self, fn, *args, **kwargs):
343 """Run a python function with the given args and kwargs with the cwd set to
344 the git repo."""
345 assert self.repo_path is not None
346 curdir = os.getcwd()
347 try:
348 os.chdir(self.repo_path)
349 return fn(*args, **kwargs)
350 finally:
351 os.chdir(curdir)
352
353
354 class GitRepoSchemaTestBase(unittest.TestCase):
355 """A TestCase with a built-in GitRepoSchema.
356
357 Expects a class variable REPO to be a GitRepoSchema string in the form
358 described by that class.
359
360 You may also set class variables in the form COMMIT_%(commit_name)s, which
361 provide the content for the given commit_name commits.
362
363 You probably will end up using either GitRepoReadOnlyTestBase or
364 GitRepoReadWriteTestBase for real tests.
365 """
366 REPO = None
367
368 @classmethod
369 def getRepoContent(cls, commit):
370 return getattr(cls, 'COMMIT_%s' % commit, None)
371
372 @classmethod
373 def setUpClass(cls):
374 super(GitRepoSchemaTestBase, cls).setUpClass()
375 assert cls.REPO is not None
376 cls.r_schema = GitRepoSchema(cls.REPO, cls.getRepoContent)
377
378
379 class GitRepoReadOnlyTestBase(GitRepoSchemaTestBase):
380 """Injects a GitRepo object given the schema and content from
381 GitRepoSchemaTestBase into TestCase classes which subclass this.
382
383 This GitRepo will appear as self.repo, and will be deleted and recreated once
384 for the duration of all the tests in the subclass.
385 """
386 REPO = None
387
388 @classmethod
389 def setUpClass(cls):
390 super(GitRepoReadOnlyTestBase, cls).setUpClass()
391 assert cls.REPO is not None
392 cls.repo = cls.r_schema.reify()
393
394 @classmethod
395 def tearDownClass(cls):
396 super(GitRepoReadOnlyTestBase, cls).tearDownClass()
397 cls.repo.nuke()
398
399
400 class GitRepoReadWriteTestBase(GitRepoSchemaTestBase):
401 """Injects a GitRepo object given the schema and content from
402 GitRepoSchemaTestBase into TestCase classes which subclass this.
403
404 This GitRepo will appear as self.repo, and will be deleted and recreated for
405 each test function in the subclass.
406 """
407 REPO = None
408
409 def setUp(self):
410 super(GitRepoReadWriteTestBase, self).setUp()
411 self.repo = self.r_schema.reify()
412
413 def tearDown(self):
414 super(GitRepoReadWriteTestBase, self).tearDown()
415 self.repo.nuke()
416
417
418 def covered_main(includes):
M-A Ruel 2013/11/07 20:59:11 This is not git specific. I'd recommend a separate
iannucci 2013/11/07 21:44:57 Done. Moved to coverage_uitls.py (even though ther
419 """Equivalent of unittest.main(), except that it gathers coverage data, and
420 asserts if the test is not at 100% coverage.
421
422 Args:
423 includes (list(str)) - List of paths to include in coverage report.
424 """
425 try:
426 import coverage
427 except ImportError:
428 sys.path.insert(0,
429 os.path.abspath(os.path.join(
430 os.path.dirname(os.path.dirname(__file__)), 'third_party')))
431 import coverage
432 COVERAGE = coverage.coverage(include=includes)
433 COVERAGE.start()
434
435 retcode = 0
436 try:
437 unittest.main()
438 except SystemExit as e:
439 retcode = e.code or retcode
440
441 COVERAGE.stop()
442 if COVERAGE.report() != 100.0:
443 print "FATAL: not at 100% coverage."
444 retcode = 2
445
446 return retcode
OLDNEW

Powered by Google App Engine
This is Rietveld 408576698