Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pywry/build_assets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 0 additions & 1 deletion pywry/hatch_build.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
which is required since we bundle native binaries.
"""


from __future__ import annotations

import os
Expand Down
54 changes: 46 additions & 8 deletions pywry/pywry/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
102 changes: 62 additions & 40 deletions pywry/pywry/notebook.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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:
Expand All @@ -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:
Expand Down
Loading
Loading