diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e127b7e..0ea5b6c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,12 @@ jobs: fail-fast: false matrix: python: ["3.11", "3.12", "3.13"] + env: + # .python-version pins 3.11 for local dev; without this, `uv run` in the + # 3.12/3.13 jobs re-resolves to 3.11 and rebuilds a bare venv without + # dev deps ("Failed to spawn: pytest"). Pin every uv invocation to the + # matrix interpreter instead. + UV_PYTHON: ${{ matrix.python }} steps: - uses: actions/checkout@v4 - uses: astral-sh/setup-uv@v4 @@ -52,13 +58,13 @@ jobs: libfontconfig1 libnss3 libasound2t64 libxcomposite1 libxdamage1 \ libxrandr2 libxtst6 libxkbfile1 - name: Install (all extras) - run: uv sync --frozen --all-extras --python ${{ matrix.python }} + run: uv sync --frozen --all-extras - name: pytest (offscreen; coverage floor on 3.11) run: | if [ "${{ matrix.python }}" = "3.11" ]; then - uv run pytest -q --cov + uv run --no-sync pytest -q --cov else - uv run pytest -q + uv run --no-sync pytest -q fi docs: diff --git a/src/qtviz/core/_host.py b/src/qtviz/core/_host.py index 2695325..45c825e 100644 --- a/src/qtviz/core/_host.py +++ b/src/qtviz/core/_host.py @@ -98,7 +98,10 @@ def __init__(self, handle, x_groups, y_groups) -> None: self._sub = handle.event_bus.subscribe(RangeEvent, self._on_range) def _on_range(self, ev) -> None: - if self._syncing or ev.pane is None: + # A throttled *trailing* delivery rides a QTimer that outlives the + # subscription — it can land after the render is disposed. Dead + # renders don't link. + if self._syncing or ev.pane is None or self._handle.widget is None: return self._syncing = True try: @@ -118,16 +121,18 @@ def _propagate(self, origin: str, axis: str, rng, groups) -> None: for label in group: if label == origin: continue + from ..errors import DisposedError # noqa: PLC0415 + try: pane = self._handle.pane(label) - except KeyError: # the render changed underneath — drop - continue - cur = getattr(pane.capture(), f"{axis}_range") - if cur is not None and all( - math.isclose(a, b, rel_tol=1e-9, abs_tol=1e-12) - for a, b in zip(cur, rng, strict=True)): - continue # value guard: already there (async echo) - pane.set_range(**{axis: tuple(rng)}) + cur = getattr(pane.capture(), f"{axis}_range") + if cur is not None and all( + math.isclose(a, b, rel_tol=1e-9, abs_tol=1e-12) + for a, b in zip(cur, rng, strict=True)): + continue # value guard: already there (async echo) + pane.set_range(**{axis: tuple(rng)}) + except (KeyError, DisposedError): + continue # the render changed/died underneath — drop return # a pane belongs to exactly one group per axis def dispose(self) -> None: diff --git a/tests/qtviz/test_resize_tracking.py b/tests/qtviz/test_resize_tracking.py index 5680093..22eb620 100644 --- a/tests/qtviz/test_resize_tracking.py +++ b/tests/qtviz/test_resize_tracking.py @@ -102,9 +102,22 @@ def test_plotly_backend_nudges_after_a_qt_resize(qtbot): view = PlotView(PlotlyBackend({"data": [], "layout": {}})) qtbot.addWidget(view) + # Observe the send itself, not the pre-ready queue: whether the command + # lands in `_command_queue` or goes straight over the bridge depends on + # when Chromium's handshake completes — a race that flaked on CI both + # ways. The wiring under test is resize → debounce → send(...). + sent: list[str] = [] + orig_send = view.send + + def send(name, payload=None): + sent.append(name) + orig_send(name, payload) + + view.send = send view.resize(300, 200) view.show() + qtbot.wait(50) # let Chromium construction/show settle before the resize view.resize(500, 400) - qtbot.waitUntil( - lambda: any(n == "plotly.resize" for n, _p in view._command_queue), - timeout=2000) + # generous: a cold CI runner spends seconds spinning up QtWebEngine on the + # GUI thread before the debounce timer can even be processed. + qtbot.waitUntil(lambda: "plotly.resize" in sent, timeout=10000)