"""A command line for ``pyodide-lock`` in JupyterLite."""
# Copyright (c) jupyterlite-pyodide-lock contributors.
# Distributed under the terms of the BSD-3-Clause License.
from __future__ import annotations
import contextlib
import os
import subprocess # noqa: S404
import sys
import textwrap
from typing import Any, ClassVar
from jupyter_core.application import JupyterApp
from jupyterlite_core.app import DescribedMixin
from jupyterlite_core.constants import JSON_FMT, UTF8
from traitlets import Bool, Float, Unicode
from . import __version__
from .constants import BROWSER_BIN, BROWSER_BIN_ALIASES, BROWSERS, CHROMIUMLIKE, WIN
from .lockers.browser import BROWSERS as BROWSER_OPTS
from .utils import find_browser_binary, get_browser_search_path
[docs]
class BrowsersApp(DescribedMixin, JupyterApp):
"""An app that lists discoverable browsers."""
version: str = Unicode(default_value=__version__, help="version of the app") # type: ignore[assignment]
format: str = Unicode(allow_none=True, help="output format, e.g. ``json``").tag(
config=True
) # type: ignore[assignment]
check_versions: bool = Bool(
default_value=False, help="fail if no browser versions are found"
).tag(config=True) # type: ignore[assignment]
check_timeout: float = Float(
default_value=5.0, help="max seconds to wait to check a browser version"
).tag(config=True) # type: ignore[assignment]
flags: ClassVar[dict[str, tuple[dict[str, Any], str]]] = { # type: ignore[misc]
"json": (
{"BrowsersApp": {"format": "json"}},
"output JSON",
),
"check": (
{"BrowsersApp": {"check_versions": True}},
"check browser versions",
),
}
[docs]
def start(self) -> None:
"""Run the application."""
results = {
"status": 1,
"ok": False,
"search_path": get_browser_search_path().split(os.path.pathsep),
"browsers": {
browser: self.collect_browser(browser) for browser in BROWSERS
},
}
if not self.check_versions:
results["status"] = 0
else:
found = [b for b, r in results["browsers"].items() if r["version"]]
results["status"] = 0 if found else 1
results["ok"] = not results["status"]
if self.format == "json":
self.emit_json(results)
else:
self.emit_console(results)
self.exit(results["status"])
[docs]
def collect_browser(self, browser: str) -> dict[str, Any]:
"""Gather data for a single browser."""
browser_bin = BROWSER_BIN[browser]
aliases = BROWSER_BIN_ALIASES.get(browser_bin)
result = {
"binary": browser_bin,
"aliases": aliases,
"found": None,
"version": None,
}
found_bin: str | None = None
with contextlib.suppress(ValueError):
result["found"] = found_bin = find_browser_binary(browser_bin, log=self.log)
if self.check_versions and found_bin:
result["version"] = self.get_browser_version(browser, found_bin)
return result
[docs]
def get_browser_version(
self, browser: str, found_bin: str
) -> str | None: # pragma: no cover
"""Try to get a browser version."""
if WIN and browser in CHROMIUMLIKE:
return "<unreliable on Windows>"
args = [found_bin, "--version"] + BROWSER_OPTS[browser]["headless"]
proc = subprocess.Popen( # noqa: S603
args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, **UTF8
)
output = None
with contextlib.suppress(subprocess.TimeoutExpired):
output = f"{proc.communicate(timeout=self.check_timeout)[0]}".strip()
return output
[docs]
def emit_json(self, results: dict[str, Any]) -> None:
"""Emit raw results JSON."""
import json
sys.stdout.write(json.dumps(results, **JSON_FMT))
[docs]
def emit_console(self, results: dict[str, Any]) -> None:
"""Print out logs for browsers."""
self.log.info(
"search path:\n%s",
textwrap.indent("\n".join(results["search_path"]), "\t"),
)
for browser, result in results["browsers"].items():
self.emit_console_one_browser(browser, result)
[docs]
def emit_console_one_browser(self, browser: str, result: dict[str, Any]) -> None:
"""Print out logs for one browser."""
self.log.info(
"[%s] %s (aliases: %s)",
browser,
result["binary"],
result["aliases"],
)
if not result["found"]: # pragma: no cover
self.log.warning("[%s] NOT found", browser)
return
self.log.info("[%s] found:\t%s", browser, result["found"])
if result["version"]: # pragma: no cover
self.log.info(
"[%s] version:\n%s", browser, textwrap.indent(result["version"], "\t")
)
[docs]
class PyodideLockApp(DescribedMixin, JupyterApp):
"""Tools for working with 'pyodide-lock' in JupyterLite."""
version: str = Unicode(default_value=__version__) # type: ignore[assignment]
subcommands: ClassVar = { # type: ignore[assignment,misc]
k: (v, f"{v.__doc__}".splitlines()[0].strip())
for k, v in dict(
browsers=BrowsersApp,
).items()
}
main = launch_new_instance = PyodideLockApp.launch_instance
if __name__ == "__main__": # pragma: no cover
main()