Code

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