Log message for revision 117037: initial commit of CompositeIndex Changed: A Zope/branches/andig-compositeindex/src/Products/PluginIndexes/CompositeIndex/ A Zope/branches/andig-compositeindex/src/Products/PluginIndexes/CompositeIndex/CompositeIndex.py A Zope/branches/andig-compositeindex/src/Products/PluginIndexes/CompositeIndex/__init__.py A Zope/branches/andig-compositeindex/src/Products/PluginIndexes/CompositeIndex/dtml/ A Zope/branches/andig-compositeindex/src/Products/PluginIndexes/CompositeIndex/dtml/addCompositeIndex.dtml A Zope/branches/andig-compositeindex/src/Products/PluginIndexes/CompositeIndex/dtml/browseIndex.dtml A Zope/branches/andig-compositeindex/src/Products/PluginIndexes/CompositeIndex/dtml/manageCompositeIndex.dtml A Zope/branches/andig-compositeindex/src/Products/PluginIndexes/CompositeIndex/help/ A Zope/branches/andig-compositeindex/src/Products/PluginIndexes/CompositeIndex/help/CompositeIndex_searchResults.stx A Zope/branches/andig-compositeindex/src/Products/PluginIndexes/CompositeIndex/util.py
-=- Added: Zope/branches/andig-compositeindex/src/Products/PluginIndexes/CompositeIndex/CompositeIndex.py =================================================================== --- Zope/branches/andig-compositeindex/src/Products/PluginIndexes/CompositeIndex/CompositeIndex.py (rev 0) +++ Zope/branches/andig-compositeindex/src/Products/PluginIndexes/CompositeIndex/CompositeIndex.py 2010-09-28 20:53:06 UTC (rev 117037) @@ -0,0 +1,552 @@ +############################################################################## +# +# Copyright (c) 2010 Zope Foundation and Contributors. +# +# This software is subject to the provisions of the Zope Public License, +# Version 2.1 (ZPL). A copy of the ZPL should accompany this distribution. +# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED +# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS +# FOR A PARTICULAR PURPOSE +# +############################################################################## + +import sys +import logging + +from Acquisition import aq_parent + +from Globals import DTMLFile + +from time import time + +from BTrees.IIBTree import IIBTree, IITreeSet, IISet, union, intersection, difference +from BTrees.OOBTree import OOBTree +from BTrees.IOBTree import IOBTree +import BTrees.Length + +from ZODB.POSException import ConflictError + +from Products.PluginIndexes.interfaces import IUniqueValueIndex +from Products.PluginIndexes.KeywordIndex.KeywordIndex import KeywordIndex +from Products.PluginIndexes.common.util import parseIndexRequest + +from util import PermuteKeywordList + +from config import PROJECTNAME + +_marker = [] + +logger = logging.getLogger(PROJECTNAME) + +class CompositeIndex(KeywordIndex): + + """Index for composition of simple fields. + or sequences of items + """ + + + meta_type="CompositeIndex" + + manage_options= ( + {'label': 'Settings', + 'action': 'manage_main', + 'help': ('CompositeIndex','CompositeIndex_Settings.stx')}, + {'label': 'Browse', + 'action': 'manage_browse', + 'help': ('CompositeIndex','CompositeIndex_Settings.stx')}, + ) + + def clear(self): + self._length = BTrees.Length.Length() + self._index = IOBTree() + self._unindex = IOBTree() + + # translation from hash key to human readable composite key + self._tindex = IOBTree() + + # component indexes + self._cindexes = OOBTree() + for i in self.getComponentIndexNames(): + self._cindexes[i] = OOBTree() + + + def _apply_index(self, request, cid='', type=type): + """ Apply the index to query parameters given in the request arg. """ + + record = parseIndexRequest(request, self.id, self.query_options) + if record.keys==None: return None + + if len(record.keys) > 0 and not isinstance(record.keys[0][1],parseIndexRequest): + if isinstance(record.keys[0],tuple): + for i,k in enumerate(record.keys): + record.keys[i] = hash(k) + + return super(CompositeIndex,self)._apply_index(request, cid=cid, type=type) + + operator = self.useOperator + + rank=[] + + for c, rec in record.keys: + # experimental code for specifing the operator + if operator == self.useOperator: + operator = rec.get('operator',operator) + + if not operator in self.operators : + raise RuntimeError,"operator not valid: %s" % escape(operator) + + res = self._apply_component_index(rec,c) + + if res is None: + continue + + res, dummy = res + + rank.append((len(res),res)) + + + # sort from short to long sets + rank.sort() + + k = None + for l,res in rank: + + k = intersection(k, res) + + if not k: + break + + + # if any operator of composite indexes is set to "and" + # switch to intersecton mode + + if operator == 'or': + set_func = union + else: + set_func = intersection + + + rank=[] + if set_func == intersection: + for key in k: + set=self._index.get(key, IISet()) + rank.append((len(set),key)) + + # sort from short to long sets + rank.sort() + + else: + # dummy length + if k: + rank = enumerate(k) + + res = None + # collect docIds + + for l,key in rank: + + set=self._index.get(key, None) + if set is None: + set = IISet(()) + elif isinstance(set, int): + set = IISet((set,)) + res = set_func(res, set) + if not res and set_func is intersection: + break + + + if isinstance(res, int): r=IISet((res,)) + + if res is None: + return IISet(),(self.id,) + + return res, (self.id,) + + + def _apply_component_index(self, record, cid): + """ Apply the component index to query parameters given in the record arg. """ + + if record.keys==None: return None + + index = self._cindexes[cid] + r = None + opr = None + + + # Range parameter + range_parm = record.get('range',None) + if range_parm: + opr = "range" + opr_args = [] + if range_parm.find("min")>-1: + opr_args.append("min") + if range_parm.find("max")>-1: + opr_args.append("max") + + if record.get('usage',None): + # see if any usage params are sent to field + opr = record.usage.lower().split(':') + opr, opr_args=opr[0], opr[1:] + + if opr=="range": # range search + if 'min' in opr_args: lo = min(record.keys) + else: lo = None + if 'max' in opr_args: hi = max(record.keys) + else: hi = None + if hi: + setlist = index.items(lo,hi) + else: + setlist = index.items(lo) + + for k, set in setlist: + if isinstance(set, tuple): + set = IISet((set,)) + r = union(r, set) + else: # not a range search + for key in record.keys: + set=index.get(key, None) + if set is None: + set = IISet(()) + elif isinstance(set, int): + set = IISet((set,)) + r = union(r, set) + + if isinstance(r, int): + r=IISet((r,)) + + if r is None: + return IISet(), (cid,) + + return r, (cid,) + + + + def index_object(self, documentId, obj, threshold=None): + """ wrapper to handle indexing of multiple attributes """ + + res = self._index_object(documentId, obj, threshold) + + return res + + + def _index_object(self, documentId, obj, threshold=None, attr=''): + """ index an object 'obj' with integer id 'i' + + Ideally, we've been passed a sequence of some sort that we + can iterate over. If however, we haven't, we should do something + useful with the results. In the case of a string, this means + indexing the entire string as a keyword.""" + + # First we need to see if there's anything interesting to look at + # self.id is the name of the index, which is also the name of the + # attribute we're interested in. If the attribute is callable, + # we'll do so. + + # unhashed keywords + newUKeywords = self._get_object_keywords(obj, attr) + + + # hashed keywords + newKeywords = map(lambda x: hash(x),newUKeywords) + + for i, kw in enumerate(newKeywords): + if not self._tindex.get(kw,None): + self._tindex[kw]=newUKeywords[i] + + + + newKeywords = map(lambda x: hash(x),newUKeywords) + + oldKeywords = self._unindex.get(documentId, None) + + if oldKeywords is None: + # we've got a new document, let's not futz around. + try: + for kw in newKeywords: + self.insertForwardIndexEntry(kw, documentId) + self._unindex[documentId] = list(newKeywords) + except TypeError: + return 0 + else: + # we have an existing entry for this document, and we need + # to figure out if any of the keywords have actually changed + if type(oldKeywords) is not IISet: + oldKeywords = IISet(oldKeywords) + newKeywords = IISet(newKeywords) + fdiff = difference(oldKeywords, newKeywords) + rdiff = difference(newKeywords, oldKeywords) + if fdiff or rdiff: + # if we've got forward or reverse changes + self._unindex[documentId] = list(newKeywords) + if fdiff: + self.unindex_objectKeywords(documentId, fdiff) + + for kw in fdiff: + indexRow = self._index.get(kw, _marker) + try: + del self._tindex[kw] + except KeyError: + # XXX should not happen + pass + + if rdiff: + for kw in rdiff: + self.insertForwardIndexEntry(kw, documentId) + + return 1 + + + def insertForwardIndexEntry(self, entry, documentId): + """Take the entry provided and put it in the correct place + in the forward index. + + This will also deal with creating the entire row if necessary. + """ + super(CompositeIndex,self).insertForwardIndexEntry(entry, documentId) + self._insertComponentIndexEntry(entry) + + + def removeForwardIndexEntry(self, entry, documentId): + """Take the entry provided and remove any reference to documentId + in its entry in the index. + """ + super(CompositeIndex,self).removeForwardIndexEntry(entry, documentId) + self._removeComponentIndexEntry(entry) + + + def _insertComponentIndexEntry(self, entry): + """Take the entry provided, extract its components and + put it in the correct place of the component index. + entry - hashed composite key """ + + # get the composite key and extract its component values + components = self._tindex[entry] + + for i,c in enumerate(self.getComponentIndexNames()): + ci = self._cindexes[c] + cd = components[i] + + indexRow = ci.get(cd, _marker) + if indexRow is _marker: + ci[cd] = entry + + else: + try: + indexRow.insert(entry) + except AttributeError: + # index row is not a IITreeSet + indexRow = IITreeSet((indexRow, entry)) + ci[cd] = indexRow + + + def _removeComponentIndexEntry(self, entry): + """ Take the entry provided, extract its components and + remove any reference to composite key of each component index. + entry - hashed composite key""" + + # get the composite key and extract its component values + components = self._tindex[entry] + + for i,c in enumerate(self.getComponentIndexNames()): + ci = self._cindexes[c] + cd = components[i] + indexRow = ci.get(cd, _marker) + if indexRow is not _marker: + try: + indexRow.remove(entry) + if not indexRow: + del ci[cd] + except ConflictError: + raise + + except AttributeError: + # index row is an int + try: + del ci[cd] + except KeyError: + pass + + except: + logger.error('%s: unindex_object could not remove ' + 'entry %s from component index %s[%s]. This ' + 'should not happen.' % (self.__class__.__name__, + str(components),str(self.id),str(c)), + exc_info=sys.exc_info()) + + else: + logger.error('%s: unindex_object tried to retrieve set %s ' + 'from component index %s[%s] but couldn\'t. This ' + 'should not happen.' % (self.__class__.__name__, + repr(components),str(self.id),str(c))) + + def _get_object_keywords(self, obj, attr): + """ composite keyword lists """ + + fields = self.getComponentIndexAttributes() + + kw_list = [] + + for attributes in fields: + kw = [] + for attr in attributes: + kw.extend(list(super(CompositeIndex,self)._get_object_keywords(obj, attr))) + kw_list.append(kw) + + pkl = PermuteKeywordList(kw_list) + + return pkl.keys + + def getComponentIndexNames(self): + """ returns component index names to composite """ + + ids = [] + + fields = self.getIndexSourceNames() + for attr in fields: + c = attr.split(':') + ids.append(c.pop()) + + return tuple(ids) + + def getComponentIndexAttributes(self): + """ returns list of attributes of each component index to composite""" + + attributes=[] + + fields = self.getIndexSourceNames() + for idx in fields: + attr = idx.split(':') + if len(attr) == 1: + attributes.append(attr) + else: + attributes.append(attr[1:]) + + return tuple(attributes) + + def getEntryForObject(self, documentId, default=_marker): + """Takes a document ID and returns all the information we have + on that specific object. + """ + datum = super(CompositeIndex,self).getEntryForObject(documentId, default=default) + + if isinstance(datum, int): + datum = IISet((datum,)) + + entry = map(lambda k : self._tindex.get(k,k), datum) + + return entry + + def keyForDocument(self, id): + # This method is superceded by documentToKeyMap + logger.warn('keyForDocument: return hashed key') + return super(CompositeIndex,self).keyForDocument(id) + + def documentToKeyMap(self): + logger.warn('documentToKeyMap: return hashed key map') + return self._unindex + + def items(self): + items = [] + for k,v in self._index.items(): + if isinstance(v, int): + v = IISet((v,)) + + kw = self._tindex.get(k,k) + items.append((kw, v)) + return items + + + manage = manage_main = DTMLFile('dtml/manageCompositeIndex', globals()) + manage_main._setName('manage_main') + manage_browse = DTMLFile('dtml/browseIndex', globals()) + + +manage_addCompositeIndexForm = DTMLFile('dtml/addCompositeIndex', globals()) + +def manage_addCompositeIndex(self, id, extra=None, + REQUEST=None, RESPONSE=None, URL3=None): + """Add a composite index""" + return self.manage_addIndex(id, 'CompositeIndex', extra=extra, \ + REQUEST=REQUEST, RESPONSE=RESPONSE, URL1=URL3) + + + +class compositeSearchArgumentsMap: + """ parse a request from the ZPublisher to optimize the query by means + of CompositeIndexes + """ + + keywords = {} + + def __init__(self, catalog, request): + """ indexes -- dict of index objects + request -- the request dictionary send from the ZPublisher + """ + + indexes = catalog.indexes + + parent = aq_parent(catalog) + + if parent.hasProperty('unimr.compositeindex') and not parent.getProperty('unimr.compositeindex',True): + logger.warn('skip compositeSearchArgumentsMap') + return + + cRank=[] + for index in indexes.values(): + if isinstance(index,CompositeIndex): + + cId = index.id + logger.debug('CompositeIndex "%s" found' % cId) + # get indexes managed by CompositeIndex + cIdxs = index.getComponentIndexNames() + cRank.append((cId,cIdxs)) + + # sort from specific to unspecific CompositeIndex + cRank.sort(lambda x,y: cmp((len(y[1]),y[1]),(len(x[1]),x[1]))) + + for cId, cIdxs in cRank: + records=[] + for i in cIdxs: + index = indexes.get(i,None) + abort = False + + if index: + rec = parseIndexRequest(request, index.id, index.query_options) + + if not IUniqueValueIndex.providedBy(index): + logger.warn('index %s: not an instance of IUniqueValueIndex' % index.id) + abort = True + + if abort or rec.keys is None: + continue + + records.append((i, rec)) + + + # transform request only if more than one component of the composite key is applied + if len(records) > 1: + + query = { cId: { 'query': records } } + + logger.debug('composite query build "%s"' % query) + + + # delete obsolete query attributes from request + for i in cIdxs[:len(records)+1]: + + if isinstance(request, dict): + if request.has_key(i): + del request[i] + else: + if request.keywords.has_key(i): + del request.keywords[i] + if isinstance(request.request, dict) and \ + request.request.has_key(i): + + del request.request[i] + + request.keywords.update(query) + + + Added: Zope/branches/andig-compositeindex/src/Products/PluginIndexes/CompositeIndex/__init__.py =================================================================== --- Zope/branches/andig-compositeindex/src/Products/PluginIndexes/CompositeIndex/__init__.py (rev 0) +++ Zope/branches/andig-compositeindex/src/Products/PluginIndexes/CompositeIndex/__init__.py 2010-09-28 20:53:06 UTC (rev 117037) @@ -0,0 +1 @@ +# empty comment for winzip and friends Added: Zope/branches/andig-compositeindex/src/Products/PluginIndexes/CompositeIndex/dtml/addCompositeIndex.dtml =================================================================== --- Zope/branches/andig-compositeindex/src/Products/PluginIndexes/CompositeIndex/dtml/addCompositeIndex.dtml (rev 0) +++ Zope/branches/andig-compositeindex/src/Products/PluginIndexes/CompositeIndex/dtml/addCompositeIndex.dtml 2010-09-28 20:53:06 UTC (rev 117037) @@ -0,0 +1,70 @@ +<dtml-var manage_page_header> + +<dtml-var "manage_form_title(this(), _, + form_title='Add CompositeIndex', + )"> + + +<p class="form-help"> +<strong>Composite Indexes</strong>. Indexes +containing more than one attribute to index an object are called +"composite index". Such indexes should be created if you expect to run +queries that will have multiple attributes in the search phrase and +all attributes combined will give significantly less hits than the any +of the attributes alone. The key of a composite index is called +"composite key" and is composed of two or more attributes of an +object. +</p> + + +<form action="manage_addCompositeIndex" method="post" enctype="multipart/form-data"> +<table cellspacing="0" cellpadding="2" border="0"> + <tr> + <td align="left" valign="top"> + <div class="form-label"> + Id + </div> + </td> + <td align="left" valign="top"> + <input type="text" name="id" size="40" /> + </td> + </tr> + + <tr> + <td align="left" valign="top"> + <div class="form-label"> + Composite key<br/> + (names of attributes to concatenate) + </div> + </td> + <td align="left" valign="top"> + <input type="text" name="extra.indexed_attrs:record:string" size="40" /><br/> + <em>indexId1,indexId2,...</em> or<br/> + <em>indexId1:attribute11:attribute12:...,indexId2:attribute21,...</em> + </td> + </tr> + + <tr> + <td align="left" valign="top"> + <div class="form-optional"> + Type + </div> + </td> + <td align="left" valign="top"> + Composite Index + </td> + </tr> + <tr> + <td align="left" valign="top"> + </td> + <td align="left" valign="top"> + <div class="form-element"> + <input class="form-element" type="submit" name="submit" + value=" Add " /> + </div> + </td> + </tr> +</table> +</form> + +<dtml-var manage_page_footer> Added: Zope/branches/andig-compositeindex/src/Products/PluginIndexes/CompositeIndex/dtml/browseIndex.dtml =================================================================== --- Zope/branches/andig-compositeindex/src/Products/PluginIndexes/CompositeIndex/dtml/browseIndex.dtml (rev 0) +++ Zope/branches/andig-compositeindex/src/Products/PluginIndexes/CompositeIndex/dtml/browseIndex.dtml 2010-09-28 20:53:06 UTC (rev 117037) @@ -0,0 +1,64 @@ +<dtml-var manage_page_header> +<dtml-var manage_tabs> + +<dtml-call "REQUEST.RESPONSE.setHeader('Content-Type', 'text/html; charset=UTF-8')" > + +<p class="form-text"> +The index "&dtml-getId;" contains <dtml-var items fmt=collection-length thousands_commas> distinct values +</p> + +<dtml-let size="20"> <!-- batch size --> + + <div class="form-text"> + <dtml-in items previous size=size start=query_start > + <a href="&dtml-URL;?query_start=&dtml-previous-sequence-start-number;"> + [Previous <dtml-var previous-sequence-size> entries] + </a> + </dtml-in> + <dtml-in items next size=size start=query_start > + <a href="&dtml-URL;?query_start=&dtml-next-sequence-start-number;"> + [Next <dtml-var next-sequence-size> entries] + </a> + </dtml-in> + </div> + + <table border="1" align="center" width="100%" class="form-help"> + + <tr><th>composite key (internally managed by integer hash)</th><th>object path</th></tr> + <dtml-in items start=query_start size=size> + <tr> + <td> + <dtml-if "meta_type in ('DateIndex',)"> + <dtml-comment><!-- + DateIndexes store dates packed into an integer, unpack + into year, month, day, hour and minute, no seconds and UTC. + --></dtml-comment> + <dtml-var "DateTime((_['sequence-key'] / 535680), + (_['sequence-key'] / 44640 ) % 12, + (_['sequence-key'] / 1440 ) % 31, + (_['sequence-key'] / 60 ) % 24, + (_['sequence-key'] ) % 60, + 0, 'UTC')"> + <dtml-else> + &dtml-sequence-key; + </dtml-if> + </td> + <td> + <ul> + <dtml-let v="_['sequence-item']"> + <dtml-if "isinstance(v, int)"> + <li><a href="<dtml-var "getpath(v)">"<dtml-var "getpath(v)"></a></li> + <dtml-else> + <dtml-in "v.keys()"> + <li> <a href="<dtml-var "getpath(_['sequence-item'])">"><dtml-var "getpath(_['sequence-item'])"></a></li> + </dtml-in> + </dtml-if> + </dtml-let> + </ul> + </td> + </tr> + </dtml-in> + </table> +</dtml-let> + +<dtml-var manage_page_footer> Added: Zope/branches/andig-compositeindex/src/Products/PluginIndexes/CompositeIndex/dtml/manageCompositeIndex.dtml =================================================================== --- Zope/branches/andig-compositeindex/src/Products/PluginIndexes/CompositeIndex/dtml/manageCompositeIndex.dtml (rev 0) +++ Zope/branches/andig-compositeindex/src/Products/PluginIndexes/CompositeIndex/dtml/manageCompositeIndex.dtml 2010-09-28 20:53:06 UTC (rev 117037) @@ -0,0 +1,12 @@ +<dtml-var manage_page_header> +<dtml-var manage_tabs> + +<p class="form-help"> +Objects indexed: <dtml-var numObjects> +<br> +Distinct values: <dtml-var indexSize> +</p> + + + +<dtml-var manage_page_footer> Added: Zope/branches/andig-compositeindex/src/Products/PluginIndexes/CompositeIndex/help/CompositeIndex_searchResults.stx =================================================================== --- Zope/branches/andig-compositeindex/src/Products/PluginIndexes/CompositeIndex/help/CompositeIndex_searchResults.stx (rev 0) +++ Zope/branches/andig-compositeindex/src/Products/PluginIndexes/CompositeIndex/help/CompositeIndex_searchResults.stx 2010-09-28 20:53:06 UTC (rev 117037) @@ -0,0 +1,23 @@ +ZCatalog - searchResults: specifying parameters for a search query + + The searchResults() method of the ZCatalog accepts parameters that + define a query to be made on that catalog. A query can either be + passed as keyword argument to searchResults(), as a mapping, or as + part of a Zope REQUEST object, typically from HTML forms. + + The index of the catalog to query is either the name of the + keyword argument, a key in a mapping, or an attribute of a record + object. + + Attributes of record objects + + 'query' -- either a sequence of objects or a single value to be + passed as query to the index (mandatory) + + 'operator' -- specifies the combination of search results when + query is a sequence of values. (optional, default: 'or'). + + Allowed values: + + 'and', 'or' + Added: Zope/branches/andig-compositeindex/src/Products/PluginIndexes/CompositeIndex/util.py =================================================================== --- Zope/branches/andig-compositeindex/src/Products/PluginIndexes/CompositeIndex/util.py (rev 0) +++ Zope/branches/andig-compositeindex/src/Products/PluginIndexes/CompositeIndex/util.py 2010-09-28 20:53:06 UTC (rev 117037) @@ -0,0 +1,97 @@ +############################################################################## +# +# Copyright (c) 2010 Zope Foundation and Contributors. +# +# This software is subject to the provisions of the Zope Public License, +# Version 2.1 (ZPL). A copy of the ZPL should accompany this distribution. +# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED +# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS +# FOR A PARTICULAR PURPOSE +# +############################################################################## + + +_marker = [] + +class PermuteKeywordList: + """ + returns a flat list of a sequential + permutation of keyword lists. + Example: + + A = [[1,2,3],[4,5],[6,7]] + + tree permutation + + 6 + / + 4 + / \ + / 7 + 1 + \ 6 + \ / + 5 + \ + 7 + + 6 + / + 4 + / \ + / 7 + 2 + \ 6 + \ / + 5 + \ + 7 + + 6 + / + 4 + / \ + / 7 + 3 + \ 6 + \ / + 5 + \ + 7 + + + corresponds to following flat list + + [[1,4,6],[1,4,7],[1,5,6],[1,5,7], + [2,4,6],[2,4,7],[2,5,6],[2,5,7], + [3,4,6],[3,4,7],[3,5,6],[3,5,7]] + + + """ + + + + def __init__(self,A): + """ A -- list of keyword lists""" + self.keys=[] + self.walking(A) + + + def walking(self,A,f=_marker): + + if f is _marker: + f = [] + + if A[:1]: + first = A[0] + for l in first: + next = f[:] + [l] + self.walking(A[1:],next) + + else: + self.keys.append(tuple(f)) + + + + _______________________________________________ Zope-Checkins maillist - Zope-Checkins@zope.org https://mail.zope.org/mailman/listinfo/zope-checkins