Code

0acff4ac8fa5999b13dbbe8ecf878a5ae09fc68f
[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.44 2002-07-14 02:05:53 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         # do the db-related init stuff
593         db.addclass(self)
595         self.auditors = {'create': [], 'set': [], 'retire': []}
596         self.reactors = {'create': [], 'set': [], 'retire': []}
598     def __repr__(self):
599         '''Slightly more useful representation
600         '''
601         return '<hypderdb.Class "%s">'%self.classname
603     # Editing nodes:
605     def create(self, **propvalues):
606         """Create a new node of this class and return its id.
608         The keyword arguments in 'propvalues' map property names to values.
610         The values of arguments must be acceptable for the types of their
611         corresponding properties or a TypeError is raised.
612         
613         If this class has a key property, it must be present and its value
614         must not collide with other key strings or a ValueError is raised.
615         
616         Any other properties on this class that are missing from the
617         'propvalues' dictionary are set to None.
618         
619         If an id in a link or multilink property does not refer to a valid
620         node, an IndexError is raised.
622         These operations trigger detectors and can be vetoed.  Attempts
623         to modify the "creation" or "activity" properties cause a KeyError.
624         """
625         if propvalues.has_key('id'):
626             raise KeyError, '"id" is reserved'
628         if self.db.journaltag is None:
629             raise DatabaseError, 'Database open read-only'
631         if propvalues.has_key('creation') or propvalues.has_key('activity'):
632             raise KeyError, '"creation" and "activity" are reserved'
634         self.fireAuditors('create', None, propvalues)
636         # new node's id
637         newid = self.db.newid(self.classname)
639         # validate propvalues
640         num_re = re.compile('^\d+$')
641         for key, value in propvalues.items():
642             if key == self.key:
643                 try:
644                     self.lookup(value)
645                 except KeyError:
646                     pass
647                 else:
648                     raise ValueError, 'node with key "%s" exists'%value
650             # try to handle this property
651             try:
652                 prop = self.properties[key]
653             except KeyError:
654                 raise KeyError, '"%s" has no property "%s"'%(self.classname,
655                     key)
657             if isinstance(prop, Link):
658                 if type(value) != type(''):
659                     raise ValueError, 'link value must be String'
660                 link_class = self.properties[key].classname
661                 # if it isn't a number, it's a key
662                 if not num_re.match(value):
663                     try:
664                         value = self.db.classes[link_class].lookup(value)
665                     except (TypeError, KeyError):
666                         raise IndexError, 'new property "%s": %s not a %s'%(
667                             key, value, link_class)
668                 elif not self.db.hasnode(link_class, value):
669                     raise IndexError, '%s has no node %s'%(link_class, value)
671                 # save off the value
672                 propvalues[key] = value
674                 # register the link with the newly linked node
675                 if self.properties[key].do_journal:
676                     self.db.addjournal(link_class, value, 'link',
677                         (self.classname, newid, key))
679             elif isinstance(prop, Multilink):
680                 if type(value) != type([]):
681                     raise TypeError, 'new property "%s" not a list of ids'%key
683                 # clean up and validate the list of links
684                 link_class = self.properties[key].classname
685                 l = []
686                 for entry in value:
687                     if type(entry) != type(''):
688                         raise ValueError, '"%s" link value (%s) must be '\
689                             'String'%(key, value)
690                     # if it isn't a number, it's a key
691                     if not num_re.match(entry):
692                         try:
693                             entry = self.db.classes[link_class].lookup(entry)
694                         except (TypeError, KeyError):
695                             raise IndexError, 'new property "%s": %s not a %s'%(
696                                 key, entry, self.properties[key].classname)
697                     l.append(entry)
698                 value = l
699                 propvalues[key] = value
701                 # handle additions
702                 for id in value:
703                     if not self.db.hasnode(link_class, id):
704                         raise IndexError, '%s has no node %s'%(link_class, id)
705                     # register the link with the newly linked node
706                     if self.properties[key].do_journal:
707                         self.db.addjournal(link_class, id, 'link',
708                             (self.classname, newid, key))
710             elif isinstance(prop, String):
711                 if type(value) != type(''):
712                     raise TypeError, 'new property "%s" not a string'%key
714             elif isinstance(prop, Password):
715                 if not isinstance(value, password.Password):
716                     raise TypeError, 'new property "%s" not a Password'%key
718             elif isinstance(prop, Date):
719                 if value is not None and not isinstance(value, date.Date):
720                     raise TypeError, 'new property "%s" not a Date'%key
722             elif isinstance(prop, Interval):
723                 if value is not None and not isinstance(value, date.Interval):
724                     raise TypeError, 'new property "%s" not an Interval'%key
726         # make sure there's data where there needs to be
727         for key, prop in self.properties.items():
728             if propvalues.has_key(key):
729                 continue
730             if key == self.key:
731                 raise ValueError, 'key property "%s" is required'%key
732             if isinstance(prop, Multilink):
733                 propvalues[key] = []
734             else:
735                 # TODO: None isn't right here, I think...
736                 propvalues[key] = None
738         # done
739         self.db.addnode(self.classname, newid, propvalues)
740         self.db.addjournal(self.classname, newid, 'create', propvalues)
742         self.fireReactors('create', newid, None)
744         return newid
746     def get(self, nodeid, propname, default=_marker, cache=1):
747         """Get the value of a property on an existing node of this class.
749         'nodeid' must be the id of an existing node of this class or an
750         IndexError is raised.  'propname' must be the name of a property
751         of this class or a KeyError is raised.
753         'cache' indicates whether the transaction cache should be queried
754         for the node. If the node has been modified and you need to
755         determine what its values prior to modification are, you need to
756         set cache=0.
758         Attempts to get the "creation" or "activity" properties should
759         do the right thing.
760         """
761         if propname == 'id':
762             return nodeid
764         if propname == 'creation':
765             journal = self.db.getjournal(self.classname, nodeid)
766             if journal:
767                 return self.db.getjournal(self.classname, nodeid)[0][1]
768             else:
769                 # on the strange chance that there's no journal
770                 return date.Date()
771         if propname == 'activity':
772             journal = self.db.getjournal(self.classname, nodeid)
773             if journal:
774                 return self.db.getjournal(self.classname, nodeid)[-1][1]
775             else:
776                 # on the strange chance that there's no journal
777                 return date.Date()
778         if propname == 'creator':
779             journal = self.db.getjournal(self.classname, nodeid)
780             if journal:
781                 name = self.db.getjournal(self.classname, nodeid)[0][2]
782             else:
783                 return None
784             return self.db.user.lookup(name)
786         # get the property (raises KeyErorr if invalid)
787         prop = self.properties[propname]
789         # get the node's dict
790         d = self.db.getnode(self.classname, nodeid, cache=cache)
792         if not d.has_key(propname):
793             if default is _marker:
794                 if isinstance(prop, Multilink):
795                     return []
796                 else:
797                     # TODO: None isn't right here, I think...
798                     return None
799             else:
800                 return default
802         return d[propname]
804     # XXX not in spec
805     def getnode(self, nodeid, cache=1):
806         ''' Return a convenience wrapper for the node.
808         'nodeid' must be the id of an existing node of this class or an
809         IndexError is raised.
811         'cache' indicates whether the transaction cache should be queried
812         for the node. If the node has been modified and you need to
813         determine what its values prior to modification are, you need to
814         set cache=0.
815         '''
816         return Node(self, nodeid, cache=cache)
818     def set(self, nodeid, **propvalues):
819         """Modify a property on an existing node of this class.
820         
821         'nodeid' must be the id of an existing node of this class or an
822         IndexError is raised.
824         Each key in 'propvalues' must be the name of a property of this
825         class or a KeyError is raised.
827         All values in 'propvalues' must be acceptable types for their
828         corresponding properties or a TypeError is raised.
830         If the value of the key property is set, it must not collide with
831         other key strings or a ValueError is raised.
833         If the value of a Link or Multilink property contains an invalid
834         node id, a ValueError is raised.
836         These operations trigger detectors and can be vetoed.  Attempts
837         to modify the "creation" or "activity" properties cause a KeyError.
838         """
839         if not propvalues:
840             return
842         if propvalues.has_key('creation') or propvalues.has_key('activity'):
843             raise KeyError, '"creation" and "activity" are reserved'
845         if propvalues.has_key('id'):
846             raise KeyError, '"id" is reserved'
848         if self.db.journaltag is None:
849             raise DatabaseError, 'Database open read-only'
851         self.fireAuditors('set', nodeid, propvalues)
852         # Take a copy of the node dict so that the subsequent set
853         # operation doesn't modify the oldvalues structure.
854         try:
855             # try not using the cache initially
856             oldvalues = copy.deepcopy(self.db.getnode(self.classname, nodeid,
857                 cache=0))
858         except IndexError:
859             # this will be needed if somone does a create() and set()
860             # with no intervening commit()
861             oldvalues = copy.deepcopy(self.db.getnode(self.classname, nodeid))
863         node = self.db.getnode(self.classname, nodeid)
864         if node.has_key(self.db.RETIRED_FLAG):
865             raise IndexError
866         num_re = re.compile('^\d+$')
867         for key, value in propvalues.items():
868             # check to make sure we're not duplicating an existing key
869             if key == self.key and node[key] != value:
870                 try:
871                     self.lookup(value)
872                 except KeyError:
873                     pass
874                 else:
875                     raise ValueError, 'node with key "%s" exists'%value
877             # this will raise the KeyError if the property isn't valid
878             # ... we don't use getprops() here because we only care about
879             # the writeable properties.
880             prop = self.properties[key]
882             # if the value's the same as the existing value, no sense in
883             # doing anything
884             if node.has_key(key) and value == node[key]:
885                 del propvalues[key]
886                 continue
888             # do stuff based on the prop type
889             if isinstance(prop, Link):
890                 link_class = self.properties[key].classname
891                 # if it isn't a number, it's a key
892                 if type(value) != type(''):
893                     raise ValueError, 'link value must be String'
894                 if not num_re.match(value):
895                     try:
896                         value = self.db.classes[link_class].lookup(value)
897                     except (TypeError, KeyError):
898                         raise IndexError, 'new property "%s": %s not a %s'%(
899                             key, value, self.properties[key].classname)
901                 if not self.db.hasnode(link_class, value):
902                     raise IndexError, '%s has no node %s'%(link_class, value)
904                 if self.properties[key].do_journal:
905                     # register the unlink with the old linked node
906                     if node[key] is not None:
907                         self.db.addjournal(link_class, node[key], 'unlink',
908                             (self.classname, nodeid, key))
910                     # register the link with the newly linked node
911                     if value is not None:
912                         self.db.addjournal(link_class, value, 'link',
913                             (self.classname, nodeid, key))
915             elif isinstance(prop, Multilink):
916                 if type(value) != type([]):
917                     raise TypeError, 'new property "%s" not a list of ids'%key
918                 link_class = self.properties[key].classname
919                 l = []
920                 for entry in value:
921                     # if it isn't a number, it's a key
922                     if type(entry) != type(''):
923                         raise ValueError, 'new property "%s" link value ' \
924                             'must be a string'%key
925                     if not num_re.match(entry):
926                         try:
927                             entry = self.db.classes[link_class].lookup(entry)
928                         except (TypeError, KeyError):
929                             raise IndexError, 'new property "%s": %s not a %s'%(
930                                 key, entry, self.properties[key].classname)
931                     l.append(entry)
932                 value = l
933                 propvalues[key] = value
935                 # handle removals
936                 if node.has_key(key):
937                     l = node[key]
938                 else:
939                     l = []
940                 for id in l[:]:
941                     if id in value:
942                         continue
943                     # register the unlink with the old linked node
944                     if self.properties[key].do_journal:
945                         self.db.addjournal(link_class, id, 'unlink',
946                             (self.classname, nodeid, key))
947                     l.remove(id)
949                 # handle additions
950                 for id in value:
951                     if not self.db.hasnode(link_class, id):
952                         raise IndexError, '%s has no node %s'%(
953                             link_class, id)
954                     if id in l:
955                         continue
956                     # register the link with the newly linked node
957                     if self.properties[key].do_journal:
958                         self.db.addjournal(link_class, id, 'link',
959                             (self.classname, nodeid, key))
960                     l.append(id)
962             elif isinstance(prop, String):
963                 if value is not None and type(value) != type(''):
964                     raise TypeError, 'new property "%s" not a string'%key
966             elif isinstance(prop, Password):
967                 if not isinstance(value, password.Password):
968                     raise TypeError, 'new property "%s" not a Password'% key
969                 propvalues[key] = value
971             elif value is not None and isinstance(prop, Date):
972                 if not isinstance(value, date.Date):
973                     raise TypeError, 'new property "%s" not a Date'% key
974                 propvalues[key] = value
976             elif value is not None and isinstance(prop, Interval):
977                 if not isinstance(value, date.Interval):
978                     raise TypeError, 'new property "%s" not an Interval'% key
979                 propvalues[key] = value
981             node[key] = value
983         # nothing to do?
984         if not propvalues:
985             return
987         # do the set, and journal it
988         self.db.setnode(self.classname, nodeid, node)
989         self.db.addjournal(self.classname, nodeid, 'set', propvalues)
991         self.fireReactors('set', nodeid, oldvalues)
993     def retire(self, nodeid):
994         """Retire a node.
995         
996         The properties on the node remain available from the get() method,
997         and the node's id is never reused.
998         
999         Retired nodes are not returned by the find(), list(), or lookup()
1000         methods, and other nodes may reuse the values of their key properties.
1002         These operations trigger detectors and can be vetoed.  Attempts
1003         to modify the "creation" or "activity" properties cause a KeyError.
1004         """
1005         if self.db.journaltag is None:
1006             raise DatabaseError, 'Database open read-only'
1008         self.fireAuditors('retire', nodeid, None)
1010         node = self.db.getnode(self.classname, nodeid)
1011         node[self.db.RETIRED_FLAG] = 1
1012         self.db.setnode(self.classname, nodeid, node)
1013         self.db.addjournal(self.classname, nodeid, 'retired', None)
1015         self.fireReactors('retire', nodeid, None)
1017     def history(self, nodeid):
1018         """Retrieve the journal of edits on a particular node.
1020         'nodeid' must be the id of an existing node of this class or an
1021         IndexError is raised.
1023         The returned list contains tuples of the form
1025             (date, tag, action, params)
1027         'date' is a Timestamp object specifying the time of the change and
1028         'tag' is the journaltag specified when the database was opened.
1029         """
1030         return self.db.getjournal(self.classname, nodeid)
1032     # Locating nodes:
1033     def hasnode(self, nodeid):
1034         '''Determine if the given nodeid actually exists
1035         '''
1036         return self.db.hasnode(self.classname, nodeid)
1038     def setkey(self, propname):
1039         """Select a String property of this class to be the key property.
1041         'propname' must be the name of a String property of this class or
1042         None, or a TypeError is raised.  The values of the key property on
1043         all existing nodes must be unique or a ValueError is raised.
1044         """
1045         # TODO: validate that the property is a String!
1046         self.key = propname
1048     def getkey(self):
1049         """Return the name of the key property for this class or None."""
1050         return self.key
1052     def labelprop(self, default_to_id=0):
1053         ''' Return the property name for a label for the given node.
1055         This method attempts to generate a consistent label for the node.
1056         It tries the following in order:
1057             1. key property
1058             2. "name" property
1059             3. "title" property
1060             4. first property from the sorted property name list
1061         '''
1062         k = self.getkey()
1063         if  k:
1064             return k
1065         props = self.getprops()
1066         if props.has_key('name'):
1067             return 'name'
1068         elif props.has_key('title'):
1069             return 'title'
1070         if default_to_id:
1071             return 'id'
1072         props = props.keys()
1073         props.sort()
1074         return props[0]
1076     # TODO: set up a separate index db file for this? profile?
1077     def lookup(self, keyvalue):
1078         """Locate a particular node by its key property and return its id.
1080         If this class has no key property, a TypeError is raised.  If the
1081         'keyvalue' matches one of the values for the key property among
1082         the nodes in this class, the matching node's id is returned;
1083         otherwise a KeyError is raised.
1084         """
1085         cldb = self.db.getclassdb(self.classname)
1086         try:
1087             for nodeid in self.db.getnodeids(self.classname, cldb):
1088                 node = self.db.getnode(self.classname, nodeid, cldb)
1089                 if node.has_key(self.db.RETIRED_FLAG):
1090                     continue
1091                 if node[self.key] == keyvalue:
1092                     cldb.close()
1093                     return nodeid
1094         finally:
1095             cldb.close()
1096         raise KeyError, keyvalue
1098     # XXX: change from spec - allows multiple props to match
1099     def find(self, **propspec):
1100         """Get the ids of nodes in this class which link to the given nodes.
1102         'propspec' consists of keyword args propname={nodeid:1,}   
1103           'propname' must be the name of a property in this class, or a
1104             KeyError is raised.  That property must be a Link or Multilink
1105             property, or a TypeError is raised.
1107         Any node in this class whose 'propname' property links to any of the
1108         nodeids will be returned. Used by the full text indexing, which knows
1109         that "foo" occurs in msg1, msg3 and file7, so we have hits on these issues:
1110             db.issue.find(messages={'1':1,'3':1}, files={'7':1})
1111         """
1112         propspec = propspec.items()
1113         for propname, nodeids in propspec:
1114             # check the prop is OK
1115             prop = self.properties[propname]
1116             if not isinstance(prop, Link) and not isinstance(prop, Multilink):
1117                 raise TypeError, "'%s' not a Link/Multilink property"%propname
1118             #XXX edit is expensive and of questionable use
1119             #for nodeid in nodeids:
1120             #    if not self.db.hasnode(prop.classname, nodeid):
1121             #        raise ValueError, '%s has no node %s'%(prop.classname, nodeid)
1123         # ok, now do the find
1124         cldb = self.db.getclassdb(self.classname)
1125         l = []
1126         try:
1127             for id in self.db.getnodeids(self.classname, db=cldb):
1128                 node = self.db.getnode(self.classname, id, db=cldb)
1129                 if node.has_key(self.db.RETIRED_FLAG):
1130                     continue
1131                 for propname, nodeids in propspec:
1132                     # can't test if the node doesn't have this property
1133                     if not node.has_key(propname):
1134                         continue
1135                     if type(nodeids) is type(''):
1136                         nodeids = {nodeids:1}
1137                     prop = self.properties[propname]
1138                     value = node[propname]
1139                     if isinstance(prop, Link) and nodeids.has_key(value):
1140                         l.append(id)
1141                         break
1142                     elif isinstance(prop, Multilink):
1143                         hit = 0
1144                         for v in value:
1145                             if nodeids.has_key(v):
1146                                 l.append(id)
1147                                 hit = 1
1148                                 break
1149                         if hit:
1150                             break
1151         finally:
1152             cldb.close()
1153         return l
1155     def stringFind(self, **requirements):
1156         """Locate a particular node by matching a set of its String
1157         properties in a caseless search.
1159         If the property is not a String property, a TypeError is raised.
1160         
1161         The return is a list of the id of all nodes that match.
1162         """
1163         for propname in requirements.keys():
1164             prop = self.properties[propname]
1165             if isinstance(not prop, String):
1166                 raise TypeError, "'%s' not a String property"%propname
1167             requirements[propname] = requirements[propname].lower()
1168         l = []
1169         cldb = self.db.getclassdb(self.classname)
1170         try:
1171             for nodeid in self.db.getnodeids(self.classname, cldb):
1172                 node = self.db.getnode(self.classname, nodeid, cldb)
1173                 if node.has_key(self.db.RETIRED_FLAG):
1174                     continue
1175                 for key, value in requirements.items():
1176                     if node[key] and node[key].lower() != value:
1177                         break
1178                 else:
1179                     l.append(nodeid)
1180         finally:
1181             cldb.close()
1182         return l
1184     def list(self):
1185         """Return a list of the ids of the active nodes in this class."""
1186         l = []
1187         cn = self.classname
1188         cldb = self.db.getclassdb(cn)
1189         try:
1190             for nodeid in self.db.getnodeids(cn, cldb):
1191                 node = self.db.getnode(cn, nodeid, cldb)
1192                 if node.has_key(self.db.RETIRED_FLAG):
1193                     continue
1194                 l.append(nodeid)
1195         finally:
1196             cldb.close()
1197         l.sort()
1198         return l
1200     # XXX not in spec
1201     def filter(self, search_matches, filterspec, sort, group, 
1202             num_re = re.compile('^\d+$')):
1203         ''' Return a list of the ids of the active nodes in this class that
1204             match the 'filter' spec, sorted by the group spec and then the
1205             sort spec
1206         '''
1207         cn = self.classname
1209         # optimise filterspec
1210         l = []
1211         props = self.getprops()
1212         for k, v in filterspec.items():
1213             propclass = props[k]
1214             if isinstance(propclass, Link):
1215                 if type(v) is not type([]):
1216                     v = [v]
1217                 # replace key values with node ids
1218                 u = []
1219                 link_class =  self.db.classes[propclass.classname]
1220                 for entry in v:
1221                     if entry == '-1': entry = None
1222                     elif not num_re.match(entry):
1223                         try:
1224                             entry = link_class.lookup(entry)
1225                         except (TypeError,KeyError):
1226                             raise ValueError, 'property "%s": %s not a %s'%(
1227                                 k, entry, self.properties[k].classname)
1228                     u.append(entry)
1230                 l.append((0, k, u))
1231             elif isinstance(propclass, Multilink):
1232                 if type(v) is not type([]):
1233                     v = [v]
1234                 # replace key values with node ids
1235                 u = []
1236                 link_class =  self.db.classes[propclass.classname]
1237                 for entry in v:
1238                     if not num_re.match(entry):
1239                         try:
1240                             entry = link_class.lookup(entry)
1241                         except (TypeError,KeyError):
1242                             raise ValueError, 'new property "%s": %s not a %s'%(
1243                                 k, entry, self.properties[k].classname)
1244                     u.append(entry)
1245                 l.append((1, k, u))
1246             elif isinstance(propclass, String):
1247                 # simple glob searching
1248                 v = re.sub(r'([\|\{\}\\\.\+\[\]\(\)])', r'\\\1', v)
1249                 v = v.replace('?', '.')
1250                 v = v.replace('*', '.*?')
1251                 l.append((2, k, re.compile(v, re.I)))
1252             else:
1253                 l.append((6, k, v))
1254         filterspec = l
1256         # now, find all the nodes that are active and pass filtering
1257         l = []
1258         cldb = self.db.getclassdb(cn)
1259         try:
1260             for nodeid in self.db.getnodeids(cn, cldb):
1261                 node = self.db.getnode(cn, nodeid, cldb)
1262                 if node.has_key(self.db.RETIRED_FLAG):
1263                     continue
1264                 # apply filter
1265                 for t, k, v in filterspec:
1266                     # this node doesn't have this property, so reject it
1267                     if not node.has_key(k): break
1269                     if t == 0 and node[k] not in v:
1270                         # link - if this node'd property doesn't appear in the
1271                         # filterspec's nodeid list, skip it
1272                         break
1273                     elif t == 1:
1274                         # multilink - if any of the nodeids required by the
1275                         # filterspec aren't in this node's property, then skip
1276                         # it
1277                         for value in v:
1278                             if value not in node[k]:
1279                                 break
1280                         else:
1281                             continue
1282                         break
1283                     elif t == 2 and (node[k] is None or not v.search(node[k])):
1284                         # RE search
1285                         break
1286                     elif t == 6 and node[k] != v:
1287                         # straight value comparison for the other types
1288                         break
1289                 else:
1290                     l.append((nodeid, node))
1291         finally:
1292             cldb.close()
1293         l.sort()
1295         # filter based on full text search
1296         if search_matches is not None:
1297             k = []
1298             l_debug = []
1299             for v in l:
1300                 l_debug.append(v[0])
1301                 if search_matches.has_key(v[0]):
1302                     k.append(v)
1303             l = k
1305         # optimise sort
1306         m = []
1307         for entry in sort:
1308             if entry[0] != '-':
1309                 m.append(('+', entry))
1310             else:
1311                 m.append((entry[0], entry[1:]))
1312         sort = m
1314         # optimise group
1315         m = []
1316         for entry in group:
1317             if entry[0] != '-':
1318                 m.append(('+', entry))
1319             else:
1320                 m.append((entry[0], entry[1:]))
1321         group = m
1322         # now, sort the result
1323         def sortfun(a, b, sort=sort, group=group, properties=self.getprops(),
1324                 db = self.db, cl=self):
1325             a_id, an = a
1326             b_id, bn = b
1327             # sort by group and then sort
1328             for list in group, sort:
1329                 for dir, prop in list:
1330                     # sorting is class-specific
1331                     propclass = properties[prop]
1333                     # handle the properties that might be "faked"
1334                     # also, handle possible missing properties
1335                     try:
1336                         if not an.has_key(prop):
1337                             an[prop] = cl.get(a_id, prop)
1338                         av = an[prop]
1339                     except KeyError:
1340                         # the node doesn't have a value for this property
1341                         if isinstance(propclass, Multilink): av = []
1342                         else: av = ''
1343                     try:
1344                         if not bn.has_key(prop):
1345                             bn[prop] = cl.get(b_id, prop)
1346                         bv = bn[prop]
1347                     except KeyError:
1348                         # the node doesn't have a value for this property
1349                         if isinstance(propclass, Multilink): bv = []
1350                         else: bv = ''
1352                     # String and Date values are sorted in the natural way
1353                     if isinstance(propclass, String):
1354                         # clean up the strings
1355                         if av and av[0] in string.uppercase:
1356                             av = an[prop] = av.lower()
1357                         if bv and bv[0] in string.uppercase:
1358                             bv = bn[prop] = bv.lower()
1359                     if (isinstance(propclass, String) or
1360                             isinstance(propclass, Date)):
1361                         # it might be a string that's really an integer
1362                         try:
1363                             av = int(av)
1364                             bv = int(bv)
1365                         except:
1366                             pass
1367                         if dir == '+':
1368                             r = cmp(av, bv)
1369                             if r != 0: return r
1370                         elif dir == '-':
1371                             r = cmp(bv, av)
1372                             if r != 0: return r
1374                     # Link properties are sorted according to the value of
1375                     # the "order" property on the linked nodes if it is
1376                     # present; or otherwise on the key string of the linked
1377                     # nodes; or finally on  the node ids.
1378                     elif isinstance(propclass, Link):
1379                         link = db.classes[propclass.classname]
1380                         if av is None and bv is not None: return -1
1381                         if av is not None and bv is None: return 1
1382                         if av is None and bv is None: continue
1383                         if link.getprops().has_key('order'):
1384                             if dir == '+':
1385                                 r = cmp(link.get(av, 'order'),
1386                                     link.get(bv, 'order'))
1387                                 if r != 0: return r
1388                             elif dir == '-':
1389                                 r = cmp(link.get(bv, 'order'),
1390                                     link.get(av, 'order'))
1391                                 if r != 0: return r
1392                         elif link.getkey():
1393                             key = link.getkey()
1394                             if dir == '+':
1395                                 r = cmp(link.get(av, key), link.get(bv, key))
1396                                 if r != 0: return r
1397                             elif dir == '-':
1398                                 r = cmp(link.get(bv, key), link.get(av, key))
1399                                 if r != 0: return r
1400                         else:
1401                             if dir == '+':
1402                                 r = cmp(av, bv)
1403                                 if r != 0: return r
1404                             elif dir == '-':
1405                                 r = cmp(bv, av)
1406                                 if r != 0: return r
1408                     # Multilink properties are sorted according to how many
1409                     # links are present.
1410                     elif isinstance(propclass, Multilink):
1411                         if dir == '+':
1412                             r = cmp(len(av), len(bv))
1413                             if r != 0: return r
1414                         elif dir == '-':
1415                             r = cmp(len(bv), len(av))
1416                             if r != 0: return r
1417                 # end for dir, prop in list:
1418             # end for list in sort, group:
1419             # if all else fails, compare the ids
1420             return cmp(a[0], b[0])
1422         l.sort(sortfun)
1423         return [i[0] for i in l]
1425     def count(self):
1426         """Get the number of nodes in this class.
1428         If the returned integer is 'numnodes', the ids of all the nodes
1429         in this class run from 1 to numnodes, and numnodes+1 will be the
1430         id of the next node to be created in this class.
1431         """
1432         return self.db.countnodes(self.classname)
1434     # Manipulating properties:
1436     def getprops(self, protected=1):
1437         """Return a dictionary mapping property names to property objects.
1438            If the "protected" flag is true, we include protected properties -
1439            those which may not be modified.
1441            In addition to the actual properties on the node, these
1442            methods provide the "creation" and "activity" properties. If the
1443            "protected" flag is true, we include protected properties - those
1444            which may not be modified.
1445         """
1446         d = self.properties.copy()
1447         if protected:
1448             d['id'] = String()
1449             d['creation'] = hyperdb.Date()
1450             d['activity'] = hyperdb.Date()
1451             d['creator'] = hyperdb.Link("user")
1452         return d
1454     def addprop(self, **properties):
1455         """Add properties to this class.
1457         The keyword arguments in 'properties' must map names to property
1458         objects, or a TypeError is raised.  None of the keys in 'properties'
1459         may collide with the names of existing properties, or a ValueError
1460         is raised before any properties have been added.
1461         """
1462         for key in properties.keys():
1463             if self.properties.has_key(key):
1464                 raise ValueError, key
1465         self.properties.update(properties)
1467     def index(self, nodeid):
1468         '''Add (or refresh) the node to search indexes
1469         '''
1470         # find all the String properties that have indexme
1471         for prop, propclass in self.getprops().items():
1472             if isinstance(propclass, String) and propclass.indexme:
1473                 # and index them under (classname, nodeid, property)
1474                 self.db.indexer.add_text((self.classname, nodeid, prop),
1475                     str(self.get(nodeid, prop)))
1477     #
1478     # Detector interface
1479     #
1480     def audit(self, event, detector):
1481         """Register a detector
1482         """
1483         l = self.auditors[event]
1484         if detector not in l:
1485             self.auditors[event].append(detector)
1487     def fireAuditors(self, action, nodeid, newvalues):
1488         """Fire all registered auditors.
1489         """
1490         for audit in self.auditors[action]:
1491             audit(self.db, self, nodeid, newvalues)
1493     def react(self, event, detector):
1494         """Register a detector
1495         """
1496         l = self.reactors[event]
1497         if detector not in l:
1498             self.reactors[event].append(detector)
1500     def fireReactors(self, action, nodeid, oldvalues):
1501         """Fire all registered reactors.
1502         """
1503         for react in self.reactors[action]:
1504             react(self.db, self, nodeid, oldvalues)
1506 class FileClass(Class):
1507     '''This class defines a large chunk of data. To support this, it has a
1508        mandatory String property "content" which is typically saved off
1509        externally to the hyperdb.
1511        The default MIME type of this data is defined by the
1512        "default_mime_type" class attribute, which may be overridden by each
1513        node if the class defines a "type" String property.
1514     '''
1515     default_mime_type = 'text/plain'
1517     def create(self, **propvalues):
1518         ''' snaffle the file propvalue and store in a file
1519         '''
1520         content = propvalues['content']
1521         del propvalues['content']
1522         newid = Class.create(self, **propvalues)
1523         self.db.storefile(self.classname, newid, None, content)
1524         return newid
1526     def get(self, nodeid, propname, default=_marker, cache=1):
1527         ''' trap the content propname and get it from the file
1528         '''
1530         poss_msg = 'Possibly a access right configuration problem.'
1531         if propname == 'content':
1532             try:
1533                 return self.db.getfile(self.classname, nodeid, None)
1534             except IOError, (strerror):
1535                 # BUG: by catching this we donot see an error in the log.
1536                 return 'ERROR reading file: %s%s\n%s\n%s'%(
1537                         self.classname, nodeid, poss_msg, strerror)
1538         if default is not _marker:
1539             return Class.get(self, nodeid, propname, default, cache=cache)
1540         else:
1541             return Class.get(self, nodeid, propname, cache=cache)
1543     def getprops(self, protected=1):
1544         ''' In addition to the actual properties on the node, these methods
1545             provide the "content" property. If the "protected" flag is true,
1546             we include protected properties - those which may not be
1547             modified.
1548         '''
1549         d = Class.getprops(self, protected=protected).copy()
1550         if protected:
1551             d['content'] = hyperdb.String()
1552         return d
1554     def index(self, nodeid):
1555         ''' Index the node in the search index.
1557             We want to index the content in addition to the normal String
1558             property indexing.
1559         '''
1560         # perform normal indexing
1561         Class.index(self, nodeid)
1563         # get the content to index
1564         content = self.get(nodeid, 'content')
1566         # figure the mime type
1567         if self.properties.has_key('type'):
1568             mime_type = self.get(nodeid, 'type')
1569         else:
1570             mime_type = self.default_mime_type
1572         # and index!
1573         self.db.indexer.add_text((self.classname, nodeid, 'content'), content,
1574             mime_type)
1576 # XXX deviation from spec - was called ItemClass
1577 class IssueClass(Class, roundupdb.IssueClass):
1578     # Overridden methods:
1579     def __init__(self, db, classname, **properties):
1580         """The newly-created class automatically includes the "messages",
1581         "files", "nosy", and "superseder" properties.  If the 'properties'
1582         dictionary attempts to specify any of these properties or a
1583         "creation" or "activity" property, a ValueError is raised.
1584         """
1585         if not properties.has_key('title'):
1586             properties['title'] = hyperdb.String(indexme='yes')
1587         if not properties.has_key('messages'):
1588             properties['messages'] = hyperdb.Multilink("msg")
1589         if not properties.has_key('files'):
1590             properties['files'] = hyperdb.Multilink("file")
1591         if not properties.has_key('nosy'):
1592             properties['nosy'] = hyperdb.Multilink("user")
1593         if not properties.has_key('superseder'):
1594             properties['superseder'] = hyperdb.Multilink(classname)
1595         Class.__init__(self, db, classname, **properties)
1598 #$Log: not supported by cvs2svn $
1599 #Revision 1.43  2002/07/10 06:30:30  richard
1600 #...except of course it's nice to use valid Python syntax
1602 #Revision 1.42  2002/07/10 06:21:38  richard
1603 #Be extra safe
1605 #Revision 1.41  2002/07/10 00:21:45  richard
1606 #explicit database closing
1608 #Revision 1.40  2002/07/09 04:19:09  richard
1609 #Added reindex command to roundup-admin.
1610 #Fixed reindex on first access.
1611 #Also fixed reindexing of entries that change.
1613 #Revision 1.39  2002/07/09 03:02:52  richard
1614 #More indexer work:
1615 #- all String properties may now be indexed too. Currently there's a bit of
1616 #  "issue" specific code in the actual searching which needs to be
1617 #  addressed. In a nutshell:
1618 #  + pass 'indexme="yes"' as a String() property initialisation arg, eg:
1619 #        file = FileClass(db, "file", name=String(), type=String(),
1620 #            comment=String(indexme="yes"))
1621 #  + the comment will then be indexed and be searchable, with the results
1622 #    related back to the issue that the file is linked to
1623 #- as a result of this work, the FileClass has a default MIME type that may
1624 #  be overridden in a subclass, or by the use of a "type" property as is
1625 #  done in the default templates.
1626 #- the regeneration of the indexes (if necessary) is done once the schema is
1627 #  set up in the dbinit.
1629 #Revision 1.38  2002/07/08 06:58:15  richard
1630 #cleaned up the indexer code:
1631 # - it splits more words out (much simpler, faster splitter)
1632 # - removed code we'll never use (roundup.roundup_indexer has the full
1633 #   implementation, and replaces roundup.indexer)
1634 # - only index text/plain and rfc822/message (ideas for other text formats to
1635 #   index are welcome)
1636 # - added simple unit test for indexer. Needs more tests for regression.
1638 #Revision 1.37  2002/06/20 23:52:35  richard
1639 #More informative error message
1641 #Revision 1.36  2002/06/19 03:07:19  richard
1642 #Moved the file storage commit into blobfiles where it belongs.
1644 #Revision 1.35  2002/05/25 07:16:24  rochecompaan
1645 #Merged search_indexing-branch with HEAD
1647 #Revision 1.34  2002/05/15 06:21:21  richard
1648 # . node caching now works, and gives a small boost in performance
1650 #As a part of this, I cleaned up the DEBUG output and implemented TRACE
1651 #output (HYPERDBTRACE='file to trace to') with checkpoints at the start of
1652 #CGI requests. Run roundup with python -O to skip all the DEBUG/TRACE stuff
1653 #(using if __debug__ which is compiled out with -O)
1655 #Revision 1.33  2002/04/24 10:38:26  rochecompaan
1656 #All database files are now created group readable and writable.
1658 #Revision 1.32  2002/04/15 23:25:15  richard
1659 #. node ids are now generated from a lockable store - no more race conditions
1661 #We're using the portalocker code by Jonathan Feinberg that was contributed
1662 #to the ASPN Python cookbook. This gives us locking across Unix and Windows.
1664 #Revision 1.31  2002/04/03 05:54:31  richard
1665 #Fixed serialisation problem by moving the serialisation step out of the
1666 #hyperdb.Class (get, set) into the hyperdb.Database.
1668 #Also fixed htmltemplate after the showid changes I made yesterday.
1670 #Unit tests for all of the above written.
1672 #Revision 1.30.2.1  2002/04/03 11:55:57  rochecompaan
1673 # . Added feature #526730 - search for messages capability
1675 #Revision 1.30  2002/02/27 03:40:59  richard
1676 #Ran it through pychecker, made fixes
1678 #Revision 1.29  2002/02/25 14:34:31  grubert
1679 # . use blobfiles in back_anydbm which is used in back_bsddb.
1680 #   change test_db as dirlist does not work for subdirectories.
1681 #   ATTENTION: blobfiles now creates subdirectories for files.
1683 #Revision 1.28  2002/02/16 09:14:17  richard
1684 # . #514854 ] History: "User" is always ticket creator
1686 #Revision 1.27  2002/01/22 07:21:13  richard
1687 #. fixed back_bsddb so it passed the journal tests
1689 #... it didn't seem happy using the back_anydbm _open method, which is odd.
1690 #Yet another occurrance of whichdb not being able to recognise older bsddb
1691 #databases. Yadda yadda. Made the HYPERDBDEBUG stuff more sane in the
1692 #process.
1694 #Revision 1.26  2002/01/22 05:18:38  rochecompaan
1695 #last_set_entry was referenced before assignment
1697 #Revision 1.25  2002/01/22 05:06:08  rochecompaan
1698 #We need to keep the last 'set' entry in the journal to preserve
1699 #information on 'activity' for nodes.
1701 #Revision 1.24  2002/01/21 16:33:20  rochecompaan
1702 #You can now use the roundup-admin tool to pack the database
1704 #Revision 1.23  2002/01/18 04:32:04  richard
1705 #Rollback was breaking because a message hadn't actually been written to the file. Needs
1706 #more investigation.
1708 #Revision 1.22  2002/01/14 02:20:15  richard
1709 # . changed all config accesses so they access either the instance or the
1710 #   config attriubute on the db. This means that all config is obtained from
1711 #   instance_config instead of the mish-mash of classes. This will make
1712 #   switching to a ConfigParser setup easier too, I hope.
1714 #At a minimum, this makes migration a _little_ easier (a lot easier in the
1715 #0.5.0 switch, I hope!)
1717 #Revision 1.21  2002/01/02 02:31:38  richard
1718 #Sorry for the huge checkin message - I was only intending to implement #496356
1719 #but I found a number of places where things had been broken by transactions:
1720 # . modified ROUNDUPDBSENDMAILDEBUG to be SENDMAILDEBUG and hold a filename
1721 #   for _all_ roundup-generated smtp messages to be sent to.
1722 # . the transaction cache had broken the roundupdb.Class set() reactors
1723 # . newly-created author users in the mailgw weren't being committed to the db
1725 #Stuff that made it into CHANGES.txt (ie. the stuff I was actually working
1726 #on when I found that stuff :):
1727 # . #496356 ] Use threading in messages
1728 # . detectors were being registered multiple times
1729 # . added tests for mailgw
1730 # . much better attaching of erroneous messages in the mail gateway
1732 #Revision 1.20  2001/12/18 15:30:34  rochecompaan
1733 #Fixed bugs:
1734 # .  Fixed file creation and retrieval in same transaction in anydbm
1735 #    backend
1736 # .  Cgi interface now renders new issue after issue creation
1737 # .  Could not set issue status to resolved through cgi interface
1738 # .  Mail gateway was changing status back to 'chatting' if status was
1739 #    omitted as an argument
1741 #Revision 1.19  2001/12/17 03:52:48  richard
1742 #Implemented file store rollback. As a bonus, the hyperdb is now capable of
1743 #storing more than one file per node - if a property name is supplied,
1744 #the file is called designator.property.
1745 #I decided not to migrate the existing files stored over to the new naming
1746 #scheme - the FileClass just doesn't specify the property name.
1748 #Revision 1.18  2001/12/16 10:53:38  richard
1749 #take a copy of the node dict so that the subsequent set
1750 #operation doesn't modify the oldvalues structure
1752 #Revision 1.17  2001/12/14 23:42:57  richard
1753 #yuck, a gdbm instance tests false :(
1754 #I've left the debugging code in - it should be removed one day if we're ever
1755 #_really_ anal about performace :)
1757 #Revision 1.16  2001/12/12 03:23:14  richard
1758 #Cor blimey this anydbm/whichdb stuff is yecchy. Turns out that whichdb
1759 #incorrectly identifies a dbm file as a dbhash file on my system. This has
1760 #been submitted to the python bug tracker as issue #491888:
1761 #https://sourceforge.net/tracker/index.php?func=detail&aid=491888&group_id=5470&atid=105470
1763 #Revision 1.15  2001/12/12 02:30:51  richard
1764 #I fixed the problems with people whose anydbm was using the dbm module at the
1765 #backend. It turns out the dbm module modifies the file name to append ".db"
1766 #and my check to determine if we're opening an existing or new db just
1767 #tested os.path.exists() on the filename. Well, no longer! We now perform a
1768 #much better check _and_ cope with the anydbm implementation module changing
1769 #too!
1770 #I also fixed the backends __init__ so only ImportError is squashed.
1772 #Revision 1.14  2001/12/10 22:20:01  richard
1773 #Enabled transaction support in the bsddb backend. It uses the anydbm code
1774 #where possible, only replacing methods where the db is opened (it uses the
1775 #btree opener specifically.)
1776 #Also cleaned up some change note generation.
1777 #Made the backends package work with pydoc too.
1779 #Revision 1.13  2001/12/02 05:06:16  richard
1780 #. We now use weakrefs in the Classes to keep the database reference, so
1781 #  the close() method on the database is no longer needed.
1782 #  I bumped the minimum python requirement up to 2.1 accordingly.
1783 #. #487480 ] roundup-server
1784 #. #487476 ] INSTALL.txt
1786 #I also cleaned up the change message / post-edit stuff in the cgi client.
1787 #There's now a clearly marked "TODO: append the change note" where I believe
1788 #the change note should be added there. The "changes" list will obviously
1789 #have to be modified to be a dict of the changes, or somesuch.
1791 #More testing needed.
1793 #Revision 1.12  2001/12/01 07:17:50  richard
1794 #. We now have basic transaction support! Information is only written to
1795 #  the database when the commit() method is called. Only the anydbm
1796 #  backend is modified in this way - neither of the bsddb backends have been.
1797 #  The mail, admin and cgi interfaces all use commit (except the admin tool
1798 #  doesn't have a commit command, so interactive users can't commit...)
1799 #. Fixed login/registration forwarding the user to the right page (or not,
1800 #  on a failure)
1802 #Revision 1.11  2001/11/21 02:34:18  richard
1803 #Added a target version field to the extended issue schema
1805 #Revision 1.10  2001/10/09 23:58:10  richard
1806 #Moved the data stringification up into the hyperdb.Class class' get, set
1807 #and create methods. This means that the data is also stringified for the
1808 #journal call, and removes duplication of code from the backends. The
1809 #backend code now only sees strings.
1811 #Revision 1.9  2001/10/09 07:25:59  richard
1812 #Added the Password property type. See "pydoc roundup.password" for
1813 #implementation details. Have updated some of the documentation too.
1815 #Revision 1.8  2001/09/29 13:27:00  richard
1816 #CGI interfaces now spit up a top-level index of all the instances they can
1817 #serve.
1819 #Revision 1.7  2001/08/12 06:32:36  richard
1820 #using isinstance(blah, Foo) now instead of isFooType
1822 #Revision 1.6  2001/08/07 00:24:42  richard
1823 #stupid typo
1825 #Revision 1.5  2001/08/07 00:15:51  richard
1826 #Added the copyright/license notice to (nearly) all files at request of
1827 #Bizar Software.
1829 #Revision 1.4  2001/07/30 01:41:36  richard
1830 #Makes schema changes mucho easier.
1832 #Revision 1.3  2001/07/25 01:23:07  richard
1833 #Added the Roundup spec to the new documentation directory.
1835 #Revision 1.2  2001/07/23 08:20:44  richard
1836 #Moved over to using marshal in the bsddb and anydbm backends.
1837 #roundup-admin now has a "freshen" command that'll load/save all nodes (not
1838 # retired - mod hyperdb.Class.list() so it lists retired nodes)