Repository: geopy/geopy
Pull request #306 (2018-06-26 14:28:21)

Baidu sk support
Supersedes #298


Patch:
diff --git a/geopy/compat.py b/geopy/compat.py
index 6f7594f..431090b 100644
--- a/geopy/compat.py
+++ b/geopy/compat.py
@@ -50,7 +50,7 @@ else:
 
 if py3k:  # pragma: no cover
     from urllib.error import HTTPError
-    from urllib.parse import parse_qs, quote, urlencode, urlparse
+    from urllib.parse import parse_qs, quote, quote_plus, urlencode, urlparse
     from urllib.request import (HTTPBasicAuthHandler, HTTPPasswordMgrWithDefaultRealm,
                                 HTTPSHandler, ProxyHandler, Request, URLError,
                                 build_opener, urlopen)
@@ -72,7 +72,7 @@ if py3k:  # pragma: no cover
         return iter(d.items())
 
 else:  # pragma: no cover
-    from urllib import quote  # noqa
+    from urllib import quote, quote_plus  # noqa
     from urllib import urlencode as original_urlencode
     from urllib2 import (HTTPBasicAuthHandler, HTTPError,  # noqa
                          HTTPPasswordMgrWithDefaultRealm, HTTPSHandler, ProxyHandler,
diff --git a/geopy/geocoders/baidu.py b/geopy/geocoders/baidu.py
index 974f12a..ea5c021 100644
--- a/geopy/geocoders/baidu.py
+++ b/geopy/geocoders/baidu.py
@@ -2,8 +2,11 @@
 :class:`.Baidu` is the Baidu Maps geocoder.
 """
 
-from geopy.compat import urlencode
+import hashlib
+
+from geopy.compat import quote_plus, urlencode
 from geopy.exc import (
+    GeocoderServiceError,
     GeocoderAuthenticationFailure,
     GeocoderQueryError,
     GeocoderQuotaExceeded,
@@ -33,10 +36,11 @@ class Baidu(Geocoder):
             user_agent=None,
             format_string=None,
             ssl_context=DEFAULT_SENTINEL,
+            security_key=None,
     ):
         """
 
-        :param str api_key: The API key required by Baidu Map to perform
+        :param str api_key: The API key (AK) required by Baidu Map to perform
             geocoding requests. API keys are managed through the Baidu APIs
             console (http://lbsyun.baidu.com/apiconsole/key).
 
@@ -67,6 +71,10 @@ class Baidu(Geocoder):
             See :attr:`geopy.geocoders.options.default_ssl_context`.
 
             .. versionadded:: 1.14.0
+
+        :param str security_key: The security key (SK) to calculate
+            the SN parameter in request if authentication setting requires
+            (http://lbsyun.baidu.com/index.php?title=lbscloud/api/appendix).
         """
         super(Baidu, self).__init__(
             format_string=format_string,
@@ -77,7 +85,9 @@ class Baidu(Geocoder):
             ssl_context=ssl_context,
         )
         self.api_key = api_key
-        self.api = '%s://api.map.baidu.com/geocoder/v2/' % self.scheme
+        self.api_path = '/geocoder/v2/'
+        self.api = '%s://api.map.baidu.com%s' % (self.scheme, self.api_path)
+        self.security_key = security_key
 
     @staticmethod
     def _format_components_param(components):
@@ -117,7 +127,8 @@ class Baidu(Geocoder):
             'address': self.format_string % query,
         }
 
-        url = "?".join((self.api, urlencode(params)))
+        url = self._construct_url(params)
+
         logger.debug("%s.geocode: %s", self.__class__.__name__, url)
         return self._parse_json(
             self._call_geocoder(url, timeout=timeout), exactly_one=exactly_one
@@ -152,7 +163,7 @@ class Baidu(Geocoder):
             'location': self._coerce_point_to_string(query),
         }
 
-        url = "?".join((self.api, urlencode(params)))
+        url = self._construct_url(params)
 
         logger.debug("%s.reverse: %s", self.__class__.__name__, url)
         return self._parse_reverse_json(
@@ -165,6 +176,10 @@ class Baidu(Geocoder):
         """
         place = page.get('result')
 
+        if not place:
+            self._check_status(page.get('status'))
+            return None
+
         location = place.get('formatted_address').encode('utf-8')
         latitude = place['location']['lat']
         longitude = place['location']['lng']
@@ -180,7 +195,7 @@ class Baidu(Geocoder):
         Returns location, (latitude, longitude) from JSON feed.
         """
 
-        place = page.get('result', None)
+        place = page.get('result')
 
         if not place:
             self._check_status(page.get('status'))
@@ -205,44 +220,62 @@ class Baidu(Geocoder):
         """
         Validates error statuses.
         """
-        if status == '0':
+        if status == 0:
             # When there are no results, just return.
             return
-        if status == '1':
-            raise GeocoderQueryError(
+        if status == 1:
+            raise GeocoderServiceError(
                 'Internal server error.'
             )
-        elif status == '2':
+        elif status == 2:
             raise GeocoderQueryError(
                 'Invalid request.'
             )
-        elif status == '3':
+        elif status == 3:
             raise GeocoderAuthenticationFailure(
                 'Authentication failure.'
             )
-        elif status == '4':
+        elif status == 4:
             raise GeocoderQuotaExceeded(
                 'Quota validate failure.'
             )
-        elif status == '5':
+        elif status == 5:
             raise GeocoderQueryError(
                 'AK Illegal or Not Exist.'
             )
-        elif status == '101':
-            raise GeocoderQueryError(
-                'Your request was denied.'
+        elif status == 101:
+            raise GeocoderAuthenticationFailure(
+                'No AK'
             )
-        elif status == '102':
-            raise GeocoderQueryError(
-                'IP/SN/SCODE/REFERER Illegal:'
+        elif status == 102:
+            raise GeocoderAuthenticationFailure(
+                'MCODE Error'
             )
-        elif status == '2xx':
-            raise GeocoderQueryError(
-                'Has No Privilleges.'
+        elif status == 200:
+            raise GeocoderAuthenticationFailure(
+                'Invalid AK'
+            )
+        elif status == 211:
+            raise GeocoderAuthenticationFailure(
+                'Invalid SN'
+            )
+        elif 200 <= status < 300:
+            raise GeocoderAuthenticationFailure(
+                'Authentication Failure'
             )
-        elif status == '3xx':
+        elif 300 <= status < 500:
             raise GeocoderQuotaExceeded(
                 'Quota Error.'
             )
         else:
-            raise GeocoderQueryError('Unknown error')
+            raise GeocoderQueryError('Unknown error. Status: %r' % status)
+
+    def _construct_url(self, params):
+        query_string = urlencode(params)
+        if self.security_key is None:
+            return "%s?%s" % (self.api, query_string)
+        else:
+            # http://lbsyun.baidu.com/index.php?title=lbscloud/api/appendix
+            raw = "%s?%s%s" % (self.api_path, query_string, self.security_key)
+            sn = hashlib.md5(quote_plus(raw).encode('utf-8')).hexdigest()
+            return "%s?%s&sn=%s" % (self.api, query_string, sn)
diff --git a/geopy/geocoders/osm.py b/geopy/geocoders/osm.py
index 6c70c60..c6f815a 100644
--- a/geopy/geocoders/osm.py
+++ b/geopy/geocoders/osm.py
@@ -40,6 +40,7 @@ class Nominatim(Geocoder):
             self,
             format_string=None,
             view_box=None,
+            bounded=False,
             country_bias=None,
             timeout=DEFAULT_SENTINEL,
             proxies=DEFAULT_SENTINEL,
@@ -62,6 +63,9 @@ class Nominatim(Geocoder):
 
         :param str country_bias: Bias results to this country.
 
+        :param bool bounded: Restrict the results to only items contained
+            within the bounding view_box.
+
         :param int timeout:
             See :attr:`geopy.geocoders.options.default_timeout`.
 
@@ -101,6 +105,7 @@ class Nominatim(Geocoder):
         )
         self.country_bias = country_bias
         self.view_box = view_box
+        self.bounded = bounded
         self.domain = domain.strip('/')
 
         self.api = "%s://%s/search" % (self.scheme, self.domain)
@@ -214,6 +219,9 @@ class Nominatim(Geocoder):
             else:
                 params['viewbox'] = ','.join(str(coord) for coord in self.view_box)
 
+        if self.bounded:
+            params['bounded'] = 1
+
         if self.country_bias:
             params['countrycodes'] = self.country_bias
 
diff --git a/geopy/geocoders/pickpoint.py b/geopy/geocoders/pickpoint.py
index 8c5dbb8..17b6b0b 100644
--- a/geopy/geocoders/pickpoint.py
+++ b/geopy/geocoders/pickpoint.py
@@ -23,6 +23,7 @@ class PickPoint(Nominatim):
             api_key,
             format_string=None,
             view_box=None,
+            bounded=False,
             country_bias=None,
             timeout=DEFAULT_SENTINEL,
             proxies=DEFAULT_SENTINEL,
@@ -43,6 +44,9 @@ class PickPoint(Nominatim):
 
         :param string country_bias: Bias results to this country.
 
+        :param bool bounded: Restrict the results to only items contained
+            within the bounding view_box.
+
         :param int timeout:
             See :attr:`geopy.geocoders.options.default_timeout`.
 
@@ -69,6 +73,7 @@ class PickPoint(Nominatim):
         super(PickPoint, self).__init__(
             format_string=format_string,
             view_box=view_box,
+            bounded=bounded,
             country_bias=country_bias,
             timeout=timeout,
             proxies=proxies,


Test patch:
diff --git a/test/geocoders/baidu.py b/test/geocoders/baidu.py
index f6c554c..f36d3fa 100644
--- a/test/geocoders/baidu.py
+++ b/test/geocoders/baidu.py
@@ -5,31 +5,23 @@ from geopy.compat import u
 from geopy.point import Point
 from geopy.geocoders import Baidu
 from test.geocoders.util import GeocoderTestBase, env
+from geopy.exc import GeocoderAuthenticationFailure
 
 
 class BaiduTestCaseUnitTest(GeocoderTestBase):
 
-    def test_user_agent_custom(self):
-        geocoder = Baidu(
+    @classmethod
+    def setUpClass(cls):
+        cls.geocoder = Baidu(
             api_key='DUMMYKEY1234',
             user_agent='my_user_agent/1.0'
         )
-        self.assertEqual(geocoder.headers['User-Agent'], 'my_user_agent/1.0')
 
+    def test_user_agent_custom(self):
+        self.assertEqual(self.geocoder.headers['User-Agent'], 'my_user_agent/1.0')
 
-@unittest.skipUnless(
-    bool(env.get('BAIDU_KEY')),
-    "No BAIDU_KEY env variable set"
-)
-class BaiduTestCase(GeocoderTestBase):
 
-    @classmethod
-    def setUpClass(cls):
-        cls.geocoder = Baidu(
-            api_key=env['BAIDU_KEY'],
-            timeout=3,
-        )
-        cls.delta_exact = 0.02
+class BaiduQueriesTestCaseMixin:
 
     def test_basic_address(self):
         """
@@ -55,3 +47,48 @@ class BaiduTestCase(GeocoderTestBase):
             {"query": Point(39.983615544507, 116.32295155093), "exactly_one": False},
             {"latitude": 39.983615544507, "longitude": 116.32295155093},
         )
+
+
+@unittest.skipUnless(
+    bool(env.get('BAIDU_KEY')),
+    "No BAIDU_KEY env variable set"
+)
+class BaiduTestCase(BaiduQueriesTestCaseMixin, GeocoderTestBase):
+
+    @classmethod
+    def setUpClass(cls):
+        cls.geocoder = Baidu(
+            api_key=env['BAIDU_KEY'],
+            timeout=3,
+        )
+
+    def test_invalid_ak(self):
+        self.geocoder = Baidu(api_key='DUMMYKEY1234')
+        with self.assertRaises(GeocoderAuthenticationFailure) as cm:
+            self.geocode_run({"query": u("baidu")}, None)
+        self.assertEqual(str(cm.exception), 'Invalid AK')
+
+
+@unittest.skipUnless(
+    bool(env.get('BAIDU_KEY_REQUIRES_SK')) and bool(env.get('BAIDU_SEC_KEY')),
+    "BAIDU_KEY_REQUIRES_SK and BAIDU_SEC_KEY env variables not set"
+)
+class BaiduSKTestCase(BaiduQueriesTestCaseMixin, GeocoderTestBase):
+
+    @classmethod
+    def setUpClass(cls):
+        cls.geocoder = Baidu(
+            api_key=env['BAIDU_KEY_REQUIRES_SK'],
+            security_key=env['BAIDU_SEC_KEY'],
+            timeout=3,
+        )
+
+    def test_sn_with_peculiar_chars(self):
+        self.geocode_run(
+            {"query": u(
+                "\u5317\u4eac\u5e02\u6d77\u6dc0\u533a"
+                "\u4e2d\u5173\u6751\u5927\u885727\u53f7"
+                " ' & = , ? %"
+            )},
+            {"latitude": 39.983615544507, "longitude": 116.32295155093},
+        )
diff --git a/test/geocoders/nominatim.py b/test/geocoders/nominatim.py
index d9ae8ce..2126fa0 100644
--- a/test/geocoders/nominatim.py
+++ b/test/geocoders/nominatim.py
@@ -186,6 +186,23 @@ class BaseNominatimTestCase(with_metaclass(ABCMeta, object)):
                 {"latitude": 51.5223513, "longitude": -0.1382104}
             )
 
+    def test_bounded(self):
+        bb = ('84.719353', '56.588456', '85.296822', '56.437293')
+        query = u('\u0441\u0442\u0440\u043e\u0438\u0442\u0435\u043b\u044c '
+                  '\u0442\u043e\u043c\u0441\u043a')
+
+        self.geocoder = self.make_geocoder(view_box=bb)
+        self.geocode_run(
+            {"query": query},
+            {"latitude": 56.4129459, "longitude": 84.847831069814},
+        )
+
+        self.geocoder = self.make_geocoder(view_box=bb, bounded=True)
+        self.geocode_run(
+            {"query": query},
+            {"latitude": 56.4803224, "longitude": 85.0060457653324},
+        )
+
 
 class NominatimTestCase(BaseNominatimTestCase, GeocoderTestBase):
 
diff --git a/test/geocoders/util.py b/test/geocoders/util.py
index e3a342e..f8437e5 100644
--- a/test/geocoders/util.py
+++ b/test/geocoders/util.py
@@ -21,6 +21,8 @@ except IOError:
         'LIVESTREETS_AUTH_TOKEN',
         'GEOCODEFARM_KEY',
         'BAIDU_KEY',
+        'BAIDU_KEY_REQUIRES_SK',
+        'BAIDU_SEC_KEY',
         'OPENCAGE_KEY',
         'OPENMAPQUEST_APIKEY',
         'PICKPOINT_KEY',