You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@bloodhound.apache.org by ju...@apache.org on 2012/12/24 14:47:07 UTC

svn commit: r1425649 - in /incubator/bloodhound/trunk/bloodhound_search: ./ bhsearch/ bhsearch/default-pages/ bhsearch/templates/ bhsearch/tests/

Author: jure
Date: Mon Dec 24 13:47:06 2012
New Revision: 1425649

URL: http://svn.apache.org/viewvc?rev=1425649&view=rev
Log:
 bloodhound search plugin update, towards #285 (from andrej)


Added:
    incubator/bloodhound/trunk/bloodhound_search/bhsearch/admin.py
    incubator/bloodhound/trunk/bloodhound_search/bhsearch/default-pages/
    incubator/bloodhound/trunk/bloodhound_search/bhsearch/default-pages/BloodhoundSearch
    incubator/bloodhound/trunk/bloodhound_search/bhsearch/query_parser.py
    incubator/bloodhound/trunk/bloodhound_search/bhsearch/tests/api.py
    incubator/bloodhound/trunk/bloodhound_search/bhsearch/tests/index_with_whoosh.py
    incubator/bloodhound/trunk/bloodhound_search/bhsearch/tests/ticket_search.py
    incubator/bloodhound/trunk/bloodhound_search/bhsearch/tests/utils.py
    incubator/bloodhound/trunk/bloodhound_search/bhsearch/tests/web_ui.py
    incubator/bloodhound/trunk/bloodhound_search/bhsearch/tests/whoosh_backend.py
    incubator/bloodhound/trunk/bloodhound_search/bhsearch/ticket_search.py
    incubator/bloodhound/trunk/bloodhound_search/bhsearch/whoosh_backend.py
Modified:
    incubator/bloodhound/trunk/bloodhound_search/bhsearch/api.py
    incubator/bloodhound/trunk/bloodhound_search/bhsearch/templates/bhsearch.html
    incubator/bloodhound/trunk/bloodhound_search/bhsearch/tests/__init__.py
    incubator/bloodhound/trunk/bloodhound_search/bhsearch/web_ui.py
    incubator/bloodhound/trunk/bloodhound_search/setup.py

Added: incubator/bloodhound/trunk/bloodhound_search/bhsearch/admin.py
URL: http://svn.apache.org/viewvc/incubator/bloodhound/trunk/bloodhound_search/bhsearch/admin.py?rev=1425649&view=auto
==============================================================================
--- incubator/bloodhound/trunk/bloodhound_search/bhsearch/admin.py (added)
+++ incubator/bloodhound/trunk/bloodhound_search/bhsearch/admin.py Mon Dec 24 13:47:06 2012
@@ -0,0 +1,38 @@
+#!/usr/bin/env python
+# -*- coding: UTF-8 -*-
+
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing,
+#  software distributed under the License is distributed on an
+#  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+#  KIND, either express or implied.  See the License for the
+#  specific language governing permissions and limitations
+#  under the License.
+
+r"""Administration commands for Bloodhound Search."""
+from trac.admin import *
+from bhsearch.api import BloodhoundSearchApi
+
+class BloodhoundSearchAdmin(Component):
+    """Bloodhound Search administration component."""
+
+    implements(IAdminCommandProvider)
+
+    # IAdminCommandProvider methods
+    def get_admin_commands(self):
+        yield ('bhsearch rebuild', '',
+            'Rebuild Bloodhound Search index',
+            None, BloodhoundSearchApi(self.env).rebuild_index)
+        yield ('bhsearch optimize', '',
+            'Optimize Bloodhound search index',
+            None, BloodhoundSearchApi(self.env).optimize)
+

Modified: incubator/bloodhound/trunk/bloodhound_search/bhsearch/api.py
URL: http://svn.apache.org/viewvc/incubator/bloodhound/trunk/bloodhound_search/bhsearch/api.py?rev=1425649&r1=1425648&r2=1425649&view=diff
==============================================================================
--- incubator/bloodhound/trunk/bloodhound_search/bhsearch/api.py (original)
+++ incubator/bloodhound/trunk/bloodhound_search/bhsearch/api.py Mon Dec 24 13:47:06 2012
@@ -18,66 +18,177 @@
 #  specific language governing permissions and limitations
 #  under the License.
 
-"""Core Bloodhound Search components"""
+r"""Core Bloodhound Search components."""
 
 from trac.core import *
+from trac.config import ExtensionOption
 
-class BloodhoundQuerySystem(Component):
-    """Implements core query functionality.
+ASC = "asc"
+DESC = "desc"
+SCORE = "score"
+
+class QueryResult(object):
+    def __init__(self):
+        self.hits = 0
+        self.page_count = 0
+        self.page_number = 0
+        self.offset = 0
+        self.docs = []
+        self.facets = None
+
+
+class ISearchBackend(Interface):
+    """Extension point interface for search backend systems.
     """
 
-    def query(self, query, sort = None, boost = None, filters = None,
-              facets = None, start = 0, rows = None):
-        """Return query result from on underlying backend.
+    def add_doc(self, doc, commit=True):
+        """
+        Called when new document instance must be added
+
+        :param doc: document to add
+        :param commit: flag if commit should be automatically called
+        """
+
+    def delete_doc(self, doc, commit=True):
+        """
+        Delete document from index
+
+        :param doc: document to delete
+        :param commit: flag if commit should be automatically called
+        """
+
+    def commit(self):
+        """
+        Commits changes
+        """
+
+    def optimize(self):
+        """
+        Optimize index if needed
+        """
+
+    def recreate_index(self):
+        """
+        Create a new index, if index exists, it will be deleted
+        """
+
+    def open_or_create_index_if_missing(self):
+        """
+        Open existing index, if index does not exist, create new one
+        """
+    def query(self, query, sort = None, fields = None, boost = None, filters = None,
+                  facets = None, pagenum = 1, pagelen = 20):
+        """
+        Perform query implementation
+
+        :param query:
+        :param sort:
+        :param fields:
+        :param boost:
+        :param filters:
+        :param facets:
+        :param pagenum:
+        :param pagelen:
+        :return: TBD!!!
+        """
+        pass
+
+class ISearchParticipant(Interface):
+    """Extension point interface for components that should be searched.
+    """
+
+    def get_search_filters(req):
+        """Called when we want to build the list of components with search.
+        Passes the request object to do permission checking."""
+        pass
+
+    def build_search_index(backend):
+        """Called when we want to rebuild the entire index.
+        :type backend: ISearchBackend
+        """
+        pass
+
+    def format_search_results(contents):
+        """Called to see if the module wants to format the search results."""
+
+class IQueryParser(Interface):
+    """Extension point for Bloodhound Search query parser.
+    """
+
+    def parse(query_string, req = None):
+        pass
+
+class BloodhoundSearchApi(Component):
+    """Implements core indexing functionality, provides methods for
+    searching, adding and deleting documents from index.
+    """
+    backend = ExtensionOption('bhsearch', 'search_backend',
+        ISearchBackend, 'WhooshBackend',
+        'Name of the component implementing Bloodhound Search backend \
+        interface: ISearchBackend.')
+
+    parser = ExtensionOption('bhsearch', 'query_parser',
+        IQueryParser, 'DefaultQueryParser',
+        'Name of the component implementing Bloodhound Search query \
+        parser.')
+
+    search_participants = ExtensionPoint(ISearchParticipant)
+
+    def query(self, query, req = None, sort = None, fields = None, boost = None, filters = None,
+                  facets = None, pagenum = 1, pagelen = 20):
+        """Return query result from an underlying search backend.
 
         Arguments:
-        query -- query string e.g. “bla status:closed” or a parsed
+        :param query: query string e.g. “bla status:closed” or a parsed
             representation of the query.
-        sort -- optional sorting
-        boost -- optional list of fields with boost values e.g.
+        :param sort: optional sorting
+        :param boost: optional list of fields with boost values e.g.
             {“id”: 1000, “subject” :100, “description”:10}.
-        filters -- optional list of terms. Usually can be cached by underlying
+        :param filters: optional list of terms. Usually can be cached by underlying
             search framework. For example {“type”: “wiki”}
-        facets - optional list of facet terms, can be field or expression.
-        start, rows -- paging support
+        :param facets: optional list of facet terms, can be field or expression.
+        :param page: paging support
+        :param pagelen: paging support
 
-        The result is returned as the following dictionary: {
-            "docs": [
-                {
-                    "id": "ticket:123",
-                    "resource_id": "123",
-                    "type": "ticket",
-                    ...
-                }
-            ],
-            "numFound":3,"
-            "start":0,
-            "facet_counts":{
-                "facet_fields":{
-                    "cat":[ "electronics",3, "card",2, "graphics",2, "music",1]
-                }
-            },
-        }
+        :return: result QueryResult
         """
         self.env.log.debug("Receive query request: %s", locals())
 
-        #TODO: add implementation here
-        dummy_result = dict(docs = [
-            dict(
-                resource_id = "123",
-                summary = "Dummy result for query: " + (query or ''),
-            )
-        ])
-        return dummy_result
+        # TODO: add query parsers and meta keywords post-parsing
+
+        # TODO: apply security filters
+
+        parsed_query = self.parser.parse(query, req)
+
+        #some backend-independent logic will come here...
+        query_result = self.backend.query(
+            query = parsed_query,
+            sort = sort,
+            fields = fields,
+            filters = filters,
+            facets = facets,
+            pagenum = pagenum,
+            pagelen = pagelen,
+        )
+
+        return query_result
 
-class BloodhoundIndexSystem(Component):
-    """Implements core indexing functionality, provides methods for
-    adding and deleting documents form index.
-    """
 
     def rebuild_index(self):
-        """Erase the index if it exists. Then create a new index from scratch.
-        """
+        """Delete the index if it exists. Then create a new full index."""
+        self.log.info('Rebuilding the search index.')
+        self.backend.recreate_index()
+
+        for participant in self.search_participants:
+            participant.build_search_index(self.backend)
+        self.backend.commit()
+        self.backend.optimize()
+
+        #Erase the index if it exists. Then create a new index from scratch.
+
+        #erase ticket
+        #call reindex for each resource
+        #commit
         pass
 
     def optimize(self):
@@ -89,12 +200,14 @@ class BloodhoundIndexSystem(Component):
 
         The doc must be dictionary with obligatory "type" field
         """
-        pass
+        self.backend.add_doc(doc)
 
-    def delete_doc(self, doc):
+    def delete_doc(self, type, id):
         """Add a document from underlying search backend.
 
         The doc must be dictionary with obligatory "type" field
         """
         pass
 
+
+

Added: incubator/bloodhound/trunk/bloodhound_search/bhsearch/default-pages/BloodhoundSearch
URL: http://svn.apache.org/viewvc/incubator/bloodhound/trunk/bloodhound_search/bhsearch/default-pages/BloodhoundSearch?rev=1425649&view=auto
==============================================================================
--- incubator/bloodhound/trunk/bloodhound_search/bhsearch/default-pages/BloodhoundSearch (added)
+++ incubator/bloodhound/trunk/bloodhound_search/bhsearch/default-pages/BloodhoundSearch Mon Dec 24 13:47:06 2012
@@ -0,0 +1,3 @@
+= Bloodhound Search =
+
+TODO: add docs here
\ No newline at end of file

Added: incubator/bloodhound/trunk/bloodhound_search/bhsearch/query_parser.py
URL: http://svn.apache.org/viewvc/incubator/bloodhound/trunk/bloodhound_search/bhsearch/query_parser.py?rev=1425649&view=auto
==============================================================================
--- incubator/bloodhound/trunk/bloodhound_search/bhsearch/query_parser.py (added)
+++ incubator/bloodhound/trunk/bloodhound_search/bhsearch/query_parser.py Mon Dec 24 13:47:06 2012
@@ -0,0 +1,57 @@
+#!/usr/bin/env python
+# -*- coding: UTF-8 -*-
+
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing,
+#  software distributed under the License is distributed on an
+#  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+#  KIND, either express or implied.  See the License for the
+#  specific language governing permissions and limitations
+#  under the License.
+
+r"""Provides Bloodhound Search query parsing functionality"""
+
+from bhsearch.api import IQueryParser
+from bhsearch.whoosh_backend import WhooshBackend
+from trac.core import Component, implements
+from whoosh.qparser import QueryParser, DisMaxParser, MultifieldParser
+
+class DefaultQueryParser(Component):
+    implements(IQueryParser)
+
+    def parse(self, query_string, req = None):
+        #todo: make field boost configurable e.g. read from config setting
+        #this is prototype implementation ,the fields boost must be tuned later
+        field_boosts = dict(
+            id = 6,
+            type = 2,
+            summary = 5,
+            author = 3,
+            milestone = 2,
+            keywords = 2,
+            component = 2,
+            status = 2,
+            content = 1,
+            changes = 1,
+        )
+        parser = MultifieldParser(
+            field_boosts.keys(),
+            WhooshBackend.SCHEMA,
+            fieldboosts=field_boosts
+        )
+        query_string = unicode(query_string)
+        parsed_query = parser.parse(query_string)
+
+        #todo: impelement pluggable mechanizem for query post processing
+        #e.g. meta keyword replacement etc.
+        return parsed_query
+

Modified: incubator/bloodhound/trunk/bloodhound_search/bhsearch/templates/bhsearch.html
URL: http://svn.apache.org/viewvc/incubator/bloodhound/trunk/bloodhound_search/bhsearch/templates/bhsearch.html?rev=1425649&r1=1425648&r2=1425649&view=diff
==============================================================================
--- incubator/bloodhound/trunk/bloodhound_search/bhsearch/templates/bhsearch.html (original)
+++ incubator/bloodhound/trunk/bloodhound_search/bhsearch/templates/bhsearch.html Mon Dec 24 13:47:06 2012
@@ -27,9 +27,15 @@
   <xi:include href="layout.html" />
   <head>
     <title py:choose="">
-      <py:when test="query">Search Results</py:when>
-      <py:otherwise>Search</py:otherwise>
+      <py:when test="query">Bloodhound Search Results</py:when>
+      <py:otherwise>Bloodhound Search</py:otherwise>
     </title>
+    <py:if test="results">
+        <meta name="startIndex" content="${results.span[0] + 1}"/>
+        <meta name="totalResults" content="$results.num_items"/>
+        <meta name="itemsPerPage" content="$results.max_per_page"/>
+    </py:if>
+
     <script type="text/javascript">
       jQuery(document).ready(function($) {$("#q").get(0).focus()});
     </script>
@@ -37,22 +43,35 @@
   <body>
     <div id="content" class="search">
 
-      <h1>This is dummy page. Implementation is coming...</h1>
+      <h1>This page provides prototype functionality. Implementation is coming...</h1>
       <h1><label for="q">Search</label></h1>
-      <form id="bhsearch" action="${href.bhsearch()}" method="get">
+      <form id="fullsearch" action="${href.bhsearch()}" method="get">
         <p>
           <input type="text" id="q" name="q" size="40" value="${query}" />
+          <input type="hidden" name="noquickjump" value="1" />
           <input type="submit" value="${_('Search')}" />
         </p>
       </form>
 
       <py:if test="results"><hr />
         <h2 py:if="results">
-          Results <span class="numresults">(${results.displayed_items()})</span>
+          Results <small>(${results.displayed_items()})</small>
         </h2>
-        <xi:include py:with="paginator = results" href="page_index.html" />
         <div>
           <dl id="results">
+
+            <!--This just a prototype stub. Should be replaced by proper ui mocks-->
+            <div>
+              <ul class="nav nav-tabs" id="mainnav">
+                <!--<li py:if="chrome.nav.mainnav"-->
+                    <!--py:for="idx, item in enumerate(i for i in chrome.nav.mainnav if i.name in mainnav_show)" -->
+                    <!--class="${classes(first_last(idx, chrome.nav.mainnav), active=item.active)}">${item.label}</li>-->
+                <li class="$active}"><a href="${page_href}">All (XXX)</a></li>
+                <li class=""><a href="${page_href}">Wiki (XXX)</a></li>
+                <li class=""><a href="${page_href}">Tickets (XXX)</a></li>
+              </ul>
+            </div>
+
             <py:for each="result in results">
               <dt><a href="${result.href}" class="searchable">${result.title}</a></dt>
               <dd class="searchable">${result.excerpt}</dd>
@@ -63,12 +82,21 @@
             </py:for>
           </dl>
         </div>
-        <xi:include py:with="paginator = results" href="page_index.html" />
+        <xi:include py:with="paginator = results" href="bh_page_index.html" />
       </py:if>
 
-      <div id="notfound" py:if="query and not (results)">
-        No matches found.
+      <div class="span12"
+          py:if="query and not (results or quickjump)">
+        <p id="notfound" class="alert">
+          No matches found.
+        </p>
       </div>
+
+      <div id="help" class="help-block pull-right" i18n:msg="">
+        <strong>Note:</strong> See <a href="${href.wiki('BloodhoundSearch')}">BloodhoundSearch</a>
+        for help on searching.
+      </div>
+
     </div>
   </body>
 </html>

Modified: incubator/bloodhound/trunk/bloodhound_search/bhsearch/tests/__init__.py
URL: http://svn.apache.org/viewvc/incubator/bloodhound/trunk/bloodhound_search/bhsearch/tests/__init__.py?rev=1425649&r1=1425648&r2=1425649&view=diff
==============================================================================
--- incubator/bloodhound/trunk/bloodhound_search/bhsearch/tests/__init__.py (original)
+++ incubator/bloodhound/trunk/bloodhound_search/bhsearch/tests/__init__.py Mon Dec 24 13:47:06 2012
@@ -17,4 +17,18 @@
 #  KIND, either express or implied.  See the License for the
 #  specific language governing permissions and limitations
 #  under the License.
+import doctest
+import unittest
+from bhsearch.tests import whoosh_backend, index_with_whoosh, web_ui, ticket_search, api
 
+def suite():
+    suite = unittest.TestSuite()
+    suite.addTest(index_with_whoosh.suite())
+    suite.addTest(whoosh_backend.suite())
+    suite.addTest(web_ui.suite())
+    suite.addTest(ticket_search.suite())
+    suite.addTest(api.suite())
+    return suite
+
+if __name__ == '__main__':
+    unittest.main(defaultTest='suite')
\ No newline at end of file

Added: incubator/bloodhound/trunk/bloodhound_search/bhsearch/tests/api.py
URL: http://svn.apache.org/viewvc/incubator/bloodhound/trunk/bloodhound_search/bhsearch/tests/api.py?rev=1425649&view=auto
==============================================================================
--- incubator/bloodhound/trunk/bloodhound_search/bhsearch/tests/api.py (added)
+++ incubator/bloodhound/trunk/bloodhound_search/bhsearch/tests/api.py Mon Dec 24 13:47:06 2012
@@ -0,0 +1,122 @@
+#!/usr/bin/env python
+# -*- coding: UTF-8 -*-
+
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing,
+#  software distributed under the License is distributed on an
+#  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+#  KIND, either express or implied.  See the License for the
+#  specific language governing permissions and limitations
+#  under the License.
+from datetime import datetime, timedelta
+from pprint import pprint
+
+import unittest
+import tempfile
+import shutil
+from bhsearch.api import BloodhoundSearchApi
+from bhsearch.query_parser import DefaultQueryParser
+from bhsearch.tests.utils import BaseBloodhoundSearchTest
+from bhsearch.ticket_search import TicketSearchParticipant
+
+from bhsearch.whoosh_backend import WhooshBackend
+from trac.test import EnvironmentStub, Mock, MockPerm
+from trac.ticket.api import TicketSystem
+
+
+class ApiQueryWithWhooshTestCase(BaseBloodhoundSearchTest):
+    def setUp(self):
+        self.env = EnvironmentStub(enable=['bhsearch.*'])
+        self.env.path = tempfile.mkdtemp('bhsearch-tempenv')
+        self.ticket_system = TicketSystem(self.env)
+        self.whoosh_backend = WhooshBackend(self.env)
+        self.whoosh_backend.recreate_index()
+        self.search_api = BloodhoundSearchApi(self.env)
+        self.ticket_participant = TicketSearchParticipant(self.env)
+        self.ticket_system = TicketSystem(self.env)
+        self.query_parser = DefaultQueryParser(self.env)
+
+    def tearDown(self):
+        shutil.rmtree(self.env.path)
+        self.env.reset_db()
+
+    def test_can_search_free_description(self):
+        #arrange
+        self.insert_ticket("dummy summary", description="aaa keyword bla")
+        #act
+        results = self.search_api.query("keyword")
+        #assert
+        self.print_result(results)
+        self.assertEqual(1, results.hits)
+
+    def test_can_query_free_summary(self):
+        #arrange
+        self.insert_ticket("summary1 keyword")
+        #act
+        results = self.search_api.query("keyword")
+        #assert
+        self.print_result(results)
+        self.assertEqual(1, results.hits)
+
+    def test_can_query_strict_summary(self):
+        #arrange
+        self.insert_ticket("summary1 keyword")
+        self.insert_ticket("summary2", description = "bla keyword")
+        #act
+        results = self.search_api.query("summary:keyword")
+        #assert
+        self.print_result(results)
+        self.assertEqual(1, results.hits)
+
+    def test_that_summary_hit_is_higher_than_description(self):
+        #arrange
+        self.insert_ticket("summary1 keyword")
+        self.insert_ticket("summary2", description = "bla keyword")
+        #act
+        results = self.search_api.query("keyword")
+        self.print_result(results)
+        #assert
+        self.assertEqual(2, results.hits)
+        docs = results.docs
+        self.assertEqual("summary1 keyword", docs[0]["summary"])
+        self.assertEqual("summary2", docs[1]["summary"])
+
+    def test_other_conditions_applied(self):
+        #arrange
+        self.insert_ticket("summary1 keyword", status="closed")
+        self.insert_ticket("summary2", description = "bla keyword")
+        self.insert_ticket("summary3", status="closed")
+        #act
+        results = self.search_api.query("keyword status:closed")
+        self.print_result(results)
+        #assert
+        self.assertEqual(1, results.hits)
+        docs = results.docs
+        self.assertEqual("summary1 keyword", docs[0]["summary"])
+
+    def test_can_search_id_and_summary(self):
+        #arrange
+        self.insert_ticket("summary1")
+        self.insert_ticket("summary2 1")
+        #act
+        results = self.search_api.query("1")
+        self.print_result(results)
+        #assert
+        self.assertEqual(2, results.hits)
+        docs = results.docs
+        self.assertEqual("summary1", docs[0]["summary"])
+
+def suite():
+    return unittest.makeSuite(ApiQueryWithWhooshTestCase, 'test')
+
+if __name__ == '__main__':
+    unittest.main()

Added: incubator/bloodhound/trunk/bloodhound_search/bhsearch/tests/index_with_whoosh.py
URL: http://svn.apache.org/viewvc/incubator/bloodhound/trunk/bloodhound_search/bhsearch/tests/index_with_whoosh.py?rev=1425649&view=auto
==============================================================================
--- incubator/bloodhound/trunk/bloodhound_search/bhsearch/tests/index_with_whoosh.py (added)
+++ incubator/bloodhound/trunk/bloodhound_search/bhsearch/tests/index_with_whoosh.py Mon Dec 24 13:47:06 2012
@@ -0,0 +1,84 @@
+#!/usr/bin/env python
+# -*- coding: UTF-8 -*-
+
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing,
+#  software distributed under the License is distributed on an
+#  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+#  KIND, either express or implied.  See the License for the
+#  specific language governing permissions and limitations
+#  under the License.
+from datetime import datetime
+
+import unittest
+import tempfile
+import shutil
+from bhsearch.api import BloodhoundSearchApi
+from bhsearch.tests.utils import BaseBloodhoundSearchTest
+from bhsearch.ticket_search import TicketSearchParticipant
+
+from bhsearch.whoosh_backend import WhooshBackend
+from trac.test import EnvironmentStub
+from trac.ticket.api import TicketSystem
+
+
+class IndexWhooshTestCase(BaseBloodhoundSearchTest):
+    def setUp(self):
+        self.env = EnvironmentStub(enable=['bhsearch.*'])
+        self.env.path = tempfile.mkdtemp('bhsearch-tempenv')
+#        self.perm = PermissionSystem(self.env)
+        self.whoosh_backend = WhooshBackend(self.env)
+        self.whoosh_backend.recreate_index()
+        self.search_api = BloodhoundSearchApi(self.env)
+        self.ticket_participant = TicketSearchParticipant(self.env)
+        self.ticket_system = TicketSystem(self.env)
+
+    def tearDown(self):
+        shutil.rmtree(self.env.path)
+        self.env.reset_db()
+
+    def test_can_index_ticket(self):
+        ticket = self.create_dummy_ticket()
+        ticket.id = "1"
+        self.ticket_participant.ticket_created(ticket)
+
+        results = self.search_api.query("*:*")
+        self.print_result(results)
+        self.assertEqual(1, results.hits)
+
+    def test_that_ticket_indexed_when_inserted_in_db(self):
+        ticket = self.create_dummy_ticket()
+        ticket_id = ticket.insert()
+        print "Created ticket #%s" % ticket_id
+
+        results = self.search_api.query("*:*")
+        self.print_result(results)
+        self.assertEqual(1, results.hits)
+
+    def test_can_reindex(self):
+        self.insert_ticket("t1")
+        self.insert_ticket("t2")
+        self.insert_ticket("t3")
+        self.whoosh_backend.recreate_index()
+        #act
+        self.search_api.rebuild_index()
+        #assert
+        results = self.search_api.query("*:*")
+        self.print_result(results)
+        self.assertEqual(3, results.hits)
+
+
+def suite():
+    return unittest.makeSuite(IndexWhooshTestCase, 'test')
+
+if __name__ == '__main__':
+    unittest.main()

Added: incubator/bloodhound/trunk/bloodhound_search/bhsearch/tests/ticket_search.py
URL: http://svn.apache.org/viewvc/incubator/bloodhound/trunk/bloodhound_search/bhsearch/tests/ticket_search.py?rev=1425649&view=auto
==============================================================================
--- incubator/bloodhound/trunk/bloodhound_search/bhsearch/tests/ticket_search.py (added)
+++ incubator/bloodhound/trunk/bloodhound_search/bhsearch/tests/ticket_search.py Mon Dec 24 13:47:06 2012
@@ -0,0 +1,60 @@
+#!/usr/bin/env python
+# -*- coding: UTF-8 -*-
+
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing,
+#  software distributed under the License is distributed on an
+#  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+#  KIND, either express or implied.  See the License for the
+#  specific language governing permissions and limitations
+#  under the License.
+from datetime import datetime
+
+import unittest
+import tempfile
+import shutil
+from bhsearch.api import BloodhoundSearchApi
+from bhsearch.tests.utils import BaseBloodhoundSearchTest
+from bhsearch.ticket_search import TicketSearchParticipant
+
+from bhsearch.whoosh_backend import WhooshBackend
+from trac.test import EnvironmentStub
+from trac.ticket.api import TicketSystem
+
+
+class TicketSearchSilenceOnExceptionTestCase(BaseBloodhoundSearchTest):
+    def setUp(self):
+        self.env = EnvironmentStub(
+            enable=['bhsearch.*'],
+            path=tempfile.mkdtemp('bhsearch-tempenv'),
+        )
+        self.ticket_participant = TicketSearchParticipant(self.env)
+
+    def tearDown(self):
+        pass
+
+    def test_does_not_raise_exception_by_default(self):
+        self.ticket_participant.ticket_created(None)
+
+    def test_raise_exception_if_configured(self):
+        self.env.config.set('bhsearch', 'silence_on_error', "False")
+        self.assertRaises(
+            Exception,
+            self.ticket_participant.ticket_created,
+            None)
+
+
+def suite():
+    return unittest.makeSuite(TicketSearchSilenceOnExceptionTestCase, 'test')
+
+if __name__ == '__main__':
+    unittest.main()

Added: incubator/bloodhound/trunk/bloodhound_search/bhsearch/tests/utils.py
URL: http://svn.apache.org/viewvc/incubator/bloodhound/trunk/bloodhound_search/bhsearch/tests/utils.py?rev=1425649&view=auto
==============================================================================
--- incubator/bloodhound/trunk/bloodhound_search/bhsearch/tests/utils.py (added)
+++ incubator/bloodhound/trunk/bloodhound_search/bhsearch/tests/utils.py Mon Dec 24 13:47:06 2012
@@ -0,0 +1,50 @@
+#!/usr/bin/env python
+# -*- coding: UTF-8 -*-
+
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing,
+#  software distributed under the License is distributed on an
+#  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+#  KIND, either express or implied.  See the License for the
+#  specific language governing permissions and limitations
+#  under the License.
+
+r"""
+Test utils methods
+"""
+import pprint
+import unittest
+from trac.ticket import Ticket
+
+class BaseBloodhoundSearchTest(unittest.TestCase):
+    def print_result(self, result):
+        print "Received result:"
+        pprint.pprint(result.__dict__)
+
+    def create_dummy_ticket(self, summary = None):
+        if not summary:
+            summary = 'Hello World'
+        data = {'component': 'foo', 'milestone': 'bar'}
+        return self.create_ticket(summary, reporter='john', **data)
+
+    def create_ticket(self, summary, **kw):
+        ticket = Ticket(self.env)
+        ticket["summary"] = summary
+        for k, v in kw.items():
+            ticket[k] = v
+        return ticket
+
+    def insert_ticket(self, summary, **kw):
+        """Helper for inserting a ticket into the database"""
+        ticket = self.create_ticket(summary, **kw)
+        return ticket.insert()
+

Added: incubator/bloodhound/trunk/bloodhound_search/bhsearch/tests/web_ui.py
URL: http://svn.apache.org/viewvc/incubator/bloodhound/trunk/bloodhound_search/bhsearch/tests/web_ui.py?rev=1425649&view=auto
==============================================================================
--- incubator/bloodhound/trunk/bloodhound_search/bhsearch/tests/web_ui.py (added)
+++ incubator/bloodhound/trunk/bloodhound_search/bhsearch/tests/web_ui.py Mon Dec 24 13:47:06 2012
@@ -0,0 +1,169 @@
+#!/usr/bin/env python
+# -*- coding: UTF-8 -*-
+
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing,
+#  software distributed under the License is distributed on an
+#  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+#  KIND, either express or implied.  See the License for the
+#  specific language governing permissions and limitations
+#  under the License.
+from datetime import datetime, timedelta
+from pprint import pprint
+
+import unittest
+import tempfile
+import shutil
+from bhsearch.api import BloodhoundSearchApi
+from bhsearch.tests.utils import BaseBloodhoundSearchTest
+from bhsearch.ticket_search import TicketSearchParticipant
+from bhsearch.web_ui import BloodhoundSearchModule, SEARCH_PERMISSION
+
+from bhsearch.whoosh_backend import WhooshBackend
+from trac.test import EnvironmentStub, Mock, MockPerm
+from trac.ticket.api import TicketSystem
+from trac.ticket import Ticket
+from trac.util.datefmt import FixedOffset
+from trac.util import format_datetime
+from trac.web import Href
+
+BHSEARCH_URL = "/bhsearch"
+DEFAULT_DOCS_PER_PAGE = 10
+
+class WebUiTestCaseWithWhoosh(BaseBloodhoundSearchTest):
+    def setUp(self):
+        self.env = EnvironmentStub(enable=['bhsearch.*'])
+        self.env.path = tempfile.mkdtemp('bhsearch-tempenv')
+
+#        self.perm = PermissionSystem(self.env)
+        self.ticket_system = TicketSystem(self.env)
+        self.whoosh_backend = WhooshBackend(self.env)
+        self.whoosh_backend.recreate_index()
+        self.search_api = BloodhoundSearchApi(self.env)
+        self.ticket_participant = TicketSearchParticipant(self.env)
+        self.ticket_system = TicketSystem(self.env)
+        self.web_ui = BloodhoundSearchModule(self.env)
+
+        self.req = Mock(
+            perm=MockPerm(),
+            chrome={'logo': {}},
+            href=Href("/bhsearch"),
+            args={},
+        )
+#                self.req = Mock(href=self.env.href, authname='anonymous', tz=utc)
+#        self.req = Mock(base_path='/trac.cgi', path_info='',
+#                        href=Href('/trac.cgi'), chrome={'logo': {}},
+#                        abs_href=Href('http://example.org/trac.cgi'),
+#                        environ={}, perm=[], authname='-', args={}, tz=None,
+#                        locale='', session=None, form_token=None)
+
+#        self.req = Mock(href=self.env.href, abs_href=self.env.abs_href, tz=utc,
+#                        perm=MockPerm())
+#
+
+    def tearDown(self):
+        shutil.rmtree(self.env.path)
+        self.env.reset_db()
+
+    def _process_request(self):
+        response = self.web_ui.process_request(self.req)
+        url, data, x = response
+        print "Received url: %s data:" % url
+        pprint(data)
+        if data.has_key("results"):
+            print "results :"
+            pprint(data["results"].__dict__)
+        return data
+
+    def test_can_process_empty_request(self):
+        data = self._process_request()
+        self.assertEqual("", data["query"])
+
+    def test_can_process_query_empty_data(self):
+        self.req.args["q"] = "bla"
+        data = self._process_request()
+        self.assertEqual("bla", data["query"])
+        self.assertEqual([], data["results"].items)
+
+    def test_can_process_first_page(self):
+        self._insert_docs(5)
+        self.req.args["q"] = "summary:test"
+        data = self._process_request()
+        self.assertEqual("summary:test", data["query"])
+        self.assertEqual(5, len(data["results"].items))
+
+    def test_can_return_utc_time(self):
+        #arrange
+        ticket_id = self.insert_ticket("bla")
+        ticket = Ticket(self.env, ticket_id)
+        ticket_time = ticket.time_changed
+        #act
+        self.req.args["q"] = "*:*"
+        data = self._process_request()
+        result_items = data["results"].items
+        #assert
+        self.assertEqual(1, len(result_items))
+        result_datetime = result_items[0]["date"]
+        print "Ticket time: %s, Returned time: %s" % (ticket_time, result_datetime)
+        self.assertEqual(format_datetime(ticket_time), result_items[0]["date"])
+
+    def test_can_return_user_time(self):
+        #arrange
+        ticket_id = self.insert_ticket("bla")
+        ticket = Ticket(self.env, ticket_id)
+        ticket_time = ticket.time_changed
+        #act
+        self.req.tz = FixedOffset(60, 'GMT +1:00')
+        self.req.args["q"] = "*:*"
+        data = self._process_request()
+        result_items = data["results"].items
+        #asset
+        self.assertEqual(1, len(result_items))
+        expected_datetime = format_datetime(ticket_time)
+        result_datetime = result_items[0]["date"]
+        print "Ticket time: %s, Formatted time: %s ,Returned time: %s" % (
+            ticket_time, expected_datetime,result_datetime)
+        self.assertEqual(expected_datetime, result_datetime)
+
+    def test_ticket_href(self):
+        self._insert_docs(1)
+        self.req.args["q"] = "*:*"
+        data = self._process_request()
+        docs = data["results"].items
+        self.assertEqual(1, len(docs))
+        self.assertEqual(BHSEARCH_URL + "/ticket/1", docs[0]["href"])
+
+    def test_page_href(self):
+        self._insert_docs(DEFAULT_DOCS_PER_PAGE+1)
+        self.req.args["q"] = "*:*"
+        data = self._process_request()
+        shown_pages =  data["results"].shown_pages
+        self.assertEqual(BHSEARCH_URL + "/bhsearch?q=*%3A*&page=2&noquickjump=1", shown_pages[1]["href"])
+
+    def test_facets(self):
+        self.insert_ticket("summary1 keyword", status="closed")
+        self.insert_ticket("summary2 keyword", status="new")
+        self.req.args["q"] = "*:*"
+        data = self._process_request()
+        facets =  data["facets"]
+        pprint(facets)
+        self.assertEqual({u'ticket': 2}, facets["type"])
+
+
+    def _insert_docs(self, n):
+        for i in range(1, n+1):
+            self.insert_ticket("test %s" % i)
+def suite():
+    return unittest.makeSuite(WebUiTestCaseWithWhoosh, 'test')
+
+if __name__ == '__main__':
+    unittest.main()

Added: incubator/bloodhound/trunk/bloodhound_search/bhsearch/tests/whoosh_backend.py
URL: http://svn.apache.org/viewvc/incubator/bloodhound/trunk/bloodhound_search/bhsearch/tests/whoosh_backend.py?rev=1425649&view=auto
==============================================================================
--- incubator/bloodhound/trunk/bloodhound_search/bhsearch/tests/whoosh_backend.py (added)
+++ incubator/bloodhound/trunk/bloodhound_search/bhsearch/tests/whoosh_backend.py Mon Dec 24 13:47:06 2012
@@ -0,0 +1,292 @@
+#!/usr/bin/env python
+# -*- coding: UTF-8 -*-
+
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing,
+#  software distributed under the License is distributed on an
+#  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+#  KIND, either express or implied.  See the License for the
+#  specific language governing permissions and limitations
+#  under the License.
+from datetime import datetime, timedelta
+
+import unittest
+import tempfile
+import shutil
+from bhsearch.api import ASC, DESC, SCORE
+from bhsearch.tests.utils import BaseBloodhoundSearchTest
+from bhsearch.whoosh_backend import WhooshBackend
+from trac.test import EnvironmentStub
+from trac.util.datefmt import FixedOffset, utc
+from whoosh.qparser import MultifieldParser, MultifieldPlugin, syntax, QueryParser, WhitespacePlugin, PhrasePlugin, PlusMinusPlugin
+
+
+class WhooshBackendTestCase(BaseBloodhoundSearchTest):
+    def setUp(self):
+        self.env = EnvironmentStub(enable=['bhsearch.*'])
+        self.env.path = tempfile.mkdtemp('bhsearch-tempenv')
+#        self.perm = PermissionSystem(self.env)
+        self.whoosh_backend = WhooshBackend(self.env)
+        self.whoosh_backend.recreate_index()
+
+    def tearDown(self):
+        shutil.rmtree(self.env.path)
+        self.env.reset_db()
+
+    def test_can_retrieve_docs(self):
+        self.whoosh_backend.add_doc(dict(id="1", type="ticket"))
+        self.whoosh_backend.add_doc(dict(id="2", type="ticket"))
+        result = self.whoosh_backend.query(
+#        result = self.search_api.query(
+            "*:*",
+            sort = [("id", ASC)],
+        )
+        self.print_result(result)
+        self.assertEqual(2, result.hits)
+        docs = result.docs
+        self.assertEqual(
+            {'id': '1', 'type': 'ticket', 'unique_id': 'ticket:1',
+             'score': u'1'},
+            docs[0])
+        self.assertEqual(
+            {'id': '2', 'type': 'ticket', 'unique_id': 'ticket:2',
+             'score': u'2'},
+            docs[1])
+
+    def test_can_return_all_fields(self):
+        self.whoosh_backend.add_doc(dict(id="1", type="ticket"))
+        result = self.whoosh_backend.query("*:*")
+        self.print_result(result)
+        docs = result.docs
+        self.assertEqual(
+            {'id': '1', 'type': 'ticket', 'unique_id': 'ticket:1',
+                "score": 1.0},
+            docs[0])
+
+    def test_can_select_fields(self):
+        self.whoosh_backend.add_doc(dict(id="1", type="ticket"))
+        result = self.whoosh_backend.query("*:*",
+            fields=("id", "type"))
+        self.print_result(result)
+        docs = result.docs
+        self.assertEqual(
+            {'id': '1', 'type': 'ticket'},
+            docs[0])
+
+
+    def test_can_survive_after_restart(self):
+        self.whoosh_backend.add_doc(dict(id="1", type="ticket"))
+        whoosh_backend2 = WhooshBackend(self.env)
+        whoosh_backend2.add_doc(dict(id="2", type="ticket"))
+        result = whoosh_backend2.query("*:*")
+        self.assertEqual(2, result.hits)
+
+    def test_can_multi_sort_asc(self):
+        self.whoosh_backend.add_doc(dict(id="2", type="ticket2"))
+        self.whoosh_backend.add_doc(dict(id="3", type="ticket1"))
+        self.whoosh_backend.add_doc(dict(id="4", type="ticket3"))
+        self.whoosh_backend.add_doc(dict(id="1", type="ticket1"))
+        result = self.whoosh_backend.query(
+            "*:*",
+            sort = [("type", ASC), ("id", ASC)],
+            fields=("id", "type"),
+        )
+        self.print_result(result)
+        self.assertEqual([{'type': 'ticket1', 'id': '1'},
+                          {'type': 'ticket1', 'id': '3'},
+                          {'type': 'ticket2', 'id': '2'},
+                          {'type': 'ticket3', 'id': '4'}],
+            result.docs)
+
+    def test_can_multi_sort_desc(self):
+        self.whoosh_backend.add_doc(dict(id="2", type="ticket2"))
+        self.whoosh_backend.add_doc(dict(id="3", type="ticket1"))
+        self.whoosh_backend.add_doc(dict(id="4", type="ticket3"))
+        self.whoosh_backend.add_doc(dict(id="1", type="ticket1"))
+        result = self.whoosh_backend.query(
+            "*:*",
+            sort = [("type", ASC), ("id", DESC)],
+            fields=("id", "type"),
+        )
+        self.print_result(result)
+        self.assertEqual([{'type': 'ticket1', 'id': '3'},
+                          {'type': 'ticket1', 'id': '1'},
+                          {'type': 'ticket2', 'id': '2'},
+                          {'type': 'ticket3', 'id': '4'}],
+            result.docs)
+
+    def test_can_sort_by_score_and_date(self):
+        the_first_date = datetime(2012, 12, 1)
+        the_second_date = datetime(2012, 12, 2)
+        the_third_date = datetime(2012, 12, 3)
+
+        exact_match_string = "texttofind"
+        not_exact_match_string = "texttofind bla"
+
+        self.whoosh_backend.add_doc(dict(
+            id="1",
+            type="ticket",
+            summary=not_exact_match_string,
+            time=the_first_date,
+        ))
+        self.whoosh_backend.add_doc(dict(
+            id="2",
+            type="ticket",
+            summary=exact_match_string,
+            time=the_second_date,
+        ))
+        self.whoosh_backend.add_doc(dict(
+            id="3",
+            type="ticket",
+            summary=not_exact_match_string,
+            time=the_third_date,
+        ))
+        self.whoosh_backend.add_doc(dict(
+            id="4",
+            type="ticket",
+            summary="some text out of search scope",
+            time=the_third_date,
+        ))
+        result = self.whoosh_backend.query(
+            "summary:texttofind",
+            sort = [(SCORE, ASC), ("time", DESC)],
+#            fields=("id", "type"),
+        )
+        self.print_result(result)
+        self.assertEqual(3, result.hits)
+        docs = result.docs
+        #must be found first, because the highest score (of exact match)
+        self.assertEqual("2", docs[0]["id"])
+        #must be found second, because the time order DESC
+        self.assertEqual("3", docs[1]["id"])
+        #must be found third, because the time order DESC
+        self.assertEqual("1", docs[2]["id"])
+
+    def test_can_do_facet_count(self):
+        self.whoosh_backend.add_doc(dict(id="1", type="ticket", product="A"))
+        self.whoosh_backend.add_doc(dict(id="2", type="ticket", product="B"))
+        self.whoosh_backend.add_doc(dict(id="3", type="wiki", product="A"))
+        result = self.whoosh_backend.query(
+            "*:*",
+            sort = [("type", ASC), ("id", DESC)],
+            fields=("id", "type"),
+            facets= ("type", "product")
+        )
+        self.print_result(result)
+        self.assertEqual(3, result.hits)
+        facets = result.facets
+        self.assertEqual({"ticket":2, "wiki":1}, facets["type"])
+        self.assertEqual({"A":2, "B":1}, facets["product"])
+
+    @unittest.skip(
+        "Fix this, check why exception is raise on Whoosh mailing list")
+    #TODO: fix this!!!!
+    def test_can_do_facet_if_filed_missing_TODO(self):
+        self.whoosh_backend.add_doc(dict(id="1", type="ticket"))
+        self.whoosh_backend.add_doc(dict(id="2", type="ticket", status="New"))
+        result = self.whoosh_backend.query(
+            "*:*",
+            facets= ("type", "status")
+        )
+        self.print_result(result)
+        self.assertEqual(2, result.hits)
+        facets = result.facets
+        self.assertEqual({"ticket":2}, facets["type"])
+        self.assertEqual({"new":1}, facets["status"])
+
+    def test_can_return_empty_result(self):
+        result = self.whoosh_backend.query(
+            "*:*",
+            sort = [("type", ASC), ("id", DESC)],
+            fields=("id", "type"),
+            facets= ("type", "product")
+        )
+        self.print_result(result)
+        self.assertEqual(0, result.hits)
+
+    def test_can_search_time_with_utc_tzinfo(self):
+        time = datetime(2012, 12, 13, 11, 8, 34, 711957, tzinfo=FixedOffset(0, 'UTC'))
+        self.whoosh_backend.add_doc(dict(id="1", type="ticket", time=time))
+        result = self.whoosh_backend.query("*:*")
+        self.print_result(result)
+        self.assertEqual(time, result.docs[0]["time"])
+
+    def test_can_search_time_without_tzinfo(self):
+        time = datetime(2012, 12, 13, 11, 8, 34, 711957, tzinfo=None)
+        self.whoosh_backend.add_doc(dict(id="1", type="ticket", time=time))
+        result = self.whoosh_backend.query("*:*")
+        self.print_result(result)
+        self.assertEqual(time.replace(tzinfo=utc), result.docs[0]["time"])
+
+    def test_can_search_time_with_non_utc_tzinfo(self):
+        hours = 8
+        tz_diff = 1
+        time = datetime(2012, 12, 13, 11, hours, 34, 711957,
+            tzinfo=FixedOffset(tz_diff, "just_one_timezone"))
+        self.whoosh_backend.add_doc(dict(id="1", type="ticket", time=time))
+        result = self.whoosh_backend.query("*:*")
+        self.print_result(result)
+        self.assertEqual(datetime(2012, 12, 13, 11, hours-tz_diff, 34, 711957,
+                    tzinfo=utc), result.docs[0]["time"])
+
+    @unittest.skip("TODO clarify behavior on Whoosh mail list")
+    def test_can_search_id_and_summary(self):
+        #arrange
+        self.insert_ticket("test x")
+        self.insert_ticket("test 1")
+
+#        field_boosts = dict(
+#            id = 6,
+#            type = 2,
+#            summary = 5,
+#            author = 3,
+#            milestone = 2,
+#            keywords = 2,
+#            component = 2,
+#            status = 2,
+#            content = 1,
+#            changes = 0.8,
+#        )
+        fieldboosts = dict(
+            id = 1,
+            summary = 1,
+        )
+
+#        parser = MultifieldParser(
+#            fieldboosts.keys(),
+#            WhooshBackend.SCHEMA,
+##            fieldboosts=field_boosts
+#        )
+
+        mfp = MultifieldPlugin(list(fieldboosts.keys()),
+#                                       fieldboosts=fieldboosts,
+#                                       group=syntax.DisMaxGroup
+        )
+        pins = [WhitespacePlugin,
+#                PlusMinusPlugin,
+                PhrasePlugin,
+                mfp]
+        parser =  QueryParser(None, WhooshBackend.SCHEMA, plugins=pins)
+
+
+        parsed_query = parser.parse(u"1")
+#        parsed_query = parser.parse(u"test")
+        result = self.whoosh_backend.query(parsed_query)
+        self.print_result(result)
+        self.assertEqual(2, result.hits)
+
+
+def suite():
+    return unittest.makeSuite(WhooshBackendTestCase, 'test')
+
+if __name__ == '__main__':
+    unittest.main()

Added: incubator/bloodhound/trunk/bloodhound_search/bhsearch/ticket_search.py
URL: http://svn.apache.org/viewvc/incubator/bloodhound/trunk/bloodhound_search/bhsearch/ticket_search.py?rev=1425649&view=auto
==============================================================================
--- incubator/bloodhound/trunk/bloodhound_search/bhsearch/ticket_search.py (added)
+++ incubator/bloodhound/trunk/bloodhound_search/bhsearch/ticket_search.py Mon Dec 24 13:47:06 2012
@@ -0,0 +1,128 @@
+#!/usr/bin/env python
+# -*- coding: UTF-8 -*-
+
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing,
+#  software distributed under the License is distributed on an
+#  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+#  KIND, either express or implied.  See the License for the
+#  specific language governing permissions and limitations
+#  under the License.
+
+r"""Ticket specifics for Bloodhound Search plugin."""
+from bhsearch.api import ISearchParticipant, BloodhoundSearchApi
+from genshi.builder import tag
+from trac.core import *
+from trac.ticket.api import ITicketChangeListener, TicketSystem
+from trac.ticket import Ticket
+from trac.ticket.query import Query
+from trac.config import Option
+from trac.web.chrome import add_warning
+from trac.util.datefmt import to_datetime
+
+TICKET = "ticket"
+TICKET_STATUS = 'status'
+
+class TicketSearchParticipant(Component):
+    implements(ITicketChangeListener, ISearchParticipant)
+    silence_on_error = Option('bhsearch', 'silence_on_error', "True",
+        """If true, do not throw an exception during indexing a resource""")
+
+    def _index_ticket(self, ticket, search_api=None, raise_exception = False):
+        """Internal method for actually indexing a ticket.
+        This reduces duplicating code."""
+        try:
+            if not search_api:
+                search_api = BloodhoundSearchApi(self.env)
+
+            #This is very naive prototype implementation
+            #TODO: a lot of improvements must be added here!!!
+            contents = {
+                'id': unicode(ticket.id),
+                'time': ticket.time_changed,
+                'type': TICKET,
+                }
+            fields = [('component',), ('description','content'), ('component',),
+                      ('keywords',), ('milestone',), ('summary',),
+                      ('status',), ('resolution',), ('reporter','author')]
+            for f in fields:
+              if f[0] in ticket.values:
+                  if len(f) == 1:
+                      contents[f[0]] = ticket.values[f[0]]
+                  elif len(f) == 2:
+                      contents[f[1]] = ticket.values[f[0]]
+            contents['changes'] = u'\n\n'.join([x[4] for x in ticket.get_changelog()
+              if x[2] == u'comment'])
+            search_api.add_doc(contents)
+        except Exception, e:
+            if (not raise_exception) and self.silence_on_error.lower() == "true":
+                #Is there any way to get request object to add warning?
+    #            add_warning(req, _('Exception during ticket indexing: %s' % e))
+                self.log.error("Error occurs during ticke indexing. \
+                    The error will not be propagated. Exception: %s", e)
+            else:
+                raise
+
+
+    #ITicketChangeListener methods
+    def ticket_created(self, ticket):
+        """Index a recently created ticket."""
+        self._index_ticket(ticket)
+
+    def ticket_changed(self, ticket, comment, author, old_values):
+        """Reindex a recently modified ticket."""
+        self._index_ticket(ticket)
+
+    def ticket_deleted(self, ticket):
+        """Called when a ticket is deleted."""
+        s = BloodhoundSearchApi(self.env)
+        s.delete_doc(u'ticket', unicode(ticket.id))
+
+    # ISearchParticipant methods
+    def get_search_filters(self, req=None):
+        if not req or 'TICKET_VIEW' in req.perm:
+            return ('ticket', 'Tickets')
+
+    def build_search_index(self, backend):
+        """
+        :type backend: ISearchBackend
+        """
+        #TODO: some king of paging/batch size should be introduced in order to
+        # avoid loading of all ticket ids in memory
+        query_records = self.load_tickets_ids()
+        for record in query_records:
+            ticket_id = record["id"]
+            ticket = Ticket(self.env, ticket_id)
+            self._index_ticket(ticket, backend, raise_exception=True)
+
+    def load_tickets_ids(self):
+        #is there better way to get all tickets?
+        query = Query(self.env, cols=['id'], order='id')
+        return query.execute()
+
+    def format_search_results(self, res):
+        if not TICKET_STATUS in res:
+          stat = 'undefined_status'
+          class_ = 'undefined_status'
+        else:
+            class_= res[TICKET_STATUS]
+            if res[TICKET_STATUS] == 'closed':
+                resolution = ""
+                if 'resolution' in res:
+                    resolution = res['resolution']
+                stat = '%s: %s' % (res['status'], resolution)
+            else:
+                stat = res[TICKET_STATUS]
+
+        id = tag(tag.span('#'+res['id'], class_=class_))
+        return id + ': %s (%s)' % (res['summary'], stat)
+

Modified: incubator/bloodhound/trunk/bloodhound_search/bhsearch/web_ui.py
URL: http://svn.apache.org/viewvc/incubator/bloodhound/trunk/bloodhound_search/bhsearch/web_ui.py?rev=1425649&r1=1425648&r2=1425649&view=diff
==============================================================================
--- incubator/bloodhound/trunk/bloodhound_search/bhsearch/web_ui.py (original)
+++ incubator/bloodhound/trunk/bloodhound_search/bhsearch/web_ui.py Mon Dec 24 13:47:06 2012
@@ -18,7 +18,7 @@
 #  specific language governing permissions and limitations
 #  under the License.
 
-"""Bloodhound Search user interface"""
+r"""Bloodhound Search user interface."""
 
 import pkg_resources
 import re
@@ -26,12 +26,15 @@ import re
 from trac.core import *
 from genshi.builder import tag
 from trac.perm import IPermissionRequestor
+from trac.search import shorten_result
+from trac.util.presentation import Paginator
+from trac.util.datefmt import format_datetime, user_time
 from trac.web import IRequestHandler
 from trac.util.translation import _
 from trac.web.chrome import (INavigationContributor, ITemplateProvider,
                              add_link, add_stylesheet, add_warning,
                              web_context)
-from bhsearch.api import BloodhoundQuerySystem
+from bhsearch.api import BloodhoundSearchApi, ISearchParticipant, SCORE, ASC, DESC
 
 SEARCH_PERMISSION = 'SEARCH_VIEW'
 
@@ -43,6 +46,12 @@ class BloodhoundSearchModule(Component):
     #           IWikiSyntaxProvider #todo: implement later
     )
 
+    search_participants = ExtensionPoint(ISearchParticipant)
+
+    RESULTS_PER_PAGE = 10
+    DEFAULT_SORT = [(SCORE, ASC), ("time", DESC)]
+
+
     # INavigationContributor methods
     def get_active_navigation_item(self, req):
         return 'bhsearch'
@@ -65,18 +74,130 @@ class BloodhoundSearchModule(Component):
         req.perm.assert_permission(SEARCH_PERMISSION)
 
         query = req.args.get('q')
+        if query == None:
+            query = ""
+
+        #TODO add quick jump support
 
-        data = {}
-        if query:
-            data["query"] = query
-
-        #TODO: add implementation here
-        querySystem = BloodhoundQuerySystem(self.env)
-        result = querySystem.query(query)
+        #TODO: refactor filters or replace with facets
+        filters = []
+#        available_filters = filter(None, [p.get_search_filters(req) for p
+#            in self.search_participants])
+#        filters = [f[0] for f in available_filters if req.args.has_key(f[0])]
+#        if not filters:
+#            filters = [f[0] for f in available_filters
+#                       if f[0] not in self.default_disabled_filters and
+#                       (len(f) < 3 or len(f) > 2 and f[2])]
+#        data = {'filters': [{'name': f[0], 'label': f[1],
+#                             'active': f[0] in filters}
+#                            for f in available_filters],
+#                'quickjump': None,
+#                'results': []}
+
+        data = {
+      			'query': query,
+      		}
+
+        # Initial page request
+        #todo: filters check, tickets etc
+        if not any((query, )):
+            return self._return_data(req, data)
+
+        page = int(req.args.get('page', '1'))
+
+        #todo: retrieve sort from query string
+        sort = self.DEFAULT_SORT
+
+        #todo: add proper facets functionality
+#        facets = ("type", "status")
+        facets = ("type",)
+
+
+        querySystem = BloodhoundSearchApi(self.env)
+        query_result = querySystem.query(
+            query,
+            pagenum = page,
+            pagelen = self.RESULTS_PER_PAGE,
+            sort = sort,
+            facets = facets,
+        )
+        ui_docs = [self._process_doc(doc, req)
+                   for doc in query_result.docs]
+
+
+        results = Paginator(
+            ui_docs,
+            page - 1,
+            self.RESULTS_PER_PAGE,
+            query_result.hits,
+        )
+
+        results.shown_pages = self._prepare_shown_pages(
+            filters,
+            query,
+            req,
+            shown_pages = results.get_shown_pages(self.RESULTS_PER_PAGE))
+
+        results.current_page = {'href': None, 'class': 'current',
+                                'string': str(results.page + 1),
+                                'title':None}
+
+        if results.has_next_page:
+            next_href = req.href.bhsearch(zip(filters, ['on'] * len(filters)),
+                                        q=req.args.get('q'), page=page + 1,
+                                        noquickjump=1)
+            add_link(req, 'next', next_href, _('Next Page'))
+
+        if results.has_previous_page:
+            prev_href = req.href.bhsearch(zip(filters, ['on'] * len(filters)),
+                                        q=req.args.get('q'), page=page - 1,
+                                        noquickjump=1)
+            add_link(req, 'prev', prev_href, _('Previous Page'))
+
+        data['results'] = results
+
+        #add proper facet links
+        data['facets'] = query_result.facets
+
+        data['page_href'] = req.href.bhsearch(
+            zip(filters, ['on'] * len(filters)), q=req.args.get('q'),
+            noquickjump=1)
+        return self._return_data(req, data)
 
+    def _return_data(self, req, data):
         add_stylesheet(req, 'common/css/search.css')
         return 'bhsearch.html', data, None
 
+    def _process_doc(self, doc,req):
+        titlers = dict([(x.get_search_filters(req)[0], x.format_search_results)
+            for x in self.search_participants if x.get_search_filters(req)])
+
+        #todo: introduce copy by predefined value
+        ui_doc = dict(doc)
+
+        ui_doc["href"] = req.href(doc['type'], doc['id'])
+        #todo: perform content adaptation here
+        if doc.has_key('content'):
+            ui_doc['excerpt'] = shorten_result(doc['content'])
+        if doc.has_key('time'):
+            ui_doc['date'] = user_time(req, format_datetime, doc['time'])
+
+        ui_doc['title'] = titlers[doc['type']](doc)
+        return ui_doc
+
+    def _prepare_shown_pages(self, filters, query, req, shown_pages):
+        pagedata = []
+        for shown_page in shown_pages:
+            page_href = req.href.bhsearch([(f, 'on') for f in filters],
+                q=query,
+                page=shown_page, noquickjump=1)
+            pagedata.append([page_href, None, str(shown_page),
+                             'page ' + str(shown_page)])
+        fields = ['href', 'class', 'string', 'title']
+        result_shown_pages = [dict(zip(fields, p)) for p in pagedata]
+        return result_shown_pages
+
+
     # ITemplateProvider methods
     def get_htdocs_dirs(self):
 #        return [('bhsearch', pkg_resources.resource_filename(__name__, 'htdocs'))]
@@ -85,3 +206,4 @@ class BloodhoundSearchModule(Component):
     def get_templates_dirs(self):
         return [pkg_resources.resource_filename(__name__, 'templates')]
 
+

Added: incubator/bloodhound/trunk/bloodhound_search/bhsearch/whoosh_backend.py
URL: http://svn.apache.org/viewvc/incubator/bloodhound/trunk/bloodhound_search/bhsearch/whoosh_backend.py?rev=1425649&view=auto
==============================================================================
--- incubator/bloodhound/trunk/bloodhound_search/bhsearch/whoosh_backend.py (added)
+++ incubator/bloodhound/trunk/bloodhound_search/bhsearch/whoosh_backend.py Mon Dec 24 13:47:06 2012
@@ -0,0 +1,261 @@
+#!/usr/bin/env python
+# -*- coding: UTF-8 -*-
+
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing,
+#  software distributed under the License is distributed on an
+#  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+#  KIND, either express or implied.  See the License for the
+#  specific language governing permissions and limitations
+#  under the License.
+
+r"""Whoosh specific backend for Bloodhound Search plugin."""
+from bhsearch.api import ISearchBackend, DESC, QueryResult, SCORE
+import os
+from trac.core import *
+from trac.config import Option, PathOption
+from trac.util.datefmt import utc
+from whoosh.fields import *
+from whoosh import index, sorting
+from whoosh.qparser import QueryParser
+from whoosh.searching import ResultsPage
+from whoosh.sorting import FieldFacet
+from whoosh.writing import AsyncWriter
+from datetime import datetime, date
+
+class WhooshBackend(Component):
+    """
+    Implements Whoosh SearchBackend interface
+    """
+    implements(ISearchBackend)
+
+    index_dir_setting = Option('bhsearch', 'whoosh_index_dir', 'whoosh_index',
+        """Relative path is resolved relatively to the
+        directory of the environment.""")
+
+    #This is schema prototype. It will be changed later
+    #TODO: add other fields support, add dynamic field support
+    SCHEMA = Schema(
+        unique_id=ID(stored=True, unique=True),
+        id=ID(stored=True),
+        type=ID(stored=True),
+        product=ID(stored=True),
+        time=DATETIME(stored=True),
+        author=ID(stored=True),
+        component=KEYWORD(stored=True),
+        status=KEYWORD(stored=True),
+        resolution=KEYWORD(stored=True),
+        keywords=KEYWORD(scorable=True),
+        milestone=TEXT(spelling=True),
+        summary=TEXT(stored=True),
+        content=TEXT(stored=True),
+        changes=TEXT(),
+        )
+
+    def __init__(self):
+        self.index_dir = self.index_dir_setting
+        if not os.path.isabs(self.index_dir):
+            self.index_dir = os.path.join(self.env.path, self.index_dir)
+        self.open_or_create_index_if_missing()
+
+    #ISearchBackend methods
+    def add_doc(self, doc, commit=True):
+        """Add any type of  document index.
+
+        The contents should be a dict with fields matching the search schema.
+        The only required fields are type and id, everything else is optional.
+        """
+        # Really make sure it's unicode, because Whoosh won't have it any
+        # other way.
+        for key in doc:
+            doc[key] = self._to_whoosh_format(doc[key])
+
+        doc["unique_id"] = u"%s:%s" % (doc["type"], doc["id"])
+
+        writer = AsyncWriter(self.index)
+        committed = False
+        try:
+            #todo: remove it!!!
+            self.log.debug("Doc to index: %s", doc)
+            writer.update_document(**doc)
+            writer.commit()
+            committed = True
+        finally:
+            if not committed:
+                writer.cancel()
+
+
+    def query(self, query, sort = None, fields = None, boost = None, filters = None,
+                  facets = None, pagenum = 1, pagelen = 20):
+
+        with self.index.searcher() as searcher:
+            parser = QueryParser("content", self.index.schema)
+            if isinstance(query, basestring):
+                query = unicode(query)
+                parsed_query = parser.parse(unicode(query))
+            else:
+                parsed_query = query
+
+            sortedby = self._prepare_sortedby(sort)
+            groupedby = self._prepare_groupedby(facets)
+            self.env.log.debug("Whoosh query to execute: %s, sortedby = %s, \
+                               pagenum=%s, pagelen=%s, facets=%s",
+                parsed_query,
+                sortedby,
+                pagenum,
+                pagelen,
+                groupedby,
+            )
+            raw_page = searcher.search_page(
+                parsed_query,
+                pagenum = pagenum,
+                pagelen = pagelen,
+                sortedby = sortedby,
+                groupedby = groupedby,
+            )
+#            raw_page = ResultsPage(whoosh_results, pagenum, pagelen)
+            results = self._process_results(raw_page, fields)
+        return results
+
+    def delete_doc(self, doc, commit=True):
+        pass
+
+    def commit(self):
+        pass
+
+    def optimize(self):
+        pass
+
+    def recreate_index(self):
+        self.index = self._create_index()
+
+    def open_or_create_index_if_missing(self):
+        if index.exists_in(self.index_dir):
+            self.index = index.open_dir(self.index_dir)
+        else:
+            self.index = self._create_index()
+
+
+    def _to_whoosh_format(self, value):
+        if isinstance(value, basestring):
+            value = unicode(value)
+        elif isinstance(value, datetime):
+            value = self._convert_date_to_tz_naive_utc(value)
+        return value
+
+    def _convert_date_to_tz_naive_utc(self, value):
+        """Convert datetime to naive utc datetime
+        Whoosh can not read  from index datetime value with
+        tzinfo=trac.util.datefmt.FixedOffset because of non-empty
+        constructor"""
+        if value.tzinfo:
+            utc_time = value.astimezone(utc)
+            value = utc_time.replace(tzinfo=None)
+        return value
+
+    def _from_whoosh_format(self, value):
+        if isinstance(value, datetime):
+            value = utc.localize(value)
+        return value
+
+    def _prepare_groupedby(self, facets):
+        if not facets:
+            return None
+        groupedby = sorting.Facets()
+        for facet_name in facets:
+            groupedby.add_field(facet_name, allow_overlap=True, maptype=sorting.Count)
+        return groupedby
+
+    def _prepare_sortedby(self, sort):
+        if not sort:
+            return None
+        sortedby = []
+        for (field, order) in sort:
+            if field.lower() == SCORE:
+                if self._is_desc(order):
+                    #We can implement later our own ScoreFacet with
+                    # "score DESC" support
+                    raise TracError("Whoosh does not support DESC score ordering.")
+                sort_condition = sorting.ScoreFacet()
+            else:
+                sort_condition = sorting.FieldFacet(field, reverse=self._is_desc(order))
+            sortedby.append(sort_condition)
+        return sortedby
+
+    def _is_desc(self, order):
+        return (order.lower()==DESC)
+
+    def _process_results(self, page, fields):
+        # It's important to grab the hits first before slicing. Otherwise, this
+        # can cause pagination failures.
+        """
+        :type fields: iterator
+        :type page: ResultsPage
+        """
+        results = QueryResult()
+        results.hits = page.total
+        results.total_page_count = page.pagecount
+        results.page_number = page.pagenum
+        results.offset = page.offset
+        results.facets = self._load_facets(page)
+
+        docs = []
+        for doc_offset, retrieved_record in enumerate(page):
+            result_doc = self._process_record(fields, retrieved_record)
+            docs.append(result_doc)
+        results.docs = docs
+        return results
+
+    def _process_record(self, fields, retrieved_record):
+        result_doc = dict()
+        #add score field by default
+        if not fields or SCORE in fields:
+            score = retrieved_record.score
+            result_doc[SCORE] = score
+
+        if fields:
+            for field in fields:
+                if field in retrieved_record:
+                    result_doc[field] = retrieved_record[field]
+        else:
+            for key, value in retrieved_record.items():
+                result_doc[key] = value
+
+        for key, value in result_doc.iteritems():
+            result_doc[key] = self._from_whoosh_format(value)
+        return result_doc
+
+    def _load_facets(self, page):
+        non_paged_results = page.results
+        facet_names = non_paged_results.facet_names()
+        if not facet_names:
+            return None
+        facets_result = dict()
+        for name in facet_names:
+            facets_result[name] = non_paged_results.groups(name)
+        return facets_result
+
+    def _create_index(self):
+        self.log.info('Creating Whoosh index in %s' % self.index_dir)
+        self._mkdir_if_not_exists()
+        return index.create_in(self.index_dir, schema=self.SCHEMA)
+
+    def _mkdir_if_not_exists(self):
+        if not os.path.exists(self.index_dir):
+            os.mkdir(self.index_dir)
+
+        if not os.access(self.index_dir, os.W_OK):
+            raise TracError(
+                "The path to Whoosh index '%s' is not writable for the\
+                 current user."
+                % self.index_dir)
+

Modified: incubator/bloodhound/trunk/bloodhound_search/setup.py
URL: http://svn.apache.org/viewvc/incubator/bloodhound/trunk/bloodhound_search/setup.py?rev=1425649&r1=1425648&r2=1425649&view=diff
==============================================================================
--- incubator/bloodhound/trunk/bloodhound_search/setup.py (original)
+++ incubator/bloodhound/trunk/bloodhound_search/setup.py Mon Dec 24 13:47:06 2012
@@ -29,7 +29,7 @@ Add free text search and query functiona
 """
 
 versions = [
-    (0, 4, 0),
+    (0, 4, 1),
     ]
 
 latest = '.'.join(str(x) for x in versions[-1])
@@ -95,11 +95,11 @@ PKG_INFO = {'bhsearch' : ('bhsearch',   
                               'htdocs/img/*.*', 'htdocs/js/*.js',
                               'templates/*', 'default-pages/*'],
                           ),
-#            'search.widgets' : ('search/widgets',     # Package dir
+#            'search.widgets' : ('bhsearch/widgets',     # Package dir
 #                            # Package data
 #                            ['templates/*', 'htdocs/*.css'],
 #                          ),
-#            'search.layouts' : ('search/layouts',     # Package dir
+#            'search.layouts' : ('bhsearch/layouts',     # Package dir
 #                            # Package data
 #                            ['templates/*'],
 #                          ),
@@ -109,21 +109,38 @@ PKG_INFO = {'bhsearch' : ('bhsearch',   
                           ),
             }
 
-ENTRY_POINTS = r"""
-               [trac.plugins]
-               bhsearch.web_ui = bhsearch.web_ui
-               bhsearch.api = bhsearch.api
-               """
+#ENTRY_POINTS = r"""
+#               [trac.plugins]
+#               bhsearch.web_ui = bhsearch.web_ui
+#               bhsearch.api = bhsearch.api
+#               bhsearch.admin = bhsearch.admin
+#               bhsearch.ticket_search = bhsearch.ticket_search
+#               bhsearch.query_parser = bhsearch.query_parser
+#               bhsearch.whoosh_backend = bhsearch.whoosh_backend
+#               """
+ENTRY_POINTS = {
+        'trac.plugins': [
+            'bhsearch.web_ui = bhsearch.web_ui',
+            'bhsearch.api = bhsearch.api',
+            'bhsearch.admin = bhsearch.admin',
+            'bhsearch.ticket_search = bhsearch.ticket_search',
+            'bhsearch.query_parser = bhsearch.query_parser',
+            'bhsearch.whoosh_backend = bhsearch.whoosh_backend',
+        ],
+    }
+#bhsearch.whoosh_backend = bhsearch.whoosh_backend
+#bhsearch.ticket_search = bhsearch.ticket_search
 
 setup(
     name=DIST_NM,
     version=latest,
     description=DESC.split('\n', 1)[0],
     requires = ['trac'],
-    tests_require = ['dutest>=0.2.4', 'TracXMLRPC'],
+#    tests_require = ['dutest>=0.2.4', 'TracXMLRPC'],
     install_requires = [
         'setuptools>=0.6b1',
         'Trac>=0.11',
+        'whoosh>=2.4.1',
     ],
     package_dir = dict([p, i[0]] for p, i in PKG_INFO.iteritems()),
     packages = PKG_INFO.keys(),