Code

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