220 lines
7.4 KiB
Python
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]
|