diff --git a/pyproject.toml b/pyproject.toml index 944c119c..8c3e7368 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -80,7 +80,9 @@ obsplot = ["pyobsplot>=0.5.0"] panel = ["panel", "jupyter_bokeh>=4.1"] # pyarrow is required for pandas DataFrame conversion (perspective-python # doesn't depend on it) -perspective = ["perspective-python>=3.0", "pyarrow"] +# 5.7.0 excluded: its "Better WASM init" change breaks second-viewer init and +# re-rendering (viewers never display data), seen in tests/playwright/render_perspective +perspective = ["perspective-python>=3.0,!=5.7.0", "pyarrow"] plotly = ["plotly>=5.12.0", "narwhals", "pandas"] plotnine = ["plotnine", "ipympl"] # duckdb is listed because the security model requires apps to `import duckdb` @@ -156,7 +158,7 @@ test = [ "networkx", "mosaic-widget; python_version < '3.15'", "panel", - "perspective-python>=3.0", + "perspective-python>=3.0,!=5.7.0", "playwright>=1.40.0", "pyobsplot>=0.5.0", "plotly>=5.12.0", @@ -228,7 +230,7 @@ dev = [ "networkx", "mosaic-widget; python_version < '3.15'", "panel", - "perspective-python>=3.0", + "perspective-python>=3.0,!=5.7.0", "playwright>=1.40.0", "pyobsplot>=0.5.0", "plotly>=5.12.0", @@ -306,7 +308,7 @@ test = [ "networkx", "mosaic-widget; python_version < '3.15'", "panel", - "perspective-python>=3.0", + "perspective-python>=3.0,!=5.7.0", "playwright>=1.40.0", "pyobsplot>=0.5.0", "plotly>=5.12.0", diff --git a/shinywidgets/_dependencies.py b/shinywidgets/_dependencies.py index 1b711851..4a4081db 100644 --- a/shinywidgets/_dependencies.py +++ b/shinywidgets/_dependencies.py @@ -169,9 +169,6 @@ def require_dependencies( ) return [] - if module_name == "anywidget": - module_dir = relax_anywidget_init_timeout(module_dir) - version = parse_version_safely(getattr(w, "_model_module_version", "1.0")) deps: List[HTMLDependency] = [] @@ -924,72 +921,6 @@ def _url_sources(url: str) -> List[str]: return sources -# anywidget's front-end rejects a model's `ready` promise if initialization -# (i.e., loading the widget's `_esm`) takes longer than a hard-coded 2s -# (`AbortSignal.timeout(2e3)` -> "[anywidget] Failed to initialize model."). -# `_handle_comm_msg` awaits that promise, so once it rejects, every subsequent -# Python -> browser state update is dropped (and surfaces as a console error). -# That's unsuitable outside Jupyter for widgets with multi-MB inline ESM -# bundles (keplergl's `_esm` is ~13MB), which can't compile within 2s on -# slower hardware. Serve a patched copy with a more generous budget instead. -# Interim workaround for https://github.com/manzt/anywidget/issues/1030 -- -# remove this (and serve anywidget's files unmodified) once upstream makes -# the init budget configurable or stops gating comm messages on it. -ANYWIDGET_INIT_TIMEOUT_MS = 60000 - - -def relax_anywidget_init_timeout(module_dir: str) -> str: - """ - Return a directory holding a copy of anywidget's front-end (nbextension) - with the hard-coded 2s model-initialization budget raised. Falls back to - `module_dir` itself if the timeout can't be located (e.g., a future - anywidget release restructures this code). - """ - index = os.path.join(module_dir, "index.js") - try: - mtime = os.path.getmtime(index) - except OSError: - return module_dir - - key = (module_dir, mtime) - if key in _anywidget_patched_dirs: - return _anywidget_patched_dirs[key] - - patched_dir = module_dir - with open(index, encoding="utf-8") as f: - js = f.read() - - i = js.find("Failed to initialize model") - if i >= 0: - # The timeout that guards model initialization sits just before the - # rejection; match its numeric argument (2e3 or 2000) - m = re.search( - r"AbortSignal\.timeout\(\s*(?:2e3|2000)\s*\)", js[max(0, i - 500) : i] - ) - if m is not None: - patched_dir = tempfile.mkdtemp(prefix="shinywidgets-anywidget-") - for f_ in os.listdir(module_dir): - src = os.path.join(module_dir, f_) - if not os.path.isfile(src): - continue - with open(src, "rb") as fin: - contents = fin.read() - if f_ == "index.js": - contents = ( - js[: max(0, i - 500) + m.start()] - + f"AbortSignal.timeout({ANYWIDGET_INIT_TIMEOUT_MS})" - + js[max(0, i - 500) + m.end() :] - ).encode("utf-8") - with open(os.path.join(patched_dir, f_), "wb") as fout: - fout.write(contents) - - _anywidget_patched_dirs[key] = patched_dir - return patched_dir - - -_anywidget_patched_dirs: dict[tuple[str, float], str] = {} - - def jupyter_extension_path(module_name: str) -> Optional[str]: paths: List[str] = jupyter_path() for x in paths: diff --git a/tests/playwright/conftest.py b/tests/playwright/conftest.py index f2dba44c..c98e55ac 100644 --- a/tests/playwright/conftest.py +++ b/tests/playwright/conftest.py @@ -1,9 +1,12 @@ from __future__ import annotations +import importlib.metadata import io from typing import Any import numpy as np +import pytest +from packaging.version import Version from PIL import Image from playwright.sync_api import Error as PlaywrightError from playwright.sync_api import Page, Route, expect @@ -280,6 +283,18 @@ def wait_for_red_pixels(page: Page, selector: str, timeout_ms: int = 30000) -> N "luma.gl: This version of luma.gl has already been initialized", ) +# anywidget <0.11.1 rejects a model's init after a hard-coded 2s and then +# drops all subsequent state updates (manzt/anywidget#1030, fixed by +# manzt/anywidget#1032). Widgets with multi-MB inline _esm (keplergl ~13MB, +# lonboard) trip that budget under CI-grade CPU contention, permanently +# desyncing. Non-strict so a fast runner that happens to pass doesn't fail +# the suite; the mark lifts itself once anywidget 0.11.1 is installed. +ANYWIDGET_INIT_XFAIL = pytest.mark.xfail( + Version(importlib.metadata.version("anywidget")) < Version("0.11.1"), + reason="anywidget <0.11.1 can permanently desync keplergl's ~13MB _esm under CPU contention (manzt/anywidget#1032)", + strict=False, +) + def assert_no_unexpected_errors( errors: list[str], diff --git a/tests/playwright/example_plotly_afterplot_stable/test_example_plotly_afterplot_stable.py b/tests/playwright/example_plotly_afterplot_stable/test_example_plotly_afterplot_stable.py index 41044053..3fcf9f3e 100644 --- a/tests/playwright/example_plotly_afterplot_stable/test_example_plotly_afterplot_stable.py +++ b/tests/playwright/example_plotly_afterplot_stable/test_example_plotly_afterplot_stable.py @@ -1,6 +1,9 @@ from __future__ import annotations from playwright.sync_api import Page +from tests.playwright.conftest import ANYWIDGET_INIT_XFAIL + +pytestmark = ANYWIDGET_INIT_XFAIL def test_example_plotly_first_visible_afterplot_is_already_resized( diff --git a/tests/playwright/plotly_accessors/test_plotly_accessors.py b/tests/playwright/plotly_accessors/test_plotly_accessors.py index 971d72a3..9df90f1a 100644 --- a/tests/playwright/plotly_accessors/test_plotly_accessors.py +++ b/tests/playwright/plotly_accessors/test_plotly_accessors.py @@ -1,7 +1,13 @@ from __future__ import annotations from playwright.sync_api import Page, expect -from tests.playwright.conftest import assert_no_unexpected_errors, collect_errors +from tests.playwright.conftest import ( + ANYWIDGET_INIT_XFAIL, + assert_no_unexpected_errors, + collect_errors, +) + +pytestmark = ANYWIDGET_INIT_XFAIL # Page coordinates of a data point, via plotly.js's axis conversions. POINT_JS = """([id, x, y]) => { diff --git a/tests/playwright/plotly_accessors_rerender/test_plotly_accessors_rerender.py b/tests/playwright/plotly_accessors_rerender/test_plotly_accessors_rerender.py index 892ad95f..48d09071 100644 --- a/tests/playwright/plotly_accessors_rerender/test_plotly_accessors_rerender.py +++ b/tests/playwright/plotly_accessors_rerender/test_plotly_accessors_rerender.py @@ -1,7 +1,13 @@ from __future__ import annotations from playwright.sync_api import Page, expect -from tests.playwright.conftest import assert_no_unexpected_errors, collect_errors +from tests.playwright.conftest import ( + ANYWIDGET_INIT_XFAIL, + assert_no_unexpected_errors, + collect_errors, +) + +pytestmark = ANYWIDGET_INIT_XFAIL POINT_JS = """([x, y]) => { const gd = document.querySelector("#plot .js-plotly-plot"); diff --git a/tests/playwright/plotly_config/test_plotly_config.py b/tests/playwright/plotly_config/test_plotly_config.py index d4916dd1..cc4fcf45 100644 --- a/tests/playwright/plotly_config/test_plotly_config.py +++ b/tests/playwright/plotly_config/test_plotly_config.py @@ -1,7 +1,13 @@ from __future__ import annotations from playwright.sync_api import Page, expect -from tests.playwright.conftest import assert_no_unexpected_errors, collect_errors +from tests.playwright.conftest import ( + ANYWIDGET_INIT_XFAIL, + assert_no_unexpected_errors, + collect_errors, +) + +pytestmark = ANYWIDGET_INIT_XFAIL def test_plotly_config_tuple_hides_modebar(page: Page, local_app) -> None: diff --git a/tests/playwright/plotly_rerender_cleanup/test_plotly_rerender_cleanup.py b/tests/playwright/plotly_rerender_cleanup/test_plotly_rerender_cleanup.py index 00ff801e..bafd4cdd 100644 --- a/tests/playwright/plotly_rerender_cleanup/test_plotly_rerender_cleanup.py +++ b/tests/playwright/plotly_rerender_cleanup/test_plotly_rerender_cleanup.py @@ -1,7 +1,12 @@ from __future__ import annotations from playwright.sync_api import Page, expect -from tests.playwright.conftest import assert_rerender_cleanup +from tests.playwright.conftest import ( + ANYWIDGET_INIT_XFAIL, + assert_rerender_cleanup, +) + +pytestmark = ANYWIDGET_INIT_XFAIL def test_plotly_rerender_does_not_log_cleanup_errors(page: Page, local_app) -> None: diff --git a/tests/playwright/render_keplergl/test_render_keplergl.py b/tests/playwright/render_keplergl/test_render_keplergl.py index f0bc975b..503829c5 100644 --- a/tests/playwright/render_keplergl/test_render_keplergl.py +++ b/tests/playwright/render_keplergl/test_render_keplergl.py @@ -4,6 +4,7 @@ from playwright.sync_api import Page, expect from tests.playwright.conftest import ( + ANYWIDGET_INIT_XFAIL, KEPLERGL_ALLOWED_ERRORS, assert_no_unexpected_errors, collect_errors, @@ -11,6 +12,8 @@ wait_for_stable_view_count, ) +pytestmark = ANYWIDGET_INIT_XFAIL + # kepler.gl bundles a ~12MB script and builds a WebGL map, so first paint is slow LOAD_TIMEOUT = 60000 diff --git a/tests/playwright/render_keplergl_redisplay/test_render_keplergl_redisplay.py b/tests/playwright/render_keplergl_redisplay/test_render_keplergl_redisplay.py index 5d2f45e7..31d798f8 100644 --- a/tests/playwright/render_keplergl_redisplay/test_render_keplergl_redisplay.py +++ b/tests/playwright/render_keplergl_redisplay/test_render_keplergl_redisplay.py @@ -2,11 +2,14 @@ from playwright.sync_api import Page, expect from tests.playwright.conftest import ( + ANYWIDGET_INIT_XFAIL, KEPLERGL_ALLOWED_ERRORS, assert_no_unexpected_errors, collect_errors, ) +pytestmark = ANYWIDGET_INIT_XFAIL + LOAD_TIMEOUT = 60000 HEIGHTS_JS = """(id) => { diff --git a/tests/playwright/render_lonboard/test_render_lonboard.py b/tests/playwright/render_lonboard/test_render_lonboard.py index 20b306b2..670afdaf 100644 --- a/tests/playwright/render_lonboard/test_render_lonboard.py +++ b/tests/playwright/render_lonboard/test_render_lonboard.py @@ -2,12 +2,15 @@ from playwright.sync_api import Page, expect from tests.playwright.conftest import ( + ANYWIDGET_INIT_XFAIL, assert_no_unexpected_errors, client_models, collect_errors, expose_manager, ) +pytestmark = ANYWIDGET_INIT_XFAIL + # Each lonboard map bundles its own copy of luma.gl, which logs this (harmless) # console error when a page shows more than one map. How many of them have # logged it by the time a test asserts depends on timing, so it can't be diff --git a/tests/unit/test_dependencies.py b/tests/unit/test_dependencies.py index 9bc3c54c..b94fdfbc 100644 --- a/tests/unit/test_dependencies.py +++ b/tests/unit/test_dependencies.py @@ -16,7 +16,6 @@ output_binding_dependency, parse_version, parse_version_safely, - relax_anywidget_init_timeout, require_dependencies, widget_pkg, ) @@ -478,43 +477,3 @@ def test_amd_externals_ignores_prebundled_and_relative_modules(tmp_path) -> None def test_amd_externals_non_amd_bundle(tmp_path) -> None: module_dir = _nbextension(tmp_path, "some-widget", "export const x = 1;") assert amd_externals(module_dir) == [] - - -ANYWIDGET_INDEX_JS = """ - AbortSignal.timeout(2e3).addEventListener("abort", () => { - resolvers.reject(new Error("[anywidget] Failed to initialize model.")); - }); -""" - - -def test_relax_anywidget_init_timeout_patches_copy(tmp_path) -> None: - module_dir = tmp_path / "anywidget" - module_dir.mkdir() - (module_dir / "index.js").write_text(ANYWIDGET_INDEX_JS, encoding="utf-8") - (module_dir / "extension.js").write_text("// ext", encoding="utf-8") - - patched_dir = relax_anywidget_init_timeout(str(module_dir)) - assert patched_dir != str(module_dir) - - with open(os.path.join(patched_dir, "index.js"), encoding="utf-8") as f: - js = f.read() - assert "AbortSignal.timeout(60000)" in js - assert "2e3" not in js - # The original extension directory is left untouched - with open(module_dir / "index.js", encoding="utf-8") as f: - assert f.read() == ANYWIDGET_INDEX_JS - # Sibling files come along for the ride - assert os.path.exists(os.path.join(patched_dir, "extension.js")) - - # Repeat calls are cached - assert relax_anywidget_init_timeout(str(module_dir)) == patched_dir - - -def test_relax_anywidget_init_timeout_falls_back_when_pattern_missing( - tmp_path, -) -> None: - module_dir = tmp_path / "anywidget" - module_dir.mkdir() - (module_dir / "index.js").write_text("// no timeout here", encoding="utf-8") - - assert relax_anywidget_init_timeout(str(module_dir)) == str(module_dir)