Code

- Add docstring to safeget method.
[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.95 2003-11-16 22:56:46 jlgijsbers Exp $
20 """
21 Hyperdatabase implementation, especially field types.
22 """
24 # standard python modules
25 import sys, os, time, re
27 # roundup modules
28 import date, password
30 # configure up the DEBUG and TRACE captures
31 class Sink:
32     def write(self, content):
33         pass
34 DEBUG = os.environ.get('HYPERDBDEBUG', '')
35 if DEBUG and __debug__:
36     if DEBUG == 'stdout':
37         DEBUG = sys.stdout
38     else:
39         DEBUG = open(DEBUG, 'a')
40 else:
41     DEBUG = Sink()
42 TRACE = os.environ.get('HYPERDBTRACE', '')
43 if TRACE and __debug__:
44     if TRACE == 'stdout':
45         TRACE = sys.stdout
46     else:
47         TRACE = open(TRACE, 'w')
48 else:
49     TRACE = Sink()
50 def traceMark():
51     print >>TRACE, '**MARK', time.ctime()
52 del Sink
54 #
55 # Types
56 #
57 class String:
58     """An object designating a String property."""
59     def __init__(self, indexme='no'):
60         self.indexme = indexme == 'yes'
61     def __repr__(self):
62         ' more useful for dumps '
63         return '<%s>'%self.__class__
65 class Password:
66     """An object designating a Password property."""
67     def __repr__(self):
68         ' more useful for dumps '
69         return '<%s>'%self.__class__
71 class Date:
72     """An object designating a Date property."""
73     def __repr__(self):
74         ' more useful for dumps '
75         return '<%s>'%self.__class__
77 class Interval:
78     """An object designating an Interval property."""
79     def __repr__(self):
80         ' more useful for dumps '
81         return '<%s>'%self.__class__
83 class Link:
84     """An object designating a Link property that links to a
85        node in a specified class."""
86     def __init__(self, classname, do_journal='yes'):
87         ''' Default is to not journal link and unlink events
88         '''
89         self.classname = classname
90         self.do_journal = do_journal == 'yes'
91     def __repr__(self):
92         ' more useful for dumps '
93         return '<%s to "%s">'%(self.__class__, self.classname)
95 class Multilink:
96     """An object designating a Multilink property that links
97        to nodes in a specified class.
99        "classname" indicates the class to link to
101        "do_journal" indicates whether the linked-to nodes should have
102                     'link' and 'unlink' events placed in their journal
103     """
104     def __init__(self, classname, do_journal='yes'):
105         ''' Default is to not journal link and unlink events
106         '''
107         self.classname = classname
108         self.do_journal = do_journal == 'yes'
109     def __repr__(self):
110         ' more useful for dumps '
111         return '<%s to "%s">'%(self.__class__, self.classname)
113 class Boolean:
114     """An object designating a boolean property"""
115     def __repr__(self):
116         'more useful for dumps'
117         return '<%s>' % self.__class__
118     
119 class Number:
120     """An object designating a numeric property"""
121     def __repr__(self):
122         'more useful for dumps'
123         return '<%s>' % self.__class__
125 # Support for splitting designators
127 class DesignatorError(ValueError):
128     pass
129 def splitDesignator(designator, dre=re.compile(r'([^\d]+)(\d+)')):
130     ''' Take a foo123 and return ('foo', 123)
131     '''
132     m = dre.match(designator)
133     if m is None:
134         raise DesignatorError, '"%s" not a node designator'%designator
135     return m.group(1), m.group(2)
138 # the base Database class
140 class DatabaseError(ValueError):
141     '''Error to be raised when there is some problem in the database code
142     '''
143     pass
144 class Database:
145     '''A database for storing records containing flexible data types.
147 This class defines a hyperdatabase storage layer, which the Classes use to
148 store their data.
151 Transactions
152 ------------
153 The Database should support transactions through the commit() and
154 rollback() methods. All other Database methods should be transaction-aware,
155 using data from the current transaction before looking up the database.
157 An implementation must provide an override for the get() method so that the
158 in-database value is returned in preference to the in-transaction value.
159 This is necessary to determine if any values have changed during a
160 transaction.
163 Implementation
164 --------------
166 All methods except __repr__ must be implemented by a concrete backend Database.
168 '''
170     # flag to set on retired entries
171     RETIRED_FLAG = '__hyperdb_retired'
173     def __init__(self, config, journaltag=None):
174         """Open a hyperdatabase given a specifier to some storage.
176         The 'storagelocator' is obtained from config.DATABASE.
177         The meaning of 'storagelocator' depends on the particular
178         implementation of the hyperdatabase.  It could be a file name,
179         a directory path, a socket descriptor for a connection to a
180         database over the network, etc.
182         The 'journaltag' is a token that will be attached to the journal
183         entries for any edits done on the database.  If 'journaltag' is
184         None, the database is opened in read-only mode: the Class.create(),
185         Class.set(), and Class.retire() methods are disabled.
186         """
187         raise NotImplementedError
189     def post_init(self):
190         """Called once the schema initialisation has finished. 
191            If 'refresh' is true, we want to rebuild the backend
192            structures.
193         """
194         raise NotImplementedError
196     def refresh_database(self):
197         """Called to indicate that the backend should rebuild all tables
198            and structures. Not called in normal usage."""
199         raise NotImplementedError
201     def __getattr__(self, classname):
202         """A convenient way of calling self.getclass(classname)."""
203         raise NotImplementedError
205     def addclass(self, cl):
206         '''Add a Class to the hyperdatabase.
207         '''
208         raise NotImplementedError
210     def getclasses(self):
211         """Return a list of the names of all existing classes."""
212         raise NotImplementedError
214     def getclass(self, classname):
215         """Get the Class object representing a particular class.
217         If 'classname' is not a valid class name, a KeyError is raised.
218         """
219         raise NotImplementedError
221     def clear(self):
222         '''Delete all database contents.
223         '''
224         raise NotImplementedError
226     def getclassdb(self, classname, mode='r'):
227         '''Obtain a connection to the class db that will be used for
228            multiple actions.
229         '''
230         raise NotImplementedError
232     def addnode(self, classname, nodeid, node):
233         """Add the specified node to its class's db.
234         """
235         raise NotImplementedError
237     def serialise(self, classname, node):
238         '''Copy the node contents, converting non-marshallable data into
239            marshallable data.
240         '''
241         return node
243     def setnode(self, classname, nodeid, node):
244         '''Change the specified node.
245         '''
246         raise NotImplementedError
248     def unserialise(self, classname, node):
249         '''Decode the marshalled node data
250         '''
251         return node
253     def getnode(self, classname, nodeid, db=None, cache=1):
254         '''Get a node from the database.
256         'cache' exists for backwards compatibility, and is not used.
257         '''
258         raise NotImplementedError
260     def hasnode(self, classname, nodeid, db=None):
261         '''Determine if the database has a given node.
262         '''
263         raise NotImplementedError
265     def countnodes(self, classname, db=None):
266         '''Count the number of nodes that exist for a particular Class.
267         '''
268         raise NotImplementedError
270     def storefile(self, classname, nodeid, property, content):
271         '''Store the content of the file in the database.
272         
273            The property may be None, in which case the filename does not
274            indicate which property is being saved.
275         '''
276         raise NotImplementedError
278     def getfile(self, classname, nodeid, property):
279         '''Store the content of the file in the database.
280         '''
281         raise NotImplementedError
283     def addjournal(self, classname, nodeid, action, params):
284         ''' Journal the Action
285         'action' may be:
287             'create' or 'set' -- 'params' is a dictionary of property values
288             'link' or 'unlink' -- 'params' is (classname, nodeid, propname)
289             'retire' -- 'params' is None
290         '''
291         raise NotImplementedError
293     def getjournal(self, classname, nodeid):
294         ''' get the journal for id
295         '''
296         raise NotImplementedError
298     def pack(self, pack_before):
299         ''' pack the database
300         '''
301         raise NotImplementedError
303     def commit(self):
304         ''' Commit the current transactions.
306         Save all data changed since the database was opened or since the
307         last commit() or rollback().
308         '''
309         raise NotImplementedError
311     def rollback(self):
312         ''' Reverse all actions from the current transaction.
314         Undo all the changes made since the database was opened or the last
315         commit() or rollback() was performed.
316         '''
317         raise NotImplementedError
320 # The base Class class
322 class Class:
323     """ The handle to a particular class of nodes in a hyperdatabase.
324         
325         All methods except __repr__ and getnode must be implemented by a
326         concrete backend Class.
327     """
329     def __init__(self, db, classname, **properties):
330         """Create a new class with a given name and property specification.
332         'classname' must not collide with the name of an existing class,
333         or a ValueError is raised.  The keyword arguments in 'properties'
334         must map names to property objects, or a TypeError is raised.
335         """
336         raise NotImplementedError
338     def __repr__(self):
339         '''Slightly more useful representation
340         '''
341         return '<hyperdb.Class "%s">'%self.classname
343     # Editing nodes:
345     def create(self, **propvalues):
346         """Create a new node of this class and return its id.
348         The keyword arguments in 'propvalues' map property names to values.
350         The values of arguments must be acceptable for the types of their
351         corresponding properties or a TypeError is raised.
352         
353         If this class has a key property, it must be present and its value
354         must not collide with other key strings or a ValueError is raised.
355         
356         Any other properties on this class that are missing from the
357         'propvalues' dictionary are set to None.
358         
359         If an id in a link or multilink property does not refer to a valid
360         node, an IndexError is raised.
361         """
362         raise NotImplementedError
364     _marker = []
365     def get(self, nodeid, propname, default=_marker, cache=1):
366         """Get the value of a property on an existing node of this class.
368         'nodeid' must be the id of an existing node of this class or an
369         IndexError is raised.  'propname' must be the name of a property
370         of this class or a KeyError is raised.
372         'cache' exists for backwards compatibility, and is not used.
373         """
374         raise NotImplementedError
376     # not in spec
377     def getnode(self, nodeid, cache=1):
378         ''' Return a convenience wrapper for the node.
380         'nodeid' must be the id of an existing node of this class or an
381         IndexError is raised.
383         'cache' exists for backwards compatibility, and is not used.
384         '''
385         return Node(self, nodeid)
387     def getnodeids(self, db=None):
388         '''Retrieve all the ids of the nodes for a particular Class.
389         '''
390         raise NotImplementedError
392     def set(self, nodeid, **propvalues):
393         """Modify a property on an existing node of this class.
394         
395         'nodeid' must be the id of an existing node of this class or an
396         IndexError is raised.
398         Each key in 'propvalues' must be the name of a property of this
399         class or a KeyError is raised.
401         All values in 'propvalues' must be acceptable types for their
402         corresponding properties or a TypeError is raised.
404         If the value of the key property is set, it must not collide with
405         other key strings or a ValueError is raised.
407         If the value of a Link or Multilink property contains an invalid
408         node id, a ValueError is raised.
409         """
410         raise NotImplementedError
412     def retire(self, nodeid):
413         """Retire a node.
414         
415         The properties on the node remain available from the get() method,
416         and the node's id is never reused.
417         
418         Retired nodes are not returned by the find(), list(), or lookup()
419         methods, and other nodes may reuse the values of their key properties.
420         """
421         raise NotImplementedError
423     def restore(self, nodeid):
424         '''Restpre a retired node.
426         Make node available for all operations like it was before retirement.
427         '''
428         raise NotImplementedError
429     
430     def is_retired(self, nodeid):
431         '''Return true if the node is rerired
432         '''
433         raise NotImplementedError
435     def destroy(self, nodeid):
436         """Destroy a node.
437         
438         WARNING: this method should never be used except in extremely rare
439                  situations where there could never be links to the node being
440                  deleted
441         WARNING: use retire() instead
442         WARNING: the properties of this node will not be available ever again
443         WARNING: really, use retire() instead
445         Well, I think that's enough warnings. This method exists mostly to
446         support the session storage of the cgi interface.
448         The node is completely removed from the hyperdb, including all journal
449         entries. It will no longer be available, and will generally break code
450         if there are any references to the node.
451         """
453     def history(self, nodeid):
454         """Retrieve the journal of edits on a particular node.
456         'nodeid' must be the id of an existing node of this class or an
457         IndexError is raised.
459         The returned list contains tuples of the form
461             (date, tag, action, params)
463         'date' is a Timestamp object specifying the time of the change and
464         'tag' is the journaltag specified when the database was opened.
465         """
466         raise NotImplementedError
468     # Locating nodes:
469     def hasnode(self, nodeid):
470         '''Determine if the given nodeid actually exists
471         '''
472         raise NotImplementedError
474     def setkey(self, propname):
475         """Select a String property of this class to be the key property.
477         'propname' must be the name of a String property of this class or
478         None, or a TypeError is raised.  The values of the key property on
479         all existing nodes must be unique or a ValueError is raised.
480         """
481         raise NotImplementedError
483     def getkey(self):
484         """Return the name of the key property for this class or None."""
485         raise NotImplementedError
487     def labelprop(self, default_to_id=0):
488         ''' Return the property name for a label for the given node.
490         This method attempts to generate a consistent label for the node.
491         It tries the following in order:
492             1. key property
493             2. "name" property
494             3. "title" property
495             4. first property from the sorted property name list
496         '''
497         raise NotImplementedError
499     def lookup(self, keyvalue):
500         """Locate a particular node by its key property and return its id.
502         If this class has no key property, a TypeError is raised.  If the
503         'keyvalue' matches one of the values for the key property among
504         the nodes in this class, the matching node's id is returned;
505         otherwise a KeyError is raised.
506         """
507         raise NotImplementedError
509     def find(self, **propspec):
510         """Get the ids of nodes in this class which link to the given nodes.
512         'propspec' consists of keyword args propname={nodeid:1,}   
513         'propname' must be the name of a property in this class, or a
514         KeyError is raised.  That property must be a Link or Multilink
515         property, or a TypeError is raised.
517         Any node in this class whose 'propname' property links to any of the
518         nodeids will be returned. Used by the full text indexing, which knows
519         that "foo" occurs in msg1, msg3 and file7, so we have hits on these
520         issues:
522             db.issue.find(messages={'1':1,'3':1}, files={'7':1})
523         """
524         raise NotImplementedError
526     def filter(self, search_matches, filterspec, sort=(None,None),
527             group=(None,None)):
528         ''' Return a list of the ids of the active nodes in this class that
529             match the 'filter' spec, sorted by the group spec and then the
530             sort spec.
532             "filterspec" is {propname: value(s)}
533             "sort" and "group" are (dir, prop) where dir is '+', '-' or None
534                                and prop is a prop name or None
535             "search_matches" is {nodeid: marker}
537             The filter must match all properties specificed - but if the
538             property value to match is a list, any one of the values in the
539             list may match for that property to match.
540         '''
541         raise NotImplementedError
543     def count(self):
544         """Get the number of nodes in this class.
546         If the returned integer is 'numnodes', the ids of all the nodes
547         in this class run from 1 to numnodes, and numnodes+1 will be the
548         id of the next node to be created in this class.
549         """
550         raise NotImplementedError
552     # Manipulating properties:
553     def getprops(self, protected=1):
554         """Return a dictionary mapping property names to property objects.
555            If the "protected" flag is true, we include protected properties -
556            those which may not be modified.
557         """
558         raise NotImplementedError
560     def addprop(self, **properties):
561         """Add properties to this class.
563         The keyword arguments in 'properties' must map names to property
564         objects, or a TypeError is raised.  None of the keys in 'properties'
565         may collide with the names of existing properties, or a ValueError
566         is raised before any properties have been added.
567         """
568         raise NotImplementedError
570     def index(self, nodeid):
571         '''Add (or refresh) the node to search indexes
572         '''
573         raise NotImplementedError
575     def safeget(self, nodeid, propname, default=None):
576         """Safely get the value of a property on an existing node of this class.
578         Return 'default' if the node doesn't exist.
579         """
580         try:
581             return self.get(nodeid, propname)
582         except IndexError:
583             return default            
585 class HyperdbValueError(ValueError):
586     ''' Error converting a raw value into a Hyperdb value '''
587     pass
589 def convertLinkValue(db, propname, prop, value, idre=re.compile('\d+')):
590     ''' Convert the link value (may be id or key value) to an id value. '''
591     linkcl = db.classes[prop.classname]
592     if not idre.match(value):
593         if linkcl.getkey():
594             try:
595                 value = linkcl.lookup(value)
596             except KeyError, message:
597                 raise HyperdbValueError, 'property %s: %r is not a %s.'%(
598                     propname, value, prop.classname)
599         else:
600             raise HyperdbValueError, 'you may only enter ID values '\
601                 'for property %s'%propname
602     return value
604 def fixNewlines(text):
605     """ Homogenise line endings.
607         Different web clients send different line ending values, but
608         other systems (eg. email) don't necessarily handle those line
609         endings. Our solution is to convert all line endings to LF.
610     """
611     text = text.replace('\r\n', '\n')
612     return text.replace('\r', '\n')
614 def rawToHyperdb(db, klass, itemid, propname, value,
615         pwre=re.compile(r'{(\w+)}(.+)')):
616     ''' Convert the raw (user-input) value to a hyperdb-storable value. The
617         value is for the "propname" property on itemid (may be None for a
618         new item) of "klass" in "db".
620         The value is usually a string, but in the case of multilink inputs
621         it may be either a list of strings or a string with comma-separated
622         values.
623     '''
624     properties = klass.getprops()
626     # ensure it's a valid property name
627     propname = propname.strip()
628     try:
629         proptype =  properties[propname]
630     except KeyError:
631         raise HyperdbValueError, '%r is not a property of %s'%(propname,
632             klass.classname)
634     # if we got a string, strip it now
635     if isinstance(value, type('')):
636         value = value.strip()
638     # convert the input value to a real property value
639     if isinstance(proptype, String):
640         # fix the CRLF/CR -> LF stuff
641         value = fixNewlines(value)
642     if isinstance(proptype, Password):
643         m = pwre.match(value)
644         if m:
645             # password is being given to us encrypted
646             p = password.Password()
647             p.scheme = m.group(1)
648             if p.scheme not in 'SHA crypt plaintext'.split():
649                 raise HyperdbValueError, 'property %s: unknown encryption '\
650                     'scheme %r'%(propname, p.scheme)
651             p.password = m.group(2)
652             value = p
653         else:
654             try:
655                 value = password.Password(value)
656             except password.PasswordValueError, message:
657                 raise HyperdbValueError, 'property %s: %s'%(propname, message)
658     elif isinstance(proptype, Date):
659         try:
660             tz = db.getUserTimezone()
661             value = date.Date(value).local(tz)
662         except ValueError, message:
663             raise HyperdbValueError, 'property %s: %r is an invalid '\
664                 'date (%s)'%(propname, value, message)
665     elif isinstance(proptype, Interval):
666         try:
667             value = date.Interval(value)
668         except ValueError, message:
669             raise HyperdbValueError, 'property %s: %r is an invalid '\
670                 'date interval (%s)'%(propname, value, message)
671     elif isinstance(proptype, Link):
672         if value == '-1' or not value:
673             value = None
674         else:
675             value = convertLinkValue(db, propname, proptype, value)
677     elif isinstance(proptype, Multilink):
678         # get the current item value if it's not a new item
679         if itemid and not itemid.startswith('-'):
680             curvalue = klass.get(itemid, propname)
681         else:
682             curvalue = []
684         # if the value is a comma-separated string then split it now
685         if isinstance(value, type('')):
686             value = value.split(',')
688         # handle each add/remove in turn
689         # keep an extra list for all items that are
690         # definitely in the new list (in case of e.g.
691         # <propname>=A,+B, which should replace the old
692         # list with A,B)
693         set = 1
694         newvalue = []
695         for item in value:
696             item = item.strip()
698             # skip blanks
699             if not item: continue
701             # handle +/-
702             remove = 0
703             if item.startswith('-'):
704                 remove = 1
705                 item = item[1:]
706                 set = 0
707             elif item.startswith('+'):
708                 item = item[1:]
709                 set = 0
711             # look up the value
712             itemid = convertLinkValue(db, propname, proptype, item)
714             # perform the add/remove
715             if remove:
716                 try:
717                     curvalue.remove(itemid)
718                 except ValueError:
719                     raise HyperdbValueError, 'property %s: %r is not ' \
720                         'currently an element'%(propname, item)
721             else:
722                 newvalue.append(itemid)
723                 if itemid not in curvalue:
724                     curvalue.append(itemid)
726         # that's it, set the new Multilink property value,
727         # or overwrite it completely
728         if set:
729             value = newvalue
730         else:
731             value = curvalue
733         # TODO: one day, we'll switch to numeric ids and this will be
734         # unnecessary :(
735         value = [int(x) for x in value]
736         value.sort()
737         value = [str(x) for x in value]
738     elif isinstance(proptype, Boolean):
739         value = value.strip()
740         value = value.lower() in ('yes', 'true', 'on', '1')
741     elif isinstance(proptype, Number):
742         value = value.strip()
743         try:
744             value = float(value)
745         except ValueError:
746             raise HyperdbValueError, 'property %s: %r is not a number'%(
747                 propname, value)
748     return value
750 class FileClass:
751     ''' A class that requires the "content" property and stores it on
752         disk.
753     '''
754     pass
756 class Node:
757     ''' A convenience wrapper for the given node
758     '''
759     def __init__(self, cl, nodeid, cache=1):
760         self.__dict__['cl'] = cl
761         self.__dict__['nodeid'] = nodeid
762     def keys(self, protected=1):
763         return self.cl.getprops(protected=protected).keys()
764     def values(self, protected=1):
765         l = []
766         for name in self.cl.getprops(protected=protected).keys():
767             l.append(self.cl.get(self.nodeid, name))
768         return l
769     def items(self, protected=1):
770         l = []
771         for name in self.cl.getprops(protected=protected).keys():
772             l.append((name, self.cl.get(self.nodeid, name)))
773         return l
774     def has_key(self, name):
775         return self.cl.getprops().has_key(name)
776     def get(self, name, default=None): 
777         if self.has_key(name):
778             return self[name]
779         else:
780             return default
781     def __getattr__(self, name):
782         if self.__dict__.has_key(name):
783             return self.__dict__[name]
784         try:
785             return self.cl.get(self.nodeid, name)
786         except KeyError, value:
787             # we trap this but re-raise it as AttributeError - all other
788             # exceptions should pass through untrapped
789             pass
790         # nope, no such attribute
791         raise AttributeError, str(value)
792     def __getitem__(self, name):
793         return self.cl.get(self.nodeid, name)
794     def __setattr__(self, name, value):
795         try:
796             return self.cl.set(self.nodeid, **{name: value})
797         except KeyError, value:
798             raise AttributeError, str(value)
799     def __setitem__(self, name, value):
800         self.cl.set(self.nodeid, **{name: value})
801     def history(self):
802         return self.cl.history(self.nodeid)
803     def retire(self):
804         return self.cl.retire(self.nodeid)
807 def Choice(name, db, *options):
808     '''Quick helper to create a simple class with choices
809     '''
810     cl = Class(db, name, name=String(), order=String())
811     for i in range(len(options)):
812         cl.create(name=options[i], order=i)
813     return hyperdb.Link(name)
815 # vim: set filetype=python ts=4 sw=4 et si