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 InvalidStorageVersion(ConnectionError):
30 def __init__(self, active_version, expected_version=None):
31 if expected_version == None:
32 expected_version = libbe.storage.STORAGE_VERSION
33 msg = 'Storage in "%s" not the expected "%s"' \
34 % (active_version, expected_version)
35 Exception.__init__(self, msg)
36 self.active_version = active_version
37 self.expected_version = expected_version
39 class InvalidID (KeyError):
40 def __init__(self, id=None, revision=None, msg=None):
41 if msg == None and id != None:
43 KeyError.__init__(self, msg)
45 self.revision = revision
47 class InvalidRevision (KeyError):
50 class InvalidDirectory (Exception):
53 class DirectoryNotEmpty (InvalidDirectory):
56 class NotWriteable (NotSupported):
57 def __init__(self, msg):
58 NotSupported.__init__(self, 'write', msg)
60 class NotReadable (NotSupported):
61 def __init__(self, msg):
62 NotSupported.__init__(self, 'read', msg)
64 class EmptyCommit(Exception):
66 Exception.__init__(self, 'No changes to commit')
70 def __init__(self, id, value=None, parent=None, directory=False,
75 Tree.__init__(self, children)
79 if self.parent != None:
80 if self.parent.directory == False:
81 raise InvalidDirectory(
82 'Non-directory %s cannot have children' % self.parent)
84 self.directory = directory
87 return '<Entry %s: %s>' % (self.id, self.value)
92 def __cmp__(self, other, local=False):
95 if cmp(self.id, other.id) != 0:
96 return cmp(self.id, other.id)
97 if cmp(self.value, other.value) != 0:
98 return cmp(self.value, other.value)
100 if self.parent == None:
101 if cmp(self.parent, other.parent) != 0:
102 return cmp(self.parent, other.parent)
103 elif self.parent.__cmp__(other.parent, local=True) != 0:
104 return self.parent.__cmp__(other.parent, local=True)
105 for sc,oc in zip(self, other):
106 if sc.__cmp__(oc, local=True) != 0:
107 return sc.__cmp__(oc, local=True)
110 def _objects_to_ids(self):
111 if self.parent != None:
112 self.parent = self.parent.id
113 for i,c in enumerate(self):
117 def _ids_to_objects(self, dict):
118 if self.parent != None:
119 self.parent = dict[self.parent]
120 for i,c in enumerate(self):
124 class Storage (object):
126 This class declares all the methods required by a Storage
127 interface. This implementation just keeps the data in a
128 dictionary and uses pickle for persistent storage.
132 def __init__(self, repo='/', encoding='utf-8', options=None):
134 self.encoding = encoding
135 self.options = options
136 self.readable = True # soft limit (user choice)
137 self._readable = True # hard limit (backend choice)
138 self.writeable = True # soft limit (user choice)
139 self._writeable = True # hard limit (backend choice)
140 self.versioned = False
144 return '<%s %s %s>' % (self.__class__.__name__, id(self), self.repo)
150 """Return a version string for this backend."""
153 def storage_version(self, revision=None):
154 """Return the storage format for this backend."""
155 return libbe.storage.STORAGE_VERSION
157 def is_readable(self):
158 return self.readable and self._readable
160 def is_writeable(self):
161 return self.writeable and self._writeable
164 """Create a new storage repository."""
165 if self.can_init == False:
166 raise NotSupported('init',
167 'Cannot initialize this repository format.')
168 if self.is_writeable() == False:
169 raise NotWriteable('Cannot initialize unwriteable storage.')
173 f = open(os.path.join(self.repo, 'repo.pkl'), 'wb')
174 root = Entry(id='__ROOT__', directory=True)
176 pickle.dump(dict((k,v._objects_to_ids()) for k,v in d.items()), f, -1)
180 """Remove the storage repository."""
181 if self.is_writeable() == False:
182 raise NotWriteable('Cannot destroy unwriteable storage.')
183 return self._destroy()
186 os.remove(os.path.join(self.repo, 'repo.pkl'))
189 """Open a connection to the repository."""
190 if self.is_readable() == False:
191 raise NotReadable('Cannot connect to unreadable storage.')
196 f = open(os.path.join(self.repo, 'repo.pkl'), 'rb')
198 raise ConnectionError(self)
200 self._data = dict((k,v._ids_to_objects(d)) for k,v in d.items())
203 def disconnect(self):
204 """Close the connection to the repository."""
205 if self.is_writeable() == False:
207 f = open(os.path.join(self.repo, 'repo.pkl'), 'wb')
208 pickle.dump(dict((k,v._objects_to_ids())
209 for k,v in self._data.items()), f, -1)
213 def add(self, id, *args, **kwargs):
215 if self.is_writeable() == False:
216 raise NotWriteable('Cannot add entry to unwriteable storage.')
217 try: # Maybe we've already added that id?
219 pass # yup, no need to add another
221 self._add(id, *args, **kwargs)
223 def _add(self, id, parent=None, directory=False):
226 p = self._data[parent]
227 self._data[id] = Entry(id, parent=p, directory=directory)
229 def remove(self, *args, **kwargs):
230 """Remove an entry."""
231 if self.is_writeable() == False:
232 raise NotSupported('write',
233 'Cannot remove entry from unwriteable storage.')
234 self._remove(*args, **kwargs)
236 def _remove(self, id):
237 if self._data[id].directory == True \
238 and len(self.children(id)) > 0:
239 raise DirectoryNotEmpty(id)
240 e = self._data.pop(id)
243 def recursive_remove(self, *args, **kwargs):
244 """Remove an entry and all its decendents."""
245 if self.is_writeable() == False:
246 raise NotSupported('write',
247 'Cannot remove entries from unwriteable storage.')
248 self._recursive_remove(*args, **kwargs)
250 def _recursive_remove(self, id):
251 for entry in reversed(list(self._data[id].traverse())):
252 self._remove(entry.id)
254 def children(self, *args, **kwargs):
255 """Return a list of specified entry's children's ids."""
256 if self.is_readable() == False:
257 raise NotReadable('Cannot list children with unreadable storage.')
258 return self._children(*args, **kwargs)
260 def _children(self, id=None, revision=None):
263 return [c.id for c in self._data[id] if not c.id.startswith('__')]
265 def get(self, *args, **kwargs):
267 Get contents of and entry as they were in a given revision.
268 revision==None specifies the current revision.
270 If there is no id, return default, unless default is not
271 given, in which case raise InvalidID.
273 if self.is_readable() == False:
274 raise NotReadable('Cannot get entry with unreadable storage.')
275 if 'decode' in kwargs:
276 decode = kwargs.pop('decode')
279 value = self._get(*args, **kwargs)
281 if decode == True and type(value) != types.UnicodeType:
282 return unicode(value, self.encoding)
283 elif decode == False and type(value) != types.StringType:
284 return value.encode(self.encoding)
287 def _get(self, id, default=InvalidObject, revision=None):
289 return self._data[id].value
290 elif default == InvalidObject:
294 def set(self, id, value, *args, **kwargs):
296 Set the entry contents.
298 if self.is_writeable() == False:
299 raise NotWriteable('Cannot set entry in unwriteable storage.')
300 if type(value) == types.UnicodeType:
301 value = value.encode(self.encoding)
302 self._set(id, value, *args, **kwargs)
304 def _set(self, id, value):
305 if id not in self._data:
307 if self._data[id].directory == True:
308 raise InvalidDirectory(
309 'Directory %s cannot have data' % self.parent)
310 self._data[id].value = value
312 class VersionedStorage (Storage):
314 This class declares all the methods required by a Storage
315 interface that supports versioning. This implementation just
316 keeps the data in a list and uses pickle for persistent
319 name = 'VersionedStorage'
321 def __init__(self, *args, **kwargs):
322 Storage.__init__(self, *args, **kwargs)
323 self.versioned = True
326 f = open(os.path.join(self.repo, 'repo.pkl'), 'wb')
327 root = Entry(id='__ROOT__', directory=True)
328 summary = Entry(id='__COMMIT__SUMMARY__', value='Initial commit')
329 body = Entry(id='__COMMIT__BODY__')
330 initial_commit = {root.id:root, summary.id:summary, body.id:body}
331 d = dict((k,v._objects_to_ids()) for k,v in initial_commit.items())
332 pickle.dump([d, copy.deepcopy(d)], f, -1) # [inital tree, working tree]
337 f = open(os.path.join(self.repo, 'repo.pkl'), 'rb')
339 raise ConnectionError(self)
341 self._data = [dict((k,v._ids_to_objects(t)) for k,v in t.items())
345 def disconnect(self):
346 """Close the connection to the repository."""
347 if self.is_writeable() == False:
349 f = open(os.path.join(self.repo, 'repo.pkl'), 'wb')
350 pickle.dump([dict((k,v._objects_to_ids())
351 for k,v in t.items()) for t in self._data], f, -1)
355 def _add(self, id, parent=None, directory=False):
358 p = self._data[-1][parent]
359 self._data[-1][id] = Entry(id, parent=p, directory=directory)
361 def _remove(self, id):
362 if self._data[-1][id].directory == True \
363 and len(self.children(id)) > 0:
364 raise DirectoryNotEmpty(id)
365 e = self._data[-1].pop(id)
368 def _recursive_remove(self, id):
369 for entry in reversed(list(self._data[-1][id].traverse())):
370 self._remove(entry.id)
372 def _children(self, id=None, revision=None):
377 return [c.id for c in self._data[revision][id]
378 if not c.id.startswith('__')]
380 def _get(self, id, default=InvalidObject, revision=None):
383 if id in self._data[revision]:
384 return self._data[revision][id].value
385 elif default == InvalidObject:
389 def _set(self, id, value):
390 if id not in self._data[-1]:
392 self._data[-1][id].value = value
394 def commit(self, *args, **kwargs):
396 Commit the current repository, with a commit message string
397 summary and body. Return the name of the new revision.
399 If allow_empty == False (the default), raise EmptyCommit if
400 there are no changes to commit.
402 if self.is_writeable() == False:
403 raise NotWriteable('Cannot commit to unwriteable storage.')
404 return self._commit(*args, **kwargs)
406 def _commit(self, summary, body=None, allow_empty=False):
407 if self._data[-1] == self._data[-2] and allow_empty == False:
409 self._data[-1]["__COMMIT__SUMMARY__"].value = summary
410 self._data[-1]["__COMMIT__BODY__"].value = body
411 rev = len(self._data)-1
412 self._data.append(copy.deepcopy(self._data[-1]))
415 def revision_id(self, index=None):
417 Return the name of the <index>th revision. The choice of
418 which branch to follow when crossing branches/merges is not
419 defined. Revision indices start at 1; ID 0 is the blank
422 Return None if index==None.
424 If the specified revision does not exist, raise InvalidRevision.
429 if int(index) != index:
430 raise InvalidRevision(index)
432 raise InvalidRevision(index)
433 L = len(self._data) - 1 # -1 b/c of initial commit
434 if index >= -L and index <= L:
436 raise InvalidRevision(i)
439 class StorageTestCase (unittest.TestCase):
440 """Test cases for base Storage class."""
444 def __init__(self, *args, **kwargs):
445 super(StorageTestCase, self).__init__(*args, **kwargs)
449 """Set up test fixtures for Storage test case."""
450 super(StorageTestCase, self).setUp()
452 self.dirname = self.dir.path
453 self.s = self.Class(repo=self.dirname)
454 self.assert_failed_connect()
459 super(StorageTestCase, self).tearDown()
462 self.assert_failed_connect()
465 def assert_failed_connect(self):
469 "Connected to %(name)s repository before initialising"
471 except ConnectionError:
474 class Storage_init_TestCase (StorageTestCase):
475 """Test cases for Storage.init method."""
477 def test_connect_should_succeed_after_init(self):
478 """Should connect after initialization."""
481 class Storage_add_remove_TestCase (StorageTestCase):
482 """Test cases for Storage.add, .remove, and .recursive_remove methods."""
484 def test_initially_empty(self):
485 """New repository should be empty."""
486 self.failUnless(len(self.s.children()) == 0, self.s.children())
488 def test_add_identical_rooted(self):
490 Adding entries with the same ID should not increase the number of children.
493 self.s.add('some id', directory=False)
494 s = sorted(self.s.children())
495 self.failUnless(s == ['some id'], s)
497 def test_add_rooted(self):
499 Adding entries should increase the number of children (rooted).
504 self.s.add(ids[-1], directory=(i % 2 == 0))
505 s = sorted(self.s.children())
506 self.failUnless(s == ids, '\n %s\n !=\n %s' % (s, ids))
508 def test_add_nonrooted(self):
510 Adding entries should increase the number of children (nonrooted).
512 self.s.add('parent', directory=True)
516 self.s.add(ids[-1], 'parent', directory=(i % 2 == 0))
517 s = sorted(self.s.children('parent'))
518 self.failUnless(s == ids, '\n %s\n !=\n %s' % (s, ids))
519 s = self.s.children()
520 self.failUnless(s == ['parent'], s)
522 def test_children(self):
524 Non-UUID ids should be returned as such.
526 self.s.add('parent', directory=True)
529 ids.append('parent/%s' % str(i))
530 self.s.add(ids[-1], 'parent', directory=(i % 2 == 0))
531 s = sorted(self.s.children('parent'))
532 self.failUnless(s == ids, '\n %s\n !=\n %s' % (s, ids))
534 def test_add_invalid_directory(self):
536 Should not be able to add children to non-directories.
538 self.s.add('parent', directory=False)
540 self.s.add('child', 'parent', directory=False)
542 '%s.add() succeeded instead of raising InvalidDirectory'
543 % (vars(self.Class)['name']))
544 except InvalidDirectory:
547 self.s.add('child', 'parent', directory=True)
549 '%s.add() succeeded instead of raising InvalidDirectory'
550 % (vars(self.Class)['name']))
551 except InvalidDirectory:
553 self.failUnless(len(self.s.children('parent')) == 0,
554 self.s.children('parent'))
556 def test_remove_rooted(self):
558 Removing entries should decrease the number of children (rooted).
563 self.s.add(ids[-1], directory=(i % 2 == 0))
565 self.s.remove(ids.pop())
566 s = sorted(self.s.children())
567 self.failUnless(s == ids, '\n %s\n !=\n %s' % (s, ids))
569 def test_remove_nonrooted(self):
571 Removing entries should decrease the number of children (nonrooted).
573 self.s.add('parent', directory=True)
577 self.s.add(ids[-1], 'parent', directory=False)#(i % 2 == 0))
579 self.s.remove(ids.pop())
580 s = sorted(self.s.children('parent'))
581 self.failUnless(s == ids, '\n %s\n !=\n %s' % (s, ids))
583 s = self.s.children()
584 self.failUnless(s == ['parent'], s)
586 def test_remove_directory_not_empty(self):
588 Removing a non-empty directory entry should raise exception.
590 self.s.add('parent', directory=True)
594 self.s.add(ids[-1], 'parent', directory=(i % 2 == 0))
595 self.s.remove(ids.pop()) # empty directory removal succeeds
597 self.s.remove('parent') # empty directory removal succeeds
599 "%s.remove() didn't raise DirectoryNotEmpty"
600 % (vars(self.Class)['name']))
601 except DirectoryNotEmpty:
604 def test_recursive_remove(self):
606 Recursive remove should empty the tree.
608 self.s.add('parent', directory=True)
612 self.s.add(ids[-1], 'parent', directory=True)
613 for j in range(10): # add some grandkids
614 self.s.add(str(20*(i+1)+j), ids[-1], directory=(i%2 == 0))
615 self.s.recursive_remove('parent')
616 s = sorted(self.s.children())
617 self.failUnless(s == [], s)
619 class Storage_get_set_TestCase (StorageTestCase):
620 """Test cases for Storage.get and .set methods."""
623 val = 'unlikely value'
625 def test_get_default(self):
627 Get should return specified default if id not in Storage.
629 ret = self.s.get(self.id, default=self.val)
630 self.failUnless(ret == self.val,
631 "%s.get() returned %s not %s"
632 % (vars(self.Class)['name'], ret, self.val))
634 def test_get_default_exception(self):
636 Get should raise exception if id not in Storage and no default.
639 ret = self.s.get(self.id)
641 "%s.get() returned %s instead of raising InvalidID"
642 % (vars(self.Class)['name'], ret))
646 def test_get_initial_value(self):
648 Data value should be None before any value has been set.
650 self.s.add(self.id, directory=False)
651 ret = self.s.get(self.id)
652 self.failUnless(ret == None,
653 "%s.get() returned %s not None"
654 % (vars(self.Class)['name'], ret))
656 def test_set_exception(self):
658 Set should raise exception if id not in Storage.
661 self.s.set(self.id, self.val)
663 "%(name)s.set() did not raise InvalidID"
670 Set should define the value returned by get.
672 self.s.add(self.id, directory=False)
673 self.s.set(self.id, self.val)
674 ret = self.s.get(self.id)
675 self.failUnless(ret == self.val,
676 "%s.get() returned %s not %s"
677 % (vars(self.Class)['name'], ret, self.val))
679 def test_unicode_set(self):
681 Set should define the value returned by get.
684 self.s.add(self.id, directory=False)
685 self.s.set(self.id, val)
686 ret = self.s.get(self.id, decode=True)
687 self.failUnless(type(ret) == types.UnicodeType,
688 "%s.get() returned %s not UnicodeType"
689 % (vars(self.Class)['name'], type(ret)))
690 self.failUnless(ret == val,
691 "%s.get() returned %s not %s"
692 % (vars(self.Class)['name'], ret, self.val))
693 ret = self.s.get(self.id)
694 self.failUnless(type(ret) == types.StringType,
695 "%s.get() returned %s not StringType"
696 % (vars(self.Class)['name'], type(ret)))
697 s = unicode(ret, self.s.encoding)
698 self.failUnless(s == val,
699 "%s.get() returned %s not %s"
700 % (vars(self.Class)['name'], s, self.val))
703 class Storage_persistence_TestCase (StorageTestCase):
704 """Test cases for Storage.disconnect and .connect methods."""
707 val = 'unlikely value'
709 def test_get_set_persistence(self):
711 Set should define the value returned by get after reconnect.
713 self.s.add(self.id, directory=False)
714 self.s.set(self.id, self.val)
717 ret = self.s.get(self.id)
718 self.failUnless(ret == self.val,
719 "%s.get() returned %s not %s"
720 % (vars(self.Class)['name'], ret, self.val))
722 def test_add_nonrooted_persistence(self):
724 Adding entries should increase the number of children after reconnect.
726 self.s.add('parent', directory=True)
730 self.s.add(ids[-1], 'parent', directory=(i % 2 == 0))
733 s = sorted(self.s.children('parent'))
734 self.failUnless(s == ids, '\n %s\n !=\n %s' % (s, ids))
735 s = self.s.children()
736 self.failUnless(s == ['parent'], s)
738 class VersionedStorageTestCase (StorageTestCase):
739 """Test cases for base VersionedStorage class."""
741 Class = VersionedStorage
743 class VersionedStorage_commit_TestCase (VersionedStorageTestCase):
744 """Test cases for VersionedStorage methods."""
748 commit_msg = 'Committing something interesting'
749 commit_body = 'Some\nlonger\ndescription\n'
751 def _setup_for_empty_commit(self):
753 Initialization might add some files to version control, so
754 commit those first, before testing the empty commit
758 self.s.commit('Added initialization files')
762 def test_revision_id_exception(self):
764 Invalid revision id should raise InvalidRevision.
767 rev = self.s.revision_id('highly unlikely revision id')
769 "%s.revision_id() didn't raise InvalidRevision, returned %s."
770 % (vars(self.Class)['name'], rev))
771 except InvalidRevision:
774 def test_empty_commit_raises_exception(self):
776 Empty commit should raise exception.
778 self._setup_for_empty_commit()
780 self.s.commit(self.commit_msg, self.commit_body)
782 "Empty %(name)s.commit() didn't raise EmptyCommit."
787 def test_empty_commit_allowed(self):
789 Empty commit should _not_ raise exception if allow_empty=True.
791 self._setup_for_empty_commit()
792 self.s.commit(self.commit_msg, self.commit_body,
795 def test_commit_revision_ids(self):
797 Commit / revision_id should agree on revision ids.
800 return '%s:%d' % (self.val, i+1)
801 self.s.add(self.id, directory=False)
804 self.s.set(self.id, val(i))
805 revs.append(self.s.commit('%s: %d' % (self.commit_msg, i),
808 rev = self.s.revision_id(i+1)
809 self.failUnless(rev == revs[i],
810 "%s.revision_id(%d) returned %s not %s"
811 % (vars(self.Class)['name'], i+1, rev, revs[i]))
812 for i in range(-1, -9, -1):
813 rev = self.s.revision_id(i)
814 self.failUnless(rev == revs[i],
815 "%s.revision_id(%d) returned %s not %s"
816 % (vars(self.Class)['name'], i, rev, revs[i]))
818 def test_get_previous_version(self):
820 Get should be able to return the previous version.
823 return '%s:%d' % (self.val, i+1)
824 self.s.add(self.id, directory=False)
827 self.s.set(self.id, val(i))
828 revs.append(self.s.commit('%s: %d' % (self.commit_msg, i),
831 ret = self.s.get(self.id, revision=revs[i])
832 self.failUnless(ret == val(i),
833 "%s.get() returned %s not %s for revision %s"
834 % (vars(self.Class)['name'], ret, val(i), revs[i]))
836 def test_get_previous_children(self):
838 Children list should be revision dependent.
840 self.s.add('parent', directory=True)
846 self.s.add(new_child, 'parent', directory=(i % 2 == 0))
847 self.s.set(new_child, self.val)
848 revs.append(self.s.commit('%s: %d' % (self.commit_msg, i),
850 cur_children.append(new_child)
851 children.append(list(cur_children))
853 ret = self.s.children('parent', revision=revs[i])
854 self.failUnless(ret == children[i],
855 "%s.get() returned %s not %s for revision %s"
856 % (vars(self.Class)['name'], ret,
857 children[i], revs[i]))
859 def make_storage_testcase_subclasses(storage_class, namespace):
860 """Make StorageTestCase subclasses for storage_class in namespace."""
861 storage_testcase_classes = [
863 ob for ob in globals().values() if isinstance(ob, type))
864 if issubclass(c, StorageTestCase) \
865 and c.Class == Storage]
867 for base_class in storage_testcase_classes:
868 testcase_class_name = storage_class.__name__ + base_class.__name__
869 testcase_class_bases = (base_class,)
870 testcase_class_dict = dict(base_class.__dict__)
871 testcase_class_dict['Class'] = storage_class
872 testcase_class = type(
873 testcase_class_name, testcase_class_bases, testcase_class_dict)
874 setattr(namespace, testcase_class_name, testcase_class)
876 def make_versioned_storage_testcase_subclasses(storage_class, namespace):
877 """Make VersionedStorageTestCase subclasses for storage_class in namespace."""
878 storage_testcase_classes = [
880 ob for ob in globals().values() if isinstance(ob, type))
881 if issubclass(c, StorageTestCase) \
882 and c.Class == Storage]
884 for base_class in storage_testcase_classes:
885 testcase_class_name = storage_class.__name__ + base_class.__name__
886 testcase_class_bases = (base_class,)
887 testcase_class_dict = dict(base_class.__dict__)
888 testcase_class_dict['Class'] = storage_class
889 testcase_class = type(
890 testcase_class_name, testcase_class_bases, testcase_class_dict)
891 setattr(namespace, testcase_class_name, testcase_class)
893 make_storage_testcase_subclasses(VersionedStorage, sys.modules[__name__])
895 unitsuite =unittest.TestLoader().loadTestsFromModule(sys.modules[__name__])
896 suite = unittest.TestSuite([unitsuite, doctest.DocTestSuite()])