"""Run OMC directly or through a user-provided wrapper command.""" from __future__ import annotations import asyncio import os import signal import shlex import subprocess import time from collections.abc import Callable, Mapping, Sequence from dataclasses import dataclass from pathlib import Path from threading import Event, Thread from typing import Any, TypeVar @dataclass(frozen=True) class ProcessResult: """Captured output from one OMC invocation.""" command: tuple[str, ...] return_code: int stdout: str stderr: str def diagnostics(self) -> str: sections = [] if self.stdout.strip(): sections.append(f"OpenModelica output:\n{self.stdout.strip()}") if self.stderr.strip(): sections.append(f"OpenModelica errors:\n{self.stderr.strip()}") return "\n\n".join(sections) class OpenModelicaError(RuntimeError): """Raised when OMC cannot start or reports a failure.""" class SimulationCancelledError(RuntimeError): """Raised when a running compiled simulation is cancelled.""" ProcessExecutor = Callable[ [Sequence[str], Path, float | None, Mapping[str, str] | None], ProcessResult, ] _Result = TypeVar("_Result") class OpenModelicaRunner: """Execute ``.mos`` scripts using an OMC command or wrapper script. ``command`` may be ``"omc"``, a path to a wrapper script, or a shell-like command prefix such as ``"docker run ... omc"``. It is split with :func:`shlex.split` and is never passed through a shell. """ def __init__( self, command: str | Sequence[str] = "omc", *, timeout: float | None = None, environment: Mapping[str, str] | None = None, executor: ProcessExecutor | None = None, ) -> None: self.command = _command_parts(command) self.timeout = timeout self.environment = environment self._executor = executor or _execute_process def _run(self, script: Path, working_directory: Path) -> ProcessResult: """Run ``script`` in ``working_directory`` and return captured output.""" command = (*self.command, str(script.resolve())) try: result = self._executor( command, working_directory, self.timeout, self.environment, ) except FileNotFoundError: raise OpenModelicaError( f"OpenModelica command {self.command[0]!r} was not found" ) from None except subprocess.TimeoutExpired as error: raise OpenModelicaError( f"OpenModelica timed out after {error.timeout} seconds" ) from error if result.return_code != 0: details = result.diagnostics() suffix = f": {details}" if details else "" raise OpenModelicaError( f"OpenModelica exited with status {result.return_code}{suffix}" ) return result async def run( self, script: Path, working_directory: Path, ) -> ProcessResult: """Run OMC on an asyncio worker thread.""" return await _run_on_worker(self._run, script, working_directory) def _run_cancellable(self, script: Path, working_directory: Path, cancel_event: Event) -> ProcessResult: command = (*self.command, str(script.resolve())) return self._run_cancellable_process(command, working_directory, cancel_event) def _run_cancellable_process(self, command: Sequence[str], working_directory: Path, cancel_event: Event) -> ProcessResult: process_environment = None if self.environment is not None: process_environment = {**os.environ, **self.environment} try: process = subprocess.Popen(list(command), cwd=working_directory, env=process_environment, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, start_new_session=os.name != "nt") except OSError as exc: raise OpenModelicaError(f"simulation executable could not start: {exc}") from exc deadline = time.monotonic() + self.timeout if self.timeout is not None else None while True: if cancel_event.is_set(): _terminate_process(process) try: stdout, stderr = process.communicate(timeout=5) except subprocess.TimeoutExpired: _kill_process(process) stdout, stderr = process.communicate() raise SimulationCancelledError("simulation was cancelled") if deadline is not None and time.monotonic() >= deadline: _kill_process(process) process.communicate() raise OpenModelicaError(f"simulation timed out after {self.timeout} seconds") try: stdout, stderr = process.communicate(timeout=0.05) break except subprocess.TimeoutExpired: continue result = ProcessResult(command=tuple(command), return_code=process.returncode, stdout=stdout, stderr=stderr) if result.return_code != 0: details = result.diagnostics() suffix = f": {details}" if details else "" raise OpenModelicaError(f"simulation exited with status {result.return_code}{suffix}") return result def _terminate_process(process: subprocess.Popen[str]) -> None: if os.name == "nt": process.terminate() else: os.killpg(process.pid, signal.SIGTERM) def _kill_process(process: subprocess.Popen[str]) -> None: if os.name == "nt": process.kill() else: os.killpg(process.pid, signal.SIGKILL) def _command_parts(command: str | Sequence[str]) -> tuple[str, ...]: if isinstance(command, str): expanded = os.path.expandvars(os.path.expanduser(command)) parts = tuple(shlex.split(expanded)) else: parts = tuple(map(str, command)) if not parts: raise ValueError("OpenModelica command cannot be empty") return parts def _execute_process( command: Sequence[str], working_directory: Path, timeout: float | None, environment: Mapping[str, str] | None, ) -> ProcessResult: process_environment = None if environment is not None: process_environment = {**os.environ, **environment} completed = subprocess.run( list(command), cwd=working_directory, env=process_environment, timeout=timeout, text=True, capture_output=True, check=False, ) return ProcessResult( command=tuple(command), return_code=completed.returncode, stdout=completed.stdout, stderr=completed.stderr, ) async def _run_on_worker( operation: Callable[..., _Result], *args: Any, **kwargs: Any, ) -> _Result: """Run a blocking callable on a thread and await it without Qt.""" finished = Event() results: list[_Result] = [] errors: list[BaseException] = [] def invoke() -> None: try: results.append(operation(*args, **kwargs)) except Exception as error: # noqa: BLE001 - worker must relay operation failures errors.append(error) finally: finished.set() Thread(target=invoke, name="bedit-openmodelica", daemon=True).start() while not finished.is_set(): await asyncio.sleep(0.01) if errors: raise errors[0] return results[0]