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.62 2002-08-21 07:07:27 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, re, weakref, string, copy
27 from roundup import hyperdb, date, password, roundupdb, security
28 from blobfiles import FileStorage
29 from sessions import Sessions
30 from roundup.indexer import Indexer
31 from locking import acquire_lock, release_lock
32 from roundup.hyperdb import String, Password, Date, Interval, Link, \
33 Multilink, DatabaseError, Boolean, Number
35 #
36 # Now the database
37 #
38 class Database(FileStorage, hyperdb.Database, roundupdb.Database):
39 """A database for storing records containing flexible data types.
41 Transaction stuff TODO:
42 . check the timestamp of the class file and nuke the cache if it's
43 modified. Do some sort of conflict checking on the dirty stuff.
44 . perhaps detect write collisions (related to above)?
46 """
47 def __init__(self, config, journaltag=None):
48 """Open a hyperdatabase given a specifier to some storage.
50 The 'storagelocator' is obtained from config.DATABASE.
51 The meaning of 'storagelocator' depends on the particular
52 implementation of the hyperdatabase. It could be a file name,
53 a directory path, a socket descriptor for a connection to a
54 database over the network, etc.
56 The 'journaltag' is a token that will be attached to the journal
57 entries for any edits done on the database. If 'journaltag' is
58 None, the database is opened in read-only mode: the Class.create(),
59 Class.set(), and Class.retire() methods are disabled.
60 """
61 self.config, self.journaltag = config, journaltag
62 self.dir = config.DATABASE
63 self.classes = {}
64 self.cache = {} # cache of nodes loaded or created
65 self.dirtynodes = {} # keep track of the dirty nodes by class
66 self.newnodes = {} # keep track of the new nodes by class
67 self.destroyednodes = {}# keep track of the destroyed nodes by class
68 self.transactions = []
69 self.indexer = Indexer(self.dir)
70 self.sessions = Sessions(self.config)
71 self.security = security.Security(self)
72 # ensure files are group readable and writable
73 os.umask(0002)
75 def post_init(self):
76 """Called once the schema initialisation has finished."""
77 # reindex the db if necessary
78 if self.indexer.should_reindex():
79 self.reindex()
81 def reindex(self):
82 for klass in self.classes.values():
83 for nodeid in klass.list():
84 klass.index(nodeid)
85 self.indexer.save_index()
87 def __repr__(self):
88 return '<back_anydbm instance at %x>'%id(self)
90 #
91 # Classes
92 #
93 def __getattr__(self, classname):
94 """A convenient way of calling self.getclass(classname)."""
95 if self.classes.has_key(classname):
96 if __debug__:
97 print >>hyperdb.DEBUG, '__getattr__', (self, classname)
98 return self.classes[classname]
99 raise AttributeError, classname
101 def addclass(self, cl):
102 if __debug__:
103 print >>hyperdb.DEBUG, 'addclass', (self, cl)
104 cn = cl.classname
105 if self.classes.has_key(cn):
106 raise ValueError, cn
107 self.classes[cn] = cl
109 def getclasses(self):
110 """Return a list of the names of all existing classes."""
111 if __debug__:
112 print >>hyperdb.DEBUG, 'getclasses', (self,)
113 l = self.classes.keys()
114 l.sort()
115 return l
117 def getclass(self, classname):
118 """Get the Class object representing a particular class.
120 If 'classname' is not a valid class name, a KeyError is raised.
121 """
122 if __debug__:
123 print >>hyperdb.DEBUG, 'getclass', (self, classname)
124 return self.classes[classname]
126 #
127 # Class DBs
128 #
129 def clear(self):
130 '''Delete all database contents
131 '''
132 if __debug__:
133 print >>hyperdb.DEBUG, 'clear', (self,)
134 for cn in self.classes.keys():
135 for dummy in 'nodes', 'journals':
136 path = os.path.join(self.dir, 'journals.%s'%cn)
137 if os.path.exists(path):
138 os.remove(path)
139 elif os.path.exists(path+'.db'): # dbm appends .db
140 os.remove(path+'.db')
142 def getclassdb(self, classname, mode='r'):
143 ''' grab a connection to the class db that will be used for
144 multiple actions
145 '''
146 if __debug__:
147 print >>hyperdb.DEBUG, 'getclassdb', (self, classname, mode)
148 return self.opendb('nodes.%s'%classname, mode)
150 def determine_db_type(self, path):
151 ''' determine which DB wrote the class file
152 '''
153 db_type = ''
154 if os.path.exists(path):
155 db_type = whichdb.whichdb(path)
156 if not db_type:
157 raise hyperdb.DatabaseError, "Couldn't identify database type"
158 elif os.path.exists(path+'.db'):
159 # if the path ends in '.db', it's a dbm database, whether
160 # anydbm says it's dbhash or not!
161 db_type = 'dbm'
162 return db_type
164 def opendb(self, name, mode):
165 '''Low-level database opener that gets around anydbm/dbm
166 eccentricities.
167 '''
168 if __debug__:
169 print >>hyperdb.DEBUG, 'opendb', (self, name, mode)
171 # figure the class db type
172 path = os.path.join(os.getcwd(), self.dir, name)
173 db_type = self.determine_db_type(path)
175 # new database? let anydbm pick the best dbm
176 if not db_type:
177 if __debug__:
178 print >>hyperdb.DEBUG, "opendb anydbm.open(%r, 'n')"%path
179 return anydbm.open(path, 'n')
181 # open the database with the correct module
182 try:
183 dbm = __import__(db_type)
184 except ImportError:
185 raise hyperdb.DatabaseError, \
186 "Couldn't open database - the required module '%s'"\
187 " is not available"%db_type
188 if __debug__:
189 print >>hyperdb.DEBUG, "opendb %r.open(%r, %r)"%(db_type, path,
190 mode)
191 return dbm.open(path, mode)
193 def lockdb(self, name):
194 ''' Lock a database file
195 '''
196 path = os.path.join(os.getcwd(), self.dir, '%s.lock'%name)
197 return acquire_lock(path)
199 #
200 # Node IDs
201 #
202 def newid(self, classname):
203 ''' Generate a new id for the given class
204 '''
205 # open the ids DB - create if if doesn't exist
206 lock = self.lockdb('_ids')
207 db = self.opendb('_ids', 'c')
208 if db.has_key(classname):
209 newid = db[classname] = str(int(db[classname]) + 1)
210 else:
211 # the count() bit is transitional - older dbs won't start at 1
212 newid = str(self.getclass(classname).count()+1)
213 db[classname] = newid
214 db.close()
215 release_lock(lock)
216 return newid
218 def setid(self, classname, setid):
219 ''' Set the id counter: used during import of database
220 '''
221 # open the ids DB - create if if doesn't exist
222 lock = self.lockdb('_ids')
223 db = self.opendb('_ids', 'c')
224 db[classname] = str(setid)
225 db.close()
226 release_lock(lock)
228 #
229 # Nodes
230 #
231 def addnode(self, classname, nodeid, node):
232 ''' add the specified node to its class's db
233 '''
234 if __debug__:
235 print >>hyperdb.DEBUG, 'addnode', (self, classname, nodeid, node)
236 self.newnodes.setdefault(classname, {})[nodeid] = 1
237 self.cache.setdefault(classname, {})[nodeid] = node
238 self.savenode(classname, nodeid, node)
240 def setnode(self, classname, nodeid, node):
241 ''' change the specified node
242 '''
243 if __debug__:
244 print >>hyperdb.DEBUG, 'setnode', (self, classname, nodeid, node)
245 self.dirtynodes.setdefault(classname, {})[nodeid] = 1
247 # can't set without having already loaded the node
248 self.cache[classname][nodeid] = node
249 self.savenode(classname, nodeid, node)
251 def savenode(self, classname, nodeid, node):
252 ''' perform the saving of data specified by the set/addnode
253 '''
254 if __debug__:
255 print >>hyperdb.DEBUG, 'savenode', (self, classname, nodeid, node)
256 self.transactions.append((self.doSaveNode, (classname, nodeid, node)))
258 def getnode(self, classname, nodeid, db=None, cache=1):
259 ''' get a node from the database
260 '''
261 if __debug__:
262 print >>hyperdb.DEBUG, 'getnode', (self, classname, nodeid, db)
263 if cache:
264 # try the cache
265 cache_dict = self.cache.setdefault(classname, {})
266 if cache_dict.has_key(nodeid):
267 if __debug__:
268 print >>hyperdb.TRACE, 'get %s %s cached'%(classname,
269 nodeid)
270 return cache_dict[nodeid]
272 if __debug__:
273 print >>hyperdb.TRACE, 'get %s %s'%(classname, nodeid)
275 # get from the database and save in the cache
276 if db is None:
277 db = self.getclassdb(classname)
278 if not db.has_key(nodeid):
279 raise IndexError, "no such %s %s"%(classname, nodeid)
281 # check the uncommitted, destroyed nodes
282 if (self.destroyednodes.has_key(classname) and
283 self.destroyednodes[classname].has_key(nodeid)):
284 raise IndexError, "no such %s %s"%(classname, nodeid)
286 # decode
287 res = marshal.loads(db[nodeid])
289 # reverse the serialisation
290 res = self.unserialise(classname, res)
292 # store off in the cache dict
293 if cache:
294 cache_dict[nodeid] = res
296 return res
298 def destroynode(self, classname, nodeid):
299 '''Remove a node from the database. Called exclusively by the
300 destroy() method on Class.
301 '''
302 if __debug__:
303 print >>hyperdb.DEBUG, 'destroynode', (self, classname, nodeid)
305 # remove from cache and newnodes if it's there
306 if (self.cache.has_key(classname) and
307 self.cache[classname].has_key(nodeid)):
308 del self.cache[classname][nodeid]
309 if (self.newnodes.has_key(classname) and
310 self.newnodes[classname].has_key(nodeid)):
311 del self.newnodes[classname][nodeid]
313 # see if there's any obvious commit actions that we should get rid of
314 for entry in self.transactions[:]:
315 if entry[1][:2] == (classname, nodeid):
316 self.transactions.remove(entry)
318 # add to the destroyednodes map
319 self.destroyednodes.setdefault(classname, {})[nodeid] = 1
321 # add the destroy commit action
322 self.transactions.append((self.doDestroyNode, (classname, nodeid)))
324 def serialise(self, classname, node):
325 '''Copy the node contents, converting non-marshallable data into
326 marshallable data.
327 '''
328 if __debug__:
329 print >>hyperdb.DEBUG, 'serialise', classname, node
330 properties = self.getclass(classname).getprops()
331 d = {}
332 for k, v in node.items():
333 # if the property doesn't exist, or is the "retired" flag then
334 # it won't be in the properties dict
335 if not properties.has_key(k):
336 d[k] = v
337 continue
339 # get the property spec
340 prop = properties[k]
342 if isinstance(prop, Password):
343 d[k] = str(v)
344 elif isinstance(prop, Date) and v is not None:
345 d[k] = v.serialise()
346 elif isinstance(prop, Interval) and v is not None:
347 d[k] = v.serialise()
348 else:
349 d[k] = v
350 return d
352 def unserialise(self, classname, node):
353 '''Decode the marshalled node data
354 '''
355 if __debug__:
356 print >>hyperdb.DEBUG, 'unserialise', classname, node
357 properties = self.getclass(classname).getprops()
358 d = {}
359 for k, v in node.items():
360 # if the property doesn't exist, or is the "retired" flag then
361 # it won't be in the properties dict
362 if not properties.has_key(k):
363 d[k] = v
364 continue
366 # get the property spec
367 prop = properties[k]
369 if isinstance(prop, Date) and v is not None:
370 d[k] = date.Date(v)
371 elif isinstance(prop, Interval) and v is not None:
372 d[k] = date.Interval(v)
373 elif isinstance(prop, Password):
374 p = password.Password()
375 p.unpack(v)
376 d[k] = p
377 else:
378 d[k] = v
379 return d
381 def hasnode(self, classname, nodeid, db=None):
382 ''' determine if the database has a given node
383 '''
384 if __debug__:
385 print >>hyperdb.DEBUG, 'hasnode', (self, classname, nodeid, db)
387 # try the cache
388 cache = self.cache.setdefault(classname, {})
389 if cache.has_key(nodeid):
390 if __debug__:
391 print >>hyperdb.TRACE, 'has %s %s cached'%(classname, nodeid)
392 return 1
393 if __debug__:
394 print >>hyperdb.TRACE, 'has %s %s'%(classname, nodeid)
396 # not in the cache - check the database
397 if db is None:
398 db = self.getclassdb(classname)
399 res = db.has_key(nodeid)
400 return res
402 def countnodes(self, classname, db=None):
403 if __debug__:
404 print >>hyperdb.DEBUG, 'countnodes', (self, classname, db)
406 count = 0
408 # include the uncommitted nodes
409 if self.newnodes.has_key(classname):
410 count += len(self.newnodes[classname])
411 if self.destroyednodes.has_key(classname):
412 count -= len(self.destroyednodes[classname])
414 # and count those in the DB
415 if db is None:
416 db = self.getclassdb(classname)
417 count = count + len(db.keys())
418 return count
420 def getnodeids(self, classname, db=None):
421 if __debug__:
422 print >>hyperdb.DEBUG, 'getnodeids', (self, classname, db)
424 res = []
426 # start off with the new nodes
427 if self.newnodes.has_key(classname):
428 res += self.newnodes[classname].keys()
430 if db is None:
431 db = self.getclassdb(classname)
432 res = res + db.keys()
434 # remove the uncommitted, destroyed nodes
435 if self.destroyednodes.has_key(classname):
436 for nodeid in self.destroyednodes[classname].keys():
437 if db.has_key(nodeid):
438 res.remove(nodeid)
440 return res
443 #
444 # Files - special node properties
445 # inherited from FileStorage
447 #
448 # Journal
449 #
450 def addjournal(self, classname, nodeid, action, params):
451 ''' Journal the Action
452 'action' may be:
454 'create' or 'set' -- 'params' is a dictionary of property values
455 'link' or 'unlink' -- 'params' is (classname, nodeid, propname)
456 'retire' -- 'params' is None
457 '''
458 if __debug__:
459 print >>hyperdb.DEBUG, 'addjournal', (self, classname, nodeid,
460 action, params)
461 self.transactions.append((self.doSaveJournal, (classname, nodeid,
462 action, params)))
464 def getjournal(self, classname, nodeid):
465 ''' get the journal for id
467 Raise IndexError if the node doesn't exist (as per history()'s
468 API)
469 '''
470 if __debug__:
471 print >>hyperdb.DEBUG, 'getjournal', (self, classname, nodeid)
472 # attempt to open the journal - in some rare cases, the journal may
473 # not exist
474 try:
475 db = self.opendb('journals.%s'%classname, 'r')
476 except anydbm.error, error:
477 if str(error) == "need 'c' or 'n' flag to open new db":
478 raise IndexError, 'no such %s %s'%(classname, nodeid)
479 elif error.args[0] != 2:
480 raise
481 raise IndexError, 'no such %s %s'%(classname, nodeid)
482 try:
483 journal = marshal.loads(db[nodeid])
484 except KeyError:
485 db.close()
486 raise IndexError, 'no such %s %s'%(classname, nodeid)
487 db.close()
488 res = []
489 for nodeid, date_stamp, user, action, params in journal:
490 res.append((nodeid, date.Date(date_stamp), user, action, params))
491 return res
493 def pack(self, pack_before):
494 ''' delete all journal entries before 'pack_before' '''
495 if __debug__:
496 print >>hyperdb.DEBUG, 'packjournal', (self, pack_before)
498 pack_before = pack_before.get_tuple()
500 classes = self.getclasses()
502 # figure the class db type
504 for classname in classes:
505 db_name = 'journals.%s'%classname
506 path = os.path.join(os.getcwd(), self.dir, classname)
507 db_type = self.determine_db_type(path)
508 db = self.opendb(db_name, 'w')
510 for key in db.keys():
511 journal = marshal.loads(db[key])
512 l = []
513 last_set_entry = None
514 for entry in journal:
515 (nodeid, date_stamp, self.journaltag, action,
516 params) = entry
517 if date_stamp > pack_before or action == 'create':
518 l.append(entry)
519 elif action == 'set':
520 # grab the last set entry to keep information on
521 # activity
522 last_set_entry = entry
523 if last_set_entry:
524 date_stamp = last_set_entry[1]
525 # if the last set entry was made after the pack date
526 # then it is already in the list
527 if date_stamp < pack_before:
528 l.append(last_set_entry)
529 db[key] = marshal.dumps(l)
530 if db_type == 'gdbm':
531 db.reorganize()
532 db.close()
535 #
536 # Basic transaction support
537 #
538 def commit(self):
539 ''' Commit the current transactions.
540 '''
541 if __debug__:
542 print >>hyperdb.DEBUG, 'commit', (self,)
543 # TODO: lock the DB
545 # keep a handle to all the database files opened
546 self.databases = {}
548 # now, do all the transactions
549 reindex = {}
550 for method, args in self.transactions:
551 reindex[method(*args)] = 1
553 # now close all the database files
554 for db in self.databases.values():
555 db.close()
556 del self.databases
557 # TODO: unlock the DB
559 # reindex the nodes that request it
560 for classname, nodeid in filter(None, reindex.keys()):
561 print >>hyperdb.DEBUG, 'commit.reindex', (classname, nodeid)
562 self.getclass(classname).index(nodeid)
564 # save the indexer state
565 self.indexer.save_index()
567 # all transactions committed, back to normal
568 self.cache = {}
569 self.dirtynodes = {}
570 self.newnodes = {}
571 self.destroyednodes = {}
572 self.transactions = []
574 def getCachedClassDB(self, classname):
575 ''' get the class db, looking in our cache of databases for commit
576 '''
577 # get the database handle
578 db_name = 'nodes.%s'%classname
579 if not self.databases.has_key(db_name):
580 self.databases[db_name] = self.getclassdb(classname, 'c')
581 return self.databases[db_name]
583 def doSaveNode(self, classname, nodeid, node):
584 if __debug__:
585 print >>hyperdb.DEBUG, 'doSaveNode', (self, classname, nodeid,
586 node)
588 db = self.getCachedClassDB(classname)
590 # now save the marshalled data
591 db[nodeid] = marshal.dumps(self.serialise(classname, node))
593 # return the classname, nodeid so we reindex this content
594 return (classname, nodeid)
596 def getCachedJournalDB(self, classname):
597 ''' get the journal db, looking in our cache of databases for commit
598 '''
599 # get the database handle
600 db_name = 'journals.%s'%classname
601 if not self.databases.has_key(db_name):
602 self.databases[db_name] = self.opendb(db_name, 'c')
603 return self.databases[db_name]
605 def doSaveJournal(self, classname, nodeid, action, params):
606 # handle supply of the special journalling parameters (usually
607 # supplied on importing an existing database)
608 if isinstance(params, type({})):
609 if params.has_key('creator'):
610 journaltag = self.user.get(params['creator'], 'username')
611 del params['creator']
612 else:
613 journaltag = self.journaltag
614 if params.has_key('created'):
615 journaldate = params['created'].serialise()
616 del params['created']
617 else:
618 journaldate = date.Date().serialise()
619 if params.has_key('activity'):
620 del params['activity']
622 # serialise the parameters now
623 if action in ('set', 'create'):
624 params = self.serialise(classname, params)
625 else:
626 journaltag = self.journaltag
627 journaldate = date.Date().serialise()
629 # create the journal entry
630 entry = (nodeid, journaldate, journaltag, action, params)
631 print 'doSaveJournal', entry
633 if __debug__:
634 print >>hyperdb.DEBUG, 'doSaveJournal', entry
636 db = self.getCachedJournalDB(classname)
638 # now insert the journal entry
639 if db.has_key(nodeid):
640 # append to existing
641 s = db[nodeid]
642 l = marshal.loads(s)
643 l.append(entry)
644 else:
645 l = [entry]
647 db[nodeid] = marshal.dumps(l)
649 def doDestroyNode(self, classname, nodeid):
650 if __debug__:
651 print >>hyperdb.DEBUG, 'doDestroyNode', (self, classname, nodeid)
653 # delete from the class database
654 db = self.getCachedClassDB(classname)
655 if db.has_key(nodeid):
656 del db[nodeid]
658 # delete from the database
659 db = self.getCachedJournalDB(classname)
660 if db.has_key(nodeid):
661 del db[nodeid]
663 # return the classname, nodeid so we reindex this content
664 return (classname, nodeid)
666 def rollback(self):
667 ''' Reverse all actions from the current transaction.
668 '''
669 if __debug__:
670 print >>hyperdb.DEBUG, 'rollback', (self, )
671 for method, args in self.transactions:
672 # delete temporary files
673 if method == self.doStoreFile:
674 self.rollbackStoreFile(*args)
675 self.cache = {}
676 self.dirtynodes = {}
677 self.newnodes = {}
678 self.destroyednodes = {}
679 self.transactions = []
681 _marker = []
682 class Class(hyperdb.Class):
683 """The handle to a particular class of nodes in a hyperdatabase."""
685 def __init__(self, db, classname, **properties):
686 """Create a new class with a given name and property specification.
688 'classname' must not collide with the name of an existing class,
689 or a ValueError is raised. The keyword arguments in 'properties'
690 must map names to property objects, or a TypeError is raised.
691 """
692 if (properties.has_key('creation') or properties.has_key('activity')
693 or properties.has_key('creator')):
694 raise ValueError, '"creation", "activity" and "creator" are '\
695 'reserved'
697 self.classname = classname
698 self.properties = properties
699 self.db = weakref.proxy(db) # use a weak ref to avoid circularity
700 self.key = ''
702 # should we journal changes (default yes)
703 self.do_journal = 1
705 # do the db-related init stuff
706 db.addclass(self)
708 self.auditors = {'create': [], 'set': [], 'retire': []}
709 self.reactors = {'create': [], 'set': [], 'retire': []}
711 def enableJournalling(self):
712 '''Turn journalling on for this class
713 '''
714 self.do_journal = 1
716 def disableJournalling(self):
717 '''Turn journalling off for this class
718 '''
719 self.do_journal = 0
721 # Editing nodes:
723 def create(self, **propvalues):
724 """Create a new node of this class and return its id.
726 The keyword arguments in 'propvalues' map property names to values.
728 The values of arguments must be acceptable for the types of their
729 corresponding properties or a TypeError is raised.
731 If this class has a key property, it must be present and its value
732 must not collide with other key strings or a ValueError is raised.
734 Any other properties on this class that are missing from the
735 'propvalues' dictionary are set to None.
737 If an id in a link or multilink property does not refer to a valid
738 node, an IndexError is raised.
740 These operations trigger detectors and can be vetoed. Attempts
741 to modify the "creation" or "activity" properties cause a KeyError.
742 """
743 if propvalues.has_key('id'):
744 raise KeyError, '"id" is reserved'
746 if self.db.journaltag is None:
747 raise DatabaseError, 'Database open read-only'
749 if propvalues.has_key('creation') or propvalues.has_key('activity'):
750 raise KeyError, '"creation" and "activity" are reserved'
752 self.fireAuditors('create', None, propvalues)
754 # new node's id
755 newid = self.db.newid(self.classname)
757 # validate propvalues
758 num_re = re.compile('^\d+$')
759 for key, value in propvalues.items():
760 if key == self.key:
761 try:
762 self.lookup(value)
763 except KeyError:
764 pass
765 else:
766 raise ValueError, 'node with key "%s" exists'%value
768 # try to handle this property
769 try:
770 prop = self.properties[key]
771 except KeyError:
772 raise KeyError, '"%s" has no property "%s"'%(self.classname,
773 key)
775 if value is not None and isinstance(prop, Link):
776 if type(value) != type(''):
777 raise ValueError, 'link value must be String'
778 link_class = self.properties[key].classname
779 # if it isn't a number, it's a key
780 if not num_re.match(value):
781 try:
782 value = self.db.classes[link_class].lookup(value)
783 except (TypeError, KeyError):
784 raise IndexError, 'new property "%s": %s not a %s'%(
785 key, value, link_class)
786 elif not self.db.getclass(link_class).hasnode(value):
787 raise IndexError, '%s has no node %s'%(link_class, value)
789 # save off the value
790 propvalues[key] = value
792 # register the link with the newly linked node
793 if self.do_journal and self.properties[key].do_journal:
794 self.db.addjournal(link_class, value, 'link',
795 (self.classname, newid, key))
797 elif isinstance(prop, Multilink):
798 if type(value) != type([]):
799 raise TypeError, 'new property "%s" not a list of ids'%key
801 # clean up and validate the list of links
802 link_class = self.properties[key].classname
803 l = []
804 for entry in value:
805 if type(entry) != type(''):
806 raise ValueError, '"%s" link value (%s) must be '\
807 'String'%(key, value)
808 # if it isn't a number, it's a key
809 if not num_re.match(entry):
810 try:
811 entry = self.db.classes[link_class].lookup(entry)
812 except (TypeError, KeyError):
813 raise IndexError, 'new property "%s": %s not a %s'%(
814 key, entry, self.properties[key].classname)
815 l.append(entry)
816 value = l
817 propvalues[key] = value
819 # handle additions
820 for nodeid in value:
821 if not self.db.getclass(link_class).hasnode(nodeid):
822 raise IndexError, '%s has no node %s'%(link_class,
823 nodeid)
824 # register the link with the newly linked node
825 if self.do_journal and self.properties[key].do_journal:
826 self.db.addjournal(link_class, nodeid, 'link',
827 (self.classname, newid, key))
829 elif isinstance(prop, String):
830 if type(value) != type(''):
831 raise TypeError, 'new property "%s" not a string'%key
833 elif isinstance(prop, Password):
834 if not isinstance(value, password.Password):
835 raise TypeError, 'new property "%s" not a Password'%key
837 elif isinstance(prop, Date):
838 if value is not None and not isinstance(value, date.Date):
839 raise TypeError, 'new property "%s" not a Date'%key
841 elif isinstance(prop, Interval):
842 if value is not None and not isinstance(value, date.Interval):
843 raise TypeError, 'new property "%s" not an Interval'%key
845 elif value is not None and isinstance(prop, Number):
846 try:
847 float(value)
848 except ValueError:
849 raise TypeError, 'new property "%s" not numeric'%key
851 elif value is not None and isinstance(prop, Boolean):
852 try:
853 int(value)
854 except ValueError:
855 raise TypeError, 'new property "%s" not boolean'%key
857 # make sure there's data where there needs to be
858 for key, prop in self.properties.items():
859 if propvalues.has_key(key):
860 continue
861 if key == self.key:
862 raise ValueError, 'key property "%s" is required'%key
863 if isinstance(prop, Multilink):
864 propvalues[key] = []
865 else:
866 propvalues[key] = None
868 # done
869 self.db.addnode(self.classname, newid, propvalues)
870 if self.do_journal:
871 self.db.addjournal(self.classname, newid, 'create', propvalues)
873 self.fireReactors('create', newid, None)
875 return newid
877 def export_list(self, propnames, nodeid):
878 ''' Export a node - generate a list of CSV-able data in the order
879 specified by propnames for the given node.
880 '''
881 properties = self.getprops()
882 l = []
883 for prop in propnames:
884 proptype = properties[prop]
885 value = self.get(nodeid, prop)
886 # "marshal" data where needed
887 if isinstance(proptype, hyperdb.Date):
888 value = value.get_tuple()
889 elif isinstance(proptype, hyperdb.Interval):
890 value = value.get_tuple()
891 elif isinstance(proptype, hyperdb.Password):
892 value = str(value)
893 l.append(repr(value))
894 return l
896 def import_list(self, propnames, proplist):
897 ''' Import a node - all information including "id" is present and
898 should not be sanity checked. Triggers are not triggered. The
899 journal should be initialised using the "creator" and "created"
900 information.
902 Return the nodeid of the node imported.
903 '''
904 if self.db.journaltag is None:
905 raise DatabaseError, 'Database open read-only'
906 properties = self.getprops()
908 # make the new node's property map
909 d = {}
910 for i in range(len(propnames)):
911 # Use eval to reverse the repr() used to output the CSV
912 value = eval(proplist[i])
914 # Figure the property for this column
915 propname = propnames[i]
916 prop = properties[propname]
918 # "unmarshal" where necessary
919 if propname == 'id':
920 newid = value
921 continue
922 elif isinstance(prop, hyperdb.Date):
923 value = date.Date(value)
924 elif isinstance(prop, hyperdb.Interval):
925 value = date.Interval(value)
926 elif isinstance(prop, hyperdb.Password):
927 pwd = password.Password()
928 pwd.unpack(value)
929 value = pwd
930 if value is not None:
931 d[propname] = value
933 # add
934 self.db.addnode(self.classname, newid, d)
935 self.db.addjournal(self.classname, newid, 'create', d)
936 return newid
938 def get(self, nodeid, propname, default=_marker, cache=1):
939 """Get the value of a property on an existing node of this class.
941 'nodeid' must be the id of an existing node of this class or an
942 IndexError is raised. 'propname' must be the name of a property
943 of this class or a KeyError is raised.
945 'cache' indicates whether the transaction cache should be queried
946 for the node. If the node has been modified and you need to
947 determine what its values prior to modification are, you need to
948 set cache=0.
950 Attempts to get the "creation" or "activity" properties should
951 do the right thing.
952 """
953 if propname == 'id':
954 return nodeid
956 if propname == 'creation':
957 if not self.do_journal:
958 raise ValueError, 'Journalling is disabled for this class'
959 journal = self.db.getjournal(self.classname, nodeid)
960 if journal:
961 return self.db.getjournal(self.classname, nodeid)[0][1]
962 else:
963 # on the strange chance that there's no journal
964 return date.Date()
965 if propname == 'activity':
966 if not self.do_journal:
967 raise ValueError, 'Journalling is disabled for this class'
968 journal = self.db.getjournal(self.classname, nodeid)
969 if journal:
970 return self.db.getjournal(self.classname, nodeid)[-1][1]
971 else:
972 # on the strange chance that there's no journal
973 return date.Date()
974 if propname == 'creator':
975 if not self.do_journal:
976 raise ValueError, 'Journalling is disabled for this class'
977 journal = self.db.getjournal(self.classname, nodeid)
978 if journal:
979 name = self.db.getjournal(self.classname, nodeid)[0][2]
980 else:
981 return None
982 return self.db.user.lookup(name)
984 # get the property (raises KeyErorr if invalid)
985 prop = self.properties[propname]
987 # get the node's dict
988 d = self.db.getnode(self.classname, nodeid, cache=cache)
990 if not d.has_key(propname):
991 if default is _marker:
992 if isinstance(prop, Multilink):
993 return []
994 else:
995 return None
996 else:
997 return default
999 return d[propname]
1001 # XXX not in spec
1002 def getnode(self, nodeid, cache=1):
1003 ''' Return a convenience wrapper for the node.
1005 'nodeid' must be the id of an existing node of this class or an
1006 IndexError is raised.
1008 'cache' indicates whether the transaction cache should be queried
1009 for the node. If the node has been modified and you need to
1010 determine what its values prior to modification are, you need to
1011 set cache=0.
1012 '''
1013 return Node(self, nodeid, cache=cache)
1015 def set(self, nodeid, **propvalues):
1016 """Modify a property on an existing node of this class.
1018 'nodeid' must be the id of an existing node of this class or an
1019 IndexError is raised.
1021 Each key in 'propvalues' must be the name of a property of this
1022 class or a KeyError is raised.
1024 All values in 'propvalues' must be acceptable types for their
1025 corresponding properties or a TypeError is raised.
1027 If the value of the key property is set, it must not collide with
1028 other key strings or a ValueError is raised.
1030 If the value of a Link or Multilink property contains an invalid
1031 node id, a ValueError is raised.
1033 These operations trigger detectors and can be vetoed. Attempts
1034 to modify the "creation" or "activity" properties cause a KeyError.
1035 """
1036 if not propvalues:
1037 return propvalues
1039 if propvalues.has_key('creation') or propvalues.has_key('activity'):
1040 raise KeyError, '"creation" and "activity" are reserved'
1042 if propvalues.has_key('id'):
1043 raise KeyError, '"id" is reserved'
1045 if self.db.journaltag is None:
1046 raise DatabaseError, 'Database open read-only'
1048 self.fireAuditors('set', nodeid, propvalues)
1049 # Take a copy of the node dict so that the subsequent set
1050 # operation doesn't modify the oldvalues structure.
1051 try:
1052 # try not using the cache initially
1053 oldvalues = copy.deepcopy(self.db.getnode(self.classname, nodeid,
1054 cache=0))
1055 except IndexError:
1056 # this will be needed if somone does a create() and set()
1057 # with no intervening commit()
1058 oldvalues = copy.deepcopy(self.db.getnode(self.classname, nodeid))
1060 node = self.db.getnode(self.classname, nodeid)
1061 if node.has_key(self.db.RETIRED_FLAG):
1062 raise IndexError
1063 num_re = re.compile('^\d+$')
1065 # if the journal value is to be different, store it in here
1066 journalvalues = {}
1068 for propname, value in propvalues.items():
1069 # check to make sure we're not duplicating an existing key
1070 if propname == self.key and node[propname] != value:
1071 try:
1072 self.lookup(value)
1073 except KeyError:
1074 pass
1075 else:
1076 raise ValueError, 'node with key "%s" exists'%value
1078 # this will raise the KeyError if the property isn't valid
1079 # ... we don't use getprops() here because we only care about
1080 # the writeable properties.
1081 prop = self.properties[propname]
1083 # if the value's the same as the existing value, no sense in
1084 # doing anything
1085 if node.has_key(propname) and value == node[propname]:
1086 del propvalues[propname]
1087 continue
1089 # do stuff based on the prop type
1090 if isinstance(prop, Link):
1091 link_class = prop.classname
1092 # if it isn't a number, it's a key
1093 if value is not None and not isinstance(value, type('')):
1094 raise ValueError, 'property "%s" link value be a string'%(
1095 propname)
1096 if isinstance(value, type('')) and not num_re.match(value):
1097 try:
1098 value = self.db.classes[link_class].lookup(value)
1099 except (TypeError, KeyError):
1100 raise IndexError, 'new property "%s": %s not a %s'%(
1101 propname, value, prop.classname)
1103 if (value is not None and
1104 not self.db.getclass(link_class).hasnode(value)):
1105 raise IndexError, '%s has no node %s'%(link_class, value)
1107 if self.do_journal and prop.do_journal:
1108 # register the unlink with the old linked node
1109 if node[propname] is not None:
1110 self.db.addjournal(link_class, node[propname], 'unlink',
1111 (self.classname, nodeid, propname))
1113 # register the link with the newly linked node
1114 if value is not None:
1115 self.db.addjournal(link_class, value, 'link',
1116 (self.classname, nodeid, propname))
1118 elif isinstance(prop, Multilink):
1119 if type(value) != type([]):
1120 raise TypeError, 'new property "%s" not a list of'\
1121 ' ids'%propname
1122 link_class = self.properties[propname].classname
1123 l = []
1124 for entry in value:
1125 # if it isn't a number, it's a key
1126 if type(entry) != type(''):
1127 raise ValueError, 'new property "%s" link value ' \
1128 'must be a string'%propname
1129 if not num_re.match(entry):
1130 try:
1131 entry = self.db.classes[link_class].lookup(entry)
1132 except (TypeError, KeyError):
1133 raise IndexError, 'new property "%s": %s not a %s'%(
1134 propname, entry,
1135 self.properties[propname].classname)
1136 l.append(entry)
1137 value = l
1138 propvalues[propname] = value
1140 # figure the journal entry for this property
1141 add = []
1142 remove = []
1144 # handle removals
1145 if node.has_key(propname):
1146 l = node[propname]
1147 else:
1148 l = []
1149 for id in l[:]:
1150 if id in value:
1151 continue
1152 # register the unlink with the old linked node
1153 if self.do_journal and self.properties[propname].do_journal:
1154 self.db.addjournal(link_class, id, 'unlink',
1155 (self.classname, nodeid, propname))
1156 l.remove(id)
1157 remove.append(id)
1159 # handle additions
1160 for id in value:
1161 if not self.db.getclass(link_class).hasnode(id):
1162 raise IndexError, '%s has no node %s'%(link_class, id)
1163 if id in l:
1164 continue
1165 # register the link with the newly linked node
1166 if self.do_journal and self.properties[propname].do_journal:
1167 self.db.addjournal(link_class, id, 'link',
1168 (self.classname, nodeid, propname))
1169 l.append(id)
1170 add.append(id)
1172 # figure the journal entry
1173 l = []
1174 if add:
1175 l.append(('add', add))
1176 if remove:
1177 l.append(('remove', remove))
1178 if l:
1179 journalvalues[propname] = tuple(l)
1181 elif isinstance(prop, String):
1182 if value is not None and type(value) != type(''):
1183 raise TypeError, 'new property "%s" not a string'%propname
1185 elif isinstance(prop, Password):
1186 if not isinstance(value, password.Password):
1187 raise TypeError, 'new property "%s" not a Password'%propname
1188 propvalues[propname] = value
1190 elif value is not None and isinstance(prop, Date):
1191 if not isinstance(value, date.Date):
1192 raise TypeError, 'new property "%s" not a Date'% propname
1193 propvalues[propname] = value
1195 elif value is not None and isinstance(prop, Interval):
1196 if not isinstance(value, date.Interval):
1197 raise TypeError, 'new property "%s" not an '\
1198 'Interval'%propname
1199 propvalues[propname] = value
1201 elif value is not None and isinstance(prop, Number):
1202 try:
1203 float(value)
1204 except ValueError:
1205 raise TypeError, 'new property "%s" not numeric'%propname
1207 elif value is not None and isinstance(prop, Boolean):
1208 try:
1209 int(value)
1210 except ValueError:
1211 raise TypeError, 'new property "%s" not boolean'%propname
1213 node[propname] = value
1215 # nothing to do?
1216 if not propvalues:
1217 return propvalues
1219 # do the set, and journal it
1220 self.db.setnode(self.classname, nodeid, node)
1222 if self.do_journal:
1223 propvalues.update(journalvalues)
1224 self.db.addjournal(self.classname, nodeid, 'set', propvalues)
1226 self.fireReactors('set', nodeid, oldvalues)
1228 return propvalues
1230 def retire(self, nodeid):
1231 """Retire a node.
1233 The properties on the node remain available from the get() method,
1234 and the node's id is never reused.
1236 Retired nodes are not returned by the find(), list(), or lookup()
1237 methods, and other nodes may reuse the values of their key properties.
1239 These operations trigger detectors and can be vetoed. Attempts
1240 to modify the "creation" or "activity" properties cause a KeyError.
1241 """
1242 if self.db.journaltag is None:
1243 raise DatabaseError, 'Database open read-only'
1245 self.fireAuditors('retire', nodeid, None)
1247 node = self.db.getnode(self.classname, nodeid)
1248 node[self.db.RETIRED_FLAG] = 1
1249 self.db.setnode(self.classname, nodeid, node)
1250 if self.do_journal:
1251 self.db.addjournal(self.classname, nodeid, 'retired', None)
1253 self.fireReactors('retire', nodeid, None)
1255 def is_retired(self, nodeid):
1256 '''Return true if the node is retired.
1257 '''
1258 node = self.db.getnode(cn, nodeid, cldb)
1259 if node.has_key(self.db.RETIRED_FLAG):
1260 return 1
1261 return 0
1263 def destroy(self, nodeid):
1264 """Destroy a node.
1266 WARNING: this method should never be used except in extremely rare
1267 situations where there could never be links to the node being
1268 deleted
1269 WARNING: use retire() instead
1270 WARNING: the properties of this node will not be available ever again
1271 WARNING: really, use retire() instead
1273 Well, I think that's enough warnings. This method exists mostly to
1274 support the session storage of the cgi interface.
1275 """
1276 if self.db.journaltag is None:
1277 raise DatabaseError, 'Database open read-only'
1278 self.db.destroynode(self.classname, nodeid)
1280 def history(self, nodeid):
1281 """Retrieve the journal of edits on a particular node.
1283 'nodeid' must be the id of an existing node of this class or an
1284 IndexError is raised.
1286 The returned list contains tuples of the form
1288 (date, tag, action, params)
1290 'date' is a Timestamp object specifying the time of the change and
1291 'tag' is the journaltag specified when the database was opened.
1292 """
1293 if not self.do_journal:
1294 raise ValueError, 'Journalling is disabled for this class'
1295 return self.db.getjournal(self.classname, nodeid)
1297 # Locating nodes:
1298 def hasnode(self, nodeid):
1299 '''Determine if the given nodeid actually exists
1300 '''
1301 return self.db.hasnode(self.classname, nodeid)
1303 def setkey(self, propname):
1304 """Select a String property of this class to be the key property.
1306 'propname' must be the name of a String property of this class or
1307 None, or a TypeError is raised. The values of the key property on
1308 all existing nodes must be unique or a ValueError is raised. If the
1309 property doesn't exist, KeyError is raised.
1310 """
1311 prop = self.getprops()[propname]
1312 if not isinstance(prop, String):
1313 raise TypeError, 'key properties must be String'
1314 self.key = propname
1316 def getkey(self):
1317 """Return the name of the key property for this class or None."""
1318 return self.key
1320 def labelprop(self, default_to_id=0):
1321 ''' Return the property name for a label for the given node.
1323 This method attempts to generate a consistent label for the node.
1324 It tries the following in order:
1325 1. key property
1326 2. "name" property
1327 3. "title" property
1328 4. first property from the sorted property name list
1329 '''
1330 k = self.getkey()
1331 if k:
1332 return k
1333 props = self.getprops()
1334 if props.has_key('name'):
1335 return 'name'
1336 elif props.has_key('title'):
1337 return 'title'
1338 if default_to_id:
1339 return 'id'
1340 props = props.keys()
1341 props.sort()
1342 return props[0]
1344 # TODO: set up a separate index db file for this? profile?
1345 def lookup(self, keyvalue):
1346 """Locate a particular node by its key property and return its id.
1348 If this class has no key property, a TypeError is raised. If the
1349 'keyvalue' matches one of the values for the key property among
1350 the nodes in this class, the matching node's id is returned;
1351 otherwise a KeyError is raised.
1352 """
1353 cldb = self.db.getclassdb(self.classname)
1354 try:
1355 for nodeid in self.db.getnodeids(self.classname, cldb):
1356 node = self.db.getnode(self.classname, nodeid, cldb)
1357 if node.has_key(self.db.RETIRED_FLAG):
1358 continue
1359 if node[self.key] == keyvalue:
1360 cldb.close()
1361 return nodeid
1362 finally:
1363 cldb.close()
1364 raise KeyError, keyvalue
1366 # XXX: change from spec - allows multiple props to match
1367 def find(self, **propspec):
1368 """Get the ids of nodes in this class which link to the given nodes.
1370 'propspec' consists of keyword args propname={nodeid:1,}
1371 'propname' must be the name of a property in this class, or a
1372 KeyError is raised. That property must be a Link or Multilink
1373 property, or a TypeError is raised.
1375 Any node in this class whose 'propname' property links to any of the
1376 nodeids will be returned. Used by the full text indexing, which knows
1377 that "foo" occurs in msg1, msg3 and file7, so we have hits on these issues:
1378 db.issue.find(messages={'1':1,'3':1}, files={'7':1})
1379 """
1380 propspec = propspec.items()
1381 for propname, nodeids in propspec:
1382 # check the prop is OK
1383 prop = self.properties[propname]
1384 if not isinstance(prop, Link) and not isinstance(prop, Multilink):
1385 raise TypeError, "'%s' not a Link/Multilink property"%propname
1387 # ok, now do the find
1388 cldb = self.db.getclassdb(self.classname)
1389 l = []
1390 try:
1391 for id in self.db.getnodeids(self.classname, db=cldb):
1392 node = self.db.getnode(self.classname, id, db=cldb)
1393 if node.has_key(self.db.RETIRED_FLAG):
1394 continue
1395 for propname, nodeids in propspec:
1396 # can't test if the node doesn't have this property
1397 if not node.has_key(propname):
1398 continue
1399 if type(nodeids) is type(''):
1400 nodeids = {nodeids:1}
1401 prop = self.properties[propname]
1402 value = node[propname]
1403 if isinstance(prop, Link) and nodeids.has_key(value):
1404 l.append(id)
1405 break
1406 elif isinstance(prop, Multilink):
1407 hit = 0
1408 for v in value:
1409 if nodeids.has_key(v):
1410 l.append(id)
1411 hit = 1
1412 break
1413 if hit:
1414 break
1415 finally:
1416 cldb.close()
1417 return l
1419 def stringFind(self, **requirements):
1420 """Locate a particular node by matching a set of its String
1421 properties in a caseless search.
1423 If the property is not a String property, a TypeError is raised.
1425 The return is a list of the id of all nodes that match.
1426 """
1427 for propname in requirements.keys():
1428 prop = self.properties[propname]
1429 if isinstance(not prop, String):
1430 raise TypeError, "'%s' not a String property"%propname
1431 requirements[propname] = requirements[propname].lower()
1432 l = []
1433 cldb = self.db.getclassdb(self.classname)
1434 try:
1435 for nodeid in self.db.getnodeids(self.classname, cldb):
1436 node = self.db.getnode(self.classname, nodeid, cldb)
1437 if node.has_key(self.db.RETIRED_FLAG):
1438 continue
1439 for key, value in requirements.items():
1440 if node[key] is None or node[key].lower() != value:
1441 break
1442 else:
1443 l.append(nodeid)
1444 finally:
1445 cldb.close()
1446 return l
1448 def list(self):
1449 """Return a list of the ids of the active nodes in this class."""
1450 l = []
1451 cn = self.classname
1452 cldb = self.db.getclassdb(cn)
1453 try:
1454 for nodeid in self.db.getnodeids(cn, cldb):
1455 node = self.db.getnode(cn, nodeid, cldb)
1456 if node.has_key(self.db.RETIRED_FLAG):
1457 continue
1458 l.append(nodeid)
1459 finally:
1460 cldb.close()
1461 l.sort()
1462 return l
1464 def filter(self, search_matches, filterspec, sort, group,
1465 num_re = re.compile('^\d+$')):
1466 ''' Return a list of the ids of the active nodes in this class that
1467 match the 'filter' spec, sorted by the group spec and then the
1468 sort spec.
1470 "filterspec" is {propname: value(s)}
1471 "sort" is ['+propname', '-propname', 'propname', ...]
1472 "group is ['+propname', '-propname', 'propname', ...]
1473 '''
1474 cn = self.classname
1476 # optimise filterspec
1477 l = []
1478 props = self.getprops()
1479 LINK = 0
1480 MULTILINK = 1
1481 STRING = 2
1482 OTHER = 6
1483 for k, v in filterspec.items():
1484 propclass = props[k]
1485 if isinstance(propclass, Link):
1486 if type(v) is not type([]):
1487 v = [v]
1488 # replace key values with node ids
1489 u = []
1490 link_class = self.db.classes[propclass.classname]
1491 for entry in v:
1492 if entry == '-1': entry = None
1493 elif not num_re.match(entry):
1494 try:
1495 entry = link_class.lookup(entry)
1496 except (TypeError,KeyError):
1497 raise ValueError, 'property "%s": %s not a %s'%(
1498 k, entry, self.properties[k].classname)
1499 u.append(entry)
1501 l.append((LINK, k, u))
1502 elif isinstance(propclass, Multilink):
1503 if type(v) is not type([]):
1504 v = [v]
1505 # replace key values with node ids
1506 u = []
1507 link_class = self.db.classes[propclass.classname]
1508 for entry in v:
1509 if not num_re.match(entry):
1510 try:
1511 entry = link_class.lookup(entry)
1512 except (TypeError,KeyError):
1513 raise ValueError, 'new property "%s": %s not a %s'%(
1514 k, entry, self.properties[k].classname)
1515 u.append(entry)
1516 l.append((MULTILINK, k, u))
1517 elif isinstance(propclass, String):
1518 # simple glob searching
1519 v = re.sub(r'([\|\{\}\\\.\+\[\]\(\)])', r'\\\1', v)
1520 v = v.replace('?', '.')
1521 v = v.replace('*', '.*?')
1522 l.append((STRING, k, re.compile(v, re.I)))
1523 elif isinstance(propclass, Boolean):
1524 if type(v) is type(''):
1525 bv = v.lower() in ('yes', 'true', 'on', '1')
1526 else:
1527 bv = v
1528 l.append((OTHER, k, bv))
1529 elif isinstance(propclass, Number):
1530 l.append((OTHER, k, int(v)))
1531 else:
1532 l.append((OTHER, k, v))
1533 filterspec = l
1535 # now, find all the nodes that are active and pass filtering
1536 l = []
1537 cldb = self.db.getclassdb(cn)
1538 try:
1539 # TODO: only full-scan once (use items())
1540 for nodeid in self.db.getnodeids(cn, cldb):
1541 node = self.db.getnode(cn, nodeid, cldb)
1542 if node.has_key(self.db.RETIRED_FLAG):
1543 continue
1544 # apply filter
1545 for t, k, v in filterspec:
1546 # make sure the node has the property
1547 if not node.has_key(k):
1548 # this node doesn't have this property, so reject it
1549 break
1551 # now apply the property filter
1552 if t == LINK:
1553 # link - if this node's property doesn't appear in the
1554 # filterspec's nodeid list, skip it
1555 if node[k] not in v:
1556 break
1557 elif t == MULTILINK:
1558 # multilink - if any of the nodeids required by the
1559 # filterspec aren't in this node's property, then skip
1560 # it
1561 have = node[k]
1562 for want in v:
1563 if want not in have:
1564 break
1565 else:
1566 continue
1567 break
1568 elif t == STRING:
1569 # RE search
1570 if node[k] is None or not v.search(node[k]):
1571 break
1572 elif t == OTHER:
1573 # straight value comparison for the other types
1574 if node[k] != v:
1575 break
1576 else:
1577 l.append((nodeid, node))
1578 finally:
1579 cldb.close()
1580 l.sort()
1582 # filter based on full text search
1583 if search_matches is not None:
1584 k = []
1585 for v in l:
1586 if search_matches.has_key(v[0]):
1587 k.append(v)
1588 l = k
1590 # optimise sort
1591 m = []
1592 for entry in sort:
1593 if entry[0] != '-':
1594 m.append(('+', entry))
1595 else:
1596 m.append((entry[0], entry[1:]))
1597 sort = m
1599 # optimise group
1600 m = []
1601 for entry in group:
1602 if entry[0] != '-':
1603 m.append(('+', entry))
1604 else:
1605 m.append((entry[0], entry[1:]))
1606 group = m
1607 # now, sort the result
1608 def sortfun(a, b, sort=sort, group=group, properties=self.getprops(),
1609 db = self.db, cl=self):
1610 a_id, an = a
1611 b_id, bn = b
1612 # sort by group and then sort
1613 for list in group, sort:
1614 for dir, prop in list:
1615 # sorting is class-specific
1616 propclass = properties[prop]
1618 # handle the properties that might be "faked"
1619 # also, handle possible missing properties
1620 try:
1621 if not an.has_key(prop):
1622 an[prop] = cl.get(a_id, prop)
1623 av = an[prop]
1624 except KeyError:
1625 # the node doesn't have a value for this property
1626 if isinstance(propclass, Multilink): av = []
1627 else: av = ''
1628 try:
1629 if not bn.has_key(prop):
1630 bn[prop] = cl.get(b_id, prop)
1631 bv = bn[prop]
1632 except KeyError:
1633 # the node doesn't have a value for this property
1634 if isinstance(propclass, Multilink): bv = []
1635 else: bv = ''
1637 # String and Date values are sorted in the natural way
1638 if isinstance(propclass, String):
1639 # clean up the strings
1640 if av and av[0] in string.uppercase:
1641 av = an[prop] = av.lower()
1642 if bv and bv[0] in string.uppercase:
1643 bv = bn[prop] = bv.lower()
1644 if (isinstance(propclass, String) or
1645 isinstance(propclass, Date)):
1646 # it might be a string that's really an integer
1647 try:
1648 av = int(av)
1649 bv = int(bv)
1650 except:
1651 pass
1652 if dir == '+':
1653 r = cmp(av, bv)
1654 if r != 0: return r
1655 elif dir == '-':
1656 r = cmp(bv, av)
1657 if r != 0: return r
1659 # Link properties are sorted according to the value of
1660 # the "order" property on the linked nodes if it is
1661 # present; or otherwise on the key string of the linked
1662 # nodes; or finally on the node ids.
1663 elif isinstance(propclass, Link):
1664 link = db.classes[propclass.classname]
1665 if av is None and bv is not None: return -1
1666 if av is not None and bv is None: return 1
1667 if av is None and bv is None: continue
1668 if link.getprops().has_key('order'):
1669 if dir == '+':
1670 r = cmp(link.get(av, 'order'),
1671 link.get(bv, 'order'))
1672 if r != 0: return r
1673 elif dir == '-':
1674 r = cmp(link.get(bv, 'order'),
1675 link.get(av, 'order'))
1676 if r != 0: return r
1677 elif link.getkey():
1678 key = link.getkey()
1679 if dir == '+':
1680 r = cmp(link.get(av, key), link.get(bv, key))
1681 if r != 0: return r
1682 elif dir == '-':
1683 r = cmp(link.get(bv, key), link.get(av, key))
1684 if r != 0: return r
1685 else:
1686 if dir == '+':
1687 r = cmp(av, bv)
1688 if r != 0: return r
1689 elif dir == '-':
1690 r = cmp(bv, av)
1691 if r != 0: return r
1693 # Multilink properties are sorted according to how many
1694 # links are present.
1695 elif isinstance(propclass, Multilink):
1696 if dir == '+':
1697 r = cmp(len(av), len(bv))
1698 if r != 0: return r
1699 elif dir == '-':
1700 r = cmp(len(bv), len(av))
1701 if r != 0: return r
1702 elif isinstance(propclass, Number) or isinstance(propclass, Boolean):
1703 if dir == '+':
1704 r = cmp(av, bv)
1705 elif dir == '-':
1706 r = cmp(bv, av)
1708 # end for dir, prop in list:
1709 # end for list in sort, group:
1710 # if all else fails, compare the ids
1711 return cmp(a[0], b[0])
1713 l.sort(sortfun)
1714 return [i[0] for i in l]
1716 def count(self):
1717 """Get the number of nodes in this class.
1719 If the returned integer is 'numnodes', the ids of all the nodes
1720 in this class run from 1 to numnodes, and numnodes+1 will be the
1721 id of the next node to be created in this class.
1722 """
1723 return self.db.countnodes(self.classname)
1725 # Manipulating properties:
1727 def getprops(self, protected=1):
1728 """Return a dictionary mapping property names to property objects.
1729 If the "protected" flag is true, we include protected properties -
1730 those which may not be modified.
1732 In addition to the actual properties on the node, these
1733 methods provide the "creation" and "activity" properties. If the
1734 "protected" flag is true, we include protected properties - those
1735 which may not be modified.
1736 """
1737 d = self.properties.copy()
1738 if protected:
1739 d['id'] = String()
1740 d['creation'] = hyperdb.Date()
1741 d['activity'] = hyperdb.Date()
1742 d['creator'] = hyperdb.Link("user")
1743 return d
1745 def addprop(self, **properties):
1746 """Add properties to this class.
1748 The keyword arguments in 'properties' must map names to property
1749 objects, or a TypeError is raised. None of the keys in 'properties'
1750 may collide with the names of existing properties, or a ValueError
1751 is raised before any properties have been added.
1752 """
1753 for key in properties.keys():
1754 if self.properties.has_key(key):
1755 raise ValueError, key
1756 self.properties.update(properties)
1758 def index(self, nodeid):
1759 '''Add (or refresh) the node to search indexes
1760 '''
1761 # find all the String properties that have indexme
1762 for prop, propclass in self.getprops().items():
1763 if isinstance(propclass, String) and propclass.indexme:
1764 try:
1765 value = str(self.get(nodeid, prop))
1766 except IndexError:
1767 # node no longer exists - entry should be removed
1768 self.db.indexer.purge_entry((self.classname, nodeid, prop))
1769 else:
1770 # and index them under (classname, nodeid, property)
1771 self.db.indexer.add_text((self.classname, nodeid, prop),
1772 value)
1774 #
1775 # Detector interface
1776 #
1777 def audit(self, event, detector):
1778 """Register a detector
1779 """
1780 l = self.auditors[event]
1781 if detector not in l:
1782 self.auditors[event].append(detector)
1784 def fireAuditors(self, action, nodeid, newvalues):
1785 """Fire all registered auditors.
1786 """
1787 for audit in self.auditors[action]:
1788 audit(self.db, self, nodeid, newvalues)
1790 def react(self, event, detector):
1791 """Register a detector
1792 """
1793 l = self.reactors[event]
1794 if detector not in l:
1795 self.reactors[event].append(detector)
1797 def fireReactors(self, action, nodeid, oldvalues):
1798 """Fire all registered reactors.
1799 """
1800 for react in self.reactors[action]:
1801 react(self.db, self, nodeid, oldvalues)
1803 class FileClass(Class):
1804 '''This class defines a large chunk of data. To support this, it has a
1805 mandatory String property "content" which is typically saved off
1806 externally to the hyperdb.
1808 The default MIME type of this data is defined by the
1809 "default_mime_type" class attribute, which may be overridden by each
1810 node if the class defines a "type" String property.
1811 '''
1812 default_mime_type = 'text/plain'
1814 def create(self, **propvalues):
1815 ''' snaffle the file propvalue and store in a file
1816 '''
1817 content = propvalues['content']
1818 del propvalues['content']
1819 newid = Class.create(self, **propvalues)
1820 self.db.storefile(self.classname, newid, None, content)
1821 return newid
1823 def import_list(self, propnames, proplist):
1824 ''' Trap the "content" property...
1825 '''
1826 # dupe this list so we don't affect others
1827 propnames = propnames[:]
1829 # extract the "content" property from the proplist
1830 i = propnames.index('content')
1831 content = proplist[i]
1832 del propnames[i]
1833 del proplist[i]
1835 # do the normal import
1836 newid = Class.import_list(self, propnames, proplist)
1838 # save off the "content" file
1839 self.db.storefile(self.classname, newid, None, content)
1840 return newid
1842 def get(self, nodeid, propname, default=_marker, cache=1):
1843 ''' trap the content propname and get it from the file
1844 '''
1846 poss_msg = 'Possibly a access right configuration problem.'
1847 if propname == 'content':
1848 try:
1849 return self.db.getfile(self.classname, nodeid, None)
1850 except IOError, (strerror):
1851 # BUG: by catching this we donot see an error in the log.
1852 return 'ERROR reading file: %s%s\n%s\n%s'%(
1853 self.classname, nodeid, poss_msg, strerror)
1854 if default is not _marker:
1855 return Class.get(self, nodeid, propname, default, cache=cache)
1856 else:
1857 return Class.get(self, nodeid, propname, cache=cache)
1859 def getprops(self, protected=1):
1860 ''' In addition to the actual properties on the node, these methods
1861 provide the "content" property. If the "protected" flag is true,
1862 we include protected properties - those which may not be
1863 modified.
1864 '''
1865 d = Class.getprops(self, protected=protected).copy()
1866 if protected:
1867 d['content'] = hyperdb.String()
1868 return d
1870 def index(self, nodeid):
1871 ''' Index the node in the search index.
1873 We want to index the content in addition to the normal String
1874 property indexing.
1875 '''
1876 # perform normal indexing
1877 Class.index(self, nodeid)
1879 # get the content to index
1880 content = self.get(nodeid, 'content')
1882 # figure the mime type
1883 if self.properties.has_key('type'):
1884 mime_type = self.get(nodeid, 'type')
1885 else:
1886 mime_type = self.default_mime_type
1888 # and index!
1889 self.db.indexer.add_text((self.classname, nodeid, 'content'), content,
1890 mime_type)
1892 # XXX deviation from spec - was called ItemClass
1893 class IssueClass(Class, roundupdb.IssueClass):
1894 # Overridden methods:
1895 def __init__(self, db, classname, **properties):
1896 """The newly-created class automatically includes the "messages",
1897 "files", "nosy", and "superseder" properties. If the 'properties'
1898 dictionary attempts to specify any of these properties or a
1899 "creation" or "activity" property, a ValueError is raised.
1900 """
1901 if not properties.has_key('title'):
1902 properties['title'] = hyperdb.String(indexme='yes')
1903 if not properties.has_key('messages'):
1904 properties['messages'] = hyperdb.Multilink("msg")
1905 if not properties.has_key('files'):
1906 properties['files'] = hyperdb.Multilink("file")
1907 if not properties.has_key('nosy'):
1908 properties['nosy'] = hyperdb.Multilink("user")
1909 if not properties.has_key('superseder'):
1910 properties['superseder'] = hyperdb.Multilink(classname)
1911 Class.__init__(self, db, classname, **properties)
1913 #
1914 #$Log: not supported by cvs2svn $
1915 #Revision 1.61 2002/08/19 02:53:27 richard
1916 #full database export and import is done
1917 #
1918 #Revision 1.60 2002/08/19 00:23:19 richard
1919 #handle "unset" initial Link values (!)
1920 #
1921 #Revision 1.59 2002/08/16 04:28:13 richard
1922 #added is_retired query to Class
1923 #
1924 #Revision 1.58 2002/08/01 15:06:24 gmcm
1925 #Use same regex to split search terms as used to index text.
1926 #Fix to back_metakit for not changing journaltag on reopen.
1927 #Fix htmltemplate's do_link so [No <whatever>] strings are href'd.
1928 #Fix bogus "nosy edited ok" msg - the **d syntax does NOT share d between caller and callee.
1929 #
1930 #Revision 1.57 2002/07/31 23:57:36 richard
1931 # . web forms may now unset Link values (like assignedto)
1932 #
1933 #Revision 1.56 2002/07/31 22:04:33 richard
1934 #cleanup
1935 #
1936 #Revision 1.55 2002/07/30 08:22:38 richard
1937 #Session storage in the hyperdb was horribly, horribly inefficient. We use
1938 #a simple anydbm wrapper now - which could be overridden by the metakit
1939 #backend or RDB backend if necessary.
1940 #Much, much better.
1941 #
1942 #Revision 1.54 2002/07/26 08:26:59 richard
1943 #Very close now. The cgi and mailgw now use the new security API. The two
1944 #templates have been migrated to that setup. Lots of unit tests. Still some
1945 #issue in the web form for editing Roles assigned to users.
1946 #
1947 #Revision 1.53 2002/07/25 07:14:06 richard
1948 #Bugger it. Here's the current shape of the new security implementation.
1949 #Still to do:
1950 # . call the security funcs from cgi and mailgw
1951 # . change shipped templates to include correct initialisation and remove
1952 # the old config vars
1953 #... that seems like a lot. The bulk of the work has been done though. Honest :)
1954 #
1955 #Revision 1.52 2002/07/19 03:36:34 richard
1956 #Implemented the destroy() method needed by the session database (and possibly
1957 #others). At the same time, I removed the leading underscores from the hyperdb
1958 #methods that Really Didn't Need Them.
1959 #The journal also raises IndexError now for all situations where there is a
1960 #request for the journal of a node that doesn't have one. It used to return
1961 #[] in _some_ situations, but not all. This _may_ break code, but the tests
1962 #pass...
1963 #
1964 #Revision 1.51 2002/07/18 23:07:08 richard
1965 #Unit tests and a few fixes.
1966 #
1967 #Revision 1.50 2002/07/18 11:50:58 richard
1968 #added tests for number type too
1969 #
1970 #Revision 1.49 2002/07/18 11:41:10 richard
1971 #added tests for boolean type, and fixes to anydbm backend
1972 #
1973 #Revision 1.48 2002/07/18 11:17:31 gmcm
1974 #Add Number and Boolean types to hyperdb.
1975 #Add conversion cases to web, mail & admin interfaces.
1976 #Add storage/serialization cases to back_anydbm & back_metakit.
1977 #
1978 #Revision 1.47 2002/07/14 23:18:20 richard
1979 #. fixed the journal bloat from multilink changes - we just log the add or
1980 # remove operations, not the whole list
1981 #
1982 #Revision 1.46 2002/07/14 06:06:34 richard
1983 #Did some old TODOs
1984 #
1985 #Revision 1.45 2002/07/14 04:03:14 richard
1986 #Implemented a switch to disable journalling for a Class. CGI session
1987 #database now uses it.
1988 #
1989 #Revision 1.44 2002/07/14 02:05:53 richard
1990 #. all storage-specific code (ie. backend) is now implemented by the backends
1991 #
1992 #Revision 1.43 2002/07/10 06:30:30 richard
1993 #...except of course it's nice to use valid Python syntax
1994 #
1995 #Revision 1.42 2002/07/10 06:21:38 richard
1996 #Be extra safe
1997 #
1998 #Revision 1.41 2002/07/10 00:21:45 richard
1999 #explicit database closing
2000 #
2001 #Revision 1.40 2002/07/09 04:19:09 richard
2002 #Added reindex command to roundup-admin.
2003 #Fixed reindex on first access.
2004 #Also fixed reindexing of entries that change.
2005 #
2006 #Revision 1.39 2002/07/09 03:02:52 richard
2007 #More indexer work:
2008 #- all String properties may now be indexed too. Currently there's a bit of
2009 # "issue" specific code in the actual searching which needs to be
2010 # addressed. In a nutshell:
2011 # + pass 'indexme="yes"' as a String() property initialisation arg, eg:
2012 # file = FileClass(db, "file", name=String(), type=String(),
2013 # comment=String(indexme="yes"))
2014 # + the comment will then be indexed and be searchable, with the results
2015 # related back to the issue that the file is linked to
2016 #- as a result of this work, the FileClass has a default MIME type that may
2017 # be overridden in a subclass, or by the use of a "type" property as is
2018 # done in the default templates.
2019 #- the regeneration of the indexes (if necessary) is done once the schema is
2020 # set up in the dbinit.
2021 #
2022 #Revision 1.38 2002/07/08 06:58:15 richard
2023 #cleaned up the indexer code:
2024 # - it splits more words out (much simpler, faster splitter)
2025 # - removed code we'll never use (roundup.roundup_indexer has the full
2026 # implementation, and replaces roundup.indexer)
2027 # - only index text/plain and rfc822/message (ideas for other text formats to
2028 # index are welcome)
2029 # - added simple unit test for indexer. Needs more tests for regression.
2030 #
2031 #Revision 1.37 2002/06/20 23:52:35 richard
2032 #More informative error message
2033 #
2034 #Revision 1.36 2002/06/19 03:07:19 richard
2035 #Moved the file storage commit into blobfiles where it belongs.
2036 #
2037 #Revision 1.35 2002/05/25 07:16:24 rochecompaan
2038 #Merged search_indexing-branch with HEAD
2039 #
2040 #Revision 1.34 2002/05/15 06:21:21 richard
2041 # . node caching now works, and gives a small boost in performance
2042 #
2043 #As a part of this, I cleaned up the DEBUG output and implemented TRACE
2044 #output (HYPERDBTRACE='file to trace to') with checkpoints at the start of
2045 #CGI requests. Run roundup with python -O to skip all the DEBUG/TRACE stuff
2046 #(using if __debug__ which is compiled out with -O)
2047 #
2048 #Revision 1.33 2002/04/24 10:38:26 rochecompaan
2049 #All database files are now created group readable and writable.
2050 #
2051 #Revision 1.32 2002/04/15 23:25:15 richard
2052 #. node ids are now generated from a lockable store - no more race conditions
2053 #
2054 #We're using the portalocker code by Jonathan Feinberg that was contributed
2055 #to the ASPN Python cookbook. This gives us locking across Unix and Windows.
2056 #
2057 #Revision 1.31 2002/04/03 05:54:31 richard
2058 #Fixed serialisation problem by moving the serialisation step out of the
2059 #hyperdb.Class (get, set) into the hyperdb.Database.
2060 #
2061 #Also fixed htmltemplate after the showid changes I made yesterday.
2062 #
2063 #Unit tests for all of the above written.
2064 #
2065 #Revision 1.30.2.1 2002/04/03 11:55:57 rochecompaan
2066 # . Added feature #526730 - search for messages capability
2067 #
2068 #Revision 1.30 2002/02/27 03:40:59 richard
2069 #Ran it through pychecker, made fixes
2070 #
2071 #Revision 1.29 2002/02/25 14:34:31 grubert
2072 # . use blobfiles in back_anydbm which is used in back_bsddb.
2073 # change test_db as dirlist does not work for subdirectories.
2074 # ATTENTION: blobfiles now creates subdirectories for files.
2075 #
2076 #Revision 1.28 2002/02/16 09:14:17 richard
2077 # . #514854 ] History: "User" is always ticket creator
2078 #
2079 #Revision 1.27 2002/01/22 07:21:13 richard
2080 #. fixed back_bsddb so it passed the journal tests
2081 #
2082 #... it didn't seem happy using the back_anydbm _open method, which is odd.
2083 #Yet another occurrance of whichdb not being able to recognise older bsddb
2084 #databases. Yadda yadda. Made the HYPERDBDEBUG stuff more sane in the
2085 #process.
2086 #
2087 #Revision 1.26 2002/01/22 05:18:38 rochecompaan
2088 #last_set_entry was referenced before assignment
2089 #
2090 #Revision 1.25 2002/01/22 05:06:08 rochecompaan
2091 #We need to keep the last 'set' entry in the journal to preserve
2092 #information on 'activity' for nodes.
2093 #
2094 #Revision 1.24 2002/01/21 16:33:20 rochecompaan
2095 #You can now use the roundup-admin tool to pack the database
2096 #
2097 #Revision 1.23 2002/01/18 04:32:04 richard
2098 #Rollback was breaking because a message hadn't actually been written to the file. Needs
2099 #more investigation.
2100 #
2101 #Revision 1.22 2002/01/14 02:20:15 richard
2102 # . changed all config accesses so they access either the instance or the
2103 # config attriubute on the db. This means that all config is obtained from
2104 # instance_config instead of the mish-mash of classes. This will make
2105 # switching to a ConfigParser setup easier too, I hope.
2106 #
2107 #At a minimum, this makes migration a _little_ easier (a lot easier in the
2108 #0.5.0 switch, I hope!)
2109 #
2110 #Revision 1.21 2002/01/02 02:31:38 richard
2111 #Sorry for the huge checkin message - I was only intending to implement #496356
2112 #but I found a number of places where things had been broken by transactions:
2113 # . modified ROUNDUPDBSENDMAILDEBUG to be SENDMAILDEBUG and hold a filename
2114 # for _all_ roundup-generated smtp messages to be sent to.
2115 # . the transaction cache had broken the roundupdb.Class set() reactors
2116 # . newly-created author users in the mailgw weren't being committed to the db
2117 #
2118 #Stuff that made it into CHANGES.txt (ie. the stuff I was actually working
2119 #on when I found that stuff :):
2120 # . #496356 ] Use threading in messages
2121 # . detectors were being registered multiple times
2122 # . added tests for mailgw
2123 # . much better attaching of erroneous messages in the mail gateway
2124 #
2125 #Revision 1.20 2001/12/18 15:30:34 rochecompaan
2126 #Fixed bugs:
2127 # . Fixed file creation and retrieval in same transaction in anydbm
2128 # backend
2129 # . Cgi interface now renders new issue after issue creation
2130 # . Could not set issue status to resolved through cgi interface
2131 # . Mail gateway was changing status back to 'chatting' if status was
2132 # omitted as an argument
2133 #
2134 #Revision 1.19 2001/12/17 03:52:48 richard
2135 #Implemented file store rollback. As a bonus, the hyperdb is now capable of
2136 #storing more than one file per node - if a property name is supplied,
2137 #the file is called designator.property.
2138 #I decided not to migrate the existing files stored over to the new naming
2139 #scheme - the FileClass just doesn't specify the property name.
2140 #
2141 #Revision 1.18 2001/12/16 10:53:38 richard
2142 #take a copy of the node dict so that the subsequent set
2143 #operation doesn't modify the oldvalues structure
2144 #
2145 #Revision 1.17 2001/12/14 23:42:57 richard
2146 #yuck, a gdbm instance tests false :(
2147 #I've left the debugging code in - it should be removed one day if we're ever
2148 #_really_ anal about performace :)
2149 #
2150 #Revision 1.16 2001/12/12 03:23:14 richard
2151 #Cor blimey this anydbm/whichdb stuff is yecchy. Turns out that whichdb
2152 #incorrectly identifies a dbm file as a dbhash file on my system. This has
2153 #been submitted to the python bug tracker as issue #491888:
2154 #https://sourceforge.net/tracker/index.php?func=detail&aid=491888&group_id=5470&atid=105470
2155 #
2156 #Revision 1.15 2001/12/12 02:30:51 richard
2157 #I fixed the problems with people whose anydbm was using the dbm module at the
2158 #backend. It turns out the dbm module modifies the file name to append ".db"
2159 #and my check to determine if we're opening an existing or new db just
2160 #tested os.path.exists() on the filename. Well, no longer! We now perform a
2161 #much better check _and_ cope with the anydbm implementation module changing
2162 #too!
2163 #I also fixed the backends __init__ so only ImportError is squashed.
2164 #
2165 #Revision 1.14 2001/12/10 22:20:01 richard
2166 #Enabled transaction support in the bsddb backend. It uses the anydbm code
2167 #where possible, only replacing methods where the db is opened (it uses the
2168 #btree opener specifically.)
2169 #Also cleaned up some change note generation.
2170 #Made the backends package work with pydoc too.
2171 #
2172 #Revision 1.13 2001/12/02 05:06:16 richard
2173 #. We now use weakrefs in the Classes to keep the database reference, so
2174 # the close() method on the database is no longer needed.
2175 # I bumped the minimum python requirement up to 2.1 accordingly.
2176 #. #487480 ] roundup-server
2177 #. #487476 ] INSTALL.txt
2178 #
2179 #I also cleaned up the change message / post-edit stuff in the cgi client.
2180 #There's now a clearly marked "TODO: append the change note" where I believe
2181 #the change note should be added there. The "changes" list will obviously
2182 #have to be modified to be a dict of the changes, or somesuch.
2183 #
2184 #More testing needed.
2185 #
2186 #Revision 1.12 2001/12/01 07:17:50 richard
2187 #. We now have basic transaction support! Information is only written to
2188 # the database when the commit() method is called. Only the anydbm
2189 # backend is modified in this way - neither of the bsddb backends have been.
2190 # The mail, admin and cgi interfaces all use commit (except the admin tool
2191 # doesn't have a commit command, so interactive users can't commit...)
2192 #. Fixed login/registration forwarding the user to the right page (or not,
2193 # on a failure)
2194 #
2195 #Revision 1.11 2001/11/21 02:34:18 richard
2196 #Added a target version field to the extended issue schema
2197 #
2198 #Revision 1.10 2001/10/09 23:58:10 richard
2199 #Moved the data stringification up into the hyperdb.Class class' get, set
2200 #and create methods. This means that the data is also stringified for the
2201 #journal call, and removes duplication of code from the backends. The
2202 #backend code now only sees strings.
2203 #
2204 #Revision 1.9 2001/10/09 07:25:59 richard
2205 #Added the Password property type. See "pydoc roundup.password" for
2206 #implementation details. Have updated some of the documentation too.
2207 #
2208 #Revision 1.8 2001/09/29 13:27:00 richard
2209 #CGI interfaces now spit up a top-level index of all the instances they can
2210 #serve.
2211 #
2212 #Revision 1.7 2001/08/12 06:32:36 richard
2213 #using isinstance(blah, Foo) now instead of isFooType
2214 #
2215 #Revision 1.6 2001/08/07 00:24:42 richard
2216 #stupid typo
2217 #
2218 #Revision 1.5 2001/08/07 00:15:51 richard
2219 #Added the copyright/license notice to (nearly) all files at request of
2220 #Bizar Software.
2221 #
2222 #Revision 1.4 2001/07/30 01:41:36 richard
2223 #Makes schema changes mucho easier.
2224 #
2225 #Revision 1.3 2001/07/25 01:23:07 richard
2226 #Added the Roundup spec to the new documentation directory.
2227 #
2228 #Revision 1.2 2001/07/23 08:20:44 richard
2229 #Moved over to using marshal in the bsddb and anydbm backends.
2230 #roundup-admin now has a "freshen" command that'll load/save all nodes (not
2231 # retired - mod hyperdb.Class.list() so it lists retired nodes)
2232 #
2233 #