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):
42 class InvalidRevision (KeyError):
45 class InvalidDirectory (Exception):
48 class DirectoryNotEmpty (InvalidDirectory):
51 class NotWriteable (NotSupported):
52 def __init__(self, msg):
53 NotSupported.__init__(self, 'write', msg)
55 class NotReadable (NotSupported):
56 def __init__(self, msg):
57 NotSupported.__init__(self, 'read', msg)
59 class EmptyCommit(Exception):
61 Exception.__init__(self, 'No changes to commit')
65 def __init__(self, id, value=None, parent=None, directory=False,
70 Tree.__init__(self, children)
74 if self.parent != None:
75 if self.parent.directory == False:
76 raise InvalidDirectory(
77 'Non-directory %s cannot have children' % self.parent)
79 self.directory = directory
82 return '<Entry %s: %s>' % (self.id, self.value)
87 def __cmp__(self, other, local=False):
90 if cmp(self.id, other.id) != 0:
91 return cmp(self.id, other.id)
92 if cmp(self.value, other.value) != 0:
93 return cmp(self.value, other.value)
95 if self.parent == None:
96 if cmp(self.parent, other.parent) != 0:
97 return cmp(self.parent, other.parent)
98 elif self.parent.__cmp__(other.parent, local=True) != 0:
99 return self.parent.__cmp__(other.parent, local=True)
100 for sc,oc in zip(self, other):
101 if sc.__cmp__(oc, local=True) != 0:
102 return sc.__cmp__(oc, local=True)
105 def _objects_to_ids(self):
106 if self.parent != None:
107 self.parent = self.parent.id
108 for i,c in enumerate(self):
112 def _ids_to_objects(self, dict):
113 if self.parent != None:
114 self.parent = dict[self.parent]
115 for i,c in enumerate(self):
119 class Storage (object):
121 This class declares all the methods required by a Storage
122 interface. This implementation just keeps the data in a
123 dictionary and uses pickle for persistent storage.
127 def __init__(self, repo='/', encoding='utf-8', options=None):
129 self.encoding = encoding
130 self.options = options
131 self.readable = True # soft limit (user choice)
132 self._readable = True # hard limit (backend choice)
133 self.writeable = True # soft limit (user choice)
134 self._writeable = True # hard limit (backend choice)
135 self.versioned = False
139 return '<%s %s %s>' % (self.__class__.__name__, id(self), self.repo)
145 """Return a version string for this backend."""
148 def storage_version(self, revision=None):
149 """Return the storage format for this backend."""
150 return libbe.storage.STORAGE_VERSION
152 def is_readable(self):
153 return self.readable and self._readable
155 def is_writeable(self):
156 return self.writeable and self._writeable
159 """Create a new storage repository."""
160 if self.can_init == False:
161 raise NotSupported('init',
162 'Cannot initialize this repository format.')
163 if self.is_writeable() == False:
164 raise NotWriteable('Cannot initialize unwriteable storage.')
168 f = open(os.path.join(self.repo, 'repo.pkl'), 'wb')
169 root = Entry(id='__ROOT__', directory=True)
171 pickle.dump(dict((k,v._objects_to_ids()) for k,v in d.items()), f, -1)
175 """Remove the storage repository."""
176 if self.is_writeable() == False:
177 raise NotWriteable('Cannot destroy unwriteable storage.')
178 return self._destroy()
181 os.remove(os.path.join(self.repo, 'repo.pkl'))
184 """Open a connection to the repository."""
185 if self.is_readable() == False:
186 raise NotReadable('Cannot connect to unreadable storage.')
191 f = open(os.path.join(self.repo, 'repo.pkl'), 'rb')
193 raise ConnectionError(self)
195 self._data = dict((k,v._ids_to_objects(d)) for k,v in d.items())
198 def disconnect(self):
199 """Close the connection to the repository."""
200 if self.is_writeable() == False:
202 f = open(os.path.join(self.repo, 'repo.pkl'), 'wb')
203 pickle.dump(dict((k,v._objects_to_ids())
204 for k,v in self._data.items()), f, -1)
208 def add(self, id, *args, **kwargs):
210 if self.is_writeable() == False:
211 raise NotWriteable('Cannot add entry to unwriteable storage.')
212 try: # Maybe we've already added that id?
214 pass # yup, no need to add another
216 self._add(id, *args, **kwargs)
218 def _add(self, id, parent=None, directory=False):
221 p = self._data[parent]
222 self._data[id] = Entry(id, parent=p, directory=directory)
224 def remove(self, *args, **kwargs):
225 """Remove an entry."""
226 if self.is_writeable() == False:
227 raise NotSupported('write',
228 'Cannot remove entry from unwriteable storage.')
229 self._remove(*args, **kwargs)
231 def _remove(self, id):
232 if self._data[id].directory == True \
233 and len(self.children(id)) > 0:
234 raise DirectoryNotEmpty(id)
235 e = self._data.pop(id)
238 def recursive_remove(self, *args, **kwargs):
239 """Remove an entry and all its decendents."""
240 if self.is_writeable() == False:
241 raise NotSupported('write',
242 'Cannot remove entries from unwriteable storage.')
243 self._recursive_remove(*args, **kwargs)
245 def _recursive_remove(self, id):
246 for entry in reversed(list(self._data[id].traverse())):
247 self._remove(entry.id)
249 def children(self, *args, **kwargs):
250 """Return a list of specified entry's children's ids."""
251 if self.is_readable() == False:
252 raise NotReadable('Cannot list children with unreadable storage.')
253 return self._children(*args, **kwargs)
255 def _children(self, id=None, revision=None):
258 return [c.id for c in self._data[id] if not c.id.startswith('__')]
260 def get(self, *args, **kwargs):
262 Get contents of and entry as they were in a given revision.
263 revision==None specifies the current revision.
265 If there is no id, return default, unless default is not
266 given, in which case raise InvalidID.
268 if self.is_readable() == False:
269 raise NotReadable('Cannot get entry with unreadable storage.')
270 if 'decode' in kwargs:
271 decode = kwargs.pop('decode')
274 value = self._get(*args, **kwargs)
276 if decode == True and type(value) != types.UnicodeType:
277 return unicode(value, self.encoding)
278 elif decode == False and type(value) != types.StringType:
279 return value.encode(self.encoding)
282 def _get(self, id, default=InvalidObject, revision=None):
284 return self._data[id].value
285 elif default == InvalidObject:
289 def set(self, id, value, *args, **kwargs):
291 Set the entry contents.
293 if self.is_writeable() == False:
294 raise NotWriteable('Cannot set entry in unwriteable storage.')
295 if type(value) == types.UnicodeType:
296 value = value.encode(self.encoding)
297 self._set(id, value, *args, **kwargs)
299 def _set(self, id, value):
300 if id not in self._data:
302 if self._data[id].directory == True:
303 raise InvalidDirectory(
304 'Directory %s cannot have data' % self.parent)
305 self._data[id].value = value
307 class VersionedStorage (Storage):
309 This class declares all the methods required by a Storage
310 interface that supports versioning. This implementation just
311 keeps the data in a list and uses pickle for persistent
314 name = 'VersionedStorage'
316 def __init__(self, *args, **kwargs):
317 Storage.__init__(self, *args, **kwargs)
318 self.versioned = True
321 f = open(os.path.join(self.repo, 'repo.pkl'), 'wb')
322 root = Entry(id='__ROOT__', directory=True)
323 summary = Entry(id='__COMMIT__SUMMARY__', value='Initial commit')
324 body = Entry(id='__COMMIT__BODY__')
325 initial_commit = {root.id:root, summary.id:summary, body.id:body}
326 d = dict((k,v._objects_to_ids()) for k,v in initial_commit.items())
327 pickle.dump([d, copy.deepcopy(d)], f, -1) # [inital tree, working tree]
332 f = open(os.path.join(self.repo, 'repo.pkl'), 'rb')
334 raise ConnectionError(self)
336 self._data = [dict((k,v._ids_to_objects(t)) for k,v in t.items())
340 def disconnect(self):
341 """Close the connection to the repository."""
342 if self.is_writeable() == False:
344 f = open(os.path.join(self.repo, 'repo.pkl'), 'wb')
345 pickle.dump([dict((k,v._objects_to_ids())
346 for k,v in t.items()) for t in self._data], f, -1)
350 def _add(self, id, parent=None, directory=False):
353 p = self._data[-1][parent]
354 self._data[-1][id] = Entry(id, parent=p, directory=directory)
356 def _remove(self, id):
357 if self._data[-1][id].directory == True \
358 and len(self.children(id)) > 0:
359 raise DirectoryNotEmpty(id)
360 e = self._data[-1].pop(id)
363 def _recursive_remove(self, id):
364 for entry in reversed(list(self._data[-1][id].traverse())):
365 self._remove(entry.id)
367 def _children(self, id=None, revision=None):
372 return [c.id for c in self._data[revision][id]
373 if not c.id.startswith('__')]
375 def _get(self, id, default=InvalidObject, revision=None):
378 if id in self._data[revision]:
379 return self._data[revision][id].value
380 elif default == InvalidObject:
384 def _set(self, id, value):
385 if id not in self._data[-1]:
387 self._data[-1][id].value = value
389 def commit(self, *args, **kwargs):
391 Commit the current repository, with a commit message string
392 summary and body. Return the name of the new revision.
394 If allow_empty == False (the default), raise EmptyCommit if
395 there are no changes to commit.
397 if self.is_writeable() == False:
398 raise NotWriteable('Cannot commit to unwriteable storage.')
399 return self._commit(*args, **kwargs)
401 def _commit(self, summary, body=None, allow_empty=False):
402 if self._data[-1] == self._data[-2] and allow_empty == False:
404 self._data[-1]["__COMMIT__SUMMARY__"].value = summary
405 self._data[-1]["__COMMIT__BODY__"].value = body
406 rev = len(self._data)-1
407 self._data.append(copy.deepcopy(self._data[-1]))
410 def revision_id(self, index=None):
412 Return the name of the <index>th revision. The choice of
413 which branch to follow when crossing branches/merges is not
414 defined. Revision indices start at 1; ID 0 is the blank
417 Return None if index==None.
419 If the specified revision does not exist, raise InvalidRevision.
424 if int(index) != index:
425 raise InvalidRevision(index)
427 raise InvalidRevision(index)
428 L = len(self._data) - 1 # -1 b/c of initial commit
429 if index >= -L and index <= L:
431 raise InvalidRevision(i)
434 class StorageTestCase (unittest.TestCase):
435 """Test cases for base Storage class."""
439 def __init__(self, *args, **kwargs):
440 super(StorageTestCase, self).__init__(*args, **kwargs)
444 """Set up test fixtures for Storage test case."""
445 super(StorageTestCase, self).setUp()
447 self.dirname = self.dir.path
448 self.s = self.Class(repo=self.dirname)
449 self.assert_failed_connect()
454 super(StorageTestCase, self).tearDown()
457 self.assert_failed_connect()
460 def assert_failed_connect(self):
464 "Connected to %(name)s repository before initialising"
466 except ConnectionError:
469 class Storage_init_TestCase (StorageTestCase):
470 """Test cases for Storage.init method."""
472 def test_connect_should_succeed_after_init(self):
473 """Should connect after initialization."""
476 class Storage_add_remove_TestCase (StorageTestCase):
477 """Test cases for Storage.add, .remove, and .recursive_remove methods."""
479 def test_initially_empty(self):
480 """New repository should be empty."""
481 self.failUnless(len(self.s.children()) == 0, self.s.children())
483 def test_add_identical_rooted(self):
485 Adding entries with the same ID should not increase the number of children.
488 self.s.add('some id', directory=False)
489 s = sorted(self.s.children())
490 self.failUnless(s == ['some id'], s)
492 def test_add_rooted(self):
494 Adding entries should increase the number of children (rooted).
499 self.s.add(ids[-1], directory=False)
500 s = sorted(self.s.children())
501 self.failUnless(s == ids, '\n %s\n !=\n %s' % (s, ids))
503 def test_add_nonrooted(self):
505 Adding entries should increase the number of children (nonrooted).
507 self.s.add('parent', directory=True)
511 self.s.add(ids[-1], 'parent', directory=True)
512 s = sorted(self.s.children('parent'))
513 self.failUnless(s == ids, '\n %s\n !=\n %s' % (s, ids))
514 s = self.s.children()
515 self.failUnless(s == ['parent'], s)
517 def test_children(self):
519 Non-UUID ids should be returned as such.
521 self.s.add('parent', directory=True)
524 ids.append('parent/%s' % str(i))
525 self.s.add(ids[-1], 'parent', directory=True)
526 s = sorted(self.s.children('parent'))
527 self.failUnless(s == ids, '\n %s\n !=\n %s' % (s, ids))
529 def test_add_invalid_directory(self):
531 Should not be able to add children to non-directories.
533 self.s.add('parent', directory=False)
535 self.s.add('child', 'parent', directory=False)
537 '%s.add() succeeded instead of raising InvalidDirectory'
538 % (vars(self.Class)['name']))
539 except InvalidDirectory:
542 self.s.add('child', 'parent', directory=True)
544 '%s.add() succeeded instead of raising InvalidDirectory'
545 % (vars(self.Class)['name']))
546 except InvalidDirectory:
548 self.failUnless(len(self.s.children('parent')) == 0,
549 self.s.children('parent'))
551 def test_remove_rooted(self):
553 Removing entries should decrease the number of children (rooted).
558 self.s.add(ids[-1], directory=True)
560 self.s.remove(ids.pop())
561 s = sorted(self.s.children())
562 self.failUnless(s == ids, '\n %s\n !=\n %s' % (s, ids))
564 def test_remove_nonrooted(self):
566 Removing entries should decrease the number of children (nonrooted).
568 self.s.add('parent', directory=True)
572 self.s.add(ids[-1], 'parent', directory=False)
574 self.s.remove(ids.pop())
575 s = sorted(self.s.children('parent'))
576 self.failUnless(s == ids, '\n %s\n !=\n %s' % (s, ids))
577 s = self.s.children()
578 self.failUnless(s == ['parent'], s)
580 def test_remove_directory_not_empty(self):
582 Removing a non-empty directory entry should raise exception.
584 self.s.add('parent', directory=True)
588 self.s.add(ids[-1], 'parent', directory=True)
589 self.s.remove(ids.pop()) # empty directory removal succeeds
591 self.s.remove('parent') # empty directory removal succeeds
593 "%s.remove() didn't raise DirectoryNotEmpty"
594 % (vars(self.Class)['name']))
595 except DirectoryNotEmpty:
598 def test_recursive_remove(self):
600 Recursive remove should empty the tree.
602 self.s.add('parent', directory=True)
606 self.s.add(ids[-1], 'parent', directory=True)
607 for j in range(10): # add some grandkids
608 self.s.add(str(20*(i+1)+j), ids[-1], directory=False)
609 self.s.recursive_remove('parent')
610 s = sorted(self.s.children())
611 self.failUnless(s == [], s)
613 class Storage_get_set_TestCase (StorageTestCase):
614 """Test cases for Storage.get and .set methods."""
617 val = 'unlikely value'
619 def test_get_default(self):
621 Get should return specified default if id not in Storage.
623 ret = self.s.get(self.id, default=self.val)
624 self.failUnless(ret == self.val,
625 "%s.get() returned %s not %s"
626 % (vars(self.Class)['name'], ret, self.val))
628 def test_get_default_exception(self):
630 Get should raise exception if id not in Storage and no default.
633 ret = self.s.get(self.id)
635 "%s.get() returned %s instead of raising InvalidID"
636 % (vars(self.Class)['name'], ret))
640 def test_get_initial_value(self):
642 Data value should be None before any value has been set.
644 self.s.add(self.id, directory=False)
645 ret = self.s.get(self.id)
646 self.failUnless(ret == None,
647 "%s.get() returned %s not None"
648 % (vars(self.Class)['name'], ret))
650 def test_set_exception(self):
652 Set should raise exception if id not in Storage.
655 self.s.set(self.id, self.val)
657 "%(name)s.set() did not raise InvalidID"
664 Set should define the value returned by get.
666 self.s.add(self.id, directory=False)
667 self.s.set(self.id, self.val)
668 ret = self.s.get(self.id)
669 self.failUnless(ret == self.val,
670 "%s.get() returned %s not %s"
671 % (vars(self.Class)['name'], ret, self.val))
673 def test_unicode_set(self):
675 Set should define the value returned by get.
678 self.s.add(self.id, directory=False)
679 self.s.set(self.id, val)
680 ret = self.s.get(self.id, decode=True)
681 self.failUnless(type(ret) == types.UnicodeType,
682 "%s.get() returned %s not UnicodeType"
683 % (vars(self.Class)['name'], type(ret)))
684 self.failUnless(ret == val,
685 "%s.get() returned %s not %s"
686 % (vars(self.Class)['name'], ret, self.val))
687 ret = self.s.get(self.id)
688 self.failUnless(type(ret) == types.StringType,
689 "%s.get() returned %s not StringType"
690 % (vars(self.Class)['name'], type(ret)))
691 s = unicode(ret, self.s.encoding)
692 self.failUnless(s == val,
693 "%s.get() returned %s not %s"
694 % (vars(self.Class)['name'], s, self.val))
697 class Storage_persistence_TestCase (StorageTestCase):
698 """Test cases for Storage.disconnect and .connect methods."""
701 val = 'unlikely value'
703 def test_get_set_persistence(self):
705 Set should define the value returned by get after reconnect.
707 self.s.add(self.id, directory=False)
708 self.s.set(self.id, self.val)
711 ret = self.s.get(self.id)
712 self.failUnless(ret == self.val,
713 "%s.get() returned %s not %s"
714 % (vars(self.Class)['name'], ret, self.val))
716 def test_add_nonrooted_persistence(self):
718 Adding entries should increase the number of children after reconnect.
720 self.s.add('parent', directory=True)
724 self.s.add(ids[-1], 'parent', directory=False)
727 s = sorted(self.s.children('parent'))
728 self.failUnless(s == ids, '\n %s\n !=\n %s' % (s, ids))
729 s = self.s.children()
730 self.failUnless(s == ['parent'], s)
732 class VersionedStorageTestCase (StorageTestCase):
733 """Test cases for base VersionedStorage class."""
735 Class = VersionedStorage
737 class VersionedStorage_commit_TestCase (VersionedStorageTestCase):
738 """Test cases for VersionedStorage methods."""
742 commit_msg = 'Committing something interesting'
743 commit_body = 'Some\nlonger\ndescription\n'
745 def _setup_for_empty_commit(self):
747 Initialization might add some files to version control, so
748 commit those first, before testing the empty commit
752 self.s.commit('Added initialization files')
756 def test_revision_id_exception(self):
758 Invalid revision id should raise InvalidRevision.
761 rev = self.s.revision_id('highly unlikely revision id')
763 "%s.revision_id() didn't raise InvalidRevision, returned %s."
764 % (vars(self.Class)['name'], rev))
765 except InvalidRevision:
768 def test_empty_commit_raises_exception(self):
770 Empty commit should raise exception.
772 self._setup_for_empty_commit()
774 self.s.commit(self.commit_msg, self.commit_body)
776 "Empty %(name)s.commit() didn't raise EmptyCommit."
781 def test_empty_commit_allowed(self):
783 Empty commit should _not_ raise exception if allow_empty=True.
785 self._setup_for_empty_commit()
786 self.s.commit(self.commit_msg, self.commit_body,
789 def test_commit_revision_ids(self):
791 Commit / revision_id should agree on revision ids.
794 return '%s:%d' % (self.val, i+1)
795 self.s.add(self.id, directory=False)
798 self.s.set(self.id, val(i))
799 revs.append(self.s.commit('%s: %d' % (self.commit_msg, i),
802 rev = self.s.revision_id(i+1)
803 self.failUnless(rev == revs[i],
804 "%s.revision_id(%d) returned %s not %s"
805 % (vars(self.Class)['name'], i+1, rev, revs[i]))
806 for i in range(-1, -9, -1):
807 rev = self.s.revision_id(i)
808 self.failUnless(rev == revs[i],
809 "%s.revision_id(%d) returned %s not %s"
810 % (vars(self.Class)['name'], i, rev, revs[i]))
812 def test_get_previous_version(self):
814 Get should be able to return the previous version.
817 return '%s:%d' % (self.val, i+1)
818 self.s.add(self.id, directory=False)
821 self.s.set(self.id, val(i))
822 revs.append(self.s.commit('%s: %d' % (self.commit_msg, i),
825 ret = self.s.get(self.id, revision=revs[i])
826 self.failUnless(ret == val(i),
827 "%s.get() returned %s not %s for revision %s"
828 % (vars(self.Class)['name'], ret, val(i), revs[i]))
830 def make_storage_testcase_subclasses(storage_class, namespace):
831 """Make StorageTestCase subclasses for storage_class in namespace."""
832 storage_testcase_classes = [
834 ob for ob in globals().values() if isinstance(ob, type))
835 if issubclass(c, StorageTestCase) \
836 and not issubclass(c, VersionedStorageTestCase)]
838 for base_class in storage_testcase_classes:
839 testcase_class_name = storage_class.__name__ + base_class.__name__
840 testcase_class_bases = (base_class,)
841 testcase_class_dict = dict(base_class.__dict__)
842 testcase_class_dict['Class'] = storage_class
843 testcase_class = type(
844 testcase_class_name, testcase_class_bases, testcase_class_dict)
845 setattr(namespace, testcase_class_name, testcase_class)
847 def make_versioned_storage_testcase_subclasses(storage_class, namespace):
848 """Make VersionedStorageTestCase subclasses for storage_class in namespace."""
849 storage_testcase_classes = [
851 ob for ob in globals().values() if isinstance(ob, type))
852 if issubclass(c, StorageTestCase)]
854 for base_class in storage_testcase_classes:
855 testcase_class_name = storage_class.__name__ + base_class.__name__
856 testcase_class_bases = (base_class,)
857 testcase_class_dict = dict(base_class.__dict__)
858 testcase_class_dict['Class'] = storage_class
859 testcase_class = type(
860 testcase_class_name, testcase_class_bases, testcase_class_dict)
861 setattr(namespace, testcase_class_name, testcase_class)
863 make_storage_testcase_subclasses(VersionedStorage, sys.modules[__name__])
865 unitsuite =unittest.TestLoader().loadTestsFromModule(sys.modules[__name__])
866 suite = unittest.TestSuite([unitsuite, doctest.DocTestSuite()])