diff --git a/CHANGELOG.md b/CHANGELOG.md index 07fc13f73..5a0549935 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added `requests>=2.31.0` dependency for HTTP communication - Comprehensive test suite for LLM service and chat panel +## [0.7.1] - 2026-07-30 + +### Added + +- The desktop GUI can update its exact virtual environment in place, restart, + and restore the complete plate-manager document, UI configuration, and typed + ObjectState history. The detached updater runs outside the target + environment so Windows can replace active environment files safely. +- The update check is available through the existing typed main-window MCP + action surface. + +### Changed + +- UI configuration forms now retain annotated validation metadata while using + dedicated key-sequence and finite color controls instead of free-text + editing. +- Placeholder and enabled-state styling is applied as fields materialize + instead of appearing only after a large form finishes constructing. + +### Fixed + +- ObjectState 1.1 history persistence now preserves paths, enums, dataclasses, + callable objects, shared identity, the active timeline position, and unsaved + typed state across application restarts. +- Function-pattern add and reset operations no longer fail on invalid transient + editor text, and selectors support registered plate-scoped functions that do + not consume image arrays. +- Compact UI and MCP parameter help render the owning type of annotated + parameters without exposing raw `typing.Annotated` representations. +- ZMQ submission timeouts now cover progress-stream registration as well as + execution dispatch, preventing installer smoke runs and slow-starting + runtimes from falling back to an unrelated five-second handshake limit. + ## [0.7.0] - 2026-07-30 ### Changed diff --git a/external/ObjectState b/external/ObjectState index 1b433ed2f..97b92e6e5 160000 --- a/external/ObjectState +++ b/external/ObjectState @@ -1 +1 @@ -Subproject commit 1b433ed2f191e7497c202670d194fc774573c1aa +Subproject commit 97b92e6e5622c3a0ea581b96a2a791535a6d9a62 diff --git a/external/pyqt-reactive b/external/pyqt-reactive index bbbe1224b..bcce66a00 160000 --- a/external/pyqt-reactive +++ b/external/pyqt-reactive @@ -1 +1 @@ -Subproject commit bbbe1224bc509568678fc5fd163b88b16e720854 +Subproject commit bcce66a0038f929c113fe884274f8737af9d11d9 diff --git a/external/python-introspect b/external/python-introspect index c4af75793..161a4ca16 160000 --- a/external/python-introspect +++ b/external/python-introspect @@ -1 +1 @@ -Subproject commit c4af75793ebef320aa45c00c94144d4c3a2d309e +Subproject commit 161a4ca162ed8749657e03489ee5b2ee9940c359 diff --git a/external/zmqruntime b/external/zmqruntime index e96420f1b..92b232ce1 160000 --- a/external/zmqruntime +++ b/external/zmqruntime @@ -1 +1 @@ -Subproject commit e96420f1be34cef287c8e4f1baf00eb226f26201 +Subproject commit 92b232ce19931ae15f3efe8382f5575fa1712fde diff --git a/openhcs/__init__.py b/openhcs/__init__.py index f84d48617..b5a7161c4 100644 --- a/openhcs/__init__.py +++ b/openhcs/__init__.py @@ -14,7 +14,7 @@ from openhcs._source_dependencies import ensure_source_checkout_external_paths -__version__ = "0.7.0" +__version__ = "0.7.1" # Configure polystore defaults for OpenHCS integration os.environ.setdefault("POLYSTORE_METADATA_FILENAME", "openhcs_metadata.json") diff --git a/openhcs/agent/ui_bridge_actions.py b/openhcs/agent/ui_bridge_actions.py index 7296d0649..16efb0dbd 100644 --- a/openhcs/agent/ui_bridge_actions.py +++ b/openhcs/agent/ui_bridge_actions.py @@ -13,6 +13,35 @@ class PlateOperation(str, Enum): RUN = "run" +class MainWindowAction(str, Enum): + """Closed set of agent-facing main-window actions.""" + + title: str + side_effects: tuple[str, ...] + confirmation_required: bool + + def __new__( + cls, + value: str, + title: str, + side_effects: tuple[str, ...], + confirmation_required: bool, + ) -> "MainWindowAction": + member = str.__new__(cls, value) + member._value_ = value + member.title = title + member.side_effects = side_effects + member.confirmation_required = confirmation_required + return member + + CHECK_FOR_UPDATES = ( + "check_for_updates", + "Check for Updates", + ("checks_trusted_release_service", "may_open_update_confirmation"), + False, + ) + + class PlateManagerAction(str, Enum): """Closed set of PlateManager button actions and agent-facing semantics.""" diff --git a/openhcs/processing/backends/lib_registry/unified_registry.py b/openhcs/processing/backends/lib_registry/unified_registry.py index e56ba3c37..adaa49724 100644 --- a/openhcs/processing/backends/lib_registry/unified_registry.py +++ b/openhcs/processing/backends/lib_registry/unified_registry.py @@ -1193,15 +1193,17 @@ def display_name(self) -> str: return self.original_name return self.name - def get_memory_type(self) -> str: + def get_memory_type(self) -> str | None: """ - Get the actual memory type (backend) of this function. + Get the actual memory type (backend), if the function consumes arrays. Returns the memory type recorded at metadata creation time, otherwise the registry-level memory type for older cache entries. Returns: - Memory type string (e.g., "cupy", "numpy", "torch", "pyclesperanto") + Memory type string (e.g., "cupy", "numpy", "torch", + "pyclesperanto"), or ``None`` for plate-scoped functions that do + not consume image arrays. """ if self.memory_type is not None: return self.memory_type diff --git a/openhcs/pyqt_gui/launch.py b/openhcs/pyqt_gui/launch.py index 29fdfa07a..bf13e123a 100644 --- a/openhcs/pyqt_gui/launch.py +++ b/openhcs/pyqt_gui/launch.py @@ -285,6 +285,14 @@ def parse_arguments(): action='version', version='OpenHCS PyQt6 GUI 1.0.0' ) + + from openhcs.pyqt_gui.services.desktop_update import UPDATE_SESSION_ARGUMENT + + parser.add_argument( + UPDATE_SESSION_ARGUMENT, + type=Path, + help=argparse.SUPPRESS, + ) return parser.parse_args() @@ -438,6 +446,49 @@ def main( install_global_window_bounds_filter(app) # install once, early def _main_window_ready() -> None: + from openhcs.pyqt_gui.services.desktop_update import DesktopUpdateSession + from PyQt6.QtWidgets import QMessageBox + + session = ( + DesktopUpdateSession(args.restore_update_session) + if args.restore_update_session is not None + else DesktopUpdateSession.pending() + ) + if session.directory.exists(): + if not session.is_complete: + QMessageBox.warning( + app.main_window, + "OpenHCS Update Recovery", + "OpenHCS found an incomplete saved update session. The " + f"recovery files were preserved at:\n\n{session.directory}", + ) + else: + try: + update_error = session.restore(app.main_window) + except Exception as error: + logging.exception("Failed to restore the saved update session") + QMessageBox.warning( + app.main_window, + "OpenHCS Update Recovery", + "OpenHCS reopened, but could not restore the saved " + "session. The recovery files were preserved at:\n\n" + f"{session.directory}\n\n{type(error).__name__}: {error}", + ) + else: + if update_error: + QMessageBox.warning( + app.main_window, + "OpenHCS Update Failed", + "The update failed, so OpenHCS reopened the prior " + f"installation and restored the session.\n\n{update_error}", + ) + else: + QMessageBox.information( + app.main_window, + "OpenHCS Updated", + "OpenHCS updated successfully and restored the " + "working session and ObjectState history.", + ) if startup_progress is not None: startup_progress.ready() diff --git a/openhcs/pyqt_gui/main.py b/openhcs/pyqt_gui/main.py index 23a57d56e..926b5d0ca 100644 --- a/openhcs/pyqt_gui/main.py +++ b/openhcs/pyqt_gui/main.py @@ -34,6 +34,8 @@ from openhcs.pyqt_gui.services.desktop_update import ( DesktopUpdate, DesktopUpdateError, + DesktopRuntimeEnvironment, + DesktopUpdateSession, DesktopUpdateService, ) from objectstate.object_state import ObjectState @@ -1464,40 +1466,60 @@ def _on_update_check_completed(self, update: DesktopUpdate) -> None: return self.status_message.emit(f"OpenHCS {update.latest_version} is available") - destination = ( - "the official installer download" - if update.has_native_installer - else "the official release page" - ) - handoff_detail = ( - "After downloading, open the installer. It creates and validates a " - "new environment before switching the OpenHCS launcher." - if update.has_native_installer - else "The release page contains the supported update downloads and " - "instructions for this platform." - ) response = QMessageBox.question( self, "OpenHCS Update Available", f"OpenHCS {update.latest_version} is available " f"(installed: {update.installed_version}).\n\n" - f"Open {destination} now?\n\n{handoff_detail}", - QMessageBox.StandardButton.Open | QMessageBox.StandardButton.Cancel, - QMessageBox.StandardButton.Open, + "Install the update now? OpenHCS will save the complete working " + "session and ObjectState history, close, update its current " + "environment, then reopen and restore the session.", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel, + QMessageBox.StandardButton.Yes, ) - if response != QMessageBox.StandardButton.Open: + if response != QMessageBox.StandardButton.Yes: return + session = None try: - opened = self.desktop_update_service.open_update(update) + runtime = DesktopRuntimeEnvironment.current() + session = DesktopUpdateSession.capture(self) + started = self.desktop_update_service.start_update( + update, + runtime=runtime, + session=session, + ) except DesktopUpdateError as exc: - opened = False - logger.warning("Refused OpenHCS update handoff: %s", exc) - if not opened: + if session is not None: + session.discard() + logger.warning("Automatic OpenHCS update is unavailable: %s", exc) + QMessageBox.warning( + self, + "OpenHCS Updates", + f"OpenHCS could not start the automatic update.\n\n{exc}", + ) + return + except Exception as exc: + if session is not None: + session.discard() + logger.exception("Failed to prepare the OpenHCS update") + QMessageBox.warning( + self, + "OpenHCS Updates", + f"OpenHCS could not save and start the update.\n\n{exc}", + ) + return + if not started: + session.discard() QMessageBox.warning( self, "OpenHCS Updates", - "The system browser could not open the official update destination.", + "OpenHCS could not start the background updater. The current " + "application and session are unchanged.", ) + return + + self.status_message.emit("OpenHCS update prepared; restarting…") + self.close() def _on_update_check_failed(self, error_message: str) -> None: self.check_for_updates_action.setEnabled(True) diff --git a/openhcs/pyqt_gui/services/desktop_update.py b/openhcs/pyqt_gui/services/desktop_update.py index a8bae81b7..fc2e85749 100644 --- a/openhcs/pyqt_gui/services/desktop_update.py +++ b/openhcs/pyqt_gui/services/desktop_update.py @@ -2,30 +2,308 @@ from __future__ import annotations -from dataclasses import dataclass -from importlib.metadata import version as distribution_version import json +import os import platform -from typing import Callable +import shutil +import sys +from collections.abc import Callable +from dataclasses import dataclass +from importlib.metadata import distribution +from importlib.metadata import version as distribution_version +from importlib.util import find_spec +from pathlib import Path from urllib.parse import urlparse from packaging.version import InvalidVersion, Version -from PyQt6.QtCore import QByteArray, QObject, QUrl, pyqtSignal +from PyQt6.QtCore import QByteArray, QObject, QProcess, QUrl, pyqtSignal from PyQt6.QtGui import QDesktopServices from PyQt6.QtNetwork import QNetworkAccessManager, QNetworkReply, QNetworkRequest - LATEST_RELEASE_API_URL = ( "https://api.github.com/repos/OpenHCSDev/openhcs/releases/latest" ) _OFFICIAL_RELEASE_HOST = "github.com" _OFFICIAL_RELEASE_PATH = "/OpenHCSDev/openhcs/releases/" +_SESSION_DOCUMENT_NAME = "session.py" +_HISTORY_DOCUMENT_NAME = "objectstate-history.objectstate" +_WORKER_DOCUMENT_NAME = "desktop-update-worker.py" +_UPDATE_ERROR_NAME = "update-error.txt" +_UV_EXECUTABLE_ENV = "OPENHCS_UV_EXECUTABLE" +UPDATE_SESSION_ARGUMENT = "--restore-update-session" class DesktopUpdateError(RuntimeError): """Raised when official release metadata cannot produce a safe update.""" +@dataclass(frozen=True, slots=True) +class DesktopUpdateCommandPlan: + """One argument-vector update command for the exact running environment.""" + + executable: Path + arguments: tuple[str, ...] + + @classmethod + def for_environment( + cls, + *, + python_executable: Path, + latest_version: Version, + environment: dict[str, str] | None = None, + ) -> DesktopUpdateCommandPlan: + environment_values = os.environ if environment is None else environment + configured_uv = environment_values.get(_UV_EXECUTABLE_ENV) + uv_executable = Path(configured_uv).expanduser() if configured_uv else None + if uv_executable is None: + discovered_uv = shutil.which("uv") + if discovered_uv is not None: + uv_executable = Path(discovered_uv) + requirement = f"openhcs=={latest_version}" + if uv_executable is not None and uv_executable.is_file(): + return cls( + executable=uv_executable.resolve(), + arguments=( + "--no-config", + "pip", + "install", + "--python", + str(python_executable), + "--upgrade", + requirement, + ), + ) + if find_spec("pip") is None: + raise DesktopUpdateError( + "The running OpenHCS environment has neither an available uv " + "executable nor its own pip module. Use the official installer " + "or release instructions to update this installation." + ) + return cls( + executable=python_executable, + arguments=( + "-m", + "pip", + "install", + "--disable-pip-version-check", + "--no-input", + "--upgrade", + requirement, + ), + ) + + +@dataclass(frozen=True, slots=True) +class DesktopRuntimeEnvironment: + """Validated installed virtual environment that owns the running GUI.""" + + python_executable: Path + worker_python_executable: Path + environment_root: Path + restart_executable: Path + restart_arguments: tuple[str, ...] + + @classmethod + def current(cls) -> DesktopRuntimeEnvironment: + python_executable = Path(sys.executable).absolute() + worker_python_executable = Path(sys._base_executable).resolve() + environment_root = Path(sys.prefix).resolve() + base_prefix = Path(sys.base_prefix).resolve() + if environment_root == base_prefix: + raise DesktopUpdateError( + "Automatic updates require OpenHCS to run from a virtual " + "environment. Use the official installer or release instructions " + "for this installation." + ) + if ( + not python_executable.is_file() + or not python_executable.is_relative_to(environment_root) + ): + raise DesktopUpdateError( + "The running Python executable does not belong to the OpenHCS " + f"virtual environment: {python_executable}" + ) + if ( + not worker_python_executable.is_file() + or worker_python_executable.is_relative_to(environment_root) + ): + raise DesktopUpdateError( + "Automatic updates require a base Python interpreter outside " + "the OpenHCS environment. Use the official installer or release " + "instructions to update this installation." + ) + + installed_distribution = distribution("openhcs") + distribution_root = Path(installed_distribution.locate_file("")).resolve() + direct_url_text = installed_distribution.read_text("direct_url.json") + if direct_url_text is not None: + try: + direct_url = json.loads(direct_url_text) + except json.JSONDecodeError as exc: + raise DesktopUpdateError( + "The installed OpenHCS distribution has invalid origin metadata." + ) from exc + if not isinstance(direct_url, dict): + raise DesktopUpdateError( + "The installed OpenHCS distribution has invalid origin metadata." + ) + directory_info = direct_url.get("dir_info") + if ( + isinstance(directory_info, dict) + and directory_info.get("editable") is True + ): + raise DesktopUpdateError( + "Automatic updates are disabled for editable or source checkouts. " + "Use the official release instructions for this installation." + ) + if not distribution_root.is_relative_to(environment_root): + raise DesktopUpdateError( + "Automatic updates are disabled for editable or source checkouts. " + "Use the official release instructions for this installation." + ) + if not os.access(environment_root, os.W_OK): + raise DesktopUpdateError( + f"The OpenHCS environment is not writable: {environment_root}" + ) + + invoked_path = Path(sys.argv[0]).expanduser() + restart_arguments = _without_update_session_arguments(sys.argv[1:]) + if invoked_path.is_file() and invoked_path.resolve().is_relative_to( + environment_root + ): + restart_executable = invoked_path.resolve() + else: + restart_executable = python_executable + restart_arguments = ("-m", "openhcs.pyqt_gui", *restart_arguments) + return cls( + python_executable=python_executable, + worker_python_executable=worker_python_executable, + environment_root=environment_root, + restart_executable=restart_executable, + restart_arguments=restart_arguments, + ) + + def update_command(self, latest_version: Version) -> DesktopUpdateCommandPlan: + return DesktopUpdateCommandPlan.for_environment( + python_executable=self.python_executable, + latest_version=latest_version, + ) + + +def _without_update_session_arguments(arguments: list[str]) -> tuple[str, ...]: + """Remove updater-owned restore arguments before a successive restart.""" + + sanitized: list[str] = [] + position = 0 + assignment_prefix = f"{UPDATE_SESSION_ARGUMENT}=" + while position < len(arguments): + argument = arguments[position] + if argument == UPDATE_SESSION_ARGUMENT: + position += 2 + continue + if argument.startswith(assignment_prefix): + position += 1 + continue + sanitized.append(argument) + position += 1 + return tuple(sanitized) + + +@dataclass(frozen=True, slots=True) +class DesktopUpdateSession: + """Canonical plate-manager source plus ObjectState history for one restart.""" + + directory: Path + + @property + def session_document(self) -> Path: + return self.directory / _SESSION_DOCUMENT_NAME + + @property + def history_document(self) -> Path: + return self.directory / _HISTORY_DOCUMENT_NAME + + @property + def worker_document(self) -> Path: + return self.directory / _WORKER_DOCUMENT_NAME + + @property + def update_error_document(self) -> Path: + return self.directory / _UPDATE_ERROR_NAME + + @classmethod + def pending(cls) -> DesktopUpdateSession: + from openhcs.core.xdg_paths import get_openhcs_cache_dir + + return cls(get_openhcs_cache_dir() / "desktop-updates" / "pending") + + @property + def is_complete(self) -> bool: + return self.session_document.is_file() and self.history_document.is_file() + + @classmethod + def capture(cls, main_window) -> DesktopUpdateSession: + from objectstate.object_state import ObjectStateRegistry + + from openhcs.pyqt_gui.config import save_ui_config_sync + from openhcs.pyqt_gui.widgets.plate_manager import ( + PlateManagerCodeSelectionMode, + ) + + plate_manager = main_window.embedded_widgets.require_plate_manager() + if plate_manager.is_any_plate_running(): + raise DesktopUpdateError( + "Stop the active plate execution before updating OpenHCS." + ) + context = plate_manager.orchestrator_code_document_context( + selection_mode=PlateManagerCodeSelectionMode.ALL, + ) + session = cls.pending() + if session.directory.exists(): + raise DesktopUpdateError( + "A saved update session is already pending. Restart OpenHCS to " + "recover it before starting another update." + ) + session.directory.mkdir(parents=True) + try: + session.session_document.write_text(context.source, encoding="utf-8") + ObjectStateRegistry.save_history_to_file(str(session.history_document)) + shutil.copyfile( + Path(__file__).with_name("desktop_update_worker.py"), + session.worker_document, + ) + if not save_ui_config_sync(main_window.runtime_context.ui_config): + raise DesktopUpdateError( + "OpenHCS could not persist the current UI configuration." + ) + except Exception: + session.discard() + raise + return session + + def restore(self, main_window) -> str | None: + """Restore through the existing code-document and ObjectState owners.""" + + from objectstate.object_state import ObjectStateRegistry + + source = self.session_document.read_text(encoding="utf-8") + plate_manager = main_window.embedded_widgets.require_plate_manager() + plate_manager.apply_code_document_source(source) + ObjectStateRegistry.load_history_from_file(str(self.history_document)) + main_window.time_travel_widget.refresh() + plate_manager.update_item_list() + error_message = ( + self.update_error_document.read_text(encoding="utf-8").strip() + if self.update_error_document.is_file() + else None + ) + self.discard() + return error_message + + def discard(self) -> None: + shutil.rmtree(self.directory, ignore_errors=True) + + @dataclass(frozen=True, slots=True) class DesktopUpdate: """One installed-to-release comparison and its verified handoff URL.""" @@ -87,7 +365,9 @@ def parse_latest_release( current = Version(installed_version) latest = Version(tag_name.removeprefix("v")) except InvalidVersion as exc: - raise DesktopUpdateError("The release service returned an invalid version.") from exc + raise DesktopUpdateError( + "The release service returned an invalid version." + ) from exc handoff_url = release_url has_native_installer = False @@ -190,3 +470,48 @@ def open_update(self, update: DesktopUpdate) -> bool: if not _is_official_release_url(update.handoff_url, asset=is_asset): raise DesktopUpdateError("Refusing an untrusted update destination.") return self._url_opener(QUrl(update.handoff_url)) + + def start_update( + self, + update: DesktopUpdate, + *, + runtime: DesktopRuntimeEnvironment, + session: DesktopUpdateSession, + parent_pid: int | None = None, + ) -> bool: + """Launch the detached updater that waits for this GUI to close.""" + + if not update.update_available: + raise DesktopUpdateError("The selected release is not newer than OpenHCS.") + if not session.worker_document.is_file(): + raise DesktopUpdateError( + "The saved update session does not contain its detached update worker." + ) + command = runtime.update_command(update.latest_version) + arguments = [ + str(session.worker_document), + "--parent-pid", + str(os.getpid() if parent_pid is None else parent_pid), + "--session-directory", + str(session.directory), + "--update-executable", + str(command.executable), + "--restart-executable", + str(runtime.restart_executable), + "--expected-version", + str(update.latest_version), + "--verification-executable", + str(runtime.python_executable), + "--error-file", + str(session.update_error_document), + f"--restore-option={UPDATE_SESSION_ARGUMENT}", + ] + for argument in command.arguments: + arguments.append(f"--update-argument={argument}") + for argument in runtime.restart_arguments: + arguments.append(f"--restart-argument={argument}") + started, _process_id = QProcess.startDetached( + str(runtime.worker_python_executable), + arguments, + ) + return started diff --git a/openhcs/pyqt_gui/services/desktop_update_worker.py b/openhcs/pyqt_gui/services/desktop_update_worker.py new file mode 100644 index 000000000..86f04617f --- /dev/null +++ b/openhcs/pyqt_gui/services/desktop_update_worker.py @@ -0,0 +1,160 @@ +"""Out-of-process OpenHCS environment update and restart worker.""" + +from __future__ import annotations + +import argparse +import ctypes +import os +import shutil +import subprocess +import time +from pathlib import Path + + +def _wait_for_parent_exit( + parent_pid: int, + *, + timeout_seconds: float = 60.0, +) -> bool: + if os.name == "nt": + synchronize = 0x00100000 + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + handle = kernel32.OpenProcess(synchronize, False, parent_pid) + if not handle: + return True + try: + wait_object_0 = 0 + return ( + kernel32.WaitForSingleObject(handle, int(timeout_seconds * 1000)) + == wait_object_0 + ) + finally: + kernel32.CloseHandle(handle) + + deadline = time.monotonic() + timeout_seconds + while True: + try: + os.kill(parent_pid, 0) + except ProcessLookupError: + return True + except PermissionError: + return False + if time.monotonic() >= deadline: + return False + time.sleep(0.1) + + +def _run_update( + executable: str, + arguments: list[str], + *, + expected_version: str, + verification_executable: str, +) -> str | None: + completed = subprocess.run( + [executable, *arguments], + check=False, + capture_output=True, + text=True, + ) + if completed.returncode != 0: + detail = (completed.stderr or completed.stdout).strip() + return f"OpenHCS update failed with exit code {completed.returncode}." + ( + f"\n\n{detail[-4000:]}" if detail else "" + ) + + verification = subprocess.run( + [ + verification_executable, + "-c", + ( + "from importlib.metadata import version;" + "import sys;" + "sys.exit(0 if version('openhcs') == sys.argv[1] else 1)" + ), + expected_version, + ], + check=False, + capture_output=True, + text=True, + ) + if verification.returncode != 0: + return ( + "OpenHCS finished installing, but the updated environment did not " + f"report version {expected_version}." + ) + return None + + +def _restart( + executable: str, + arguments: list[str], + *, + session_directory: Path, + restore_option: str, +) -> None: + command = [ + executable, + *arguments, + restore_option, + str(session_directory), + ] + kwargs: dict[str, object] = { + "close_fds": True, + "stdin": subprocess.DEVNULL, + "stdout": subprocess.DEVNULL, + "stderr": subprocess.DEVNULL, + } + if os.name == "nt": + kwargs["creationflags"] = ( + subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS + ) + else: + kwargs["start_new_session"] = True + subprocess.Popen(command, **kwargs) + + +def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--parent-pid", required=True, type=int) + parser.add_argument("--session-directory", required=True, type=Path) + parser.add_argument("--update-executable", required=True) + parser.add_argument("--update-argument", action="append", default=[]) + parser.add_argument("--restart-executable", required=True) + parser.add_argument("--restart-argument", action="append", default=[]) + parser.add_argument("--expected-version", required=True) + parser.add_argument("--verification-executable", required=True) + parser.add_argument("--error-file", required=True, type=Path) + parser.add_argument("--restore-option", required=True) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + arguments = parse_arguments(argv) + if not _wait_for_parent_exit(arguments.parent_pid): + arguments.error_file.write_text( + "OpenHCS did not close within 60 seconds, so the update was " + "cancelled before modifying the environment.", + encoding="utf-8", + ) + shutil.rmtree(arguments.session_directory, ignore_errors=True) + return 2 + error_message = _run_update( + arguments.update_executable, + arguments.update_argument, + expected_version=arguments.expected_version, + verification_executable=arguments.verification_executable, + ) + if error_message is not None: + arguments.error_file.write_text(error_message, encoding="utf-8") + _restart( + arguments.restart_executable, + arguments.restart_argument, + session_directory=arguments.session_directory, + restore_option=arguments.restore_option, + ) + return 0 if error_message is None else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/openhcs/pyqt_gui/services/ui_bridge_windows.py b/openhcs/pyqt_gui/services/ui_bridge_windows.py index a4a504cc1..f050a34c7 100644 --- a/openhcs/pyqt_gui/services/ui_bridge_windows.py +++ b/openhcs/pyqt_gui/services/ui_bridge_windows.py @@ -83,7 +83,11 @@ UiWidgetTreeRequest, UiWidgetTreeResult, ) -from openhcs.agent.ui_bridge_identities import ManagedWindowWidgetIdentity +from openhcs.agent.ui_bridge_actions import MainWindowAction +from openhcs.agent.ui_bridge_identities import ( + MainWindowWidgetIdentity, + ManagedWindowWidgetIdentity, +) from objectstate import ObjectState from openhcs.runtime.qt_window_snapshot import ( QtWindowSnapshotRequest, @@ -120,6 +124,7 @@ QT_TOP_LEVEL_WINDOW_KIND = "qt_top_level" QT_TOP_LEVEL_WINDOW_ID_PREFIX = "qt_top_level:" MANAGED_WINDOW_ACTIONS_TITLE = "Managed window actions" +MAIN_WINDOW_ACTIONS_TITLE = "Main window actions" FIELD_INPUT_ACTION_ROLE = "field_input" FIELD_RESET_ACTION_ROLE = "field_reset" ITEM_SELECT_ACTION_ROLE = "item_select" @@ -135,6 +140,10 @@ def _agent_object_state_scope_id(scope_id: str | None) -> str | None: ManagedWindowWidgetIdentity, title=MANAGED_WINDOW_ACTIONS_TITLE, ) +MAIN_WINDOW_ACTION_PROVIDER_IDENTITY = UiActionProviderIdentity.from_widget_declaration( + MainWindowWidgetIdentity, + title=MAIN_WINDOW_ACTIONS_TITLE, +) class ManagedWindowSummaryProjection: @@ -2748,6 +2757,82 @@ def _navigate_result( ) +class MainWindowActionProvider(UiActionProviderABC): + """Action provider for main-window application commands.""" + + identity = MAIN_WINDOW_ACTION_PROVIDER_IDENTITY + + def __init__(self, main_window: "OpenHCSMainWindow") -> None: + self._main_window = main_window + + def catalog(self) -> UiActionCatalog: + return UiActionCatalog( + schema_version=SCHEMA_VERSION, + actions=tuple(self.summary(action.value) for action in MainWindowAction), + ) + + def summary(self, action_id: str) -> UiActionSummary: + action = self._action(action_id) + enabled = self._main_window.check_for_updates_action.isEnabled() + return UiActionSummary( + schema_version=SCHEMA_VERSION, + identity=UiActionIdentity( + widget_id=self.identity.widget_id, + action_id=action.value, + ), + title=action.title, + enabled=enabled, + invocation_mode="async", + side_effects=action.side_effects, + confirmation_required=action.confirmation_required, + selection_mode="global", + current_selection_count=0, + target_scope_ids=(), + disabled_error=( + None + if enabled + else AgentError( + code="update_check_in_progress", + message="An OpenHCS update check is already in progress.", + ) + ), + ) + + def invoke(self, request: UiActionInvokeRequest) -> UiActionInvokeResult: + try: + action = self._action(request.action_id) + self._main_window.check_for_updates() + except Exception as exc: + return UiActionInvokeResult( + schema_version=SCHEMA_VERSION, + identity=UiActionIdentity( + widget_id=request.widget_id, + action_id=request.action_id, + ), + status=UiActionInvocationStatus.REJECTED.value, + receipt=UiMutationReceipt.rejected_for(request.request_token), + errors=( + AgentError.from_exception("main_window_action_failed", exc), + ), + ) + return UiActionInvokeResult( + schema_version=SCHEMA_VERSION, + identity=UiActionIdentity( + widget_id=self.identity.widget_id, + action_id=action.value, + ), + status=UiActionInvocationStatus.ACCEPTED.value, + receipt=UiMutationReceipt.accepted_for(request.request_token), + ) + + @staticmethod + def _action(action_id: str) -> MainWindowAction: + action = MainWindowAction(action_id) + if action is not MainWindowAction.CHECK_FOR_UPDATES: + raise ValueError(f"Main-window action has no route: {action_id!r}") + return action + + class ManagedWindowActionProvider(UiActionProviderABC): """Action provider for generic WindowManager-managed form windows.""" @@ -2902,4 +2987,7 @@ def register(self, context: UiBridgeRegistrationContext) -> None: context.registry.register_window_provider( provider_type.create(self.main_window) ) + context.registry.register_action_provider( + MainWindowActionProvider(self.main_window) + ) context.registry.register_action_provider(ManagedWindowActionProvider()) diff --git a/openhcs/runtime/zmq_execution_client.py b/openhcs/runtime/zmq_execution_client.py index fea86bc96..06570af6e 100644 --- a/openhcs/runtime/zmq_execution_client.py +++ b/openhcs/runtime/zmq_execution_client.py @@ -490,7 +490,7 @@ def _submit_submission( connect_timeout_seconds = max(timeout_ms / 1000, 0.001) if not self._connected and not self.connect(timeout=connect_timeout_seconds): raise RuntimeError("Failed to connect to execution server") - self._ensure_progress_subscription() + self._ensure_progress_subscription(timeout_ms=timeout_ms) request = self.serialize_task(submission, None) if MessageFields.TYPE not in request: request[MessageFields.TYPE] = ControlMessageType.EXECUTE.value diff --git a/openhcs/textual_tui/windows/function_selector_window.py b/openhcs/textual_tui/windows/function_selector_window.py index d039ddff1..fd1ac82f8 100644 --- a/openhcs/textual_tui/windows/function_selector_window.py +++ b/openhcs/textual_tui/windows/function_selector_window.py @@ -185,7 +185,7 @@ def _populate_table(self, table: DataTable, functions_metadata: Dict[str, Functi row_key = table.add_row( metadata.display_name, metadata.module.split('.')[-1] if metadata.module else "unknown", # Show only last part of module - memory_type.title(), # Show actual memory type (cupy, numpy, etc.) + (memory_type or "").title(), registry_name.title(), # Show registry source (openhcs, skimage, etc.) metadata.contract.name if metadata.contract else "unknown", tags_str, diff --git a/packaging/codex/openhcs/.codex-plugin/plugin.json b/packaging/codex/openhcs/.codex-plugin/plugin.json index 099e2fd1d..9aba0f256 100644 --- a/packaging/codex/openhcs/.codex-plugin/plugin.json +++ b/packaging/codex/openhcs/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "openhcs", - "version": "0.7.0", + "version": "0.7.1", "description": "Inspect, author, validate, and run OpenHCS microscopy workflows through the local OpenHCS MCP server.", "author": { "name": "OpenHCSDev", diff --git a/packaging/codex/openhcs/.mcp.json b/packaging/codex/openhcs/.mcp.json index 9f4a002d0..6253fcff5 100644 --- a/packaging/codex/openhcs/.mcp.json +++ b/packaging/codex/openhcs/.mcp.json @@ -4,7 +4,7 @@ "command": "uvx", "args": [ "--from", - "openhcs[gui,mcp,viz]==0.7.0", + "openhcs[gui,mcp,viz]==0.7.1", "openhcs-mcp" ] } diff --git a/packaging/installers/macos/install-openhcs.sh b/packaging/installers/macos/install-openhcs.sh index 0d1050a36..8e06fed05 100755 --- a/packaging/installers/macos/install-openhcs.sh +++ b/packaging/installers/macos/install-openhcs.sh @@ -254,11 +254,13 @@ stable_launch_command_json=$( ) printf -v stable_launch_command_shell '%q' "$stable_launch_command_json" printf -v installation_pointer_shell '%q' "$current_environment" +printf -v uv_executable_shell '%q' "$uv_executable" environment_launcher="$new_environment/launch-openhcs.sh" /bin/cat >"$environment_launcher" <=5.9.0", # External dependencies (PyPI versions for production builds) - "zmqruntime>=0.1.19", + "zmqruntime>=0.1.20", "pycodify>=0.1.3", - "objectstate>=1.0.22", - "python-introspect>=0.1.7", + "objectstate>=1.1.0", + "python-introspect>=0.1.8", "metaclass-registry>=0.1.6", "arraybridge>=0.2.11", "polystore>=0.1.26", - "pyqt-reactive>=0.1.28", + "pyqt-reactive>=0.1.30", ] [project.optional-dependencies] diff --git a/server.json b/server.json index 7fa6dd45e..747eaeb0f 100644 --- a/server.json +++ b/server.json @@ -8,19 +8,19 @@ "url": "https://github.com/OpenHCSDev/OpenHCS", "source": "github" }, - "version": "0.7.0", + "version": "0.7.1", "packages": [ { "registryType": "pypi", "registryBaseUrl": "https://pypi.org", "identifier": "openhcs", - "version": "0.7.0", + "version": "0.7.1", "runtimeHint": "uvx", "runtimeArguments": [ { "type": "named", "name": "--with", - "value": "openhcs[gui,mcp,viz]==0.7.0", + "value": "openhcs[gui,mcp,viz]==0.7.1", "description": "Install the matching OpenHCS GUI, MCP, Napari, and Fiji dependencies." } ], diff --git a/tests/installer/test_macos_simple_installer.py b/tests/installer/test_macos_simple_installer.py index 8ea643a8b..6acb87dd7 100644 --- a/tests/installer/test_macos_simple_installer.py +++ b/tests/installer/test_macos_simple_installer.py @@ -274,6 +274,8 @@ def test_macos_installer_registers_agent_clients_through_stable_launcher() -> No assert "--register-detected" in source assert "OPENHCS_MCP_STABLE_LAUNCH_COMMAND_JSON" in source assert "OPENHCS_MCP_INSTALLATION_POINTER" in source + assert "OPENHCS_UV_EXECUTABLE" in source + assert "uv_executable_shell" in source assert 'stable_mcp_launcher="$current_environment/launch-openhcs.sh"' in source assert "agent-registration.json" in source assert "agent-registration-status connected" in source diff --git a/tests/installer/test_windows_simple_installer.py b/tests/installer/test_windows_simple_installer.py index 178e6789f..84f3e89f4 100644 --- a/tests/installer/test_windows_simple_installer.py +++ b/tests/installer/test_windows_simple_installer.py @@ -370,6 +370,8 @@ def test_windows_installer_registers_agent_clients_through_stable_launcher() -> assert '"mcp"' in source assert "OPENHCS_MCP_STABLE_LAUNCH_COMMAND_JSON" in source assert "OPENHCS_MCP_INSTALLATION_POINTER" in source + assert "OPENHCS_UV_EXECUTABLE" in source + assert '"bootstrap\\uv\\uv.exe"' in source assert "$installationPointerLiteral = $launcherPath.Replace" in source assert "agent-registration.json" in source assert "agent-registration-status" in source diff --git a/tests/unit/pyqt_gui/test_desktop_update.py b/tests/unit/pyqt_gui/test_desktop_update.py index 17239c419..dc0c9f671 100644 --- a/tests/unit/pyqt_gui/test_desktop_update.py +++ b/tests/unit/pyqt_gui/test_desktop_update.py @@ -1,17 +1,21 @@ from __future__ import annotations from dataclasses import replace +from pathlib import Path from types import SimpleNamespace import pytest -from PyQt6.QtWidgets import QMessageBox from PyQt6.QtNetwork import QNetworkReply +from PyQt6.QtWidgets import QMessageBox import openhcs.pyqt_gui.main as main_module from openhcs.pyqt_gui.services.desktop_update import ( + LATEST_RELEASE_API_URL, + DesktopRuntimeEnvironment, + DesktopUpdateCommandPlan, DesktopUpdateError, DesktopUpdateService, - LATEST_RELEASE_API_URL, + DesktopUpdateSession, parse_latest_release, ) @@ -221,6 +225,7 @@ class _UpdateService: def __init__(self, *, starts: bool = True) -> None: self.starts = starts self.opened = [] + self.started = [] def check_for_updates(self) -> bool: return self.starts @@ -229,6 +234,10 @@ def open_update(self, update) -> bool: self.opened.append(update) return True + def start_update(self, update, *, runtime, session) -> bool: + self.started.append((update, runtime, session)) + return True + def test_main_window_starts_check_only_from_explicit_action() -> None: action = _Action() @@ -245,7 +254,7 @@ def test_main_window_starts_check_only_from_explicit_action() -> None: assert status.messages == ["Checking for OpenHCS updates…"] -def test_main_window_available_update_opens_verified_handoff(monkeypatch) -> None: +def test_main_window_available_update_saves_and_starts_restart(monkeypatch) -> None: update = parse_latest_release( _release_payload(), installed_version="0.6.2", @@ -256,6 +265,9 @@ def test_main_window_available_update_opens_verified_handoff(monkeypatch) -> Non status = _StatusSignal() service = _UpdateService() dialog_calls = [] + runtime = object() + session = SimpleNamespace(discard=lambda: None) + closed = [] class _DialogProbe: StandardButton = QMessageBox.StandardButton @@ -263,22 +275,488 @@ class _DialogProbe: @staticmethod def question(*args): dialog_calls.append(args) - return QMessageBox.StandardButton.Open + return QMessageBox.StandardButton.Yes @staticmethod def warning(*_args): raise AssertionError("successful handoff must not show a warning") monkeypatch.setattr(main_module, "QMessageBox", _DialogProbe) + monkeypatch.setattr( + main_module.DesktopRuntimeEnvironment, + "current", + classmethod(lambda cls: runtime), + ) + monkeypatch.setattr( + main_module.DesktopUpdateSession, + "capture", + classmethod(lambda cls, window: session), + ) main_like = SimpleNamespace( desktop_update_service=service, check_for_updates_action=action, status_message=status, + close=lambda: closed.append(True), ) main_module.OpenHCSMainWindow._on_update_check_completed(main_like, update) assert action.enabled - assert status.messages == ["OpenHCS 0.7.0 is available"] - assert service.opened == [update] + assert status.messages == [ + "OpenHCS 0.7.0 is available", + "OpenHCS update prepared; restarting…", + ] + assert service.started == [(update, runtime, session)] + assert closed == [True] assert len(dialog_calls) == 1 + + +def test_update_command_prefers_configured_uv(tmp_path: Path) -> None: + uv = tmp_path / "uv" + uv.touch() + python = tmp_path / "python" + + command = DesktopUpdateCommandPlan.for_environment( + python_executable=python, + latest_version=parse_latest_release( + _release_payload(), + installed_version="0.6.2", + system_name="Linux", + ).latest_version, + environment={"OPENHCS_UV_EXECUTABLE": str(uv)}, + ) + + assert command.executable == uv.resolve() + assert command.arguments == ( + "--no-config", + "pip", + "install", + "--python", + str(python), + "--upgrade", + "openhcs==0.7.0", + ) + + +def test_update_command_falls_back_to_running_interpreter_pip( + monkeypatch, + tmp_path: Path, +) -> None: + monkeypatch.setattr( + "openhcs.pyqt_gui.services.desktop_update.shutil.which", + lambda _name: None, + ) + monkeypatch.setattr( + "openhcs.pyqt_gui.services.desktop_update.find_spec", + lambda name: object() if name == "pip" else None, + ) + python = tmp_path / "python" + + command = DesktopUpdateCommandPlan.for_environment( + python_executable=python, + latest_version=parse_latest_release( + _release_payload(), + installed_version="0.6.2", + system_name="Linux", + ).latest_version, + environment={}, + ) + + assert command.executable == python + assert command.arguments[:3] == ("-m", "pip", "install") + assert command.arguments[-1] == "openhcs==0.7.0" + + +def test_update_command_rejects_environment_without_uv_or_pip( + monkeypatch, + tmp_path: Path, +) -> None: + monkeypatch.setattr( + "openhcs.pyqt_gui.services.desktop_update.shutil.which", + lambda _name: None, + ) + monkeypatch.setattr( + "openhcs.pyqt_gui.services.desktop_update.find_spec", + lambda _name: None, + ) + + with pytest.raises(DesktopUpdateError, match="neither an available uv"): + DesktopUpdateCommandPlan.for_environment( + python_executable=tmp_path / "python", + latest_version=parse_latest_release( + _release_payload(), + installed_version="0.6.2", + system_name="Linux", + ).latest_version, + environment={}, + ) + + +def test_service_starts_worker_with_unambiguous_argument_vectors( + monkeypatch, + tmp_path: Path, +) -> None: + update = parse_latest_release( + _release_payload(), + installed_version="0.6.2", + system_name="Linux", + ) + python = tmp_path / "python" + worker_python = tmp_path / "base-python" + uv = tmp_path / "uv" + runtime = DesktopRuntimeEnvironment( + python_executable=python, + worker_python_executable=worker_python, + environment_root=tmp_path, + restart_executable=tmp_path / "openhcs", + restart_arguments=("--log-level", "DEBUG"), + ) + session = DesktopUpdateSession(tmp_path / "pending") + session.directory.mkdir() + session.worker_document.write_text("worker", encoding="utf-8") + launched = [] + monkeypatch.setattr( + DesktopRuntimeEnvironment, + "update_command", + lambda self, version: DesktopUpdateCommandPlan( + executable=uv, + arguments=("--no-config", "pip", "install", f"openhcs=={version}"), + ), + ) + monkeypatch.setattr( + "openhcs.pyqt_gui.services.desktop_update.QProcess.startDetached", + lambda executable, arguments: ( + launched.append((executable, arguments)) or True, + 73, + ), + ) + service = DesktopUpdateService( + installed_version="0.6.2", + system_name="Linux", + network_manager=_NetworkManager(_UnavailableReply()), + ) + + assert service.start_update( + update, + runtime=runtime, + session=session, + parent_pid=42, + ) + + executable, arguments = launched[0] + assert executable == str(worker_python) + assert arguments[0] == str(session.worker_document) + assert "--update-argument=--no-config" in arguments + assert "--restart-argument=--log-level" in arguments + assert arguments[arguments.index("--verification-executable") + 1] == str(python) + assert "--restore-option=--restore-update-session" in arguments + assert arguments[arguments.index("--parent-pid") + 1] == "42" + + +def test_runtime_environment_rejects_distribution_outside_virtual_environment( + monkeypatch, + tmp_path: Path, +) -> None: + environment_root = tmp_path / "venv" + environment_root.mkdir() + source_root = tmp_path / "source" + source_root.mkdir() + python = environment_root / "python" + python.touch() + base_python = tmp_path / "base-python" + base_python.touch() + monkeypatch.setattr( + "openhcs.pyqt_gui.services.desktop_update.sys.executable", + str(python), + ) + monkeypatch.setattr( + "openhcs.pyqt_gui.services.desktop_update.sys.prefix", + str(environment_root), + ) + monkeypatch.setattr( + "openhcs.pyqt_gui.services.desktop_update.sys.base_prefix", + str(tmp_path / "base"), + ) + monkeypatch.setattr( + "openhcs.pyqt_gui.services.desktop_update.sys._base_executable", + str(base_python), + ) + monkeypatch.setattr( + "openhcs.pyqt_gui.services.desktop_update.distribution", + lambda _name: SimpleNamespace( + locate_file=lambda _path: source_root, + read_text=lambda _name: None, + ), + ) + + with pytest.raises(DesktopUpdateError, match="editable or source"): + DesktopRuntimeEnvironment.current() + + +def test_runtime_environment_rejects_worker_interpreter_inside_target_environment( + monkeypatch, + tmp_path: Path, +) -> None: + environment_root = tmp_path / "venv" + distribution_root = environment_root / "lib" / "site-packages" + distribution_root.mkdir(parents=True) + python = environment_root / "bin" / "python" + python.parent.mkdir() + python.touch() + monkeypatch.setattr( + "openhcs.pyqt_gui.services.desktop_update.sys.executable", + str(python), + ) + monkeypatch.setattr( + "openhcs.pyqt_gui.services.desktop_update.sys._base_executable", + str(python), + ) + monkeypatch.setattr( + "openhcs.pyqt_gui.services.desktop_update.sys.prefix", + str(environment_root), + ) + monkeypatch.setattr( + "openhcs.pyqt_gui.services.desktop_update.sys.base_prefix", + str(tmp_path / "base"), + ) + + with pytest.raises(DesktopUpdateError, match="base Python interpreter outside"): + DesktopRuntimeEnvironment.current() + + +def test_runtime_environment_derives_restart_from_installed_entry_point( + monkeypatch, + tmp_path: Path, +) -> None: + environment_root = tmp_path / "venv" + distribution_root = environment_root / "lib" / "site-packages" + distribution_root.mkdir(parents=True) + python = environment_root / "bin" / "python" + python.parent.mkdir() + python.touch() + base_python = tmp_path / "base-python" + base_python.touch() + entry_point = environment_root / "bin" / "openhcs" + entry_point.touch() + monkeypatch.setattr( + "openhcs.pyqt_gui.services.desktop_update.sys.executable", + str(python), + ) + monkeypatch.setattr( + "openhcs.pyqt_gui.services.desktop_update.sys.prefix", + str(environment_root), + ) + monkeypatch.setattr( + "openhcs.pyqt_gui.services.desktop_update.sys.base_prefix", + str(tmp_path / "base"), + ) + monkeypatch.setattr( + "openhcs.pyqt_gui.services.desktop_update.sys._base_executable", + str(base_python), + ) + monkeypatch.setattr( + "openhcs.pyqt_gui.services.desktop_update.sys.argv", + [ + str(entry_point), + "--log-level", + "DEBUG", + "--restore-update-session", + str(tmp_path / "old-session"), + f"--restore-update-session={tmp_path / 'older-session'}", + "--config=--leading-dash-value", + ], + ) + monkeypatch.setattr( + "openhcs.pyqt_gui.services.desktop_update.distribution", + lambda _name: SimpleNamespace( + locate_file=lambda _path: distribution_root, + read_text=lambda _name: None, + ), + ) + + runtime = DesktopRuntimeEnvironment.current() + + assert runtime.python_executable == python.resolve() + assert runtime.worker_python_executable == base_python.resolve() + assert runtime.environment_root == environment_root.resolve() + assert runtime.restart_executable == entry_point.resolve() + assert runtime.restart_arguments == ( + "--log-level", + "DEBUG", + "--config=--leading-dash-value", + ) + + +def test_runtime_environment_preserves_virtual_environment_python_symlink( + monkeypatch, + tmp_path: Path, +) -> None: + environment_root = tmp_path / "venv" + distribution_root = environment_root / "lib" / "site-packages" + distribution_root.mkdir(parents=True) + base_python = tmp_path / "managed-python" + base_python.touch() + python = environment_root / "bin" / "python" + python.parent.mkdir() + python.symlink_to(base_python) + monkeypatch.setattr( + "openhcs.pyqt_gui.services.desktop_update.sys.executable", + str(python), + ) + monkeypatch.setattr( + "openhcs.pyqt_gui.services.desktop_update.sys._base_executable", + str(base_python), + ) + monkeypatch.setattr( + "openhcs.pyqt_gui.services.desktop_update.sys.prefix", + str(environment_root), + ) + monkeypatch.setattr( + "openhcs.pyqt_gui.services.desktop_update.sys.base_prefix", + str(tmp_path / "base"), + ) + monkeypatch.setattr( + "openhcs.pyqt_gui.services.desktop_update.sys.argv", + ["openhcs-gui"], + ) + monkeypatch.setattr( + "openhcs.pyqt_gui.services.desktop_update.distribution", + lambda _name: SimpleNamespace( + locate_file=lambda _path: distribution_root, + read_text=lambda _name: None, + ), + ) + + runtime = DesktopRuntimeEnvironment.current() + + assert runtime.python_executable == python + assert runtime.worker_python_executable == base_python.resolve() + assert runtime.update_command( + parse_latest_release( + _release_payload(), + installed_version="0.6.99", + system_name="Linux", + ).latest_version + ).arguments[4] == str(python) + + +def test_runtime_environment_rejects_read_only_install( + monkeypatch, + tmp_path: Path, +) -> None: + environment_root = tmp_path / "venv" + distribution_root = environment_root / "lib" / "site-packages" + distribution_root.mkdir(parents=True) + python = environment_root / "bin" / "python" + python.parent.mkdir() + python.touch() + base_python = tmp_path / "base-python" + base_python.touch() + monkeypatch.setattr( + "openhcs.pyqt_gui.services.desktop_update.sys.executable", + str(python), + ) + monkeypatch.setattr( + "openhcs.pyqt_gui.services.desktop_update.sys.prefix", + str(environment_root), + ) + monkeypatch.setattr( + "openhcs.pyqt_gui.services.desktop_update.sys.base_prefix", + str(tmp_path / "base"), + ) + monkeypatch.setattr( + "openhcs.pyqt_gui.services.desktop_update.sys._base_executable", + str(base_python), + ) + monkeypatch.setattr( + "openhcs.pyqt_gui.services.desktop_update.distribution", + lambda _name: SimpleNamespace( + locate_file=lambda _path: distribution_root, + read_text=lambda _name: None, + ), + ) + monkeypatch.setattr( + "openhcs.pyqt_gui.services.desktop_update.os.access", + lambda *_args: False, + ) + + with pytest.raises(DesktopUpdateError, match="not writable"): + DesktopRuntimeEnvironment.current() + + +def test_capture_uses_canonical_plate_source_and_objectstate_history( + monkeypatch, + tmp_path: Path, +) -> None: + plate_manager = SimpleNamespace( + is_any_plate_running=lambda: False, + orchestrator_code_document_context=lambda **_kwargs: SimpleNamespace( + source="canonical session source" + ), + ) + main_window = SimpleNamespace( + embedded_widgets=SimpleNamespace( + require_plate_manager=lambda: plate_manager, + ), + runtime_context=SimpleNamespace(ui_config=object()), + ) + monkeypatch.setattr( + "openhcs.core.xdg_paths.get_openhcs_cache_dir", + lambda: tmp_path, + ) + monkeypatch.setattr( + "openhcs.pyqt_gui.config.save_ui_config_sync", + lambda _config: True, + ) + monkeypatch.setattr( + "objectstate.object_state.ObjectStateRegistry.save_history_to_file", + lambda path: Path(path).write_text("canonical history", encoding="utf-8"), + ) + + session = DesktopUpdateSession.capture(main_window) + + assert session.session_document.read_text(encoding="utf-8") == ( + "canonical session source" + ) + assert session.history_document.read_text(encoding="utf-8") == ("canonical history") + assert session.worker_document.read_text(encoding="utf-8").startswith( + '"""Out-of-process OpenHCS environment update' + ) + session.discard() + + +def test_saved_update_session_restores_through_existing_authorities( + monkeypatch, + tmp_path: Path, +) -> None: + session = DesktopUpdateSession(tmp_path) + session.session_document.write_text("plate_paths = []", encoding="utf-8") + session.history_document.write_text("{}", encoding="utf-8") + session.update_error_document.write_text("install failed", encoding="utf-8") + calls = [] + plate_manager = SimpleNamespace( + apply_code_document_source=lambda source: calls.append(("source", source)), + update_item_list=lambda: calls.append(("refresh", None)), + ) + time_travel = SimpleNamespace(refresh=lambda: calls.append(("history-ui", None))) + main_window = SimpleNamespace( + embedded_widgets=SimpleNamespace( + require_plate_manager=lambda: plate_manager, + ), + time_travel_widget=time_travel, + ) + monkeypatch.setattr( + "objectstate.object_state.ObjectStateRegistry.load_history_from_file", + lambda path: calls.append(("history", path)), + ) + + update_error = session.restore(main_window) + + assert update_error == "install failed" + assert calls == [ + ("source", "plate_paths = []"), + ("history", str(session.history_document)), + ("history-ui", None), + ("refresh", None), + ] + assert not tmp_path.exists() diff --git a/tests/unit/pyqt_gui/test_desktop_update_worker.py b/tests/unit/pyqt_gui/test_desktop_update_worker.py new file mode 100644 index 000000000..751eb0ac0 --- /dev/null +++ b/tests/unit/pyqt_gui/test_desktop_update_worker.py @@ -0,0 +1,346 @@ +from __future__ import annotations + +import json +import os +import subprocess +import sys +import threading +import time +from importlib.metadata import version as distribution_version +from pathlib import Path +from types import SimpleNamespace + +from openhcs.pyqt_gui.services import desktop_update_worker + + +def test_worker_reports_bounded_install_failure(monkeypatch) -> None: + monkeypatch.setattr( + desktop_update_worker.subprocess, + "run", + lambda *_args, **_kwargs: SimpleNamespace( + returncode=7, + stderr="failure detail", + stdout="", + ), + ) + + error = desktop_update_worker._run_update( + "uv", + ["pip", "install"], + expected_version="0.7.1", + verification_executable="/target/venv/python", + ) + + assert error == "OpenHCS update failed with exit code 7.\n\nfailure detail" + + +def test_worker_verifies_with_target_environment_interpreter(monkeypatch) -> None: + calls = [] + + def _run(command, **_kwargs): + calls.append(command) + return SimpleNamespace(returncode=0, stderr="", stdout="") + + monkeypatch.setattr(desktop_update_worker.subprocess, "run", _run) + + error = desktop_update_worker._run_update( + "uv", + ["pip", "install"], + expected_version="0.7.1", + verification_executable="/target/venv/python", + ) + + assert error is None + assert calls[0] == ["uv", "pip", "install"] + assert calls[1][0] == "/target/venv/python" + assert calls[1][-1] == "0.7.1" + + +def test_worker_restarts_prior_entry_with_saved_session( + monkeypatch, + tmp_path: Path, +) -> None: + launched = [] + monkeypatch.setattr( + desktop_update_worker.subprocess, + "Popen", + lambda command, **kwargs: launched.append((command, kwargs)), + ) + + desktop_update_worker._restart( + "openhcs", + ["--log-level", "INFO"], + session_directory=tmp_path, + restore_option="--restore-update-session", + ) + + assert launched[0][0] == [ + "openhcs", + "--log-level", + "INFO", + "--restore-update-session", + str(tmp_path), + ] + assert launched[0][1]["close_fds"] is True + assert launched[0][1]["stdin"] is subprocess.DEVNULL + assert launched[0][1]["stdout"] is subprocess.DEVNULL + assert launched[0][1]["stderr"] is subprocess.DEVNULL + + +def test_worker_relaunches_and_preserves_session_after_update_failure( + monkeypatch, + tmp_path: Path, +) -> None: + calls = [] + monkeypatch.setattr( + desktop_update_worker, + "_wait_for_parent_exit", + lambda pid: calls.append(("wait", pid)) or True, + ) + monkeypatch.setattr( + desktop_update_worker, + "_run_update", + lambda *_args, **_kwargs: "network unavailable", + ) + monkeypatch.setattr( + desktop_update_worker, + "_restart", + lambda executable, arguments, *, session_directory, restore_option: calls.append( + ("restart", executable, arguments, session_directory) + ), + ) + error_file = tmp_path / "update-error.txt" + + result = desktop_update_worker.main( + [ + "--parent-pid", + "42", + "--session-directory", + str(tmp_path), + "--update-executable", + "uv", + "--update-argument=--no-config", + "--restart-executable", + "openhcs", + "--restart-argument=--log-level", + "--restart-argument=INFO", + "--expected-version", + "0.7.1", + "--verification-executable", + "/target/venv/python", + "--error-file", + str(error_file), + "--restore-option=--restore-update-session", + ] + ) + + assert result == 1 + assert error_file.read_text(encoding="utf-8") == "network unavailable" + assert calls == [ + ("wait", 42), + ("restart", "openhcs", ["--log-level", "INFO"], tmp_path), + ] + + +def test_worker_cancels_before_update_when_parent_does_not_exit( + monkeypatch, + tmp_path: Path, +) -> None: + monkeypatch.setattr( + desktop_update_worker, + "_wait_for_parent_exit", + lambda _pid: False, + ) + monkeypatch.setattr( + desktop_update_worker, + "_run_update", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + AssertionError("update must not start while parent is alive") + ), + ) + error_file = tmp_path / "update-error.txt" + + result = desktop_update_worker.main( + [ + "--parent-pid", + "42", + "--session-directory", + str(tmp_path), + "--update-executable", + "uv", + "--update-argument=--no-config", + "--restart-executable", + "openhcs", + "--restart-argument=--log-level", + "--expected-version", + "0.7.1", + "--verification-executable", + "/target/venv/python", + "--error-file", + str(error_file), + "--restore-option=--restore-update-session", + ] + ) + + assert result == 2 + assert not tmp_path.exists() + + +def test_parser_preserves_leading_dash_forwarded_arguments() -> None: + arguments = desktop_update_worker.parse_arguments( + [ + "--parent-pid", + "42", + "--session-directory", + "/tmp/session", + "--update-executable", + "uv", + "--update-argument=--no-config", + "--update-argument=--upgrade", + "--restart-executable", + "openhcs", + "--restart-argument=--log-level", + "--restart-argument=DEBUG", + "--expected-version", + "0.7.1", + "--verification-executable", + "/target/venv/python", + "--error-file", + "/tmp/session/update-error.txt", + "--restore-option=--restore-update-session", + ] + ) + + assert arguments.update_argument == ["--no-config", "--upgrade"] + assert arguments.restart_argument == ["--log-level", "DEBUG"] + assert arguments.verification_executable == "/target/venv/python" + + +def test_worker_process_waits_updates_restarts_and_restores_session( + tmp_path: Path, +) -> None: + session_directory = tmp_path / "pending" + session_directory.mkdir() + (session_directory / "session.py").write_text( + "canonical session source", + encoding="utf-8", + ) + (session_directory / "objectstate-history.objectstate").write_text( + "canonical history", + encoding="utf-8", + ) + update_marker = tmp_path / "updated.txt" + restore_marker = tmp_path / "restored.json" + restore_script = tmp_path / "restore.py" + restore_script.write_text( + """ +import argparse +import json +from pathlib import Path +from types import SimpleNamespace + +from objectstate.object_state import ObjectStateRegistry +from openhcs.pyqt_gui.services.desktop_update import DesktopUpdateSession + +parser = argparse.ArgumentParser() +parser.add_argument("marker", type=Path) +parser.add_argument("--restore-update-session", required=True, type=Path) +args = parser.parse_args() +calls = [] +ObjectStateRegistry.load_history_from_file = classmethod( + lambda cls, path: calls.append(["history", Path(path).read_text(encoding="utf-8")]) +) +plate_manager = SimpleNamespace( + apply_code_document_source=lambda source: calls.append(["source", source]), + update_item_list=lambda: calls.append(["refresh", None]), +) +main_window = SimpleNamespace( + embedded_widgets=SimpleNamespace( + require_plate_manager=lambda: plate_manager, + ), + time_travel_widget=SimpleNamespace( + refresh=lambda: calls.append(["history-ui", None]), + ), +) +error = DesktopUpdateSession(args.restore_update_session).restore(main_window) +args.marker.write_text( + json.dumps({"calls": calls, "error": error}), + encoding="utf-8", +) +""".strip(), + encoding="utf-8", + ) + parent = subprocess.Popen( + [sys.executable, "-c", "import time; time.sleep(0.3)"], + ) + parent_reaper = threading.Thread(target=parent.wait) + parent_reaper.start() + source_root = Path(__file__).resolve().parents[3] + external_roots = ( + "external/ObjectState/src", + "external/python-introspect/src", + "external/metaclass-registry/src", + "external/arraybridge/src", + "external/pycodify/src", + "external/PolyStore/src", + "external/pyqt-reactive/src", + "external/zmqruntime/src", + ) + environment = os.environ.copy() + environment["PYTHONPATH"] = os.pathsep.join( + (str(source_root), *(str(source_root / path) for path in external_roots)) + ) + update_code = ( + "from pathlib import Path;" + f"Path({str(update_marker)!r}).write_text('updated', encoding='utf-8')" + ) + + completed = subprocess.run( + [ + sys.executable, + "-m", + "openhcs.pyqt_gui.services.desktop_update_worker", + "--parent-pid", + str(parent.pid), + "--session-directory", + str(session_directory), + "--update-executable", + sys.executable, + "--update-argument=-c", + f"--update-argument={update_code}", + "--restart-executable", + sys.executable, + f"--restart-argument={restore_script}", + f"--restart-argument={restore_marker}", + "--expected-version", + distribution_version("openhcs"), + "--verification-executable", + sys.executable, + "--error-file", + str(session_directory / "update-error.txt"), + "--restore-option=--restore-update-session", + ], + check=False, + capture_output=True, + text=True, + timeout=15, + env=environment, + ) + parent_reaper.join(timeout=2) + deadline = time.monotonic() + 10 + while not restore_marker.is_file() and time.monotonic() < deadline: + time.sleep(0.05) + + assert completed.returncode == 0, completed.stderr + assert update_marker.read_text(encoding="utf-8") == "updated" + restored = json.loads(restore_marker.read_text(encoding="utf-8")) + assert restored == { + "calls": [ + ["source", "canonical session source"], + ["history", "canonical history"], + ["history-ui", None], + ["refresh", None], + ], + "error": None, + } + assert not session_directory.exists() diff --git a/tests/unit/pyqt_gui/test_function_selector_column_presentation.py b/tests/unit/pyqt_gui/test_function_selector_column_presentation.py index 1c4ed2ffd..0bb8ce155 100644 --- a/tests/unit/pyqt_gui/test_function_selector_column_presentation.py +++ b/tests/unit/pyqt_gui/test_function_selector_column_presentation.py @@ -5,6 +5,10 @@ from PyQt6.QtTest import QTest from pyqt_reactive.widgets.shared.abstract_table_browser import ColumnPresentation +from openhcs.processing.backends.cellprofiler.spreadsheet_export import ( + export_to_spreadsheet, +) +from openhcs.processing.backends.lib_registry.openhcs_registry import OpenHCSRegistry from openhcs.processing.custom_functions.signals import CustomFunctionSignals from openhcs.pyqt_gui.dialogs import function_selector_dialog as selector_module from openhcs.pyqt_gui.dialogs.function_selector_dialog import FunctionSelectorDialog @@ -123,3 +127,14 @@ def test_function_selector_has_no_column_filter_plumbing(qapp, monkeypatch) -> N dialog.close() dialog.deleteLater() FunctionSelectorDialog._metadata_cache = prior_cache + + +def test_function_selector_renders_plate_scoped_function_without_backend(qapp) -> None: + metadata = OpenHCSRegistry.metadata_for_declared_callable(export_to_spreadsheet) + assert metadata is not None + assert metadata.get_memory_type() is None + + browser = selector_module.FunctionTableBrowser() + browser.set_items({metadata.composite_key: metadata}) + + assert browser.extract_row_data(metadata)[2] == "" diff --git a/tests/unit/pyqt_gui/test_gui_startup_progress.py b/tests/unit/pyqt_gui/test_gui_startup_progress.py index 4a8e5ad07..0c82476b4 100644 --- a/tests/unit/pyqt_gui/test_gui_startup_progress.py +++ b/tests/unit/pyqt_gui/test_gui_startup_progress.py @@ -197,6 +197,7 @@ def run(self, *, on_main_window_ready, on_startup_failure): log_file=None, config=None, no_gpu=False, + restore_update_session=None, ), startup_progress=_Progress(), ) diff --git a/tests/unit/pyqt_gui/test_ui_agent_bridge.py b/tests/unit/pyqt_gui/test_ui_agent_bridge.py index 7586b539f..15df01598 100644 --- a/tests/unit/pyqt_gui/test_ui_agent_bridge.py +++ b/tests/unit/pyqt_gui/test_ui_agent_bridge.py @@ -39,6 +39,7 @@ UiWindowOpenPolicy, UiWindowSnapshotRequest, ) +from openhcs.agent.ui_bridge_actions import MainWindowAction from openhcs.agent.ui_bridge_identities import PipelineDebugToolbarWidgetIdentity from openhcs.serialization.json import to_jsonable from openhcs.agent.services.ui_bridge_service import ( @@ -520,6 +521,47 @@ class FakeMainWindow: def __init__(self) -> None: self.embedded_widgets = FakeEmbeddedWindowWidgets() self.window_specs = {} + self.check_for_updates_action = QPushButton() + self.update_check_count = 0 + + def check_for_updates(self) -> None: + self.update_check_count += 1 + + +def test_main_window_update_action_is_projected_and_dispatches() -> None: + QtApplicationAuthority.app() + main_window = FakeMainWindow() + registry = UiBridgeSurfaceRegistry() + snapshot_provider = UiObjectStateSnapshotProvider() + MainWindowBridgeProviderSet(main_window).register( + UiBridgeRegistrationContext( + registry=registry, + snapshot_provider=snapshot_provider, + ) + ) + bridge = UiAgentBridgeService( + registry=registry, + dispatcher=InlineDispatcher(), + snapshot_provider=snapshot_provider, + ) + + action = next( + summary + for summary in bridge.list_actions().actions + if summary.identity.widget_id == "main_window" + ) + result = bridge.invoke_action( + UiActionInvokeRequest( + widget_id=action.identity.widget_id, + action_id=action.identity.action_id, + ) + ) + + assert action.identity.action_id == MainWindowAction.CHECK_FOR_UPDATES.value + assert action.enabled is True + assert action.invocation_mode == "async" + assert result.status == "accepted" + assert main_window.update_check_count == 1 def test_ui_bridge_composition_discovers_new_provider_set_declarations() -> None: @@ -2493,7 +2535,7 @@ def test_object_state_exact_field_paths_include_path_type_and_description() -> N assert field.address.field_path == "napari_display_config" assert field.object_state_path_type == "openhcs.core.config.NapariDisplayConfig" assert field.parameter_description is not None - assert "napari display behavior" in field.parameter_description + assert field.parameter_description == "Configuration for Napari display behavior." def test_object_state_field_help_uses_object_state_path_types() -> None: @@ -2520,7 +2562,7 @@ def test_object_state_field_help_uses_object_state_path_types() -> None: assert section.help_target_type == "openhcs.core.config.NapariDisplayConfig" assert section.parameter_name == "napari_display_config" assert section.description is not None - assert "napari display behavior" in section.description + assert section.description == "Configuration for Napari display behavior." assert child.errors == () assert child.help_target_type == "openhcs.core.config.NapariDisplayConfig" diff --git a/tests/unit/pyqt_gui/test_ui_config_widget_projection.py b/tests/unit/pyqt_gui/test_ui_config_widget_projection.py new file mode 100644 index 000000000..9ac238fed --- /dev/null +++ b/tests/unit/pyqt_gui/test_ui_config_widget_projection.py @@ -0,0 +1,99 @@ +"""Acceptance coverage for typed widgets in the real nested UI configuration form.""" + +from dataclasses import fields +from typing import get_type_hints + +from PyQt6.QtGui import QColor, QKeySequence +from PyQt6.QtTest import QTest + + +def _wait_until(qapp, predicate, *, attempts: int = 400) -> None: + for _ in range(attempts): + qapp.processEvents() + if predicate(): + return + QTest.qWait(5) + raise AssertionError("Nested UI configuration form did not finish materializing") + + +def test_nested_ui_config_projects_color_enums_and_shortcut_capture(qapp) -> None: + from objectstate import ( + ObjectState, + ObjectStateRegistry, + get_base_config_type, + set_base_config_type, + ) + from pyqt_reactive.forms.parameter_form_manager import ( + FormManagerConfig, + ParameterFormManager, + ) + from pyqt_reactive.protocols import KeySequenceEditAdapter + from pyqt_reactive.services.system_monitor_config import ( + PerformanceGraphColor, + PerformanceMonitorColors, + ) + from pyqt_reactive.theming import ColorScheme + from pyqt_reactive.widgets.no_scroll_spinbox import NoScrollComboBox + + from openhcs.pyqt_gui.config import UIConfig + + previous_base_type = get_base_config_type() + set_base_config_type(UIConfig) + ObjectStateRegistry.clear() + state = ObjectState(UIConfig(), scope_id="typed-ui-config-widget-projection") + manager = ParameterFormManager( + state, + FormManagerConfig( + color_scheme=ColorScheme(), + use_scroll_area=False, + ), + ) + + try: + _wait_until( + qapp, + lambda: ( + "performance_monitor" in manager.nested_managers + and "colors" + in manager.nested_managers["performance_monitor"].nested_managers + and "shortcuts" in manager.nested_managers + and "show_help" + in manager.nested_managers["shortcuts"].widgets + ), + ) + colors = manager.nested_managers["performance_monitor"].nested_managers[ + "colors" + ] + shortcuts = manager.nested_managers["shortcuts"] + + color_hints = get_type_hints(PerformanceMonitorColors, include_extras=True) + assert all( + color_hints[declared_field.name] is PerformanceGraphColor + for declared_field in fields(PerformanceMonitorColors) + ) + assert all(QColor.isValidColor(color.value) for color in PerformanceGraphColor) + for widget in colors.widgets.values(): + assert isinstance(widget, NoScrollComboBox) + assert tuple(widget.itemData(index) for index in range(widget.count())) == tuple( + PerformanceGraphColor + ) + + help_widget = shortcuts.widgets["show_help"] + assert isinstance(help_widget, KeySequenceEditAdapter) + + flashes: list[str] = [] + shortcuts.queue_field_flash = flashes.append + help_widget.setKeySequence(QKeySequence("Ctrl+Shift+H")) + qapp.processEvents() + assert state.parameters["shortcuts.show_help"] == "F1" + + help_widget.editingFinished.emit() + qapp.processEvents() + assert state.parameters["shortcuts.show_help"] == "Ctrl+Shift+H" + assert flashes == ["shortcuts.show_help"] + finally: + manager.close() + manager.deleteLater() + qapp.processEvents() + ObjectStateRegistry.clear() + set_base_config_type(previous_base_type) diff --git a/tests/unit/test_zmq_submission_timeout.py b/tests/unit/test_zmq_submission_timeout.py new file mode 100644 index 000000000..869d42090 --- /dev/null +++ b/tests/unit/test_zmq_submission_timeout.py @@ -0,0 +1,43 @@ +from types import MethodType + +from zmqruntime.messages import ControlMessageType, MessageFields + +from openhcs.runtime.zmq_execution_client import ZMQExecutionClient + + +class _Submission: + def to_task(self): + return object() + + +def test_submission_timeout_covers_progress_registration_and_execution_request(): + client = ZMQExecutionClient() + client._connected = True + observed: list[tuple[str, int]] = [] + + def ensure_progress_subscription(self, *, timeout_ms: int): + observed.append(("progress", timeout_ms)) + + def serialize_task(self, _task, _config=None): + return {} + + def send_control_request(self, request, *, timeout_ms: int): + observed.append((request[MessageFields.TYPE], timeout_ms)) + return {MessageFields.STATUS: "accepted"} + + client._ensure_progress_subscription = MethodType( + ensure_progress_subscription, + client, + ) + client.serialize_task = MethodType(serialize_task, client) + client._send_control_request_bounded = MethodType( + send_control_request, + client, + ) + + client._submit_submission(_Submission(), timeout_ms=15000) + + assert observed == [ + ("progress", 15000), + (ControlMessageType.EXECUTE.value, 15000), + ]