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.20 2001-12-18 15:30:34 rochecompaan 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 filename = self.filename(classname, nodeid, property)
276 try:
277 return open(filename, 'rb').read()
278 except:
279 return open(filename+'.tmp', 'rb').read()
282 #
283 # Journal
284 #
285 def addjournal(self, classname, nodeid, action, params):
286 ''' Journal the Action
287 'action' may be:
289 'create' or 'set' -- 'params' is a dictionary of property values
290 'link' or 'unlink' -- 'params' is (classname, nodeid, propname)
291 'retire' -- 'params' is None
292 '''
293 if DEBUG:
294 print 'addjournal', (self, classname, nodeid, action, params)
295 self.transactions.append((self._doSaveJournal, (classname, nodeid,
296 action, params)))
298 def getjournal(self, classname, nodeid):
299 ''' get the journal for id
300 '''
301 if DEBUG:
302 print 'getjournal', (self, classname, nodeid)
303 # attempt to open the journal - in some rare cases, the journal may
304 # not exist
305 try:
306 db = self._opendb('journals.%s'%classname, 'r')
307 except anydbm.error, error:
308 if str(error) == "need 'c' or 'n' flag to open new db": return []
309 elif error.args[0] != 2: raise
310 return []
311 journal = marshal.loads(db[nodeid])
312 res = []
313 for entry in journal:
314 (nodeid, date_stamp, self.journaltag, action, params) = entry
315 date_obj = date.Date(date_stamp)
316 res.append((nodeid, date_obj, self.journaltag, action, params))
317 return res
320 #
321 # Basic transaction support
322 #
323 def commit(self):
324 ''' Commit the current transactions.
325 '''
326 if DEBUG:
327 print 'commit', (self,)
328 # TODO: lock the DB
330 # keep a handle to all the database files opened
331 self.databases = {}
333 # now, do all the transactions
334 for method, args in self.transactions:
335 method(*args)
337 # now close all the database files
338 for db in self.databases.values():
339 db.close()
340 del self.databases
341 # TODO: unlock the DB
343 # all transactions committed, back to normal
344 self.cache = {}
345 self.dirtynodes = {}
346 self.newnodes = {}
347 self.transactions = []
349 def _doSaveNode(self, classname, nodeid, node):
350 if DEBUG:
351 print '_doSaveNode', (self, classname, nodeid, node)
353 # get the database handle
354 db_name = 'nodes.%s'%classname
355 if self.databases.has_key(db_name):
356 db = self.databases[db_name]
357 else:
358 db = self.databases[db_name] = self.getclassdb(classname, 'c')
360 # now save the marshalled data
361 db[nodeid] = marshal.dumps(node)
363 def _doSaveJournal(self, classname, nodeid, action, params):
364 if DEBUG:
365 print '_doSaveJournal', (self, classname, nodeid, action, params)
366 entry = (nodeid, date.Date().get_tuple(), self.journaltag, action,
367 params)
369 # get the database handle
370 db_name = 'journals.%s'%classname
371 if self.databases.has_key(db_name):
372 db = self.databases[db_name]
373 else:
374 db = self.databases[db_name] = self._opendb(db_name, 'c')
376 # now insert the journal entry
377 if db.has_key(nodeid):
378 s = db[nodeid]
379 l = marshal.loads(db[nodeid])
380 l.append(entry)
381 else:
382 l = [entry]
383 db[nodeid] = marshal.dumps(l)
385 def _doStoreFile(self, name, **databases):
386 # the file is currently ".tmp" - move it to its real name to commit
387 os.rename(name+".tmp", name)
389 def rollback(self):
390 ''' Reverse all actions from the current transaction.
391 '''
392 if DEBUG:
393 print 'rollback', (self, )
394 for method, args in self.transactions:
395 # delete temporary files
396 if method == self._doStoreFile:
397 os.remove(args[0]+".tmp")
398 self.cache = {}
399 self.dirtynodes = {}
400 self.newnodes = {}
401 self.transactions = []
403 #
404 #$Log: not supported by cvs2svn $
405 #Revision 1.19 2001/12/17 03:52:48 richard
406 #Implemented file store rollback. As a bonus, the hyperdb is now capable of
407 #storing more than one file per node - if a property name is supplied,
408 #the file is called designator.property.
409 #I decided not to migrate the existing files stored over to the new naming
410 #scheme - the FileClass just doesn't specify the property name.
411 #
412 #Revision 1.18 2001/12/16 10:53:38 richard
413 #take a copy of the node dict so that the subsequent set
414 #operation doesn't modify the oldvalues structure
415 #
416 #Revision 1.17 2001/12/14 23:42:57 richard
417 #yuck, a gdbm instance tests false :(
418 #I've left the debugging code in - it should be removed one day if we're ever
419 #_really_ anal about performace :)
420 #
421 #Revision 1.16 2001/12/12 03:23:14 richard
422 #Cor blimey this anydbm/whichdb stuff is yecchy. Turns out that whichdb
423 #incorrectly identifies a dbm file as a dbhash file on my system. This has
424 #been submitted to the python bug tracker as issue #491888:
425 #https://sourceforge.net/tracker/index.php?func=detail&aid=491888&group_id=5470&atid=105470
426 #
427 #Revision 1.15 2001/12/12 02:30:51 richard
428 #I fixed the problems with people whose anydbm was using the dbm module at the
429 #backend. It turns out the dbm module modifies the file name to append ".db"
430 #and my check to determine if we're opening an existing or new db just
431 #tested os.path.exists() on the filename. Well, no longer! We now perform a
432 #much better check _and_ cope with the anydbm implementation module changing
433 #too!
434 #I also fixed the backends __init__ so only ImportError is squashed.
435 #
436 #Revision 1.14 2001/12/10 22:20:01 richard
437 #Enabled transaction support in the bsddb backend. It uses the anydbm code
438 #where possible, only replacing methods where the db is opened (it uses the
439 #btree opener specifically.)
440 #Also cleaned up some change note generation.
441 #Made the backends package work with pydoc too.
442 #
443 #Revision 1.13 2001/12/02 05:06:16 richard
444 #. We now use weakrefs in the Classes to keep the database reference, so
445 # the close() method on the database is no longer needed.
446 # I bumped the minimum python requirement up to 2.1 accordingly.
447 #. #487480 ] roundup-server
448 #. #487476 ] INSTALL.txt
449 #
450 #I also cleaned up the change message / post-edit stuff in the cgi client.
451 #There's now a clearly marked "TODO: append the change note" where I believe
452 #the change note should be added there. The "changes" list will obviously
453 #have to be modified to be a dict of the changes, or somesuch.
454 #
455 #More testing needed.
456 #
457 #Revision 1.12 2001/12/01 07:17:50 richard
458 #. We now have basic transaction support! Information is only written to
459 # the database when the commit() method is called. Only the anydbm
460 # backend is modified in this way - neither of the bsddb backends have been.
461 # The mail, admin and cgi interfaces all use commit (except the admin tool
462 # doesn't have a commit command, so interactive users can't commit...)
463 #. Fixed login/registration forwarding the user to the right page (or not,
464 # on a failure)
465 #
466 #Revision 1.11 2001/11/21 02:34:18 richard
467 #Added a target version field to the extended issue schema
468 #
469 #Revision 1.10 2001/10/09 23:58:10 richard
470 #Moved the data stringification up into the hyperdb.Class class' get, set
471 #and create methods. This means that the data is also stringified for the
472 #journal call, and removes duplication of code from the backends. The
473 #backend code now only sees strings.
474 #
475 #Revision 1.9 2001/10/09 07:25:59 richard
476 #Added the Password property type. See "pydoc roundup.password" for
477 #implementation details. Have updated some of the documentation too.
478 #
479 #Revision 1.8 2001/09/29 13:27:00 richard
480 #CGI interfaces now spit up a top-level index of all the instances they can
481 #serve.
482 #
483 #Revision 1.7 2001/08/12 06:32:36 richard
484 #using isinstance(blah, Foo) now instead of isFooType
485 #
486 #Revision 1.6 2001/08/07 00:24:42 richard
487 #stupid typo
488 #
489 #Revision 1.5 2001/08/07 00:15:51 richard
490 #Added the copyright/license notice to (nearly) all files at request of
491 #Bizar Software.
492 #
493 #Revision 1.4 2001/07/30 01:41:36 richard
494 #Makes schema changes mucho easier.
495 #
496 #Revision 1.3 2001/07/25 01:23:07 richard
497 #Added the Roundup spec to the new documentation directory.
498 #
499 #Revision 1.2 2001/07/23 08:20:44 richard
500 #Moved over to using marshal in the bsddb and anydbm backends.
501 #roundup-admin now has a "freshen" command that'll load/save all nodes (not
502 # retired - mod hyperdb.Class.list() so it lists retired nodes)
503 #
504 #