Repository: mapbox/mapbox-sdk-py

Issue:
Add proximity feature to forward geocoding
https://www.mapbox.com/developers/api/geocoding/#proximity

Discussion:

Patch:
diff --git a/mapbox/__init__.py b/mapbox/__init__.py
index c1889b3..362ae7c 100644
--- a/mapbox/__init__.py
+++ b/mapbox/__init__.py
@@ -9,11 +9,18 @@ from uritemplate import URITemplate
 __version__ = "0.1.0"
 
 
+class InvalidPlaceTypeError(KeyError):
+    pass
+
+
 class Service:
     """Base service class"""
 
     def get_session(self, token=None, env=None):
-        access_token = token or (env or os.environ).get('MapboxAccessToken')
+        access_token = (
+            token or
+            (env or os.environ).get('MapboxAccessToken') or
+            (env or os.environ).get('MAPBOX_ACCESS_TOKEN'))
         session = requests.Session()
         session.params.update(access_token=access_token)
         return session
@@ -27,18 +34,47 @@ class Geocoder(Service):
         self.baseuri = 'https://api.mapbox.com/v4/geocode'
         self.session = self.get_session(access_token)
 
-    def forward(self, address, params=None):
+    def _validate_place_types(self, types):
+        """Validate place types and return a mapping for use in requests"""
+        for pt in types:
+            if pt not in self.place_types:
+                raise InvalidPlaceTypeError(pt)
+        return {'types': ",".join(types)}
+
+    def forward(self, address, types=None, lng=None, lat=None):
         """A forward geocoding request
 
-        See: https://www.mapbox.com/developers/api/geocoding/#forward"""
+        Results may be constrained to those in a sequence of place_types or
+        biased toward a given longitude and latitude.
+
+        See: https://www.mapbox.com/developers/api/geocoding/#forward."""
         uri = URITemplate('%s/{dataset}/{query}.json' % self.baseuri).expand(
             dataset=self.name, query=address)
+        params = {}
+        if types:
+            params.update(self._validate_place_types(types))
+        if lng is not None and lat is not None:
+            params.update(proximity='{0},{1}'.format(lng, lat))
         return self.session.get(uri, params=params)
 
-    def reverse(self, lon, lat, params=None):
+    def reverse(self, lon, lat, types=None):
         """A reverse geocoding request
 
-        See: https://www.mapbox.com/developers/api/geocoding/#reverse"""
+        See: https://www.mapbox.com/developers/api/geocoding/#reverse."""
         uri = URITemplate(self.baseuri + '/{dataset}/{lon},{lat}.json').expand(
             dataset=self.name, lon=str(lon), lat=str(lat))
+        params = {}
+        if types:
+            params.update(self._validate_place_types(types))
         return self.session.get(uri, params=params)
+
+    @property
+    def place_types(self):
+        """A mapping of place type names to descriptions"""
+        return {
+            'address': "A street address with house number. Examples: 1600 Pennsylvania Ave NW, 1051 Market St, Oberbaumstrasse 7.",
+            'country': "Sovereign states and other political entities. Examples: United States, France, China, Russia.",
+            'place': "City, town, village or other municipality relevant to a country's address or postal system. Examples: Cleveland, Saratoga Springs, Berlin, Paris.",
+            'poi': "Places of interest including commercial venues, major landmarks, parks, and other features. Examples: Yosemite National Park, Lake Superior.",
+            'postcode': "Postal code, varies by a country's postal system. Examples: 20009, CR0 3RL.",
+            'region': "First order administrative divisions within a country, usually provinces or states. Examples: California, Ontario, Essonne."}
diff --git a/mapbox/scripts/cli.py b/mapbox/scripts/cli.py
index f1bf3a3..b18509a 100644
--- a/mapbox/scripts/cli.py
+++ b/mapbox/scripts/cli.py
@@ -35,10 +35,11 @@ def main_group(ctx, verbose, quiet, access_token):
 
       $ mbx --access-token MY_TOKEN ...
 
-    or as an environment variable.
+    or as an environment variable named MAPBOX_ACCESS_TOKEN or 
+    MapboxAccessToken.
 
     \b
-      $ export MapboxAccessToken=MY_TOKEN
+      $ export MAPBOX_ACCESS_TOKEN=MY_TOKEN
       $ mbx ...
 
     """
diff --git a/mapbox/scripts/geocoder.py b/mapbox/scripts/geocoder.py
index f8f0b3a..e97f11e 100644
--- a/mapbox/scripts/geocoder.py
+++ b/mapbox/scripts/geocoder.py
@@ -8,7 +8,7 @@ import mapbox
 from mapbox.compat import map
 
 
-class MapboxException(click.ClickException):
+class MapboxCLIException(click.ClickException):
     pass
 
 
@@ -41,18 +41,30 @@ def echo_headers(headers, file=None):
 
 @click.command(short_help="Geocode an address or coordinates.")
 @click.argument('query', default='-', required=False)
-@click.option('--include', '-i', 'include_headers',
-              is_flag=True, default=False,
-              help="Include HTTP headers in the output.")
 @click.option(
     '--forward/--reverse',
     default=True,
     help="Perform a forward or reverse geocode. [default: forward]")
+@click.option('--include', '-i', 'include_headers',
+              is_flag=True, default=False,
+              help="Include HTTP headers in the output.")
+@click.option(
+    '--lat', type=float, default=None,
+    help="Bias results toward this latitude (decimal degrees). --lng "
+         "is also required.")
+@click.option(
+    '--lng', type=float, default=None,
+    help="Bias results toward this longitude (decimal degrees). --lat "
+         "is also required.")
+@click.option(
+    '--place-type', '-t', multiple=True, metavar='NAME', default=None,
+    help="Restrict results to one or more of these place types: {0}.".format(
+        sorted(mapbox.Geocoder().place_types.keys())))
 @click.option('--output', '-o', default='-', help="Save output to a file.")
 @click.pass_context
-def geocode(ctx, query, include_headers, forward, output):
-    """This command gets coordinates for an address (forward mode) or
-    addresses for coordinates (reverse mode).
+def geocode(ctx, query, include_headers, forward, lat, lng, place_type, output):
+    """This command returns places matching an address (forward mode) or
+    places matching coordinates (reverse mode).
 
     In forward (the default) mode the query argument shall be an address
     such as '1600 pennsylvania ave nw'.
@@ -64,6 +76,7 @@ def geocode(ctx, query, include_headers, forward, output):
 
       $ mbx geocode --reverse '[-77.4371, 37.5227]'
 
+    An access token is required, see `mbx --help`.
     """
     verbosity = (ctx.obj and ctx.obj.get('verbosity')) or 2
     logger = logging.getLogger('mapbox')
@@ -75,19 +88,20 @@ def geocode(ctx, query, include_headers, forward, output):
 
     if forward:
         for q in iter_query(query):
-            resp = geocoder.forward(q)
+            resp = geocoder.forward(
+                q, types=place_type, lat=lat, lng=lng)
             if include_headers:
                 echo_headers(resp.headers, file=stdout)
             if resp.status_code == 200:
                 click.echo(resp.text, file=stdout)
             else:
-                raise MapboxException(resp.text.strip())
+                raise MapboxCLIException(resp.text.strip())
     else:
         for coords in map(coords_from_query, iter_query(query)):
-            resp = geocoder.reverse(*coords)
+            resp = geocoder.reverse(*coords, types=place_type)
             if include_headers:
                 echo_headers(resp.headers, file=stdout)
             if resp.status_code == 200:
                 click.echo(resp.text, file=stdout)
             else:
-                raise MapboxException(resp.text.strip())
+                raise MapboxCLIException(resp.text.strip())


Test patch:
diff --git a/tests/test_geocoder.py b/tests/test_geocoder.py
index 84dd846..86ae506 100644
--- a/tests/test_geocoder.py
+++ b/tests/test_geocoder.py
@@ -1,5 +1,6 @@
 import json
 import responses
+
 import mapbox
 
 
@@ -24,6 +25,14 @@ def test_service_session_os_environ(monkeypatch):
     monkeypatch.undo()
 
 
+def test_service_session_os_environ_caps(monkeypatch):
+    """Get a session using os.environ's token"""
+    monkeypatch.setenv('MAPBOX_ACCESS_TOKEN', 'pk.test_os_environ')
+    session = mapbox.Service().get_session()
+    assert session.params.get('access_token') == 'pk.test_os_environ'
+    monkeypatch.undo()
+
+
 def test_geocoder_default_name():
     """Default name is set"""
     geocoder = mapbox.Geocoder()
@@ -69,3 +78,85 @@ def test_geocoder_reverse():
     response = mapbox.Geocoder(access_token='pk.test').reverse(*[str(x) for x in coords])
     assert response.status_code == 200
     assert response.json()['query'] == coords
+
+
+def test_geocoder_place_types():
+    """Place types are enumerated"""
+    assert sorted(mapbox.Geocoder().place_types.items()) == [
+        ('address', "A street address with house number. Examples: 1600 Pennsylvania Ave NW, 1051 Market St, Oberbaumstrasse 7."),
+        ('country', "Sovereign states and other political entities. Examples: United States, France, China, Russia."),
+        ('place', "City, town, village or other municipality relevant to a country's address or postal system. Examples: Cleveland, Saratoga Springs, Berlin, Paris."),
+        ('poi', "Places of interest including commercial venues, major landmarks, parks, and other features. Examples: Yosemite National Park, Lake Superior."),
+        ('postcode', "Postal code, varies by a country's postal system. Examples: 20009, CR0 3RL."),
+        ('region', "First order administrative divisions within a country, usually provinces or states. Examples: California, Ontario, Essonne.")]
+
+
+def test_validate_place_types_err():
+    try:
+        mapbox.Geocoder()._validate_place_types(('address', 'bogus'))
+    except mapbox.InvalidPlaceTypeError as err:
+        assert str(err) == "'bogus'"
+
+
+def test_validate_place_types():
+    assert mapbox.Geocoder()._validate_place_types(
+        ('address', 'poi')) == {'types': 'address,poi'}
+
+
+@responses.activate
+def test_geocoder_forward_types():
+    """Type filtering of forward geocoding works"""
+
+    responses.add(
+        responses.GET,
+        'https://api.mapbox.com/v4/geocode/mapbox.places/1600%20pennsylvania%20ave%20nw.json?types=address,country,place,poi,postcode,region&access_token=pk.test',
+        match_querystring=True,
+        body='{"query": ["1600", "pennsylvania", "ave", "nw"]}', status=200,
+        content_type='application/json')
+
+    response = mapbox.Geocoder(
+        access_token='pk.test').forward(
+            '1600 pennsylvania ave nw',
+            types=('address', 'country', 'place', 'poi', 'postcode', 'region'))
+    assert response.status_code == 200
+    assert response.json()['query'] == ["1600", "pennsylvania", "ave", "nw"]
+
+
+@responses.activate
+def test_geocoder_reverse_types():
+    """Type filtering of reverse geocoding works"""
+
+    coords = [-77.4371, 37.5227]
+
+    responses.add(
+        responses.GET,
+        'https://api.mapbox.com/v4/geocode/mapbox.places/%s.json?types=address,country,place,poi,postcode,region&access_token=pk.test' % ','.join([str(x) for x in coords]),
+        match_querystring=True,
+        body='{"query": %s}' % json.dumps(coords),
+        status=200,
+        content_type='application/json')
+
+    response = mapbox.Geocoder(
+        access_token='pk.test').reverse(
+            *[str(x) for x in coords],
+            types=('address', 'country', 'place', 'poi', 'postcode', 'region'))
+    assert response.status_code == 200
+    assert response.json()['query'] == coords
+
+
+@responses.activate
+def test_geocoder_forward_proximity():
+    """Proximity parameter works"""
+
+    responses.add(
+        responses.GET,
+        'https://api.mapbox.com/v4/geocode/mapbox.places/1600%20pennsylvania%20ave%20nw.json?proximity=0,0&access_token=pk.test',
+        match_querystring=True,
+        body='{"query": ["1600", "pennsylvania", "ave", "nw"]}', status=200,
+        content_type='application/json')
+
+    response = mapbox.Geocoder(
+        access_token='pk.test').forward(
+            '1600 pennsylvania ave nw', lng=0, lat=0)
+    assert response.status_code == 200
+    assert response.json()['query'] == ["1600", "pennsylvania", "ave", "nw"]