Simulate with OMC

This commit is contained in:
2026-07-24 16:30:14 +02:00
parent ae3b46f7ad
commit b09caa3475
6 changed files with 1091 additions and 15 deletions

View File

@@ -0,0 +1,153 @@
"""Run OMC directly or through a user-provided wrapper command."""
from __future__ import annotations
import asyncio
import os
import shlex
import subprocess
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
class OpenModelicaError(RuntimeError):
"""Raised when OMC cannot start or reports a failure."""
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.stderr.strip() or result.stdout.strip()
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 _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 BaseException as error:
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]