Code

. login_action and newuser_action return values were being ignored
[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.37 2001-11-28 21:55:35 richard Exp $
20 __doc__ = """
21 Hyperdatabase implementation, especially field types.
22 """
24 # standard python modules
25 import cPickle, re, string
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):
58         self.classname = classname
59     def __repr__(self):
60         return '<%s to "%s">'%(self.__class__, self.classname)
62 class Multilink:
63     """An object designating a Multilink property that links
64        to nodes in a specified class.
65     """
66     def __init__(self, classname):
67         self.classname = classname
68     def __repr__(self):
69         return '<%s to "%s">'%(self.__class__, self.classname)
71 class DatabaseError(ValueError):
72     pass
75 #
76 # the base Database class
77 #
78 class Database:
79     # flag to set on retired entries
80     RETIRED_FLAG = '__hyperdb_retired'
83 _marker = []
84 #
85 # The base Class class
86 #
87 class Class:
88     """The handle to a particular class of nodes in a hyperdatabase."""
90     def __init__(self, db, classname, **properties):
91         """Create a new class with a given name and property specification.
93         'classname' must not collide with the name of an existing class,
94         or a ValueError is raised.  The keyword arguments in 'properties'
95         must map names to property objects, or a TypeError is raised.
96         """
97         self.classname = classname
98         self.properties = properties
99         self.db = db
100         self.key = ''
102         # do the db-related init stuff
103         db.addclass(self)
105     # Editing nodes:
107     def create(self, **propvalues):
108         """Create a new node of this class and return its id.
110         The keyword arguments in 'propvalues' map property names to values.
112         The values of arguments must be acceptable for the types of their
113         corresponding properties or a TypeError is raised.
114         
115         If this class has a key property, it must be present and its value
116         must not collide with other key strings or a ValueError is raised.
117         
118         Any other properties on this class that are missing from the
119         'propvalues' dictionary are set to None.
120         
121         If an id in a link or multilink property does not refer to a valid
122         node, an IndexError is raised.
123         """
124         if propvalues.has_key('id'):
125             raise KeyError, '"id" is reserved'
127         if self.db.journaltag is None:
128             raise DatabaseError, 'Database open read-only'
130         # new node's id
131         newid = str(self.count() + 1)
133         # validate propvalues
134         num_re = re.compile('^\d+$')
135         for key, value in propvalues.items():
136             if key == self.key:
137                 try:
138                     self.lookup(value)
139                 except KeyError:
140                     pass
141                 else:
142                     raise ValueError, 'node with key "%s" exists'%value
144             # try to handle this property
145             try:
146                 prop = self.properties[key]
147             except KeyError:
148                 raise KeyError, '"%s" has no property "%s"'%(self.classname,
149                     key)
151             if isinstance(prop, Link):
152                 if type(value) != type(''):
153                     raise ValueError, 'link value must be String'
154                 link_class = self.properties[key].classname
155                 # if it isn't a number, it's a key
156                 if not num_re.match(value):
157                     try:
158                         value = self.db.classes[link_class].lookup(value)
159                     except:
160                         raise IndexError, 'new property "%s": %s not a %s'%(
161                             key, value, link_class)
162                 elif not self.db.hasnode(link_class, value):
163                     raise IndexError, '%s has no node %s'%(link_class, value)
165                 # save off the value
166                 propvalues[key] = value
168                 # register the link with the newly linked node
169                 self.db.addjournal(link_class, value, 'link',
170                     (self.classname, newid, key))
172             elif isinstance(prop, Multilink):
173                 if type(value) != type([]):
174                     raise TypeError, 'new property "%s" not a list of ids'%key
175                 link_class = self.properties[key].classname
176                 l = []
177                 for entry in value:
178                     if type(entry) != type(''):
179                         raise ValueError, 'link value must be String'
180                     # if it isn't a number, it's a key
181                     if not num_re.match(entry):
182                         try:
183                             entry = self.db.classes[link_class].lookup(entry)
184                         except:
185                             raise IndexError, 'new property "%s": %s not a %s'%(
186                                 key, entry, self.properties[key].classname)
187                     l.append(entry)
188                 value = l
189                 propvalues[key] = value
191                 # handle additions
192                 for id in value:
193                     if not self.db.hasnode(link_class, id):
194                         raise IndexError, '%s has no node %s'%(link_class, id)
195                     # register the link with the newly linked node
196                     self.db.addjournal(link_class, id, 'link',
197                         (self.classname, newid, key))
199             elif isinstance(prop, String):
200                 if type(value) != type(''):
201                     raise TypeError, 'new property "%s" not a string'%key
203             elif isinstance(prop, Password):
204                 if not isinstance(value, password.Password):
205                     raise TypeError, 'new property "%s" not a Password'%key
207             elif isinstance(prop, Date):
208                 if not isinstance(value, date.Date):
209                     raise TypeError, 'new property "%s" not a Date'%key
211             elif isinstance(prop, Interval):
212                 if not isinstance(value, date.Interval):
213                     raise TypeError, 'new property "%s" not an Interval'%key
215         # make sure there's data where there needs to be
216         for key, prop in self.properties.items():
217             if propvalues.has_key(key):
218                 continue
219             if key == self.key:
220                 raise ValueError, 'key property "%s" is required'%key
221             if isinstance(prop, Multilink):
222                 propvalues[key] = []
223             else:
224                 propvalues[key] = None
226         # convert all data to strings
227         for key, prop in self.properties.items():
228             if isinstance(prop, Date):
229                 propvalues[key] = propvalues[key].get_tuple()
230             elif isinstance(prop, Interval):
231                 propvalues[key] = propvalues[key].get_tuple()
232             elif isinstance(prop, Password):
233                 propvalues[key] = str(propvalues[key])
235         # done
236         self.db.addnode(self.classname, newid, propvalues)
237         self.db.addjournal(self.classname, newid, 'create', propvalues)
238         return newid
240     def get(self, nodeid, propname, default=_marker):
241         """Get the value of a property on an existing node of this class.
243         'nodeid' must be the id of an existing node of this class or an
244         IndexError is raised.  'propname' must be the name of a property
245         of this class or a KeyError is raised.
246         """
247         d = self.db.getnode(self.classname, nodeid)
249         # convert the marshalled data to instances
250         for key, prop in self.properties.items():
251             if isinstance(prop, Date):
252                 d[key] = date.Date(d[key])
253             elif isinstance(prop, Interval):
254                 d[key] = date.Interval(d[key])
255             elif isinstance(prop, Password):
256                 p = password.Password()
257                 p.unpack(d[key])
258                 d[key] = p
260         if propname == 'id':
261             return nodeid
262         if not d.has_key(propname) and default is not _marker:
263             return default
264         return d[propname]
266     # XXX not in spec
267     def getnode(self, nodeid):
268         ''' Return a convenience wrapper for the node
269         '''
270         return Node(self, nodeid)
272     def set(self, nodeid, **propvalues):
273         """Modify a property on an existing node of this class.
274         
275         'nodeid' must be the id of an existing node of this class or an
276         IndexError is raised.
278         Each key in 'propvalues' must be the name of a property of this
279         class or a KeyError is raised.
281         All values in 'propvalues' must be acceptable types for their
282         corresponding properties or a TypeError is raised.
284         If the value of the key property is set, it must not collide with
285         other key strings or a ValueError is raised.
287         If the value of a Link or Multilink property contains an invalid
288         node id, a ValueError is raised.
289         """
290         if not propvalues:
291             return
293         if propvalues.has_key('id'):
294             raise KeyError, '"id" is reserved'
296         if self.db.journaltag is None:
297             raise DatabaseError, 'Database open read-only'
299         node = self.db.getnode(self.classname, nodeid)
300         if node.has_key(self.db.RETIRED_FLAG):
301             raise IndexError
302         num_re = re.compile('^\d+$')
303         for key, value in propvalues.items():
304             # check to make sure we're not duplicating an existing key
305             if key == self.key and node[key] != value:
306                 try:
307                     self.lookup(value)
308                 except KeyError:
309                     pass
310                 else:
311                     raise ValueError, 'node with key "%s" exists'%value
313             # this will raise the KeyError if the property isn't valid
314             # ... we don't use getprops() here because we only care about
315             # the writeable properties.
316             prop = self.properties[key]
318             if isinstance(prop, Link):
319                 link_class = self.properties[key].classname
320                 # if it isn't a number, it's a key
321                 if type(value) != type(''):
322                     raise ValueError, 'link value must be String'
323                 if not num_re.match(value):
324                     try:
325                         value = self.db.classes[link_class].lookup(value)
326                     except:
327                         raise IndexError, 'new property "%s": %s not a %s'%(
328                             key, value, self.properties[key].classname)
330                 if not self.db.hasnode(link_class, value):
331                     raise IndexError, '%s has no node %s'%(link_class, value)
333                 # register the unlink with the old linked node
334                 if node[key] is not None:
335                     self.db.addjournal(link_class, node[key], 'unlink',
336                         (self.classname, nodeid, key))
338                 # register the link with the newly linked node
339                 if value is not None:
340                     self.db.addjournal(link_class, value, 'link',
341                         (self.classname, nodeid, key))
343             elif isinstance(prop, Multilink):
344                 if type(value) != type([]):
345                     raise TypeError, 'new property "%s" not a list of ids'%key
346                 link_class = self.properties[key].classname
347                 l = []
348                 for entry in value:
349                     # if it isn't a number, it's a key
350                     if type(entry) != type(''):
351                         raise ValueError, 'link value must be String'
352                     if not num_re.match(entry):
353                         try:
354                             entry = self.db.classes[link_class].lookup(entry)
355                         except:
356                             raise IndexError, 'new property "%s": %s not a %s'%(
357                                 key, entry, self.properties[key].classname)
358                     l.append(entry)
359                 value = l
360                 propvalues[key] = value
362                 #handle removals
363                 l = node[key]
364                 for id in l[:]:
365                     if id in value:
366                         continue
367                     # register the unlink with the old linked node
368                     self.db.addjournal(link_class, id, 'unlink',
369                         (self.classname, nodeid, key))
370                     l.remove(id)
372                 # handle additions
373                 for id in value:
374                     if not self.db.hasnode(link_class, id):
375                         raise IndexError, '%s has no node %s'%(link_class, id)
376                     if id in l:
377                         continue
378                     # register the link with the newly linked node
379                     self.db.addjournal(link_class, id, 'link',
380                         (self.classname, nodeid, key))
381                     l.append(id)
383             elif isinstance(prop, String):
384                 if value is not None and type(value) != type(''):
385                     raise TypeError, 'new property "%s" not a string'%key
387             elif isinstance(prop, Password):
388                 if not isinstance(value, password.Password):
389                     raise TypeError, 'new property "%s" not a Password'% key
390                 propvalues[key] = value = str(value)
392             elif isinstance(prop, Date):
393                 if not isinstance(value, date.Date):
394                     raise TypeError, 'new property "%s" not a Date'% key
395                 propvalues[key] = value = value.get_tuple()
397             elif isinstance(prop, Interval):
398                 if not isinstance(value, date.Interval):
399                     raise TypeError, 'new property "%s" not an Interval'% key
400                 propvalues[key] = value = value.get_tuple()
402             node[key] = value
404         self.db.setnode(self.classname, nodeid, node)
405         self.db.addjournal(self.classname, nodeid, 'set', propvalues)
407     def retire(self, nodeid):
408         """Retire a node.
409         
410         The properties on the node remain available from the get() method,
411         and the node's id is never reused.
412         
413         Retired nodes are not returned by the find(), list(), or lookup()
414         methods, and other nodes may reuse the values of their key properties.
415         """
416         if self.db.journaltag is None:
417             raise DatabaseError, 'Database open read-only'
418         node = self.db.getnode(self.classname, nodeid)
419         node[self.db.RETIRED_FLAG] = 1
420         self.db.setnode(self.classname, nodeid, node)
421         self.db.addjournal(self.classname, nodeid, 'retired', None)
423     def history(self, nodeid):
424         """Retrieve the journal of edits on a particular node.
426         'nodeid' must be the id of an existing node of this class or an
427         IndexError is raised.
429         The returned list contains tuples of the form
431             (date, tag, action, params)
433         'date' is a Timestamp object specifying the time of the change and
434         'tag' is the journaltag specified when the database was opened.
435         """
436         return self.db.getjournal(self.classname, nodeid)
438     # Locating nodes:
440     def setkey(self, propname):
441         """Select a String property of this class to be the key property.
443         'propname' must be the name of a String property of this class or
444         None, or a TypeError is raised.  The values of the key property on
445         all existing nodes must be unique or a ValueError is raised.
446         """
447         # TODO: validate that the property is a String!
448         self.key = propname
450     def getkey(self):
451         """Return the name of the key property for this class or None."""
452         return self.key
454     def labelprop(self, default_to_id=0):
455         ''' Return the property name for a label for the given node.
457         This method attempts to generate a consistent label for the node.
458         It tries the following in order:
459             1. key property
460             2. "name" property
461             3. "title" property
462             4. first property from the sorted property name list
463         '''
464         k = self.getkey()
465         if  k:
466             return k
467         props = self.getprops()
468         if props.has_key('name'):
469             return 'name'
470         elif props.has_key('title'):
471             return 'title'
472         if default_to_id:
473             return 'id'
474         props = props.keys()
475         props.sort()
476         return props[0]
478     # TODO: set up a separate index db file for this? profile?
479     def lookup(self, keyvalue):
480         """Locate a particular node by its key property and return its id.
482         If this class has no key property, a TypeError is raised.  If the
483         'keyvalue' matches one of the values for the key property among
484         the nodes in this class, the matching node's id is returned;
485         otherwise a KeyError is raised.
486         """
487         cldb = self.db.getclassdb(self.classname)
488         for nodeid in self.db.getnodeids(self.classname, cldb):
489             node = self.db.getnode(self.classname, nodeid, cldb)
490             if node.has_key(self.db.RETIRED_FLAG):
491                 continue
492             if node[self.key] == keyvalue:
493                 return nodeid
494         cldb.close()
495         raise KeyError, keyvalue
497     # XXX: change from spec - allows multiple props to match
498     def find(self, **propspec):
499         """Get the ids of nodes in this class which link to a given node.
501         'propspec' consists of keyword args propname=nodeid   
502           'propname' must be the name of a property in this class, or a
503             KeyError is raised.  That property must be a Link or Multilink
504             property, or a TypeError is raised.
506           'nodeid' must be the id of an existing node in the class linked
507             to by the given property, or an IndexError is raised.
508         """
509         propspec = propspec.items()
510         for propname, nodeid in propspec:
511             # check the prop is OK
512             prop = self.properties[propname]
513             if not isinstance(prop, Link) and not isinstance(prop, Multilink):
514                 raise TypeError, "'%s' not a Link/Multilink property"%propname
515             if not self.db.hasnode(prop.classname, nodeid):
516                 raise ValueError, '%s has no node %s'%(prop.classname, nodeid)
518         # ok, now do the find
519         cldb = self.db.getclassdb(self.classname)
520         l = []
521         for id in self.db.getnodeids(self.classname, cldb):
522             node = self.db.getnode(self.classname, id, cldb)
523             if node.has_key(self.db.RETIRED_FLAG):
524                 continue
525             for propname, nodeid in propspec:
526                 property = node[propname]
527                 if isinstance(prop, Link) and nodeid == property:
528                     l.append(id)
529                 elif isinstance(prop, Multilink) and nodeid in property:
530                     l.append(id)
531         cldb.close()
532         return l
534     def stringFind(self, **requirements):
535         """Locate a particular node by matching a set of its String
536         properties in a caseless search.
538         If the property is not a String property, a TypeError is raised.
539         
540         The return is a list of the id of all nodes that match.
541         """
542         for propname in requirements.keys():
543             prop = self.properties[propname]
544             if isinstance(not prop, String):
545                 raise TypeError, "'%s' not a String property"%propname
546             requirements[propname] = requirements[propname].lower()
547         l = []
548         cldb = self.db.getclassdb(self.classname)
549         for nodeid in self.db.getnodeids(self.classname, cldb):
550             node = self.db.getnode(self.classname, nodeid, cldb)
551             if node.has_key(self.db.RETIRED_FLAG):
552                 continue
553             for key, value in requirements.items():
554                 if node[key] and node[key].lower() != value:
555                     break
556             else:
557                 l.append(nodeid)
558         cldb.close()
559         return l
561     def list(self):
562         """Return a list of the ids of the active nodes in this class."""
563         l = []
564         cn = self.classname
565         cldb = self.db.getclassdb(cn)
566         for nodeid in self.db.getnodeids(cn, cldb):
567             node = self.db.getnode(cn, nodeid, cldb)
568             if node.has_key(self.db.RETIRED_FLAG):
569                 continue
570             l.append(nodeid)
571         l.sort()
572         cldb.close()
573         return l
575     # XXX not in spec
576     def filter(self, filterspec, sort, group, num_re = re.compile('^\d+$')):
577         ''' Return a list of the ids of the active nodes in this class that
578             match the 'filter' spec, sorted by the group spec and then the
579             sort spec
580         '''
581         cn = self.classname
583         # optimise filterspec
584         l = []
585         props = self.getprops()
586         for k, v in filterspec.items():
587             propclass = props[k]
588             if isinstance(propclass, Link):
589                 if type(v) is not type([]):
590                     v = [v]
591                 # replace key values with node ids
592                 u = []
593                 link_class =  self.db.classes[propclass.classname]
594                 for entry in v:
595                     if entry == '-1': entry = None
596                     elif not num_re.match(entry):
597                         try:
598                             entry = link_class.lookup(entry)
599                         except:
600                             raise ValueError, 'property "%s": %s not a %s'%(
601                                 k, entry, self.properties[k].classname)
602                     u.append(entry)
604                 l.append((0, k, u))
605             elif isinstance(propclass, Multilink):
606                 if type(v) is not type([]):
607                     v = [v]
608                 # replace key values with node ids
609                 u = []
610                 link_class =  self.db.classes[propclass.classname]
611                 for entry in v:
612                     if not num_re.match(entry):
613                         try:
614                             entry = link_class.lookup(entry)
615                         except:
616                             raise ValueError, 'new property "%s": %s not a %s'%(
617                                 k, entry, self.properties[k].classname)
618                     u.append(entry)
619                 l.append((1, k, u))
620             elif isinstance(propclass, String):
621                 # simple glob searching
622                 v = re.sub(r'([\|\{\}\\\.\+\[\]\(\)])', r'\\\1', v)
623                 v = v.replace('?', '.')
624                 v = v.replace('*', '.*?')
625                 l.append((2, k, re.compile(v, re.I)))
626             else:
627                 l.append((6, k, v))
628         filterspec = l
630         # now, find all the nodes that are active and pass filtering
631         l = []
632         cldb = self.db.getclassdb(cn)
633         for nodeid in self.db.getnodeids(cn, cldb):
634             node = self.db.getnode(cn, nodeid, cldb)
635             if node.has_key(self.db.RETIRED_FLAG):
636                 continue
637             # apply filter
638             for t, k, v in filterspec:
639                 # this node doesn't have this property, so reject it
640                 if not node.has_key(k): break
642                 if t == 0 and node[k] not in v:
643                     # link - if this node'd property doesn't appear in the
644                     # filterspec's nodeid list, skip it
645                     break
646                 elif t == 1:
647                     # multilink - if any of the nodeids required by the
648                     # filterspec aren't in this node's property, then skip
649                     # it
650                     for value in v:
651                         if value not in node[k]:
652                             break
653                     else:
654                         continue
655                     break
656                 elif t == 2 and not v.search(node[k]):
657                     # RE search
658                     break
659                 elif t == 6 and node[k] != v:
660                     # straight value comparison for the other types
661                     break
662             else:
663                 l.append((nodeid, node))
664         l.sort()
665         cldb.close()
667         # optimise sort
668         m = []
669         for entry in sort:
670             if entry[0] != '-':
671                 m.append(('+', entry))
672             else:
673                 m.append((entry[0], entry[1:]))
674         sort = m
676         # optimise group
677         m = []
678         for entry in group:
679             if entry[0] != '-':
680                 m.append(('+', entry))
681             else:
682                 m.append((entry[0], entry[1:]))
683         group = m
684         # now, sort the result
685         def sortfun(a, b, sort=sort, group=group, properties=self.getprops(),
686                 db = self.db, cl=self):
687             a_id, an = a
688             b_id, bn = b
689             # sort by group and then sort
690             for list in group, sort:
691                 for dir, prop in list:
692                     # sorting is class-specific
693                     propclass = properties[prop]
695                     # handle the properties that might be "faked"
696                     # also, handle possible missing properties
697                     try:
698                         if not an.has_key(prop):
699                             an[prop] = cl.get(a_id, prop)
700                         av = an[prop]
701                     except KeyError:
702                         # the node doesn't have a value for this property
703                         if isinstance(propclass, Multilink): av = []
704                         else: av = ''
705                     try:
706                         if not bn.has_key(prop):
707                             bn[prop] = cl.get(b_id, prop)
708                         bv = bn[prop]
709                     except KeyError:
710                         # the node doesn't have a value for this property
711                         if isinstance(propclass, Multilink): bv = []
712                         else: bv = ''
714                     # String and Date values are sorted in the natural way
715                     if isinstance(propclass, String):
716                         # clean up the strings
717                         if av and av[0] in string.uppercase:
718                             av = an[prop] = av.lower()
719                         if bv and bv[0] in string.uppercase:
720                             bv = bn[prop] = bv.lower()
721                     if (isinstance(propclass, String) or
722                             isinstance(propclass, Date)):
723                         # it might be a string that's really an integer
724                         try:
725                             av = int(av)
726                             bv = int(bv)
727                         except:
728                             pass
729                         if dir == '+':
730                             r = cmp(av, bv)
731                             if r != 0: return r
732                         elif dir == '-':
733                             r = cmp(bv, av)
734                             if r != 0: return r
736                     # Link properties are sorted according to the value of
737                     # the "order" property on the linked nodes if it is
738                     # present; or otherwise on the key string of the linked
739                     # nodes; or finally on  the node ids.
740                     elif isinstance(propclass, Link):
741                         link = db.classes[propclass.classname]
742                         if av is None and bv is not None: return -1
743                         if av is not None and bv is None: return 1
744                         if av is None and bv is None: return 0
745                         if link.getprops().has_key('order'):
746                             if dir == '+':
747                                 r = cmp(link.get(av, 'order'),
748                                     link.get(bv, 'order'))
749                                 if r != 0: return r
750                             elif dir == '-':
751                                 r = cmp(link.get(bv, 'order'),
752                                     link.get(av, 'order'))
753                                 if r != 0: return r
754                         elif link.getkey():
755                             key = link.getkey()
756                             if dir == '+':
757                                 r = cmp(link.get(av, key), link.get(bv, key))
758                                 if r != 0: return r
759                             elif dir == '-':
760                                 r = cmp(link.get(bv, key), link.get(av, key))
761                                 if r != 0: return r
762                         else:
763                             if dir == '+':
764                                 r = cmp(av, bv)
765                                 if r != 0: return r
766                             elif dir == '-':
767                                 r = cmp(bv, av)
768                                 if r != 0: return r
770                     # Multilink properties are sorted according to how many
771                     # links are present.
772                     elif isinstance(propclass, Multilink):
773                         if dir == '+':
774                             r = cmp(len(av), len(bv))
775                             if r != 0: return r
776                         elif dir == '-':
777                             r = cmp(len(bv), len(av))
778                             if r != 0: return r
779                 # end for dir, prop in list:
780             # end for list in sort, group:
781             # if all else fails, compare the ids
782             return cmp(a[0], b[0])
784         l.sort(sortfun)
785         return [i[0] for i in l]
787     def count(self):
788         """Get the number of nodes in this class.
790         If the returned integer is 'numnodes', the ids of all the nodes
791         in this class run from 1 to numnodes, and numnodes+1 will be the
792         id of the next node to be created in this class.
793         """
794         return self.db.countnodes(self.classname)
796     # Manipulating properties:
798     def getprops(self, protected=1):
799         """Return a dictionary mapping property names to property objects.
800            If the "protected" flag is true, we include protected properties -
801            those which may not be modified."""
802         d = self.properties.copy()
803         if protected:
804             d['id'] = String()
805         return d
807     def addprop(self, **properties):
808         """Add properties to this class.
810         The keyword arguments in 'properties' must map names to property
811         objects, or a TypeError is raised.  None of the keys in 'properties'
812         may collide with the names of existing properties, or a ValueError
813         is raised before any properties have been added.
814         """
815         for key in properties.keys():
816             if self.properties.has_key(key):
817                 raise ValueError, key
818         self.properties.update(properties)
821 # XXX not in spec
822 class Node:
823     ''' A convenience wrapper for the given node
824     '''
825     def __init__(self, cl, nodeid):
826         self.__dict__['cl'] = cl
827         self.__dict__['nodeid'] = nodeid
828     def keys(self, protected=1):
829         return self.cl.getprops(protected=protected).keys()
830     def values(self, protected=1):
831         l = []
832         for name in self.cl.getprops(protected=protected).keys():
833             l.append(self.cl.get(self.nodeid, name))
834         return l
835     def items(self, protected=1):
836         l = []
837         for name in self.cl.getprops(protected=protected).keys():
838             l.append((name, self.cl.get(self.nodeid, name)))
839         return l
840     def has_key(self, name):
841         return self.cl.getprops().has_key(name)
842     def __getattr__(self, name):
843         if self.__dict__.has_key(name):
844             return self.__dict__[name]
845         try:
846             return self.cl.get(self.nodeid, name)
847         except KeyError, value:
848             raise AttributeError, str(value)
849     def __getitem__(self, name):
850         return self.cl.get(self.nodeid, name)
851     def __setattr__(self, name, value):
852         try:
853             return self.cl.set(self.nodeid, **{name: value})
854         except KeyError, value:
855             raise AttributeError, str(value)
856     def __setitem__(self, name, value):
857         self.cl.set(self.nodeid, **{name: value})
858     def history(self):
859         return self.cl.history(self.nodeid)
860     def retire(self):
861         return self.cl.retire(self.nodeid)
864 def Choice(name, *options):
865     cl = Class(db, name, name=hyperdb.String(), order=hyperdb.String())
866     for i in range(len(options)):
867         cl.create(name=option[i], order=i)
868     return hyperdb.Link(name)
871 # $Log: not supported by cvs2svn $
872 # Revision 1.36  2001/11/27 03:16:09  richard
873 # Another place that wasn't handling missing properties.
875 # Revision 1.35  2001/11/22 15:46:42  jhermann
876 # Added module docstrings to all modules.
878 # Revision 1.34  2001/11/21 04:04:43  richard
879 # *sigh* more missing value handling
881 # Revision 1.33  2001/11/21 03:40:54  richard
882 # more new property handling
884 # Revision 1.32  2001/11/21 03:11:28  richard
885 # Better handling of new properties.
887 # Revision 1.31  2001/11/12 22:01:06  richard
888 # Fixed issues with nosy reaction and author copies.
890 # Revision 1.30  2001/11/09 10:11:08  richard
891 #  . roundup-admin now handles all hyperdb exceptions
893 # Revision 1.29  2001/10/27 00:17:41  richard
894 # Made Class.stringFind() do caseless matching.
896 # Revision 1.28  2001/10/21 04:44:50  richard
897 # bug #473124: UI inconsistency with Link fields.
898 #    This also prompted me to fix a fairly long-standing usability issue -
899 #    that of being able to turn off certain filters.
901 # Revision 1.27  2001/10/20 23:44:27  richard
902 # Hyperdatabase sorts strings-that-look-like-numbers as numbers now.
904 # Revision 1.26  2001/10/16 03:48:01  richard
905 # admin tool now complains if a "find" is attempted with a non-link property.
907 # Revision 1.25  2001/10/11 00:17:51  richard
908 # Reverted a change in hyperdb so the default value for missing property
909 # values in a create() is None and not '' (the empty string.) This obviously
910 # breaks CSV import/export - the string 'None' will be created in an
911 # export/import operation.
913 # Revision 1.24  2001/10/10 03:54:57  richard
914 # Added database importing and exporting through CSV files.
915 # Uses the csv module from object-craft for exporting if it's available.
916 # Requires the csv module for importing.
918 # Revision 1.23  2001/10/09 23:58:10  richard
919 # Moved the data stringification up into the hyperdb.Class class' get, set
920 # and create methods. This means that the data is also stringified for the
921 # journal call, and removes duplication of code from the backends. The
922 # backend code now only sees strings.
924 # Revision 1.22  2001/10/09 07:25:59  richard
925 # Added the Password property type. See "pydoc roundup.password" for
926 # implementation details. Have updated some of the documentation too.
928 # Revision 1.21  2001/10/05 02:23:24  richard
929 #  . roundup-admin create now prompts for property info if none is supplied
930 #    on the command-line.
931 #  . hyperdb Class getprops() method may now return only the mutable
932 #    properties.
933 #  . Login now uses cookies, which makes it a whole lot more flexible. We can
934 #    now support anonymous user access (read-only, unless there's an
935 #    "anonymous" user, in which case write access is permitted). Login
936 #    handling has been moved into cgi_client.Client.main()
937 #  . The "extended" schema is now the default in roundup init.
938 #  . The schemas have had their page headings modified to cope with the new
939 #    login handling. Existing installations should copy the interfaces.py
940 #    file from the roundup lib directory to their instance home.
941 #  . Incorrectly had a Bizar Software copyright on the cgitb.py module from
942 #    Ping - has been removed.
943 #  . Fixed a whole bunch of places in the CGI interface where we should have
944 #    been returning Not Found instead of throwing an exception.
945 #  . Fixed a deviation from the spec: trying to modify the 'id' property of
946 #    an item now throws an exception.
948 # Revision 1.20  2001/10/04 02:12:42  richard
949 # Added nicer command-line item adding: passing no arguments will enter an
950 # interactive more which asks for each property in turn. While I was at it, I
951 # fixed an implementation problem WRT the spec - I wasn't raising a
952 # ValueError if the key property was missing from a create(). Also added a
953 # protected=boolean argument to getprops() so we can list only the mutable
954 # properties (defaults to yes, which lists the immutables).
956 # Revision 1.19  2001/08/29 04:47:18  richard
957 # Fixed CGI client change messages so they actually include the properties
958 # changed (again).
960 # Revision 1.18  2001/08/16 07:34:59  richard
961 # better CGI text searching - but hidden filter fields are disappearing...
963 # Revision 1.17  2001/08/16 06:59:58  richard
964 # all searches use re now - and they're all case insensitive
966 # Revision 1.16  2001/08/15 23:43:18  richard
967 # Fixed some isFooTypes that I missed.
968 # Refactored some code in the CGI code.
970 # Revision 1.15  2001/08/12 06:32:36  richard
971 # using isinstance(blah, Foo) now instead of isFooType
973 # Revision 1.14  2001/08/07 00:24:42  richard
974 # stupid typo
976 # Revision 1.13  2001/08/07 00:15:51  richard
977 # Added the copyright/license notice to (nearly) all files at request of
978 # Bizar Software.
980 # Revision 1.12  2001/08/02 06:38:17  richard
981 # Roundupdb now appends "mailing list" information to its messages which
982 # include the e-mail address and web interface address. Templates may
983 # override this in their db classes to include specific information (support
984 # instructions, etc).
986 # Revision 1.11  2001/08/01 04:24:21  richard
987 # mailgw was assuming certain properties existed on the issues being created.
989 # Revision 1.10  2001/07/30 02:38:31  richard
990 # get() now has a default arg - for migration only.
992 # Revision 1.9  2001/07/29 09:28:23  richard
993 # Fixed sorting by clicking on column headings.
995 # Revision 1.8  2001/07/29 08:27:40  richard
996 # Fixed handling of passed-in values in form elements (ie. during a
997 # drill-down)
999 # Revision 1.7  2001/07/29 07:01:39  richard
1000 # Added vim command to all source so that we don't get no steenkin' tabs :)
1002 # Revision 1.6  2001/07/29 05:36:14  richard
1003 # Cleanup of the link label generation.
1005 # Revision 1.5  2001/07/29 04:05:37  richard
1006 # Added the fabricated property "id".
1008 # Revision 1.4  2001/07/27 06:25:35  richard
1009 # Fixed some of the exceptions so they're the right type.
1010 # Removed the str()-ification of node ids so we don't mask oopsy errors any
1011 # more.
1013 # Revision 1.3  2001/07/27 05:17:14  richard
1014 # just some comments
1016 # Revision 1.2  2001/07/22 12:09:32  richard
1017 # Final commit of Grande Splite
1019 # Revision 1.1  2001/07/22 11:58:35  richard
1020 # More Grande Splite
1023 # vim: set filetype=python ts=4 sw=4 et si