Code

Handles new node display now.
[roundup.git] / roundup / hyperdb.py
1 # $Id: hyperdb.py,v 1.9 2001-07-29 09:28:23 richard Exp $
3 # standard python modules
4 import cPickle, re, string
6 # roundup modules
7 import date
10 #
11 # Types
12 #
13 class BaseType:
14     isStringType = 0
15     isDateType = 0
16     isIntervalType = 0
17     isLinkType = 0
18     isMultilinkType = 0
20 class String(BaseType):
21     def __init__(self):
22         """An object designating a String property."""
23         pass
24     def __repr__(self):
25         return '<%s>'%self.__class__
26     isStringType = 1
28 class Date(BaseType, String):
29     isDateType = 1
31 class Interval(BaseType, String):
32     isIntervalType = 1
34 class Link(BaseType):
35     def __init__(self, classname):
36         """An object designating a Link property that links to
37         nodes in a specified class."""
38         self.classname = classname
39     def __repr__(self):
40         return '<%s to "%s">'%(self.__class__, self.classname)
41     isLinkType = 1
43 class Multilink(BaseType, Link):
44     """An object designating a Multilink property that links
45        to nodes in a specified class.
46     """
47     isMultilinkType = 1
49 class DatabaseError(ValueError):
50     pass
53 #
54 # the base Database class
55 #
56 class Database:
57     # flag to set on retired entries
58     RETIRED_FLAG = '__hyperdb_retired'
61 #
62 # The base Class class
63 #
64 class Class:
65     """The handle to a particular class of nodes in a hyperdatabase."""
67     def __init__(self, db, classname, **properties):
68         """Create a new class with a given name and property specification.
70         'classname' must not collide with the name of an existing class,
71         or a ValueError is raised.  The keyword arguments in 'properties'
72         must map names to property objects, or a TypeError is raised.
73         """
74         self.classname = classname
75         self.properties = properties
76         self.db = db
77         self.key = ''
79         # do the db-related init stuff
80         db.addclass(self)
82     # Editing nodes:
84     def create(self, **propvalues):
85         """Create a new node of this class and return its id.
87         The keyword arguments in 'propvalues' map property names to values.
89         The values of arguments must be acceptable for the types of their
90         corresponding properties or a TypeError is raised.
91         
92         If this class has a key property, it must be present and its value
93         must not collide with other key strings or a ValueError is raised.
94         
95         Any other properties on this class that are missing from the
96         'propvalues' dictionary are set to None.
97         
98         If an id in a link or multilink property does not refer to a valid
99         node, an IndexError is raised.
100         """
101         if propvalues.has_key('id'):
102             raise KeyError, '"id" is reserved'
104         if self.db.journaltag is None:
105             raise DatabaseError, 'Database open read-only'
107         # new node's id
108         newid = str(self.count() + 1)
110         # validate propvalues
111         num_re = re.compile('^\d+$')
112         for key, value in propvalues.items():
113             if key == self.key:
114                 try:
115                     self.lookup(value)
116                 except KeyError:
117                     pass
118                 else:
119                     raise ValueError, 'node with key "%s" exists'%value
121             prop = self.properties[key]
123             if prop.isLinkType:
124                 if type(value) != type(''):
125                     raise ValueError, 'link value must be String'
126 #                value = str(value)
127                 link_class = self.properties[key].classname
128                 # if it isn't a number, it's a key
129                 if not num_re.match(value):
130                     try:
131                         value = self.db.classes[link_class].lookup(value)
132                     except:
133                         raise IndexError, 'new property "%s": %s not a %s'%(
134                             key, value, self.properties[key].classname)
135                 propvalues[key] = value
136                 if not self.db.hasnode(link_class, value):
137                     raise IndexError, '%s has no node %s'%(link_class, value)
139                 # register the link with the newly linked node
140                 self.db.addjournal(link_class, value, 'link',
141                     (self.classname, newid, key))
143             elif prop.isMultilinkType:
144                 if type(value) != type([]):
145                     raise TypeError, 'new property "%s" not a list of ids'%key
146                 link_class = self.properties[key].classname
147                 l = []
148                 for entry in value:
149                     if type(entry) != type(''):
150                         raise ValueError, 'link value must be String'
151                     # if it isn't a number, it's a key
152                     if not num_re.match(entry):
153                         try:
154                             entry = self.db.classes[link_class].lookup(entry)
155                         except:
156                             raise IndexError, 'new property "%s": %s not a %s'%(
157                                 key, entry, self.properties[key].classname)
158                     l.append(entry)
159                 value = l
160                 propvalues[key] = value
162                 # handle additions
163                 for id in value:
164                     if not self.db.hasnode(link_class, id):
165                         raise IndexError, '%s has no node %s'%(link_class, id)
166                     # register the link with the newly linked node
167                     self.db.addjournal(link_class, id, 'link',
168                         (self.classname, newid, key))
170             elif prop.isStringType:
171                 if type(value) != type(''):
172                     raise TypeError, 'new property "%s" not a string'%key
174             elif prop.isDateType:
175                 if not hasattr(value, 'isDate'):
176                     raise TypeError, 'new property "%s" not a Date'% key
178             elif prop.isIntervalType:
179                 if not hasattr(value, 'isInterval'):
180                     raise TypeError, 'new property "%s" not an Interval'% key
182         for key, prop in self.properties.items():
183             if propvalues.has_key(key):
184                 continue
185             if prop.isMultilinkType:
186                 propvalues[key] = []
187             else:
188                 propvalues[key] = None
190         # done
191         self.db.addnode(self.classname, newid, propvalues)
192         self.db.addjournal(self.classname, newid, 'create', propvalues)
193         return newid
195     def get(self, nodeid, propname):
196         """Get the value of a property on an existing node of this class.
198         'nodeid' must be the id of an existing node of this class or an
199         IndexError is raised.  'propname' must be the name of a property
200         of this class or a KeyError is raised.
201         """
202         if propname == 'id':
203             return nodeid
204 #        nodeid = str(nodeid)
205         d = self.db.getnode(self.classname, nodeid)
206         return d[propname]
208     # XXX not in spec
209     def getnode(self, nodeid):
210         ''' Return a convenience wrapper for the node
211         '''
212         return Node(self, nodeid)
214     def set(self, nodeid, **propvalues):
215         """Modify a property on an existing node of this class.
216         
217         'nodeid' must be the id of an existing node of this class or an
218         IndexError is raised.
220         Each key in 'propvalues' must be the name of a property of this
221         class or a KeyError is raised.
223         All values in 'propvalues' must be acceptable types for their
224         corresponding properties or a TypeError is raised.
226         If the value of the key property is set, it must not collide with
227         other key strings or a ValueError is raised.
229         If the value of a Link or Multilink property contains an invalid
230         node id, a ValueError is raised.
231         """
232         if not propvalues:
233             return
235         if propvalues.has_key('id'):
236             raise KeyError, '"id" is reserved'
238         if self.db.journaltag is None:
239             raise DatabaseError, 'Database open read-only'
241 #        nodeid = str(nodeid)
242         node = self.db.getnode(self.classname, nodeid)
243         if node.has_key(self.db.RETIRED_FLAG):
244             raise IndexError
245         num_re = re.compile('^\d+$')
246         for key, value in propvalues.items():
247             if not node.has_key(key):
248                 raise KeyError, key
250             if key == self.key:
251                 try:
252                     self.lookup(value)
253                 except KeyError:
254                     pass
255                 else:
256                     raise ValueError, 'node with key "%s" exists'%value
258             prop = self.properties[key]
260             if prop.isLinkType:
261 #                value = str(value)
262                 link_class = self.properties[key].classname
263                 # if it isn't a number, it's a key
264                 if type(value) != type(''):
265                     raise ValueError, 'link value must be String'
266                 if not num_re.match(value):
267                     try:
268                         value = self.db.classes[link_class].lookup(value)
269                     except:
270                         raise IndexError, 'new property "%s": %s not a %s'%(
271                             key, value, self.properties[key].classname)
273                 if not self.db.hasnode(link_class, value):
274                     raise IndexError, '%s has no node %s'%(link_class, value)
276                 # register the unlink with the old linked node
277                 if node[key] is not None:
278                     self.db.addjournal(link_class, node[key], 'unlink',
279                         (self.classname, nodeid, key))
281                 # register the link with the newly linked node
282                 if value is not None:
283                     self.db.addjournal(link_class, value, 'link',
284                         (self.classname, nodeid, key))
286             elif prop.isMultilinkType:
287                 if type(value) != type([]):
288                     raise TypeError, 'new property "%s" not a list of ids'%key
289                 link_class = self.properties[key].classname
290                 l = []
291                 for entry in value:
292                     # if it isn't a number, it's a key
293                     if type(entry) != type(''):
294                         raise ValueError, 'link value must be String'
295                     if not num_re.match(entry):
296                         try:
297                             entry = self.db.classes[link_class].lookup(entry)
298                         except:
299                             raise IndexError, 'new property "%s": %s not a %s'%(
300                                 key, entry, self.properties[key].classname)
301                     l.append(entry)
302                 value = l
303                 propvalues[key] = value
305                 #handle removals
306                 l = node[key]
307                 for id in l[:]:
308                     if id in value:
309                         continue
310                     # register the unlink with the old linked node
311                     self.db.addjournal(link_class, id, 'unlink',
312                         (self.classname, nodeid, key))
313                     l.remove(id)
315                 # handle additions
316                 for id in value:
317                     if not self.db.hasnode(link_class, id):
318                         raise IndexError, '%s has no node %s'%(link_class, id)
319                     if id in l:
320                         continue
321                     # register the link with the newly linked node
322                     self.db.addjournal(link_class, id, 'link',
323                         (self.classname, nodeid, key))
324                     l.append(id)
326             elif prop.isStringType:
327                 if value is not None and type(value) != type(''):
328                     raise TypeError, 'new property "%s" not a string'%key
330             elif prop.isDateType:
331                 if not hasattr(value, 'isDate'):
332                     raise TypeError, 'new property "%s" not a Date'% key
334             elif prop.isIntervalType:
335                 if not hasattr(value, 'isInterval'):
336                     raise TypeError, 'new property "%s" not an Interval'% key
338             node[key] = value
340         self.db.setnode(self.classname, nodeid, node)
341         self.db.addjournal(self.classname, nodeid, 'set', propvalues)
343     def retire(self, nodeid):
344         """Retire a node.
345         
346         The properties on the node remain available from the get() method,
347         and the node's id is never reused.
348         
349         Retired nodes are not returned by the find(), list(), or lookup()
350         methods, and other nodes may reuse the values of their key properties.
351         """
352 #        nodeid = str(nodeid)
353         if self.db.journaltag is None:
354             raise DatabaseError, 'Database open read-only'
355         node = self.db.getnode(self.classname, nodeid)
356         node[self.db.RETIRED_FLAG] = 1
357         self.db.setnode(self.classname, nodeid, node)
358         self.db.addjournal(self.classname, nodeid, 'retired', None)
360     def history(self, nodeid):
361         """Retrieve the journal of edits on a particular node.
363         'nodeid' must be the id of an existing node of this class or an
364         IndexError is raised.
366         The returned list contains tuples of the form
368             (date, tag, action, params)
370         'date' is a Timestamp object specifying the time of the change and
371         'tag' is the journaltag specified when the database was opened.
372         """
373         return self.db.getjournal(self.classname, nodeid)
375     # Locating nodes:
377     def setkey(self, propname):
378         """Select a String property of this class to be the key property.
380         'propname' must be the name of a String property of this class or
381         None, or a TypeError is raised.  The values of the key property on
382         all existing nodes must be unique or a ValueError is raised.
383         """
384         self.key = propname
386     def getkey(self):
387         """Return the name of the key property for this class or None."""
388         return self.key
390     def labelprop(self):
391         ''' Return the property name for a label for the given node.
393         This method attempts to generate a consistent label for the node.
394         It tries the following in order:
395             1. key property
396             2. "name" property
397             3. "title" property
398             4. first property from the sorted property name list
399         '''
400         k = self.getkey()
401         if  k:
402             return k
403         props = self.getprops()
404         if props.has_key('name'):
405             return 'name'
406         elif props.has_key('title'):
407             return 'title'
408         props = props.keys()
409         props.sort()
410         return props[0]
412     # TODO: set up a separate index db file for this? profile?
413     def lookup(self, keyvalue):
414         """Locate a particular node by its key property and return its id.
416         If this class has no key property, a TypeError is raised.  If the
417         'keyvalue' matches one of the values for the key property among
418         the nodes in this class, the matching node's id is returned;
419         otherwise a KeyError is raised.
420         """
421         cldb = self.db.getclassdb(self.classname)
422         for nodeid in self.db.getnodeids(self.classname, cldb):
423             node = self.db.getnode(self.classname, nodeid, cldb)
424             if node.has_key(self.db.RETIRED_FLAG):
425                 continue
426             if node[self.key] == keyvalue:
427                 return nodeid
428         cldb.close()
429         raise KeyError, keyvalue
431     # XXX: change from spec - allows multiple props to match
432     def find(self, **propspec):
433         """Get the ids of nodes in this class which link to a given node.
435         'propspec' consists of keyword args propname=nodeid   
436           'propname' must be the name of a property in this class, or a
437             KeyError is raised.  That property must be a Link or Multilink
438             property, or a TypeError is raised.
440           'nodeid' must be the id of an existing node in the class linked
441             to by the given property, or an IndexError is raised.
442         """
443         propspec = propspec.items()
444         for propname, nodeid in propspec:
445 #            nodeid = str(nodeid)
446             # check the prop is OK
447             prop = self.properties[propname]
448             if not prop.isLinkType and not prop.isMultilinkType:
449                 raise TypeError, "'%s' not a Link/Multilink property"%propname
450             if not self.db.hasnode(prop.classname, nodeid):
451                 raise ValueError, '%s has no node %s'%(link_class, nodeid)
453         # ok, now do the find
454         cldb = self.db.getclassdb(self.classname)
455         l = []
456         for id in self.db.getnodeids(self.classname, cldb):
457             node = self.db.getnode(self.classname, id, cldb)
458             if node.has_key(self.db.RETIRED_FLAG):
459                 continue
460             for propname, nodeid in propspec:
461 #                nodeid = str(nodeid)
462                 property = node[propname]
463                 if prop.isLinkType and nodeid == property:
464                     l.append(id)
465                 elif prop.isMultilinkType and nodeid in property:
466                     l.append(id)
467         cldb.close()
468         return l
470     def stringFind(self, **requirements):
471         """Locate a particular node by matching a set of its String properties.
473         If the property is not a String property, a TypeError is raised.
474         
475         The return is a list of the id of all nodes that match.
476         """
477         for propname in requirements.keys():
478             prop = self.properties[propname]
479             if not prop.isStringType:
480                 raise TypeError, "'%s' not a String property"%propname
481         l = []
482         cldb = self.db.getclassdb(self.classname)
483         for nodeid in self.db.getnodeids(self.classname, cldb):
484             node = self.db.getnode(self.classname, nodeid, cldb)
485             if node.has_key(self.db.RETIRED_FLAG):
486                 continue
487             for key, value in requirements.items():
488                 if node[key] != value:
489                     break
490             else:
491                 l.append(nodeid)
492         cldb.close()
493         return l
495     def list(self):
496         """Return a list of the ids of the active nodes in this class."""
497         l = []
498         cn = self.classname
499         cldb = self.db.getclassdb(cn)
500         for nodeid in self.db.getnodeids(cn, cldb):
501             node = self.db.getnode(cn, nodeid, cldb)
502             if node.has_key(self.db.RETIRED_FLAG):
503                 continue
504             l.append(nodeid)
505         l.sort()
506         cldb.close()
507         return l
509     # XXX not in spec
510     def filter(self, filterspec, sort, group, num_re = re.compile('^\d+$')):
511         ''' Return a list of the ids of the active nodes in this class that
512             match the 'filter' spec, sorted by the group spec and then the
513             sort spec
514         '''
515         cn = self.classname
517         # optimise filterspec
518         l = []
519         props = self.getprops()
520         for k, v in filterspec.items():
521             propclass = props[k]
522             if propclass.isLinkType:
523                 if type(v) is not type([]):
524                     v = [v]
525                 # replace key values with node ids
526                 u = []
527                 link_class =  self.db.classes[propclass.classname]
528                 for entry in v:
529                     if not num_re.match(entry):
530                         try:
531                             entry = link_class.lookup(entry)
532                         except:
533                             raise ValueError, 'new property "%s": %s not a %s'%(
534                                 k, entry, self.properties[k].classname)
535                     u.append(entry)
537                 l.append((0, k, u))
538             elif propclass.isMultilinkType:
539                 if type(v) is not type([]):
540                     v = [v]
541                 # replace key values with node ids
542                 u = []
543                 link_class =  self.db.classes[propclass.classname]
544                 for entry in v:
545                     if not num_re.match(entry):
546                         try:
547                             entry = link_class.lookup(entry)
548                         except:
549                             raise ValueError, 'new property "%s": %s not a %s'%(
550                                 k, entry, self.properties[k].classname)
551                     u.append(entry)
552                 l.append((1, k, u))
553             elif propclass.isStringType:
554                 if '*' in v or '?' in v:
555                     # simple glob searching
556                     v = v.replace('?', '.')
557                     v = v.replace('*', '.*?')
558                     v = re.compile(v)
559                     l.append((2, k, v))
560                 elif v[0] == '^':
561                     # start-anchored
562                     if v[-1] == '$':
563                         # _and_ end-anchored
564                         l.append((6, k, v[1:-1]))
565                     l.append((3, k, v[1:]))
566                 elif v[-1] == '$':
567                     # end-anchored
568                     l.append((4, k, v[:-1]))
569                 else:
570                     # substring
571                     l.append((5, k, v))
572             else:
573                 l.append((6, k, v))
574         filterspec = l
576         # now, find all the nodes that are active and pass filtering
577         l = []
578         cldb = self.db.getclassdb(cn)
579         for nodeid in self.db.getnodeids(cn, cldb):
580             node = self.db.getnode(cn, nodeid, cldb)
581             if node.has_key(self.db.RETIRED_FLAG):
582                 continue
583             # apply filter
584             for t, k, v in filterspec:
585                 if t == 0 and node[k] not in v:
586                     # link - if this node'd property doesn't appear in the
587                     # filterspec's nodeid list, skip it
588                     break
589                 elif t == 1:
590                     # multilink - if any of the nodeids required by the
591                     # filterspec aren't in this node's property, then skip
592                     # it
593                     for value in v:
594                         if value not in node[k]:
595                             break
596                     else:
597                         continue
598                     break
599                 elif t == 2 and not v.search(node[k]):
600                     # RE search
601                     break
602                 elif t == 3 and node[k][:len(v)] != v:
603                     # start anchored
604                     break
605                 elif t == 4 and node[k][-len(v):] != v:
606                     # end anchored
607                     break
608                 elif t == 5 and node[k].find(v) == -1:
609                     # substring search
610                     break
611                 elif t == 6 and node[k] != v:
612                     # straight value comparison for the other types
613                     break
614             else:
615                 l.append((nodeid, node))
616         l.sort()
617         cldb.close()
619         # optimise sort
620         m = []
621         for entry in sort:
622             if entry[0] != '-':
623                 m.append(('+', entry))
624             else:
625                 m.append((entry[0], entry[1:]))
626         sort = m
628         # optimise group
629         m = []
630         for entry in group:
631             if entry[0] != '-':
632                 m.append(('+', entry))
633             else:
634                 m.append((entry[0], entry[1:]))
635         group = m
636         # now, sort the result
637         def sortfun(a, b, sort=sort, group=group, properties=self.getprops(),
638                 db = self.db, cl=self):
639             a_id, an = a
640             b_id, bn = b
641             # sort by group and then sort
642             for list in group, sort:
643                 for dir, prop in list:
644                     # handle the properties that might be "faked"
645                     if not an.has_key(prop):
646                         an[prop] = cl.get(a_id, prop)
647                     av = an[prop]
648                     if not bn.has_key(prop):
649                         bn[prop] = cl.get(b_id, prop)
650                     bv = bn[prop]
652                     # sorting is class-specific
653                     propclass = properties[prop]
655                     # String and Date values are sorted in the natural way
656                     if propclass.isStringType:
657                         # clean up the strings
658                         if av and av[0] in string.uppercase:
659                             av = an[prop] = av.lower()
660                         if bv and bv[0] in string.uppercase:
661                             bv = bn[prop] = bv.lower()
662                     if propclass.isStringType or propclass.isDateType:
663                         if dir == '+':
664                             r = cmp(av, bv)
665                             if r != 0: return r
666                         elif dir == '-':
667                             r = cmp(bv, av)
668                             if r != 0: return r
670                     # Link properties are sorted according to the value of
671                     # the "order" property on the linked nodes if it is
672                     # present; or otherwise on the key string of the linked
673                     # nodes; or finally on  the node ids.
674                     elif propclass.isLinkType:
675                         link = db.classes[propclass.classname]
676                         if av is None and bv is not None: return -1
677                         if av is not None and bv is None: return 1
678                         if av is None and bv is None: return 0
679                         if link.getprops().has_key('order'):
680                             if dir == '+':
681                                 r = cmp(link.get(av, 'order'),
682                                     link.get(bv, 'order'))
683                                 if r != 0: return r
684                             elif dir == '-':
685                                 r = cmp(link.get(bv, 'order'),
686                                     link.get(av, 'order'))
687                                 if r != 0: return r
688                         elif link.getkey():
689                             key = link.getkey()
690                             if dir == '+':
691                                 r = cmp(link.get(av, key), link.get(bv, key))
692                                 if r != 0: return r
693                             elif dir == '-':
694                                 r = cmp(link.get(bv, key), link.get(av, key))
695                                 if r != 0: return r
696                         else:
697                             if dir == '+':
698                                 r = cmp(av, bv)
699                                 if r != 0: return r
700                             elif dir == '-':
701                                 r = cmp(bv, av)
702                                 if r != 0: return r
704                     # Multilink properties are sorted according to how many
705                     # links are present.
706                     elif propclass.isMultilinkType:
707                         if dir == '+':
708                             r = cmp(len(av), len(bv))
709                             if r != 0: return r
710                         elif dir == '-':
711                             r = cmp(len(bv), len(av))
712                             if r != 0: return r
713                 # end for dir, prop in list:
714             # end for list in sort, group:
715             # if all else fails, compare the ids
716             return cmp(a[0], b[0])
718         l.sort(sortfun)
719         return [i[0] for i in l]
721     def count(self):
722         """Get the number of nodes in this class.
724         If the returned integer is 'numnodes', the ids of all the nodes
725         in this class run from 1 to numnodes, and numnodes+1 will be the
726         id of the next node to be created in this class.
727         """
728         return self.db.countnodes(self.classname)
730     # Manipulating properties:
732     def getprops(self):
733         """Return a dictionary mapping property names to property objects."""
734         d = self.properties.copy()
735         d['id'] = String()
736         return d
738     def addprop(self, **properties):
739         """Add properties to this class.
741         The keyword arguments in 'properties' must map names to property
742         objects, or a TypeError is raised.  None of the keys in 'properties'
743         may collide with the names of existing properties, or a ValueError
744         is raised before any properties have been added.
745         """
746         for key in properties.keys():
747             if self.properties.has_key(key):
748                 raise ValueError, key
749         self.properties.update(properties)
752 # XXX not in spec
753 class Node:
754     ''' A convenience wrapper for the given node
755     '''
756     def __init__(self, cl, nodeid):
757         self.__dict__['cl'] = cl
758         self.__dict__['nodeid'] = nodeid
759     def keys(self):
760         return self.cl.getprops().keys()
761     def has_key(self, name):
762         return self.cl.getprops().has_key(name)
763     def __getattr__(self, name):
764         if self.__dict__.has_key(name):
765             return self.__dict__['name']
766         try:
767             return self.cl.get(self.nodeid, name)
768         except KeyError, value:
769             raise AttributeError, str(value)
770     def __getitem__(self, name):
771         return self.cl.get(self.nodeid, name)
772     def __setattr__(self, name, value):
773         try:
774             return self.cl.set(self.nodeid, **{name: value})
775         except KeyError, value:
776             raise AttributeError, str(value)
777     def __setitem__(self, name, value):
778         self.cl.set(self.nodeid, **{name: value})
779     def history(self):
780         return self.cl.history(self.nodeid)
781     def retire(self):
782         return self.cl.retire(self.nodeid)
785 def Choice(name, *options):
786     cl = Class(db, name, name=hyperdb.String(), order=hyperdb.String())
787     for i in range(len(options)):
788         cl.create(name=option[i], order=i)
789     return hyperdb.Link(name)
792 # $Log: not supported by cvs2svn $
793 # Revision 1.8  2001/07/29 08:27:40  richard
794 # Fixed handling of passed-in values in form elements (ie. during a
795 # drill-down)
797 # Revision 1.7  2001/07/29 07:01:39  richard
798 # Added vim command to all source so that we don't get no steenkin' tabs :)
800 # Revision 1.6  2001/07/29 05:36:14  richard
801 # Cleanup of the link label generation.
803 # Revision 1.5  2001/07/29 04:05:37  richard
804 # Added the fabricated property "id".
806 # Revision 1.4  2001/07/27 06:25:35  richard
807 # Fixed some of the exceptions so they're the right type.
808 # Removed the str()-ification of node ids so we don't mask oopsy errors any
809 # more.
811 # Revision 1.3  2001/07/27 05:17:14  richard
812 # just some comments
814 # Revision 1.2  2001/07/22 12:09:32  richard
815 # Final commit of Grande Splite
817 # Revision 1.1  2001/07/22 11:58:35  richard
818 # More Grande Splite
821 # vim: set filetype=python ts=4 sw=4 et si