Converted libbe.storage.vcs.base to new Storage format.
[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 from libbe.util.tree import Tree
14 from libbe.util import InvalidObject
15 from libbe import TESTING
16
17 if TESTING == True:
18     import doctest
19     import os.path
20     import sys
21     import unittest
22
23     from libbe.util.utility import Dir
24
25 class ConnectionError (Exception):
26     pass
27
28 class InvalidID (KeyError):
29     pass
30
31 class InvalidRevision (KeyError):
32     pass
33
34 class InvalidDirectory (Exception):
35     pass
36
37 class DirectoryNotEmpty (InvalidDirectory):
38     pass
39
40 class NotWriteable (NotSupported):
41     def __init__(self, msg):
42         NotSupported.__init__(self, 'write', msg)
43
44 class NotReadable (NotSupported):
45     def __init__(self, msg):
46         NotSupported.__init__(self, 'read', msg)
47
48 class EmptyCommit(Exception):
49     def __init__(self):
50         Exception.__init__(self, 'No changes to commit')
51
52 class Entry (Tree):
53     def __init__(self, id, value=None, parent=None, directory=False,
54                  children=None):
55         if children == None:
56             Tree.__init__(self)
57         else:
58             Tree.__init__(self, children)
59         self.id = id
60         self.value = value
61         self.parent = parent
62         if self.parent != None:
63             if self.parent.directory == False:
64                 raise InvalidDirectory(
65                     'Non-directory %s cannot have children' % self.parent)
66             parent.append(self)
67         self.directory = directory
68
69     def __str__(self):
70         return '<Entry %s: %s>' % (self.id, self.value)
71
72     def __repr__(self):
73         return str(self)
74
75     def __cmp__(self, other, local=False):
76         if other == None:
77             return cmp(1, None)
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)
82         if local == False:
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)
91         return 0
92
93     def _objects_to_ids(self):
94         if self.parent != None:
95             self.parent = self.parent.id
96         for i,c in enumerate(self):
97             self[i] = c.id
98         return self
99
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):
104             self[i] = dict[c]
105         return self
106
107 class Storage (object):
108     """
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.
112     """
113     name = 'Storage'
114
115     def __init__(self, repo='/', encoding='utf-8', options=None):
116         self.repo = repo
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
124         self.can_init = True
125
126     def __str__(self):
127         return '<%s %s %s>' % (self.__class__.__name__, id(self), self.repo)
128
129     def __repr__(self):
130         return str(self)
131
132     def version(self):
133         """Return a version string for this backend."""
134         return '0'
135
136     def is_readable(self):
137         return self.readable and self._readable
138
139     def is_writeable(self):
140         return self.writeable and self._writeable
141
142     def init(self):
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.')
149         return self._init()
150
151     def _init(self):
152         f = open(self.repo, 'wb')
153         root = Entry(id='__ROOT__', directory=True)
154         d = {root.id:root}
155         pickle.dump(dict((k,v._objects_to_ids()) for k,v in d.items()), f, -1)
156         f.close()
157
158     def destroy(self):
159         """Remove the storage repository."""
160         if self.is_writeable() == False:
161             raise NotWriteable('Cannot destroy unwriteable storage.')
162         return self._destroy()
163
164     def _destroy(self):
165         os.remove(self.repo)
166
167     def connect(self):
168         """Open a connection to the repository."""
169         if self.is_readable() == False:
170             raise NotReadable('Cannot connect to unreadable storage.')
171         self._connect()
172
173     def _connect(self):
174         try:
175             f = open(self.repo, 'rb')
176         except IOError:
177             raise ConnectionError(self)
178         d = pickle.load(f)
179         self._data = dict((k,v._ids_to_objects(d)) for k,v in d.items())
180         f.close()
181
182     def disconnect(self):
183         """Close the connection to the repository."""
184         if self.is_writeable() == False:
185             return
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)
189         f.close()
190         self._data = None
191
192     def add(self, id, *args, **kwargs):
193         """Add an entry"""
194         if self.is_writeable() == False:
195             raise NotWriteable('Cannot add entry to unwriteable storage.')
196         try:  # Maybe we've already added that id?
197             self.get(id)
198             pass # yup, no need to add another
199         except InvalidID:
200             self._add(id, *args, **kwargs)
201
202     def _add(self, id, parent=None, directory=False):
203         if parent == None:
204             parent = '__ROOT__'
205         p = self._data[parent]
206         self._data[id] = Entry(id, parent=p, directory=directory)
207
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)
214
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)
220         e.parent.remove(e)
221
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)
228
229     def _recursive_remove(self, id):
230         for entry in reversed(list(self._data[id].traverse())):
231             self._remove(entry.id)
232
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)
238
239     def _children(self, id=None, revision=None):
240         if id == None:
241             id = '__ROOT__'
242         return [c.id for c in self._data[id] if not c.id.startswith('__')]
243
244     def get(self, *args, **kwargs):
245         """
246         Get contents of and entry as they were in a given revision.
247         revision==None specifies the current revision.
248
249         If there is no id, return default, unless default is not
250         given, in which case raise InvalidID.
251         """
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')
256         else:
257             decode = False
258         value = self._get(*args, **kwargs)
259         if decode == True:
260             return unicode(value, self.encoding)
261         return value
262
263     def _get(self, id, default=InvalidObject, revision=None):
264         if id in self._data:
265             return self._data[id].value
266         elif default == InvalidObject:
267             raise InvalidID(id)
268         return default
269
270     def set(self, id, value, *args, **kwargs):
271         """
272         Set the entry contents.
273         """
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)
279
280     def _set(self, id, value):
281         if id not in self._data:
282             raise InvalidID(id)
283         if self._data[id].directory == True:
284             raise InvalidDirectory(
285                 'Directory %s cannot have data' % self.parent)
286         self._data[id].value = value
287
288 class VersionedStorage (Storage):
289     """
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
293     storage.
294     """
295     name = 'VersionedStorage'
296
297     def __init__(self, *args, **kwargs):
298         Storage.__init__(self, *args, **kwargs)
299         self.versioned = True
300
301     def _init(self):
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]
309         f.close()
310
311     def _connect(self):
312         try:
313             f = open(self.repo, 'rb')
314         except IOError:
315             raise ConnectionError(self)
316         d = pickle.load(f)
317         self._data = [dict((k,v._ids_to_objects(t)) for k,v in t.items())
318                       for t in d]
319         f.close()
320
321     def disconnect(self):
322         """Close the connection to the repository."""
323         if self.is_writeable() == False:
324             return
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)
328         f.close()
329         self._data = None
330
331     def _add(self, id, parent=None, directory=False):
332         if parent == None:
333             parent = '__ROOT__'
334         p = self._data[-1][parent]
335         self._data[-1][id] = Entry(id, parent=p, directory=directory)
336
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)
342         e.parent.remove(e)
343
344     def _recursive_remove(self, id):
345         for entry in reversed(list(self._data[-1][id].traverse())):
346             self._remove(entry.id)
347
348     def _children(self, id=None, revision=None):
349         if id == None:
350             id = '__ROOT__'
351         if revision == None:
352             revision = -1
353         return [c.id for c in self._data[revision][id]
354                 if not c.id.startswith('__')]
355
356     def _get(self, id, default=InvalidObject, revision=None):
357         if revision == None:
358             revision = -1
359         if id in self._data[revision]:
360             return self._data[revision][id].value
361         elif default == InvalidObject:
362             raise InvalidID(id)
363         return default
364
365     def _set(self, id, value):
366         if id not in self._data[-1]:
367             raise InvalidID(id)
368         self._data[-1][id].value = value
369
370     def commit(self, *args, **kwargs):
371         """
372         Commit the current repository, with a commit message string
373         summary and body.  Return the name of the new revision.
374
375         If allow_empty == False (the default), raise EmptyCommit if
376         there are no changes to commit.
377         """
378         if self.is_writeable() == False:
379             raise NotWriteable('Cannot commit to unwriteable storage.')
380         return self._commit(*args, **kwargs)
381
382     def _commit(self, summary, body=None, allow_empty=False):
383         if self._data[-1] == self._data[-2] and allow_empty == False:
384             raise EmptyCommit
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]))
389         return rev
390
391     def revision_id(self, index=None):
392         """
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
396         repository.
397
398         Return None if index==None.
399
400         If the specified revision does not exist, raise InvalidRevision.
401         """
402         if index == None:
403             return None
404         try:
405             if int(index) != index:
406                 raise InvalidRevision(index)
407         except ValueError:
408             raise InvalidRevision(index)
409         L = len(self._data) - 1  # -1 b/c of initial commit
410         if index >= -L and index <= L:
411             return index % L
412         raise InvalidRevision(i)
413
414 if TESTING == True:
415     class StorageTestCase (unittest.TestCase):
416         """Test cases for base Storage class."""
417
418         Class = Storage
419
420         def __init__(self, *args, **kwargs):
421             super(StorageTestCase, self).__init__(*args, **kwargs)
422             self.dirname = None
423
424         def setUp(self):
425             """Set up test fixtures for Storage test case."""
426             super(StorageTestCase, self).setUp()
427             self.dir = Dir()
428             self.dirname = self.dir.path
429             self.s = self.Class(repo=os.path.join(self.dirname, 'repo.pkl'))
430             self.assert_failed_connect()
431             self.s.init()
432             self.s.connect()
433
434         def tearDown(self):
435             super(StorageTestCase, self).tearDown()
436             self.s.disconnect()
437             self.s.destroy()
438             self.assert_failed_connect()
439             self.dir.cleanup()
440
441         def assert_failed_connect(self):
442             try:
443                 self.s.connect()
444                 self.fail(
445                     "Connected to %(name)s repository before initialising"
446                     % vars(self.Class))
447             except ConnectionError:
448                 pass
449
450     class Storage_init_TestCase (StorageTestCase):
451         """Test cases for Storage.init method."""
452
453         def test_connect_should_succeed_after_init(self):
454             """Should connect after initialization."""
455             self.s.connect()
456
457     class Storage_add_remove_TestCase (StorageTestCase):
458         """Test cases for Storage.add, .remove, and .recursive_remove methods."""
459
460         def test_initially_empty(self):
461             """New repository should be empty."""
462             self.failUnless(len(self.s.children()) == 0, self.s.children())
463
464         def test_add_identical_rooted(self):
465             """
466             Adding entries with the same ID should not increase the number of children.
467             """
468             for i in range(10):
469                 self.s.add('some id', directory=False)
470                 s = sorted(self.s.children())
471                 self.failUnless(s == ['some id'], s)
472
473         def test_add_rooted(self):
474             """
475             Adding entries should increase the number of children (rooted).
476             """
477             ids = []
478             for i in range(10):
479                 ids.append(str(i))
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))
483
484         def test_add_nonrooted(self):
485             """
486             Adding entries should increase the number of children (nonrooted).
487             """
488             self.s.add('parent', directory=True)
489             ids = []
490             for i in range(10):
491                 ids.append(str(i))
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)
497
498         def test_children(self):
499             """
500             Non-UUID ids should be returned as such.
501             """
502             self.s.add('parent', directory=True)
503             ids = []
504             for i in range(10):
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))
509
510         def test_add_invalid_directory(self):
511             """
512             Should not be able to add children to non-directories.
513             """
514             self.s.add('parent', directory=False)
515             try:
516                 self.s.add('child', 'parent', directory=False)
517                 self.fail(
518                     '%s.add() succeeded instead of raising InvalidDirectory'
519                     % (vars(self.Class)['name']))
520             except InvalidDirectory:
521                 pass
522             try:
523                 self.s.add('child', 'parent', directory=True)
524                 self.fail(
525                     '%s.add() succeeded instead of raising InvalidDirectory'
526                     % (vars(self.Class)['name']))
527             except InvalidDirectory:
528                 pass
529             self.failUnless(len(self.s.children('parent')) == 0,
530                             self.s.children('parent'))
531
532         def test_remove_rooted(self):
533             """
534             Removing entries should decrease the number of children (rooted).
535             """
536             ids = []
537             for i in range(10):
538                 ids.append(str(i))
539                 self.s.add(ids[-1], directory=True)
540             for i in range(10):
541                 self.s.remove(ids.pop())
542                 s = sorted(self.s.children())
543                 self.failUnless(s == ids, '\n  %s\n  !=\n  %s' % (s, ids))
544
545         def test_remove_nonrooted(self):
546             """
547             Removing entries should decrease the number of children (nonrooted).
548             """
549             self.s.add('parent', directory=True)
550             ids = []
551             for i in range(10):
552                 ids.append(str(i))
553                 self.s.add(ids[-1], 'parent', directory=False)
554             for i in range(10):
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)
560
561         def test_remove_directory_not_empty(self):
562             """
563             Removing a non-empty directory entry should raise exception.
564             """
565             self.s.add('parent', directory=True)
566             ids = []
567             for i in range(10):
568                 ids.append(str(i))
569                 self.s.add(ids[-1], 'parent', directory=True)
570             self.s.remove(ids.pop()) # empty directory removal succeeds
571             try:
572                 self.s.remove('parent') # empty directory removal succeeds
573                 self.fail(
574                     "%s.remove() didn't raise DirectoryNotEmpty"
575                     % (vars(self.Class)['name']))
576             except DirectoryNotEmpty:
577                 pass
578
579         def test_recursive_remove(self):
580             """
581             Recursive remove should empty the tree.
582             """
583             self.s.add('parent', directory=True)
584             ids = []
585             for i in range(10):
586                 ids.append(str(i))
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)
593
594     class Storage_get_set_TestCase (StorageTestCase):
595         """Test cases for Storage.get and .set methods."""
596
597         id = 'unlikely id'
598         val = 'unlikely value'
599
600         def test_get_default(self):
601             """
602             Get should return specified default if id not in Storage.
603             """
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))
608
609         def test_get_default_exception(self):
610             """
611             Get should raise exception if id not in Storage and no default.
612             """
613             try:
614                 ret = self.s.get(self.id)
615                 self.fail(
616                     "%s.get() returned %s instead of raising InvalidID"
617                     % (vars(self.Class)['name'], ret))
618             except InvalidID:
619                 pass
620
621         def test_get_initial_value(self):
622             """
623             Data value should be None before any value has been set.
624             """
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))
630
631         def test_set_exception(self):
632             """
633             Set should raise exception if id not in Storage.
634             """
635             try:
636                 self.s.set(self.id, self.val)
637                 self.fail(
638                     "%(name)s.set() did not raise InvalidID"
639                     % vars(self.Class))
640             except InvalidID:
641                 pass
642
643         def test_set(self):
644             """
645             Set should define the value returned by get.
646             """
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))
653
654         def test_unicode_set(self):
655             """
656             Set should define the value returned by get.
657             """
658             val = u'Fran\xe7ois'
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))
676             
677
678     class Storage_persistence_TestCase (StorageTestCase):
679         """Test cases for Storage.disconnect and .connect methods."""
680
681         id = 'unlikely id'
682         val = 'unlikely value'
683
684         def test_get_set_persistence(self):
685             """
686             Set should define the value returned by get after reconnect.
687             """
688             self.s.add(self.id, directory=False)
689             self.s.set(self.id, self.val)
690             self.s.disconnect()
691             self.s.connect()
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))
696
697         def test_add_nonrooted_persistence(self):
698             """
699             Adding entries should increase the number of children after reconnect.
700             """
701             self.s.add('parent', directory=True)
702             ids = []
703             for i in range(10):
704                 ids.append(str(i))
705                 self.s.add(ids[-1], 'parent', directory=False)
706             self.s.disconnect()
707             self.s.connect()
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)
712
713     class VersionedStorageTestCase (StorageTestCase):
714         """Test cases for base VersionedStorage class."""
715
716         Class = VersionedStorage
717
718     class VersionedStorage_commit_TestCase (VersionedStorageTestCase):
719         """Test cases for VersionedStorage methods."""
720
721         id = 'I' #unlikely id'
722         val = 'X'
723         commit_msg = 'C' #ommitting something interesting'
724         commit_body = 'B' #ome\nlonger\ndescription\n'
725
726         def test_revision_id_exception(self):
727             """
728             Invalid revision id should raise InvalidRevision.
729             """
730             try:
731                 rev = self.s.revision_id('highly unlikely revision id')
732                 self.fail(
733                     "%s.revision_id() didn't raise InvalidRevision, returned %s."
734                     % (vars(self.Class)['name'], rev))
735             except InvalidRevision:
736                 pass
737
738         def test_empty_commit_raises_exception(self):
739             """
740             Empty commit should raise exception.
741             """
742             try:
743                 self.s.commit(self.commit_msg, self.commit_body)
744                 self.fail(
745                     "Empty %(name)s.commit() didn't raise EmptyCommit."
746                     % vars(self.Class))
747             except EmptyCommit:
748                 pass
749
750         def test_empty_commit_allowed(self):
751             """
752             Empty commit should _not_ raise exception if allow_empty=True.
753             """
754             self.s.commit(self.commit_msg, self.commit_body,
755                           allow_empty=True)
756
757         def test_commit_revision_ids(self):
758             """
759             Commit / revision_id should agree on revision ids.
760             """
761             revs = []
762             for s in range(10):
763                 revs.append(self.s.commit(self.commit_msg,
764                                           self.commit_body,
765                                           allow_empty=True))
766             for i in range(10):
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]))
776
777         def test_get_previous_version(self):
778             """
779             Get should be able to return the previous version.
780             """
781             def val(i):
782                 return '%s:%d' % (self.val, i+1)
783             self.s.add(self.id, directory=False)
784             revs = []
785             for i in range(10):
786                 self.s.set(self.id, val(i))
787                 revs.append(self.s.commit('%s: %d' % (self.commit_msg, i),
788                                           self.commit_body))
789             for i in range(10):
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]))
794         
795     def make_storage_testcase_subclasses(storage_class, namespace):
796         """Make StorageTestCase subclasses for storage_class in namespace."""
797         storage_testcase_classes = [
798             c for c in (
799                 ob for ob in globals().values() if isinstance(ob, type))
800             if issubclass(c, StorageTestCase) \
801                 and not issubclass(c, VersionedStorageTestCase)]
802
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)
811
812     def make_versioned_storage_testcase_subclasses(storage_class, namespace):
813         """Make VersionedStorageTestCase subclasses for storage_class in namespace."""
814         storage_testcase_classes = [
815             c for c in (
816                 ob for ob in globals().values() if isinstance(ob, type))
817             if issubclass(c, StorageTestCase)]
818
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)
827
828     make_storage_testcase_subclasses(VersionedStorage, sys.modules[__name__])
829
830     unitsuite =unittest.TestLoader().loadTestsFromModule(sys.modules[__name__])
831     suite = unittest.TestSuite([unitsuite, doctest.DocTestSuite()])