diff --git a/pyproject.toml b/pyproject.toml index 1e95b53..59b5eaa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,7 @@ requires-python = ">=3.10" dependencies = [ "matplotlib>=3.8,<4", "msgpack>=1.0,<2", + "OMPython>=3.6,<5", "PySide6>=6.7,<7", ] diff --git a/src/bedit_gui/application.py b/src/bedit_gui/application.py index f42cedc..423b510 100644 --- a/src/bedit_gui/application.py +++ b/src/bedit_gui/application.py @@ -21,6 +21,7 @@ from bedit_gui.services.application_settings import ApplicationSettings from bedit_gui.services.clipboard import ClipboardService from bedit_gui.views.main_window import MainWindow from bedit_gui.versions import BEDIT_VERSION +from bedit_simulation import OpenModelicaRunner, compile_component_sync def parse_arguments(): parser = argparse.ArgumentParser(exit_on_error=False) @@ -46,6 +47,8 @@ def main() -> int: app.setApplicationVersion(BEDIT_VERSION) settings = ApplicationSettings() + omc_runner = OpenModelicaRunner() + app.aboutToQuit.connect(omc_runner.close) document = Document(app) window = MainWindow(settings.snap_to_grid_size) window.ui.actionAbout.triggered.connect(lambda: QMessageBox.about(window, "About BEdit", f"BEdit {BEDIT_VERSION}")) @@ -55,7 +58,7 @@ def main() -> int: DocumentController(document, window) settings_controller = SettingsController(window, settings) SimulationSettingsController(document, window) - SimulationController(document, window) + SimulationController(document, window, compiler=lambda component, directory: compile_component_sync(component, directory, runner=omc_runner)) UndoController(document, window) ViewMenuController(window) document_tree_controller = DocumentTreeController(document, window) diff --git a/src/bedit_gui/controllers/simulation_file_controller.py b/src/bedit_gui/controllers/simulation_file_controller.py index d7ad0d9..6731cab 100644 --- a/src/bedit_gui/controllers/simulation_file_controller.py +++ b/src/bedit_gui/controllers/simulation_file_controller.py @@ -13,6 +13,7 @@ from bedit_gui.services.application_logging import get_logger from bedit_gui.simulation_models import CompiledModel, SimulationRoot from bedit_gui.views.dialogs.simulation_settings_dialog import SimulationSettingsDialog from bedit_gui.views.simulation_window import SimulationWindow +from bedit_simulation import OpenModelicaRunner logger = get_logger(__name__) @@ -20,9 +21,10 @@ logger = get_logger(__name__) class SimulationFileController(QObject): runtime_changed = Signal() - def __init__(self, window: SimulationWindow) -> None: + def __init__(self, window: SimulationWindow, runner: OpenModelicaRunner | None = None) -> None: super().__init__(window) self.window = window + self.runner = runner self.root: SimulationRoot | None = None self.compiled_model: CompiledModel | None = None self.path: Path | None = None @@ -147,14 +149,14 @@ class SimulationFileController(QObject): def _compile_with_wait_cursor(self, path: Path, *, component: str | None, simulation: str | None) -> tuple[SimulationRoot, CompiledModel]: QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor) try: - return load_and_compile_bedit(path, component_selector=component, simulation_selector=simulation) + return load_and_compile_bedit(path, component_selector=component, simulation_selector=simulation, runner=self.runner) finally: QApplication.restoreOverrideCursor() def _recompile_with_wait_cursor(self, root: SimulationRoot) -> CompiledModel: QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor) try: - return compile_simulation_root(root) + return compile_simulation_root(root, runner=self.runner) finally: QApplication.restoreOverrideCursor() diff --git a/src/bedit_gui/services/simulation_loader.py b/src/bedit_gui/services/simulation_loader.py index e70da66..eef7b12 100644 --- a/src/bedit_gui/services/simulation_loader.py +++ b/src/bedit_gui/services/simulation_loader.py @@ -7,7 +7,7 @@ from bedit_core.models import Component, ComponentID, Document, GraphImplementat from bedit_gui.models import Simulation, SimulationMethod from bedit_gui.services import document_files from bedit_gui.simulation_models import CompiledModel, SimulationRoot -from bedit_simulation import compile_component_sync +from bedit_simulation import OpenModelicaRunner, compile_component_sync def component_choices(document: Document) -> list[tuple[ComponentID, Component, str]]: @@ -24,7 +24,7 @@ def component_choices(document: Document) -> list[tuple[ComponentID, Component, return choices -def load_and_compile_bedit(path: str | Path, *, component_selector: str | None = None, simulation_selector: str | None = None, working_directory: str | Path | None = None, omc_command: str = "omc") -> tuple[SimulationRoot, CompiledModel]: +def load_and_compile_bedit(path: str | Path, *, component_selector: str | None = None, simulation_selector: str | None = None, working_directory: str | Path | None = None, omc_command: str = "omc", runner: OpenModelicaRunner | None = None) -> tuple[SimulationRoot, CompiledModel]: """Open a BEdit document, resolve one launch target, and compile it.""" if bool(component_selector) == bool(simulation_selector): raise ValueError("specify exactly one component or simulation settings block") @@ -57,16 +57,16 @@ def load_and_compile_bedit(path: str | Path, *, component_selector: str | None = settings_name=settings_name, settings=settings, ) - return root, _compile(component, build_directory, omc_command) + return root, _compile(component, build_directory, omc_command, runner) -def compile_simulation_root(root: SimulationRoot, *, working_directory: str | Path | None = None, omc_command: str = "omc") -> CompiledModel: +def compile_simulation_root(root: SimulationRoot, *, working_directory: str | Path | None = None, omc_command: str = "omc", runner: OpenModelicaRunner | None = None) -> CompiledModel: if root.source_document is None: raise ValueError("the simulation does not reference a BEdit source document and cannot be recompiled") document = document_files.load(root.source_document) _component_id, component, _component_path = _find_component(component_choices(document), str(root.component)) build_directory = Path(working_directory) if working_directory is not None else Path(tempfile.mkdtemp(prefix="bedit-compiled-")) - return _compile(component, build_directory, omc_command) + return _compile(component, build_directory, omc_command, runner) def _find_component(choices: list[tuple[ComponentID, Component, str]], selector: str) -> tuple[ComponentID, Component, str]: @@ -90,6 +90,6 @@ def _default_settings(component_id: ComponentID) -> Simulation: ) -def _compile(component: Component, working_directory: Path, omc_command: str) -> CompiledModel: - build = compile_component_sync(component, working_directory, omc_command=omc_command) +def _compile(component: Component, working_directory: Path, omc_command: str, runner: OpenModelicaRunner | None) -> CompiledModel: + build = compile_component_sync(component, working_directory, omc_command=omc_command, runner=runner) return CompiledModel(build.model_name, str(build.executable.resolve()), str(build.executable.parent.resolve()), build.output, build.errors) diff --git a/src/bedit_gui/simulation_application.py b/src/bedit_gui/simulation_application.py index 5381ccc..198782a 100644 --- a/src/bedit_gui/simulation_application.py +++ b/src/bedit_gui/simulation_application.py @@ -14,6 +14,7 @@ from bedit_gui.simulation_models import CompiledModel from bedit_gui.services.application_settings import SimulationApplicationSettings from bedit_gui.views.simulation_window import SimulationWindow from bedit_gui.versions import BESIM_VERSION +from bedit_simulation import OpenModelicaRunner def parse_arguments(arguments: list[str] | None = None) -> argparse.Namespace: @@ -46,7 +47,9 @@ def main(arguments: list[str] | None = None) -> int: window = SimulationWindow() window.ui.actionAbout.triggered.connect(lambda: QMessageBox.about(window, "About BEsim", f"BEsim {BESIM_VERSION}")) window.ui.actionAbout_QT.triggered.connect(app.aboutQt) - controller = SimulationFileController(window) + omc_runner = OpenModelicaRunner() + app.aboutToQuit.connect(omc_runner.close) + controller = SimulationFileController(window, omc_runner) settings = SimulationApplicationSettings() LogController(window, settings.log_level) diff --git a/src/bedit_simulation/compile.py b/src/bedit_simulation/compile.py index 052c77b..cb94a0f 100644 --- a/src/bedit_simulation/compile.py +++ b/src/bedit_simulation/compile.py @@ -9,13 +9,13 @@ from .openmodelica import OpenModelicaRunner from .simulation import ModelBuildResult, Simulation -async def compile_component(component: Component, working_directory: str | Path, *, omc_command: str = "omc", timeout: float | None = None) -> ModelBuildResult: +async def compile_component(component: Component, working_directory: str | Path, *, omc_command: str = "omc", timeout: float | None = None, runner: OpenModelicaRunner | None = None) -> ModelBuildResult: """Compose and compile a component without creating a GUI application.""" - simulation = Simulation(OpenModelicaRunner(omc_command, timeout=timeout)) + simulation = Simulation(runner or OpenModelicaRunner(omc_command, timeout=timeout)) await simulation.load(component) return await simulation.compile(working_directory) -def compile_component_sync(component: Component, working_directory: str | Path, *, omc_command: str = "omc", timeout: float | None = None) -> ModelBuildResult: +def compile_component_sync(component: Component, working_directory: str | Path, *, omc_command: str = "omc", timeout: float | None = None, runner: OpenModelicaRunner | None = None) -> ModelBuildResult: """Synchronous entry point for scripts and non-async suite applications.""" - return asyncio.run(compile_component(component, working_directory, omc_command=omc_command, timeout=timeout)) + return asyncio.run(compile_component(component, working_directory, omc_command=omc_command, timeout=timeout, runner=runner)) diff --git a/src/bedit_simulation/openmodelica.py b/src/bedit_simulation/openmodelica.py index c875013..cb664ff 100644 --- a/src/bedit_simulation/openmodelica.py +++ b/src/bedit_simulation/openmodelica.py @@ -6,13 +6,15 @@ import asyncio import os import signal import shlex +import shutil import subprocess import time from collections.abc import Callable, Mapping, Sequence +from contextlib import suppress from dataclasses import dataclass from pathlib import Path -from threading import Event, Thread -from typing import Any, TypeVar +from threading import Event, Lock, Thread +from typing import Any, Protocol, Self, TypeVar @dataclass(frozen=True) @@ -48,6 +50,15 @@ ProcessExecutor = Callable[ _Result = TypeVar("_Result") +class InteractiveSession(Protocol): + """Small part of the OMPython session API used by the runner.""" + + def sendExpression(self, command: str, parsed: bool = True) -> Any: ... + + +SessionFactory = Callable[[tuple[str, ...], float | None], InteractiveSession] + + class OpenModelicaRunner: """Execute ``.mos`` scripts using an OMC command or wrapper script. @@ -63,14 +74,22 @@ class OpenModelicaRunner: timeout: float | None = None, environment: Mapping[str, str] | None = None, executor: ProcessExecutor | None = None, + session_factory: SessionFactory | None = None, + reuse_session: bool = True, ) -> None: self.command = _command_parts(command) self.timeout = timeout self.environment = environment self._executor = executor or _execute_process + self._session_factory = session_factory or _create_interactive_session + self._reuse_session = reuse_session and executor is None and environment is None and len(self.command) == 1 + self._session: InteractiveSession | None = None + self._session_lock = Lock() def _run(self, script: Path, working_directory: Path) -> ProcessResult: """Run ``script`` in ``working_directory`` and return captured output.""" + if self._reuse_session: + return self._run_in_session(script, working_directory) command = (*self.command, str(script.resolve())) try: result = self._executor( @@ -95,6 +114,48 @@ class OpenModelicaRunner: ) return result + def _run_in_session(self, script: Path, working_directory: Path) -> ProcessResult: + script_path = script.resolve() + expression = f"runScript({_modelica_string(str(script_path))})" + with self._session_lock: + try: + session = self._session + if session is None: + session = self._session_factory(self.command, self.timeout) + self._session = session + output = session.sendExpression(expression, parsed=False) + except Exception as exc: # OMPython exceptions are optional at import time + self._close_session_locked() + if isinstance(exc, OpenModelicaError): + raise + raise OpenModelicaError(f"OpenModelica session failed: {exc}") from exc + return ProcessResult(command=(*self.command, str(script_path)), return_code=0, stdout=str(output), stderr="") + + @property + def session_running(self) -> bool: + """Return whether this runner has started its reusable OMC session.""" + with self._session_lock: + return self._session is not None + + def close(self) -> None: + """Stop the reusable OMC session, if it has been started.""" + with self._session_lock: + self._close_session_locked() + + def _close_session_locked(self) -> None: + session = self._session + self._session = None + if session is None: + return + with suppress(Exception): + session.sendExpression("quit()", parsed=False) + + def __enter__(self) -> Self: + return self + + def __exit__(self, *_args: object) -> None: + self.close() + async def run( self, script: Path, @@ -167,6 +228,30 @@ def _command_parts(command: str | Sequence[str]) -> tuple[str, ...]: return parts +def _modelica_string(value: str) -> str: + return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"' + + +def _create_interactive_session(command: tuple[str, ...], timeout: float | None) -> InteractiveSession: + if len(command) != 1: + raise OpenModelicaError("a reusable OpenModelica session requires a single OMC executable command") + executable = shutil.which(command[0]) + if executable is None: + raise OpenModelicaError(f"OpenModelica command {command[0]!r} was not found") + try: + from OMPython import OMCSessionZMQ + except ImportError: + raise OpenModelicaError("reusable OpenModelica sessions require the OMPython package") from None + # Preserve wrapper symlinks: OMPython reconstructs ``/bin/omc``. + omhome = str(Path(executable).parent.parent) + try: + if timeout is None: + return OMCSessionZMQ(omhome=omhome) + return OMCSessionZMQ(timeout=timeout, omhome=omhome) + except Exception as exc: + raise OpenModelicaError(f"OpenModelica session could not start: {exc}") from exc + + def _execute_process( command: Sequence[str], working_directory: Path, diff --git a/src/bedit_simulation/simulation.py b/src/bedit_simulation/simulation.py index 02998a1..fc57b25 100644 --- a/src/bedit_simulation/simulation.py +++ b/src/bedit_simulation/simulation.py @@ -705,6 +705,7 @@ def _load_model_script( ]) return "\n".join( [ + "clear();", f"cd({json.dumps(str(working_directory.resolve()))});", *load_libraries, f"loaded := loadFile({json.dumps(_MODEL_FILE)});",