Repository navigation
Conversation
Collaborator
Author
|
[Claude Code] Closing per the author: the fixes should land on #606's own branch rather than a separate PR. The commits stay on |
Collaborator
Author
Alpaca233
marked this pull request as ready for review
August 26, 2026 23:18
Alpaca233
added a commit
that referenced
this pull request
Aug 27, 2026
#606's new test builds the full HCS GUI inside the main pytest process. Destroying that GUI segfaults (pre-existing destructor-order crash: QSplitter -> QTabWidget -> QFrame -> QVBoxLayout -> QGridLayout -> ~QHBoxLayout; reproducible by flushing DeferredDelete right after the test), and pytest-qt deleteLater()s the window at teardown. A later test's event-loop activity flushes that deferred delete — test_squid_laser_engine::test_status_updated_signal did, killing the run at 67% on every CI attempt for #606/#622 and locally under PyQt5 and PyQt6 alike. Same treatment test_HighContentScreeningGui.py already gets: own step, own process, SQUID_PYTEST_HARD_EXIT. Excluding the file from the main run, the full suite completes under PyQt6 (1551 passed). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This was referenced Sep 6, 2026
Alpaca233
pushed a commit
that referenced
this pull request
Sep 6, 2026
…tack (#641) > **Stacked on #622.** Base is `feat/pyqt6-napari07-migration-v2` so the diff stays to a single file. Retarget to `master` once #622 lands. ## Problem The current install line produces a **broken environment**. `basicpy` pins `scipy<1.13` ([peng-lab/BaSiCPy#173](peng-lab/BaSiCPy#173)), and scipy 1.12 ships no NumPy-2 wheel — so pip resolves the whole environment back to numpy 1.26. After running `setup_22.04.sh` as written: ``` $ pip check napari 0.7.1 has requirement scipy>=1.14.0, but you have scipy 1.12.0. ome-zarr-models 1.8.0 has requirement zarr>=3.1.1, but you have zarr 2.15.0. opencv-contrib-python-headless 5.0.0.93 has requirement numpy>=2, but you have numpy 1.26.4. opencv-python-headless 5.0.0.93 has requirement numpy>=2, but you have numpy 1.26.4. pydantic-zarr 0.10.0 has requirement numpy>=2.0.0, but you have numpy 1.26.4. ``` napari then fails to import. The comment being replaced states that the pinned-back packages "were verified to work" with NumPy 2 — that does not hold once `basicpy` is in the resolution. This isn't a "prefer newer packages" cleanup: **you cannot have both** `basicpy` and a working napari in one environment, because their scipy constraints don't intersect. ## Why removal is safe `basicpy` and `aicsimageio` are imported by exactly one file, `control/stitcher.py`, and **nothing imports that module**: - no static import — `control.stitcher` / `import stitcher` match nowhere - no dynamic import — the only `__import__` uses are the MCP server's sandbox builtins and a test mock - `tools/stitcher.py` defines its **own**, unrelated `Stitcher` class and imports neither package Stitching now lives in the separate [image-stitcher](https://github.com/Cephla-Lab/image-stitcher) repo, which the README already points users to. No code is deleted: `control/stitcher.py` stays on disk and remains usable from a dedicated venv, as the new comment explains. ## Alternative If you'd rather keep it installable in-tree, a `[stitching]` extras group would work as well — happy to rework it that way. Flagging this as the judgment call in the change, since it reverses an existing claim in the script. ## Verification With these two dropped, `pip check` reports **no broken requirements**, and the suite runs **1634 passed, 0 failed, 12 skipped** under Python 3.12 / PyQt6 6.11.0 / napari 0.7.1.
Alpaca233
pushed a commit
that referenced
this pull request
Sep 8, 2026
> **Stacked on #622.** Base is `feat/pyqt6-napari07-migration-v2`, not `master` — see [Why this can't target master](#why-this-cant-target-master). Retarget once #622 lands. ## Problem There is no reproducible way to install this software on Windows. - `setup_22.04.sh` is Ubuntu-only: apt, udev rules, `dialout`, `apt-mark`, bundled `.run` camera installers. - The [forum post the README links for Windows](https://forum.squid-imaging.org/t/setting-up-the-software-on-a-windows-computer/77) predates the PyQt6 work — it installs Python 3.10, **PyQt5** and **napari 0.5.6**, so following it today produces an environment #622's code cannot run in. ## What this adds **`setup_windows.ps1`** — the counterpart of `setup_22.04.sh`. Installs Git + Python 3.12 via winget, clones recursively, builds a virtualenv with the same pinned PyQt6 6.11 / napari 0.7.1 stack, seeds `machine_configs`, and writes Desktop shortcuts. Steps with no Windows equivalent are dropped; camera drivers are vendor installers, so the script *reports* what remains rather than installing it. **`main_hcs.bat`** — the launcher the shortcuts point at. **`.gitattributes`** — two lines pinning `*.bat`/`*.ps1` to `eol=crlf`. The repo had none, and `cmd.exe` can misparse a multi-line `if errorlevel 1 ( ... )` block in a `.bat` stored with LF — a failure that presents as a syntax error rather than a line-ending problem. ## Deliberate differences from the Ubuntu script - **A virtualenv, not a system-wide pip install.** Windows has no distro Python to protect, and an isolated env is the only way to guarantee exactly one Qt binding — which napari/vispy OpenGL rendering requires. - **PATH is re-read from the registry after winget.** Installers update the registry, not the running process. Without the refresh `git.exe` is still missing — and `control/utils.py` imports GitPython *at module scope*, which raises `ImportError` with no git executable. That makes git a hard runtime requirement, not just an installer one. - **It refuses to guess a `configuration*.ini`.** `_def.py` globs that pattern and silently takes the first match, so the script reports when zero or several are present and lists the candidates. It does seed `illumination_channel_config.yaml` from the example (its absence degrades the GUI and fails ~13 tests) but warns loudly that the laser lines must be checked. ## Why this can't target master `setup_windows.ps1` installs PyQt6 6.11 + napari 0.7.1. On `master` that produces an environment the code cannot run in — master still sets `QT_API = "pyqt5"` and uses four APIs that don't exist in PyQt6: ``` gui_hcs.py:1441: desktop_info = QDesktopWidget() gui_hcs.py:2488: if isinstance(format_, QVariant): widgets.py:4461: self.dropdown_modeSelection.activated[str].connect(...) core.py:1686: if isinstance(sample_format, QVariant): ``` These are the exact APIs #622 fixes. They sit inside functions, so the modules import fine and the GUI then fails during construction — and because qtpy *falls back* from a missing binding with only a warning (`Selected binding 'pyqt5' could not be found; falling back to 'pyqt6'`), the resulting failure is confusing to diagnose. So this belongs on top of #622. ## Verification On Windows 11 x64, run against an existing checkout to confirm idempotency: ``` ==> Verifying the environment No broken requirements found. qtpy binding : PyQt6 Qt : 6.11.0 napari : 0.7.1 numpy : 2.5.2 opencv : 5.0.0 ``` Re-running reuses the venv, leaves an existing `illumination_channel_config.yaml` alone, detects the single `configuration*.ini`, and exits 0. `main_hcs.bat --simulation` brings up the GUI. Full suite on this branch: **1634 passed, 0 failed, 12 skipped**. ## Note on scope This makes Windows a supported platform, which is a policy question as much as a technical one — the README currently says Ubuntu 22.04 is the most tested platform and says nothing about Windows beyond the forum link. Happy to add a README section pointing at this script if you want it discoverable, or to leave it undocumented as a best-effort path.
WHY
---
The HCS GUI embeds napari viewers (Live, Multichannel, and the unified
Mosaic/Plate view) for image display. napari 0.7 targets PyQt6, but the app
was pinning the Qt binding to PyQt5 via `QT_API=pyqt5`. Running napari 0.7 +
vispy 0.16 under PyQt5 made the Mosaic view glitchy/broken and blocked moving
to Python 3.14 (PyQt5 has no 3.14 wheels; old napari pulled in pydantic v1,
which is also incompatible with 3.14). See "Python 3.14 Compatibility Check.md"
for the full dependency analysis behind these decisions.
This migrates the app to PyQt6 so the embedded napari 0.7 viewers render
correctly and the stack can run on Python 3.14 (napari 0.7.0, PyQt6 6.11,
numpy 2.4, pydantic v2).
WHAT CHANGED
------------
1. Qt binding flipped PyQt5 -> PyQt6.
`os.environ["QT_API"]` is set to "pyqt6" in every entry module that
configures it before the first qtpy import: main_hcs.py, control/gui_hcs.py,
control/widgets.py, control/console.py, control/single_instance.py,
control/core/core.py, control/core_volumetric_imaging.py,
control/core_usbspectrometer.py, control/core_PDAF.py,
control/core_displacement_measurement.py, control/widgets_usbspectrometer.py,
scripts/run_acquisition.py (child env), and tests/control/test_single_instance.py.
Almost all Qt access already goes through qtpy, which shims the common
PyQt5->PyQt6 differences (unscoped enums, .exec_(), QAction/QShortcut
re-exports), so most call sites needed no change.
2. The handful of real PyQt6 breakers that qtpy does NOT shim:
- QDesktopWidget (removed in Qt6) -> QApplication.primaryScreen() in
control/gui_hcs.py and control/core_volumetric_imaging.py.
- QVariant (removed in PyQt6): dropped the now-dead isinstance(x, QVariant)
unwrap guards in control/core/core.py and control/gui_hcs.py (PyQt6 hands
back plain Python objects).
- QComboBox.activated[str] overload removed in Qt6 -> use the dedicated
`textActivated` signal in control/widgets.py.
- tools/view_laser_af_reference_image.py: converted its direct PyQt5 imports
to qtpy and matplotlib backend_qt5agg -> backend_qtagg (binding-agnostic).
3. Shared OpenGL context for the multiple embedded napari viewers.
main_hcs.py now sets AA_ShareOpenGLContexts before the QApplication is
constructed. Several vispy/OpenGL canvases in one process must share a GL
context; without it, rendering fails with "Cannot SIZE object N ..." /
GL_INVALID_FRAMEBUFFER_OPERATION.
4. Single Qt binding requirement + setup scripts.
The blank-canvas rendering seen during bring-up was root-caused to PyQt5 and
PyQt6 being installed side by side (conflicting Qt libraries break napari's
OpenGL rendering). setup_22.04.sh and setup_cuda_22.04.sh now install exactly
one binding: PyQt6 (+ PyQt6-Qt6, PyQt6-sip) via pip, remove any pre-existing
PyQt5, move pyqtgraph to pip (apt's python3-pyqtgraph drags in python3-pyqt5),
bump napari to >=0.7,<0.8, and drop the "numpy<2" pin (napari 0.7 needs
numpy>=2). Comments explain the one-binding rule so it isn't reintroduced.
5. Mosaic view: defer GL redraws while its tab is hidden (control/widgets_mosaic.py).
Under PyQt6, painting a napari/vispy canvas that lives on a hidden QTabWidget
page fails because the hidden QOpenGLWidget has an incomplete framebuffer,
which corrupts the shared GL program and cascades into repeated
"Cannot SIZE object N because it does not exist" errors during acquisition.
The widget now writes tile pixel data unconditionally (so on-disk saves stay
correct) but skips the GL refresh/reset/fit calls while hidden, records a
pending-refresh flag, and flushes it in showEvent once the tab is visible.
This makes live-during-acquisition mosaic updates work regardless of which
tab is in front.
TESTING
-------
- Verified against the live dev environment: Python 3.14, napari 0.7.0,
PyQt6 6.11, numpy 2.4.4, pydantic 2.13.4, qtpy 2.4.3, vispy 0.16.1.
- qtpy resolves to PyQt6; full test suite collects cleanly (1435 tests).
- GUI/pytest-qt suites pass, including tests/control/test_unified_mosaic_widget.py
(its fixture builds a real napari.Viewer). The pre-existing full-suite
single-process teardown crash (napari/vispy OpenGL shutdown) is unrelated to
the binding change; run napari-heavy test files separately.
- App launches and the Mosaic/Live/Multichannel napari views render; a full
plate acquisition completes and the mosaic populates in both Full View and
Plate View.
KNOWN FOLLOW-UPS (pre-existing, not introduced here)
----------------------------------------------------
- Memory: full-view mosaic of a multi-well plate builds a large in-memory
canvas (~2GB+), which napari displays at ~4x overhead, and the end-of-run
save copies the whole canvas; back-to-back runs can stack these. Not an
unbounded leak (canvas is reused), but peak RAM is high and causes GUI-thread
stalls. Candidate fixes: bound the save-snapshot copy, pre-allocate the
full-view canvas, optionally coarser full-view render resolution.
- A napari 0.7 / vispy 0.16 layer-teardown ValueError ("list.remove(x): x not
in list") can fire when clearing layers on a mode switch. Needs a guard.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nce at completion
WHY
---
With the Mosaic view active, rendering every tile into the napari canvas during a
multi-well acquisition stalls the GUI thread (repeated GL texture rebuilds and
canvas reallocations), so the run visibly stutters and slows as it progresses. See
"Mosaic Memory Diagnosis.md" for the full analysis.
Previously "Performance Mode" avoided this by disconnecting the mosaic/multichannel
data feeds entirely — the run stayed fast, but the mosaic was never populated (the
image data only existed in the saved files on disk).
This changes Performance Mode so the mosaic still assembles during the run but
without per-tile rendering, then renders the finished mosaic once when the run
completes — you get the speed of not rendering mid-run and still see the result.
WHAT CHANGED (control/gui_hcs.py)
---------------------------------
1. updateNapariConnections: in performance mode the mosaic's data feed
(mosaic_tile_update -> updateTile, plate_view_init -> setPlateLayout) now stays
CONNECTED, so its canvas keeps building during acquisition. Rendering is what gets
suppressed: UnifiedMosaicWidget already defers all GL refresh/reset/fit work while
its tab is hidden (see UnifiedMosaicWidget.showEvent / _pending_refresh), and
performance mode keeps that tab hidden for the whole run. The remaining napari views
(e.g. multichannel) are still disconnected in performance mode as before.
2. toggleAcquisitionStart: the start/finish hook now manages the heavy tabs in
performance mode.
- start: re-apply toggleNapariTabs() so the mosaic/multichannel tabs are hidden and
disabled for the duration of the run (a previous run's completion may have
re-enabled them), guaranteeing rendering stays deferred.
- finish: re-enable the napari tabs and switch to the Mosaic view; becoming visible
fires showEvent, which flushes the single deferred refresh — the one-shot render
of the assembled mosaic.
TEST
----
tests/control/test_performance_mode.py (new): builds the simulated GUI with the mosaic
view forced on, enters performance mode, and asserts the mosaic feed remains connected,
the mosaic tab is disabled during the run, and is re-enabled + made current at
completion. It lives in its own module on purpose: forcing the mosaic napari.Viewer on
alongside the other full-GUI tests accumulates enough napari/vispy viewers to trigger
the known STATUS_HEAP_CORRUPTION on OpenGL teardown at process exit (documented; run
napari-heavy GUI test files separately). Split out, it and test_HighContentScreeningGui.py
each exit cleanly.
NOTES / TRADE-OFFS
------------------
- Performance mode now builds the full mosaic canvas in RAM during the run (same peak
as normal mode), trading the old mode's lower memory for a usable final render and
no mid-run stalls. Reducing that peak is separate follow-up work (see
"Mosaic Memory Diagnosis.md").
- The acquisition RAM pre-check is still skipped in performance mode
(check_ram_available_with_error_dialog(..., performance_mode=...)); since the canvas
is now built, re-enabling that check for performance mode is a reasonable follow-up
(left unchanged here to avoid scope creep and an existing test that asserts the skip).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…mode toggles Plain signal.connect() does not raise when the connection already exists; it adds a second one. updateNapariConnections re-ran connect() for every widget that stays connected (always the mosaic widget, and every other napari view on the way back out of performance mode), so each toggle stacked another connection and slots like updateTile ran N times per emit. Disconnect before connecting so the method is idempotent. Qt.UniqueConnection was considered but cannot be OR'd with the requested connection type (plate_view_init uses QueuedConnection) under PyQt6, where Qt.ConnectionType is a plain Enum. Adds a unit test that drives the method against a minimal stand-in and asserts exact receiver counts after repeated toggles. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…yqt6 - setup_22.04.sh: install napari via the [pyqt6] extra so pip enforces napari's Qt blocklist (napari 0.7.1 blocks PyQt6-Qt6 6.11.0/6.11.1 for dock-widget and resizing bugs, napari/napari#9052; the unpinned install put 6.11.1 on CI). Pin napari 0.7.1 / PyQt6 6.11.0 / PyQt6-Qt6 6.11.2 / PyQt6-sip 13.12.0 so installs are reproducible. Correct the comments: napari 0.7 requires neither PyQt6 nor numpy>=2 (metadata: pyqt5 extra, numpy>=1.24.2). - main.yml: the separate GUI-test step pinned QT_API/PYTEST_QT_API to pyqt5, which the setup script now uninstalls; pytest-qt aborts at startup with ModuleNotFoundError in that case. Pin to pyqt6. - widgets.py: import matplotlib's binding-agnostic backend_qtagg instead of backend_qt5agg, matching tools/view_laser_af_reference_image.py. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PyQt5 installs on 3.14 (5.15.11 wheels are cp38-abi3; PyQt5-sip 12.19.0 ships cp314 wheels), pydantic v1 is not used by our napari (0.5.x already ran on pydantic 2), and napari still ships a [pyqt5] extra. Point at the actual unknowns instead: the pinned-back packages aicsimageio and basicpy pull in, and vendor SDKs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
SimulatedCamera.close() was a no-op, so a streaming camera (the laser-AF focus camera, which LiveController.stop_live() only stops in CONTINUOUS trigger mode) kept its stream thread alive after Microscope.close()/camera_focus.close(). Make close() stop streaming, as owners assume it does. The GUI created a SlackNotifier worker thread and never closed it; _cleanup_common now does. Found by probing threads left behind by tests/control/test_performance_mode.py (which builds the full GUI in-process). Regression test for the camera half. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
#606's new test builds the full HCS GUI inside the main pytest process. Destroying that GUI segfaults (pre-existing destructor-order crash: QSplitter -> QTabWidget -> QFrame -> QVBoxLayout -> QGridLayout -> ~QHBoxLayout; reproducible by flushing DeferredDelete right after the test), and pytest-qt deleteLater()s the window at teardown. A later test's event-loop activity flushes that deferred delete — test_squid_laser_engine::test_status_updated_signal did, killing the run at 67% on every CI attempt for #606/#622 and locally under PyQt5 and PyQt6 alike. Same treatment test_HighContentScreeningGui.py already gets: own step, own process, SQUID_PYTEST_HARD_EXIT. Excluding the file from the main run, the full suite completes under PyQt6 (1551 passed). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
JobRunner.shutdown() could hang forever: after terminate()ing a worker with unread queue data, the feeder thread stays blocked writing to a pipe that no longer has a reader, and join_thread() waits on it. cancel_join_thread() first discards those bytes, which is correct when both queues are being dropped. Deterministic on Windows; latent on Linux, where a small payload happens to fit the pipe buffer and the write completes before the child is gone. test_position_caching leaked the open descriptor from tempfile.mkstemp, so the os.replace() in cache_position() was denied while a handle to the destination was still held. test_zarr_writer compared os.path.join output against POSIX literals. Those assertions are about path structure, not separator style, so normalize first. setup_22.04.sh now installs tensorstore, required by control/ndviewer_light and by test_zarr_writer, which begins with pytest.importorskip(tensorstore) -- so those ~79 zarr tests were silently skipping rather than running.
…tack (#641) > **Stacked on #622.** Base is `feat/pyqt6-napari07-migration-v2` so the diff stays to a single file. Retarget to `master` once #622 lands. ## Problem The current install line produces a **broken environment**. `basicpy` pins `scipy<1.13` ([peng-lab/BaSiCPy#173](peng-lab/BaSiCPy#173)), and scipy 1.12 ships no NumPy-2 wheel — so pip resolves the whole environment back to numpy 1.26. After running `setup_22.04.sh` as written: ``` $ pip check napari 0.7.1 has requirement scipy>=1.14.0, but you have scipy 1.12.0. ome-zarr-models 1.8.0 has requirement zarr>=3.1.1, but you have zarr 2.15.0. opencv-contrib-python-headless 5.0.0.93 has requirement numpy>=2, but you have numpy 1.26.4. opencv-python-headless 5.0.0.93 has requirement numpy>=2, but you have numpy 1.26.4. pydantic-zarr 0.10.0 has requirement numpy>=2.0.0, but you have numpy 1.26.4. ``` napari then fails to import. The comment being replaced states that the pinned-back packages "were verified to work" with NumPy 2 — that does not hold once `basicpy` is in the resolution. This isn't a "prefer newer packages" cleanup: **you cannot have both** `basicpy` and a working napari in one environment, because their scipy constraints don't intersect. ## Why removal is safe `basicpy` and `aicsimageio` are imported by exactly one file, `control/stitcher.py`, and **nothing imports that module**: - no static import — `control.stitcher` / `import stitcher` match nowhere - no dynamic import — the only `__import__` uses are the MCP server's sandbox builtins and a test mock - `tools/stitcher.py` defines its **own**, unrelated `Stitcher` class and imports neither package Stitching now lives in the separate [image-stitcher](https://github.com/Cephla-Lab/image-stitcher) repo, which the README already points users to. No code is deleted: `control/stitcher.py` stays on disk and remains usable from a dedicated venv, as the new comment explains. ## Alternative If you'd rather keep it installable in-tree, a `[stitching]` extras group would work as well — happy to rework it that way. Flagging this as the judgment call in the change, since it reverses an existing claim in the script. ## Verification With these two dropped, `pip check` reports **no broken requirements**, and the suite runs **1634 passed, 0 failed, 12 skipped** under Python 3.12 / PyQt6 6.11.0 / napari 0.7.1.
Alpaca233
force-pushed
the
feat/pyqt6-napari07-migration-v2
branch
from
October 14, 2026 17:24
7977471 to
eee1c4e
Compare
…ter, fewer copies Cleanup pass over the rebased branch; no behaviour change except where noted. - widgets_mosaic: the five "refresh now or defer until shown" branches collapse into _refresh_or_defer(); _display_active() and the four copies of its rationale go. showEvent now replays a plate-mode flush with _fit_view_to_plate() instead of resetView(), which the visible path already uses (resetView() would be re-clamped by _on_zoom_changed and show a fraction of the plate). - gui_hcs: toggleNapariTabs() becomes _set_napari_tabs_enabled(enabled) and the end-of-run branch uses it instead of a hand-rolled loop that also re-enabled the laser-AF and fluidics tabs. The mosaic's permanent connections move to make_connections as plain connect() calls; makeNapariConnections only collects the set performance mode toggles. - test_performance_mode.py folds into test_HighContentScreeningGui.py, which already runs in its own hard-exit process and builds the same GUI ten times; the extra CI step, --ignore and the duplicated confirm_exit_yes fixture go with it. The test asserts the feed with QObject.receivers() instead of disconnecting and reconnecting the real signal, and toggles via the button. - test_napari_connections: receiver counts only (no emit/wait timing), stub is a SimpleNamespace. - job_processing: the queue teardown is a loop over the two queues with a shorter note; test_stage uses tmp_path instead of mkstemp; setup_22.04.sh installs the PyQt6 pins in the same pip call as napari[pyqt6] and says the one-binding rule once. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…h bindings The signal carries an int on PyQt5 and PyQt6, but on PyQt6 Qt.Checked is a plain Enum member, so `state == Qt.Checked` is always False there. That made toggleClickToMove disconnect on every change (TypeError: 'method' object is not connected, 19 CI failures in test_holder_alignment_dialog.py and test_wellplate_format_widget.py under PyQt6) and silently broke the N/A toggles of the wavelength and source-code fields in the channel configurator. One helper, checkbox_state_is_checked(), replaces the three comparisons and the getattr(.value) dance the Preferences dialog already had. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ry fluidics run
napari 0.7.1's pytest plugin (napari.utils._testsupport) has a session-wide autouse
fixture, use_leak_safe_log_records, that installs a LogRecord factory rewriting
record.args as tuple(str(a) for a in args). When a call logs a single dict, logging
stores the dict itself as args, so iterating it yields the keys and the record no
longer formats ("not all arguments converted during string formatting"). pytest's
capture handler re-raises that into the caller.
The fluidics library logs each sequence as `_logger.debug("Running: %s", sequence)`,
so under the PyQt6 / napari 0.7 environment every fluidics run failed at its first
sequence: 5 failures in the fluidics protocol and widget tests on CI, plus a
deleted-MplCanvas error in the protocol-widget test that followed the aborted runs.
Master's napari 0.5.6 plugin has no such fixture, which is why master is green.
None of these tests use napari's fixtures, so tests/conftest.py defines an inert
fixture of the same name; a conftest fixture shadows a plugin's. Verified in a
PyQt6 6.11 / napari 0.7.1 / numpy 2.2.6 venv: the fluidics protocol and widget
tests, the holder-alignment and wellplate-format dialogs go from 24 failures to
128 passed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Supersedes #606 (
feat/pyqt6-napari07-migration). That PR's head branch lives on thejsschwrz/Squidfork, and pushes to it are rejected for the author and maintainers alike (no push permission; "allow edits from maintainers" is unavailable because the PR author doesn't own the fork), so the review items could not land there. This branch is #606's two commits rebased onto currentmaster, and the fixes on top.Review items addressed
updateNapariConnectionsis now idempotent (Copilot) — plainconnect()silently stacks a second connection, so every performance-mode toggle re-wired the mosaic widget andupdateTileran N times per emit. Disconnect-before-connect;Qt.UniqueConnectioncan't be OR'd withQueuedConnectionunder PyQt6 (Qt.ConnectionTypeis a plainEnum). Newtests/control/test_napari_connections.pyasserts exact receiver counts across toggles.napari[pyqt6]==0.7.1(Copilot + dependency review item 3) —PyQt6==6.11.0,PyQt6-Qt6==6.11.2,PyQt6-sip==13.12.0. The[pyqt6]extra makes pip enforce napari's Qt blocklist: napari 0.7.1 blocks Qt 6.11.0/6.11.1 (Mnt: Block qt6 6.11.0, 6.11.1 due to dock widget and resizing bugs napari/napari#9052, dock-widget + resizing bugs) and the unpinned install had put 6.11.1 on CI. Setup-script comments corrected — napari 0.7 requires neither PyQt6 nornumpy>=2(numpy>=1.24.2; it still ships a[pyqt5]extra).pyqt6(dependency review item 2) — master's step still saidQT_API/PYTEST_QT_API=pyqt5; with PyQt5 uninstalled by the setup script, pytest-qt aborts at startup (ModuleNotFoundError: No module named 'PyQt5'). This only shows up once Feat/pyqt6 napari07 migration #606 is updated with master, which is why it's fixed here.widgets.py:backend_qtagginstead ofbackend_qt5agg(matches thetools/change; the old import worked via matplotlib's shim but was misleading).cp38-abi3wheels + cp314 PyQt5-sip), pydantic v1 isn't in play (napari 0.5.x already ran on pydantic 2), napari still ships a pyqt5 extra. Points at the actual unknowns (aicsimageio/basicpy pins, vendor SDKs) instead.Why CI was red, and the fix
Every CI run of #606 and the first run of this PR segfaulted at 67% in
test_squid_laser_engine.py::test_status_updated_signal— same on PyQt5/3.10, PyQt6/3.10 and PyQt6/3.12 locally, so not a Qt- or Python-version issue, and not the #604 leak (that fixture is already in this branch via master). Root cause: #606's newtest_performance_mode.pybuilds the full HCS GUI in the main pytest process. Destroying the full GUI segfaults (pre-existing destructor-order crash —QSplitter → QTabWidget → QFrame → QVBoxLayout → QGridLayout → ~QHBoxLayoutunder gdb; reproducible by flushingDeferredDeleteright after the test), and pytest-qtdeleteLater()s the window at teardown, so the first later test whose event-loop activity flushes deferred deletes dies. With the file excluded, the whole suite runs to completion under PyQt6.test_HighContentScreeningGui.py, which already runs in its own hard-exit process and builds the same GUI for its other tests. (The first version of this PR gave it a file and a CI step of its own; the cleanup below folded that back.)SimulatedCamera.close()was a no-op, so the laser-AF focus camera's stream thread survivedMicroscope.close(); and the GUI never closed itsSlackNotifierworker. Both fixed, with a regression test for the camera half.Rebased onto master (2026-10-14)
napari_connectionsinto one list that performance mode disconnects wholesale. The performance-mode behaviour from Feat/pyqt6 napari07 migration #606 (the mosaic's feed stays connected so its canvas builds during the run; only the rendering is deferred until the run finishes) is ported by wiring the mosaic's connections permanently inmake_connections, so only the multichannel view is innapari_connections.test_napari_connections.pywas rewritten for the flat list; the performance-mode test still checks that the mosaic feed stays connected.state == Qt.Checkedin astateChangedslot is always False on PyQt6 (the signal carries an int,Qt.Checkedis an Enum member), sotoggleClickToMovedisconnected on every change;widgets.checkbox_state_is_checked()replaces the three such comparisons (19 failures in the holder-alignment and wellplate-format dialogs). (2) napari 0.7's pytest plugin installs a session-wide LogRecord factory that rewrites a dict argument into a tuple of its keys, so the fluidics library's("Running: %s", sequence)call raised inside every fluidics run (5 failures, plus a follow-on deleted-canvas error in the protocol-widget test);tests/conftest.pyshadows that fixture with an inert one, since no test here uses napari's fixtures._refresh_or_defer()in the mosaic widget instead of five copies of the hidden-tab branch (its show-time flush now uses_fit_view_to_plate()in plate mode, as the visible path does, instead of aresetView()the zoom clamp would undo);_set_napari_tabs_enabled(enabled)instead of a hand-rolled copy of the tab loop at run end that also re-enabled the laser-AF and fluidics tabs; receiver-count assertions instead of disconnect/reconnect probes in the tests; the PyQt6 pins in the same pip call asnapari[pyqt6].test_HighContentScreeningGui.pyis 10 passed in its own process.Deliberately not addressed
basicpy (falls back to an un-importable 1.1.0 in the new env) and the PyQt5-importing
ndviewer_lightsubmodule — both are slated for removal, so left as-is per discussion on #606.Verification
tests/control/test_napari_connections.py(new, 2 tests) fails on the old code, passes on the fix.test_HighContentScreeningGui.py) passes locally under PyQt5 and PyQt6 and leaves no threads behind.TestNDViewerTab*hittingndviewer_light's PyQt5 import — local-only (CI has no submodule checkout, so that dir imports as an empty namespace package).test_HighContentScreeningGui.pyalone: 5 passed.uv pip compileof the pinned setup-script set (py3.10) resolves: PyQt6 6.11.0 / PyQt6-Qt6 6.11.2 / napari 0.7.1 / numpy 2.2.6 / vispy 0.16.2, no PySide6.black --checkclean.🤖 Generated with Claude Code