diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index 01e5bfbd..0065673a 100644 --- a/OMPython/ModelicaSystem.py +++ b/OMPython/ModelicaSystem.py @@ -21,16 +21,20 @@ import numpy as np -from OMPython.OMCSession import ( +from OMPython.model_execution import ( + ModelExecutionCmd, ModelExecutionData, ModelExecutionException, - - OMCSessionException, - OMCSessionLocal, - +) +from OMPython.om_session_abc import ( OMPathABC, - OMSessionABC, + OMSessionException, +) +from OMPython.om_session_omc import ( + OMCSessionLocal, +) +from OMPython.om_session_runner import ( OMSessionRunner, ) @@ -95,253 +99,6 @@ def __getitem__(self, index: int): return {0: self.A, 1: self.B, 2: self.C, 3: self.D}[index] -class ModelExecutionCmd: - """ - All information about a compiled model executable. This should include data about all structured parameters, i.e. - parameters which need a recompilation of the model. All non-structured parameters can be easily changed without - the need for recompilation. - """ - - def __init__( - self, - runpath: os.PathLike, - cmd_prefix: list[str], - cmd_local: bool = False, - cmd_windows: bool = False, - timeout: float = 10.0, - model_name: Optional[str] = None, - ) -> None: - if model_name is None: - raise ModelExecutionException("Missing model name!") - - self._cmd_local = cmd_local - self._cmd_windows = cmd_windows - self._cmd_prefix = cmd_prefix - self._runpath = pathlib.PurePosixPath(runpath) - self._model_name = model_name - self._timeout = timeout - - # dictionaries of command line arguments for the model executable - self._args: dict[str, str | None] = {} - # 'override' argument needs special handling, as it is a dict on its own saved as dict elements following the - # structure: 'key' => 'key=value' - self._arg_override: dict[str, str] = {} - - def arg_set( - self, - key: str, - val: Optional[str | dict[str, Any] | numbers.Number] = None, - ) -> None: - """ - Set one argument for the executable model. - - Args: - key: identifier / argument name to be used for the call of the model executable. - val: value for the given key; None for no value and for key == 'override' a dictionary can be used which - indicates variables to override - """ - - def override2str( - orkey: str, - orval: str | bool | numbers.Number, - ) -> str: - """ - Convert a value for 'override' to a string taking into account differences between Modelica and Python. - """ - # check oval for any string representations of numbers (or bool) and convert these to Python representations - if isinstance(orval, str): - try: - val_evaluated = ast.literal_eval(orval) - if isinstance(val_evaluated, (numbers.Number, bool)): - orval = val_evaluated - except (ValueError, SyntaxError): - pass - - if isinstance(orval, str): - val_str = orval.strip() - elif isinstance(orval, bool): - val_str = 'true' if orval else 'false' - elif isinstance(orval, numbers.Number): - val_str = str(orval) - else: - raise ModelExecutionException(f"Invalid value for override key {orkey}: {type(orval)}") - - return f"{orkey}={val_str}" - - if not isinstance(key, str): - raise ModelExecutionException(f"Invalid argument key: {repr(key)} (type: {type(key)})") - key = key.strip() - - if isinstance(val, dict): - if key != 'override': - raise ModelExecutionException("Dictionary input only possible for key 'override'!") - - for okey, oval in val.items(): - if not isinstance(okey, str): - raise ModelExecutionException("Invalid key for argument 'override': " - f"{repr(okey)} (type: {type(okey)})") - - if not isinstance(oval, (str, bool, numbers.Number, type(None))): - raise ModelExecutionException(f"Invalid input for 'override'.{repr(okey)}: " - f"{repr(oval)} (type: {type(oval)})") - - if okey in self._arg_override: - if oval is None: - logger.info(f"Remove model executable override argument: {repr(self._arg_override[okey])}") - del self._arg_override[okey] - continue - - logger.info(f"Update model executable override argument: {repr(okey)} = {repr(oval)} " - f"(was: {repr(self._arg_override[okey])})") - - if oval is not None: - self._arg_override[okey] = override2str(orkey=okey, orval=oval) - - argval = ','.join(sorted(self._arg_override.values())) - elif val is None: - argval = None - elif isinstance(val, str): - argval = val.strip() - elif isinstance(val, numbers.Number): - argval = str(val) - else: - raise ModelExecutionException(f"Invalid argument value for {repr(key)}: {repr(val)} (type: {type(val)})") - - if key in self._args: - logger.warning(f"Override model executable argument: {repr(key)} = {repr(argval)} " - f"(was: {repr(self._args[key])})") - self._args[key] = argval - - def arg_get(self, key: str) -> Optional[str | dict[str, str | bool | numbers.Number]]: - """ - Return the value for the given key - """ - if key in self._args: - return self._args[key] - - return None - - def args_set( - self, - args: dict[str, Optional[str | dict[str, Any] | numbers.Number]], - ) -> None: - """ - Define arguments for the model executable. - """ - for arg in args: - self.arg_set(key=arg, val=args[arg]) - - def get_cmd_args(self) -> list[str]: - """ - Get a list with the command arguments for the model executable. - """ - - cmdl = [] - for key in sorted(self._args): - if self._args[key] is None: - cmdl.append(f"-{key}") - else: - cmdl.append(f"-{key}={self._args[key]}") - - return cmdl - - def definition(self) -> ModelExecutionData: - """ - Define all needed data to run the model executable. The data is stored in an OMCSessionRunData object. - """ - # ensure that a result filename is provided - result_file = self.arg_get('r') - if not isinstance(result_file, str): - result_file = (self._runpath / f"{self._model_name}.mat").as_posix() - - # as this is the local implementation, pathlib.Path can be used - cmd_path = self._runpath - - cmd_library_path = None - if self._cmd_local and self._cmd_windows: - cmd_library_path = "" - - # set the process environment from the generated .bat file in windows which should have all the dependencies - # for this pathlib.PurePosixPath() must be converted to a pathlib.Path() object, i.e. WindowsPath - path_bat = pathlib.Path(cmd_path) / f"{self._model_name}.bat" - if not path_bat.is_file(): - raise ModelExecutionException("Batch file (*.bat) does not exist " + str(path_bat)) - - content = path_bat.read_text(encoding='utf-8') - for line in content.splitlines(): - match = re.match(pattern=r"^SET PATH=([^%]*)", string=line, flags=re.IGNORECASE) - if match: - cmd_library_path = match.group(1).strip(';') # Remove any trailing semicolons - my_env = os.environ.copy() - my_env["PATH"] = cmd_library_path + os.pathsep + my_env["PATH"] - - cmd_model_executable = cmd_path / f"{self._model_name}.exe" - else: - # for Linux the paths to the needed libraries should be included in the executable (using rpath) - cmd_model_executable = cmd_path / self._model_name - - # define local(!) working directory - cmd_cwd_local = None - if self._cmd_local: - cmd_cwd_local = cmd_path.as_posix() - - omc_run_data = ModelExecutionData( - cmd_path=cmd_path.as_posix(), - cmd_model_name=self._model_name, - cmd_args=self.get_cmd_args(), - cmd_result_file=result_file, - cmd_prefix=self._cmd_prefix, - cmd_library_path=cmd_library_path, - cmd_model_executable=cmd_model_executable.as_posix(), - cmd_cwd_local=cmd_cwd_local, - cmd_timeout=self._timeout, - ) - - return omc_run_data - - @staticmethod - def parse_simflags(simflags: str) -> dict[str, Optional[str | dict[str, Any] | numbers.Number]]: - """ - Parse a simflag definition; this is deprecated! - - The return data can be used as input for self.args_set(). - """ - warnings.warn( - message="The argument 'simflags' is depreciated and will be removed in future versions; " - "please use 'simargs' instead", - category=DeprecationWarning, - stacklevel=2, - ) - - simargs: dict[str, Optional[str | dict[str, Any] | numbers.Number]] = {} - - args = [s for s in simflags.split(' ') if s] - for arg in args: - if arg[0] != '-': - raise ModelExecutionException(f"Invalid simulation flag: {arg}") - arg = arg[1:] - parts = arg.split('=') - if len(parts) == 1: - simargs[parts[0]] = None - elif parts[0] == 'override': - override = '='.join(parts[1:]) - - override_dict = {} - for item in override.split(','): - kv = item.split('=') - if not 0 < len(kv) < 3: - raise ModelExecutionException(f"Invalid value for '-override': {override}") - if kv[0]: - try: - override_dict[kv[0]] = kv[1] - except (KeyError, IndexError) as ex: - raise ModelExecutionException(f"Invalid value for '-override': {override}") from ex - - simargs[parts[0]] = override_dict - - return simargs - - class ModelicaSystemABC(metaclass=abc.ABCMeta): """ Base class to simulate a Modelica models. @@ -1688,7 +1445,7 @@ def sendExpression(self, expr: str, parsed: bool = True) -> Any: """ try: retval = self._session.sendExpression(expr=expr, parsed=parsed) - except OMCSessionException as ex: + except OMSessionException as ex: raise ModelicaSystemError(f"Error executing {repr(expr)}: {ex}") from ex logger.debug(f"Result of executing {repr(expr)}: {textwrap.shorten(repr(retval), width=100)}") @@ -2829,7 +2586,9 @@ def _prepare_structure_parameters( class ModelicaSystemCmd(ModelExecutionCmd): - # TODO: docstring + """ + Compatibility class; in the new version it is renamed as MOdelExecutionCmd. + """ def __init__( self, diff --git a/OMPython/OMCSession.py b/OMPython/OMCSession.py index 04b5d9cc..c5511923 100644 --- a/OMPython/OMCSession.py +++ b/OMPython/OMCSession.py @@ -5,63 +5,35 @@ from __future__ import annotations -import abc -import dataclasses -import io -import json import logging -import os -import pathlib -import platform -import re -import shutil -import signal -import subprocess -import sys -import tempfile -import time -from typing import Any, Optional, Tuple, Type -import uuid +from typing import Any, Optional import warnings -import psutil import pyparsing -import zmq -# TODO: replace this with the new parser -from OMPython.OMTypedParser import om_parser_typed -from OMPython.OMParser import om_parser_basic +from OMPython.om_session_abc import ( + OMPathABC, + OMSessionABC, + OMSessionException, +) +from OMPython.om_session_omc import ( + DockerPopen, + OMCSessionABC, + OMCSessionDocker, + OMCSessionDockerContainer, + OMCSessionLocal, + OMCSessionPort, + OMCSessionWSL, +) + # define logger using the current module name as ID logger = logging.getLogger(__name__) -class DockerPopen: - """ - Dummy implementation of Popen for a (running) docker process. The process is identified by its process ID (pid). - """ - - def __init__(self, pid): - self.pid = pid - self.process = psutil.Process(pid) - self.returncode = 0 - - def poll(self): - return None if self.process.is_running() else True - - def kill(self): - return os.kill(pid=self.pid, signal=signal.SIGKILL) - - def wait(self, timeout): - try: - self.process.wait(timeout=timeout) - except psutil.TimeoutExpired: - pass - - -class OMCSessionException(Exception): +class OMCSessionException(OMSessionException): """ - Exception which is raised by any OMC* class. + Just a compatibility layer ... """ @@ -71,6 +43,13 @@ class OMCSessionCmd: """ def __init__(self, session: OMSessionABC, readonly: bool = False): + warnings.warn( + message="The class OMCSessionCMD is depreciated and will be removed in future versions; " + "please use OMCSession*.sendExpression(...) instead!", + category=DeprecationWarning, + stacklevel=2, + ) + if not isinstance(session, OMSessionABC): raise OMCSessionException("Invalid OMC process definition!") self._session = session @@ -84,7 +63,7 @@ def _ask(self, question: str, opt: Optional[list[str]] = None, parsed: bool = Tr elif isinstance(opt, list): expression = f"{question}({','.join([str(x) for x in opt])})" else: - raise OMCSessionException(f"Invalid definition of options for {repr(question)}: {repr(opt)}") + raise OMSessionException(f"Invalid definition of options for {repr(question)}: {repr(opt)}") p = (expression, parsed) @@ -95,8 +74,8 @@ def _ask(self, question: str, opt: Optional[list[str]] = None, parsed: bool = Tr try: res = self._session.sendExpression(expression, parsed=parsed) - except OMCSessionException as ex: - raise OMCSessionException(f"OMC _ask() failed: {expression} (parsed={parsed})") from ex + except OMSessionException as ex: + raise OMSessionException(f"OMC _ask() failed: {expression} (parsed={parsed})") from ex # save response self._omc_cache[p] = res @@ -249,1276 +228,6 @@ def getClassNames(self, className=None, recursive=False, qualified=False, sort=F return self._ask(question='getClassNames', opt=opt) -# due to the compatibility layer to Python < 3.12, the OM(C)Path classes must be hidden behind the following if -# conditions. This is also the reason for OMPathABC, a simple base class to be used in ModelicaSystem* classes. -# Reason: before Python 3.12, pathlib.PurePosixPath can not be derived from; therefore, OMPathABC is not possible -if sys.version_info < (3, 12): - class OMPathCompatibility(pathlib.Path): - """ - Compatibility class for OMPathABC in Python < 3.12. This allows to run all code which uses OMPathABC (mainly - ModelicaSystem) on these Python versions. There are remaining limitation as only local execution is possible. - """ - - # modified copy of pathlib.Path.__new__() definition - def __new__(cls, *args, **kwargs): - logger.warning("Python < 3.12 - using a version of class OMCPath " - "based on pathlib.Path for local usage only.") - - if cls is OMPathCompatibility: - cls = OMPathCompatibilityWindows if os.name == 'nt' else OMPathCompatibilityPosix - self = cls._from_parts(args) - if not self._flavour.is_supported: - raise NotImplementedError(f"cannot instantiate {cls.__name__} on your system") - return self - - def size(self) -> int: - """ - Needed compatibility function to have the same interface as OMCPathReal - """ - return self.stat().st_size - - class OMPathCompatibilityPosix(pathlib.PosixPath, OMPathCompatibility): - """ - Compatibility class for OMCPath on Posix systems (Python < 3.12) - """ - - class OMPathCompatibilityWindows(pathlib.WindowsPath, OMPathCompatibility): - """ - Compatibility class for OMCPath on Windows systems (Python < 3.12) - """ - - OMPathABC = OMPathCompatibility - OMCPath = OMPathCompatibility - OMPathRunnerABC = OMPathCompatibility - OMPathRunnerLocal = OMPathCompatibility - OMPathRunnerBash = OMPathCompatibility - -else: - class OMPathABC(pathlib.PurePosixPath, metaclass=abc.ABCMeta): - """ - Implementation of a basic (PurePosix)Path object to be used within OMPython. The derived classes can use OMC as - backend and - thus - work on different configurations like docker or WSL. The connection to OMC is provided via - an instances of classes derived from BaseSession. - - PurePosixPath is selected as it covers all but Windows systems (Linux, docker, WSL). However, the code is - written such that possible Windows system are taken into account. Nevertheless, the overall functionality is - limited compared to standard pathlib.Path objects. - """ - - def __init__(self, *path, session: OMSessionABC) -> None: - super().__init__(*path) - self._session = session - - def get_session(self) -> OMSessionABC: - """ - Get session definition used for this instance of OMPath. - """ - return self._session - - def with_segments(self, *pathsegments) -> OMPathABC: - """ - Create a new OMCPath object with the given path segments. - - The original definition of Path is overridden to ensure the session data is set. - """ - return type(self)(*pathsegments, session=self._session) - - @abc.abstractmethod - def is_file(self) -> bool: - """ - Check if the path is a regular file. - """ - - @abc.abstractmethod - def is_dir(self) -> bool: - """ - Check if the path is a directory. - """ - - @abc.abstractmethod - def is_absolute(self) -> bool: - """ - Check if the path is an absolute path. - """ - - @abc.abstractmethod - def read_text(self) -> str: - """ - Read the content of the file represented by this path as text. - """ - - @abc.abstractmethod - def write_text(self, data: str) -> int: - """ - Write text data to the file represented by this path. - """ - - @abc.abstractmethod - def mkdir(self, parents: bool = True, exist_ok: bool = False) -> None: - """ - Create a directory at the path represented by this class. - - The argument parents with default value True exists to ensure compatibility with the fallback solution for - Python < 3.12. In this case, pathlib.Path is used directly and this option ensures, that missing parent - directories are also created. - """ - - @abc.abstractmethod - def cwd(self) -> OMPathABC: - """ - Returns the current working directory as an OMPathABC object. - """ - - @abc.abstractmethod - def unlink(self, missing_ok: bool = False) -> None: - """ - Unlink (delete) the file or directory represented by this path. - """ - - @abc.abstractmethod - def resolve(self, strict: bool = False) -> OMPathABC: - """ - Resolve the path to an absolute path. - """ - - def absolute(self) -> OMPathABC: - """ - Resolve the path to an absolute path. Just a wrapper for resolve(). - """ - return self.resolve() - - def exists(self) -> bool: - """ - Semi replacement for pathlib.Path.exists(). - """ - return self.is_file() or self.is_dir() - - @abc.abstractmethod - def size(self) -> int: - """ - Get the size of the file in bytes - this is an extra function and the best we can do using OMC. - """ - - class _OMCPath(OMPathABC): - """ - Implementation of a OMPathABC using OMC as backend. The connection to OMC is provided via an instances of an - OMCSession* classes. - """ - - def is_file(self) -> bool: - """ - Check if the path is a regular file. - """ - retval = self.get_session().sendExpression(expr=f'regularFileExists("{self.as_posix()}")') - if not isinstance(retval, bool): - raise OMCSessionException(f"Invalid return value for is_file(): {retval} - expect bool") - return retval - - def is_dir(self) -> bool: - """ - Check if the path is a directory. - """ - retval = self.get_session().sendExpression(expr=f'directoryExists("{self.as_posix()}")') - if not isinstance(retval, bool): - raise OMCSessionException(f"Invalid return value for is_dir(): {retval} - expect bool") - return retval - - def is_absolute(self) -> bool: - """ - Check if the path is an absolute path. Special handling to differentiate Windows and Posix definitions. - """ - if isinstance(self._session, OMCSessionLocal) and platform.system() == 'Windows': - return pathlib.PureWindowsPath(self.as_posix()).is_absolute() - return pathlib.PurePosixPath(self.as_posix()).is_absolute() - - def read_text(self) -> str: - """ - Read the content of the file represented by this path as text. - """ - retval = self.get_session().sendExpression(expr=f'readFile("{self.as_posix()}")') - if not isinstance(retval, str): - raise OMCSessionException(f"Invalid return value for read_text(): {retval} - expect str") - return retval - - def write_text(self, data: str) -> int: - """ - Write text data to the file represented by this path. - """ - if not isinstance(data, str): - raise TypeError(f"data must be str, not {data.__class__.__name__}") - - data_omc = self._session.escape_str(data) - self._session.sendExpression(expr=f'writeFile("{self.as_posix()}", "{data_omc}", false);') - - return len(data) - - def mkdir(self, parents: bool = True, exist_ok: bool = False) -> None: - """ - Create a directory at the path represented by this class. - - The argument parents with default value True exists to ensure compatibility with the fallback solution for - Python < 3.12. In this case, pathlib.Path is used directly and this option ensures, that missing parent - directories are also created. - """ - if self.is_dir() and not exist_ok: - raise FileExistsError(f"Directory {self.as_posix()} already exists!") - - if not self._session.sendExpression(expr=f'mkdir("{self.as_posix()}")'): - raise OMCSessionException(f"Error on directory creation for {self.as_posix()}!") - - def cwd(self) -> OMPathABC: - """ - Returns the current working directory as an OMPathABC object. - """ - cwd_str = self._session.sendExpression(expr='cd()') - return type(self)(cwd_str, session=self._session) - - def unlink(self, missing_ok: bool = False) -> None: - """ - Unlink (delete) the file or directory represented by this path. - """ - res = self._session.sendExpression(expr=f'deleteFile("{self.as_posix()}")') - if not res and not missing_ok: - raise FileNotFoundError(f"Cannot delete file {self.as_posix()} - it does not exists!") - - def resolve(self, strict: bool = False) -> OMPathABC: - """ - Resolve the path to an absolute path. This is done based on available OMC functions. - """ - if strict and not (self.is_file() or self.is_dir()): - raise OMCSessionException(f"Path {self.as_posix()} does not exist!") - - if self.is_file(): - pathstr_resolved = self._omc_resolve(self.parent.as_posix()) - omcpath_resolved = self._session.omcpath(pathstr_resolved) / self.name - elif self.is_dir(): - pathstr_resolved = self._omc_resolve(self.as_posix()) - omcpath_resolved = self._session.omcpath(pathstr_resolved) - else: - raise OMCSessionException(f"Path {self.as_posix()} is neither a file nor a directory!") - - if not omcpath_resolved.is_file() and not omcpath_resolved.is_dir(): - raise OMCSessionException(f"OMCPath resolve failed for {self.as_posix()} - path does not exist!") - - return omcpath_resolved - - def _omc_resolve(self, pathstr: str) -> str: - """ - Internal function to resolve the path of the OMCPath object using OMC functions *WITHOUT* changing the cwd - within OMC. - """ - expr = ('omcpath_cwd := cd(); ' - f'omcpath_check := cd("{pathstr}"); ' # check requested pathstring - 'cd(omcpath_cwd)') - - try: - retval = self.get_session().sendExpression(expr=expr, parsed=False) - if not isinstance(retval, str): - raise OMCSessionException(f"Invalid return value for _omc_resolve(): {retval} - expect str") - result_parts = retval.split('\n') - pathstr_resolved = result_parts[1] - pathstr_resolved = pathstr_resolved[1:-1] # remove quotes - except OMCSessionException as ex: - raise OMCSessionException(f"OMCPath resolve failed for {pathstr}!") from ex - - return pathstr_resolved - - def size(self) -> int: - """ - Get the size of the file in bytes - this is an extra function and the best we can do using OMC. - """ - if not self.is_file(): - raise OMCSessionException(f"Path {self.as_posix()} is not a file!") - - res = self._session.sendExpression(expr=f'stat("{self.as_posix()}")') - if res[0]: - return int(res[1]) - - raise OMCSessionException(f"Error reading file size for path {self.as_posix()}!") - - class OMPathRunnerABC(OMPathABC, metaclass=abc.ABCMeta): - """ - Base function for OMPath definitions *without* OMC server - """ - - def _path(self) -> pathlib.Path: - return pathlib.Path(self.as_posix()) - - class _OMPathRunnerLocal(OMPathRunnerABC): - """ - Implementation of OMPathABC which does not use the session data at all. Thus, this implementation can run - locally without any usage of OMC. - - This class is based on OMPathABC and, therefore, on pathlib.PurePosixPath. This is working well, but it is not - the correct implementation on Windows systems. To get a valid Windows representation of the path, use the - conversion via pathlib.Path(.as_posix()). - """ - - def is_file(self) -> bool: - """ - Check if the path is a regular file. - """ - return self._path().is_file() - - def is_dir(self) -> bool: - """ - Check if the path is a directory. - """ - return self._path().is_dir() - - def is_absolute(self) -> bool: - """ - Check if the path is an absolute path. - """ - return self._path().is_absolute() - - def read_text(self) -> str: - """ - Read the content of the file represented by this path as text. - """ - return self._path().read_text(encoding='utf-8') - - def write_text(self, data: str): - """ - Write text data to the file represented by this path. - """ - if not isinstance(data, str): - raise TypeError(f"data must be str, not {data.__class__.__name__}") - - return self._path().write_text(data=data, encoding='utf-8') - - def mkdir(self, parents: bool = True, exist_ok: bool = False) -> None: - """ - Create a directory at the path represented by this class. - - The argument parents with default value True exists to ensure compatibility with the fallback solution for - Python < 3.12. In this case, pathlib.Path is used directly and this option ensures, that missing parent - directories are also created. - """ - self._path().mkdir(parents=parents, exist_ok=exist_ok) - - def cwd(self) -> OMPathABC: - """ - Returns the current working directory as an OMPathABC object. - """ - return type(self)(self._path().cwd().as_posix(), session=self._session) - - def unlink(self, missing_ok: bool = False) -> None: - """ - Unlink (delete) the file or directory represented by this path. - """ - self._path().unlink(missing_ok=missing_ok) - - def resolve(self, strict: bool = False) -> OMPathABC: - """ - Resolve the path to an absolute path. This is done based on available OMC functions. - """ - path_resolved = self._path().resolve(strict=strict) - return type(self)(path_resolved, session=self._session) - - def size(self) -> int: - """ - Get the size of the file in bytes - implementation baseon on pathlib.Path. - """ - if not self.is_file(): - raise OMCSessionException(f"Path {self.as_posix()} is not a file!") - - path = self._path() - return path.stat().st_size - - class _OMPathRunnerBash(OMPathRunnerABC): - """ - Implementation of OMPathABC which does not use the session data at all. Thus, this implementation can run - locally without any usage of OMC. The special case of this class is the usage of POSIX bash to run all the - commands. Thus, it can be used in WSL or docker. - - This class is based on OMPathABC and, therefore, on pathlib.PurePosixPath. This is working well, but it is not - the correct implementation on Windows systems. To get a valid Windows representation of the path, use the - conversion via pathlib.Path(.as_posix()). - """ - - def is_file(self) -> bool: - """ - Check if the path is a regular file. - """ - cmdl = self.get_session().get_cmd_prefix() - cmdl += ['bash', '-c', f'test -f "{self.as_posix()}"'] - - try: - subprocess.run(cmdl, check=True) - return True - except subprocess.CalledProcessError: - return False - - def is_dir(self) -> bool: - """ - Check if the path is a directory. - """ - cmdl = self.get_session().get_cmd_prefix() - cmdl += ['bash', '-c', f'test -d "{self.as_posix()}"'] - - try: - subprocess.run(cmdl, check=True) - return True - except subprocess.CalledProcessError: - return False - - def is_absolute(self) -> bool: - """ - Check if the path is an absolute path. - """ - - cmdl = self.get_session().get_cmd_prefix() - cmdl += ['bash', '-c', f'case "{self.as_posix()}" in /*) exit 0;; *) exit 1;; esac'] - - try: - subprocess.check_call(cmdl) - return True - except subprocess.CalledProcessError: - return False - - def read_text(self) -> str: - """ - Read the content of the file represented by this path as text. - """ - cmdl = self.get_session().get_cmd_prefix() - cmdl += ['bash', '-c', f'cat "{self.as_posix()}"'] - - result = subprocess.run(cmdl, capture_output=True, check=True) - if result.returncode == 0: - return result.stdout.decode('utf-8') - raise FileNotFoundError(f"Cannot read file: {self.as_posix()}") - - def write_text(self, data: str) -> int: - """ - Write text data to the file represented by this path. - """ - if not isinstance(data, str): - raise TypeError(f"data must be str, not {data.__class__.__name__}") - - data_escape = self._session.escape_str(data) - - cmdl = self.get_session().get_cmd_prefix() - cmdl += ['bash', '-c', f'printf %s "{data_escape}" > "{self.as_posix()}"'] - - try: - subprocess.run(cmdl, check=True) - return len(data) - except subprocess.CalledProcessError as exc: - raise IOError(f"Error writing data to file {self.as_posix()}!") from exc - - def mkdir(self, parents: bool = True, exist_ok: bool = False) -> None: - """ - Create a directory at the path represented by this class. - - The argument parents with default value True exists to ensure compatibility with the fallback solution for - Python < 3.12. In this case, pathlib.Path is used directly and this option ensures, that missing parent - directories are also created. - """ - - if self.is_file(): - raise OSError(f"The given path {self.as_posix()} exists and is a file!") - if self.is_dir() and not exist_ok: - raise OSError(f"The given path {self.as_posix()} exists and is a directory!") - if not parents and not self.parent.is_dir(): - raise FileNotFoundError(f"Parent directory of {self.as_posix()} does not exists!") - - cmdl = self.get_session().get_cmd_prefix() - cmdl += ['bash', '-c', f'mkdir -p "{self.as_posix()}"'] - - try: - subprocess.run(cmdl, check=True) - except subprocess.CalledProcessError as exc: - raise OMCSessionException(f"Error on directory creation for {self.as_posix()}!") from exc - - def cwd(self) -> OMPathABC: - """ - Returns the current working directory as an OMPathABC object. - """ - cmdl = self.get_session().get_cmd_prefix() - cmdl += ['bash', '-c', 'pwd'] - - result = subprocess.run(cmdl, capture_output=True, text=True, check=True) - if result.returncode == 0: - return type(self)(result.stdout.strip(), session=self._session) - raise OSError("Can not get current work directory ...") - - def unlink(self, missing_ok: bool = False) -> None: - """ - Unlink (delete) the file or directory represented by this path. - """ - - if not self.is_file(): - raise OSError(f"Can not unlink a directory: {self.as_posix()}!") - - if not self.is_file(): - return - - cmdl = self.get_session().get_cmd_prefix() - cmdl += ['bash', '-c', f'rm "{self.as_posix()}"'] - - try: - subprocess.run(cmdl, check=True) - except subprocess.CalledProcessError as exc: - raise OSError(f"Cannot unlink file {self.as_posix()}: {exc}") from exc - - def resolve(self, strict: bool = False) -> OMPathABC: - """ - Resolve the path to an absolute path. This is done based on available OMC functions. - """ - cmdl = self.get_session().get_cmd_prefix() - cmdl += ['bash', '-c', f'readlink -f "{self.as_posix()}"'] - - result = subprocess.run(cmdl, capture_output=True, text=True, check=True) - if result.returncode == 0: - return type(self)(result.stdout.strip(), session=self._session) - raise FileNotFoundError(f"Cannot resolve path: {self.as_posix()}") - - def size(self) -> int: - """ - Get the size of the file in bytes - implementation baseon on pathlib.Path. - """ - if not self.is_file(): - raise OMCSessionException(f"Path {self.as_posix()} is not a file!") - - cmdl = self.get_session().get_cmd_prefix() - cmdl += ['bash', '-c', f'stat -c %s "{self.as_posix()}"'] - - result = subprocess.run(cmdl, capture_output=True, text=True, check=True) - stdout = result.stdout.strip() - if result.returncode == 0: - try: - return int(stdout) - except ValueError as exc: - raise OSError(f"Invalid return value for filesize ({self.as_posix()}): {stdout}") from exc - else: - raise OSError(f"Cannot get size for file {self.as_posix()}") - - OMCPath = _OMCPath - OMPathRunnerLocal = _OMPathRunnerLocal - OMPathRunnerBash = _OMPathRunnerBash - - -class ModelExecutionException(Exception): - """ - Exception which is raised by ModelException* classes. - """ - - -@dataclasses.dataclass -class ModelExecutionData: - """ - Data class to store the command line data for running a model executable in the OMC environment. - - All data should be defined for the environment, where OMC is running (local, docker or WSL) - - To use this as a definition of an OMC simulation run, it has to be processed within - OMCProcess*.self_update(). This defines the attribute cmd_model_executable. - """ - # cmd_path is the expected working directory - cmd_path: str - cmd_model_name: str - # command prefix data (as list of strings); needed for docker or WSL - cmd_prefix: list[str] - # cmd_model_executable is build out of cmd_path and cmd_model_name; this is mainly needed on Windows (add *.exe) - cmd_model_executable: str - # command line arguments for the model executable - cmd_args: list[str] - # result file with the simulation output - cmd_result_file: str - # command timeout - cmd_timeout: float - - # additional library search path; this is mainly needed if OMCProcessLocal is run on Windows - cmd_library_path: Optional[str] = None - # working directory to be used on the *local* system - cmd_cwd_local: Optional[str] = None - - def get_cmd(self) -> list[str]: - """ - Get the command line to run the model executable in the environment defined by the OMCProcess definition. - """ - - cmdl = self.cmd_prefix - cmdl += [self.cmd_model_executable] - cmdl += self.cmd_args - - return cmdl - - def run(self) -> int: - """ - Run the model execution defined in this class. - """ - - my_env = os.environ.copy() - if isinstance(self.cmd_library_path, str): - my_env["PATH"] = self.cmd_library_path + os.pathsep + my_env["PATH"] - - cmdl = self.get_cmd() - - logger.debug("Run OM command %s in %s (timeout=%2fs)", repr(cmdl), self.cmd_path, self.cmd_timeout) - try: - cmdres = subprocess.run( - cmdl, - capture_output=True, - text=True, - env=my_env, - cwd=self.cmd_cwd_local, - timeout=self.cmd_timeout, - check=True, - ) - stdout = cmdres.stdout.strip() - stderr = cmdres.stderr.strip() - returncode = cmdres.returncode - - logger.debug("OM output for command %s:\n%s", repr(cmdl), stdout) - - if stderr: - raise ModelExecutionException(f"Error running model executable {repr(cmdl)}: {stderr}") - except subprocess.TimeoutExpired as ex: - raise ModelExecutionException("OMPython timeout running model executable " - f"(timeout={self.cmd_timeout:.2f}s){repr(cmdl)}: {ex}") from ex - except subprocess.CalledProcessError as ex: - raise ModelExecutionException(f"Error running model executable {repr(cmdl)}: {ex}") from ex - - return returncode - - -class PostInitCaller(type): - """ - Metaclass definition to define a new function __post_init__() which is called after all __init__() functions where - executed. The workflow would read as follows: - - On creating a class with the following inheritance Class2 => Class1 => Class0, where each class calls the __init__() - functions of its parent, i.e. super().__init__(), as well as __post_init__() the call schema would be: - - myclass = Class2() - Class2.__init__() - Class1.__init__() - Class0.__init__() - Class2.__post_init__() <= this is done due to the metaclass - Class1.__post_init__() - Class0.__post_init__() - - References: - * https://stackoverflow.com/questions/100003/what-are-metaclasses-in-python - * https://stackoverflow.com/questions/795190/how-to-perform-common-post-initialization-tasks-in-inherited-classes - """ - - def __call__(cls, *args, **kwargs): - obj = type.__call__(cls, *args, **kwargs) - obj.__post_init__() - return obj - - -class OMSessionMeta(abc.ABCMeta, PostInitCaller): - """ - Helper class to get a combined metaclass of ABCMeta and PostInitCaller. - - References: - * https://stackoverflow.com/questions/11276037/resolving-metaclass-conflicts - """ - - -class OMSessionABC(metaclass=OMSessionMeta): - """ - This class implements the basic structure a OMPython session definition needs. It provides the structure for an - implementation using OMC as backend (via ZMQ) or a dummy implementation which just runs a model executable. - """ - - def __init__( - self, - timeout: Optional[float] = None, - **kwargs, - ) -> None: - """ - Initialisation for OMSessionBase - """ - - # some helper data - self.model_execution_windows = platform.system() == "Windows" - self.model_execution_local = False - - # store variables - self._timeout = 10.0 - self.set_timeout(timeout=timeout) - # command prefix (to be used for docker or WSL) - self._cmd_prefix: list[str] = [] - - def __post_init__(self) -> None: - """ - Post initialisation method. - """ - - def set_timeout(self, timeout: Optional[float] = None) -> float: - """ - Set the timeout to be used for OMC communication (OMCSession). - - The defined value is set and the current value is returned. If None is provided as argument, nothing is changed. - """ - retval = self._timeout - if timeout is not None: - if timeout <= 0.0: - raise OMCSessionException(f"Invalid timeout value: {timeout}s!") - logger.info(f"Update timeout for {self.__class__.__name__}: {retval}s => {timeout}s") - self._timeout = timeout - return retval - - def get_cmd_prefix(self) -> list[str]: - """ - Get session definition used for this instance of OMPath. - """ - return self._cmd_prefix.copy() - - @staticmethod - def escape_str(value: str) -> str: - """ - Escape a string such that it can be used as string within OMC expressions, i.e. escape all double quotes. - """ - return value.replace("\\", "\\\\").replace('"', '\\"') - - @abc.abstractmethod - def model_execution_prefix(self, cwd: Optional[OMPathABC] = None) -> list[str]: - """ - Helper function which returns a command prefix. - """ - - @abc.abstractmethod - def get_version(self) -> str: - """ - Get the OM version. - """ - - @abc.abstractmethod - def set_workdir(self, workdir: OMPathABC) -> None: - """ - Set the workdir for this session. - """ - - @abc.abstractmethod - def omcpath(self, *path) -> OMPathABC: - """ - Create an OMPathABC object based on the given path segments and the current class. - """ - - @abc.abstractmethod - def omcpath_tempdir(self, tempdir_base: Optional[OMPathABC] = None) -> OMPathABC: - """ - Get a temporary directory based on the specific definition for this session. - """ - - @staticmethod - def _tempdir(tempdir_base: OMPathABC) -> OMPathABC: - names = [str(uuid.uuid4()) for _ in range(100)] - - tempdir: Optional[OMPathABC] = None - for name in names: - # create a unique temporary directory name - tempdir = tempdir_base / name - - if tempdir.exists(): - continue - - tempdir.mkdir(parents=True, exist_ok=False) - break - - if tempdir is None or not tempdir.is_dir(): - raise FileNotFoundError(f"Cannot create a temporary directory in {tempdir_base}!") - - return tempdir - - @abc.abstractmethod - def sendExpression(self, expr: str, parsed: bool = True) -> Any: - """ - Function needed to send expressions to the OMC server via ZMQ. - """ - - -class OMCSessionABC(OMSessionABC, metaclass=abc.ABCMeta): - """ - Base class for an OMC session started via ZMQ. This class contains common functionality for all variants of an - OMC session definition. - - The main method is sendExpression() which is used to send commands to the OMC process. - - The following variants are defined: - - * OMCSessionLocal - - * OMCSessionPort - - * OMCSessionDocker - - * OMCSessionDockerContainer - - * OMCSessionWSL - """ - - def __init__( - self, - timeout: Optional[float] = None, - **kwargs, - ) -> None: - """ - Initialisation for OMCSession - """ - super().__init__(timeout=timeout) - - # some helper data - self.model_execution_windows = platform.system() == "Windows" - self.model_execution_local = False - - # generate a random string for this instance of OMC - self._random_string = uuid.uuid4().hex - # get a temporary directory - self._temp_dir = pathlib.Path(tempfile.gettempdir()) - - # omc process - self._omc_process: Optional[subprocess.Popen] = None - # omc ZMQ port to use - self._omc_port: Optional[str] = None - # omc port and log file - self._omc_filebase = f"openmodelica.{self._random_string}" - # ZMQ socket to communicate with OMC - self._omc_zmq: Optional[zmq.Socket[bytes]] = None - - # setup log file - this file must be closed in the destructor - self._omc_logfile = self._temp_dir / (self._omc_filebase + ".log") - self._omc_loghandle: Optional[io.TextIOWrapper] = None - try: - self._omc_loghandle = open(file=self._omc_logfile, mode="w+", encoding="utf-8") - except OSError as ex: - raise OMCSessionException(f"Cannot open log file {self._omc_logfile}.") from ex - - # variables to store compiled re expressions use in self.sendExpression() - self._re_log_entries: Optional[re.Pattern[str]] = None - self._re_log_raw: Optional[re.Pattern[str]] = None - - self._re_portfile_path = re.compile(pattern=r'\nDumped server port in file: (.*?)($|\n)', - flags=re.MULTILINE | re.DOTALL) - - def __post_init__(self) -> None: - """ - Create the connection to the OMC server using ZeroMQ. - """ - port = self.get_port() - if not isinstance(port, str): - raise OMCSessionException(f"Invalid content for port: {port}") - - # Create the ZeroMQ socket and connect to OMC server - context = zmq.Context.instance() - omc = context.socket(zmq.REQ) - omc.setsockopt(zmq.LINGER, 0) # Dismisses pending messages if closed - omc.setsockopt(zmq.IMMEDIATE, True) # Queue messages only to completed connections - omc.connect(port) - - self._omc_zmq = omc - - def __del__(self): - if isinstance(self._omc_zmq, zmq.Socket): - try: - self.sendExpression(expr="quit()") - except OMCSessionException as exc: - logger.warning(f"Exception on sending 'quit()' to OMC: {exc}! Continue nevertheless ...") - finally: - self._omc_zmq = None - - if self._omc_loghandle is not None: - try: - self._omc_loghandle.close() - except (OSError, IOError): - pass - finally: - self._omc_loghandle = None - - if isinstance(self._omc_process, subprocess.Popen): - try: - self._omc_process.wait(timeout=2.0) - except subprocess.TimeoutExpired: - if self._omc_process: - logger.warning("OMC did not exit after being sent the 'quit()' command; " - "killing the process with pid=%s", self._omc_process.pid) - self._omc_process.kill() - self._omc_process.wait() - finally: - - self._omc_process = None - - def _timeout_loop( - self, - timeout: Optional[float] = None, - timestep: float = 0.1, - ): - """ - Helper (using yield) for while loops to check OMC startup / response. The loop is executed as long as True is - returned, i.e. the first False will stop the while loop. - """ - - if timeout is None: - timeout = self._timeout - if timeout <= 0: - raise OMCSessionException(f"Invalid timeout: {timeout}") - - timer = 0.0 - yield True - while True: - timer += timestep - if timer > timeout: - break - time.sleep(timestep) - yield True - yield False - - @staticmethod - def escape_str(value: str) -> str: - """ - Escape a string such that it can be used as string within OMC expressions, i.e. escape all double quotes. - """ - return value.replace("\\", "\\\\").replace('"', '\\"') - - def get_version(self) -> str: - """ - Get the OM version. - """ - return self.sendExpression("getVersion()", parsed=True) - - def set_workdir(self, workdir: OMPathABC) -> None: - """ - Set the workdir for this session. - """ - exp = f'cd("{workdir.as_posix()}")' - self.sendExpression(exp) - - def model_execution_prefix(self, cwd: Optional[OMPathABC] = None) -> list[str]: - """ - Helper function which returns a command prefix needed for docker and WSL. It defaults to an empty list. - """ - - return [] - - def omcpath(self, *path) -> OMPathABC: - """ - Create an OMCPath object based on the given path segments and the current OMCSession* class. - """ - - # fallback solution for Python < 3.12; a modified pathlib.Path object is used as OMCPath replacement - if sys.version_info < (3, 12): - if isinstance(self, OMCSessionLocal): - # noinspection PyArgumentList - return OMCPath(*path) - raise OMCSessionException("OMCPath is supported for Python < 3.12 only if OMCSessionLocal is used!") - return OMCPath(*path, session=self) - - def omcpath_tempdir(self, tempdir_base: Optional[OMPathABC] = None) -> OMPathABC: - """ - Get a temporary directory using OMC. It is our own implementation as non-local usage relies on OMC to run all - filesystem related access. - """ - - if tempdir_base is None: - # fallback solution for Python < 3.12; a modified pathlib.Path object is used as OMCPath replacement - if sys.version_info < (3, 12): - tempdir_str = tempfile.gettempdir() - else: - tempdir_str = self.sendExpression(expr="getTempDirectoryPath()") - tempdir_base = self.omcpath(tempdir_str) - - return self._tempdir(tempdir_base=tempdir_base) - - @staticmethod - def _tempdir(tempdir_base: OMPathABC) -> OMPathABC: - names = [str(uuid.uuid4()) for _ in range(100)] - - tempdir: Optional[OMPathABC] = None - for name in names: - # create a unique temporary directory name - tempdir = tempdir_base / name - - if tempdir.exists(): - continue - - tempdir.mkdir(parents=True, exist_ok=False) - break - - if tempdir is None or not tempdir.is_dir(): - raise OMCSessionException("Cannot create a temporary directory!") - - return tempdir - - def execute(self, command: str): - warnings.warn( - message="This function is depreciated and will be removed in future versions; " - "please use sendExpression() instead", - category=DeprecationWarning, - stacklevel=2, - ) - - return self.sendExpression(command, parsed=False) - - def sendExpression(self, expr: str, parsed: bool = True) -> Any: - """ - Send an expression to the OMC server and return the result. - - The complete error handling of the OMC result is done within this method using '"getMessagesStringInternal()'. - Caller should only check for OMCSessionException. - """ - - if self._omc_zmq is None: - raise OMCSessionException("No OMC running. Please create a new instance of OMCSession!") - - logger.debug("sendExpression(expr='%r', parsed=%r)", str(expr), parsed) - - loop = self._timeout_loop(timestep=0.05) - while next(loop): - try: - self._omc_zmq.send_string(str(expr), flags=zmq.NOBLOCK) - break - except zmq.error.Again: - pass - else: - # in the deletion process, the content is cleared. Thus, any access to a class attribute must be checked - try: - log_content = self.get_log() - except OMCSessionException: - log_content = 'log not available' - - logger.error(f"OMC did not start. Log-file says:\n{log_content}") - raise OMCSessionException(f"No connection with OMC (timeout={self._timeout:.2f}s).") - - if expr == "quit()": - self._omc_zmq.close() - self._omc_zmq = None - return None - - result = self._omc_zmq.recv_string() - - if result.startswith('Error occurred building AST'): - raise OMCSessionException(f"OMC error: {result}") - - if expr == "getErrorString()": - # no error handling if 'getErrorString()' is called - if parsed: - logger.warning("Result of 'getErrorString()' cannot be parsed!") - return result - - if expr == "getMessagesStringInternal()": - # no error handling if 'getMessagesStringInternal()' is called - if parsed: - logger.warning("Result of 'getMessagesStringInternal()' cannot be parsed!") - return result - - # always check for error - self._omc_zmq.send_string('getMessagesStringInternal()', flags=zmq.NOBLOCK) - error_raw = self._omc_zmq.recv_string() - # run error handling only if there is something to check - msg_long_list = [] - has_error = False - if error_raw != "{}\n": - if not self._re_log_entries: - self._re_log_entries = re.compile(pattern=r'record OpenModelica\.Scripting\.ErrorMessage' - '(.*?)' - r'end OpenModelica\.Scripting\.ErrorMessage;', - flags=re.MULTILINE | re.DOTALL) - if not self._re_log_raw: - self._re_log_raw = re.compile( - pattern=r"\s*info = record OpenModelica\.Scripting\.SourceInfo\n" - r"\s*filename = \"(.*?)\",\n" - r"\s*readonly = (.*?),\n" - r"\s*lineStart = (\d+),\n" - r"\s*columnStart = (\d+),\n" - r"\s*lineEnd = (\d+),\n" - r"\s*columnEnd = (\d+)\n" - r"\s*end OpenModelica\.Scripting\.SourceInfo;,\n" - r"\s*message = \"(.*?)\",\n" # message - r"\s*kind = \.OpenModelica\.Scripting\.ErrorKind\.(.*?),\n" # kind - r"\s*level = \.OpenModelica\.Scripting\.ErrorLevel\.(.*?),\n" # level - r"\s*id = (\d+)", # id - flags=re.MULTILINE | re.DOTALL) - - # extract all ErrorMessage records - log_entries = self._re_log_entries.findall(string=error_raw) - for log_entry in reversed(log_entries): - log_raw = self._re_log_raw.findall(string=log_entry) - if len(log_raw) != 1 or len(log_raw[0]) != 10: - logger.warning("Invalid ErrorMessage record returned by 'getMessagesStringInternal()':" - f" {repr(log_entry)}!") - continue - - log_filename = log_raw[0][0] - log_readonly = log_raw[0][1] - log_lstart = log_raw[0][2] - log_cstart = log_raw[0][3] - log_lend = log_raw[0][4] - log_cend = log_raw[0][5] - log_message = log_raw[0][6].encode().decode('unicode_escape') - log_kind = log_raw[0][7] - log_level = log_raw[0][8] - log_id = log_raw[0][9] - - msg_short = (f"[OMC log for 'sendExpression(expr={expr}, parsed={parsed})']: " - f"[{log_kind}:{log_level}:{log_id}] {log_message}") - - # response according to the used log level - # see: https://build.openmodelica.org/Documentation/OpenModelica.Scripting.ErrorLevel.html - if log_level == 'error': - logger.error(msg_short) - has_error = True - elif log_level == 'warning': - logger.warning(msg_short) - elif log_level == 'notification': - logger.info(msg_short) - else: # internal - logger.debug(msg_short) - - # track all messages such that this list can be reported if an error occurred - msg_long = (f"[{log_kind}:{log_level}:{log_id}] " - f"[{log_filename}:{log_readonly}:{log_lstart}:{log_cstart}:{log_lend}:{log_cend}] " - f"{log_message}") - msg_long_list.append(msg_long) - if has_error: - msg_long_str = '\n'.join(f"{idx:02d}: {msg}" for idx, msg in enumerate(msg_long_list)) - raise OMCSessionException(f"OMC error occurred for 'sendExpression(expr={expr}, parsed={parsed}):\n" - f"{msg_long_str}") - - if not parsed: - return result - - try: - return om_parser_typed(result) - except pyparsing.ParseException as ex1: - logger.warning('OMTypedParser error: %s. Returning the basic parser result.', ex1.msg) - try: - return om_parser_basic(result) - except (TypeError, UnboundLocalError) as ex2: - raise OMCSessionException("Cannot parse OMC result") from ex2 - - def get_port(self) -> Optional[str]: - """ - Get the port to connect to the OMC session. - """ - if not isinstance(self._omc_port, str): - raise OMCSessionException(f"Invalid port to connect to OMC process: {self._omc_port}") - return self._omc_port - - def get_log(self) -> str: - """ - Get the log file content of the OMC session. - """ - if self._omc_loghandle is None: - raise OMCSessionException("Log file not available!") - - self._omc_loghandle.seek(0) - log = self._omc_loghandle.read() - - return log - - def _get_portfile_path(self) -> Optional[pathlib.Path]: - omc_log = self.get_log() - - portfile = self._re_portfile_path.findall(string=omc_log) - - portfile_path = None - if portfile: - portfile_path = pathlib.Path(portfile[-1][0]) - - return portfile_path - - -class OMCSessionPort(OMCSessionABC): - """ - OMCSession implementation which uses a port to connect to an already running OMC server. - """ - - def __init__( - self, - omc_port: str, - timeout: Optional[float] = None, - ) -> None: - super().__init__(timeout=timeout) - self._omc_port = omc_port - - -class OMCSessionLocal(OMCSessionABC): - """ - OMCSession implementation which runs the OMC server locally on the machine (Linux / Windows). - """ - - def __init__( - self, - timeout: Optional[float] = None, - omhome: Optional[str | os.PathLike] = None, - ) -> None: - - super().__init__(timeout=timeout) - - self.model_execution_local = True - - # where to find OpenModelica - self._omhome = self._omc_home_get(omhome=omhome) - # start up omc executable, which is waiting for the ZMQ connection - self._omc_process = self._omc_process_get() - # connect to the running omc instance using ZMQ - self._omc_port = self._omc_port_get() - - @staticmethod - def _omc_home_get(omhome: Optional[str | os.PathLike] = None) -> pathlib.Path: - # use the provided path - if omhome is not None: - return pathlib.Path(omhome) - - # check the environment variable - omhome = os.environ.get('OPENMODELICAHOME') - if omhome is not None: - return pathlib.Path(omhome) - - # Get the path to the OMC executable, if not installed this will be None - path_to_omc = shutil.which("omc") - if path_to_omc is not None: - return pathlib.Path(path_to_omc).parents[1] - - raise OMCSessionException("Cannot find OpenModelica executable, please install from openmodelica.org") - - def _omc_process_get(self) -> subprocess.Popen: - my_env = os.environ.copy() - my_env["PATH"] = (self._omhome / "bin").as_posix() + os.pathsep + my_env["PATH"] - - omc_command = [ - (self._omhome / "bin" / "omc").as_posix(), - "--locale=C", - "--interactive=zmq", - f"-z={self._random_string}"] - - omc_process = subprocess.Popen(omc_command, - stdout=self._omc_loghandle, - stderr=self._omc_loghandle, - env=my_env) - return omc_process - - def _omc_port_get(self) -> str: - port = None - - # See if the omc server is running - loop = self._timeout_loop(timestep=0.1) - while next(loop): - omc_portfile_path = self._get_portfile_path() - if omc_portfile_path is not None and omc_portfile_path.is_file(): - # Read the port file - with open(file=omc_portfile_path, mode='r', encoding="utf-8") as f_p: - port = f_p.readline() - break - if port is not None: - break - else: - logger.error(f"OMC server did not start. Log-file says:\n{self.get_log()}") - raise OMCSessionException(f"OMC Server did not start (timeout={self._timeout:.2f}s, " - f"logfile={repr(self._omc_logfile)}).") - - logger.info(f"Local OMC Server is up and running at ZMQ port {port} " - f"pid={self._omc_process.pid if isinstance(self._omc_process, subprocess.Popen) else '?'}") - - return port - - class OMCSessionZMQ(OMSessionABC): """ This class is a compatibility layer for the new schema using OMCSession* classes. @@ -1541,9 +250,11 @@ def __init__( if omc_process is None: omc_process = OMCSessionLocal(omhome=omhome, timeout=timeout) elif not isinstance(omc_process, OMCSessionABC): - raise OMCSessionException("Invalid definition of the OMC process!") + raise OMSessionException("Invalid definition of the OMC process!") self.omc_process = omc_process + super().__init__(timeout=timeout) + def __del__(self): if hasattr(self, 'omc_process'): del self.omc_process @@ -1576,7 +287,7 @@ def sendExpression(self, command: str, parsed: bool = True) -> Any: Send an expression to the OMC server and return the result. The complete error handling of the OMC result is done within this method using '"getMessagesStringInternal()'. - Caller should only check for OMCSessionException. + Caller should only check for OMSessionException. """ return self.omc_process.sendExpression(expr=command, parsed=parsed) @@ -1590,572 +301,6 @@ def set_workdir(self, workdir: OMPathABC) -> None: return self.omc_process.set_workdir(workdir=workdir) -class OMCSessionDockerABC(OMCSessionABC, metaclass=abc.ABCMeta): - """ - Base class for OMCSession implementations which run the OMC server in a Docker container. - """ - - def __init__( - self, - timeout: Optional[float] = None, - docker: Optional[str] = None, - dockerContainer: Optional[str] = None, - dockerExtraArgs: Optional[list] = None, - dockerOpenModelicaPath: str | os.PathLike = "omc", - dockerNetwork: Optional[str] = None, - port: Optional[int] = None, - ) -> None: - super().__init__(timeout=timeout) - - if dockerExtraArgs is None: - dockerExtraArgs = [] - - self._docker_extra_args = dockerExtraArgs - self._docker_open_modelica_path = pathlib.PurePosixPath(dockerOpenModelicaPath) - self._docker_network = dockerNetwork - self._docker_container_id: str - self._docker_process: Optional[DockerPopen] - - # start up omc executable in docker container waiting for the ZMQ connection - self._omc_process, self._docker_process, self._docker_container_id = self._docker_omc_start( - docker_image=docker, - docker_cid=dockerContainer, - omc_port=port, - ) - # connect to the running omc instance using ZMQ - self._omc_port = self._omc_port_get(docker_cid=self._docker_container_id) - if port is not None and not self._omc_port.endswith(f":{port}"): - raise OMCSessionException(f"Port mismatch: {self._omc_port} is not using the defined port {port}!") - - self._cmd_prefix = self.model_execution_prefix() - - def _docker_process_get(self, docker_cid: str) -> Optional[DockerPopen]: - if sys.platform == 'win32': - raise NotImplementedError("Docker not supported on win32!") - - loop = self._timeout_loop(timestep=0.2) - while next(loop): - docker_top = subprocess.check_output(["docker", "top", docker_cid]).decode().strip() - docker_process = None - for line in docker_top.split("\n"): - columns = line.split() - if self._random_string in line: - try: - docker_process = DockerPopen(int(columns[1])) - except psutil.NoSuchProcess as ex: - raise OMCSessionException(f"Could not find PID {docker_top} - " - "is this a docker instance spawned without --pid=host?") from ex - if docker_process is not None: - break - else: - logger.error(f"Docker did not start. Log-file says:\n{self.get_log()}") - raise OMCSessionException(f"Docker based OMC Server did not start (timeout={self._timeout:.2f}s).") - - return docker_process - - @abc.abstractmethod - def _docker_omc_start( - self, - docker_image: Optional[str] = None, - docker_cid: Optional[str] = None, - omc_port: Optional[int] = None, - ) -> Tuple[subprocess.Popen, DockerPopen, str]: - pass - - @staticmethod - def _getuid() -> int: - """ - The uid to give to docker. - On Windows, volumes are mapped with all files are chmod ugo+rwx, - so uid does not matter as long as it is not the root user. - """ - # mypy complained about os.getuid() not being available on - # Windows, hence the type: ignore comment. - return 1000 if sys.platform == 'win32' else os.getuid() # type: ignore - - def _omc_port_get( - self, - docker_cid: str, - ) -> str: - port = None - - if not isinstance(docker_cid, str): - raise OMCSessionException(f"Invalid docker container ID: {docker_cid}") - - # See if the omc server is running - loop = self._timeout_loop(timestep=0.1) - while next(loop): - omc_portfile_path = self._get_portfile_path() - if omc_portfile_path is not None: - try: - output = subprocess.check_output(args=["docker", - "exec", docker_cid, - "cat", omc_portfile_path.as_posix()], - stderr=subprocess.DEVNULL) - port = output.decode().strip() - except subprocess.CalledProcessError: - pass - if port is not None: - break - else: - logger.error(f"Docker did not start. Log-file says:\n{self.get_log()}") - raise OMCSessionException(f"Docker based OMC Server did not start (timeout={self._timeout:.2f}s, " - f"logfile={repr(self._omc_logfile)}).") - - logger.info(f"Docker based OMC Server is up and running at port {port}") - - return port - - def get_server_address(self) -> Optional[str]: - """ - Get the server address of the OMC server running in a Docker container. - """ - if self._docker_network == "separate" and isinstance(self._docker_container_id, str): - output = subprocess.check_output(["docker", "inspect", self._docker_container_id]).decode().strip() - address = json.loads(output)[0]["NetworkSettings"]["IPAddress"] - if not isinstance(address, str): - raise OMCSessionException(f"Invalid docker server address: {address}!") - return address - - return None - - def get_docker_container_id(self) -> str: - """ - Get the Docker container ID of the Docker container with the OMC server. - """ - if not isinstance(self._docker_container_id, str): - raise OMCSessionException(f"Invalid docker container ID: {self._docker_container_id}!") - - return self._docker_container_id - - def model_execution_prefix(self, cwd: Optional[OMPathABC] = None) -> list[str]: - """ - Helper function which returns a command prefix needed for docker and WSL. It defaults to an empty list. - """ - docker_cmd = [ - "docker", "exec", - "--user", str(self._getuid()), - ] - if isinstance(cwd, OMPathABC): - docker_cmd += ["--workdir", cwd.as_posix()] - docker_cmd += self._docker_extra_args - if isinstance(self._docker_container_id, str): - docker_cmd += [self._docker_container_id] - - return docker_cmd - - -class OMCSessionDocker(OMCSessionDockerABC): - """ - OMC process running in a Docker container. - """ - - def __init__( - self, - timeout: Optional[float] = None, - docker: Optional[str] = None, - dockerExtraArgs: Optional[list] = None, - dockerOpenModelicaPath: str | os.PathLike = "omc", - dockerNetwork: Optional[str] = None, - port: Optional[int] = None, - ) -> None: - - super().__init__( - timeout=timeout, - docker=docker, - dockerExtraArgs=dockerExtraArgs, - dockerOpenModelicaPath=dockerOpenModelicaPath, - dockerNetwork=dockerNetwork, - port=port, - ) - - def __del__(self) -> None: - - if hasattr(self, '_docker_process') and isinstance(self._docker_process, DockerPopen): - try: - self._docker_process.wait(timeout=2.0) - except subprocess.TimeoutExpired: - if self._docker_process: - logger.warning("OMC did not exit after being sent the quit() command; " - "killing the process with pid=%s", self._docker_process.pid) - self._docker_process.kill() - self._docker_process.wait(timeout=2.0) - finally: - self._docker_process = None - - super().__del__() - - def _docker_omc_cmd( - self, - docker_image: str, - docker_cid_file: pathlib.Path, - omc_path_and_args_list: list[str], - omc_port: Optional[int | str] = None, - ) -> list: - """ - Define the command that will be called by the subprocess module. - """ - - extra_flags = [] - - if sys.platform == "win32": - extra_flags = ["-d=zmqDangerousAcceptConnectionsFromAnywhere"] - if not self._omc_port: - raise OMCSessionException("Docker on Windows requires knowing which port to connect to - " - "please set the interactivePort argument") - - port: Optional[int] = None - if isinstance(omc_port, str): - port = int(omc_port) - elif isinstance(omc_port, int): - port = omc_port - - if sys.platform == "win32": - if not isinstance(port, int): - raise OMCSessionException("OMC on Windows needs the interactive port - " - f"missing or invalid value: {repr(omc_port)}!") - docker_network_str = ["-p", f"127.0.0.1:{port}:{port}"] - elif self._docker_network == "host" or self._docker_network is None: - docker_network_str = ["--network=host"] - elif self._docker_network == "separate": - docker_network_str = [] - extra_flags = ["-d=zmqDangerousAcceptConnectionsFromAnywhere"] - else: - raise OMCSessionException(f'dockerNetwork was set to {self._docker_network}, ' - 'but only \"host\" or \"separate\" is allowed') - - if isinstance(port, int): - extra_flags = extra_flags + [f"--interactivePort={port}"] - - omc_command = ([ - "docker", "run", - "--cidfile", docker_cid_file.as_posix(), - "--rm", - "--user", str(self._getuid()), - ] - + self._docker_extra_args - + docker_network_str - + [docker_image, self._docker_open_modelica_path.as_posix()] - + omc_path_and_args_list - + extra_flags) - - return omc_command - - def _docker_omc_start( - self, - docker_image: Optional[str] = None, - docker_cid: Optional[str] = None, - omc_port: Optional[int] = None, - ) -> Tuple[subprocess.Popen, DockerPopen, str]: - - if not isinstance(docker_image, str): - raise OMCSessionException("A docker image name must be provided!") - - my_env = os.environ.copy() - - docker_cid_file = self._temp_dir / (self._omc_filebase + ".docker.cid") - - omc_command = self._docker_omc_cmd( - docker_image=docker_image, - docker_cid_file=docker_cid_file, - omc_path_and_args_list=["--locale=C", - "--interactive=zmq", - f"-z={self._random_string}"], - omc_port=omc_port, - ) - - omc_process = subprocess.Popen(omc_command, - stdout=self._omc_loghandle, - stderr=self._omc_loghandle, - env=my_env) - - if not isinstance(docker_cid_file, pathlib.Path): - raise OMCSessionException(f"Invalid content for docker container ID file path: {docker_cid_file}") - - # the provided value for docker_cid is not used - docker_cid = None - loop = self._timeout_loop(timestep=0.1) - while next(loop): - try: - with open(file=docker_cid_file, mode="r", encoding="utf-8") as fh: - docker_cid = fh.read().strip() - except IOError: - pass - if docker_cid is not None: - break - - if docker_cid is None: - raise OMCSessionException(f"Docker did not start (timeout={self._timeout:.2f}s might be too short " - "especially if you did not docker pull the image before this command). " - f"Log-file says:\n{self.get_log()}") - - docker_process = self._docker_process_get(docker_cid=docker_cid) - if docker_process is None: - logger.error(f"Docker did not start. Log-file says:\n{self.get_log()}") - raise OMCSessionException(f"Docker top did not contain omc process {self._random_string}.") - - return omc_process, docker_process, docker_cid - - -class OMCSessionDockerContainer(OMCSessionDockerABC): - """ - OMC process running in a Docker container (by container ID). - """ - - def __init__( - self, - timeout: Optional[float] = None, - dockerContainer: Optional[str] = None, - dockerExtraArgs: Optional[list] = None, - dockerOpenModelicaPath: str | os.PathLike = "omc", - dockerNetwork: Optional[str] = None, - port: Optional[int] = None, - ) -> None: - - super().__init__( - timeout=timeout, - dockerContainer=dockerContainer, - dockerExtraArgs=dockerExtraArgs, - dockerOpenModelicaPath=dockerOpenModelicaPath, - dockerNetwork=dockerNetwork, - port=port, - ) - - def __del__(self) -> None: - - super().__del__() - - # docker container ID was provided - do NOT kill the docker process! - self._docker_process = None - - def _docker_omc_cmd( - self, - docker_cid: str, - omc_path_and_args_list: list[str], - omc_port: Optional[int] = None, - ) -> list: - """ - Define the command that will be called by the subprocess module. - """ - extra_flags: list[str] = [] - - if sys.platform == "win32": - extra_flags = ["-d=zmqDangerousAcceptConnectionsFromAnywhere"] - if not isinstance(omc_port, int): - raise OMCSessionException("Docker on Windows requires knowing which port to connect to - " - "Please set the interactivePort argument. Furthermore, the container needs " - "to have already manually exposed this port when it was started " - "(-p 127.0.0.1:n:n) or you get an error later.") - - if isinstance(omc_port, int): - extra_flags = extra_flags + [f"--interactivePort={omc_port}"] - - omc_command = ([ - "docker", "exec", - "--user", str(self._getuid()), - ] - + self._docker_extra_args - + [docker_cid, self._docker_open_modelica_path.as_posix()] - + omc_path_and_args_list - + extra_flags) - - return omc_command - - def _docker_omc_start( - self, - docker_image: Optional[str] = None, - docker_cid: Optional[str] = None, - omc_port: Optional[int] = None, - ) -> Tuple[subprocess.Popen, DockerPopen, str]: - - if not isinstance(docker_cid, str): - raise OMCSessionException("A docker container ID must be provided!") - - my_env = os.environ.copy() - - omc_command = self._docker_omc_cmd( - docker_cid=docker_cid, - omc_path_and_args_list=["--locale=C", - "--interactive=zmq", - f"-z={self._random_string}"], - omc_port=omc_port, - ) - - omc_process = subprocess.Popen(omc_command, - stdout=self._omc_loghandle, - stderr=self._omc_loghandle, - env=my_env) - - docker_process = None - if isinstance(docker_cid, str): - docker_process = self._docker_process_get(docker_cid=docker_cid) - - if docker_process is None: - raise OMCSessionException(f"Docker top did not contain omc process {self._random_string} " - f"/ {docker_cid}. Log-file says:\n{self.get_log()}") - - return omc_process, docker_process, docker_cid - - -class OMCSessionWSL(OMCSessionABC): - """ - OMC process running in Windows Subsystem for Linux (WSL). - """ - - def __init__( - self, - timeout: Optional[float] = None, - wsl_omc: str = 'omc', - wsl_distribution: Optional[str] = None, - wsl_user: Optional[str] = None, - ) -> None: - - super().__init__(timeout=timeout) - - # where to find OpenModelica - self._wsl_omc = wsl_omc - # store WSL distribution and user - self._wsl_distribution = wsl_distribution - self._wsl_user = wsl_user - # start up omc executable, which is waiting for the ZMQ connection - self._omc_process = self._omc_process_get() - # connect to the running omc instance using ZMQ - self._omc_port = self._omc_port_get() - - self._cmd_prefix = self.model_execution_prefix() - - def model_execution_prefix(self, cwd: Optional[OMPathABC] = None) -> list[str]: - """ - Helper function which returns a command prefix needed for docker and WSL. It defaults to an empty list. - """ - # get wsl base command - wsl_cmd = ['wsl'] - if isinstance(self._wsl_distribution, str): - wsl_cmd += ['--distribution', self._wsl_distribution] - if isinstance(self._wsl_user, str): - wsl_cmd += ['--user', self._wsl_user] - if isinstance(cwd, OMPathABC): - wsl_cmd += ['--cd', cwd.as_posix()] - wsl_cmd += ['--'] - - return wsl_cmd - - def _omc_process_get(self) -> subprocess.Popen: - my_env = os.environ.copy() - - omc_command = self.model_execution_prefix() + [ - self._wsl_omc, - "--locale=C", - "--interactive=zmq", - f"-z={self._random_string}", - ] - - omc_process = subprocess.Popen(omc_command, - stdout=self._omc_loghandle, - stderr=self._omc_loghandle, - env=my_env) - return omc_process - - def _omc_port_get(self) -> str: - port = None - - # See if the omc server is running - loop = self._timeout_loop(timestep=0.1) - while next(loop): - try: - omc_portfile_path = self._get_portfile_path() - if omc_portfile_path is not None: - output = subprocess.check_output( - args=self.model_execution_prefix() + ["cat", omc_portfile_path.as_posix()], - stderr=subprocess.DEVNULL, - ) - port = output.decode().strip() - except subprocess.CalledProcessError: - pass - if port is not None: - break - else: - logger.error(f"WSL based OMC server did not start. Log-file says:\n{self.get_log()}") - raise OMCSessionException(f"WSL based OMC Server did not start (timeout={self._timeout:2f}s, " - f"logfile={repr(self._omc_logfile)}).") - - logger.info(f"WSL based OMC Server is up and running at ZMQ port {port} " - f"pid={self._omc_process.pid if isinstance(self._omc_process, subprocess.Popen) else '?'}") - - return port - - -class OMSessionRunner(OMSessionABC): - """ - Implementation based on OMSessionABC without any use of an OMC server. - """ - - def __init__( - self, - timeout: Optional[float] = None, - version: str = "1.27.0", - ompath_runner: Type[OMPathRunnerABC] = OMPathRunnerLocal, - cmd_prefix: Optional[list[str]] = None, - model_execution_local: bool = True, - ) -> None: - super().__init__(timeout=timeout) - self._version = version - - if not issubclass(ompath_runner, OMPathRunnerABC): - raise OMCSessionException(f"Invalid OMPathRunner class: {type(ompath_runner)}!") - self._ompath_runner = ompath_runner - - self.model_execution_local = model_execution_local - if cmd_prefix is not None: - self._cmd_prefix = cmd_prefix - - # TODO: some checking?! - # if ompath_runner == Type[OMPathRunnerBash]: - - def __post_init__(self) -> None: - """ - No connection to an OMC server is created by this class! - """ - - def model_execution_prefix(self, cwd: Optional[OMPathABC] = None) -> list[str]: - """ - Helper function which returns a command prefix. - """ - return self.get_cmd_prefix() - - def get_version(self) -> str: - """ - We can not provide an OM version as we are not link to an OMC server. Thus, the provided version string is used - directly. - """ - return self._version - - def set_workdir(self, workdir: OMPathABC) -> None: - """ - Set the workdir for this session. For OMSessionRunner this is a nop. The workdir must be defined within the - definition of cmd_prefix. - """ - - def omcpath(self, *path) -> OMPathABC: - """ - Create an OMCPath object based on the given path segments and the current OMCSession* class. - """ - return self._ompath_runner(*path, session=self) - - def omcpath_tempdir(self, tempdir_base: Optional[OMPathABC] = None) -> OMPathABC: - """ - Get a temporary directory without using OMC. - """ - if tempdir_base is None: - tempdir_str = tempfile.gettempdir() - tempdir_base = self.omcpath(tempdir_str) - - return self._tempdir(tempdir_base=tempdir_base) - - def sendExpression(self, expr: str, parsed: bool = True) -> Any: - raise OMCSessionException(f"{self.__class__.__name__} does not uses an OMC server!") - - DummyPopen = DockerPopen OMCProcessLocal = OMCSessionLocal OMCProcessPort = OMCSessionPort diff --git a/OMPython/__init__.py b/OMPython/__init__.py index c12f8524..f541df25 100644 --- a/OMPython/__init__.py +++ b/OMPython/__init__.py @@ -11,11 +11,35 @@ """ +from OMPython.model_execution import ( + ModelExecutionCmd, + ModelExecutionData, + ModelExecutionException, +) +from OMPython.om_session_abc import ( + OMPathABC, + OMSessionABC, + OMSessionException, +) +from OMPython.om_session_omc import ( + OMCPath, + OMCSessionABC, + OMCSessionDocker, + OMCSessionDockerContainer, + OMCSessionLocal, + OMCSessionPort, + OMCSessionWSL, +) +from OMPython.om_session_runner import ( + OMPathRunnerBash, + OMPathRunnerLocal, + OMSessionRunner, +) + from OMPython.ModelicaSystem import ( LinearizationResult, ModelicaSystem, ModelicaSystemOMC, - ModelExecutionCmd, ModelicaSystemDoE, ModelicaDoEOMC, ModelicaSystemError, @@ -27,28 +51,9 @@ ModelicaSystemCmd, ) from OMPython.OMCSession import ( - OMPathABC, - OMCPath, - - OMSessionRunner, - - OMCSessionABC, - - ModelExecutionData, - ModelExecutionException, - OMCSessionCmd, - OMCSessionDocker, - OMCSessionDockerContainer, - OMCSessionException, - OMCSessionLocal, - OMCSessionPort, - - OMPathRunnerBash, - OMPathRunnerLocal, - - OMCSessionWSL, OMCSessionZMQ, + OMCSessionException, OMCProcessLocal, OMCProcessPort, @@ -60,13 +65,29 @@ __all__ = [ 'LinearizationResult', + 'ModelExecutionCmd', 'ModelExecutionData', 'ModelExecutionException', + 'OMPathABC', + 'OMSessionABC', + 'OMSessionException', + + 'OMCPath', + 'OMCSessionABC', + 'OMCSessionDocker', + 'OMCSessionDockerContainer', + 'OMCSessionLocal', + 'OMCSessionPort', + 'OMCSessionWSL', + + 'OMPathRunnerBash', + 'OMPathRunnerLocal', + 'OMSessionRunner', + 'ModelicaSystem', 'ModelicaSystemOMC', 'ModelicaSystemCmd', - 'ModelExecutionCmd', 'ModelicaSystemDoE', 'ModelicaDoEOMC', 'ModelicaSystemError', @@ -74,26 +95,13 @@ 'ModelicaSystemRunner', 'ModelicaDoERunner', - 'OMPathABC', - 'OMCPath', - - 'OMSessionRunner', - - 'OMCSessionABC', - 'doe_get_solutions', + 'OMCSessionABC', 'OMCSessionCmd', - 'OMCSessionDocker', - 'OMCSessionDockerContainer', - 'OMCSessionException', - 'OMCSessionPort', - 'OMCSessionLocal', - 'OMPathRunnerBash', - 'OMPathRunnerLocal', + 'OMCSessionException', - 'OMCSessionWSL', 'OMCSessionZMQ', 'OMCProcessLocal', diff --git a/OMPython/model_execution.py b/OMPython/model_execution.py new file mode 100644 index 00000000..9f8b26a2 --- /dev/null +++ b/OMPython/model_execution.py @@ -0,0 +1,350 @@ +# -*- coding: utf-8 -*- +""" +Definition of needed tools to execute a compiled (binary) OpenModelica model. +""" + +import ast +import dataclasses +import logging +import numbers +import os +import pathlib +import re +import subprocess +from typing import Any, Optional +import warnings + +# define logger using the current module name as ID +logger = logging.getLogger(__name__) + + +class ModelExecutionException(Exception): + """ + Exception which is raised by ModelException* classes. + """ + + +@dataclasses.dataclass +class ModelExecutionData: + """ + Data class to store the command line data for running a model executable in the OMC environment. + + All data should be defined for the environment, where OMC is running (local, docker or WSL) + + To use this as a definition of an OMC simulation run, it has to be processed within + OMCProcess*.self_update(). This defines the attribute cmd_model_executable. + """ + # cmd_path is the expected working directory + cmd_path: str + cmd_model_name: str + # command prefix data (as list of strings); needed for docker or WSL + cmd_prefix: list[str] + # cmd_model_executable is build out of cmd_path and cmd_model_name; this is mainly needed on Windows (add *.exe) + cmd_model_executable: str + # command line arguments for the model executable + cmd_args: list[str] + # result file with the simulation output + cmd_result_file: str + # command timeout + cmd_timeout: float + + # additional library search path; this is mainly needed if OMCProcessLocal is run on Windows + cmd_library_path: Optional[str] = None + # working directory to be used on the *local* system + cmd_cwd_local: Optional[str] = None + + def get_cmd(self) -> list[str]: + """ + Get the command line to run the model executable in the environment defined by the OMCProcess definition. + """ + + cmdl = self.cmd_prefix + cmdl += [self.cmd_model_executable] + cmdl += self.cmd_args + + return cmdl + + def run(self) -> int: + """ + Run the model execution defined in this class. + """ + + my_env = os.environ.copy() + if isinstance(self.cmd_library_path, str): + my_env["PATH"] = self.cmd_library_path + os.pathsep + my_env["PATH"] + + cmdl = self.get_cmd() + + logger.debug("Run OM command %s in %s (timeout=%2fs)", repr(cmdl), self.cmd_path, self.cmd_timeout) + try: + cmdres = subprocess.run( + cmdl, + capture_output=True, + text=True, + env=my_env, + cwd=self.cmd_cwd_local, + timeout=self.cmd_timeout, + check=True, + ) + stdout = cmdres.stdout.strip() + stderr = cmdres.stderr.strip() + returncode = cmdres.returncode + + logger.debug("OM output for command %s:\n%s", repr(cmdl), stdout) + + if stderr: + raise ModelExecutionException(f"Error running model executable {repr(cmdl)}: {stderr}") + except subprocess.TimeoutExpired as ex: + raise ModelExecutionException("OMPython timeout running model executable " + f"(timeout={self.cmd_timeout:.2f}s){repr(cmdl)}: {ex}") from ex + except subprocess.CalledProcessError as ex: + raise ModelExecutionException(f"Error running model executable {repr(cmdl)}: {ex}") from ex + + return returncode + + +class ModelExecutionCmd: + """ + All information about a compiled model executable. This should include data about all structured parameters, i.e. + parameters which need a recompilation of the model. All non-structured parameters can be easily changed without + the need for recompilation. + """ + + def __init__( + self, + runpath: os.PathLike, + cmd_prefix: list[str], + cmd_local: bool = False, + cmd_windows: bool = False, + timeout: float = 10.0, + model_name: Optional[str] = None, + ) -> None: + if model_name is None: + raise ModelExecutionException("Missing model name!") + + self._cmd_local = cmd_local + self._cmd_windows = cmd_windows + self._cmd_prefix = cmd_prefix + self._runpath = pathlib.PurePosixPath(runpath) + self._model_name = model_name + self._timeout = timeout + + # dictionaries of command line arguments for the model executable + self._args: dict[str, str | None] = {} + # 'override' argument needs special handling, as it is a dict on its own saved as dict elements following the + # structure: 'key' => 'key=value' + self._arg_override: dict[str, str] = {} + + def arg_set( + self, + key: str, + val: Optional[str | dict[str, Any] | numbers.Number] = None, + ) -> None: + """ + Set one argument for the executable model. + + Args: + key: identifier / argument name to be used for the call of the model executable. + val: value for the given key; None for no value and for key == 'override' a dictionary can be used which + indicates variables to override + """ + + def override2str( + orkey: str, + orval: str | bool | numbers.Number, + ) -> str: + """ + Convert a value for 'override' to a string taking into account differences between Modelica and Python. + """ + # check oval for any string representations of numbers (or bool) and convert these to Python representations + if isinstance(orval, str): + try: + val_evaluated = ast.literal_eval(orval) + if isinstance(val_evaluated, (numbers.Number, bool)): + orval = val_evaluated + except (ValueError, SyntaxError): + pass + + if isinstance(orval, str): + val_str = orval.strip() + elif isinstance(orval, bool): + val_str = 'true' if orval else 'false' + elif isinstance(orval, numbers.Number): + val_str = str(orval) + else: + raise ModelExecutionException(f"Invalid value for override key {orkey}: {type(orval)}") + + return f"{orkey}={val_str}" + + if not isinstance(key, str): + raise ModelExecutionException(f"Invalid argument key: {repr(key)} (type: {type(key)})") + key = key.strip() + + if isinstance(val, dict): + if key != 'override': + raise ModelExecutionException("Dictionary input only possible for key 'override'!") + + for okey, oval in val.items(): + if not isinstance(okey, str): + raise ModelExecutionException("Invalid key for argument 'override': " + f"{repr(okey)} (type: {type(okey)})") + + if not isinstance(oval, (str, bool, numbers.Number, type(None))): + raise ModelExecutionException(f"Invalid input for 'override'.{repr(okey)}: " + f"{repr(oval)} (type: {type(oval)})") + + if okey in self._arg_override: + if oval is None: + logger.info(f"Remove model executable override argument: {repr(self._arg_override[okey])}") + del self._arg_override[okey] + continue + + logger.info(f"Update model executable override argument: {repr(okey)} = {repr(oval)} " + f"(was: {repr(self._arg_override[okey])})") + + if oval is not None: + self._arg_override[okey] = override2str(orkey=okey, orval=oval) + + argval = ','.join(sorted(self._arg_override.values())) + elif val is None: + argval = None + elif isinstance(val, str): + argval = val.strip() + elif isinstance(val, numbers.Number): + argval = str(val) + else: + raise ModelExecutionException(f"Invalid argument value for {repr(key)}: {repr(val)} (type: {type(val)})") + + if key in self._args: + logger.warning(f"Override model executable argument: {repr(key)} = {repr(argval)} " + f"(was: {repr(self._args[key])})") + self._args[key] = argval + + def arg_get(self, key: str) -> Optional[str | dict[str, str | bool | numbers.Number]]: + """ + Return the value for the given key + """ + if key in self._args: + return self._args[key] + + return None + + def args_set( + self, + args: dict[str, Optional[str | dict[str, Any] | numbers.Number]], + ) -> None: + """ + Define arguments for the model executable. + """ + for arg in args: + self.arg_set(key=arg, val=args[arg]) + + def get_cmd_args(self) -> list[str]: + """ + Get a list with the command arguments for the model executable. + """ + + cmdl = [] + for key in sorted(self._args): + if self._args[key] is None: + cmdl.append(f"-{key}") + else: + cmdl.append(f"-{key}={self._args[key]}") + + return cmdl + + def definition(self) -> ModelExecutionData: + """ + Define all needed data to run the model executable. The data is stored in an OMCSessionRunData object. + """ + # ensure that a result filename is provided + result_file = self.arg_get('r') + if not isinstance(result_file, str): + result_file = (self._runpath / f"{self._model_name}.mat").as_posix() + + # as this is the local implementation, pathlib.Path can be used + cmd_path = self._runpath + + cmd_library_path = None + if self._cmd_local and self._cmd_windows: + cmd_library_path = "" + + # set the process environment from the generated .bat file in windows which should have all the dependencies + # for this pathlib.PurePosixPath() must be converted to a pathlib.Path() object, i.e. WindowsPath + path_bat = pathlib.Path(cmd_path) / f"{self._model_name}.bat" + if not path_bat.is_file(): + raise ModelExecutionException("Batch file (*.bat) does not exist " + str(path_bat)) + + content = path_bat.read_text(encoding='utf-8') + for line in content.splitlines(): + match = re.match(pattern=r"^SET PATH=([^%]*)", string=line, flags=re.IGNORECASE) + if match: + cmd_library_path = match.group(1).strip(';') # Remove any trailing semicolons + my_env = os.environ.copy() + my_env["PATH"] = cmd_library_path + os.pathsep + my_env["PATH"] + + cmd_model_executable = cmd_path / f"{self._model_name}.exe" + else: + # for Linux the paths to the needed libraries should be included in the executable (using rpath) + cmd_model_executable = cmd_path / self._model_name + + # define local(!) working directory + cmd_cwd_local = None + if self._cmd_local: + cmd_cwd_local = cmd_path.as_posix() + + omc_run_data = ModelExecutionData( + cmd_path=cmd_path.as_posix(), + cmd_model_name=self._model_name, + cmd_args=self.get_cmd_args(), + cmd_result_file=result_file, + cmd_prefix=self._cmd_prefix, + cmd_library_path=cmd_library_path, + cmd_model_executable=cmd_model_executable.as_posix(), + cmd_cwd_local=cmd_cwd_local, + cmd_timeout=self._timeout, + ) + + return omc_run_data + + @staticmethod + def parse_simflags(simflags: str) -> dict[str, Optional[str | dict[str, Any] | numbers.Number]]: + """ + Parse a simflag definition; this is deprecated! + + The return data can be used as input for self.args_set(). + """ + warnings.warn( + message="The argument 'simflags' is depreciated and will be removed in future versions; " + "please use 'simargs' instead", + category=DeprecationWarning, + stacklevel=2, + ) + + simargs: dict[str, Optional[str | dict[str, Any] | numbers.Number]] = {} + + args = [s for s in simflags.split(' ') if s] + for arg in args: + if arg[0] != '-': + raise ModelExecutionException(f"Invalid simulation flag: {arg}") + arg = arg[1:] + parts = arg.split('=') + if len(parts) == 1: + simargs[parts[0]] = None + elif parts[0] == 'override': + override = '='.join(parts[1:]) + + override_dict = {} + for item in override.split(','): + kv = item.split('=') + if not 0 < len(kv) < 3: + raise ModelExecutionException(f"Invalid value for '-override': {override}") + if kv[0]: + try: + override_dict[kv[0]] = kv[1] + except (KeyError, IndexError) as ex: + raise ModelExecutionException(f"Invalid value for '-override': {override}") from ex + + simargs[parts[0]] = override_dict + + return simargs diff --git a/OMPython/om_session_abc.py b/OMPython/om_session_abc.py new file mode 100644 index 00000000..53efcb58 --- /dev/null +++ b/OMPython/om_session_abc.py @@ -0,0 +1,321 @@ +# -*- coding: utf-8 -*- +""" +Definition of a generic OM session. +""" + +from __future__ import annotations + +import abc +import logging +import os +import pathlib +import platform +import sys +from typing import Any, Optional +import uuid + +# define logger using the current module name as ID +logger = logging.getLogger(__name__) + + +class OMSessionException(Exception): + """ + Exception which is raised by any OMC* class. + """ + + +# due to the compatibility layer to Python < 3.12, the OM(C)Path classes must be hidden behind the following if +# conditions. This is also the reason for OMPathABC, a simple base class to be used in ModelicaSystem* classes. +# Reason: before Python 3.12, pathlib.PurePosixPath can not be derived from; therefore, OMPathABC is not possible +if sys.version_info < (3, 12): + class _OMPathCompatibility(pathlib.Path): + """ + Compatibility class for OMPathABC in Python < 3.12. This allows to run all code which uses OMPathABC (mainly + ModelicaSystem) on these Python versions. There are remaining limitation as only local execution is possible. + """ + + # modified copy of pathlib.Path.__new__() definition + def __new__(cls, *args, **kwargs): + logger.warning("Python < 3.12 - using a version of class OMCPath " + "based on pathlib.Path for local usage only.") + + if cls is _OMPathCompatibility: + cls = _OMPathCompatibilityWindows if os.name == 'nt' else _OMPathCompatibilityPosix + self = cls._from_parts(args) + if not self._flavour.is_supported: + raise NotImplementedError(f"cannot instantiate {cls.__name__} on your system") + return self + + def size(self) -> int: + """ + Needed compatibility function to have the same interface as OMCPathReal + """ + return self.stat().st_size + + class _OMPathCompatibilityPosix(pathlib.PosixPath, _OMPathCompatibility): + """ + Compatibility class for OMCPath on Posix systems (Python < 3.12) + """ + + class _OMPathCompatibilityWindows(pathlib.WindowsPath, _OMPathCompatibility): + """ + Compatibility class for OMCPath on Windows systems (Python < 3.12) + """ + + OMPathABC = _OMPathCompatibility + +else: + class OMPathABC(pathlib.PurePosixPath, metaclass=abc.ABCMeta): + """ + Implementation of a basic (PurePosix)Path object to be used within OMPython. The derived classes can use OMC as + backend and - thus - work on different configurations like docker or WSL. The connection to OMC is provided via + an instances of classes derived from BaseSession. + + PurePosixPath is selected as it covers all but Windows systems (Linux, docker, WSL). However, the code is + written such that possible Windows system are taken into account. Nevertheless, the overall functionality is + limited compared to standard pathlib.Path objects. + """ + + def __init__(self, *path, session: OMSessionABC) -> None: + super().__init__(*path) + self._session = session + + def get_session(self) -> OMSessionABC: + """ + Get session definition used for this instance of OMPath. + """ + return self._session + + def with_segments(self, *pathsegments) -> OMPathABC: + """ + Create a new OMCPath object with the given path segments. + + The original definition of Path is overridden to ensure the session data is set. + """ + return type(self)(*pathsegments, session=self._session) + + @abc.abstractmethod + def is_file(self) -> bool: + """ + Check if the path is a regular file. + """ + + @abc.abstractmethod + def is_dir(self) -> bool: + """ + Check if the path is a directory. + """ + + @abc.abstractmethod + def is_absolute(self) -> bool: + """ + Check if the path is an absolute path. + """ + + @abc.abstractmethod + def read_text(self) -> str: + """ + Read the content of the file represented by this path as text. + """ + + @abc.abstractmethod + def write_text(self, data: str) -> int: + """ + Write text data to the file represented by this path. + """ + + @abc.abstractmethod + def mkdir(self, parents: bool = True, exist_ok: bool = False) -> None: + """ + Create a directory at the path represented by this class. + + The argument parents with default value True exists to ensure compatibility with the fallback solution for + Python < 3.12. In this case, pathlib.Path is used directly and this option ensures, that missing parent + directories are also created. + """ + + @abc.abstractmethod + def cwd(self) -> OMPathABC: + """ + Returns the current working directory as an OMPathABC object. + """ + + @abc.abstractmethod + def unlink(self, missing_ok: bool = False) -> None: + """ + Unlink (delete) the file or directory represented by this path. + """ + + @abc.abstractmethod + def resolve(self, strict: bool = False) -> OMPathABC: + """ + Resolve the path to an absolute path. + """ + + def absolute(self) -> OMPathABC: + """ + Resolve the path to an absolute path. Just a wrapper for resolve(). + """ + return self.resolve() + + def exists(self) -> bool: + """ + Semi replacement for pathlib.Path.exists(). + """ + return self.is_file() or self.is_dir() + + @abc.abstractmethod + def size(self) -> int: + """ + Get the size of the file in bytes - this is an extra function and the best we can do using OMC. + """ + + +class PostInitCaller(type): + """ + Metaclass definition to define a new function __post_init__() which is called after all __init__() functions where + executed. The workflow would read as follows: + + On creating a class with the following inheritance Class2 => Class1 => Class0, where each class calls the __init__() + functions of its parent, i.e. super().__init__(), as well as __post_init__() the call schema would be: + + myclass = Class2() + Class2.__init__() + Class1.__init__() + Class0.__init__() + Class2.__post_init__() <= this is done due to the metaclass + Class1.__post_init__() + Class0.__post_init__() + + References: + * https://stackoverflow.com/questions/100003/what-are-metaclasses-in-python + * https://stackoverflow.com/questions/795190/how-to-perform-common-post-initialization-tasks-in-inherited-classes + """ + + def __call__(cls, *args, **kwargs): + obj = type.__call__(cls, *args, **kwargs) + obj.__post_init__() + return obj + + +class OMSessionMeta(abc.ABCMeta, PostInitCaller): + """ + Helper class to get a combined metaclass of ABCMeta and PostInitCaller. + + References: + * https://stackoverflow.com/questions/11276037/resolving-metaclass-conflicts + """ + + +class OMSessionABC(metaclass=OMSessionMeta): + """ + This class implements the basic structure a OMPython session definition needs. It provides the structure for an + implementation using OMC as backend (via ZMQ) or a dummy implementation which just runs a model executable. + """ + + def __init__( + self, + timeout: Optional[float] = None, + **kwargs, + ) -> None: + """ + Initialisation for OMSessionBase + """ + + # some helper data + self.model_execution_windows = platform.system() == "Windows" + self.model_execution_local = False + + # store variables + self._timeout = 10.0 + self.set_timeout(timeout=timeout) + # command prefix (to be used for docker or WSL) + self._cmd_prefix: list[str] = [] + + def __post_init__(self) -> None: + """ + Post initialisation method. + """ + + def set_timeout(self, timeout: Optional[float] = None) -> float: + """ + Set the timeout to be used for OMC communication (OMCSession). + + The defined value is set and the current value is returned. If None is provided as argument, nothing is changed. + """ + retval = self._timeout + if timeout is not None: + if timeout <= 0.0: + raise OMSessionException(f"Invalid timeout value: {timeout}s!") + logger.info(f"Update timeout for {self.__class__.__name__}: {retval}s => {timeout}s") + self._timeout = timeout + return retval + + def get_cmd_prefix(self) -> list[str]: + """ + Get session definition used for this instance of OMPath. + """ + return self._cmd_prefix.copy() + + @staticmethod + def escape_str(value: str) -> str: + """ + Escape a string such that it can be used as string within OMC expressions, i.e. escape all double quotes. + """ + return value.replace("\\", "\\\\").replace('"', '\\"') + + @abc.abstractmethod + def model_execution_prefix(self, cwd: Optional[OMPathABC] = None) -> list[str]: + """ + Helper function which returns a command prefix. + """ + + @abc.abstractmethod + def get_version(self) -> str: + """ + Get the OM version. + """ + + @abc.abstractmethod + def set_workdir(self, workdir: OMPathABC) -> None: + """ + Set the workdir for this session. + """ + + @abc.abstractmethod + def omcpath(self, *path) -> OMPathABC: + """ + Create an OMPathABC object based on the given path segments and the current class. + """ + + @abc.abstractmethod + def omcpath_tempdir(self, tempdir_base: Optional[OMPathABC] = None) -> OMPathABC: + """ + Get a temporary directory based on the specific definition for this session. + """ + + @staticmethod + def _tempdir(tempdir_base: OMPathABC) -> OMPathABC: + names = [str(uuid.uuid4()) for _ in range(100)] + + tempdir: Optional[OMPathABC] = None + for name in names: + # create a unique temporary directory name + tempdir = tempdir_base / name + + if tempdir.exists(): + continue + + tempdir.mkdir(parents=True, exist_ok=False) + break + + if tempdir is None or not tempdir.is_dir(): + raise FileNotFoundError(f"Cannot create a temporary directory in {tempdir_base}!") + + return tempdir + + @abc.abstractmethod + def sendExpression(self, expr: str, parsed: bool = True) -> Any: + """ + Function needed to send expressions to the OMC server via ZMQ. + """ diff --git a/OMPython/om_session_omc.py b/OMPython/om_session_omc.py new file mode 100644 index 00000000..6626cd17 --- /dev/null +++ b/OMPython/om_session_omc.py @@ -0,0 +1,1169 @@ +# -*- coding: utf-8 -*- +""" +Definition of an OMC session using OMC server. +""" + +from __future__ import annotations + +import abc +import io +import json +import logging +import os +import pathlib +import platform +import re +import shutil +import signal +import subprocess +import sys +import tempfile +import time +from typing import Any, Optional, Tuple +import uuid +import warnings + +import psutil +import pyparsing +import zmq + +from OMPython.om_session_abc import ( + OMPathABC, + OMSessionABC, + OMSessionException, +) + +# TODO: replace this with the new parser +from OMPython.OMTypedParser import om_parser_typed +from OMPython.OMParser import om_parser_basic + +# define logger using the current module name as ID +logger = logging.getLogger(__name__) +# due to the compatibility layer to Python < 3.12, the OM(C)Path classes must be hidden behind the following if +# conditions. This is also the reason for OMPathABC, a simple base class to be used in ModelicaSystem* classes. +# Reason: before Python 3.12, pathlib.PurePosixPath can not be derived from; therefore, OMPathABC is not possible +if sys.version_info < (3, 12): + OMCPath = OMPathABC + +else: + class _OMCPath(OMPathABC): + """ + Implementation of a OMPathABC using OMC as backend. The connection to OMC is provided via an instances of an + OMCSession* classes. + """ + + def is_file(self) -> bool: + """ + Check if the path is a regular file. + """ + retval = self.get_session().sendExpression(expr=f'regularFileExists("{self.as_posix()}")') + if not isinstance(retval, bool): + raise OMSessionException(f"Invalid return value for is_file(): {retval} - expect bool") + return retval + + def is_dir(self) -> bool: + """ + Check if the path is a directory. + """ + retval = self.get_session().sendExpression(expr=f'directoryExists("{self.as_posix()}")') + if not isinstance(retval, bool): + raise OMSessionException(f"Invalid return value for is_dir(): {retval} - expect bool") + return retval + + def is_absolute(self) -> bool: + """ + Check if the path is an absolute path. Special handling to differentiate Windows and Posix definitions. + """ + if self._session.model_execution_windows and self._session.model_execution_local: + return pathlib.PureWindowsPath(self.as_posix()).is_absolute() + return pathlib.PurePosixPath(self.as_posix()).is_absolute() + + def read_text(self) -> str: + """ + Read the content of the file represented by this path as text. + """ + retval = self.get_session().sendExpression(expr=f'readFile("{self.as_posix()}")') + if not isinstance(retval, str): + raise OMSessionException(f"Invalid return value for read_text(): {retval} - expect str") + return retval + + def write_text(self, data: str) -> int: + """ + Write text data to the file represented by this path. + """ + if not isinstance(data, str): + raise TypeError(f"data must be str, not {data.__class__.__name__}") + + data_omc = self._session.escape_str(data) + self._session.sendExpression(expr=f'writeFile("{self.as_posix()}", "{data_omc}", false);') + + return len(data) + + def mkdir(self, parents: bool = True, exist_ok: bool = False) -> None: + """ + Create a directory at the path represented by this class. + + The argument parents with default value True exists to ensure compatibility with the fallback solution for + Python < 3.12. In this case, pathlib.Path is used directly and this option ensures, that missing parent + directories are also created. + """ + if self.is_dir() and not exist_ok: + raise FileExistsError(f"Directory {self.as_posix()} already exists!") + + if not self._session.sendExpression(expr=f'mkdir("{self.as_posix()}")'): + raise OMSessionException(f"Error on directory creation for {self.as_posix()}!") + + def cwd(self) -> OMPathABC: + """ + Returns the current working directory as an OMPathABC object. + """ + cwd_str = self._session.sendExpression(expr='cd()') + return type(self)(cwd_str, session=self._session) + + def unlink(self, missing_ok: bool = False) -> None: + """ + Unlink (delete) the file or directory represented by this path. + """ + res = self._session.sendExpression(expr=f'deleteFile("{self.as_posix()}")') + if not res and not missing_ok: + raise FileNotFoundError(f"Cannot delete file {self.as_posix()} - it does not exists!") + + def resolve(self, strict: bool = False) -> OMPathABC: + """ + Resolve the path to an absolute path. This is done based on available OMC functions. + """ + if strict and not (self.is_file() or self.is_dir()): + raise OMSessionException(f"Path {self.as_posix()} does not exist!") + + if self.is_file(): + pathstr_resolved = self._omc_resolve(self.parent.as_posix()) + omcpath_resolved = self._session.omcpath(pathstr_resolved) / self.name + elif self.is_dir(): + pathstr_resolved = self._omc_resolve(self.as_posix()) + omcpath_resolved = self._session.omcpath(pathstr_resolved) + else: + raise OMSessionException(f"Path {self.as_posix()} is neither a file nor a directory!") + + if not omcpath_resolved.is_file() and not omcpath_resolved.is_dir(): + raise OMSessionException(f"OMCPath resolve failed for {self.as_posix()} - path does not exist!") + + return omcpath_resolved + + def _omc_resolve(self, pathstr: str) -> str: + """ + Internal function to resolve the path of the OMCPath object using OMC functions *WITHOUT* changing the cwd + within OMC. + """ + expr = ('omcpath_cwd := cd(); ' + f'omcpath_check := cd("{pathstr}"); ' # check requested pathstring + 'cd(omcpath_cwd)') + + try: + retval = self.get_session().sendExpression(expr=expr, parsed=False) + if not isinstance(retval, str): + raise OMSessionException(f"Invalid return value for _omc_resolve(): {retval} - expect str") + result_parts = retval.split('\n') + pathstr_resolved = result_parts[1] + pathstr_resolved = pathstr_resolved[1:-1] # remove quotes + except OMSessionException as ex: + raise OMSessionException(f"OMCPath resolve failed for {pathstr}!") from ex + + return pathstr_resolved + + def size(self) -> int: + """ + Get the size of the file in bytes - this is an extra function and the best we can do using OMC. + """ + if not self.is_file(): + raise OMSessionException(f"Path {self.as_posix()} is not a file!") + + res = self._session.sendExpression(expr=f'stat("{self.as_posix()}")') + if res[0]: + return int(res[1]) + + raise OMSessionException(f"Error reading file size for path {self.as_posix()}!") + + OMCPath = _OMCPath + + +class OMCSessionABC(OMSessionABC, metaclass=abc.ABCMeta): + """ + Base class for an OMC session started via ZMQ. This class contains common functionality for all variants of an + OMC session definition. + + The main method is sendExpression() which is used to send commands to the OMC process. + + The following variants are defined: + + * OMCSessionLocal + + * OMCSessionPort + + * OMCSessionDocker + + * OMCSessionDockerContainer + + * OMCSessionWSL + """ + + def __init__( + self, + timeout: Optional[float] = None, + **kwargs, + ) -> None: + """ + Initialisation for OMCSession + """ + super().__init__(timeout=timeout) + + # some helper data + self.model_execution_windows = platform.system() == "Windows" + self.model_execution_local = False + + # generate a random string for this instance of OMC + self._random_string = uuid.uuid4().hex + # get a temporary directory + self._temp_dir = pathlib.Path(tempfile.gettempdir()) + + # omc process + self._omc_process: Optional[subprocess.Popen] = None + # omc ZMQ port to use + self._omc_port: Optional[str] = None + # omc port and log file + self._omc_filebase = f"openmodelica.{self._random_string}" + # ZMQ socket to communicate with OMC + self._omc_zmq: Optional[zmq.Socket[bytes]] = None + + # setup log file - this file must be closed in the destructor + self._omc_logfile = self._temp_dir / (self._omc_filebase + ".log") + self._omc_loghandle: Optional[io.TextIOWrapper] = None + try: + self._omc_loghandle = open(file=self._omc_logfile, mode="w+", encoding="utf-8") + except OSError as ex: + raise OMSessionException(f"Cannot open log file {self._omc_logfile}.") from ex + + # variables to store compiled re expressions use in self.sendExpression() + self._re_log_entries: Optional[re.Pattern[str]] = None + self._re_log_raw: Optional[re.Pattern[str]] = None + + self._re_portfile_path = re.compile(pattern=r'\nDumped server port in file: (.*?)($|\n)', + flags=re.MULTILINE | re.DOTALL) + + def __post_init__(self) -> None: + """ + Create the connection to the OMC server using ZeroMQ. + """ + port = self.get_port() + if not isinstance(port, str): + raise OMSessionException(f"Invalid content for port: {port}") + + # Create the ZeroMQ socket and connect to OMC server + context = zmq.Context.instance() + omc = context.socket(zmq.REQ) + omc.setsockopt(zmq.LINGER, 0) # Dismisses pending messages if closed + omc.setsockopt(zmq.IMMEDIATE, True) # Queue messages only to completed connections + omc.connect(port) + + self._omc_zmq = omc + + def __del__(self): + if isinstance(self._omc_zmq, zmq.Socket): + try: + self.sendExpression(expr="quit()") + except OMSessionException as exc: + logger.warning(f"Exception on sending 'quit()' to OMC: {exc}! Continue nevertheless ...") + finally: + self._omc_zmq = None + + if self._omc_loghandle is not None: + try: + self._omc_loghandle.close() + except (OSError, IOError): + pass + finally: + self._omc_loghandle = None + + if isinstance(self._omc_process, subprocess.Popen): + try: + self._omc_process.wait(timeout=2.0) + except subprocess.TimeoutExpired: + if self._omc_process: + logger.warning("OMC did not exit after being sent the 'quit()' command; " + "killing the process with pid=%s", self._omc_process.pid) + self._omc_process.kill() + self._omc_process.wait() + finally: + + self._omc_process = None + + def _timeout_loop( + self, + timeout: Optional[float] = None, + timestep: float = 0.1, + ): + """ + Helper (using yield) for while loops to check OMC startup / response. The loop is executed as long as True is + returned, i.e. the first False will stop the while loop. + """ + + if timeout is None: + timeout = self._timeout + if timeout <= 0: + raise OMSessionException(f"Invalid timeout: {timeout}") + + timer = 0.0 + yield True + while True: + timer += timestep + if timer > timeout: + break + time.sleep(timestep) + yield True + yield False + + @staticmethod + def escape_str(value: str) -> str: + """ + Escape a string such that it can be used as string within OMC expressions, i.e. escape all double quotes. + """ + return value.replace("\\", "\\\\").replace('"', '\\"') + + def get_version(self) -> str: + """ + Get the OM version. + """ + return self.sendExpression("getVersion()", parsed=True) + + def set_workdir(self, workdir: OMPathABC) -> None: + """ + Set the workdir for this session. + """ + exp = f'cd("{workdir.as_posix()}")' + self.sendExpression(exp) + + def model_execution_prefix(self, cwd: Optional[OMPathABC] = None) -> list[str]: + """ + Helper function which returns a command prefix needed for docker and WSL. It defaults to an empty list. + """ + + return [] + + def omcpath(self, *path) -> OMPathABC: + """ + Create an OMCPath object based on the given path segments and the current OMCSession* class. + """ + + # fallback solution for Python < 3.12; a modified pathlib.Path object is used as OMCPath replacement + if sys.version_info < (3, 12): + if isinstance(self, OMCSessionLocal): + # noinspection PyArgumentList + return OMCPath(*path) + raise OMSessionException("OMCPath is supported for Python < 3.12 only if OMCSessionLocal is used!") + return OMCPath(*path, session=self) + + def omcpath_tempdir(self, tempdir_base: Optional[OMPathABC] = None) -> OMPathABC: + """ + Get a temporary directory using OMC. It is our own implementation as non-local usage relies on OMC to run all + filesystem related access. + """ + + if tempdir_base is None: + # fallback solution for Python < 3.12; a modified pathlib.Path object is used as OMCPath replacement + if sys.version_info < (3, 12): + tempdir_str = tempfile.gettempdir() + else: + tempdir_str = self.sendExpression(expr="getTempDirectoryPath()") + tempdir_base = self.omcpath(tempdir_str) + + return self._tempdir(tempdir_base=tempdir_base) + + def execute(self, command: str): + warnings.warn( + message="This function is depreciated and will be removed in future versions; " + "please use sendExpression() instead", + category=DeprecationWarning, + stacklevel=2, + ) + + return self.sendExpression(command, parsed=False) + + def sendExpression(self, expr: str, parsed: bool = True) -> Any: + """ + Send an expression to the OMC server and return the result. + + The complete error handling of the OMC result is done within this method using 'getMessagesStringInternal()'. + Caller should only check for OMSessionException. + """ + + if self._omc_zmq is None: + raise OMSessionException("No OMC running. Please create a new instance of OMCSession!") + + logger.debug("sendExpression(expr='%r', parsed=%r)", str(expr), parsed) + + loop = self._timeout_loop(timestep=0.05) + while next(loop): + try: + self._omc_zmq.send_string(str(expr), flags=zmq.NOBLOCK) + break + except zmq.error.Again: + pass + else: + # in the deletion process, the content is cleared. Thus, any access to a class attribute must be checked + try: + log_content = self.get_log() + except OMSessionException: + log_content = 'log not available' + + logger.error(f"OMC did not start. Log-file says:\n{log_content}") + raise OMSessionException(f"No connection with OMC (timeout={self._timeout:.2f}s).") + + if expr == "quit()": + self._omc_zmq.close() + self._omc_zmq = None + return None + + result = self._omc_zmq.recv_string() + + if result.startswith('Error occurred building AST'): + raise OMSessionException(f"OMC error: {result}") + + if expr == "getErrorString()": + # no error handling if 'getErrorString()' is called + if parsed: + logger.warning("Result of 'getErrorString()' cannot be parsed!") + return result + + if expr == "getMessagesStringInternal()": + # no error handling if 'getMessagesStringInternal()' is called + if parsed: + logger.warning("Result of 'getMessagesStringInternal()' cannot be parsed!") + return result + + # always check for error + self._omc_zmq.send_string('getMessagesStringInternal()', flags=zmq.NOBLOCK) + error_raw = self._omc_zmq.recv_string() + # run error handling only if there is something to check + msg_long_list = [] + has_error = False + if error_raw != "{}\n": + if not self._re_log_entries: + self._re_log_entries = re.compile(pattern=r'record OpenModelica\.Scripting\.ErrorMessage' + '(.*?)' + r'end OpenModelica\.Scripting\.ErrorMessage;', + flags=re.MULTILINE | re.DOTALL) + if not self._re_log_raw: + self._re_log_raw = re.compile( + pattern=r"\s*info = record OpenModelica\.Scripting\.SourceInfo\n" + r"\s*filename = \"(.*?)\",\n" + r"\s*readonly = (.*?),\n" + r"\s*lineStart = (\d+),\n" + r"\s*columnStart = (\d+),\n" + r"\s*lineEnd = (\d+),\n" + r"\s*columnEnd = (\d+)\n" + r"\s*end OpenModelica\.Scripting\.SourceInfo;,\n" + r"\s*message = \"(.*?)\",\n" # message + r"\s*kind = \.OpenModelica\.Scripting\.ErrorKind\.(.*?),\n" # kind + r"\s*level = \.OpenModelica\.Scripting\.ErrorLevel\.(.*?),\n" # level + r"\s*id = (\d+)", # id + flags=re.MULTILINE | re.DOTALL) + + # extract all ErrorMessage records + log_entries = self._re_log_entries.findall(string=error_raw) + for log_entry in reversed(log_entries): + log_raw = self._re_log_raw.findall(string=log_entry) + if len(log_raw) != 1 or len(log_raw[0]) != 10: + logger.warning("Invalid ErrorMessage record returned by 'getMessagesStringInternal()':" + f" {repr(log_entry)}!") + continue + + log_filename = log_raw[0][0] + log_readonly = log_raw[0][1] + log_lstart = log_raw[0][2] + log_cstart = log_raw[0][3] + log_lend = log_raw[0][4] + log_cend = log_raw[0][5] + log_message = log_raw[0][6].encode().decode('unicode_escape') + log_kind = log_raw[0][7] + log_level = log_raw[0][8] + log_id = log_raw[0][9] + + msg_short = (f"[OMC log for 'sendExpression(expr={expr}, parsed={parsed})']: " + f"[{log_kind}:{log_level}:{log_id}] {log_message}") + + # response according to the used log level + # see: https://build.openmodelica.org/Documentation/OpenModelica.Scripting.ErrorLevel.html + if log_level == 'error': + logger.error(msg_short) + has_error = True + elif log_level == 'warning': + logger.warning(msg_short) + elif log_level == 'notification': + logger.info(msg_short) + else: # internal + logger.debug(msg_short) + + # track all messages such that this list can be reported if an error occurred + msg_long = (f"[{log_kind}:{log_level}:{log_id}] " + f"[{log_filename}:{log_readonly}:{log_lstart}:{log_cstart}:{log_lend}:{log_cend}] " + f"{log_message}") + msg_long_list.append(msg_long) + if has_error: + msg_long_str = '\n'.join(f"{idx:02d}: {msg}" for idx, msg in enumerate(msg_long_list)) + raise OMSessionException(f"OMC error occurred for 'sendExpression(expr={expr}, parsed={parsed}):\n" + f"{msg_long_str}") + + if not parsed: + return result + + try: + return om_parser_typed(result) + except pyparsing.ParseException as ex1: + logger.warning('OMTypedParser error: %s. Returning the basic parser result.', ex1.msg) + try: + return om_parser_basic(result) + except (TypeError, UnboundLocalError) as ex2: + raise OMSessionException("Cannot parse OMC result") from ex2 + + def get_port(self) -> Optional[str]: + """ + Get the port to connect to the OMC session. + """ + if not isinstance(self._omc_port, str): + raise OMSessionException(f"Invalid port to connect to OMC process: {self._omc_port}") + return self._omc_port + + def get_log(self) -> str: + """ + Get the log file content of the OMC session. + """ + if self._omc_loghandle is None: + raise OMSessionException("Log file not available!") + + self._omc_loghandle.seek(0) + log = self._omc_loghandle.read() + + return log + + def _get_portfile_path(self) -> Optional[pathlib.Path]: + omc_log = self.get_log() + + portfile = self._re_portfile_path.findall(string=omc_log) + + portfile_path = None + if portfile: + portfile_path = pathlib.Path(portfile[-1][0]) + + return portfile_path + + +class DockerPopen: + """ + Dummy implementation of Popen for a (running) docker process. The process is identified by its process ID (pid). + """ + + def __init__(self, pid): + self.pid = pid + self.process = psutil.Process(pid) + self.returncode = 0 + + def poll(self): + return None if self.process.is_running() else True + + def kill(self): + return os.kill(pid=self.pid, signal=signal.SIGKILL) + + def wait(self, timeout): + try: + self.process.wait(timeout=timeout) + except psutil.TimeoutExpired: + pass + + +class OMCSessionDockerABC(OMCSessionABC, metaclass=abc.ABCMeta): + """ + Base class for OMCSession implementations which run the OMC server in a Docker container. + """ + + def __init__( + self, + timeout: Optional[float] = None, + docker: Optional[str] = None, + dockerContainer: Optional[str] = None, + dockerExtraArgs: Optional[list] = None, + dockerOpenModelicaPath: str | os.PathLike = "omc", + dockerNetwork: Optional[str] = None, + port: Optional[int] = None, + ) -> None: + super().__init__(timeout=timeout) + + if dockerExtraArgs is None: + dockerExtraArgs = [] + + self._docker_extra_args = dockerExtraArgs + self._docker_open_modelica_path = pathlib.PurePosixPath(dockerOpenModelicaPath) + self._docker_network = dockerNetwork + self._docker_container_id: str + self._docker_process: Optional[DockerPopen] + + # start up omc executable in docker container waiting for the ZMQ connection + self._omc_process, self._docker_process, self._docker_container_id = self._docker_omc_start( + docker_image=docker, + docker_cid=dockerContainer, + omc_port=port, + ) + # connect to the running omc instance using ZMQ + self._omc_port = self._omc_port_get(docker_cid=self._docker_container_id) + if port is not None and not self._omc_port.endswith(f":{port}"): + raise OMSessionException(f"Port mismatch: {self._omc_port} is not using the defined port {port}!") + + self._cmd_prefix = self.model_execution_prefix() + + def _docker_process_get(self, docker_cid: str) -> Optional[DockerPopen]: + if sys.platform == 'win32': + raise NotImplementedError("Docker not supported on win32!") + + loop = self._timeout_loop(timestep=0.2) + while next(loop): + docker_top = subprocess.check_output(["docker", "top", docker_cid]).decode().strip() + docker_process = None + for line in docker_top.split("\n"): + columns = line.split() + if self._random_string in line: + try: + docker_process = DockerPopen(int(columns[1])) + except psutil.NoSuchProcess as ex: + raise OMSessionException(f"Could not find PID {docker_top} - " + "is this a docker instance spawned without --pid=host?") from ex + if docker_process is not None: + break + else: + logger.error(f"Docker did not start. Log-file says:\n{self.get_log()}") + raise OMSessionException(f"Docker based OMC Server did not start (timeout={self._timeout:.2f}s).") + + return docker_process + + @abc.abstractmethod + def _docker_omc_start( + self, + docker_image: Optional[str] = None, + docker_cid: Optional[str] = None, + omc_port: Optional[int] = None, + ) -> Tuple[subprocess.Popen, DockerPopen, str]: + pass + + @staticmethod + def _getuid() -> int: + """ + The uid to give to docker. + On Windows, volumes are mapped with all files are chmod ugo+rwx, + so uid does not matter as long as it is not the root user. + """ + # mypy complained about os.getuid() not being available on + # Windows, hence the type: ignore comment. + return 1000 if sys.platform == 'win32' else os.getuid() # type: ignore + + def _omc_port_get( + self, + docker_cid: str, + ) -> str: + port = None + + if not isinstance(docker_cid, str): + raise OMSessionException(f"Invalid docker container ID: {docker_cid}") + + # See if the omc server is running + loop = self._timeout_loop(timestep=0.1) + while next(loop): + omc_portfile_path = self._get_portfile_path() + if omc_portfile_path is not None: + try: + output = subprocess.check_output(args=["docker", + "exec", docker_cid, + "cat", omc_portfile_path.as_posix()], + stderr=subprocess.DEVNULL) + port = output.decode().strip() + except subprocess.CalledProcessError: + pass + if port is not None: + break + else: + logger.error(f"Docker did not start. Log-file says:\n{self.get_log()}") + raise OMSessionException(f"Docker based OMC Server did not start (timeout={self._timeout:.2f}s, " + f"logfile={repr(self._omc_logfile)}).") + + logger.info(f"Docker based OMC Server is up and running at port {port}") + + return port + + def get_server_address(self) -> Optional[str]: + """ + Get the server address of the OMC server running in a Docker container. + """ + if self._docker_network == "separate" and isinstance(self._docker_container_id, str): + output = subprocess.check_output(["docker", "inspect", self._docker_container_id]).decode().strip() + address = json.loads(output)[0]["NetworkSettings"]["IPAddress"] + if not isinstance(address, str): + raise OMSessionException(f"Invalid docker server address: {address}!") + return address + + return None + + def get_docker_container_id(self) -> str: + """ + Get the Docker container ID of the Docker container with the OMC server. + """ + if not isinstance(self._docker_container_id, str): + raise OMSessionException(f"Invalid docker container ID: {self._docker_container_id}!") + + return self._docker_container_id + + def model_execution_prefix(self, cwd: Optional[OMPathABC] = None) -> list[str]: + """ + Helper function which returns a command prefix needed for docker and WSL. It defaults to an empty list. + """ + docker_cmd = [ + "docker", "exec", + "--user", str(self._getuid()), + ] + if isinstance(cwd, OMPathABC): + docker_cmd += ["--workdir", cwd.as_posix()] + docker_cmd += self._docker_extra_args + if isinstance(self._docker_container_id, str): + docker_cmd += [self._docker_container_id] + + return docker_cmd + + +class OMCSessionDocker(OMCSessionDockerABC): + """ + OMC process running in a Docker container. + """ + + def __init__( + self, + timeout: Optional[float] = None, + docker: Optional[str] = None, + dockerExtraArgs: Optional[list] = None, + dockerOpenModelicaPath: str | os.PathLike = "omc", + dockerNetwork: Optional[str] = None, + port: Optional[int] = None, + ) -> None: + + super().__init__( + timeout=timeout, + docker=docker, + dockerExtraArgs=dockerExtraArgs, + dockerOpenModelicaPath=dockerOpenModelicaPath, + dockerNetwork=dockerNetwork, + port=port, + ) + + def __del__(self) -> None: + + if hasattr(self, '_docker_process') and isinstance(self._docker_process, DockerPopen): + try: + self._docker_process.wait(timeout=2.0) + except subprocess.TimeoutExpired: + if self._docker_process: + logger.warning("OMC did not exit after being sent the quit() command; " + "killing the process with pid=%s", self._docker_process.pid) + self._docker_process.kill() + self._docker_process.wait(timeout=2.0) + finally: + self._docker_process = None + + super().__del__() + + def _docker_omc_cmd( + self, + docker_image: str, + docker_cid_file: pathlib.Path, + omc_path_and_args_list: list[str], + omc_port: Optional[int | str] = None, + ) -> list: + """ + Define the command that will be called by the subprocess module. + """ + + extra_flags = [] + + if sys.platform == "win32": + extra_flags = ["-d=zmqDangerousAcceptConnectionsFromAnywhere"] + if not self._omc_port: + raise OMSessionException("Docker on Windows requires knowing which port to connect to - " + "please set the interactivePort argument") + + port: Optional[int] = None + if isinstance(omc_port, str): + port = int(omc_port) + elif isinstance(omc_port, int): + port = omc_port + + if sys.platform == "win32": + if not isinstance(port, int): + raise OMSessionException("OMC on Windows needs the interactive port - " + f"missing or invalid value: {repr(omc_port)}!") + docker_network_str = ["-p", f"127.0.0.1:{port}:{port}"] + elif self._docker_network == "host" or self._docker_network is None: + docker_network_str = ["--network=host"] + elif self._docker_network == "separate": + docker_network_str = [] + extra_flags = ["-d=zmqDangerousAcceptConnectionsFromAnywhere"] + else: + raise OMSessionException(f'dockerNetwork was set to {self._docker_network}, ' + 'but only \"host\" or \"separate\" is allowed') + + if isinstance(port, int): + extra_flags = extra_flags + [f"--interactivePort={port}"] + + omc_command = ([ + "docker", "run", + "--cidfile", docker_cid_file.as_posix(), + "--rm", + "--user", str(self._getuid()), + ] + + self._docker_extra_args + + docker_network_str + + [docker_image, self._docker_open_modelica_path.as_posix()] + + omc_path_and_args_list + + extra_flags) + + return omc_command + + def _docker_omc_start( + self, + docker_image: Optional[str] = None, + docker_cid: Optional[str] = None, + omc_port: Optional[int] = None, + ) -> Tuple[subprocess.Popen, DockerPopen, str]: + + if not isinstance(docker_image, str): + raise OMSessionException("A docker image name must be provided!") + + my_env = os.environ.copy() + + docker_cid_file = self._temp_dir / (self._omc_filebase + ".docker.cid") + + omc_command = self._docker_omc_cmd( + docker_image=docker_image, + docker_cid_file=docker_cid_file, + omc_path_and_args_list=["--locale=C", + "--interactive=zmq", + f"-z={self._random_string}"], + omc_port=omc_port, + ) + + omc_process = subprocess.Popen(omc_command, + stdout=self._omc_loghandle, + stderr=self._omc_loghandle, + env=my_env) + + if not isinstance(docker_cid_file, pathlib.Path): + raise OMSessionException(f"Invalid content for docker container ID file path: {docker_cid_file}") + + # the provided value for docker_cid is not used + docker_cid = None + loop = self._timeout_loop(timestep=0.1) + while next(loop): + try: + with open(file=docker_cid_file, mode="r", encoding="utf-8") as fh: + docker_cid = fh.read().strip() + except IOError: + pass + if docker_cid is not None: + break + + if docker_cid is None: + raise OMSessionException(f"Docker did not start (timeout={self._timeout:.2f}s might be too short " + "especially if you did not docker pull the image before this command). " + f"Log-file says:\n{self.get_log()}") + + docker_process = self._docker_process_get(docker_cid=docker_cid) + if docker_process is None: + logger.error(f"Docker did not start. Log-file says:\n{self.get_log()}") + raise OMSessionException(f"Docker top did not contain omc process {self._random_string}.") + + return omc_process, docker_process, docker_cid + + +class OMCSessionDockerContainer(OMCSessionDockerABC): + """ + OMC process running in a Docker container (by container ID). + """ + + def __init__( + self, + timeout: Optional[float] = None, + dockerContainer: Optional[str] = None, + dockerExtraArgs: Optional[list] = None, + dockerOpenModelicaPath: str | os.PathLike = "omc", + dockerNetwork: Optional[str] = None, + port: Optional[int] = None, + ) -> None: + + super().__init__( + timeout=timeout, + dockerContainer=dockerContainer, + dockerExtraArgs=dockerExtraArgs, + dockerOpenModelicaPath=dockerOpenModelicaPath, + dockerNetwork=dockerNetwork, + port=port, + ) + + def __del__(self) -> None: + + super().__del__() + + # docker container ID was provided - do NOT kill the docker process! + self._docker_process = None + + def _docker_omc_cmd( + self, + docker_cid: str, + omc_path_and_args_list: list[str], + omc_port: Optional[int] = None, + ) -> list: + """ + Define the command that will be called by the subprocess module. + """ + extra_flags: list[str] = [] + + if sys.platform == "win32": + extra_flags = ["-d=zmqDangerousAcceptConnectionsFromAnywhere"] + if not isinstance(omc_port, int): + raise OMSessionException("Docker on Windows requires knowing which port to connect to - " + "Please set the interactivePort argument. Furthermore, the container needs " + "to have already manually exposed this port when it was started " + "(-p 127.0.0.1:n:n) or you get an error later.") + + if isinstance(omc_port, int): + extra_flags = extra_flags + [f"--interactivePort={omc_port}"] + + omc_command = ([ + "docker", "exec", + "--user", str(self._getuid()), + ] + + self._docker_extra_args + + [docker_cid, self._docker_open_modelica_path.as_posix()] + + omc_path_and_args_list + + extra_flags) + + return omc_command + + def _docker_omc_start( + self, + docker_image: Optional[str] = None, + docker_cid: Optional[str] = None, + omc_port: Optional[int] = None, + ) -> Tuple[subprocess.Popen, DockerPopen, str]: + + if not isinstance(docker_cid, str): + raise OMSessionException("A docker container ID must be provided!") + + my_env = os.environ.copy() + + omc_command = self._docker_omc_cmd( + docker_cid=docker_cid, + omc_path_and_args_list=["--locale=C", + "--interactive=zmq", + f"-z={self._random_string}"], + omc_port=omc_port, + ) + + omc_process = subprocess.Popen(omc_command, + stdout=self._omc_loghandle, + stderr=self._omc_loghandle, + env=my_env) + + docker_process = None + if isinstance(docker_cid, str): + docker_process = self._docker_process_get(docker_cid=docker_cid) + + if docker_process is None: + raise OMSessionException(f"Docker top did not contain omc process {self._random_string} " + f"/ {docker_cid}. Log-file says:\n{self.get_log()}") + + return omc_process, docker_process, docker_cid + + +class OMCSessionLocal(OMCSessionABC): + """ + OMCSession implementation which runs the OMC server locally on the machine (Linux / Windows). + """ + + def __init__( + self, + timeout: Optional[float] = None, + omhome: Optional[str | os.PathLike] = None, + ) -> None: + + super().__init__(timeout=timeout) + + self.model_execution_local = True + + # where to find OpenModelica + self._omhome = self._omc_home_get(omhome=omhome) + # start up omc executable, which is waiting for the ZMQ connection + self._omc_process = self._omc_process_get() + # connect to the running omc instance using ZMQ + self._omc_port = self._omc_port_get() + + @staticmethod + def _omc_home_get(omhome: Optional[str | os.PathLike] = None) -> pathlib.Path: + # use the provided path + if omhome is not None: + return pathlib.Path(omhome) + + # check the environment variable + omhome = os.environ.get('OPENMODELICAHOME') + if omhome is not None: + return pathlib.Path(omhome) + + # Get the path to the OMC executable, if not installed this will be None + path_to_omc = shutil.which("omc") + if path_to_omc is not None: + return pathlib.Path(path_to_omc).parents[1] + + raise OMSessionException("Cannot find OpenModelica executable, please install from openmodelica.org") + + def _omc_process_get(self) -> subprocess.Popen: + my_env = os.environ.copy() + my_env["PATH"] = (self._omhome / "bin").as_posix() + os.pathsep + my_env["PATH"] + + omc_command = [ + (self._omhome / "bin" / "omc").as_posix(), + "--locale=C", + "--interactive=zmq", + f"-z={self._random_string}"] + + omc_process = subprocess.Popen(omc_command, + stdout=self._omc_loghandle, + stderr=self._omc_loghandle, + env=my_env) + return omc_process + + def _omc_port_get(self) -> str: + port = None + + # See if the omc server is running + loop = self._timeout_loop(timestep=0.1) + while next(loop): + omc_portfile_path = self._get_portfile_path() + if omc_portfile_path is not None and omc_portfile_path.is_file(): + # Read the port file + with open(file=omc_portfile_path, mode='r', encoding="utf-8") as f_p: + port = f_p.readline() + break + if port is not None: + break + else: + logger.error(f"OMC server did not start. Log-file says:\n{self.get_log()}") + raise OMSessionException(f"OMC Server did not start (timeout={self._timeout:.2f}s, " + f"logfile={repr(self._omc_logfile)}).") + + logger.info(f"Local OMC Server is up and running at ZMQ port {port} " + f"pid={self._omc_process.pid if isinstance(self._omc_process, subprocess.Popen) else '?'}") + + return port + + +class OMCSessionPort(OMCSessionABC): + """ + OMCSession implementation which uses a port to connect to an already running OMC server. + """ + + def __init__( + self, + omc_port: str, + timeout: Optional[float] = None, + ) -> None: + super().__init__(timeout=timeout) + self._omc_port = omc_port + + +class OMCSessionWSL(OMCSessionABC): + """ + OMC process running in Windows Subsystem for Linux (WSL). + """ + + def __init__( + self, + timeout: Optional[float] = None, + wsl_omc: str = 'omc', + wsl_distribution: Optional[str] = None, + wsl_user: Optional[str] = None, + ) -> None: + + super().__init__(timeout=timeout) + + # where to find OpenModelica + self._wsl_omc = wsl_omc + # store WSL distribution and user + self._wsl_distribution = wsl_distribution + self._wsl_user = wsl_user + # start up omc executable, which is waiting for the ZMQ connection + self._omc_process = self._omc_process_get() + # connect to the running omc instance using ZMQ + self._omc_port = self._omc_port_get() + + self._cmd_prefix = self.model_execution_prefix() + + def model_execution_prefix(self, cwd: Optional[OMPathABC] = None) -> list[str]: + """ + Helper function which returns a command prefix needed for docker and WSL. It defaults to an empty list. + """ + # get wsl base command + wsl_cmd = ['wsl'] + if isinstance(self._wsl_distribution, str): + wsl_cmd += ['--distribution', self._wsl_distribution] + if isinstance(self._wsl_user, str): + wsl_cmd += ['--user', self._wsl_user] + if isinstance(cwd, OMPathABC): + wsl_cmd += ['--cd', cwd.as_posix()] + wsl_cmd += ['--'] + + return wsl_cmd + + def _omc_process_get(self) -> subprocess.Popen: + my_env = os.environ.copy() + + omc_command = self.model_execution_prefix() + [ + self._wsl_omc, + "--locale=C", + "--interactive=zmq", + f"-z={self._random_string}", + ] + + omc_process = subprocess.Popen(omc_command, + stdout=self._omc_loghandle, + stderr=self._omc_loghandle, + env=my_env) + return omc_process + + def _omc_port_get(self) -> str: + port = None + + # See if the omc server is running + loop = self._timeout_loop(timestep=0.1) + while next(loop): + try: + omc_portfile_path = self._get_portfile_path() + if omc_portfile_path is not None: + output = subprocess.check_output( + args=self.model_execution_prefix() + ["cat", omc_portfile_path.as_posix()], + stderr=subprocess.DEVNULL, + ) + port = output.decode().strip() + except subprocess.CalledProcessError: + pass + if port is not None: + break + else: + logger.error(f"WSL based OMC server did not start. Log-file says:\n{self.get_log()}") + raise OMSessionException(f"WSL based OMC Server did not start (timeout={self._timeout:2f}s, " + f"logfile={repr(self._omc_logfile)}).") + + logger.info(f"WSL based OMC Server is up and running at ZMQ port {port} " + f"pid={self._omc_process.pid if isinstance(self._omc_process, subprocess.Popen) else '?'}") + + return port diff --git a/OMPython/om_session_runner.py b/OMPython/om_session_runner.py new file mode 100644 index 00000000..55ba5a49 --- /dev/null +++ b/OMPython/om_session_runner.py @@ -0,0 +1,383 @@ +# -*- coding: utf-8 -*- +""" +Definition of an OM session just executing a compiled model executable (Runner). +""" + +from __future__ import annotations + +import abc +import logging +import pathlib +import subprocess +import sys +import tempfile +from typing import Any, Optional, Type + +from OMPython.om_session_abc import ( + OMPathABC, + OMSessionABC, + OMSessionException, +) + +# define logger using the current module name as ID +logger = logging.getLogger(__name__) + +# due to the compatibility layer to Python < 3.12, the OM(C)Path classes must be hidden behind the following if +# conditions. This is also the reason for OMPathABC, a simple base class to be used in ModelicaSystem* classes. +# Reason: before Python 3.12, pathlib.PurePosixPath can not be derived from; therefore, OMPathABC is not possible +if sys.version_info < (3, 12): + OMPathRunnerABC = OMPathABC + OMPathRunnerLocal = OMPathABC + OMPathRunnerBash = OMPathABC + +else: + class OMPathRunnerABC(OMPathABC, metaclass=abc.ABCMeta): + """ + Base function for OMPath definitions *without* OMC server + """ + + def _path(self) -> pathlib.Path: + return pathlib.Path(self.as_posix()) + + class _OMPathRunnerLocal(OMPathRunnerABC): + """ + Implementation of OMPathABC which does not use the session data at all. Thus, this implementation can run + locally without any usage of OMC. + + This class is based on OMPathABC and, therefore, on pathlib.PurePosixPath. This is working well, but it is not + the correct implementation on Windows systems. To get a valid Windows representation of the path, use the + conversion via pathlib.Path(.as_posix()). + """ + + def is_file(self) -> bool: + """ + Check if the path is a regular file. + """ + return self._path().is_file() + + def is_dir(self) -> bool: + """ + Check if the path is a directory. + """ + return self._path().is_dir() + + def is_absolute(self) -> bool: + """ + Check if the path is an absolute path. + """ + return self._path().is_absolute() + + def read_text(self) -> str: + """ + Read the content of the file represented by this path as text. + """ + return self._path().read_text(encoding='utf-8') + + def write_text(self, data: str): + """ + Write text data to the file represented by this path. + """ + if not isinstance(data, str): + raise TypeError(f"data must be str, not {data.__class__.__name__}") + + return self._path().write_text(data=data, encoding='utf-8') + + def mkdir(self, parents: bool = True, exist_ok: bool = False) -> None: + """ + Create a directory at the path represented by this class. + + The argument parents with default value True exists to ensure compatibility with the fallback solution for + Python < 3.12. In this case, pathlib.Path is used directly and this option ensures, that missing parent + directories are also created. + """ + self._path().mkdir(parents=parents, exist_ok=exist_ok) + + def cwd(self) -> OMPathABC: + """ + Returns the current working directory as an OMPathABC object. + """ + return type(self)(self._path().cwd().as_posix(), session=self._session) + + def unlink(self, missing_ok: bool = False) -> None: + """ + Unlink (delete) the file or directory represented by this path. + """ + self._path().unlink(missing_ok=missing_ok) + + def resolve(self, strict: bool = False) -> OMPathABC: + """ + Resolve the path to an absolute path. This is done based on available OMC functions. + """ + path_resolved = self._path().resolve(strict=strict) + return type(self)(path_resolved, session=self._session) + + def size(self) -> int: + """ + Get the size of the file in bytes - implementation based on pathlib.Path. + """ + if not self.is_file(): + raise OMSessionException(f"Path {self.as_posix()} is not a file!") + + path = self._path() + return path.stat().st_size + + class _OMPathRunnerBash(OMPathRunnerABC): + """ + Implementation of OMPathABC which does not use the session data at all. Thus, this implementation can run + locally without any usage of OMC. The special case of this class is the usage of POSIX bash to run all the + commands. Thus, it can be used in WSL or docker. + + This class is based on OMPathABC and, therefore, on pathlib.PurePosixPath. This is working well, but it is not + the correct implementation on Windows systems. To get a valid Windows representation of the path, use the + conversion via pathlib.Path(.as_posix()). + """ + + def is_file(self) -> bool: + """ + Check if the path is a regular file. + """ + cmdl = self.get_session().get_cmd_prefix() + cmdl += ['bash', '-c', f'test -f "{self.as_posix()}"'] + + try: + subprocess.run(cmdl, check=True) + return True + except subprocess.CalledProcessError: + return False + + def is_dir(self) -> bool: + """ + Check if the path is a directory. + """ + cmdl = self.get_session().get_cmd_prefix() + cmdl += ['bash', '-c', f'test -d "{self.as_posix()}"'] + + try: + subprocess.run(cmdl, check=True) + return True + except subprocess.CalledProcessError: + return False + + def is_absolute(self) -> bool: + """ + Check if the path is an absolute path. + """ + + cmdl = self.get_session().get_cmd_prefix() + cmdl += ['bash', '-c', f'case "{self.as_posix()}" in /*) exit 0;; *) exit 1;; esac'] + + try: + subprocess.check_call(cmdl) + return True + except subprocess.CalledProcessError: + return False + + def read_text(self) -> str: + """ + Read the content of the file represented by this path as text. + """ + cmdl = self.get_session().get_cmd_prefix() + cmdl += ['bash', '-c', f'cat "{self.as_posix()}"'] + + result = subprocess.run(cmdl, capture_output=True, check=True) + if result.returncode == 0: + return result.stdout.decode('utf-8') + raise FileNotFoundError(f"Cannot read file: {self.as_posix()}") + + def write_text(self, data: str) -> int: + """ + Write text data to the file represented by this path. + """ + if not isinstance(data, str): + raise TypeError(f"data must be str, not {data.__class__.__name__}") + + data_escape = self._session.escape_str(data) + + cmdl = self.get_session().get_cmd_prefix() + cmdl += ['bash', '-c', f'printf %s "{data_escape}" > "{self.as_posix()}"'] + + try: + subprocess.run(cmdl, check=True) + return len(data) + except subprocess.CalledProcessError as exc: + raise IOError(f"Error writing data to file {self.as_posix()}!") from exc + + def mkdir(self, parents: bool = True, exist_ok: bool = False) -> None: + """ + Create a directory at the path represented by this class. + + The argument parents with default value True exists to ensure compatibility with the fallback solution for + Python < 3.12. In this case, pathlib.Path is used directly and this option ensures, that missing parent + directories are also created. + """ + + if self.is_file(): + raise OSError(f"The given path {self.as_posix()} exists and is a file!") + if self.is_dir() and not exist_ok: + raise OSError(f"The given path {self.as_posix()} exists and is a directory!") + if not parents and not self.parent.is_dir(): + raise FileNotFoundError(f"Parent directory of {self.as_posix()} does not exists!") + + cmdl = self.get_session().get_cmd_prefix() + cmdl += ['bash', '-c', f'mkdir -p "{self.as_posix()}"'] + + try: + subprocess.run(cmdl, check=True) + except subprocess.CalledProcessError as exc: + raise OMSessionException(f"Error on directory creation for {self.as_posix()}!") from exc + + def cwd(self) -> OMPathABC: + """ + Returns the current working directory as an OMPathABC object. + """ + cmdl = self.get_session().get_cmd_prefix() + cmdl += ['bash', '-c', 'pwd'] + + result = subprocess.run(cmdl, capture_output=True, text=True, check=True) + if result.returncode == 0: + return type(self)(result.stdout.strip(), session=self._session) + raise OSError("Can not get current work directory ...") + + def unlink(self, missing_ok: bool = False) -> None: + """ + Unlink (delete) the file or directory represented by this path. + """ + + if not self.is_file(): + raise OSError(f"Can not unlink a directory: {self.as_posix()}!") + + if not self.is_file(): + return + + cmdl = self.get_session().get_cmd_prefix() + cmdl += ['bash', '-c', f'rm "{self.as_posix()}"'] + + try: + subprocess.run(cmdl, check=True) + except subprocess.CalledProcessError as exc: + raise OSError(f"Cannot unlink file {self.as_posix()}: {exc}") from exc + + def resolve(self, strict: bool = False) -> OMPathABC: + """ + Resolve the path to an absolute path. This is done based on available OMC functions. + """ + cmdl = self.get_session().get_cmd_prefix() + cmdl += ['bash', '-c', f'readlink -f "{self.as_posix()}"'] + + result = subprocess.run(cmdl, capture_output=True, text=True, check=True) + if result.returncode == 0: + return type(self)(result.stdout.strip(), session=self._session) + raise FileNotFoundError(f"Cannot resolve path: {self.as_posix()}") + + def size(self) -> int: + """ + Get the size of the file in bytes - implementation based on pathlib.Path. + """ + if not self.is_file(): + raise OMSessionException(f"Path {self.as_posix()} is not a file!") + + cmdl = self.get_session().get_cmd_prefix() + cmdl += ['bash', '-c', f'stat -c %s "{self.as_posix()}"'] + + result = subprocess.run(cmdl, capture_output=True, text=True, check=True) + stdout = result.stdout.strip() + if result.returncode == 0: + try: + return int(stdout) + except ValueError as exc: + raise OSError(f"Invalid return value for file size ({self.as_posix()}): {stdout}") from exc + else: + raise OSError(f"Cannot get size for file {self.as_posix()}") + + OMPathRunnerLocal = _OMPathRunnerLocal + OMPathRunnerBash = _OMPathRunnerBash + + +class OMSessionRunnerABC(OMSessionABC, metaclass=abc.ABCMeta): + """ + Implementation based on OMSessionABC without any use of an OMC server. + """ + + def __init__( + self, + ompath_runner: Type[OMPathRunnerABC], + timeout: Optional[float] = None, + version: str = "1.27.0", + cmd_prefix: Optional[list[str]] = None, + model_execution_local: bool = True, + ) -> None: + super().__init__(timeout=timeout) + self._version = version + + if not issubclass(ompath_runner, OMPathRunnerABC): + raise OMSessionException(f"Invalid OMPathRunner class: {type(ompath_runner)}!") + self._ompath_runner = ompath_runner + + self.model_execution_local = model_execution_local + if cmd_prefix is not None: + self._cmd_prefix = cmd_prefix + + +class OMSessionRunner(OMSessionRunnerABC): + """ + Implementation based on OMSessionABC without any use of an OMC server. + """ + + def __init__( + self, + ompath_runner: Type[OMPathRunnerABC] = OMPathRunnerLocal, + timeout: float = 10.0, + version: str = "1.27.0", + cmd_prefix: Optional[list[str]] = None, + model_execution_local: bool = True, + ) -> None: + super().__init__( + ompath_runner=ompath_runner, + timeout=timeout, + version=version, + cmd_prefix=cmd_prefix, + model_execution_local=model_execution_local, + ) + + def __post_init__(self) -> None: + """ + No connection to an OMC server is created by this class! + """ + + def model_execution_prefix(self, cwd: Optional[OMPathABC] = None) -> list[str]: + """ + Helper function which returns a command prefix. + """ + return self.get_cmd_prefix() + + def get_version(self) -> str: + """ + We can not provide an OM version as we are not link to an OMC server. Thus, the provided version string is used + directly. + """ + return self._version + + def set_workdir(self, workdir: OMPathABC) -> None: + """ + Set the workdir for this session. For OMSessionRunner this is a nop. The workdir must be defined within the + definition of cmd_prefix. + """ + + def omcpath(self, *path) -> OMPathABC: + """ + Create an OMCPath object based on the given path segments and the current OMCSession* class. + """ + return self._ompath_runner(*path, session=self) + + def omcpath_tempdir(self, tempdir_base: Optional[OMPathABC] = None) -> OMPathABC: + """ + Get a temporary directory without using OMC. + """ + if tempdir_base is None: + tempdir_str = tempfile.gettempdir() + tempdir_base = self.omcpath(tempdir_str) + + return self._tempdir(tempdir_base=tempdir_base) + + def sendExpression(self, expr: str, parsed: bool = True) -> Any: + raise OMSessionException(f"{self.__class__.__name__} does not uses an OMC server!") diff --git a/tests/test_FMIExport.py b/tests/test_FMIExport.py index c7ab038a..65ac2766 100644 --- a/tests/test_FMIExport.py +++ b/tests/test_FMIExport.py @@ -1,6 +1,6 @@ -import shutil import os import pathlib +import shutil import OMPython diff --git a/tests/test_ModelicaSystemCmd.py b/tests/test_ModelExecutionCmd.py similarity index 97% rename from tests/test_ModelicaSystemCmd.py rename to tests/test_ModelExecutionCmd.py index 3d35376b..db5aadeb 100644 --- a/tests/test_ModelicaSystemCmd.py +++ b/tests/test_ModelExecutionCmd.py @@ -29,7 +29,7 @@ def mscmd_firstorder(model_firstorder): cmd_local=mod.get_session().model_execution_local, cmd_windows=mod.get_session().model_execution_windows, cmd_prefix=mod.get_session().model_execution_prefix(cwd=mod.getWorkDirectory()), - model_name=mod._model_name, + model_name=mod.get_model_name(), ) return mscmd diff --git a/tests/test_ModelicaDoEOMC.py b/tests/test_ModelicaDoEOMC.py index 143932fc..9d6afc63 100644 --- a/tests/test_ModelicaDoEOMC.py +++ b/tests/test_ModelicaDoEOMC.py @@ -159,6 +159,6 @@ def _run_ModelicaDoEOMC(doe_mod): f"y[{row['p']}]": float(row['b']), } - for var in var_dict: - assert var in sol['data'] - assert np.isclose(sol['data'][var][-1], var_dict[var]) + for key, val in var_dict.items(): + assert key in sol['data'] + assert np.isclose(sol['data'][key][-1], val) diff --git a/tests/test_ModelicaDoERunner.py b/tests/test_ModelicaDoERunner.py index 2d41315f..e29e7e05 100644 --- a/tests/test_ModelicaDoERunner.py +++ b/tests/test_ModelicaDoERunner.py @@ -153,6 +153,6 @@ def _check_runner_result(mod, doe_mod): 'b': float(row['b']), } - for var in var_dict: - assert var in sol['data'] - assert np.isclose(sol['data'][var][-1], var_dict[var]) + for key, val in var_dict.items(): + assert key in sol['data'] + assert np.isclose(sol['data'][key][-1], val) diff --git a/tests/test_ModelicaSystemOMC.py b/tests/test_ModelicaSystemOMC.py index 8dd17ef0..c63b92e1 100644 --- a/tests/test_ModelicaSystemOMC.py +++ b/tests/test_ModelicaSystemOMC.py @@ -495,7 +495,7 @@ def test_simulate_inputs(tmp_path): } mod.setInputs(**inputs) csv_file = mod._createCSVData() - assert pathlib.Path(csv_file).read_text() == """time,u1,u2,end + assert pathlib.Path(csv_file).read_text(encoding='utf-8') == """time,u1,u2,end 0.0,0.0,0.0,0 0.25,0.25,0.5,0 0.5,0.5,1.0,0 diff --git a/tests/test_ModelicaSystemRunner.py b/tests/test_ModelicaSystemRunner.py index ec9d734d..a207368c 100644 --- a/tests/test_ModelicaSystemRunner.py +++ b/tests/test_ModelicaSystemRunner.py @@ -1,9 +1,22 @@ +import sys + import numpy as np import pytest import OMPython +skip_on_windows = pytest.mark.skipif( + sys.platform.startswith("win"), + reason="OpenModelica Docker image is Linux-only; skipping on Windows.", +) + +skip_python_older_312 = pytest.mark.skipif( + sys.version_info < (3, 12), + reason="OMCPath(non-local) only working for Python >= 3.12.", +) + + @pytest.fixture def model_firstorder_content(): return """ @@ -37,7 +50,7 @@ def param(): } -def test_runner(model_firstorder, param): +def test_ModelicaSystemRunner_OMC(model_firstorder, param): # create a model using ModelicaSystem mod = OMPython.ModelicaSystemOMC() mod.model( @@ -71,6 +84,167 @@ def test_runner(model_firstorder, param): _check_result(mod=mod, resultfile=resultfile_modr, param=param) +def test_ModelicaSystemRunner_local(model_firstorder, param): + # create a model using ModelicaSystem + mod = OMPython.ModelicaSystemOMC() + mod.model( + model_file=model_firstorder, + model_name="M", + ) + + resultfile_mod = mod.getWorkDirectory() / f"{mod.get_model_name()}_res_mod.mat" + _run_simulation(mod=mod, resultfile=resultfile_mod, param=param) + + # run the model using only the runner class + omcs = OMPython.OMSessionRunner( + version=mod.get_session().get_version(), + ompath_runner=OMPython.OMPathRunnerLocal, + ) + modr = OMPython.ModelicaSystemRunner( + session=omcs, + work_directory=mod.getWorkDirectory(), + ) + modr.setup( + model_name="M", + ) + + resultfile_modr = mod.getWorkDirectory() / f"{mod.get_model_name()}_res_modr.mat" + _run_simulation(mod=modr, resultfile=resultfile_modr, param=param) + + # cannot check the content as runner does not have the capability to open a result file + assert resultfile_mod.size() == resultfile_modr.size() + + # check results + _check_result(mod=mod, resultfile=resultfile_mod, param=param) + _check_result(mod=mod, resultfile=resultfile_modr, param=param) + + +@skip_on_windows +def test_ModelicaSystemRunner_bash(model_firstorder, param): + # create a model using ModelicaSystem + mod = OMPython.ModelicaSystemOMC() + mod.model( + model_file=model_firstorder, + model_name="M", + ) + + resultfile_mod = mod.getWorkDirectory() / f"{mod.get_model_name()}_res_mod.mat" + _run_simulation(mod=mod, resultfile=resultfile_mod, param=param) + + # run the model using only the runner class + omcsr = OMPython.OMSessionRunner( + version=mod.get_session().get_version(), + ompath_runner=OMPython.OMPathRunnerBash, + ) + modr = OMPython.ModelicaSystemRunner( + session=omcsr, + work_directory=mod.getWorkDirectory(), + ) + modr.setup( + model_name="M", + ) + + resultfile_modr = mod.getWorkDirectory() / f"{mod.get_model_name()}_res_modr.mat" + _run_simulation(mod=modr, resultfile=resultfile_modr, param=param) + + # cannot check the content as runner does not have the capability to open a result file + assert resultfile_mod.size() == resultfile_modr.size() + + # check results + _check_result(mod=mod, resultfile=resultfile_mod, param=param) + _check_result(mod=mod, resultfile=resultfile_modr, param=param) + + +@skip_on_windows +@skip_python_older_312 +def test_ModelicaSystemRunner_bash_docker(model_firstorder, param): + omcs = OMPython.OMCSessionDocker(docker="openmodelica/openmodelica:v1.25.0-minimal") + omversion = omcs.sendExpression("getVersion()") + assert isinstance(omversion, str) and omversion.startswith("OpenModelica") + + # create a model using ModelicaSystem + mod = OMPython.ModelicaSystemOMC( + session=omcs, + ) + mod.model( + model_file=model_firstorder, + model_name="M", + ) + + resultfile_mod = mod.getWorkDirectory() / f"{mod.get_model_name()}_res_mod.mat" + _run_simulation(mod=mod, resultfile=resultfile_mod, param=param) + + # run the model using only the runner class + omcsr = OMPython.OMSessionRunner( + version=mod.get_session().get_version(), + cmd_prefix=omcs.model_execution_prefix(cwd=mod.getWorkDirectory()), + ompath_runner=OMPython.OMPathRunnerBash, + model_execution_local=False, + ) + modr = OMPython.ModelicaSystemRunner( + session=omcsr, + work_directory=mod.getWorkDirectory(), + ) + modr.setup( + model_name="M", + ) + + resultfile_modr = mod.getWorkDirectory() / f"{mod.get_model_name()}_res_modr.mat" + _run_simulation(mod=modr, resultfile=resultfile_modr, param=param) + + # cannot check the content as runner does not have the capability to open a result file + assert resultfile_mod.size() == resultfile_modr.size() + + # check results + _check_result(mod=mod, resultfile=resultfile_mod, param=param) + _check_result(mod=mod, resultfile=resultfile_modr, param=param) + + +@pytest.mark.skip(reason="Not able to run WSL on github") +@skip_python_older_312 +def test_ModelicaSystemDoE_WSL(tmp_path, model_doe, param_doe): + omcs = OMPython.OMCSessionWSL() + omversion = omcs.sendExpression("getVersion()") + assert isinstance(omversion, str) and omversion.startswith("OpenModelica") + + # create a model using ModelicaSystem + mod = OMPython.ModelicaSystemOMC( + session=omcs, + ) + mod.model( + model_file=model_firstorder, + model_name="M", + ) + + resultfile_mod = mod.getWorkDirectory() / f"{mod.get_model_name()}_res_mod.mat" + _run_simulation(mod=mod, resultfile=resultfile_mod, param=param) + + # run the model using only the runner class + omcsr = OMPython.OMSessionRunner( + version=mod.get_session().get_version(), + cmd_prefix=omcs.model_execution_prefix(cwd=mod.getWorkDirectory()), + ompath_runner=OMPython.OMPathRunnerBash, + model_execution_local=False, + ) + modr = OMPython.ModelicaSystemRunner( + session=omcsr, + work_directory=mod.getWorkDirectory(), + ) + modr.setup( + model_name="M", + ) + + resultfile_modr = mod.getWorkDirectory() / f"{mod.get_model_name()}_res_modr.mat" + _run_simulation(mod=modr, resultfile=resultfile_modr, param=param) + + # cannot check the content as runner does not have the capability to open a result file + assert resultfile_mod.size() == resultfile_modr.size() + + # check results + _check_result(mod=mod, resultfile=resultfile_mod, param=param) + _check_result(mod=mod, resultfile=resultfile_modr, param=param) + + def _run_simulation(mod, resultfile, param): simOptions = {"stopTime": param['stopTime'], "stepSize": 0.1, "tolerance": 1e-8} mod.setSimulationOptions(**simOptions) diff --git a/tests/test_OMCPath.py b/tests/test_OMCPath.py index df01b86a..e15c75ff 100644 --- a/tests/test_OMCPath.py +++ b/tests/test_OMCPath.py @@ -15,42 +15,98 @@ ) -def test_OMCPath_OMCProcessLocal(): - omcs = OMPython.OMCSessionLocal() +# TODO: based on compatibility layer +def test_OMCPath_OMCSessionZMQ(): + om = OMPython.OMCSessionZMQ() - _run_OMCPath_checks(omcs) + _run_OMPath_checks(om) + _run_OMPath_write_file(om) - del omcs + +def test_OMCPath_OMCSessionLocal(): + oms = OMPython.OMCSessionLocal() + + _run_OMPath_checks(oms) + _run_OMPath_write_file(oms) @skip_on_windows @skip_python_older_312 -def test_OMCPath_OMCProcessDocker(): +def test_OMCPath_OMCSessionDocker(): omcs = OMPython.OMCSessionDocker(docker="openmodelica/openmodelica:v1.25.0-minimal") omversion = omcs.sendExpression("getVersion()") assert isinstance(omversion, str) and omversion.startswith("OpenModelica") - _run_OMCPath_checks(omcs) - - del omcs + _run_OMPath_checks(omcs) + _run_OMPath_write_file(omcs) @pytest.mark.skip(reason="Not able to run WSL on github") @skip_python_older_312 -def test_OMCPath_OMCProcessWSL(): - omcs = OMPython.OMCSessionWSL( +def test_OMCPath_OMCSessionWSL(): + oms = OMPython.OMCSessionWSL( wsl_omc='omc', wsl_user='omc', timeout=30.0, ) - _run_OMCPath_checks(omcs) + _run_OMPath_checks(oms) + _run_OMPath_write_file(oms) + + +@skip_python_older_312 +def test_OMPathLocal_OMSessionRunner(): + oms = OMPython.OMSessionRunner() + + _run_OMPath_checks(oms) + _run_OMPath_write_file(oms) + + +@skip_on_windows +@skip_python_older_312 +def test_OMPathBash_OMSessionRunner(): + oms = OMPython.OMSessionRunner( + ompath_runner=OMPython.OMPathRunnerBash, + ) + + _run_OMPath_checks(oms) + _run_OMPath_write_file(oms) + + +@skip_on_windows +@skip_python_older_312 +def test_OMPathBash_OMSessionRunner_Docker(): + oms_docker = OMPython.OMCSessionDocker(docker="openmodelica/openmodelica:v1.25.0-minimal") + omversion = oms_docker.sendExpression("getVersion()") + assert isinstance(omversion, str) and omversion.startswith("OpenModelica") + + oms = OMPython.OMSessionRunner( + cmd_prefix=oms_docker.get_cmd_prefix(), + ompath_runner=OMPython.OMPathRunnerBash, + ) + + _run_OMPath_checks(oms) + _run_OMPath_write_file(oms) - del omcs +@pytest.mark.skip(reason="Not able to run WSL on github") +@skip_python_older_312 +def test_OMPathBash_OMSessionRunner_WSL(): + oms_docker = OMPython.OMCSessionWSL() + omversion = oms_docker.sendExpression("getVersion()") + assert isinstance(omversion, str) and omversion.startswith("OpenModelica") + + oms = OMPython.OMSessionRunner( + cmd_prefix=oms_docker.get_cmd_prefix(), + ompath_runner=OMPython.OMPathRunnerBash, + ) + + _run_OMPath_checks(oms) + _run_OMPath_write_file(oms) -def _run_OMCPath_checks(omcs: OMPython.OMCSessionABC): - p1 = omcs.omcpath_tempdir() + +def _run_OMPath_checks(om: OMPython.OMSessionABC): + p1 = om.omcpath_tempdir() p2 = p1 / 'test' p2.mkdir() assert p2.is_dir() @@ -59,8 +115,8 @@ def _run_OMCPath_checks(omcs: OMPython.OMCSessionABC): assert p3.write_text('test') assert p3.is_file() assert p3.size() > 0 - p3 = p3.resolve().absolute() - assert str(p3) == str((p2 / 'test.txt').resolve().absolute()) + p3 = p3.resolve() + assert str(p3) == str((p2 / 'test.txt').resolve()) assert p3.read_text() == "test" assert p3.is_file() assert p3.parent.is_dir() @@ -68,15 +124,11 @@ def _run_OMCPath_checks(omcs: OMPython.OMCSessionABC): assert p3.is_file() is False -def test_OMCPath_write_file(tmpdir): - omcs = OMPython.OMCSessionLocal() - +def _run_OMPath_write_file(om: OMPython.OMSessionABC): data = "abc # \\t # \" # \\n # xyz" - p1 = omcs.omcpath_tempdir() + p1 = om.omcpath_tempdir() p2 = p1 / 'test.txt' p2.write_text(data=data) assert data == p2.read_text() - - del omcs diff --git a/tests/test_OMParser.py b/tests/test_OMParser.py index 875604e5..9dca784d 100644 --- a/tests/test_OMParser.py +++ b/tests/test_OMParser.py @@ -1,6 +1,6 @@ -from OMPython import OMParser +import OMPython -typeCheck = OMParser.typeCheck +parser = OMPython.OMParser.om_parser_basic def test_newline_behaviour(): @@ -8,31 +8,38 @@ def test_newline_behaviour(): def test_boolean(): - assert typeCheck('TRUE') is True - assert typeCheck('True') is True - assert typeCheck('true') is True - assert typeCheck('FALSE') is False - assert typeCheck('False') is False - assert typeCheck('false') is False + assert parser('TRUE') is True + assert parser('True') is True + assert parser('true') is True + assert parser('FALSE') is False + assert parser('False') is False + assert parser('false') is False def test_int(): - assert typeCheck('2') == 2 - assert type(typeCheck('1')) == int - assert type(typeCheck('123123123123123123232323')) == int - assert type(typeCheck('9223372036854775808')) == int + assert parser('2') == 2 + assert type(parser('1')) == int + assert type(parser('123123123123123123232323')) == int + assert type(parser('9223372036854775808')) == int def test_float(): - assert type(typeCheck('1.2e3')) == float + assert type(parser('1.2e3')) == float -# def test_dict(): -# assert type(typeCheck('{"a": "b"}')) == dict +def test_dict(): + # TODO: why does it fail? + # assert type(parser('{"a": "b"}')) == dict + pass def test_ident(): - assert typeCheck('blabla2') == "blabla2" + assert parser('blabla2') == "blabla2" + + +def test_empty(): + # TODO: this differs from OMTypedParser + assert parser('') == {} def test_str(): @@ -41,3 +48,17 @@ def test_str(): def test_UnStringable(): pass + + +# def test_everything(): +# # this test used to be in OMTypedParser.py's main() +# testdata = """ +# (1.0,{{1,true,3},{"4\\" +# ",5.9,6,NONE ( )},record ABC +# startTime = ErrorLevel.warning, +# 'stop*Time' = SOME(1.0) +# end ABC;}) +# """ +# expected = (1.0, ((1, True, 3), ('4"\n', 5.9, 6, None), {"'stop*Time'": 1.0, 'startTime': 'ErrorLevel.warning'})) +# results = parser(testdata) +# assert results == expected diff --git a/tests/test_OMTypedParser.py b/tests/test_OMTypedParser.py new file mode 100644 index 00000000..94a14210 --- /dev/null +++ b/tests/test_OMTypedParser.py @@ -0,0 +1,65 @@ +import OMPython + +parser = OMPython.OMTypedParser.om_parser_typed + + +def test_newline_behaviour(): + pass + + +def test_boolean(): + # TODO: why does these fail? + # assert parser('TRUE') is True + # assert parser('True') is True + assert parser('true') is True + # TODO: why does these fail? + # assert parser('FALSE') is False + # assert parser('False') is False + assert parser('false') is False + + +def test_int(): + assert parser('2') == 2 + assert type(parser('1')) == int + assert type(parser('123123123123123123232323')) == int + assert type(parser('9223372036854775808')) == int + + +def test_float(): + assert type(parser('1.2e3')) == float + + +def test_dict(): + # TODO: why does it fail? + # assert type(parser('{"a": "b"}')) == dict + pass + + +def test_ident(): + assert parser('blabla2') == "blabla2" + + +def test_empty(): + assert parser('') is None + + +def test_str(): + pass + + +def test_UnStringable(): + pass + + +def test_everything(): + # this test used to be in OMTypedParser.py's main() + testdata = """ + (1.0,{{1,true,3},{"4\\" +",5.9,6,NONE ( )},record ABC + startTime = ErrorLevel.warning, + 'stop*Time' = SOME(1.0) +end ABC;}) + """ + expected = (1.0, ((1, True, 3), ('4"\n', 5.9, 6, None), {"'stop*Time'": 1.0, 'startTime': 'ErrorLevel.warning'})) + results = parser(testdata) + assert results == expected diff --git a/tests/test_ZMQ.py b/tests/test_ZMQ.py index 1302a79d..89a8387b 100644 --- a/tests/test_ZMQ.py +++ b/tests/test_ZMQ.py @@ -1,5 +1,6 @@ import pathlib import os + import pytest import OMPython diff --git a/tests/test_docker.py b/tests/test_docker.py index a1acfbe1..50d2763a 100644 --- a/tests/test_docker.py +++ b/tests/test_docker.py @@ -1,5 +1,7 @@ import sys + import pytest + import OMPython skip_on_windows = pytest.mark.skipif( diff --git a/tests/test_typedParser.py b/tests/test_typedParser.py deleted file mode 100644 index 8e74a556..00000000 --- a/tests/test_typedParser.py +++ /dev/null @@ -1,53 +0,0 @@ -from OMPython import OMTypedParser - -typeCheck = OMTypedParser.om_parser_typed - - -def test_newline_behaviour(): - pass - - -def test_boolean(): - assert typeCheck('true') is True - assert typeCheck('false') is False - - -def test_int(): - assert typeCheck('2') == 2 - assert type(typeCheck('1')) == int - assert type(typeCheck('123123123123123123232323')) == int - assert type(typeCheck('9223372036854775808')) == int - - -def test_float(): - assert type(typeCheck('1.2e3')) == float - - -def test_ident(): - assert typeCheck('blabla2') == "blabla2" - - -def test_empty(): - assert typeCheck('') is None - - -def test_str(): - pass - - -def test_UnStringable(): - pass - - -def test_everything(): - # this test used to be in OMTypedParser.py's main() - testdata = """ - (1.0,{{1,true,3},{"4\\" -",5.9,6,NONE ( )},record ABC - startTime = ErrorLevel.warning, - 'stop*Time' = SOME(1.0) -end ABC;}) - """ - expected = (1.0, ((1, True, 3), ('4"\n', 5.9, 6, None), {"'stop*Time'": 1.0, 'startTime': 'ErrorLevel.warning'})) - results = typeCheck(testdata) - assert results == expected