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. _______________________________________________ Mailing list: https://launchpad.net/~launchpad-reviewers Post to : [email protected] Unsubscribe : https://launchpad.net/~launchpad-reviewers More help : https://help.launchpad.net/ListHelp

