From 5747f5e2cda28f65eafeff6efb0e4a01cb856ba0 Mon Sep 17 00:00:00 2001 From: jthetzel Date: Sat, 22 Nov 2025 19:38:34 -0600 Subject: [PATCH 1/7] feat: Pass headers to tile server requests --- contextily/tile.py | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/contextily/tile.py b/contextily/tile.py index 2e64ac31..aeb70039 100644 --- a/contextily/tile.py +++ b/contextily/tile.py @@ -73,6 +73,7 @@ def bounds2raster( path, zoom="auto", source=None, + headers: dict | None = None, ll=False, wait=0, max_retries=2, @@ -106,6 +107,9 @@ def bounds2raster( `rasterio` and all bands are loaded into the basemap. IMPORTANT: tiles are assumed to be in the Spherical Mercator projection (EPSG:3857), unless the `crs` keyword is specified. + headers : dict or None + [Optional. Default: None] + Headers to include with requests to the tile server. ll : Boolean [Optional. Default: False] If True, `w`, `s`, `e`, `n` are assumed to be lon/lat as opposed to Spherical Mercator. @@ -136,6 +140,9 @@ def bounds2raster( extent : tuple Bounding box [minX, maxX, minY, maxY] of the returned image """ + if headers is None: + headers = {} + if not ll: # Convert w, s, e, n into lon/lat w, s = _sm2ll(w, s) @@ -148,6 +155,7 @@ def bounds2raster( n, zoom=zoom, source=source, + headers=headers, ll=True, n_connections=n_connections, use_cache=use_cache, @@ -187,6 +195,7 @@ def bounds2img( n, zoom="auto", source=None, + headers: dict | None = None, ll=False, wait=0, max_retries=2, @@ -219,6 +228,9 @@ def bounds2img( `rasterio` and all bands are loaded into the basemap. IMPORTANT: tiles are assumed to be in the Spherical Mercator projection (EPSG:3857), unless the `crs` keyword is specified. + headers : dict or None + [Optional. Default: None] + Headers to include with requests to the tile server. ll : Boolean [Optional. Default: False] If True, `w`, `s`, `e`, `n` are assumed to be lon/lat as opposed to Spherical Mercator. @@ -253,6 +265,9 @@ def bounds2img( extent : tuple Bounding box [minX, maxX, minY, maxY] of the returned image """ + if headers is None: + headers = {} + if not ll: # Convert w, s, e, n into lon/lat w, s = _sm2ll(w, s) @@ -281,7 +296,7 @@ def bounds2img( ) fetch_tile_fn = memory.cache(_fetch_tile) if use_cache else _fetch_tile arrays = Parallel(n_jobs=n_connections, prefer=preferred_backend)( - delayed(fetch_tile_fn)(tile_url, wait, max_retries) for tile_url in tile_urls + delayed(fetch_tile_fn)(tile_url, wait, max_retries, headers) for tile_url in tile_urls ) # merge downloaded tiles merged, extent = _merge_tiles(tiles, arrays) @@ -309,8 +324,8 @@ def _process_source(source): return provider -def _fetch_tile(tile_url, wait, max_retries): - array = _retryer(tile_url, wait, max_retries) +def _fetch_tile(tile_url, wait, max_retries, headers: dict): + array = _retryer(tile_url, wait, max_retries, headers) return array @@ -428,7 +443,7 @@ def _warper(img, transform, s_crs, t_crs, resampling): return img, bounds, transform -def _retryer(tile_url, wait, max_retries): +def _retryer(tile_url, wait, max_retries, headers): """ Retry a url many times in attempt to get a tile and read the image @@ -443,13 +458,15 @@ def _retryer(tile_url, wait, max_retries): max_retries : int total number of rejected requests allowed before contextily will stop trying to fetch more tiles from a rate-limited API. + headers: dict + headers to include with request. Returns ------- array of the tile """ try: - request = requests.get(tile_url, headers={"user-agent": USER_AGENT}) + request = requests.get(tile_url, headers={"user-agent": USER_AGENT, **headers}) request.raise_for_status() with io.BytesIO(request.content) as image_stream: image = Image.open(image_stream).convert("RGBA") From 8e0088c5386b6d200599a14c9467ba80ba53ce9d Mon Sep 17 00:00:00 2001 From: jthetzel Date: Sat, 22 Nov 2025 19:50:53 -0600 Subject: [PATCH 2/7] test: Test request headers --- tests/test_cx.py | 162 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 162 insertions(+) diff --git a/tests/test_cx.py b/tests/test_cx.py index a58c5a5c..3c118202 100644 --- a/tests/test_cx.py +++ b/tests/test_cx.py @@ -10,6 +10,9 @@ from contextily.tile import _calculate_zoom from numpy.testing import assert_array_almost_equal import pytest +from unittest.mock import patch, MagicMock +import io +from PIL import Image TOL = 7 SEARCH = "boulder" @@ -117,6 +120,165 @@ def test_bounds2img(n_connections): ) +def test_custom_headers(): + """Test that custom headers are properly passed to tile requests.""" + w, s, e, n = ( + -106.6495132446289, + 25.845197677612305, + -93.50721740722656, + 36.49387741088867, + ) + + # Create a mock image to return + img_array = np.random.randint(0, 255, (256, 256, 4), dtype=np.uint8) + img = Image.fromarray(img_array, mode='RGBA') + img_bytes = io.BytesIO() + img.save(img_bytes, format='PNG') + img_bytes.seek(0) + + # Create mock response + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.content = img_bytes.read() + + custom_headers = { + "Authorization": "Bearer test-token-123", + "X-Custom-Header": "test-value" + } + + with patch('contextily.tile.requests.get', return_value=mock_response) as mock_get: + mock_get.return_value.raise_for_status = MagicMock() + + # Test bounds2img with custom headers + # Disable cache to ensure requests.get is actually called + img, ext = cx.bounds2img( + w, s, e, n, + zoom=4, + ll=True, + headers=custom_headers, + use_cache=False, + source=cx.providers.CartoDB.Positron + ) + + # Verify requests.get was called + assert mock_get.called, "requests.get should have been called" + + # Verify that the headers were passed correctly + # The actual call should merge custom headers with the default user-agent + call_args = mock_get.call_args + headers_used = call_args.kwargs.get('headers', call_args[1].get('headers')) + + # Check that custom headers are present + assert "Authorization" in headers_used + assert headers_used["Authorization"] == "Bearer test-token-123" + assert "X-Custom-Header" in headers_used + assert headers_used["X-Custom-Header"] == "test-value" + + # Check that the default user-agent is also present + assert "user-agent" in headers_used + assert headers_used["user-agent"].startswith("contextily-") + + +def test_custom_headers_bounds2raster(tmpdir): + """Test that custom headers work with bounds2raster.""" + w, s, e, n = ( + -106.6495132446289, + 25.845197677612305, + -93.50721740722656, + 36.49387741088867, + ) + + # Create a mock image to return + img_array = np.random.randint(0, 255, (256, 256, 4), dtype=np.uint8) + img = Image.fromarray(img_array, mode='RGBA') + img_bytes = io.BytesIO() + img.save(img_bytes, format='PNG') + img_bytes.seek(0) + + # Create mock response + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.content = img_bytes.read() + + custom_headers = { + "Authorization": "Bearer test-token-456", + } + + output_path = str(tmpdir.join("test_headers.tif")) + + with patch('contextily.tile.requests.get', return_value=mock_response) as mock_get: + mock_get.return_value.raise_for_status = MagicMock() + + # Test bounds2raster with custom headers + # Disable cache to ensure requests.get is actually called + _ = cx.bounds2raster( + w, s, e, n, + output_path, + zoom=4, + ll=True, + headers=custom_headers, + use_cache=False, + source=cx.providers.CartoDB.Positron + ) + + # Verify requests.get was called with correct headers + assert mock_get.called + call_args = mock_get.call_args + headers_used = call_args.kwargs.get('headers', call_args[1].get('headers')) + + assert "Authorization" in headers_used + assert headers_used["Authorization"] == "Bearer test-token-456" + assert "user-agent" in headers_used + + +def test_no_custom_headers(): + """Test that the function works correctly when no custom headers are provided.""" + w, s, e, n = ( + -106.6495132446289, + 25.845197677612305, + -93.50721740722656, + 36.49387741088867, + ) + + # Create a mock image to return + img_array = np.random.randint(0, 255, (256, 256, 4), dtype=np.uint8) + img = Image.fromarray(img_array, mode='RGBA') + img_bytes = io.BytesIO() + img.save(img_bytes, format='PNG') + img_bytes.seek(0) + + # Create mock response + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.content = img_bytes.read() + + with patch('contextily.tile.requests.get', return_value=mock_response) as mock_get: + mock_get.return_value.raise_for_status = MagicMock() + + # Test bounds2img without custom headers (default behavior) + # Disable cache to ensure requests.get is actually called + img, ext = cx.bounds2img( + w, s, e, n, + zoom=4, + ll=True, + use_cache=False, + source=cx.providers.CartoDB.Positron + ) + + # Verify requests.get was called + assert mock_get.called + + # Verify that only the default user-agent header is present + call_args = mock_get.call_args + headers_used = call_args.kwargs.get('headers', call_args[1].get('headers')) + + # Should only have the user-agent header + assert "user-agent" in headers_used + assert headers_used["user-agent"].startswith("contextily-") + # Should not have any custom headers + assert "Authorization" not in headers_used + + @pytest.mark.network def test_warp_tiles(): w, s, e, n = ( From 3da11a16fdfe751cb38d1889feb522195d2818f4 Mon Sep 17 00:00:00 2001 From: jthetzel Date: Sun, 23 Nov 2025 06:34:04 -0600 Subject: [PATCH 3/7] feat: Update place.py to accept headers --- contextily/place.py | 7 +++++++ contextily/plotting.py | 5 +++++ contextily/tile.py | 16 ++++++++-------- 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/contextily/place.py b/contextily/place.py index 3bf01896..f94870aa 100644 --- a/contextily/place.py +++ b/contextily/place.py @@ -45,6 +45,9 @@ class Place(object): `rasterio` and all bands are loaded into the basemap. IMPORTANT: tiles are assumed to be in the Spherical Mercator projection (EPSG:3857), unless the `crs` keyword is specified. + headers : dict[str, str] or None + [Optional. Default: None] + Headers to include with requests to the tile server. geocoder : geopy.geocoders [Optional. Default: geopy.geocoders.Nominatim()] Geocoder method to process `search` @@ -77,12 +80,14 @@ def __init__( path=None, zoom_adjust=None, source=None, + headers: dict[str, str] | None = None, geocoder=gp.geocoders.Nominatim(user_agent=_default_user_agent), ): self.path = path if source is None: source = providers.OpenStreetMap.HOT self.source = source + self.headers = headers self.zoom_adjust = zoom_adjust # Get geocoded values @@ -119,6 +124,8 @@ def _get_map(self): kwargs = {"ll": True} if self.source is not None: kwargs["source"] = self.source + if self.headers is not None: + kwargs["headers"] = self.headers try: if isinstance(self.path, str): diff --git a/contextily/plotting.py b/contextily/plotting.py index ec14d6cd..f313b19a 100644 --- a/contextily/plotting.py +++ b/contextily/plotting.py @@ -19,6 +19,7 @@ def add_basemap( ax, zoom=ZOOM, source=None, + headers=None, interpolation=INTERPOLATION, attribution=None, attribution_size=ATTRIBUTION_SIZE, @@ -50,6 +51,9 @@ def add_basemap( the file is read with `rasterio` and all bands are loaded into the basemap. IMPORTANT: tiles are assumed to be in the Spherical Mercator projection (EPSG:3857), unless the `crs` keyword is specified. + headers : dict or None + [Optional. Default: None] + Headers to include with requests to the tile server. interpolation : str [Optional. Default='bilinear'] Interpolation algorithm to be passed to `imshow`. See `matplotlib.pyplot.imshow` for further details. @@ -138,6 +142,7 @@ def add_basemap( top, zoom=zoom, source=source, + headers=headers, ll=False, zoom_adjust=zoom_adjust, ) diff --git a/contextily/tile.py b/contextily/tile.py index aeb70039..58ff6948 100644 --- a/contextily/tile.py +++ b/contextily/tile.py @@ -73,7 +73,7 @@ def bounds2raster( path, zoom="auto", source=None, - headers: dict | None = None, + headers: dict[str, str] | None = None, ll=False, wait=0, max_retries=2, @@ -107,7 +107,7 @@ def bounds2raster( `rasterio` and all bands are loaded into the basemap. IMPORTANT: tiles are assumed to be in the Spherical Mercator projection (EPSG:3857), unless the `crs` keyword is specified. - headers : dict or None + headers : dict[str, str] or None [Optional. Default: None] Headers to include with requests to the tile server. ll : Boolean @@ -195,7 +195,7 @@ def bounds2img( n, zoom="auto", source=None, - headers: dict | None = None, + headers: dict[str, str] | None = None, ll=False, wait=0, max_retries=2, @@ -228,7 +228,7 @@ def bounds2img( `rasterio` and all bands are loaded into the basemap. IMPORTANT: tiles are assumed to be in the Spherical Mercator projection (EPSG:3857), unless the `crs` keyword is specified. - headers : dict or None + headers : dict[str, str] or None [Optional. Default: None] Headers to include with requests to the tile server. ll : Boolean @@ -324,7 +324,7 @@ def _process_source(source): return provider -def _fetch_tile(tile_url, wait, max_retries, headers: dict): +def _fetch_tile(tile_url, wait, max_retries, headers: dict[str, str]): array = _retryer(tile_url, wait, max_retries, headers) return array @@ -443,7 +443,7 @@ def _warper(img, transform, s_crs, t_crs, resampling): return img, bounds, transform -def _retryer(tile_url, wait, max_retries, headers): +def _retryer(tile_url, wait, max_retries, headers: dict[str, str]): """ Retry a url many times in attempt to get a tile and read the image @@ -458,7 +458,7 @@ def _retryer(tile_url, wait, max_retries, headers): max_retries : int total number of rejected requests allowed before contextily will stop trying to fetch more tiles from a rate-limited API. - headers: dict + headers: dict[str, str] headers to include with request. Returns @@ -485,7 +485,7 @@ def _retryer(tile_url, wait, max_retries, headers): if max_retries > 0: time.sleep(wait) max_retries -= 1 - request = _retryer(tile_url, wait, max_retries) + request = _retryer(tile_url, wait, max_retries, headers) else: raise requests.HTTPError("Connection reset by peer too many times. " f"Last message was: {request.status_code} " From 4cfa350f104c8d6c319229387b5ea973b8eee75d Mon Sep 17 00:00:00 2001 From: jthetzel Date: Sun, 23 Nov 2025 08:25:31 -0600 Subject: [PATCH 4/7] test: Update expected sums --- tests/test_cx.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_cx.py b/tests/test_cx.py index 3c118202..583f8701 100644 --- a/tests/test_cx.py +++ b/tests/test_cx.py @@ -617,8 +617,8 @@ def test_add_basemap_auto_zoom(): 4852834.0517692715, 4891969.810251278, ), - 764077703, - 1031464583, + 764120533, + 1031507413, (1024, 1024, 4), ), # zoom_adjust and expected values where zoom_adjust == -1 From ad65bcb9d92afdefb98df47d2350391807a0a7ca Mon Sep 17 00:00:00 2001 From: jthetzel Date: Sun, 23 Nov 2025 08:34:02 -0600 Subject: [PATCH 5/7] test: Remove pyplot warning > contextily/tests/test_cx.py:806: RuntimeWarning: More than 20 figures have been opened. Figures created through the pyplot interface (`matplotlib.pyplot.figure`) are retained until explicitly closed and may consume too much memory. (To control this warning, see the rcParam `figure.max_open_warning`). Consider using `matplotlib.pyplot.close()`. --- tests/test_cx.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_cx.py b/tests/test_cx.py index 583f8701..969bb5d7 100644 --- a/tests/test_cx.py +++ b/tests/test_cx.py @@ -807,12 +807,14 @@ def test_attribution(): txt = cx.add_attribution(ax, "Test") assert isinstance(txt, matplotlib.text.Text) assert txt.get_text() == "Test" + matplotlib.pyplot.close(fig) # test passthrough font size and kwargs fig, ax = matplotlib.pyplot.subplots(1) txt = cx.add_attribution(ax, "Test", font_size=15, fontfamily="monospace") assert txt.get_size() == 15 assert txt.get_fontfamily() == ["monospace"] + matplotlib.pyplot.close(fig) @pytest.mark.network From e4c2ee5943916624508139d07542e54a5d935b59 Mon Sep 17 00:00:00 2001 From: jthetzel Date: Sun, 23 Nov 2025 08:56:16 -0600 Subject: [PATCH 6/7] test: Increase patch test coverage --- tests/test_cx.py | 148 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 148 insertions(+) diff --git a/tests/test_cx.py b/tests/test_cx.py index 969bb5d7..c297c20c 100644 --- a/tests/test_cx.py +++ b/tests/test_cx.py @@ -279,6 +279,154 @@ def test_no_custom_headers(): assert "Authorization" not in headers_used +def test_place_with_custom_headers(): + """Test that Place class properly passes custom headers through to bounds2img.""" + # Create a mock image to return + img_array = np.random.randint(0, 255, (256, 256, 4), dtype=np.uint8) + img = Image.fromarray(img_array, mode='RGBA') + img_bytes = io.BytesIO() + img.save(img_bytes, format='PNG') + img_bytes.seek(0) + + # Create mock response + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.content = img_bytes.read() + + custom_headers = { + "X-API-Key": "test-api-key-789", + } + + with patch('contextily.tile.requests.get', return_value=mock_response) as mock_get: + mock_get.return_value.raise_for_status = MagicMock() + + # Create a Place with custom headers + loc = cx.Place( + SEARCH, + zoom_adjust=ADJUST, + headers=custom_headers, + ) + + # Verify requests.get was called with correct headers + assert mock_get.called + call_args = mock_get.call_args + headers_used = call_args.kwargs.get('headers', call_args[1].get('headers')) + + assert "X-API-Key" in headers_used + assert headers_used["X-API-Key"] == "test-api-key-789" + assert "user-agent" in headers_used + + +def test_add_basemap_with_custom_headers(): + """Test that add_basemap properly passes custom headers through to bounds2img.""" + # Create a mock image to return + img_array = np.random.randint(0, 255, (256, 256, 4), dtype=np.uint8) + img = Image.fromarray(img_array, mode='RGBA') + img_bytes = io.BytesIO() + img.save(img_bytes, format='PNG') + img_bytes.seek(0) + + # Create mock response + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.content = img_bytes.read() + + custom_headers = { + "X-Custom-Auth": "custom-token", + } + + with patch('contextily.tile.requests.get', return_value=mock_response) as mock_get: + mock_get.return_value.raise_for_status = MagicMock() + + # Create a simple plot and add basemap with custom headers + x1, x2, y1, y2 = [ + -11740727.544603072, + -11701591.786121061, + 4852834.0517692715, + 4891969.810251278, + ] + + fig, ax = matplotlib.pyplot.subplots(1) + ax.set_xlim(x1, x2) + ax.set_ylim(y1, y2) + + cx.add_basemap(ax, zoom=10, headers=custom_headers) + + # Verify requests.get was called with correct headers + assert mock_get.called + call_args = mock_get.call_args + headers_used = call_args.kwargs.get('headers', call_args[1].get('headers')) + + assert "X-Custom-Auth" in headers_used + assert headers_used["X-Custom-Auth"] == "custom-token" + assert "user-agent" in headers_used + + matplotlib.pyplot.close(fig) + + +def test_retryer_error_handling(): + """Test error handling and retry logic in _retryer function.""" + from contextily.tile import _retryer + import requests + + # Test 404 error + mock_response = MagicMock() + mock_response.status_code = 404 + mock_response.raise_for_status.side_effect = requests.HTTPError("404 Not Found") + + with patch('contextily.tile.requests.get', return_value=mock_response): + with pytest.raises(requests.HTTPError) as exc_info: + _retryer("http://example.com/tile.png", wait=0, max_retries=0, headers={}) + + assert "404 error" in str(exc_info.value) + assert "http://example.com/tile.png" in str(exc_info.value) + + # Test retry exhaustion with non-404 error + mock_response = MagicMock() + mock_response.status_code = 503 + mock_response.reason = "Service Unavailable" + mock_response.url = "http://example.com/tile.png" + mock_response.raise_for_status.side_effect = requests.HTTPError("503 Service Unavailable") + + with patch('contextily.tile.requests.get', return_value=mock_response): + with pytest.raises(requests.HTTPError) as exc_info: + _retryer("http://example.com/tile.png", wait=0, max_retries=0, headers={}) + + assert "Connection reset by peer too many times" in str(exc_info.value) + assert "503" in str(exc_info.value) + + +def test_retryer_with_retries(): + """Test that _retryer actually retries when max_retries > 0 and passes headers.""" + from contextily.tile import _retryer + import requests + + # Test that retry logic is executed with proper headers + mock_response = MagicMock() + mock_response.status_code = 503 + mock_response.reason = "Service Unavailable" + mock_response.url = "http://example.com/tile.png" + mock_response.raise_for_status.side_effect = requests.HTTPError("503") + + custom_headers = {"X-API-Key": "test-key"} + + with patch('contextily.tile.requests.get', return_value=mock_response) as mock_get: + with patch('contextily.tile.time.sleep') as mock_sleep: + # Should exhaust retries and raise exception + with pytest.raises(requests.HTTPError) as exc_info: + _retryer("http://example.com/tile.png", wait=1, max_retries=2, headers=custom_headers) + + # Verify sleep was called (indicating retry logic executed) + assert mock_sleep.call_count == 2 + # Verify each call to requests.get included the custom headers + for call in mock_get.call_args_list: + headers_used = call.kwargs.get('headers', call[1].get('headers')) + assert "X-API-Key" in headers_used + assert headers_used["X-API-Key"] == "test-key" + + assert "Connection reset by peer too many times" in str(exc_info.value) + + @pytest.mark.network def test_warp_tiles(): w, s, e, n = ( From 546fdc852bdc4be352a1aa402d0f9e20a213dbe1 Mon Sep 17 00:00:00 2001 From: jthetzel Date: Mon, 24 Nov 2025 09:23:58 -0600 Subject: [PATCH 7/7] test: Test to confirm user provided user-agent overrides --- tests/test_cx.py | 54 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/tests/test_cx.py b/tests/test_cx.py index c297c20c..12183e7a 100644 --- a/tests/test_cx.py +++ b/tests/test_cx.py @@ -279,6 +279,60 @@ def test_no_custom_headers(): assert "Authorization" not in headers_used +def test_custom_user_agent_override(): + """Test that a custom user-agent header overrides the default one.""" + w, s, e, n = ( + -106.6495132446289, + 25.845197677612305, + -93.50721740722656, + 36.49387741088867, + ) + + # Create a mock image to return + img_array = np.random.randint(0, 255, (256, 256, 4), dtype=np.uint8) + img = Image.fromarray(img_array, mode='RGBA') + img_bytes = io.BytesIO() + img.save(img_bytes, format='PNG') + img_bytes.seek(0) + + # Create mock response + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.content = img_bytes.read() + + custom_user_agent = "MyCustomAgent/1.0" + custom_headers = { + "user-agent": custom_user_agent + } + + with patch('contextily.tile.requests.get', return_value=mock_response) as mock_get: + mock_get.return_value.raise_for_status = MagicMock() + + # Test bounds2img with custom user-agent header + # Disable cache to ensure requests.get is actually called + img, ext = cx.bounds2img( + w, s, e, n, + zoom=4, + ll=True, + headers=custom_headers, + use_cache=False, + source=cx.providers.CartoDB.Positron + ) + + # Verify requests.get was called + assert mock_get.called, "requests.get should have been called" + + # Verify that the custom user-agent was used, not the default + call_args = mock_get.call_args + headers_used = call_args.kwargs.get('headers', call_args[1].get('headers')) + + # Check that custom user-agent is present + assert "user-agent" in headers_used + assert headers_used["user-agent"] == custom_user_agent + # Verify it's NOT the default contextily user-agent + assert not headers_used["user-agent"].startswith("contextily-") + + def test_place_with_custom_headers(): """Test that Place class properly passes custom headers through to bounds2img.""" # Create a mock image to return