Chromium Code Reviews| 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 |