Code

Fix first part of Password handling security issue2550688 (thanks
[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 #
19 """Hyperdatabase implementation, especially field types.
20 """
21 __docformat__ = 'restructuredtext'
23 # standard python modules
24 import os, re, shutil, weakref
25 # Python 2.3 ... 2.6 compatibility:
26 from roundup.anypy.sets_ import set
28 # roundup modules
29 import date, password
30 from support import ensureParentsExist, PrioList, sorted, reversed
31 from roundup.i18n import _
33 #
34 # Types
35 #
36 class _Type(object):
37     """A roundup property type."""
38     def __init__(self, required=False):
39         self.required = required
40     def __repr__(self):
41         ' more useful for dumps '
42         return '<%s.%s>'%(self.__class__.__module__, self.__class__.__name__)
43     def sort_repr (self, cls, val, name):
44         """Representation used for sorting. This should be a python
45         built-in type, otherwise sorting will take ages. Note that
46         individual backends may chose to use something different for
47         sorting as long as the outcome is the same.
48         """
49         return val
51 class String(_Type):
52     """An object designating a String property."""
53     def __init__(self, indexme='no', required=False):
54         super(String, self).__init__(required)
55         self.indexme = indexme == 'yes'
56     def from_raw(self, value, propname='', **kw):
57         """fix the CRLF/CR -> LF stuff"""
58         if propname == 'content':
59             # Why oh why wasn't the FileClass content property a File
60             # type from the beginning?
61             return value
62         return fixNewlines(value)
63     def sort_repr (self, cls, val, name):
64         if not val:
65             return val
66         if name == 'id':
67             return int(val)
68         return val.lower()
70 class Password(_Type):
71     """An object designating a Password property."""
72     def from_raw(self, value, **kw):
73         if not value:
74             return None
75         try:
76             return password.Password(encrypted=value, strict=True)
77         except password.PasswordValueError, message:
78             raise HyperdbValueError, \
79                     _('property %s: %s')%(kw['propname'], message)
81     def sort_repr (self, cls, val, name):
82         if not val:
83             return val
84         return str(val)
86 class Date(_Type):
87     """An object designating a Date property."""
88     def __init__(self, offset=None, required=False):
89         super(Date, self).__init__(required)
90         self._offset = offset
91     def offset(self, db):
92         if self._offset is not None:
93             return self._offset
94         return db.getUserTimezone()
95     def from_raw(self, value, db, **kw):
96         try:
97             value = date.Date(value, self.offset(db))
98         except ValueError, message:
99             raise HyperdbValueError, _('property %s: %r is an invalid '\
100                 'date (%s)')%(kw['propname'], value, message)
101         return value
102     def range_from_raw(self, value, db):
103         """return Range value from given raw value with offset correction"""
104         return date.Range(value, date.Date, offset=self.offset(db))
105     def sort_repr (self, cls, val, name):
106         if not val:
107             return val
108         return str(val)
110 class Interval(_Type):
111     """An object designating an Interval property."""
112     def from_raw(self, value, **kw):
113         try:
114             value = date.Interval(value)
115         except ValueError, message:
116             raise HyperdbValueError, _('property %s: %r is an invalid '\
117                 'date interval (%s)')%(kw['propname'], value, message)
118         return value
119     def sort_repr (self, cls, val, name):
120         if not val:
121             return val
122         return val.as_seconds()
124 class _Pointer(_Type):
125     """An object designating a Pointer property that links or multilinks
126     to a node in a specified class."""
127     def __init__(self, classname, do_journal='yes', required=False):
128         """ Default is to journal link and unlink events
129         """
130         super(_Pointer, self).__init__(required)
131         self.classname = classname
132         self.do_journal = do_journal == 'yes'
133     def __repr__(self):
134         """more useful for dumps. But beware: This is also used in schema
135         storage in SQL backends!
136         """
137         return '<%s.%s to "%s">'%(self.__class__.__module__,
138             self.__class__.__name__, self.classname)
140 class Link(_Pointer):
141     """An object designating a Link property that links to a
142        node in a specified class."""
143     def from_raw(self, value, db, propname, **kw):
144         if value == '-1' or not value:
145             value = None
146         else:
147             value = convertLinkValue(db, propname, self, value)
148         return value
149     def sort_repr (self, cls, val, name):
150         if not val:
151             return val
152         op = cls.labelprop()
153         if op == 'id':
154             return int(cls.get(val, op))
155         return cls.get(val, op)
157 class Multilink(_Pointer):
158     """An object designating a Multilink property that links
159        to nodes in a specified class.
161        "classname" indicates the class to link to
163        "do_journal" indicates whether the linked-to nodes should have
164                     'link' and 'unlink' events placed in their journal
165     """
166     def from_raw(self, value, db, klass, propname, itemid, **kw):
167         if not value:
168             return []
170         # get the current item value if it's not a new item
171         if itemid and not itemid.startswith('-'):
172             curvalue = klass.get(itemid, propname)
173         else:
174             curvalue = []
176         # if the value is a comma-separated string then split it now
177         if isinstance(value, type('')):
178             value = value.split(',')
180         # handle each add/remove in turn
181         # keep an extra list for all items that are
182         # definitely in the new list (in case of e.g.
183         # <propname>=A,+B, which should replace the old
184         # list with A,B)
185         do_set = 1
186         newvalue = []
187         for item in value:
188             item = item.strip()
190             # skip blanks
191             if not item: continue
193             # handle +/-
194             remove = 0
195             if item.startswith('-'):
196                 remove = 1
197                 item = item[1:]
198                 do_set = 0
199             elif item.startswith('+'):
200                 item = item[1:]
201                 do_set = 0
203             # look up the value
204             itemid = convertLinkValue(db, propname, self, item)
206             # perform the add/remove
207             if remove:
208                 try:
209                     curvalue.remove(itemid)
210                 except ValueError:
211                     raise HyperdbValueError, _('property %s: %r is not ' \
212                         'currently an element')%(propname, item)
213             else:
214                 newvalue.append(itemid)
215                 if itemid not in curvalue:
216                     curvalue.append(itemid)
218         # that's it, set the new Multilink property value,
219         # or overwrite it completely
220         if do_set:
221             value = newvalue
222         else:
223             value = curvalue
225         # TODO: one day, we'll switch to numeric ids and this will be
226         # unnecessary :(
227         value = [int(x) for x in value]
228         value.sort()
229         value = [str(x) for x in value]
230         return value
232     def sort_repr (self, cls, val, name):
233         if not val:
234             return val
235         op = cls.labelprop()
236         if op == 'id':
237             return [int(cls.get(v, op)) for v in val]
238         return [cls.get(v, op) for v in val]
240 class Boolean(_Type):
241     """An object designating a boolean property"""
242     def from_raw(self, value, **kw):
243         value = value.strip()
244         # checked is a common HTML checkbox value
245         value = value.lower() in ('checked', 'yes', 'true', 'on', '1')
246         return value
248 class Number(_Type):
249     """An object designating a numeric property"""
250     def from_raw(self, value, **kw):
251         value = value.strip()
252         try:
253             value = float(value)
254         except ValueError:
255             raise HyperdbValueError, _('property %s: %r is not a number')%(
256                 kw['propname'], value)
257         return value
259 # Support for splitting designators
261 class DesignatorError(ValueError):
262     pass
263 def splitDesignator(designator, dre=re.compile(r'([^\d]+)(\d+)')):
264     """ Take a foo123 and return ('foo', 123)
265     """
266     m = dre.match(designator)
267     if m is None:
268         raise DesignatorError, _('"%s" not a node designator')%designator
269     return m.group(1), m.group(2)
271 class Proptree(object):
272     """ Simple tree data structure for optimizing searching of
273     properties. Each node in the tree represents a roundup Class
274     Property that has to be navigated for finding the given search
275     or sort properties. The need_for attribute is used for
276     distinguishing nodes in the tree used for sorting, searching or
277     retrieval: The attribute is a dictionary containing one or several
278     of the values 'sort', 'search', 'retrieve'.
280     The Proptree is also used for transitively searching attributes for
281     backends that do not support transitive search (e.g. anydbm). The
282     _val attribute with set_val is used for this.
283     """
285     def __init__(self, db, cls, name, props, parent=None, retr=False):
286         self.db = db
287         self.name = name
288         self.props = props
289         self.parent = parent
290         self._val = None
291         self.has_values = False
292         self.cls = cls
293         self.classname = None
294         self.uniqname = None
295         self.children = []
296         self.sortattr = []
297         self.propdict = {}
298         self.need_for = {'search' : True}
299         self.sort_direction = None
300         self.sort_ids = None
301         self.sort_ids_needed = False
302         self.sort_result = None
303         self.attr_sort_done = False
304         self.tree_sort_done = False
305         self.propclass = None
306         self.orderby = []
307         self.sql_idx = None # index of retrieved column in sql result
308         if parent:
309             self.root = parent.root
310             self.depth = parent.depth + 1
311         else:
312             self.root = self
313             self.seqno = 1
314             self.depth = 0
315             self.need_for['sort'] = True
316         self.id = self.root.seqno
317         self.root.seqno += 1
318         if self.cls:
319             self.classname = self.cls.classname
320             self.uniqname = '%s%s' % (self.cls.classname, self.id)
321         if not self.parent:
322             self.uniqname = self.cls.classname
323         if retr:
324             self.append_retr_props()
326     def append(self, name, need_for='search', retr=False):
327         """Append a property to self.children. Will create a new
328         propclass for the child.
329         """
330         if name in self.propdict:
331             pt = self.propdict[name]
332             pt.need_for[need_for] = True
333             if retr and isinstance(pt.propclass, Link):
334                 pt.append_retr_props()
335             return pt
336         propclass = self.props[name]
337         cls = None
338         props = None
339         if isinstance(propclass, (Link, Multilink)):
340             cls = self.db.getclass(propclass.classname)
341             props = cls.getprops()
342         child = self.__class__(self.db, cls, name, props, parent = self)
343         child.need_for = {need_for : True}
344         child.propclass = propclass
345         self.children.append(child)
346         self.propdict[name] = child
347         if retr and isinstance(child.propclass, Link):
348             child.append_retr_props()
349         return child
351     def append_retr_props(self):
352         """Append properties for retrieval."""
353         for name, prop in self.cls.getprops(protected=1).iteritems():
354             if isinstance(prop, Multilink):
355                 continue
356             self.append(name, need_for='retrieve')
358     def compute_sort_done(self, mlseen=False):
359         """ Recursively check if attribute is needed for sorting
360         ('sort' in self.need_for) or all children have tree_sort_done set and
361         sort_ids_needed unset: set self.tree_sort_done if one of the conditions
362         holds. Also remove sort_ids_needed recursively once having seen a
363         Multilink.
364         """
365         if isinstance (self.propclass, Multilink):
366             mlseen = True
367         if mlseen:
368             self.sort_ids_needed = False
369         self.tree_sort_done = True
370         for p in self.children:
371             p.compute_sort_done(mlseen)
372             if not p.tree_sort_done:
373                 self.tree_sort_done = False
374         if 'sort' not in self.need_for:
375             self.tree_sort_done = True
376         if mlseen:
377             self.tree_sort_done = False
379     def ancestors(self):
380         p = self
381         while p.parent:
382             yield p
383             p = p.parent
385     def search(self, search_matches=None, sort=True):
386         """ Recursively search for the given properties in a proptree.
387         Once all properties are non-transitive, the search generates a
388         simple _filter call which does the real work
389         """
390         filterspec = {}
391         for p in self.children:
392             if 'search' in p.need_for:
393                 if p.children:
394                     p.search(sort = False)
395                 filterspec[p.name] = p.val
396         self.val = self.cls._filter(search_matches, filterspec, sort and self)
397         return self.val
399     def sort (self, ids=None):
400         """ Sort ids by the order information stored in self. With
401         optimisations: Some order attributes may be precomputed (by the
402         backend) and some properties may already be sorted.
403         """
404         if ids is None:
405             ids = self.val
406         if self.sortattr and [s for s in self.sortattr if not s.attr_sort_done]:
407             return self._searchsort(ids, True, True)
408         return ids
410     def sortable_children(self, intermediate=False):
411         """ All children needed for sorting. If intermediate is True,
412         intermediate nodes (not being a sort attribute) are returned,
413         too.
414         """
415         return [p for p in self.children
416                 if 'sort' in p.need_for and (intermediate or p.sort_direction)]
418     def __iter__(self):
419         """ Yield nodes in depth-first order -- visited nodes first """
420         for p in self.children:
421             yield p
422             for c in p:
423                 yield c
425     def _get (self, ids):
426         """Lookup given ids -- possibly a list of list. We recurse until
427         we have a list of ids.
428         """
429         if not ids:
430             return ids
431         if isinstance (ids[0], list):
432             cids = [self._get(i) for i in ids]
433         else:
434             cids = [i and self.parent.cls.get(i, self.name) for i in ids]
435             if self.sortattr:
436                 cids = [self._searchsort(i, False, True) for i in cids]
437         return cids
439     def _searchsort(self, ids=None, update=True, dosort=True):
440         """ Recursively compute the sort attributes. Note that ids
441         may be a deeply nested list of lists of ids if several
442         multilinks are encountered on the way from the root to an
443         individual attribute. We make sure that everything is properly
444         sorted on the way up. Note that the individual backend may
445         already have precomputed self.result or self.sort_ids. In this
446         case we do nothing for existing sa.result and recurse further if
447         self.sort_ids is available.
449         Yech, Multilinks: This gets especially complicated if somebody
450         sorts by different attributes of the same multilink (or
451         transitively across several multilinks). My use-case is sorting
452         by issue.messages.author and (reverse) by issue.messages.date.
453         In this case we sort the messages by author and date and use
454         this sorted list twice for sorting issues. This means that
455         issues are sorted by author and then by the time of the messages
456         *of this author*. Probably what the user intends in that case,
457         so we do *not* use two sorted lists of messages, one sorted by
458         author and one sorted by date for sorting issues.
459         """
460         for pt in self.sortable_children(intermediate = True):
461             # ids can be an empty list
462             if pt.tree_sort_done or not ids:
463                 continue
464             if pt.sort_ids: # cached or computed by backend
465                 cids = pt.sort_ids
466             else:
467                 cids = pt._get(ids)
468             if pt.sort_direction and not pt.sort_result:
469                 sortrep = pt.propclass.sort_repr
470                 pt.sort_result = pt._sort_repr(sortrep, cids)
471             pt.sort_ids = cids
472             if pt.children:
473                 pt._searchsort(cids, update, False)
474         if self.sortattr and dosort:
475             ids = self._sort(ids)
476         if not update:
477             for pt in self.sortable_children(intermediate = True):
478                 pt.sort_ids = None
479             for pt in self.sortattr:
480                 pt.sort_result = None
481         return ids
483     def _set_val(self, val):
484         """Check if self._val is already defined. If yes, we compute the
485         intersection of the old and the new value(s)
486         """
487         if self.has_values:
488             v = self._val
489             if not isinstance(self._val, type([])):
490                 v = [self._val]
491             vals = set(v)
492             vals.intersection_update(val)
493             self._val = [v for v in vals]
494         else:
495             self._val = val
496         self.has_values = True
498     val = property(lambda self: self._val, _set_val)
500     def _sort(self, val):
501         """Finally sort by the given sortattr.sort_result. Note that we
502         do not sort by attrs having attr_sort_done set. The caller is
503         responsible for setting attr_sort_done only for trailing
504         attributes (otherwise the sort order is wrong). Since pythons
505         sort is stable, we can sort already sorted lists without
506         destroying the sort-order for items that compare equal with the
507         current sort.
509         Sorting-Strategy: We sort repeatedly by different sort-keys from
510         right to left. Since pythons sort is stable, we can safely do
511         that. An optimisation is a "run-length encoding" of the
512         sort-directions: If several sort attributes sort in the same
513         direction we can combine them into a single sort. Note that
514         repeated sorting is probably more efficient than using
515         compare-methods in python due to the overhead added by compare
516         methods.
517         """
518         if not val:
519             return val
520         sortattr = []
521         directions = []
522         dir_idx = []
523         idx = 0
524         curdir = None
525         for sa in self.sortattr:
526             if sa.attr_sort_done:
527                 break
528             if sortattr:
529                 assert len(sortattr[0]) == len(sa.sort_result)
530             sortattr.append (sa.sort_result)
531             if curdir != sa.sort_direction:
532                 dir_idx.append (idx)
533                 directions.append (sa.sort_direction)
534                 curdir = sa.sort_direction
535             idx += 1
536         sortattr.append (val)
537         sortattr = zip (*sortattr)
538         for dir, i in reversed(zip(directions, dir_idx)):
539             rev = dir == '-'
540             sortattr = sorted (sortattr, key = lambda x:x[i:idx], reverse = rev)
541             idx = i
542         return [x[-1] for x in sortattr]
544     def _sort_repr(self, sortrep, ids):
545         """Call sortrep for given ids -- possibly a list of list. We
546         recurse until we have a list of ids.
547         """
548         if not ids:
549             return ids
550         if isinstance (ids[0], list):
551             res = [self._sort_repr(sortrep, i) for i in ids]
552         else:
553             res = [sortrep(self.cls, i, self.name) for i in ids]
554         return res
556     def __repr__(self):
557         r = ["proptree:" + self.name]
558         for n in self:
559             r.append("proptree:" + "    " * n.depth + n.name)
560         return '\n'.join(r)
561     __str__ = __repr__
564 # the base Database class
566 class DatabaseError(ValueError):
567     """Error to be raised when there is some problem in the database code
568     """
569     pass
570 class Database:
571     """A database for storing records containing flexible data types.
573 This class defines a hyperdatabase storage layer, which the Classes use to
574 store their data.
577 Transactions
578 ------------
579 The Database should support transactions through the commit() and
580 rollback() methods. All other Database methods should be transaction-aware,
581 using data from the current transaction before looking up the database.
583 An implementation must provide an override for the get() method so that the
584 in-database value is returned in preference to the in-transaction value.
585 This is necessary to determine if any values have changed during a
586 transaction.
589 Implementation
590 --------------
592 All methods except __repr__ must be implemented by a concrete backend Database.
594 """
596     # flag to set on retired entries
597     RETIRED_FLAG = '__hyperdb_retired'
599     BACKEND_MISSING_STRING = None
600     BACKEND_MISSING_NUMBER = None
601     BACKEND_MISSING_BOOLEAN = None
603     def __init__(self, config, journaltag=None):
604         """Open a hyperdatabase given a specifier to some storage.
606         The 'storagelocator' is obtained from config.DATABASE.
607         The meaning of 'storagelocator' depends on the particular
608         implementation of the hyperdatabase.  It could be a file name,
609         a directory path, a socket descriptor for a connection to a
610         database over the network, etc.
612         The 'journaltag' is a token that will be attached to the journal
613         entries for any edits done on the database.  If 'journaltag' is
614         None, the database is opened in read-only mode: the Class.create(),
615         Class.set(), and Class.retire() methods are disabled.
616         """
617         raise NotImplementedError
619     def post_init(self):
620         """Called once the schema initialisation has finished.
621            If 'refresh' is true, we want to rebuild the backend
622            structures.
623         """
624         raise NotImplementedError
626     def refresh_database(self):
627         """Called to indicate that the backend should rebuild all tables
628            and structures. Not called in normal usage."""
629         raise NotImplementedError
631     def __getattr__(self, classname):
632         """A convenient way of calling self.getclass(classname)."""
633         raise NotImplementedError
635     def addclass(self, cl):
636         """Add a Class to the hyperdatabase.
637         """
638         raise NotImplementedError
640     def getclasses(self):
641         """Return a list of the names of all existing classes."""
642         raise NotImplementedError
644     def getclass(self, classname):
645         """Get the Class object representing a particular class.
647         If 'classname' is not a valid class name, a KeyError is raised.
648         """
649         raise NotImplementedError
651     def clear(self):
652         """Delete all database contents.
653         """
654         raise NotImplementedError
656     def getclassdb(self, classname, mode='r'):
657         """Obtain a connection to the class db that will be used for
658            multiple actions.
659         """
660         raise NotImplementedError
662     def addnode(self, classname, nodeid, node):
663         """Add the specified node to its class's db.
664         """
665         raise NotImplementedError
667     def serialise(self, classname, node):
668         """Copy the node contents, converting non-marshallable data into
669            marshallable data.
670         """
671         return node
673     def setnode(self, classname, nodeid, node):
674         """Change the specified node.
675         """
676         raise NotImplementedError
678     def unserialise(self, classname, node):
679         """Decode the marshalled node data
680         """
681         return node
683     def getnode(self, classname, nodeid):
684         """Get a node from the database.
686         'cache' exists for backwards compatibility, and is not used.
687         """
688         raise NotImplementedError
690     def hasnode(self, classname, nodeid):
691         """Determine if the database has a given node.
692         """
693         raise NotImplementedError
695     def countnodes(self, classname):
696         """Count the number of nodes that exist for a particular Class.
697         """
698         raise NotImplementedError
700     def storefile(self, classname, nodeid, property, content):
701         """Store the content of the file in the database.
703            The property may be None, in which case the filename does not
704            indicate which property is being saved.
705         """
706         raise NotImplementedError
708     def getfile(self, classname, nodeid, property):
709         """Get the content of the file in the database.
710         """
711         raise NotImplementedError
713     def addjournal(self, classname, nodeid, action, params):
714         """ Journal the Action
715         'action' may be:
717             'create' or 'set' -- 'params' is a dictionary of property values
718             'link' or 'unlink' -- 'params' is (classname, nodeid, propname)
719             'retire' -- 'params' is None
720         """
721         raise NotImplementedError
723     def getjournal(self, classname, nodeid):
724         """ get the journal for id
725         """
726         raise NotImplementedError
728     def pack(self, pack_before):
729         """ pack the database
730         """
731         raise NotImplementedError
733     def commit(self):
734         """ Commit the current transactions.
736         Save all data changed since the database was opened or since the
737         last commit() or rollback().
739         fail_ok indicates that the commit is allowed to fail. This is used
740         in the web interface when committing cleaning of the session
741         database. We don't care if there's a concurrency issue there.
743         The only backend this seems to affect is postgres.
744         """
745         raise NotImplementedError
747     def rollback(self):
748         """ Reverse all actions from the current transaction.
750         Undo all the changes made since the database was opened or the last
751         commit() or rollback() was performed.
752         """
753         raise NotImplementedError
755     def close(self):
756         """Close the database.
758         This method must be called at the end of processing.
760         """
762 def iter_roles(roles):
763     ''' handle the text processing of turning the roles list
764         into something python can use more easily
765     '''
766     if not roles or not roles.strip():
767         raise StopIteration, "Empty roles given"
768     for role in [x.lower().strip() for x in roles.split(',')]:
769         yield role
773 # The base Class class
775 class Class:
776     """ The handle to a particular class of nodes in a hyperdatabase.
778         All methods except __repr__ and getnode must be implemented by a
779         concrete backend Class.
780     """
782     def __init__(self, db, classname, **properties):
783         """Create a new class with a given name and property specification.
785         'classname' must not collide with the name of an existing class,
786         or a ValueError is raised.  The keyword arguments in 'properties'
787         must map names to property objects, or a TypeError is raised.
788         """
789         for name in 'creation activity creator actor'.split():
790             if properties.has_key(name):
791                 raise ValueError, '"creation", "activity", "creator" and '\
792                     '"actor" are reserved'
794         self.classname = classname
795         self.properties = properties
796         self.db = weakref.proxy(db)       # use a weak ref to avoid circularity
797         self.key = ''
799         # should we journal changes (default yes)
800         self.do_journal = 1
802         # do the db-related init stuff
803         db.addclass(self)
805         actions = "create set retire restore".split()
806         self.auditors = dict([(a, PrioList()) for a in actions])
807         self.reactors = dict([(a, PrioList()) for a in actions])
809     def __repr__(self):
810         """Slightly more useful representation
811         """
812         return '<hyperdb.Class "%s">'%self.classname
814     # Editing nodes:
816     def create(self, **propvalues):
817         """Create a new node of this class and return its id.
819         The keyword arguments in 'propvalues' map property names to values.
821         The values of arguments must be acceptable for the types of their
822         corresponding properties or a TypeError is raised.
824         If this class has a key property, it must be present and its value
825         must not collide with other key strings or a ValueError is raised.
827         Any other properties on this class that are missing from the
828         'propvalues' dictionary are set to None.
830         If an id in a link or multilink property does not refer to a valid
831         node, an IndexError is raised.
832         """
833         raise NotImplementedError
835     _marker = []
836     def get(self, nodeid, propname, default=_marker, cache=1):
837         """Get the value of a property on an existing node of this class.
839         'nodeid' must be the id of an existing node of this class or an
840         IndexError is raised.  'propname' must be the name of a property
841         of this class or a KeyError is raised.
843         'cache' exists for backwards compatibility, and is not used.
844         """
845         raise NotImplementedError
847     # not in spec
848     def getnode(self, nodeid):
849         """ Return a convenience wrapper for the node.
851         'nodeid' must be the id of an existing node of this class or an
852         IndexError is raised.
854         'cache' exists for backwards compatibility, and is not used.
855         """
856         return Node(self, nodeid)
858     def getnodeids(self, retired=None):
859         """Retrieve all the ids of the nodes for a particular Class.
860         """
861         raise NotImplementedError
863     def set(self, nodeid, **propvalues):
864         """Modify a property on an existing node of this class.
866         'nodeid' must be the id of an existing node of this class or an
867         IndexError is raised.
869         Each key in 'propvalues' must be the name of a property of this
870         class or a KeyError is raised.
872         All values in 'propvalues' must be acceptable types for their
873         corresponding properties or a TypeError is raised.
875         If the value of the key property is set, it must not collide with
876         other key strings or a ValueError is raised.
878         If the value of a Link or Multilink property contains an invalid
879         node id, a ValueError is raised.
880         """
881         raise NotImplementedError
883     def retire(self, nodeid):
884         """Retire a node.
886         The properties on the node remain available from the get() method,
887         and the node's id is never reused.
889         Retired nodes are not returned by the find(), list(), or lookup()
890         methods, and other nodes may reuse the values of their key properties.
891         """
892         raise NotImplementedError
894     def restore(self, nodeid):
895         """Restpre a retired node.
897         Make node available for all operations like it was before retirement.
898         """
899         raise NotImplementedError
901     def is_retired(self, nodeid):
902         """Return true if the node is rerired
903         """
904         raise NotImplementedError
906     def destroy(self, nodeid):
907         """Destroy a node.
909         WARNING: this method should never be used except in extremely rare
910                  situations where there could never be links to the node being
911                  deleted
913         WARNING: use retire() instead
915         WARNING: the properties of this node will not be available ever again
917         WARNING: really, use retire() instead
919         Well, I think that's enough warnings. This method exists mostly to
920         support the session storage of the cgi interface.
922         The node is completely removed from the hyperdb, including all journal
923         entries. It will no longer be available, and will generally break code
924         if there are any references to the node.
925         """
927     def history(self, nodeid):
928         """Retrieve the journal of edits on a particular node.
930         'nodeid' must be the id of an existing node of this class or an
931         IndexError is raised.
933         The returned list contains tuples of the form
935             (date, tag, action, params)
937         'date' is a Timestamp object specifying the time of the change and
938         'tag' is the journaltag specified when the database was opened.
939         """
940         raise NotImplementedError
942     # Locating nodes:
943     def hasnode(self, nodeid):
944         """Determine if the given nodeid actually exists
945         """
946         raise NotImplementedError
948     def setkey(self, propname):
949         """Select a String property of this class to be the key property.
951         'propname' must be the name of a String property of this class or
952         None, or a TypeError is raised.  The values of the key property on
953         all existing nodes must be unique or a ValueError is raised.
954         """
955         raise NotImplementedError
957     def setlabelprop(self, labelprop):
958         """Set the label property. Used for override of labelprop
959            resolution order.
960         """
961         if labelprop not in self.getprops():
962             raise ValueError, _("Not a property name: %s") % labelprop
963         self._labelprop = labelprop
965     def setorderprop(self, orderprop):
966         """Set the order property. Used for override of orderprop
967            resolution order
968         """
969         if orderprop not in self.getprops():
970             raise ValueError, _("Not a property name: %s") % orderprop
971         self._orderprop = orderprop
973     def getkey(self):
974         """Return the name of the key property for this class or None."""
975         raise NotImplementedError
977     def labelprop(self, default_to_id=0):
978         """Return the property name for a label for the given node.
980         This method attempts to generate a consistent label for the node.
981         It tries the following in order:
983         0. self._labelprop if set
984         1. key property
985         2. "name" property
986         3. "title" property
987         4. first property from the sorted property name list
988         """
989         if hasattr(self, '_labelprop'):
990             return self._labelprop
991         k = self.getkey()
992         if  k:
993             return k
994         props = self.getprops()
995         if props.has_key('name'):
996             return 'name'
997         elif props.has_key('title'):
998             return 'title'
999         if default_to_id:
1000             return 'id'
1001         props = props.keys()
1002         props.sort()
1003         return props[0]
1005     def orderprop(self):
1006         """Return the property name to use for sorting for the given node.
1008         This method computes the property for sorting.
1009         It tries the following in order:
1011         0. self._orderprop if set
1012         1. "order" property
1013         2. self.labelprop()
1014         """
1016         if hasattr(self, '_orderprop'):
1017             return self._orderprop
1018         props = self.getprops()
1019         if props.has_key('order'):
1020             return 'order'
1021         return self.labelprop()
1023     def lookup(self, keyvalue):
1024         """Locate a particular node by its key property and return its id.
1026         If this class has no key property, a TypeError is raised.  If the
1027         'keyvalue' matches one of the values for the key property among
1028         the nodes in this class, the matching node's id is returned;
1029         otherwise a KeyError is raised.
1030         """
1031         raise NotImplementedError
1033     def find(self, **propspec):
1034         """Get the ids of nodes in this class which link to the given nodes.
1036         'propspec' consists of keyword args propname={nodeid:1,}
1037         'propname' must be the name of a property in this class, or a
1038         KeyError is raised.  That property must be a Link or Multilink
1039         property, or a TypeError is raised.
1041         Any node in this class whose 'propname' property links to any of the
1042         nodeids will be returned. Used by the full text indexing, which knows
1043         that "foo" occurs in msg1, msg3 and file7, so we have hits on these
1044         issues:
1046             db.issue.find(messages={'1':1,'3':1}, files={'7':1})
1047         """
1048         raise NotImplementedError
1050     def _filter(self, search_matches, filterspec, sort=(None,None),
1051             group=(None,None)):
1052         """For some backends this implements the non-transitive
1053         search, for more information see the filter method.
1054         """
1055         raise NotImplementedError
1057     def _proptree(self, filterspec, sortattr=[], retr=False):
1058         """Build a tree of all transitive properties in the given
1059         filterspec.
1060         If we retrieve (retr is True) linked items we don't follow
1061         across multilinks. We also don't follow if the searched value
1062         can contain NULL values.
1063         """
1064         proptree = Proptree(self.db, self, '', self.getprops(), retr=retr)
1065         for key, v in filterspec.iteritems():
1066             keys = key.split('.')
1067             p = proptree
1068             mlseen = False
1069             for k in keys:
1070                 if isinstance (p.propclass, Multilink):
1071                     mlseen = True
1072                 isnull = v == '-1' or v is None
1073                 nullin = isinstance(v, type([])) and ('-1' in v or None in v)
1074                 r = retr and not mlseen and not isnull and not nullin
1075                 p = p.append(k, retr=r)
1076             p.val = v
1077         multilinks = {}
1078         for s in sortattr:
1079             keys = s[1].split('.')
1080             p = proptree
1081             mlseen = False
1082             for k in keys:
1083                 if isinstance (p.propclass, Multilink):
1084                     mlseen = True
1085                 r = retr and not mlseen
1086                 p = p.append(k, need_for='sort', retr=r)
1087                 if isinstance (p.propclass, Multilink):
1088                     multilinks[p] = True
1089             if p.cls:
1090                 p = p.append(p.cls.orderprop(), need_for='sort')
1091             if p.sort_direction: # if an orderprop is also specified explicitly
1092                 continue
1093             p.sort_direction = s[0]
1094             proptree.sortattr.append (p)
1095         for p in multilinks.iterkeys():
1096             sattr = {}
1097             for c in p:
1098                 if c.sort_direction:
1099                     sattr [c] = True
1100             for sa in proptree.sortattr:
1101                 if sa in sattr:
1102                     p.sortattr.append (sa)
1103         return proptree
1105     def get_transitive_prop(self, propname_path, default = None):
1106         """Expand a transitive property (individual property names
1107         separated by '.' into a new property at the end of the path. If
1108         one of the names does not refer to a valid property, we return
1109         None.
1110         Example propname_path (for class issue): "messages.author"
1111         """
1112         props = self.db.getclass(self.classname).getprops()
1113         for k in propname_path.split('.'):
1114             try:
1115                 prop = props[k]
1116             except (KeyError, TypeError):
1117                 return default
1118             cl = getattr(prop, 'classname', None)
1119             props = None
1120             if cl:
1121                 props = self.db.getclass(cl).getprops()
1122         return prop
1124     def _sortattr(self, sort=[], group=[]):
1125         """Build a single list of sort attributes in the correct order
1126         with sanity checks (no duplicate properties) included. Always
1127         sort last by id -- if id is not already in sortattr.
1128         """
1129         seen = {}
1130         sortattr = []
1131         for srt in group, sort:
1132             if not isinstance(srt, list):
1133                 srt = [srt]
1134             for s in srt:
1135                 if s[1] and s[1] not in seen:
1136                     sortattr.append((s[0] or '+', s[1]))
1137                     seen[s[1]] = True
1138         if 'id' not in seen :
1139             sortattr.append(('+', 'id'))
1140         return sortattr
1142     def filter(self, search_matches, filterspec, sort=[], group=[]):
1143         """Return a list of the ids of the active nodes in this class that
1144         match the 'filter' spec, sorted by the group spec and then the
1145         sort spec.
1147         "filterspec" is {propname: value(s)}
1149         "sort" and "group" are [(dir, prop), ...] where dir is '+', '-'
1150         or None and prop is a prop name or None. Note that for
1151         backward-compatibility reasons a single (dir, prop) tuple is
1152         also allowed.
1154         "search_matches" is a container type
1156         The filter must match all properties specificed. If the property
1157         value to match is a list:
1159         1. String properties must match all elements in the list, and
1160         2. Other properties must match any of the elements in the list.
1162         Note that now the propname in filterspec and prop in a
1163         sort/group spec may be transitive, i.e., it may contain
1164         properties of the form link.link.link.name, e.g. you can search
1165         for all issues where a message was added by a certain user in
1166         the last week with a filterspec of
1167         {'messages.author' : '42', 'messages.creation' : '.-1w;'}
1169         Implementation note:
1170         This implements a non-optimized version of Transitive search
1171         using _filter implemented in a backend class. A more efficient
1172         version can be implemented in the individual backends -- e.g.,
1173         an SQL backend will want to create a single SQL statement and
1174         override the filter method instead of implementing _filter.
1175         """
1176         sortattr = self._sortattr(sort = sort, group = group)
1177         proptree = self._proptree(filterspec, sortattr)
1178         proptree.search(search_matches)
1179         return proptree.sort()
1181     # non-optimized filter_iter, a backend may chose to implement a
1182     # better version that provides a real iterator that pre-fills the
1183     # cache for each id returned. Note that the filter_iter doesn't
1184     # promise to correctly sort by multilink (which isn't sane to do
1185     # anyway).
1186     filter_iter = filter
1188     def count(self):
1189         """Get the number of nodes in this class.
1191         If the returned integer is 'numnodes', the ids of all the nodes
1192         in this class run from 1 to numnodes, and numnodes+1 will be the
1193         id of the next node to be created in this class.
1194         """
1195         raise NotImplementedError
1197     # Manipulating properties:
1198     def getprops(self, protected=1):
1199         """Return a dictionary mapping property names to property objects.
1200            If the "protected" flag is true, we include protected properties -
1201            those which may not be modified.
1202         """
1203         raise NotImplementedError
1205     def get_required_props(self, propnames = []):
1206         """Return a dict of property names mapping to property objects.
1207         All properties that have the "required" flag set will be
1208         returned in addition to all properties in the propnames
1209         parameter.
1210         """
1211         props = self.getprops(protected = False)
1212         pdict = dict([(p, props[p]) for p in propnames])
1213         pdict.update([(k, v) for k, v in props.iteritems() if v.required])
1214         return pdict
1216     def addprop(self, **properties):
1217         """Add properties to this class.
1219         The keyword arguments in 'properties' must map names to property
1220         objects, or a TypeError is raised.  None of the keys in 'properties'
1221         may collide with the names of existing properties, or a ValueError
1222         is raised before any properties have been added.
1223         """
1224         raise NotImplementedError
1226     def index(self, nodeid):
1227         """Add (or refresh) the node to search indexes"""
1228         raise NotImplementedError
1230     #
1231     # Detector interface
1232     #
1233     def audit(self, event, detector, priority = 100):
1234         """Register an auditor detector"""
1235         self.auditors[event].append((priority, detector.__name__, detector))
1237     def fireAuditors(self, event, nodeid, newvalues):
1238         """Fire all registered auditors"""
1239         for prio, name, audit in self.auditors[event]:
1240             audit(self.db, self, nodeid, newvalues)
1242     def react(self, event, detector, priority = 100):
1243         """Register a reactor detector"""
1244         self.reactors[event].append((priority, detector.__name__, detector))
1246     def fireReactors(self, event, nodeid, oldvalues):
1247         """Fire all registered reactors"""
1248         for prio, name, react in self.reactors[event]:
1249             react(self.db, self, nodeid, oldvalues)
1251     #
1252     # import / export support
1253     #
1254     def export_propnames(self):
1255         """List the property names for export from this Class"""
1256         propnames = self.getprops().keys()
1257         propnames.sort()
1258         return propnames
1260     def import_journals(self, entries):
1261         """Import a class's journal.
1263         Uses setjournal() to set the journal for each item.
1264         Strategy for import: Sort first by id, then import journals for
1265         each id, this way the memory footprint is a lot smaller than the
1266         initial implementation which stored everything in a big hash by
1267         id and then proceeded to import journals for each id."""
1268         properties = self.getprops()
1269         a = []
1270         for l in entries:
1271             # first element in sorted list is the (numeric) id
1272             # in python2.4 and up we would use sorted with a key...
1273             a.append ((int (l [0].strip ("'")), l))
1274         a.sort ()
1277         last = 0
1278         r = []
1279         for n, l in a:
1280             nodeid, jdate, user, action, params = map(eval, l)
1281             assert (str(n) == nodeid)
1282             if n != last:
1283                 if r:
1284                     self.db.setjournal(self.classname, str(last), r)
1285                 last = n
1286                 r = []
1288             if action == 'set':
1289                 for propname, value in params.iteritems():
1290                     prop = properties[propname]
1291                     if value is None:
1292                         pass
1293                     elif isinstance(prop, Date):
1294                         value = date.Date(value)
1295                     elif isinstance(prop, Interval):
1296                         value = date.Interval(value)
1297                     elif isinstance(prop, Password):
1298                         value = password.Password(encrypted=value)
1299                     params[propname] = value
1300             elif action == 'create' and params:
1301                 # old tracker with data stored in the create!
1302                 params = {}
1303             r.append((nodeid, date.Date(jdate), user, action, params))
1304         if r:
1305             self.db.setjournal(self.classname, nodeid, r)
1307     #
1308     # convenience methods
1309     #
1310     def get_roles(self, nodeid):
1311         """Return iterator for all roles for this nodeid.
1313            Yields string-processed roles.
1314            This method can be overridden to provide a hook where we can
1315            insert other permission models (e.g. get roles from database)
1316            In standard schemas only a user has a roles property but
1317            this may be different in customized schemas.
1318            Note that this is the *central place* where role
1319            processing happens!
1320         """
1321         node = self.db.getnode(self.classname, nodeid)
1322         return iter_roles(node['roles'])
1324     def has_role(self, nodeid, *roles):
1325         '''See if this node has any roles that appear in roles.
1327            For convenience reasons we take a list.
1328            In standard schemas only a user has a roles property but
1329            this may be different in customized schemas.
1330         '''
1331         roles = dict.fromkeys ([r.strip().lower() for r in roles])
1332         for role in self.get_roles(nodeid):
1333             if role in roles:
1334                 return True
1335         return False
1338 class HyperdbValueError(ValueError):
1339     """ Error converting a raw value into a Hyperdb value """
1340     pass
1342 def convertLinkValue(db, propname, prop, value, idre=re.compile('^\d+$')):
1343     """ Convert the link value (may be id or key value) to an id value. """
1344     linkcl = db.classes[prop.classname]
1345     if not idre.match(value):
1346         if linkcl.getkey():
1347             try:
1348                 value = linkcl.lookup(value)
1349             except KeyError, message:
1350                 raise HyperdbValueError, _('property %s: %r is not a %s.')%(
1351                     propname, value, prop.classname)
1352         else:
1353             raise HyperdbValueError, _('you may only enter ID values '\
1354                 'for property %s')%propname
1355     return value
1357 def fixNewlines(text):
1358     """ Homogenise line endings.
1360         Different web clients send different line ending values, but
1361         other systems (eg. email) don't necessarily handle those line
1362         endings. Our solution is to convert all line endings to LF.
1363     """
1364     text = text.replace('\r\n', '\n')
1365     return text.replace('\r', '\n')
1367 def rawToHyperdb(db, klass, itemid, propname, value, **kw):
1368     """ Convert the raw (user-input) value to a hyperdb-storable value. The
1369         value is for the "propname" property on itemid (may be None for a
1370         new item) of "klass" in "db".
1372         The value is usually a string, but in the case of multilink inputs
1373         it may be either a list of strings or a string with comma-separated
1374         values.
1375     """
1376     properties = klass.getprops()
1378     # ensure it's a valid property name
1379     propname = propname.strip()
1380     try:
1381         proptype =  properties[propname]
1382     except KeyError:
1383         raise HyperdbValueError, _('%r is not a property of %s')%(propname,
1384             klass.classname)
1386     # if we got a string, strip it now
1387     if isinstance(value, type('')):
1388         value = value.strip()
1390     # convert the input value to a real property value
1391     value = proptype.from_raw(value, db=db, klass=klass,
1392         propname=propname, itemid=itemid, **kw)
1394     return value
1396 class FileClass:
1397     """ A class that requires the "content" property and stores it on
1398         disk.
1399     """
1400     default_mime_type = 'text/plain'
1402     def __init__(self, db, classname, **properties):
1403         """The newly-created class automatically includes the "content"
1404         property.
1405         """
1406         if not properties.has_key('content'):
1407             properties['content'] = String(indexme='yes')
1409     def export_propnames(self):
1410         """ Don't export the "content" property
1411         """
1412         propnames = self.getprops().keys()
1413         propnames.remove('content')
1414         propnames.sort()
1415         return propnames
1417     def exportFilename(self, dirname, nodeid):
1418         subdir_filename = self.db.subdirFilename(self.classname, nodeid)
1419         return os.path.join(dirname, self.classname+'-files', subdir_filename)
1421     def export_files(self, dirname, nodeid):
1422         """ Export the "content" property as a file, not csv column
1423         """
1424         source = self.db.filename(self.classname, nodeid)
1426         dest = self.exportFilename(dirname, nodeid)
1427         ensureParentsExist(dest)
1428         shutil.copyfile(source, dest)
1430     def import_files(self, dirname, nodeid):
1431         """ Import the "content" property as a file
1432         """
1433         source = self.exportFilename(dirname, nodeid)
1435         dest = self.db.filename(self.classname, nodeid, create=1)
1436         ensureParentsExist(dest)
1437         shutil.copyfile(source, dest)
1439         mime_type = None
1440         props = self.getprops()
1441         if props.has_key('type'):
1442             mime_type = self.get(nodeid, 'type')
1443         if not mime_type:
1444             mime_type = self.default_mime_type
1445         if props['content'].indexme:
1446             self.db.indexer.add_text((self.classname, nodeid, 'content'),
1447                 self.get(nodeid, 'content'), mime_type)
1449 class Node:
1450     """ A convenience wrapper for the given node
1451     """
1452     def __init__(self, cl, nodeid, cache=1):
1453         self.__dict__['cl'] = cl
1454         self.__dict__['nodeid'] = nodeid
1455     def keys(self, protected=1):
1456         return self.cl.getprops(protected=protected).keys()
1457     def values(self, protected=1):
1458         l = []
1459         for name in self.cl.getprops(protected=protected).keys():
1460             l.append(self.cl.get(self.nodeid, name))
1461         return l
1462     def items(self, protected=1):
1463         l = []
1464         for name in self.cl.getprops(protected=protected).keys():
1465             l.append((name, self.cl.get(self.nodeid, name)))
1466         return l
1467     def has_key(self, name):
1468         return self.cl.getprops().has_key(name)
1469     def get(self, name, default=None):
1470         if self.has_key(name):
1471             return self[name]
1472         else:
1473             return default
1474     def __getattr__(self, name):
1475         if self.__dict__.has_key(name):
1476             return self.__dict__[name]
1477         try:
1478             return self.cl.get(self.nodeid, name)
1479         except KeyError, value:
1480             # we trap this but re-raise it as AttributeError - all other
1481             # exceptions should pass through untrapped
1482             pass
1483         # nope, no such attribute
1484         raise AttributeError, str(value)
1485     def __getitem__(self, name):
1486         return self.cl.get(self.nodeid, name)
1487     def __setattr__(self, name, value):
1488         try:
1489             return self.cl.set(self.nodeid, **{name: value})
1490         except KeyError, value:
1491             raise AttributeError, str(value)
1492     def __setitem__(self, name, value):
1493         self.cl.set(self.nodeid, **{name: value})
1494     def history(self):
1495         return self.cl.history(self.nodeid)
1496     def retire(self):
1497         return self.cl.retire(self.nodeid)
1500 def Choice(name, db, *options):
1501     """Quick helper to create a simple class with choices
1502     """
1503     cl = Class(db, name, name=String(), order=String())
1504     for i in range(len(options)):
1505         cl.create(name=options[i], order=i)
1506     return Link(name)
1508 # vim: set filetype=python sts=4 sw=4 et si :