1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448
|
import binascii
import copy
import pygit2
from pygit2.enums import ObjectType
import gitubuntu.importer
from gitubuntu.source_builder import Source, SourceSpec
import gitubuntu.spec
"""Build test git repositories as data structures
Represent a git repository as a data structure, nesting using Tree objects as
required. Name objects in a flat namespace using the name keyword object to
these constructors.
Use a SourceTree object to get something that behaves like a Tree but is
actually the exact source of a full, unpacked source package described using
source_builder.Source. As source_builder.Source is slow, however, using a Tree
directly is preferred when the behaviour under test does not need a complete
source tree.
Reference the same object with a Placeholder giving the name of the target to
its constructor. This allows a build in a single data structure instead of
having to use multiple statements to grab references to other parts of the same
structure.
Use a Commit object to represent a git commit (constructed with a Tree object
to represent the git tree contained within it), and a Repo object to
contain and create multiple commits as needed, including ones that reference
each other using Placeholder objects.
Write any object (including any children as necessary) using the write method,
which takes a single pygit2.Repository parameter. This returns the pygit2.Oid
of the top level written object.
Objects must be treated as immutable. The result of mutating an object after it has been constructed is undefined.
"""
__all__ = [
'Blob',
'Commit',
'Repo',
'ExecutableBlob',
'SourceTree',
'Symlink',
'Placeholder',
'Tree',
]
# General notes
# Sometimes tests need to check an object hash matches something in a supplied
# builder repo tree. In this case, it seems redundant and messy to have to
# write that object again to get its hash. Perhaps objects should cache their
# hashes or something?
DEFAULT_AUTHOR_SIGNATURE = pygit2.Signature(
# Match the default from source_builder.CHANGELOG_TEMPLATE
name='git ubuntu',
email='ubuntu-distributed-devel@lists.ubuntu.com',
time=0, # don't default to current time for hash reproducibility
offset=0,
)
DEFAULT_COMMITTER_SIGNATURE = pygit2.Signature(
name=gitubuntu.spec.SYNTHESIZED_COMMITTER_NAME,
email=gitubuntu.spec.SYNTHESIZED_COMMITTER_EMAIL,
time=0,
offset=0,
)
class NamedMixin:
def __init__(self, name=None):
self.name = name
class WriteMixin:
def write(self, repo, record=None):
record = record or dict()
try:
return record[self]
except KeyError:
record[self] = self._obj_to_oid(repo, record)
return record[self]
class Placeholder:
def __init__(self, target_name):
self.target_name = target_name
def walk(self, parent=None):
yield parent, self
class Blob(NamedMixin, WriteMixin):
GIT_TREE_ATTR = pygit2.GIT_FILEMODE_BLOB
def __init__(self, content, **kwargs):
"""Construct a Blob
@param content: a bytes object of the desired blob contents
"""
super().__init__(**kwargs)
self.content = content
def walk(self, parent=None):
yield parent, self
def _obj_to_oid(self, repo, record):
return repo.create_blob(self.content)
class ExecutableBlob(Blob):
GIT_TREE_ATTR = pygit2.GIT_FILEMODE_BLOB_EXECUTABLE
class Symlink(NamedMixin, WriteMixin):
GIT_TREE_ATTR = pygit2.GIT_FILEMODE_LINK
def __init__(self, target, **kwargs):
"""Construct a symlink
@param target: a bytes object containing the target
target is not interpreted; it is exactly equivalent to the first
parameter to ln(1).
"""
super().__init__(**kwargs)
self.target = target
def walk(self, parent=None):
yield parent, self
def _obj_to_oid(self, repo, record):
return repo.create_blob(self.target)
class Tree(NamedMixin, WriteMixin):
GIT_TREE_ATTR = pygit2.GIT_FILEMODE_TREE
def __init__(self, entries, **kwargs):
"""Construct a tree
A tree is equivalent to a directory and contains zero or more entries
@param entries: a dictionary whose values are other objects from this
module
"""
super().__init__(**kwargs)
self.entries = entries
def replace(self, old, new):
# XXX inefficient
for name, entry in self.entries.items():
if entry is old:
self.entries[name] = new
return
raise KeyError("Cannot find %r in entries" % old)
def walk(self, parent=None):
for entry in self.entries.values():
yield from entry.walk(parent=self)
yield parent, self
def _obj_to_oid(self, repo, record):
tree_builder = repo.TreeBuilder()
for name, entry in self.entries.items():
tree_builder.insert(
name, # name
entry.write(repo, record=record), # oid
entry.GIT_TREE_ATTR, # attr
)
return tree_builder.write()
class SourceTree(NamedMixin, WriteMixin):
def __init__(self, source, patches_applied=False, **kwargs):
"""Construct a git tree representation of a source_builder.Source
:param source_tree.SourceBuilder source: the source whose git tree this
object will represent
:param patches_applied bool: whether patches should be applied
in this source tree
"""
super().__init__(**kwargs)
self.source = source
self.patches_applied = patches_applied
def walk(self, parent=None):
yield parent, self
def _obj_to_oid(self, repo, record):
with self.source as dsc_path:
oid_str = gitubuntu.importer.dsc_to_tree_hash(
repo,
dsc_path,
self.patches_applied,
)
return pygit2.Oid(binascii.unhexlify(oid_str))
class _Signature:
"""Wrapper around pygit2.Signature
pygit2.Signature doesn't support copy.deepcopy(), which we expect to work
for Repo and Commit objects. So provide a wrapper that Repo and Commit
objects can use to store pygit2.Signature objects that can be deep-copied.
"""
def __init__(self, signature):
self.signature = signature
@classmethod
def from_pygit2_signature(cls, other):
return cls(other)
def __deepcopy__(self, memo):
return type(self)(pygit2.Signature(
name=self.signature.name,
email=self.signature.email,
time=self.signature.time,
offset=self.signature.offset,
))
class Commit(NamedMixin, WriteMixin):
def __init__(
self,
tree=None,
parents=None,
message=None,
author=DEFAULT_AUTHOR_SIGNATURE,
committer=DEFAULT_COMMITTER_SIGNATURE,
**kwargs,
):
"""Construct a Commit object
:param Tree tree: the tree contained by the commit. If None, an empty
tree is used.
:param list(Commit) parents: the commit objects that are the parents of
this commit. The list may be empty if the commit is to have no
parent.
:param str message: the commit message.
:param pygit2.Signature author: use this author for the commit
:param pygit2.Signature committer: use this committer for the commit
:param **kwargs: other parameters supplied to superclasses.
"""
super().__init__(**kwargs)
self.tree = tree or Tree({})
self.parents = parents or []
self.message = 'Test commit' if message is None else message
self.author = _Signature.from_pygit2_signature(author)
self.committer = _Signature.from_pygit2_signature(committer)
@classmethod
def from_spec(
cls,
parents=None,
message=None,
name=None,
patches_applied=False,
**kwargs,
):
"""Construct a Commit object containing a test source package
:param list(Commit) parents: the commit objects that are the parents of
this commit. The list may be empty if the commit is to have no
parent.
:param str message: the commit message.
:param str name: the value for the name attribute of the constructed
object, used for matching with Placeholder objects after
construction.
:param bool patches_applied: whether the tree inside the commit should
have patches applied. Passed to the SourceTree constructor. If
True, then the test source package is generated with patches using
the has_patches argument to the SourceSpec constructor, unless
overridden by kwargs.
:param **kwargs: additional parameters are passed to the SourceSpec
constructor to customise the test source package used for the tree
of the commit.
"""
spec_args = {'has_patches': patches_applied}
spec_args.update(kwargs)
return cls(
tree=SourceTree(
Source(SourceSpec(**spec_args)),
patches_applied=patches_applied,
),
parents=parents,
message=message,
name=name,
)
def replace(self, old, new):
for i, parent in enumerate(self.parents):
if parent is old:
self.parents[i] = new
return
raise KeyError("Cannot find %r in parents" % old)
def walk(self, parent=None):
for commit_parent in self.parents:
yield from commit_parent.walk(parent=self)
yield from self.tree.walk(parent=self)
yield parent, self
def _obj_to_oid(self, repo, record=None):
return repo.create_commit(
None,
self.author.signature,
self.committer.signature,
self.message,
self.tree.write(repo),
[parent.write(repo, record=record) for parent in self.parents],
)
class Repo:
"""Represent a graph of commits
We keep the commits as a list, but understand that they may relate to each
other using parenting relationships.
We keep the branches and tags as dictionaries of str names to either
Commit objects or placeholder strings.
This is affectively a commit container that exists for the
convenience of writing them to a git repository at once.
"""
def __init__(
self,
commits=None,
branches=None,
tags=None,
tagger=DEFAULT_COMMITTER_SIGNATURE,
):
"""Construct a Repo instance
:param list(Commit) commits: the commits this Repo should
contain. These may be related to each other with parenting
relationships, but do not have to be.
:param dict(str: Commit or Placeholder) branches: the branches
this Repo should contain. A branch has a name and a target
to point to. The target can be a Commit object or
Placeholder object.
:param dict(str: Commit or Placeholder) tags: the tags this Repo
should contain. A tag has a name and a target to point to.
The target can be a Commit object or Placeholder object.
:param pygit2.Signature tagger: if specified, use this instead of
DEFAULT_COMMITTER_SIGNATURE as the tagger of any tags.
"""
self.commit_list = commits or list()
self.branches = branches or dict()
self.tags = tags or dict()
self.tagger = _Signature.from_pygit2_signature(tagger)
def replace(self, old, new):
for name, target in self.branches.items():
if target is old:
self.branches[name] = new
return
for name, target in self.tags.items():
if target is old:
self.tags[name] = new
return
raise KeyError("Cannot find %r in tags or branches" % old)
def write(self, repo, record=None):
replace_placeholders(self)
record = record or dict()
written_commits = [
commit.write(repo=repo, record=record)
for commit
in self.commit_list
]
for name, target in self.branches.items():
repo.create_branch(
name,
repo.get(target.write(repo)).peel(pygit2.Commit),
)
for name, target in self.tags.items():
repo.create_tag(
name,
target.write(repo),
ObjectType.COMMIT,
self.tagger.signature,
'Tag message',
)
return written_commits[0] if written_commits else None
def walk(self):
for commit in self.commit_list:
yield from commit.walk(parent=self)
for _, branch in self.branches.items():
yield self, branch
for _, tag in self.tags.items():
yield self, tag
def copy(self, add_commits=None, update_branches=None, update_tags=None):
"""Clone the Repo, optionally with some changes
:param list(Commit) add_commits: commits to add to the cloned Repo
:param dict(str: Commit or Placeholder) branches: branches to update in
the cloned Repo.
:param dict(str: Commit or Placeholder) tags: tags to update in the
cloned Repo.
"update" is in the sense of a dict object's update method: this allows
for both the definition of new branches and tags, and updates to
existing branches and tags.
There is currently no facility to delete branches or tags.
"""
new_repo = copy.deepcopy(self)
if add_commits:
new_repo.commit_list.extend(add_commits)
if update_branches:
new_repo.branches.update(update_branches)
if update_tags:
new_repo.tags.update(update_tags)
return new_repo
def find_node(top, name):
for parent, obj in top.walk():
if isinstance(obj, NamedMixin):
if obj.name == name:
return obj
raise KeyError("Object named %r not found" % name)
def replace_placeholders(top):
"""
Walk @top replacing Placeholder objects with the object specified by
the Placeholder's target_name.
Raises KeyError if a Placeholder's target_name cannot be found under
@top
"""
for parent, obj in top.walk():
if isinstance(obj, Placeholder):
parent.replace(obj, find_node(top, obj.target_name))
|