diff --git a/docs/tutorials/installation.md b/docs/tutorials/installation.md index 5c03d0d..1c2d888 100644 --- a/docs/tutorials/installation.md +++ b/docs/tutorials/installation.md @@ -27,6 +27,12 @@ terminal. By the end, `pls --version` will print the installed version. pixi add --pypi "panel-live-server[pydata]" ``` + Install the browser the `screenshot` tool needs: + + ```bash + pixi run pls install-browser + ``` + Find the `pls` path: **macOS / Linux:** @@ -51,6 +57,12 @@ terminal. By the end, `pls --version` will print the installed version. uv tool install "panel-live-server[pydata]" ``` + Install the browser the `screenshot` tool needs: + + ```bash + pls install-browser + ``` + Find the `pls` path: **macOS / Linux:** @@ -98,6 +110,12 @@ terminal. By the end, `pls --version` will print the installed version. pip install "panel-live-server[pydata]" ``` + Install the browser the `screenshot` tool needs: + + ```bash + pls install-browser + ``` + Find the `pls` path: **macOS / Linux:** diff --git a/docs/tutorials/mcp-server.md b/docs/tutorials/mcp-server.md index 914633e..21d4d57 100644 --- a/docs/tutorials/mcp-server.md +++ b/docs/tutorials/mcp-server.md @@ -187,12 +187,17 @@ The server runs in an isolated uv tool environment. Install missing packages as ### `screenshot` fails with a Playwright error -The `screenshot` tool needs the Chromium browser that Playwright manages. Install it once with: +The `screenshot` tool needs the Chromium browser that Playwright manages, which is +not installed automatically. Install it once with: ```bash -playwright install chromium +pls install-browser ``` +This downloads Chromium into the same environment that runs `pls`. See +[Installation → Enable the screenshot tool](installation.md#enable-the-screenshot-tool) +for the per-installer command. + --- ## What You've Learned diff --git a/src/panel_live_server/cli.py b/src/panel_live_server/cli.py index 9f4ce23..8e92819 100644 --- a/src/panel_live_server/cli.py +++ b/src/panel_live_server/cli.py @@ -271,6 +271,29 @@ def list_packages( typer.echo(f"{name:<{name_width}} {version}") +@app.command(name="install-browser") +def install_browser() -> None: + """Download the Chromium browser the `screenshot` MCP tool needs. + + Playwright ships its browser binary separately from the Python package, so a + `pip` or `uv` install does not fetch it automatically. Run this once after + installing (pixi users get it via `pixi run postinstall`). It lands in the + same environment that runs `pls`. + """ + from panel_live_server.screenshot import install_browser as _install_browser + + typer.echo("Installing Chromium for the screenshot tool (one-time)...") + code = _install_browser() + if code == 0: + typer.echo("Done — the screenshot tool is ready.") + else: + typer.echo( + "Browser install failed. Try manually: python -m playwright install chromium", + err=True, + ) + raise typer.Exit(code) + + def main() -> None: """Entry point for the pls command.""" app() diff --git a/src/panel_live_server/screenshot.py b/src/panel_live_server/screenshot.py index 14a3095..e94e943 100644 --- a/src/panel_live_server/screenshot.py +++ b/src/panel_live_server/screenshot.py @@ -12,6 +12,8 @@ import asyncio import logging +import os +import sys logger = logging.getLogger(__name__) @@ -20,7 +22,46 @@ class PlaywrightUnavailableError(RuntimeError): """Raised when Playwright or its browser is not installed/launchable.""" -_INSTALL_HINT = "Playwright Chromium is not installed. Run:\n" " playwright install chromium" +_INSTALL_HINT = "Playwright's Chromium browser is not installed. Run:\n pls install-browser" + + +def install_browser() -> int: + """Download the headless Chromium browser the screenshot tool needs. + + Playwright ships its browser binary separately from the Python package, so + ``pip``/``uv`` installs do not fetch it automatically. This shells out to + `` -m playwright install chromium`` so the browser always + lands in the same environment that is running ``pls`` — avoiding the common + trap where the binary is installed under a different interpreter. + + Returns + ------- + int + The installer subprocess exit code (``0`` on success). + """ + import subprocess + + return subprocess.run([sys.executable, "-m", "playwright", "install", "chromium"]).returncode + + +def is_browser_installed() -> bool: + """Return ``True`` if the Chromium binary Playwright needs is present. + + This is a cheap check — it does not launch a browser. It uses Playwright's + sync API, so call it from a worker thread (e.g. ``asyncio.to_thread``), not + directly inside a running event loop. + """ + try: + from playwright.sync_api import sync_playwright + except ImportError: + return False + try: + with sync_playwright() as p: + path = p.chromium.executable_path + return bool(path) and os.path.exists(path) + except Exception: + return False + # Best-effort wait for Panel/Bokeh content to mount before capturing. _CONTENT_SELECTOR = "canvas, .bk-Row, .bk-Column, .bk, .markdown, table, img, svg" diff --git a/src/panel_live_server/server.py b/src/panel_live_server/server.py index 63cf91d..19e5992 100644 --- a/src/panel_live_server/server.py +++ b/src/panel_live_server/server.py @@ -262,6 +262,19 @@ async def app_lifespan(app): else: logger.warning("Panel Live Server failed to start - show tool will not work") + # Warn early if the screenshot browser is missing, so users find out now + # rather than mid-screenshot. The check uses Playwright's sync API, so run + # it in a worker thread to stay off the event loop. + try: + from panel_live_server.screenshot import is_browser_installed + + if not await asyncio.to_thread(is_browser_installed): + msg = "The `screenshot` tool needs Chromium, which is not installed. Run `pls install-browser` to enable it." + print(f"\n {msg}\n", file=sys.stderr, flush=True) # noqa: T201 + logger.warning(msg) + except Exception: + logger.debug("Could not check screenshot browser availability", exc_info=True) + try: yield finally: