VersionedStorage_commit_TestCase now allows for versioned files created by self.s...
[be.git] / libbe / storage / base.py
1 # Copyright
2
3 """
4 Abstract bug repository data storage to easily support multiple backends.
5 """
6
7 import copy
8 import os
9 import pickle
10 import types
11
12 from libbe.error import NotSupported
13 import libbe.storage
14 from libbe.util.tree import Tree
15 from libbe.util import InvalidObject
16 from libbe import TESTING
17
18 if TESTING == True:
19     import doctest
20     import os.path
21     import sys
22     import unittest
23
24     from libbe.util.utility import Dir
25
26 class ConnectionError (Exception):
27     pass
28
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
38
39 class InvalidID (KeyError):
40     pass
41
42 class InvalidRevision (KeyError):
43     pass
44
45 class InvalidDirectory (Exception):
46     pass
47
48 class DirectoryNotEmpty (InvalidDirectory):
49     pass
50
51 class NotWriteable (NotSupported):
52     def __init__(self, msg):
53         NotSupported.__init__(self, 'write', msg)
54
55 class NotReadable (NotSupported):
56     def __init__(self, msg):
57         NotSupported.__init__(self, 'read', msg)
58
59 class EmptyCommit(Exception):
60     def __init__(self):
61         Exception.__init__(self, 'No changes to commit')
62
63
64 class Entry (Tree):
65     def __init__(self, id, value=None, parent=None, directory=False,
66                  children=None):
67         if children == None:
68             Tree.__init__(self)
69         else:
70             Tree.__init__(self, children)
71         self.id = id
72         self.value = value
73         self.parent = parent
74         if self.parent != None:
75             if self.parent.directory == False:
76                 raise InvalidDirectory(
77                     'Non-directory %s cannot have children' % self.parent)
78             parent.append(self)
79         self.directory = directory
80
81     def __str__(self):
82         return '<Entry %s: %s>' % (self.id, self.value)
83
84     def __repr__(self):
85         return str(self)
86
87     def __cmp__(self, other, local=False):
88         if other == None:
89             return cmp(1, None)
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)
94         if local == False:
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)
103         return 0
104
105     def _objects_to_ids(self):
106         if self.parent != None:
107             self.parent = self.parent.id
108         for i,c in enumerate(self):
109             self[i] = c.id
110         return self
111
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):
116             self[i] = dict[c]
117         return self
118
119 class Storage (object):
120     """
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.
124     """
125     name = 'Storage'
126
127     def __init__(self, repo='/', encoding='utf-8', options=None):
128         self.repo = repo
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
136         self.can_init = True
137
138     def __str__(self):
139         return '<%s %s %s>' % (self.__class__.__name__, id(self), self.repo)
140
141     def __repr__(self):
142         return str(self)
143
144     def version(self):
145         """Return a version string for this backend."""
146         return '0'
147
148     def storage_version(self, revision=None):
149         """Return the storage format for this backend."""
150         return libbe.storage.STORAGE_VERSION
151
152     def is_readable(self):
153         return self.readable and self._readable
154
155     def is_writeable(self):
156         return self.writeable and self._writeable
157
158     def init(self):
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.')
165         return self._init()
166
167     def _init(self):
168         f = open(os.path.join(self.repo, 'repo.pkl'), 'wb')
169         root = Entry(id='__ROOT__', directory=True)
170         d = {root.id:root}
171         pickle.dump(dict((k,v._objects_to_ids()) for k,v in d.items()), f, -1)
172         f.close()
173
174     def destroy(self):
175         """Remove the storage repository."""
176         if self.is_writeable() == False:
177             raise NotWriteable('Cannot destroy unwriteable storage.')
178         return self._destroy()
179
180     def _destroy(self):
181         os.remove(os.path.join(self.repo, 'repo.pkl'))
182
183     def connect(self):
184         """Open a connection to the repository."""
185         if self.is_readable() == False:
186             raise NotReadable('Cannot connect to unreadable storage.')
187         self._connect()
188
189     def _connect(self):
190         try:
191             f = open(os.path.join(self.repo, 'repo.pkl'), 'rb')
192         except IOError:
193             raise ConnectionError(self)
194         d = pickle.load(f)
195         self._data = dict((k,v._ids_to_objects(d)) for k,v in d.items())
196         f.close()
197
198     def disconnect(self):
199         """Close the connection to the repository."""
200         if self.is_writeable() == False:
201             return
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)
205         f.close()
206         self._data = None
207
208     def add(self, id, *args, **kwargs):
209         """Add an entry"""
210         if self.is_writeable() == False:
211             raise NotWriteable('Cannot add entry to unwriteable storage.')
212         try:  # Maybe we've already added that id?
213             self.get(id)
214             pass # yup, no need to add another
215         except InvalidID:
216             self._add(id, *args, **kwargs)
217
218     def _add(self, id, parent=None, directory=False):
219         if parent == None:
220             parent = '__ROOT__'
221         p = self._data[parent]
222         self._data[id] = Entry(id, parent=p, directory=directory)
223
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)
230
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)
236         e.parent.remove(e)
237
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)
244
245     def _recursive_remove(self, id):
246         for entry in reversed(list(self._data[id].traverse())):
247             self._remove(entry.id)
248
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)
254
255     def _children(self, id=None, revision=None):
256         if id == None:
257             id = '__ROOT__'
258         return [c.id for c in self._data[id] if not c.id.startswith('__')]
259
260     def get(self, *args, **kwargs):
261         """
262         Get contents of and entry as they were in a given revision.
263         revision==None specifies the current revision.
264
265         If there is no id, return default, unless default is not
266         given, in which case raise InvalidID.
267         """
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')
272         else:
273             decode = False
274         value = self._get(*args, **kwargs)
275         if value != None:
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)
280         return value
281
282     def _get(self, id, default=InvalidObject, revision=None):
283         if id in self._data:
284             return self._data[id].value
285         elif default == InvalidObject:
286             raise InvalidID(id)
287         return default
288
289     def set(self, id, value, *args, **kwargs):
290         """
291         Set the entry contents.
292         """
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)
298
299     def _set(self, id, value):
300         if id not in self._data:
301             raise InvalidID(id)
302         if self._data[id].directory == True:
303             raise InvalidDirectory(
304                 'Directory %s cannot have data' % self.parent)
305         self._data[id].value = value
306
307 class VersionedStorage (Storage):
308     """
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
312     storage.
313     """
314     name = 'VersionedStorage'
315
316     def __init__(self, *args, **kwargs):
317         Storage.__init__(self, *args, **kwargs)
318         self.versioned = True
319
320     def _init(self):
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]
328         f.close()
329
330     def _connect(self):
331         try:
332             f = open(os.path.join(self.repo, 'repo.pkl'), 'rb')
333         except IOError:
334             raise ConnectionError(self)
335         d = pickle.load(f)
336         self._data = [dict((k,v._ids_to_objects(t)) for k,v in t.items())
337                       for t in d]
338         f.close()
339
340     def disconnect(self):
341         """Close the connection to the repository."""
342         if self.is_writeable() == False:
343             return
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)
347         f.close()
348         self._data = None
349
350     def _add(self, id, parent=None, directory=False):
351         if parent == None:
352             parent = '__ROOT__'
353         p = self._data[-1][parent]
354         self._data[-1][id] = Entry(id, parent=p, directory=directory)
355
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)
361         e.parent.remove(e)
362
363     def _recursive_remove(self, id):
364         for entry in reversed(list(self._data[-1][id].traverse())):
365             self._remove(entry.id)
366
367     def _children(self, id=None, revision=None):
368         if id == None:
369             id = '__ROOT__'
370         if revision == None:
371             revision = -1
372         return [c.id for c in self._data[revision][id]
373                 if not c.id.startswith('__')]
374
375     def _get(self, id, default=InvalidObject, revision=None):
376         if revision == None:
377             revision = -1
378         if id in self._data[revision]:
379             return self._data[revision][id].value
380         elif default == InvalidObject:
381             raise InvalidID(id)
382         return default
383
384     def _set(self, id, value):
385         if id not in self._data[-1]:
386             raise InvalidID(id)
387         self._data[-1][id].value = value
388
389     def commit(self, *args, **kwargs):
390         """
391         Commit the current repository, with a commit message string
392         summary and body.  Return the name of the new revision.
393
394         If allow_empty == False (the default), raise EmptyCommit if
395         there are no changes to commit.
396         """
397         if self.is_writeable() == False:
398             raise NotWriteable('Cannot commit to unwriteable storage.')
399         return self._commit(*args, **kwargs)
400
401     def _commit(self, summary, body=None, allow_empty=False):
402         if self._data[-1] == self._data[-2] and allow_empty == False:
403             raise EmptyCommit
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]))
408         return rev
409
410     def revision_id(self, index=None):
411         """
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
415         repository.
416
417         Return None if index==None.
418
419         If the specified revision does not exist, raise InvalidRevision.
420         """
421         if index == None:
422             return None
423         try:
424             if int(index) != index:
425                 raise InvalidRevision(index)
426         except ValueError:
427             raise InvalidRevision(index)
428         L = len(self._data) - 1  # -1 b/c of initial commit
429         if index >= -L and index <= L:
430             return index % L
431         raise InvalidRevision(i)
432
433 if TESTING == True:
434     class StorageTestCase (unittest.TestCase):
435         """Test cases for base Storage class."""
436
437         Class = Storage
438
439         def __init__(self, *args, **kwargs):
440             super(StorageTestCase, self).__init__(*args, **kwargs)
441             self.dirname = None
442
443         def setUp(self):
444             """Set up test fixtures for Storage test case."""
445             super(StorageTestCase, self).setUp()
446             self.dir = Dir()
447             self.dirname = self.dir.path
448             self.s = self.Class(repo=self.dirname)
449             self.assert_failed_connect()
450             self.s.init()
451             self.s.connect()
452
453         def tearDown(self):
454             super(StorageTestCase, self).tearDown()
455             self.s.disconnect()
456             self.s.destroy()
457             self.assert_failed_connect()
458             self.dir.cleanup()
459
460         def assert_failed_connect(self):
461             try:
462                 self.s.connect()
463                 self.fail(
464                     "Connected to %(name)s repository before initialising"
465                     % vars(self.Class))
466             except ConnectionError:
467                 pass
468
469     class Storage_init_TestCase (StorageTestCase):
470         """Test cases for Storage.init method."""
471
472         def test_connect_should_succeed_after_init(self):
473             """Should connect after initialization."""
474             self.s.connect()
475
476     class Storage_add_remove_TestCase (StorageTestCase):
477         """Test cases for Storage.add, .remove, and .recursive_remove methods."""
478
479         def test_initially_empty(self):
480             """New repository should be empty."""
481             self.failUnless(len(self.s.children()) == 0, self.s.children())
482
483         def test_add_identical_rooted(self):
484             """
485             Adding entries with the same ID should not increase the number of children.
486             """
487             for i in range(10):
488                 self.s.add('some id', directory=False)
489                 s = sorted(self.s.children())
490                 self.failUnless(s == ['some id'], s)
491
492         def test_add_rooted(self):
493             """
494             Adding entries should increase the number of children (rooted).
495             """
496             ids = []
497             for i in range(10):
498                 ids.append(str(i))
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))
502
503         def test_add_nonrooted(self):
504             """
505             Adding entries should increase the number of children (nonrooted).
506             """
507             self.s.add('parent', directory=True)
508             ids = []
509             for i in range(10):
510                 ids.append(str(i))
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)
516
517         def test_children(self):
518             """
519             Non-UUID ids should be returned as such.
520             """
521             self.s.add('parent', directory=True)
522             ids = []
523             for i in range(10):
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))
528
529         def test_add_invalid_directory(self):
530             """
531             Should not be able to add children to non-directories.
532             """
533             self.s.add('parent', directory=False)
534             try:
535                 self.s.add('child', 'parent', directory=False)
536                 self.fail(
537                     '%s.add() succeeded instead of raising InvalidDirectory'
538                     % (vars(self.Class)['name']))
539             except InvalidDirectory:
540                 pass
541             try:
542                 self.s.add('child', 'parent', directory=True)
543                 self.fail(
544                     '%s.add() succeeded instead of raising InvalidDirectory'
545                     % (vars(self.Class)['name']))
546             except InvalidDirectory:
547                 pass
548             self.failUnless(len(self.s.children('parent')) == 0,
549                             self.s.children('parent'))
550
551         def test_remove_rooted(self):
552             """
553             Removing entries should decrease the number of children (rooted).
554             """
555             ids = []
556             for i in range(10):
557                 ids.append(str(i))
558                 self.s.add(ids[-1], directory=True)
559             for i in range(10):
560                 self.s.remove(ids.pop())
561                 s = sorted(self.s.children())
562                 self.failUnless(s == ids, '\n  %s\n  !=\n  %s' % (s, ids))
563
564         def test_remove_nonrooted(self):
565             """
566             Removing entries should decrease the number of children (nonrooted).
567             """
568             self.s.add('parent', directory=True)
569             ids = []
570             for i in range(10):
571                 ids.append(str(i))
572                 self.s.add(ids[-1], 'parent', directory=False)
573             for i in range(10):
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)
579
580         def test_remove_directory_not_empty(self):
581             """
582             Removing a non-empty directory entry should raise exception.
583             """
584             self.s.add('parent', directory=True)
585             ids = []
586             for i in range(10):
587                 ids.append(str(i))
588                 self.s.add(ids[-1], 'parent', directory=True)
589             self.s.remove(ids.pop()) # empty directory removal succeeds
590             try:
591                 self.s.remove('parent') # empty directory removal succeeds
592                 self.fail(
593                     "%s.remove() didn't raise DirectoryNotEmpty"
594                     % (vars(self.Class)['name']))
595             except DirectoryNotEmpty:
596                 pass
597
598         def test_recursive_remove(self):
599             """
600             Recursive remove should empty the tree.
601             """
602             self.s.add('parent', directory=True)
603             ids = []
604             for i in range(10):
605                 ids.append(str(i))
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)
612
613     class Storage_get_set_TestCase (StorageTestCase):
614         """Test cases for Storage.get and .set methods."""
615
616         id = 'unlikely id'
617         val = 'unlikely value'
618
619         def test_get_default(self):
620             """
621             Get should return specified default if id not in Storage.
622             """
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))
627
628         def test_get_default_exception(self):
629             """
630             Get should raise exception if id not in Storage and no default.
631             """
632             try:
633                 ret = self.s.get(self.id)
634                 self.fail(
635                     "%s.get() returned %s instead of raising InvalidID"
636                     % (vars(self.Class)['name'], ret))
637             except InvalidID:
638                 pass
639
640         def test_get_initial_value(self):
641             """
642             Data value should be None before any value has been set.
643             """
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))
649
650         def test_set_exception(self):
651             """
652             Set should raise exception if id not in Storage.
653             """
654             try:
655                 self.s.set(self.id, self.val)
656                 self.fail(
657                     "%(name)s.set() did not raise InvalidID"
658                     % vars(self.Class))
659             except InvalidID:
660                 pass
661
662         def test_set(self):
663             """
664             Set should define the value returned by get.
665             """
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))
672
673         def test_unicode_set(self):
674             """
675             Set should define the value returned by get.
676             """
677             val = u'Fran\xe7ois'
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))
695
696
697     class Storage_persistence_TestCase (StorageTestCase):
698         """Test cases for Storage.disconnect and .connect methods."""
699
700         id = 'unlikely id'
701         val = 'unlikely value'
702
703         def test_get_set_persistence(self):
704             """
705             Set should define the value returned by get after reconnect.
706             """
707             self.s.add(self.id, directory=False)
708             self.s.set(self.id, self.val)
709             self.s.disconnect()
710             self.s.connect()
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))
715
716         def test_add_nonrooted_persistence(self):
717             """
718             Adding entries should increase the number of children after reconnect.
719             """
720             self.s.add('parent', directory=True)
721             ids = []
722             for i in range(10):
723                 ids.append(str(i))
724                 self.s.add(ids[-1], 'parent', directory=False)
725             self.s.disconnect()
726             self.s.connect()
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)
731
732     class VersionedStorageTestCase (StorageTestCase):
733         """Test cases for base VersionedStorage class."""
734
735         Class = VersionedStorage
736
737     class VersionedStorage_commit_TestCase (VersionedStorageTestCase):
738         """Test cases for VersionedStorage methods."""
739
740         id = 'unlikely id'
741         val = 'Some value'
742         commit_msg = 'Committing something interesting'
743         commit_body = 'Some\nlonger\ndescription\n'
744
745         def _setup_for_empty_commit(self):
746             """
747             Initialization might add some files to version control, so
748             commit those first, before testing the empty commit
749             functionality.
750             """
751             try:
752                 self.s.commit('Added initialization files')
753             except EmptyCommit:
754                 pass
755                 
756         def test_revision_id_exception(self):
757             """
758             Invalid revision id should raise InvalidRevision.
759             """
760             try:
761                 rev = self.s.revision_id('highly unlikely revision id')
762                 self.fail(
763                     "%s.revision_id() didn't raise InvalidRevision, returned %s."
764                     % (vars(self.Class)['name'], rev))
765             except InvalidRevision:
766                 pass
767
768         def test_empty_commit_raises_exception(self):
769             """
770             Empty commit should raise exception.
771             """
772             self._setup_for_empty_commit()
773             try:
774                 self.s.commit(self.commit_msg, self.commit_body)
775                 self.fail(
776                     "Empty %(name)s.commit() didn't raise EmptyCommit."
777                     % vars(self.Class))
778             except EmptyCommit:
779                 pass
780
781         def test_empty_commit_allowed(self):
782             """
783             Empty commit should _not_ raise exception if allow_empty=True.
784             """
785             self._setup_for_empty_commit()
786             self.s.commit(self.commit_msg, self.commit_body,
787                           allow_empty=True)
788
789         def test_commit_revision_ids(self):
790             """
791             Commit / revision_id should agree on revision ids.
792             """
793             def val(i):
794                 return '%s:%d' % (self.val, i+1)
795             self.s.add(self.id, directory=False)
796             revs = []
797             for i in range(10):
798                 self.s.set(self.id, val(i))
799                 revs.append(self.s.commit('%s: %d' % (self.commit_msg, i),
800                                           self.commit_body))
801             for i in range(10):
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]))
811
812         def test_get_previous_version(self):
813             """
814             Get should be able to return the previous version.
815             """
816             def val(i):
817                 return '%s:%d' % (self.val, i+1)
818             self.s.add(self.id, directory=False)
819             revs = []
820             for i in range(10):
821                 self.s.set(self.id, val(i))
822                 revs.append(self.s.commit('%s: %d' % (self.commit_msg, i),
823                                           self.commit_body))
824             for i in range(10):
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]))
829
830     def make_storage_testcase_subclasses(storage_class, namespace):
831         """Make StorageTestCase subclasses for storage_class in namespace."""
832         storage_testcase_classes = [
833             c for c in (
834                 ob for ob in globals().values() if isinstance(ob, type))
835             if issubclass(c, StorageTestCase) \
836                 and not issubclass(c, VersionedStorageTestCase)]
837
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)
846
847     def make_versioned_storage_testcase_subclasses(storage_class, namespace):
848         """Make VersionedStorageTestCase subclasses for storage_class in namespace."""
849         storage_testcase_classes = [
850             c for c in (
851                 ob for ob in globals().values() if isinstance(ob, type))
852             if issubclass(c, StorageTestCase)]
853
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)
862
863     make_storage_testcase_subclasses(VersionedStorage, sys.modules[__name__])
864
865     unitsuite =unittest.TestLoader().loadTestsFromModule(sys.modules[__name__])
866     suite = unittest.TestSuite([unitsuite, doctest.DocTestSuite()])