Repository: mapbox/mapbox-sdk-py

Issue:
Retina with Static Api? 
Hello, it seems I can't choose the retina format for my static generated maps.

If I look in the code:

` if auto:
pth = '/{mapid}/geojson({overlay})/auto/{width}x{height}.{fmt}'`

But if I look at the global documentation, the url scheme is `{width}x{height}@2x.{fmt}`

So the is not the possibility to add retina images here?

Discussion:

Patch:
diff --git a/mapbox/errors.py b/mapbox/errors.py
index 005796a..95d982f 100644
--- a/mapbox/errors.py
+++ b/mapbox/errors.py
@@ -46,14 +46,22 @@ class InvalidParameterError(ValidationError):
 class InvalidFileError(ValidationError):
     pass
 
+
 class InvalidResourceTypeError(ValidationError):
     pass
-        
+
+
 class InvalidPeriodError(ValidationError):
     pass
 
+
 class InvalidUsernameError(ValidationError):
     pass
 
+
 class InvalidId(ValidationError):
     pass
+
+
+class MapboxDeprecationWarning(UserWarning):
+    pass
diff --git a/mapbox/services/static.py b/mapbox/services/static.py
index fe5a9fb..235a238 100644
--- a/mapbox/services/static.py
+++ b/mapbox/services/static.py
@@ -40,7 +40,8 @@ class Static(Service):
         return val
 
     def image(self, mapid, lon=None, lat=None, z=None, features=None,
-              width=600, height=600, image_format='png256', sort_keys=False):
+              width=600, height=600, image_format='png256', sort_keys=False,
+              retina=False):
 
         if lon is not None and lat is not None and z is not None:
             auto = False
@@ -58,8 +59,7 @@ class Static(Service):
             lat=str(lat),
             z=str(z),
             width=str(width),
-            height=str(height),
-            fmt=image_format)
+            height=str(height))
 
         if features:
             collection = normalize_geojson_featurecollection(features)
@@ -69,19 +69,25 @@ class Static(Service):
             self._validate_overlay(values['overlay'])
 
             if auto:
-                pth = '/{mapid}/geojson({overlay})/auto/{width}x{height}.{fmt}'
+                pth = '/{mapid}/geojson({overlay})/auto/{width}x{height}'
             else:
                 pth = ('/{mapid}/geojson({overlay})/{lon},{lat},{z}'
-                       '/{width}x{height}.{fmt}')
+                       '/{width}x{height}')
         else:
             if auto:
                 raise errors.InvalidCoordError(
                     "Must provide features if lat, lon, z are None")
 
             # No overlay
-            pth = '/{mapid}/{lon},{lat},{z}/{width}x{height}.{fmt}'
+            pth = '/{mapid}/{lon},{lat},{z}/{width}x{height}'
 
         uri = URITemplate(self.baseuri + pth).expand(**values)
+
+        # @2x.format handled separately to avoid HTML escaping the ampersand
+        twox = '@2x' if retina else ''
+        full_fmt = '{0}.{1}'.format(twox, image_format)
+        uri += full_fmt
+
         res = self.session.get(uri)
         self.handle_http_error(res)
         return res
diff --git a/mapbox/services/static_style.py b/mapbox/services/static_style.py
index 50eb0b5..b0cef3a 100644
--- a/mapbox/services/static_style.py
+++ b/mapbox/services/static_style.py
@@ -1,4 +1,5 @@
 import json
+import warnings
 
 from uritemplate import URITemplate
 
@@ -61,13 +62,13 @@ class StaticStyle(Service):
             raise errors.ImageSizeError('tile_size must be 256 or 512 pixels')
 
         pth = '/{username}/{style_id}/tiles/{tile_size}/{z}/{x}/{y}'
-        if retina:
-            pth += '@2x'
 
         values = dict(username=username, style_id=style_id,
                       tile_size=tile_size, z=z, x=x, y=y)
 
         uri = URITemplate(self.baseuri + pth).expand(**values)
+        if retina:
+            uri += '@2x'
         res = self.session.get(uri)
         self.handle_http_error(res)
         return res
@@ -80,8 +81,8 @@ class StaticStyle(Service):
         return res
 
     def image(self, username, style_id, lon=None, lat=None, zoom=None, features=None,
-              pitch=0, bearing=0, width=600, height=600, twox=False, sort_keys=False,
-              attribution=None, logo=None, before_layer=None):
+              pitch=0, bearing=0, width=600, height=600, retina=None, sort_keys=False,
+              attribution=None, logo=None, before_layer=None, twox=None):
 
         params = {}
         if attribution is not None:
@@ -91,6 +92,16 @@ class StaticStyle(Service):
         if before_layer is not None:
             params['before_layer'] = before_layer
 
+        # twox as a deprecated alias for retina
+        if retina is None:
+            if twox is not None:
+                warnings.warn('twox is a deprecated alias for retina',
+                              errors.MapboxDeprecationWarning)
+                retina = twox
+        else:
+            if twox is not None:
+                raise errors.ValidationError('Conflicting args; Remove twox and use retina')
+
         if lon is not None and lat is not None and zoom is not None:
             auto = False
             lat = validate_lat(lat)
@@ -112,7 +123,6 @@ class StaticStyle(Service):
             lat=str(lat),
             zoom=str(zoom),
             auto=auto,
-            twox='@2x' if twox else '',
             width=str(width),
             height=str(height))
 
@@ -126,9 +136,9 @@ class StaticStyle(Service):
             pth = '/{username}/{style_id}/static/geojson({overlay})/'
             if auto:
                 # TODO what about {bearing} and {pitch}
-                pth += 'auto/{width}x{height}{twox}'
+                pth += 'auto/{width}x{height}'
             else:
-                pth += '{lon},{lat},{zoom},{bearing},{pitch}/{width}x{height}{twox}'
+                pth += '{lon},{lat},{zoom},{bearing},{pitch}/{width}x{height}'
         else:
             if auto:
                 raise errors.InvalidCoordError(
@@ -136,9 +146,14 @@ class StaticStyle(Service):
 
             # No overlay
             pth = ('/{username}/{style_id}/static/'
-                   '{lon},{lat},{zoom},{bearing},{pitch}/{width}x{height}{twox}')
+                   '{lon},{lat},{zoom},{bearing},{pitch}/{width}x{height}')
 
         uri = URITemplate(self.baseuri + pth).expand(**values)
+
+        # @2x handled separately to avoid HTML escaping the ampersand
+        if retina:
+            uri += '@2x'
+
         res = self.session.get(uri, params=params)
         self.handle_http_error(res)
         return res


Test patch:
diff --git a/tests/test_staticmaps.py b/tests/test_staticmaps.py
index 4e582d4..cb325bf 100644
--- a/tests/test_staticmaps.py
+++ b/tests/test_staticmaps.py
@@ -104,6 +104,7 @@ def test_staticmap_featurestoolarge(points):
     with pytest.raises(mapbox.errors.ValidationError):
         service._validate_overlay(json.dumps(points * 100))
 
+
 def test_staticmap_imagesize():
     service = mapbox.Static(access_token='pk.test')
     with pytest.raises(mapbox.errors.ValidationError):
@@ -111,14 +112,32 @@ def test_staticmap_imagesize():
     with pytest.raises(mapbox.errors.ValidationError):
         service._validate_image_size(2000)
 
+
 def test_latlon():
     service = mapbox.Static(access_token='pk.test')
     assert -179.0 == service._validate_lon(-179.0)
     assert -85.0 == service._validate_lat(-85.0)
 
+
 def test_lon_invalid():
     service = mapbox.Static(access_token='pk.test')
     with pytest.raises(mapbox.errors.ValidationError):
         service._validate_lat(-86.0)
     with pytest.raises(mapbox.errors.ValidationError):
         service._validate_lon(-181.0)
+
+
+@responses.activate
+def test_staticmap_retina():
+
+    responses.add(
+        responses.GET,
+        'https://api.mapbox.com/v4/mapbox.satellite/-61.7,12.1,12/600x600@2x.png256?access_token=pk.test',
+        match_querystring=True,
+        body='png123',
+        status=200,
+        content_type='image/png')
+
+    res = mapbox.Static(access_token='pk.test').image(
+        'mapbox.satellite', -61.7, 12.1, 12, retina=True)
+    assert res.status_code == 200
diff --git a/tests/test_staticstyle.py b/tests/test_staticstyle.py
index 1d89344..6cf9941 100644
--- a/tests/test_staticstyle.py
+++ b/tests/test_staticstyle.py
@@ -184,7 +184,7 @@ def test_bad_tilesize():
 
 
 @responses.activate
-def test_staticmap_tile():
+def test_staticmap_tile_retina():
 
     responses.add(
         responses.GET,
@@ -212,3 +212,44 @@ def test_staticmap_wmts():
 
     res = mapbox.StaticStyle(access_token='pk.test').wmts('mapbox', 'streets-v9')
     assert res.status_code == 200
+
+
+@responses.activate
+def test_staticmap_retina():
+
+    responses.add(
+        responses.GET,
+        'https://api.mapbox.com/styles/v1/mapbox/streets-v9/static/-61.7,12.1,12.5,75,25/600x600@2x?access_token=pk.test',
+        match_querystring=True,
+        body='png123',
+        status=200,
+        content_type='image/png')
+
+    res = mapbox.StaticStyle(access_token='pk.test').image(
+        'mapbox', 'streets-v9', -61.7, 12.1, 12.5, pitch=25,
+        bearing=75, retina=True)
+    assert res.status_code == 200
+
+
+@responses.activate
+def test_staticmap_twox_deprecated():
+    responses.add(
+        responses.GET,
+        'https://api.mapbox.com/styles/v1/mapbox/streets-v9/static/-61.7,12.1,12.5,75,25/600x600@2x?access_token=pk.test',
+        match_querystring=True,
+        body='png123',
+        status=200,
+        content_type='image/png')
+
+    with pytest.warns(mapbox.errors.MapboxDeprecationWarning):
+        res = mapbox.StaticStyle(access_token='pk.test').image(
+            'mapbox', 'streets-v9', -61.7, 12.1, 12.5, pitch=25,
+            bearing=75, twox=True)
+    assert res.status_code == 200
+
+
+def test_staticmap_twox_deprecated_error():
+    with pytest.raises(mapbox.errors.ValidationError):
+        mapbox.StaticStyle(access_token='pk.test').image(
+            'mapbox', 'streets-v9', -61.7, 12.1, 12.5, pitch=25,
+            bearing=75, retina=True, twox=True)