1212
1313import asyncio
1414import logging
15+ import os
16+ import sys
1517
1618logger = logging .getLogger (__name__ )
1719
@@ -20,7 +22,46 @@ class PlaywrightUnavailableError(RuntimeError):
2022 """Raised when Playwright or its browser is not installed/launchable."""
2123
2224
23- _INSTALL_HINT = "Playwright Chromium is not installed. Run:\n " " playwright install chromium"
25+ _INSTALL_HINT = "Playwright's Chromium browser is not installed. Run:\n pls install-browser"
26+
27+
28+ def install_browser () -> int :
29+ """Download the headless Chromium browser the screenshot tool needs.
30+
31+ Playwright ships its browser binary separately from the Python package, so
32+ ``pip``/``uv`` installs do not fetch it automatically. This shells out to
33+ ``<this-interpreter> -m playwright install chromium`` so the browser always
34+ lands in the same environment that is running ``pls`` — avoiding the common
35+ trap where the binary is installed under a different interpreter.
36+
37+ Returns
38+ -------
39+ int
40+ The installer subprocess exit code (``0`` on success).
41+ """
42+ import subprocess
43+
44+ return subprocess .run ([sys .executable , "-m" , "playwright" , "install" , "chromium" ]).returncode
45+
46+
47+ def is_browser_installed () -> bool :
48+ """Return ``True`` if the Chromium binary Playwright needs is present.
49+
50+ This is a cheap check — it does not launch a browser. It uses Playwright's
51+ sync API, so call it from a worker thread (e.g. ``asyncio.to_thread``), not
52+ directly inside a running event loop.
53+ """
54+ try :
55+ from playwright .sync_api import sync_playwright
56+ except ImportError :
57+ return False
58+ try :
59+ with sync_playwright () as p :
60+ path = p .chromium .executable_path
61+ return bool (path ) and os .path .exists (path )
62+ except Exception :
63+ return False
64+
2465
2566# Best-effort wait for Panel/Bokeh content to mount before capturing.
2667_CONTENT_SELECTOR = "canvas, .bk-Row, .bk-Column, .bk, .markdown, table, img, svg"
0 commit comments