4 Abstract bug repository data storage to easily support multiple backends.
12 from libbe.error import NotSupported
13 from libbe.util.tree import Tree
14 from libbe.util import InvalidObject
15 from libbe import TESTING
23 from libbe.util.utility import Dir
25 class ConnectionError (Exception):
28 class InvalidID (KeyError):
31 class InvalidRevision (KeyError):
34 class InvalidDirectory (Exception):
37 class DirectoryNotEmpty (InvalidDirectory):
40 class NotWriteable (NotSupported):
41 def __init__(self, msg):
42 NotSupported.__init__(self, 'write', msg)
44 class NotReadable (NotSupported):
45 def __init__(self, msg):
46 NotSupported.__init__(self, 'read', msg)
48 class EmptyCommit(Exception):
50 Exception.__init__(self, 'No changes to commit')
53 def __init__(self, id, value=None, parent=None, directory=False,
58 Tree.__init__(self, children)
62 if self.parent != None:
63 if self.parent.directory == False:
64 raise InvalidDirectory(
65 'Non-directory %s cannot have children' % self.parent)
67 self.directory = directory
70 return '<Entry %s: %s>' % (self.id, self.value)
75 def __cmp__(self, other, local=False):
78 if cmp(self.id, other.id) != 0:
79 return cmp(self.id, other.id)
80 if cmp(self.value, other.value) != 0:
81 return cmp(self.value, other.value)
83 if self.parent == None:
84 if cmp(self.parent, other.parent) != 0:
85 return cmp(self.parent, other.parent)
86 elif self.parent.__cmp__(other.parent, local=True) != 0:
87 return self.parent.__cmp__(other.parent, local=True)
88 for sc,oc in zip(self, other):
89 if sc.__cmp__(oc, local=True) != 0:
90 return sc.__cmp__(oc, local=True)
93 def _objects_to_ids(self):
94 if self.parent != None:
95 self.parent = self.parent.id
96 for i,c in enumerate(self):
100 def _ids_to_objects(self, dict):
101 if self.parent != None:
102 self.parent = dict[self.parent]
103 for i,c in enumerate(self):
107 class Storage (object):
109 This class declares all the methods required by a Storage
110 interface. This implementation just keeps the data in a
111 dictionary and uses pickle for persistent storage.
115 def __init__(self, repo='/', encoding='utf-8', options=None):
117 self.encoding = encoding
118 self.options = options
119 self.readable = True # soft limit (user choice)
120 self._readable = True # hard limit (backend choice)
121 self.writeable = True # soft limit (user choice)
122 self._writeable = True # hard limit (backend choice)
123 self.versioned = False
127 return '<%s %s %s>' % (self.__class__.__name__, id(self), self.repo)
133 """Return a version string for this backend."""
136 def is_readable(self):
137 return self.readable and self._readable
139 def is_writeable(self):
140 return self.writeable and self._writeable
143 """Create a new storage repository."""
144 if self.can_init == False:
145 raise NotSupported('init',
146 'Cannot initialize this repository format.')
147 if self.is_writeable() == False:
148 raise NotWriteable('Cannot initialize unwriteable storage.')
152 f = open(self.repo, 'wb')
153 root = Entry(id='__ROOT__', directory=True)
155 pickle.dump(dict((k,v._objects_to_ids()) for k,v in d.items()), f, -1)
159 """Remove the storage repository."""
160 if self.is_writeable() == False:
161 raise NotWriteable('Cannot destroy unwriteable storage.')
162 return self._destroy()
168 """Open a connection to the repository."""
169 if self.is_readable() == False:
170 raise NotReadable('Cannot connect to unreadable storage.')
175 f = open(self.repo, 'rb')
177 raise ConnectionError(self)
179 self._data = dict((k,v._ids_to_objects(d)) for k,v in d.items())
182 def disconnect(self):
183 """Close the connection to the repository."""
184 if self.is_writeable() == False:
186 f = open(self.repo, 'wb')
187 pickle.dump(dict((k,v._objects_to_ids())
188 for k,v in self._data.items()), f, -1)
192 def add(self, id, *args, **kwargs):
194 if self.is_writeable() == False:
195 raise NotWriteable('Cannot add entry to unwriteable storage.')
196 try: # Maybe we've already added that id?
198 pass # yup, no need to add another
200 self._add(id, *args, **kwargs)
202 def _add(self, id, parent=None, directory=False):
205 p = self._data[parent]
206 self._data[id] = Entry(id, parent=p, directory=directory)
208 def remove(self, *args, **kwargs):
209 """Remove an entry."""
210 if self.is_writeable() == False:
211 raise NotSupported('write',
212 'Cannot remove entry from unwriteable storage.')
213 self._remove(*args, **kwargs)
215 def _remove(self, id):
216 if self._data[id].directory == True \
217 and len(self.children(id)) > 0:
218 raise DirectoryNotEmpty(id)
219 e = self._data.pop(id)
222 def recursive_remove(self, *args, **kwargs):
223 """Remove an entry and all its decendents."""
224 if self.is_writeable() == False:
225 raise NotSupported('write',
226 'Cannot remove entries from unwriteable storage.')
227 self._recursive_remove(*args, **kwargs)
229 def _recursive_remove(self, id):
230 for entry in reversed(list(self._data[id].traverse())):
231 self._remove(entry.id)
233 def children(self, *args, **kwargs):
234 """Return a list of specified entry's children's ids."""
235 if self.is_readable() == False:
236 raise NotReadable('Cannot list children with unreadable storage.')
237 return self._children(*args, **kwargs)
239 def _children(self, id=None, revision=None):
242 return [c.id for c in self._data[id] if not c.id.startswith('__')]
244 def get(self, *args, **kwargs):
246 Get contents of and entry as they were in a given revision.
247 revision==None specifies the current revision.
249 If there is no id, return default, unless default is not
250 given, in which case raise InvalidID.
252 if self.is_readable() == False:
253 raise NotReadable('Cannot get entry with unreadable storage.')
254 if 'decode' in kwargs:
255 decode = kwargs.pop('decode')
258 value = self._get(*args, **kwargs)
260 return unicode(value, self.encoding)
263 def _get(self, id, default=InvalidObject, revision=None):
265 return self._data[id].value
266 elif default == InvalidObject:
270 def set(self, id, value, *args, **kwargs):
272 Set the entry contents.
274 if self.is_writeable() == False:
275 raise NotWriteable('Cannot set entry in unwriteable storage.')
276 if type(value) == types.UnicodeType:
277 value = value.encode(self.encoding)
278 self._set(id, value, *args, **kwargs)
280 def _set(self, id, value):
281 if id not in self._data:
283 if self._data[id].directory == True:
284 raise InvalidDirectory(
285 'Directory %s cannot have data' % self.parent)
286 self._data[id].value = value
288 class VersionedStorage (Storage):
290 This class declares all the methods required by a Storage
291 interface that supports versioning. This implementation just
292 keeps the data in a list and uses pickle for persistent
295 name = 'VersionedStorage'
297 def __init__(self, *args, **kwargs):
298 Storage.__init__(self, *args, **kwargs)
299 self.versioned = True
302 f = open(self.repo, 'wb')
303 root = Entry(id='__ROOT__', directory=True)
304 summary = Entry(id='__COMMIT__SUMMARY__', value='Initial commit')
305 body = Entry(id='__COMMIT__BODY__')
306 initial_commit = {root.id:root, summary.id:summary, body.id:body}
307 d = dict((k,v._objects_to_ids()) for k,v in initial_commit.items())
308 pickle.dump([d, copy.deepcopy(d)], f, -1) # [inital tree, working tree]
313 f = open(self.repo, 'rb')
315 raise ConnectionError(self)
317 self._data = [dict((k,v._ids_to_objects(t)) for k,v in t.items())
321 def disconnect(self):
322 """Close the connection to the repository."""
323 if self.is_writeable() == False:
325 f = open(self.repo, 'wb')
326 pickle.dump([dict((k,v._objects_to_ids())
327 for k,v in t.items()) for t in self._data], f, -1)
331 def _add(self, id, parent=None, directory=False):
334 p = self._data[-1][parent]
335 self._data[-1][id] = Entry(id, parent=p, directory=directory)
337 def _remove(self, id):
338 if self._data[-1][id].directory == True \
339 and len(self.children(id)) > 0:
340 raise DirectoryNotEmpty(id)
341 e = self._data[-1].pop(id)
344 def _recursive_remove(self, id):
345 for entry in reversed(list(self._data[-1][id].traverse())):
346 self._remove(entry.id)
348 def _children(self, id=None, revision=None):
353 return [c.id for c in self._data[revision][id]
354 if not c.id.startswith('__')]
356 def _get(self, id, default=InvalidObject, revision=None):
359 if id in self._data[revision]:
360 return self._data[revision][id].value
361 elif default == InvalidObject:
365 def _set(self, id, value):
366 if id not in self._data[-1]:
368 self._data[-1][id].value = value
370 def commit(self, *args, **kwargs):
372 Commit the current repository, with a commit message string
373 summary and body. Return the name of the new revision.
375 If allow_empty == False (the default), raise EmptyCommit if
376 there are no changes to commit.
378 if self.is_writeable() == False:
379 raise NotWriteable('Cannot commit to unwriteable storage.')
380 return self._commit(*args, **kwargs)
382 def _commit(self, summary, body=None, allow_empty=False):
383 if self._data[-1] == self._data[-2] and allow_empty == False:
385 self._data[-1]["__COMMIT__SUMMARY__"].value = summary
386 self._data[-1]["__COMMIT__BODY__"].value = body
387 rev = len(self._data)-1
388 self._data.append(copy.deepcopy(self._data[-1]))
391 def revision_id(self, index=None):
393 Return the name of the <index>th revision. The choice of
394 which branch to follow when crossing branches/merges is not
395 defined. Revision indices start at 1; ID 0 is the blank
398 Return None if index==None.
400 If the specified revision does not exist, raise InvalidRevision.
405 if int(index) != index:
406 raise InvalidRevision(index)
408 raise InvalidRevision(index)
409 L = len(self._data) - 1 # -1 b/c of initial commit
410 if index >= -L and index <= L:
412 raise InvalidRevision(i)
415 class StorageTestCase (unittest.TestCase):
416 """Test cases for base Storage class."""
420 def __init__(self, *args, **kwargs):
421 super(StorageTestCase, self).__init__(*args, **kwargs)
425 """Set up test fixtures for Storage test case."""
426 super(StorageTestCase, self).setUp()
428 self.dirname = self.dir.path
429 self.s = self.Class(repo=os.path.join(self.dirname, 'repo.pkl'))
430 self.assert_failed_connect()
435 super(StorageTestCase, self).tearDown()
438 self.assert_failed_connect()
441 def assert_failed_connect(self):
445 "Connected to %(name)s repository before initialising"
447 except ConnectionError:
450 class Storage_init_TestCase (StorageTestCase):
451 """Test cases for Storage.init method."""
453 def test_connect_should_succeed_after_init(self):
454 """Should connect after initialization."""
457 class Storage_add_remove_TestCase (StorageTestCase):
458 """Test cases for Storage.add, .remove, and .recursive_remove methods."""
460 def test_initially_empty(self):
461 """New repository should be empty."""
462 self.failUnless(len(self.s.children()) == 0, self.s.children())
464 def test_add_identical_rooted(self):
466 Adding entries with the same ID should not increase the number of children.
469 self.s.add('some id', directory=False)
470 s = sorted(self.s.children())
471 self.failUnless(s == ['some id'], s)
473 def test_add_rooted(self):
475 Adding entries should increase the number of children (rooted).
480 self.s.add(ids[-1], directory=False)
481 s = sorted(self.s.children())
482 self.failUnless(s == ids, '\n %s\n !=\n %s' % (s, ids))
484 def test_add_nonrooted(self):
486 Adding entries should increase the number of children (nonrooted).
488 self.s.add('parent', directory=True)
492 self.s.add(ids[-1], 'parent', directory=True)
493 s = sorted(self.s.children('parent'))
494 self.failUnless(s == ids, '\n %s\n !=\n %s' % (s, ids))
495 s = self.s.children()
496 self.failUnless(s == ['parent'], s)
498 def test_children(self):
500 Non-UUID ids should be returned as such.
502 self.s.add('parent', directory=True)
505 ids.append('parent/%s' % str(i))
506 self.s.add(ids[-1], 'parent', directory=True)
507 s = sorted(self.s.children('parent'))
508 self.failUnless(s == ids, '\n %s\n !=\n %s' % (s, ids))
510 def test_add_invalid_directory(self):
512 Should not be able to add children to non-directories.
514 self.s.add('parent', directory=False)
516 self.s.add('child', 'parent', directory=False)
518 '%s.add() succeeded instead of raising InvalidDirectory'
519 % (vars(self.Class)['name']))
520 except InvalidDirectory:
523 self.s.add('child', 'parent', directory=True)
525 '%s.add() succeeded instead of raising InvalidDirectory'
526 % (vars(self.Class)['name']))
527 except InvalidDirectory:
529 self.failUnless(len(self.s.children('parent')) == 0,
530 self.s.children('parent'))
532 def test_remove_rooted(self):
534 Removing entries should decrease the number of children (rooted).
539 self.s.add(ids[-1], directory=True)
541 self.s.remove(ids.pop())
542 s = sorted(self.s.children())
543 self.failUnless(s == ids, '\n %s\n !=\n %s' % (s, ids))
545 def test_remove_nonrooted(self):
547 Removing entries should decrease the number of children (nonrooted).
549 self.s.add('parent', directory=True)
553 self.s.add(ids[-1], 'parent', directory=False)
555 self.s.remove(ids.pop())
556 s = sorted(self.s.children('parent'))
557 self.failUnless(s == ids, '\n %s\n !=\n %s' % (s, ids))
558 s = self.s.children()
559 self.failUnless(s == ['parent'], s)
561 def test_remove_directory_not_empty(self):
563 Removing a non-empty directory entry should raise exception.
565 self.s.add('parent', directory=True)
569 self.s.add(ids[-1], 'parent', directory=True)
570 self.s.remove(ids.pop()) # empty directory removal succeeds
572 self.s.remove('parent') # empty directory removal succeeds
574 "%s.remove() didn't raise DirectoryNotEmpty"
575 % (vars(self.Class)['name']))
576 except DirectoryNotEmpty:
579 def test_recursive_remove(self):
581 Recursive remove should empty the tree.
583 self.s.add('parent', directory=True)
587 self.s.add(ids[-1], 'parent', directory=True)
588 for j in range(10): # add some grandkids
589 self.s.add(str(20*(i+1)+j), ids[-1], directory=False)
590 self.s.recursive_remove('parent')
591 s = sorted(self.s.children())
592 self.failUnless(s == [], s)
594 class Storage_get_set_TestCase (StorageTestCase):
595 """Test cases for Storage.get and .set methods."""
598 val = 'unlikely value'
600 def test_get_default(self):
602 Get should return specified default if id not in Storage.
604 ret = self.s.get(self.id, default=self.val)
605 self.failUnless(ret == self.val,
606 "%s.get() returned %s not %s"
607 % (vars(self.Class)['name'], ret, self.val))
609 def test_get_default_exception(self):
611 Get should raise exception if id not in Storage and no default.
614 ret = self.s.get(self.id)
616 "%s.get() returned %s instead of raising InvalidID"
617 % (vars(self.Class)['name'], ret))
621 def test_get_initial_value(self):
623 Data value should be None before any value has been set.
625 self.s.add(self.id, directory=False)
626 ret = self.s.get(self.id)
627 self.failUnless(ret == None,
628 "%s.get() returned %s not None"
629 % (vars(self.Class)['name'], ret))
631 def test_set_exception(self):
633 Set should raise exception if id not in Storage.
636 self.s.set(self.id, self.val)
638 "%(name)s.set() did not raise InvalidID"
645 Set should define the value returned by get.
647 self.s.add(self.id, directory=False)
648 self.s.set(self.id, self.val)
649 ret = self.s.get(self.id)
650 self.failUnless(ret == self.val,
651 "%s.get() returned %s not %s"
652 % (vars(self.Class)['name'], ret, self.val))
654 def test_unicode_set(self):
656 Set should define the value returned by get.
659 self.s.add(self.id, directory=False)
660 self.s.set(self.id, val)
661 ret = self.s.get(self.id, decode=True)
662 self.failUnless(type(ret) == types.UnicodeType,
663 "%s.get() returned %s not UnicodeType"
664 % (vars(self.Class)['name'], type(ret)))
665 self.failUnless(ret == val,
666 "%s.get() returned %s not %s"
667 % (vars(self.Class)['name'], ret, self.val))
668 ret = self.s.get(self.id)
669 self.failUnless(type(ret) == types.StringType,
670 "%s.get() returned %s not StringType"
671 % (vars(self.Class)['name'], type(ret)))
672 s = unicode(ret, self.s.encoding)
673 self.failUnless(s == val,
674 "%s.get() returned %s not %s"
675 % (vars(self.Class)['name'], s, self.val))
678 class Storage_persistence_TestCase (StorageTestCase):
679 """Test cases for Storage.disconnect and .connect methods."""
682 val = 'unlikely value'
684 def test_get_set_persistence(self):
686 Set should define the value returned by get after reconnect.
688 self.s.add(self.id, directory=False)
689 self.s.set(self.id, self.val)
692 ret = self.s.get(self.id)
693 self.failUnless(ret == self.val,
694 "%s.get() returned %s not %s"
695 % (vars(self.Class)['name'], ret, self.val))
697 def test_add_nonrooted_persistence(self):
699 Adding entries should increase the number of children after reconnect.
701 self.s.add('parent', directory=True)
705 self.s.add(ids[-1], 'parent', directory=False)
708 s = sorted(self.s.children('parent'))
709 self.failUnless(s == ids, '\n %s\n !=\n %s' % (s, ids))
710 s = self.s.children()
711 self.failUnless(s == ['parent'], s)
713 class VersionedStorageTestCase (StorageTestCase):
714 """Test cases for base VersionedStorage class."""
716 Class = VersionedStorage
718 class VersionedStorage_commit_TestCase (VersionedStorageTestCase):
719 """Test cases for VersionedStorage methods."""
721 id = 'I' #unlikely id'
723 commit_msg = 'C' #ommitting something interesting'
724 commit_body = 'B' #ome\nlonger\ndescription\n'
726 def test_revision_id_exception(self):
728 Invalid revision id should raise InvalidRevision.
731 rev = self.s.revision_id('highly unlikely revision id')
733 "%s.revision_id() didn't raise InvalidRevision, returned %s."
734 % (vars(self.Class)['name'], rev))
735 except InvalidRevision:
738 def test_empty_commit_raises_exception(self):
740 Empty commit should raise exception.
743 self.s.commit(self.commit_msg, self.commit_body)
745 "Empty %(name)s.commit() didn't raise EmptyCommit."
750 def test_empty_commit_allowed(self):
752 Empty commit should _not_ raise exception if allow_empty=True.
754 self.s.commit(self.commit_msg, self.commit_body,
757 def test_commit_revision_ids(self):
759 Commit / revision_id should agree on revision ids.
763 revs.append(self.s.commit(self.commit_msg,
767 rev = self.s.revision_id(i+1)
768 self.failUnless(rev == revs[i],
769 "%s.revision_id(%d) returned %s not %s"
770 % (vars(self.Class)['name'], i+1, rev, revs[i]))
771 for i in range(-1, -9, -1):
772 rev = self.s.revision_id(i)
773 self.failUnless(rev == revs[i],
774 "%s.revision_id(%d) returned %s not %s"
775 % (vars(self.Class)['name'], i, rev, revs[i]))
777 def test_get_previous_version(self):
779 Get should be able to return the previous version.
782 return '%s:%d' % (self.val, i+1)
783 self.s.add(self.id, directory=False)
786 self.s.set(self.id, val(i))
787 revs.append(self.s.commit('%s: %d' % (self.commit_msg, i),
790 ret = self.s.get(self.id, revision=revs[i])
791 self.failUnless(ret == val(i),
792 "%s.get() returned %s not %s for revision %s"
793 % (vars(self.Class)['name'], ret, val(i), revs[i]))
795 def make_storage_testcase_subclasses(storage_class, namespace):
796 """Make StorageTestCase subclasses for storage_class in namespace."""
797 storage_testcase_classes = [
799 ob for ob in globals().values() if isinstance(ob, type))
800 if issubclass(c, StorageTestCase) \
801 and not issubclass(c, VersionedStorageTestCase)]
803 for base_class in storage_testcase_classes:
804 testcase_class_name = storage_class.__name__ + base_class.__name__
805 testcase_class_bases = (base_class,)
806 testcase_class_dict = dict(base_class.__dict__)
807 testcase_class_dict['Class'] = storage_class
808 testcase_class = type(
809 testcase_class_name, testcase_class_bases, testcase_class_dict)
810 setattr(namespace, testcase_class_name, testcase_class)
812 def make_versioned_storage_testcase_subclasses(storage_class, namespace):
813 """Make VersionedStorageTestCase subclasses for storage_class in namespace."""
814 storage_testcase_classes = [
816 ob for ob in globals().values() if isinstance(ob, type))
817 if issubclass(c, StorageTestCase)]
819 for base_class in storage_testcase_classes:
820 testcase_class_name = storage_class.__name__ + base_class.__name__
821 testcase_class_bases = (base_class,)
822 testcase_class_dict = dict(base_class.__dict__)
823 testcase_class_dict['Class'] = storage_class
824 testcase_class = type(
825 testcase_class_name, testcase_class_bases, testcase_class_dict)
826 setattr(namespace, testcase_class_name, testcase_class)
828 make_storage_testcase_subclasses(VersionedStorage, sys.modules[__name__])
830 unitsuite =unittest.TestLoader().loadTestsFromModule(sys.modules[__name__])
831 suite = unittest.TestSuite([unitsuite, doctest.DocTestSuite()])