diff --git a/doc/sphinx_gallery_tutorials/custom_operators_and_plugins/GALLERY_HEADER.rst b/doc/sphinx_gallery_tutorials/custom_operators_and_plugins/GALLERY_HEADER.rst index 76ce85189f3..d792ff39ff6 100644 --- a/doc/sphinx_gallery_tutorials/custom_operators_and_plugins/GALLERY_HEADER.rst +++ b/doc/sphinx_gallery_tutorials/custom_operators_and_plugins/GALLERY_HEADER.rst @@ -31,6 +31,19 @@ componentized, and easily distributable custom operators. +++ Requires DPF 7.1 or above (2024 R1). + .. grid-item-card:: Add logging to custom operators and plugins + :link: tutorials_custom_operators_and_plugins_log_in_custom_operator + :link-type: ref + :text-align: center + :class-header: sd-bg-light sd-text-dark + :class-footer: sd-bg-light sd-text-dark + + This tutorial shows how to use the DPF logging API to emit debug and error messages + from within a custom Python operator or plugin. + + +++ + Requires DPF 2027.1.0pre0 or above. + .. grid-item-card:: Create a DPF plugin with multiple operators :text-align: center :class-header: sd-bg-light sd-text-dark diff --git a/doc/sphinx_gallery_tutorials/custom_operators_and_plugins/custom_operators.py b/doc/sphinx_gallery_tutorials/custom_operators_and_plugins/custom_operators.py index fd992e1d423..0ab8ba28bf3 100644 --- a/doc/sphinx_gallery_tutorials/custom_operators_and_plugins/custom_operators.py +++ b/doc/sphinx_gallery_tutorials/custom_operators_and_plugins/custom_operators.py @@ -49,6 +49,12 @@ In this tutorial the DPF client API used is PyDPF-Core but, once recorded on the server, you can call the operators of the plugin using any of the DPF client APIs (C++, CPython, IronPython), as you would any other operator. + +.. note:: + + Starting with DPF 2027R1, you can add logging to custom Python operators to emit debug and + error messages. See :ref:`tutorials_custom_operators_and_plugins_log_in_custom_operator` + for details. """ ############################################################################### # Create a custom operator diff --git a/doc/sphinx_gallery_tutorials/custom_operators_and_plugins/log_in_custom_operator.py b/doc/sphinx_gallery_tutorials/custom_operators_and_plugins/log_in_custom_operator.py new file mode 100644 index 00000000000..7f0eb449ae3 --- /dev/null +++ b/doc/sphinx_gallery_tutorials/custom_operators_and_plugins/log_in_custom_operator.py @@ -0,0 +1,199 @@ +# Copyright (C) 2020 - 2026 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +# _order: 2 +""" +.. _tutorials_custom_operators_and_plugins_log_in_custom_operator: + +Add logging to custom operators and plugins +============================================ + +This tutorial shows how to use the DPF logging API to emit debug and error messages +from within a custom Python operator or plugin. + +You will learn how to register a logger with different log levels and output sinks, +and how to emit messages that are captured by the DPF framework's logging system. +This is useful for debugging custom operators and providing visibility into plugin +execution. + +.. note:: + + This tutorial requires DPF 2027.1.0pre0 or above. +""" +############################################################################### +# Import modules and define a custom operator with logging +# --------------------------------------------------------- +# +# To use logging in a custom operator, import the required DPF modules and the +# logging API from :mod:`ansys.dpf.core.dpf_logger`. +# +# This example creates a custom operator that logs its execution steps. + +from ansys.dpf import core as dpf +from ansys.dpf.core.custom_operator import CustomOperatorBase +from ansys.dpf.core.dpf_logger import ( + LoggerConfig, + LoggerSink, + LogLevel, + register_logger, +) +from ansys.dpf.core.operator_specification import ( + CustomSpecification, + PinSpecification, + SpecificationProperties, +) + + +class LoggingCustomOperator(CustomOperatorBase): + """Example of a custom DPF operator that uses logging.""" + + @property + def name(self): + """Return the scripting name of the operator.""" + return "my_logging_operator" + + @property + def specification(self) -> CustomSpecification: + """Create the specification of the custom operator.""" + spec = CustomSpecification() + spec.description = "A custom operator that demonstrates logging." + spec.inputs = { + 0: PinSpecification( + name="field_input", + type_names=[dpf.Field], + document="Input field to process.", + ), + } + spec.outputs = { + 0: PinSpecification( + name="result_field", type_names=[dpf.Field], document="Output field." + ), + } + spec.properties = SpecificationProperties( + user_name="my logging operator", + category="my_category", + ) + return spec + + def run(self): + """Run the operator with logging at different levels.""" + # Register a logger for this operator. + # Using info level and stdout sink for visibility. + logger_config = LoggerConfig( + level=LogLevel.debug, + sinks=[LoggerSink.stdout], + ) + my_logger = register_logger(name="my_operator", config=logger_config) + + my_logger.info("Operator execution started") + + # Get the input field + try: + field: dpf.Field = self.get_input(0, dpf.Field) + if field is None: + my_logger.error("No input field provided") + self.set_failed() + return + + my_logger.debug(f"Received field with {field.size} values") + except Exception as e: + my_logger.error(f"Failed to retrieve input field: {e}") + self.set_failed() + return + + # Process the field + my_logger.info("Processing field data") + + try: + # Create a new field as output + result_field = field.deep_copy() + my_logger.debug(f"Created output field with {result_field.size} values") + + # Set the output and mark the operator as succeeded + self.set_output(0, result_field) + my_logger.info("Operator execution completed successfully") + self.set_succeeded() + except Exception as e: + my_logger.error(f"Error during field processing: {e}") + self.set_failed() + + +############################################################################### +# Use multiple log levels +# ----------------------- +# +# The DPF logging API supports six log levels (trace, debug, info, warn, error, critical) +# for different severity levels. You can emit messages at any level depending on +# the importance and verbosity desired. + +example_logger_config = LoggerConfig( + level=LogLevel.debug, # Capture debug and above + sinks=[LoggerSink.stdout], +) + +# In a real operator's run() method, you would do: +# my_logger = register_logger(name="example_operator", config=example_logger_config) +# my_logger.trace("Very detailed tracing information") +# my_logger.debug("Debug information for troubleshooting") +# my_logger.info("Informational message about normal operation") +# my_logger.warn("Warning about potentially problematic condition") +# my_logger.error("Error that occurred during processing") +# my_logger.critical("Critical error requiring immediate attention") + +############################################################################### +# Configure logging with file output +# ----------------------------------- +# +# Instead of only logging to stdout, you can also write logs to a file +# by including :class:`LoggerSink.file ` in the sinks list. + +file_logger_config = LoggerConfig( + level=LogLevel.info, + sinks=[LoggerSink.stdout, LoggerSink.file], +) + +# In a real operator's run() method, you would do: +# my_logger = register_logger( +# name="file_logging_operator", +# config=file_logger_config, +# ) +# my_logger.info("This message appears in both stdout and the log file") + +############################################################################### +# Retrieve and flush existing loggers +# ------------------------------------------- +# +# If you need to access a logger that was already registered, +# use :func:`get_logger ` instead of +# :func:`register_logger `. + +# In a real operator's run() method: +# my_logger = get_logger(name="my_operator") +# my_logger.info("Retrieved existing logger") + +# To ensure all log messages are written immediately, call +# :func:`flush_all ` at the end +# of your operator: + +# from ansys.dpf.core.dpf_logger import flush_all +# my_logger.info("Final message before flush") +# flush_all() diff --git a/src/ansys/dpf/core/dpf_logger.py b/src/ansys/dpf/core/dpf_logger.py new file mode 100644 index 00000000000..3b90dad8f65 --- /dev/null +++ b/src/ansys/dpf/core/dpf_logger.py @@ -0,0 +1,248 @@ +# Copyright (C) 2020 - 2026 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +"""High-level access to DPF framework logging APIs. + +This module provides a Python wrapper around the native DPF logging APIs. +It is designed for use in custom Python plugins, where code executes in-process +with the DPF server and has direct access to native C-layer logging functions +through the CAPI. + +Note: This API is only available in custom Python plugins. Remote or +client-side Python environments cannot access these logging functions. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +from typing import Iterable, Optional + +from ansys.dpf.core import errors, server as server_module +from ansys.dpf.core.data_tree import DataTree +from ansys.dpf.gate import data_processing_capi + + +class LogLevel(Enum): + """DPF logging levels.""" + + trace = 0 + debug = 1 + info = 2 + warn = 3 + error = 4 + critical = 5 + off = 6 + + +class LoggerSink(Enum): + """DPF logging sinks.""" + + stdout = 0 + file = 1 + + +@dataclass +class LoggerConfig: + """Configuration used when registering a DPF logger.""" + + level: LogLevel = LogLevel.info + sinks: Optional[Iterable[LoggerSink]] = None + + def sink_values(self) -> list[int]: + """Return sink values as integers expected by C-layer APIs.""" + if self.sinks is None: + return [int(LoggerSink.stdout.value)] + return [_enum_or_int(value, LoggerSink, "sinks") for value in self.sinks] + + +class DPFLogger: + """Wrapper around a native DPF logger implementation pointer.""" + + def __init__(self, implementation, core_api): + self._implementation = implementation + self._core_api = core_api + + def log(self, message: str, level: LogLevel = LogLevel.info) -> None: + """Log one message with the given level.""" + self._core_api.data_processing_logging_log_message( + self._implementation, + message, + _enum_or_int(level, LogLevel, "level"), + ) + + def trace(self, message: str) -> None: + """Log message at trace level.""" + self.log(message, LogLevel.trace) + + def debug(self, message: str) -> None: + """Log message at debug level.""" + self.log(message, LogLevel.debug) + + def info(self, message: str) -> None: + """Log message at info level.""" + self.log(message, LogLevel.info) + + def warn(self, message: str) -> None: + """Log message at warn level.""" + self.log(message, LogLevel.warn) + + def error(self, message: str) -> None: + """Log message at error level.""" + self.log(message, LogLevel.error) + + def critical(self, message: str) -> None: + """Log message at critical level.""" + self.log(message, LogLevel.critical) + + def flush(self) -> None: + """Flush this logger sinks.""" + self._core_api.data_processing_logging_flush(self._implementation) + + +def register_logger(name: str, config: Optional[LoggerConfig] = None, server=None) -> DPFLogger: + """Register and return a DPF logger instance. + + Intended for use in custom Python plugins. + + Parameters + ---------- + name : str + Logger name to register. + config : LoggerConfig, optional + Logger configuration (level, sinks). Default is Info level, stdout sink. + server : DPFServer, optional + DPF server instance. If None, uses global server. + + Returns + ------- + DPFLogger + Logger wrapper bound to native DPF logger implementation. + + Raises + ------ + ServerTypeError + If called outside a custom Python plugin context or from a remote client. + """ + if config is None: + config = LoggerConfig() + server_instance, core_api = _server_and_api(server) + params = _build_logger_params( + server=server_instance, + logger_name=name, + log_level=_enum_or_int(config.level, LogLevel, "config.level"), + sinks=config.sink_values(), + ) + implementation = _call_api( + core_api.data_processing_logging_register_logger, + params, + operation="register_logger", + ) + return DPFLogger(implementation, core_api) + + +def get_logger(name: str, server=None) -> DPFLogger: + """Get an existing DPF logger by name. + + Intended for use in custom Python plugins. + + Parameters + ---------- + name : str + Logger name to retrieve. + server : DPFServer, optional + DPF server instance. If None, uses global server. + + Returns + ------- + DPFLogger + Logger wrapper bound to native DPF logger implementation. + + Raises + ------ + ServerTypeError + If called outside a custom Python plugin context or from a remote client. + """ + server_instance, core_api = _server_and_api(server) + params = _build_logger_params(server=server_instance, logger_name=name) + implementation = _call_api( + core_api.data_processing_logging_get_logger, + params, + operation="get_logger", + ) + return DPFLogger(implementation, core_api) + + +def flush_all(server=None) -> None: + """Flush all currently registered DPF loggers. + + Intended for use in custom Python plugins. + + Parameters + ---------- + server : DPFServer, optional + DPF server instance. If None, uses global server. + + Raises + ------ + ServerTypeError + If called outside a custom Python plugin context or from a remote client. + """ + _, core_api = _server_and_api(server) + _call_api(core_api.data_processing_logging_flush_all, operation="flush_all") + + +def _build_logger_params(server, **kwargs) -> DataTree: + params = DataTree(server=server) + params.add(kwargs) + return params + + +def _server_and_api(server): + server_instance = server_module.get_or_create_server(server) + core_api = server_instance.get_api_for_type( + capi=data_processing_capi.DataProcessingCAPI, + grpcapi=None, # Custom operators are always in-process, no gRPC support needed + ) + core_api.init_data_processing_environment(server_instance) + return server_instance, core_api + + +def _call_api(function, *args, operation: str): + """Call a logging API function, converting NotImplementedError to ServerTypeError.""" + try: + return function(*args) + except NotImplementedError as exc: + raise errors.ServerTypeError( + "DPF logger API is only available in custom Python plugins. " + f"The backend does not implement logging (during '{operation}'). " + "Ensure this code is executing within a custom Python plugin context." + ) from exc + + +def _enum_or_int(value, enum_type, argument_name: str) -> int: + """Convert enum or int to int, validating type.""" + if isinstance(value, enum_type): + return int(value.value) + if isinstance(value, int): + return value + raise TypeError(f"{argument_name} must be an int or {enum_type.__name__}.") diff --git a/tests/test_dpf_logger.py b/tests/test_dpf_logger.py new file mode 100644 index 00000000000..123223052cc --- /dev/null +++ b/tests/test_dpf_logger.py @@ -0,0 +1,155 @@ +# Copyright (C) 2020 - 2026 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# + +import pytest + +from ansys.dpf import core as dpf +from ansys.dpf.core import errors +import ansys.dpf.core.dpf_logger as dpf_logger + +import conftest + + +class _FakeDataTree: + def __init__(self, server=None): + self.server = server + self.attrs = {} + + def add(self, data): + self.attrs.update(data) + + +class _FakeAPI: + """Mock CAPI backend for in-process logger testing.""" + + def __init__(self): + self.last_registered_params = None + self.last_retrieved_params = None + self.logged_calls = [] + self.flush_all_called = False + self.flushed_impl = None + + def init_data_processing_environment(self, server): + self.server = server + + def data_processing_logging_register_logger(self, register_logger_params): + self.last_registered_params = register_logger_params + return "logger-impl" + + def data_processing_logging_get_logger(self, get_logger_params): + self.last_retrieved_params = get_logger_params + return "logger-impl" + + def data_processing_logging_log_message(self, logger_impl, message, log_level): + self.logged_calls.append((logger_impl, message, log_level)) + + def data_processing_logging_flush(self, logger_impl): + self.flushed_impl = logger_impl + + def data_processing_logging_flush_all(self): + self.flush_all_called = True + + +class _FakeServer: + def __init__(self, api): + self.api = api + + def get_api_for_type(self, capi=None, grpcapi=None): + return self.api + + +def _patch_server_and_tree(monkeypatch, api): + """Patch server factory and DataTree for testing.""" + fake_server = _FakeServer(api) + monkeypatch.setattr(dpf_logger.server_module, "get_or_create_server", lambda _: fake_server) + monkeypatch.setattr(dpf_logger, "DataTree", _FakeDataTree) + + +def test_register_logger_builds_expected_payload(monkeypatch): + """Test that register_logger constructs correct param tree.""" + api = _FakeAPI() + _patch_server_and_tree(monkeypatch, api) + + logger = dpf_logger.register_logger( + "custom.plugin", + dpf_logger.LoggerConfig( + level=dpf_logger.LogLevel.debug, + sinks=[dpf_logger.LoggerSink.stdout, dpf_logger.LoggerSink.file], + ), + ) + + assert isinstance(logger, dpf_logger.DPFLogger) + assert api.last_registered_params.attrs == { + "logger_name": "custom.plugin", + "log_level": 1, + "sinks": [0, 1], + } + + +def test_get_logger_and_log_message(monkeypatch): + """Test get_logger, message emission, and flush chain.""" + api = _FakeAPI() + _patch_server_and_tree(monkeypatch, api) + + logger = dpf_logger.get_logger("custom.plugin") + logger.log("test message", dpf_logger.LogLevel.warn) + logger.flush() + dpf_logger.flush_all() + + assert api.last_retrieved_params.attrs == {"logger_name": "custom.plugin"} + assert api.logged_calls == [("logger-impl", "test message", 3)] + assert api.flushed_impl == "logger-impl" + assert api.flush_all_called + + +def test_unsupported_backend_raises_server_type_error(monkeypatch): + """Test that NotImplementedError is converted to ServerTypeError.""" + + class _NotImplementedAPI(_FakeAPI): + def data_processing_logging_register_logger(self, register_logger_params): + raise NotImplementedError() + + api = _NotImplementedAPI() + _patch_server_and_tree(monkeypatch, api) + + try: + dpf_logger.register_logger("custom.plugin") + assert False, "Expected a ServerTypeError" + except errors.ServerTypeError as exc: + assert "custom Python plugin" in str(exc) + assert "implement" in str(exc).lower() + + +@pytest.mark.skipif( + not conftest.SERVERS_VERSION_GREATER_THAN_OR_EQUAL_TO_10_0, + reason="Requires DPF server >= 2025.2", +) +def test_logger_with_real_global_inprocess_server(): + """Validate logger API against the real global InProcess server.""" + server = dpf._global_server() + if not isinstance(server, dpf.server_types.InProcessServer): + pytest.skip("Global server is not InProcess in this environment") + + logger_name = "custom.plugin.real.integration_test" + + logger = dpf_logger.register_logger( + name=logger_name, + config=dpf_logger.LoggerConfig( + level=dpf_logger.LogLevel.debug, + sinks=[dpf_logger.LoggerSink.stdout], + ), + server=server, + ) + + assert isinstance(logger, dpf_logger.DPFLogger) + + logger.debug("debug message from real in-process logger test") + logger.info("info message from real in-process logger test") + + same_logger = dpf_logger.get_logger(name=logger_name, server=server) + assert isinstance(same_logger, dpf_logger.DPFLogger) + + same_logger.warn("warn message from real in-process logger test") + same_logger.flush() + dpf_logger.flush_all(server=server)