Code

Bugs fixed:
[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.43 2001-12-20 06:13:24 rochecompaan Exp $
20 __doc__ = """
21 Hyperdatabase implementation, especially field types.
22 """
24 # standard python modules
25 import cPickle, re, string, weakref
27 # roundup modules
28 import date, password
31 #
32 # Types
33 #
34 class String:
35     """An object designating a String property."""
36     def __repr__(self):
37         return '<%s>'%self.__class__
39 class Password:
40     """An object designating a Password property."""
41     def __repr__(self):
42         return '<%s>'%self.__class__
44 class Date:
45     """An object designating a Date property."""
46     def __repr__(self):
47         return '<%s>'%self.__class__
49 class Interval:
50     """An object designating an Interval property."""
51     def __repr__(self):
52         return '<%s>'%self.__class__
54 class Link:
55     """An object designating a Link property that links to a
56        node in a specified class."""
57     def __init__(self, classname):
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):
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.
249         """
250         if propname == 'id':
251             return nodeid
253         # get the node's dict
254         d = self.db.getnode(self.classname, nodeid)
255         if not d.has_key(propname) and default is not _marker:
256             return default
258         # get the value
259         prop = self.properties[propname]
261         # possibly convert the marshalled data to instances
262         if isinstance(prop, Date):
263             return date.Date(d[propname])
264         elif isinstance(prop, Interval):
265             return date.Interval(d[propname])
266         elif isinstance(prop, Password):
267             p = password.Password()
268             p.unpack(d[propname])
269             return p
271         return d[propname]
273     # XXX not in spec
274     def getnode(self, nodeid):
275         ''' Return a convenience wrapper for the node
276         '''
277         return Node(self, nodeid)
279     def set(self, nodeid, **propvalues):
280         """Modify a property on an existing node of this class.
281         
282         'nodeid' must be the id of an existing node of this class or an
283         IndexError is raised.
285         Each key in 'propvalues' must be the name of a property of this
286         class or a KeyError is raised.
288         All values in 'propvalues' must be acceptable types for their
289         corresponding properties or a TypeError is raised.
291         If the value of the key property is set, it must not collide with
292         other key strings or a ValueError is raised.
294         If the value of a Link or Multilink property contains an invalid
295         node id, a ValueError is raised.
296         """
297         if not propvalues:
298             return
300         if propvalues.has_key('id'):
301             raise KeyError, '"id" is reserved'
303         if self.db.journaltag is None:
304             raise DatabaseError, 'Database open read-only'
306         node = self.db.getnode(self.classname, nodeid)
307         if node.has_key(self.db.RETIRED_FLAG):
308             raise IndexError
309         num_re = re.compile('^\d+$')
310         for key, value in propvalues.items():
311             # check to make sure we're not duplicating an existing key
312             if key == self.key and node[key] != value:
313                 try:
314                     self.lookup(value)
315                 except KeyError:
316                     pass
317                 else:
318                     raise ValueError, 'node with key "%s" exists'%value
320             # this will raise the KeyError if the property isn't valid
321             # ... we don't use getprops() here because we only care about
322             # the writeable properties.
323             prop = self.properties[key]
325             if isinstance(prop, Link):
326                 link_class = self.properties[key].classname
327                 # if it isn't a number, it's a key
328                 if type(value) != type(''):
329                     raise ValueError, 'link value must be String'
330                 if not num_re.match(value):
331                     try:
332                         value = self.db.classes[link_class].lookup(value)
333                     except (TypeError, KeyError):
334                         raise IndexError, 'new property "%s": %s not a %s'%(
335                             key, value, self.properties[key].classname)
337                 if not self.db.hasnode(link_class, value):
338                     raise IndexError, '%s has no node %s'%(link_class, value)
340                 # register the unlink with the old linked node
341                 if node[key] is not None:
342                     self.db.addjournal(link_class, node[key], 'unlink',
343                         (self.classname, nodeid, key))
345                 # register the link with the newly linked node
346                 if value is not None:
347                     self.db.addjournal(link_class, value, 'link',
348                         (self.classname, nodeid, key))
350             elif isinstance(prop, Multilink):
351                 if type(value) != type([]):
352                     raise TypeError, 'new property "%s" not a list of ids'%key
353                 link_class = self.properties[key].classname
354                 l = []
355                 for entry in value:
356                     # if it isn't a number, it's a key
357                     if type(entry) != type(''):
358                         raise ValueError, 'link value must be String'
359                     if not num_re.match(entry):
360                         try:
361                             entry = self.db.classes[link_class].lookup(entry)
362                         except (TypeError, KeyError):
363                             raise IndexError, 'new property "%s": %s not a %s'%(
364                                 key, entry, self.properties[key].classname)
365                     l.append(entry)
366                 value = l
367                 propvalues[key] = value
369                 #handle removals
370                 l = node[key]
371                 for id in l[:]:
372                     if id in value:
373                         continue
374                     # register the unlink with the old linked node
375                     self.db.addjournal(link_class, id, 'unlink',
376                         (self.classname, nodeid, key))
377                     l.remove(id)
379                 # handle additions
380                 for id in value:
381                     if not self.db.hasnode(link_class, id):
382                         raise IndexError, '%s has no node %s'%(link_class, id)
383                     if id in l:
384                         continue
385                     # register the link with the newly linked node
386                     self.db.addjournal(link_class, id, 'link',
387                         (self.classname, nodeid, key))
388                     l.append(id)
390             elif isinstance(prop, String):
391                 if value is not None and type(value) != type(''):
392                     raise TypeError, 'new property "%s" not a string'%key
394             elif isinstance(prop, Password):
395                 if not isinstance(value, password.Password):
396                     raise TypeError, 'new property "%s" not a Password'% key
397                 propvalues[key] = value = str(value)
399             elif isinstance(prop, Date):
400                 if not isinstance(value, date.Date):
401                     raise TypeError, 'new property "%s" not a Date'% key
402                 propvalues[key] = value = value.get_tuple()
404             elif isinstance(prop, Interval):
405                 if not isinstance(value, date.Interval):
406                     raise TypeError, 'new property "%s" not an Interval'% key
407                 propvalues[key] = value = value.get_tuple()
409             node[key] = value
411         self.db.setnode(self.classname, nodeid, node)
412         self.db.addjournal(self.classname, nodeid, 'set', propvalues)
414     def retire(self, nodeid):
415         """Retire a node.
416         
417         The properties on the node remain available from the get() method,
418         and the node's id is never reused.
419         
420         Retired nodes are not returned by the find(), list(), or lookup()
421         methods, and other nodes may reuse the values of their key properties.
422         """
423         if self.db.journaltag is None:
424             raise DatabaseError, 'Database open read-only'
425         node = self.db.getnode(self.classname, nodeid)
426         node[self.db.RETIRED_FLAG] = 1
427         self.db.setnode(self.classname, nodeid, node)
428         self.db.addjournal(self.classname, nodeid, 'retired', None)
430     def history(self, nodeid):
431         """Retrieve the journal of edits on a particular node.
433         'nodeid' must be the id of an existing node of this class or an
434         IndexError is raised.
436         The returned list contains tuples of the form
438             (date, tag, action, params)
440         'date' is a Timestamp object specifying the time of the change and
441         'tag' is the journaltag specified when the database was opened.
442         """
443         return self.db.getjournal(self.classname, nodeid)
445     # Locating nodes:
447     def setkey(self, propname):
448         """Select a String property of this class to be the key property.
450         'propname' must be the name of a String property of this class or
451         None, or a TypeError is raised.  The values of the key property on
452         all existing nodes must be unique or a ValueError is raised.
453         """
454         # TODO: validate that the property is a String!
455         self.key = propname
457     def getkey(self):
458         """Return the name of the key property for this class or None."""
459         return self.key
461     def labelprop(self, default_to_id=0):
462         ''' Return the property name for a label for the given node.
464         This method attempts to generate a consistent label for the node.
465         It tries the following in order:
466             1. key property
467             2. "name" property
468             3. "title" property
469             4. first property from the sorted property name list
470         '''
471         k = self.getkey()
472         if  k:
473             return k
474         props = self.getprops()
475         if props.has_key('name'):
476             return 'name'
477         elif props.has_key('title'):
478             return 'title'
479         if default_to_id:
480             return 'id'
481         props = props.keys()
482         props.sort()
483         return props[0]
485     # TODO: set up a separate index db file for this? profile?
486     def lookup(self, keyvalue):
487         """Locate a particular node by its key property and return its id.
489         If this class has no key property, a TypeError is raised.  If the
490         'keyvalue' matches one of the values for the key property among
491         the nodes in this class, the matching node's id is returned;
492         otherwise a KeyError is raised.
493         """
494         cldb = self.db.getclassdb(self.classname)
495         for nodeid in self.db.getnodeids(self.classname, cldb):
496             node = self.db.getnode(self.classname, nodeid, cldb)
497             if node.has_key(self.db.RETIRED_FLAG):
498                 continue
499             if node[self.key] == keyvalue:
500                 return nodeid
501         raise KeyError, keyvalue
503     # XXX: change from spec - allows multiple props to match
504     def find(self, **propspec):
505         """Get the ids of nodes in this class which link to a given node.
507         'propspec' consists of keyword args propname=nodeid   
508           'propname' must be the name of a property in this class, or a
509             KeyError is raised.  That property must be a Link or Multilink
510             property, or a TypeError is raised.
512           'nodeid' must be the id of an existing node in the class linked
513             to by the given property, or an IndexError is raised.
514         """
515         propspec = propspec.items()
516         for propname, nodeid in propspec:
517             # check the prop is OK
518             prop = self.properties[propname]
519             if not isinstance(prop, Link) and not isinstance(prop, Multilink):
520                 raise TypeError, "'%s' not a Link/Multilink property"%propname
521             if not self.db.hasnode(prop.classname, nodeid):
522                 raise ValueError, '%s has no node %s'%(prop.classname, nodeid)
524         # ok, now do the find
525         cldb = self.db.getclassdb(self.classname)
526         l = []
527         for id in self.db.getnodeids(self.classname, cldb):
528             node = self.db.getnode(self.classname, id, cldb)
529             if node.has_key(self.db.RETIRED_FLAG):
530                 continue
531             for propname, nodeid in propspec:
532                 property = node[propname]
533                 if isinstance(prop, Link) and nodeid == property:
534                     l.append(id)
535                 elif isinstance(prop, Multilink) and nodeid in property:
536                     l.append(id)
537         return l
539     def stringFind(self, **requirements):
540         """Locate a particular node by matching a set of its String
541         properties in a caseless search.
543         If the property is not a String property, a TypeError is raised.
544         
545         The return is a list of the id of all nodes that match.
546         """
547         for propname in requirements.keys():
548             prop = self.properties[propname]
549             if isinstance(not prop, String):
550                 raise TypeError, "'%s' not a String property"%propname
551             requirements[propname] = requirements[propname].lower()
552         l = []
553         cldb = self.db.getclassdb(self.classname)
554         for nodeid in self.db.getnodeids(self.classname, cldb):
555             node = self.db.getnode(self.classname, nodeid, cldb)
556             if node.has_key(self.db.RETIRED_FLAG):
557                 continue
558             for key, value in requirements.items():
559                 if node[key] and node[key].lower() != value:
560                     break
561             else:
562                 l.append(nodeid)
563         return l
565     def list(self):
566         """Return a list of the ids of the active nodes in this class."""
567         l = []
568         cn = self.classname
569         cldb = self.db.getclassdb(cn)
570         for nodeid in self.db.getnodeids(cn, cldb):
571             node = self.db.getnode(cn, nodeid, cldb)
572             if node.has_key(self.db.RETIRED_FLAG):
573                 continue
574             l.append(nodeid)
575         l.sort()
576         return l
578     # XXX not in spec
579     def filter(self, filterspec, sort, group, num_re = re.compile('^\d+$')):
580         ''' Return a list of the ids of the active nodes in this class that
581             match the 'filter' spec, sorted by the group spec and then the
582             sort spec
583         '''
584         cn = self.classname
586         # optimise filterspec
587         l = []
588         props = self.getprops()
589         for k, v in filterspec.items():
590             propclass = props[k]
591             if isinstance(propclass, Link):
592                 if type(v) is not type([]):
593                     v = [v]
594                 # replace key values with node ids
595                 u = []
596                 link_class =  self.db.classes[propclass.classname]
597                 for entry in v:
598                     if entry == '-1': entry = None
599                     elif not num_re.match(entry):
600                         try:
601                             entry = link_class.lookup(entry)
602                         except (TypeError,KeyError):
603                             raise ValueError, 'property "%s": %s not a %s'%(
604                                 k, entry, self.properties[k].classname)
605                     u.append(entry)
607                 l.append((0, k, u))
608             elif isinstance(propclass, Multilink):
609                 if type(v) is not type([]):
610                     v = [v]
611                 # replace key values with node ids
612                 u = []
613                 link_class =  self.db.classes[propclass.classname]
614                 for entry in v:
615                     if not num_re.match(entry):
616                         try:
617                             entry = link_class.lookup(entry)
618                         except (TypeError,KeyError):
619                             raise ValueError, 'new property "%s": %s not a %s'%(
620                                 k, entry, self.properties[k].classname)
621                     u.append(entry)
622                 l.append((1, k, u))
623             elif isinstance(propclass, String):
624                 # simple glob searching
625                 v = re.sub(r'([\|\{\}\\\.\+\[\]\(\)])', r'\\\1', v)
626                 v = v.replace('?', '.')
627                 v = v.replace('*', '.*?')
628                 l.append((2, k, re.compile(v, re.I)))
629             else:
630                 l.append((6, k, v))
631         filterspec = l
633         # now, find all the nodes that are active and pass filtering
634         l = []
635         cldb = self.db.getclassdb(cn)
636         for nodeid in self.db.getnodeids(cn, cldb):
637             node = self.db.getnode(cn, nodeid, cldb)
638             if node.has_key(self.db.RETIRED_FLAG):
639                 continue
640             # apply filter
641             for t, k, v in filterspec:
642                 # this node doesn't have this property, so reject it
643                 if not node.has_key(k): break
645                 if t == 0 and node[k] not in v:
646                     # link - if this node'd property doesn't appear in the
647                     # filterspec's nodeid list, skip it
648                     break
649                 elif t == 1:
650                     # multilink - if any of the nodeids required by the
651                     # filterspec aren't in this node's property, then skip
652                     # it
653                     for value in v:
654                         if value not in node[k]:
655                             break
656                     else:
657                         continue
658                     break
659                 elif t == 2 and not v.search(node[k]):
660                     # RE search
661                     break
662                 elif t == 6 and node[k] != v:
663                     # straight value comparison for the other types
664                     break
665             else:
666                 l.append((nodeid, node))
667         l.sort()
669         # optimise sort
670         m = []
671         for entry in sort:
672             if entry[0] != '-':
673                 m.append(('+', entry))
674             else:
675                 m.append((entry[0], entry[1:]))
676         sort = m
678         # optimise group
679         m = []
680         for entry in group:
681             if entry[0] != '-':
682                 m.append(('+', entry))
683             else:
684                 m.append((entry[0], entry[1:]))
685         group = m
686         # now, sort the result
687         def sortfun(a, b, sort=sort, group=group, properties=self.getprops(),
688                 db = self.db, cl=self):
689             a_id, an = a
690             b_id, bn = b
691             # sort by group and then sort
692             for list in group, sort:
693                 for dir, prop in list:
694                     # sorting is class-specific
695                     propclass = properties[prop]
697                     # handle the properties that might be "faked"
698                     # also, handle possible missing properties
699                     try:
700                         if not an.has_key(prop):
701                             an[prop] = cl.get(a_id, prop)
702                         av = an[prop]
703                     except KeyError:
704                         # the node doesn't have a value for this property
705                         if isinstance(propclass, Multilink): av = []
706                         else: av = ''
707                     try:
708                         if not bn.has_key(prop):
709                             bn[prop] = cl.get(b_id, prop)
710                         bv = bn[prop]
711                     except KeyError:
712                         # the node doesn't have a value for this property
713                         if isinstance(propclass, Multilink): bv = []
714                         else: bv = ''
716                     # String and Date values are sorted in the natural way
717                     if isinstance(propclass, String):
718                         # clean up the strings
719                         if av and av[0] in string.uppercase:
720                             av = an[prop] = av.lower()
721                         if bv and bv[0] in string.uppercase:
722                             bv = bn[prop] = bv.lower()
723                     if (isinstance(propclass, String) or
724                             isinstance(propclass, Date)):
725                         # it might be a string that's really an integer
726                         try:
727                             av = int(av)
728                             bv = int(bv)
729                         except:
730                             pass
731                         if dir == '+':
732                             r = cmp(av, bv)
733                             if r != 0: return r
734                         elif dir == '-':
735                             r = cmp(bv, av)
736                             if r != 0: return r
738                     # Link properties are sorted according to the value of
739                     # the "order" property on the linked nodes if it is
740                     # present; or otherwise on the key string of the linked
741                     # nodes; or finally on  the node ids.
742                     elif isinstance(propclass, Link):
743                         link = db.classes[propclass.classname]
744                         if av is None and bv is not None: return -1
745                         if av is not None and bv is None: return 1
746                         if av is None and bv is None: continue
747                         if link.getprops().has_key('order'):
748                             if dir == '+':
749                                 r = cmp(link.get(av, 'order'),
750                                     link.get(bv, 'order'))
751                                 if r != 0: return r
752                             elif dir == '-':
753                                 r = cmp(link.get(bv, 'order'),
754                                     link.get(av, 'order'))
755                                 if r != 0: return r
756                         elif link.getkey():
757                             key = link.getkey()
758                             if dir == '+':
759                                 r = cmp(link.get(av, key), link.get(bv, key))
760                                 if r != 0: return r
761                             elif dir == '-':
762                                 r = cmp(link.get(bv, key), link.get(av, key))
763                                 if r != 0: return r
764                         else:
765                             if dir == '+':
766                                 r = cmp(av, bv)
767                                 if r != 0: return r
768                             elif dir == '-':
769                                 r = cmp(bv, av)
770                                 if r != 0: return r
772                     # Multilink properties are sorted according to how many
773                     # links are present.
774                     elif isinstance(propclass, Multilink):
775                         if dir == '+':
776                             r = cmp(len(av), len(bv))
777                             if r != 0: return r
778                         elif dir == '-':
779                             r = cmp(len(bv), len(av))
780                             if r != 0: return r
781                 # end for dir, prop in list:
782             # end for list in sort, group:
783             # if all else fails, compare the ids
784             return cmp(a[0], b[0])
786         l.sort(sortfun)
787         return [i[0] for i in l]
789     def count(self):
790         """Get the number of nodes in this class.
792         If the returned integer is 'numnodes', the ids of all the nodes
793         in this class run from 1 to numnodes, and numnodes+1 will be the
794         id of the next node to be created in this class.
795         """
796         return self.db.countnodes(self.classname)
798     # Manipulating properties:
800     def getprops(self, protected=1):
801         """Return a dictionary mapping property names to property objects.
802            If the "protected" flag is true, we include protected properties -
803            those which may not be modified."""
804         d = self.properties.copy()
805         if protected:
806             d['id'] = String()
807         return d
809     def addprop(self, **properties):
810         """Add properties to this class.
812         The keyword arguments in 'properties' must map names to property
813         objects, or a TypeError is raised.  None of the keys in 'properties'
814         may collide with the names of existing properties, or a ValueError
815         is raised before any properties have been added.
816         """
817         for key in properties.keys():
818             if self.properties.has_key(key):
819                 raise ValueError, key
820         self.properties.update(properties)
823 # XXX not in spec
824 class Node:
825     ''' A convenience wrapper for the given node
826     '''
827     def __init__(self, cl, nodeid):
828         self.__dict__['cl'] = cl
829         self.__dict__['nodeid'] = nodeid
830     def keys(self, protected=1):
831         return self.cl.getprops(protected=protected).keys()
832     def values(self, protected=1):
833         l = []
834         for name in self.cl.getprops(protected=protected).keys():
835             l.append(self.cl.get(self.nodeid, name))
836         return l
837     def items(self, protected=1):
838         l = []
839         for name in self.cl.getprops(protected=protected).keys():
840             l.append((name, self.cl.get(self.nodeid, name)))
841         return l
842     def has_key(self, name):
843         return self.cl.getprops().has_key(name)
844     def __getattr__(self, name):
845         if self.__dict__.has_key(name):
846             return self.__dict__[name]
847         try:
848             return self.cl.get(self.nodeid, name)
849         except KeyError, value:
850             # we trap this but re-raise it as AttributeError - all other
851             # exceptions should pass through untrapped
852             pass
853         # nope, no such attribute
854         raise AttributeError, str(value)
855     def __getitem__(self, name):
856         return self.cl.get(self.nodeid, name)
857     def __setattr__(self, name, value):
858         try:
859             return self.cl.set(self.nodeid, **{name: value})
860         except KeyError, value:
861             raise AttributeError, str(value)
862     def __setitem__(self, name, value):
863         self.cl.set(self.nodeid, **{name: value})
864     def history(self):
865         return self.cl.history(self.nodeid)
866     def retire(self):
867         return self.cl.retire(self.nodeid)
870 def Choice(name, *options):
871     cl = Class(db, name, name=hyperdb.String(), order=hyperdb.String())
872     for i in range(len(options)):
873         cl.create(name=option[i], order=i)
874     return hyperdb.Link(name)
877 # $Log: not supported by cvs2svn $
878 # Revision 1.42  2001/12/16 10:53:37  richard
879 # take a copy of the node dict so that the subsequent set
880 # operation doesn't modify the oldvalues structure
882 # Revision 1.41  2001/12/15 23:47:47  richard
883 # Cleaned up some bare except statements
885 # Revision 1.40  2001/12/14 23:42:57  richard
886 # yuck, a gdbm instance tests false :(
887 # I've left the debugging code in - it should be removed one day if we're ever
888 # _really_ anal about performace :)
890 # Revision 1.39  2001/12/02 05:06:16  richard
891 # . We now use weakrefs in the Classes to keep the database reference, so
892 #   the close() method on the database is no longer needed.
893 #   I bumped the minimum python requirement up to 2.1 accordingly.
894 # . #487480 ] roundup-server
895 # . #487476 ] INSTALL.txt
897 # I also cleaned up the change message / post-edit stuff in the cgi client.
898 # There's now a clearly marked "TODO: append the change note" where I believe
899 # the change note should be added there. The "changes" list will obviously
900 # have to be modified to be a dict of the changes, or somesuch.
902 # More testing needed.
904 # Revision 1.38  2001/12/01 07:17:50  richard
905 # . We now have basic transaction support! Information is only written to
906 #   the database when the commit() method is called. Only the anydbm
907 #   backend is modified in this way - neither of the bsddb backends have been.
908 #   The mail, admin and cgi interfaces all use commit (except the admin tool
909 #   doesn't have a commit command, so interactive users can't commit...)
910 # . Fixed login/registration forwarding the user to the right page (or not,
911 #   on a failure)
913 # Revision 1.37  2001/11/28 21:55:35  richard
914 #  . login_action and newuser_action return values were being ignored
915 #  . Woohoo! Found that bloody re-login bug that was killing the mail
916 #    gateway.
917 #  (also a minor cleanup in hyperdb)
919 # Revision 1.36  2001/11/27 03:16:09  richard
920 # Another place that wasn't handling missing properties.
922 # Revision 1.35  2001/11/22 15:46:42  jhermann
923 # Added module docstrings to all modules.
925 # Revision 1.34  2001/11/21 04:04:43  richard
926 # *sigh* more missing value handling
928 # Revision 1.33  2001/11/21 03:40:54  richard
929 # more new property handling
931 # Revision 1.32  2001/11/21 03:11:28  richard
932 # Better handling of new properties.
934 # Revision 1.31  2001/11/12 22:01:06  richard
935 # Fixed issues with nosy reaction and author copies.
937 # Revision 1.30  2001/11/09 10:11:08  richard
938 #  . roundup-admin now handles all hyperdb exceptions
940 # Revision 1.29  2001/10/27 00:17:41  richard
941 # Made Class.stringFind() do caseless matching.
943 # Revision 1.28  2001/10/21 04:44:50  richard
944 # bug #473124: UI inconsistency with Link fields.
945 #    This also prompted me to fix a fairly long-standing usability issue -
946 #    that of being able to turn off certain filters.
948 # Revision 1.27  2001/10/20 23:44:27  richard
949 # Hyperdatabase sorts strings-that-look-like-numbers as numbers now.
951 # Revision 1.26  2001/10/16 03:48:01  richard
952 # admin tool now complains if a "find" is attempted with a non-link property.
954 # Revision 1.25  2001/10/11 00:17:51  richard
955 # Reverted a change in hyperdb so the default value for missing property
956 # values in a create() is None and not '' (the empty string.) This obviously
957 # breaks CSV import/export - the string 'None' will be created in an
958 # export/import operation.
960 # Revision 1.24  2001/10/10 03:54:57  richard
961 # Added database importing and exporting through CSV files.
962 # Uses the csv module from object-craft for exporting if it's available.
963 # Requires the csv module for importing.
965 # Revision 1.23  2001/10/09 23:58:10  richard
966 # Moved the data stringification up into the hyperdb.Class class' get, set
967 # and create methods. This means that the data is also stringified for the
968 # journal call, and removes duplication of code from the backends. The
969 # backend code now only sees strings.
971 # Revision 1.22  2001/10/09 07:25:59  richard
972 # Added the Password property type. See "pydoc roundup.password" for
973 # implementation details. Have updated some of the documentation too.
975 # Revision 1.21  2001/10/05 02:23:24  richard
976 #  . roundup-admin create now prompts for property info if none is supplied
977 #    on the command-line.
978 #  . hyperdb Class getprops() method may now return only the mutable
979 #    properties.
980 #  . Login now uses cookies, which makes it a whole lot more flexible. We can
981 #    now support anonymous user access (read-only, unless there's an
982 #    "anonymous" user, in which case write access is permitted). Login
983 #    handling has been moved into cgi_client.Client.main()
984 #  . The "extended" schema is now the default in roundup init.
985 #  . The schemas have had their page headings modified to cope with the new
986 #    login handling. Existing installations should copy the interfaces.py
987 #    file from the roundup lib directory to their instance home.
988 #  . Incorrectly had a Bizar Software copyright on the cgitb.py module from
989 #    Ping - has been removed.
990 #  . Fixed a whole bunch of places in the CGI interface where we should have
991 #    been returning Not Found instead of throwing an exception.
992 #  . Fixed a deviation from the spec: trying to modify the 'id' property of
993 #    an item now throws an exception.
995 # Revision 1.20  2001/10/04 02:12:42  richard
996 # Added nicer command-line item adding: passing no arguments will enter an
997 # interactive more which asks for each property in turn. While I was at it, I
998 # fixed an implementation problem WRT the spec - I wasn't raising a
999 # ValueError if the key property was missing from a create(). Also added a
1000 # protected=boolean argument to getprops() so we can list only the mutable
1001 # properties (defaults to yes, which lists the immutables).
1003 # Revision 1.19  2001/08/29 04:47:18  richard
1004 # Fixed CGI client change messages so they actually include the properties
1005 # changed (again).
1007 # Revision 1.18  2001/08/16 07:34:59  richard
1008 # better CGI text searching - but hidden filter fields are disappearing...
1010 # Revision 1.17  2001/08/16 06:59:58  richard
1011 # all searches use re now - and they're all case insensitive
1013 # Revision 1.16  2001/08/15 23:43:18  richard
1014 # Fixed some isFooTypes that I missed.
1015 # Refactored some code in the CGI code.
1017 # Revision 1.15  2001/08/12 06:32:36  richard
1018 # using isinstance(blah, Foo) now instead of isFooType
1020 # Revision 1.14  2001/08/07 00:24:42  richard
1021 # stupid typo
1023 # Revision 1.13  2001/08/07 00:15:51  richard
1024 # Added the copyright/license notice to (nearly) all files at request of
1025 # Bizar Software.
1027 # Revision 1.12  2001/08/02 06:38:17  richard
1028 # Roundupdb now appends "mailing list" information to its messages which
1029 # include the e-mail address and web interface address. Templates may
1030 # override this in their db classes to include specific information (support
1031 # instructions, etc).
1033 # Revision 1.11  2001/08/01 04:24:21  richard
1034 # mailgw was assuming certain properties existed on the issues being created.
1036 # Revision 1.10  2001/07/30 02:38:31  richard
1037 # get() now has a default arg - for migration only.
1039 # Revision 1.9  2001/07/29 09:28:23  richard
1040 # Fixed sorting by clicking on column headings.
1042 # Revision 1.8  2001/07/29 08:27:40  richard
1043 # Fixed handling of passed-in values in form elements (ie. during a
1044 # drill-down)
1046 # Revision 1.7  2001/07/29 07:01:39  richard
1047 # Added vim command to all source so that we don't get no steenkin' tabs :)
1049 # Revision 1.6  2001/07/29 05:36:14  richard
1050 # Cleanup of the link label generation.
1052 # Revision 1.5  2001/07/29 04:05:37  richard
1053 # Added the fabricated property "id".
1055 # Revision 1.4  2001/07/27 06:25:35  richard
1056 # Fixed some of the exceptions so they're the right type.
1057 # Removed the str()-ification of node ids so we don't mask oopsy errors any
1058 # more.
1060 # Revision 1.3  2001/07/27 05:17:14  richard
1061 # just some comments
1063 # Revision 1.2  2001/07/22 12:09:32  richard
1064 # Final commit of Grande Splite
1066 # Revision 1.1  2001/07/22 11:58:35  richard
1067 # More Grande Splite
1070 # vim: set filetype=python ts=4 sw=4 et si