diff --git a/src/napari_imagej/java.py b/src/napari_imagej/java.py index b4a132d3..2e781994 100644 --- a/src/napari_imagej/java.py +++ b/src/napari_imagej/java.py @@ -11,14 +11,14 @@ * jc - object whose fields are lazily-loaded Java Class instances. """ -from typing import Any, Callable, Dict +from typing import Any, Callable, Dict, List import imagej from jpype import JClass from scyjava import config, get_version, is_version_at_least, jimport, jvm_started -from napari_imagej import settings -from napari_imagej.utilities.logging import log_debug +from napari_imagej import __version__, settings +from napari_imagej.utilities.logging import log_debug, warn # -- Constants -- @@ -34,9 +34,18 @@ "sc.fiji:TrackMate": "7.11.0", } +recommended_versions = { + "net.imagej:imagej": "2.10.0", + "net.imagej:imagej-legacy": "1.2.1", + "net.imagej:imagej-ops": "2.0.1", + "org.scijava:scijava-search": "2.0.4", + "sc.fiji:fiji": "2.10.0", +} + # -- ImageJ API -- # _ij = None +_init_warnings: List[str] = [] def ij(): @@ -47,6 +56,14 @@ def ij(): return _ij +def init_warnings(): + if _ij is None: + raise Exception( + "The ImageJ instance has not yet been initialized! Please run init_ij()" + ) + return _init_warnings + + def init_ij() -> "jc.ImageJ": """ Creates the ImageJ instance @@ -119,11 +136,13 @@ def _validate_imagej(): # If we want to require a minimum version for a java component, we need to # be able to find our current version. We do that by querying a Java class # within that component. + ImageJ = jimport("net.imagej.Main") RGRAI = jimport("net.imglib2.python.ReferenceGuardingRandomAccessibleInterval") SCIFIO = jimport("io.scif.SCIFIO") UnsafeImg = jimport("net.imglib2.img.unsafe.UnsafeImg") component_requirements = { "io.scif:scifio": SCIFIO, + "net.imagej:imagej": ImageJ, "net.imagej:imagej-common": jc.Dataset, "net.imagej:imagej-ops": jc.OpInfo, "net.imglib2:imglib2-unsafe": UnsafeImg, @@ -135,6 +154,8 @@ def _validate_imagej(): # Find version that violate the minimum violations = [] for component, cls in component_requirements.items(): + if component not in minimum_versions: + continue min_version = minimum_versions[component] component_version = get_version(cls) if not is_version_at_least(component_version, min_version): @@ -152,6 +173,29 @@ def _validate_imagej(): ) raise RuntimeError(failure_str) + # Find versions below recommended + violations = [] + for component, cls in component_requirements.items(): + if component not in recommended_versions: + continue + recommended_version = recommended_versions[component] + component_version = get_version(cls) + if not is_version_at_least(component_version, recommended_version): + violations.append( + f"{component} : {recommended_version} (Installed: {component_version})" + ) + + # If there are older versions, warn the user + if violations: + failure_str = ( + f"napari-imagej v{__version__} recommends using " + "the following component versions:" + ) + violations.insert(0, failure_str) + failure_str = "\n\t".join(violations) + _init_warnings.append(failure_str) + warn(failure_str) + def _optional_requirements(): optionals = {} @@ -159,10 +203,15 @@ def _optional_requirements(): if _ij.legacy and _ij.legacy.isActive(): optionals["net.imagej:imagej-legacy"] = _ij.legacy.getClass() # Add additional minimum versions for fiji components - try: - optionals["sc.fiji:TrackMate"] = jimport("fiji.plugin.trackmate.TrackMate") - except Exception: - pass + optional_classes = { + "sc.fiji:TrackMate": "fiji.plugin.trackmate.TrackMate", + "sc.fiji:Fiji": "fiji.Main", + } + for artifact, cls in optional_classes.items(): + try: + optionals[artifact] = jimport(cls) + except Exception: + pass return optionals diff --git a/src/napari_imagej/settings.py b/src/napari_imagej/settings.py index d6adc5f0..f739ab87 100644 --- a/src/napari_imagej/settings.py +++ b/src/napari_imagej/settings.py @@ -35,6 +35,11 @@ Additional command line arguments to pass to the Java Virtual Machine (JVM). For example, "-Xmx4g" to allow Java to use up to 4 GB of memory. By default, no arguments are passed. + +display_imagej_initialization_warnings: bool = True + When napari-imagej encounters a warnable issue pertaining to Java components + it will display the warnings iff this flag is true. + Defaults to True. """ import os @@ -56,6 +61,7 @@ "include_imagej_legacy": True, "enable_imagej_gui": True, "jvm_command_line_arguments": "", + "display_java_warnings": True, } # -- Configuration options -- @@ -65,6 +71,7 @@ include_imagej_legacy: bool = defaults["include_imagej_legacy"] enable_imagej_gui: bool = defaults["enable_imagej_gui"] jvm_command_line_arguments: str = defaults["jvm_command_line_arguments"] +display_java_warnings: bool = defaults["display_java_warnings"] _test_mode = bool(os.environ.get("NAPARI_IMAGEJ_TESTING", None)) _is_macos = sys.platform == "darwin" diff --git a/src/napari_imagej/widgets/napari_imagej.py b/src/napari_imagej/widgets/napari_imagej.py index 6893555d..499b2469 100644 --- a/src/napari_imagej/widgets/napari_imagej.py +++ b/src/napari_imagej/widgets/napari_imagej.py @@ -5,7 +5,7 @@ This Widget is made accessible to napari through napari.yml """ from traceback import format_exception -from typing import Callable +from typing import Callable, List from jpype import JArray, JImplements, JOverride from magicgui.widgets import FunctionGui, Widget @@ -15,7 +15,8 @@ from qtpy.QtWidgets import QTreeWidgetItem, QVBoxLayout, QWidget from scyjava import isjava, jstacktrace, when_jvm_stops -from napari_imagej.java import ij, init_ij, jc +from napari_imagej import settings +from napari_imagej.java import ij, init_ij, init_warnings, jc from napari_imagej.utilities._module_utils import _non_layer_widget from napari_imagej.utilities.event_subscribers import ( NapariEventSubscriber, @@ -33,7 +34,10 @@ SearchResultTreeItem, ) from napari_imagej.widgets.searchbar import JVMEnabledSearchbar -from napari_imagej.widgets.widget_utils import JavaErrorMessageBox +from napari_imagej.widgets.widget_utils import ( + JavaErrorMessageBox, + JavaWarningMessageBox, +) class NapariImageJWidget(QWidget): @@ -42,6 +46,7 @@ class NapariImageJWidget(QWidget): output_handler = Signal(object) progress_handler = Signal(object) ij_error_handler = Signal(object) + ij_warning_handler = Signal(object) def __init__(self, napari_viewer: Viewer): super().__init__() @@ -118,6 +123,7 @@ def return_search_bar(): self.output_handler.connect(self._handle_output) self.progress_handler.connect(self._update_progress) self.ij_error_handler.connect(self._handle_ij_init_error) + self.ij_warning_handler.connect(self._handle_ij_init_warning) # -- Final setup -- # @@ -216,6 +222,24 @@ def _handle_ij_init_error(self, exc: Exception): msg: JavaErrorMessageBox = JavaErrorMessageBox(title, exception_str) msg.exec() + @Slot(object) + def _handle_ij_init_warning(self, warnings: List[Exception]): + """ + Handles warnings associated initializing ImageJ. + Initializing ImageJ can fail for all sorts of reasons, + so we give it special attention here. + + NB: This MUST be done within this slot, as slot functions + are run on the GUI thread. napari-imagej runs ImageJ initialization + on a separate Qt thread, which isn't the GUI thread. + """ + # Print thet error + if not settings.display_java_warnings or len(warnings) == 0: + return + title = "During the initialization of ImageJ, warnings were raised:" + msg: JavaWarningMessageBox = JavaWarningMessageBox(title, "\n\n".join(warnings)) + msg.exec() + class ImageJInitializer(QThread): """ @@ -236,6 +260,8 @@ def run(self): try: # Initialize ImageJ init_ij() + # Log any warnings + self.widget.ij_warning_handler.emit(init_warnings()) # Finalize the menu self.widget.menu.finalize() # Finalize the search bar @@ -249,6 +275,7 @@ def run(self): except Exception as e: # Handle the exception on the GUI thread self.widget.ij_error_handler.emit(e) + return def _finalize_results_tree(self): """ diff --git a/src/napari_imagej/widgets/widget_utils.py b/src/napari_imagej/widgets/widget_utils.py index 578d829b..095fd305 100644 --- a/src/napari_imagej/widgets/widget_utils.py +++ b/src/napari_imagej/widgets/widget_utils.py @@ -7,6 +7,7 @@ from qtpy.QtGui import QFontMetrics from qtpy.QtWidgets import ( QApplication, + QCheckBox, QComboBox, QDialog, QDialogButtonBox, @@ -20,6 +21,7 @@ QWidget, ) +from napari_imagej import settings from napari_imagej.java import ij, jc from napari_imagej.utilities._module_utils import ( execute_function_modally, @@ -140,6 +142,48 @@ def __init__(self, title: str, error_message: str, *args, **kwargs): self.layout().addWidget(btn_box, 2, 0, 1, self.layout().columnCount()) +class JavaWarningMessageBox(QDialog): + def __init__(self, title: str, error_message: str, *args, **kwargs): + QDialog.__init__(self, *args, **kwargs) + self.setLayout(QGridLayout()) + # Write the title to a Label + self.layout().addWidget( + QLabel(title, self), 0, 0, 1, self.layout().columnCount() + ) + + # Write the error message to a TextEdit + msg_edit = QTextEdit(self) + msg_edit.setReadOnly(True) + msg_edit.setText(error_message) + self.layout().addWidget(msg_edit, 1, 0, 1, self.layout().columnCount()) + msg_edit.setLineWrapMode(0) + + # Default size - size of the error message + font = msg_edit.document().defaultFont() + fontMetrics = QFontMetrics(font) + textSize = fontMetrics.size(0, error_message) + textWidth = textSize.width() + 100 + textHeight = textSize.height() + 100 + self.resize(textWidth, textHeight) + # Maximum size - ~80% of the user's screen + screen_size = QApplication.desktop().screenGeometry() + self.setMaximumSize( + int(screen_size.width() * 0.8), int(screen_size.height() * 0.8) + ) + self.checkbox: QCheckBox = QCheckBox("Don't warn me again") + self.layout().addWidget(self.checkbox, 2, 0, 1, self.layout().columnCount()) + + btn_box = QDialogButtonBox(QDialogButtonBox.Ok) + btn_box.accepted.connect(self.accept) + self.layout().addWidget(btn_box, 3, 0, 1, self.layout().columnCount()) + + def accept(self): + super().accept() + if self.checkbox.isChecked(): + settings.display_java_warnings = False + settings.save() + + _IMAGE_LAYER_TYPES = (Image, Labels) _ROI_LAYER_TYPES = (Points, Shapes) diff --git a/tests/test_java.py b/tests/test_java.py index 2f0f4b22..e19b1a89 100644 --- a/tests/test_java.py +++ b/tests/test_java.py @@ -2,8 +2,8 @@ from scyjava import get_version, is_version_at_least, jimport -from napari_imagej import settings -from napari_imagej.java import minimum_versions +from napari_imagej import __version__, settings +from napari_imagej.java import _validate_imagej, init_warnings, minimum_versions version_checks = { "io.scif:scifio": "io.scif.SCIFIO", @@ -57,3 +57,28 @@ def test_endpoint(ij): version = gav[2] exp_version = get_version(jimport(version_checks[ga])) assert is_version_at_least(version, exp_version) + + +def test_recommended_version(ij): + # Save old recommended versions + import napari_imagej.java + + existing_recommendations = napari_imagej.java.recommended_versions + existing_warnings = [w for w in napari_imagej.java._init_warnings] + napari_imagej.java.recommended_versions = {"org.scijava:scijava-common": "999.0.0"} + + # Validate ImageJ - capture lower-than-recommended version + _validate_imagej() + warnings = init_warnings() + assert len(warnings) == 1 + + # Assert warning given + assert warnings[0] == ( + f"napari-imagej v{__version__} recommends using the " + "following component versions:\n\torg.scijava:scijava-common : " + "999.0.0 (Installed: 2.94.1)" + ) + + # restore recommended versions + napari_imagej.java.recommended_versions = existing_recommendations + napari_imagej.java._init_warnings = existing_warnings diff --git a/tests/widgets/test_napari_imagej.py b/tests/widgets/test_napari_imagej.py index b6fe770a..5b23db45 100644 --- a/tests/widgets/test_napari_imagej.py +++ b/tests/widgets/test_napari_imagej.py @@ -19,7 +19,10 @@ from napari_imagej.widgets.napari_imagej import NapariImageJWidget, ResultRunner from napari_imagej.widgets.result_tree import SearchResultTree from napari_imagej.widgets.searchbar import JVMEnabledSearchbar -from napari_imagej.widgets.widget_utils import JavaErrorMessageBox +from napari_imagej.widgets.widget_utils import ( + JavaErrorMessageBox, + JavaWarningMessageBox, +) from tests.utils import jc from tests.widgets.widget_utils import _searcher_tree_named @@ -307,3 +310,30 @@ def new_exec(self): # Finally, restore JavaErrorMessageBox.exec JavaErrorMessageBox.exec = old_exec + + +def test_handle_ij_init_warning(imagej_widget: NapariImageJWidget): + """ + Ensure that napari-imagej's ij init warnings are displayed correctly + """ + title = "" + contents = "" + + # first, mock JavaErrorMessageBox.exec + old_exec = JavaWarningMessageBox.exec + + def new_exec(self): + nonlocal title, contents + title = self.findChild(QLabel).text() + contents = self.findChild(QTextEdit).toPlainText() + + JavaWarningMessageBox.exec = new_exec + + # Then, test a Java exception is correctly configured + warnings = ["This is a warning"] + imagej_widget._handle_ij_init_warning(warnings) + assert title == "During the initialization of ImageJ, warnings were raised:" + assert contents == "This is a warning" + + # Finally, restore JavaErrorMessageBox.exec + JavaWarningMessageBox.exec = old_exec