Code

fa550fdc84b5dd9a3c6f1b0db62a1378c775d8bd
[roundup.git] / roundup / backends / back_anydbm.py
1 #
2 # Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/)
3 # This module is free software, and you may redistribute it and/or modify
4 # under the same terms as Python, so long as this copyright message and
5 # disclaimer are retained in their original form.
6 #
7 # IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR
8 # DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING
9 # OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE
10 # POSSIBILITY OF SUCH DAMAGE.
11 #
12 # BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING,
13 # BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
14 # FOR A PARTICULAR PURPOSE.  THE CODE PROVIDED HEREUNDER IS ON AN "AS IS"
15 # BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
16 # SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
17
18 #$Id: back_anydbm.py,v 1.50 2002-07-18 11:50:58 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
28 from blobfiles import FileStorage
29 from roundup.indexer import Indexer
30 from locking import acquire_lock, release_lock
31 from roundup.hyperdb import String, Password, Date, Interval, Link, \
32     Multilink, DatabaseError, Boolean, Number
34 #
35 # Now the database
36 #
37 class Database(FileStorage, hyperdb.Database, roundupdb.Database):
38     """A database for storing records containing flexible data types.
40     Transaction stuff TODO:
41         . check the timestamp of the class file and nuke the cache if it's
42           modified. Do some sort of conflict checking on the dirty stuff.
43         . perhaps detect write collisions (related to above)?
45     """
46     def __init__(self, config, journaltag=None):
47         """Open a hyperdatabase given a specifier to some storage.
49         The 'storagelocator' is obtained from config.DATABASE.
50         The meaning of 'storagelocator' depends on the particular
51         implementation of the hyperdatabase.  It could be a file name,
52         a directory path, a socket descriptor for a connection to a
53         database over the network, etc.
55         The 'journaltag' is a token that will be attached to the journal
56         entries for any edits done on the database.  If 'journaltag' is
57         None, the database is opened in read-only mode: the Class.create(),
58         Class.set(), and Class.retire() methods are disabled.
59         """
60         self.config, self.journaltag = config, journaltag
61         self.dir = config.DATABASE
62         self.classes = {}
63         self.cache = {}         # cache of nodes loaded or created
64         self.dirtynodes = {}    # keep track of the dirty nodes by class
65         self.newnodes = {}      # keep track of the new nodes by class
66         self.transactions = []
67         self.indexer = Indexer(self.dir)
68         # ensure files are group readable and writable
69         os.umask(0002)
71     def post_init(self):
72         """Called once the schema initialisation has finished."""
73         # reindex the db if necessary
74         if self.indexer.should_reindex():
75             self.reindex()
77     def reindex(self):
78         for klass in self.classes.values():
79             for nodeid in klass.list():
80                 klass.index(nodeid)
81         self.indexer.save_index()
83     def __repr__(self):
84         return '<back_anydbm instance at %x>'%id(self) 
86     #
87     # Classes
88     #
89     def __getattr__(self, classname):
90         """A convenient way of calling self.getclass(classname)."""
91         if self.classes.has_key(classname):
92             if __debug__:
93                 print >>hyperdb.DEBUG, '__getattr__', (self, classname)
94             return self.classes[classname]
95         raise AttributeError, classname
97     def addclass(self, cl):
98         if __debug__:
99             print >>hyperdb.DEBUG, 'addclass', (self, cl)
100         cn = cl.classname
101         if self.classes.has_key(cn):
102             raise ValueError, cn
103         self.classes[cn] = cl
105     def getclasses(self):
106         """Return a list of the names of all existing classes."""
107         if __debug__:
108             print >>hyperdb.DEBUG, 'getclasses', (self,)
109         l = self.classes.keys()
110         l.sort()
111         return l
113     def getclass(self, classname):
114         """Get the Class object representing a particular class.
116         If 'classname' is not a valid class name, a KeyError is raised.
117         """
118         if __debug__:
119             print >>hyperdb.DEBUG, 'getclass', (self, classname)
120         return self.classes[classname]
122     #
123     # Class DBs
124     #
125     def clear(self):
126         '''Delete all database contents
127         '''
128         if __debug__:
129             print >>hyperdb.DEBUG, 'clear', (self,)
130         for cn in self.classes.keys():
131             for dummy in 'nodes', 'journals':
132                 path = os.path.join(self.dir, 'journals.%s'%cn)
133                 if os.path.exists(path):
134                     os.remove(path)
135                 elif os.path.exists(path+'.db'):    # dbm appends .db
136                     os.remove(path+'.db')
138     def getclassdb(self, classname, mode='r'):
139         ''' grab a connection to the class db that will be used for
140             multiple actions
141         '''
142         if __debug__:
143             print >>hyperdb.DEBUG, 'getclassdb', (self, classname, mode)
144         return self._opendb('nodes.%s'%classname, mode)
146     def determine_db_type(self, path):
147         ''' determine which DB wrote the class file
148         '''
149         db_type = ''
150         if os.path.exists(path):
151             db_type = whichdb.whichdb(path)
152             if not db_type:
153                 raise hyperdb.DatabaseError, "Couldn't identify database type"
154         elif os.path.exists(path+'.db'):
155             # if the path ends in '.db', it's a dbm database, whether
156             # anydbm says it's dbhash or not!
157             db_type = 'dbm'
158         return db_type
160     def _opendb(self, name, mode):
161         '''Low-level database opener that gets around anydbm/dbm
162            eccentricities.
163         '''
164         if __debug__:
165             print >>hyperdb.DEBUG, '_opendb', (self, name, mode)
167         # figure the class db type
168         path = os.path.join(os.getcwd(), self.dir, name)
169         db_type = self.determine_db_type(path)
171         # new database? let anydbm pick the best dbm
172         if not db_type:
173             if __debug__:
174                 print >>hyperdb.DEBUG, "_opendb anydbm.open(%r, 'n')"%path
175             return anydbm.open(path, 'n')
177         # open the database with the correct module
178         try:
179             dbm = __import__(db_type)
180         except ImportError:
181             raise hyperdb.DatabaseError, \
182                 "Couldn't open database - the required module '%s'"\
183                 " is not available"%db_type
184         if __debug__:
185             print >>hyperdb.DEBUG, "_opendb %r.open(%r, %r)"%(db_type, path,
186                 mode)
187         return dbm.open(path, mode)
189     def _lockdb(self, name):
190         ''' Lock a database file
191         '''
192         path = os.path.join(os.getcwd(), self.dir, '%s.lock'%name)
193         return acquire_lock(path)
195     #
196     # Node IDs
197     #
198     def newid(self, classname):
199         ''' Generate a new id for the given class
200         '''
201         # open the ids DB - create if if doesn't exist
202         lock = self._lockdb('_ids')
203         db = self._opendb('_ids', 'c')
204         if db.has_key(classname):
205             newid = db[classname] = str(int(db[classname]) + 1)
206         else:
207             # the count() bit is transitional - older dbs won't start at 1
208             newid = str(self.getclass(classname).count()+1)
209             db[classname] = newid
210         db.close()
211         release_lock(lock)
212         return newid
214     #
215     # Nodes
216     #
217     def addnode(self, classname, nodeid, node):
218         ''' add the specified node to its class's db
219         '''
220         if __debug__:
221             print >>hyperdb.DEBUG, 'addnode', (self, classname, nodeid, node)
222         self.newnodes.setdefault(classname, {})[nodeid] = 1
223         self.cache.setdefault(classname, {})[nodeid] = node
224         self.savenode(classname, nodeid, node)
226     def setnode(self, classname, nodeid, node):
227         ''' change the specified node
228         '''
229         if __debug__:
230             print >>hyperdb.DEBUG, 'setnode', (self, classname, nodeid, node)
231         self.dirtynodes.setdefault(classname, {})[nodeid] = 1
233         # can't set without having already loaded the node
234         self.cache[classname][nodeid] = node
235         self.savenode(classname, nodeid, node)
237     def savenode(self, classname, nodeid, node):
238         ''' perform the saving of data specified by the set/addnode
239         '''
240         if __debug__:
241             print >>hyperdb.DEBUG, 'savenode', (self, classname, nodeid, node)
242         self.transactions.append((self._doSaveNode, (classname, nodeid, node)))
244     def getnode(self, classname, nodeid, db=None, cache=1):
245         ''' get a node from the database
246         '''
247         if __debug__:
248             print >>hyperdb.DEBUG, 'getnode', (self, classname, nodeid, db)
249         if cache:
250             # try the cache
251             cache_dict = self.cache.setdefault(classname, {})
252             if cache_dict.has_key(nodeid):
253                 if __debug__:
254                     print >>hyperdb.TRACE, 'get %s %s cached'%(classname,
255                         nodeid)
256                 return cache_dict[nodeid]
258         if __debug__:
259             print >>hyperdb.TRACE, 'get %s %s'%(classname, nodeid)
261         # get from the database and save in the cache
262         if db is None:
263             db = self.getclassdb(classname)
264         if not db.has_key(nodeid):
265             raise IndexError, "no such %s %s"%(classname, nodeid)
267         # decode
268         res = marshal.loads(db[nodeid])
270         # reverse the serialisation
271         res = self.unserialise(classname, res)
273         # store off in the cache dict
274         if cache:
275             cache_dict[nodeid] = res
277         return res
279     def serialise(self, classname, node):
280         '''Copy the node contents, converting non-marshallable data into
281            marshallable data.
282         '''
283         if __debug__:
284             print >>hyperdb.DEBUG, 'serialise', classname, node
285         properties = self.getclass(classname).getprops()
286         d = {}
287         for k, v in node.items():
288             # if the property doesn't exist, or is the "retired" flag then
289             # it won't be in the properties dict
290             if not properties.has_key(k):
291                 d[k] = v
292                 continue
294             # get the property spec
295             prop = properties[k]
297             if isinstance(prop, Password):
298                 d[k] = str(v)
299             elif isinstance(prop, Date) and v is not None:
300                 d[k] = v.get_tuple()
301             elif isinstance(prop, Interval) and v is not None:
302                 d[k] = v.get_tuple()
303             else:
304                 d[k] = v
305         return d
307     def unserialise(self, classname, node):
308         '''Decode the marshalled node data
309         '''
310         if __debug__:
311             print >>hyperdb.DEBUG, 'unserialise', classname, node
312         properties = self.getclass(classname).getprops()
313         d = {}
314         for k, v in node.items():
315             # if the property doesn't exist, or is the "retired" flag then
316             # it won't be in the properties dict
317             if not properties.has_key(k):
318                 d[k] = v
319                 continue
321             # get the property spec
322             prop = properties[k]
324             if isinstance(prop, Date) and v is not None:
325                 d[k] = date.Date(v)
326             elif isinstance(prop, Interval) and v is not None:
327                 d[k] = date.Interval(v)
328             elif isinstance(prop, Password):
329                 p = password.Password()
330                 p.unpack(v)
331                 d[k] = p
332             else:
333                 d[k] = v
334         return d
336     def hasnode(self, classname, nodeid, db=None):
337         ''' determine if the database has a given node
338         '''
339         if __debug__:
340             print >>hyperdb.DEBUG, 'hasnode', (self, classname, nodeid, db)
342         # try the cache
343         cache = self.cache.setdefault(classname, {})
344         if cache.has_key(nodeid):
345             if __debug__:
346                 print >>hyperdb.TRACE, 'has %s %s cached'%(classname, nodeid)
347             return 1
348         if __debug__:
349             print >>hyperdb.TRACE, 'has %s %s'%(classname, nodeid)
351         # not in the cache - check the database
352         if db is None:
353             db = self.getclassdb(classname)
354         res = db.has_key(nodeid)
355         return res
357     def countnodes(self, classname, db=None):
358         if __debug__:
359             print >>hyperdb.DEBUG, 'countnodes', (self, classname, db)
360         # include the new nodes not saved to the DB yet
361         count = len(self.newnodes.get(classname, {}))
363         # and count those in the DB
364         if db is None:
365             db = self.getclassdb(classname)
366         count = count + len(db.keys())
367         return count
369     def getnodeids(self, classname, db=None):
370         if __debug__:
371             print >>hyperdb.DEBUG, 'getnodeids', (self, classname, db)
372         # start off with the new nodes
373         res = self.newnodes.get(classname, {}).keys()
375         if db is None:
376             db = self.getclassdb(classname)
377         res = res + db.keys()
378         return res
381     #
382     # Files - special node properties
383     # inherited from FileStorage
385     #
386     # Journal
387     #
388     def addjournal(self, classname, nodeid, action, params):
389         ''' Journal the Action
390         'action' may be:
392             'create' or 'set' -- 'params' is a dictionary of property values
393             'link' or 'unlink' -- 'params' is (classname, nodeid, propname)
394             'retire' -- 'params' is None
395         '''
396         if __debug__:
397             print >>hyperdb.DEBUG, 'addjournal', (self, classname, nodeid,
398                 action, params)
399         self.transactions.append((self._doSaveJournal, (classname, nodeid,
400             action, params)))
402     def getjournal(self, classname, nodeid):
403         ''' get the journal for id
404         '''
405         if __debug__:
406             print >>hyperdb.DEBUG, 'getjournal', (self, classname, nodeid)
407         # attempt to open the journal - in some rare cases, the journal may
408         # not exist
409         try:
410             db = self._opendb('journals.%s'%classname, 'r')
411         except anydbm.error, error:
412             if str(error) == "need 'c' or 'n' flag to open new db": return []
413             elif error.args[0] != 2: raise
414             return []
415         try:
416             journal = marshal.loads(db[nodeid])
417         except KeyError:
418             db.close()
419             raise KeyError, 'no such %s %s'%(classname, nodeid)
420         db.close()
421         res = []
422         for entry in journal:
423             (nodeid, date_stamp, user, action, params) = entry
424             date_obj = date.Date(date_stamp)
425             res.append((nodeid, date_obj, user, action, params))
426         return res
428     def pack(self, pack_before):
429         ''' delete all journal entries before 'pack_before' '''
430         if __debug__:
431             print >>hyperdb.DEBUG, 'packjournal', (self, pack_before)
433         pack_before = pack_before.get_tuple()
435         classes = self.getclasses()
437         # figure the class db type
439         for classname in classes:
440             db_name = 'journals.%s'%classname
441             path = os.path.join(os.getcwd(), self.dir, classname)
442             db_type = self.determine_db_type(path)
443             db = self._opendb(db_name, 'w')
445             for key in db.keys():
446                 journal = marshal.loads(db[key])
447                 l = []
448                 last_set_entry = None
449                 for entry in journal:
450                     (nodeid, date_stamp, self.journaltag, action, 
451                         params) = entry
452                     if date_stamp > pack_before or action == 'create':
453                         l.append(entry)
454                     elif action == 'set':
455                         # grab the last set entry to keep information on
456                         # activity
457                         last_set_entry = entry
458                 if last_set_entry:
459                     date_stamp = last_set_entry[1]
460                     # if the last set entry was made after the pack date
461                     # then it is already in the list
462                     if date_stamp < pack_before:
463                         l.append(last_set_entry)
464                 db[key] = marshal.dumps(l)
465             if db_type == 'gdbm':
466                 db.reorganize()
467             db.close()
468             
470     #
471     # Basic transaction support
472     #
473     def commit(self):
474         ''' Commit the current transactions.
475         '''
476         if __debug__:
477             print >>hyperdb.DEBUG, 'commit', (self,)
478         # TODO: lock the DB
480         # keep a handle to all the database files opened
481         self.databases = {}
483         # now, do all the transactions
484         reindex = {}
485         for method, args in self.transactions:
486             reindex[method(*args)] = 1
488         # now close all the database files
489         for db in self.databases.values():
490             db.close()
491         del self.databases
492         # TODO: unlock the DB
494         # reindex the nodes that request it
495         for classname, nodeid in filter(None, reindex.keys()):
496             print >>hyperdb.DEBUG, 'commit.reindex', (classname, nodeid)
497             self.getclass(classname).index(nodeid)
499         # save the indexer state
500         self.indexer.save_index()
502         # all transactions committed, back to normal
503         self.cache = {}
504         self.dirtynodes = {}
505         self.newnodes = {}
506         self.transactions = []
508     def _doSaveNode(self, classname, nodeid, node):
509         if __debug__:
510             print >>hyperdb.DEBUG, '_doSaveNode', (self, classname, nodeid,
511                 node)
513         # get the database handle
514         db_name = 'nodes.%s'%classname
515         if self.databases.has_key(db_name):
516             db = self.databases[db_name]
517         else:
518             db = self.databases[db_name] = self.getclassdb(classname, 'c')
520         # now save the marshalled data
521         db[nodeid] = marshal.dumps(self.serialise(classname, node))
523         # return the classname, nodeid so we reindex this content
524         return (classname, nodeid)
526     def _doSaveJournal(self, classname, nodeid, action, params):
527         # serialise first
528         if action in ('set', 'create'):
529             params = self.serialise(classname, params)
531         # create the journal entry
532         entry = (nodeid, date.Date().get_tuple(), self.journaltag, action,
533             params)
535         if __debug__:
536             print >>hyperdb.DEBUG, '_doSaveJournal', entry
538         # get the database handle
539         db_name = 'journals.%s'%classname
540         if self.databases.has_key(db_name):
541             db = self.databases[db_name]
542         else:
543             db = self.databases[db_name] = self._opendb(db_name, 'c')
545         # now insert the journal entry
546         if db.has_key(nodeid):
547             # append to existing
548             s = db[nodeid]
549             l = marshal.loads(s)
550             l.append(entry)
551         else:
552             l = [entry]
554         db[nodeid] = marshal.dumps(l)
556     def rollback(self):
557         ''' Reverse all actions from the current transaction.
558         '''
559         if __debug__:
560             print >>hyperdb.DEBUG, 'rollback', (self, )
561         for method, args in self.transactions:
562             # delete temporary files
563             if method == self._doStoreFile:
564                 self._rollbackStoreFile(*args)
565         self.cache = {}
566         self.dirtynodes = {}
567         self.newnodes = {}
568         self.transactions = []
570 _marker = []
571 class Class(hyperdb.Class):
572     """The handle to a particular class of nodes in a hyperdatabase."""
574     def __init__(self, db, classname, **properties):
575         """Create a new class with a given name and property specification.
577         'classname' must not collide with the name of an existing class,
578         or a ValueError is raised.  The keyword arguments in 'properties'
579         must map names to property objects, or a TypeError is raised.
580         """
581         if (properties.has_key('creation') or properties.has_key('activity')
582                 or properties.has_key('creator')):
583             raise ValueError, '"creation", "activity" and "creator" are '\
584                 'reserved'
586         self.classname = classname
587         self.properties = properties
588         self.db = weakref.proxy(db)       # use a weak ref to avoid circularity
589         self.key = ''
591         # should we journal changes (default yes)
592         self.do_journal = 1
594         # do the db-related init stuff
595         db.addclass(self)
597         self.auditors = {'create': [], 'set': [], 'retire': []}
598         self.reactors = {'create': [], 'set': [], 'retire': []}
600     def enableJournalling(self):
601         '''Turn journalling on for this class
602         '''
603         self.do_journal = 1
605     def disableJournalling(self):
606         '''Turn journalling off for this class
607         '''
608         self.do_journal = 0
610     # Editing nodes:
612     def create(self, **propvalues):
613         """Create a new node of this class and return its id.
615         The keyword arguments in 'propvalues' map property names to values.
617         The values of arguments must be acceptable for the types of their
618         corresponding properties or a TypeError is raised.
619         
620         If this class has a key property, it must be present and its value
621         must not collide with other key strings or a ValueError is raised.
622         
623         Any other properties on this class that are missing from the
624         'propvalues' dictionary are set to None.
625         
626         If an id in a link or multilink property does not refer to a valid
627         node, an IndexError is raised.
629         These operations trigger detectors and can be vetoed.  Attempts
630         to modify the "creation" or "activity" properties cause a KeyError.
631         """
632         if propvalues.has_key('id'):
633             raise KeyError, '"id" is reserved'
635         if self.db.journaltag is None:
636             raise DatabaseError, 'Database open read-only'
638         if propvalues.has_key('creation') or propvalues.has_key('activity'):
639             raise KeyError, '"creation" and "activity" are reserved'
641         self.fireAuditors('create', None, propvalues)
643         # new node's id
644         newid = self.db.newid(self.classname)
646         # validate propvalues
647         num_re = re.compile('^\d+$')
648         for key, value in propvalues.items():
649             if key == self.key:
650                 try:
651                     self.lookup(value)
652                 except KeyError:
653                     pass
654                 else:
655                     raise ValueError, 'node with key "%s" exists'%value
657             # try to handle this property
658             try:
659                 prop = self.properties[key]
660             except KeyError:
661                 raise KeyError, '"%s" has no property "%s"'%(self.classname,
662                     key)
664             if isinstance(prop, Link):
665                 if type(value) != type(''):
666                     raise ValueError, 'link value must be String'
667                 link_class = self.properties[key].classname
668                 # if it isn't a number, it's a key
669                 if not num_re.match(value):
670                     try:
671                         value = self.db.classes[link_class].lookup(value)
672                     except (TypeError, KeyError):
673                         raise IndexError, 'new property "%s": %s not a %s'%(
674                             key, value, link_class)
675                 elif not self.db.hasnode(link_class, value):
676                     raise IndexError, '%s has no node %s'%(link_class, value)
678                 # save off the value
679                 propvalues[key] = value
681                 # register the link with the newly linked node
682                 if self.do_journal and self.properties[key].do_journal:
683                     self.db.addjournal(link_class, value, 'link',
684                         (self.classname, newid, key))
686             elif isinstance(prop, Multilink):
687                 if type(value) != type([]):
688                     raise TypeError, 'new property "%s" not a list of ids'%key
690                 # clean up and validate the list of links
691                 link_class = self.properties[key].classname
692                 l = []
693                 for entry in value:
694                     if type(entry) != type(''):
695                         raise ValueError, '"%s" link value (%s) must be '\
696                             'String'%(key, value)
697                     # if it isn't a number, it's a key
698                     if not num_re.match(entry):
699                         try:
700                             entry = self.db.classes[link_class].lookup(entry)
701                         except (TypeError, KeyError):
702                             raise IndexError, 'new property "%s": %s not a %s'%(
703                                 key, entry, self.properties[key].classname)
704                     l.append(entry)
705                 value = l
706                 propvalues[key] = value
708                 # handle additions
709                 for id in value:
710                     if not self.db.hasnode(link_class, id):
711                         raise IndexError, '%s has no node %s'%(link_class, id)
712                     # register the link with the newly linked node
713                     if self.do_journal and self.properties[key].do_journal:
714                         self.db.addjournal(link_class, id, 'link',
715                             (self.classname, newid, key))
717             elif isinstance(prop, String):
718                 if type(value) != type(''):
719                     raise TypeError, 'new property "%s" not a string'%key
721             elif isinstance(prop, Password):
722                 if not isinstance(value, password.Password):
723                     raise TypeError, 'new property "%s" not a Password'%key
725             elif isinstance(prop, Date):
726                 if value is not None and not isinstance(value, date.Date):
727                     raise TypeError, 'new property "%s" not a Date'%key
729             elif isinstance(prop, Interval):
730                 if value is not None and not isinstance(value, date.Interval):
731                     raise TypeError, 'new property "%s" not an Interval'%key
733             elif value is not None and isinstance(prop, Number):
734                 try:
735                     int(value)
736                 except ValueError:
737                     try:
738                         float(value)
739                     except ValueError:
740                         raise TypeError, 'new property "%s" not numeric'%key
742             elif value is not None and isinstance(prop, Boolean):
743                 if isinstance(value, type('')):
744                     s = value.lower()
745                     if s in ('0', 'false', 'no'):
746                         value = 0
747                     elif s in ('1', 'true', 'yes'):
748                         value = 1
749                     else:
750                         raise TypeError, 'new property "%s" not boolean'%key
751                 else:
752                     try:
753                         int(value)
754                     except TypeError:
755                         raise TypeError, 'new property "%s" not boolean'%key
757         # make sure there's data where there needs to be
758         for key, prop in self.properties.items():
759             if propvalues.has_key(key):
760                 continue
761             if key == self.key:
762                 raise ValueError, 'key property "%s" is required'%key
763             if isinstance(prop, Multilink):
764                 propvalues[key] = []
765             else:
766                 propvalues[key] = None
768         # done
769         self.db.addnode(self.classname, newid, propvalues)
770         if self.do_journal:
771             self.db.addjournal(self.classname, newid, 'create', propvalues)
773         self.fireReactors('create', newid, None)
775         return newid
777     def get(self, nodeid, propname, default=_marker, cache=1):
778         """Get the value of a property on an existing node of this class.
780         'nodeid' must be the id of an existing node of this class or an
781         IndexError is raised.  'propname' must be the name of a property
782         of this class or a KeyError is raised.
784         'cache' indicates whether the transaction cache should be queried
785         for the node. If the node has been modified and you need to
786         determine what its values prior to modification are, you need to
787         set cache=0.
789         Attempts to get the "creation" or "activity" properties should
790         do the right thing.
791         """
792         if propname == 'id':
793             return nodeid
795         if propname == 'creation':
796             if not self.do_journal:
797                 raise ValueError, 'Journalling is disabled for this class'
798             journal = self.db.getjournal(self.classname, nodeid)
799             if journal:
800                 return self.db.getjournal(self.classname, nodeid)[0][1]
801             else:
802                 # on the strange chance that there's no journal
803                 return date.Date()
804         if propname == 'activity':
805             if not self.do_journal:
806                 raise ValueError, 'Journalling is disabled for this class'
807             journal = self.db.getjournal(self.classname, nodeid)
808             if journal:
809                 return self.db.getjournal(self.classname, nodeid)[-1][1]
810             else:
811                 # on the strange chance that there's no journal
812                 return date.Date()
813         if propname == 'creator':
814             if not self.do_journal:
815                 raise ValueError, 'Journalling is disabled for this class'
816             journal = self.db.getjournal(self.classname, nodeid)
817             if journal:
818                 name = self.db.getjournal(self.classname, nodeid)[0][2]
819             else:
820                 return None
821             return self.db.user.lookup(name)
823         # get the property (raises KeyErorr if invalid)
824         prop = self.properties[propname]
826         # get the node's dict
827         d = self.db.getnode(self.classname, nodeid, cache=cache)
829         if not d.has_key(propname):
830             if default is _marker:
831                 if isinstance(prop, Multilink):
832                     return []
833                 else:
834                     return None
835             else:
836                 return default
838         return d[propname]
840     # XXX not in spec
841     def getnode(self, nodeid, cache=1):
842         ''' Return a convenience wrapper for the node.
844         'nodeid' must be the id of an existing node of this class or an
845         IndexError is raised.
847         'cache' indicates whether the transaction cache should be queried
848         for the node. If the node has been modified and you need to
849         determine what its values prior to modification are, you need to
850         set cache=0.
851         '''
852         return Node(self, nodeid, cache=cache)
854     def set(self, nodeid, **propvalues):
855         """Modify a property on an existing node of this class.
856         
857         'nodeid' must be the id of an existing node of this class or an
858         IndexError is raised.
860         Each key in 'propvalues' must be the name of a property of this
861         class or a KeyError is raised.
863         All values in 'propvalues' must be acceptable types for their
864         corresponding properties or a TypeError is raised.
866         If the value of the key property is set, it must not collide with
867         other key strings or a ValueError is raised.
869         If the value of a Link or Multilink property contains an invalid
870         node id, a ValueError is raised.
872         These operations trigger detectors and can be vetoed.  Attempts
873         to modify the "creation" or "activity" properties cause a KeyError.
874         """
875         if not propvalues:
876             return
878         if propvalues.has_key('creation') or propvalues.has_key('activity'):
879             raise KeyError, '"creation" and "activity" are reserved'
881         if propvalues.has_key('id'):
882             raise KeyError, '"id" is reserved'
884         if self.db.journaltag is None:
885             raise DatabaseError, 'Database open read-only'
887         self.fireAuditors('set', nodeid, propvalues)
888         # Take a copy of the node dict so that the subsequent set
889         # operation doesn't modify the oldvalues structure.
890         try:
891             # try not using the cache initially
892             oldvalues = copy.deepcopy(self.db.getnode(self.classname, nodeid,
893                 cache=0))
894         except IndexError:
895             # this will be needed if somone does a create() and set()
896             # with no intervening commit()
897             oldvalues = copy.deepcopy(self.db.getnode(self.classname, nodeid))
899         node = self.db.getnode(self.classname, nodeid)
900         if node.has_key(self.db.RETIRED_FLAG):
901             raise IndexError
902         num_re = re.compile('^\d+$')
904         # if the journal value is to be different, store it in here
905         journalvalues = {}
907         for propname, value in propvalues.items():
908             # check to make sure we're not duplicating an existing key
909             if propname == self.key and node[propname] != value:
910                 try:
911                     self.lookup(value)
912                 except KeyError:
913                     pass
914                 else:
915                     raise ValueError, 'node with key "%s" exists'%value
917             # this will raise the KeyError if the property isn't valid
918             # ... we don't use getprops() here because we only care about
919             # the writeable properties.
920             prop = self.properties[propname]
922             # if the value's the same as the existing value, no sense in
923             # doing anything
924             if node.has_key(propname) and value == node[propname]:
925                 del propvalues[propname]
926                 continue
928             # do stuff based on the prop type
929             if isinstance(prop, Link):
930                 link_class = self.properties[propname].classname
931                 # if it isn't a number, it's a key
932                 if type(value) != type(''):
933                     raise ValueError, 'link value must be String'
934                 if not num_re.match(value):
935                     try:
936                         value = self.db.classes[link_class].lookup(value)
937                     except (TypeError, KeyError):
938                         raise IndexError, 'new property "%s": %s not a %s'%(
939                             propname, value, self.properties[propname].classname)
941                 if not self.db.hasnode(link_class, value):
942                     raise IndexError, '%s has no node %s'%(link_class, value)
944                 if self.do_journal and self.properties[propname].do_journal:
945                     # register the unlink with the old linked node
946                     if node[propname] is not None:
947                         self.db.addjournal(link_class, node[propname], 'unlink',
948                             (self.classname, nodeid, propname))
950                     # register the link with the newly linked node
951                     if value is not None:
952                         self.db.addjournal(link_class, value, 'link',
953                             (self.classname, nodeid, propname))
955             elif isinstance(prop, Multilink):
956                 if type(value) != type([]):
957                     raise TypeError, 'new property "%s" not a list of'\
958                         ' ids'%propname
959                 link_class = self.properties[propname].classname
960                 l = []
961                 for entry in value:
962                     # if it isn't a number, it's a key
963                     if type(entry) != type(''):
964                         raise ValueError, 'new property "%s" link value ' \
965                             'must be a string'%propname
966                     if not num_re.match(entry):
967                         try:
968                             entry = self.db.classes[link_class].lookup(entry)
969                         except (TypeError, KeyError):
970                             raise IndexError, 'new property "%s": %s not a %s'%(
971                                 propname, entry,
972                                 self.properties[propname].classname)
973                     l.append(entry)
974                 value = l
975                 propvalues[propname] = value
977                 # figure the journal entry for this property
978                 add = []
979                 remove = []
981                 # handle removals
982                 if node.has_key(propname):
983                     l = node[propname]
984                 else:
985                     l = []
986                 for id in l[:]:
987                     if id in value:
988                         continue
989                     # register the unlink with the old linked node
990                     if self.do_journal and self.properties[propname].do_journal:
991                         self.db.addjournal(link_class, id, 'unlink',
992                             (self.classname, nodeid, propname))
993                     l.remove(id)
994                     remove.append(id)
996                 # handle additions
997                 for id in value:
998                     if not self.db.hasnode(link_class, id):
999                         raise IndexError, '%s has no node %s'%(link_class, id)
1000                     if id in l:
1001                         continue
1002                     # register the link with the newly linked node
1003                     if self.do_journal and self.properties[propname].do_journal:
1004                         self.db.addjournal(link_class, id, 'link',
1005                             (self.classname, nodeid, propname))
1006                     l.append(id)
1007                     add.append(id)
1009                 # figure the journal entry
1010                 l = []
1011                 if add:
1012                     l.append(('add', add))
1013                 if remove:
1014                     l.append(('remove', remove))
1015                 if l:
1016                     journalvalues[propname] = tuple(l)
1018             elif isinstance(prop, String):
1019                 if value is not None and type(value) != type(''):
1020                     raise TypeError, 'new property "%s" not a string'%propname
1022             elif isinstance(prop, Password):
1023                 if not isinstance(value, password.Password):
1024                     raise TypeError, 'new property "%s" not a Password'%propname
1025                 propvalues[propname] = value
1027             elif value is not None and isinstance(prop, Date):
1028                 if not isinstance(value, date.Date):
1029                     raise TypeError, 'new property "%s" not a Date'% propname
1030                 propvalues[propname] = value
1032             elif value is not None and isinstance(prop, Interval):
1033                 if not isinstance(value, date.Interval):
1034                     raise TypeError, 'new property "%s" not an '\
1035                         'Interval'%propname
1036                 propvalues[propname] = value
1038             elif value is not None and isinstance(prop, Number):
1039                 try:
1040                     int(value)
1041                 except ValueError:
1042                     try:
1043                         float(value)
1044                     except ValueError:
1045                         raise TypeError, 'new property "%s" not '\
1046                             'numeric'%propname
1048             elif value is not None and isinstance(prop, Boolean):
1049                 if isinstance(value, type('')):
1050                     s = value.lower()
1051                     if s in ('0', 'false', 'no'):
1052                         value = 0
1053                     elif s in ('1', 'true', 'yes'):
1054                         value = 1
1055                     else:
1056                         raise TypeError, 'new property "%s" not '\
1057                             'boolean'%propname
1058                 else:
1059                     try:
1060                         int(value)
1061                     except ValueError:
1062                         raise TypeError, 'new property "%s" not '\
1063                             'boolean'%propname
1065             node[propname] = value
1067         # nothing to do?
1068         if not propvalues:
1069             return
1071         # do the set, and journal it
1072         self.db.setnode(self.classname, nodeid, node)
1074         if self.do_journal:
1075             propvalues.update(journalvalues)
1076             self.db.addjournal(self.classname, nodeid, 'set', propvalues)
1078         self.fireReactors('set', nodeid, oldvalues)
1080     def retire(self, nodeid):
1081         """Retire a node.
1082         
1083         The properties on the node remain available from the get() method,
1084         and the node's id is never reused.
1085         
1086         Retired nodes are not returned by the find(), list(), or lookup()
1087         methods, and other nodes may reuse the values of their key properties.
1089         These operations trigger detectors and can be vetoed.  Attempts
1090         to modify the "creation" or "activity" properties cause a KeyError.
1091         """
1092         if self.db.journaltag is None:
1093             raise DatabaseError, 'Database open read-only'
1095         self.fireAuditors('retire', nodeid, None)
1097         node = self.db.getnode(self.classname, nodeid)
1098         node[self.db.RETIRED_FLAG] = 1
1099         self.db.setnode(self.classname, nodeid, node)
1100         if self.do_journal:
1101             self.db.addjournal(self.classname, nodeid, 'retired', None)
1103         self.fireReactors('retire', nodeid, None)
1105     def history(self, nodeid):
1106         """Retrieve the journal of edits on a particular node.
1108         'nodeid' must be the id of an existing node of this class or an
1109         IndexError is raised.
1111         The returned list contains tuples of the form
1113             (date, tag, action, params)
1115         'date' is a Timestamp object specifying the time of the change and
1116         'tag' is the journaltag specified when the database was opened.
1117         """
1118         if not self.do_journal:
1119             raise ValueError, 'Journalling is disabled for this class'
1120         return self.db.getjournal(self.classname, nodeid)
1122     # Locating nodes:
1123     def hasnode(self, nodeid):
1124         '''Determine if the given nodeid actually exists
1125         '''
1126         return self.db.hasnode(self.classname, nodeid)
1128     def setkey(self, propname):
1129         """Select a String property of this class to be the key property.
1131         'propname' must be the name of a String property of this class or
1132         None, or a TypeError is raised.  The values of the key property on
1133         all existing nodes must be unique or a ValueError is raised. If the
1134         property doesn't exist, KeyError is raised.
1135         """
1136         prop = self.getprops()[propname]
1137         if not isinstance(prop, String):
1138             raise TypeError, 'key properties must be String'
1139         self.key = propname
1141     def getkey(self):
1142         """Return the name of the key property for this class or None."""
1143         return self.key
1145     def labelprop(self, default_to_id=0):
1146         ''' Return the property name for a label for the given node.
1148         This method attempts to generate a consistent label for the node.
1149         It tries the following in order:
1150             1. key property
1151             2. "name" property
1152             3. "title" property
1153             4. first property from the sorted property name list
1154         '''
1155         k = self.getkey()
1156         if  k:
1157             return k
1158         props = self.getprops()
1159         if props.has_key('name'):
1160             return 'name'
1161         elif props.has_key('title'):
1162             return 'title'
1163         if default_to_id:
1164             return 'id'
1165         props = props.keys()
1166         props.sort()
1167         return props[0]
1169     # TODO: set up a separate index db file for this? profile?
1170     def lookup(self, keyvalue):
1171         """Locate a particular node by its key property and return its id.
1173         If this class has no key property, a TypeError is raised.  If the
1174         'keyvalue' matches one of the values for the key property among
1175         the nodes in this class, the matching node's id is returned;
1176         otherwise a KeyError is raised.
1177         """
1178         cldb = self.db.getclassdb(self.classname)
1179         try:
1180             for nodeid in self.db.getnodeids(self.classname, cldb):
1181                 node = self.db.getnode(self.classname, nodeid, cldb)
1182                 if node.has_key(self.db.RETIRED_FLAG):
1183                     continue
1184                 if node[self.key] == keyvalue:
1185                     cldb.close()
1186                     return nodeid
1187         finally:
1188             cldb.close()
1189         raise KeyError, keyvalue
1191     # XXX: change from spec - allows multiple props to match
1192     def find(self, **propspec):
1193         """Get the ids of nodes in this class which link to the given nodes.
1195         'propspec' consists of keyword args propname={nodeid:1,}   
1196           'propname' must be the name of a property in this class, or a
1197             KeyError is raised.  That property must be a Link or Multilink
1198             property, or a TypeError is raised.
1200         Any node in this class whose 'propname' property links to any of the
1201         nodeids will be returned. Used by the full text indexing, which knows
1202         that "foo" occurs in msg1, msg3 and file7, so we have hits on these issues:
1203             db.issue.find(messages={'1':1,'3':1}, files={'7':1})
1204         """
1205         propspec = propspec.items()
1206         for propname, nodeids in propspec:
1207             # check the prop is OK
1208             prop = self.properties[propname]
1209             if not isinstance(prop, Link) and not isinstance(prop, Multilink):
1210                 raise TypeError, "'%s' not a Link/Multilink property"%propname
1211             #XXX edit is expensive and of questionable use
1212             #for nodeid in nodeids:
1213             #    if not self.db.hasnode(prop.classname, nodeid):
1214             #        raise ValueError, '%s has no node %s'%(prop.classname, nodeid)
1216         # ok, now do the find
1217         cldb = self.db.getclassdb(self.classname)
1218         l = []
1219         try:
1220             for id in self.db.getnodeids(self.classname, db=cldb):
1221                 node = self.db.getnode(self.classname, id, db=cldb)
1222                 if node.has_key(self.db.RETIRED_FLAG):
1223                     continue
1224                 for propname, nodeids in propspec:
1225                     # can't test if the node doesn't have this property
1226                     if not node.has_key(propname):
1227                         continue
1228                     if type(nodeids) is type(''):
1229                         nodeids = {nodeids:1}
1230                     prop = self.properties[propname]
1231                     value = node[propname]
1232                     if isinstance(prop, Link) and nodeids.has_key(value):
1233                         l.append(id)
1234                         break
1235                     elif isinstance(prop, Multilink):
1236                         hit = 0
1237                         for v in value:
1238                             if nodeids.has_key(v):
1239                                 l.append(id)
1240                                 hit = 1
1241                                 break
1242                         if hit:
1243                             break
1244         finally:
1245             cldb.close()
1246         return l
1248     def stringFind(self, **requirements):
1249         """Locate a particular node by matching a set of its String
1250         properties in a caseless search.
1252         If the property is not a String property, a TypeError is raised.
1253         
1254         The return is a list of the id of all nodes that match.
1255         """
1256         for propname in requirements.keys():
1257             prop = self.properties[propname]
1258             if isinstance(not prop, String):
1259                 raise TypeError, "'%s' not a String property"%propname
1260             requirements[propname] = requirements[propname].lower()
1261         l = []
1262         cldb = self.db.getclassdb(self.classname)
1263         try:
1264             for nodeid in self.db.getnodeids(self.classname, cldb):
1265                 node = self.db.getnode(self.classname, nodeid, cldb)
1266                 if node.has_key(self.db.RETIRED_FLAG):
1267                     continue
1268                 for key, value in requirements.items():
1269                     if node[key] and node[key].lower() != value:
1270                         break
1271                 else:
1272                     l.append(nodeid)
1273         finally:
1274             cldb.close()
1275         return l
1277     def list(self):
1278         """Return a list of the ids of the active nodes in this class."""
1279         l = []
1280         cn = self.classname
1281         cldb = self.db.getclassdb(cn)
1282         try:
1283             for nodeid in self.db.getnodeids(cn, cldb):
1284                 node = self.db.getnode(cn, nodeid, cldb)
1285                 if node.has_key(self.db.RETIRED_FLAG):
1286                     continue
1287                 l.append(nodeid)
1288         finally:
1289             cldb.close()
1290         l.sort()
1291         return l
1293     # XXX not in spec
1294     def filter(self, search_matches, filterspec, sort, group, 
1295             num_re = re.compile('^\d+$')):
1296         ''' Return a list of the ids of the active nodes in this class that
1297             match the 'filter' spec, sorted by the group spec and then the
1298             sort spec
1299         '''
1300         cn = self.classname
1302         # optimise filterspec
1303         l = []
1304         props = self.getprops()
1305         for k, v in filterspec.items():
1306             propclass = props[k]
1307             if isinstance(propclass, Link):
1308                 if type(v) is not type([]):
1309                     v = [v]
1310                 # replace key values with node ids
1311                 u = []
1312                 link_class =  self.db.classes[propclass.classname]
1313                 for entry in v:
1314                     if entry == '-1': entry = None
1315                     elif not num_re.match(entry):
1316                         try:
1317                             entry = link_class.lookup(entry)
1318                         except (TypeError,KeyError):
1319                             raise ValueError, 'property "%s": %s not a %s'%(
1320                                 k, entry, self.properties[k].classname)
1321                     u.append(entry)
1323                 l.append((0, k, u))
1324             elif isinstance(propclass, Multilink):
1325                 if type(v) is not type([]):
1326                     v = [v]
1327                 # replace key values with node ids
1328                 u = []
1329                 link_class =  self.db.classes[propclass.classname]
1330                 for entry in v:
1331                     if not num_re.match(entry):
1332                         try:
1333                             entry = link_class.lookup(entry)
1334                         except (TypeError,KeyError):
1335                             raise ValueError, 'new property "%s": %s not a %s'%(
1336                                 k, entry, self.properties[k].classname)
1337                     u.append(entry)
1338                 l.append((1, k, u))
1339             elif isinstance(propclass, String):
1340                 # simple glob searching
1341                 v = re.sub(r'([\|\{\}\\\.\+\[\]\(\)])', r'\\\1', v)
1342                 v = v.replace('?', '.')
1343                 v = v.replace('*', '.*?')
1344                 l.append((2, k, re.compile(v, re.I)))
1345             elif isinstance(propclass, Boolean):
1346                 if type(v) is type(''):
1347                     bv = v.lower() in ('yes', 'true', 'on', '1')
1348                 else:
1349                     bv = v
1350                 l.append((6, k, bv))
1351             elif isinstance(propclass, Number):
1352                 l.append((6, k, int(v)))
1353             else:
1354                 l.append((6, k, v))
1355         filterspec = l
1357         # now, find all the nodes that are active and pass filtering
1358         l = []
1359         cldb = self.db.getclassdb(cn)
1360         try:
1361             for nodeid in self.db.getnodeids(cn, cldb):
1362                 node = self.db.getnode(cn, nodeid, cldb)
1363                 if node.has_key(self.db.RETIRED_FLAG):
1364                     continue
1365                 # apply filter
1366                 for t, k, v in filterspec:
1367                     # this node doesn't have this property, so reject it
1368                     if not node.has_key(k): break
1370                     if t == 0 and node[k] not in v:
1371                         # link - if this node'd property doesn't appear in the
1372                         # filterspec's nodeid list, skip it
1373                         break
1374                     elif t == 1:
1375                         # multilink - if any of the nodeids required by the
1376                         # filterspec aren't in this node's property, then skip
1377                         # it
1378                         for value in v:
1379                             if value not in node[k]:
1380                                 break
1381                         else:
1382                             continue
1383                         break
1384                     elif t == 2 and (node[k] is None or not v.search(node[k])):
1385                         # RE search
1386                         break
1387                     elif t == 6 and node[k] != v:
1388                         # straight value comparison for the other types
1389                         break
1390                 else:
1391                     l.append((nodeid, node))
1392         finally:
1393             cldb.close()
1394         l.sort()
1396         # filter based on full text search
1397         if search_matches is not None:
1398             k = []
1399             l_debug = []
1400             for v in l:
1401                 l_debug.append(v[0])
1402                 if search_matches.has_key(v[0]):
1403                     k.append(v)
1404             l = k
1406         # optimise sort
1407         m = []
1408         for entry in sort:
1409             if entry[0] != '-':
1410                 m.append(('+', entry))
1411             else:
1412                 m.append((entry[0], entry[1:]))
1413         sort = m
1415         # optimise group
1416         m = []
1417         for entry in group:
1418             if entry[0] != '-':
1419                 m.append(('+', entry))
1420             else:
1421                 m.append((entry[0], entry[1:]))
1422         group = m
1423         # now, sort the result
1424         def sortfun(a, b, sort=sort, group=group, properties=self.getprops(),
1425                 db = self.db, cl=self):
1426             a_id, an = a
1427             b_id, bn = b
1428             # sort by group and then sort
1429             for list in group, sort:
1430                 for dir, prop in list:
1431                     # sorting is class-specific
1432                     propclass = properties[prop]
1434                     # handle the properties that might be "faked"
1435                     # also, handle possible missing properties
1436                     try:
1437                         if not an.has_key(prop):
1438                             an[prop] = cl.get(a_id, prop)
1439                         av = an[prop]
1440                     except KeyError:
1441                         # the node doesn't have a value for this property
1442                         if isinstance(propclass, Multilink): av = []
1443                         else: av = ''
1444                     try:
1445                         if not bn.has_key(prop):
1446                             bn[prop] = cl.get(b_id, prop)
1447                         bv = bn[prop]
1448                     except KeyError:
1449                         # the node doesn't have a value for this property
1450                         if isinstance(propclass, Multilink): bv = []
1451                         else: bv = ''
1453                     # String and Date values are sorted in the natural way
1454                     if isinstance(propclass, String):
1455                         # clean up the strings
1456                         if av and av[0] in string.uppercase:
1457                             av = an[prop] = av.lower()
1458                         if bv and bv[0] in string.uppercase:
1459                             bv = bn[prop] = bv.lower()
1460                     if (isinstance(propclass, String) or
1461                             isinstance(propclass, Date)):
1462                         # it might be a string that's really an integer
1463                         try:
1464                             av = int(av)
1465                             bv = int(bv)
1466                         except:
1467                             pass
1468                         if dir == '+':
1469                             r = cmp(av, bv)
1470                             if r != 0: return r
1471                         elif dir == '-':
1472                             r = cmp(bv, av)
1473                             if r != 0: return r
1475                     # Link properties are sorted according to the value of
1476                     # the "order" property on the linked nodes if it is
1477                     # present; or otherwise on the key string of the linked
1478                     # nodes; or finally on  the node ids.
1479                     elif isinstance(propclass, Link):
1480                         link = db.classes[propclass.classname]
1481                         if av is None and bv is not None: return -1
1482                         if av is not None and bv is None: return 1
1483                         if av is None and bv is None: continue
1484                         if link.getprops().has_key('order'):
1485                             if dir == '+':
1486                                 r = cmp(link.get(av, 'order'),
1487                                     link.get(bv, 'order'))
1488                                 if r != 0: return r
1489                             elif dir == '-':
1490                                 r = cmp(link.get(bv, 'order'),
1491                                     link.get(av, 'order'))
1492                                 if r != 0: return r
1493                         elif link.getkey():
1494                             key = link.getkey()
1495                             if dir == '+':
1496                                 r = cmp(link.get(av, key), link.get(bv, key))
1497                                 if r != 0: return r
1498                             elif dir == '-':
1499                                 r = cmp(link.get(bv, key), link.get(av, key))
1500                                 if r != 0: return r
1501                         else:
1502                             if dir == '+':
1503                                 r = cmp(av, bv)
1504                                 if r != 0: return r
1505                             elif dir == '-':
1506                                 r = cmp(bv, av)
1507                                 if r != 0: return r
1509                     # Multilink properties are sorted according to how many
1510                     # links are present.
1511                     elif isinstance(propclass, Multilink):
1512                         if dir == '+':
1513                             r = cmp(len(av), len(bv))
1514                             if r != 0: return r
1515                         elif dir == '-':
1516                             r = cmp(len(bv), len(av))
1517                             if r != 0: return r
1518                     elif isinstance(propclass, Number) or isinstance(propclass, Boolean):
1519                         if dir == '+':
1520                             r = cmp(av, bv)
1521                         elif dir == '-':
1522                             r = cmp(bv, av)
1523                         
1524                 # end for dir, prop in list:
1525             # end for list in sort, group:
1526             # if all else fails, compare the ids
1527             return cmp(a[0], b[0])
1529         l.sort(sortfun)
1530         return [i[0] for i in l]
1532     def count(self):
1533         """Get the number of nodes in this class.
1535         If the returned integer is 'numnodes', the ids of all the nodes
1536         in this class run from 1 to numnodes, and numnodes+1 will be the
1537         id of the next node to be created in this class.
1538         """
1539         return self.db.countnodes(self.classname)
1541     # Manipulating properties:
1543     def getprops(self, protected=1):
1544         """Return a dictionary mapping property names to property objects.
1545            If the "protected" flag is true, we include protected properties -
1546            those which may not be modified.
1548            In addition to the actual properties on the node, these
1549            methods provide the "creation" and "activity" properties. If the
1550            "protected" flag is true, we include protected properties - those
1551            which may not be modified.
1552         """
1553         d = self.properties.copy()
1554         if protected:
1555             d['id'] = String()
1556             d['creation'] = hyperdb.Date()
1557             d['activity'] = hyperdb.Date()
1558             d['creator'] = hyperdb.Link("user")
1559         return d
1561     def addprop(self, **properties):
1562         """Add properties to this class.
1564         The keyword arguments in 'properties' must map names to property
1565         objects, or a TypeError is raised.  None of the keys in 'properties'
1566         may collide with the names of existing properties, or a ValueError
1567         is raised before any properties have been added.
1568         """
1569         for key in properties.keys():
1570             if self.properties.has_key(key):
1571                 raise ValueError, key
1572         self.properties.update(properties)
1574     def index(self, nodeid):
1575         '''Add (or refresh) the node to search indexes
1576         '''
1577         # find all the String properties that have indexme
1578         for prop, propclass in self.getprops().items():
1579             if isinstance(propclass, String) and propclass.indexme:
1580                 # and index them under (classname, nodeid, property)
1581                 self.db.indexer.add_text((self.classname, nodeid, prop),
1582                     str(self.get(nodeid, prop)))
1584     #
1585     # Detector interface
1586     #
1587     def audit(self, event, detector):
1588         """Register a detector
1589         """
1590         l = self.auditors[event]
1591         if detector not in l:
1592             self.auditors[event].append(detector)
1594     def fireAuditors(self, action, nodeid, newvalues):
1595         """Fire all registered auditors.
1596         """
1597         for audit in self.auditors[action]:
1598             audit(self.db, self, nodeid, newvalues)
1600     def react(self, event, detector):
1601         """Register a detector
1602         """
1603         l = self.reactors[event]
1604         if detector not in l:
1605             self.reactors[event].append(detector)
1607     def fireReactors(self, action, nodeid, oldvalues):
1608         """Fire all registered reactors.
1609         """
1610         for react in self.reactors[action]:
1611             react(self.db, self, nodeid, oldvalues)
1613 class FileClass(Class):
1614     '''This class defines a large chunk of data. To support this, it has a
1615        mandatory String property "content" which is typically saved off
1616        externally to the hyperdb.
1618        The default MIME type of this data is defined by the
1619        "default_mime_type" class attribute, which may be overridden by each
1620        node if the class defines a "type" String property.
1621     '''
1622     default_mime_type = 'text/plain'
1624     def create(self, **propvalues):
1625         ''' snaffle the file propvalue and store in a file
1626         '''
1627         content = propvalues['content']
1628         del propvalues['content']
1629         newid = Class.create(self, **propvalues)
1630         self.db.storefile(self.classname, newid, None, content)
1631         return newid
1633     def get(self, nodeid, propname, default=_marker, cache=1):
1634         ''' trap the content propname and get it from the file
1635         '''
1637         poss_msg = 'Possibly a access right configuration problem.'
1638         if propname == 'content':
1639             try:
1640                 return self.db.getfile(self.classname, nodeid, None)
1641             except IOError, (strerror):
1642                 # BUG: by catching this we donot see an error in the log.
1643                 return 'ERROR reading file: %s%s\n%s\n%s'%(
1644                         self.classname, nodeid, poss_msg, strerror)
1645         if default is not _marker:
1646             return Class.get(self, nodeid, propname, default, cache=cache)
1647         else:
1648             return Class.get(self, nodeid, propname, cache=cache)
1650     def getprops(self, protected=1):
1651         ''' In addition to the actual properties on the node, these methods
1652             provide the "content" property. If the "protected" flag is true,
1653             we include protected properties - those which may not be
1654             modified.
1655         '''
1656         d = Class.getprops(self, protected=protected).copy()
1657         if protected:
1658             d['content'] = hyperdb.String()
1659         return d
1661     def index(self, nodeid):
1662         ''' Index the node in the search index.
1664             We want to index the content in addition to the normal String
1665             property indexing.
1666         '''
1667         # perform normal indexing
1668         Class.index(self, nodeid)
1670         # get the content to index
1671         content = self.get(nodeid, 'content')
1673         # figure the mime type
1674         if self.properties.has_key('type'):
1675             mime_type = self.get(nodeid, 'type')
1676         else:
1677             mime_type = self.default_mime_type
1679         # and index!
1680         self.db.indexer.add_text((self.classname, nodeid, 'content'), content,
1681             mime_type)
1683 # XXX deviation from spec - was called ItemClass
1684 class IssueClass(Class, roundupdb.IssueClass):
1685     # Overridden methods:
1686     def __init__(self, db, classname, **properties):
1687         """The newly-created class automatically includes the "messages",
1688         "files", "nosy", and "superseder" properties.  If the 'properties'
1689         dictionary attempts to specify any of these properties or a
1690         "creation" or "activity" property, a ValueError is raised.
1691         """
1692         if not properties.has_key('title'):
1693             properties['title'] = hyperdb.String(indexme='yes')
1694         if not properties.has_key('messages'):
1695             properties['messages'] = hyperdb.Multilink("msg")
1696         if not properties.has_key('files'):
1697             properties['files'] = hyperdb.Multilink("file")
1698         if not properties.has_key('nosy'):
1699             properties['nosy'] = hyperdb.Multilink("user")
1700         if not properties.has_key('superseder'):
1701             properties['superseder'] = hyperdb.Multilink(classname)
1702         Class.__init__(self, db, classname, **properties)
1705 #$Log: not supported by cvs2svn $
1706 #Revision 1.49  2002/07/18 11:41:10  richard
1707 #added tests for boolean type, and fixes to anydbm backend
1709 #Revision 1.48  2002/07/18 11:17:31  gmcm
1710 #Add Number and Boolean types to hyperdb.
1711 #Add conversion cases to web, mail & admin interfaces.
1712 #Add storage/serialization cases to back_anydbm & back_metakit.
1714 #Revision 1.47  2002/07/14 23:18:20  richard
1715 #. fixed the journal bloat from multilink changes - we just log the add or
1716 #  remove operations, not the whole list
1718 #Revision 1.46  2002/07/14 06:06:34  richard
1719 #Did some old TODOs
1721 #Revision 1.45  2002/07/14 04:03:14  richard
1722 #Implemented a switch to disable journalling for a Class. CGI session
1723 #database now uses it.
1725 #Revision 1.44  2002/07/14 02:05:53  richard
1726 #. all storage-specific code (ie. backend) is now implemented by the backends
1728 #Revision 1.43  2002/07/10 06:30:30  richard
1729 #...except of course it's nice to use valid Python syntax
1731 #Revision 1.42  2002/07/10 06:21:38  richard
1732 #Be extra safe
1734 #Revision 1.41  2002/07/10 00:21:45  richard
1735 #explicit database closing
1737 #Revision 1.40  2002/07/09 04:19:09  richard
1738 #Added reindex command to roundup-admin.
1739 #Fixed reindex on first access.
1740 #Also fixed reindexing of entries that change.
1742 #Revision 1.39  2002/07/09 03:02:52  richard
1743 #More indexer work:
1744 #- all String properties may now be indexed too. Currently there's a bit of
1745 #  "issue" specific code in the actual searching which needs to be
1746 #  addressed. In a nutshell:
1747 #  + pass 'indexme="yes"' as a String() property initialisation arg, eg:
1748 #        file = FileClass(db, "file", name=String(), type=String(),
1749 #            comment=String(indexme="yes"))
1750 #  + the comment will then be indexed and be searchable, with the results
1751 #    related back to the issue that the file is linked to
1752 #- as a result of this work, the FileClass has a default MIME type that may
1753 #  be overridden in a subclass, or by the use of a "type" property as is
1754 #  done in the default templates.
1755 #- the regeneration of the indexes (if necessary) is done once the schema is
1756 #  set up in the dbinit.
1758 #Revision 1.38  2002/07/08 06:58:15  richard
1759 #cleaned up the indexer code:
1760 # - it splits more words out (much simpler, faster splitter)
1761 # - removed code we'll never use (roundup.roundup_indexer has the full
1762 #   implementation, and replaces roundup.indexer)
1763 # - only index text/plain and rfc822/message (ideas for other text formats to
1764 #   index are welcome)
1765 # - added simple unit test for indexer. Needs more tests for regression.
1767 #Revision 1.37  2002/06/20 23:52:35  richard
1768 #More informative error message
1770 #Revision 1.36  2002/06/19 03:07:19  richard
1771 #Moved the file storage commit into blobfiles where it belongs.
1773 #Revision 1.35  2002/05/25 07:16:24  rochecompaan
1774 #Merged search_indexing-branch with HEAD
1776 #Revision 1.34  2002/05/15 06:21:21  richard
1777 # . node caching now works, and gives a small boost in performance
1779 #As a part of this, I cleaned up the DEBUG output and implemented TRACE
1780 #output (HYPERDBTRACE='file to trace to') with checkpoints at the start of
1781 #CGI requests. Run roundup with python -O to skip all the DEBUG/TRACE stuff
1782 #(using if __debug__ which is compiled out with -O)
1784 #Revision 1.33  2002/04/24 10:38:26  rochecompaan
1785 #All database files are now created group readable and writable.
1787 #Revision 1.32  2002/04/15 23:25:15  richard
1788 #. node ids are now generated from a lockable store - no more race conditions
1790 #We're using the portalocker code by Jonathan Feinberg that was contributed
1791 #to the ASPN Python cookbook. This gives us locking across Unix and Windows.
1793 #Revision 1.31  2002/04/03 05:54:31  richard
1794 #Fixed serialisation problem by moving the serialisation step out of the
1795 #hyperdb.Class (get, set) into the hyperdb.Database.
1797 #Also fixed htmltemplate after the showid changes I made yesterday.
1799 #Unit tests for all of the above written.
1801 #Revision 1.30.2.1  2002/04/03 11:55:57  rochecompaan
1802 # . Added feature #526730 - search for messages capability
1804 #Revision 1.30  2002/02/27 03:40:59  richard
1805 #Ran it through pychecker, made fixes
1807 #Revision 1.29  2002/02/25 14:34:31  grubert
1808 # . use blobfiles in back_anydbm which is used in back_bsddb.
1809 #   change test_db as dirlist does not work for subdirectories.
1810 #   ATTENTION: blobfiles now creates subdirectories for files.
1812 #Revision 1.28  2002/02/16 09:14:17  richard
1813 # . #514854 ] History: "User" is always ticket creator
1815 #Revision 1.27  2002/01/22 07:21:13  richard
1816 #. fixed back_bsddb so it passed the journal tests
1818 #... it didn't seem happy using the back_anydbm _open method, which is odd.
1819 #Yet another occurrance of whichdb not being able to recognise older bsddb
1820 #databases. Yadda yadda. Made the HYPERDBDEBUG stuff more sane in the
1821 #process.
1823 #Revision 1.26  2002/01/22 05:18:38  rochecompaan
1824 #last_set_entry was referenced before assignment
1826 #Revision 1.25  2002/01/22 05:06:08  rochecompaan
1827 #We need to keep the last 'set' entry in the journal to preserve
1828 #information on 'activity' for nodes.
1830 #Revision 1.24  2002/01/21 16:33:20  rochecompaan
1831 #You can now use the roundup-admin tool to pack the database
1833 #Revision 1.23  2002/01/18 04:32:04  richard
1834 #Rollback was breaking because a message hadn't actually been written to the file. Needs
1835 #more investigation.
1837 #Revision 1.22  2002/01/14 02:20:15  richard
1838 # . changed all config accesses so they access either the instance or the
1839 #   config attriubute on the db. This means that all config is obtained from
1840 #   instance_config instead of the mish-mash of classes. This will make
1841 #   switching to a ConfigParser setup easier too, I hope.
1843 #At a minimum, this makes migration a _little_ easier (a lot easier in the
1844 #0.5.0 switch, I hope!)
1846 #Revision 1.21  2002/01/02 02:31:38  richard
1847 #Sorry for the huge checkin message - I was only intending to implement #496356
1848 #but I found a number of places where things had been broken by transactions:
1849 # . modified ROUNDUPDBSENDMAILDEBUG to be SENDMAILDEBUG and hold a filename
1850 #   for _all_ roundup-generated smtp messages to be sent to.
1851 # . the transaction cache had broken the roundupdb.Class set() reactors
1852 # . newly-created author users in the mailgw weren't being committed to the db
1854 #Stuff that made it into CHANGES.txt (ie. the stuff I was actually working
1855 #on when I found that stuff :):
1856 # . #496356 ] Use threading in messages
1857 # . detectors were being registered multiple times
1858 # . added tests for mailgw
1859 # . much better attaching of erroneous messages in the mail gateway
1861 #Revision 1.20  2001/12/18 15:30:34  rochecompaan
1862 #Fixed bugs:
1863 # .  Fixed file creation and retrieval in same transaction in anydbm
1864 #    backend
1865 # .  Cgi interface now renders new issue after issue creation
1866 # .  Could not set issue status to resolved through cgi interface
1867 # .  Mail gateway was changing status back to 'chatting' if status was
1868 #    omitted as an argument
1870 #Revision 1.19  2001/12/17 03:52:48  richard
1871 #Implemented file store rollback. As a bonus, the hyperdb is now capable of
1872 #storing more than one file per node - if a property name is supplied,
1873 #the file is called designator.property.
1874 #I decided not to migrate the existing files stored over to the new naming
1875 #scheme - the FileClass just doesn't specify the property name.
1877 #Revision 1.18  2001/12/16 10:53:38  richard
1878 #take a copy of the node dict so that the subsequent set
1879 #operation doesn't modify the oldvalues structure
1881 #Revision 1.17  2001/12/14 23:42:57  richard
1882 #yuck, a gdbm instance tests false :(
1883 #I've left the debugging code in - it should be removed one day if we're ever
1884 #_really_ anal about performace :)
1886 #Revision 1.16  2001/12/12 03:23:14  richard
1887 #Cor blimey this anydbm/whichdb stuff is yecchy. Turns out that whichdb
1888 #incorrectly identifies a dbm file as a dbhash file on my system. This has
1889 #been submitted to the python bug tracker as issue #491888:
1890 #https://sourceforge.net/tracker/index.php?func=detail&aid=491888&group_id=5470&atid=105470
1892 #Revision 1.15  2001/12/12 02:30:51  richard
1893 #I fixed the problems with people whose anydbm was using the dbm module at the
1894 #backend. It turns out the dbm module modifies the file name to append ".db"
1895 #and my check to determine if we're opening an existing or new db just
1896 #tested os.path.exists() on the filename. Well, no longer! We now perform a
1897 #much better check _and_ cope with the anydbm implementation module changing
1898 #too!
1899 #I also fixed the backends __init__ so only ImportError is squashed.
1901 #Revision 1.14  2001/12/10 22:20:01  richard
1902 #Enabled transaction support in the bsddb backend. It uses the anydbm code
1903 #where possible, only replacing methods where the db is opened (it uses the
1904 #btree opener specifically.)
1905 #Also cleaned up some change note generation.
1906 #Made the backends package work with pydoc too.
1908 #Revision 1.13  2001/12/02 05:06:16  richard
1909 #. We now use weakrefs in the Classes to keep the database reference, so
1910 #  the close() method on the database is no longer needed.
1911 #  I bumped the minimum python requirement up to 2.1 accordingly.
1912 #. #487480 ] roundup-server
1913 #. #487476 ] INSTALL.txt
1915 #I also cleaned up the change message / post-edit stuff in the cgi client.
1916 #There's now a clearly marked "TODO: append the change note" where I believe
1917 #the change note should be added there. The "changes" list will obviously
1918 #have to be modified to be a dict of the changes, or somesuch.
1920 #More testing needed.
1922 #Revision 1.12  2001/12/01 07:17:50  richard
1923 #. We now have basic transaction support! Information is only written to
1924 #  the database when the commit() method is called. Only the anydbm
1925 #  backend is modified in this way - neither of the bsddb backends have been.
1926 #  The mail, admin and cgi interfaces all use commit (except the admin tool
1927 #  doesn't have a commit command, so interactive users can't commit...)
1928 #. Fixed login/registration forwarding the user to the right page (or not,
1929 #  on a failure)
1931 #Revision 1.11  2001/11/21 02:34:18  richard
1932 #Added a target version field to the extended issue schema
1934 #Revision 1.10  2001/10/09 23:58:10  richard
1935 #Moved the data stringification up into the hyperdb.Class class' get, set
1936 #and create methods. This means that the data is also stringified for the
1937 #journal call, and removes duplication of code from the backends. The
1938 #backend code now only sees strings.
1940 #Revision 1.9  2001/10/09 07:25:59  richard
1941 #Added the Password property type. See "pydoc roundup.password" for
1942 #implementation details. Have updated some of the documentation too.
1944 #Revision 1.8  2001/09/29 13:27:00  richard
1945 #CGI interfaces now spit up a top-level index of all the instances they can
1946 #serve.
1948 #Revision 1.7  2001/08/12 06:32:36  richard
1949 #using isinstance(blah, Foo) now instead of isFooType
1951 #Revision 1.6  2001/08/07 00:24:42  richard
1952 #stupid typo
1954 #Revision 1.5  2001/08/07 00:15:51  richard
1955 #Added the copyright/license notice to (nearly) all files at request of
1956 #Bizar Software.
1958 #Revision 1.4  2001/07/30 01:41:36  richard
1959 #Makes schema changes mucho easier.
1961 #Revision 1.3  2001/07/25 01:23:07  richard
1962 #Added the Roundup spec to the new documentation directory.
1964 #Revision 1.2  2001/07/23 08:20:44  richard
1965 #Moved over to using marshal in the bsddb and anydbm backends.
1966 #roundup-admin now has a "freshen" command that'll load/save all nodes (not
1967 # retired - mod hyperdb.Class.list() so it lists retired nodes)