Code

Implemented file store rollback. As a bonus, the hyperdb is now capable of
[roundup.git] / roundup / backends / back_anydbm.py
1 #
2 # Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/)
3 # This module is free software, and you may redistribute it and/or modify
4 # under the same terms as Python, so long as this copyright message and
5 # disclaimer are retained in their original form.
6 #
7 # IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR
8 # DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING
9 # OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE
10 # POSSIBILITY OF SUCH DAMAGE.
11 #
12 # BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING,
13 # BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
14 # FOR A PARTICULAR PURPOSE.  THE CODE PROVIDED HEREUNDER IS ON AN "AS IS"
15 # BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
16 # SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
17
18 #$Id: back_anydbm.py,v 1.19 2001-12-17 03:52:48 richard Exp $
19 '''
20 This module defines a backend that saves the hyperdatabase in a database
21 chosen by anydbm. It is guaranteed to always be available in python
22 versions >2.1.1 (the dumbdbm fallback in 2.1.1 and earlier has several
23 serious bugs, and is not available)
24 '''
26 import whichdb, anydbm, os, marshal
27 from roundup import hyperdb, date, password
29 DEBUG=os.environ.get('HYPERDBDEBUG', '')
31 #
32 # Now the database
33 #
34 class Database(hyperdb.Database):
35     """A database for storing records containing flexible data types.
37     Transaction stuff TODO:
38         . check the timestamp of the class file and nuke the cache if it's
39           modified. Do some sort of conflict checking on the dirty stuff.
40         . perhaps detect write collisions (related to above)?
42     """
43     def __init__(self, storagelocator, journaltag=None):
44         """Open a hyperdatabase given a specifier to some storage.
46         The meaning of 'storagelocator' depends on the particular
47         implementation of the hyperdatabase.  It could be a file name,
48         a directory path, a socket descriptor for a connection to a
49         database over the network, etc.
51         The 'journaltag' is a token that will be attached to the journal
52         entries for any edits done on the database.  If 'journaltag' is
53         None, the database is opened in read-only mode: the Class.create(),
54         Class.set(), and Class.retire() methods are disabled.
55         """
56         self.dir, self.journaltag = storagelocator, journaltag
57         self.classes = {}
58         self.cache = {}         # cache of nodes loaded or created
59         self.dirtynodes = {}    # keep track of the dirty nodes by class
60         self.newnodes = {}      # keep track of the new nodes by class
61         self.transactions = []
63     def __repr__(self):
64         return '<back_anydbm instance at %x>'%id(self) 
66     #
67     # Classes
68     #
69     def __getattr__(self, classname):
70         """A convenient way of calling self.getclass(classname)."""
71         if self.classes.has_key(classname):
72             if DEBUG:
73                 print '__getattr__', (self, classname)
74             return self.classes[classname]
75         raise AttributeError, classname
77     def addclass(self, cl):
78         if DEBUG:
79             print 'addclass', (self, cl)
80         cn = cl.classname
81         if self.classes.has_key(cn):
82             raise ValueError, cn
83         self.classes[cn] = cl
85     def getclasses(self):
86         """Return a list of the names of all existing classes."""
87         if DEBUG:
88             print 'getclasses', (self,)
89         l = self.classes.keys()
90         l.sort()
91         return l
93     def getclass(self, classname):
94         """Get the Class object representing a particular class.
96         If 'classname' is not a valid class name, a KeyError is raised.
97         """
98         if DEBUG:
99             print 'getclass', (self, classname)
100         return self.classes[classname]
102     #
103     # Class DBs
104     #
105     def clear(self):
106         '''Delete all database contents
107         '''
108         if DEBUG:
109             print 'clear', (self,)
110         for cn in self.classes.keys():
111             for type in 'nodes', 'journals':
112                 path = os.path.join(self.dir, 'journals.%s'%cn)
113                 if os.path.exists(path):
114                     os.remove(path)
115                 elif os.path.exists(path+'.db'):    # dbm appends .db
116                     os.remove(path+'.db')
118     def getclassdb(self, classname, mode='r'):
119         ''' grab a connection to the class db that will be used for
120             multiple actions
121         '''
122         if DEBUG:
123             print 'getclassdb', (self, classname, mode)
124         return self._opendb('nodes.%s'%classname, mode)
126     def _opendb(self, name, mode):
127         '''Low-level database opener that gets around anydbm/dbm
128            eccentricities.
129         '''
130         if DEBUG:
131             print '_opendb', (self, name, mode)
132         # determine which DB wrote the class file
133         db_type = ''
134         path = os.path.join(os.getcwd(), self.dir, name)
135         if os.path.exists(path):
136             db_type = whichdb.whichdb(path)
137             if not db_type:
138                 raise hyperdb.DatabaseError, "Couldn't identify database type"
139         elif os.path.exists(path+'.db'):
140             # if the path ends in '.db', it's a dbm database, whether
141             # anydbm says it's dbhash or not!
142             db_type = 'dbm'
144         # new database? let anydbm pick the best dbm
145         if not db_type:
146             if DEBUG:
147                 print "_opendb anydbm.open(%r, 'n')"%path
148             return anydbm.open(path, 'n')
150         # open the database with the correct module
151         try:
152             dbm = __import__(db_type)
153         except ImportError:
154             raise hyperdb.DatabaseError, \
155                 "Couldn't open database - the required module '%s'"\
156                 "is not available"%db_type
157         if DEBUG:
158             print "_opendb %r.open(%r, %r)"%(db_type, path, mode)
159         return dbm.open(path, mode)
161     #
162     # Nodes
163     #
164     def addnode(self, classname, nodeid, node):
165         ''' add the specified node to its class's db
166         '''
167         if DEBUG:
168             print 'addnode', (self, classname, nodeid, node)
169         self.newnodes.setdefault(classname, {})[nodeid] = 1
170         self.cache.setdefault(classname, {})[nodeid] = node
171         self.savenode(classname, nodeid, node)
173     def setnode(self, classname, nodeid, node):
174         ''' change the specified node
175         '''
176         if DEBUG:
177             print 'setnode', (self, classname, nodeid, node)
178         self.dirtynodes.setdefault(classname, {})[nodeid] = 1
179         # can't set without having already loaded the node
180         self.cache[classname][nodeid] = node
181         self.savenode(classname, nodeid, node)
183     def savenode(self, classname, nodeid, node):
184         ''' perform the saving of data specified by the set/addnode
185         '''
186         if DEBUG:
187             print 'savenode', (self, classname, nodeid, node)
188         self.transactions.append((self._doSaveNode, (classname, nodeid, node)))
190     def getnode(self, classname, nodeid, db=None):
191         ''' get a node from the database
192         '''
193         if DEBUG:
194             print 'getnode', (self, classname, nodeid, cldb)
195         # try the cache
196         cache = self.cache.setdefault(classname, {})
197         if cache.has_key(nodeid):
198             return cache[nodeid]
200         # get from the database and save in the cache
201         if db is None:
202             db = self.getclassdb(classname)
203         if not db.has_key(nodeid):
204             raise IndexError, nodeid
205         res = marshal.loads(db[nodeid])
206         cache[nodeid] = res
207         return res
209     def hasnode(self, classname, nodeid, db=None):
210         ''' determine if the database has a given node
211         '''
212         if DEBUG:
213             print 'hasnode', (self, classname, nodeid, cldb)
214         # try the cache
215         cache = self.cache.setdefault(classname, {})
216         if cache.has_key(nodeid):
217             return 1
219         # not in the cache - check the database
220         if db is None:
221             db = self.getclassdb(classname)
222         res = db.has_key(nodeid)
223         return res
225     def countnodes(self, classname, db=None):
226         if DEBUG:
227             print 'countnodes', (self, classname, cldb)
228         # include the new nodes not saved to the DB yet
229         count = len(self.newnodes.get(classname, {}))
231         # and count those in the DB
232         if db is None:
233             db = self.getclassdb(classname)
234         count = count + len(db.keys())
235         return count
237     def getnodeids(self, classname, db=None):
238         if DEBUG:
239             print 'getnodeids', (self, classname, db)
240         # start off with the new nodes
241         res = self.newnodes.get(classname, {}).keys()
243         if db is None:
244             db = self.getclassdb(classname)
245         res = res + db.keys()
246         return res
249     #
250     # Files - special node properties
251     #
252     def filename(self, classname, nodeid, property=None):
253         '''Determine what the filename for the given node and optionally property is.
254         '''
255         # TODO: split into multiple files directories
256         if property:
257             return os.path.join(self.dir, 'files', '%s%s.%s'%(classname,
258                 nodeid, property))
259         else:
260             # roundupdb.FileClass never specified the property name, so don't include it
261             return os.path.join(self.dir, 'files', '%s%s'%(classname,
262                 nodeid))
264     def storefile(self, classname, nodeid, property, content):
265         '''Store the content of the file in the database. The property may be None, in
266            which case the filename does not indicate which property is being saved.
267         '''
268         name = self.filename(classname, nodeid, property)
269         open(name + '.tmp', 'wb').write(content)
270         self.transactions.append((self._doStoreFile, (name, )))
272     def getfile(self, classname, nodeid, property):
273         '''Store the content of the file in the database.
274         '''
275         return open(self.filename(classname, nodeid, property), 'rb').read()
278     #
279     # Journal
280     #
281     def addjournal(self, classname, nodeid, action, params):
282         ''' Journal the Action
283         'action' may be:
285             'create' or 'set' -- 'params' is a dictionary of property values
286             'link' or 'unlink' -- 'params' is (classname, nodeid, propname)
287             'retire' -- 'params' is None
288         '''
289         if DEBUG:
290             print 'addjournal', (self, classname, nodeid, action, params)
291         self.transactions.append((self._doSaveJournal, (classname, nodeid,
292             action, params)))
294     def getjournal(self, classname, nodeid):
295         ''' get the journal for id
296         '''
297         if DEBUG:
298             print 'getjournal', (self, classname, nodeid)
299         # attempt to open the journal - in some rare cases, the journal may
300         # not exist
301         try:
302             db = self._opendb('journals.%s'%classname, 'r')
303         except anydbm.error, error:
304             if str(error) == "need 'c' or 'n' flag to open new db": return []
305             elif error.args[0] != 2: raise
306             return []
307         journal = marshal.loads(db[nodeid])
308         res = []
309         for entry in journal:
310             (nodeid, date_stamp, self.journaltag, action, params) = entry
311             date_obj = date.Date(date_stamp)
312             res.append((nodeid, date_obj, self.journaltag, action, params))
313         return res
316     #
317     # Basic transaction support
318     #
319     def commit(self):
320         ''' Commit the current transactions.
321         '''
322         if DEBUG:
323             print 'commit', (self,)
324         # TODO: lock the DB
326         # keep a handle to all the database files opened
327         self.databases = {}
329         # now, do all the transactions
330         for method, args in self.transactions:
331             method(*args)
333         # now close all the database files
334         for db in self.databases.values():
335             db.close()
336         del self.databases
337         # TODO: unlock the DB
339         # all transactions committed, back to normal
340         self.cache = {}
341         self.dirtynodes = {}
342         self.newnodes = {}
343         self.transactions = []
345     def _doSaveNode(self, classname, nodeid, node):
346         if DEBUG:
347             print '_doSaveNode', (self, classname, nodeid, node)
349         # get the database handle
350         db_name = 'nodes.%s'%classname
351         if self.databases.has_key(db_name):
352             db = self.databases[db_name]
353         else:
354             db = self.databases[db_name] = self.getclassdb(classname, 'c')
356         # now save the marshalled data
357         db[nodeid] = marshal.dumps(node)
359     def _doSaveJournal(self, classname, nodeid, action, params):
360         if DEBUG:
361             print '_doSaveJournal', (self, classname, nodeid, action, params)
362         entry = (nodeid, date.Date().get_tuple(), self.journaltag, action,
363             params)
365         # get the database handle
366         db_name = 'journals.%s'%classname
367         if self.databases.has_key(db_name):
368             db = self.databases[db_name]
369         else:
370             db = self.databases[db_name] = self._opendb(db_name, 'c')
372         # now insert the journal entry
373         if db.has_key(nodeid):
374             s = db[nodeid]
375             l = marshal.loads(db[nodeid])
376             l.append(entry)
377         else:
378             l = [entry]
379         db[nodeid] = marshal.dumps(l)
381     def _doStoreFile(self, name, **databases):
382         # the file is currently ".tmp" - move it to its real name to commit
383         os.rename(name+".tmp", name)
385     def rollback(self):
386         ''' Reverse all actions from the current transaction.
387         '''
388         if DEBUG:
389             print 'rollback', (self, )
390         for method, args in self.transactions:
391             # delete temporary files
392             if method == self._doStoreFile:
393                 os.remove(args[0]+".tmp")
394         self.cache = {}
395         self.dirtynodes = {}
396         self.newnodes = {}
397         self.transactions = []
400 #$Log: not supported by cvs2svn $
401 #Revision 1.18  2001/12/16 10:53:38  richard
402 #take a copy of the node dict so that the subsequent set
403 #operation doesn't modify the oldvalues structure
405 #Revision 1.17  2001/12/14 23:42:57  richard
406 #yuck, a gdbm instance tests false :(
407 #I've left the debugging code in - it should be removed one day if we're ever
408 #_really_ anal about performace :)
410 #Revision 1.16  2001/12/12 03:23:14  richard
411 #Cor blimey this anydbm/whichdb stuff is yecchy. Turns out that whichdb
412 #incorrectly identifies a dbm file as a dbhash file on my system. This has
413 #been submitted to the python bug tracker as issue #491888:
414 #https://sourceforge.net/tracker/index.php?func=detail&aid=491888&group_id=5470&atid=105470
416 #Revision 1.15  2001/12/12 02:30:51  richard
417 #I fixed the problems with people whose anydbm was using the dbm module at the
418 #backend. It turns out the dbm module modifies the file name to append ".db"
419 #and my check to determine if we're opening an existing or new db just
420 #tested os.path.exists() on the filename. Well, no longer! We now perform a
421 #much better check _and_ cope with the anydbm implementation module changing
422 #too!
423 #I also fixed the backends __init__ so only ImportError is squashed.
425 #Revision 1.14  2001/12/10 22:20:01  richard
426 #Enabled transaction support in the bsddb backend. It uses the anydbm code
427 #where possible, only replacing methods where the db is opened (it uses the
428 #btree opener specifically.)
429 #Also cleaned up some change note generation.
430 #Made the backends package work with pydoc too.
432 #Revision 1.13  2001/12/02 05:06:16  richard
433 #. We now use weakrefs in the Classes to keep the database reference, so
434 #  the close() method on the database is no longer needed.
435 #  I bumped the minimum python requirement up to 2.1 accordingly.
436 #. #487480 ] roundup-server
437 #. #487476 ] INSTALL.txt
439 #I also cleaned up the change message / post-edit stuff in the cgi client.
440 #There's now a clearly marked "TODO: append the change note" where I believe
441 #the change note should be added there. The "changes" list will obviously
442 #have to be modified to be a dict of the changes, or somesuch.
444 #More testing needed.
446 #Revision 1.12  2001/12/01 07:17:50  richard
447 #. We now have basic transaction support! Information is only written to
448 #  the database when the commit() method is called. Only the anydbm
449 #  backend is modified in this way - neither of the bsddb backends have been.
450 #  The mail, admin and cgi interfaces all use commit (except the admin tool
451 #  doesn't have a commit command, so interactive users can't commit...)
452 #. Fixed login/registration forwarding the user to the right page (or not,
453 #  on a failure)
455 #Revision 1.11  2001/11/21 02:34:18  richard
456 #Added a target version field to the extended issue schema
458 #Revision 1.10  2001/10/09 23:58:10  richard
459 #Moved the data stringification up into the hyperdb.Class class' get, set
460 #and create methods. This means that the data is also stringified for the
461 #journal call, and removes duplication of code from the backends. The
462 #backend code now only sees strings.
464 #Revision 1.9  2001/10/09 07:25:59  richard
465 #Added the Password property type. See "pydoc roundup.password" for
466 #implementation details. Have updated some of the documentation too.
468 #Revision 1.8  2001/09/29 13:27:00  richard
469 #CGI interfaces now spit up a top-level index of all the instances they can
470 #serve.
472 #Revision 1.7  2001/08/12 06:32:36  richard
473 #using isinstance(blah, Foo) now instead of isFooType
475 #Revision 1.6  2001/08/07 00:24:42  richard
476 #stupid typo
478 #Revision 1.5  2001/08/07 00:15:51  richard
479 #Added the copyright/license notice to (nearly) all files at request of
480 #Bizar Software.
482 #Revision 1.4  2001/07/30 01:41:36  richard
483 #Makes schema changes mucho easier.
485 #Revision 1.3  2001/07/25 01:23:07  richard
486 #Added the Roundup spec to the new documentation directory.
488 #Revision 1.2  2001/07/23 08:20:44  richard
489 #Moved over to using marshal in the bsddb and anydbm backends.
490 #roundup-admin now has a "freshen" command that'll load/save all nodes (not
491 # retired - mod hyperdb.Class.list() so it lists retired nodes)