diff --git a/.github/workflows/ci_cd.yml b/.github/workflows/ci_cd.yml index d394d16d8..4336cd2b7 100644 --- a/.github/workflows/ci_cd.yml +++ b/.github/workflows/ci_cd.yml @@ -139,14 +139,13 @@ jobs: path: mechanical_tests_log-${{ matrix.mechanical-version }}.txt retention-days: 7 - embedding-tests: name: Embedding testing and coverage runs-on: ubuntu-latest + # needs: [smoke-tests] container: image: ghcr.io/ansys/mechanical:23.2.0 options: --entrypoint /bin/bash - needs: [smoke-tests] strategy: fail-fast: false matrix: @@ -172,9 +171,11 @@ jobs: ANSYSCL232_DIR: /install/ansys_inc/v232/licensingclient ANSYSLMD_LICENSE_FILE: 1055@${{ secrets.LICENSE_SERVER }} ANSYS_WORKBENCH_LOGGING_CONSOLE: 0 + ANSYS_WORKBENCH_LOGGING: 0 + ANSYS_WORKBENCH_LOGGING_FILTER_LEVEL: 2 NUM_CORES: 1 run: | - /install/ansys_inc/v232/aisol/.workbench_lite pytest -m embedding > pytest_output.txt || true + /install/ansys_inc/v232/aisol/.workbench_lite pytest -m embedding -k logging > pytest_output.txt || true cat pytest_output.txt # # Check if failure occurred @@ -386,7 +387,6 @@ jobs: path: .coverage-combined retention-days: 7 - package: name: Package library needs: [tests, embedding-tests, docs] diff --git a/doc/source/examples/pymechanical_examples_repo/index.rst b/doc/source/examples/pymechanical_examples_repo/index.rst index cf4586b10..86a014c89 100644 --- a/doc/source/examples/pymechanical_examples_repo/index.rst +++ b/doc/source/examples/pymechanical_examples_repo/index.rst @@ -1,19 +1,18 @@ .. _ref_pymechanical_examples_repository: -PyMechanical remote sessions examples repository +PyMechanical remote session examples repository ================================================ -Remote sessions examples are hosted in the `PyMechanical Examples repository `_. +Remote session examples are hosted in the `PyMechanical Examples repository `_. The documentation for these examples can be found in the `PyMechanical Examples documentation `_. .. === REMOTE SESSIONS EXAMPLES === -Remote Sessions examples +Remote session examples ------------------------ -Remote sessions examples demonstrate the basic simulation capabilities of Mechanical using remote sessions. +Remote session examples demonstrate the basic simulation capabilities of Mechanical using remote sessions. -The documentation to these examples can be found in the -`Remote sessions examples `_ section. +For more information, see `Remote sessions examples `_. diff --git a/doc/source/user_guide_embedding/index.rst b/doc/source/user_guide_embedding/index.rst index 1738a8668..62209155e 100644 --- a/doc/source/user_guide_embedding/index.rst +++ b/doc/source/user_guide_embedding/index.rst @@ -17,6 +17,7 @@ an instance of Mechanical in Python. configuration globals + logging Overview @@ -50,11 +51,16 @@ By default, an instance of the :class:`Application ` class to +configure logging to the standard output for all warning messages and above (which are error and fatal messages). +For example: + +.. code:: python + + import ansys.mechanical.core as mech + from ansys.mechanical.core.embedding.logger import Configuration, Logger + + Configuration.configure(level=logging.WARNING, to_stdout=True) + _ = mech.App() + +After the embedded application has been created, you can write messages to the same +log using the :class:`Logger ` class like this: + +.. code:: python + + from ansys.mechanical.core.embedding.logger import Logger + + Logger.error("message") diff --git a/doc/styles/Vocab/ANSYS/accept.txt b/doc/styles/Vocab/ANSYS/accept.txt index 0630d783a..7dbac5c39 100644 --- a/doc/styles/Vocab/ANSYS/accept.txt +++ b/doc/styles/Vocab/ANSYS/accept.txt @@ -26,6 +26,7 @@ Windows windows WSL wsl +stdout CPython namespaces Globals diff --git a/src/ansys/mechanical/core/embedding/__init__.py b/src/ansys/mechanical/core/embedding/__init__.py index 141c9db22..60fe5b7d4 100644 --- a/src/ansys/mechanical/core/embedding/__init__.py +++ b/src/ansys/mechanical/core/embedding/__init__.py @@ -2,4 +2,3 @@ from .app import App from .config import Configuration from .imports import global_variables -from .logging import Logging diff --git a/src/ansys/mechanical/core/embedding/app.py b/src/ansys/mechanical/core/embedding/app.py index 2ffe030b3..a5c9b6134 100644 --- a/src/ansys/mechanical/core/embedding/app.py +++ b/src/ansys/mechanical/core/embedding/app.py @@ -55,8 +55,7 @@ def __init__(self, db_file=None, **kwargs): self._app = Ansys.Mechanical.Embedding.Application(db_file) runtime.initialize(self._version) self._disposed = False - if len(INSTANCES) == 0: - atexit.register(_dispose_embedded_app, INSTANCES) + atexit.register(_dispose_embedded_app, INSTANCES) INSTANCES.append(self) def __repr__(self): diff --git a/src/ansys/mechanical/core/embedding/initializer.py b/src/ansys/mechanical/core/embedding/initializer.py index 67851f0e6..a3c04790c 100644 --- a/src/ansys/mechanical/core/embedding/initializer.py +++ b/src/ansys/mechanical/core/embedding/initializer.py @@ -74,11 +74,12 @@ def initialize(version=None): if INITIALIZED_VERSION != None: assert INITIALIZED_VERSION == version return - INITIALIZED_VERSION = version if version == None: version = _get_default_version() + INITIALIZED_VERSION = version + __disable_sec() # need to add system path in order to import the assembly with the resolver diff --git a/src/ansys/mechanical/core/embedding/logger/__init__.py b/src/ansys/mechanical/core/embedding/logger/__init__.py new file mode 100644 index 000000000..e2dce341a --- /dev/null +++ b/src/ansys/mechanical/core/embedding/logger/__init__.py @@ -0,0 +1,194 @@ +"""Embedding logger. + +Module to interact with the built-in logging system of Mechanical. + +Usage +----- + +Configuring logger +~~~~~~~~~~~~~~~~~~ + +Configuring the logger can be done using the :class:`Configuration ` class: + +.. code:: python + import ansys.mechanical.core as mech + from ansys.mechanical.core.embedding.logger import Configuration, Logger + + Configuration.configure(level=logging.INFO, to_stdout=True, base_directory=None) + app = mech.App(version=241) + +Then, the :class:`Logger ` class can be used to write messages to the log: + +.. code:: python + + Logger.error("message") + + +""" + +import logging +import os +import typing + +from ansys.mechanical.core.embedding import initializer +from ansys.mechanical.core.embedding.logger import environ, linux_api, sinks, windows_api + +LOGGING_SINKS: typing.Set[int] = set() +LOGGING_CONTEXT: str = "PYMECHANICAL" + + +def _get_backend() -> ( + typing.Union[windows_api.APIBackend, linux_api.APIBackend, environ.EnvironBackend] +): + """Get the appropriate logger backend. + + Before embedding is initialized, logging is configured via environment variables. + After embedding is initialized, logging is configured by making API calls into the + Mechanical logging system. + + However, the API is mostly the same in both cases, though some methods only work + in one of the two backends. + + Setting the base directory only works before initializing. + Actually logging a message or flushing the log only works after initializing. + """ + # TODO - use abc instead of a union type? + embedding_initialized = initializer.INITIALIZED_VERSION != None + if not embedding_initialized: + return environ.EnvironBackend() + if os.name == "nt": + return windows_api.APIBackend() + return linux_api.APIBackend() + + +class Configuration: + """Configures logger for Mechanical embedding.""" + + @classmethod + def configure(cls, level=logging.WARNING, directory=None, base_directory=None, to_stdout=True): + """Configure the logger for PyMechanical embedding. + + Parameters + ---------- + level : int, optional + Level of logging that is defined in the ``logging`` package. The default is 'DEBUG'. + Options are ``"DEBUG"``, ``"INFO"``, ``"WARNING"``, and ``"ERROR"``. + directory : str, optional + Directory to write log file to. The default is ``None``, but by default the log + will appear somewhere in the system temp folder. + base_directory: str, optional + Base directory to write log files to. Each instance of Mechanical will write its + log to a time-stamped subfolder within this directory. This is only possible to set + before Mechanical is initialized. + to_stdout : bool, optional + Whether to write log messages to the standard output, which is the + command line. The default is ``True``. + """ + # Set up the global log configuration. + cls.set_log_directory(directory) + cls.set_log_base_directory(base_directory) + + # Set up the sink-specific log configuration and store to global state. + cls._store_stdout_sink_enabled(to_stdout) + file_sink_enabled = directory != None or base_directory != None + cls._store_file_sink_enabled(file_sink_enabled) + + # Commit the sink-specific log configuration global state to the backend. + cls._commit_enabled_configuration() + cls.set_log_level(level) + + @classmethod + def set_log_to_stdout(cls, value: bool) -> None: + """Configure logging to write to the standard output.""" + cls._store_stdout_sink_enabled(value) + cls._commit_enabled_configuration() + + @classmethod + def set_log_to_file(cls, value: bool) -> None: + """Configure logging to write to a file.""" + cls._store_file_sink_enabled(value) + cls._commit_enabled_configuration() + + @classmethod + def set_log_level(cls, level: int) -> None: + """Set the log level for all configured sinks.""" + if len(LOGGING_SINKS) == 0: + raise Exception("No logging backend configured!") + cls._commit_level_configuration(level) + + @classmethod + def set_log_directory(cls, value: str) -> None: + """Configure logging to write to a directory.""" + if value == None: + return + _get_backend().set_directory(value) + + @classmethod + def set_log_base_directory(cls, directory: str) -> None: + """Configure logging to write in a time-stamped subfolder in this directory.""" + if directory == None: + return + _get_backend().set_base_directory(directory) + + @classmethod + def _commit_level_configuration(cls, level: int) -> None: + for sink in LOGGING_SINKS: + _get_backend().set_log_level(level, sink) + + @classmethod + def _commit_enabled_configuration(cls) -> None: + for sink in LOGGING_SINKS: + _get_backend().enable(sink) + + @classmethod + def _store_stdout_sink_enabled(cls, value: bool) -> None: + if value: + LOGGING_SINKS.add(sinks.StandardSinks.CONSOLE) + else: + LOGGING_SINKS.discard(sinks.StandardSinks.CONSOLE) + + @classmethod + def _store_file_sink_enabled(cls, value: bool) -> None: + if value: + LOGGING_SINKS.add(sinks.StandardSinks.STANDARD_LOG_FILE) + else: + LOGGING_SINKS.discard(sinks.StandardSinks.STANDARD_LOG_FILE) + + +class Logger: + """Provides the ``Logger`` class for embedding.""" + + @classmethod + def flush(cls): + """Flush the log.""" + _get_backend().flush() + + @classmethod + def can_log_message(cls, level: int) -> bool: + """Get whether a message at this level is logged.""" + return _get_backend().can_log_message(level) + + @classmethod + def debug(cls, msg: str): + """Write a debug message to the log.""" + _get_backend().log_message(logging.DEBUG, LOGGING_CONTEXT, msg) + + @classmethod + def error(cls, msg: str): + """Write a error message to the log.""" + _get_backend().log_message(logging.ERROR, LOGGING_CONTEXT, msg) + + @classmethod + def info(cls, msg: str): + """Write an info message to the log.""" + _get_backend().log_message(logging.INFO, LOGGING_CONTEXT, msg) + + @classmethod + def warning(cls, msg: str): + """Write a warning message to the log.""" + _get_backend().log_message(logging.WARNING, LOGGING_CONTEXT, msg) + + @classmethod + def fatal(cls, msg: str): + """Write a fatal message to the log.""" + _get_backend().log_message(logging.FATAL, LOGGING_CONTEXT, msg) diff --git a/src/ansys/mechanical/core/embedding/logger/environ.py b/src/ansys/mechanical/core/embedding/logger/environ.py new file mode 100644 index 000000000..10ce13dc5 --- /dev/null +++ b/src/ansys/mechanical/core/embedding/logger/environ.py @@ -0,0 +1,101 @@ +"""Environment variables for mechanical logging.""" + +import logging +import os + +from ansys.mechanical.core.embedding.logger import sinks + + +class EnvironBackend: + """Provides the environment variable backend for Mechanical logging.""" + + def flush(self): + """Flush the log.""" + raise Exception("The embedding log cannot be flushed until Mechanical is initialized.") + + def enable(self, sink: int = sinks.StandardSinks.CONSOLE): + """Enable the given sink.""" + os.environ["ANSYS_WORKBENCH_LOGGING"] = "1" + if sink == sinks.StandardSinks.CONSOLE: + os.environ["ANSYS_WORKBENCH_LOGGING_CONSOLE"] = "1" + elif sink == sinks.StandardSinks.WINDOWS_DEBUGGER: + os.environ["ANSYS_WORKBENCH_LOGGING_DEBUGGER"] = "1" + elif sink == sinks.StandardSinks.WINDOWS_ERROR_MESSAGE_BOX: + os.environ["ANSYS_WORKBENCH_LOGGING_ERROR_MESSAGE_BOX"] = "1" + elif sink == sinks.StandardSinks.WINDOWS_FATAL_MESSAGE_BOX: + os.environ["ANSYS_WORKBENCH_LOGGING_FATAL_MESSAGE_BOX"] = "1" + + def disable(self, sink: int = sinks.StandardSinks.CONSOLE): + """Disable the log level for this sink.""" + if sink == sinks.StandardSinks.CONSOLE: + os.environ["ANSYS_WORKBENCH_LOGGING_CONSOLE"] = "0" + elif sink == sinks.StandardSinks.WINDOWS_DEBUGGER: + os.environ["ANSYS_WORKBENCH_LOGGING_DEBUGGER"] = "0" + elif sink == sinks.StandardSinks.WINDOWS_ERROR_MESSAGE_BOX: + os.environ["ANSYS_WORKBENCH_LOGGING_ERROR_MESSAGE_BOX"] = "0" + elif sink == sinks.StandardSinks.WINDOWS_FATAL_MESSAGE_BOX: + os.environ["ANSYS_WORKBENCH_LOGGING_FATAL_MESSAGE_BOX"] = "0" + else: + # only disable global logging if none of the sinks match? + os.environ["ANSYS_WORKBENCH_LOGGING"] = "0" + + def set_log_level(self, level: int, sink: int = sinks.StandardSinks.CONSOLE): + """Set the log level for this sink based on the Python log level.""" + if level == logging.NOTSET: + self.disable(sink) + return + + if level <= logging.DEBUG: + # level 0 in workbench logging is trace, level 1 is debug. + # python logging.DEBUG will imply trace. + os.environ["ANSYS_WORKBENCH_LOGGING_FILTER_LEVEL"] = "0" + elif level <= logging.INFO: + os.environ["ANSYS_WORKBENCH_LOGGING_FILTER_LEVEL"] = "2" + elif level <= logging.WARNING: + os.environ["ANSYS_WORKBENCH_LOGGING_FILTER_LEVEL"] = "3" + elif level <= logging.ERROR: + os.environ["ANSYS_WORKBENCH_LOGGING_FILTER_LEVEL"] = "4" + elif level <= logging.CRITICAL: + os.environ["ANSYS_WORKBENCH_LOGGING_FILTER_LEVEL"] = "5" + + def set_auto_flush(self, flush: bool) -> None: + """Set whether to auto flush to the standard log file.""" + value = "1" if flush else "0" + os.environ["ANSYS_WORKBENCH_LOGGING_AUTO_FLUSH"] = value + + def set_directory(self, directory: str) -> None: + """Set the location to write the log file to.""" + os.environ["ANSYS_WORKBENCH_LOGGING_DIRECTORY"] = directory + + def set_base_directory(self, base_directory: str) -> None: + """Set the base location to write the log file to. + + The base directory contains time-stamped subfolders where the log file + is actually written to. If a base directory is set, it takes precedence over the + ``set_directory`` location. + """ + os.environ["ANSYS_WORKBENCH_LOGGING_BASE_DIRECTORY"] = base_directory + + def can_log_message(self, level: int) -> bool: + """Return whether a message with the given severity is outputted to the log.""" + if os.environ.get("ANSYS_WORKBENCH_LOGGING", 0) == 0: + return False + + wb_int_level = int(os.environ.get("ANSYS_WORKBENCH_LOGGING_FILTER_LEVEL", 2)) + if wb_int_level == 0: + return True + if wb_int_level == 1: + # This is not exactly right. WB might be set to DEBUG but the level expected is trace. + return True + if wb_int_level == 2: + return level >= logging.INFO + if wb_int_level == 3: + return level >= logging.WARNING + if wb_int_level == 4: + return level >= logging.ERROR + if wb_int_level == 5: + return level >= logging.CRITICAL + + def log_message(self, level: int, context: str, message: str) -> None: + """Log the message to the configured logging mechanism.""" + raise Exception("Can't log to the embedding logger until Mechanical is initialized.") diff --git a/src/ansys/mechanical/core/embedding/logger/linux_api.py b/src/ansys/mechanical/core/embedding/logger/linux_api.py new file mode 100644 index 000000000..8c490ec67 --- /dev/null +++ b/src/ansys/mechanical/core/embedding/logger/linux_api.py @@ -0,0 +1,134 @@ +"""Internal Mechanical logging Linux API. + +.. note:: + The Linux API is needed for version 2023 R2, where the .NET backend is Windows-only. + This API does not support some options, namely the base directory and log filename. + +""" + +import ctypes +import logging +import os + +from ansys.mechanical.core.embedding import initializer +from ansys.mechanical.core.embedding.logger import sinks + + +def _get_dll(): + installdir = os.environ[f"AWP_ROOT{initializer.INITIALIZED_VERSION}"] + dll = ctypes.CDLL(os.path.join(installdir, "aisol/dll/linx64/libAns.Common.WBLogger.so")) + + dll.wb_logger_enable_sink.argtypes = [ctypes.c_int32] + + dll.wb_logger_disable_sink.argtypes = [ctypes.c_int32] + + dll.wb_logger_set_sink_filter_level.argtypes = [ctypes.c_int32, ctypes.c_int32] + + dll.wb_logger_flush.argtypes = [] + + dll.wb_logger_set_logging_auto_flush.argtypes = [ctypes.c_int8] + + dll.wb_logger_set_logging_directory.argtypes = [ctypes.c_char_p] + + dll.wb_logger_can_log_message.argtypes = [ctypes.c_int32] + dll.wb_logger_can_log_message.restype = ctypes.c_int32 + + dll.wb_logger_log_message.argtypes = [ctypes.c_int32, ctypes.c_char_p, ctypes.c_char_p] + return dll + + +def _get_sink_id(standard_sink_type: int) -> ctypes.c_int32: + """Convert standard sink type to sink id.""" + return { + sinks.StandardSinks.STANDARD_LOG_FILE: ctypes.c_int32(1), + sinks.StandardSinks.CONSOLE: ctypes.c_int32(2), + }[standard_sink_type] + + +def _str_to_utf8_ptr(value: str) -> ctypes.c_char_p: + return ctypes.create_string_buffer(value.encode()) + + +def _bool_to_single_byte_int(value: bool) -> ctypes.c_int8: + if value: + return ctypes.c_int8(1) + return ctypes.c_int8(0) + + +def _to_wb_logger_severity(level: int) -> ctypes.c_int32: + """Convert to internal integer.""" + if level <= logging.DEBUG: + # Level 0 in Workbench logging is trace. Level 1 is debug. + # Python logging.DEBUG implies trace. + return ctypes.c_int32(0) + elif level <= logging.INFO: + return ctypes.c_int32(2) + elif level <= logging.WARNING: + return ctypes.c_int32(3) + elif level <= logging.ERROR: + return ctypes.c_int32(4) + elif level <= logging.CRITICAL: + return ctypes.c_int32(5) + else: + # default to info + return ctypes.c_int32(2) + + +class APIBackend: + """Provides the API backend for the Mechanical logging system.""" + + def flush(self) -> None: + """Flush the log manually.""" + return _get_dll().wb_logger_flush() + + def enable(self, sink: int = sinks.StandardSinks.CONSOLE) -> None: + """Enable logging.""" + sinkid = _get_sink_id(sink) + _get_dll().wb_logger_enable_sink(sinkid) + + def disable(self, sink: int = sinks.StandardSinks.CONSOLE) -> None: + """Disable logging.""" + sinkid = _get_sink_id(sink) + _get_dll().wb_logger_disable_sink(sinkid) + + def set_log_level(self, level: int, sink: int = sinks.StandardSinks.CONSOLE) -> None: + """Set the log level for Mechanical based on the Python log level.""" + if level == logging.NOTSET: + self.disable(sink) + + sinkid = _get_sink_id(sink) + wb_level = _to_wb_logger_severity(level) + _get_dll().wb_logger_set_sink_filter_level(sinkid, wb_level) + + def set_auto_flush(self, flush: bool) -> None: + """Set whether to auto flush to the standard log file.""" + flag = _bool_to_single_byte_int(flush) + _get_dll().wb_logger_set_logging_auto_flush(flag) + + def set_directory(self, directory: str) -> None: + """Set the location to write the log file to.""" + value = _str_to_utf8_ptr(directory) + _get_dll().wb_logger_set_logging_directory(value) + + def set_base_directory(self, base_directory: str) -> None: + """Set the base location to write the log file to. + + The base directory contains time-stamped subfolders where the log file + is actually written to. If a base directory is set, it takes precedence over the + ``set_directory`` location. + + This does not have an API to set at runtime. + """ + raise Exception("Base directory can only be set before starting the Mechanical instance.") + + def can_log_message(self, level: int) -> bool: + """Return whether a message with the given severity is outputted to the log.""" + wb_level = _to_wb_logger_severity(level) + return bool(_get_dll().wb_logger_can_log_message(wb_level)) + + def log_message(self, level: int, context: str, message: str) -> None: + """Log the message to the configured logging mechanism.""" + wb_level = _to_wb_logger_severity(level) + wb_context = _str_to_utf8_ptr(context) + wb_message = _str_to_utf8_ptr(message) + _get_dll().wb_logger_log_message(wb_level, wb_context, wb_message) diff --git a/src/ansys/mechanical/core/embedding/logger/sinks.py b/src/ansys/mechanical/core/embedding/logger/sinks.py new file mode 100644 index 000000000..19a197926 --- /dev/null +++ b/src/ansys/mechanical/core/embedding/logger/sinks.py @@ -0,0 +1,11 @@ +"""Mechanical application logging sinks.""" + + +class StandardSinks: + """Provides built-in sinks.""" + + STANDARD_LOG_FILE = 0 + CONSOLE = 1 + WINDOWS_DEBUGGER = 2 + WINDOWS_ERROR_MESSAGE_BOX = 3 + WINDOWS_FATAL_MESSAGE_BOX = 4 diff --git a/src/ansys/mechanical/core/embedding/logger/windows_api.py b/src/ansys/mechanical/core/embedding/logger/windows_api.py new file mode 100644 index 000000000..947fb644e --- /dev/null +++ b/src/ansys/mechanical/core/embedding/logger/windows_api.py @@ -0,0 +1,110 @@ +"""Windows API for internal Mechanical logging. + +.. note:: + This API does not support some options, namely the base directory + and log filename. + +""" + +import logging + +from ansys.mechanical.core.embedding.logger import sinks + + +def _get_logger(): + import clr + + try: + clr.AddReference("Ans.Common.WB1ManagedUtils") + import Ansys + + return Ansys.Common.WB1ManagedUtils.Logger + except: + raise Exception("Logging cannot be used until after Mechanical embedding is initialized.") + + +def _get_sink_id(standard_sink_type: int) -> int: + """Convert standard sink type to sink ID.""" + sink_enum = _get_logger().StandardSinks + return { + sinks.StandardSinks.STANDARD_LOG_FILE: sink_enum.StandardLogFile, + sinks.StandardSinks.CONSOLE: sink_enum.Console, + sinks.StandardSinks.WINDOWS_DEBUGGER: sink_enum.WindowsDebugger, + sinks.StandardSinks.WINDOWS_ERROR_MESSAGE_BOX: sink_enum.WindowsErrorMessageBox, + sinks.StandardSinks.WINDOWS_FATAL_MESSAGE_BOX: sink_enum.WindowsFatalMessageBox, + }[standard_sink_type] + + +def _to_wb_logger_severity(level: int): + """Convert to internal enum.""" + if level <= logging.DEBUG: + # Level 0 in Workbench logging is trace. Level 1 is debug. + # Python logging.DEBUG implies trace. + return _get_logger().LoggerSeverity.Trace + elif level <= logging.INFO: + return _get_logger().LoggerSeverity.Info + elif level <= logging.WARNING: + return _get_logger().LoggerSeverity.Warning + elif level <= logging.ERROR: + return _get_logger().LoggerSeverity.Error + elif level <= logging.CRITICAL: + return _get_logger().LoggerSeverity.Fatal + else: + # default to info + return _get_logger().LoggerSeverity.Info + + +class APIBackend: + """Provides API backend for Mechanical logging system.""" + + def flush(self) -> None: + """Flush the log manually.""" + return _get_logger().Flush() + + def enable(self, sink: int = sinks.StandardSinks.CONSOLE) -> None: + """Enable logging.""" + sinkid = _get_sink_id(sink) + _get_logger().Configuration.EnableSink(sinkid) + + def disable(self, sink: int = sinks.StandardSinks.CONSOLE) -> None: + """Disable logging.""" + sinkid = _get_sink_id(sink) + _get_logger().Configuration.DisableSink(sinkid) + + def set_log_level(self, level: int, sink: int = sinks.StandardSinks.CONSOLE) -> None: + """Set the log level for Mechanical based on the Python log level.""" + if level == logging.NOTSET: + self.disable(sink) + + sinkid = _get_sink_id(sink) + wb_level = _to_wb_logger_severity(level) + _get_logger().Configuration.SetSinkFilterLevel(sinkid, wb_level) + + def set_auto_flush(self, flush: bool) -> None: + """Set whether to auto flush to the standard log file.""" + _get_logger().Configuration.StandardLogAutoFlush = flush + + def set_directory(self, directory: str) -> None: + """Set the location to write the log file to.""" + _get_logger().Configuration.StandardLogDirectory = directory + + def set_base_directory(self, base_directory: str) -> None: + """Set the base location to write the log file to. + + The base directory contains time-stamped subfolders where the log file + is actually written to. If a base directory is set, it takes precedence over the + ``set_directory`` location. + + This does not have an API to set at runtime. + """ + raise Exception("Base directory can only be set before starting the Mechanical instance.") + + def can_log_message(self, level: int) -> bool: + """Return whether a message with the given severity is outputted to the log.""" + wb_level = _to_wb_logger_severity(level) + return _get_logger().CanLogMessage(wb_level) + + def log_message(self, level: int, context: str, message: str) -> None: + """Log the message to the configured logging mechanism.""" + wb_level = _to_wb_logger_severity(level) + _get_logger().LogMessage(wb_level, context, message) diff --git a/src/ansys/mechanical/core/embedding/logging.py b/src/ansys/mechanical/core/embedding/logging.py deleted file mode 100644 index de54f527e..000000000 --- a/src/ansys/mechanical/core/embedding/logging.py +++ /dev/null @@ -1,68 +0,0 @@ -"""Logging module, used to setup logging options for embedded mechanical.""" -from pathlib import Path - - -def _get_embedding(): - import clr - - try: - clr.AddReference("Ansys.Mechanical.Embedding") - import Ansys - - return Ansys.Mechanical.Embedding - except: - raise Exception("Cannot use logging until after mechanical embedding is initialized") - - -class Logging: - """Logging class for embedded Mechanical.""" - - class Level: - """Logging level for Mechanical.""" - - TRACE = 0 - DEBUG = 1 - INFO = 2 - WARNING = 3 - ERROR = 4 - FATAL = 5 - - @classmethod - def config(cls, **kwargs): - """Similar to logging.basicConfig. - - Available options: - filename -> str - level -> Logging.Level values - auto_flush -> bool - enabled -> bool - stdout -> bool - """ - config = _get_embedding().LoggingConfiguration - if "filename" in kwargs: - path = Path(kwargs["filename"]) - config.Filename = path.name - config.Path = str(path.parent) - if "enabled" in kwargs: - config.Enabled = kwargs["enabled"] - if "auto_flush" in kwargs: - config.AutoFlush = kwargs["auto_flush"] - if "stdout" in kwargs: - config.LogToStdOut = kwargs["stdout"] - if "level" in kwargs: - config.FilterLevel = kwargs["level"] - - @classmethod - def log_message(cls, severity: Level, context: str, message: str) -> None: - """Log the message to the configured logging mechanism.""" - _get_embedding().Logging.LogMessage(severity, context, message) - - @classmethod - def can_log_message(cls, severity: Level) -> bool: - """Return whether a message with the given severity will be output into the log.""" - return _get_embedding().Logging.CanLogMessage(severity) - - @classmethod - def flush(cls) -> None: - """Flushes the log manually.""" - return _get_embedding().Logging.Flush() diff --git a/tests/conftest.py b/tests/conftest.py index 92683a757..921690748 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -133,9 +133,16 @@ def mke_app_reset(request): EMBEDDED_APP.new() +@pytest.fixture() +def rootdir(): + """Return the root directory of the local clone of the PyMechanical GitHub repository.""" + base = pathlib.Path(__file__).parent + yield base.parent + + @pytest.fixture() def test_env(): - """Create a virtual environment scoped to the test""" + """Create a virtual environment scoped to the test.""" venv_name = "test_env" base = pathlib.Path(__file__).parent @@ -160,8 +167,6 @@ class TestEnv: env = env_copy # python executable inside the environment python = os.path.join(venv_bin, exe_name) - # for convenience - the root directory of the pymechanical git repo - pymechanical_root = base.parent test_env_object = TestEnv() diff --git a/tests/embedding/test_app.py b/tests/embedding/test_app.py index b2d4a90b8..3b15fa9a9 100644 --- a/tests/embedding/test_app.py +++ b/tests/embedding/test_app.py @@ -57,13 +57,13 @@ def test_app_version(embedded_app): @pytest.mark.embedding @pytest.mark.python_env -def test_warning_message(test_env): - """Test pythonnet warning of the embedded instance, using a test-scoped python environment.""" +def test_warning_message(test_env, rootdir): + """Test Python.NET warning of the embedded instance using a test-scoped Python environment.""" # Install pymechanical subprocess.check_call( [test_env.python, "-m", "pip", "install", "-e", "."], - cwd=test_env.pymechanical_root, + cwd=rootdir, env=test_env.env, ) @@ -71,9 +71,7 @@ def test_warning_message(test_env): subprocess.check_call([test_env.python, "-m", "pip", "install", "pythonnet"], env=test_env.env) # Run embedded instance in virtual env with pythonnet installed - embedded_py = os.path.join( - test_env.pymechanical_root, "tests", "scripts", "run_embedded_app.py" - ) + embedded_py = os.path.join(rootdir, "tests", "scripts", "run_embedded_app.py") check_warning = subprocess.Popen( [test_env.python, embedded_py], stderr=subprocess.PIPE, diff --git a/tests/embedding/test_logger.py b/tests/embedding/test_logger.py new file mode 100644 index 000000000..7f468caec --- /dev/null +++ b/tests/embedding/test_logger.py @@ -0,0 +1,79 @@ +"""Miscellaneous embedding tests""" +import os +import subprocess +import sys + +import pytest + + +def _run_embedding_log_test_process(rootdir, testname) -> subprocess.Popen: + """Runs the process and returns it after it finishes""" + embedded_py = os.path.join(rootdir, "tests", "scripts", "embedding_log_test.py") + p = subprocess.Popen( + [sys.executable, embedded_py, testname], stderr=subprocess.PIPE, stdout=subprocess.PIPE + ) + p.wait() + return p + + +def _assert_success(process: subprocess.Popen, pass_expected: bool) -> int: + """Asserts the outcome of the process matches pass_expected""" + if os.name == "nt": + passing = process.returncode == 0 + assert passing == pass_expected + + # HACK! On linux, due to bug #85, there is always a crash on shutdown + # so instead there's a print("success") that happens after the test + # function runs, which will only be execution if the function doesn't + # throw. To check for the subprocess success, ensure that the stdout + # has "@@success@@" (a value written there in the subprocess after the + # test function runs) + stdout = process.stdout.read().decode() + if pass_expected: + assert "@@success@@" in stdout + else: + assert "@@success@@" not in stdout + + +def _run_embedding_log_test(rootdir: str, testname: str, pass_expected: bool = True) -> str: + """Test stderr logging using a subprocess. + + Also ensure that the subprocess either passes or fails based on pass_expected + Mechanical logging all goes into the process stderr at the C level, but capturing + that from python isn't possible because python's stderr stream isn't aware of content + that doesn't come from python (or its C/API) + + Returns the stderr + """ + p = _run_embedding_log_test_process(rootdir, testname) + stderr = p.stderr.read().decode() + _assert_success(p, pass_expected) + return stderr + + +@pytest.mark.embedding +def test_logging_write_log_before_init(rootdir): + """Test that an error is thrown when trying to log before initializing""" + stderr = _run_embedding_log_test(rootdir, "log_before_initialize", False) + assert "Can't log to the embedding logger until Mechanical is initialized" in stderr + + +@pytest.mark.embedding +def test_logging_write_info_after_initialize_with_error_level(rootdir): + """Test that no output is written when an info is logged when configured at the error level.""" + stderr = _run_embedding_log_test(rootdir, "log_info_after_initialize_with_error_level") + assert "0xdeadbeef" not in stderr + + +@pytest.mark.embedding +def test_logging_write_error_after_initialize_with_info_level(rootdir): + """Test that output is written when an error is logged when configured at the info level.""" + stderr = _run_embedding_log_test(rootdir, "log_error_after_initialize_with_info_level") + assert "Will no one rid me of this turbulent priest?" in stderr + + +@pytest.mark.embedding +def test_logging_level_before_and_after_initialization(rootdir): + """Test logging level API before and after initialization.""" + p = _run_embedding_log_test_process(rootdir, "log_check_can_log_message") + _assert_success(p, True) diff --git a/tests/scripts/embedding_log_test.py b/tests/scripts/embedding_log_test.py new file mode 100644 index 000000000..124740271 --- /dev/null +++ b/tests/scripts/embedding_log_test.py @@ -0,0 +1,55 @@ +"""Test cases for embedding logging.""" + +import logging +import sys + +import ansys.mechanical.core as mech +from ansys.mechanical.core.embedding.logger import Configuration, Logger + + +def log_before_initialize(): + """Write a log without initializing the embedded instance.""" + Logger.error("message") + + +def log_info_after_initialize_with_error_level(): + """Log at the info level after initializing with the error level.""" + Configuration.configure(level=logging.ERROR, to_stdout=True, base_directory=None) + _ = mech.App(version=232) + Logger.info("0xdeadbeef") + + +def log_error_after_initialize_with_info_level(): + """Log at the info level after initializing with the error level.""" + _ = mech.App(version=232) + Configuration.configure(level=logging.INFO, to_stdout=True, base_directory=None) + Logger.error("Will no one rid me of this turbulent priest?") + + +def log_check_can_log_message(): + """Configure logger before app initialization and check can_log_message.""" + Configuration.configure(level=logging.WARNING, to_stdout=True, base_directory=None) + assert Logger.can_log_message(logging.DEBUG) is False + assert Logger.can_log_message(logging.INFO) is False + assert Logger.can_log_message(logging.WARNING) is True + assert Logger.can_log_message(logging.ERROR) is True + assert Logger.can_log_message(logging.FATAL) is True + _ = mech.App(version=232) + Configuration.set_log_level(logging.INFO) + assert Logger.can_log_message(logging.DEBUG) is False + assert Logger.can_log_message(logging.INFO) is True + assert Logger.can_log_message(logging.WARNING) is True + assert Logger.can_log_message(logging.ERROR) is True + assert Logger.can_log_message(logging.FATAL) is True + + +if __name__ == "__main__": + test_name = sys.argv[1] + tests = { + "log_before_initialize": log_before_initialize, + "log_info_after_initialize_with_error_level": log_info_after_initialize_with_error_level, + "log_error_after_initialize_with_info_level": log_error_after_initialize_with_info_level, + "log_check_can_log_message": log_check_can_log_message, + } + tests[test_name]() + print("@@success@@") diff --git a/tests/test_mechanical.py b/tests/test_mechanical.py index 1542a2dfc..445f910c2 100644 --- a/tests/test_mechanical.py +++ b/tests/test_mechanical.py @@ -515,13 +515,13 @@ def test_launch_grpc_not_supported_version(): @pytest.mark.remote_session_launch @pytest.mark.python_env -def test_warning_message_pythonnet(test_env): - """Test pythonnet warning of the remote session in virtual env.""" +def test_warning_message_pythonnet(test_env, rootdir): + """Test Python.NET warning of the remote session in the virtual environment.""" # Install pymechanical subprocess.check_call( [test_env.python, "-m", "pip", "install", "-e", "."], - cwd=test_env.pymechanical_root, + cwd=rootdir, env=test_env.env, ) @@ -529,9 +529,7 @@ def test_warning_message_pythonnet(test_env): subprocess.check_call([test_env.python, "-m", "pip", "install", "pythonnet"], env=test_env.env) # Run remote session in virtual env with pythonnet installed - remote_py = os.path.join( - test_env.pymechanical_root, "tests", "scripts", "run_remote_session.py" - ) + remote_py = os.path.join(rootdir, "tests", "scripts", "run_remote_session.py") check_warning = subprocess.Popen( [test_env.python, remote_py], stderr=subprocess.PIPE,