Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
69 changes: 0 additions & 69 deletions shinywidgets/_dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -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] = []
Expand Down Expand Up @@ -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:
Expand Down
15 changes: 15 additions & 0 deletions tests/playwright/conftest.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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],
Expand Down
Original file line number Diff line number Diff line change
@@ -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(
Expand Down
8 changes: 7 additions & 1 deletion tests/playwright/plotly_accessors/test_plotly_accessors.py
Original file line number Diff line number Diff line change
@@ -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]) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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");
Expand Down
8 changes: 7 additions & 1 deletion tests/playwright/plotly_config/test_plotly_config.py
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
3 changes: 3 additions & 0 deletions tests/playwright/render_keplergl/test_render_keplergl.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,16 @@

from playwright.sync_api import Page, expect
from tests.playwright.conftest import (
ANYWIDGET_INIT_XFAIL,
KEPLERGL_ALLOWED_ERRORS,
assert_no_unexpected_errors,
collect_errors,
expose_manager,
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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
3 changes: 3 additions & 0 deletions tests/playwright/render_lonboard/test_render_lonboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
41 changes: 0 additions & 41 deletions tests/unit/test_dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
output_binding_dependency,
parse_version,
parse_version_safely,
relax_anywidget_init_timeout,
require_dependencies,
widget_pkg,
)
Expand Down Expand Up @@ -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)
Loading