Code

ea4817479b29002fdd3cfb0964754fea0c455b71
[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.44 2002-01-02 02:31:38 richard 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):
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 = weakref.proxy(db)       # use a weak ref to avoid circularity
100         self.key = ''
102         # do the db-related init stuff
103         db.addclass(self)
105     def __repr__(self):
106         return '<hypderdb.Class "%s">'%self.classname
108     # Editing nodes:
110     def create(self, **propvalues):
111         """Create a new node of this class and return its id.
113         The keyword arguments in 'propvalues' map property names to values.
115         The values of arguments must be acceptable for the types of their
116         corresponding properties or a TypeError is raised.
117         
118         If this class has a key property, it must be present and its value
119         must not collide with other key strings or a ValueError is raised.
120         
121         Any other properties on this class that are missing from the
122         'propvalues' dictionary are set to None.
123         
124         If an id in a link or multilink property does not refer to a valid
125         node, an IndexError is raised.
126         """
127         if propvalues.has_key('id'):
128             raise KeyError, '"id" is reserved'
130         if self.db.journaltag is None:
131             raise DatabaseError, 'Database open read-only'
133         # new node's id
134         newid = str(self.count() + 1)
136         # validate propvalues
137         num_re = re.compile('^\d+$')
138         for key, value in propvalues.items():
139             if key == self.key:
140                 try:
141                     self.lookup(value)
142                 except KeyError:
143                     pass
144                 else:
145                     raise ValueError, 'node with key "%s" exists'%value
147             # try to handle this property
148             try:
149                 prop = self.properties[key]
150             except KeyError:
151                 raise KeyError, '"%s" has no property "%s"'%(self.classname,
152                     key)
154             if isinstance(prop, Link):
155                 if type(value) != type(''):
156                     raise ValueError, 'link value must be String'
157                 link_class = self.properties[key].classname
158                 # if it isn't a number, it's a key
159                 if not num_re.match(value):
160                     try:
161                         value = self.db.classes[link_class].lookup(value)
162                     except (TypeError, KeyError):
163                         raise IndexError, 'new property "%s": %s not a %s'%(
164                             key, value, link_class)
165                 elif not self.db.hasnode(link_class, value):
166                     raise IndexError, '%s has no node %s'%(link_class, value)
168                 # save off the value
169                 propvalues[key] = value
171                 # register the link with the newly linked node
172                 self.db.addjournal(link_class, value, 'link',
173                     (self.classname, newid, key))
175             elif isinstance(prop, Multilink):
176                 if type(value) != type([]):
177                     raise TypeError, 'new property "%s" not a list of ids'%key
178                 link_class = self.properties[key].classname
179                 l = []
180                 for entry in value:
181                     if type(entry) != type(''):
182                         raise ValueError, 'link value must be String'
183                     # if it isn't a number, it's a key
184                     if not num_re.match(entry):
185                         try:
186                             entry = self.db.classes[link_class].lookup(entry)
187                         except (TypeError, KeyError):
188                             raise IndexError, 'new property "%s": %s not a %s'%(
189                                 key, entry, self.properties[key].classname)
190                     l.append(entry)
191                 value = l
192                 propvalues[key] = value
194                 # handle additions
195                 for id in value:
196                     if not self.db.hasnode(link_class, id):
197                         raise IndexError, '%s has no node %s'%(link_class, id)
198                     # register the link with the newly linked node
199                     self.db.addjournal(link_class, id, 'link',
200                         (self.classname, newid, key))
202             elif isinstance(prop, String):
203                 if type(value) != type(''):
204                     raise TypeError, 'new property "%s" not a string'%key
206             elif isinstance(prop, Password):
207                 if not isinstance(value, password.Password):
208                     raise TypeError, 'new property "%s" not a Password'%key
210             elif isinstance(prop, Date):
211                 if not isinstance(value, date.Date):
212                     raise TypeError, 'new property "%s" not a Date'%key
214             elif isinstance(prop, Interval):
215                 if not isinstance(value, date.Interval):
216                     raise TypeError, 'new property "%s" not an Interval'%key
218         # make sure there's data where there needs to be
219         for key, prop in self.properties.items():
220             if propvalues.has_key(key):
221                 continue
222             if key == self.key:
223                 raise ValueError, 'key property "%s" is required'%key
224             if isinstance(prop, Multilink):
225                 propvalues[key] = []
226             else:
227                 propvalues[key] = None
229         # convert all data to strings
230         for key, prop in self.properties.items():
231             if isinstance(prop, Date):
232                 propvalues[key] = propvalues[key].get_tuple()
233             elif isinstance(prop, Interval):
234                 propvalues[key] = propvalues[key].get_tuple()
235             elif isinstance(prop, Password):
236                 propvalues[key] = str(propvalues[key])
238         # done
239         self.db.addnode(self.classname, newid, propvalues)
240         self.db.addjournal(self.classname, newid, 'create', propvalues)
241         return newid
243     def get(self, nodeid, propname, default=_marker, cache=1):
244         """Get the value of a property on an existing node of this class.
246         'nodeid' must be the id of an existing node of this class or an
247         IndexError is raised.  'propname' must be the name of a property
248         of this class or a KeyError is raised.
250         'cache' indicates whether the transaction cache should be queried
251         for the node. If the node has been modified and you need to
252         determine what its values prior to modification are, you need to
253         set cache=0.
254         """
255         if propname == 'id':
256             return nodeid
258         # get the node's dict
259         d = self.db.getnode(self.classname, nodeid, cache=cache)
260         if not d.has_key(propname) and default is not _marker:
261             return default
263         # get the value
264         prop = self.properties[propname]
266         # possibly convert the marshalled data to instances
267         if isinstance(prop, Date):
268             return date.Date(d[propname])
269         elif isinstance(prop, Interval):
270             return date.Interval(d[propname])
271         elif isinstance(prop, Password):
272             p = password.Password()
273             p.unpack(d[propname])
274             return p
276         return d[propname]
278     # XXX not in spec
279     def getnode(self, nodeid, cache=1):
280         ''' Return a convenience wrapper for the node.
282         'nodeid' must be the id of an existing node of this class or an
283         IndexError is raised.
285         'cache' indicates whether the transaction cache should be queried
286         for the node. If the node has been modified and you need to
287         determine what its values prior to modification are, you need to
288         set cache=0.
289         '''
290         return Node(self, nodeid, cache=cache)
292     def set(self, nodeid, **propvalues):
293         """Modify a property on an existing node of this class.
294         
295         'nodeid' must be the id of an existing node of this class or an
296         IndexError is raised.
298         Each key in 'propvalues' must be the name of a property of this
299         class or a KeyError is raised.
301         All values in 'propvalues' must be acceptable types for their
302         corresponding properties or a TypeError is raised.
304         If the value of the key property is set, it must not collide with
305         other key strings or a ValueError is raised.
307         If the value of a Link or Multilink property contains an invalid
308         node id, a ValueError is raised.
309         """
310         if not propvalues:
311             return
313         if propvalues.has_key('id'):
314             raise KeyError, '"id" is reserved'
316         if self.db.journaltag is None:
317             raise DatabaseError, 'Database open read-only'
319         node = self.db.getnode(self.classname, nodeid)
320         if node.has_key(self.db.RETIRED_FLAG):
321             raise IndexError
322         num_re = re.compile('^\d+$')
323         for key, value in propvalues.items():
324             # check to make sure we're not duplicating an existing key
325             if key == self.key and node[key] != value:
326                 try:
327                     self.lookup(value)
328                 except KeyError:
329                     pass
330                 else:
331                     raise ValueError, 'node with key "%s" exists'%value
333             # this will raise the KeyError if the property isn't valid
334             # ... we don't use getprops() here because we only care about
335             # the writeable properties.
336             prop = self.properties[key]
338             if isinstance(prop, Link):
339                 link_class = self.properties[key].classname
340                 # if it isn't a number, it's a key
341                 if type(value) != type(''):
342                     raise ValueError, 'link value must be String'
343                 if not num_re.match(value):
344                     try:
345                         value = self.db.classes[link_class].lookup(value)
346                     except (TypeError, KeyError):
347                         raise IndexError, 'new property "%s": %s not a %s'%(
348                             key, value, self.properties[key].classname)
350                 if not self.db.hasnode(link_class, value):
351                     raise IndexError, '%s has no node %s'%(link_class, value)
353                 # register the unlink with the old linked node
354                 if node[key] is not None:
355                     self.db.addjournal(link_class, node[key], 'unlink',
356                         (self.classname, nodeid, key))
358                 # register the link with the newly linked node
359                 if value is not None:
360                     self.db.addjournal(link_class, value, 'link',
361                         (self.classname, nodeid, key))
363             elif isinstance(prop, Multilink):
364                 if type(value) != type([]):
365                     raise TypeError, 'new property "%s" not a list of ids'%key
366                 link_class = self.properties[key].classname
367                 l = []
368                 for entry in value:
369                     # if it isn't a number, it's a key
370                     if type(entry) != type(''):
371                         raise ValueError, 'link value must be String'
372                     if not num_re.match(entry):
373                         try:
374                             entry = self.db.classes[link_class].lookup(entry)
375                         except (TypeError, KeyError):
376                             raise IndexError, 'new property "%s": %s not a %s'%(
377                                 key, entry, self.properties[key].classname)
378                     l.append(entry)
379                 value = l
380                 propvalues[key] = value
382                 #handle removals
383                 l = node[key]
384                 for id in l[:]:
385                     if id in value:
386                         continue
387                     # register the unlink with the old linked node
388                     self.db.addjournal(link_class, id, 'unlink',
389                         (self.classname, nodeid, key))
390                     l.remove(id)
392                 # handle additions
393                 for id in value:
394                     if not self.db.hasnode(link_class, id):
395                         raise IndexError, '%s has no node %s'%(link_class, id)
396                     if id in l:
397                         continue
398                     # register the link with the newly linked node
399                     self.db.addjournal(link_class, id, 'link',
400                         (self.classname, nodeid, key))
401                     l.append(id)
403             elif isinstance(prop, String):
404                 if value is not None and type(value) != type(''):
405                     raise TypeError, 'new property "%s" not a string'%key
407             elif isinstance(prop, Password):
408                 if not isinstance(value, password.Password):
409                     raise TypeError, 'new property "%s" not a Password'% key
410                 propvalues[key] = value = str(value)
412             elif isinstance(prop, Date):
413                 if not isinstance(value, date.Date):
414                     raise TypeError, 'new property "%s" not a Date'% key
415                 propvalues[key] = value = value.get_tuple()
417             elif isinstance(prop, Interval):
418                 if not isinstance(value, date.Interval):
419                     raise TypeError, 'new property "%s" not an Interval'% key
420                 propvalues[key] = value = value.get_tuple()
422             node[key] = value
424         self.db.setnode(self.classname, nodeid, node)
425         self.db.addjournal(self.classname, nodeid, 'set', propvalues)
427     def retire(self, nodeid):
428         """Retire a node.
429         
430         The properties on the node remain available from the get() method,
431         and the node's id is never reused.
432         
433         Retired nodes are not returned by the find(), list(), or lookup()
434         methods, and other nodes may reuse the values of their key properties.
435         """
436         if self.db.journaltag is None:
437             raise DatabaseError, 'Database open read-only'
438         node = self.db.getnode(self.classname, nodeid)
439         node[self.db.RETIRED_FLAG] = 1
440         self.db.setnode(self.classname, nodeid, node)
441         self.db.addjournal(self.classname, nodeid, 'retired', None)
443     def history(self, nodeid):
444         """Retrieve the journal of edits on a particular node.
446         'nodeid' must be the id of an existing node of this class or an
447         IndexError is raised.
449         The returned list contains tuples of the form
451             (date, tag, action, params)
453         'date' is a Timestamp object specifying the time of the change and
454         'tag' is the journaltag specified when the database was opened.
455         """
456         return self.db.getjournal(self.classname, nodeid)
458     # Locating nodes:
460     def setkey(self, propname):
461         """Select a String property of this class to be the key property.
463         'propname' must be the name of a String property of this class or
464         None, or a TypeError is raised.  The values of the key property on
465         all existing nodes must be unique or a ValueError is raised.
466         """
467         # TODO: validate that the property is a String!
468         self.key = propname
470     def getkey(self):
471         """Return the name of the key property for this class or None."""
472         return self.key
474     def labelprop(self, default_to_id=0):
475         ''' Return the property name for a label for the given node.
477         This method attempts to generate a consistent label for the node.
478         It tries the following in order:
479             1. key property
480             2. "name" property
481             3. "title" property
482             4. first property from the sorted property name list
483         '''
484         k = self.getkey()
485         if  k:
486             return k
487         props = self.getprops()
488         if props.has_key('name'):
489             return 'name'
490         elif props.has_key('title'):
491             return 'title'
492         if default_to_id:
493             return 'id'
494         props = props.keys()
495         props.sort()
496         return props[0]
498     # TODO: set up a separate index db file for this? profile?
499     def lookup(self, keyvalue):
500         """Locate a particular node by its key property and return its id.
502         If this class has no key property, a TypeError is raised.  If the
503         'keyvalue' matches one of the values for the key property among
504         the nodes in this class, the matching node's id is returned;
505         otherwise a KeyError is raised.
506         """
507         cldb = self.db.getclassdb(self.classname)
508         for nodeid in self.db.getnodeids(self.classname, cldb):
509             node = self.db.getnode(self.classname, nodeid, cldb)
510             if node.has_key(self.db.RETIRED_FLAG):
511                 continue
512             if node[self.key] == keyvalue:
513                 return nodeid
514         raise KeyError, keyvalue
516     # XXX: change from spec - allows multiple props to match
517     def find(self, **propspec):
518         """Get the ids of nodes in this class which link to a given node.
520         'propspec' consists of keyword args propname=nodeid   
521           'propname' must be the name of a property in this class, or a
522             KeyError is raised.  That property must be a Link or Multilink
523             property, or a TypeError is raised.
525           'nodeid' must be the id of an existing node in the class linked
526             to by the given property, or an IndexError is raised.
527         """
528         propspec = propspec.items()
529         for propname, nodeid in propspec:
530             # check the prop is OK
531             prop = self.properties[propname]
532             if not isinstance(prop, Link) and not isinstance(prop, Multilink):
533                 raise TypeError, "'%s' not a Link/Multilink property"%propname
534             if not self.db.hasnode(prop.classname, nodeid):
535                 raise ValueError, '%s has no node %s'%(prop.classname, nodeid)
537         # ok, now do the find
538         cldb = self.db.getclassdb(self.classname)
539         l = []
540         for id in self.db.getnodeids(self.classname, cldb):
541             node = self.db.getnode(self.classname, id, cldb)
542             if node.has_key(self.db.RETIRED_FLAG):
543                 continue
544             for propname, nodeid in propspec:
545                 property = node[propname]
546                 if isinstance(prop, Link) and nodeid == property:
547                     l.append(id)
548                 elif isinstance(prop, Multilink) and nodeid in property:
549                     l.append(id)
550         return l
552     def stringFind(self, **requirements):
553         """Locate a particular node by matching a set of its String
554         properties in a caseless search.
556         If the property is not a String property, a TypeError is raised.
557         
558         The return is a list of the id of all nodes that match.
559         """
560         for propname in requirements.keys():
561             prop = self.properties[propname]
562             if isinstance(not prop, String):
563                 raise TypeError, "'%s' not a String property"%propname
564             requirements[propname] = requirements[propname].lower()
565         l = []
566         cldb = self.db.getclassdb(self.classname)
567         for nodeid in self.db.getnodeids(self.classname, cldb):
568             node = self.db.getnode(self.classname, nodeid, cldb)
569             if node.has_key(self.db.RETIRED_FLAG):
570                 continue
571             for key, value in requirements.items():
572                 if node[key] and node[key].lower() != value:
573                     break
574             else:
575                 l.append(nodeid)
576         return l
578     def list(self):
579         """Return a list of the ids of the active nodes in this class."""
580         l = []
581         cn = self.classname
582         cldb = self.db.getclassdb(cn)
583         for nodeid in self.db.getnodeids(cn, cldb):
584             node = self.db.getnode(cn, nodeid, cldb)
585             if node.has_key(self.db.RETIRED_FLAG):
586                 continue
587             l.append(nodeid)
588         l.sort()
589         return l
591     # XXX not in spec
592     def filter(self, filterspec, sort, group, num_re = re.compile('^\d+$')):
593         ''' Return a list of the ids of the active nodes in this class that
594             match the 'filter' spec, sorted by the group spec and then the
595             sort spec
596         '''
597         cn = self.classname
599         # optimise filterspec
600         l = []
601         props = self.getprops()
602         for k, v in filterspec.items():
603             propclass = props[k]
604             if isinstance(propclass, Link):
605                 if type(v) is not type([]):
606                     v = [v]
607                 # replace key values with node ids
608                 u = []
609                 link_class =  self.db.classes[propclass.classname]
610                 for entry in v:
611                     if entry == '-1': entry = None
612                     elif not num_re.match(entry):
613                         try:
614                             entry = link_class.lookup(entry)
615                         except (TypeError,KeyError):
616                             raise ValueError, 'property "%s": %s not a %s'%(
617                                 k, entry, self.properties[k].classname)
618                     u.append(entry)
620                 l.append((0, k, u))
621             elif isinstance(propclass, Multilink):
622                 if type(v) is not type([]):
623                     v = [v]
624                 # replace key values with node ids
625                 u = []
626                 link_class =  self.db.classes[propclass.classname]
627                 for entry in v:
628                     if not num_re.match(entry):
629                         try:
630                             entry = link_class.lookup(entry)
631                         except (TypeError,KeyError):
632                             raise ValueError, 'new property "%s": %s not a %s'%(
633                                 k, entry, self.properties[k].classname)
634                     u.append(entry)
635                 l.append((1, k, u))
636             elif isinstance(propclass, String):
637                 # simple glob searching
638                 v = re.sub(r'([\|\{\}\\\.\+\[\]\(\)])', r'\\\1', v)
639                 v = v.replace('?', '.')
640                 v = v.replace('*', '.*?')
641                 l.append((2, k, re.compile(v, re.I)))
642             else:
643                 l.append((6, k, v))
644         filterspec = l
646         # now, find all the nodes that are active and pass filtering
647         l = []
648         cldb = self.db.getclassdb(cn)
649         for nodeid in self.db.getnodeids(cn, cldb):
650             node = self.db.getnode(cn, nodeid, cldb)
651             if node.has_key(self.db.RETIRED_FLAG):
652                 continue
653             # apply filter
654             for t, k, v in filterspec:
655                 # this node doesn't have this property, so reject it
656                 if not node.has_key(k): break
658                 if t == 0 and node[k] not in v:
659                     # link - if this node'd property doesn't appear in the
660                     # filterspec's nodeid list, skip it
661                     break
662                 elif t == 1:
663                     # multilink - if any of the nodeids required by the
664                     # filterspec aren't in this node's property, then skip
665                     # it
666                     for value in v:
667                         if value not in node[k]:
668                             break
669                     else:
670                         continue
671                     break
672                 elif t == 2 and not v.search(node[k]):
673                     # RE search
674                     break
675                 elif t == 6 and node[k] != v:
676                     # straight value comparison for the other types
677                     break
678             else:
679                 l.append((nodeid, node))
680         l.sort()
682         # optimise sort
683         m = []
684         for entry in sort:
685             if entry[0] != '-':
686                 m.append(('+', entry))
687             else:
688                 m.append((entry[0], entry[1:]))
689         sort = m
691         # optimise group
692         m = []
693         for entry in group:
694             if entry[0] != '-':
695                 m.append(('+', entry))
696             else:
697                 m.append((entry[0], entry[1:]))
698         group = m
699         # now, sort the result
700         def sortfun(a, b, sort=sort, group=group, properties=self.getprops(),
701                 db = self.db, cl=self):
702             a_id, an = a
703             b_id, bn = b
704             # sort by group and then sort
705             for list in group, sort:
706                 for dir, prop in list:
707                     # sorting is class-specific
708                     propclass = properties[prop]
710                     # handle the properties that might be "faked"
711                     # also, handle possible missing properties
712                     try:
713                         if not an.has_key(prop):
714                             an[prop] = cl.get(a_id, prop)
715                         av = an[prop]
716                     except KeyError:
717                         # the node doesn't have a value for this property
718                         if isinstance(propclass, Multilink): av = []
719                         else: av = ''
720                     try:
721                         if not bn.has_key(prop):
722                             bn[prop] = cl.get(b_id, prop)
723                         bv = bn[prop]
724                     except KeyError:
725                         # the node doesn't have a value for this property
726                         if isinstance(propclass, Multilink): bv = []
727                         else: bv = ''
729                     # String and Date values are sorted in the natural way
730                     if isinstance(propclass, String):
731                         # clean up the strings
732                         if av and av[0] in string.uppercase:
733                             av = an[prop] = av.lower()
734                         if bv and bv[0] in string.uppercase:
735                             bv = bn[prop] = bv.lower()
736                     if (isinstance(propclass, String) or
737                             isinstance(propclass, Date)):
738                         # it might be a string that's really an integer
739                         try:
740                             av = int(av)
741                             bv = int(bv)
742                         except:
743                             pass
744                         if dir == '+':
745                             r = cmp(av, bv)
746                             if r != 0: return r
747                         elif dir == '-':
748                             r = cmp(bv, av)
749                             if r != 0: return r
751                     # Link properties are sorted according to the value of
752                     # the "order" property on the linked nodes if it is
753                     # present; or otherwise on the key string of the linked
754                     # nodes; or finally on  the node ids.
755                     elif isinstance(propclass, Link):
756                         link = db.classes[propclass.classname]
757                         if av is None and bv is not None: return -1
758                         if av is not None and bv is None: return 1
759                         if av is None and bv is None: continue
760                         if link.getprops().has_key('order'):
761                             if dir == '+':
762                                 r = cmp(link.get(av, 'order'),
763                                     link.get(bv, 'order'))
764                                 if r != 0: return r
765                             elif dir == '-':
766                                 r = cmp(link.get(bv, 'order'),
767                                     link.get(av, 'order'))
768                                 if r != 0: return r
769                         elif link.getkey():
770                             key = link.getkey()
771                             if dir == '+':
772                                 r = cmp(link.get(av, key), link.get(bv, key))
773                                 if r != 0: return r
774                             elif dir == '-':
775                                 r = cmp(link.get(bv, key), link.get(av, key))
776                                 if r != 0: return r
777                         else:
778                             if dir == '+':
779                                 r = cmp(av, bv)
780                                 if r != 0: return r
781                             elif dir == '-':
782                                 r = cmp(bv, av)
783                                 if r != 0: return r
785                     # Multilink properties are sorted according to how many
786                     # links are present.
787                     elif isinstance(propclass, Multilink):
788                         if dir == '+':
789                             r = cmp(len(av), len(bv))
790                             if r != 0: return r
791                         elif dir == '-':
792                             r = cmp(len(bv), len(av))
793                             if r != 0: return r
794                 # end for dir, prop in list:
795             # end for list in sort, group:
796             # if all else fails, compare the ids
797             return cmp(a[0], b[0])
799         l.sort(sortfun)
800         return [i[0] for i in l]
802     def count(self):
803         """Get the number of nodes in this class.
805         If the returned integer is 'numnodes', the ids of all the nodes
806         in this class run from 1 to numnodes, and numnodes+1 will be the
807         id of the next node to be created in this class.
808         """
809         return self.db.countnodes(self.classname)
811     # Manipulating properties:
813     def getprops(self, protected=1):
814         """Return a dictionary mapping property names to property objects.
815            If the "protected" flag is true, we include protected properties -
816            those which may not be modified."""
817         d = self.properties.copy()
818         if protected:
819             d['id'] = String()
820         return d
822     def addprop(self, **properties):
823         """Add properties to this class.
825         The keyword arguments in 'properties' must map names to property
826         objects, or a TypeError is raised.  None of the keys in 'properties'
827         may collide with the names of existing properties, or a ValueError
828         is raised before any properties have been added.
829         """
830         for key in properties.keys():
831             if self.properties.has_key(key):
832                 raise ValueError, key
833         self.properties.update(properties)
836 # XXX not in spec
837 class Node:
838     ''' A convenience wrapper for the given node
839     '''
840     def __init__(self, cl, nodeid, cache=1):
841         self.__dict__['cl'] = cl
842         self.__dict__['nodeid'] = nodeid
843         self.cache = cache
844     def keys(self, protected=1):
845         return self.cl.getprops(protected=protected).keys()
846     def values(self, protected=1):
847         l = []
848         for name in self.cl.getprops(protected=protected).keys():
849             l.append(self.cl.get(self.nodeid, name, cache=self.cache))
850         return l
851     def items(self, protected=1):
852         l = []
853         for name in self.cl.getprops(protected=protected).keys():
854             l.append((name, self.cl.get(self.nodeid, name, cache=self.cache)))
855         return l
856     def has_key(self, name):
857         return self.cl.getprops().has_key(name)
858     def __getattr__(self, name):
859         if self.__dict__.has_key(name):
860             return self.__dict__[name]
861         try:
862             return self.cl.get(self.nodeid, name, cache=self.cache)
863         except KeyError, value:
864             # we trap this but re-raise it as AttributeError - all other
865             # exceptions should pass through untrapped
866             pass
867         # nope, no such attribute
868         raise AttributeError, str(value)
869     def __getitem__(self, name):
870         return self.cl.get(self.nodeid, name, cache=self.cache)
871     def __setattr__(self, name, value):
872         try:
873             return self.cl.set(self.nodeid, **{name: value})
874         except KeyError, value:
875             raise AttributeError, str(value)
876     def __setitem__(self, name, value):
877         self.cl.set(self.nodeid, **{name: value})
878     def history(self):
879         return self.cl.history(self.nodeid)
880     def retire(self):
881         return self.cl.retire(self.nodeid)
884 def Choice(name, *options):
885     cl = Class(db, name, name=hyperdb.String(), order=hyperdb.String())
886     for i in range(len(options)):
887         cl.create(name=option[i], order=i)
888     return hyperdb.Link(name)
891 # $Log: not supported by cvs2svn $
892 # Revision 1.43  2001/12/20 06:13:24  rochecompaan
893 # Bugs fixed:
894 #   . Exception handling in hyperdb for strings-that-look-like numbers got
895 #     lost somewhere
896 #   . Internet Explorer submits full path for filename - we now strip away
897 #     the path
898 # Features added:
899 #   . Link and multilink properties are now displayed sorted in the cgi
900 #     interface
902 # Revision 1.42  2001/12/16 10:53:37  richard
903 # take a copy of the node dict so that the subsequent set
904 # operation doesn't modify the oldvalues structure
906 # Revision 1.41  2001/12/15 23:47:47  richard
907 # Cleaned up some bare except statements
909 # Revision 1.40  2001/12/14 23:42:57  richard
910 # yuck, a gdbm instance tests false :(
911 # I've left the debugging code in - it should be removed one day if we're ever
912 # _really_ anal about performace :)
914 # Revision 1.39  2001/12/02 05:06:16  richard
915 # . We now use weakrefs in the Classes to keep the database reference, so
916 #   the close() method on the database is no longer needed.
917 #   I bumped the minimum python requirement up to 2.1 accordingly.
918 # . #487480 ] roundup-server
919 # . #487476 ] INSTALL.txt
921 # I also cleaned up the change message / post-edit stuff in the cgi client.
922 # There's now a clearly marked "TODO: append the change note" where I believe
923 # the change note should be added there. The "changes" list will obviously
924 # have to be modified to be a dict of the changes, or somesuch.
926 # More testing needed.
928 # Revision 1.38  2001/12/01 07:17:50  richard
929 # . We now have basic transaction support! Information is only written to
930 #   the database when the commit() method is called. Only the anydbm
931 #   backend is modified in this way - neither of the bsddb backends have been.
932 #   The mail, admin and cgi interfaces all use commit (except the admin tool
933 #   doesn't have a commit command, so interactive users can't commit...)
934 # . Fixed login/registration forwarding the user to the right page (or not,
935 #   on a failure)
937 # Revision 1.37  2001/11/28 21:55:35  richard
938 #  . login_action and newuser_action return values were being ignored
939 #  . Woohoo! Found that bloody re-login bug that was killing the mail
940 #    gateway.
941 #  (also a minor cleanup in hyperdb)
943 # Revision 1.36  2001/11/27 03:16:09  richard
944 # Another place that wasn't handling missing properties.
946 # Revision 1.35  2001/11/22 15:46:42  jhermann
947 # Added module docstrings to all modules.
949 # Revision 1.34  2001/11/21 04:04:43  richard
950 # *sigh* more missing value handling
952 # Revision 1.33  2001/11/21 03:40:54  richard
953 # more new property handling
955 # Revision 1.32  2001/11/21 03:11:28  richard
956 # Better handling of new properties.
958 # Revision 1.31  2001/11/12 22:01:06  richard
959 # Fixed issues with nosy reaction and author copies.
961 # Revision 1.30  2001/11/09 10:11:08  richard
962 #  . roundup-admin now handles all hyperdb exceptions
964 # Revision 1.29  2001/10/27 00:17:41  richard
965 # Made Class.stringFind() do caseless matching.
967 # Revision 1.28  2001/10/21 04:44:50  richard
968 # bug #473124: UI inconsistency with Link fields.
969 #    This also prompted me to fix a fairly long-standing usability issue -
970 #    that of being able to turn off certain filters.
972 # Revision 1.27  2001/10/20 23:44:27  richard
973 # Hyperdatabase sorts strings-that-look-like-numbers as numbers now.
975 # Revision 1.26  2001/10/16 03:48:01  richard
976 # admin tool now complains if a "find" is attempted with a non-link property.
978 # Revision 1.25  2001/10/11 00:17:51  richard
979 # Reverted a change in hyperdb so the default value for missing property
980 # values in a create() is None and not '' (the empty string.) This obviously
981 # breaks CSV import/export - the string 'None' will be created in an
982 # export/import operation.
984 # Revision 1.24  2001/10/10 03:54:57  richard
985 # Added database importing and exporting through CSV files.
986 # Uses the csv module from object-craft for exporting if it's available.
987 # Requires the csv module for importing.
989 # Revision 1.23  2001/10/09 23:58:10  richard
990 # Moved the data stringification up into the hyperdb.Class class' get, set
991 # and create methods. This means that the data is also stringified for the
992 # journal call, and removes duplication of code from the backends. The
993 # backend code now only sees strings.
995 # Revision 1.22  2001/10/09 07:25:59  richard
996 # Added the Password property type. See "pydoc roundup.password" for
997 # implementation details. Have updated some of the documentation too.
999 # Revision 1.21  2001/10/05 02:23:24  richard
1000 #  . roundup-admin create now prompts for property info if none is supplied
1001 #    on the command-line.
1002 #  . hyperdb Class getprops() method may now return only the mutable
1003 #    properties.
1004 #  . Login now uses cookies, which makes it a whole lot more flexible. We can
1005 #    now support anonymous user access (read-only, unless there's an
1006 #    "anonymous" user, in which case write access is permitted). Login
1007 #    handling has been moved into cgi_client.Client.main()
1008 #  . The "extended" schema is now the default in roundup init.
1009 #  . The schemas have had their page headings modified to cope with the new
1010 #    login handling. Existing installations should copy the interfaces.py
1011 #    file from the roundup lib directory to their instance home.
1012 #  . Incorrectly had a Bizar Software copyright on the cgitb.py module from
1013 #    Ping - has been removed.
1014 #  . Fixed a whole bunch of places in the CGI interface where we should have
1015 #    been returning Not Found instead of throwing an exception.
1016 #  . Fixed a deviation from the spec: trying to modify the 'id' property of
1017 #    an item now throws an exception.
1019 # Revision 1.20  2001/10/04 02:12:42  richard
1020 # Added nicer command-line item adding: passing no arguments will enter an
1021 # interactive more which asks for each property in turn. While I was at it, I
1022 # fixed an implementation problem WRT the spec - I wasn't raising a
1023 # ValueError if the key property was missing from a create(). Also added a
1024 # protected=boolean argument to getprops() so we can list only the mutable
1025 # properties (defaults to yes, which lists the immutables).
1027 # Revision 1.19  2001/08/29 04:47:18  richard
1028 # Fixed CGI client change messages so they actually include the properties
1029 # changed (again).
1031 # Revision 1.18  2001/08/16 07:34:59  richard
1032 # better CGI text searching - but hidden filter fields are disappearing...
1034 # Revision 1.17  2001/08/16 06:59:58  richard
1035 # all searches use re now - and they're all case insensitive
1037 # Revision 1.16  2001/08/15 23:43:18  richard
1038 # Fixed some isFooTypes that I missed.
1039 # Refactored some code in the CGI code.
1041 # Revision 1.15  2001/08/12 06:32:36  richard
1042 # using isinstance(blah, Foo) now instead of isFooType
1044 # Revision 1.14  2001/08/07 00:24:42  richard
1045 # stupid typo
1047 # Revision 1.13  2001/08/07 00:15:51  richard
1048 # Added the copyright/license notice to (nearly) all files at request of
1049 # Bizar Software.
1051 # Revision 1.12  2001/08/02 06:38:17  richard
1052 # Roundupdb now appends "mailing list" information to its messages which
1053 # include the e-mail address and web interface address. Templates may
1054 # override this in their db classes to include specific information (support
1055 # instructions, etc).
1057 # Revision 1.11  2001/08/01 04:24:21  richard
1058 # mailgw was assuming certain properties existed on the issues being created.
1060 # Revision 1.10  2001/07/30 02:38:31  richard
1061 # get() now has a default arg - for migration only.
1063 # Revision 1.9  2001/07/29 09:28:23  richard
1064 # Fixed sorting by clicking on column headings.
1066 # Revision 1.8  2001/07/29 08:27:40  richard
1067 # Fixed handling of passed-in values in form elements (ie. during a
1068 # drill-down)
1070 # Revision 1.7  2001/07/29 07:01:39  richard
1071 # Added vim command to all source so that we don't get no steenkin' tabs :)
1073 # Revision 1.6  2001/07/29 05:36:14  richard
1074 # Cleanup of the link label generation.
1076 # Revision 1.5  2001/07/29 04:05:37  richard
1077 # Added the fabricated property "id".
1079 # Revision 1.4  2001/07/27 06:25:35  richard
1080 # Fixed some of the exceptions so they're the right type.
1081 # Removed the str()-ification of node ids so we don't mask oopsy errors any
1082 # more.
1084 # Revision 1.3  2001/07/27 05:17:14  richard
1085 # just some comments
1087 # Revision 1.2  2001/07/22 12:09:32  richard
1088 # Final commit of Grande Splite
1090 # Revision 1.1  2001/07/22 11:58:35  richard
1091 # More Grande Splite
1094 # vim: set filetype=python ts=4 sw=4 et si