From 5754915b4eb5c91b7d1b0ce8cca6f2c039f4c616 Mon Sep 17 00:00:00 2001 From: Sebastiano Romi Date: Wed, 16 Sep 2026 14:51:54 +0200 Subject: [PATCH 1/4] Add plugin updates and distinct provenance symbols [minor] Signed-off-by: Codex --- CHANGELOG.md | 7 + custom_plugins/README.md | 3 +- docs/plugins.md | 29 ++- docs/quick-start.md | 9 +- src/curvemole/core/extensions.py | 14 +- src/curvemole/core/plugin_identity.py | 20 ++ src/curvemole/core/plugin_updates.py | 176 +++++++++++++ src/curvemole/core/plugins.py | 28 +++ src/curvemole/gui/app.py | 2 + src/curvemole/gui/dialogs.py | 27 +- src/curvemole/gui/folder_import.py | 6 + src/curvemole/gui/main_window.py | 2 + src/curvemole/gui/panels.py | 8 + src/curvemole/gui/plugin_host.py | 8 +- src/curvemole/gui/plugin_updates.py | 260 ++++++++++++++++++++ src/curvemole/gui/quick_function_library.py | 29 ++- tests/test_plugin_identity.py | 70 ++++++ tests/test_plugin_updates.py | 203 +++++++++++++++ tests/test_quick_function_library.py | 4 + 19 files changed, 879 insertions(+), 26 deletions(-) create mode 100644 src/curvemole/core/plugin_identity.py create mode 100644 src/curvemole/core/plugin_updates.py create mode 100644 src/curvemole/gui/plugin_updates.py create mode 100644 tests/test_plugin_identity.py create mode 100644 tests/test_plugin_updates.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 90c0459..dca583d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes follow Keep a Changelog and Semantic Versioning. ## [Unreleased] +### Added + +- Check loaded plugins for validated community updates at startup and hourly, show + version-aware status, and stage selected updates for the next application start. +- Give each plugin a persistent, distinct symbol and show its name when hovering over + contributed menus, functions, solvers, workflows and plugin-manager entries. + ### Fixed - Ruby monitor 0.4.0 defaults to 296 K without confirmation, reports pressure with diff --git a/custom_plugins/README.md b/custom_plugins/README.md index e563acf..4a9c0bd 100644 --- a/custom_plugins/README.md +++ b/custom_plugins/README.md @@ -29,7 +29,8 @@ pass on Linux, Windows and macOS. Failed checks leave the previous download in p unmerged pull requests are never offered through these links. Extract the bundle, then open **File > Plugin manager**, select the chosen plugin's -folder, review and load it. Its additions carry the ◆ marker; built-in actions are +folder, review and load it. Its additions carry a plugin-specific symbol; hover over it to see the plugin name. +Built-in actions are not replaced. Plugins remain installed across restarts and can be disabled/removed in the manager. No plugin is downloaded, installed or trusted automatically. diff --git a/docs/plugins.md b/docs/plugins.md index 78b7418..2963229 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -1,10 +1,31 @@ # Creating and managing CurveMole plugins Plugins add features to CurveMole. They do not replace built-in commands, exporters, -solvers or functions through the supported API. Every contribution has a diamond -symbol (◆) in its menu or selector and identifies its providing plugin in the tooltip. +solvers or functions through the supported API. Every contribution has its plugin's own symbol (for example ◆, ● or ▲) in its +menu or selector and identifies its providing plugin in the tooltip. Executable Python plugins are different from safe custom-function/formula JSON files. +## Updates inside CurveMole + +CurveMole checks at startup and hourly for updates to **loaded plugins only**, using +its validated Community Plugins catalog. The **Plugins** status badge follows the +application's colours: green when current, yellow for bug fixes, red for feature or +major updates. An available version is announced once. Open the badge or +**File > Plugin Manager > Plugin updates…** to check again and choose **Update selected**. +**Help > Check for updates** checks the application and loaded plugins. + +Updating downloads and trusts the selected versions. CurveMole verifies the archive +against the catalog and checks plugin identity and API compatibility before switching +the saved installation. Save your project and reopen CurveMole to activate updates. +Current panels, fits and folder monitoring continue with the running version until +then. Original plugin folders and project settings are preserved; the updated copy, +including its own README/manual, is kept in CurveMole's plugin storage. + +Unloaded or disabled plugins are not checked or installed. Plugins absent from the +community catalog, package entry points and nonstandard versions are reported as +having no automatic update source. Incompatible updates require updating CurveMole +first. Failed or interrupted downloads leave the previous installation in place. + ## Install, enable, disable and remove 1. Keep the plugin's `.curvemole-plugin.json` manifest and its Python module together @@ -92,7 +113,7 @@ def register(api): description="Active spectrum, comma-separated x/y with header") ``` -This adds **File > ◆ Exporters > ◆ My CSV export**. CurveMole asks for a destination; +This adds **File > Plugins: Exporters > ◆ My CSV export**. CurveMole asks for a destination; the built-in export commands remain available. The callback owns the external file write; use a temporary file followed by atomic replacement if partial output would be a problem. File writes cannot be undone by CurveMole's Undo. @@ -203,7 +224,7 @@ def register(api): )) ``` -Use a namespaced function identifier. Functions receive the diamond symbol in their +Use a namespaced function identifier. Functions receive their plugin's symbol in their display names. Registering an existing identifier or passing `replace=True` fails, with no changes to built-ins. Custom function JSON import/export remains separate. diff --git a/docs/quick-start.md b/docs/quick-start.md index 4bdaab8..36ba43d 100644 --- a/docs/quick-start.md +++ b/docs/quick-start.md @@ -217,10 +217,13 @@ choose an entry to reopen it, or clear the list from that menu. Open **File > Plugin Manager**, choose the folder containing a plugin manifest and Python module, scan, review the source and explicitly trust it. Loaded plugins add -entries marked with a diamond symbol; built-in commands stay available. Enabled +entries marked with a distinct symbol for each plugin; hover over a marked entry to +see the plugin name. Built-in commands stay available. Enabled plugins load again on restart. Use **Disable** or **Remove** in the manager to stop -loading them. After an abnormal exit, automatic plugin loading is disabled so you -can recover safely. See [the plugin guide](plugins.md) for installation details, +loading them. CurveMole checks loaded plugins at startup and hourly against the +validated community catalog. Open the **Plugins** badge or **Plugin updates...** in +the manager to install selected updates; reopen CurveMole to activate them. After +an abnormal exit, automatic plugin loading is disabled so you can recover safely. See [the plugin guide](plugins.md) for installation details, security limitations and complete examples for writing an exporter or fit algorithm. diff --git a/src/curvemole/core/extensions.py b/src/curvemole/core/extensions.py index db25f5e..f4184c9 100644 --- a/src/curvemole/core/extensions.py +++ b/src/curvemole/core/extensions.py @@ -21,6 +21,8 @@ class Contribution: callback: Callable[..., Any] description: str = "" auto_show: bool = False + symbol: str = "◆" + plugin_name: str = "" class ExtensionRegistry: @@ -54,8 +56,10 @@ def register(self, definition: Any, *, replace: bool = False) -> None: def evaluate(*args: Any, **kwargs: Any) -> Any: return self._manager.invoke(self.identifier, original, *args, **kwargs) marked = dataclass_replace( - definition, display_name=f"◆ {definition.display_name}", evaluator=evaluate, - custom_metadata={**definition.custom_metadata, "plugin_owner": self.identifier}) + definition, display_name=f"{self._manager.symbol(self.identifier)} {definition.display_name}", evaluator=evaluate, + custom_metadata={**definition.custom_metadata, "plugin_owner": self.identifier, + "plugin_symbol": self._manager.symbol(self.identifier), + "plugin_name": self._manager.plugin_name(self.identifier)}) self._manager.registry.register(marked) self._manager.function_owners[definition.identifier] = self.identifier @@ -83,6 +87,8 @@ def checked(*args: Any, **kwargs: Any) -> Any: def guarded(*args: Any, **kwargs: Any) -> Any: return self._manager.invoke(self.identifier, checked, *args, **kwargs) - extensions.entries[key] = Contribution(self.identifier, key, f"◆ {label}", kind, - guarded, description, auto_show) + extensions.entries[key] = Contribution( + self.identifier, key, f"{self._manager.symbol(self.identifier)} {label}", kind, + guarded, description, auto_show, self._manager.symbol(self.identifier), + self._manager.plugin_name(self.identifier)) return key diff --git a/src/curvemole/core/plugin_identity.py b/src/curvemole/core/plugin_identity.py new file mode 100644 index 0000000..1c81e99 --- /dev/null +++ b/src/curvemole/core/plugin_identity.py @@ -0,0 +1,20 @@ +"""Consistent plugin provenance labels, independent of Qt.""" +SYMBOLS = ("◆", "●", "▲", "■", "★", "⬟", "▼", "◉", "✚", "◈", "◐", "✦") + + +def provenance(symbol: str, name: str, identifier: str) -> str: + return f"{symbol} This feature belongs to plugin {name} ({identifier})." + + +def function_tooltip(definition) -> str: + metadata = definition.custom_metadata + owner = metadata.get("plugin_owner") + if not owner: + return definition.description + return provenance(metadata.get("plugin_symbol", "◆"), metadata.get("plugin_name", owner), owner) + ( + "\n" + definition.description if definition.description else "") + + +def contribution_tooltip(entry) -> str: + return provenance(entry.symbol, entry.plugin_name or entry.owner, entry.owner) + ( + "\n" + entry.description if entry.description else "") diff --git a/src/curvemole/core/plugin_updates.py b/src/curvemole/core/plugin_updates.py new file mode 100644 index 0000000..777a7d8 --- /dev/null +++ b/src/curvemole/core/plugin_updates.py @@ -0,0 +1,176 @@ +"""Validated community plugin updates, staged without executing downloaded code.""" +from __future__ import annotations + +import copy +import hashlib +import io +import json +import re +import shutil +import stat +import tempfile +import zipfile +from dataclasses import asdict, dataclass +from pathlib import Path, PurePosixPath +from typing import Any + +from curvemole.core.errors import CurveMoleError +from curvemole.core.plugins import PluginCandidate, PluginManager, PluginMetadata +from curvemole.version import PLUGIN_API_VERSION + +DOWNLOAD_ROOT = "https://github.com/SebRoLENS/curvemole/releases/download/community-plugins-latest" +CATALOG_URL = DOWNLOAD_ROOT + "/catalog.json" +MAX_ARCHIVE_BYTES = 20 * 1024 * 1024 +MAX_EXTRACTED_BYTES = 80 * 1024 * 1024 + + +def version(value: str) -> tuple[int, ...] | None: + if not re.fullmatch(r"\d+\.\d+\.\d+", value): + return None + return tuple(map(int, value.split("."))) + + +@dataclass(frozen=True) +class PluginUpdate: + identifier: str + name: str + current: str + latest: str + folder: str + sha256: str + api: str + capabilities: tuple[str, ...] + + @property + def url(self) -> str: + return f"{DOWNLOAD_ROOT}/{self.folder}.zip" + + @property + def compatible(self) -> bool: + return self.api == PLUGIN_API_VERSION + + +def available_updates(manager: PluginManager, payload: Any) -> tuple[list[PluginUpdate], list[str]]: + """Only running local plugins are eligible; never install catalog additions.""" + if not isinstance(payload, dict) or not isinstance(payload.get("plugins"), list): + raise CurveMoleError("Invalid community plugin catalog.") + catalog = {} + for item in payload["plugins"]: + if not isinstance(item, dict) or not isinstance(item.get("identifier"), str): + raise CurveMoleError("Invalid community plugin catalog entry.") + if item["identifier"] in catalog: + raise CurveMoleError("Duplicate plugin identifier in catalog.") + catalog[item["identifier"]] = item + updates, unsupported = [], [] + for identifier, metadata in manager.loaded.items(): + if identifier in manager.errors: + continue + installed = manager.installed.get(identifier, {}) + if not installed.get("enabled"): + continue + item = catalog.get(identifier) + current = version(metadata.version) + if item is None or installed.get("kind") != "local" or current is None: + unsupported.append(identifier) + continue + latest = version(str(item.get("version", ""))) + folder, digest = item.get("folder", ""), item.get("sha256", "") + if (latest is None or not isinstance(folder, str) + or not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_-]*", folder) + or not isinstance(digest, str) or not re.fullmatch(r"[a-f0-9]{64}", digest) + or not isinstance(item.get("capabilities"), list)): + raise CurveMoleError(f"Invalid update metadata for {identifier}.") + pending = version(str(installed.get("metadata", {}).get("version", ""))) + if latest <= max(current, pending or current): + continue + updates.append(PluginUpdate(identifier, str(item.get("name", identifier)), metadata.version, + item["version"], folder, digest, str(item.get("api_compatibility", "")), + tuple(item["capabilities"]))) + return updates, unsupported + + +def _unpack(archive: bytes, root: Path, update: PluginUpdate) -> Path: + if len(archive) > MAX_ARCHIVE_BYTES: + raise CurveMoleError("Plugin download exceeds the size limit.") + with zipfile.ZipFile(io.BytesIO(archive)) as bundle: + members = bundle.infolist() + if len(members) > 2000 or sum(item.file_size for item in members) > MAX_EXTRACTED_BYTES: + raise CurveMoleError("Plugin archive exceeds the extraction limit.") + seen = set() + for member in members: + path = PurePosixPath(member.filename) + mode = member.external_attr >> 16 + if (not path.parts or path.is_absolute() or path.parts[0] != update.folder + or any(part in (".", "..") or ":" in part for part in path.parts) + or "\\" in member.filename or stat.S_ISLNK(mode) + or path.as_posix().casefold() in seen): + raise CurveMoleError("Unsafe or duplicate path in plugin archive.") + seen.add(path.as_posix().casefold()) + destination = root.joinpath(*path.parts) + if member.is_dir(): + destination.mkdir(parents=True, exist_ok=True) + else: + destination.parent.mkdir(parents=True, exist_ok=True) + with bundle.open(member) as source, destination.open("wb") as output: + shutil.copyfileobj(source, output) + folder = root / update.folder + digest = hashlib.sha256() + for path in sorted(folder.rglob("*"), key=lambda item: item.relative_to(folder).as_posix()): + if path.is_file(): + digest.update(path.relative_to(folder).as_posix().encode()) + digest.update(b"\0") + digest.update(path.read_bytes()) + if digest.hexdigest() != update.sha256: + raise CurveMoleError("Plugin checksum mismatch. Check for updates again and retry.") + return folder + + +def stage_update(manager: PluginManager, update: PluginUpdate, archive: bytes) -> None: + """Atomically switch the next-startup record; running code and source stay intact.""" + current = manager.loaded.get(update.identifier) + record = manager.installed.get(update.identifier) + if (current is None or current.version != update.current or not record + or not record.get("enabled") or update.identifier in manager.errors + or record.get("kind") != "local"): + raise CurveMoleError("Plugin is no longer loaded; check for updates again.") + if not update.compatible: + raise CurveMoleError("Update CurveMole first: this plugin requires a different plugin API.") + installed_version = version(str(record["metadata"]["version"])) + if version(update.latest) is None or version(update.latest) <= (installed_version or ()): + raise CurveMoleError("This plugin version is already installed or newer.") + if manager.storage is None: + raise CurveMoleError("Persistent plugin storage is unavailable.") + staging = manager.storage / "updates" + staging.mkdir(parents=True, exist_ok=True) + temporary = Path(tempfile.mkdtemp(prefix="plugin-", dir=staging)) + original = copy.deepcopy(record) + committed = False + try: + folder = _unpack(archive, temporary, update) + manifests = list(folder.glob("*.curvemole-plugin.json")) + if len(manifests) != 1: + raise CurveMoleError("Update must contain exactly one plugin manifest.") + manifest = manifests[0] + metadata = PluginMetadata.from_mapping(json.loads(manifest.read_text(encoding="utf-8")), + source=str(manifest)) + if (metadata.identifier != update.identifier or metadata.version != update.latest + or metadata.api_compatibility != update.api + or metadata.capabilities != update.capabilities + or not re.fullmatch(r"[a-zA-Z0-9_]+\.py", metadata.module or "") + or not (folder / metadata.module).is_file()): + raise CurveMoleError("Plugin manifest does not match the validated catalog.") + candidate = PluginCandidate(metadata, str(manifest), "local") + manager.installed[update.identifier] = { + "metadata": asdict(metadata), "reference": str(manifest), "kind": "local", + "fingerprint": manager._fingerprint(candidate), "enabled": True, + "symbol": manager.symbol(update.identifier), + } + try: + manager._save() + except Exception: + manager.installed[update.identifier] = original + raise + committed = True + finally: + if not committed: + shutil.rmtree(temporary, ignore_errors=True) diff --git a/src/curvemole/core/plugins.py b/src/curvemole/core/plugins.py index 756ecca..7927f61 100644 --- a/src/curvemole/core/plugins.py +++ b/src/curvemole/core/plugins.py @@ -28,6 +28,7 @@ class PluginMetadata: capabilities: tuple[str, ...] source: str module: str | None = None + name: str = "" @classmethod def from_mapping(cls, value: Mapping[str, Any], *, source: str) -> PluginMetadata: @@ -51,6 +52,7 @@ def from_mapping(cls, value: Mapping[str, Any], *, source: str) -> PluginMetadat licence=str(value["licence"]), capabilities=tuple(str(item) for item in value["capabilities"]), source=source, + name=str(value.get("name", "")), module=str(value["module"]) if value.get("module") else None, ) @@ -77,6 +79,8 @@ def __init__( self.storage = Path(storage) if storage is not None else None self.installed: dict[str, dict[str, Any]] = {} self.errors: dict[str, str] = {} + self.symbols: dict[str, str] = {} + self.names: dict[str, str] = {} self.session_path: Path | None = None if self.storage: self.storage.mkdir(parents=True, exist_ok=True) @@ -89,6 +93,28 @@ def __init__( except (OSError, ValueError): self.installed = {} + def symbol(self, identifier: str) -> str: + from curvemole.core.plugin_identity import SYMBOLS + if identifier not in self.symbols: + used = set(self.symbols.values()) | { + str(record.get("symbol", "")) for record in self.installed.values()} + saved = self.installed.get(identifier, {}).get("symbol") + if saved and saved not in self.symbols.values(): + self.symbols[identifier] = saved + else: + index = 0 + while True: + candidate = SYMBOLS[index] if index < len(SYMBOLS) else f"◆{index + 1}" + if candidate not in used: + self.symbols[identifier] = candidate + break + index += 1 + return self.symbols[identifier] + + def plugin_name(self, identifier: str) -> str: + return self.names.get(identifier) or self.installed.get(identifier, {}).get( + "metadata", {}).get("name") or identifier + def _save(self) -> None: if self.storage: destination = self.storage / "installed.json" @@ -238,6 +264,7 @@ def load(self, candidate: PluginCandidate, *, trust: bool = False) -> PluginMeta from curvemole.core.extensions import PluginAPI fingerprint = self._fingerprint(candidate) self.errors.pop(metadata.identifier, None) + self.names[metadata.identifier] = metadata.name or metadata.identifier try: if candidate.kind == "local": loaded = self._load_local(candidate) @@ -262,6 +289,7 @@ def load(self, candidate: PluginCandidate, *, trust: bool = False) -> PluginMeta "metadata": asdict(metadata), "reference": str(Path(candidate.reference).resolve()) if candidate.kind == "local" else candidate.reference, "kind": candidate.kind, "fingerprint": fingerprint, "enabled": True, + "symbol": self.symbol(metadata.identifier), } self._save() return metadata diff --git a/src/curvemole/gui/app.py b/src/curvemole/gui/app.py index 61c2fb9..0f30f95 100644 --- a/src/curvemole/gui/app.py +++ b/src/curvemole/gui/app.py @@ -383,6 +383,8 @@ def main(argv: Sequence[str] | None = None) -> int: QCoreApplication.setApplicationVersion(__version__) window = CurveMoleMainWindow() window._release_update_controller = UpdateController(window) + from curvemole.gui.plugin_updates import PluginUpdateController + window.plugin_update_controller = PluginUpdateController(window) window.show() if os.environ.get("CURVEMOLE_SMOKE_TEST") == "1": from PySide6.QtCore import QTimer diff --git a/src/curvemole/gui/dialogs.py b/src/curvemole/gui/dialogs.py index acced3f..318849e 100644 --- a/src/curvemole/gui/dialogs.py +++ b/src/curvemole/gui/dialogs.py @@ -41,6 +41,7 @@ from curvemole.core.fitting import FitMode, FitPlan, FitSettings from curvemole.core.importers import ColumnMapping, ImportConfig, inspect_file from curvemole.core.models import Component +from curvemole.core.plugin_identity import contribution_tooltip, function_tooltip, provenance from curvemole.core.plugins import PluginCandidate, PluginManager from curvemole.core.project import Project from curvemole.core.registry import FunctionRegistry @@ -308,6 +309,7 @@ def __init__( for component in candidates: definition = registry.get(component.function_id) item = QListWidgetItem(f"{component.name} · {definition.display_name}") + item.setToolTip(function_tooltip(definition)) item.setData(Qt.ItemDataRole.UserRole, component.id) item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable) item.setCheckState( @@ -351,6 +353,8 @@ def selected_component_ids(self) -> list[str]: ] + + class AddComponentDialog(QDialog): def __init__( self, @@ -367,6 +371,8 @@ def __init__( self.function = QComboBox() for definition in registry.values(): self.function.addItem(definition.display_name, definition.identifier) + self.function.setItemData(self.function.count() - 1, function_tooltip(definition), + Qt.ItemDataRole.ToolTipRole) self.name = QLineEdit() self.operator = QComboBox() self.operator.addItems(["add", "subtract", "multiply", "divide", "convolve"]) @@ -420,7 +426,8 @@ def component(self) -> Component: def _update(self) -> None: definition = self.registry.get(self.function.currentData()) - self.description.setText(definition.description or definition.display_name) + self.description.setText(function_tooltip(definition) or definition.display_name) + self.function.setToolTip(function_tooltip(definition)) self.polynomial_order.setEnabled(definition.identifier == "polynomial") is_spline = definition.identifier == "cubic_spline" self.spline_nodes.setVisible(False) @@ -714,6 +721,10 @@ def __init__( from curvemole.core.extensions import extensions for entry in extensions.values("fit_solvers"): self.solver.addItem(entry.label, entry.identifier) + self.solver.setItemData(self.solver.count() - 1, contribution_tooltip(entry), + Qt.ItemDataRole.ToolTipRole) + self.solver.currentIndexChanged.connect(lambda index: self.solver.setToolTip( + self.solver.itemData(index, Qt.ItemDataRole.ToolTipRole) or "")) self.solver.setCurrentIndex(max(0, self.solver.findData(settings.solver))) self.loss = QComboBox() self.loss.addItems(["linear", "soft_l1", "huber", "cauchy"]) @@ -1082,18 +1093,23 @@ def __init__( community = QHBoxLayout() for label, address in ( (self.tr("Browse validated plugins"), - "https://github.com/SebRoLENS/curvemole/actions/workflows/community-plugins.yml"), + "https://github.com/SebRoLENS/curvemole/releases/tag/community-plugins-latest"), (self.tr("Share my plugin on GitHub…"), "https://github.com/SebRoLENS/curvemole/tree/main/custom_plugins#submit-a-plugin"), ): button = QPushButton(label) button.clicked.connect(lambda checked=False, url=address: QDesktopServices.openUrl(QUrl(url))) community.addWidget(button) + controller = getattr(parent, "plugin_update_controller", None) + if controller is not None: + updates = QPushButton(self.tr("Plugin updates…")) + updates.clicked.connect(controller.open) + community.addWidget(updates) layout.addLayout(community) explanation = QLabel( self.tr( "Python plugins can execute arbitrary code. CurveMole reads local JSON metadata first " - "and loads code only after your explicit approval. ◆ marks plugin contributions." + "and loads code only after your explicit approval. Each plugin has its own symbol; hover for its name." ) ) explanation.setWordWrap(True) @@ -1148,9 +1164,12 @@ def scan(self) -> None: self.list.clear() for candidate in self.candidates: self.list.addItem( - f"◆ {candidate.metadata.identifier} {candidate.metadata.version} " + f"{self.manager.symbol(candidate.metadata.identifier)} {candidate.metadata.identifier} {candidate.metadata.version} " + ("[loaded]" if candidate.metadata.identifier in self.manager.loaded else "[not loaded]") ) + self.list.item(self.list.count() - 1).setToolTip(provenance( + self.manager.symbol(candidate.metadata.identifier), + candidate.metadata.name or candidate.metadata.identifier, candidate.metadata.identifier)) if self.candidates: self.list.setCurrentRow(0) else: diff --git a/src/curvemole/gui/folder_import.py b/src/curvemole/gui/folder_import.py index 1863ed4..a7d8605 100644 --- a/src/curvemole/gui/folder_import.py +++ b/src/curvemole/gui/folder_import.py @@ -33,6 +33,7 @@ from curvemole.core.fitting import CancellationToken from curvemole.core.folder_import import FolderScan from curvemole.core.importers import ColumnMapping, import_file +from curvemole.core.plugin_identity import contribution_tooltip from curvemole.core.project import Project from curvemole.gui.plugin_host import PluginContext @@ -100,6 +101,8 @@ def __init__(self, controller): for entry in extensions.values("import_processors"): if entry.owner not in controller.window.plugin_manager.errors: self.processor.addItem(entry.label, entry.identifier) + self.processor.setItemData(self.processor.count() - 1, contribution_tooltip(entry), + Qt.ItemDataRole.ToolTipRole) self.processor.currentIndexChanged.connect(self.processor_changed) form.addRow("Automatic workflow", self.processor) self.x_column = QSpinBox() @@ -161,6 +164,7 @@ def choose_folder(self): self.folder.setText(path) def processor_changed(self): + self.processor.setToolTip(self.processor.currentData(Qt.ItemDataRole.ToolTipRole) or "") entry = extensions.entries.get(self.processor.currentData()) if entry: data = self.controller.window.project.ui_state.get("plugin_data", {}).get( @@ -243,9 +247,11 @@ def show(self): for entry in extensions.values("import_processors"): if entry.owner not in self.window.plugin_manager.errors: combo.addItem(entry.label, entry.identifier) + combo.setItemData(combo.count() - 1, contribution_tooltip(entry), Qt.ItemDataRole.ToolTipRole) index = combo.findData(selected) combo.setCurrentIndex(max(0, index)) combo.blockSignals(False) + combo.setToolTip(combo.currentData(Qt.ItemDataRole.ToolTipRole) or "") if self.scan is not None: self.dialog.folder.setText(str(self.scan.folder)) self.dialog.contains.setText(self.scan.contains) diff --git a/src/curvemole/gui/main_window.py b/src/curvemole/gui/main_window.py index f17d21b..d397a80 100644 --- a/src/curvemole/gui/main_window.py +++ b/src/curvemole/gui/main_window.py @@ -2370,6 +2370,8 @@ def show_plugin_manager(self) -> None: self.settings.value("plugin_directory", ""), self) dialog.exec() self.settings.setValue("plugin_directory", dialog.directory.text().strip()) + if hasattr(self, "plugin_update_controller"): + self.plugin_update_controller.check() self.plugin_host.refresh() self.refresh_all() diff --git a/src/curvemole/gui/panels.py b/src/curvemole/gui/panels.py index 5338519..745c77d 100644 --- a/src/curvemole/gui/panels.py +++ b/src/curvemole/gui/panels.py @@ -34,6 +34,7 @@ from curvemole.core.diagnostics import residual_diagnostics from curvemole.core.expressions import SafeExpression, expression_parameters from curvemole.core.functions import formula_definition +from curvemole.core.plugin_identity import function_tooltip from curvemole.core.project import Project from curvemole.core.registry import FunctionRegistry @@ -173,7 +174,14 @@ def refresh(self, selected_component_id: str | None = None) -> None: label = component.name if component.is_background: label += self.tr(" · Background") + if component.function_id in self.registry.identifiers(): + definition = self.registry.get(component.function_id) + symbol = definition.custom_metadata.get("plugin_symbol", "") + if symbol and not label.startswith(symbol + " "): + label = symbol + " " + label item = QListWidgetItem(label) + if component.function_id in self.registry.identifiers(): + item.setToolTip(function_tooltip(self.registry.get(component.function_id))) item.setData(Qt.ItemDataRole.UserRole, component.id) item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable) item.setCheckState(Qt.CheckState.Checked if component.enabled else Qt.CheckState.Unchecked) diff --git a/src/curvemole/gui/plugin_host.py b/src/curvemole/gui/plugin_host.py index ed8f18e..c5d1a2f 100644 --- a/src/curvemole/gui/plugin_host.py +++ b/src/curvemole/gui/plugin_host.py @@ -19,6 +19,7 @@ ) from curvemole.core.extensions import extensions +from curvemole.core.plugin_identity import contribution_tooltip @dataclass @@ -49,7 +50,7 @@ def __init__(self, window: Any) -> None: locations = {"importers": "File", "exporters": "File", "transformations": "Data", "analysis": "Tools", "actions": "Tools", "workflows": "Tools", "panels": "View", "plot_layers": "View"} - self.menus = {kind: menus[location].addMenu("◆ " + kind.replace("_", " ").title()) + self.menus = {kind: menus[location].addMenu("Plugins: " + kind.replace("_", " ").title()) for kind, location in locations.items()} self.refresh() @@ -71,12 +72,14 @@ def refresh(self) -> None: self.window._refresh_quick_function_selector() for kind, menu in self.menus.items(): menu.clear() + menu.setToolTipsVisible(True) available = [entry for entry in extensions.values(kind) if entry.owner not in self.window.plugin_manager.errors] menu.menuAction().setVisible(bool(available)) for entry in available: action = menu.addAction(entry.label) - action.setToolTip(f"Plugin: {entry.owner}\n{entry.description}") + action.setToolTip(contribution_tooltip(entry)) + action.setStatusTip(contribution_tooltip(entry)) action.triggered.connect(lambda checked=False, item=entry: self.run(item)) if kind == "panels" and entry.auto_show and entry.identifier not in self._auto_opened: self._auto_opened.add(entry.identifier) @@ -141,6 +144,7 @@ def restore(project: Any) -> None: scroll.setWidgetResizable(True) scroll.setWidget(result) dialog.setWidget(scroll) + dialog.setToolTip(contribution_tooltip(entry)) dialog.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) # Auto-opened panels are created after MainWindow.restoreState(). # Restore their saved dock placement before choosing a first-use default. diff --git a/src/curvemole/gui/plugin_updates.py b/src/curvemole/gui/plugin_updates.py new file mode 100644 index 0000000..3bbb309 --- /dev/null +++ b/src/curvemole/gui/plugin_updates.py @@ -0,0 +1,260 @@ +"""Nonblocking update checks and downloads for loaded community plugins.""" +from __future__ import annotations + +import json + +from PySide6.QtCore import QObject, Qt, QTimer, QUrl +from PySide6.QtNetwork import QNetworkAccessManager, QNetworkReply, QNetworkRequest +from PySide6.QtWidgets import ( + QDialog, + QHBoxLayout, + QLabel, + QPushButton, + QTableWidget, + QTableWidgetItem, + QToolButton, + QVBoxLayout, +) + +from curvemole.core.plugin_identity import provenance +from curvemole.core.plugin_updates import ( + CATALOG_URL, + MAX_ARCHIVE_BYTES, + PluginUpdate, + available_updates, + stage_update, +) +from curvemole.gui.updates import CHECK_INTERVAL_MS, UpdateController, semantic_version, update_kind +from curvemole.version import __version__ + + +class PluginUpdateController(QObject): + def __init__(self, window): + super().__init__(window) + self.window = window + self.plugins = window.plugin_manager + self.settings = window.settings + self.network = QNetworkAccessManager(self) + self.reply = None + self.updates = [] + self.unsupported = [] + self.queue = [] + self.errors = [] + self.busy = False + self.dialog = None + self.message = "Check for updates to loaded plugins." + self.badge = QToolButton(window) + self.badge.setText("Plugins") + self.badge.clicked.connect(self.open) + window.statusBar().addPermanentWidget(self.badge) + self._refresh_badge() + window.update_action.triggered.connect(lambda checked=False: self.check(force=True)) + self.timer = QTimer(self) + self.timer.setInterval(CHECK_INTERVAL_MS) + self.timer.timeout.connect(self.check) + self.timer.start() + QTimer.singleShot(0, self.check) + + def _pending(self): + return {key: record["metadata"]["version"] + for key, record in self.plugins.installed.items() + if key in self.plugins.loaded and record.get("enabled") + and record["metadata"]["version"] != self.plugins.loaded[key].version} + + def _refresh_badge(self, error=False): + pending = self._pending() + self.badge.setVisible(bool(self.plugins.loaded)) + state = "error" if error else "current" + if self.busy: + state = "checking" + elif self.updates: + kinds = [update_kind(semantic_version(u.current), semantic_version(u.latest)) for u in self.updates] + state = "major" if "major" in kinds else "minor" if "minor" in kinds else "patch" + elif pending: + state = "patch" + self.badge.setStyleSheet(UpdateController._BADGE_STYLES[state]) + self.badge.setText(f"Plugins: {len(self.updates)} updates" if self.updates else + "Plugins: restart required" if pending else "Plugins") + self.badge.setToolTip(self.message) + + def _fetch(self, url, limit, callback): + request = QNetworkRequest(QUrl(url)) + request.setRawHeader(b"User-Agent", f"CurveMole/{__version__}".encode()) + request.setTransferTimeout(30_000) + reply = self.network.get(request) + self.reply = reply + data = bytearray() + too_large = False + + def consume(): + nonlocal too_large + data.extend(bytes(reply.readAll())) + if len(data) > limit and not too_large: + too_large = True + reply.abort() + + def finished(): + consume() + error = ("Download exceeds the size limit." if too_large else reply.errorString() + if reply.error() != QNetworkReply.NetworkError.NoError else "") + self.reply = None + reply.deleteLater() + callback(bytes(data), error) + + reply.readyRead.connect(consume) + reply.finished.connect(finished) + + def check(self, *, force=False): + if self.busy: + return + if not any(key not in self.plugins.errors for key in self.plugins.loaded): + self.updates, self.unsupported = [], [] + self.message = "No loaded plugins to check." + self._refresh() + return + self.busy = True + self.message = "Checking updates for loaded plugins…" + self._refresh() + self._fetch(CATALOG_URL, 1024 * 1024, + lambda data, error: self._checked(data, error, force)) + + def _checked(self, data, error, force): + self.busy = False + try: + if error: + raise ValueError(error) + self.updates, self.unsupported = available_updates(self.plugins, json.loads(data)) + self.message = (f"{len(self.updates)} plugin update(s) available." if self.updates else + "Loaded community plugins are up to date." if not self.unsupported else + "No updates available for supported loaded plugins.") + unseen = [u for u in self.updates if self.settings.value( + f"plugin_updates/notified/{u.identifier}", "") != u.latest] + self._refresh() + if unseen or force: + self.open(check=False) + for update in self.updates: + self.settings.setValue(f"plugin_updates/notified/{update.identifier}", update.latest) + except Exception as exc: + self.message = f"Could not check plugin updates: {exc}" + self.window._log(self.message) + self._refresh(error=True) + if force: + self.open(check=False) + + def open(self, checked=False, *, check=True): + if self.dialog is None: + dialog = QDialog(self.window) + self.dialog = dialog + dialog.setWindowTitle("Plugin updates") + dialog.resize(800, 440) + layout = QVBoxLayout(dialog) + help_text = QLabel( + "Only loaded plugins are checked against the validated Community Plugins catalog. " + "Update selected downloads and trusts the listed versions. They become active when " + "you reopen CurveMole; current fits and monitoring continue with the running versions." + ) + help_text.setWordWrap(True) + layout.addWidget(help_text) + self.status = QLabel() + self.status.setWordWrap(True) + layout.addWidget(self.status) + self.table = QTableWidget(0, 4) + self.table.setHorizontalHeaderLabels(["Plugin", "Running", "Available", "Status"]) + self.table.horizontalHeader().setStretchLastSection(True) + layout.addWidget(self.table) + buttons = QHBoxLayout() + self.check_button = QPushButton("Check now") + self.check_button.clicked.connect(lambda: self.check(force=True)) + self.update_button = QPushButton("Update selected") + self.update_button.clicked.connect(self.install_selected) + close = QPushButton("Close") + close.clicked.connect(dialog.hide) + for button in (self.check_button, self.update_button, close): + buttons.addWidget(button) + layout.addLayout(buttons) + self._refresh() + self.dialog.show() + self.dialog.raise_() + if check: + self.check() + + def _refresh(self, error=False): + self._refresh_badge(error) + if self.dialog is None: + return + pending = self._pending() + message = self.message + if pending: + message += "\nSave your project and reopen CurveMole to use installed updates." + if self.unsupported: + message += "\nNo automatic update source: " + ", ".join(self.unsupported) + self.status.setText(message) + self.check_button.setEnabled(not self.busy) + self.update_button.setEnabled(not self.busy and any(u.compatible for u in self.updates)) + self.table.setRowCount(0) + for update in self.updates: + status = "Ready to update" + if semantic_version(update.latest)[0] != semantic_version(update.current)[0]: + status = "Major update — review compatibility" + if not update.compatible: + status = "Update CurveMole first" + self._add_row(update.identifier, update.current, update.latest, status, update.compatible) + for key, latest in pending.items(): + self._add_row(key, self.plugins.loaded[key].version, latest, "Installed — restart required", False) + self.table.resizeColumnsToContents() + + def _add_row(self, identifier, current, latest, status, selectable): + row = self.table.rowCount() + self.table.insertRow(row) + for column, value in enumerate((identifier, current, latest, status)): + item = QTableWidgetItem(value) + item.setFlags(Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable) + if column == 0: + symbol = self.plugins.symbol(identifier) + name = self.plugins.plugin_name(identifier) + item.setText(f"{symbol} {name}") + item.setData(Qt.ItemDataRole.UserRole, identifier) + item.setToolTip(provenance(symbol, name, identifier)) + if selectable: + item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable) + item.setCheckState(Qt.CheckState.Checked) + self.table.setItem(row, column, item) + + def install_selected(self): + if self.busy: + return + selected = {self.table.item(row, 0).data(Qt.ItemDataRole.UserRole) for row in range(self.table.rowCount()) + if self.table.item(row, 0).checkState() == Qt.CheckState.Checked} + self.queue = [u for u in self.updates if u.identifier in selected and u.compatible] + if not self.queue: + self.message = "Select at least one compatible plugin update." + self._refresh() + return + self.errors = [] + self.busy = True + self._download_next() + + def _download_next(self): + if not self.queue: + self.busy = False + self.message = ("\n".join(self.errors) if self.errors else + "Selected updates installed. Reopen CurveMole to activate them.") + self._refresh(error=bool(self.errors)) + return + update = self.queue.pop(0) + self.message = f"Downloading {update.name} {update.latest}…" + self._refresh() + self._fetch(update.url, MAX_ARCHIVE_BYTES, + lambda data, error: self._downloaded(update, data, error)) + + def _downloaded(self, update: PluginUpdate, data, error): + try: + if error: + raise ValueError(error) + stage_update(self.plugins, update, data) + self.updates = [u for u in self.updates if u.identifier != update.identifier] + except Exception as exc: + message = f"{update.identifier}: {exc}" + self.errors.append(message) + self.window._log(message) + self._download_next() diff --git a/src/curvemole/gui/quick_function_library.py b/src/curvemole/gui/quick_function_library.py index 9ef877d..56f93ef 100644 --- a/src/curvemole/gui/quick_function_library.py +++ b/src/curvemole/gui/quick_function_library.py @@ -11,6 +11,7 @@ from pathlib import Path from typing import Any +from PySide6.QtCore import Qt from PySide6.QtWidgets import ( QComboBox, QFileDialog, @@ -22,6 +23,7 @@ from curvemole.core.functions import formula_definition from curvemole.core.initialization import find_peak_suggestions, initialise_peak_component from curvemole.core.models import Component, Model +from curvemole.core.plugin_identity import function_tooltip from curvemole.core.plugins import export_custom_function, import_custom_function from curvemole.gui.main_window import MainWindow from curvemole.gui.panels import FunctionBuilderPanel @@ -135,16 +137,20 @@ def _refresh_quick_function_selector( selector.clear() for definition in window.registry.values(): selector.addItem(definition.display_name, definition.identifier) + selector.setItemData(selector.count() - 1, function_tooltip(definition), Qt.ItemDataRole.ToolTipRole) index = selector.findData(wanted) if index < 0 and selector.count(): index = 0 selector.setCurrentIndex(index) + selector.setToolTip(selector.itemData(index, Qt.ItemDataRole.ToolTipRole) or "") finally: selector.blockSignals(False) def _remember_quick_function(window: MainWindow, function_id: str) -> None: definition = window.registry.get(function_id) + if hasattr(window, "quick_function_selector"): + window.quick_function_selector.setToolTip(function_tooltip(definition)) window.last_quick_function_id = definition.identifier window.settings.setValue("last_quick_function", definition.identifier) if definition.kind == "peak": @@ -314,14 +320,21 @@ def _find_peaks(window: MainWindow) -> None: 0, ) function_names = [definition.display_name for definition in peak_definitions] - selected_name, accepted = QInputDialog.getItem( - window, - window.tr("Find Peaks — Function"), - window.tr("Function to use for detected peaks:"), - function_names, - default_index, - False, - ) + chooser = QInputDialog(window) + chooser.setWindowTitle(window.tr("Find Peaks — Function")) + chooser.setLabelText(window.tr("Function to use for detected peaks:")) + chooser.setComboBoxItems(function_names) + chooser.setComboBoxEditable(False) + chooser.setTextValue(function_names[default_index]) + combo = chooser.findChild(QComboBox) + if combo is not None: + for index, definition in enumerate(peak_definitions): + combo.setItemData(index, function_tooltip(definition), Qt.ItemDataRole.ToolTipRole) + combo.currentIndexChanged.connect( + lambda index: combo.setToolTip(combo.itemData(index, Qt.ItemDataRole.ToolTipRole) or "")) + combo.setToolTip(function_tooltip(peak_definitions[default_index])) + accepted = chooser.exec() + selected_name = chooser.textValue() if not accepted: return selected_index = function_names.index(selected_name) diff --git a/tests/test_plugin_identity.py b/tests/test_plugin_identity.py new file mode 100644 index 0000000..b26a31b --- /dev/null +++ b/tests/test_plugin_identity.py @@ -0,0 +1,70 @@ +import json +from types import SimpleNamespace + +from PySide6.QtCore import QCoreApplication, QEvent, QSettings, Qt +from PySide6.QtWidgets import QApplication, QMainWindow + +from curvemole.core.extensions import extensions +from curvemole.core.plugin_identity import contribution_tooltip, function_tooltip +from curvemole.core.plugins import PluginManager +from curvemole.core.registry import FunctionRegistry +from curvemole.gui.dialogs import AddComponentDialog +from curvemole.gui.plugin_host import PluginHost + + +def test_distinct_symbols_persist_and_explain_provenance(tmp_path, monkeypatch): + monkeypatch.setattr(extensions, "entries", {}) + registry = FunctionRegistry() + manager = PluginManager(registry, storage=tmp_path / "settings") + for identifier, name in (("first", "First plugin"), ("second", "Second plugin")): + folder = tmp_path / identifier + folder.mkdir() + manifest = dict(identifier=identifier, name=name, version="1.0.0", api_compatibility="1", + licence="MIT", capabilities=["functions", "actions"], module="plugin.py") + (folder / "plugin.curvemole-plugin.json").write_text(json.dumps(manifest)) + (folder / "plugin.py").write_text( + 'from curvemole.core.functions import formula_definition\n' + 'def register(api):\n' + ' api.register(formula_definition(api.identifier + "_line", "Line", "a*x"))\n' + ' api.add("actions", "action", "Action", lambda ctx: None)\n') + manager.load(manager.discover_local(folder)[0], trust=True) + symbols = {key: manager.symbol(key) for key in manager.loaded} + assert len(set(symbols.values())) == 2 + for key in symbols: + definition = registry.get(key + "_line") + entry = extensions.entries[key + ":action"] + assert definition.display_name.startswith(symbols[key] + " ") + assert entry.label.startswith(symbols[key] + " ") + assert manager.plugin_name(key) in function_tooltip(definition) + assert symbols[key] in contribution_tooltip(entry) + + app = QApplication.instance() or QApplication([]) + window = QMainWindow() + window.plugin_manager = manager + window.project = SimpleNamespace(ui_state={}) + window.active_curve_id = None + window.curve_tree = SimpleNamespace(selected_curve_ids=lambda: []) + window.settings = QSettings(str(tmp_path / "ui.ini"), QSettings.Format.IniFormat) + for name in ("File", "Data", "Tools", "View"): + window.menuBar().addMenu(name) + host = PluginHost(window) + assert host.menus["actions"].toolTipsVisible() + actions = host.menus["actions"].actions() + assert "First plugin" in actions[0].toolTip() + assert "Second plugin" in actions[1].toolTip() + dialog = AddComponentDialog(registry, None, window) + assert "First plugin" in dialog.function.itemData(0, Qt.ItemDataRole.ToolTipRole) + dialog.function.setCurrentIndex(1) + assert "Second plugin" in dialog.function.toolTip() + window.close() + window.deleteLater() + QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete) + app.processEvents() + + extensions.entries.clear() + restarted = PluginManager(FunctionRegistry(), storage=manager.storage) + restarted.autoload() + assert {key: restarted.symbol(key) for key in restarted.loaded} == symbols + restarted.disable("first") + restarted.load(next(c for c in restarted.installed_candidates() if c.metadata.identifier == "first"), trust=True) + assert restarted.symbol("first") == symbols["first"] diff --git a/tests/test_plugin_updates.py b/tests/test_plugin_updates.py new file mode 100644 index 0000000..e747c52 --- /dev/null +++ b/tests/test_plugin_updates.py @@ -0,0 +1,203 @@ +import copy +import hashlib +import io +import json +import zipfile +from dataclasses import replace +from pathlib import Path + +import pytest + +from curvemole.core.errors import CurveMoleError +from curvemole.core.extensions import extensions +from curvemole.core.plugin_updates import available_updates, stage_update +from curvemole.core.plugins import PluginManager +from curvemole.core.registry import FunctionRegistry + + +@pytest.fixture +def update_case(tmp_path, monkeypatch): + monkeypatch.setattr(extensions, "entries", {}) + original = tmp_path / "original" + original.mkdir() + manifest = dict(identifier="test.plugin", name="Test plugin", version="1.0.0", + api_compatibility="1", licence="MIT", capabilities=["actions"], module="plugin.py") + (original / "plugin.curvemole-plugin.json").write_text(json.dumps(manifest)) + (original / "plugin.py").write_text('def register(api):\n api.add("actions", "test", "Test", lambda ctx: None)\n') + manager = PluginManager(FunctionRegistry(), storage=tmp_path / "storage") + manager.load(manager.discover_local(original)[0], trust=True) + manifest["version"] = "1.1.0" + files = {"plugin.curvemole-plugin.json": json.dumps(manifest).encode(), + "plugin.py": b'def register(api):\n api.add("actions", "test", "Updated", lambda ctx: None)\n', + "README.md": b"Plugin-specific manual", "LICENSE": b"MIT"} + digest = hashlib.sha256() + for name, content in sorted(files.items()): + digest.update(name.encode() + b"\0" + content) + catalog = {"plugins": [{**manifest, "folder": "test_plugin", "sha256": digest.hexdigest()}]} + output = io.BytesIO() + with zipfile.ZipFile(output, "w") as archive: + for name, content in files.items(): + archive.writestr("test_plugin/" + name, content) + update = available_updates(manager, catalog)[0][0] + return manager, catalog, update, output.getvalue(), original + + +def test_checks_only_loaded_local_plugins(update_case): + manager, catalog, update, _, _ = update_case + unloaded = dict(catalog["plugins"][0], identifier="unloaded") + catalog["plugins"].append(unloaded) + manager.installed["unloaded"] = {"enabled": False} + updates, unsupported = available_updates(manager, catalog) + assert updates == [update] + assert unsupported == [] + manager.disable(update.identifier) + assert available_updates(manager, catalog) == ([], []) + + +def test_unknown_and_package_plugins_are_not_replaced(update_case): + manager, catalog, _, _, _ = update_case + assert available_updates(manager, {"plugins": []}) == ([], ["test.plugin"]) + manager.installed["test.plugin"]["kind"] = "entry_point" + assert available_updates(manager, catalog) == ([], ["test.plugin"]) + + +def test_stage_preserves_running_code_and_loads_update_next_start(update_case): + manager, catalog, update, data, original = update_case + callback = extensions.entries["test.plugin:test"].callback + stage_update(manager, update, data) + assert manager.loaded[update.identifier].version == "1.0.0" + assert extensions.entries["test.plugin:test"].callback is callback + assert json.loads((original / "plugin.curvemole-plugin.json").read_text())["version"] == "1.0.0" + installed_path = Path(manager.installed[update.identifier]["reference"]) + assert (installed_path.parent / "README.md").read_text() == "Plugin-specific manual" + assert available_updates(manager, catalog)[0] == [] + symbol = manager.symbol(update.identifier) + extensions.entries.clear() + restarted = PluginManager(FunctionRegistry(), storage=manager.storage) + restarted.autoload() + assert restarted.loaded[update.identifier].version == "1.1.0" + assert restarted.symbol(update.identifier) == symbol + assert "Updated" in extensions.entries["test.plugin:test"].label + + +@pytest.mark.parametrize("fault", ["checksum", "identity", "api", "disabled", "save", "truncated"]) +def test_failed_update_retains_previous_installation(update_case, monkeypatch, fault): + manager, _, update, data, _ = update_case + if fault == "disabled": + manager.disable(update.identifier) + before = copy.deepcopy(manager.installed) + persisted = (manager.storage / "installed.json").read_bytes() + if fault == "checksum": + update = replace(update, sha256="0" * 64) + elif fault == "identity": + update = replace(update, capabilities=("exporters",)) + elif fault == "api": + update = replace(update, api="999") + elif fault == "save": + def fail(): + raise OSError("Disk full") + monkeypatch.setattr(manager, "_save", fail) + elif fault == "truncated": + data = data[:30] + with pytest.raises((CurveMoleError, OSError, zipfile.BadZipFile)): + stage_update(manager, update, data) + assert manager.installed == before + assert (manager.storage / "installed.json").read_bytes() == persisted + assert not list((manager.storage / "updates").glob("plugin-*")) + + +@pytest.mark.parametrize("name", ["../escape.py", "test_plugin/../../escape.py", + "/tmp/escape.py", "test_plugin/C:/escape.py", "test_plugin\\escape.py"]) +def test_archive_paths_cannot_escape(update_case, name): + manager, _, update, _, _ = update_case + output = io.BytesIO() + with zipfile.ZipFile(output, "w") as archive: + archive.writestr(name, b"bad") + with pytest.raises(CurveMoleError, match="Unsafe"): + stage_update(manager, update, output.getvalue()) + + +def test_catalog_and_archive_disagree_after_rolling_release(update_case): + manager, _, update, data, _ = update_case + with pytest.raises(CurveMoleError, match="checksum"): + stage_update(manager, replace(update, sha256="1" * 64), data) + assert manager.loaded[update.identifier].version == "1.0.0" + + +def test_no_downgrade_and_incompatible_updates_are_reported(update_case): + manager, catalog, _, _, _ = update_case + catalog["plugins"][0]["version"] = "0.9.0" + assert available_updates(manager, catalog)[0] == [] + catalog["plugins"][0].update(version="2.0.0", api_compatibility="2") + updates, _ = available_updates(manager, catalog) + assert not updates[0].compatible + + +@pytest.fixture +def controller_case(update_case, tmp_path, monkeypatch): + from PySide6.QtCore import QCoreApplication, QEvent, QSettings + from PySide6.QtGui import QAction + from PySide6.QtWidgets import QApplication, QMainWindow + + from curvemole.gui.plugin_updates import PluginUpdateController + app = QApplication.instance() or QApplication([]) + manager, catalog, update, data, _ = update_case + window = QMainWindow() + window.plugin_manager = manager + window.settings = QSettings(str(tmp_path / "gui.ini"), QSettings.Format.IniFormat) + window.update_action = QAction(window) + window._log = lambda message: None + requests = [] + monkeypatch.setattr(PluginUpdateController, "_fetch", lambda self, url, limit, callback: + requests.append((url, callback))) + controller = PluginUpdateController(window) + app.processEvents() + yield app, controller, requests, catalog, update, data + controller.timer.stop() + window.close() + window.deleteLater() + QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete) + + +def test_controller_hourly_once_per_version_and_download(controller_case): + from curvemole.core.plugin_updates import CATALOG_URL + app, controller, requests, catalog, update, archive = controller_case + assert controller.timer.interval() == 60 * 60 * 1000 + assert requests[0][0] == CATALOG_URL + requests.pop()[1](json.dumps(catalog).encode(), "") + assert controller.dialog.isVisible() + assert controller.table.rowCount() == 1 + controller.dialog.hide() + controller.timer.timeout.emit() + requests.pop()[1](json.dumps(catalog).encode(), "") + assert not controller.dialog.isVisible() + controller.open(check=False) + controller.install_selected() + assert requests[-1][0] == update.url + assert not controller.update_button.isEnabled() + requests.pop()[1](archive, "") + assert controller.plugins.loaded[update.identifier].version == update.current + assert controller.table.item(0, 3).text() == "Installed — restart required" + assert not controller.update_button.isEnabled() + assert "restart required" in controller.badge.text() + + +def test_controller_offline_and_empty_loaded_set(controller_case): + _, controller, requests, _, update, _ = controller_case + requests.pop()[1](b"", "Offline") + assert "Offline" in controller.message + assert controller.dialog is None + controller.plugins.disable(update.identifier) + controller.check(force=True) + assert requests == [] + assert controller.badge.isHidden() + + +def test_controller_does_not_install_plugin_disabled_during_download(controller_case): + _, controller, requests, catalog, update, archive = controller_case + requests.pop()[1](json.dumps(catalog).encode(), "") + controller.install_selected() + controller.plugins.disable(update.identifier) + requests.pop()[1](archive, "") + assert "no longer loaded" in controller.message + assert controller.plugins.installed[update.identifier]["metadata"]["version"] == update.current diff --git a/tests/test_quick_function_library.py b/tests/test_quick_function_library.py index 033a3d3..d69fb9e 100644 --- a/tests/test_quick_function_library.py +++ b/tests/test_quick_function_library.py @@ -83,6 +83,10 @@ def test_automatic_peak_search_uses_selected_peak_function( ] ) monkeypatch.setattr(QInputDialog, "getItem", lambda *args, **kwargs: next(answers)) + def choose_function(dialog): + dialog.setTextValue(window.registry.get("lorentzian").display_name) + return 1 + monkeypatch.setattr(QInputDialog, "exec", choose_function) monkeypatch.setattr(QInputDialog, "getInt", lambda *args, **kwargs: (1, True)) window.find_peaks() From 9be19644f65ba121f7f38825422c1699c01be944 Mon Sep 17 00:00:00 2001 From: Sebastiano Romi Date: Wed, 16 Sep 2026 14:59:12 +0200 Subject: [PATCH 2/4] Fix Windows archive path test Signed-off-by: Codex --- tests/test_plugin_updates.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/test_plugin_updates.py b/tests/test_plugin_updates.py index e747c52..a7b27a4 100644 --- a/tests/test_plugin_updates.py +++ b/tests/test_plugin_updates.py @@ -111,10 +111,16 @@ def fail(): def test_archive_paths_cannot_escape(update_case, name): manager, _, update, _, _ = update_case output = io.BytesIO() + # ZipInfo normalises backslashes on Windows. Replace the stored filename bytes + # afterward so the archive exercises the same hostile input on every platform. + stored_name = name.replace("\\", "/") with zipfile.ZipFile(output, "w") as archive: - archive.writestr(name, b"bad") + archive.writestr(stored_name, b"bad") + data = output.getvalue() + if stored_name != name: + data = data.replace(stored_name.encode(), name.encode()) with pytest.raises(CurveMoleError, match="Unsafe"): - stage_update(manager, update, output.getvalue()) + stage_update(manager, update, data) def test_catalog_and_archive_disagree_after_rolling_release(update_case): From 512c8d1425835dc1c89e62d4527cffd0fcae7b62 Mon Sep 17 00:00:00 2001 From: Sebastiano Romi Date: Wed, 16 Sep 2026 15:03:10 +0200 Subject: [PATCH 3/4] Exercise raw backslash ZIP names on Windows Signed-off-by: Codex --- tests/test_plugin_updates.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_plugin_updates.py b/tests/test_plugin_updates.py index a7b27a4..469d6d5 100644 --- a/tests/test_plugin_updates.py +++ b/tests/test_plugin_updates.py @@ -107,7 +107,7 @@ def fail(): @pytest.mark.parametrize("name", ["../escape.py", "test_plugin/../../escape.py", - "/tmp/escape.py", "test_plugin/C:/escape.py", "test_plugin\\escape.py"]) + "/tmp/escape.py", "test_plugin/C:/escape.py", r"test_plugin\escape.py"]) def test_archive_paths_cannot_escape(update_case, name): manager, _, update, _, _ = update_case output = io.BytesIO() @@ -118,7 +118,9 @@ def test_archive_paths_cannot_escape(update_case, name): archive.writestr(stored_name, b"bad") data = output.getvalue() if stored_name != name: + assert len(stored_name) == len(name) data = data.replace(stored_name.encode(), name.encode()) + assert name.encode() in data with pytest.raises(CurveMoleError, match="Unsafe"): stage_update(manager, update, data) From 2bb69704c05d685ecd6ad0321e779cd994575d0f Mon Sep 17 00:00:00 2001 From: Sebastiano Romi Date: Wed, 16 Sep 2026 15:07:47 +0200 Subject: [PATCH 4/4] Accept platform-specific ZIP rejection reasons Signed-off-by: Codex --- tests/test_plugin_updates.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/test_plugin_updates.py b/tests/test_plugin_updates.py index 469d6d5..1e219aa 100644 --- a/tests/test_plugin_updates.py +++ b/tests/test_plugin_updates.py @@ -121,8 +121,12 @@ def test_archive_paths_cannot_escape(update_case, name): assert len(stored_name) == len(name) data = data.replace(stored_name.encode(), name.encode()) assert name.encode() in data - with pytest.raises(CurveMoleError, match="Unsafe"): + # Windows also normalises raw backslashes while reading ZIP metadata. In that + # case the path is contained, but the altered archive still fails its catalog checksum. + expected = "Unsafe" if "\\" not in name else "Unsafe|checksum mismatch" + with pytest.raises(CurveMoleError, match=expected): stage_update(manager, update, data) + assert not list((manager.storage / "updates").glob("plugin-*")) def test_catalog_and_archive_disagree_after_rolling_release(update_case):