Code

Implemented a switch to disable journalling for a Class. CGI session
[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.45 2002-07-14 04:03:14 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
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 _opendb(self, name, mode):
147         '''Low-level database opener that gets around anydbm/dbm
148            eccentricities.
149         '''
150         if __debug__:
151             print >>hyperdb.DEBUG, '_opendb', (self, name, mode)
153         # determine which DB wrote the class file
154         db_type = ''
155         path = os.path.join(os.getcwd(), self.dir, name)
156         if os.path.exists(path):
157             db_type = whichdb.whichdb(path)
158             if not db_type:
159                 raise hyperdb.DatabaseError, "Couldn't identify database type"
160         elif os.path.exists(path+'.db'):
161             # if the path ends in '.db', it's a dbm database, whether
162             # anydbm says it's dbhash or not!
163             db_type = 'dbm'
165         # new database? let anydbm pick the best dbm
166         if not db_type:
167             if __debug__:
168                 print >>hyperdb.DEBUG, "_opendb anydbm.open(%r, 'n')"%path
169             return anydbm.open(path, 'n')
171         # open the database with the correct module
172         try:
173             dbm = __import__(db_type)
174         except ImportError:
175             raise hyperdb.DatabaseError, \
176                 "Couldn't open database - the required module '%s'"\
177                 " is not available"%db_type
178         if __debug__:
179             print >>hyperdb.DEBUG, "_opendb %r.open(%r, %r)"%(db_type, path,
180                 mode)
181         return dbm.open(path, mode)
183     def _lockdb(self, name):
184         ''' Lock a database file
185         '''
186         path = os.path.join(os.getcwd(), self.dir, '%s.lock'%name)
187         return acquire_lock(path)
189     #
190     # Node IDs
191     #
192     def newid(self, classname):
193         ''' Generate a new id for the given class
194         '''
195         # open the ids DB - create if if doesn't exist
196         lock = self._lockdb('_ids')
197         db = self._opendb('_ids', 'c')
198         if db.has_key(classname):
199             newid = db[classname] = str(int(db[classname]) + 1)
200         else:
201             # the count() bit is transitional - older dbs won't start at 1
202             newid = str(self.getclass(classname).count()+1)
203             db[classname] = newid
204         db.close()
205         release_lock(lock)
206         return newid
208     #
209     # Nodes
210     #
211     def addnode(self, classname, nodeid, node):
212         ''' add the specified node to its class's db
213         '''
214         if __debug__:
215             print >>hyperdb.DEBUG, 'addnode', (self, classname, nodeid, node)
216         self.newnodes.setdefault(classname, {})[nodeid] = 1
217         self.cache.setdefault(classname, {})[nodeid] = node
218         self.savenode(classname, nodeid, node)
220     def setnode(self, classname, nodeid, node):
221         ''' change the specified node
222         '''
223         if __debug__:
224             print >>hyperdb.DEBUG, 'setnode', (self, classname, nodeid, node)
225         self.dirtynodes.setdefault(classname, {})[nodeid] = 1
227         # can't set without having already loaded the node
228         self.cache[classname][nodeid] = node
229         self.savenode(classname, nodeid, node)
231     def savenode(self, classname, nodeid, node):
232         ''' perform the saving of data specified by the set/addnode
233         '''
234         if __debug__:
235             print >>hyperdb.DEBUG, 'savenode', (self, classname, nodeid, node)
236         self.transactions.append((self._doSaveNode, (classname, nodeid, node)))
238     def getnode(self, classname, nodeid, db=None, cache=1):
239         ''' get a node from the database
240         '''
241         if __debug__:
242             print >>hyperdb.DEBUG, 'getnode', (self, classname, nodeid, db)
243         if cache:
244             # try the cache
245             cache_dict = self.cache.setdefault(classname, {})
246             if cache_dict.has_key(nodeid):
247                 if __debug__:
248                     print >>hyperdb.TRACE, 'get %s %s cached'%(classname,
249                         nodeid)
250                 return cache_dict[nodeid]
252         if __debug__:
253             print >>hyperdb.TRACE, 'get %s %s'%(classname, nodeid)
255         # get from the database and save in the cache
256         if db is None:
257             db = self.getclassdb(classname)
258         if not db.has_key(nodeid):
259             raise IndexError, "no such %s %s"%(classname, nodeid)
261         # decode
262         res = marshal.loads(db[nodeid])
264         # reverse the serialisation
265         res = self.unserialise(classname, res)
267         # store off in the cache dict
268         if cache:
269             cache_dict[nodeid] = res
271         return res
273     def serialise(self, classname, node):
274         '''Copy the node contents, converting non-marshallable data into
275            marshallable data.
276         '''
277         if __debug__:
278             print >>hyperdb.DEBUG, 'serialise', classname, node
279         properties = self.getclass(classname).getprops()
280         d = {}
281         for k, v in node.items():
282             # if the property doesn't exist, or is the "retired" flag then
283             # it won't be in the properties dict
284             if not properties.has_key(k):
285                 d[k] = v
286                 continue
288             # get the property spec
289             prop = properties[k]
291             if isinstance(prop, Password):
292                 d[k] = str(v)
293             elif isinstance(prop, Date) and v is not None:
294                 d[k] = v.get_tuple()
295             elif isinstance(prop, Interval) and v is not None:
296                 d[k] = v.get_tuple()
297             else:
298                 d[k] = v
299         return d
301     def unserialise(self, classname, node):
302         '''Decode the marshalled node data
303         '''
304         if __debug__:
305             print >>hyperdb.DEBUG, 'unserialise', classname, node
306         properties = self.getclass(classname).getprops()
307         d = {}
308         for k, v in node.items():
309             # if the property doesn't exist, or is the "retired" flag then
310             # it won't be in the properties dict
311             if not properties.has_key(k):
312                 d[k] = v
313                 continue
315             # get the property spec
316             prop = properties[k]
318             if isinstance(prop, Date) and v is not None:
319                 d[k] = date.Date(v)
320             elif isinstance(prop, Interval) and v is not None:
321                 d[k] = date.Interval(v)
322             elif isinstance(prop, Password):
323                 p = password.Password()
324                 p.unpack(v)
325                 d[k] = p
326             else:
327                 d[k] = v
328         return d
330     def hasnode(self, classname, nodeid, db=None):
331         ''' determine if the database has a given node
332         '''
333         if __debug__:
334             print >>hyperdb.DEBUG, 'hasnode', (self, classname, nodeid, db)
336         # try the cache
337         cache = self.cache.setdefault(classname, {})
338         if cache.has_key(nodeid):
339             if __debug__:
340                 print >>hyperdb.TRACE, 'has %s %s cached'%(classname, nodeid)
341             return 1
342         if __debug__:
343             print >>hyperdb.TRACE, 'has %s %s'%(classname, nodeid)
345         # not in the cache - check the database
346         if db is None:
347             db = self.getclassdb(classname)
348         res = db.has_key(nodeid)
349         return res
351     def countnodes(self, classname, db=None):
352         if __debug__:
353             print >>hyperdb.DEBUG, 'countnodes', (self, classname, db)
354         # include the new nodes not saved to the DB yet
355         count = len(self.newnodes.get(classname, {}))
357         # and count those in the DB
358         if db is None:
359             db = self.getclassdb(classname)
360         count = count + len(db.keys())
361         return count
363     def getnodeids(self, classname, db=None):
364         if __debug__:
365             print >>hyperdb.DEBUG, 'getnodeids', (self, classname, db)
366         # start off with the new nodes
367         res = self.newnodes.get(classname, {}).keys()
369         if db is None:
370             db = self.getclassdb(classname)
371         res = res + db.keys()
372         return res
375     #
376     # Files - special node properties
377     # inherited from FileStorage
379     #
380     # Journal
381     #
382     def addjournal(self, classname, nodeid, action, params):
383         ''' Journal the Action
384         'action' may be:
386             'create' or 'set' -- 'params' is a dictionary of property values
387             'link' or 'unlink' -- 'params' is (classname, nodeid, propname)
388             'retire' -- 'params' is None
389         '''
390         if __debug__:
391             print >>hyperdb.DEBUG, 'addjournal', (self, classname, nodeid,
392                 action, params)
393         self.transactions.append((self._doSaveJournal, (classname, nodeid,
394             action, params)))
396     def getjournal(self, classname, nodeid):
397         ''' get the journal for id
398         '''
399         if __debug__:
400             print >>hyperdb.DEBUG, 'getjournal', (self, classname, nodeid)
401         # attempt to open the journal - in some rare cases, the journal may
402         # not exist
403         try:
404             db = self._opendb('journals.%s'%classname, 'r')
405         except anydbm.error, error:
406             if str(error) == "need 'c' or 'n' flag to open new db": return []
407             elif error.args[0] != 2: raise
408             return []
409         try:
410             journal = marshal.loads(db[nodeid])
411         except KeyError:
412             db.close()
413             raise KeyError, 'no such %s %s'%(classname, nodeid)
414         db.close()
415         res = []
416         for entry in journal:
417             (nodeid, date_stamp, user, action, params) = entry
418             date_obj = date.Date(date_stamp)
419             res.append((nodeid, date_obj, user, action, params))
420         return res
422     def pack(self, pack_before):
423         ''' delete all journal entries before 'pack_before' '''
424         if __debug__:
425             print >>hyperdb.DEBUG, 'packjournal', (self, pack_before)
427         pack_before = pack_before.get_tuple()
429         classes = self.getclasses()
431         # TODO: factor this out to method - we're already doing it in
432         # _opendb.
433         db_type = ''
434         path = os.path.join(os.getcwd(), self.dir, classes[0])
435         if os.path.exists(path):
436             db_type = whichdb.whichdb(path)
437             if not db_type:
438                 raise hyperdb.DatabaseError, "Couldn't identify database type"
439         elif os.path.exists(path+'.db'):
440             db_type = 'dbm'
442         for classname in classes:
443             db_name = 'journals.%s'%classname
444             db = self._opendb(db_name, 'w')
446             for key in db.keys():
447                 journal = marshal.loads(db[key])
448                 l = []
449                 last_set_entry = None
450                 for entry in journal:
451                     (nodeid, date_stamp, self.journaltag, action, 
452                         params) = entry
453                     if date_stamp > pack_before or action == 'create':
454                         l.append(entry)
455                     elif action == 'set':
456                         # grab the last set entry to keep information on
457                         # activity
458                         last_set_entry = entry
459                 if last_set_entry:
460                     date_stamp = last_set_entry[1]
461                     # if the last set entry was made after the pack date
462                     # then it is already in the list
463                     if date_stamp < pack_before:
464                         l.append(last_set_entry)
465                 db[key] = marshal.dumps(l)
466             if db_type == 'gdbm':
467                 db.reorganize()
468             db.close()
469             
471     #
472     # Basic transaction support
473     #
474     def commit(self):
475         ''' Commit the current transactions.
476         '''
477         if __debug__:
478             print >>hyperdb.DEBUG, 'commit', (self,)
479         # TODO: lock the DB
481         # keep a handle to all the database files opened
482         self.databases = {}
484         # now, do all the transactions
485         reindex = {}
486         for method, args in self.transactions:
487             reindex[method(*args)] = 1
489         # now close all the database files
490         for db in self.databases.values():
491             db.close()
492         del self.databases
493         # TODO: unlock the DB
495         # reindex the nodes that request it
496         for classname, nodeid in filter(None, reindex.keys()):
497             print >>hyperdb.DEBUG, 'commit.reindex', (classname, nodeid)
498             self.getclass(classname).index(nodeid)
500         # save the indexer state
501         self.indexer.save_index()
503         # all transactions committed, back to normal
504         self.cache = {}
505         self.dirtynodes = {}
506         self.newnodes = {}
507         self.transactions = []
509     def _doSaveNode(self, classname, nodeid, node):
510         if __debug__:
511             print >>hyperdb.DEBUG, '_doSaveNode', (self, classname, nodeid,
512                 node)
514         # get the database handle
515         db_name = 'nodes.%s'%classname
516         if self.databases.has_key(db_name):
517             db = self.databases[db_name]
518         else:
519             db = self.databases[db_name] = self.getclassdb(classname, 'c')
521         # now save the marshalled data
522         db[nodeid] = marshal.dumps(self.serialise(classname, node))
524         # return the classname, nodeid so we reindex this content
525         return (classname, nodeid)
527     def _doSaveJournal(self, classname, nodeid, action, params):
528         # serialise first
529         if action in ('set', 'create'):
530             params = self.serialise(classname, params)
532         # create the journal entry
533         entry = (nodeid, date.Date().get_tuple(), self.journaltag, action,
534             params)
536         if __debug__:
537             print >>hyperdb.DEBUG, '_doSaveJournal', entry
539         # get the database handle
540         db_name = 'journals.%s'%classname
541         if self.databases.has_key(db_name):
542             db = self.databases[db_name]
543         else:
544             db = self.databases[db_name] = self._opendb(db_name, 'c')
546         # now insert the journal entry
547         if db.has_key(nodeid):
548             # append to existing
549             s = db[nodeid]
550             l = marshal.loads(s)
551             l.append(entry)
552         else:
553             l = [entry]
555         db[nodeid] = marshal.dumps(l)
557     def rollback(self):
558         ''' Reverse all actions from the current transaction.
559         '''
560         if __debug__:
561             print >>hyperdb.DEBUG, 'rollback', (self, )
562         for method, args in self.transactions:
563             # delete temporary files
564             if method == self._doStoreFile:
565                 self._rollbackStoreFile(*args)
566         self.cache = {}
567         self.dirtynodes = {}
568         self.newnodes = {}
569         self.transactions = []
571 _marker = []
572 class Class(hyperdb.Class):
573     """The handle to a particular class of nodes in a hyperdatabase."""
575     def __init__(self, db, classname, **properties):
576         """Create a new class with a given name and property specification.
578         'classname' must not collide with the name of an existing class,
579         or a ValueError is raised.  The keyword arguments in 'properties'
580         must map names to property objects, or a TypeError is raised.
581         """
582         if (properties.has_key('creation') or properties.has_key('activity')
583                 or properties.has_key('creator')):
584             raise ValueError, '"creation", "activity" and "creator" are '\
585                 'reserved'
587         self.classname = classname
588         self.properties = properties
589         self.db = weakref.proxy(db)       # use a weak ref to avoid circularity
590         self.key = ''
592         # should we journal changes (default yes)
593         self.do_journal = 1
595         # do the db-related init stuff
596         db.addclass(self)
598         self.auditors = {'create': [], 'set': [], 'retire': []}
599         self.reactors = {'create': [], 'set': [], 'retire': []}
601     def enableJournalling(self):
602         '''Turn journalling on for this class
603         '''
604         self.do_journal = 1
606     def disableJournalling(self):
607         '''Turn journalling off for this class
608         '''
609         self.do_journal = 0
611     # Editing nodes:
613     def create(self, **propvalues):
614         """Create a new node of this class and return its id.
616         The keyword arguments in 'propvalues' map property names to values.
618         The values of arguments must be acceptable for the types of their
619         corresponding properties or a TypeError is raised.
620         
621         If this class has a key property, it must be present and its value
622         must not collide with other key strings or a ValueError is raised.
623         
624         Any other properties on this class that are missing from the
625         'propvalues' dictionary are set to None.
626         
627         If an id in a link or multilink property does not refer to a valid
628         node, an IndexError is raised.
630         These operations trigger detectors and can be vetoed.  Attempts
631         to modify the "creation" or "activity" properties cause a KeyError.
632         """
633         if propvalues.has_key('id'):
634             raise KeyError, '"id" is reserved'
636         if self.db.journaltag is None:
637             raise DatabaseError, 'Database open read-only'
639         if propvalues.has_key('creation') or propvalues.has_key('activity'):
640             raise KeyError, '"creation" and "activity" are reserved'
642         self.fireAuditors('create', None, propvalues)
644         # new node's id
645         newid = self.db.newid(self.classname)
647         # validate propvalues
648         num_re = re.compile('^\d+$')
649         for key, value in propvalues.items():
650             if key == self.key:
651                 try:
652                     self.lookup(value)
653                 except KeyError:
654                     pass
655                 else:
656                     raise ValueError, 'node with key "%s" exists'%value
658             # try to handle this property
659             try:
660                 prop = self.properties[key]
661             except KeyError:
662                 raise KeyError, '"%s" has no property "%s"'%(self.classname,
663                     key)
665             if isinstance(prop, Link):
666                 if type(value) != type(''):
667                     raise ValueError, 'link value must be String'
668                 link_class = self.properties[key].classname
669                 # if it isn't a number, it's a key
670                 if not num_re.match(value):
671                     try:
672                         value = self.db.classes[link_class].lookup(value)
673                     except (TypeError, KeyError):
674                         raise IndexError, 'new property "%s": %s not a %s'%(
675                             key, value, link_class)
676                 elif not self.db.hasnode(link_class, value):
677                     raise IndexError, '%s has no node %s'%(link_class, value)
679                 # save off the value
680                 propvalues[key] = value
682                 # register the link with the newly linked node
683                 if self.do_journal and self.properties[key].do_journal:
684                     self.db.addjournal(link_class, value, 'link',
685                         (self.classname, newid, key))
687             elif isinstance(prop, Multilink):
688                 if type(value) != type([]):
689                     raise TypeError, 'new property "%s" not a list of ids'%key
691                 # clean up and validate the list of links
692                 link_class = self.properties[key].classname
693                 l = []
694                 for entry in value:
695                     if type(entry) != type(''):
696                         raise ValueError, '"%s" link value (%s) must be '\
697                             'String'%(key, value)
698                     # if it isn't a number, it's a key
699                     if not num_re.match(entry):
700                         try:
701                             entry = self.db.classes[link_class].lookup(entry)
702                         except (TypeError, KeyError):
703                             raise IndexError, 'new property "%s": %s not a %s'%(
704                                 key, entry, self.properties[key].classname)
705                     l.append(entry)
706                 value = l
707                 propvalues[key] = value
709                 # handle additions
710                 for id in value:
711                     if not self.db.hasnode(link_class, id):
712                         raise IndexError, '%s has no node %s'%(link_class, id)
713                     # register the link with the newly linked node
714                     if self.do_journal and self.properties[key].do_journal:
715                         self.db.addjournal(link_class, id, 'link',
716                             (self.classname, newid, key))
718             elif isinstance(prop, String):
719                 if type(value) != type(''):
720                     raise TypeError, 'new property "%s" not a string'%key
722             elif isinstance(prop, Password):
723                 if not isinstance(value, password.Password):
724                     raise TypeError, 'new property "%s" not a Password'%key
726             elif isinstance(prop, Date):
727                 if value is not None and not isinstance(value, date.Date):
728                     raise TypeError, 'new property "%s" not a Date'%key
730             elif isinstance(prop, Interval):
731                 if value is not None and not isinstance(value, date.Interval):
732                     raise TypeError, 'new property "%s" not an Interval'%key
734         # make sure there's data where there needs to be
735         for key, prop in self.properties.items():
736             if propvalues.has_key(key):
737                 continue
738             if key == self.key:
739                 raise ValueError, 'key property "%s" is required'%key
740             if isinstance(prop, Multilink):
741                 propvalues[key] = []
742             else:
743                 # TODO: None isn't right here, I think...
744                 propvalues[key] = None
746         # done
747         self.db.addnode(self.classname, newid, propvalues)
748         if self.do_journal:
749             self.db.addjournal(self.classname, newid, 'create', propvalues)
751         self.fireReactors('create', newid, None)
753         return newid
755     def get(self, nodeid, propname, default=_marker, cache=1):
756         """Get the value of a property on an existing node of this class.
758         'nodeid' must be the id of an existing node of this class or an
759         IndexError is raised.  'propname' must be the name of a property
760         of this class or a KeyError is raised.
762         'cache' indicates whether the transaction cache should be queried
763         for the node. If the node has been modified and you need to
764         determine what its values prior to modification are, you need to
765         set cache=0.
767         Attempts to get the "creation" or "activity" properties should
768         do the right thing.
769         """
770         if propname == 'id':
771             return nodeid
773         if propname == 'creation':
774             if not self.do_journal:
775                 raise ValueError, 'Journalling is disabled for this class'
776             journal = self.db.getjournal(self.classname, nodeid)
777             if journal:
778                 return self.db.getjournal(self.classname, nodeid)[0][1]
779             else:
780                 # on the strange chance that there's no journal
781                 return date.Date()
782         if propname == 'activity':
783             if not self.do_journal:
784                 raise ValueError, 'Journalling is disabled for this class'
785             journal = self.db.getjournal(self.classname, nodeid)
786             if journal:
787                 return self.db.getjournal(self.classname, nodeid)[-1][1]
788             else:
789                 # on the strange chance that there's no journal
790                 return date.Date()
791         if propname == 'creator':
792             if not self.do_journal:
793                 raise ValueError, 'Journalling is disabled for this class'
794             journal = self.db.getjournal(self.classname, nodeid)
795             if journal:
796                 name = self.db.getjournal(self.classname, nodeid)[0][2]
797             else:
798                 return None
799             return self.db.user.lookup(name)
801         # get the property (raises KeyErorr if invalid)
802         prop = self.properties[propname]
804         # get the node's dict
805         d = self.db.getnode(self.classname, nodeid, cache=cache)
807         if not d.has_key(propname):
808             if default is _marker:
809                 if isinstance(prop, Multilink):
810                     return []
811                 else:
812                     # TODO: None isn't right here, I think...
813                     return None
814             else:
815                 return default
817         return d[propname]
819     # XXX not in spec
820     def getnode(self, nodeid, cache=1):
821         ''' Return a convenience wrapper for the node.
823         'nodeid' must be the id of an existing node of this class or an
824         IndexError is raised.
826         'cache' indicates whether the transaction cache should be queried
827         for the node. If the node has been modified and you need to
828         determine what its values prior to modification are, you need to
829         set cache=0.
830         '''
831         return Node(self, nodeid, cache=cache)
833     def set(self, nodeid, **propvalues):
834         """Modify a property on an existing node of this class.
835         
836         'nodeid' must be the id of an existing node of this class or an
837         IndexError is raised.
839         Each key in 'propvalues' must be the name of a property of this
840         class or a KeyError is raised.
842         All values in 'propvalues' must be acceptable types for their
843         corresponding properties or a TypeError is raised.
845         If the value of the key property is set, it must not collide with
846         other key strings or a ValueError is raised.
848         If the value of a Link or Multilink property contains an invalid
849         node id, a ValueError is raised.
851         These operations trigger detectors and can be vetoed.  Attempts
852         to modify the "creation" or "activity" properties cause a KeyError.
853         """
854         if not propvalues:
855             return
857         if propvalues.has_key('creation') or propvalues.has_key('activity'):
858             raise KeyError, '"creation" and "activity" are reserved'
860         if propvalues.has_key('id'):
861             raise KeyError, '"id" is reserved'
863         if self.db.journaltag is None:
864             raise DatabaseError, 'Database open read-only'
866         self.fireAuditors('set', nodeid, propvalues)
867         # Take a copy of the node dict so that the subsequent set
868         # operation doesn't modify the oldvalues structure.
869         try:
870             # try not using the cache initially
871             oldvalues = copy.deepcopy(self.db.getnode(self.classname, nodeid,
872                 cache=0))
873         except IndexError:
874             # this will be needed if somone does a create() and set()
875             # with no intervening commit()
876             oldvalues = copy.deepcopy(self.db.getnode(self.classname, nodeid))
878         node = self.db.getnode(self.classname, nodeid)
879         if node.has_key(self.db.RETIRED_FLAG):
880             raise IndexError
881         num_re = re.compile('^\d+$')
882         for key, value in propvalues.items():
883             # check to make sure we're not duplicating an existing key
884             if key == self.key and node[key] != value:
885                 try:
886                     self.lookup(value)
887                 except KeyError:
888                     pass
889                 else:
890                     raise ValueError, 'node with key "%s" exists'%value
892             # this will raise the KeyError if the property isn't valid
893             # ... we don't use getprops() here because we only care about
894             # the writeable properties.
895             prop = self.properties[key]
897             # if the value's the same as the existing value, no sense in
898             # doing anything
899             if node.has_key(key) and value == node[key]:
900                 del propvalues[key]
901                 continue
903             # do stuff based on the prop type
904             if isinstance(prop, Link):
905                 link_class = self.properties[key].classname
906                 # if it isn't a number, it's a key
907                 if type(value) != type(''):
908                     raise ValueError, 'link value must be String'
909                 if not num_re.match(value):
910                     try:
911                         value = self.db.classes[link_class].lookup(value)
912                     except (TypeError, KeyError):
913                         raise IndexError, 'new property "%s": %s not a %s'%(
914                             key, value, self.properties[key].classname)
916                 if not self.db.hasnode(link_class, value):
917                     raise IndexError, '%s has no node %s'%(link_class, value)
919                 if self.do_journal and self.properties[key].do_journal:
920                     # register the unlink with the old linked node
921                     if node[key] is not None:
922                         self.db.addjournal(link_class, node[key], 'unlink',
923                             (self.classname, nodeid, key))
925                     # register the link with the newly linked node
926                     if value is not None:
927                         self.db.addjournal(link_class, value, 'link',
928                             (self.classname, nodeid, key))
930             elif isinstance(prop, Multilink):
931                 if type(value) != type([]):
932                     raise TypeError, 'new property "%s" not a list of ids'%key
933                 link_class = self.properties[key].classname
934                 l = []
935                 for entry in value:
936                     # if it isn't a number, it's a key
937                     if type(entry) != type(''):
938                         raise ValueError, 'new property "%s" link value ' \
939                             'must be a string'%key
940                     if not num_re.match(entry):
941                         try:
942                             entry = self.db.classes[link_class].lookup(entry)
943                         except (TypeError, KeyError):
944                             raise IndexError, 'new property "%s": %s not a %s'%(
945                                 key, entry, self.properties[key].classname)
946                     l.append(entry)
947                 value = l
948                 propvalues[key] = value
950                 # handle removals
951                 if node.has_key(key):
952                     l = node[key]
953                 else:
954                     l = []
955                 for id in l[:]:
956                     if id in value:
957                         continue
958                     # register the unlink with the old linked node
959                     if self.do_journal and self.properties[key].do_journal:
960                         self.db.addjournal(link_class, id, 'unlink',
961                             (self.classname, nodeid, key))
962                     l.remove(id)
964                 # handle additions
965                 for id in value:
966                     if not self.db.hasnode(link_class, id):
967                         raise IndexError, '%s has no node %s'%(
968                             link_class, id)
969                     if id in l:
970                         continue
971                     # register the link with the newly linked node
972                     if self.do_journal and self.properties[key].do_journal:
973                         self.db.addjournal(link_class, id, 'link',
974                             (self.classname, nodeid, key))
975                     l.append(id)
977             elif isinstance(prop, String):
978                 if value is not None and type(value) != type(''):
979                     raise TypeError, 'new property "%s" not a string'%key
981             elif isinstance(prop, Password):
982                 if not isinstance(value, password.Password):
983                     raise TypeError, 'new property "%s" not a Password'% key
984                 propvalues[key] = value
986             elif value is not None and isinstance(prop, Date):
987                 if not isinstance(value, date.Date):
988                     raise TypeError, 'new property "%s" not a Date'% key
989                 propvalues[key] = value
991             elif value is not None and isinstance(prop, Interval):
992                 if not isinstance(value, date.Interval):
993                     raise TypeError, 'new property "%s" not an Interval'% key
994                 propvalues[key] = value
996             node[key] = value
998         # nothing to do?
999         if not propvalues:
1000             return
1002         # do the set, and journal it
1003         self.db.setnode(self.classname, nodeid, node)
1004         if self.do_journal:
1005             self.db.addjournal(self.classname, nodeid, 'set', propvalues)
1007         self.fireReactors('set', nodeid, oldvalues)
1009     def retire(self, nodeid):
1010         """Retire a node.
1011         
1012         The properties on the node remain available from the get() method,
1013         and the node's id is never reused.
1014         
1015         Retired nodes are not returned by the find(), list(), or lookup()
1016         methods, and other nodes may reuse the values of their key properties.
1018         These operations trigger detectors and can be vetoed.  Attempts
1019         to modify the "creation" or "activity" properties cause a KeyError.
1020         """
1021         if self.db.journaltag is None:
1022             raise DatabaseError, 'Database open read-only'
1024         self.fireAuditors('retire', nodeid, None)
1026         node = self.db.getnode(self.classname, nodeid)
1027         node[self.db.RETIRED_FLAG] = 1
1028         self.db.setnode(self.classname, nodeid, node)
1029         if self.do_journal:
1030             self.db.addjournal(self.classname, nodeid, 'retired', None)
1032         self.fireReactors('retire', nodeid, None)
1034     def history(self, nodeid):
1035         """Retrieve the journal of edits on a particular node.
1037         'nodeid' must be the id of an existing node of this class or an
1038         IndexError is raised.
1040         The returned list contains tuples of the form
1042             (date, tag, action, params)
1044         'date' is a Timestamp object specifying the time of the change and
1045         'tag' is the journaltag specified when the database was opened.
1046         """
1047         if not self.do_journal:
1048             raise ValueError, 'Journalling is disabled for this class'
1049         return self.db.getjournal(self.classname, nodeid)
1051     # Locating nodes:
1052     def hasnode(self, nodeid):
1053         '''Determine if the given nodeid actually exists
1054         '''
1055         return self.db.hasnode(self.classname, nodeid)
1057     def setkey(self, propname):
1058         """Select a String property of this class to be the key property.
1060         'propname' must be the name of a String property of this class or
1061         None, or a TypeError is raised.  The values of the key property on
1062         all existing nodes must be unique or a ValueError is raised.
1063         """
1064         # TODO: validate that the property is a String!
1065         self.key = propname
1067     def getkey(self):
1068         """Return the name of the key property for this class or None."""
1069         return self.key
1071     def labelprop(self, default_to_id=0):
1072         ''' Return the property name for a label for the given node.
1074         This method attempts to generate a consistent label for the node.
1075         It tries the following in order:
1076             1. key property
1077             2. "name" property
1078             3. "title" property
1079             4. first property from the sorted property name list
1080         '''
1081         k = self.getkey()
1082         if  k:
1083             return k
1084         props = self.getprops()
1085         if props.has_key('name'):
1086             return 'name'
1087         elif props.has_key('title'):
1088             return 'title'
1089         if default_to_id:
1090             return 'id'
1091         props = props.keys()
1092         props.sort()
1093         return props[0]
1095     # TODO: set up a separate index db file for this? profile?
1096     def lookup(self, keyvalue):
1097         """Locate a particular node by its key property and return its id.
1099         If this class has no key property, a TypeError is raised.  If the
1100         'keyvalue' matches one of the values for the key property among
1101         the nodes in this class, the matching node's id is returned;
1102         otherwise a KeyError is raised.
1103         """
1104         cldb = self.db.getclassdb(self.classname)
1105         try:
1106             for nodeid in self.db.getnodeids(self.classname, cldb):
1107                 node = self.db.getnode(self.classname, nodeid, cldb)
1108                 if node.has_key(self.db.RETIRED_FLAG):
1109                     continue
1110                 if node[self.key] == keyvalue:
1111                     cldb.close()
1112                     return nodeid
1113         finally:
1114             cldb.close()
1115         raise KeyError, keyvalue
1117     # XXX: change from spec - allows multiple props to match
1118     def find(self, **propspec):
1119         """Get the ids of nodes in this class which link to the given nodes.
1121         'propspec' consists of keyword args propname={nodeid:1,}   
1122           'propname' must be the name of a property in this class, or a
1123             KeyError is raised.  That property must be a Link or Multilink
1124             property, or a TypeError is raised.
1126         Any node in this class whose 'propname' property links to any of the
1127         nodeids will be returned. Used by the full text indexing, which knows
1128         that "foo" occurs in msg1, msg3 and file7, so we have hits on these issues:
1129             db.issue.find(messages={'1':1,'3':1}, files={'7':1})
1130         """
1131         propspec = propspec.items()
1132         for propname, nodeids in propspec:
1133             # check the prop is OK
1134             prop = self.properties[propname]
1135             if not isinstance(prop, Link) and not isinstance(prop, Multilink):
1136                 raise TypeError, "'%s' not a Link/Multilink property"%propname
1137             #XXX edit is expensive and of questionable use
1138             #for nodeid in nodeids:
1139             #    if not self.db.hasnode(prop.classname, nodeid):
1140             #        raise ValueError, '%s has no node %s'%(prop.classname, nodeid)
1142         # ok, now do the find
1143         cldb = self.db.getclassdb(self.classname)
1144         l = []
1145         try:
1146             for id in self.db.getnodeids(self.classname, db=cldb):
1147                 node = self.db.getnode(self.classname, id, db=cldb)
1148                 if node.has_key(self.db.RETIRED_FLAG):
1149                     continue
1150                 for propname, nodeids in propspec:
1151                     # can't test if the node doesn't have this property
1152                     if not node.has_key(propname):
1153                         continue
1154                     if type(nodeids) is type(''):
1155                         nodeids = {nodeids:1}
1156                     prop = self.properties[propname]
1157                     value = node[propname]
1158                     if isinstance(prop, Link) and nodeids.has_key(value):
1159                         l.append(id)
1160                         break
1161                     elif isinstance(prop, Multilink):
1162                         hit = 0
1163                         for v in value:
1164                             if nodeids.has_key(v):
1165                                 l.append(id)
1166                                 hit = 1
1167                                 break
1168                         if hit:
1169                             break
1170         finally:
1171             cldb.close()
1172         return l
1174     def stringFind(self, **requirements):
1175         """Locate a particular node by matching a set of its String
1176         properties in a caseless search.
1178         If the property is not a String property, a TypeError is raised.
1179         
1180         The return is a list of the id of all nodes that match.
1181         """
1182         for propname in requirements.keys():
1183             prop = self.properties[propname]
1184             if isinstance(not prop, String):
1185                 raise TypeError, "'%s' not a String property"%propname
1186             requirements[propname] = requirements[propname].lower()
1187         l = []
1188         cldb = self.db.getclassdb(self.classname)
1189         try:
1190             for nodeid in self.db.getnodeids(self.classname, cldb):
1191                 node = self.db.getnode(self.classname, nodeid, cldb)
1192                 if node.has_key(self.db.RETIRED_FLAG):
1193                     continue
1194                 for key, value in requirements.items():
1195                     if node[key] and node[key].lower() != value:
1196                         break
1197                 else:
1198                     l.append(nodeid)
1199         finally:
1200             cldb.close()
1201         return l
1203     def list(self):
1204         """Return a list of the ids of the active nodes in this class."""
1205         l = []
1206         cn = self.classname
1207         cldb = self.db.getclassdb(cn)
1208         try:
1209             for nodeid in self.db.getnodeids(cn, cldb):
1210                 node = self.db.getnode(cn, nodeid, cldb)
1211                 if node.has_key(self.db.RETIRED_FLAG):
1212                     continue
1213                 l.append(nodeid)
1214         finally:
1215             cldb.close()
1216         l.sort()
1217         return l
1219     # XXX not in spec
1220     def filter(self, search_matches, filterspec, sort, group, 
1221             num_re = re.compile('^\d+$')):
1222         ''' Return a list of the ids of the active nodes in this class that
1223             match the 'filter' spec, sorted by the group spec and then the
1224             sort spec
1225         '''
1226         cn = self.classname
1228         # optimise filterspec
1229         l = []
1230         props = self.getprops()
1231         for k, v in filterspec.items():
1232             propclass = props[k]
1233             if isinstance(propclass, Link):
1234                 if type(v) is not type([]):
1235                     v = [v]
1236                 # replace key values with node ids
1237                 u = []
1238                 link_class =  self.db.classes[propclass.classname]
1239                 for entry in v:
1240                     if entry == '-1': entry = None
1241                     elif not num_re.match(entry):
1242                         try:
1243                             entry = link_class.lookup(entry)
1244                         except (TypeError,KeyError):
1245                             raise ValueError, 'property "%s": %s not a %s'%(
1246                                 k, entry, self.properties[k].classname)
1247                     u.append(entry)
1249                 l.append((0, k, u))
1250             elif isinstance(propclass, Multilink):
1251                 if type(v) is not type([]):
1252                     v = [v]
1253                 # replace key values with node ids
1254                 u = []
1255                 link_class =  self.db.classes[propclass.classname]
1256                 for entry in v:
1257                     if not num_re.match(entry):
1258                         try:
1259                             entry = link_class.lookup(entry)
1260                         except (TypeError,KeyError):
1261                             raise ValueError, 'new property "%s": %s not a %s'%(
1262                                 k, entry, self.properties[k].classname)
1263                     u.append(entry)
1264                 l.append((1, k, u))
1265             elif isinstance(propclass, String):
1266                 # simple glob searching
1267                 v = re.sub(r'([\|\{\}\\\.\+\[\]\(\)])', r'\\\1', v)
1268                 v = v.replace('?', '.')
1269                 v = v.replace('*', '.*?')
1270                 l.append((2, k, re.compile(v, re.I)))
1271             else:
1272                 l.append((6, k, v))
1273         filterspec = l
1275         # now, find all the nodes that are active and pass filtering
1276         l = []
1277         cldb = self.db.getclassdb(cn)
1278         try:
1279             for nodeid in self.db.getnodeids(cn, cldb):
1280                 node = self.db.getnode(cn, nodeid, cldb)
1281                 if node.has_key(self.db.RETIRED_FLAG):
1282                     continue
1283                 # apply filter
1284                 for t, k, v in filterspec:
1285                     # this node doesn't have this property, so reject it
1286                     if not node.has_key(k): break
1288                     if t == 0 and node[k] not in v:
1289                         # link - if this node'd property doesn't appear in the
1290                         # filterspec's nodeid list, skip it
1291                         break
1292                     elif t == 1:
1293                         # multilink - if any of the nodeids required by the
1294                         # filterspec aren't in this node's property, then skip
1295                         # it
1296                         for value in v:
1297                             if value not in node[k]:
1298                                 break
1299                         else:
1300                             continue
1301                         break
1302                     elif t == 2 and (node[k] is None or not v.search(node[k])):
1303                         # RE search
1304                         break
1305                     elif t == 6 and node[k] != v:
1306                         # straight value comparison for the other types
1307                         break
1308                 else:
1309                     l.append((nodeid, node))
1310         finally:
1311             cldb.close()
1312         l.sort()
1314         # filter based on full text search
1315         if search_matches is not None:
1316             k = []
1317             l_debug = []
1318             for v in l:
1319                 l_debug.append(v[0])
1320                 if search_matches.has_key(v[0]):
1321                     k.append(v)
1322             l = k
1324         # optimise sort
1325         m = []
1326         for entry in sort:
1327             if entry[0] != '-':
1328                 m.append(('+', entry))
1329             else:
1330                 m.append((entry[0], entry[1:]))
1331         sort = m
1333         # optimise group
1334         m = []
1335         for entry in group:
1336             if entry[0] != '-':
1337                 m.append(('+', entry))
1338             else:
1339                 m.append((entry[0], entry[1:]))
1340         group = m
1341         # now, sort the result
1342         def sortfun(a, b, sort=sort, group=group, properties=self.getprops(),
1343                 db = self.db, cl=self):
1344             a_id, an = a
1345             b_id, bn = b
1346             # sort by group and then sort
1347             for list in group, sort:
1348                 for dir, prop in list:
1349                     # sorting is class-specific
1350                     propclass = properties[prop]
1352                     # handle the properties that might be "faked"
1353                     # also, handle possible missing properties
1354                     try:
1355                         if not an.has_key(prop):
1356                             an[prop] = cl.get(a_id, prop)
1357                         av = an[prop]
1358                     except KeyError:
1359                         # the node doesn't have a value for this property
1360                         if isinstance(propclass, Multilink): av = []
1361                         else: av = ''
1362                     try:
1363                         if not bn.has_key(prop):
1364                             bn[prop] = cl.get(b_id, prop)
1365                         bv = bn[prop]
1366                     except KeyError:
1367                         # the node doesn't have a value for this property
1368                         if isinstance(propclass, Multilink): bv = []
1369                         else: bv = ''
1371                     # String and Date values are sorted in the natural way
1372                     if isinstance(propclass, String):
1373                         # clean up the strings
1374                         if av and av[0] in string.uppercase:
1375                             av = an[prop] = av.lower()
1376                         if bv and bv[0] in string.uppercase:
1377                             bv = bn[prop] = bv.lower()
1378                     if (isinstance(propclass, String) or
1379                             isinstance(propclass, Date)):
1380                         # it might be a string that's really an integer
1381                         try:
1382                             av = int(av)
1383                             bv = int(bv)
1384                         except:
1385                             pass
1386                         if dir == '+':
1387                             r = cmp(av, bv)
1388                             if r != 0: return r
1389                         elif dir == '-':
1390                             r = cmp(bv, av)
1391                             if r != 0: return r
1393                     # Link properties are sorted according to the value of
1394                     # the "order" property on the linked nodes if it is
1395                     # present; or otherwise on the key string of the linked
1396                     # nodes; or finally on  the node ids.
1397                     elif isinstance(propclass, Link):
1398                         link = db.classes[propclass.classname]
1399                         if av is None and bv is not None: return -1
1400                         if av is not None and bv is None: return 1
1401                         if av is None and bv is None: continue
1402                         if link.getprops().has_key('order'):
1403                             if dir == '+':
1404                                 r = cmp(link.get(av, 'order'),
1405                                     link.get(bv, 'order'))
1406                                 if r != 0: return r
1407                             elif dir == '-':
1408                                 r = cmp(link.get(bv, 'order'),
1409                                     link.get(av, 'order'))
1410                                 if r != 0: return r
1411                         elif link.getkey():
1412                             key = link.getkey()
1413                             if dir == '+':
1414                                 r = cmp(link.get(av, key), link.get(bv, key))
1415                                 if r != 0: return r
1416                             elif dir == '-':
1417                                 r = cmp(link.get(bv, key), link.get(av, key))
1418                                 if r != 0: return r
1419                         else:
1420                             if dir == '+':
1421                                 r = cmp(av, bv)
1422                                 if r != 0: return r
1423                             elif dir == '-':
1424                                 r = cmp(bv, av)
1425                                 if r != 0: return r
1427                     # Multilink properties are sorted according to how many
1428                     # links are present.
1429                     elif isinstance(propclass, Multilink):
1430                         if dir == '+':
1431                             r = cmp(len(av), len(bv))
1432                             if r != 0: return r
1433                         elif dir == '-':
1434                             r = cmp(len(bv), len(av))
1435                             if r != 0: return r
1436                 # end for dir, prop in list:
1437             # end for list in sort, group:
1438             # if all else fails, compare the ids
1439             return cmp(a[0], b[0])
1441         l.sort(sortfun)
1442         return [i[0] for i in l]
1444     def count(self):
1445         """Get the number of nodes in this class.
1447         If the returned integer is 'numnodes', the ids of all the nodes
1448         in this class run from 1 to numnodes, and numnodes+1 will be the
1449         id of the next node to be created in this class.
1450         """
1451         return self.db.countnodes(self.classname)
1453     # Manipulating properties:
1455     def getprops(self, protected=1):
1456         """Return a dictionary mapping property names to property objects.
1457            If the "protected" flag is true, we include protected properties -
1458            those which may not be modified.
1460            In addition to the actual properties on the node, these
1461            methods provide the "creation" and "activity" properties. If the
1462            "protected" flag is true, we include protected properties - those
1463            which may not be modified.
1464         """
1465         d = self.properties.copy()
1466         if protected:
1467             d['id'] = String()
1468             d['creation'] = hyperdb.Date()
1469             d['activity'] = hyperdb.Date()
1470             d['creator'] = hyperdb.Link("user")
1471         return d
1473     def addprop(self, **properties):
1474         """Add properties to this class.
1476         The keyword arguments in 'properties' must map names to property
1477         objects, or a TypeError is raised.  None of the keys in 'properties'
1478         may collide with the names of existing properties, or a ValueError
1479         is raised before any properties have been added.
1480         """
1481         for key in properties.keys():
1482             if self.properties.has_key(key):
1483                 raise ValueError, key
1484         self.properties.update(properties)
1486     def index(self, nodeid):
1487         '''Add (or refresh) the node to search indexes
1488         '''
1489         # find all the String properties that have indexme
1490         for prop, propclass in self.getprops().items():
1491             if isinstance(propclass, String) and propclass.indexme:
1492                 # and index them under (classname, nodeid, property)
1493                 self.db.indexer.add_text((self.classname, nodeid, prop),
1494                     str(self.get(nodeid, prop)))
1496     #
1497     # Detector interface
1498     #
1499     def audit(self, event, detector):
1500         """Register a detector
1501         """
1502         l = self.auditors[event]
1503         if detector not in l:
1504             self.auditors[event].append(detector)
1506     def fireAuditors(self, action, nodeid, newvalues):
1507         """Fire all registered auditors.
1508         """
1509         for audit in self.auditors[action]:
1510             audit(self.db, self, nodeid, newvalues)
1512     def react(self, event, detector):
1513         """Register a detector
1514         """
1515         l = self.reactors[event]
1516         if detector not in l:
1517             self.reactors[event].append(detector)
1519     def fireReactors(self, action, nodeid, oldvalues):
1520         """Fire all registered reactors.
1521         """
1522         for react in self.reactors[action]:
1523             react(self.db, self, nodeid, oldvalues)
1525 class FileClass(Class):
1526     '''This class defines a large chunk of data. To support this, it has a
1527        mandatory String property "content" which is typically saved off
1528        externally to the hyperdb.
1530        The default MIME type of this data is defined by the
1531        "default_mime_type" class attribute, which may be overridden by each
1532        node if the class defines a "type" String property.
1533     '''
1534     default_mime_type = 'text/plain'
1536     def create(self, **propvalues):
1537         ''' snaffle the file propvalue and store in a file
1538         '''
1539         content = propvalues['content']
1540         del propvalues['content']
1541         newid = Class.create(self, **propvalues)
1542         self.db.storefile(self.classname, newid, None, content)
1543         return newid
1545     def get(self, nodeid, propname, default=_marker, cache=1):
1546         ''' trap the content propname and get it from the file
1547         '''
1549         poss_msg = 'Possibly a access right configuration problem.'
1550         if propname == 'content':
1551             try:
1552                 return self.db.getfile(self.classname, nodeid, None)
1553             except IOError, (strerror):
1554                 # BUG: by catching this we donot see an error in the log.
1555                 return 'ERROR reading file: %s%s\n%s\n%s'%(
1556                         self.classname, nodeid, poss_msg, strerror)
1557         if default is not _marker:
1558             return Class.get(self, nodeid, propname, default, cache=cache)
1559         else:
1560             return Class.get(self, nodeid, propname, cache=cache)
1562     def getprops(self, protected=1):
1563         ''' In addition to the actual properties on the node, these methods
1564             provide the "content" property. If the "protected" flag is true,
1565             we include protected properties - those which may not be
1566             modified.
1567         '''
1568         d = Class.getprops(self, protected=protected).copy()
1569         if protected:
1570             d['content'] = hyperdb.String()
1571         return d
1573     def index(self, nodeid):
1574         ''' Index the node in the search index.
1576             We want to index the content in addition to the normal String
1577             property indexing.
1578         '''
1579         # perform normal indexing
1580         Class.index(self, nodeid)
1582         # get the content to index
1583         content = self.get(nodeid, 'content')
1585         # figure the mime type
1586         if self.properties.has_key('type'):
1587             mime_type = self.get(nodeid, 'type')
1588         else:
1589             mime_type = self.default_mime_type
1591         # and index!
1592         self.db.indexer.add_text((self.classname, nodeid, 'content'), content,
1593             mime_type)
1595 # XXX deviation from spec - was called ItemClass
1596 class IssueClass(Class, roundupdb.IssueClass):
1597     # Overridden methods:
1598     def __init__(self, db, classname, **properties):
1599         """The newly-created class automatically includes the "messages",
1600         "files", "nosy", and "superseder" properties.  If the 'properties'
1601         dictionary attempts to specify any of these properties or a
1602         "creation" or "activity" property, a ValueError is raised.
1603         """
1604         if not properties.has_key('title'):
1605             properties['title'] = hyperdb.String(indexme='yes')
1606         if not properties.has_key('messages'):
1607             properties['messages'] = hyperdb.Multilink("msg")
1608         if not properties.has_key('files'):
1609             properties['files'] = hyperdb.Multilink("file")
1610         if not properties.has_key('nosy'):
1611             properties['nosy'] = hyperdb.Multilink("user")
1612         if not properties.has_key('superseder'):
1613             properties['superseder'] = hyperdb.Multilink(classname)
1614         Class.__init__(self, db, classname, **properties)
1617 #$Log: not supported by cvs2svn $
1618 #Revision 1.44  2002/07/14 02:05:53  richard
1619 #. all storage-specific code (ie. backend) is now implemented by the backends
1621 #Revision 1.43  2002/07/10 06:30:30  richard
1622 #...except of course it's nice to use valid Python syntax
1624 #Revision 1.42  2002/07/10 06:21:38  richard
1625 #Be extra safe
1627 #Revision 1.41  2002/07/10 00:21:45  richard
1628 #explicit database closing
1630 #Revision 1.40  2002/07/09 04:19:09  richard
1631 #Added reindex command to roundup-admin.
1632 #Fixed reindex on first access.
1633 #Also fixed reindexing of entries that change.
1635 #Revision 1.39  2002/07/09 03:02:52  richard
1636 #More indexer work:
1637 #- all String properties may now be indexed too. Currently there's a bit of
1638 #  "issue" specific code in the actual searching which needs to be
1639 #  addressed. In a nutshell:
1640 #  + pass 'indexme="yes"' as a String() property initialisation arg, eg:
1641 #        file = FileClass(db, "file", name=String(), type=String(),
1642 #            comment=String(indexme="yes"))
1643 #  + the comment will then be indexed and be searchable, with the results
1644 #    related back to the issue that the file is linked to
1645 #- as a result of this work, the FileClass has a default MIME type that may
1646 #  be overridden in a subclass, or by the use of a "type" property as is
1647 #  done in the default templates.
1648 #- the regeneration of the indexes (if necessary) is done once the schema is
1649 #  set up in the dbinit.
1651 #Revision 1.38  2002/07/08 06:58:15  richard
1652 #cleaned up the indexer code:
1653 # - it splits more words out (much simpler, faster splitter)
1654 # - removed code we'll never use (roundup.roundup_indexer has the full
1655 #   implementation, and replaces roundup.indexer)
1656 # - only index text/plain and rfc822/message (ideas for other text formats to
1657 #   index are welcome)
1658 # - added simple unit test for indexer. Needs more tests for regression.
1660 #Revision 1.37  2002/06/20 23:52:35  richard
1661 #More informative error message
1663 #Revision 1.36  2002/06/19 03:07:19  richard
1664 #Moved the file storage commit into blobfiles where it belongs.
1666 #Revision 1.35  2002/05/25 07:16:24  rochecompaan
1667 #Merged search_indexing-branch with HEAD
1669 #Revision 1.34  2002/05/15 06:21:21  richard
1670 # . node caching now works, and gives a small boost in performance
1672 #As a part of this, I cleaned up the DEBUG output and implemented TRACE
1673 #output (HYPERDBTRACE='file to trace to') with checkpoints at the start of
1674 #CGI requests. Run roundup with python -O to skip all the DEBUG/TRACE stuff
1675 #(using if __debug__ which is compiled out with -O)
1677 #Revision 1.33  2002/04/24 10:38:26  rochecompaan
1678 #All database files are now created group readable and writable.
1680 #Revision 1.32  2002/04/15 23:25:15  richard
1681 #. node ids are now generated from a lockable store - no more race conditions
1683 #We're using the portalocker code by Jonathan Feinberg that was contributed
1684 #to the ASPN Python cookbook. This gives us locking across Unix and Windows.
1686 #Revision 1.31  2002/04/03 05:54:31  richard
1687 #Fixed serialisation problem by moving the serialisation step out of the
1688 #hyperdb.Class (get, set) into the hyperdb.Database.
1690 #Also fixed htmltemplate after the showid changes I made yesterday.
1692 #Unit tests for all of the above written.
1694 #Revision 1.30.2.1  2002/04/03 11:55:57  rochecompaan
1695 # . Added feature #526730 - search for messages capability
1697 #Revision 1.30  2002/02/27 03:40:59  richard
1698 #Ran it through pychecker, made fixes
1700 #Revision 1.29  2002/02/25 14:34:31  grubert
1701 # . use blobfiles in back_anydbm which is used in back_bsddb.
1702 #   change test_db as dirlist does not work for subdirectories.
1703 #   ATTENTION: blobfiles now creates subdirectories for files.
1705 #Revision 1.28  2002/02/16 09:14:17  richard
1706 # . #514854 ] History: "User" is always ticket creator
1708 #Revision 1.27  2002/01/22 07:21:13  richard
1709 #. fixed back_bsddb so it passed the journal tests
1711 #... it didn't seem happy using the back_anydbm _open method, which is odd.
1712 #Yet another occurrance of whichdb not being able to recognise older bsddb
1713 #databases. Yadda yadda. Made the HYPERDBDEBUG stuff more sane in the
1714 #process.
1716 #Revision 1.26  2002/01/22 05:18:38  rochecompaan
1717 #last_set_entry was referenced before assignment
1719 #Revision 1.25  2002/01/22 05:06:08  rochecompaan
1720 #We need to keep the last 'set' entry in the journal to preserve
1721 #information on 'activity' for nodes.
1723 #Revision 1.24  2002/01/21 16:33:20  rochecompaan
1724 #You can now use the roundup-admin tool to pack the database
1726 #Revision 1.23  2002/01/18 04:32:04  richard
1727 #Rollback was breaking because a message hadn't actually been written to the file. Needs
1728 #more investigation.
1730 #Revision 1.22  2002/01/14 02:20:15  richard
1731 # . changed all config accesses so they access either the instance or the
1732 #   config attriubute on the db. This means that all config is obtained from
1733 #   instance_config instead of the mish-mash of classes. This will make
1734 #   switching to a ConfigParser setup easier too, I hope.
1736 #At a minimum, this makes migration a _little_ easier (a lot easier in the
1737 #0.5.0 switch, I hope!)
1739 #Revision 1.21  2002/01/02 02:31:38  richard
1740 #Sorry for the huge checkin message - I was only intending to implement #496356
1741 #but I found a number of places where things had been broken by transactions:
1742 # . modified ROUNDUPDBSENDMAILDEBUG to be SENDMAILDEBUG and hold a filename
1743 #   for _all_ roundup-generated smtp messages to be sent to.
1744 # . the transaction cache had broken the roundupdb.Class set() reactors
1745 # . newly-created author users in the mailgw weren't being committed to the db
1747 #Stuff that made it into CHANGES.txt (ie. the stuff I was actually working
1748 #on when I found that stuff :):
1749 # . #496356 ] Use threading in messages
1750 # . detectors were being registered multiple times
1751 # . added tests for mailgw
1752 # . much better attaching of erroneous messages in the mail gateway
1754 #Revision 1.20  2001/12/18 15:30:34  rochecompaan
1755 #Fixed bugs:
1756 # .  Fixed file creation and retrieval in same transaction in anydbm
1757 #    backend
1758 # .  Cgi interface now renders new issue after issue creation
1759 # .  Could not set issue status to resolved through cgi interface
1760 # .  Mail gateway was changing status back to 'chatting' if status was
1761 #    omitted as an argument
1763 #Revision 1.19  2001/12/17 03:52:48  richard
1764 #Implemented file store rollback. As a bonus, the hyperdb is now capable of
1765 #storing more than one file per node - if a property name is supplied,
1766 #the file is called designator.property.
1767 #I decided not to migrate the existing files stored over to the new naming
1768 #scheme - the FileClass just doesn't specify the property name.
1770 #Revision 1.18  2001/12/16 10:53:38  richard
1771 #take a copy of the node dict so that the subsequent set
1772 #operation doesn't modify the oldvalues structure
1774 #Revision 1.17  2001/12/14 23:42:57  richard
1775 #yuck, a gdbm instance tests false :(
1776 #I've left the debugging code in - it should be removed one day if we're ever
1777 #_really_ anal about performace :)
1779 #Revision 1.16  2001/12/12 03:23:14  richard
1780 #Cor blimey this anydbm/whichdb stuff is yecchy. Turns out that whichdb
1781 #incorrectly identifies a dbm file as a dbhash file on my system. This has
1782 #been submitted to the python bug tracker as issue #491888:
1783 #https://sourceforge.net/tracker/index.php?func=detail&aid=491888&group_id=5470&atid=105470
1785 #Revision 1.15  2001/12/12 02:30:51  richard
1786 #I fixed the problems with people whose anydbm was using the dbm module at the
1787 #backend. It turns out the dbm module modifies the file name to append ".db"
1788 #and my check to determine if we're opening an existing or new db just
1789 #tested os.path.exists() on the filename. Well, no longer! We now perform a
1790 #much better check _and_ cope with the anydbm implementation module changing
1791 #too!
1792 #I also fixed the backends __init__ so only ImportError is squashed.
1794 #Revision 1.14  2001/12/10 22:20:01  richard
1795 #Enabled transaction support in the bsddb backend. It uses the anydbm code
1796 #where possible, only replacing methods where the db is opened (it uses the
1797 #btree opener specifically.)
1798 #Also cleaned up some change note generation.
1799 #Made the backends package work with pydoc too.
1801 #Revision 1.13  2001/12/02 05:06:16  richard
1802 #. We now use weakrefs in the Classes to keep the database reference, so
1803 #  the close() method on the database is no longer needed.
1804 #  I bumped the minimum python requirement up to 2.1 accordingly.
1805 #. #487480 ] roundup-server
1806 #. #487476 ] INSTALL.txt
1808 #I also cleaned up the change message / post-edit stuff in the cgi client.
1809 #There's now a clearly marked "TODO: append the change note" where I believe
1810 #the change note should be added there. The "changes" list will obviously
1811 #have to be modified to be a dict of the changes, or somesuch.
1813 #More testing needed.
1815 #Revision 1.12  2001/12/01 07:17:50  richard
1816 #. We now have basic transaction support! Information is only written to
1817 #  the database when the commit() method is called. Only the anydbm
1818 #  backend is modified in this way - neither of the bsddb backends have been.
1819 #  The mail, admin and cgi interfaces all use commit (except the admin tool
1820 #  doesn't have a commit command, so interactive users can't commit...)
1821 #. Fixed login/registration forwarding the user to the right page (or not,
1822 #  on a failure)
1824 #Revision 1.11  2001/11/21 02:34:18  richard
1825 #Added a target version field to the extended issue schema
1827 #Revision 1.10  2001/10/09 23:58:10  richard
1828 #Moved the data stringification up into the hyperdb.Class class' get, set
1829 #and create methods. This means that the data is also stringified for the
1830 #journal call, and removes duplication of code from the backends. The
1831 #backend code now only sees strings.
1833 #Revision 1.9  2001/10/09 07:25:59  richard
1834 #Added the Password property type. See "pydoc roundup.password" for
1835 #implementation details. Have updated some of the documentation too.
1837 #Revision 1.8  2001/09/29 13:27:00  richard
1838 #CGI interfaces now spit up a top-level index of all the instances they can
1839 #serve.
1841 #Revision 1.7  2001/08/12 06:32:36  richard
1842 #using isinstance(blah, Foo) now instead of isFooType
1844 #Revision 1.6  2001/08/07 00:24:42  richard
1845 #stupid typo
1847 #Revision 1.5  2001/08/07 00:15:51  richard
1848 #Added the copyright/license notice to (nearly) all files at request of
1849 #Bizar Software.
1851 #Revision 1.4  2001/07/30 01:41:36  richard
1852 #Makes schema changes mucho easier.
1854 #Revision 1.3  2001/07/25 01:23:07  richard
1855 #Added the Roundup spec to the new documentation directory.
1857 #Revision 1.2  2001/07/23 08:20:44  richard
1858 #Moved over to using marshal in the bsddb and anydbm backends.
1859 #roundup-admin now has a "freshen" command that'll load/save all nodes (not
1860 # retired - mod hyperdb.Class.list() so it lists retired nodes)