OLD | NEW |
---|---|
(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 | |
OLD | NEW |