root/pyndexter/trunk/pyndexter/__init__.py

Revision 454, 28.9 KB (checked in by athomas, 3 years ago)

pyndexter: Whoops.

Line 
1# -*- coding: utf-8 -*-
2#
3# Copyright (C) 2006 Alec Thomas <alec@swapoff.org>
4#
5# This software is licensed as described in the file COPYING, which
6# you should have received as part of this distribution.
7#
8
9
10"""
11Pyndexter provides a uniform API for accessing a variety of full-text
12indexing engines. It is similar in purpose to the Python DB API.
13
14The main class users will be dealing with is Framework. This presents
15a convenient interface to the backend indexers.
16
17An example of indexing all .txt files underneath ``/usr/share/doc``:
18
19::
20
21    import os
22    from pyndexter import Framework, Document
23
24    framework = Framework('hyperestraier:///tmp/hyperestraier.idx')
25
26    path = '/usr/share/doc'
27
28    for file in [path + f for f in os.listdir(path) if f.endswith('.txt')]:
29        doc = Document(file, open(file).read())
30        framework.index(doc)
31
32    # Find all documents with Linus and Torvalds in them
33    for hit in framework.search('Linus Torvalds'):
34        print hit.uri
35
36    framework.close()
37"""
38
39
40import re
41import os
42import pickle
43import gzip
44import inspect
45from StringIO import StringIO
46from urlparse import urlsplit, urlunsplit
47from pyndexter.util import set, URI
48
49
50__version__ = '0.2'
51__author__ = 'Alec Thomas <alec@swapoff.org>'
52
53
54__all__ = """
55Error
56InvalidURI
57DocumentNotFound
58InvalidMode
59InvalidState
60IndexerError
61SourceError
62InvalidQuery
63FrameworkError
64InvalidModule
65
66REMOVED ADDED MODIFIED
67
68READONLY READWRITE
69
70Query Framework Document Indexer Result Hit PluginFactory URI
71""".split()
72
73
74# Source state difference constants
75REMOVED = 0
76ADDED = 1
77MODIFIED = 2
78
79# Indexer open flags
80READONLY = 0
81READWRITE = 1
82
83
84class Error(Exception):
85    """ Base of all pyndexter exceptions. """
86
87class DocumentNotFound(Error):
88    """ Raised when a document could not be found, usually by the fetch()
89    methods. """
90
91class InvalidURI(Error):
92    """ The URI provided was invalid in that context. """
93
94class SourceError(Error):
95    """ Base of all exceptions raised exclusively by Sources. """
96
97class InvalidState(SourceError):
98    """ The state provided to a source was invalid. """
99
100class IndexerError(Error):
101    """ Base of all exceptions raised exclusively by Indexers. """
102
103class InvalidMode(IndexerError):
104    """ The mode (READONLY or READWRITE) of the indexer is an
105    invalid state for a particular operation. """
106
107class InvalidQuery(Error):
108    """ Invalid query string. """
109
110class FrameworkError(Error):
111    """Base of Framework errors."""
112
113class InvalidModule(FrameworkError):
114    """The module provided was not loadable."""
115    def __init__(self, module, exception=None):
116        message = 'Could not load module "%s"' % module
117        if exception:
118            message += '. Original exception was: %s' % exception
119        FrameworkError.__init__(self, message)
120
121
122class Document(object):
123    """ A Document represents an indexable object in pyndexter. All string
124    attributes must be unicode, including the content.
125
126    ``content``
127        Optional, and if not provided will be fetched from the `source`. If it
128        is a callable, it will be called to fetch the content, passing the uri
129        as the only argument.
130
131    ``change``
132        Should be a numeric value representing the current point in the
133        documents lifetime. Typically a timestamp, but could be a revision
134        number, etc.
135
136    ``source``
137        Is the Source object where this documents content can be lazily fetched
138        from. """
139
140    __slots__ = ('attributes', '_content', 'source', 'quality')
141
142    def __init__(self, uri, content=None, source=None, quality=1.0,
143                 **attributes):
144        assert isinstance(content, unicode)
145        self._content = content
146        self.source = source
147        self.quality = quality
148        self.attributes = attributes
149        self.attributes.update({'uri': URI(uri)})
150
151    def __repr__(self):
152        return '<%s %s>' % (self.__class__.__name__,
153                            ' '.join(['%s=%s' % (k, repr(v)) for k, v in
154                                      self.attributes.iteritems()]))
155
156    def __getattr__(self, key):
157        try:
158            return self.attributes[key]
159        except KeyError, e:
160            raise AttributeError(unicode(e))
161
162    def __contains__(self, key):
163        return key in self.attributes
164
165    def __hash__(self):
166        return hash(self.uri)
167
168    def get(self, key, default=None):
169        return self.attributes.get(key, default)
170
171    def _set_content(self, content):
172        self._content = content
173
174    def _get_content(self):
175        if callable(self._content):
176            self._content = self._content(self.uri)
177        return self._content
178    content = property(lambda self: self._get_content(),
179                       lambda self, value: self._set_content(value))
180
181
182class QueryNode(object):
183    """A query parse node.
184
185    >>> QueryNode(QueryNode.TERM, 'one')
186    ("one")
187    >>> QueryNode(QueryNode.AND,
188    ...     left=QueryNode(QueryNode.TERM, 'one'),
189    ...     right=QueryNode(QueryNode.TERM, 'two'))
190    (and
191      ("one")
192      ("two"))
193    >>> QueryNode(QueryNode.NOT, left=QueryNode(QueryNode.TERM, 'one'))
194    (not
195      ("one")
196      nil)
197    """
198
199
200    # TODO Separate lexer and parser identifiers
201    NULL = 0
202    TERM = 1
203    NOT = 2
204    AND = 3
205    OR = 4
206    ATTR = 5
207    BEGINSUB = 6
208    ENDSUB = 7
209
210    __slots__ = ('type', 'value', 'left', 'right')
211
212    def __init__(self, type, value=None, left=None, right=None):
213        self.type = type
214        self.value = value
215        self.left = left
216        self.right = right
217
218    def __repr__(self):
219        type_map = {self.NULL: 'null', self.TERM: 'term', self.NOT: 'not',
220                    self.AND: 'and', self.OR: 'or', self.ATTR: 'attr'}
221        def show(node, depth=0):
222            if node.type == QueryNode.TERM:
223                text = '%s("%s"' % ('  ' * depth, node.value)
224            elif node.type == QueryNode.ATTR:
225                text = '%s(%s:"%s"' % ('  ' * depth, node.value[0], node.value[1])
226            else:
227                text = "%s(%s%s" % ('  ' * depth, type_map[node.type],
228                                    node.value and ' "%s"' % (node.value,) or "")
229            if node.left or node.right:
230                text += "\n"
231                if node.left:
232                    text += show(node.left, depth + 1)
233                else:
234                    text += "%snil" % ('  ' * (depth + 1))
235                text += "\n"
236                if node.right:
237                    text += show(node.right, depth + 1)
238                else:
239                    text += "%snil" % ('  ' * (depth + 1))
240            text += ")"
241            return text
242        return show(self)
243
244
245class Query(QueryNode):
246    """ Query parser. Converts a simple query language into a parse tree which
247    Indexers can then convert into their own implementation-specific
248    representation.
249
250    The query language is in the following form:
251
252        <term> <term>     document must contain all of these terms
253        "some term"       return documents matching this exact phrase
254        -<term>           exclude documents containing this term
255        <term> or <term>  return documents matching either term
256        <attr>:<term>     return documents with term in the specified attribute
257
258    eg.
259
260    >>> Query('lettuce tomato -cheese')
261    (and
262      ("lettuce")
263      (and
264        ("tomato")
265        (not
266          ("cheese")
267          nil)))
268
269    >>> Query('"mint slices" -timtams')
270    (and
271      ("mint slices")
272      (not
273        ("timtams")
274        nil))
275
276    >>> Query('"brie cheese" or "camembert cheese"')
277    (or
278      ("brie cheese")
279      ("camembert cheese"))
280
281    >>> Query('one two:three')
282    (and
283      ("one")
284      (two:"three"))
285    """
286
287    _tokenise_re = re.compile(r"(?P<ex>-)|(?P<or>or)|\"(?P<dq>(?:\\.|[^\"])*)\"|'(?P<sq>(?:\\.|[^'])*)'|(?P<ss>\()|(?P<se>\))|(?P<at>\w+?:\w+)|(?P<te>\w+)", re.I)
288    _group_map = {'dq': QueryNode.TERM, 'sq': QueryNode.TERM, 'te': QueryNode.TERM,
289                  'ex': QueryNode.NOT, 'or': QueryNode.OR, 'at': QueryNode.ATTR,
290                  'ss': QueryNode.BEGINSUB, 'se': QueryNode.ENDSUB}
291
292    def __init__(self, phrase):
293        QueryNode.__init__(self, None)
294        tokens = self._tokenise(phrase)
295        root = self.parse(tokens)
296        self.phrase = phrase
297        self._compiled = None
298        if root:
299            # Make ourselves into the root node
300            for k in self.__slots__:
301                setattr(self, k, getattr(root, k))
302
303    def parse(self, tokens):
304        left = self.parse_unary(tokens)
305        if tokens:
306            if tokens[0][0] == QueryNode.ENDSUB:
307                return left
308            if tokens[0][0] == QueryNode.OR:
309                tokens.pop(0)
310                return QueryNode(QueryNode.OR, left=left, right=self.parse(tokens))
311            else:
312                return QueryNode(QueryNode.AND, left=left, right=self.parse(tokens))
313        return left
314
315    def parse_unary(self, tokens):
316        """Parse a unary operator. Currently only NOT.
317
318        >>> q = Query('')
319        >>> q.parse_unary(q._tokenise('-foo'))
320        (not
321          ("foo")
322          nil)
323        """
324        if not tokens:
325            return None
326        if tokens[0][0] == QueryNode.BEGINSUB:
327            tokens.pop(0)
328            node = self.parse(tokens)
329            if not tokens or tokens[0][0] != QueryNode.ENDSUB:
330                raise InvalidQuery('Expected ) at end of sub-expression')
331            tokens.pop(0)
332            return node
333        if tokens[0][0] == QueryNode.NOT:
334            tokens.pop(0)
335            return QueryNode(QueryNode.NOT, left=self.parse_terminal(tokens))
336        return self.parse_terminal(tokens)
337
338    def parse_terminal(self, tokens):
339        """Parse a terminal token.
340
341        >>> q = Query('')
342        >>> q.parse_terminal(q._tokenise('foo'))
343        ("foo")
344        """
345
346        if not tokens:
347            raise InvalidQuery('Unexpected end of string')
348        if tokens[0][0] == QueryNode.ATTR:
349            token = tokens.pop(0)
350            attr, value = token[1].split(':', 1)
351            return QueryNode(QueryNode.ATTR, (attr, value))
352        if tokens[0][0] in (QueryNode.TERM, QueryNode.OR):
353            token = tokens.pop(0)
354            return QueryNode(QueryNode.TERM, value=token[1])
355        raise InvalidQuery('Expected terminal, got "%s"' % tokens[0][1])
356
357    def terms(self, exclude_not=True):
358        """A generator returning the terms contained in the Query."""
359        def _convert(node):
360            if not node:
361                return
362            if node.type == node.TERM:
363                yield node.value
364            elif node.type == node.NOT and exclude_not:
365                return
366            else:
367                for child in _convert(node.left):
368                    yield child
369                for child in _convert(node.right):
370                    yield child
371
372        return _convert(self)
373
374    def __call__(self, text):
375        """Match the query against a block of text. The Query will be lazily
376        compiled to Python code."""
377        import compiler
378        from compiler import ast, misc, pycodegen
379
380        # TODO Make Query.ATTR work
381        def _generate(node):
382            if node.type == node.TERM:
383                return ast.Compare(ast.Const(node.value.lower()),
384                                   [('in', ast.Name('text'))])
385            elif node.type == node.AND:
386                return ast.And([_generate(node.left), _generate(node.right)])
387            elif node.type == node.OR:
388                return ast.Or([_generate(node.left), _generate(node.right)])
389            elif node.type == node.NOT:
390                return ast.Not(_generate(node.left))
391            else:
392                raise NotImplementedError
393
394        qast = ast.Expression(ast.Lambda(['text'], [], 0, _generate(self)))
395        misc.set_filename('<%s compiled query>' % self.__class__.__name__,
396                          qast)
397        gen = pycodegen.ExpressionCodeGenerator(qast)
398        self.__call__ = eval(gen.getCode())
399        return self.__call__(text)
400
401    def as_string(self, and_=' AND ', or_=' OR ', not_='NOT '):
402        """Convert Query to a boolean expression. Useful for indexers with
403        "typical" boolean query syntaxes.
404
405        eg. "term AND term OR term AND NOT term"
406
407        The expanded operators can be customised for syntactical variations.
408
409        >>> Query('foo bar').as_string()
410        'foo AND bar'
411        >>> Query('foo bar or baz').as_string()
412        'foo AND bar OR baz'
413        >>> Query('foo -bar or baz').as_string()
414        'foo AND NOT bar OR baz'
415        """
416        def _convert(node):
417            if not node or node.type == node.NULL:
418                return ''
419            if node.type == node.AND:
420                return '%s%s%s' % (_convert(node.left), and_,
421                                   _convert(node.right))
422            elif node.type == node.OR:
423                return '%s%s%s' % (_convert(node.left), or_,
424                                   _convert(node.right))
425            elif node.type == node.NOT:
426                return '%s%s' % (not_, _convert(node.left))
427            elif node.type == node.TERM:
428                return node.value
429            else:
430                raise NotImplementedError
431        return _convert(self)
432
433    def reduce(self, reduce):
434        """Pass each TERM node through `Reducer`."""
435        def _reduce(node):
436            if not node:
437                return
438            if node.type == node.TERM:
439                node.value = reduce(node.value, unique=False, split=False)
440            _reduce(node.left)
441            _reduce(node.right)
442        _reduce(self)
443
444    # Internal methods
445    def _tokenise(self, phrase):
446        """Tokenise a phrase string.
447
448        >>> q = Query('')
449        >>> q._tokenise('one')
450        [(1, 'one')]
451        >>> q._tokenise('one two')
452        [(1, 'one'), (1, 'two')]
453        >>> q._tokenise('one or two')
454        [(1, 'one'), (4, 'or'), (1, 'two')]
455        >>> q._tokenise('"one two"')
456        [(1, 'one two')]
457        >>> q._tokenise("'one two'")
458        [(1, 'one two')]
459        >>> q._tokenise('-one')
460        [(2, '-'), (1, 'one')]
461        """
462        tokens = [(self._group_map[token.lastgroup], token.group(token.lastindex))
463                  for token in self._tokenise_re.finditer(phrase)]
464        return tokens
465
466
467class Reducer(object):
468    """Compact all words in a block of text."""
469
470    def __init__(self, words_re=r'\w+', stemmer=lambda w: w,
471                 min_word_length=3, max_word_length=64, unique=False,
472                 split=False, lower=True):
473        """`words_re` is a regular expression object or string.
474
475        `stemmer` is a callable that stems a single word.
476
477        If `unique` is true, return a string of **unordered** words with
478        duplicates removed.
479
480        If `split` is true, return words in a collection rather than joining
481        them into a single string.
482
483        If `lower` is true, lowercase text."""
484
485        if isinstance(words_re, basestring):
486            words_re = re.compile(words_re, re.UNICODE)
487        self.words_re = words_re
488        self.stemmer = stemmer or (lambda w: w)
489        self.min_word_length = min_word_length
490        self.max_word_length = max_word_length
491        self.unique = unique
492        self.split = split
493        self.lower = lower
494
495    def __call__(self, text, unique=None, split=None):
496        if unique is None:
497            unique = self.unique
498
499        if unique:
500            out = set()
501            def append(word):
502                out.add(word)
503        else:
504            out = []
505            def append(word):
506                out.append(word)
507
508        min = self.min_word_length
509        max = self.max_word_length
510        stemmer = self.stemmer
511
512        if self.lower:
513            text = text.lower()
514
515        words = self.words_re.findall(text)
516        if unique:
517            words = set(words)
518
519        for word in words:
520            if min > len(word) > max:
521                continue
522            append(stemmer(word))
523
524        if split is None:
525            split = self.split
526        if split:
527            return out
528        return u' '.join(out)
529
530
531class Indexer(object):
532    """An Indexer performs document indexing and searching. This base object
533    provides a framework for indexers."""
534
535    def __init__(self, framework):
536        """ Initialise indexer. """
537        self.framework = framework
538
539    def close(self):
540        """ Close the indexer. The object is subsequently not usable.
541
542        `flush()` is automatically called by the `Framework` prior to
543        `close()`."""
544        raise NotImplementedError
545
546    def index(self, document):
547        """ Index a single Document object. """
548        raise NotImplementedError
549
550    def discard(self, uri):
551        """ Discard a document. """
552        raise NotImplementedError
553
554    def search(self, query):
555        """ Search with the given Query. """
556        # TODO Add support for result ordering
557        raise NotImplementedError
558
559    # Optional methods
560    def __iter__(self):
561        """ Iterate over all URI's in the index. """
562        raise NotImplementedError
563
564    def fetch(self, uri):
565        """Attempt to fetch indexer representation of the document.
566
567        Must return a `Document` object with a `quality` attribute between 0.0
568        and 1.0, representing the quality of the document in comparison to the
569        original."""
570        raise DocumentNotFound(uri)
571
572    def replace(self, document):
573        """Replace a document in the index. Default is to `discard()` and
574        `index()`."""
575        self.discard(document.uri)
576        self.index(document)
577
578    def optimise(self):
579        """ Optimise the indexer. """
580
581    def flush(self):
582        """Flush indexer state to disk."""
583
584    def state_store(self):
585        """If this Indexer is capable of storing framework state, return a
586        `StateStore` object. By default, if the indexer has a `state_path`
587        attribute, a new `StateStore` object will be returned on that path."""
588        if hasattr(self, 'state_path'):
589            return StateStore(self.state_path)
590        return None
591
592
593class PluginFactory(object):
594    """Factory for translating URL-style query parameters into a standard
595    plugin constructor call.
596
597    >>> class C:
598    ...   def __init__(self, one, two, three=3):
599    ...     print one, two, three
600    >>> f = PluginFactory(C, three=int, four="three")
601    >>> c = f(one=1, two=2, three=3)
602    1 2 3
603    >>> c = f(uri='scheme://?one=1&two=2&three=three')
604    Traceback (most recent call last):
605    ...
606    ValueError: could not coerce argument "three" with value "three" to type "<type 'int'>": invalid literal for int(): three
607    >>> c = f(uri='scheme://?one=1&two=2&four=3')
608    1 2 3
609    """
610
611    BOOL_TRUE = ('1', 'true', 'yes', 'on', 'aye')
612
613    class List(object):
614        """Translate a parameter that is a list of elements of `type`,
615        optionally splitting on commas."""
616        def __init__(self, type, split=None):
617            self.type = type
618            self.split = split
619
620        def __call__(self, value):
621            if self.split:
622                out = []
623                for v in value:
624                    split_out += i.split(',')
625                return split_out
626            else:
627                return [self.type(v) for v in value]
628
629    def __init__(self, plugin, **arg_types):
630        """Create a new factory.
631
632        arg_types is a dictionary of <arg>:<type> mappings. If <type> is a
633        string, <arg> will be renamed to this before calling the plugin
634        constructor."""
635
636        self.plugin = plugin
637        self.remapped = dict([(k, v) for k, v in arg_types.iteritems()
638                              if isinstance(v, basestring)])
639        self.arg_types = arg_types
640        args, varargs, self.varkw, defaults = \
641            inspect.getargspec(self.plugin.__init__)
642        defaults = defaults or []
643        self.defaults = dict(zip(list(args[-len(defaults):]), defaults))
644        self.defaults.pop('self', None)
645        self.args = defaults and args[:-len(defaults)] or args
646
647    def __call__(self, uri=None, **kwargs):
648        args = dict(self.defaults.items())
649
650        if uri is not None:
651            # Merge URI arguments
652            if isinstance(uri, basestring):
653                from pyndexter.util import URI
654                uri = URI(uri)
655
656            uri.username = uri.username or None
657            uri.password = uri.password or None
658            uri_components = {'username': uri.username, 'password': uri.password,
659                              'host': uri.host, 'path': uri.path,
660                              'fragment': uri.fragment}
661            # Discard them if they're empty
662            uri_components = dict([(k, v) for k, v in uri_components.iteritems() if v])
663            args.update(uri.query)
664            args.update(uri_components)
665
666        # Add keyword arguments
667        args.update(kwargs)
668
669        # Remap (rename) arguments
670        for k, v in self.remapped.iteritems():
671            if k in args:
672                args[v] = args[k]
673                del args[k]
674
675        # Translate all remaining arguments
676        for k, v in args.items():
677            if v is not None:
678                type = self.arg_types.get(k, lambda v: v)
679                # If it's a list, and not marked as such, convert it to a scalar
680                if isinstance(v, (tuple, list)) and not \
681                        isinstance(type, self.List):
682                    if len(v) != 1:
683                        raise ValueError('argument "%s" should be a scalar' % k)
684                    v = v[0]
685                if type is bool:
686                    # Special-case bool
687                    if str(v) in self.BOOL_TRUE:
688                        args[k] = True
689                    else:
690                        try:
691                            args[k] = bool(float(v))
692                        except ValueError:
693                            args[k] = False
694                else:
695                    try:
696                        args[k] = type(v)
697                    except ValueError, e:
698                        raise ValueError('could not coerce argument "%s" with '
699                                         'value "%s" to type "%s": %s'
700                                         % (k, v, type, e))
701
702        return self.plugin(**args)
703
704
705class Framework(object):
706    """The glue. Ties `Indexer` and `Source` together, performs housekeeping
707    tasks and provides a convenient interface to it all.
708
709    If the `Indexer` is not capable of storing state and automatic updates are
710    desired, a `StateStore` object should be passed to the `Framework`."""
711
712    def __init__(self, indexer=None, mode=READWRITE, reduce=None,
713                 stemmer=None):
714        """`indexer` is a URI used to construct an indexer, or an `Indexer`
715        object.
716
717        `reduce` is a `Reducer` object.If `reduce` is not specified, a default
718        `Reduce` object will be instantiated using `stemmer` (URI or callable)
719        as defaults. '''NOTE:''' Use of the reducer is optional - some
720        indexers may implement stemming and reduction internally."""
721        self.mode = mode
722
723        if reduce is None:
724            if stemmer is None:
725                stemmer = lambda word: word
726            elif isinstance(stemmer, (basestring, URI)):
727                Stemmer = self._load_plugin('stemmer', stemmer)
728                stemmer = Stemmer(uri=stemmer)
729            self.reduce = Reducer(stemmer=stemmer)
730        else:
731            self.reduce = reduce
732
733        self.indexer = indexer
734
735    def set_indexer(self, indexer):
736        """Set the `Framework` indexer. Can either be a URI or an `Indexer`
737        object."""
738        if isinstance(indexer, (basestring, URI)):
739            Indexer = self._load_plugin('indexer', indexer)
740            self._indexer = Indexer(framework=self, uri=indexer)
741        else:
742            self._indexer = indexer
743
744    def get_indexer(self):
745        return self._indexer
746
747    indexer = property(get_indexer, set_indexer)
748
749    def fetch(self, uri):
750        """ Fetch a document. """
751        return self.indexer.fetch(URI(uri))
752
753    def __iter__(self):
754        """ Iterate over all URI's in the indexer. """
755        for uri in self.indexer:
756            yield uri
757
758    def index(self, document):
759        """Index a single document, specified as a Document object."""
760        self._assert_rw()
761        assert isinstance(document, Document)
762        return self.indexer.index(document)
763
764    def discard(self, document):
765        """Discard the specified document from the index, specified as either a
766        Document object or a URI."""
767        self._assert_rw()
768        if isinstance(document, Document):
769            document = document.uri
770        return self.indexer.discard(document)
771
772    def replace(self, document):
773        """Replace document in the index, specified as a Document object."""
774        self._assert_rw()
775        assert isinstance(document, Document)
776        return self.indexer.replace(document)
777
778    def search(self, query):
779        """ Search the index for documents matching the given query.  This
780        method is guaranteed to work across all indexers.
781
782        `query` is a pyndexter compatible search string.
783
784        Returns a `Result` object. """
785        if isinstance(query, basestring):
786            query = Query(query)
787        return self.indexer.search(query)
788
789    def close(self):
790        """ Sync and close the indexer. The object is subsequently not
791        usable. """
792        self.flush()
793        self.indexer.close()
794
795    def optimise(self):
796        """ Optimise the indexer. """
797        self.indexer.optimise()
798
799    def flush(self):
800        """Flush indexer state to disk."""
801        if self.mode == READWRITE:
802            self.indexer.flush()
803
804    # Helper methods
805    def _load_plugin(self, type, uri):
806        from pyndexter.util import URI
807        uri = URI(uri)
808        try:
809            module_name = 'pyndexter.%ss._%s' % (type, uri.scheme)
810            module = __import__(module_name, {}, {}, [''])
811        except ImportError, e:
812            raise InvalidModule(module_name, e)
813        indexer_factory = getattr(module, type + '_factory')
814        assert isinstance(indexer_factory, PluginFactory)
815        return indexer_factory
816
817    def _assert_rw(self):
818        if self.mode != READWRITE:
819            raise InvalidMode("%s must be in READWRITE mode for this "
820                              "operation" % self.__class__.__name__)
821
822
823class Result(object):
824    """Represents the result of a search. Each hit is returned as a Hit
825    object."""
826
827    def __init__(self, indexer, query, context):
828        self.indexer = indexer
829        self.query = query
830        self.context = context
831
832    def __iter__(self):
833        """Return an iterator over the result set, returning a Hit object
834        for each matching document."""
835        raise NotImplementedError
836
837    def __len__(self):
838        """ Return the length of the result set. """
839        raise NotImplementedError
840
841    def __getitem__(self, index):
842        """Return a Hit object for a specific index in the search result.
843        Not necessarily implemented by all Indexers."""
844        raise NotImplementedError
845
846    def __getslice__(self, i, j):
847        """ Return an iterator over a slice of the search set. """
848        for idx in xrange(i, j):
849            try:
850                yield self[idx]
851            except (IndexError, NotImplementedError):
852                break
853
854
855class Hit(object):
856    """ Wrapper around a search hit. If `current` is a callable, it should
857    be a function that fetches the Document associated with `uri`, which is
858    passed as the only argument.
859    """
860
861    __slots__ = ('attributes', '_current', '_indexed')
862
863    def __init__(self, uri, current=None, indexed=None, **attributes):
864        self._current = current
865        self._indexed = indexed
866        self.attributes = attributes
867#        if isinstance(uri, basestring):
868#            from pyndexter.util import URI
869#            uri = URI(uri)
870        self.attributes['uri'] = uri
871
872    def get(self, key, default=None):
873        """Get an attribute, but if it doesn't exist return a default value."""
874        return self.attributes.get(key, default)
875
876    def get_document(self):
877        """Fetch the `active` document, preferring to fetch a fresh document
878        from the source, but falling back on the indexed version.
879        """
880        try:
881            return self.current
882        except:
883            return self.indexed
884    document = property(get_document)
885
886    def __getattr__(self, key):
887        """Access hit attributes."""
888        try:
889            return self.attributes[key]
890        except KeyError, e:
891            raise AttributeError(unicode(e))
892
893    def __contains__(self, key):
894        """Determine whether a Hit contains an attribute."""
895        return key in self.attributes
896
897    def __repr__(self):
898        return '<Hit %s>' % ' '.join(['%s=%s' % (k, repr(v)) for k, v in
899                                              self.attributes.iteritems()])
900
901    def _get_current(self):
902        """Fetch current Document (if possible)."""
903        if callable(self._current):
904            self._current = self._current(self.uri)
905        return self._current
906    current = property(_get_current)
907
908    def _get_indexed(self):
909        """Fetch Indexer representation of Document (if possible)."""
910        if callable(self._indexed):
911            self._indexed = self._indexed(self.uri)
912        return self._indexed
913    indexed = property(_get_indexed)
Note: See TracBrowser for help on using the browser.