Repository: SciTools/cartopy
Pull request #2269 (2023-10-12 01:42:59)

ENH: Add Stadia Maps image tile server class
The Stamen tiles are now being served by Stadia Maps. Stadia Maps requires an API key and has different naming conventions for their tiles, so create a new class to interact with the new server. Replaces #2266

xref: https://stamen.com/stamen-x-stadia-the-end-of-the-road-for-stamens-legacy-map-tiles/

Apparently, we don't have any tests for this... I did make an API key and test locally with the examples and those did seem to work.

cc @lgolston @ianthetechie

lgolston: Thanks @greglucas! This will be a nice enhancement switching things over to Stadia. It looks good to me, except I think the desired_tile_form input should be kept?
greglucas: > Thanks @greglucas! This will be a nice enhancement switching things over to Stadia. It looks good to me, except I think the desired_tile_form input should be kept?

Yeah, I wasn't entirely sure what the `desired_tile_form` was used for and how it was chosen for the various tile styles. I don't see any mention of anything like RGB / RGBA on the Stadia or Stamen website, so I figured we would just leave it out for now and if a user wants a specific form we can add it later? Most of the other classes don't have the desired_tile_form either which is why I dropped it.
lgolston: > > Thanks @greglucas! This will be a nice enhancement switching things over to Stadia. It looks good to me, except I think the desired_tile_form input should be kept?
> 
> Yeah, I wasn't entirely sure what the `desired_tile_form` was used for and how it was chosen for the various tile styles. I don't see any mention of anything like RGB / RGBA on the Stadia or Stamen website, so I figured we would just leave it out for now and if a user wants a specific form we can add it later? Most of the other classes don't have the desired_tile_form either which is why I dropped it.

I don't see it explicitly mentioned but plotting, for instance, stamen_toner_labels without having desired_tile_form gives a warning from `PIL\Image.py:992: UserWarning: Palette images with Transparency expressed in bytes should be converted to RGBA images`. It seems a necessary if anyone is using the sub-styles that are just overlaying lines/labels and not a full image

Patch:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 326ac1d1..6f707a3d 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -9,14 +9,6 @@ on:
   release:
     types:
       - published
-  # Also allow running this action on PRs if requested by applying the
-  # "Run cibuildwheel" label.
-  pull_request:
-    types:
-      - opened
-      - synchronize
-      - reopened
-      - labeled
 
 jobs:
   build_sdist:
@@ -57,17 +49,6 @@ jobs:
           if-no-files-found: error
 
   generate-wheels-matrix:
-    if: |
-      github.event_name == 'release' ||
-      (github.event_name == 'pull_request' && (
-        (
-          github.event.action == 'labeled' &&
-          github.event.label.name == 'CI: build wheels'
-        ) ||
-        contains(github.event.pull_request.labels.*.name,
-                'CI: build wheels')
-        )
-      )
     name: Generate wheels matrix
     runs-on: ubuntu-latest
     outputs:
@@ -75,7 +56,7 @@ jobs:
     steps:
       - uses: actions/checkout@v4
       - name: Install cibuildwheel
-        run: pipx install cibuildwheel==2.16.2
+        run: pipx install cibuildwheel==2.14.1
       - id: set-matrix
         run: |
           MATRIX=$(
@@ -90,9 +71,9 @@ jobs:
           )
           echo "include=$MATRIX" >> $GITHUB_OUTPUT
         env:
-          CIBW_BUILD: "cp39-* cp310-* cp311-* cp312-*"
-          # Skip 32 bit builds and musllinux due to lack of numpy wheels
-          CIBW_SKIP: "*-win32 *_i686 *-musllinux*"
+          CIBW_BUILD: "cp39-* cp310-* cp311-*"
+          # Skip 32 bit windows builds and musllinux due to lack of numpy wheels
+          CIBW_SKIP: "*-win32 *-musllinux*"
           CIBW_ARCHS_MACOS: x86_64 arm64
 
   build_wheels:
@@ -113,7 +94,7 @@ jobs:
           name: sdist
           path: dist
 
-      - uses: pypa/cibuildwheel@fff9ec32ed25a9c576750c91e06b410ed0c15db7 # v2.16.2
+      - uses: pypa/cibuildwheel@a873dd9cbf9e3c4c73a1fd11ac31cf835f6eb502 # v2.16.0
         with:
           only: ${{ matrix.only }}
           package-dir: dist/${{ needs.build_sdist.outputs.SDIST_NAME }}
diff --git a/docs/source/reference/io.rst b/docs/source/reference/io.rst
index 1eebec5d..72b69b75 100644
--- a/docs/source/reference/io.rst
+++ b/docs/source/reference/io.rst
@@ -63,6 +63,7 @@ automatically load the proper tile and resolution depending on the desired domai
     MapboxTiles
     OrdnanceSurvey
     QuadtreeTiles
+    StadiaMapsTiles
     Stamen
 
 Open Geospatial Consortium (OGC)
diff --git a/lib/cartopy/io/img_tiles.py b/lib/cartopy/io/img_tiles.py
index 76b939d6..9e82edd3 100644
--- a/lib/cartopy/io/img_tiles.py
+++ b/lib/cartopy/io/img_tiles.py
@@ -320,6 +320,66 @@ class OSM(GoogleWTS):
         return f'https://a.tile.openstreetmap.org/{z}/{x}/{y}.png'
 
 
+class StadiaMapsTiles(GoogleWTS):
+    """
+    Retrieves tiles from stadiamaps.com.
+
+    For a full reference on the styles available please see
+    https://docs.stadiamaps.com/themes/. A few of the specific styles
+    that are made available are ``alidade_smooth``, ``stamen_terrain`` and
+    ``osm_bright``.
+
+    Using the Stadia Maps API requires including an attribution. Please see
+    https://docs.stadiamaps.com/attribution/ for details.
+
+    For most styles that means including the following attribution:
+
+    `© Stadia Maps <https://www.stadiamaps.com/>`_
+    `© OpenMapTiles <https://openmaptiles.org/>`_
+    `© OpenStreetMap contributors <https://www.openstreetmap.org/about/>`_
+
+    with Stamen styles *additionally* requiring the following attribution:
+
+    `© Stamen Design <https://stamen.com/>`_
+
+    Parameters
+    ----------
+    apikey : str, required
+        The authentication key provided by Stadia Maps to query their APIs
+    style : str, optional
+        Name of the desired style. Defaults to ``alidade_smooth``.
+        See https://docs.stadiamaps.com/themes/ for a full list of styles.
+    resolution : str, optional
+        Resolution of the images to return. Defaults to an empty string,
+        standard resolution (256x256). You can also specify "@2x" for high
+        resolution (512x512) tiles.
+    cache : bool or str, optional
+        If True, the default cache directory is used. If False, no cache is
+        used. If a string, the string is used as the path to the cache.
+    """
+
+    def __init__(self,
+                 apikey,
+                 style="alidade_smooth",
+                 resolution="",
+                 cache=False):
+        super().__init__(cache=cache, desired_tile_form="RGBA")
+        self.apikey = apikey
+        self.style = style
+        self.resolution = resolution
+        if style == "stamen_watercolor":
+            # Known style that has the jpg extension
+            self.extension = "jpg"
+        else:
+            self.extension = "png"
+
+    def _image_url(self, tile):
+        x, y, z = tile
+        return ("http://tiles.stadiamaps.com/tiles/"
+                f"{self.style}/{z}/{x}/{y}{self.resolution}.{self.extension}"
+                f"?api_key={self.apikey}")
+
+
 class Stamen(GoogleWTS):
     """
     Retrieves tiles from maps.stamen.com. Styles include
@@ -351,6 +411,9 @@ class Stamen(GoogleWTS):
 
     def __init__(self, style='toner',
                  desired_tile_form=None, cache=False):
+        warnings.warn("The Stamen styles are no longer served by Stamen and "
+                      "are now served by Stadia Maps. Please use the "
+                      "StadiaMapsTiles class instead.")
 
         # preset layer configuration
         layer_config = {
diff --git a/lib/cartopy/trace.pyx b/lib/cartopy/trace.pyx
index acbd9e6b..d4550b47 100644
--- a/lib/cartopy/trace.pyx
+++ b/lib/cartopy/trace.pyx
@@ -582,10 +582,7 @@ def project_linear(geometry not None, src_crs not None,
     cdef bool geom_fully_inside = False
     if isinstance(dest_projection, (ccrs._RectangularProjection, ccrs._WarpedRectangularProjection)):
         dest_line = sgeom.LineString([(x[0], x[1]) for x in dest_coords])
-        if dest_line.is_valid:
-            # We can only check for covers with valid geometries
-            # some have nans/infs at this point still
-            geom_fully_inside = gp_domain.covers(dest_line)
+        geom_fully_inside = gp_domain.covers(dest_line)
 
     lines = LineAccumulator()
     for src_idx in range(1, src_size):
diff --git a/pyproject.toml b/pyproject.toml
index 0faffca8..f2ffe48a 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -33,7 +33,6 @@ classifiers = [
     'Programming Language :: Python :: 3.9',
     'Programming Language :: Python :: 3.10',
     'Programming Language :: Python :: 3.11',
-    'Programming Language :: Python :: 3.12',
     'Programming Language :: Python :: 3 :: Only',
     'Topic :: Scientific/Engineering',
     'Topic :: Scientific/Engineering :: GIS',


Test patch:
diff --git a/.github/workflows/ci-testing.yml b/.github/workflows/ci-testing.yml
index 86cac340..82140eae 100644
--- a/.github/workflows/ci-testing.yml
+++ b/.github/workflows/ci-testing.yml
@@ -11,7 +11,7 @@ jobs:
       fail-fast: false
       matrix:
         os: [ubuntu-latest, macos-latest, windows-latest]
-        python-version: [3.9, '3.10', '3.11', '3.12']
+        python-version: [3.9, '3.10', '3.11']
         shapely-dev: [false]
         include:
           - os: ubuntu-latest
diff --git a/lib/cartopy/tests/test_img_tiles.py b/lib/cartopy/tests/test_img_tiles.py
index a7d4f0cd..792a7be7 100644
--- a/lib/cartopy/tests/test_img_tiles.py
+++ b/lib/cartopy/tests/test_img_tiles.py
@@ -214,6 +214,22 @@ def test_mapbox_style_tiles_api_url():
     assert url_str == exp_url
 
 
+@pytest.mark.parametrize("style,extension,resolution", [
+    ("alidade_smooth", "png", ""),
+    ("alidade_smooth", "png", "@2x"),
+    ("stamen_watercolor", "jpg", "")])
+def test_stadia_maps_tiles_api_url(style, extension, resolution):
+    apikey = 'foo'
+    tile = [0, 1, 2]
+    exp_url = ('http://tiles.stadiamaps.com/tiles/'
+               f'{style}/2/0/1{resolution}.{extension}'
+               '?api_key=foo')
+
+    sample = cimgt.StadiaMapsTiles(apikey, style=style, resolution=resolution)
+    url_str = sample._image_url(tile)
+    assert url_str == exp_url
+
+
 def test_ordnance_survey_tile_styles():
     """
     Tests that setting the Ordnance Survey tile style works as expected.
diff --git a/lib/cartopy/tests/test_line_string.py b/lib/cartopy/tests/test_line_string.py
index 338cbc0f..f1cc0c83 100644
--- a/lib/cartopy/tests/test_line_string.py
+++ b/lib/cartopy/tests/test_line_string.py
@@ -194,15 +194,6 @@ class TestBisect:
                 assert not any(np.isnan(coord)), \
                     'Unexpected NaN in projected coords.'
 
-    def test_nan_rectangular(self):
-        # Make sure rectangular projections can handle invalid geometries
-        projection = ccrs.Robinson()
-        line_string = sgeom.LineString([(0, 0), (1, 1), (np.nan, np.nan),
-                                        (2, 2), (3, 3)])
-        multi_line_string = projection.project_geometry(line_string,
-                                                        ccrs.PlateCarree())
-        assert len(multi_line_string.geoms) == 2
-
 
 class TestMisc:
     def test_misc(self):