4 Abstract bug repository data storage to easily support multiple backends.
12 from libbe.error import NotSupported
14 from libbe.util.tree import Tree
15 from libbe.util import InvalidObject
16 from libbe import TESTING
24 from libbe.util.utility import Dir
26 class ConnectionError (Exception):
29 class InvalidID (KeyError):
32 class InvalidRevision (KeyError):
35 class InvalidDirectory (Exception):
38 class DirectoryNotEmpty (InvalidDirectory):
41 class NotWriteable (NotSupported):
42 def __init__(self, msg):
43 NotSupported.__init__(self, 'write', msg)
45 class NotReadable (NotSupported):
46 def __init__(self, msg):
47 NotSupported.__init__(self, 'read', msg)
49 class EmptyCommit(Exception):
51 Exception.__init__(self, 'No changes to commit')
54 def __init__(self, id, value=None, parent=None, directory=False,
59 Tree.__init__(self, children)
63 if self.parent != None:
64 if self.parent.directory == False:
65 raise InvalidDirectory(
66 'Non-directory %s cannot have children' % self.parent)
68 self.directory = directory
71 return '<Entry %s: %s>' % (self.id, self.value)
76 def __cmp__(self, other, local=False):
79 if cmp(self.id, other.id) != 0:
80 return cmp(self.id, other.id)
81 if cmp(self.value, other.value) != 0:
82 return cmp(self.value, other.value)
84 if self.parent == None:
85 if cmp(self.parent, other.parent) != 0:
86 return cmp(self.parent, other.parent)
87 elif self.parent.__cmp__(other.parent, local=True) != 0:
88 return self.parent.__cmp__(other.parent, local=True)
89 for sc,oc in zip(self, other):
90 if sc.__cmp__(oc, local=True) != 0:
91 return sc.__cmp__(oc, local=True)
94 def _objects_to_ids(self):
95 if self.parent != None:
96 self.parent = self.parent.id
97 for i,c in enumerate(self):
101 def _ids_to_objects(self, dict):
102 if self.parent != None:
103 self.parent = dict[self.parent]
104 for i,c in enumerate(self):
108 class Storage (object):
110 This class declares all the methods required by a Storage
111 interface. This implementation just keeps the data in a
112 dictionary and uses pickle for persistent storage.
116 def __init__(self, repo='/', encoding='utf-8', options=None):
118 self.encoding = encoding
119 self.options = options
120 self.readable = True # soft limit (user choice)
121 self._readable = True # hard limit (backend choice)
122 self.writeable = True # soft limit (user choice)
123 self._writeable = True # hard limit (backend choice)
124 self.versioned = False
128 return '<%s %s %s>' % (self.__class__.__name__, id(self), self.repo)
134 """Return a version string for this backend."""
137 def storage_version(self):
138 """Return the storage format for this backend."""
139 return libbe.storage.STORAGE_VERSION
141 def is_readable(self):
142 return self.readable and self._readable
144 def is_writeable(self):
145 return self.writeable and self._writeable
148 """Create a new storage repository."""
149 if self.can_init == False:
150 raise NotSupported('init',
151 'Cannot initialize this repository format.')
152 if self.is_writeable() == False:
153 raise NotWriteable('Cannot initialize unwriteable storage.')
157 f = open(os.path.join(self.repo, 'repo.pkl'), 'wb')
158 root = Entry(id='__ROOT__', directory=True)
160 pickle.dump(dict((k,v._objects_to_ids()) for k,v in d.items()), f, -1)
164 """Remove the storage repository."""
165 if self.is_writeable() == False:
166 raise NotWriteable('Cannot destroy unwriteable storage.')
167 return self._destroy()
170 os.remove(os.path.join(self.repo, 'repo.pkl'))
173 """Open a connection to the repository."""
174 if self.is_readable() == False:
175 raise NotReadable('Cannot connect to unreadable storage.')
180 f = open(os.path.join(self.repo, 'repo.pkl'), 'rb')
182 raise ConnectionError(self)
184 self._data = dict((k,v._ids_to_objects(d)) for k,v in d.items())
187 def disconnect(self):
188 """Close the connection to the repository."""
189 if self.is_writeable() == False:
191 f = open(os.path.join(self.repo, 'repo.pkl'), 'wb')
192 pickle.dump(dict((k,v._objects_to_ids())
193 for k,v in self._data.items()), f, -1)
197 def add(self, id, *args, **kwargs):
199 if self.is_writeable() == False:
200 raise NotWriteable('Cannot add entry to unwriteable storage.')
201 try: # Maybe we've already added that id?
203 pass # yup, no need to add another
205 self._add(id, *args, **kwargs)
207 def _add(self, id, parent=None, directory=False):
210 p = self._data[parent]
211 self._data[id] = Entry(id, parent=p, directory=directory)
213 def remove(self, *args, **kwargs):
214 """Remove an entry."""
215 if self.is_writeable() == False:
216 raise NotSupported('write',
217 'Cannot remove entry from unwriteable storage.')
218 self._remove(*args, **kwargs)
220 def _remove(self, id):
221 if self._data[id].directory == True \
222 and len(self.children(id)) > 0:
223 raise DirectoryNotEmpty(id)
224 e = self._data.pop(id)
227 def recursive_remove(self, *args, **kwargs):
228 """Remove an entry and all its decendents."""
229 if self.is_writeable() == False:
230 raise NotSupported('write',
231 'Cannot remove entries from unwriteable storage.')
232 self._recursive_remove(*args, **kwargs)
234 def _recursive_remove(self, id):
235 for entry in reversed(list(self._data[id].traverse())):
236 self._remove(entry.id)
238 def children(self, *args, **kwargs):
239 """Return a list of specified entry's children's ids."""
240 if self.is_readable() == False:
241 raise NotReadable('Cannot list children with unreadable storage.')
242 return self._children(*args, **kwargs)
244 def _children(self, id=None, revision=None):
247 return [c.id for c in self._data[id] if not c.id.startswith('__')]
249 def get(self, *args, **kwargs):
251 Get contents of and entry as they were in a given revision.
252 revision==None specifies the current revision.
254 If there is no id, return default, unless default is not
255 given, in which case raise InvalidID.
257 if self.is_readable() == False:
258 raise NotReadable('Cannot get entry with unreadable storage.')
259 if 'decode' in kwargs:
260 decode = kwargs.pop('decode')
263 value = self._get(*args, **kwargs)
264 if decode == True and type(value) != types.UnicodeType:
265 return unicode(value, self.encoding)
266 if decode == False and type(value) != types.StringType:
267 return value.encode(self.encoding)
270 def _get(self, id, default=InvalidObject, revision=None):
272 return self._data[id].value
273 elif default == InvalidObject:
277 def set(self, id, value, *args, **kwargs):
279 Set the entry contents.
281 if self.is_writeable() == False:
282 raise NotWriteable('Cannot set entry in unwriteable storage.')
283 if type(value) == types.UnicodeType:
284 value = value.encode(self.encoding)
285 self._set(id, value, *args, **kwargs)
287 def _set(self, id, value):
288 if id not in self._data:
290 if self._data[id].directory == True:
291 raise InvalidDirectory(
292 'Directory %s cannot have data' % self.parent)
293 self._data[id].value = value
295 class VersionedStorage (Storage):
297 This class declares all the methods required by a Storage
298 interface that supports versioning. This implementation just
299 keeps the data in a list and uses pickle for persistent
302 name = 'VersionedStorage'
304 def __init__(self, *args, **kwargs):
305 Storage.__init__(self, *args, **kwargs)
306 self.versioned = True
309 f = open(os.path.join(self.repo, 'repo.pkl'), 'wb')
310 root = Entry(id='__ROOT__', directory=True)
311 summary = Entry(id='__COMMIT__SUMMARY__', value='Initial commit')
312 body = Entry(id='__COMMIT__BODY__')
313 initial_commit = {root.id:root, summary.id:summary, body.id:body}
314 d = dict((k,v._objects_to_ids()) for k,v in initial_commit.items())
315 pickle.dump([d, copy.deepcopy(d)], f, -1) # [inital tree, working tree]
320 f = open(os.path.join(self.repo, 'repo.pkl'), 'rb')
322 raise ConnectionError(self)
324 self._data = [dict((k,v._ids_to_objects(t)) for k,v in t.items())
328 def disconnect(self):
329 """Close the connection to the repository."""
330 if self.is_writeable() == False:
332 f = open(os.path.join(self.repo, 'repo.pkl'), 'wb')
333 pickle.dump([dict((k,v._objects_to_ids())
334 for k,v in t.items()) for t in self._data], f, -1)
338 def _add(self, id, parent=None, directory=False):
341 p = self._data[-1][parent]
342 self._data[-1][id] = Entry(id, parent=p, directory=directory)
344 def _remove(self, id):
345 if self._data[-1][id].directory == True \
346 and len(self.children(id)) > 0:
347 raise DirectoryNotEmpty(id)
348 e = self._data[-1].pop(id)
351 def _recursive_remove(self, id):
352 for entry in reversed(list(self._data[-1][id].traverse())):
353 self._remove(entry.id)
355 def _children(self, id=None, revision=None):
360 return [c.id for c in self._data[revision][id]
361 if not c.id.startswith('__')]
363 def _get(self, id, default=InvalidObject, revision=None):
366 if id in self._data[revision]:
367 return self._data[revision][id].value
368 elif default == InvalidObject:
372 def _set(self, id, value):
373 if id not in self._data[-1]:
375 self._data[-1][id].value = value
377 def commit(self, *args, **kwargs):
379 Commit the current repository, with a commit message string
380 summary and body. Return the name of the new revision.
382 If allow_empty == False (the default), raise EmptyCommit if
383 there are no changes to commit.
385 if self.is_writeable() == False:
386 raise NotWriteable('Cannot commit to unwriteable storage.')
387 return self._commit(*args, **kwargs)
389 def _commit(self, summary, body=None, allow_empty=False):
390 if self._data[-1] == self._data[-2] and allow_empty == False:
392 self._data[-1]["__COMMIT__SUMMARY__"].value = summary
393 self._data[-1]["__COMMIT__BODY__"].value = body
394 rev = len(self._data)-1
395 self._data.append(copy.deepcopy(self._data[-1]))
398 def revision_id(self, index=None):
400 Return the name of the <index>th revision. The choice of
401 which branch to follow when crossing branches/merges is not
402 defined. Revision indices start at 1; ID 0 is the blank
405 Return None if index==None.
407 If the specified revision does not exist, raise InvalidRevision.
412 if int(index) != index:
413 raise InvalidRevision(index)
415 raise InvalidRevision(index)
416 L = len(self._data) - 1 # -1 b/c of initial commit
417 if index >= -L and index <= L:
419 raise InvalidRevision(i)
422 class StorageTestCase (unittest.TestCase):
423 """Test cases for base Storage class."""
427 def __init__(self, *args, **kwargs):
428 super(StorageTestCase, self).__init__(*args, **kwargs)
432 """Set up test fixtures for Storage test case."""
433 super(StorageTestCase, self).setUp()
435 self.dirname = self.dir.path
436 self.s = self.Class(repo=self.dirname)
437 self.assert_failed_connect()
442 super(StorageTestCase, self).tearDown()
445 self.assert_failed_connect()
448 def assert_failed_connect(self):
452 "Connected to %(name)s repository before initialising"
454 except ConnectionError:
457 class Storage_init_TestCase (StorageTestCase):
458 """Test cases for Storage.init method."""
460 def test_connect_should_succeed_after_init(self):
461 """Should connect after initialization."""
464 class Storage_add_remove_TestCase (StorageTestCase):
465 """Test cases for Storage.add, .remove, and .recursive_remove methods."""
467 def test_initially_empty(self):
468 """New repository should be empty."""
469 self.failUnless(len(self.s.children()) == 0, self.s.children())
471 def test_add_identical_rooted(self):
473 Adding entries with the same ID should not increase the number of children.
476 self.s.add('some id', directory=False)
477 s = sorted(self.s.children())
478 self.failUnless(s == ['some id'], s)
480 def test_add_rooted(self):
482 Adding entries should increase the number of children (rooted).
487 self.s.add(ids[-1], directory=False)
488 s = sorted(self.s.children())
489 self.failUnless(s == ids, '\n %s\n !=\n %s' % (s, ids))
491 def test_add_nonrooted(self):
493 Adding entries should increase the number of children (nonrooted).
495 self.s.add('parent', directory=True)
499 self.s.add(ids[-1], 'parent', directory=True)
500 s = sorted(self.s.children('parent'))
501 self.failUnless(s == ids, '\n %s\n !=\n %s' % (s, ids))
502 s = self.s.children()
503 self.failUnless(s == ['parent'], s)
505 def test_children(self):
507 Non-UUID ids should be returned as such.
509 self.s.add('parent', directory=True)
512 ids.append('parent/%s' % str(i))
513 self.s.add(ids[-1], 'parent', directory=True)
514 s = sorted(self.s.children('parent'))
515 self.failUnless(s == ids, '\n %s\n !=\n %s' % (s, ids))
517 def test_add_invalid_directory(self):
519 Should not be able to add children to non-directories.
521 self.s.add('parent', directory=False)
523 self.s.add('child', 'parent', directory=False)
525 '%s.add() succeeded instead of raising InvalidDirectory'
526 % (vars(self.Class)['name']))
527 except InvalidDirectory:
530 self.s.add('child', 'parent', directory=True)
532 '%s.add() succeeded instead of raising InvalidDirectory'
533 % (vars(self.Class)['name']))
534 except InvalidDirectory:
536 self.failUnless(len(self.s.children('parent')) == 0,
537 self.s.children('parent'))
539 def test_remove_rooted(self):
541 Removing entries should decrease the number of children (rooted).
546 self.s.add(ids[-1], directory=True)
548 self.s.remove(ids.pop())
549 s = sorted(self.s.children())
550 self.failUnless(s == ids, '\n %s\n !=\n %s' % (s, ids))
552 def test_remove_nonrooted(self):
554 Removing entries should decrease the number of children (nonrooted).
556 self.s.add('parent', directory=True)
560 self.s.add(ids[-1], 'parent', directory=False)
562 self.s.remove(ids.pop())
563 s = sorted(self.s.children('parent'))
564 self.failUnless(s == ids, '\n %s\n !=\n %s' % (s, ids))
565 s = self.s.children()
566 self.failUnless(s == ['parent'], s)
568 def test_remove_directory_not_empty(self):
570 Removing a non-empty directory entry should raise exception.
572 self.s.add('parent', directory=True)
576 self.s.add(ids[-1], 'parent', directory=True)
577 self.s.remove(ids.pop()) # empty directory removal succeeds
579 self.s.remove('parent') # empty directory removal succeeds
581 "%s.remove() didn't raise DirectoryNotEmpty"
582 % (vars(self.Class)['name']))
583 except DirectoryNotEmpty:
586 def test_recursive_remove(self):
588 Recursive remove should empty the tree.
590 self.s.add('parent', directory=True)
594 self.s.add(ids[-1], 'parent', directory=True)
595 for j in range(10): # add some grandkids
596 self.s.add(str(20*(i+1)+j), ids[-1], directory=False)
597 self.s.recursive_remove('parent')
598 s = sorted(self.s.children())
599 self.failUnless(s == [], s)
601 class Storage_get_set_TestCase (StorageTestCase):
602 """Test cases for Storage.get and .set methods."""
605 val = 'unlikely value'
607 def test_get_default(self):
609 Get should return specified default if id not in Storage.
611 ret = self.s.get(self.id, default=self.val)
612 self.failUnless(ret == self.val,
613 "%s.get() returned %s not %s"
614 % (vars(self.Class)['name'], ret, self.val))
616 def test_get_default_exception(self):
618 Get should raise exception if id not in Storage and no default.
621 ret = self.s.get(self.id)
623 "%s.get() returned %s instead of raising InvalidID"
624 % (vars(self.Class)['name'], ret))
628 def test_get_initial_value(self):
630 Data value should be None before any value has been set.
632 self.s.add(self.id, directory=False)
633 ret = self.s.get(self.id)
634 self.failUnless(ret == None,
635 "%s.get() returned %s not None"
636 % (vars(self.Class)['name'], ret))
638 def test_set_exception(self):
640 Set should raise exception if id not in Storage.
643 self.s.set(self.id, self.val)
645 "%(name)s.set() did not raise InvalidID"
652 Set should define the value returned by get.
654 self.s.add(self.id, directory=False)
655 self.s.set(self.id, self.val)
656 ret = self.s.get(self.id)
657 self.failUnless(ret == self.val,
658 "%s.get() returned %s not %s"
659 % (vars(self.Class)['name'], ret, self.val))
661 def test_unicode_set(self):
663 Set should define the value returned by get.
666 self.s.add(self.id, directory=False)
667 self.s.set(self.id, val)
668 ret = self.s.get(self.id, decode=True)
669 self.failUnless(type(ret) == types.UnicodeType,
670 "%s.get() returned %s not UnicodeType"
671 % (vars(self.Class)['name'], type(ret)))
672 self.failUnless(ret == val,
673 "%s.get() returned %s not %s"
674 % (vars(self.Class)['name'], ret, self.val))
675 ret = self.s.get(self.id)
676 self.failUnless(type(ret) == types.StringType,
677 "%s.get() returned %s not StringType"
678 % (vars(self.Class)['name'], type(ret)))
679 s = unicode(ret, self.s.encoding)
680 self.failUnless(s == val,
681 "%s.get() returned %s not %s"
682 % (vars(self.Class)['name'], s, self.val))
685 class Storage_persistence_TestCase (StorageTestCase):
686 """Test cases for Storage.disconnect and .connect methods."""
689 val = 'unlikely value'
691 def test_get_set_persistence(self):
693 Set should define the value returned by get after reconnect.
695 self.s.add(self.id, directory=False)
696 self.s.set(self.id, self.val)
699 ret = self.s.get(self.id)
700 self.failUnless(ret == self.val,
701 "%s.get() returned %s not %s"
702 % (vars(self.Class)['name'], ret, self.val))
704 def test_add_nonrooted_persistence(self):
706 Adding entries should increase the number of children after reconnect.
708 self.s.add('parent', directory=True)
712 self.s.add(ids[-1], 'parent', directory=False)
715 s = sorted(self.s.children('parent'))
716 self.failUnless(s == ids, '\n %s\n !=\n %s' % (s, ids))
717 s = self.s.children()
718 self.failUnless(s == ['parent'], s)
720 class VersionedStorageTestCase (StorageTestCase):
721 """Test cases for base VersionedStorage class."""
723 Class = VersionedStorage
725 class VersionedStorage_commit_TestCase (VersionedStorageTestCase):
726 """Test cases for VersionedStorage methods."""
728 id = 'I' #unlikely id'
730 commit_msg = 'C' #ommitting something interesting'
731 commit_body = 'B' #ome\nlonger\ndescription\n'
733 def test_revision_id_exception(self):
735 Invalid revision id should raise InvalidRevision.
738 rev = self.s.revision_id('highly unlikely revision id')
740 "%s.revision_id() didn't raise InvalidRevision, returned %s."
741 % (vars(self.Class)['name'], rev))
742 except InvalidRevision:
745 def test_empty_commit_raises_exception(self):
747 Empty commit should raise exception.
750 self.s.commit(self.commit_msg, self.commit_body)
752 "Empty %(name)s.commit() didn't raise EmptyCommit."
757 def test_empty_commit_allowed(self):
759 Empty commit should _not_ raise exception if allow_empty=True.
761 self.s.commit(self.commit_msg, self.commit_body,
764 def test_commit_revision_ids(self):
766 Commit / revision_id should agree on revision ids.
769 return '%s:%d' % (self.val, i+1)
770 self.s.add(self.id, directory=False)
773 self.s.set(self.id, val(i))
774 revs.append(self.s.commit('%s: %d' % (self.commit_msg, i),
777 rev = self.s.revision_id(i+1)
778 self.failUnless(rev == revs[i],
779 "%s.revision_id(%d) returned %s not %s"
780 % (vars(self.Class)['name'], i+1, rev, revs[i]))
781 for i in range(-1, -9, -1):
782 rev = self.s.revision_id(i)
783 self.failUnless(rev == revs[i],
784 "%s.revision_id(%d) returned %s not %s"
785 % (vars(self.Class)['name'], i, rev, revs[i]))
787 def test_get_previous_version(self):
789 Get should be able to return the previous version.
792 return '%s:%d' % (self.val, i+1)
793 self.s.add(self.id, directory=False)
796 self.s.set(self.id, val(i))
797 revs.append(self.s.commit('%s: %d' % (self.commit_msg, i),
800 ret = self.s.get(self.id, revision=revs[i])
801 self.failUnless(ret == val(i),
802 "%s.get() returned %s not %s for revision %s"
803 % (vars(self.Class)['name'], ret, val(i), revs[i]))
805 def make_storage_testcase_subclasses(storage_class, namespace):
806 """Make StorageTestCase subclasses for storage_class in namespace."""
807 storage_testcase_classes = [
809 ob for ob in globals().values() if isinstance(ob, type))
810 if issubclass(c, StorageTestCase) \
811 and not issubclass(c, VersionedStorageTestCase)]
813 for base_class in storage_testcase_classes:
814 testcase_class_name = storage_class.__name__ + base_class.__name__
815 testcase_class_bases = (base_class,)
816 testcase_class_dict = dict(base_class.__dict__)
817 testcase_class_dict['Class'] = storage_class
818 testcase_class = type(
819 testcase_class_name, testcase_class_bases, testcase_class_dict)
820 setattr(namespace, testcase_class_name, testcase_class)
822 def make_versioned_storage_testcase_subclasses(storage_class, namespace):
823 """Make VersionedStorageTestCase subclasses for storage_class in namespace."""
824 storage_testcase_classes = [
826 ob for ob in globals().values() if isinstance(ob, type))
827 if issubclass(c, StorageTestCase)]
829 for base_class in storage_testcase_classes:
830 testcase_class_name = storage_class.__name__ + base_class.__name__
831 testcase_class_bases = (base_class,)
832 testcase_class_dict = dict(base_class.__dict__)
833 testcase_class_dict['Class'] = storage_class
834 testcase_class = type(
835 testcase_class_name, testcase_class_bases, testcase_class_dict)
836 setattr(namespace, testcase_class_name, testcase_class)
838 make_storage_testcase_subclasses(VersionedStorage, sys.modules[__name__])
840 unitsuite =unittest.TestLoader().loadTestsFromModule(sys.modules[__name__])
841 suite = unittest.TestSuite([unitsuite, doctest.DocTestSuite()])