Files
BEdit/src/bedit_simulation/openmodelica.py

220 lines
7.4 KiB
Python

"""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]