diff --git a/CMakeLists.txt b/CMakeLists.txt index 1643f96..5ed55c4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -37,10 +37,10 @@ set(CMAKE_WINDOWS_EXPORT_ALL_SYMBOLS ON) option (USE_PYTHON_SABI "Use Python stable ABI" ON) if (USE_PYTHON_SABI AND CMAKE_VERSION VERSION_GREATER_EQUAL 3.26) add_compile_definitions(Py_LIMITED_API) - find_package(Python3 REQUIRED COMPONENTS Development.SABIModule) + find_package(Python3 REQUIRED COMPONENTS Interpreter Development.SABIModule) add_library (Python3::Module ALIAS Python3::SABIModule) else () - find_package(Python3 REQUIRED COMPONENTS Development.Module) + find_package(Python3 REQUIRED COMPONENTS Interpreter Development.Module) endif () if (WIN32) diff --git a/pythonfmu/fmi2slave.py b/pythonfmu/fmi2slave.py index 1599142..84d56e5 100644 --- a/pythonfmu/fmi2slave.py +++ b/pythonfmu/fmi2slave.py @@ -12,6 +12,7 @@ from .default_experiment import DefaultExperiment from ._version import __version__ as VERSION from .enums import Fmi2Type, Fmi2Status, Fmi2Causality, Fmi2Initial, Fmi2Variability +from .log_categories_data import LOG_ALL_CATEGORY, LOG_ALL_DESCRIPTION, LOG_CATEGORIES from .variables import Boolean, Integer, Real, ScalarVariable, String ModelOptions = namedtuple("ModelOptions", ["name", "value", "cli"]) @@ -29,13 +30,11 @@ class Fmi2Slave(ABC): """Abstract facade class to execute Python through FMI standard.""" - # Dictionary of (category, description) entries + # Dictionary of (category, description) entries; canonical source is + # pythonfmu/log_categories_data.py, shared with the native library. log_categories: Dict[str, str] = { - "logStatusWarning": "Log messages with fmi2Warning status.", - "logStatusDiscard": "Log messages with fmi2Discard status.", - "logStatusError": "Log messages with fmi2Error status.", - "logStatusFatal": "Log messages with fmi2Fatal status.", - "logAll": "Log all messages." + **{name: description for _, name, description in LOG_CATEGORIES}, + LOG_ALL_CATEGORY: LOG_ALL_DESCRIPTION, } def __init__(self, **kwargs): diff --git a/pythonfmu/log_categories_data.py b/pythonfmu/log_categories_data.py new file mode 100644 index 0000000..c854ad9 --- /dev/null +++ b/pythonfmu/log_categories_data.py @@ -0,0 +1,22 @@ +"""Canonical fmi2Status -> log category mapping. + +Single source of truth shared with the native library: at CMake build time, +tools/generate_log_categories_header.py imports this module and generates +LogCategories.hpp (included by src/pythonfmu/Logger.hpp), so the compiled +library and pythonfmu.fmi2slave.Fmi2Slave.log_categories can never diverge. + +Edit only this file; the generated C++ header must not be hand-edited. +""" +from typing import List, Tuple + +# (fmi2Status C enumerator, category name, description) +LOG_CATEGORIES: List[Tuple[str, str, str]] = [ + ("fmi2Warning", "logStatusWarning", "Log messages with fmi2Warning status."), + ("fmi2Discard", "logStatusDiscard", "Log messages with fmi2Discard status."), + ("fmi2Error", "logStatusError", "Log messages with fmi2Error status."), + ("fmi2Fatal", "logStatusFatal", "Log messages with fmi2Fatal status."), +] + +# Fallback category used for statuses not covered above (e.g. fmi2OK, fmi2Pending). +LOG_ALL_CATEGORY = "logAll" +LOG_ALL_DESCRIPTION = "Log all messages." diff --git a/pythonfmu/tests/test_integration.py b/pythonfmu/tests/test_integration.py index 001b195..1970c9b 100644 --- a/pythonfmu/tests/test_integration.py +++ b/pythonfmu/tests/test_integration.py @@ -1,5 +1,6 @@ import math from pathlib import Path +from unittest.mock import MagicMock import pytest @@ -354,3 +355,46 @@ def test_integration_throw_py_error(tmp_path): with pytest.raises(Exception): fmpy.simulate_fmu(str(fmu), stop_time=1.0) + + +@pytest.mark.integration +@pytest.mark.parametrize( + "categories,expect_logged", + [(("logStatusFatal",), True), (("logStatusError",), False)], +) +def test_integration_throw_py_error_respects_log_categories(tmp_path, categories, expect_logged): + """A RuntimeError raised in do_step is caught in fmi2DoStep as fmi2Fatal and + logged with category 'logStatusFatal'; it must not be dropped just because it + originates from the internal C++ exception handler (regression test).""" + script_file = Path(__file__).parent / "slaves/PythonSlaveWithException.py" + fmu = FmuBuilder.build_FMU(script_file, dest=tmp_path) + assert fmu.exists() + + logger = MagicMock() + + callbacks = fmpy.fmi2.fmi2CallbackFunctions() + callbacks.logger = fmpy.fmi2.fmi2CallbackLoggerTYPE(logger) + callbacks.allocateMemory = fmpy.fmi2.fmi2CallbackAllocateMemoryTYPE(fmpy.calloc) + callbacks.freeMemory = fmpy.fmi2.fmi2CallbackFreeMemoryTYPE(fmpy.free) + + model_description = fmpy.read_model_description(str(fmu)) + unzip_dir = fmpy.extract(str(fmu)) + + model = fmpy.fmi2.FMU2Slave( + guid=model_description.guid, + unzipDirectory=unzip_dir, + modelIdentifier=model_description.coSimulation.modelIdentifier, + instanceName="instance1") + model.instantiate(callbacks=callbacks) + model.setDebugLogging(True, categories) + model.setupExperiment() + model.enterInitializationMode() + model.exitInitializationMode() + + with pytest.raises(Exception): + model.doStep(0.0, 0.1) + + fatal_calls = [ + c for c in logger.call_args_list if c.args[3] == b"logStatusFatal" + ] + assert (len(fatal_calls) == 1) == expect_logged diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 0495376..154efa4 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -17,10 +17,25 @@ set(sources "pythonfmu/PySlaveInstance.cpp" ) +# Generate LogCategories.hpp from the canonical pythonfmu/log_categories_data.py +# so the native library and the Python package share a single source of truth. +set(GENERATED_INCLUDE_DIR "${CMAKE_CURRENT_BINARY_DIR}/generated") +set(LOG_CATEGORIES_HEADER "${GENERATED_INCLUDE_DIR}/LogCategories.hpp") +add_custom_command( + OUTPUT "${LOG_CATEGORIES_HEADER}" + COMMAND Python3::Interpreter "${CMAKE_SOURCE_DIR}/tools/generate_log_categories_header.py" "${LOG_CATEGORIES_HEADER}" + DEPENDS "${CMAKE_SOURCE_DIR}/pythonfmu/log_categories_data.py" "${CMAKE_SOURCE_DIR}/tools/generate_log_categories_header.py" + COMMENT "Generating LogCategories.hpp from pythonfmu/log_categories_data.py" + VERBATIM +) +add_custom_target(generate_log_categories DEPENDS "${LOG_CATEGORIES_HEADER}") + add_library(pythonfmu-export ${sources} ${headers}) +add_dependencies(pythonfmu-export generate_log_categories) target_compile_features(pythonfmu-export PUBLIC "cxx_std_17") target_include_directories(pythonfmu-export PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}") +target_include_directories(pythonfmu-export PRIVATE "${GENERATED_INCLUDE_DIR}") target_link_libraries (pythonfmu-export PRIVATE Python3::Module) diff --git a/src/pythonfmu/Logger.hpp b/src/pythonfmu/Logger.hpp index 0054d0b..ba1446c 100644 --- a/src/pythonfmu/Logger.hpp +++ b/src/pythonfmu/Logger.hpp @@ -3,6 +3,7 @@ #define PYTHONFMU_LOGGER_HPP #include "fmi/fmi2Functions.h" +#include "LogCategories.hpp" #include #include @@ -25,10 +26,10 @@ class PyLogger categories_ = categories; } - // Logs a message. + // Logs a message, deriving its category from the status (mirrors Fmi2Slave.log in Python). void log(fmi2Status s, const std::string& message) { - log(s, "", message); + log(s, defaultCategoryForStatus(s), message); } void log(fmi2Status s, const std::string& category, const std::string& message) @@ -45,6 +46,21 @@ class PyLogger private: bool debugLogging_{false}; + // Mirrors the category derivation in Fmi2Slave.log (Python), generated from the + // shared status->category table in LogCategories.hpp so both sides stay in sync. + static std::string defaultCategoryForStatus(fmi2Status s) + { + switch (s) { +#define PYTHONFMU_LOG_CATEGORY(status, name, description) \ + case status: \ + return name; + PYTHONFMU_LOG_CATEGORIES +#undef PYTHONFMU_LOG_CATEGORY + default: + return PYTHONFMU_LOG_ALL_CATEGORY; + } + } + protected: std::string instanceName_; std::vector categories_; diff --git a/src/pythonfmu/fmi2.cpp b/src/pythonfmu/fmi2.cpp index 1706f28..9fb9b07 100644 --- a/src/pythonfmu/fmi2.cpp +++ b/src/pythonfmu/fmi2.cpp @@ -509,7 +509,6 @@ fmi2Status fmi2GetDirectionalDerivative( { static_cast(c)->logger->log( fmi2Error, - "cppfmu", "FMI function not supported: fmi2GetDirectionalDerivative"); return fmi2Error; } @@ -523,7 +522,6 @@ fmi2Status fmi2SetRealInputDerivatives( { static_cast(c)->logger->log( fmi2Error, - "cppfmu", "FMI function not supported: fmi2SetRealInputDerivatives"); return fmi2Error; } @@ -537,7 +535,6 @@ fmi2Status fmi2GetRealOutputDerivatives( { static_cast(c)->logger->log( fmi2Error, - "cppfmu", "FMI function not supported: fmiGetRealOutputDerivatives"); return fmi2Error; } diff --git a/tools/generate_log_categories_header.py b/tools/generate_log_categories_header.py new file mode 100644 index 0000000..13317cb --- /dev/null +++ b/tools/generate_log_categories_header.py @@ -0,0 +1,44 @@ +"""Generate the native LogCategories.hpp from pythonfmu/log_categories_data.py. + +Invoked automatically by CMake at build time (see src/CMakeLists.txt) so the +native library and the Python package always agree on the log category table. + +Usage: generate_log_categories_header.py +""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from pythonfmu.log_categories_data import ( # noqa: E402 + LOG_ALL_CATEGORY, + LOG_ALL_DESCRIPTION, + LOG_CATEGORIES, +) + + +def main(output_path: str) -> None: + entries = " \\\n".join( + f' PYTHONFMU_LOG_CATEGORY({status}, "{name}", "{description}")' + for status, name, description in LOG_CATEGORIES + ) + content = f"""// Auto-generated by tools/generate_log_categories_header.py from +// pythonfmu/log_categories_data.py -- do not edit by hand. +#ifndef PYTHONFMU_LOGCATEGORIES_HPP +#define PYTHONFMU_LOGCATEGORIES_HPP + +#define PYTHONFMU_LOG_CATEGORIES \\ +{entries} + +#define PYTHONFMU_LOG_ALL_CATEGORY "{LOG_ALL_CATEGORY}" +#define PYTHONFMU_LOG_ALL_DESCRIPTION "{LOG_ALL_DESCRIPTION}" + +#endif // PYTHONFMU_LOGCATEGORIES_HPP +""" + out = Path(output_path) + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text(content) + + +if __name__ == "__main__": + main(sys.argv[1])