Code

Journal entries for link and multilink properties can now be switched on
[roundup.git] / roundup / hyperdb.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: hyperdb.py,v 1.50 2002-01-19 13:16:04 rochecompaan Exp $
20 __doc__ = """
21 Hyperdatabase implementation, especially field types.
22 """
24 # standard python modules
25 import cPickle, re, string, weakref
27 # roundup modules
28 import date, password
31 #
32 # Types
33 #
34 class String:
35     """An object designating a String property."""
36     def __repr__(self):
37         return '<%s>'%self.__class__
39 class Password:
40     """An object designating a Password property."""
41     def __repr__(self):
42         return '<%s>'%self.__class__
44 class Date:
45     """An object designating a Date property."""
46     def __repr__(self):
47         return '<%s>'%self.__class__
49 class Interval:
50     """An object designating an Interval property."""
51     def __repr__(self):
52         return '<%s>'%self.__class__
54 class Link:
55     """An object designating a Link property that links to a
56        node in a specified class."""
57     def __init__(self, classname, do_journal='no'):
58         self.classname = classname
59         self.do_journal = do_journal == 'yes'
60     def __repr__(self):
61         return '<%s to "%s">'%(self.__class__, self.classname)
63 class Multilink:
64     """An object designating a Multilink property that links
65        to nodes in a specified class.
66     """
67     def __init__(self, classname, do_journal='no'):
68         self.classname = classname
69         self.do_journal = do_journal == 'yes'
70     def __repr__(self):
71         return '<%s to "%s">'%(self.__class__, self.classname)
73 class DatabaseError(ValueError):
74     pass
77 #
78 # the base Database class
79 #
80 class Database:
81     '''A database for storing records containing flexible data types.
83 This class defines a hyperdatabase storage layer, which the Classes use to
84 store their data.
87 Transactions
88 ------------
89 The Database should support transactions through the commit() and
90 rollback() methods. All other Database methods should be transaction-aware,
91 using data from the current transaction before looking up the database.
93 An implementation must provide an override for the get() method so that the
94 in-database value is returned in preference to the in-transaction value.
95 This is necessary to determine if any values have changed during a
96 transaction.
98 '''
100     # flag to set on retired entries
101     RETIRED_FLAG = '__hyperdb_retired'
103     # XXX deviates from spec: storagelocator is obtained from the config
104     def __init__(self, config, journaltag=None):
105         """Open a hyperdatabase given a specifier to some storage.
107         The 'storagelocator' is obtained from config.DATABASE.
108         The meaning of 'storagelocator' depends on the particular
109         implementation of the hyperdatabase.  It could be a file name,
110         a directory path, a socket descriptor for a connection to a
111         database over the network, etc.
113         The 'journaltag' is a token that will be attached to the journal
114         entries for any edits done on the database.  If 'journaltag' is
115         None, the database is opened in read-only mode: the Class.create(),
116         Class.set(), and Class.retire() methods are disabled.
117         """
118         raise NotImplementedError
120     def __getattr__(self, classname):
121         """A convenient way of calling self.getclass(classname)."""
122         raise NotImplementedError
124     def addclass(self, cl):
125         '''Add a Class to the hyperdatabase.
126         '''
127         raise NotImplementedError
129     def getclasses(self):
130         """Return a list of the names of all existing classes."""
131         raise NotImplementedError
133     def getclass(self, classname):
134         """Get the Class object representing a particular class.
136         If 'classname' is not a valid class name, a KeyError is raised.
137         """
138         raise NotImplementedError
140     def clear(self):
141         '''Delete all database contents.
142         '''
143         raise NotImplementedError
145     def getclassdb(self, classname, mode='r'):
146         '''Obtain a connection to the class db that will be used for
147            multiple actions.
148         '''
149         raise NotImplementedError
151     def addnode(self, classname, nodeid, node):
152         '''Add the specified node to its class's db.
153         '''
154         raise NotImplementedError
156     def setnode(self, classname, nodeid, node):
157         '''Change the specified node.
158         '''
159         raise NotImplementedError
161     def getnode(self, classname, nodeid, db=None, cache=1):
162         '''Get a node from the database.
163         '''
164         raise NotImplementedError
166     def hasnode(self, classname, nodeid, db=None):
167         '''Determine if the database has a given node.
168         '''
169         raise NotImplementedError
171     def countnodes(self, classname, db=None):
172         '''Count the number of nodes that exist for a particular Class.
173         '''
174         raise NotImplementedError
176     def getnodeids(self, classname, db=None):
177         '''Retrieve all the ids of the nodes for a particular Class.
178         '''
179         raise NotImplementedError
181     def storefile(self, classname, nodeid, property, content):
182         '''Store the content of the file in the database.
183         
184            The property may be None, in which case the filename does not
185            indicate which property is being saved.
186         '''
187         raise NotImplementedError
189     def getfile(self, classname, nodeid, property):
190         '''Store the content of the file in the database.
191         '''
192         raise NotImplementedError
194     def addjournal(self, classname, nodeid, action, params):
195         ''' Journal the Action
196         'action' may be:
198             'create' or 'set' -- 'params' is a dictionary of property values
199             'link' or 'unlink' -- 'params' is (classname, nodeid, propname)
200             'retire' -- 'params' is None
201         '''
202         raise NotImplementedError
204     def getjournal(self, classname, nodeid):
205         ''' get the journal for id
206         '''
207         raise NotImplementedError
209     def commit(self):
210         ''' Commit the current transactions.
212         Save all data changed since the database was opened or since the
213         last commit() or rollback().
214         '''
215         raise NotImplementedError
217     def rollback(self):
218         ''' Reverse all actions from the current transaction.
220         Undo all the changes made since the database was opened or the last
221         commit() or rollback() was performed.
222         '''
223         raise NotImplementedError
225 _marker = []
227 # The base Class class
229 class Class:
230     """The handle to a particular class of nodes in a hyperdatabase."""
232     def __init__(self, db, classname, **properties):
233         """Create a new class with a given name and property specification.
235         'classname' must not collide with the name of an existing class,
236         or a ValueError is raised.  The keyword arguments in 'properties'
237         must map names to property objects, or a TypeError is raised.
238         """
239         self.classname = classname
240         self.properties = properties
241         self.db = weakref.proxy(db)       # use a weak ref to avoid circularity
242         self.key = ''
244         # do the db-related init stuff
245         db.addclass(self)
247     def __repr__(self):
248         return '<hypderdb.Class "%s">'%self.classname
250     # Editing nodes:
252     def create(self, **propvalues):
253         """Create a new node of this class and return its id.
255         The keyword arguments in 'propvalues' map property names to values.
257         The values of arguments must be acceptable for the types of their
258         corresponding properties or a TypeError is raised.
259         
260         If this class has a key property, it must be present and its value
261         must not collide with other key strings or a ValueError is raised.
262         
263         Any other properties on this class that are missing from the
264         'propvalues' dictionary are set to None.
265         
266         If an id in a link or multilink property does not refer to a valid
267         node, an IndexError is raised.
268         """
269         if propvalues.has_key('id'):
270             raise KeyError, '"id" is reserved'
272         if self.db.journaltag is None:
273             raise DatabaseError, 'Database open read-only'
275         # new node's id
276         newid = str(self.count() + 1)
278         # validate propvalues
279         num_re = re.compile('^\d+$')
280         for key, value in propvalues.items():
281             if key == self.key:
282                 try:
283                     self.lookup(value)
284                 except KeyError:
285                     pass
286                 else:
287                     raise ValueError, 'node with key "%s" exists'%value
289             # try to handle this property
290             try:
291                 prop = self.properties[key]
292             except KeyError:
293                 raise KeyError, '"%s" has no property "%s"'%(self.classname,
294                     key)
296             if isinstance(prop, Link):
297                 if type(value) != type(''):
298                     raise ValueError, 'link value must be String'
299                 link_class = self.properties[key].classname
300                 # if it isn't a number, it's a key
301                 if not num_re.match(value):
302                     try:
303                         value = self.db.classes[link_class].lookup(value)
304                     except (TypeError, KeyError):
305                         raise IndexError, 'new property "%s": %s not a %s'%(
306                             key, value, link_class)
307                 elif not self.db.hasnode(link_class, value):
308                     raise IndexError, '%s has no node %s'%(link_class, value)
310                 # save off the value
311                 propvalues[key] = value
313                 # register the link with the newly linked node
314                 if self.properties[key].do_journal:
315                     self.db.addjournal(link_class, value, 'link',
316                         (self.classname, newid, key))
318             elif isinstance(prop, Multilink):
319                 if type(value) != type([]):
320                     raise TypeError, 'new property "%s" not a list of ids'%key
321                 link_class = self.properties[key].classname
322                 l = []
323                 for entry in value:
324                     if type(entry) != type(''):
325                         raise ValueError, 'link value must be String'
326                     # if it isn't a number, it's a key
327                     if not num_re.match(entry):
328                         try:
329                             entry = self.db.classes[link_class].lookup(entry)
330                         except (TypeError, KeyError):
331                             raise IndexError, 'new property "%s": %s not a %s'%(
332                                 key, entry, self.properties[key].classname)
333                     l.append(entry)
334                 value = l
335                 propvalues[key] = value
337                 # handle additions
338                 for id in value:
339                     if not self.db.hasnode(link_class, id):
340                         raise IndexError, '%s has no node %s'%(link_class, id)
341                     # register the link with the newly linked node
342                     if self.properties[key].do_journal:
343                         self.db.addjournal(link_class, id, 'link',
344                             (self.classname, newid, key))
346             elif isinstance(prop, String):
347                 if type(value) != type(''):
348                     raise TypeError, 'new property "%s" not a string'%key
350             elif isinstance(prop, Password):
351                 if not isinstance(value, password.Password):
352                     raise TypeError, 'new property "%s" not a Password'%key
354             elif isinstance(prop, Date):
355                 if value is not None and not isinstance(value, date.Date):
356                     raise TypeError, 'new property "%s" not a Date'%key
358             elif isinstance(prop, Interval):
359                 if value is not None and not isinstance(value, date.Interval):
360                     raise TypeError, 'new property "%s" not an Interval'%key
362         # make sure there's data where there needs to be
363         for key, prop in self.properties.items():
364             if propvalues.has_key(key):
365                 continue
366             if key == self.key:
367                 raise ValueError, 'key property "%s" is required'%key
368             if isinstance(prop, Multilink):
369                 propvalues[key] = []
370             else:
371                 # TODO: None isn't right here, I think...
372                 propvalues[key] = None
374         # convert all data to strings
375         for key, prop in self.properties.items():
376             if isinstance(prop, Date):
377                 if propvalues[key] is not None:
378                     propvalues[key] = propvalues[key].get_tuple()
379             elif isinstance(prop, Interval):
380                 if propvalues[key] is not None:
381                     propvalues[key] = propvalues[key].get_tuple()
382             elif isinstance(prop, Password):
383                 propvalues[key] = str(propvalues[key])
385         # done
386         self.db.addnode(self.classname, newid, propvalues)
387         self.db.addjournal(self.classname, newid, 'create', propvalues)
388         return newid
390     def get(self, nodeid, propname, default=_marker, cache=1):
391         """Get the value of a property on an existing node of this class.
393         'nodeid' must be the id of an existing node of this class or an
394         IndexError is raised.  'propname' must be the name of a property
395         of this class or a KeyError is raised.
397         'cache' indicates whether the transaction cache should be queried
398         for the node. If the node has been modified and you need to
399         determine what its values prior to modification are, you need to
400         set cache=0.
401         """
402         if propname == 'id':
403             return nodeid
405         # get the property (raises KeyErorr if invalid)
406         prop = self.properties[propname]
408         # get the node's dict
409         d = self.db.getnode(self.classname, nodeid, cache=cache)
411         if not d.has_key(propname):
412             if default is _marker:
413                 if isinstance(prop, Multilink):
414                     return []
415                 else:
416                     # TODO: None isn't right here, I think...
417                     return None
418             else:
419                 return default
421         # possibly convert the marshalled data to instances
422         if isinstance(prop, Date):
423             if d[propname] is None:
424                 return None
425             return date.Date(d[propname])
426         elif isinstance(prop, Interval):
427             if d[propname] is None:
428                 return None
429             return date.Interval(d[propname])
430         elif isinstance(prop, Password):
431             p = password.Password()
432             p.unpack(d[propname])
433             return p
435         return d[propname]
437     # XXX not in spec
438     def getnode(self, nodeid, cache=1):
439         ''' Return a convenience wrapper for the node.
441         'nodeid' must be the id of an existing node of this class or an
442         IndexError is raised.
444         'cache' indicates whether the transaction cache should be queried
445         for the node. If the node has been modified and you need to
446         determine what its values prior to modification are, you need to
447         set cache=0.
448         '''
449         return Node(self, nodeid, cache=cache)
451     def set(self, nodeid, **propvalues):
452         """Modify a property on an existing node of this class.
453         
454         'nodeid' must be the id of an existing node of this class or an
455         IndexError is raised.
457         Each key in 'propvalues' must be the name of a property of this
458         class or a KeyError is raised.
460         All values in 'propvalues' must be acceptable types for their
461         corresponding properties or a TypeError is raised.
463         If the value of the key property is set, it must not collide with
464         other key strings or a ValueError is raised.
466         If the value of a Link or Multilink property contains an invalid
467         node id, a ValueError is raised.
468         """
469         if not propvalues:
470             return
472         if propvalues.has_key('id'):
473             raise KeyError, '"id" is reserved'
475         if self.db.journaltag is None:
476             raise DatabaseError, 'Database open read-only'
478         node = self.db.getnode(self.classname, nodeid)
479         if node.has_key(self.db.RETIRED_FLAG):
480             raise IndexError
481         num_re = re.compile('^\d+$')
482         for key, value in propvalues.items():
483             # check to make sure we're not duplicating an existing key
484             if key == self.key and node[key] != value:
485                 try:
486                     self.lookup(value)
487                 except KeyError:
488                     pass
489                 else:
490                     raise ValueError, 'node with key "%s" exists'%value
492             # this will raise the KeyError if the property isn't valid
493             # ... we don't use getprops() here because we only care about
494             # the writeable properties.
495             prop = self.properties[key]
497             if isinstance(prop, Link):
498                 link_class = self.properties[key].classname
499                 # if it isn't a number, it's a key
500                 if type(value) != type(''):
501                     raise ValueError, 'link value must be String'
502                 if not num_re.match(value):
503                     try:
504                         value = self.db.classes[link_class].lookup(value)
505                     except (TypeError, KeyError):
506                         raise IndexError, 'new property "%s": %s not a %s'%(
507                             key, value, self.properties[key].classname)
509                 if not self.db.hasnode(link_class, value):
510                     raise IndexError, '%s has no node %s'%(link_class, value)
512                 if self.properties[key].do_journal:
513                     # register the unlink with the old linked node
514                     if node[key] is not None:
515                         self.db.addjournal(link_class, node[key], 'unlink',
516                             (self.classname, nodeid, key))
518                     # register the link with the newly linked node
519                     if value is not None:
520                         self.db.addjournal(link_class, value, 'link',
521                             (self.classname, nodeid, key))
523             elif isinstance(prop, Multilink):
524                 if type(value) != type([]):
525                     raise TypeError, 'new property "%s" not a list of ids'%key
526                 link_class = self.properties[key].classname
527                 l = []
528                 for entry in value:
529                     # if it isn't a number, it's a key
530                     if type(entry) != type(''):
531                         raise ValueError, 'link value must be String'
532                     if not num_re.match(entry):
533                         try:
534                             entry = self.db.classes[link_class].lookup(entry)
535                         except (TypeError, KeyError):
536                             raise IndexError, 'new property "%s": %s not a %s'%(
537                                 key, entry, self.properties[key].classname)
538                     l.append(entry)
539                 value = l
540                 propvalues[key] = value
542                 # handle removals
543                 if node.has_key(key):
544                     l = node[key]
545                 else:
546                     l = []
547                 for id in l[:]:
548                     if id in value:
549                         continue
550                     # register the unlink with the old linked node
551                     if self.properties[key].do_journal:
552                         self.db.addjournal(link_class, id, 'unlink',
553                             (self.classname, nodeid, key))
554                     l.remove(id)
556                 # handle additions
557                 for id in value:
558                     if not self.db.hasnode(link_class, id):
559                         raise IndexError, '%s has no node %s'%(
560                             link_class, id)
561                     if id in l:
562                         continue
563                     # register the link with the newly linked node
564                     if self.properties[key].do_journal:
565                         self.db.addjournal(link_class, id, 'link',
566                             (self.classname, nodeid, key))
567                     l.append(id)
569             elif isinstance(prop, String):
570                 if value is not None and type(value) != type(''):
571                     raise TypeError, 'new property "%s" not a string'%key
573             elif isinstance(prop, Password):
574                 if not isinstance(value, password.Password):
575                     raise TypeError, 'new property "%s" not a Password'% key
576                 propvalues[key] = value = str(value)
578             elif value is not None and isinstance(prop, Date):
579                 if not isinstance(value, date.Date):
580                     raise TypeError, 'new property "%s" not a Date'% key
581                 propvalues[key] = value = value.get_tuple()
583             elif value is not None and isinstance(prop, Interval):
584                 if not isinstance(value, date.Interval):
585                     raise TypeError, 'new property "%s" not an Interval'% key
586                 propvalues[key] = value = value.get_tuple()
588             node[key] = value
590         self.db.setnode(self.classname, nodeid, node)
591         self.db.addjournal(self.classname, nodeid, 'set', propvalues)
593     def retire(self, nodeid):
594         """Retire a node.
595         
596         The properties on the node remain available from the get() method,
597         and the node's id is never reused.
598         
599         Retired nodes are not returned by the find(), list(), or lookup()
600         methods, and other nodes may reuse the values of their key properties.
601         """
602         if self.db.journaltag is None:
603             raise DatabaseError, 'Database open read-only'
604         node = self.db.getnode(self.classname, nodeid)
605         node[self.db.RETIRED_FLAG] = 1
606         self.db.setnode(self.classname, nodeid, node)
607         self.db.addjournal(self.classname, nodeid, 'retired', None)
609     def history(self, nodeid):
610         """Retrieve the journal of edits on a particular node.
612         'nodeid' must be the id of an existing node of this class or an
613         IndexError is raised.
615         The returned list contains tuples of the form
617             (date, tag, action, params)
619         'date' is a Timestamp object specifying the time of the change and
620         'tag' is the journaltag specified when the database was opened.
621         """
622         return self.db.getjournal(self.classname, nodeid)
624     # Locating nodes:
626     def setkey(self, propname):
627         """Select a String property of this class to be the key property.
629         'propname' must be the name of a String property of this class or
630         None, or a TypeError is raised.  The values of the key property on
631         all existing nodes must be unique or a ValueError is raised.
632         """
633         # TODO: validate that the property is a String!
634         self.key = propname
636     def getkey(self):
637         """Return the name of the key property for this class or None."""
638         return self.key
640     def labelprop(self, default_to_id=0):
641         ''' Return the property name for a label for the given node.
643         This method attempts to generate a consistent label for the node.
644         It tries the following in order:
645             1. key property
646             2. "name" property
647             3. "title" property
648             4. first property from the sorted property name list
649         '''
650         k = self.getkey()
651         if  k:
652             return k
653         props = self.getprops()
654         if props.has_key('name'):
655             return 'name'
656         elif props.has_key('title'):
657             return 'title'
658         if default_to_id:
659             return 'id'
660         props = props.keys()
661         props.sort()
662         return props[0]
664     # TODO: set up a separate index db file for this? profile?
665     def lookup(self, keyvalue):
666         """Locate a particular node by its key property and return its id.
668         If this class has no key property, a TypeError is raised.  If the
669         'keyvalue' matches one of the values for the key property among
670         the nodes in this class, the matching node's id is returned;
671         otherwise a KeyError is raised.
672         """
673         cldb = self.db.getclassdb(self.classname)
674         for nodeid in self.db.getnodeids(self.classname, cldb):
675             node = self.db.getnode(self.classname, nodeid, cldb)
676             if node.has_key(self.db.RETIRED_FLAG):
677                 continue
678             if node[self.key] == keyvalue:
679                 return nodeid
680         raise KeyError, keyvalue
682     # XXX: change from spec - allows multiple props to match
683     def find(self, **propspec):
684         """Get the ids of nodes in this class which link to a given node.
686         'propspec' consists of keyword args propname=nodeid   
687           'propname' must be the name of a property in this class, or a
688             KeyError is raised.  That property must be a Link or Multilink
689             property, or a TypeError is raised.
691           'nodeid' must be the id of an existing node in the class linked
692             to by the given property, or an IndexError is raised.
693         """
694         propspec = propspec.items()
695         for propname, nodeid in propspec:
696             # check the prop is OK
697             prop = self.properties[propname]
698             if not isinstance(prop, Link) and not isinstance(prop, Multilink):
699                 raise TypeError, "'%s' not a Link/Multilink property"%propname
700             if not self.db.hasnode(prop.classname, nodeid):
701                 raise ValueError, '%s has no node %s'%(prop.classname, nodeid)
703         # ok, now do the find
704         cldb = self.db.getclassdb(self.classname)
705         l = []
706         for id in self.db.getnodeids(self.classname, cldb):
707             node = self.db.getnode(self.classname, id, cldb)
708             if node.has_key(self.db.RETIRED_FLAG):
709                 continue
710             for propname, nodeid in propspec:
711                 property = node[propname]
712                 if isinstance(prop, Link) and nodeid == property:
713                     l.append(id)
714                 elif isinstance(prop, Multilink) and nodeid in property:
715                     l.append(id)
716         return l
718     def stringFind(self, **requirements):
719         """Locate a particular node by matching a set of its String
720         properties in a caseless search.
722         If the property is not a String property, a TypeError is raised.
723         
724         The return is a list of the id of all nodes that match.
725         """
726         for propname in requirements.keys():
727             prop = self.properties[propname]
728             if isinstance(not prop, String):
729                 raise TypeError, "'%s' not a String property"%propname
730             requirements[propname] = requirements[propname].lower()
731         l = []
732         cldb = self.db.getclassdb(self.classname)
733         for nodeid in self.db.getnodeids(self.classname, cldb):
734             node = self.db.getnode(self.classname, nodeid, cldb)
735             if node.has_key(self.db.RETIRED_FLAG):
736                 continue
737             for key, value in requirements.items():
738                 if node[key] and node[key].lower() != value:
739                     break
740             else:
741                 l.append(nodeid)
742         return l
744     def list(self):
745         """Return a list of the ids of the active nodes in this class."""
746         l = []
747         cn = self.classname
748         cldb = self.db.getclassdb(cn)
749         for nodeid in self.db.getnodeids(cn, cldb):
750             node = self.db.getnode(cn, nodeid, cldb)
751             if node.has_key(self.db.RETIRED_FLAG):
752                 continue
753             l.append(nodeid)
754         l.sort()
755         return l
757     # XXX not in spec
758     def filter(self, filterspec, sort, group, num_re = re.compile('^\d+$')):
759         ''' Return a list of the ids of the active nodes in this class that
760             match the 'filter' spec, sorted by the group spec and then the
761             sort spec
762         '''
763         cn = self.classname
765         # optimise filterspec
766         l = []
767         props = self.getprops()
768         for k, v in filterspec.items():
769             propclass = props[k]
770             if isinstance(propclass, Link):
771                 if type(v) is not type([]):
772                     v = [v]
773                 # replace key values with node ids
774                 u = []
775                 link_class =  self.db.classes[propclass.classname]
776                 for entry in v:
777                     if entry == '-1': entry = None
778                     elif not num_re.match(entry):
779                         try:
780                             entry = link_class.lookup(entry)
781                         except (TypeError,KeyError):
782                             raise ValueError, 'property "%s": %s not a %s'%(
783                                 k, entry, self.properties[k].classname)
784                     u.append(entry)
786                 l.append((0, k, u))
787             elif isinstance(propclass, Multilink):
788                 if type(v) is not type([]):
789                     v = [v]
790                 # replace key values with node ids
791                 u = []
792                 link_class =  self.db.classes[propclass.classname]
793                 for entry in v:
794                     if not num_re.match(entry):
795                         try:
796                             entry = link_class.lookup(entry)
797                         except (TypeError,KeyError):
798                             raise ValueError, 'new property "%s": %s not a %s'%(
799                                 k, entry, self.properties[k].classname)
800                     u.append(entry)
801                 l.append((1, k, u))
802             elif isinstance(propclass, String):
803                 # simple glob searching
804                 v = re.sub(r'([\|\{\}\\\.\+\[\]\(\)])', r'\\\1', v)
805                 v = v.replace('?', '.')
806                 v = v.replace('*', '.*?')
807                 l.append((2, k, re.compile(v, re.I)))
808             else:
809                 l.append((6, k, v))
810         filterspec = l
812         # now, find all the nodes that are active and pass filtering
813         l = []
814         cldb = self.db.getclassdb(cn)
815         for nodeid in self.db.getnodeids(cn, cldb):
816             node = self.db.getnode(cn, nodeid, cldb)
817             if node.has_key(self.db.RETIRED_FLAG):
818                 continue
819             # apply filter
820             for t, k, v in filterspec:
821                 # this node doesn't have this property, so reject it
822                 if not node.has_key(k): break
824                 if t == 0 and node[k] not in v:
825                     # link - if this node'd property doesn't appear in the
826                     # filterspec's nodeid list, skip it
827                     break
828                 elif t == 1:
829                     # multilink - if any of the nodeids required by the
830                     # filterspec aren't in this node's property, then skip
831                     # it
832                     for value in v:
833                         if value not in node[k]:
834                             break
835                     else:
836                         continue
837                     break
838                 elif t == 2 and not v.search(node[k]):
839                     # RE search
840                     break
841                 elif t == 6 and node[k] != v:
842                     # straight value comparison for the other types
843                     break
844             else:
845                 l.append((nodeid, node))
846         l.sort()
848         # optimise sort
849         m = []
850         for entry in sort:
851             if entry[0] != '-':
852                 m.append(('+', entry))
853             else:
854                 m.append((entry[0], entry[1:]))
855         sort = m
857         # optimise group
858         m = []
859         for entry in group:
860             if entry[0] != '-':
861                 m.append(('+', entry))
862             else:
863                 m.append((entry[0], entry[1:]))
864         group = m
865         # now, sort the result
866         def sortfun(a, b, sort=sort, group=group, properties=self.getprops(),
867                 db = self.db, cl=self):
868             a_id, an = a
869             b_id, bn = b
870             # sort by group and then sort
871             for list in group, sort:
872                 for dir, prop in list:
873                     # sorting is class-specific
874                     propclass = properties[prop]
876                     # handle the properties that might be "faked"
877                     # also, handle possible missing properties
878                     try:
879                         if not an.has_key(prop):
880                             an[prop] = cl.get(a_id, prop)
881                         av = an[prop]
882                     except KeyError:
883                         # the node doesn't have a value for this property
884                         if isinstance(propclass, Multilink): av = []
885                         else: av = ''
886                     try:
887                         if not bn.has_key(prop):
888                             bn[prop] = cl.get(b_id, prop)
889                         bv = bn[prop]
890                     except KeyError:
891                         # the node doesn't have a value for this property
892                         if isinstance(propclass, Multilink): bv = []
893                         else: bv = ''
895                     # String and Date values are sorted in the natural way
896                     if isinstance(propclass, String):
897                         # clean up the strings
898                         if av and av[0] in string.uppercase:
899                             av = an[prop] = av.lower()
900                         if bv and bv[0] in string.uppercase:
901                             bv = bn[prop] = bv.lower()
902                     if (isinstance(propclass, String) or
903                             isinstance(propclass, Date)):
904                         # it might be a string that's really an integer
905                         try:
906                             av = int(av)
907                             bv = int(bv)
908                         except:
909                             pass
910                         if dir == '+':
911                             r = cmp(av, bv)
912                             if r != 0: return r
913                         elif dir == '-':
914                             r = cmp(bv, av)
915                             if r != 0: return r
917                     # Link properties are sorted according to the value of
918                     # the "order" property on the linked nodes if it is
919                     # present; or otherwise on the key string of the linked
920                     # nodes; or finally on  the node ids.
921                     elif isinstance(propclass, Link):
922                         link = db.classes[propclass.classname]
923                         if av is None and bv is not None: return -1
924                         if av is not None and bv is None: return 1
925                         if av is None and bv is None: continue
926                         if link.getprops().has_key('order'):
927                             if dir == '+':
928                                 r = cmp(link.get(av, 'order'),
929                                     link.get(bv, 'order'))
930                                 if r != 0: return r
931                             elif dir == '-':
932                                 r = cmp(link.get(bv, 'order'),
933                                     link.get(av, 'order'))
934                                 if r != 0: return r
935                         elif link.getkey():
936                             key = link.getkey()
937                             if dir == '+':
938                                 r = cmp(link.get(av, key), link.get(bv, key))
939                                 if r != 0: return r
940                             elif dir == '-':
941                                 r = cmp(link.get(bv, key), link.get(av, key))
942                                 if r != 0: return r
943                         else:
944                             if dir == '+':
945                                 r = cmp(av, bv)
946                                 if r != 0: return r
947                             elif dir == '-':
948                                 r = cmp(bv, av)
949                                 if r != 0: return r
951                     # Multilink properties are sorted according to how many
952                     # links are present.
953                     elif isinstance(propclass, Multilink):
954                         if dir == '+':
955                             r = cmp(len(av), len(bv))
956                             if r != 0: return r
957                         elif dir == '-':
958                             r = cmp(len(bv), len(av))
959                             if r != 0: return r
960                 # end for dir, prop in list:
961             # end for list in sort, group:
962             # if all else fails, compare the ids
963             return cmp(a[0], b[0])
965         l.sort(sortfun)
966         return [i[0] for i in l]
968     def count(self):
969         """Get the number of nodes in this class.
971         If the returned integer is 'numnodes', the ids of all the nodes
972         in this class run from 1 to numnodes, and numnodes+1 will be the
973         id of the next node to be created in this class.
974         """
975         return self.db.countnodes(self.classname)
977     # Manipulating properties:
979     def getprops(self, protected=1):
980         """Return a dictionary mapping property names to property objects.
981            If the "protected" flag is true, we include protected properties -
982            those which may not be modified."""
983         d = self.properties.copy()
984         if protected:
985             d['id'] = String()
986         return d
988     def addprop(self, **properties):
989         """Add properties to this class.
991         The keyword arguments in 'properties' must map names to property
992         objects, or a TypeError is raised.  None of the keys in 'properties'
993         may collide with the names of existing properties, or a ValueError
994         is raised before any properties have been added.
995         """
996         for key in properties.keys():
997             if self.properties.has_key(key):
998                 raise ValueError, key
999         self.properties.update(properties)
1002 # XXX not in spec
1003 class Node:
1004     ''' A convenience wrapper for the given node
1005     '''
1006     def __init__(self, cl, nodeid, cache=1):
1007         self.__dict__['cl'] = cl
1008         self.__dict__['nodeid'] = nodeid
1009         self.__dict__['cache'] = cache
1010     def keys(self, protected=1):
1011         return self.cl.getprops(protected=protected).keys()
1012     def values(self, protected=1):
1013         l = []
1014         for name in self.cl.getprops(protected=protected).keys():
1015             l.append(self.cl.get(self.nodeid, name, cache=self.cache))
1016         return l
1017     def items(self, protected=1):
1018         l = []
1019         for name in self.cl.getprops(protected=protected).keys():
1020             l.append((name, self.cl.get(self.nodeid, name, cache=self.cache)))
1021         return l
1022     def has_key(self, name):
1023         return self.cl.getprops().has_key(name)
1024     def __getattr__(self, name):
1025         if self.__dict__.has_key(name):
1026             return self.__dict__[name]
1027         try:
1028             return self.cl.get(self.nodeid, name, cache=self.cache)
1029         except KeyError, value:
1030             # we trap this but re-raise it as AttributeError - all other
1031             # exceptions should pass through untrapped
1032             pass
1033         # nope, no such attribute
1034         raise AttributeError, str(value)
1035     def __getitem__(self, name):
1036         return self.cl.get(self.nodeid, name, cache=self.cache)
1037     def __setattr__(self, name, value):
1038         try:
1039             return self.cl.set(self.nodeid, **{name: value})
1040         except KeyError, value:
1041             raise AttributeError, str(value)
1042     def __setitem__(self, name, value):
1043         self.cl.set(self.nodeid, **{name: value})
1044     def history(self):
1045         return self.cl.history(self.nodeid)
1046     def retire(self):
1047         return self.cl.retire(self.nodeid)
1050 def Choice(name, *options):
1051     cl = Class(db, name, name=hyperdb.String(), order=hyperdb.String())
1052     for i in range(len(options)):
1053         cl.create(name=option[i], order=i)
1054     return hyperdb.Link(name)
1057 # $Log: not supported by cvs2svn $
1058 # Revision 1.49  2002/01/16 07:02:57  richard
1059 #  . lots of date/interval related changes:
1060 #    - more relaxed date format for input
1062 # Revision 1.48  2002/01/14 06:32:34  richard
1063 #  . #502951 ] adding new properties to old database
1065 # Revision 1.47  2002/01/14 02:20:15  richard
1066 #  . changed all config accesses so they access either the instance or the
1067 #    config attriubute on the db. This means that all config is obtained from
1068 #    instance_config instead of the mish-mash of classes. This will make
1069 #    switching to a ConfigParser setup easier too, I hope.
1071 # At a minimum, this makes migration a _little_ easier (a lot easier in the
1072 # 0.5.0 switch, I hope!)
1074 # Revision 1.46  2002/01/07 10:42:23  richard
1075 # oops
1077 # Revision 1.45  2002/01/02 04:18:17  richard
1078 # hyperdb docstrings
1080 # Revision 1.44  2002/01/02 02:31:38  richard
1081 # Sorry for the huge checkin message - I was only intending to implement #496356
1082 # but I found a number of places where things had been broken by transactions:
1083 #  . modified ROUNDUPDBSENDMAILDEBUG to be SENDMAILDEBUG and hold a filename
1084 #    for _all_ roundup-generated smtp messages to be sent to.
1085 #  . the transaction cache had broken the roundupdb.Class set() reactors
1086 #  . newly-created author users in the mailgw weren't being committed to the db
1088 # Stuff that made it into CHANGES.txt (ie. the stuff I was actually working
1089 # on when I found that stuff :):
1090 #  . #496356 ] Use threading in messages
1091 #  . detectors were being registered multiple times
1092 #  . added tests for mailgw
1093 #  . much better attaching of erroneous messages in the mail gateway
1095 # Revision 1.43  2001/12/20 06:13:24  rochecompaan
1096 # Bugs fixed:
1097 #   . Exception handling in hyperdb for strings-that-look-like numbers got
1098 #     lost somewhere
1099 #   . Internet Explorer submits full path for filename - we now strip away
1100 #     the path
1101 # Features added:
1102 #   . Link and multilink properties are now displayed sorted in the cgi
1103 #     interface
1105 # Revision 1.42  2001/12/16 10:53:37  richard
1106 # take a copy of the node dict so that the subsequent set
1107 # operation doesn't modify the oldvalues structure
1109 # Revision 1.41  2001/12/15 23:47:47  richard
1110 # Cleaned up some bare except statements
1112 # Revision 1.40  2001/12/14 23:42:57  richard
1113 # yuck, a gdbm instance tests false :(
1114 # I've left the debugging code in - it should be removed one day if we're ever
1115 # _really_ anal about performace :)
1117 # Revision 1.39  2001/12/02 05:06:16  richard
1118 # . We now use weakrefs in the Classes to keep the database reference, so
1119 #   the close() method on the database is no longer needed.
1120 #   I bumped the minimum python requirement up to 2.1 accordingly.
1121 # . #487480 ] roundup-server
1122 # . #487476 ] INSTALL.txt
1124 # I also cleaned up the change message / post-edit stuff in the cgi client.
1125 # There's now a clearly marked "TODO: append the change note" where I believe
1126 # the change note should be added there. The "changes" list will obviously
1127 # have to be modified to be a dict of the changes, or somesuch.
1129 # More testing needed.
1131 # Revision 1.38  2001/12/01 07:17:50  richard
1132 # . We now have basic transaction support! Information is only written to
1133 #   the database when the commit() method is called. Only the anydbm
1134 #   backend is modified in this way - neither of the bsddb backends have been.
1135 #   The mail, admin and cgi interfaces all use commit (except the admin tool
1136 #   doesn't have a commit command, so interactive users can't commit...)
1137 # . Fixed login/registration forwarding the user to the right page (or not,
1138 #   on a failure)
1140 # Revision 1.37  2001/11/28 21:55:35  richard
1141 #  . login_action and newuser_action return values were being ignored
1142 #  . Woohoo! Found that bloody re-login bug that was killing the mail
1143 #    gateway.
1144 #  (also a minor cleanup in hyperdb)
1146 # Revision 1.36  2001/11/27 03:16:09  richard
1147 # Another place that wasn't handling missing properties.
1149 # Revision 1.35  2001/11/22 15:46:42  jhermann
1150 # Added module docstrings to all modules.
1152 # Revision 1.34  2001/11/21 04:04:43  richard
1153 # *sigh* more missing value handling
1155 # Revision 1.33  2001/11/21 03:40:54  richard
1156 # more new property handling
1158 # Revision 1.32  2001/11/21 03:11:28  richard
1159 # Better handling of new properties.
1161 # Revision 1.31  2001/11/12 22:01:06  richard
1162 # Fixed issues with nosy reaction and author copies.
1164 # Revision 1.30  2001/11/09 10:11:08  richard
1165 #  . roundup-admin now handles all hyperdb exceptions
1167 # Revision 1.29  2001/10/27 00:17:41  richard
1168 # Made Class.stringFind() do caseless matching.
1170 # Revision 1.28  2001/10/21 04:44:50  richard
1171 # bug #473124: UI inconsistency with Link fields.
1172 #    This also prompted me to fix a fairly long-standing usability issue -
1173 #    that of being able to turn off certain filters.
1175 # Revision 1.27  2001/10/20 23:44:27  richard
1176 # Hyperdatabase sorts strings-that-look-like-numbers as numbers now.
1178 # Revision 1.26  2001/10/16 03:48:01  richard
1179 # admin tool now complains if a "find" is attempted with a non-link property.
1181 # Revision 1.25  2001/10/11 00:17:51  richard
1182 # Reverted a change in hyperdb so the default value for missing property
1183 # values in a create() is None and not '' (the empty string.) This obviously
1184 # breaks CSV import/export - the string 'None' will be created in an
1185 # export/import operation.
1187 # Revision 1.24  2001/10/10 03:54:57  richard
1188 # Added database importing and exporting through CSV files.
1189 # Uses the csv module from object-craft for exporting if it's available.
1190 # Requires the csv module for importing.
1192 # Revision 1.23  2001/10/09 23:58:10  richard
1193 # Moved the data stringification up into the hyperdb.Class class' get, set
1194 # and create methods. This means that the data is also stringified for the
1195 # journal call, and removes duplication of code from the backends. The
1196 # backend code now only sees strings.
1198 # Revision 1.22  2001/10/09 07:25:59  richard
1199 # Added the Password property type. See "pydoc roundup.password" for
1200 # implementation details. Have updated some of the documentation too.
1202 # Revision 1.21  2001/10/05 02:23:24  richard
1203 #  . roundup-admin create now prompts for property info if none is supplied
1204 #    on the command-line.
1205 #  . hyperdb Class getprops() method may now return only the mutable
1206 #    properties.
1207 #  . Login now uses cookies, which makes it a whole lot more flexible. We can
1208 #    now support anonymous user access (read-only, unless there's an
1209 #    "anonymous" user, in which case write access is permitted). Login
1210 #    handling has been moved into cgi_client.Client.main()
1211 #  . The "extended" schema is now the default in roundup init.
1212 #  . The schemas have had their page headings modified to cope with the new
1213 #    login handling. Existing installations should copy the interfaces.py
1214 #    file from the roundup lib directory to their instance home.
1215 #  . Incorrectly had a Bizar Software copyright on the cgitb.py module from
1216 #    Ping - has been removed.
1217 #  . Fixed a whole bunch of places in the CGI interface where we should have
1218 #    been returning Not Found instead of throwing an exception.
1219 #  . Fixed a deviation from the spec: trying to modify the 'id' property of
1220 #    an item now throws an exception.
1222 # Revision 1.20  2001/10/04 02:12:42  richard
1223 # Added nicer command-line item adding: passing no arguments will enter an
1224 # interactive more which asks for each property in turn. While I was at it, I
1225 # fixed an implementation problem WRT the spec - I wasn't raising a
1226 # ValueError if the key property was missing from a create(). Also added a
1227 # protected=boolean argument to getprops() so we can list only the mutable
1228 # properties (defaults to yes, which lists the immutables).
1230 # Revision 1.19  2001/08/29 04:47:18  richard
1231 # Fixed CGI client change messages so they actually include the properties
1232 # changed (again).
1234 # Revision 1.18  2001/08/16 07:34:59  richard
1235 # better CGI text searching - but hidden filter fields are disappearing...
1237 # Revision 1.17  2001/08/16 06:59:58  richard
1238 # all searches use re now - and they're all case insensitive
1240 # Revision 1.16  2001/08/15 23:43:18  richard
1241 # Fixed some isFooTypes that I missed.
1242 # Refactored some code in the CGI code.
1244 # Revision 1.15  2001/08/12 06:32:36  richard
1245 # using isinstance(blah, Foo) now instead of isFooType
1247 # Revision 1.14  2001/08/07 00:24:42  richard
1248 # stupid typo
1250 # Revision 1.13  2001/08/07 00:15:51  richard
1251 # Added the copyright/license notice to (nearly) all files at request of
1252 # Bizar Software.
1254 # Revision 1.12  2001/08/02 06:38:17  richard
1255 # Roundupdb now appends "mailing list" information to its messages which
1256 # include the e-mail address and web interface address. Templates may
1257 # override this in their db classes to include specific information (support
1258 # instructions, etc).
1260 # Revision 1.11  2001/08/01 04:24:21  richard
1261 # mailgw was assuming certain properties existed on the issues being created.
1263 # Revision 1.10  2001/07/30 02:38:31  richard
1264 # get() now has a default arg - for migration only.
1266 # Revision 1.9  2001/07/29 09:28:23  richard
1267 # Fixed sorting by clicking on column headings.
1269 # Revision 1.8  2001/07/29 08:27:40  richard
1270 # Fixed handling of passed-in values in form elements (ie. during a
1271 # drill-down)
1273 # Revision 1.7  2001/07/29 07:01:39  richard
1274 # Added vim command to all source so that we don't get no steenkin' tabs :)
1276 # Revision 1.6  2001/07/29 05:36:14  richard
1277 # Cleanup of the link label generation.
1279 # Revision 1.5  2001/07/29 04:05:37  richard
1280 # Added the fabricated property "id".
1282 # Revision 1.4  2001/07/27 06:25:35  richard
1283 # Fixed some of the exceptions so they're the right type.
1284 # Removed the str()-ification of node ids so we don't mask oopsy errors any
1285 # more.
1287 # Revision 1.3  2001/07/27 05:17:14  richard
1288 # just some comments
1290 # Revision 1.2  2001/07/22 12:09:32  richard
1291 # Final commit of Grande Splite
1293 # Revision 1.1  2001/07/22 11:58:35  richard
1294 # More Grande Splite
1297 # vim: set filetype=python ts=4 sw=4 et si