Repository: jupyter/nbconvert
Pull request #2013 (2023-06-28 14:10:57)

Adopt playwright
Since [pyppeteer](https://github.com/pyppeteer/pyppeteer) is now unmaintained we should adopt playwright instead.

brichet: I have no clue on this test failure too (occurs locally also):
https://github.com/jupyter/nbconvert/actions/runs/5402860958/jobs/9814806087?pr=2013#step:5:379

Don't know why it used to pass as `exporter.from_file(f, resources={})` is supposed to return a tuple https://github.com/jupyter/nbconvert/blob/50b86f8755fe45c5ad0473727fc102ce957e7c1b/nbconvert/exporters/exporter.py#L203-L205

but `exporter.from_notebook_node(nb)` expects a `NotebookNode` (it lead to `exporter.html.from_notebook_node(nb)`):
https://github.com/jupyter/nbconvert/blob/50b86f8755fe45c5ad0473727fc102ce957e7c1b/nbconvert/exporters/html.py#L227-L229
blink1073: I'm removing Python 3.7 support in #2008.  I'm updating this branch to pick up those changes.

Patch:
diff --git a/docs/source/install.rst b/docs/source/install.rst
index 8cdc10f3..c015a759 100644
--- a/docs/source/install.rst
+++ b/docs/source/install.rst
@@ -29,7 +29,7 @@ The `Miniconda <https://docs.conda.io/en/latest/miniconda.html>`_ and `Miniforge
 .. important::
 
     To unlock its full capabilities, nbconvert requires Pandoc, TeX
-    (specifically, XeLaTeX) and Pyppeteer. These must be installed separately.
+    (specifically, XeLaTeX) and playwright. These must be installed separately.
 
 Installing Pandoc
 -----------------
@@ -80,13 +80,13 @@ Installing Chromium
 -------------------
 
 For converting notebooks to PDF with ``--to webpdf``, nbconvert requires the
-`Pyppeteer <https://github.com/pyppeteer/pyppeteer>`_ Chromium automation library.
+`playwright <https://github.com/microsoft/playwright-python>`_ Chromium automation library.
 
-Pyppeteer makes use of a specific version of Chromium. If it does not find a suitable
+Playwright makes use of a specific version of Chromium. If it does not find a suitable
 installation of the web browser, it can automatically download it if the ``--allow-chromium-download``
 flag is passed to the command line.
 
-To install a suitable version of pyppeteer, you can pip install ``nbconvert[webpdf]``.
+To install a suitable version of playwright, you can pip install ``nbconvert[webpdf]``.
 
 PDF conversion on a limited TeX environment
 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
diff --git a/docs/source/usage.rst b/docs/source/usage.rst
index ef3197d7..28956690 100644
--- a/docs/source/usage.rst
+++ b/docs/source/usage.rst
@@ -139,7 +139,7 @@ WebPDF
   Generates a PDF by first rendering to HTML, rendering the HTML Chromium headless, and
   exporting to PDF. This exporter supports the same templates as ``--to html``.
 
-  The webpdf exporter requires the ``pyppeteer`` Chromium automation library, which
+  The webpdf exporter requires the ``playwright`` Chromium automation library, which
   can be installed via ``nbconvert[webpdf]``.
 
 .. _convert_revealjs:
diff --git a/nbconvert/exporters/webpdf.py b/nbconvert/exporters/webpdf.py
index 2d15b2b4..a87aa2cd 100644
--- a/nbconvert/exporters/webpdf.py
+++ b/nbconvert/exporters/webpdf.py
@@ -6,6 +6,8 @@
 import asyncio
 import concurrent.futures
 import os
+import subprocess
+import sys
 import tempfile
 from importlib import util as importlib_util
 
@@ -13,14 +15,15 @@ from traitlets import Bool, default
 
 from .html import HTMLExporter
 
-PYPPETEER_INSTALLED = importlib_util.find_spec("pyppeteer") is not None
+PLAYWRIGHT_INSTALLED = importlib_util.find_spec("playwright") is not None
+IS_WINDOWS = os.name == 'nt'
 
 
 class WebPDFExporter(HTMLExporter):
     """Writer designed to write to PDF files.
 
     This inherits from :class:`HTMLExporter`. It creates the HTML using the
-    template machinery, and then run pyppeteer to create a pdf.
+    template machinery, and then run playwright to create a pdf.
     """
 
     export_from_notebook = "PDF via HTML"
@@ -65,40 +68,44 @@ class WebPDFExporter(HTMLExporter):
         """,
     ).tag(config=True)
 
-    def _check_launch_reqs(self):
-        try:
-            from pyppeteer import launch  # type: ignore[import]
-            from pyppeteer.util import check_chromium  # type:ignore
-        except ModuleNotFoundError as e:
-            msg = (
-                "Pyppeteer is not installed to support Web PDF conversion. "
-                "Please install `nbconvert[webpdf]` to enable."
-            )
-            raise RuntimeError(msg) from e
-        if not self.allow_chromium_download and not check_chromium():
-            msg = (
-                "No suitable chromium executable found on the system. "
-                "Please use '--allow-chromium-download' to allow downloading one."
-            )
-            raise RuntimeError(msg)
-        return launch
-
-    def run_pyppeteer(self, html):
-        """Run pyppeteer."""
+    def run_playwright(self, html):
+        """Run playwright."""
 
         async def main(temp_file):
-            """Run main pyppeteer script."""
+            """Run main playwright script."""
             args = ["--no-sandbox"] if self.disable_sandbox else []
-            browser = await self._check_launch_reqs()(
-                handleSIGINT=False, handleSIGTERM=False, handleSIGHUP=False, args=args
-            )
-            page = await browser.newPage()
-            await page.emulateMedia("print")
-            await page.waitFor(100)
-            await page.goto(f"file://{temp_file.name}", waitUntil="networkidle0")
-            await page.waitFor(100)
-
-            pdf_params = {"printBackground": True}
+            try:
+                from playwright.async_api import async_playwright  # type: ignore[import]
+            except ModuleNotFoundError as e:
+                msg = (
+                    "Playwright is not installed to support Web PDF conversion. "
+                    "Please install `nbconvert[webpdf]` to enable."
+                )
+                raise RuntimeError(msg) from e
+
+            if self.allow_chromium_download:
+                cmd = [sys.executable, "-m", "playwright", "install", "chromium"]
+                subprocess.check_call(cmd)  # noqa
+
+            playwright = await async_playwright().start()
+            chromium = playwright.chromium
+
+            try:
+                browser = await chromium.launch(
+                    handle_sigint=False, handle_sigterm=False, handle_sighup=False, args=args
+                )
+            except Exception as e:
+                msg = (
+                    "No suitable chromium executable found on the system. "
+                    "Please use '--allow-chromium-download' to allow downloading one,"
+                    "or install it using `playwright install chromium`."
+                )
+                raise RuntimeError(msg) from e
+
+            page = await browser.new_page()
+            await page.goto(f"file://{temp_file.name}", wait_until="networkidle")
+
+            pdf_params = {"print_background": True}
             if not self.paginate:
                 # Floating point precision errors cause the printed
                 # PDF from spilling over a new page by a pixel fraction.
@@ -120,7 +127,7 @@ class WebPDFExporter(HTMLExporter):
                         "height": min(height, 200 * 72),
                     }
                 )
-            pdf_data = await page.pdf(pdf_params)
+            pdf_data = await page.pdf(**pdf_params)
 
             await browser.close()
             return pdf_data
@@ -139,23 +146,27 @@ class WebPDFExporter(HTMLExporter):
             # pdf_data = pool.submit(asyncio.run, main(temp_file)).result()
             def run_coroutine(coro):
                 """Run an internal coroutine."""
-                loop = asyncio.new_event_loop()
+                loop = (
+                    asyncio.ProactorEventLoop()  # type:ignore
+                    if IS_WINDOWS
+                    else asyncio.new_event_loop()
+                )
+
                 asyncio.set_event_loop(loop)
                 return loop.run_until_complete(coro)
 
             pdf_data = pool.submit(run_coroutine, main(temp_file)).result()
         finally:
-            # Ensure the file is deleted even if pypeteer raises an exception
+            # Ensure the file is deleted even if playwright raises an exception
             os.unlink(temp_file.name)
         return pdf_data
 
     def from_notebook_node(self, nb, resources=None, **kw):
         """Convert from a notebook node."""
-        self._check_launch_reqs()
         html, resources = super().from_notebook_node(nb, resources=resources, **kw)
 
         self.log.info("Building PDF")
-        pdf_data = self.run_pyppeteer(html)
+        pdf_data = self.run_playwright(html)
         self.log.info("PDF successfully created")
 
         # convert output extension to pdf
diff --git a/pyproject.toml b/pyproject.toml
index c39d64f5..73d4b4a6 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -58,7 +58,7 @@ script = "nbconvert.exporters:ScriptExporter"
 [project.optional-dependencies]
 qtpng = ["pyqtwebengine>=5.15"]
 qtpdf = ["nbconvert[qtpng]"]
-webpdf = ["pyppeteer>=1,<1.1"]
+webpdf = ["playwright"]
 test = [
     "pytest",
     "pytest-dependency",


Test patch:
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index b7871616..5f026887 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -52,7 +52,7 @@ jobs:
       - name: Run tests on other platforms
         if: ${{ !startsWith(runner.os, 'linux')  }}
         run: |
-          hatch run cov:test
+          hatch run cov:nowarn
 
       - uses: jupyterlab/maintainer-tools/.github/actions/upload-coverage@v1
 
diff --git a/nbconvert/exporters/tests/test_webpdf.py b/nbconvert/exporters/tests/test_webpdf.py
index 6d30c9b8..3a093268 100644
--- a/nbconvert/exporters/tests/test_webpdf.py
+++ b/nbconvert/exporters/tests/test_webpdf.py
@@ -3,15 +3,30 @@
 # Copyright (c) IPython Development Team.
 # Distributed under the terms of the Modified BSD License.
 
+import builtins
 from unittest.mock import patch
 
 import pytest
 
-from ..webpdf import PYPPETEER_INSTALLED, WebPDFExporter
+from ..exporter import Exporter
+from ..webpdf import PLAYWRIGHT_INSTALLED, WebPDFExporter
 from .base import ExportersTestsBase
 
+real_import = builtins.__import__
 
-@pytest.mark.skipif(not PYPPETEER_INSTALLED, reason="Pyppeteer not installed")
+
+class FakeBrowser:
+    executable_path: str = ''
+
+
+def monkey_import_notfound(name, globals_ctx=None, locals_ctx=None, fromlist=(), level=0):
+    if name == "playwright.async_api":
+        msg = 'Fake missing'
+        raise ModuleNotFoundError(msg)
+    return real_import(name, globals=globals_ctx, locals=locals_ctx, fromlist=fromlist, level=level)
+
+
+@pytest.mark.skipif(not PLAYWRIGHT_INSTALLED, reason="Playwright not installed")
 class TestWebPDFExporter(ExportersTestsBase):
     """Contains test functions for webpdf.py"""
 
@@ -27,22 +42,23 @@ class TestWebPDFExporter(ExportersTestsBase):
         )
         assert len(output) > 0
 
-    @patch("pyppeteer.util.check_chromium", return_value=False)
-    def test_webpdf_without_chromium(self, mock_check_chromium):
+    @patch("playwright.async_api._generated.Playwright.chromium", return_value=FakeBrowser())
+    def test_webpdf_without_chromium(self, mock_chromium):
         """
         Generate PDFs if chromium not present?
         """
         with pytest.raises(RuntimeError):
             WebPDFExporter(allow_chromium_download=False).from_filename(self._get_notebook())
 
-    def test_webpdf_without_pyppeteer(self):
+    @patch("builtins.__import__", monkey_import_notfound)
+    def test_webpdf_without_playwright(self):
         """
-        Generate PDFs if chromium not present?
+        Generate PDFs if playwright not installed?
         """
         with pytest.raises(RuntimeError):
+            base_exporter = Exporter()
             exporter = WebPDFExporter()
             with open(self._get_notebook(), encoding="utf-8") as f:
-                nb = exporter.from_file(f, resources={})
+                nb = base_exporter.from_file(f, resources={})[0]
                 # Have to do this as the very last action as traitlets do dynamic importing often
-                with patch("builtins.__import__", side_effect=ModuleNotFoundError("Fake missing")):
-                    exporter.from_notebook_node(nb)
+                exporter.from_notebook_node(nb)
diff --git a/nbconvert/tests/test_nbconvertapp.py b/nbconvert/tests/test_nbconvertapp.py
index cd763653..2220c8bb 100644
--- a/nbconvert/tests/test_nbconvertapp.py
+++ b/nbconvert/tests/test_nbconvertapp.py
@@ -12,7 +12,7 @@ from flaky import flaky  # type:ignore
 from traitlets.tests.utils import check_help_all_output
 
 from nbconvert.exporters import HTMLExporter
-from nbconvert.exporters.webpdf import PYPPETEER_INSTALLED
+from nbconvert.exporters.webpdf import PLAYWRIGHT_INSTALLED
 
 from ..postprocessors import PostProcessorBase
 from ..tests.utils import onlyif_cmds_exist
@@ -151,7 +151,7 @@ class TestNbConvertApp(TestsBase):
 
     @flaky
     @pytest.mark.network
-    @pytest.mark.skipif(not PYPPETEER_INSTALLED, reason="Pyppeeter not installed")
+    @pytest.mark.skipif(not PLAYWRIGHT_INSTALLED, reason="Playwright not installed")
     def test_webpdf_with_chromium(self):
         """
         Generate PDFs if chromium allowed to be downloaded?