← Back to team overview

launchpad-reviewers team mailing list archive

Re: [Merge] lp:~maxiberta/launchpad/bing-search into lp:launchpad

 

Replied each comment inline. Most issues are now fixed. Still missing:
- Add feature flag to switch search engine.
- Code deduplication.
- Turn doctests into proper unit tests.
- Run tests on search page with both Google and Bing.

Diff comments:

> 
> === modified file 'configs/development/launchpad-lazr.conf'
> --- configs/development/launchpad-lazr.conf	2018-02-02 15:29:38 +0000
> +++ configs/development/launchpad-lazr.conf	2018-03-17 20:34:09 +0000
> @@ -86,6 +86,15 @@
>  [google_test_service]
>  launch: True
>  
> +[bing]
> +# Development and the testrunner should use the stub service be default.

Fixed.

> +site: http://launchpad.dev:8093/bingcustomsearch/v7.0/search
> +subscription_key: abcdef01234567890abcdef012345678
> +custom_config_id: 1234567890
> +
> +[bing_test_service]
> +launch: True
> +
>  [gpghandler]
>  host: keyserver.launchpad.dev
>  public_host: keyserver.launchpad.dev
> 
> === modified file 'lib/lp/app/browser/root.py'
> --- lib/lp/app/browser/root.py	2018-03-16 14:50:01 +0000
> +++ lib/lp/app/browser/root.py	2018-03-17 20:34:09 +0000
> @@ -510,24 +517,24 @@
>      def searchPages(self, query_terms, start=0):
>          """Return the up to 20 pages that match the query_terms, or None.
>  
> -        :param query_terms: The unescaped terms to query Google.
> +        :param query_terms: The unescaped terms to query for.
>          :param start: The index of the page that starts the set of pages.
> -        :return: A GooglBatchNavigator or None.
> +        :return: A SearchResultsBatchNavigator or None.
>          """
>          if query_terms in [None, '']:
>              return None
> -        google_search = getUtility(ISearchService)
> +        site_search = getUtility(ISearchService)
>          try:
> -            page_matches = google_search.search(
> +            page_matches = site_search.search(
>                  terms=query_terms, start=start)
> -        except GoogleResponseError:
> -            # There was a connectivity or Google service issue that means
> +        except (BingResponseError, GoogleResponseError):

Done. Replaced BingResponseError and GoogleResponseError by SiteSearchResponseError.

> +            # There was a connectivity or the search service issue that means
>              # there is no data available at this moment.
>              self.has_page_service = False
>              return None
>          if len(page_matches) == 0:
>              return None
> -        navigator = GoogleBatchNavigator(
> +        navigator = SearchResultsBatchNavigator(
>              page_matches, self.request, start=start)
>          navigator.setHeadings(*self.batch_heading)
>          return navigator
> @@ -589,7 +596,7 @@
>          return self.start + len(self.list._window)
>  
>  
> -class GoogleBatchNavigator(BatchNavigator):
> +class SearchResultsBatchNavigator(BatchNavigator):

Fixed.

>      """A batch navigator with a fixed size of 20 items per batch."""
>  
>      _batch_factory = WindowedListBatch
> 
> === modified file 'lib/lp/app/browser/tests/test_views.py'
> --- lib/lp/app/browser/tests/test_views.py	2011-12-28 17:03:06 +0000
> +++ lib/lp/app/browser/tests/test_views.py	2018-03-17 20:34:09 +0000
> @@ -11,7 +11,7 @@
>  
>  from lp.testing.layers import (
>      DatabaseFunctionalLayer,
> -    GoogleLaunchpadFunctionalLayer,
> +    BingLaunchpadFunctionalLayer,

Fixed.

>      )
>  from lp.testing.systemdocs import (
>      LayeredDocFileSuite,
> 
> === modified file 'lib/lp/services/config/schema-lazr.conf'
> --- lib/lp/services/config/schema-lazr.conf	2018-03-16 14:02:16 +0000
> +++ lib/lp/services/config/schema-lazr.conf	2018-03-17 20:34:09 +0000
> @@ -794,6 +794,42 @@
>  # Example: help.launchpad.net login.launchapd.net
>  url_rewrite_exceptions: help.launchpad.net
>  
> +[bing_test_service]
> +# Run a web service stub that simulates the Bing search service.
> +
> +# Where are our canned JSON responses stored?
> +canned_response_directory: lib/lp/services/sitesearch/tests/data/
> +
> +# Which file maps service URLs to the JSON that the server returns?
> +mapfile: lib/lp/services/sitesearch/tests/data/bingsearchservice-mapping.txt
> +
> +# Where should the service log files live?
> +log: logs/bing-stub.log
> +
> +# Do we actually want to run the service?
> +launch: False
> +
> +[bing]
> +# site is the host and path that search requests are made to.
> +# eg. https://api.cognitive.microsoft.com/bingcustomsearch/v7.0/search
> +# datatype: string, a url to a host
> +site: https://api.cognitive.microsoft.com/bingcustomsearch/v7.0/search
> +
> +# subscription_key is the Cognitive Services subscription key for
> +# Bing Custom Search API.
> +# datatype: string
> +subscription_key:
> +
> +# custom_config_id is the id that identifies the custom search instance.
> +# datatype: string
> +custom_config_id:
> +
> +# url_rewrite_exceptions is a list of launchpad.net domains that must
> +# not be rewritten.
> +# datatype: string of space separated domains
> +# Example: help.launchpad.net login.launchapd.net

Fixed.

> +url_rewrite_exceptions: help.launchpad.net
> +
>  [gpghandler]
>  # Should we allow uploading keys to the keyserver?
>  # datatype: boolean
> 
> === modified file 'lib/lp/services/sitesearch/__init__.py'
> --- lib/lp/services/sitesearch/__init__.py	2018-03-16 14:02:16 +0000
> +++ lib/lp/services/sitesearch/__init__.py	2018-03-17 20:34:09 +0000
> @@ -337,3 +340,161 @@
>              raise GoogleWrongGSPVersion(
>                  "Could not get any PageMatches from the GSP XML response.")
>          return PageMatches(page_matches, start, total)
> +
> +
> +@implementer(ISearchService)
> +class BingSearchService:
> +    """See `ISearchService`.
> +
> +    A search service that search Bing for launchpad.net pages.

Fixed.

> +    """
> +
> +    _default_values = {
> +        'customConfig': None,
> +        'mkt': 'en-US',  # FIXME: set language based on the current request?

Done.

> +        'count': 20,
> +        'offset': 0,
> +        'q': None,
> +        }
> +
> +    @property
> +    def subscription_key(self):
> +        """The subscription key issued by Bing Custom Search."""
> +        return config.bing.subscription_key
> +
> +    @property
> +    def custom_config_id(self):
> +        """The custom search instance as configured in Bing Custom Search."""
> +        return config.bing.custom_config_id
> +
> +    @property
> +    def site(self):
> +        """The URL to the Bing Custom Search service.
> +
> +        The URL is probably
> +        https://api.cognitive.microsoft.com/bingcustomsearch/v7.0/search.
> +        """
> +        return config.bing.site
> +
> +    def search(self, terms, start=0):
> +        """See `ISearchService`.
> +
> +        The `subscription_key` and `custom_config_id` are used in the
> +        search request. Search returns 20 or fewer results for each query.
> +        For terms that match more than 20 results, the start param can be
> +        used over multiple queries to get successive sets of results.
> +
> +        :return: `ISearchResults` (PageMatches).
> +        :raise: `BingResponseError` if the json response is incomplete or
> +            cannot be parsed.
> +        """
> +        search_url = self.create_search_url(terms, start=start)
> +        search_headers = self.create_search_headers()
> +        request = get_current_browser_request()
> +        timeline = get_request_timeline(request)
> +        action = timeline.start("bing-search-api", search_url)
> +        try:
> +            response = urlfetch(search_url, headers=search_headers)
> +        except (TimeoutError, requests.RequestException) as error:
> +            raise BingResponseError("The response errored: %s" % str(error))
> +        finally:
> +            action.finish()
> +        page_matches = self._parse_bing_response(response.content, start)
> +        return page_matches
> +
> +    def _checkParameter(self, name, value, is_int=False):
> +        """Check that a parameter value is not None or an empty string."""
> +        if value in (None, ''):
> +            raise AssertionError("Missing value for parameter '%s'." % name)
> +        if is_int:
> +            try:
> +                int(value)
> +            except ValueError:
> +                raise AssertionError(
> +                    "Value for parameter '%s' is not an int." % name)

Fixed. Replaced the AssertionError by ValueError.

> +
> +    def create_search_url(self, terms, start=0):
> +        """Return a Bing Custom Search search url."""
> +        self._checkParameter('q', terms)
> +        self._checkParameter('offset', start, is_int=True)
> +        self._checkParameter('customConfig', self.custom_config_id)
> +        safe_terms = urllib.quote_plus(terms.encode('utf8'))
> +        search_params = dict(self._default_values)
> +        search_params['q'] = safe_terms
> +        search_params['offset'] = start
> +        search_params['customConfig'] = self.custom_config_id
> +        search_param_list = [
> +            '%s=%s' % (name, value)
> +            for name, value in sorted(search_params.items())]
> +        query_string = '&'.join(search_param_list)

Done, both in Bing and Google implementations.

> +        return self.site + '?' + query_string
> +
> +    def create_search_headers(self):
> +        """Return a dict with Bing Custom Search compatible request headers."""
> +        self._checkParameter('subscription_key', self.subscription_key)
> +        return {
> +            'Ocp-Apim-Subscription-Key': self.subscription_key,
> +            }
> +
> +    def _parse_bing_response(self, bing_json, start=0):
> +        """Return a `PageMatches` object.
> +
> +        :param bing_json: A string containing Bing Custom Search API v7 JSON.
> +        :return: `ISearchResults` (PageMatches).
> +        :raise: `BingResponseError` if the json response is incomplete or
> +            cannot be parsed.
> +        """
> +        try:
> +            bing_doc = json.loads(bing_json)
> +        except (TypeError, ValueError):
> +            raise BingResponseError("The response was incomplete, no JSON.")
> +
> +        try:
> +            response_type = bing_doc['_type']
> +        except (AttributeError, KeyError, ValueError):
> +            raise BingResponseError(
> +                "Could not get the '_type' from the Bing JSON response.")
> +
> +        if response_type == 'ErrorResponse':
> +            try:
> +                errors = [error['message'] for error in bing_doc['errors']]
> +                raise BingResponseError(
> +                    "Error response from Bing: %s" % '; '.join(errors))
> +            except (AttributeError, KeyError, TypeError, ValueError):
> +                raise BingResponseError(
> +                    "Unable to parse the Bing JSON error response.")
> +        elif response_type != 'SearchResponse':
> +            raise BingResponseError(
> +                "Unknown Bing JSON response type: '%s'." % response_type)
> +
> +        page_matches = []
> +        total = 0
> +        try:
> +            results = bing_doc['webPages']['value']
> +        except (AttributeError, KeyError, ValueError):
> +            # Bing did not match any pages. Return an empty PageMatches.
> +            return PageMatches(page_matches, start, total)
> +
> +        try:
> +            total = int(bing_doc['webPages']['totalEstimatedMatches'])
> +        except (AttributeError, KeyError, ValueError):
> +            # The datatype is not what PageMatches requires.
> +            raise BingResponseError(
> +                "Could not get the total from the Bing JSON response.")
> +        if total < 0:
> +            # See bug 683115.
> +            total = 0
> +        for result in results:
> +            url = result.get('url')
> +            title = result.get('name')
> +            summary = result.get('snippet')
> +            if None in (url, title, summary):
> +                # There is not enough data to create a PageMatch object.
> +                # This can be caused by an empty title or summary which
> +                # has been observed for pages that are from vhosts that
> +                # should not be indexed.
> +                continue
> +            summary = summary.replace('<br>', '')
> +            page_matches.append(PageMatch(title, url, summary))
> +
> +        return PageMatches(page_matches, start, total)
> 
> === added file 'lib/lp/services/sitesearch/bingtestservice.py'
> --- lib/lp/services/sitesearch/bingtestservice.py	1970-01-01 00:00:00 +0000
> +++ lib/lp/services/sitesearch/bingtestservice.py	2018-03-17 20:34:09 +0000
> @@ -0,0 +1,271 @@
> +#!/usr/bin/python
> +#
> +# Copyright 2018 Canonical Ltd.  This software is licensed under the
> +# GNU Affero General Public License version 3 (see the file LICENSE).
> +
> +"""
> +This script runs a simple HTTP server. The server returns JSON files
> +when given certain user-configurable URLs.
> +"""
> +
> +
> +from BaseHTTPServer import (
> +    BaseHTTPRequestHandler,
> +    HTTPServer,
> +    )

Done.

> +import errno
> +import logging
> +import os
> +import signal
> +import socket
> +import subprocess
> +import time
> +
> +from lp.services.config import config
> +from lp.services.osutils import ensure_directory_exists
> +from lp.services.pidfile import (
> +    get_pid,
> +    make_pidfile,
> +    pidfile_path,
> +    )
> +from lp.services.webapp.url import urlsplit
> +
> +# Set up basic logging.
> +log = logging.getLogger(__name__)
> +
> +# The default service name, used by the Launchpad service framework.
> +service_name = 'bing-webservice'
> +
> +
> +class BingRequestHandler(BaseHTTPRequestHandler):
> +    """Return an JSON file depending on the requested URL."""
> +
> +    default_content_type = 'application/json; charset=utf-8'
> +
> +    def do_GET(self):
> +        """See BaseHTTPRequestHandler in the Python Standard Library."""
> +        urlmap = url_to_json_map()
> +        if self.path in urlmap:
> +            self.return_file(urlmap[self.path])
> +        else:
> +            # Return our default route.
> +            self.return_file(urlmap['*'])
> +
> +    def return_file(self, filename):
> +        """Return a HTTP response with 'filename' for content.
> +
> +        :param filename: The file name to find in the canned-data
> +            storage location.
> +        """
> +        self.send_response(200)
> +        self.send_header('Content-Type', self.default_content_type)
> +        self.end_headers()
> +
> +        content_dir = config.bing_test_service.canned_response_directory
> +        filepath = os.path.join(content_dir, filename)
> +        content_body = file(filepath).read()
> +        self.wfile.write(content_body)
> +
> +    def log_message(self, format, *args):
> +        """See `BaseHTTPRequestHandler.log_message()`."""
> +        # Substitute the base class's logger with the Python Standard
> +        # Library logger.
> +        message = ("%s - - [%s] %s" %
> +                   (self.address_string(),
> +                    self.log_date_time_string(),
> +                    format % args))
> +        log.info(message)
> +
> +
> +def url_to_json_map():
> +    """Return our URL-to-JSON mapping as a dictionary."""
> +    mapfile = config.bing_test_service.mapfile
> +    mapping = {}
> +    for line in file(mapfile):
> +        if line.startswith('#') or len(line.strip()) == 0:
> +            # Skip comments and blank lines.
> +            continue
> +        url, fname = line.split()
> +        mapping[url.strip()] = fname.strip()
> +
> +    return mapping
> +
> +
> +def get_service_endpoint():
> +    """Return the host and port that the service is running on."""
> +    return hostpair(config.bing.site)
> +
> +
> +def service_is_available(timeout=2.0):
> +    """Return True if the service is up and running.
> +
> +    :param timeout: BLOCK execution for at most 'timeout' seconds
> +        before returning False.
> +    """
> +    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
> +    sock.settimeout(timeout)  # Block for 'timeout' seconds.
> +    host, port = get_service_endpoint()
> +    try:
> +        try:
> +            sock.connect((host, port))
> +        except socket.error:
> +            return False
> +        else:
> +            return True
> +    finally:
> +        sock.close()  # Clean up.
> +
> +
> +def wait_for_service(timeout=15.0):
> +    """Poll the service and BLOCK until we can connect to it.
> +
> +    :param timeout: The socket should timeout after this many seconds.
> +        Refer to the socket module documentation in the Standard Library
> +        for possible timeout values.
> +    """
> +    host, port = get_service_endpoint()
> +    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
> +    sock.settimeout(timeout)  # Block for at most X seconds.
> +
> +    start = time.time()  # Record when we started polling.
> +    try:
> +        while True:
> +            try:
> +                sock.connect((host, port))
> +            except socket.error as err:
> +                if err.args[0] in [errno.ECONNREFUSED, errno.ECONNABORTED]:
> +                    elapsed = (time.time() - start)
> +                    if elapsed > timeout:
> +                        raise RuntimeError("Socket poll time exceeded.")
> +                else:
> +                    raise
> +            else:
> +                break
> +            time.sleep(0.1)
> +    finally:
> +        sock.close()  # Clean up.
> +
> +
> +def wait_for_service_shutdown(seconds_to_wait=10.0):
> +    """Poll the service until it shuts down.
> +
> +    Raises a RuntimeError if the service doesn't shut down within the allotted
> +    time, under normal operation.  It may also raise various socket errors if
> +    there are issues connecting to the service (host lookup, etc.)
> +
> +    :param seconds_to_wait: The number of seconds to wait for the socket to
> +        open up.
> +    """
> +    host, port = get_service_endpoint()
> +
> +    start = time.time()  # Record when we started polling.
> +    try:
> +        while True:
> +            sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
> +            sock.settimeout(5.0)  # Block for at most X seconds.
> +            try:
> +                sock.connect((host, port))
> +                sock.close()
> +            except socket.error as err:
> +                if err.args[0] == errno.ECONNREFUSED:
> +                    # Success!  The socket is closed.
> +                    return
> +                else:
> +                    raise
> +            else:
> +                elapsed = (time.time() - start)
> +                if elapsed > seconds_to_wait:
> +                    raise RuntimeError(
> +                        "The service did not shut down in the allotted time.")
> +            time.sleep(0.1)
> +    finally:
> +        sock.close()  # Clean up.
> +
> +
> +def hostpair(url):
> +    """Parse the host and port number out of a URL string."""
> +    parts = urlsplit(url)
> +    host, port = parts[1].split(':')
> +    port = int(port)
> +    return (host, port)
> +
> +
> +def start_as_process():
> +    """Run this file as a stand-alone Python script.
> +
> +    Returns a subprocess.Popen object. (See the `subprocess` module in
> +    the Python Standard Library for details.)
> +    """
> +    script = os.path.join(
> +        os.path.dirname(__file__),
> +        os.pardir, os.pardir, os.pardir, os.pardir, 'bin',
> +        'bingtestservice')
> +    # Make sure we aren't using the parent stdin and stdout to avoid spam
> +    # and have fewer things that can go wrong shutting down the process.
> +    proc = subprocess.Popen(
> +        script, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
> +        stderr=subprocess.STDOUT)
> +    proc.stdin.close()
> +    return proc
> +
> +
> +def kill_running_process():
> +    """Find and kill any running web service processes."""
> +    global service_name
> +    try:
> +        pid = get_pid(service_name)
> +    except IOError:
> +        # We could not find an existing pidfile.
> +        return
> +    except ValueError:
> +        # The file contained a mangled and invalid PID number, so we should
> +        # clean the file up.
> +        safe_unlink(pidfile_path(service_name))
> +    else:
> +        if pid is not None:
> +            try:
> +                os.kill(pid, signal.SIGTERM)
> +                # We need to use a busy-wait to find out when the socket
> +                # becomes available.  Failing to do so causes a race condition
> +                # between freeing the socket in the killed process, and
> +                # opening it in the current one.
> +                wait_for_service_shutdown()
> +            except os.error as err:
> +                if err.errno == errno.ESRCH:
> +                    # Whoops, we got a 'No such process' error. The PID file
> +                    # is probably stale, so we'll remove it to prevent trash
> +                    # from lying around in the test environment.
> +                    # See bug #237086.
> +                    safe_unlink(pidfile_path(service_name))
> +                else:
> +                    raise

Moved the `try: os.kill()` snippet into lp.services.osutils.process_exists() and updated a few places with similar patterns to use it.

> +
> +
> +def safe_unlink(filepath):
> +    """Unlink a file, but don't raise an error if the file is missing."""
> +    try:
> +        os.unlink(filepath)
> +    except os.error as err:
> +        if err.errno != errno.ENOENT:
> +            raise

Done.

> +
> +
> +def main():
> +    """Run the HTTP server."""
> +    # Redirect our service output to a log file.
> +    global log
> +    ensure_directory_exists(os.path.dirname(config.bing_test_service.log))
> +    filelog = logging.FileHandler(config.bing_test_service.log)
> +    log.addHandler(filelog)
> +    log.setLevel(logging.DEBUG)
> +
> +    # To support service shutdown we need to create a PID file that is
> +    # understood by the Launchpad services framework.
> +    global service_name
> +    make_pidfile(service_name)
> +
> +    host, port = get_service_endpoint()
> +    server = HTTPServer((host, port), BingRequestHandler)
> +
> +    log.info("Starting HTTP Bing webservice server on port %s", port)
> +    server.serve_forever()
> 
> === added file 'lib/lp/services/sitesearch/tests/test_bing.py'
> --- lib/lp/services/sitesearch/tests/test_bing.py	1970-01-01 00:00:00 +0000
> +++ lib/lp/services/sitesearch/tests/test_bing.py	2018-03-17 20:34:09 +0000
> @@ -0,0 +1,133 @@
> +# Copyright 2011-2018 Canonical Ltd.  This software is licensed under the
> +# GNU Affero General Public License version 3 (see the file LICENSE).
> +
> +"""Test the bing search service."""
> +
> +__metaclass__ = type
> +
> +from contextlib import contextmanager
> +
> +from requests.exceptions import (
> +    ConnectionError,
> +    HTTPError,
> +    )
> +
> +from lp.services.sitesearch import BingSearchService
> +from lp.services.sitesearch.interfaces import BingResponseError
> +from lp.services.timeout import TimeoutError
> +from lp.testing import TestCase
> +from lp.testing.layers import (
> +    BingLaunchpadFunctionalLayer,
> +    LaunchpadFunctionalLayer,
> +    )
> +
> +
> +@contextmanager
> +def urlfetch_exception(test_error, *args):
> +    """Raise an error during the execution of urlfetch.
> +
> +    This function replaces urlfetch() with a function that
> +    raises an error.
> +    """
> +
> +    def raise_exception(url):
> +        raise test_error(*args)
> +
> +    from lp.services import timeout
> +    original_urlfetch = timeout.urlfetch
> +    timeout.urlfetch = raise_exception
> +    try:
> +        yield
> +    finally:
> +        timeout.urlfetch = original_urlfetch

Done!

> +
> +
> +class TestBingSearchService(TestCase):
> +    """Test GoogleSearchService."""
> +
> +    layer = LaunchpadFunctionalLayer
> +
> +    def setUp(self):
> +        super(TestBingSearchService, self).setUp()
> +        self.search_service = BingSearchService()
> +
> +    def test_search_converts_HTTPError(self):
> +        # The method converts HTTPError to BingResponseError.
> +        args = ('url', 500, 'oops', {}, None)
> +        with urlfetch_exception(HTTPError, *args):
> +            self.assertRaises(
> +                BingResponseError, self.search_service.search, 'fnord')
> +
> +    def test_search_converts_ConnectionError(self):
> +        # The method converts ConnectionError to BingResponseError.
> +        with urlfetch_exception(ConnectionError, 'oops'):
> +            self.assertRaises(
> +                BingResponseError, self.search_service.search, 'fnord')
> +
> +    def test_search_converts_TimeoutError(self):
> +        # The method converts TimeoutError to BingResponseError.
> +        with urlfetch_exception(TimeoutError, 'oops'):
> +            self.assertRaises(
> +                BingResponseError, self.search_service.search, 'fnord')
> +
> +    def test_parse_bing_reponse_TypeError(self):

Fixed.

> +        # The method converts TypeError to BingResponseError.
> +        self.assertRaises(
> +            BingResponseError,
> +            self.search_service._parse_bing_response, None)
> +
> +    def test_parse_bing_reponse_ValueError(self):
> +        # The method converts ValueError to BingResponseError.
> +        self.assertRaises(
> +            BingResponseError,
> +            self.search_service._parse_bing_response, '')
> +
> +    def test_parse_bing_reponse_KeyError(self):
> +        # The method converts KeyError to BingResponseError.
> +        self.assertRaises(
> +            BingResponseError,
> +            self.search_service._parse_bing_response, '{}')
> +
> +
> +class FunctionalTestBingSearchService(TestCase):
> +    """Test GoogleSearchService."""
> +
> +    layer = BingLaunchpadFunctionalLayer
> +
> +    def setUp(self):
> +        super(FunctionalTestBingSearchService, self).setUp()
> +        self.search_service = BingSearchService()
> +
> +    def test_search_with_results(self):
> +        matches = self.search_service.search('bug')
> +        self.assertEqual(0, matches.start)
> +        self.assertEqual(87000, matches.total)
> +        self.assertEqual(20, len(matches))
> +
> +    def test_search_with_results_and_offset(self):
> +        matches = self.search_service.search('bug', start=20)
> +        self.assertEqual(20, matches.start)
> +        self.assertEqual(87000, matches.total)
> +        self.assertEqual(15, len(matches))
> +
> +    def test_search_no_results(self):
> +        matches = self.search_service.search('fnord')
> +        self.assertEqual(0, matches.start)
> +        self.assertEqual(0, matches.total)
> +        self.assertEqual(0, len(matches))
> +
> +    def test_search_no_meaningful_results(self):
> +        matches = self.search_service.search('no-meaningful')
> +        self.assertEqual(0, matches.start)
> +        self.assertEqual(0, matches.total)
> +        self.assertEqual(0, len(matches))
> +
> +    def test_search_incomplete_response(self):
> +        self.assertRaises(
> +            BingResponseError,
> +            self.search_service.search, 'gnomebaker')
> +
> +    def test_search_error_response(self):
> +        self.assertRaises(
> +            BingResponseError,
> +            self.search_service.search, 'errors-please')
> 
> === added file 'lib/lp/services/sitesearch/tests/test_bingservice.py'
> --- lib/lp/services/sitesearch/tests/test_bingservice.py	1970-01-01 00:00:00 +0000
> +++ lib/lp/services/sitesearch/tests/test_bingservice.py	2018-03-17 20:34:09 +0000
> @@ -0,0 +1,53 @@
> +# Copyright 2009-2018 Canonical Ltd.  This software is licensed under the
> +# GNU Affero General Public License version 3 (see the file LICENSE).
> +
> +"""
> +Unit tests for the Bing test service stub.
> +"""
> +
> +__metaclass__ = type
> +
> +
> +import errno
> +import os
> +import unittest
> +
> +from lp.services.pidfile import pidfile_path
> +from lp.services.sitesearch import bingtestservice
> +
> +
> +class TestServiceUtilities(unittest.TestCase):
> +    """Test the service's supporting functions."""
> +
> +    def test_stale_pid_file_cleanup(self):
> +        """The service should be able to clean up invalid PID files."""
> +        bogus_pid = 9999999
> +        self.assertFalse(
> +            process_exists(bogus_pid),
> +            "There is already a process with PID '%d'." % bogus_pid)
> +
> +        # Create a stale/bogus PID file.
> +        filepath = pidfile_path(bingtestservice.service_name)
> +        pidfile = file(filepath, 'w')
> +        pidfile.write(str(bogus_pid))
> +        pidfile.close()

Done.

> +
> +        # The PID clean-up code should silently remove the file and return.
> +        bingtestservice.kill_running_process()
> +        self.assertFalse(
> +            os.path.exists(filepath),
> +            "The PID file '%s' should have been deleted." % filepath)
> +
> +
> +def process_exists(pid):
> +    """Return True if the specified process already exists."""
> +    try:
> +        os.kill(pid, 0)
> +    except os.error as err:
> +        if err.errno == errno.ESRCH:
> +            # All is well - the process doesn't exist.
> +            return False
> +        else:
> +            # We got a strange OSError, which we'll pass upwards.
> +            raise
> +    return True

Done. See: https://code.launchpad.net/~maxiberta/launchpad/deduplicate-process_exists/+merge/342150.



-- 
https://code.launchpad.net/~maxiberta/launchpad/bing-search/+merge/341549
Your team Launchpad code reviewers is subscribed to branch lp:launchpad.


References