From a6ca43fa1b53135a3ce7c972e2f26e96efec4e6d Mon Sep 17 00:00:00 2001 From: Loic Huder Date: Fri, 16 Oct 2026 09:06:17 +0200 Subject: [PATCH] Replace Ewoks import/export by file filters in the Open and Save As dialogs --- CHANGELOG.md | 2 +- src/ewoksorange/gui/canvas/main.py | 4 +- src/ewoksorange/gui/canvas/menu.py | 152 ++++++++++++++++-------- src/ewoksorange/tests/test_canvas_io.py | 51 +++++--- 4 files changed, 142 insertions(+), 67 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ec9182ce..87a865e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Two new "Import Ewoks workflow..." and "Export Ewoks workflow..." menus in the orange main canvas. +- Ewoks workflow file filter (`*.json *.yml *.yaml`) in the Open and Save As dialogs of the orange main canvas. - New property `auto_execute` in `ewoksorange.gui.owwidgets.OWEwoksBaseWidget`. Allow to propagate outputs downstream without triggering execution. - Support OASYS2 (`pip install ewoksorange[oasys2]`) next to the standard `orange-canvas-core` + `orange-widget-base` and the optional Orange3. diff --git a/src/ewoksorange/gui/canvas/main.py b/src/ewoksorange/gui/canvas/main.py index 24c7dc30..31203b46 100644 --- a/src/ewoksorange/gui/canvas/main.py +++ b/src/ewoksorange/gui/canvas/main.py @@ -3,7 +3,7 @@ from contextlib import contextmanager from ...orange_version import ORANGE_VERSION -from .menu import add_ewoks_import_export_actions +from .menu import add_ewoks_file_filters if ORANGE_VERSION == ORANGE_VERSION.latest_oasys: from oasys2.canvas import config as _oasys_config_module @@ -99,7 +99,7 @@ def _temporary_log_handlers(log_level): def main(argv=None): - add_ewoks_import_export_actions() + add_ewoks_file_filters() parser = arg_parser() parser.add_argument( diff --git a/src/ewoksorange/gui/canvas/menu.py b/src/ewoksorange/gui/canvas/menu.py index 08b9ffda..1962818f 100644 --- a/src/ewoksorange/gui/canvas/menu.py +++ b/src/ewoksorange/gui/canvas/menu.py @@ -4,28 +4,29 @@ import tempfile from pathlib import Path -from AnyQt.QtWidgets import QAction +from AnyQt.QtCore import QUrl +from AnyQt.QtWidgets import QDialog from AnyQt.QtWidgets import QFileDialog -from AnyQt.QtWidgets import QMenu +from orangecanvas.application import canvasmain from orangecanvas.application.canvasmain import CanvasMainWindow +from ...orange_version import ORANGE_VERSION from ..workflows.owscheme import ewoks_to_ows from ..workflows.owscheme import ows_to_ewoks _logger = logging.getLogger(__name__) +_SUFFIXES = (".json", ".yml", ".yaml") _FILTER = "Ewoks workflow (*.json *.yml *.yaml)" _patched = False -def _import_ewoks_workflow(window: CanvasMainWindow) -> None: +def _is_ewoks_file(filename: str) -> bool: + return Path(filename).suffix.lower() in _SUFFIXES + +def _import_ewoks_workflow(window: CanvasMainWindow, filename: str) -> None: from ...bindings import load_graph # Lazy import to avoid circular import - filename, _ = QFileDialog.getOpenFileName( - window, "Import Ewoks workflow", "", _FILTER - ) - if not filename: - return try: graph = load_graph(filename) with tempfile.TemporaryDirectory() as tmpdir: @@ -39,15 +40,9 @@ def _import_ewoks_workflow(window: CanvasMainWindow) -> None: window.output_view().write(f"Ewoks import failed: {e}\n") -def _export_ewoks_workflow(window: CanvasMainWindow) -> None: - +def _export_ewoks_workflow(window: CanvasMainWindow, filename: str) -> None: from ...bindings import save_graph # Lazy import to avoid circular import - filename, _ = QFileDialog.getSaveFileName( - window, "Export Ewoks workflow", "", _FILTER - ) - if not filename: - return if not Path(filename).suffix: filename += ".json" try: @@ -62,43 +57,106 @@ def _export_ewoks_workflow(window: CanvasMainWindow) -> None: window.output_view().write(f"Ewoks export failed: {e}\n") -def _add_actions(window: CanvasMainWindow) -> None: - menu = window.menuBar().findChild(QMenu, "file-menu") - if menu is None: - return - import_action = QAction( - "Import Ewoks workflow...", window, objectName="action-import-ewoks" - ) - import_action.triggered.connect(lambda: _import_ewoks_workflow(window)) - export_action = QAction( - "Export Ewoks workflow...", window, objectName="action-export-ewoks" - ) - export_action.triggered.connect(lambda: _export_ewoks_workflow(window)) - - before = menu.findChild(QAction, "open-actions-separator") - for action in (import_action, export_action): - if before is not None: - menu.insertAction(before, action) - else: - menu.addAction(action) +class _EwoksSaveDialog(QFileDialog): + """Save As dialog with an extra Ewoks filter. + Choosing an Ewoks file exports the workflow and rejects the dialog, so that + the document path and modified state (and thus Ctrl+S) keep targeting `.ows`. + """ -def add_ewoks_import_export_actions() -> None: + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._window = args[0] if args else kwargs.get("parent") + + def setNameFilter(self, name_filter): + self.setNameFilters([name_filter, _FILTER]) + + def exec(self): + accepted = super().exec() + if not accepted: + return accepted + filename = self.selectedFiles()[0] + if not _is_ewoks_file(filename): + return accepted + _export_ewoks_workflow(self._window, filename) + # Reject so any further saving is skipped + return 0 + + exec_ = exec + + +def _patch_open_save() -> None: + original_open_dialog = CanvasMainWindow._open_workflow_dialog + original_open_file = CanvasMainWindow.open_scheme_file + original_save_as = CanvasMainWindow.save_scheme_as + + def _open_workflow_dialog(self): + dialog = original_open_dialog(self) + dialog.setNameFilters([*dialog.nameFilters(), _FILTER]) + return dialog + + def open_scheme_file(self, filename, **kwargs): + path = filename.toLocalFile() if isinstance(filename, QUrl) else filename + if _is_ewoks_file(path): + _import_ewoks_workflow(self, path) + else: + original_open_file(self, filename, **kwargs) + + def save_scheme_as(self): + original = canvasmain.QFileDialog + canvasmain.QFileDialog = _EwoksSaveDialog + try: + return original_save_as(self) + finally: + canvasmain.QFileDialog = original + + CanvasMainWindow._open_workflow_dialog = _open_workflow_dialog + CanvasMainWindow.open_scheme_file = open_scheme_file + CanvasMainWindow.save_scheme_as = save_scheme_as + + +def _patch_oasys_open() -> None: + """OASYS2 has its own Open dialog (a static ``QFileDialog.getOpenFileName``).""" + # Imported late: it must come after the OasysConfig patch of `canvas.main`. + from oasys2.canvas.application import canvasmain as oasys_canvasmain + + window_class = oasys_canvasmain.OASYSMainWindow + original_open = window_class._open_scheme + original_load = window_class._load_scheme + + class _OasysOpenDialog(QFileDialog): + @staticmethod + def getOpenFileName(parent=None, caption="", directory="", filter="", *args): + return QFileDialog.getOpenFileName( + parent, caption, directory, f"{filter};;{_FILTER}", *args + ) + + def _open_scheme(self): + original = oasys_canvasmain.QFileDialog + oasys_canvasmain.QFileDialog = _OasysOpenDialog + try: + return original_open(self) + finally: + oasys_canvasmain.QFileDialog = original + + def _load_scheme(self, filename): + if _is_ewoks_file(str(filename)): + _import_ewoks_workflow(self, str(filename)) + return QDialog.Accepted + return original_load(self, filename) + + window_class._open_scheme = _open_scheme + window_class._load_scheme = _load_scheme + + +def add_ewoks_file_filters() -> None: """ - Add Ewoks import/export to the File menu of every canvas window. + Add the Ewoks workflow filter to the Open and Save As dialogs of the canvas. """ global _patched if _patched: return _patched = True - - # NOTE: There is no other way than a patch here because - # - We need a solution that works also with Oasys2 fork - # - Main code where the CanvasMainWindow is instantiated is in Orange3 or in Oasys2 and there is no public interface given. - original_init = CanvasMainWindow.__init__ - - def __init__(self, *args, **kwargs): - original_init(self, *args, **kwargs) - _add_actions(self) - - CanvasMainWindow.__init__ = __init__ + _patch_open_save() + if ORANGE_VERSION == ORANGE_VERSION.latest_oasys: + _patch_oasys_open() diff --git a/src/ewoksorange/tests/test_canvas_io.py b/src/ewoksorange/tests/test_canvas_io.py index 750d3dfc..fb76fc39 100644 --- a/src/ewoksorange/tests/test_canvas_io.py +++ b/src/ewoksorange/tests/test_canvas_io.py @@ -1,12 +1,13 @@ import json +import pytest + from ewoksorange.gui.canvas import menu from ewoksorange.gui.canvas.handler import OrangeCanvasHandler +from ewoksorange.orange_version import ORANGE_VERSION -def test_exported_json_matches_manual_workflow( - tmp_path, monkeypatch, ewoksorange_qtapp -): +def test_exported_json_matches_manual_workflow(tmp_path, ewoksorange_qtapp): destination_path = tmp_path / "exported_workflow.json" graph = { "nodes": [ @@ -38,20 +39,8 @@ def test_exported_json_matches_manual_workflow( # Here i import to the canvas then re export it and verify the exported json. with OrangeCanvasHandler() as handler: - monkeypatch.setattr( - menu.QFileDialog, - "getOpenFileName", - lambda *args, **kwargs: (str(source_path), ""), - ) - - menu._import_ewoks_workflow(handler.canvas) - - monkeypatch.setattr( - menu.QFileDialog, - "getSaveFileName", - lambda *_args, **_kwargs: (str(destination_path), ""), - ) - menu._export_ewoks_workflow(handler.canvas) + menu._import_ewoks_workflow(handler.canvas, str(source_path)) + menu._export_ewoks_workflow(handler.canvas, str(destination_path)) with open(destination_path) as f: exported_workflow = json.load(f) @@ -59,3 +48,31 @@ def test_exported_json_matches_manual_workflow( assert exported_workflow["nodes"][0]["id"] == "source" assert exported_workflow["nodes"][1]["id"] == "sink" assert exported_workflow["links"] == graph["links"] + + +@pytest.mark.skipif( + ORANGE_VERSION == ORANGE_VERSION.latest_oasys, reason="OASYS2 has its own dialog" +) +def test_open_dialog_has_ewoks_filter(ewoksorange_qtapp): + menu.add_ewoks_file_filters() + with OrangeCanvasHandler() as handler: + dialog = handler.canvas._open_workflow_dialog() + assert menu._FILTER in dialog.nameFilters() + + +@pytest.mark.skipif( + ORANGE_VERSION != ORANGE_VERSION.latest_oasys, reason="OASYS2 specific" +) +def test_oasys_open_dialog_has_ewoks_filter(monkeypatch, ewoksorange_qtapp): + from oasys2.canvas.application.canvasmain import OASYSMainWindow + + menu.add_ewoks_file_filters() + filters = [] + + def get_open_file_name(parent, caption, directory, name_filter, *args): + filters.append(name_filter) + return "", "" + + monkeypatch.setattr(menu.QFileDialog, "getOpenFileName", get_open_file_name) + OASYSMainWindow()._open_scheme() + assert menu._FILTER in filters[0]