Code

933738dbc6d6a436c398502f4d574fb6fefc4a9c
[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.52 2002-07-19 03:36:34 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.destroyednodes = {}# keep track of the destroyed nodes by class
67         self.transactions = []
68         self.indexer = Indexer(self.dir)
69         # ensure files are group readable and writable
70         os.umask(0002)
72     def post_init(self):
73         """Called once the schema initialisation has finished."""
74         # reindex the db if necessary
75         if self.indexer.should_reindex():
76             self.reindex()
78     def reindex(self):
79         for klass in self.classes.values():
80             for nodeid in klass.list():
81                 klass.index(nodeid)
82         self.indexer.save_index()
84     def __repr__(self):
85         return '<back_anydbm instance at %x>'%id(self) 
87     #
88     # Classes
89     #
90     def __getattr__(self, classname):
91         """A convenient way of calling self.getclass(classname)."""
92         if self.classes.has_key(classname):
93             if __debug__:
94                 print >>hyperdb.DEBUG, '__getattr__', (self, classname)
95             return self.classes[classname]
96         raise AttributeError, classname
98     def addclass(self, cl):
99         if __debug__:
100             print >>hyperdb.DEBUG, 'addclass', (self, cl)
101         cn = cl.classname
102         if self.classes.has_key(cn):
103             raise ValueError, cn
104         self.classes[cn] = cl
106     def getclasses(self):
107         """Return a list of the names of all existing classes."""
108         if __debug__:
109             print >>hyperdb.DEBUG, 'getclasses', (self,)
110         l = self.classes.keys()
111         l.sort()
112         return l
114     def getclass(self, classname):
115         """Get the Class object representing a particular class.
117         If 'classname' is not a valid class name, a KeyError is raised.
118         """
119         if __debug__:
120             print >>hyperdb.DEBUG, 'getclass', (self, classname)
121         return self.classes[classname]
123     #
124     # Class DBs
125     #
126     def clear(self):
127         '''Delete all database contents
128         '''
129         if __debug__:
130             print >>hyperdb.DEBUG, 'clear', (self,)
131         for cn in self.classes.keys():
132             for dummy in 'nodes', 'journals':
133                 path = os.path.join(self.dir, 'journals.%s'%cn)
134                 if os.path.exists(path):
135                     os.remove(path)
136                 elif os.path.exists(path+'.db'):    # dbm appends .db
137                     os.remove(path+'.db')
139     def getclassdb(self, classname, mode='r'):
140         ''' grab a connection to the class db that will be used for
141             multiple actions
142         '''
143         if __debug__:
144             print >>hyperdb.DEBUG, 'getclassdb', (self, classname, mode)
145         return self.opendb('nodes.%s'%classname, mode)
147     def determine_db_type(self, path):
148         ''' determine which DB wrote the class file
149         '''
150         db_type = ''
151         if os.path.exists(path):
152             db_type = whichdb.whichdb(path)
153             if not db_type:
154                 raise hyperdb.DatabaseError, "Couldn't identify database type"
155         elif os.path.exists(path+'.db'):
156             # if the path ends in '.db', it's a dbm database, whether
157             # anydbm says it's dbhash or not!
158             db_type = 'dbm'
159         return db_type
161     def opendb(self, name, mode):
162         '''Low-level database opener that gets around anydbm/dbm
163            eccentricities.
164         '''
165         if __debug__:
166             print >>hyperdb.DEBUG, 'opendb', (self, name, mode)
168         # figure the class db type
169         path = os.path.join(os.getcwd(), self.dir, name)
170         db_type = self.determine_db_type(path)
172         # new database? let anydbm pick the best dbm
173         if not db_type:
174             if __debug__:
175                 print >>hyperdb.DEBUG, "opendb anydbm.open(%r, 'n')"%path
176             return anydbm.open(path, 'n')
178         # open the database with the correct module
179         try:
180             dbm = __import__(db_type)
181         except ImportError:
182             raise hyperdb.DatabaseError, \
183                 "Couldn't open database - the required module '%s'"\
184                 " is not available"%db_type
185         if __debug__:
186             print >>hyperdb.DEBUG, "opendb %r.open(%r, %r)"%(db_type, path,
187                 mode)
188         return dbm.open(path, mode)
190     def lockdb(self, name):
191         ''' Lock a database file
192         '''
193         path = os.path.join(os.getcwd(), self.dir, '%s.lock'%name)
194         return acquire_lock(path)
196     #
197     # Node IDs
198     #
199     def newid(self, classname):
200         ''' Generate a new id for the given class
201         '''
202         # open the ids DB - create if if doesn't exist
203         lock = self.lockdb('_ids')
204         db = self.opendb('_ids', 'c')
205         if db.has_key(classname):
206             newid = db[classname] = str(int(db[classname]) + 1)
207         else:
208             # the count() bit is transitional - older dbs won't start at 1
209             newid = str(self.getclass(classname).count()+1)
210             db[classname] = newid
211         db.close()
212         release_lock(lock)
213         return newid
215     #
216     # Nodes
217     #
218     def addnode(self, classname, nodeid, node):
219         ''' add the specified node to its class's db
220         '''
221         if __debug__:
222             print >>hyperdb.DEBUG, 'addnode', (self, classname, nodeid, node)
223         self.newnodes.setdefault(classname, {})[nodeid] = 1
224         self.cache.setdefault(classname, {})[nodeid] = node
225         self.savenode(classname, nodeid, node)
227     def setnode(self, classname, nodeid, node):
228         ''' change the specified node
229         '''
230         if __debug__:
231             print >>hyperdb.DEBUG, 'setnode', (self, classname, nodeid, node)
232         self.dirtynodes.setdefault(classname, {})[nodeid] = 1
234         # can't set without having already loaded the node
235         self.cache[classname][nodeid] = node
236         self.savenode(classname, nodeid, node)
238     def savenode(self, classname, nodeid, node):
239         ''' perform the saving of data specified by the set/addnode
240         '''
241         if __debug__:
242             print >>hyperdb.DEBUG, 'savenode', (self, classname, nodeid, node)
243         self.transactions.append((self.doSaveNode, (classname, nodeid, node)))
245     def getnode(self, classname, nodeid, db=None, cache=1):
246         ''' get a node from the database
247         '''
248         if __debug__:
249             print >>hyperdb.DEBUG, 'getnode', (self, classname, nodeid, db)
250         if cache:
251             # try the cache
252             cache_dict = self.cache.setdefault(classname, {})
253             if cache_dict.has_key(nodeid):
254                 if __debug__:
255                     print >>hyperdb.TRACE, 'get %s %s cached'%(classname,
256                         nodeid)
257                 return cache_dict[nodeid]
259         if __debug__:
260             print >>hyperdb.TRACE, 'get %s %s'%(classname, nodeid)
262         # get from the database and save in the cache
263         if db is None:
264             db = self.getclassdb(classname)
265         if not db.has_key(nodeid):
266             raise IndexError, "no such %s %s"%(classname, nodeid)
268         # check the uncommitted, destroyed nodes
269         if (self.destroyednodes.has_key(classname) and
270                 self.destroyednodes[classname].has_key(nodeid)):
271             raise IndexError, "no such %s %s"%(classname, nodeid)
273         # decode
274         res = marshal.loads(db[nodeid])
276         # reverse the serialisation
277         res = self.unserialise(classname, res)
279         # store off in the cache dict
280         if cache:
281             cache_dict[nodeid] = res
283         return res
285     def destroynode(self, classname, nodeid):
286         '''Remove a node from the database. Called exclusively by the
287            destroy() method on Class.
288         '''
289         if __debug__:
290             print >>hyperdb.DEBUG, 'destroynode', (self, classname, nodeid)
292         # remove from cache and newnodes if it's there
293         if (self.cache.has_key(classname) and
294                 self.cache[classname].has_key(nodeid)):
295             del self.cache[classname][nodeid]
296         if (self.newnodes.has_key(classname) and
297                 self.newnodes[classname].has_key(nodeid)):
298             del self.newnodes[classname][nodeid]
300         # see if there's any obvious commit actions that we should get rid of
301         for entry in self.transactions[:]:
302             if entry[1][:2] == (classname, nodeid):
303                 self.transactions.remove(entry)
305         # add to the destroyednodes map
306         self.destroyednodes.setdefault(classname, {})[nodeid] = 1
308         # add the destroy commit action
309         self.transactions.append((self.doDestroyNode, (classname, nodeid)))
311     def serialise(self, classname, node):
312         '''Copy the node contents, converting non-marshallable data into
313            marshallable data.
314         '''
315         if __debug__:
316             print >>hyperdb.DEBUG, 'serialise', classname, node
317         properties = self.getclass(classname).getprops()
318         d = {}
319         for k, v in node.items():
320             # if the property doesn't exist, or is the "retired" flag then
321             # it won't be in the properties dict
322             if not properties.has_key(k):
323                 d[k] = v
324                 continue
326             # get the property spec
327             prop = properties[k]
329             if isinstance(prop, Password):
330                 d[k] = str(v)
331             elif isinstance(prop, Date) and v is not None:
332                 d[k] = v.get_tuple()
333             elif isinstance(prop, Interval) and v is not None:
334                 d[k] = v.get_tuple()
335             else:
336                 d[k] = v
337         return d
339     def unserialise(self, classname, node):
340         '''Decode the marshalled node data
341         '''
342         if __debug__:
343             print >>hyperdb.DEBUG, 'unserialise', classname, node
344         properties = self.getclass(classname).getprops()
345         d = {}
346         for k, v in node.items():
347             # if the property doesn't exist, or is the "retired" flag then
348             # it won't be in the properties dict
349             if not properties.has_key(k):
350                 d[k] = v
351                 continue
353             # get the property spec
354             prop = properties[k]
356             if isinstance(prop, Date) and v is not None:
357                 d[k] = date.Date(v)
358             elif isinstance(prop, Interval) and v is not None:
359                 d[k] = date.Interval(v)
360             elif isinstance(prop, Password):
361                 p = password.Password()
362                 p.unpack(v)
363                 d[k] = p
364             else:
365                 d[k] = v
366         return d
368     def hasnode(self, classname, nodeid, db=None):
369         ''' determine if the database has a given node
370         '''
371         if __debug__:
372             print >>hyperdb.DEBUG, 'hasnode', (self, classname, nodeid, db)
374         # try the cache
375         cache = self.cache.setdefault(classname, {})
376         if cache.has_key(nodeid):
377             if __debug__:
378                 print >>hyperdb.TRACE, 'has %s %s cached'%(classname, nodeid)
379             return 1
380         if __debug__:
381             print >>hyperdb.TRACE, 'has %s %s'%(classname, nodeid)
383         # not in the cache - check the database
384         if db is None:
385             db = self.getclassdb(classname)
386         res = db.has_key(nodeid)
387         return res
389     def countnodes(self, classname, db=None):
390         if __debug__:
391             print >>hyperdb.DEBUG, 'countnodes', (self, classname, db)
393         count = 0
395         # include the uncommitted nodes
396         if self.newnodes.has_key(classname):
397             count += len(self.newnodes[classname])
398         if self.destroyednodes.has_key(classname):
399             count -= len(self.destroyednodes[classname])
401         # and count those in the DB
402         if db is None:
403             db = self.getclassdb(classname)
404         count = count + len(db.keys())
405         return count
407     def getnodeids(self, classname, db=None):
408         if __debug__:
409             print >>hyperdb.DEBUG, 'getnodeids', (self, classname, db)
411         res = []
413         # start off with the new nodes
414         if self.newnodes.has_key(classname):
415             res += self.newnodes[classname].keys()
417         if db is None:
418             db = self.getclassdb(classname)
419         res = res + db.keys()
421         # remove the uncommitted, destroyed nodes
422         if self.destroyednodes.has_key(classname):
423             for nodeid in self.destroyednodes[classname].keys():
424                 if db.has_key(nodeid):
425                     res.remove(nodeid)
427         return res
430     #
431     # Files - special node properties
432     # inherited from FileStorage
434     #
435     # Journal
436     #
437     def addjournal(self, classname, nodeid, action, params):
438         ''' Journal the Action
439         'action' may be:
441             'create' or 'set' -- 'params' is a dictionary of property values
442             'link' or 'unlink' -- 'params' is (classname, nodeid, propname)
443             'retire' -- 'params' is None
444         '''
445         if __debug__:
446             print >>hyperdb.DEBUG, 'addjournal', (self, classname, nodeid,
447                 action, params)
448         self.transactions.append((self.doSaveJournal, (classname, nodeid,
449             action, params)))
451     def getjournal(self, classname, nodeid):
452         ''' get the journal for id
454             Raise IndexError if the node doesn't exist (as per history()'s
455             API)
456         '''
457         if __debug__:
458             print >>hyperdb.DEBUG, 'getjournal', (self, classname, nodeid)
459         # attempt to open the journal - in some rare cases, the journal may
460         # not exist
461         try:
462             db = self.opendb('journals.%s'%classname, 'r')
463         except anydbm.error, error:
464             if str(error) == "need 'c' or 'n' flag to open new db":
465                 raise IndexError, 'no such %s %s'%(classname, nodeid)
466             elif error.args[0] != 2:
467                 raise
468             raise IndexError, 'no such %s %s'%(classname, nodeid)
469         try:
470             journal = marshal.loads(db[nodeid])
471         except KeyError:
472             db.close()
473             raise IndexError, 'no such %s %s'%(classname, nodeid)
474         db.close()
475         res = []
476         for nodeid, date_stamp, user, action, params in journal:
477             res.append((nodeid, date.Date(date_stamp), user, action, params))
478         return res
480     def pack(self, pack_before):
481         ''' delete all journal entries before 'pack_before' '''
482         if __debug__:
483             print >>hyperdb.DEBUG, 'packjournal', (self, pack_before)
485         pack_before = pack_before.get_tuple()
487         classes = self.getclasses()
489         # figure the class db type
491         for classname in classes:
492             db_name = 'journals.%s'%classname
493             path = os.path.join(os.getcwd(), self.dir, classname)
494             db_type = self.determine_db_type(path)
495             db = self.opendb(db_name, 'w')
497             for key in db.keys():
498                 journal = marshal.loads(db[key])
499                 l = []
500                 last_set_entry = None
501                 for entry in journal:
502                     (nodeid, date_stamp, self.journaltag, action, 
503                         params) = entry
504                     if date_stamp > pack_before or action == 'create':
505                         l.append(entry)
506                     elif action == 'set':
507                         # grab the last set entry to keep information on
508                         # activity
509                         last_set_entry = entry
510                 if last_set_entry:
511                     date_stamp = last_set_entry[1]
512                     # if the last set entry was made after the pack date
513                     # then it is already in the list
514                     if date_stamp < pack_before:
515                         l.append(last_set_entry)
516                 db[key] = marshal.dumps(l)
517             if db_type == 'gdbm':
518                 db.reorganize()
519             db.close()
520             
522     #
523     # Basic transaction support
524     #
525     def commit(self):
526         ''' Commit the current transactions.
527         '''
528         if __debug__:
529             print >>hyperdb.DEBUG, 'commit', (self,)
530         # TODO: lock the DB
532         # keep a handle to all the database files opened
533         self.databases = {}
535         # now, do all the transactions
536         reindex = {}
537         for method, args in self.transactions:
538             reindex[method(*args)] = 1
540         # now close all the database files
541         for db in self.databases.values():
542             db.close()
543         del self.databases
544         # TODO: unlock the DB
546         # reindex the nodes that request it
547         for classname, nodeid in filter(None, reindex.keys()):
548             print >>hyperdb.DEBUG, 'commit.reindex', (classname, nodeid)
549             self.getclass(classname).index(nodeid)
551         # save the indexer state
552         self.indexer.save_index()
554         # all transactions committed, back to normal
555         self.cache = {}
556         self.dirtynodes = {}
557         self.newnodes = {}
558         self.destroyednodes = {}
559         self.transactions = []
561     def getCachedClassDB(self, classname):
562         ''' get the class db, looking in our cache of databases for commit
563         '''
564         # get the database handle
565         db_name = 'nodes.%s'%classname
566         if not self.databases.has_key(db_name):
567             self.databases[db_name] = self.getclassdb(classname, 'c')
568         return self.databases[db_name]
570     def doSaveNode(self, classname, nodeid, node):
571         if __debug__:
572             print >>hyperdb.DEBUG, 'doSaveNode', (self, classname, nodeid,
573                 node)
575         db = self.getCachedClassDB(classname)
577         # now save the marshalled data
578         db[nodeid] = marshal.dumps(self.serialise(classname, node))
580         # return the classname, nodeid so we reindex this content
581         return (classname, nodeid)
583     def getCachedJournalDB(self, classname):
584         ''' get the journal db, looking in our cache of databases for commit
585         '''
586         # get the database handle
587         db_name = 'journals.%s'%classname
588         if not self.databases.has_key(db_name):
589             self.databases[db_name] = self.opendb(db_name, 'c')
590         return self.databases[db_name]
592     def doSaveJournal(self, classname, nodeid, action, params):
593         # serialise first
594         if action in ('set', 'create'):
595             params = self.serialise(classname, params)
597         # create the journal entry
598         entry = (nodeid, date.Date().get_tuple(), self.journaltag, action,
599             params)
601         if __debug__:
602             print >>hyperdb.DEBUG, 'doSaveJournal', entry
604         db = self.getCachedJournalDB(classname)
606         # now insert the journal entry
607         if db.has_key(nodeid):
608             # append to existing
609             s = db[nodeid]
610             l = marshal.loads(s)
611             l.append(entry)
612         else:
613             l = [entry]
615         db[nodeid] = marshal.dumps(l)
617     def doDestroyNode(self, classname, nodeid):
618         if __debug__:
619             print >>hyperdb.DEBUG, 'doDestroyNode', (self, classname, nodeid)
621         # delete from the class database
622         db = self.getCachedClassDB(classname)
623         if db.has_key(nodeid):
624             del db[nodeid]
626         # delete from the database
627         db = self.getCachedJournalDB(classname)
628         if db.has_key(nodeid):
629             del db[nodeid]
631         # return the classname, nodeid so we reindex this content
632         return (classname, nodeid)
634     def rollback(self):
635         ''' Reverse all actions from the current transaction.
636         '''
637         if __debug__:
638             print >>hyperdb.DEBUG, 'rollback', (self, )
639         for method, args in self.transactions:
640             # delete temporary files
641             if method == self.doStoreFile:
642                 self.rollbackStoreFile(*args)
643         self.cache = {}
644         self.dirtynodes = {}
645         self.newnodes = {}
646         self.destroyednodes = {}
647         self.transactions = []
649 _marker = []
650 class Class(hyperdb.Class):
651     """The handle to a particular class of nodes in a hyperdatabase."""
653     def __init__(self, db, classname, **properties):
654         """Create a new class with a given name and property specification.
656         'classname' must not collide with the name of an existing class,
657         or a ValueError is raised.  The keyword arguments in 'properties'
658         must map names to property objects, or a TypeError is raised.
659         """
660         if (properties.has_key('creation') or properties.has_key('activity')
661                 or properties.has_key('creator')):
662             raise ValueError, '"creation", "activity" and "creator" are '\
663                 'reserved'
665         self.classname = classname
666         self.properties = properties
667         self.db = weakref.proxy(db)       # use a weak ref to avoid circularity
668         self.key = ''
670         # should we journal changes (default yes)
671         self.do_journal = 1
673         # do the db-related init stuff
674         db.addclass(self)
676         self.auditors = {'create': [], 'set': [], 'retire': []}
677         self.reactors = {'create': [], 'set': [], 'retire': []}
679     def enableJournalling(self):
680         '''Turn journalling on for this class
681         '''
682         self.do_journal = 1
684     def disableJournalling(self):
685         '''Turn journalling off for this class
686         '''
687         self.do_journal = 0
689     # Editing nodes:
691     def create(self, **propvalues):
692         """Create a new node of this class and return its id.
694         The keyword arguments in 'propvalues' map property names to values.
696         The values of arguments must be acceptable for the types of their
697         corresponding properties or a TypeError is raised.
698         
699         If this class has a key property, it must be present and its value
700         must not collide with other key strings or a ValueError is raised.
701         
702         Any other properties on this class that are missing from the
703         'propvalues' dictionary are set to None.
704         
705         If an id in a link or multilink property does not refer to a valid
706         node, an IndexError is raised.
708         These operations trigger detectors and can be vetoed.  Attempts
709         to modify the "creation" or "activity" properties cause a KeyError.
710         """
711         if propvalues.has_key('id'):
712             raise KeyError, '"id" is reserved'
714         if self.db.journaltag is None:
715             raise DatabaseError, 'Database open read-only'
717         if propvalues.has_key('creation') or propvalues.has_key('activity'):
718             raise KeyError, '"creation" and "activity" are reserved'
720         self.fireAuditors('create', None, propvalues)
722         # new node's id
723         newid = self.db.newid(self.classname)
725         # validate propvalues
726         num_re = re.compile('^\d+$')
727         for key, value in propvalues.items():
728             if key == self.key:
729                 try:
730                     self.lookup(value)
731                 except KeyError:
732                     pass
733                 else:
734                     raise ValueError, 'node with key "%s" exists'%value
736             # try to handle this property
737             try:
738                 prop = self.properties[key]
739             except KeyError:
740                 raise KeyError, '"%s" has no property "%s"'%(self.classname,
741                     key)
743             if isinstance(prop, Link):
744                 if type(value) != type(''):
745                     raise ValueError, 'link value must be String'
746                 link_class = self.properties[key].classname
747                 # if it isn't a number, it's a key
748                 if not num_re.match(value):
749                     try:
750                         value = self.db.classes[link_class].lookup(value)
751                     except (TypeError, KeyError):
752                         raise IndexError, 'new property "%s": %s not a %s'%(
753                             key, value, link_class)
754                 elif not self.db.hasnode(link_class, value):
755                     raise IndexError, '%s has no node %s'%(link_class, value)
757                 # save off the value
758                 propvalues[key] = value
760                 # register the link with the newly linked node
761                 if self.do_journal and self.properties[key].do_journal:
762                     self.db.addjournal(link_class, value, 'link',
763                         (self.classname, newid, key))
765             elif isinstance(prop, Multilink):
766                 if type(value) != type([]):
767                     raise TypeError, 'new property "%s" not a list of ids'%key
769                 # clean up and validate the list of links
770                 link_class = self.properties[key].classname
771                 l = []
772                 for entry in value:
773                     if type(entry) != type(''):
774                         raise ValueError, '"%s" link value (%s) must be '\
775                             'String'%(key, value)
776                     # if it isn't a number, it's a key
777                     if not num_re.match(entry):
778                         try:
779                             entry = self.db.classes[link_class].lookup(entry)
780                         except (TypeError, KeyError):
781                             raise IndexError, 'new property "%s": %s not a %s'%(
782                                 key, entry, self.properties[key].classname)
783                     l.append(entry)
784                 value = l
785                 propvalues[key] = value
787                 # handle additions
788                 for id in value:
789                     if not self.db.hasnode(link_class, id):
790                         raise IndexError, '%s has no node %s'%(link_class, id)
791                     # register the link with the newly linked node
792                     if self.do_journal and self.properties[key].do_journal:
793                         self.db.addjournal(link_class, id, 'link',
794                             (self.classname, newid, key))
796             elif isinstance(prop, String):
797                 if type(value) != type(''):
798                     raise TypeError, 'new property "%s" not a string'%key
800             elif isinstance(prop, Password):
801                 if not isinstance(value, password.Password):
802                     raise TypeError, 'new property "%s" not a Password'%key
804             elif isinstance(prop, Date):
805                 if value is not None and not isinstance(value, date.Date):
806                     raise TypeError, 'new property "%s" not a Date'%key
808             elif isinstance(prop, Interval):
809                 if value is not None and not isinstance(value, date.Interval):
810                     raise TypeError, 'new property "%s" not an Interval'%key
812             elif value is not None and isinstance(prop, Number):
813                 try:
814                     float(value)
815                 except ValueError:
816                     raise TypeError, 'new property "%s" not numeric'%key
818             elif value is not None and isinstance(prop, Boolean):
819                 try:
820                     int(value)
821                 except ValueError:
822                     raise TypeError, 'new property "%s" not boolean'%key
824         # make sure there's data where there needs to be
825         for key, prop in self.properties.items():
826             if propvalues.has_key(key):
827                 continue
828             if key == self.key:
829                 raise ValueError, 'key property "%s" is required'%key
830             if isinstance(prop, Multilink):
831                 propvalues[key] = []
832             else:
833                 propvalues[key] = None
835         # done
836         self.db.addnode(self.classname, newid, propvalues)
837         if self.do_journal:
838             self.db.addjournal(self.classname, newid, 'create', propvalues)
840         self.fireReactors('create', newid, None)
842         return newid
844     def get(self, nodeid, propname, default=_marker, cache=1):
845         """Get the value of a property on an existing node of this class.
847         'nodeid' must be the id of an existing node of this class or an
848         IndexError is raised.  'propname' must be the name of a property
849         of this class or a KeyError is raised.
851         'cache' indicates whether the transaction cache should be queried
852         for the node. If the node has been modified and you need to
853         determine what its values prior to modification are, you need to
854         set cache=0.
856         Attempts to get the "creation" or "activity" properties should
857         do the right thing.
858         """
859         if propname == 'id':
860             return nodeid
862         if propname == 'creation':
863             if not self.do_journal:
864                 raise ValueError, 'Journalling is disabled for this class'
865             journal = self.db.getjournal(self.classname, nodeid)
866             if journal:
867                 return self.db.getjournal(self.classname, nodeid)[0][1]
868             else:
869                 # on the strange chance that there's no journal
870                 return date.Date()
871         if propname == 'activity':
872             if not self.do_journal:
873                 raise ValueError, 'Journalling is disabled for this class'
874             journal = self.db.getjournal(self.classname, nodeid)
875             if journal:
876                 return self.db.getjournal(self.classname, nodeid)[-1][1]
877             else:
878                 # on the strange chance that there's no journal
879                 return date.Date()
880         if propname == 'creator':
881             if not self.do_journal:
882                 raise ValueError, 'Journalling is disabled for this class'
883             journal = self.db.getjournal(self.classname, nodeid)
884             if journal:
885                 name = self.db.getjournal(self.classname, nodeid)[0][2]
886             else:
887                 return None
888             return self.db.user.lookup(name)
890         # get the property (raises KeyErorr if invalid)
891         prop = self.properties[propname]
893         # get the node's dict
894         d = self.db.getnode(self.classname, nodeid, cache=cache)
896         if not d.has_key(propname):
897             if default is _marker:
898                 if isinstance(prop, Multilink):
899                     return []
900                 else:
901                     return None
902             else:
903                 return default
905         return d[propname]
907     # XXX not in spec
908     def getnode(self, nodeid, cache=1):
909         ''' Return a convenience wrapper for the node.
911         'nodeid' must be the id of an existing node of this class or an
912         IndexError is raised.
914         'cache' indicates whether the transaction cache should be queried
915         for the node. If the node has been modified and you need to
916         determine what its values prior to modification are, you need to
917         set cache=0.
918         '''
919         return Node(self, nodeid, cache=cache)
921     def set(self, nodeid, **propvalues):
922         """Modify a property on an existing node of this class.
923         
924         'nodeid' must be the id of an existing node of this class or an
925         IndexError is raised.
927         Each key in 'propvalues' must be the name of a property of this
928         class or a KeyError is raised.
930         All values in 'propvalues' must be acceptable types for their
931         corresponding properties or a TypeError is raised.
933         If the value of the key property is set, it must not collide with
934         other key strings or a ValueError is raised.
936         If the value of a Link or Multilink property contains an invalid
937         node id, a ValueError is raised.
939         These operations trigger detectors and can be vetoed.  Attempts
940         to modify the "creation" or "activity" properties cause a KeyError.
941         """
942         if not propvalues:
943             return
945         if propvalues.has_key('creation') or propvalues.has_key('activity'):
946             raise KeyError, '"creation" and "activity" are reserved'
948         if propvalues.has_key('id'):
949             raise KeyError, '"id" is reserved'
951         if self.db.journaltag is None:
952             raise DatabaseError, 'Database open read-only'
954         self.fireAuditors('set', nodeid, propvalues)
955         # Take a copy of the node dict so that the subsequent set
956         # operation doesn't modify the oldvalues structure.
957         try:
958             # try not using the cache initially
959             oldvalues = copy.deepcopy(self.db.getnode(self.classname, nodeid,
960                 cache=0))
961         except IndexError:
962             # this will be needed if somone does a create() and set()
963             # with no intervening commit()
964             oldvalues = copy.deepcopy(self.db.getnode(self.classname, nodeid))
966         node = self.db.getnode(self.classname, nodeid)
967         if node.has_key(self.db.RETIRED_FLAG):
968             raise IndexError
969         num_re = re.compile('^\d+$')
971         # if the journal value is to be different, store it in here
972         journalvalues = {}
974         for propname, value in propvalues.items():
975             # check to make sure we're not duplicating an existing key
976             if propname == self.key and node[propname] != value:
977                 try:
978                     self.lookup(value)
979                 except KeyError:
980                     pass
981                 else:
982                     raise ValueError, 'node with key "%s" exists'%value
984             # this will raise the KeyError if the property isn't valid
985             # ... we don't use getprops() here because we only care about
986             # the writeable properties.
987             prop = self.properties[propname]
989             # if the value's the same as the existing value, no sense in
990             # doing anything
991             if node.has_key(propname) and value == node[propname]:
992                 del propvalues[propname]
993                 continue
995             # do stuff based on the prop type
996             if isinstance(prop, Link):
997                 link_class = self.properties[propname].classname
998                 # if it isn't a number, it's a key
999                 if type(value) != type(''):
1000                     raise ValueError, 'link value must be String'
1001                 if not num_re.match(value):
1002                     try:
1003                         value = self.db.classes[link_class].lookup(value)
1004                     except (TypeError, KeyError):
1005                         raise IndexError, 'new property "%s": %s not a %s'%(
1006                             propname, value, self.properties[propname].classname)
1008                 if not self.db.hasnode(link_class, value):
1009                     raise IndexError, '%s has no node %s'%(link_class, value)
1011                 if self.do_journal and self.properties[propname].do_journal:
1012                     # register the unlink with the old linked node
1013                     if node[propname] is not None:
1014                         self.db.addjournal(link_class, node[propname], 'unlink',
1015                             (self.classname, nodeid, propname))
1017                     # register the link with the newly linked node
1018                     if value is not None:
1019                         self.db.addjournal(link_class, value, 'link',
1020                             (self.classname, nodeid, propname))
1022             elif isinstance(prop, Multilink):
1023                 if type(value) != type([]):
1024                     raise TypeError, 'new property "%s" not a list of'\
1025                         ' ids'%propname
1026                 link_class = self.properties[propname].classname
1027                 l = []
1028                 for entry in value:
1029                     # if it isn't a number, it's a key
1030                     if type(entry) != type(''):
1031                         raise ValueError, 'new property "%s" link value ' \
1032                             'must be a string'%propname
1033                     if not num_re.match(entry):
1034                         try:
1035                             entry = self.db.classes[link_class].lookup(entry)
1036                         except (TypeError, KeyError):
1037                             raise IndexError, 'new property "%s": %s not a %s'%(
1038                                 propname, entry,
1039                                 self.properties[propname].classname)
1040                     l.append(entry)
1041                 value = l
1042                 propvalues[propname] = value
1044                 # figure the journal entry for this property
1045                 add = []
1046                 remove = []
1048                 # handle removals
1049                 if node.has_key(propname):
1050                     l = node[propname]
1051                 else:
1052                     l = []
1053                 for id in l[:]:
1054                     if id in value:
1055                         continue
1056                     # register the unlink with the old linked node
1057                     if self.do_journal and self.properties[propname].do_journal:
1058                         self.db.addjournal(link_class, id, 'unlink',
1059                             (self.classname, nodeid, propname))
1060                     l.remove(id)
1061                     remove.append(id)
1063                 # handle additions
1064                 for id in value:
1065                     if not self.db.hasnode(link_class, id):
1066                         raise IndexError, '%s has no node %s'%(link_class, id)
1067                     if id in l:
1068                         continue
1069                     # register the link with the newly linked node
1070                     if self.do_journal and self.properties[propname].do_journal:
1071                         self.db.addjournal(link_class, id, 'link',
1072                             (self.classname, nodeid, propname))
1073                     l.append(id)
1074                     add.append(id)
1076                 # figure the journal entry
1077                 l = []
1078                 if add:
1079                     l.append(('add', add))
1080                 if remove:
1081                     l.append(('remove', remove))
1082                 if l:
1083                     journalvalues[propname] = tuple(l)
1085             elif isinstance(prop, String):
1086                 if value is not None and type(value) != type(''):
1087                     raise TypeError, 'new property "%s" not a string'%propname
1089             elif isinstance(prop, Password):
1090                 if not isinstance(value, password.Password):
1091                     raise TypeError, 'new property "%s" not a Password'%propname
1092                 propvalues[propname] = value
1094             elif value is not None and isinstance(prop, Date):
1095                 if not isinstance(value, date.Date):
1096                     raise TypeError, 'new property "%s" not a Date'% propname
1097                 propvalues[propname] = value
1099             elif value is not None and isinstance(prop, Interval):
1100                 if not isinstance(value, date.Interval):
1101                     raise TypeError, 'new property "%s" not an '\
1102                         'Interval'%propname
1103                 propvalues[propname] = value
1105             elif value is not None and isinstance(prop, Number):
1106                 try:
1107                     float(value)
1108                 except ValueError:
1109                     raise TypeError, 'new property "%s" not numeric'%propname
1111             elif value is not None and isinstance(prop, Boolean):
1112                 try:
1113                     int(value)
1114                 except ValueError:
1115                     raise TypeError, 'new property "%s" not boolean'%propname
1117             node[propname] = value
1119         # nothing to do?
1120         if not propvalues:
1121             return
1123         # do the set, and journal it
1124         self.db.setnode(self.classname, nodeid, node)
1126         if self.do_journal:
1127             propvalues.update(journalvalues)
1128             self.db.addjournal(self.classname, nodeid, 'set', propvalues)
1130         self.fireReactors('set', nodeid, oldvalues)
1132     def retire(self, nodeid):
1133         """Retire a node.
1134         
1135         The properties on the node remain available from the get() method,
1136         and the node's id is never reused.
1137         
1138         Retired nodes are not returned by the find(), list(), or lookup()
1139         methods, and other nodes may reuse the values of their key properties.
1141         These operations trigger detectors and can be vetoed.  Attempts
1142         to modify the "creation" or "activity" properties cause a KeyError.
1143         """
1144         if self.db.journaltag is None:
1145             raise DatabaseError, 'Database open read-only'
1147         self.fireAuditors('retire', nodeid, None)
1149         node = self.db.getnode(self.classname, nodeid)
1150         node[self.db.RETIRED_FLAG] = 1
1151         self.db.setnode(self.classname, nodeid, node)
1152         if self.do_journal:
1153             self.db.addjournal(self.classname, nodeid, 'retired', None)
1155         self.fireReactors('retire', nodeid, None)
1157     def destroy(self, nodeid):
1158         """Destroy a node.
1159         
1160         WARNING: this method should never be used except in extremely rare
1161                  situations where there could never be links to the node being
1162                  deleted
1163         WARNING: use retire() instead
1164         WARNING: the properties of this node will not be available ever again
1165         WARNING: really, use retire() instead
1167         Well, I think that's enough warnings. This method exists mostly to
1168         support the session storage of the cgi interface.
1169         """
1170         if self.db.journaltag is None:
1171             raise DatabaseError, 'Database open read-only'
1172         self.db.destroynode(self.classname, nodeid)
1174     def history(self, nodeid):
1175         """Retrieve the journal of edits on a particular node.
1177         'nodeid' must be the id of an existing node of this class or an
1178         IndexError is raised.
1180         The returned list contains tuples of the form
1182             (date, tag, action, params)
1184         'date' is a Timestamp object specifying the time of the change and
1185         'tag' is the journaltag specified when the database was opened.
1186         """
1187         if not self.do_journal:
1188             raise ValueError, 'Journalling is disabled for this class'
1189         return self.db.getjournal(self.classname, nodeid)
1191     # Locating nodes:
1192     def hasnode(self, nodeid):
1193         '''Determine if the given nodeid actually exists
1194         '''
1195         return self.db.hasnode(self.classname, nodeid)
1197     def setkey(self, propname):
1198         """Select a String property of this class to be the key property.
1200         'propname' must be the name of a String property of this class or
1201         None, or a TypeError is raised.  The values of the key property on
1202         all existing nodes must be unique or a ValueError is raised. If the
1203         property doesn't exist, KeyError is raised.
1204         """
1205         prop = self.getprops()[propname]
1206         if not isinstance(prop, String):
1207             raise TypeError, 'key properties must be String'
1208         self.key = propname
1210     def getkey(self):
1211         """Return the name of the key property for this class or None."""
1212         return self.key
1214     def labelprop(self, default_to_id=0):
1215         ''' Return the property name for a label for the given node.
1217         This method attempts to generate a consistent label for the node.
1218         It tries the following in order:
1219             1. key property
1220             2. "name" property
1221             3. "title" property
1222             4. first property from the sorted property name list
1223         '''
1224         k = self.getkey()
1225         if  k:
1226             return k
1227         props = self.getprops()
1228         if props.has_key('name'):
1229             return 'name'
1230         elif props.has_key('title'):
1231             return 'title'
1232         if default_to_id:
1233             return 'id'
1234         props = props.keys()
1235         props.sort()
1236         return props[0]
1238     # TODO: set up a separate index db file for this? profile?
1239     def lookup(self, keyvalue):
1240         """Locate a particular node by its key property and return its id.
1242         If this class has no key property, a TypeError is raised.  If the
1243         'keyvalue' matches one of the values for the key property among
1244         the nodes in this class, the matching node's id is returned;
1245         otherwise a KeyError is raised.
1246         """
1247         cldb = self.db.getclassdb(self.classname)
1248         try:
1249             for nodeid in self.db.getnodeids(self.classname, cldb):
1250                 node = self.db.getnode(self.classname, nodeid, cldb)
1251                 if node.has_key(self.db.RETIRED_FLAG):
1252                     continue
1253                 if node[self.key] == keyvalue:
1254                     cldb.close()
1255                     return nodeid
1256         finally:
1257             cldb.close()
1258         raise KeyError, keyvalue
1260     # XXX: change from spec - allows multiple props to match
1261     def find(self, **propspec):
1262         """Get the ids of nodes in this class which link to the given nodes.
1264         'propspec' consists of keyword args propname={nodeid:1,}   
1265           'propname' must be the name of a property in this class, or a
1266             KeyError is raised.  That property must be a Link or Multilink
1267             property, or a TypeError is raised.
1269         Any node in this class whose 'propname' property links to any of the
1270         nodeids will be returned. Used by the full text indexing, which knows
1271         that "foo" occurs in msg1, msg3 and file7, so we have hits on these issues:
1272             db.issue.find(messages={'1':1,'3':1}, files={'7':1})
1273         """
1274         propspec = propspec.items()
1275         for propname, nodeids in propspec:
1276             # check the prop is OK
1277             prop = self.properties[propname]
1278             if not isinstance(prop, Link) and not isinstance(prop, Multilink):
1279                 raise TypeError, "'%s' not a Link/Multilink property"%propname
1280             #XXX edit is expensive and of questionable use
1281             #for nodeid in nodeids:
1282             #    if not self.db.hasnode(prop.classname, nodeid):
1283             #        raise ValueError, '%s has no node %s'%(prop.classname, nodeid)
1285         # ok, now do the find
1286         cldb = self.db.getclassdb(self.classname)
1287         l = []
1288         try:
1289             for id in self.db.getnodeids(self.classname, db=cldb):
1290                 node = self.db.getnode(self.classname, id, db=cldb)
1291                 if node.has_key(self.db.RETIRED_FLAG):
1292                     continue
1293                 for propname, nodeids in propspec:
1294                     # can't test if the node doesn't have this property
1295                     if not node.has_key(propname):
1296                         continue
1297                     if type(nodeids) is type(''):
1298                         nodeids = {nodeids:1}
1299                     prop = self.properties[propname]
1300                     value = node[propname]
1301                     if isinstance(prop, Link) and nodeids.has_key(value):
1302                         l.append(id)
1303                         break
1304                     elif isinstance(prop, Multilink):
1305                         hit = 0
1306                         for v in value:
1307                             if nodeids.has_key(v):
1308                                 l.append(id)
1309                                 hit = 1
1310                                 break
1311                         if hit:
1312                             break
1313         finally:
1314             cldb.close()
1315         return l
1317     def stringFind(self, **requirements):
1318         """Locate a particular node by matching a set of its String
1319         properties in a caseless search.
1321         If the property is not a String property, a TypeError is raised.
1322         
1323         The return is a list of the id of all nodes that match.
1324         """
1325         for propname in requirements.keys():
1326             prop = self.properties[propname]
1327             if isinstance(not prop, String):
1328                 raise TypeError, "'%s' not a String property"%propname
1329             requirements[propname] = requirements[propname].lower()
1330         l = []
1331         cldb = self.db.getclassdb(self.classname)
1332         try:
1333             for nodeid in self.db.getnodeids(self.classname, cldb):
1334                 node = self.db.getnode(self.classname, nodeid, cldb)
1335                 if node.has_key(self.db.RETIRED_FLAG):
1336                     continue
1337                 for key, value in requirements.items():
1338                     if node[key] and node[key].lower() != value:
1339                         break
1340                 else:
1341                     l.append(nodeid)
1342         finally:
1343             cldb.close()
1344         return l
1346     def list(self):
1347         """Return a list of the ids of the active nodes in this class."""
1348         l = []
1349         cn = self.classname
1350         cldb = self.db.getclassdb(cn)
1351         try:
1352             for nodeid in self.db.getnodeids(cn, cldb):
1353                 node = self.db.getnode(cn, nodeid, cldb)
1354                 if node.has_key(self.db.RETIRED_FLAG):
1355                     continue
1356                 l.append(nodeid)
1357         finally:
1358             cldb.close()
1359         l.sort()
1360         return l
1362     # XXX not in spec
1363     def filter(self, search_matches, filterspec, sort, group, 
1364             num_re = re.compile('^\d+$')):
1365         ''' Return a list of the ids of the active nodes in this class that
1366             match the 'filter' spec, sorted by the group spec and then the
1367             sort spec
1368         '''
1369         cn = self.classname
1371         # optimise filterspec
1372         l = []
1373         props = self.getprops()
1374         for k, v in filterspec.items():
1375             propclass = props[k]
1376             if isinstance(propclass, Link):
1377                 if type(v) is not type([]):
1378                     v = [v]
1379                 # replace key values with node ids
1380                 u = []
1381                 link_class =  self.db.classes[propclass.classname]
1382                 for entry in v:
1383                     if entry == '-1': entry = None
1384                     elif not num_re.match(entry):
1385                         try:
1386                             entry = link_class.lookup(entry)
1387                         except (TypeError,KeyError):
1388                             raise ValueError, 'property "%s": %s not a %s'%(
1389                                 k, entry, self.properties[k].classname)
1390                     u.append(entry)
1392                 l.append((0, k, u))
1393             elif isinstance(propclass, Multilink):
1394                 if type(v) is not type([]):
1395                     v = [v]
1396                 # replace key values with node ids
1397                 u = []
1398                 link_class =  self.db.classes[propclass.classname]
1399                 for entry in v:
1400                     if not num_re.match(entry):
1401                         try:
1402                             entry = link_class.lookup(entry)
1403                         except (TypeError,KeyError):
1404                             raise ValueError, 'new property "%s": %s not a %s'%(
1405                                 k, entry, self.properties[k].classname)
1406                     u.append(entry)
1407                 l.append((1, k, u))
1408             elif isinstance(propclass, String):
1409                 # simple glob searching
1410                 v = re.sub(r'([\|\{\}\\\.\+\[\]\(\)])', r'\\\1', v)
1411                 v = v.replace('?', '.')
1412                 v = v.replace('*', '.*?')
1413                 l.append((2, k, re.compile(v, re.I)))
1414             elif isinstance(propclass, Boolean):
1415                 if type(v) is type(''):
1416                     bv = v.lower() in ('yes', 'true', 'on', '1')
1417                 else:
1418                     bv = v
1419                 l.append((6, k, bv))
1420             elif isinstance(propclass, Number):
1421                 l.append((6, k, int(v)))
1422             else:
1423                 l.append((6, k, v))
1424         filterspec = l
1426         # now, find all the nodes that are active and pass filtering
1427         l = []
1428         cldb = self.db.getclassdb(cn)
1429         try:
1430             for nodeid in self.db.getnodeids(cn, cldb):
1431                 node = self.db.getnode(cn, nodeid, cldb)
1432                 if node.has_key(self.db.RETIRED_FLAG):
1433                     continue
1434                 # apply filter
1435                 for t, k, v in filterspec:
1436                     # this node doesn't have this property, so reject it
1437                     if not node.has_key(k): break
1439                     if t == 0 and node[k] not in v:
1440                         # link - if this node'd property doesn't appear in the
1441                         # filterspec's nodeid list, skip it
1442                         break
1443                     elif t == 1:
1444                         # multilink - if any of the nodeids required by the
1445                         # filterspec aren't in this node's property, then skip
1446                         # it
1447                         for value in v:
1448                             if value not in node[k]:
1449                                 break
1450                         else:
1451                             continue
1452                         break
1453                     elif t == 2 and (node[k] is None or not v.search(node[k])):
1454                         # RE search
1455                         break
1456                     elif t == 6 and node[k] != v:
1457                         # straight value comparison for the other types
1458                         break
1459                 else:
1460                     l.append((nodeid, node))
1461         finally:
1462             cldb.close()
1463         l.sort()
1465         # filter based on full text search
1466         if search_matches is not None:
1467             k = []
1468             l_debug = []
1469             for v in l:
1470                 l_debug.append(v[0])
1471                 if search_matches.has_key(v[0]):
1472                     k.append(v)
1473             l = k
1475         # optimise sort
1476         m = []
1477         for entry in sort:
1478             if entry[0] != '-':
1479                 m.append(('+', entry))
1480             else:
1481                 m.append((entry[0], entry[1:]))
1482         sort = m
1484         # optimise group
1485         m = []
1486         for entry in group:
1487             if entry[0] != '-':
1488                 m.append(('+', entry))
1489             else:
1490                 m.append((entry[0], entry[1:]))
1491         group = m
1492         # now, sort the result
1493         def sortfun(a, b, sort=sort, group=group, properties=self.getprops(),
1494                 db = self.db, cl=self):
1495             a_id, an = a
1496             b_id, bn = b
1497             # sort by group and then sort
1498             for list in group, sort:
1499                 for dir, prop in list:
1500                     # sorting is class-specific
1501                     propclass = properties[prop]
1503                     # handle the properties that might be "faked"
1504                     # also, handle possible missing properties
1505                     try:
1506                         if not an.has_key(prop):
1507                             an[prop] = cl.get(a_id, prop)
1508                         av = an[prop]
1509                     except KeyError:
1510                         # the node doesn't have a value for this property
1511                         if isinstance(propclass, Multilink): av = []
1512                         else: av = ''
1513                     try:
1514                         if not bn.has_key(prop):
1515                             bn[prop] = cl.get(b_id, prop)
1516                         bv = bn[prop]
1517                     except KeyError:
1518                         # the node doesn't have a value for this property
1519                         if isinstance(propclass, Multilink): bv = []
1520                         else: bv = ''
1522                     # String and Date values are sorted in the natural way
1523                     if isinstance(propclass, String):
1524                         # clean up the strings
1525                         if av and av[0] in string.uppercase:
1526                             av = an[prop] = av.lower()
1527                         if bv and bv[0] in string.uppercase:
1528                             bv = bn[prop] = bv.lower()
1529                     if (isinstance(propclass, String) or
1530                             isinstance(propclass, Date)):
1531                         # it might be a string that's really an integer
1532                         try:
1533                             av = int(av)
1534                             bv = int(bv)
1535                         except:
1536                             pass
1537                         if dir == '+':
1538                             r = cmp(av, bv)
1539                             if r != 0: return r
1540                         elif dir == '-':
1541                             r = cmp(bv, av)
1542                             if r != 0: return r
1544                     # Link properties are sorted according to the value of
1545                     # the "order" property on the linked nodes if it is
1546                     # present; or otherwise on the key string of the linked
1547                     # nodes; or finally on  the node ids.
1548                     elif isinstance(propclass, Link):
1549                         link = db.classes[propclass.classname]
1550                         if av is None and bv is not None: return -1
1551                         if av is not None and bv is None: return 1
1552                         if av is None and bv is None: continue
1553                         if link.getprops().has_key('order'):
1554                             if dir == '+':
1555                                 r = cmp(link.get(av, 'order'),
1556                                     link.get(bv, 'order'))
1557                                 if r != 0: return r
1558                             elif dir == '-':
1559                                 r = cmp(link.get(bv, 'order'),
1560                                     link.get(av, 'order'))
1561                                 if r != 0: return r
1562                         elif link.getkey():
1563                             key = link.getkey()
1564                             if dir == '+':
1565                                 r = cmp(link.get(av, key), link.get(bv, key))
1566                                 if r != 0: return r
1567                             elif dir == '-':
1568                                 r = cmp(link.get(bv, key), link.get(av, key))
1569                                 if r != 0: return r
1570                         else:
1571                             if dir == '+':
1572                                 r = cmp(av, bv)
1573                                 if r != 0: return r
1574                             elif dir == '-':
1575                                 r = cmp(bv, av)
1576                                 if r != 0: return r
1578                     # Multilink properties are sorted according to how many
1579                     # links are present.
1580                     elif isinstance(propclass, Multilink):
1581                         if dir == '+':
1582                             r = cmp(len(av), len(bv))
1583                             if r != 0: return r
1584                         elif dir == '-':
1585                             r = cmp(len(bv), len(av))
1586                             if r != 0: return r
1587                     elif isinstance(propclass, Number) or isinstance(propclass, Boolean):
1588                         if dir == '+':
1589                             r = cmp(av, bv)
1590                         elif dir == '-':
1591                             r = cmp(bv, av)
1592                         
1593                 # end for dir, prop in list:
1594             # end for list in sort, group:
1595             # if all else fails, compare the ids
1596             return cmp(a[0], b[0])
1598         l.sort(sortfun)
1599         return [i[0] for i in l]
1601     def count(self):
1602         """Get the number of nodes in this class.
1604         If the returned integer is 'numnodes', the ids of all the nodes
1605         in this class run from 1 to numnodes, and numnodes+1 will be the
1606         id of the next node to be created in this class.
1607         """
1608         return self.db.countnodes(self.classname)
1610     # Manipulating properties:
1612     def getprops(self, protected=1):
1613         """Return a dictionary mapping property names to property objects.
1614            If the "protected" flag is true, we include protected properties -
1615            those which may not be modified.
1617            In addition to the actual properties on the node, these
1618            methods provide the "creation" and "activity" properties. If the
1619            "protected" flag is true, we include protected properties - those
1620            which may not be modified.
1621         """
1622         d = self.properties.copy()
1623         if protected:
1624             d['id'] = String()
1625             d['creation'] = hyperdb.Date()
1626             d['activity'] = hyperdb.Date()
1627             d['creator'] = hyperdb.Link("user")
1628         return d
1630     def addprop(self, **properties):
1631         """Add properties to this class.
1633         The keyword arguments in 'properties' must map names to property
1634         objects, or a TypeError is raised.  None of the keys in 'properties'
1635         may collide with the names of existing properties, or a ValueError
1636         is raised before any properties have been added.
1637         """
1638         for key in properties.keys():
1639             if self.properties.has_key(key):
1640                 raise ValueError, key
1641         self.properties.update(properties)
1643     def index(self, nodeid):
1644         '''Add (or refresh) the node to search indexes
1645         '''
1646         # find all the String properties that have indexme
1647         for prop, propclass in self.getprops().items():
1648             if isinstance(propclass, String) and propclass.indexme:
1649                 try:
1650                     value = str(self.get(nodeid, prop))
1651                 except IndexError:
1652                     # node no longer exists - entry should be removed
1653                     self.db.indexer.purge_entry((self.classname, nodeid, prop))
1654                 else:
1655                     # and index them under (classname, nodeid, property)
1656                     self.db.indexer.add_text((self.classname, nodeid, prop),
1657                         value)
1659     #
1660     # Detector interface
1661     #
1662     def audit(self, event, detector):
1663         """Register a detector
1664         """
1665         l = self.auditors[event]
1666         if detector not in l:
1667             self.auditors[event].append(detector)
1669     def fireAuditors(self, action, nodeid, newvalues):
1670         """Fire all registered auditors.
1671         """
1672         for audit in self.auditors[action]:
1673             audit(self.db, self, nodeid, newvalues)
1675     def react(self, event, detector):
1676         """Register a detector
1677         """
1678         l = self.reactors[event]
1679         if detector not in l:
1680             self.reactors[event].append(detector)
1682     def fireReactors(self, action, nodeid, oldvalues):
1683         """Fire all registered reactors.
1684         """
1685         for react in self.reactors[action]:
1686             react(self.db, self, nodeid, oldvalues)
1688 class FileClass(Class):
1689     '''This class defines a large chunk of data. To support this, it has a
1690        mandatory String property "content" which is typically saved off
1691        externally to the hyperdb.
1693        The default MIME type of this data is defined by the
1694        "default_mime_type" class attribute, which may be overridden by each
1695        node if the class defines a "type" String property.
1696     '''
1697     default_mime_type = 'text/plain'
1699     def create(self, **propvalues):
1700         ''' snaffle the file propvalue and store in a file
1701         '''
1702         content = propvalues['content']
1703         del propvalues['content']
1704         newid = Class.create(self, **propvalues)
1705         self.db.storefile(self.classname, newid, None, content)
1706         return newid
1708     def get(self, nodeid, propname, default=_marker, cache=1):
1709         ''' trap the content propname and get it from the file
1710         '''
1712         poss_msg = 'Possibly a access right configuration problem.'
1713         if propname == 'content':
1714             try:
1715                 return self.db.getfile(self.classname, nodeid, None)
1716             except IOError, (strerror):
1717                 # BUG: by catching this we donot see an error in the log.
1718                 return 'ERROR reading file: %s%s\n%s\n%s'%(
1719                         self.classname, nodeid, poss_msg, strerror)
1720         if default is not _marker:
1721             return Class.get(self, nodeid, propname, default, cache=cache)
1722         else:
1723             return Class.get(self, nodeid, propname, cache=cache)
1725     def getprops(self, protected=1):
1726         ''' In addition to the actual properties on the node, these methods
1727             provide the "content" property. If the "protected" flag is true,
1728             we include protected properties - those which may not be
1729             modified.
1730         '''
1731         d = Class.getprops(self, protected=protected).copy()
1732         if protected:
1733             d['content'] = hyperdb.String()
1734         return d
1736     def index(self, nodeid):
1737         ''' Index the node in the search index.
1739             We want to index the content in addition to the normal String
1740             property indexing.
1741         '''
1742         # perform normal indexing
1743         Class.index(self, nodeid)
1745         # get the content to index
1746         content = self.get(nodeid, 'content')
1748         # figure the mime type
1749         if self.properties.has_key('type'):
1750             mime_type = self.get(nodeid, 'type')
1751         else:
1752             mime_type = self.default_mime_type
1754         # and index!
1755         self.db.indexer.add_text((self.classname, nodeid, 'content'), content,
1756             mime_type)
1758 # XXX deviation from spec - was called ItemClass
1759 class IssueClass(Class, roundupdb.IssueClass):
1760     # Overridden methods:
1761     def __init__(self, db, classname, **properties):
1762         """The newly-created class automatically includes the "messages",
1763         "files", "nosy", and "superseder" properties.  If the 'properties'
1764         dictionary attempts to specify any of these properties or a
1765         "creation" or "activity" property, a ValueError is raised.
1766         """
1767         if not properties.has_key('title'):
1768             properties['title'] = hyperdb.String(indexme='yes')
1769         if not properties.has_key('messages'):
1770             properties['messages'] = hyperdb.Multilink("msg")
1771         if not properties.has_key('files'):
1772             properties['files'] = hyperdb.Multilink("file")
1773         if not properties.has_key('nosy'):
1774             properties['nosy'] = hyperdb.Multilink("user")
1775         if not properties.has_key('superseder'):
1776             properties['superseder'] = hyperdb.Multilink(classname)
1777         Class.__init__(self, db, classname, **properties)
1780 #$Log: not supported by cvs2svn $
1781 #Revision 1.51  2002/07/18 23:07:08  richard
1782 #Unit tests and a few fixes.
1784 #Revision 1.50  2002/07/18 11:50:58  richard
1785 #added tests for number type too
1787 #Revision 1.49  2002/07/18 11:41:10  richard
1788 #added tests for boolean type, and fixes to anydbm backend
1790 #Revision 1.48  2002/07/18 11:17:31  gmcm
1791 #Add Number and Boolean types to hyperdb.
1792 #Add conversion cases to web, mail & admin interfaces.
1793 #Add storage/serialization cases to back_anydbm & back_metakit.
1795 #Revision 1.47  2002/07/14 23:18:20  richard
1796 #. fixed the journal bloat from multilink changes - we just log the add or
1797 #  remove operations, not the whole list
1799 #Revision 1.46  2002/07/14 06:06:34  richard
1800 #Did some old TODOs
1802 #Revision 1.45  2002/07/14 04:03:14  richard
1803 #Implemented a switch to disable journalling for a Class. CGI session
1804 #database now uses it.
1806 #Revision 1.44  2002/07/14 02:05:53  richard
1807 #. all storage-specific code (ie. backend) is now implemented by the backends
1809 #Revision 1.43  2002/07/10 06:30:30  richard
1810 #...except of course it's nice to use valid Python syntax
1812 #Revision 1.42  2002/07/10 06:21:38  richard
1813 #Be extra safe
1815 #Revision 1.41  2002/07/10 00:21:45  richard
1816 #explicit database closing
1818 #Revision 1.40  2002/07/09 04:19:09  richard
1819 #Added reindex command to roundup-admin.
1820 #Fixed reindex on first access.
1821 #Also fixed reindexing of entries that change.
1823 #Revision 1.39  2002/07/09 03:02:52  richard
1824 #More indexer work:
1825 #- all String properties may now be indexed too. Currently there's a bit of
1826 #  "issue" specific code in the actual searching which needs to be
1827 #  addressed. In a nutshell:
1828 #  + pass 'indexme="yes"' as a String() property initialisation arg, eg:
1829 #        file = FileClass(db, "file", name=String(), type=String(),
1830 #            comment=String(indexme="yes"))
1831 #  + the comment will then be indexed and be searchable, with the results
1832 #    related back to the issue that the file is linked to
1833 #- as a result of this work, the FileClass has a default MIME type that may
1834 #  be overridden in a subclass, or by the use of a "type" property as is
1835 #  done in the default templates.
1836 #- the regeneration of the indexes (if necessary) is done once the schema is
1837 #  set up in the dbinit.
1839 #Revision 1.38  2002/07/08 06:58:15  richard
1840 #cleaned up the indexer code:
1841 # - it splits more words out (much simpler, faster splitter)
1842 # - removed code we'll never use (roundup.roundup_indexer has the full
1843 #   implementation, and replaces roundup.indexer)
1844 # - only index text/plain and rfc822/message (ideas for other text formats to
1845 #   index are welcome)
1846 # - added simple unit test for indexer. Needs more tests for regression.
1848 #Revision 1.37  2002/06/20 23:52:35  richard
1849 #More informative error message
1851 #Revision 1.36  2002/06/19 03:07:19  richard
1852 #Moved the file storage commit into blobfiles where it belongs.
1854 #Revision 1.35  2002/05/25 07:16:24  rochecompaan
1855 #Merged search_indexing-branch with HEAD
1857 #Revision 1.34  2002/05/15 06:21:21  richard
1858 # . node caching now works, and gives a small boost in performance
1860 #As a part of this, I cleaned up the DEBUG output and implemented TRACE
1861 #output (HYPERDBTRACE='file to trace to') with checkpoints at the start of
1862 #CGI requests. Run roundup with python -O to skip all the DEBUG/TRACE stuff
1863 #(using if __debug__ which is compiled out with -O)
1865 #Revision 1.33  2002/04/24 10:38:26  rochecompaan
1866 #All database files are now created group readable and writable.
1868 #Revision 1.32  2002/04/15 23:25:15  richard
1869 #. node ids are now generated from a lockable store - no more race conditions
1871 #We're using the portalocker code by Jonathan Feinberg that was contributed
1872 #to the ASPN Python cookbook. This gives us locking across Unix and Windows.
1874 #Revision 1.31  2002/04/03 05:54:31  richard
1875 #Fixed serialisation problem by moving the serialisation step out of the
1876 #hyperdb.Class (get, set) into the hyperdb.Database.
1878 #Also fixed htmltemplate after the showid changes I made yesterday.
1880 #Unit tests for all of the above written.
1882 #Revision 1.30.2.1  2002/04/03 11:55:57  rochecompaan
1883 # . Added feature #526730 - search for messages capability
1885 #Revision 1.30  2002/02/27 03:40:59  richard
1886 #Ran it through pychecker, made fixes
1888 #Revision 1.29  2002/02/25 14:34:31  grubert
1889 # . use blobfiles in back_anydbm which is used in back_bsddb.
1890 #   change test_db as dirlist does not work for subdirectories.
1891 #   ATTENTION: blobfiles now creates subdirectories for files.
1893 #Revision 1.28  2002/02/16 09:14:17  richard
1894 # . #514854 ] History: "User" is always ticket creator
1896 #Revision 1.27  2002/01/22 07:21:13  richard
1897 #. fixed back_bsddb so it passed the journal tests
1899 #... it didn't seem happy using the back_anydbm _open method, which is odd.
1900 #Yet another occurrance of whichdb not being able to recognise older bsddb
1901 #databases. Yadda yadda. Made the HYPERDBDEBUG stuff more sane in the
1902 #process.
1904 #Revision 1.26  2002/01/22 05:18:38  rochecompaan
1905 #last_set_entry was referenced before assignment
1907 #Revision 1.25  2002/01/22 05:06:08  rochecompaan
1908 #We need to keep the last 'set' entry in the journal to preserve
1909 #information on 'activity' for nodes.
1911 #Revision 1.24  2002/01/21 16:33:20  rochecompaan
1912 #You can now use the roundup-admin tool to pack the database
1914 #Revision 1.23  2002/01/18 04:32:04  richard
1915 #Rollback was breaking because a message hadn't actually been written to the file. Needs
1916 #more investigation.
1918 #Revision 1.22  2002/01/14 02:20:15  richard
1919 # . changed all config accesses so they access either the instance or the
1920 #   config attriubute on the db. This means that all config is obtained from
1921 #   instance_config instead of the mish-mash of classes. This will make
1922 #   switching to a ConfigParser setup easier too, I hope.
1924 #At a minimum, this makes migration a _little_ easier (a lot easier in the
1925 #0.5.0 switch, I hope!)
1927 #Revision 1.21  2002/01/02 02:31:38  richard
1928 #Sorry for the huge checkin message - I was only intending to implement #496356
1929 #but I found a number of places where things had been broken by transactions:
1930 # . modified ROUNDUPDBSENDMAILDEBUG to be SENDMAILDEBUG and hold a filename
1931 #   for _all_ roundup-generated smtp messages to be sent to.
1932 # . the transaction cache had broken the roundupdb.Class set() reactors
1933 # . newly-created author users in the mailgw weren't being committed to the db
1935 #Stuff that made it into CHANGES.txt (ie. the stuff I was actually working
1936 #on when I found that stuff :):
1937 # . #496356 ] Use threading in messages
1938 # . detectors were being registered multiple times
1939 # . added tests for mailgw
1940 # . much better attaching of erroneous messages in the mail gateway
1942 #Revision 1.20  2001/12/18 15:30:34  rochecompaan
1943 #Fixed bugs:
1944 # .  Fixed file creation and retrieval in same transaction in anydbm
1945 #    backend
1946 # .  Cgi interface now renders new issue after issue creation
1947 # .  Could not set issue status to resolved through cgi interface
1948 # .  Mail gateway was changing status back to 'chatting' if status was
1949 #    omitted as an argument
1951 #Revision 1.19  2001/12/17 03:52:48  richard
1952 #Implemented file store rollback. As a bonus, the hyperdb is now capable of
1953 #storing more than one file per node - if a property name is supplied,
1954 #the file is called designator.property.
1955 #I decided not to migrate the existing files stored over to the new naming
1956 #scheme - the FileClass just doesn't specify the property name.
1958 #Revision 1.18  2001/12/16 10:53:38  richard
1959 #take a copy of the node dict so that the subsequent set
1960 #operation doesn't modify the oldvalues structure
1962 #Revision 1.17  2001/12/14 23:42:57  richard
1963 #yuck, a gdbm instance tests false :(
1964 #I've left the debugging code in - it should be removed one day if we're ever
1965 #_really_ anal about performace :)
1967 #Revision 1.16  2001/12/12 03:23:14  richard
1968 #Cor blimey this anydbm/whichdb stuff is yecchy. Turns out that whichdb
1969 #incorrectly identifies a dbm file as a dbhash file on my system. This has
1970 #been submitted to the python bug tracker as issue #491888:
1971 #https://sourceforge.net/tracker/index.php?func=detail&aid=491888&group_id=5470&atid=105470
1973 #Revision 1.15  2001/12/12 02:30:51  richard
1974 #I fixed the problems with people whose anydbm was using the dbm module at the
1975 #backend. It turns out the dbm module modifies the file name to append ".db"
1976 #and my check to determine if we're opening an existing or new db just
1977 #tested os.path.exists() on the filename. Well, no longer! We now perform a
1978 #much better check _and_ cope with the anydbm implementation module changing
1979 #too!
1980 #I also fixed the backends __init__ so only ImportError is squashed.
1982 #Revision 1.14  2001/12/10 22:20:01  richard
1983 #Enabled transaction support in the bsddb backend. It uses the anydbm code
1984 #where possible, only replacing methods where the db is opened (it uses the
1985 #btree opener specifically.)
1986 #Also cleaned up some change note generation.
1987 #Made the backends package work with pydoc too.
1989 #Revision 1.13  2001/12/02 05:06:16  richard
1990 #. We now use weakrefs in the Classes to keep the database reference, so
1991 #  the close() method on the database is no longer needed.
1992 #  I bumped the minimum python requirement up to 2.1 accordingly.
1993 #. #487480 ] roundup-server
1994 #. #487476 ] INSTALL.txt
1996 #I also cleaned up the change message / post-edit stuff in the cgi client.
1997 #There's now a clearly marked "TODO: append the change note" where I believe
1998 #the change note should be added there. The "changes" list will obviously
1999 #have to be modified to be a dict of the changes, or somesuch.
2001 #More testing needed.
2003 #Revision 1.12  2001/12/01 07:17:50  richard
2004 #. We now have basic transaction support! Information is only written to
2005 #  the database when the commit() method is called. Only the anydbm
2006 #  backend is modified in this way - neither of the bsddb backends have been.
2007 #  The mail, admin and cgi interfaces all use commit (except the admin tool
2008 #  doesn't have a commit command, so interactive users can't commit...)
2009 #. Fixed login/registration forwarding the user to the right page (or not,
2010 #  on a failure)
2012 #Revision 1.11  2001/11/21 02:34:18  richard
2013 #Added a target version field to the extended issue schema
2015 #Revision 1.10  2001/10/09 23:58:10  richard
2016 #Moved the data stringification up into the hyperdb.Class class' get, set
2017 #and create methods. This means that the data is also stringified for the
2018 #journal call, and removes duplication of code from the backends. The
2019 #backend code now only sees strings.
2021 #Revision 1.9  2001/10/09 07:25:59  richard
2022 #Added the Password property type. See "pydoc roundup.password" for
2023 #implementation details. Have updated some of the documentation too.
2025 #Revision 1.8  2001/09/29 13:27:00  richard
2026 #CGI interfaces now spit up a top-level index of all the instances they can
2027 #serve.
2029 #Revision 1.7  2001/08/12 06:32:36  richard
2030 #using isinstance(blah, Foo) now instead of isFooType
2032 #Revision 1.6  2001/08/07 00:24:42  richard
2033 #stupid typo
2035 #Revision 1.5  2001/08/07 00:15:51  richard
2036 #Added the copyright/license notice to (nearly) all files at request of
2037 #Bizar Software.
2039 #Revision 1.4  2001/07/30 01:41:36  richard
2040 #Makes schema changes mucho easier.
2042 #Revision 1.3  2001/07/25 01:23:07  richard
2043 #Added the Roundup spec to the new documentation directory.
2045 #Revision 1.2  2001/07/23 08:20:44  richard
2046 #Moved over to using marshal in the bsddb and anydbm backends.
2047 #roundup-admin now has a "freshen" command that'll load/save all nodes (not
2048 # retired - mod hyperdb.Class.list() so it lists retired nodes)