diff --git a/dev-environment.yml b/dev-environment.yml index 05cb681b..c709f1c3 100644 --- a/dev-environment.yml +++ b/dev-environment.yml @@ -32,6 +32,7 @@ dependencies: - pyqt5-sip - pytest - pytest-cov + - pytest-env - pytest-qt - qtpy # Project from source diff --git a/pyproject.toml b/pyproject.toml index 131f1343..7e66525d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,4 +7,9 @@ requires = [ build-backend = "setuptools.build_meta" [tool.isort] -profile = "black" \ No newline at end of file +profile = "black" + +[tool.pytest.ini_options] +env = [ + "NAPARI_IMAGEJ_TESTING=yes" +] \ No newline at end of file diff --git a/setup.cfg b/setup.cfg index a3e31b6d..ed2138f2 100644 --- a/setup.cfg +++ b/setup.cfg @@ -65,6 +65,7 @@ dev = pyqt5 pytest pytest-cov + pytest-env pytest-qt qtpy numpy diff --git a/src/napari_imagej/__init__.py b/src/napari_imagej/__init__.py index 8d62b587..b84d2ded 100644 --- a/src/napari_imagej/__init__.py +++ b/src/napari_imagej/__init__.py @@ -17,10 +17,23 @@ napari-imagej is built upon the PyImageJ project: https://pyimagej.readthedocs.io/en/latest/ """ +import os +import sys + import confuse __version__ = "0.0.1.dev0" # napari-imagej uses confuse (https://confuse.readthedocs.io/en/latest/) to configure # user settings. -settings = confuse.Configuration(appname="napari-imagej", modname=__name__) +settings = confuse.Configuration(appname="napari-imagej", modname=__name__, read=False) +# Don't use user settings during the tests +use_user_settings = os.environ["NAPARI_IMAGEJ_TESTING"] != "yes" +settings.read(user=use_user_settings) + +# -- SETTING VALIDATION -- # + +# Ensure that the jvm mode is valid +jvm_mode: str = settings["jvm_mode"].as_choice(["interactive", "headless"]) +if jvm_mode == "interactive" and sys.platform == "darwin": + settings["jvm_mode"] = "headless" diff --git a/src/napari_imagej/config_default.yaml b/src/napari_imagej/config_default.yaml index 9a2c439c..f42ae76d 100644 --- a/src/napari_imagej/config_default.yaml +++ b/src/napari_imagej/config_default.yaml @@ -15,6 +15,14 @@ imagej_directory_or_endpoint: 'net.imagej:imagej' # Iff True, original ImageJ functionality (ij.* packages) will be available. include_imagej_legacy: false +# Designates the mode of execution for ImageJ2. +# Allowed options are 'headless' and 'interactive'. +# NB 'interactive' mode is unavailable on MacOS. More details can be found at +# https://pyimagej.readthedocs.io/en/latest/Initialization.html#interactive-mode +# If napari-imagej is launched on MacOS with this setting set to "interactive", +# the setting will silently be reassigned to "headless" +jvm_mode: 'interactive' + # This can be used to identify whether transferred data between ImageJ2 and napari # should be selected via activation or by user selection via a dialog. # By default, the active layer/window is chosen for transfer between applications. diff --git a/src/napari_imagej/java.py b/src/napari_imagej/java.py index c58642dd..d8245f9c 100644 --- a/src/napari_imagej/java.py +++ b/src/napari_imagej/java.py @@ -16,8 +16,8 @@ - object whose fields are lazily-loaded Java Class instances. """ import os -import sys from multiprocessing.pool import AsyncResult, ThreadPool +from threading import Lock from typing import Callable import imagej @@ -35,25 +35,14 @@ def ij(): Returns the ImageJ instance. If it isn't ready yet, blocks until it is ready. """ - return ij_future.get() + return imagej_init().get() def ensure_jvm_started() -> None: """ Blocks until the ImageJ instance is ready. """ - ij_future.wait() - - -def _get_mode() -> str: - """ - Returns the mode ImageJ will be run in - """ - return "headless" if sys.platform == "darwin" else "interactive" - - -def jvm_is_headless() -> bool: - return _get_mode() == "headless" + imagej_init().wait() def _imagej_init(): @@ -74,7 +63,7 @@ def _imagej_init(): # Launch PyImageJ _ij = imagej.init( ij_dir_or_version_or_endpoint=settings["imagej_directory_or_endpoint"].get(str), - mode=_get_mode(), + mode=settings["jvm_mode"].get(str), add_legacy=settings["include_imagej_legacy"].get(bool), ) log_debug(f"Initialized at version {_ij.getVersion()}") @@ -83,21 +72,33 @@ def _imagej_init(): return _ij -# There is a good debate to be had whether to multithread or multiprocess. -# From what I (Gabe) have read, it seems that threading is preferrable for -# network / IO bottlenecking, while multiprocessing is preferrable for CPU -# bottlenecking. -# While multiprocessing might theoretically be a better choice for JVM startup, -# there are two reasons we instead choose multithreading: -# 1) Multiprocessing is not supported without additional libraries on MacOS. -# See https://docs.python.org/3/library/multiprocessing.html#introduction -# 2) JPype items cannot (currently) be passed between processes due to an -# issue with pickling. See -# https://github.com/imagej/napari-imagej/issues/27#issuecomment-1130102033 -threadpool: ThreadPool = ThreadPool(processes=1) -# ij_future is not very pythonic, but we are dealing with a Java Object -# and it better conveys the object's meaning than e.g. ij_result -ij_future: AsyncResult = threadpool.apply_async(func=_imagej_init) +init_lock = Lock() +_ij_future: AsyncResult = None + + +def imagej_init() -> AsyncResult: + """Function that""" + global _ij_future + if not _ij_future: + with init_lock: + if not _ij_future: + # There is a good debate to be had whether to multithread or + # multiprocess. From what I (Gabe) have read, it seems that threading + # is preferrable for network / IO bottlenecking, while multiprocessing + # is preferrable for CPU bottlenecking. While multiprocessing might + # theoretically be a better choice for JVM startup, there are two + # reasons we instead choose multithreading: + # 1) Multiprocessing is not supported without additional libraries on + # MacOS. See + # https://docs.python.org/3/library/multiprocessing.html#introduction + # 2) JPype items cannot (currently) be passed between processes due to + # an issue with pickling. See + # https://github.com/imagej/napari-imagej/issues/27#issuecomment-1130102033 + threadpool: ThreadPool = ThreadPool(processes=1) + # ij_future is not very pythonic, but we are dealing with a Java Object + # and it better conveys the object's meaning than e.g. ij_result + _ij_future = threadpool.apply_async(func=_imagej_init) + return _ij_future class JavaClasses(object): diff --git a/src/napari_imagej/widgets/menu.py b/src/napari_imagej/widgets/menu.py index 1d159c75..8e4a761e 100644 --- a/src/napari_imagej/widgets/menu.py +++ b/src/napari_imagej/widgets/menu.py @@ -15,7 +15,7 @@ from qtpy.QtWidgets import QHBoxLayout, QMessageBox, QPushButton, QWidget from napari_imagej import settings -from napari_imagej.java import ensure_jvm_started, ij, jc, jvm_is_headless, log_debug +from napari_imagej.java import ensure_jvm_started, ij, jc, log_debug from napari_imagej.resources import resource_path from napari_imagej.utilities._module_utils import _get_layers_hack @@ -38,7 +38,7 @@ def __init__(self, viewer: Viewer): self.settings_button: SettingsButton = SettingsButton(viewer) self.layout().addWidget(self.settings_button) - if jvm_is_headless(): + if settings["jvm_mode"].get(str) == "headless": self.gui_button.clicked.connect(self.gui_button.disable_popup) else: self.gui_button.clicked.connect(self._showUI) @@ -243,7 +243,7 @@ def __init__(self): super().__init__() self.setEnabled(False) - if jvm_is_headless(): + if settings["jvm_mode"].get(str) == "headless": self._setup_headless() else: self._setup_headful() diff --git a/src/napari_imagej/widgets/napari_imagej.py b/src/napari_imagej/widgets/napari_imagej.py index 6a50b5b2..80a601ac 100644 --- a/src/napari_imagej/widgets/napari_imagej.py +++ b/src/napari_imagej/widgets/napari_imagej.py @@ -7,6 +7,7 @@ from napari import Viewer from qtpy.QtWidgets import QTreeWidgetItem, QVBoxLayout, QWidget +from napari_imagej.java import imagej_init from napari_imagej.widgets.menu import NapariImageJMenu from napari_imagej.widgets.result_runner import ResultRunner from napari_imagej.widgets.result_tree import SearchResultTree, SearchResultTreeItem @@ -20,6 +21,9 @@ def __init__(self, napari_viewer: Viewer): super().__init__() self.setLayout(QVBoxLayout()) + # First things first, let's start up imagej (in the background) + imagej_init() + # -- NapariImageJWidget construction -- # # At the top: the napari-imagej menu diff --git a/tests/conftest.py b/tests/conftest.py index 685480c2..47ec68ed 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -13,8 +13,8 @@ @pytest.fixture(autouse=True) -def confuse_settings(): - """Fixture ensuring user settings are not used in tests""" +def install_default_settings(): + """Fixture ensuring any changes made earlier to the settings are reversed""" napari_imagej.settings.clear() napari_imagej.settings.read(user=False) diff --git a/tests/widgets/test_menu.py b/tests/widgets/test_menu.py index 5f763ebb..cb5d6512 100644 --- a/tests/widgets/test_menu.py +++ b/tests/widgets/test_menu.py @@ -14,7 +14,6 @@ from qtpy.QtWidgets import QApplication, QHBoxLayout, QMessageBox from napari_imagej import settings -from napari_imagej.java import jvm_is_headless from napari_imagej.resources import resource_path from napari_imagej.widgets import menu from napari_imagej.widgets.menu import ( @@ -27,7 +26,7 @@ from tests.utils import jc # Determine whether we are testing headlessly -TESTING_HEADLESS: bool = jvm_is_headless() +TESTING_HEADLESS: bool = settings["jvm_mode"].get(str) == "headless" @pytest.fixture(autouse=True)