From a962dd6e08f089ce7b623727c39140830c781dfe Mon Sep 17 00:00:00 2001 From: Danglewood <85772166+deeleeramone@users.noreply.github.com> Date: Mon, 4 May 2026 19:18:59 -0700 Subject: [PATCH] fix notebook detection chain --- pywry/build_assets.py | 2 +- pywry/hatch_build.py | 1 - pywry/pywry/app.py | 54 +++- pywry/pywry/notebook.py | 102 ++++--- pywry/tests/test_notebook_detection.py | 386 +++++++++++++++++++++++++ 5 files changed, 495 insertions(+), 50 deletions(-) create mode 100644 pywry/tests/test_notebook_detection.py diff --git a/pywry/build_assets.py b/pywry/build_assets.py index 153209a..bec0d90 100644 --- a/pywry/build_assets.py +++ b/pywry/build_assets.py @@ -43,7 +43,7 @@ def _asset_manifest() -> dict[str, str]: data = json.loads(PACKAGE_JSON_PATH.read_text(encoding="utf-8")) dependencies = data.get("dependencies") if not isinstance(dependencies, dict): - raise ValueError("package.json must contain an object 'dependencies' field") + raise TypeError("package.json must contain an object 'dependencies' field") missing = [ package diff --git a/pywry/hatch_build.py b/pywry/hatch_build.py index 87bcb0b..83d6bcb 100644 --- a/pywry/hatch_build.py +++ b/pywry/hatch_build.py @@ -8,7 +8,6 @@ which is required since we bundle native binaries. """ - from __future__ import annotations import os diff --git a/pywry/pywry/app.py b/pywry/pywry/app.py index 4b2537e..1ae5bbc 100644 --- a/pywry/pywry/app.py +++ b/pywry/pywry/app.py @@ -28,7 +28,7 @@ WindowConfig, WindowMode, ) -from .notebook import should_use_inline_rendering +from .notebook import is_headless_environment, should_use_inline_rendering from .runtime import refresh_window as runtime_refresh_window from .state_mixins import GridStateMixin, PlotlyStateMixin, ToolbarStateMixin from .templates import build_html, build_plotly_init_script @@ -127,6 +127,24 @@ def __init__( # Initialize the appropriate window mode self._mode: WindowModeBase = self._create_mode(mode) + # Auto-fallback for environments that can't show a native window: + # if the user requested NEW/SINGLE/MULTI_WINDOW, no notebook UI is + # available to render anywidgets into (Jupyter/Colab/etc.), and the + # host has no display (e.g. a Linux VM, container, or SSH session + # without X11), promote silently to BROWSER mode. The user gets a + # FastAPI server + URL instead of a crashing pytauri subprocess. + if ( + mode in (WindowMode.NEW_WINDOW, WindowMode.SINGLE_WINDOW, WindowMode.MULTI_WINDOW) + and not should_use_inline_rendering() + and is_headless_environment() + ): + info( + f"No display detected and not in a notebook — falling back from " + f"{mode.value} to BROWSER mode (server + URL)." + ) + self._mode_enum = WindowMode.BROWSER + self._mode = BrowserMode() + # Asset loader for CSS/JS files self._asset_loader = AssetLoader() @@ -177,9 +195,28 @@ def _create_mode(self, mode: WindowMode) -> WindowModeBase: return SingleWindowMode() if mode == WindowMode.BROWSER: return BrowserMode() + # NOTEBOOK is handled by _use_inline() upstream — show*() short-circuits + # to an inline widget before this WindowModeBase is consulted. # MULTI_WINDOW return MultiWindowMode() + def _use_inline(self) -> bool: + """Return True if ``show*()`` should produce an inline widget. + + Inline rendering is selected when: + + * the user explicitly chose ``WindowMode.NOTEBOOK`` (overrides + environment auto-detection — useful in environments PyWry can't + recognise on its own); + * the user chose ``WindowMode.BROWSER`` (FastAPI server + system browser); + * the environment is auto-detected as a notebook (Colab, Jupyter, etc.). + """ + return ( + self._mode_enum == WindowMode.NOTEBOOK + or isinstance(self._mode, BrowserMode) + or should_use_inline_rendering() + ) + def _register_inline_widget(self, widget: Any) -> None: """Register an inline widget so app.emit() can route events to it. @@ -724,8 +761,9 @@ def show( # noqa: C901, PLR0912, PLR0915 # Check if we're in BROWSER mode - use inline server but open in system browser is_browser_mode = isinstance(self._mode, BrowserMode) - # Check if we're in a notebook environment OR explicit BROWSER mode - if should_use_inline_rendering() or is_browser_mode: + # Inline rendering covers: explicit NOTEBOOK mode, BROWSER mode, and + # auto-detected notebook environments (Colab, Jupyter, VSCode, etc.). + if self._use_inline(): # Convert HtmlContent to string if needed, preserving inline_css if isinstance(content, HtmlContent): html_str = content.html @@ -1022,8 +1060,8 @@ def show_plotly( # noqa: C901, PLR0912 # Check if we're in BROWSER mode - use inline server but open in system browser is_browser_mode = isinstance(self._mode, BrowserMode) - # Check if we're in a notebook environment OR explicit BROWSER mode - if should_use_inline_rendering() or is_browser_mode: + # Inline path covers explicit NOTEBOOK / BROWSER mode and auto-detected notebooks. + if self._use_inline(): from . import inline as pywry_inline # Map specific callbacks to generic dict for inline @@ -1179,8 +1217,8 @@ def show_dataframe( # Check if we're in BROWSER mode - use inline server but open in system browser is_browser_mode = isinstance(self._mode, BrowserMode) - # Check if we're in a notebook environment OR explicit BROWSER mode - if should_use_inline_rendering() or is_browser_mode: + # Inline path covers explicit NOTEBOOK / BROWSER mode and auto-detected notebooks. + if self._use_inline(): from . import inline as pywry_inline # Map specific callbacks to generic dict for inline @@ -1572,7 +1610,7 @@ def show_tvchart( # bundled in the ESM. The generic inline.show() path does not include # tvchart assets, so the chart would never initialise. is_browser_mode = isinstance(self._mode, BrowserMode) - if should_use_inline_rendering() or is_browser_mode: + if self._use_inline(): from . import inline as pywry_inline plain_callbacks: dict[str, Any] | None = None diff --git a/pywry/pywry/notebook.py b/pywry/pywry/notebook.py index bab7d52..b1f7368 100644 --- a/pywry/pywry/notebook.py +++ b/pywry/pywry/notebook.py @@ -37,20 +37,20 @@ class NotebookEnvironment(Enum): ("_check_colab", NotebookEnvironment.COLAB), ("_check_kaggle", NotebookEnvironment.KAGGLE), ("_check_azure", NotebookEnvironment.AZURE), + ("_check_databricks", NotebookEnvironment.DATABRICKS), + ("_check_cocalc", NotebookEnvironment.COCALC), ("_check_vscode", NotebookEnvironment.VSCODE), ("_check_nteract", NotebookEnvironment.NTERACT), - ("_check_cocalc", NotebookEnvironment.COCALC), - ("_check_databricks", NotebookEnvironment.DATABRICKS), ("_check_remote_jupyter", NotebookEnvironment.REMOTE_JUPYTER), ("_check_jupyterlab", NotebookEnvironment.JUPYTERLAB), ] def _check_colab() -> bool: + if "COLAB_RELEASE_TAG" in os.environ or "COLAB_NOTEBOOK_ID" in os.environ: + return True try: - import google.colab as _colab - - del _colab + import google.colab # noqa: F401 except ImportError: return False return True @@ -88,6 +88,44 @@ def _check_jupyterlab() -> bool: return "JUPYTERLAB_WORKSPACES_DIR" in os.environ +def is_headless_environment() -> bool: + """Return True if the host has no usable graphical display. + + Used to auto-fall-back from native-window modes to ``BROWSER`` mode (a + FastAPI server with a URL) when the pytauri webview cannot render — e.g. + a Linux container, an SSH session without X11 forwarding, or a CI runner. + + Detection rules: + + * **Linux / *BSD**: headless iff neither ``DISPLAY`` (X11) nor + ``WAYLAND_DISPLAY`` is set. + * **macOS / Windows**: assume a display is always available. macOS GUI + processes always have access to the Window Server; Windows always has a + desktop session except on rare Server Core installs. + + Distinct from ``runtime.is_headless()``, which is the ``PYWRY_HEADLESS`` + test/CI flag that creates real (but invisible) windows. + """ + import sys + + if sys.platform in ("darwin", "win32"): + return False + return not (os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")) + + +_CHECKERS: dict[str, Any] = { + "_check_colab": _check_colab, + "_check_kaggle": _check_kaggle, + "_check_azure": _check_azure, + "_check_vscode": _check_vscode, + "_check_nteract": _check_nteract, + "_check_cocalc": _check_cocalc, + "_check_databricks": _check_databricks, + "_check_remote_jupyter": _check_remote_jupyter, + "_check_jupyterlab": _check_jupyterlab, +} + + @lru_cache(maxsize=1) def detect_notebook_environment() -> NotebookEnvironment: """Detect the current notebook environment. @@ -105,6 +143,8 @@ def detect_notebook_environment() -> NotebookEnvironment: def _detect_environment_impl() -> NotebookEnvironment: """Implementation of environment detection (avoids too many returns).""" + # IPython must actually be running — a plain Python script is never a + # notebook even if env vars like VSCODE_PID happen to be set. try: from IPython import get_ipython except ImportError: @@ -114,46 +154,28 @@ def _detect_environment_impl() -> NotebookEnvironment: if ipython is None: return NotebookEnvironment.NONE - # Check shell class name - shell_class = ipython.__class__.__name__ - - # Terminal IPython - NOT inline rendering - if shell_class == "TerminalInteractiveShell": + # Terminal IPython renders to the console, not inline. + if ipython.__class__.__name__ == "TerminalInteractiveShell": return NotebookEnvironment.IPYTHON_TERMINAL - # Must be ZMQInteractiveShell (or similar) for notebook - if shell_class != "ZMQInteractiveShell": - return NotebookEnvironment.NONE - - # Check if kernel is from ipykernel (real notebook kernel) - kernel_module = type(ipython).__module__ - if not kernel_module.startswith("ipykernel"): - return NotebookEnvironment.NONE - - # Check specific environments and return first match - return _check_specific_environment() - - -def _check_specific_environment() -> NotebookEnvironment: - """Check for specific notebook environments.""" - checkers = { - "_check_colab": _check_colab, - "_check_kaggle": _check_kaggle, - "_check_azure": _check_azure, - "_check_vscode": _check_vscode, - "_check_nteract": _check_nteract, - "_check_cocalc": _check_cocalc, - "_check_databricks": _check_databricks, - "_check_remote_jupyter": _check_remote_jupyter, - "_check_jupyterlab": _check_jupyterlab, - } - + # Deterministic env-var / import checkers catch managed notebooks + # (Colab, Kaggle, Azure, Databricks, CoCalc, JupyterHub, VSCode, + # nteract) whose IPython shell may not be a vanilla ZMQInteractiveShell. for check_name, env in _ENV_CHECKS: - if checkers[check_name](): + if _CHECKERS[check_name](): return env - # Default to generic Jupyter notebook - return NotebookEnvironment.JUPYTER_NOTEBOOK + # Plain Jupyter: accept ZMQInteractiveShell or any subclass — strict + # leaf-class string match was the original Colab regression. + try: + from ipykernel.zmqshell import ZMQInteractiveShell + + if isinstance(ipython, ZMQInteractiveShell): + return NotebookEnvironment.JUPYTER_NOTEBOOK + except ImportError: + pass + + return NotebookEnvironment.NONE def should_use_inline_rendering() -> bool: diff --git a/pywry/tests/test_notebook_detection.py b/pywry/tests/test_notebook_detection.py new file mode 100644 index 0000000..2e3db28 --- /dev/null +++ b/pywry/tests/test_notebook_detection.py @@ -0,0 +1,386 @@ +"""Tests for notebook environment detection and the WindowMode.NOTEBOOK override. + +Covers two related fixes: + +* Cloud-managed notebooks (Colab, Kaggle, Azure, Databricks, …) are detected + via env-var / import signals even when their IPython shell is not a vanilla + ``ZMQInteractiveShell`` from the ``ipykernel`` module. +* ``WindowMode.NOTEBOOK`` is honoured as an explicit override in + ``PyWry.show*()``, regardless of what auto-detection returns. +""" + +from __future__ import annotations + +import sys +import types + +from unittest.mock import MagicMock, patch + +import pytest + +from pywry.config import clear_settings +from pywry.models import WindowMode +from pywry.notebook import ( + NotebookEnvironment, + clear_environment_cache, + detect_notebook_environment, + should_use_inline_rendering, +) + + +@pytest.fixture(autouse=True) +def _reset_detection_cache(): + clear_environment_cache() + clear_settings() + yield + clear_environment_cache() + clear_settings() + + +def _make_fake_ipython(class_name: str, module: str = "ipykernel.zmqshell") -> object: + """Return an instance of a synthetic class with the given ``__name__`` / ``__module__``.""" + fake_class = type(class_name, (), {}) + fake_class.__module__ = module + return fake_class() + + +def _make_zmq_shell_instance() -> object: + """Return an instance of ZMQInteractiveShell (or skip if ipykernel missing).""" + try: + from ipykernel.zmqshell import ZMQInteractiveShell + except ImportError: + pytest.skip("ipykernel not installed") + + # ZMQInteractiveShell.__init__ does heavy setup (kernel session, etc.). + # Bypass it — we only need an instance whose isinstance() and class + # attributes match. A subclass with a no-op __init__ is enough. + class _FakeShell(ZMQInteractiveShell): # type: ignore[misc, valid-type] + def __init__(self) -> None: # noqa: D401 - intentional no-op + pass + + return _FakeShell() + + +# --------------------------------------------------------------------------- +# Cloud-managed environments +# --------------------------------------------------------------------------- + + +class TestColabDetection: + """Colab is detected via env vars even when its shell is google.colab._shell.Shell.""" + + def test_colab_detected_via_release_tag_env_var(self, monkeypatch): + # Simulate a real Colab kernel: subclass of ZMQInteractiveShell, but in the + # google.colab._shell module — this used to misfire on the strict checks. + try: + from ipykernel.zmqshell import ZMQInteractiveShell + except ImportError: + pytest.skip("ipykernel not installed") + + class _ColabShell(ZMQInteractiveShell): # type: ignore[misc, valid-type] + def __init__(self) -> None: + pass + + _ColabShell.__module__ = "google.colab._shell" + fake_shell = _ColabShell() + + monkeypatch.setenv("COLAB_RELEASE_TAG", "release-colab-foo") + with patch("IPython.get_ipython", return_value=fake_shell): + assert detect_notebook_environment() == NotebookEnvironment.COLAB + assert should_use_inline_rendering() is True + + def test_colab_detected_via_notebook_id_env_var(self, monkeypatch): + monkeypatch.delenv("COLAB_RELEASE_TAG", raising=False) + monkeypatch.setenv("COLAB_NOTEBOOK_ID", "1abc") + fake_shell = _make_zmq_shell_instance() + with patch("IPython.get_ipython", return_value=fake_shell): + assert detect_notebook_environment() == NotebookEnvironment.COLAB + + def test_colab_detected_via_google_colab_import(self, monkeypatch): + monkeypatch.delenv("COLAB_RELEASE_TAG", raising=False) + monkeypatch.delenv("COLAB_NOTEBOOK_ID", raising=False) + + # Inject a fake `google.colab` module so the import-fallback branch fires. + google_pkg = types.ModuleType("google") + google_pkg.__path__ = [] # type: ignore[attr-defined] + colab_mod = types.ModuleType("google.colab") + monkeypatch.setitem(sys.modules, "google", google_pkg) + monkeypatch.setitem(sys.modules, "google.colab", colab_mod) + + fake_shell = _make_zmq_shell_instance() + with patch("IPython.get_ipython", return_value=fake_shell): + assert detect_notebook_environment() == NotebookEnvironment.COLAB + + +class TestEnvVarManagedNotebooks: + """Other env-var-driven environments still work after the reorder.""" + + def _patch_zmq_shell(self): + return patch("IPython.get_ipython", return_value=_make_zmq_shell_instance()) + + @staticmethod + def _clear_managed_env(monkeypatch): + for var in ( + "COLAB_RELEASE_TAG", + "COLAB_NOTEBOOK_ID", + "AZURE_NOTEBOOKS_HOST", + "VSCODE_PID", + "NTERACT_EXE", + "COCALC_PROJECT_ID", + "DATABRICKS_RUNTIME_VERSION", + "JUPYTER_SERVER_ROOT", + "JUPYTERHUB_USER", + "JUPYTERLAB_WORKSPACES_DIR", + ): + monkeypatch.delenv(var, raising=False) + + def test_kaggle_detected(self, monkeypatch): + self._clear_managed_env(monkeypatch) + # _check_kaggle uses Path("/kaggle/input").exists(). We can't write there + # in tests, so patch the entry in _CHECKERS directly. + with ( + patch.dict( + "pywry.notebook._CHECKERS", + {"_check_kaggle": lambda: True}, + ), + self._patch_zmq_shell(), + ): + assert detect_notebook_environment() == NotebookEnvironment.KAGGLE + + def test_databricks_detected(self, monkeypatch): + self._clear_managed_env(monkeypatch) + monkeypatch.setenv("DATABRICKS_RUNTIME_VERSION", "13.3") + with self._patch_zmq_shell(): + assert detect_notebook_environment() == NotebookEnvironment.DATABRICKS + + +# --------------------------------------------------------------------------- +# Plain Jupyter / terminal / no-IPython baselines +# --------------------------------------------------------------------------- + + +class TestNonManagedEnvironments: + """Detection still distinguishes plain Jupyter, terminal IPython, and scripts.""" + + def test_terminal_ipython_returns_terminal(self): + fake = _make_fake_ipython( + "TerminalInteractiveShell", module="IPython.terminal.interactiveshell" + ) + with patch("IPython.get_ipython", return_value=fake): + assert detect_notebook_environment() == NotebookEnvironment.IPYTHON_TERMINAL + assert should_use_inline_rendering() is False + + def test_plain_jupyter_returns_jupyter_notebook(self, monkeypatch): + # Clear any cloud-env vars that the test runner may have set. + for var in ( + "COLAB_RELEASE_TAG", + "COLAB_NOTEBOOK_ID", + "AZURE_NOTEBOOKS_HOST", + "VSCODE_PID", + "NTERACT_EXE", + "COCALC_PROJECT_ID", + "DATABRICKS_RUNTIME_VERSION", + "JUPYTER_SERVER_ROOT", + "JUPYTERHUB_USER", + "JUPYTERLAB_WORKSPACES_DIR", + ): + monkeypatch.delenv(var, raising=False) + + fake_shell = _make_zmq_shell_instance() + with patch("IPython.get_ipython", return_value=fake_shell): + assert detect_notebook_environment() == NotebookEnvironment.JUPYTER_NOTEBOOK + + def test_no_ipython_returns_none(self): + with patch("IPython.get_ipython", return_value=None): + assert detect_notebook_environment() == NotebookEnvironment.NONE + assert should_use_inline_rendering() is False + + def test_vscode_terminal_without_ipython_is_not_a_notebook(self, monkeypatch): + # VSCODE_PID is set whenever a process runs under VS Code's terminal. + # Without IPython, that's a plain script, not a notebook. + monkeypatch.setenv("VSCODE_PID", "1234") + with patch("IPython.get_ipython", return_value=None): + assert detect_notebook_environment() == NotebookEnvironment.NONE + + +# --------------------------------------------------------------------------- +# WindowMode.NOTEBOOK explicit override +# --------------------------------------------------------------------------- + + +class TestWindowModeNotebookOverride: + """``WindowMode.NOTEBOOK`` forces inline rendering regardless of detection.""" + + def test_use_inline_true_with_notebook_mode(self): + from pywry.app import PyWry + + with ( + patch("pywry.app.should_use_inline_rendering", return_value=False), + patch("pywry.app.is_headless_environment", return_value=False), + ): + app = PyWry(mode=WindowMode.NOTEBOOK) + assert app._use_inline() is True + + def test_use_inline_false_for_native_modes_outside_notebook(self): + from pywry.app import PyWry + + # is_headless must be False so the constructor doesn't promote NEW_WINDOW + # to BROWSER on a headless CI runner. + with ( + patch("pywry.app.should_use_inline_rendering", return_value=False), + patch("pywry.app.is_headless_environment", return_value=False), + ): + app = PyWry(mode=WindowMode.NEW_WINDOW) + assert app._use_inline() is False + + def test_use_inline_true_for_browser_mode(self): + from pywry.app import PyWry + + with ( + patch("pywry.app.should_use_inline_rendering", return_value=False), + patch("pywry.app.is_headless_environment", return_value=False), + ): + app = PyWry(mode=WindowMode.BROWSER) + assert app._use_inline() is True + + def test_show_with_notebook_mode_skips_subprocess(self): + """``app.show()`` with ``WindowMode.NOTEBOOK`` takes the inline branch + without spawning the pytauri subprocess, even when auto-detection + would otherwise return NONE. + + The widget produced depends on whether ``anywidget`` is installed in + the test environment — we don't care which one, only that the + subprocess is never started and the result is not a native handle. + """ + from pywry import runtime + from pywry.app import PyWry + from pywry.widget_protocol import NativeWindowHandle + + # Stub the inline widget factories so the test doesn't depend on + # anywidget/IPython actually being installed/usable. + fake_widget = MagicMock() + fake_widget.label = "test-widget" + + with ( + patch("pywry.app.should_use_inline_rendering", return_value=False), + patch("pywry.app.is_headless_environment", return_value=False), + patch.object(runtime, "start") as mock_start, + patch("pywry.widget.PyWryWidget.from_html", return_value=fake_widget), + patch("pywry.inline.show", return_value=fake_widget), + ): + app = PyWry(mode=WindowMode.NOTEBOOK) + result = app.show("
hello
") + + assert not isinstance(result, NativeWindowHandle) + mock_start.assert_not_called() + + +# --------------------------------------------------------------------------- +# Headless VM auto-fallback to BROWSER mode +# --------------------------------------------------------------------------- + + +class TestHeadlessEnvironmentDetection: + """``is_headless_environment()`` reflects whether a display is available.""" + + def test_no_display_on_linux_is_headless(self, monkeypatch): + from pywry.notebook import is_headless_environment + + monkeypatch.setattr("sys.platform", "linux") + monkeypatch.delenv("DISPLAY", raising=False) + monkeypatch.delenv("WAYLAND_DISPLAY", raising=False) + assert is_headless_environment() is True + + def test_x11_display_set_is_not_headless(self, monkeypatch): + from pywry.notebook import is_headless_environment + + monkeypatch.setattr("sys.platform", "linux") + monkeypatch.setenv("DISPLAY", ":0") + monkeypatch.delenv("WAYLAND_DISPLAY", raising=False) + assert is_headless_environment() is False + + def test_wayland_display_set_is_not_headless(self, monkeypatch): + from pywry.notebook import is_headless_environment + + monkeypatch.setattr("sys.platform", "linux") + monkeypatch.delenv("DISPLAY", raising=False) + monkeypatch.setenv("WAYLAND_DISPLAY", "wayland-0") + assert is_headless_environment() is False + + def test_macos_is_never_headless(self, monkeypatch): + from pywry.notebook import is_headless_environment + + monkeypatch.setattr("sys.platform", "darwin") + monkeypatch.delenv("DISPLAY", raising=False) + monkeypatch.delenv("WAYLAND_DISPLAY", raising=False) + assert is_headless_environment() is False + + def test_windows_is_never_headless(self, monkeypatch): + from pywry.notebook import is_headless_environment + + monkeypatch.setattr("sys.platform", "win32") + monkeypatch.delenv("DISPLAY", raising=False) + monkeypatch.delenv("WAYLAND_DISPLAY", raising=False) + assert is_headless_environment() is False + + +class TestHeadlessVMAutoFallbackToBrowser: + """When a native mode is requested on a headless VM (no notebook UI), + PyWry transparently falls back to ``BROWSER`` mode.""" + + def test_native_mode_on_headless_promotes_to_browser(self): + from pywry.app import PyWry + from pywry.window_manager import BrowserMode + + with ( + patch("pywry.app.should_use_inline_rendering", return_value=False), + patch("pywry.app.is_headless_environment", return_value=True), + ): + app = PyWry(mode=WindowMode.NEW_WINDOW) + assert app._mode_enum == WindowMode.BROWSER + assert isinstance(app._mode, BrowserMode) + assert app._use_inline() is True + + def test_native_mode_with_display_stays_native(self): + from pywry.app import PyWry + from pywry.window_manager import NewWindowMode + + with ( + patch("pywry.app.should_use_inline_rendering", return_value=False), + patch("pywry.app.is_headless_environment", return_value=False), + ): + app = PyWry(mode=WindowMode.NEW_WINDOW) + assert app._mode_enum == WindowMode.NEW_WINDOW + assert isinstance(app._mode, NewWindowMode) + assert app._use_inline() is False + + def test_notebook_env_takes_precedence_over_headless(self): + """On Colab/Jupyter (headless AND a notebook UI), keep the requested + native mode — _use_inline() at show*() time renders inline anywidget, + which is what users expect inside a notebook.""" + from pywry.app import PyWry + from pywry.window_manager import NewWindowMode + + with ( + patch("pywry.app.should_use_inline_rendering", return_value=True), + patch("pywry.app.is_headless_environment", return_value=True), + ): + app = PyWry(mode=WindowMode.NEW_WINDOW) + # Mode is NOT promoted to BROWSER — anywidget rendering inside the + # notebook UI is preferred over a server URL. + assert app._mode_enum == WindowMode.NEW_WINDOW + assert isinstance(app._mode, NewWindowMode) + # _use_inline() still returns True (via should_use_inline_rendering). + assert app._use_inline() is True + + def test_explicit_notebook_mode_not_demoted_on_headless(self): + """``WindowMode.NOTEBOOK`` is the user's explicit choice and must not + be silently flipped to BROWSER even on a headless host.""" + from pywry.app import PyWry + + with ( + patch("pywry.app.should_use_inline_rendering", return_value=False), + patch("pywry.app.is_headless_environment", return_value=True), + ): + app = PyWry(mode=WindowMode.NOTEBOOK) + assert app._mode_enum == WindowMode.NOTEBOOK