Fixed make_*_testcase_subclasses() to avoid duplication.
[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     def __init__(self, id=None, revision=None, msg=None):
41         if msg == None and id != None:
42             msg = id
43         KeyError.__init__(self, msg)
44         self.id = id
45         self.revision = revision
46
47 class InvalidRevision (KeyError):
48     pass
49
50 class InvalidDirectory (Exception):
51     pass
52
53 class DirectoryNotEmpty (InvalidDirectory):
54     pass
55
56 class NotWriteable (NotSupported):
57     def __init__(self, msg):
58         NotSupported.__init__(self, 'write', msg)
59
60 class NotReadable (NotSupported):
61     def __init__(self, msg):
62         NotSupported.__init__(self, 'read', msg)
63
64 class EmptyCommit(Exception):
65     def __init__(self):
66         Exception.__init__(self, 'No changes to commit')
67
68
69 class Entry (Tree):
70     def __init__(self, id, value=None, parent=None, directory=False,
71                  children=None):
72         if children == None:
73             Tree.__init__(self)
74         else:
75             Tree.__init__(self, children)
76         self.id = id
77         self.value = value
78         self.parent = parent
79         if self.parent != None:
80             if self.parent.directory == False:
81                 raise InvalidDirectory(
82                     'Non-directory %s cannot have children' % self.parent)
83             parent.append(self)
84         self.directory = directory
85
86     def __str__(self):
87         return '<Entry %s: %s>' % (self.id, self.value)
88
89     def __repr__(self):
90         return str(self)
91
92     def __cmp__(self, other, local=False):
93         if other == None:
94             return cmp(1, None)
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)
99         if local == False:
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)
108         return 0
109
110     def _objects_to_ids(self):
111         if self.parent != None:
112             self.parent = self.parent.id
113         for i,c in enumerate(self):
114             self[i] = c.id
115         return self
116
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):
121             self[i] = dict[c]
122         return self
123
124 class Storage (object):
125     """
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.
129     """
130     name = 'Storage'
131
132     def __init__(self, repo='/', encoding='utf-8', options=None):
133         self.repo = repo
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
141         self.can_init = True
142
143     def __str__(self):
144         return '<%s %s %s>' % (self.__class__.__name__, id(self), self.repo)
145
146     def __repr__(self):
147         return str(self)
148
149     def version(self):
150         """Return a version string for this backend."""
151         return '0'
152
153     def storage_version(self, revision=None):
154         """Return the storage format for this backend."""
155         return libbe.storage.STORAGE_VERSION
156
157     def is_readable(self):
158         return self.readable and self._readable
159
160     def is_writeable(self):
161         return self.writeable and self._writeable
162
163     def init(self):
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.')
170         return self._init()
171
172     def _init(self):
173         f = open(os.path.join(self.repo, 'repo.pkl'), 'wb')
174         root = Entry(id='__ROOT__', directory=True)
175         d = {root.id:root}
176         pickle.dump(dict((k,v._objects_to_ids()) for k,v in d.items()), f, -1)
177         f.close()
178
179     def destroy(self):
180         """Remove the storage repository."""
181         if self.is_writeable() == False:
182             raise NotWriteable('Cannot destroy unwriteable storage.')
183         return self._destroy()
184
185     def _destroy(self):
186         os.remove(os.path.join(self.repo, 'repo.pkl'))
187
188     def connect(self):
189         """Open a connection to the repository."""
190         if self.is_readable() == False:
191             raise NotReadable('Cannot connect to unreadable storage.')
192         self._connect()
193
194     def _connect(self):
195         try:
196             f = open(os.path.join(self.repo, 'repo.pkl'), 'rb')
197         except IOError:
198             raise ConnectionError(self)
199         d = pickle.load(f)
200         self._data = dict((k,v._ids_to_objects(d)) for k,v in d.items())
201         f.close()
202
203     def disconnect(self):
204         """Close the connection to the repository."""
205         if self.is_writeable() == False:
206             return
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)
210         f.close()
211         self._data = None
212
213     def add(self, id, *args, **kwargs):
214         """Add an entry"""
215         if self.is_writeable() == False:
216             raise NotWriteable('Cannot add entry to unwriteable storage.')
217         try:  # Maybe we've already added that id?
218             self.get(id)
219             pass # yup, no need to add another
220         except InvalidID:
221             self._add(id, *args, **kwargs)
222
223     def _add(self, id, parent=None, directory=False):
224         if parent == None:
225             parent = '__ROOT__'
226         p = self._data[parent]
227         self._data[id] = Entry(id, parent=p, directory=directory)
228
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)
235
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)
241         e.parent.remove(e)
242
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)
249
250     def _recursive_remove(self, id):
251         for entry in reversed(list(self._data[id].traverse())):
252             self._remove(entry.id)
253
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)
259
260     def _children(self, id=None, revision=None):
261         if id == None:
262             id = '__ROOT__'
263         return [c.id for c in self._data[id] if not c.id.startswith('__')]
264
265     def get(self, *args, **kwargs):
266         """
267         Get contents of and entry as they were in a given revision.
268         revision==None specifies the current revision.
269
270         If there is no id, return default, unless default is not
271         given, in which case raise InvalidID.
272         """
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')
277         else:
278             decode = False
279         value = self._get(*args, **kwargs)
280         if value != None:
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)
285         return value
286
287     def _get(self, id, default=InvalidObject, revision=None):
288         if id in self._data:
289             return self._data[id].value
290         elif default == InvalidObject:
291             raise InvalidID(id)
292         return default
293
294     def set(self, id, value, *args, **kwargs):
295         """
296         Set the entry contents.
297         """
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)
303
304     def _set(self, id, value):
305         if id not in self._data:
306             raise InvalidID(id)
307         if self._data[id].directory == True:
308             raise InvalidDirectory(
309                 'Directory %s cannot have data' % self.parent)
310         self._data[id].value = value
311
312 class VersionedStorage (Storage):
313     """
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
317     storage.
318     """
319     name = 'VersionedStorage'
320
321     def __init__(self, *args, **kwargs):
322         Storage.__init__(self, *args, **kwargs)
323         self.versioned = True
324
325     def _init(self):
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]
333         f.close()
334
335     def _connect(self):
336         try:
337             f = open(os.path.join(self.repo, 'repo.pkl'), 'rb')
338         except IOError:
339             raise ConnectionError(self)
340         d = pickle.load(f)
341         self._data = [dict((k,v._ids_to_objects(t)) for k,v in t.items())
342                       for t in d]
343         f.close()
344
345     def disconnect(self):
346         """Close the connection to the repository."""
347         if self.is_writeable() == False:
348             return
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)
352         f.close()
353         self._data = None
354
355     def _add(self, id, parent=None, directory=False):
356         if parent == None:
357             parent = '__ROOT__'
358         p = self._data[-1][parent]
359         self._data[-1][id] = Entry(id, parent=p, directory=directory)
360
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)
366         e.parent.remove(e)
367
368     def _recursive_remove(self, id):
369         for entry in reversed(list(self._data[-1][id].traverse())):
370             self._remove(entry.id)
371
372     def _children(self, id=None, revision=None):
373         if id == None:
374             id = '__ROOT__'
375         if revision == None:
376             revision = -1
377         return [c.id for c in self._data[revision][id]
378                 if not c.id.startswith('__')]
379
380     def _get(self, id, default=InvalidObject, revision=None):
381         if revision == None:
382             revision = -1
383         if id in self._data[revision]:
384             return self._data[revision][id].value
385         elif default == InvalidObject:
386             raise InvalidID(id)
387         return default
388
389     def _set(self, id, value):
390         if id not in self._data[-1]:
391             raise InvalidID(id)
392         self._data[-1][id].value = value
393
394     def commit(self, *args, **kwargs):
395         """
396         Commit the current repository, with a commit message string
397         summary and body.  Return the name of the new revision.
398
399         If allow_empty == False (the default), raise EmptyCommit if
400         there are no changes to commit.
401         """
402         if self.is_writeable() == False:
403             raise NotWriteable('Cannot commit to unwriteable storage.')
404         return self._commit(*args, **kwargs)
405
406     def _commit(self, summary, body=None, allow_empty=False):
407         if self._data[-1] == self._data[-2] and allow_empty == False:
408             raise EmptyCommit
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]))
413         return rev
414
415     def revision_id(self, index=None):
416         """
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
420         repository.
421
422         Return None if index==None.
423
424         If the specified revision does not exist, raise InvalidRevision.
425         """
426         if index == None:
427             return None
428         try:
429             if int(index) != index:
430                 raise InvalidRevision(index)
431         except ValueError:
432             raise InvalidRevision(index)
433         L = len(self._data) - 1  # -1 b/c of initial commit
434         if index >= -L and index <= L:
435             return index % L
436         raise InvalidRevision(i)
437
438 if TESTING == True:
439     class StorageTestCase (unittest.TestCase):
440         """Test cases for base Storage class."""
441
442         Class = Storage
443
444         def __init__(self, *args, **kwargs):
445             super(StorageTestCase, self).__init__(*args, **kwargs)
446             self.dirname = None
447
448         def setUp(self):
449             """Set up test fixtures for Storage test case."""
450             super(StorageTestCase, self).setUp()
451             self.dir = Dir()
452             self.dirname = self.dir.path
453             self.s = self.Class(repo=self.dirname)
454             self.assert_failed_connect()
455             self.s.init()
456             self.s.connect()
457
458         def tearDown(self):
459             super(StorageTestCase, self).tearDown()
460             self.s.disconnect()
461             self.s.destroy()
462             self.assert_failed_connect()
463             self.dir.cleanup()
464
465         def assert_failed_connect(self):
466             try:
467                 self.s.connect()
468                 self.fail(
469                     "Connected to %(name)s repository before initialising"
470                     % vars(self.Class))
471             except ConnectionError:
472                 pass
473
474     class Storage_init_TestCase (StorageTestCase):
475         """Test cases for Storage.init method."""
476
477         def test_connect_should_succeed_after_init(self):
478             """Should connect after initialization."""
479             self.s.connect()
480
481     class Storage_add_remove_TestCase (StorageTestCase):
482         """Test cases for Storage.add, .remove, and .recursive_remove methods."""
483
484         def test_initially_empty(self):
485             """New repository should be empty."""
486             self.failUnless(len(self.s.children()) == 0, self.s.children())
487
488         def test_add_identical_rooted(self):
489             """
490             Adding entries with the same ID should not increase the number of children.
491             """
492             for i in range(10):
493                 self.s.add('some id', directory=False)
494                 s = sorted(self.s.children())
495                 self.failUnless(s == ['some id'], s)
496
497         def test_add_rooted(self):
498             """
499             Adding entries should increase the number of children (rooted).
500             """
501             ids = []
502             for i in range(10):
503                 ids.append(str(i))
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))
507
508         def test_add_nonrooted(self):
509             """
510             Adding entries should increase the number of children (nonrooted).
511             """
512             self.s.add('parent', directory=True)
513             ids = []
514             for i in range(10):
515                 ids.append(str(i))
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)
521
522         def test_children(self):
523             """
524             Non-UUID ids should be returned as such.
525             """
526             self.s.add('parent', directory=True)
527             ids = []
528             for i in range(10):
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))
533
534         def test_add_invalid_directory(self):
535             """
536             Should not be able to add children to non-directories.
537             """
538             self.s.add('parent', directory=False)
539             try:
540                 self.s.add('child', 'parent', directory=False)
541                 self.fail(
542                     '%s.add() succeeded instead of raising InvalidDirectory'
543                     % (vars(self.Class)['name']))
544             except InvalidDirectory:
545                 pass
546             try:
547                 self.s.add('child', 'parent', directory=True)
548                 self.fail(
549                     '%s.add() succeeded instead of raising InvalidDirectory'
550                     % (vars(self.Class)['name']))
551             except InvalidDirectory:
552                 pass
553             self.failUnless(len(self.s.children('parent')) == 0,
554                             self.s.children('parent'))
555
556         def test_remove_rooted(self):
557             """
558             Removing entries should decrease the number of children (rooted).
559             """
560             ids = []
561             for i in range(10):
562                 ids.append(str(i))
563                 self.s.add(ids[-1], directory=(i % 2 == 0))
564             for i in range(10):
565                 self.s.remove(ids.pop())
566                 s = sorted(self.s.children())
567                 self.failUnless(s == ids, '\n  %s\n  !=\n  %s' % (s, ids))
568
569         def test_remove_nonrooted(self):
570             """
571             Removing entries should decrease the number of children (nonrooted).
572             """
573             self.s.add('parent', directory=True)
574             ids = []
575             for i in range(10):
576                 ids.append(str(i))
577                 self.s.add(ids[-1], 'parent', directory=False)#(i % 2 == 0))
578             for i in range(10):
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))
582                 if len(s) > 0:
583                     s = self.s.children()
584                     self.failUnless(s == ['parent'], s)
585
586         def test_remove_directory_not_empty(self):
587             """
588             Removing a non-empty directory entry should raise exception.
589             """
590             self.s.add('parent', directory=True)
591             ids = []
592             for i in range(10):
593                 ids.append(str(i))
594                 self.s.add(ids[-1], 'parent', directory=(i % 2 == 0))
595             self.s.remove(ids.pop()) # empty directory removal succeeds
596             try:
597                 self.s.remove('parent') # empty directory removal succeeds
598                 self.fail(
599                     "%s.remove() didn't raise DirectoryNotEmpty"
600                     % (vars(self.Class)['name']))
601             except DirectoryNotEmpty:
602                 pass
603
604         def test_recursive_remove(self):
605             """
606             Recursive remove should empty the tree.
607             """
608             self.s.add('parent', directory=True)
609             ids = []
610             for i in range(10):
611                 ids.append(str(i))
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)
618
619     class Storage_get_set_TestCase (StorageTestCase):
620         """Test cases for Storage.get and .set methods."""
621
622         id = 'unlikely id'
623         val = 'unlikely value'
624
625         def test_get_default(self):
626             """
627             Get should return specified default if id not in Storage.
628             """
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))
633
634         def test_get_default_exception(self):
635             """
636             Get should raise exception if id not in Storage and no default.
637             """
638             try:
639                 ret = self.s.get(self.id)
640                 self.fail(
641                     "%s.get() returned %s instead of raising InvalidID"
642                     % (vars(self.Class)['name'], ret))
643             except InvalidID:
644                 pass
645
646         def test_get_initial_value(self):
647             """
648             Data value should be None before any value has been set.
649             """
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))
655
656         def test_set_exception(self):
657             """
658             Set should raise exception if id not in Storage.
659             """
660             try:
661                 self.s.set(self.id, self.val)
662                 self.fail(
663                     "%(name)s.set() did not raise InvalidID"
664                     % vars(self.Class))
665             except InvalidID:
666                 pass
667
668         def test_set(self):
669             """
670             Set should define the value returned by get.
671             """
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))
678
679         def test_unicode_set(self):
680             """
681             Set should define the value returned by get.
682             """
683             val = u'Fran\xe7ois'
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))
701
702
703     class Storage_persistence_TestCase (StorageTestCase):
704         """Test cases for Storage.disconnect and .connect methods."""
705
706         id = 'unlikely id'
707         val = 'unlikely value'
708
709         def test_get_set_persistence(self):
710             """
711             Set should define the value returned by get after reconnect.
712             """
713             self.s.add(self.id, directory=False)
714             self.s.set(self.id, self.val)
715             self.s.disconnect()
716             self.s.connect()
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))
721
722         def test_add_nonrooted_persistence(self):
723             """
724             Adding entries should increase the number of children after reconnect.
725             """
726             self.s.add('parent', directory=True)
727             ids = []
728             for i in range(10):
729                 ids.append(str(i))
730                 self.s.add(ids[-1], 'parent', directory=(i % 2 == 0))
731             self.s.disconnect()
732             self.s.connect()
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)
737
738     class VersionedStorageTestCase (StorageTestCase):
739         """Test cases for base VersionedStorage class."""
740
741         Class = VersionedStorage
742
743     class VersionedStorage_commit_TestCase (VersionedStorageTestCase):
744         """Test cases for VersionedStorage methods."""
745
746         id = 'unlikely id'
747         val = 'Some value'
748         commit_msg = 'Committing something interesting'
749         commit_body = 'Some\nlonger\ndescription\n'
750
751         def _setup_for_empty_commit(self):
752             """
753             Initialization might add some files to version control, so
754             commit those first, before testing the empty commit
755             functionality.
756             """
757             try:
758                 self.s.commit('Added initialization files')
759             except EmptyCommit:
760                 pass
761                 
762         def test_revision_id_exception(self):
763             """
764             Invalid revision id should raise InvalidRevision.
765             """
766             try:
767                 rev = self.s.revision_id('highly unlikely revision id')
768                 self.fail(
769                     "%s.revision_id() didn't raise InvalidRevision, returned %s."
770                     % (vars(self.Class)['name'], rev))
771             except InvalidRevision:
772                 pass
773
774         def test_empty_commit_raises_exception(self):
775             """
776             Empty commit should raise exception.
777             """
778             self._setup_for_empty_commit()
779             try:
780                 self.s.commit(self.commit_msg, self.commit_body)
781                 self.fail(
782                     "Empty %(name)s.commit() didn't raise EmptyCommit."
783                     % vars(self.Class))
784             except EmptyCommit:
785                 pass
786
787         def test_empty_commit_allowed(self):
788             """
789             Empty commit should _not_ raise exception if allow_empty=True.
790             """
791             self._setup_for_empty_commit()
792             self.s.commit(self.commit_msg, self.commit_body,
793                           allow_empty=True)
794
795         def test_commit_revision_ids(self):
796             """
797             Commit / revision_id should agree on revision ids.
798             """
799             def val(i):
800                 return '%s:%d' % (self.val, i+1)
801             self.s.add(self.id, directory=False)
802             revs = []
803             for i in range(10):
804                 self.s.set(self.id, val(i))
805                 revs.append(self.s.commit('%s: %d' % (self.commit_msg, i),
806                                           self.commit_body))
807             for i in range(10):
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]))
817
818         def test_get_previous_version(self):
819             """
820             Get should be able to return the previous version.
821             """
822             def val(i):
823                 return '%s:%d' % (self.val, i+1)
824             self.s.add(self.id, directory=False)
825             revs = []
826             for i in range(10):
827                 self.s.set(self.id, val(i))
828                 revs.append(self.s.commit('%s: %d' % (self.commit_msg, i),
829                                           self.commit_body))
830             for i in range(10):
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]))
835
836         def test_get_previous_children(self):
837             """
838             Children list should be revision dependent.
839             """
840             self.s.add('parent', directory=True)
841             revs = []
842             cur_children = []
843             children = []
844             for i in range(10):
845                 new_child = str(i)
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),
849                                           self.commit_body))
850                 cur_children.append(new_child)
851                 children.append(list(cur_children))
852             for i in range(10):
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]))
858
859     def make_storage_testcase_subclasses(storage_class, namespace):
860         """Make StorageTestCase subclasses for storage_class in namespace."""
861         storage_testcase_classes = [
862             c for c in (
863                 ob for ob in globals().values() if isinstance(ob, type))
864             if issubclass(c, StorageTestCase) \
865                 and c.Class == Storage]
866
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)
875
876     def make_versioned_storage_testcase_subclasses(storage_class, namespace):
877         """Make VersionedStorageTestCase subclasses for storage_class in namespace."""
878         storage_testcase_classes = [
879             c for c in (
880                 ob for ob in globals().values() if isinstance(ob, type))
881             if issubclass(c, StorageTestCase) \
882                 and c.Class == Storage]
883
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)
892
893     make_storage_testcase_subclasses(VersionedStorage, sys.modules[__name__])
894
895     unitsuite =unittest.TestLoader().loadTestsFromModule(sys.modules[__name__])
896     suite = unittest.TestSuite([unitsuite, doctest.DocTestSuite()])