diff --git a/CHANGELOG.md b/CHANGELOG.md index ba78c673..ec9182ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +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. - 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 4b2b479b..24c7dc30 100644 --- a/src/ewoksorange/gui/canvas/main.py +++ b/src/ewoksorange/gui/canvas/main.py @@ -3,6 +3,7 @@ from contextlib import contextmanager from ...orange_version import ORANGE_VERSION +from .menu import add_ewoks_import_export_actions if ORANGE_VERSION == ORANGE_VERSION.latest_oasys: from oasys2.canvas import config as _oasys_config_module @@ -98,6 +99,7 @@ def _temporary_log_handlers(log_level): def main(argv=None): + add_ewoks_import_export_actions() parser = arg_parser() parser.add_argument( diff --git a/src/ewoksorange/gui/canvas/menu.py b/src/ewoksorange/gui/canvas/menu.py new file mode 100644 index 00000000..e31ba20b --- /dev/null +++ b/src/ewoksorange/gui/canvas/menu.py @@ -0,0 +1,102 @@ +import io +import logging +import os +import tempfile +from pathlib import Path + +from AnyQt.QtWidgets import QAction +from AnyQt.QtWidgets import QFileDialog +from AnyQt.QtWidgets import QMenu +from orangecanvas.application.canvasmain import CanvasMainWindow + +from ..workflows.owscheme import ewoks_to_ows +from ..workflows.owscheme import ows_to_ewoks + +_logger = logging.getLogger(__name__) +_FILTER = "Ewoks workflow (*.json *.yml *.yaml)" +_patched = False + + +def _import_ewoks_workflow(window: CanvasMainWindow) -> 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: + ows = os.path.join(tmpdir, "imported.ows") + ewoks_to_ows(graph, ows, error_on_duplicates=False) + # Opens in this window if it is empty, else in a new one, + # without associating the temporary path with the document. + window.open_example_scheme(ows) + except Exception as e: + _logger.exception("Ewoks import failed") + window.output_view().write(f"Ewoks import failed: {e}\n") + + +def _export_ewoks_workflow(window: CanvasMainWindow) -> 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: + scheme = window.current_document().scheme() + buffer = io.BytesIO() + scheme.save_to(buffer, pretty=True, pickle_fallback=True) + buffer.seek(0) + graph = ows_to_ewoks(buffer) + save_graph(graph, filename) + except Exception as e: + _logger.exception("Ewoks export failed") + 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) + + +def add_ewoks_import_export_actions() -> None: + """ + Add Ewoks import/export to the File menu of every canvas window. + """ + global _patched + if _patched: + return + _patched = True + + # CanvasMainWindow is instantiated both 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__ diff --git a/src/ewoksorange/tests/test_canvas_io.py b/src/ewoksorange/tests/test_canvas_io.py new file mode 100644 index 00000000..750d3dfc --- /dev/null +++ b/src/ewoksorange/tests/test_canvas_io.py @@ -0,0 +1,61 @@ +import json + +from ewoksorange.gui.canvas import menu +from ewoksorange.gui.canvas.handler import OrangeCanvasHandler + + +def test_exported_json_matches_manual_workflow( + tmp_path, monkeypatch, ewoksorange_qtapp +): + destination_path = tmp_path / "exported_workflow.json" + graph = { + "nodes": [ + { + "id": "source", + "task_type": "class", + "task_identifier": "ewokscore.tests.examples.tasks.sumtask.SumTask", + }, + { + "id": "sink", + "task_type": "class", + "task_identifier": "ewokscore.tests.examples.tasks.sumtask.SumTask", + }, + ], + "links": [ + { + "source": "source", + "target": "sink", + "data_mapping": [{"source_output": "result", "target_input": "a"}], + } + ], + } + + source_path = tmp_path / "source_workflow.json" + + with open(source_path, "w") as f: + json.dump(graph, f) + + # 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) + + with open(destination_path) as f: + exported_workflow = json.load(f) + + assert exported_workflow["nodes"][0]["id"] == "source" + assert exported_workflow["nodes"][1]["id"] == "sink" + assert exported_workflow["links"] == graph["links"]