Code

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