Repository: bokeh/bokeh

Issue:
[FEATURE] Transparent use on JupyterHub
### Problem description

While it is possible to write Bokeh code to work successfully behind the JupyterHub proxy,  it has two drawbacks:
   1. Users need to find and learn the work around.
   2. A notebook running on a Hub needs code changes so the same notebook will not run standalone and on a hub.

### Feature description

When the environment variable BOKEH_JUPYTER_EXTERNAL_URL is set,  "show" calls which normally require a notebook_url parameter and helper function automatically implement the workaround and "just work".  Users don't have to learn anything and the same notebook works standalone and on the hub.

### Potential alternatives

None that achieve the two benefits of transparency:  nothing to learn,  notebooks work anywhere.

### Additional information

This is a continuation of discussion https://github.com/bokeh/bokeh/discussions/13402 to enable a PR and further discussion with a concrete basis.



Discussion:

Patch:
diff --git a/src/bokeh/io/notebook.py b/src/bokeh/io/notebook.py
--- a/src/bokeh/io/notebook.py
+++ b/src/bokeh/io/notebook.py
@@ -22,6 +22,8 @@
 
 # Standard library imports
 import json
+import os
+import urllib
 from typing import (
     TYPE_CHECKING,
     Any,
@@ -29,6 +31,7 @@
     Literal,
     Protocol,
     TypedDict,
+    Union,
     cast,
     overload,
 )
@@ -37,6 +40,7 @@
 ## External imports
 if TYPE_CHECKING:
     from ipykernel.comm import Comm
+    from typing_extensions import TypeAlias
 
 # Bokeh imports
 from ..core.types import ID
@@ -71,6 +75,8 @@
 
 EXEC_MIME_TYPE = 'application/vnd.bokehjs_exec.v0+json'
 
+DEFAULT_JUPYTER_URL = "localhost:8888"
+
 __all__ = (
     'CommsHandle',
     'destroy_server',
@@ -162,7 +168,7 @@ class ShowDoc(Protocol):
     def __call__(self, obj: Model, state: State, notebook_handle: CommsHandle) -> CommsHandle: ...
 
 class ShowApp(Protocol):
-    def __call__(self, app: Application, state: State, notebook_url: str, **kw: Any) -> None: ...
+    def __call__(self, app: Application, state: State, notebook_url: str | ProxyUrlFunc, **kw: Any) -> None: ...
 
 class Hooks(TypedDict):
     load: Load
@@ -498,7 +504,16 @@ def publish_display_data(data: dict[str, Any], metadata: dict[Any, Any] | None =
     from IPython.display import publish_display_data
     publish_display_data(data, metadata, transient=transient, **kwargs)
 
-def show_app(app: Application, state: State, notebook_url: str | Callable[[int | None], str], port: int = 0, **kw: Any) -> None:
+
+ProxyUrlFunc: TypeAlias = Callable[[Union[int, None]], str]
+
+def show_app(
+        app: Application,
+        state: State,
+        notebook_url: str | ProxyUrlFunc = DEFAULT_JUPYTER_URL,
+        port: int = 0,
+        **kw: Any,
+) -> None:
     ''' Embed a Bokeh server application in a Jupyter Notebook output cell.
 
     Args:
@@ -519,6 +534,11 @@ def show_app(app: Application, state: State, notebook_url: str | Callable[[int |
             the callable must generate the server URL, otherwise if passed
             None, it must generate the origin URL for the server.
 
+            If the environment variable JUPYTER_BOKEH_EXTERNAL_URL is set
+            to the external URL of a JupyterHub, notebook_url is overridden
+            with a callable which enables Bokeh to traverse the JupyterHub
+            proxy without specifying this parameter.
+
         port (int) :
             A port for the embedded server will listen on.
 
@@ -539,6 +559,8 @@ def show_app(app: Application, state: State, notebook_url: str | Callable[[int |
 
     loop = IOLoop.current()
 
+    notebook_url = _update_notebook_url_from_env(notebook_url)
+
     if callable(notebook_url):
         origin = notebook_url(None)
     else:
@@ -640,6 +662,70 @@ def _server_url(url: str, port: int | None) -> str:
     else:
         return f"http://{url.split(':')[0]}{port_}{'/'}"
 
+
+def _remote_jupyter_proxy_url(port: int | None) -> str:
+    """ Callable to configure Bokeh's show method when a proxy must be
+    configured. If port is None we're asking about the URL
+    for the origin header.
+
+    Taken from documentation here:
+
+       https://docs.bokeh.org/en/latest/docs/user_guide/output/jupyter.html#jupyterhub
+
+    and made an implicit override when JUPYTER_BOKEH_EXTERNAL_URL is defined in
+    a user's environment to the external hostname of the hub, e.g. https://our-hub.edu
+
+    Args:
+       port (int):
+           random port generated by bokeh to avoid re-using recently closed ports
+
+       Returns:
+           str:
+               URL capable of traversing the JupyterHub proxy to return to this notebook session.
+    """
+    base_url = os.environ['JUPYTER_BOKEH_EXTERNAL_URL']
+    host = urllib.parse.urlparse(base_url).netloc
+
+    # If port is None we're asking for the URL origin
+    # so return the public hostname.
+    if port is None:
+        return host
+
+    service_url_path = os.environ['JUPYTERHUB_SERVICE_PREFIX']
+    proxy_url_path = f'proxy/{port}'
+
+    user_url = urllib.parse.urljoin(base_url, service_url_path)
+    full_url = urllib.parse.urljoin(user_url, proxy_url_path)
+    return full_url
+
+
+def _update_notebook_url_from_env(notebook_url: str | ProxyUrlFunc) -> str | ProxyUrlFunc:
+    """If the environment variable ``JUPYTER_BOKEH_EXTERNAL_URL`` is defined, returns a function which
+    generates URLs which can traverse the JupyterHub proxy.  Otherwise returns ``notebook_url`` unmodified.
+
+    A warning is issued if ``notebook_url`` is not the default and
+    ``JUPYTER_BOKEH_EXTERNAL_URL`` is also defined since setting the
+    environment variable makes specifying ``notebook_url`` irrelevant.
+
+    Args:
+       notebook_url (str | ProxyUrlFunc):
+          Either a URL string which defaults or a function that given a port
+          number will generate a URL suitable for traversing the JupyterHub proxy.
+
+    Returns:
+       str | ProxyUrlFunc
+          Either a URL string or a function that generates a URL string given a port number.  The
+          latter function may be user supplied as the input parameter or defined internally by Bokeh
+          when ``JUPYER_BOKEH_EXTERNAL_URL`` is set.
+
+    """
+    if os.environ.get("JUPYTER_BOKEH_EXTERNAL_URL"):
+        if notebook_url != DEFAULT_JUPYTER_URL:
+            log.warning("Environment var 'JUPYTER_BOKEH_EXTERNAL_URL' is defined. Ignoring 'notebook_url' parameter.")
+        return _remote_jupyter_proxy_url
+    else:
+        return notebook_url
+
 #-----------------------------------------------------------------------------
 # Code
 #-----------------------------------------------------------------------------
diff --git a/src/bokeh/io/showing.py b/src/bokeh/io/showing.py
--- a/src/bokeh/io/showing.py
+++ b/src/bokeh/io/showing.py
@@ -26,7 +26,7 @@
 # Bokeh imports
 from ..models.ui import UIElement
 from ..util.browser import NEW_PARAM, get_browser_controller
-from .notebook import run_notebook_hook
+from .notebook import DEFAULT_JUPYTER_URL, ProxyUrlFunc, run_notebook_hook
 from .saving import save
 from .state import curstate
 
@@ -52,7 +52,8 @@
 #-----------------------------------------------------------------------------
 
 def show(obj: UIElement | Application | ModifyDoc, browser: str | None = None, new: BrowserTarget = "tab",
-        notebook_handle: bool = False, notebook_url: str = "localhost:8888", **kwargs: Any) -> CommsHandle | None:
+         notebook_handle: bool = False, notebook_url: str | ProxyUrlFunc = DEFAULT_JUPYTER_URL,
+         **kwargs: Any) -> CommsHandle | None:
     '''Immediately display a Bokeh object or application.
 
     :func:`show` may be called multiple times in a single Jupyter notebook
@@ -115,6 +116,11 @@ def show(obj: UIElement | Application | ModifyDoc, browser: str | None = None, n
             to generate the full public URL to the bokeh server.  If None
             is passed, the function is to generate the origin URL.
 
+            If the environment variable JUPYTER_BOKEH_EXTERNAL_URL is set
+            to the external URL of a JupyterHub, notebook_url is overridden
+            with a callable which enables Bokeh to traverse the JupyterHub
+            proxy without specifying this parameter.
+
     Some parameters are only useful when certain output modes are active:
 
     * The ``browser`` and ``new`` parameters only apply when |output_file|


Test patch:
diff --git a/tests/unit/bokeh/io/test_notebook__io.py b/tests/unit/bokeh/io/test_notebook__io.py
--- a/tests/unit/bokeh/io/test_notebook__io.py
+++ b/tests/unit/bokeh/io/test_notebook__io.py
@@ -18,11 +18,13 @@
 
 # Standard library imports
 import json
+import os
 from typing import Any
 from unittest.mock import MagicMock, PropertyMock, patch
 
 # Bokeh imports
 from bokeh.document.document import Document
+from bokeh.io.notebook import log
 from bokeh.io.state import State
 
 # Module under test
@@ -124,6 +126,83 @@ def test__server_url() -> None:
     assert binb._server_url("http://foo.com:8888", 10) == "http://foo.com:10/"
     assert binb._server_url("https://foo.com:8888", 10) == "https://foo.com:10/"
 
+
+@patch.dict(os.environ, {"JUPYTER_BOKEH_EXTERNAL_URL": "https://our-hub.edu"})
+@patch.dict(os.environ, {"JUPYTERHUB_SERVICE_PREFIX": "/user/homer@donuts.edu/"})
+def test__remote_jupyter_proxy_url_0() -> None:
+    assert binb._remote_jupyter_proxy_url(1234) == "https://our-hub.edu/user/homer@donuts.edu/proxy/1234"
+
+@patch.dict(os.environ, {"JUPYTER_BOKEH_EXTERNAL_URL": "https://our-hub.edu"})
+@patch.dict(os.environ, {"JUPYTERHUB_SERVICE_PREFIX": "/user/homer@donuts.edu/"})
+def test__remote_jupyter_proxy_url_1() -> None:
+    assert binb._remote_jupyter_proxy_url(None) == "our-hub.edu"
+
+
+@patch.dict(os.environ, {"JUPYTER_BOKEH_EXTERNAL_URL": "https://our-hub.edu"})
+@patch.dict(os.environ, {"JUPYTERHUB_SERVICE_PREFIX": "/user/home@donuts.edu/"})
+@patch.object(log, "warning")
+def test__update_notebook_url_from_env_1(mock_warning) -> None:
+    rval = binb._update_notebook_url_from_env("https://our-hub.edu:9999")
+    assert mock_warning.called
+    assert rval == binb._remote_jupyter_proxy_url
+
+@patch.dict(os.environ, {"JUPYTER_BOKEH_EXTERNAL_URL": "https://our-hub.edu"})
+@patch.dict(os.environ, {"JUPYTERHUB_SERVICE_PREFIX": "/user/home@donuts.edu/"})
+@patch.object(log, "warning")
+def test__update_notebook_url_from_env_2(mock_warning) -> None:
+    rval = binb._update_notebook_url_from_env("localhost:8888")
+    assert not mock_warning.called
+    assert rval == binb._remote_jupyter_proxy_url
+
+@patch.dict(os.environ, {"JUPYTER_BOKEH_EXTERNAL_URL": "https://our-hub.edu"})
+@patch.dict(os.environ, {"JUPYTERHUB_SERVICE_PREFIX": "/user/home@donuts.edu/"})
+@patch.object(log, "warning")
+def test__update_notebook_url_from_env_3(mock_warning) -> None:
+    rval = binb._update_notebook_url_from_env(None)
+    assert mock_warning.called
+    assert rval == binb._remote_jupyter_proxy_url
+
+@patch.dict(os.environ, {"JUPYTER_BOKEH_EXTERNAL_URL": "https://our-hub.edu"})
+@patch.dict(os.environ, {"JUPYTERHUB_SERVICE_PREFIX": "/user/home@donuts.edu/"})
+@patch.object(log, "warning")
+def test__update_notebook_url_from_env_4(mock_warning) -> None:
+    def proxy_url_func(int):
+        return "https://some-url.com"
+    rval = binb._update_notebook_url_from_env(proxy_url_func)
+    assert mock_warning.called
+    assert rval == binb._remote_jupyter_proxy_url
+
+
+@patch.dict(os.environ, {"JUPYTERHUB_SERVICE_PREFIX": "/user/home@donuts.edu/"})
+@patch.object(log, "warning")
+def test__update_notebook_url_from_env_5(mock_warning) -> None:
+    rval = binb._update_notebook_url_from_env("https://our-hub.edu:9999")
+    assert not mock_warning.called
+    assert rval == "https://our-hub.edu:9999"
+
+@patch.dict(os.environ, {"JUPYTERHUB_SERVICE_PREFIX": "/user/home@donuts.edu/"})
+@patch.object(log, "warning")
+def test__update_notebook_url_from_env_6(mock_warning) -> None:
+    rval = binb._update_notebook_url_from_env("localhost:8888")
+    assert not mock_warning.called
+    assert rval == "localhost:8888"
+
+@patch.dict(os.environ, {"JUPYTERHUB_SERVICE_PREFIX": "/user/home@donuts.edu/"})
+@patch.object(log, "warning")
+def test__update_notebook_url_from_env_7(mock_warning) -> None:
+    rval = binb._update_notebook_url_from_env(None)
+    assert not mock_warning.called
+    assert rval is None
+
+@patch.dict(os.environ, {"JUPYTERHUB_SERVICE_PREFIX": "/user/home@donuts.edu/"})
+@patch.object(log, "warning")
+def test__update_notebook_url_from_env_8(mock_warning) -> None:
+    def proxy_url_func(int):
+        return "https://some-url.com"
+    rval = binb._update_notebook_url_from_env(proxy_url_func)
+    assert not mock_warning.called
+    assert rval == proxy_url_func
+
 #-----------------------------------------------------------------------------
 # Code
 #-----------------------------------------------------------------------------