diff --git a/leafmap/common.py b/leafmap/common.py index 5214bab563..ee482dcaff 100644 --- a/leafmap/common.py +++ b/leafmap/common.py @@ -3538,21 +3538,26 @@ 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: - os.environ["LOCALTILESERVER_CLIENT_PREFIX"] = kwargs["prefix"] - kwargs.pop("prefix") + if is_studio_lab(): + os.environ["LOCALTILESERVER_CLIENT_PREFIX"] = ( + "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: + prefix = kwargs.pop("prefix") + if prefix is not None: + os.environ["LOCALTILESERVER_CLIENT_PREFIX"] = str(prefix) from localtileserver import TileClient @@ -3583,6 +3588,22 @@ 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 as e: + warnings.warn( + f"Failed to enable jupyter loopback for the local tile client: {e}" + ) + url = client.get_tile_url( indexes=indexes, colormap=colormap, 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")