From d9641bb026f9261c2e65705f22481e85d1752101 Mon Sep 17 00:00:00 2001 From: Qiusheng Wu Date: Sat, 4 Jul 2026 16:16:53 -0400 Subject: [PATCH 1/2] fix: route local tile URLs through jupyter-loopback in get_local_tile_url maplibregl add_raster uses get_local_tile_url, which builds a bare TileClient and never enables localtileserver's jupyter-loopback bridge. As a result, tiles are served from http://127.0.0.1:/ and fail to render in containerized/remote and webview-based Jupyter frontends (VS Code, Colab, Solara, marimo), unlike the ipyleaflet/folium path which enables it automatically via get_leaflet_tile_layer. Call client.enable_jupyter_loopback() when available (guarded for older localtileserver versions), and align the LOCALTILESERVER_CLIENT_PREFIX handling with get_local_tile_layer so a user-set prefix is respected and the prefix kwarg has final precedence. Fixes #1331 --- leafmap/common.py | 42 ++++++++++++++++++++++++++++++------------ 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/leafmap/common.py b/leafmap/common.py index 5214bab563..049a00aae4 100644 --- a/leafmap/common.py +++ b/leafmap/common.py @@ -3538,19 +3538,23 @@ def get_local_tile_url( for key in client_args: kwargs[key] = client_args[key] - # Make it compatible with binder and JupyterHub - if os.environ.get("JUPYTERHUB_SERVICE_PREFIX") is not None: - os.environ["LOCALTILESERVER_CLIENT_PREFIX"] = ( - f"{os.environ['JUPYTERHUB_SERVICE_PREFIX'].lstrip('/')}/proxy/{{port}}" - ) + # Only override LOCALTILESERVER_CLIENT_PREFIX if it's unset + if os.environ.get("LOCALTILESERVER_CLIENT_PREFIX") is None: + # Make it compatible with binder and JupyterHub + if os.environ.get("JUPYTERHUB_SERVICE_PREFIX") is not None: + os.environ["LOCALTILESERVER_CLIENT_PREFIX"] = ( + f"{os.environ['JUPYTERHUB_SERVICE_PREFIX'].strip('/')}/proxy/{{port}}" + ) - if is_studio_lab(): - os.environ["LOCALTILESERVER_CLIENT_PREFIX"] = ( - f"studiolab/default/jupyter/proxy/{{port}}" - ) - elif is_on_aws(): - os.environ["LOCALTILESERVER_CLIENT_PREFIX"] = "proxy/{port}" - elif "prefix" in kwargs: + if is_studio_lab(): + os.environ["LOCALTILESERVER_CLIENT_PREFIX"] = ( + f"studiolab/default/jupyter/proxy/{{port}}" + ) + elif is_on_aws(): + os.environ["LOCALTILESERVER_CLIENT_PREFIX"] = "proxy/{port}" + + # The prefix kwarg has final precedence + if "prefix" in kwargs: os.environ["LOCALTILESERVER_CLIENT_PREFIX"] = kwargs["prefix"] kwargs.pop("prefix") @@ -3583,6 +3587,20 @@ def get_local_tile_url( colormap = colormap.lower() client = TileClient(source, port=port, **client_args) + + # Route tile URLs through the jupyter-loopback comm bridge so that rasters + # render in webview-based frontends (VS Code, Colab, Solara, marimo, etc.) + # and in containerized/remote Jupyter environments where the browser cannot + # reach the jupyter-server origin directly. The `get_leaflet_tile_layer` and + # `get_folium_tile_layer` helpers used by `get_local_tile_layer` do this + # automatically, but a bare `TileClient` does not. See + # https://github.com/opengeos/leafmap/issues/1331 + if hasattr(client, "enable_jupyter_loopback"): + try: + client.enable_jupyter_loopback() + except Exception: + pass + url = client.get_tile_url( indexes=indexes, colormap=colormap, From 8589a4ddea7c25f6aa601d8560dc83dc6721f707 Mon Sep 17 00:00:00 2001 From: Qiusheng Wu Date: Sat, 4 Jul 2026 16:28:45 -0400 Subject: [PATCH 2/2] Address Copilot and CodeRabbit review feedback - get_local_tile_url: warn instead of silently swallowing exceptions from enable_jupyter_loopback so genuine comm-bridge failures leave a diagnostic trail (Copilot + CodeRabbit). - get_local_tile_url: guard the prefix kwarg against None/non-string values so writing LOCALTILESERVER_CLIENT_PREFIX can't raise a TypeError, and only apply it when not None (Copilot). - get_local_tile_url: drop the extraneous f-prefix on the Studio Lab prefix template while keeping the literal {port} placeholder (CodeRabbit / Ruff F541). - tests: add unit tests for get_local_tile_url covering jupyter-loopback invocation (present and absent) and prefix precedence / None handling (Copilot). --- leafmap/common.py | 13 +++++++----- tests/test_common.py | 49 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 5 deletions(-) diff --git a/leafmap/common.py b/leafmap/common.py index 049a00aae4..ee482dcaff 100644 --- a/leafmap/common.py +++ b/leafmap/common.py @@ -3548,15 +3548,16 @@ def get_local_tile_url( if is_studio_lab(): os.environ["LOCALTILESERVER_CLIENT_PREFIX"] = ( - f"studiolab/default/jupyter/proxy/{{port}}" + "studiolab/default/jupyter/proxy/{port}" ) elif is_on_aws(): os.environ["LOCALTILESERVER_CLIENT_PREFIX"] = "proxy/{port}" # The prefix kwarg has final precedence if "prefix" in kwargs: - os.environ["LOCALTILESERVER_CLIENT_PREFIX"] = kwargs["prefix"] - kwargs.pop("prefix") + prefix = kwargs.pop("prefix") + if prefix is not None: + os.environ["LOCALTILESERVER_CLIENT_PREFIX"] = str(prefix) from localtileserver import TileClient @@ -3598,8 +3599,10 @@ def get_local_tile_url( if hasattr(client, "enable_jupyter_loopback"): try: client.enable_jupyter_loopback() - except Exception: - pass + except Exception as e: + warnings.warn( + f"Failed to enable jupyter loopback for the local tile client: {e}" + ) url = client.get_tile_url( indexes=indexes, diff --git a/tests/test_common.py b/tests/test_common.py index 82d8c2d4ca..5e4c7617be 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -76,6 +76,55 @@ def test_set_proxy_successful_request(self, mock_get): self.assertEqual(os.environ["HTTPS_PROXY"], "http://192.168.0.1:8080") mock_get.assert_called_once_with("https://google.com") + def _mock_localtileserver(self): + """Return a fake ``localtileserver`` module with a mocked TileClient.""" + fake_client = MagicMock() + fake_client.get_tile_url.return_value = ( + "http://127.0.0.1:0/api/tiles/{z}/{x}/{y}.png" + ) + fake_module = MagicMock() + fake_module.TileClient.return_value = fake_client + return fake_module, fake_client + + @patch.dict(os.environ, {}, clear=True) + def test_get_local_tile_url_enables_jupyter_loopback(self): + """enable_jupyter_loopback is invoked when the client supports it.""" + fake_module, fake_client = self._mock_localtileserver() + with patch.dict("sys.modules", {"localtileserver": fake_module}): + url = get_local_tile_url("test.tif") + fake_client.enable_jupyter_loopback.assert_called_once() + self.assertEqual(url, "http://127.0.0.1:0/api/tiles/{z}/{x}/{y}.png") + + @patch.dict(os.environ, {}, clear=True) + def test_get_local_tile_url_without_loopback_support(self): + """A client lacking enable_jupyter_loopback must not raise.""" + fake_client = MagicMock(spec=["get_tile_url"]) + fake_client.get_tile_url.return_value = "http://tile" + fake_module = MagicMock() + fake_module.TileClient.return_value = fake_client + with patch.dict("sys.modules", {"localtileserver": fake_module}): + url = get_local_tile_url("test.tif") + self.assertFalse(hasattr(fake_client, "enable_jupyter_loopback")) + self.assertEqual(url, "http://tile") + + @patch.dict(os.environ, {}, clear=True) + def test_get_local_tile_url_prefix_precedence(self): + """The prefix kwarg takes final precedence over the env var.""" + fake_module, _ = self._mock_localtileserver() + with patch.dict("sys.modules", {"localtileserver": fake_module}): + get_local_tile_url("test.tif", prefix="proxy/{port}") + self.assertEqual(os.environ["LOCALTILESERVER_CLIENT_PREFIX"], "proxy/{port}") + + @patch.dict( + os.environ, {"LOCALTILESERVER_CLIENT_PREFIX": "keep/{port}"}, clear=True + ) + def test_get_local_tile_url_prefix_none_ignored(self): + """prefix=None is ignored and does not overwrite an existing env var.""" + fake_module, _ = self._mock_localtileserver() + with patch.dict("sys.modules", {"localtileserver": fake_module}): + get_local_tile_url("test.tif", prefix=None) + self.assertEqual(os.environ["LOCALTILESERVER_CLIENT_PREFIX"], "keep/{port}") + # def test_pmtile_metadata_validates_pmtiles_suffix(self): # with self.assertRaises(ValueError) as cm: # pmtiles_metadata("/some/path/to/pmtiles.pmtiles")