Repository: geopy/geopy
Pull request #336 (2018-11-22 00:21:17)

Add BANFrance Geocoder
Hi there,
A Geocoder service from the  adresse.data.gouv.fr 
It does not require any Api_Key nore registration.
cheers...
sheub

sheub: Hi @KostyaEsmukov,
Thanks for the review, I did my best to make the corrections on time. Hope it's better now,
cheers,

Sheub

Patch:
diff --git a/README.rst b/README.rst
index 77adcb9..b594a03 100644
--- a/README.rst
+++ b/README.rst
@@ -26,7 +26,7 @@ geopy includes geocoder classes for the `OpenStreetMap Nominatim`_,
 `Bing Maps API`_, `Yandex`_, `IGN France`_, `Geolake`_, `GeoNames`_,
 `Pelias`_, `geocode.earth`_,
 `OpenMapQuest`_, `PickPoint`_, `What3Words`_, `OpenCage`_,
-`SmartyStreets`_, `GeocodeFarm`_, `Here`_ and `MapBox`_ geocoder services.
+`SmartyStreets`_, `GeocodeFarm`_, `Here`_, `MapBox`_ and `BANFrance`_ geocoder services.
 The various geocoder classes are located in `geopy.geocoders`_.
 
 .. _OpenStreetMap Nominatim: https://wiki.openstreetmap.org/wiki/Nominatim
@@ -48,6 +48,7 @@ The various geocoder classes are located in `geopy.geocoders`_.
 .. _GeocodeFarm: https://www.geocodefarm.com/
 .. _Here: https://developer.here.com/documentation/geocoder/
 .. _MapBox: https://www.mapbox.com/api-documentation/#geocoding
+.. _BANFrance: https://adresse.data.gouv.fr/api
 .. _geopy.geocoders: https://github.com/geopy/geopy/tree/master/geopy/geocoders
 
 geopy is tested against CPython (versions 2.7, 3.4, 3.5, 3.6, 3.7), PyPy, and
diff --git a/docs/index.rst b/docs/index.rst
index 510a532..8d16819 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -100,6 +100,14 @@ Baidu
 
    .. automethod:: __init__
 
+BANFrance
+-----
+
+.. autoclass:: geopy.geocoders.BANFrance
+   :members:
+
+   .. automethod:: __init__
+
 Bing
 ----
 
diff --git a/geopy/geocoders/__init__.py b/geopy/geocoders/__init__.py
index c44072c..d53610a 100644
--- a/geopy/geocoders/__init__.py
+++ b/geopy/geocoders/__init__.py
@@ -92,6 +92,7 @@ __all__ = (
     "ArcGIS",
     "AzureMaps",
     "Baidu",
+    "BANFrance",
     "Bing",
     "DataBC",
     "GeocodeEarth",
@@ -119,6 +120,7 @@ from geopy.exc import GeocoderNotFound
 from geopy.geocoders.arcgis import ArcGIS
 from geopy.geocoders.azure import AzureMaps
 from geopy.geocoders.baidu import Baidu
+from geopy.geocoders.banfrance import BANFrance
 from geopy.geocoders.base import options
 from geopy.geocoders.bing import Bing
 from geopy.geocoders.databc import DataBC
@@ -145,6 +147,7 @@ SERVICE_TO_GEOCODER = {
     "arcgis": ArcGIS,
     "azure": AzureMaps,
     "baidu": Baidu,
+    "banfrance": BANFrance,
     "bing": Bing,
     "databc": DataBC,
     "geocodeearth": GeocodeEarth,
diff --git a/geopy/geocoders/banfrance.py b/geopy/geocoders/banfrance.py
new file mode 100644
index 0000000..bc54eec
--- /dev/null
+++ b/geopy/geocoders/banfrance.py
@@ -0,0 +1,175 @@
+from geopy.compat import urlencode
+from geopy.geocoders.base import DEFAULT_SENTINEL, Geocoder
+from geopy.location import Location
+from geopy.util import logger
+
+__all__ = ("BANFrance", )
+
+
+class BANFrance(Geocoder):
+    """Geocoder using the Base Adresse Nationale France API.
+
+    Documentation at:
+        https://adresse.data.gouv.fr/api
+
+    """
+
+    geocode_path = '/search'
+    reverse_path = '/reverse'
+
+    def __init__(
+            self,
+            domain='api-adresse.data.gouv.fr',
+            scheme=None,
+            timeout=DEFAULT_SENTINEL,
+            proxies=DEFAULT_SENTINEL,
+            user_agent=None,
+            format_string=None,
+            ssl_context=DEFAULT_SENTINEL,
+    ):
+        """
+
+        :param str domain: Currently it is ``'api-adresse.data.gouv.fr'``, can
+            be changed for testing purposes.
+
+        :param str scheme:
+            See :attr:`geopy.geocoders.options.default_scheme`.
+
+        :param int timeout:
+            See :attr:`geopy.geocoders.options.default_timeout`.
+
+        :param dict proxies:
+            See :attr:`geopy.geocoders.options.default_proxies`.
+
+        :param str user_agent:
+            See :attr:`geopy.geocoders.options.default_user_agent`.
+
+        :param str format_string:
+            See :attr:`geopy.geocoders.options.default_format_string`.
+
+        :type ssl_context: :class:`ssl.SSLContext`
+        :param ssl_context:
+            See :attr:`geopy.geocoders.options.default_ssl_context`.
+
+        """
+        super(BANFrance, self).__init__(
+            format_string=format_string,
+            scheme=scheme,
+            timeout=timeout,
+            proxies=proxies,
+            user_agent=user_agent,
+            ssl_context=ssl_context,
+        )
+        self.domain = domain.strip('/')
+
+        self.geocode_api = (
+            '%s://%s%s' % (self.scheme, self.domain, self.geocode_path)
+        )
+        self.reverse_api = (
+            '%s://%s%s' % (self.scheme, self.domain, self.reverse_path)
+        )
+
+    def geocode(
+            self,
+            query,
+            limit=5,
+            exactly_one=True,
+            timeout=DEFAULT_SENTINEL,
+    ):
+        """
+        Return a location point by address.
+
+        :param str query: The address or query you wish to geocode.
+
+        :param int limit: Defines the maximum number of items in the
+            response structure. If not provided and there are multiple
+            results the BAN API will return 5 results by default.
+            This will be reset to one if ``exactly_one`` is True.
+
+        :param int timeout: Time, in seconds, to wait for the geocoding service
+            to respond before raising a :class:`geopy.exc.GeocoderTimedOut`
+            exception. Set this only if you wish to override, on this call
+            only, the value set during the geocoder's initialization.
+
+        :param bool exactly_one: Return one result or a list of results, if
+            available.
+
+        :rtype: ``None``, :class:`geopy.location.Location` or a list of them, if
+            ``exactly_one=False``.
+
+        """
+
+        params = {
+            'q': self.format_string % query,
+            'limit': limit
+        }
+
+        url = "?".join((self.geocode_api, urlencode(params)))
+
+        logger.debug("%s.geocode: %s", self.__class__.__name__, url)
+        return self._parse_json(
+            self._call_geocoder(url, timeout=timeout), exactly_one
+        )
+
+    def reverse(
+            self,
+            query,
+            exactly_one=True,
+            timeout=DEFAULT_SENTINEL,
+    ):
+        """
+        Return an address by location point.
+
+        :param query: The coordinates for which you wish to obtain the
+            closest human-readable addresses.
+        :type query: :class:`geopy.point.Point`, list or tuple of ``(latitude,
+            longitude)``, or string as ``"%(latitude)s, %(longitude)s"``.
+
+        :param bool exactly_one: Return one result or a list of results, if
+            available.
+
+        :param int timeout: Time, in seconds, to wait for the geocoding service
+            to respond before raising a :class:`geopy.exc.GeocoderTimedOut`
+            exception. Set this only if you wish to override, on this call
+            only, the value set during the geocoder's initialization.
+
+        :rtype: ``None``, :class:`geopy.location.Location` or a list of them, if
+            ``exactly_one=False``.
+
+        """
+
+        try:
+            lat, lng = self._coerce_point_to_string(query).split(',')
+        except ValueError:
+            raise ValueError("Must be a coordinate pair or Point")
+        params = {
+            'lat': lat,
+            'lng': lng
+        }
+
+        url = "?".join((self.reverse_api, urlencode(params)))
+        logger.debug("%s.reverse: %s", self.__class__.__name__, url)
+        return self._parse_json(
+            self._call_geocoder(url, timeout=timeout), exactly_one
+        )
+
+    @staticmethod
+    def _parse_code(feature):
+        # Parse each resource.
+        latitude = feature.get('geometry', {}).get('coordinates', [])[1]
+        longitude = feature.get('geometry', {}).get('coordinates', [])[0]
+        placename = feature.get('properties', {}).get('label')
+
+        return Location(placename, (latitude, longitude), feature)
+
+    @classmethod
+    def _parse_json(self, response, exactly_one):
+        if response is None or 'features' not in response:
+            return None
+        features = response['features']
+        if not len(features):
+            return None
+        if exactly_one:
+            return self._parse_code(features[0])
+        else:
+            return [self._parse_code(feature) for feature in features]


Test patch:
diff --git a/test/geocoders/banfrance.py b/test/geocoders/banfrance.py
new file mode 100644
index 0000000..31a9cd6
--- /dev/null
+++ b/test/geocoders/banfrance.py
@@ -0,0 +1,48 @@
+from geopy.geocoders import BANFrance
+from test.geocoders.util import GeocoderTestBase
+
+
+class BANFranceTestCase(GeocoderTestBase):
+
+    @classmethod
+    def setUpClass(cls):
+        cls.delta = 0.04
+        cls.geocoder = BANFrance(timeout=10)
+
+    def test_user_agent_custom(self):
+        geocoder = BANFrance(
+            user_agent='my_user_agent/1.0'
+        )
+        self.assertEqual(geocoder.headers['User-Agent'], 'my_user_agent/1.0')
+
+    def test_geocode_with_address(self):
+        """
+        banfrance.geocode Address
+        """
+
+        self.geocode_run(
+            {"query": "Camp des Landes, 41200 VILLEFRANCHE-SUR-CHER"},
+            {"latitude": 47.293048, "longitude": 1.718985,
+             "address": "Le Camp des Landes 41200 Villefranche-sur-Cher"},
+        )
+
+    def test_reverse(self):
+        """
+        banfrance.reverse
+        """
+
+        self.reverse_run(
+            {"query": "48.154587,3.221237", "exactly_one": True},
+            {"latitude": 48.154587, "longitude": 3.221237},
+            {"address": "15 Rue des Fontaines 89100 Collemiers"}
+        )
+
+    def test_limit(self):
+        """
+        banfrance.geocode limit
+        """
+        result = self.geocode_run(
+            {"query": "8 bd du port", "limit": 2, "exactly_one": False},
+            {}
+        )
+        self.assertGreaterEqual(2, len(result))