From 4447cbb20193a1433dab5a8b3f3d1976224122f6 Mon Sep 17 00:00:00 2001 From: AnyCPU Date: Tue, 17 Mar 2026 14:56:43 +0200 Subject: [PATCH 1/3] [C-API, python-package] Add leveled logging callback with level-aware routing --- include/LightGBM/c_api.h | 40 ++++ include/LightGBM/utils/log.h | 54 +++-- python-package/lightgbm/__init__.py | 11 +- python-package/lightgbm/basic.py | 185 ++++++++++++++- src/c_api.cpp | 12 + tests/python_package_test/test_utilities.py | 239 +++++++++++++++++++- 6 files changed, 521 insertions(+), 20 deletions(-) diff --git a/include/LightGBM/c_api.h b/include/LightGBM/c_api.h index bdf264729a07..2a8644ce136e 100644 --- a/include/LightGBM/c_api.h +++ b/include/LightGBM/c_api.h @@ -49,6 +49,19 @@ typedef void* ByteBufferHandle; /*!< \brief Handle of ByteBuffer. */ #define C_API_FEATURE_IMPORTANCE_SPLIT (0) /*!< \brief Split type of feature importance. */ #define C_API_FEATURE_IMPORTANCE_GAIN (1) /*!< \brief Gain type of feature importance. */ +/*! + * \brief Log level constants for use with ``LGBM_RegisterLogCallbackWithLevel``. + * \note Severity ordering: Lower numeric values indicate more severe messages. + * ``C_API_LOG_LEVEL_FATAL`` (-1) is most severe, and ``C_API_LOG_LEVEL_DEBUG`` (2) is least severe. + * This follows the internal ``LogLevel`` enum and differs from Python logging conventions. + * Callbacks should be careful not to use comparisons like ``level >= C_API_LOG_LEVEL_WARNING`` + * to filter "warnings and above", as that would include Info and Debug instead. + */ +#define C_API_LOG_LEVEL_FATAL (-1) /*!< \brief Fatal log level, for use with ``LGBM_RegisterLogCallbackWithLevel``. */ +#define C_API_LOG_LEVEL_WARNING (0) /*!< \brief Warning log level, for use with ``LGBM_RegisterLogCallbackWithLevel``. */ +#define C_API_LOG_LEVEL_INFO (1) /*!< \brief Info log level, for use with ``LGBM_RegisterLogCallbackWithLevel``. */ +#define C_API_LOG_LEVEL_DEBUG (2) /*!< \brief Debug log level, for use with ``LGBM_RegisterLogCallbackWithLevel``. */ + /*! * \brief Get string message of the last error. * \return Error information @@ -73,6 +86,33 @@ LIGHTGBM_C_EXPORT int LGBM_DumpParamAliases(int64_t buffer_len, */ LIGHTGBM_C_EXPORT int LGBM_RegisterLogCallback(void (*callback)(const char*)); +/*! + * \brief Register a callback function for log redirecting, with log level information. + * \note + * The callback receives the log level as an int (see ``C_API_LOG_LEVEL_*`` constants) + * and the fully-assembled message body (without prefix or trailing newline). + * Each log event produces exactly one callback invocation. + * If both ``LGBM_RegisterLogCallback`` and this function are called on the same thread, + * this callback takes precedence and the old callback is suppressed on that thread + * (both for normal log messages and for fatal errors). + * The callback must not throw; throwing from a C callback is undefined behaviour. + * The callback is only invoked on the thread that called this function; + * log messages from OpenMP worker threads will bypass the callback. + * \param callback The callback function to register + * \return 0 when succeed, -1 when failure happens + */ +LIGHTGBM_C_EXPORT int LGBM_RegisterLogCallbackWithLevel(void (*callback)(int, const char*)); + +/*! + * \brief Unregister the leveled log callback. + * \note + * This function resets the leveled callback pointer to ``nullptr`` on the calling thread. + * This is useful for cleanup in multi-threaded scenarios or in test fixtures to avoid + * dangling function pointers after the callback's lifetime ends. + * \return 0 when succeed, -1 when failure happens + */ +LIGHTGBM_C_EXPORT int LGBM_UnregisterLogCallbackWithLevel(void); + /*! * \brief Get number of samples based on parameters and total number of rows of data. * \param num_total_row Number of total rows diff --git a/include/LightGBM/utils/log.h b/include/LightGBM/utils/log.h index 84d1efdbafc3..06c541fb897c 100644 --- a/include/LightGBM/utils/log.h +++ b/include/LightGBM/utils/log.h @@ -89,6 +89,7 @@ enum class LogLevel : int { class Log { public: using Callback = void (*)(const char *); + using LeveledCallback = void (*)(int, const char *); /*! * \brief Resets the minimal log level. It is INFO by default. * \param level The new minimal log level. @@ -97,6 +98,8 @@ class Log { static void ResetCallBack(Callback callback) { GetLogCallBack() = callback; } + static void ResetCallBackWithLevel(LeveledCallback callback) { GetLogCallBackWithLevel() = callback; } + static void Debug(const char *format, ...) { va_list val; va_start(val, format); @@ -121,7 +124,7 @@ class Log { char str_buf[kBufSize]; va_start(val, format); #ifdef _MSC_VER - vsnprintf_s(str_buf, kBufSize, format, val); + vsnprintf_s(str_buf, kBufSize, _TRUNCATE, format, val); #else vsnprintf(str_buf, kBufSize, format, val); #endif @@ -130,8 +133,13 @@ class Log { // R code should write back to R's error stream, // otherwise to stderr #ifndef LGB_R_BUILD - fprintf(stderr, "[LightGBM] [Fatal] %s\n", str_buf); - fflush(stderr); + if (GetLogCallBackWithLevel() != nullptr) { + GetLogCallBackWithLevel()(static_cast(LogLevel::Fatal), str_buf); + // leveled callback is sole output on this thread — old callback and stderr suppressed + } else { + fprintf(stderr, "[LightGBM] [Fatal] %s\n", str_buf); + fflush(stderr); + } #else REprintf("[LightGBM] [Fatal] %s\n", str_buf); R_FlushConsole(); @@ -140,13 +148,39 @@ class Log { } private: + // a trick to use static variable in header file. + // May be not good, but avoid to use an additional cpp file + static LogLevel &GetLevel() { + static THREAD_LOCAL LogLevel level = LogLevel::Info; + return level; + } + + static Callback &GetLogCallBack() { + static THREAD_LOCAL Callback callback = nullptr; + return callback; + } + + static LeveledCallback &GetLogCallBackWithLevel() { + static THREAD_LOCAL LeveledCallback callback = nullptr; + return callback; + } + static void Write(LogLevel level, const char *level_str, const char *format, va_list val) { if (level <= GetLevel()) { // omit the message with low level // R code should write back to R's output stream, // otherwise to stdout #ifndef LGB_R_BUILD - if (GetLogCallBack() == nullptr) { + if (GetLogCallBackWithLevel() != nullptr) { + const size_t kBufSize = 1024; + char buf[kBufSize]; +#ifdef _MSC_VER + vsnprintf_s(buf, kBufSize, _TRUNCATE, format, val); +#else + vsnprintf(buf, kBufSize, format, val); +#endif + GetLogCallBackWithLevel()(static_cast(level), buf); + } else if (GetLogCallBack() == nullptr) { printf("[LightGBM] [%s] ", level_str); vprintf(format, val); printf("\n"); @@ -168,18 +202,6 @@ class Log { #endif } } - - // a trick to use static variable in header file. - // May be not good, but avoid to use an additional cpp file - static LogLevel &GetLevel() { - static THREAD_LOCAL LogLevel level = LogLevel::Info; - return level; - } - - static Callback &GetLogCallBack() { - static THREAD_LOCAL Callback callback = nullptr; - return callback; - } }; } // namespace LightGBM diff --git a/python-package/lightgbm/__init__.py b/python-package/lightgbm/__init__.py index d8279349eeb7..58f2e26cb905 100644 --- a/python-package/lightgbm/__init__.py +++ b/python-package/lightgbm/__init__.py @@ -8,7 +8,14 @@ # .basic is intentionally loaded as early as possible, to dlopen() lib_lightgbm.{dll,dylib,so} # and its dependencies as early as possible -from .basic import Booster, Dataset, Sequence, register_logger +from .basic import ( + Booster, + Dataset, + Sequence, + register_logger, + register_leveled_logger, + unregister_leveled_logger, +) from .callback import EarlyStopException, early_stopping, log_evaluation, record_evaluation, reset_parameter from .engine import CVBooster, cv, train @@ -36,6 +43,8 @@ "CVBooster", "Sequence", "register_logger", + "register_leveled_logger", + "unregister_leveled_logger", "train", "cv", "LGBMModel", diff --git a/python-package/lightgbm/basic.py b/python-package/lightgbm/basic.py index 3342e5205393..f07392ca77b8 100644 --- a/python-package/lightgbm/basic.py +++ b/python-package/lightgbm/basic.py @@ -12,6 +12,7 @@ import ctypes import inspect import json +import sys import warnings from collections import OrderedDict from copy import deepcopy @@ -61,6 +62,8 @@ "LGBMDeprecationWarning", "LightGBMError", "register_logger", + "register_leveled_logger", + "unregister_leveled_logger", "Sequence", ] @@ -228,10 +231,33 @@ def warning(self, msg: str) -> None: warnings.warn(msg, stacklevel=3) +class _DummyLeveledLogger: + def debug(self, msg: str) -> None: + print(msg) # noqa: T201 + + def info(self, msg: str) -> None: + print(msg) # noqa: T201 + + def warning(self, msg: str) -> None: + print(msg) # noqa: T201 + + def error(self, msg: str) -> None: + print(msg, file=sys.stderr) # noqa: T201 + + _LOGGER: Any = _DummyLogger() _INFO_METHOD_NAME = "info" _WARNING_METHOD_NAME = "warning" +_LEVELED_LOGGER: Any = _DummyLeveledLogger() +_LEVELED_DEBUG_METHOD_NAME = "debug" +_LEVELED_INFO_METHOD_NAME = "info" +_LEVELED_WARNING_METHOD_NAME = "warning" +_LEVELED_ERROR_METHOD_NAME = "error" + +# Type for leveled callback — avoid recreating on every register_leveled_logger() call +_LEVELED_CALLBACK_TYPE = ctypes.CFUNCTYPE(None, ctypes.c_int, ctypes.c_char_p) + def _has_method(logger: Any, method_name: str) -> bool: return callable(getattr(logger, method_name, None)) @@ -262,6 +288,132 @@ def register_logger( _WARNING_METHOD_NAME = warning_method_name +def register_leveled_logger( + logger: Any, + debug_method_name: str = "debug", + info_method_name: str = "info", + warning_method_name: str = "warning", + error_method_name: str = "error", +) -> None: + """Register custom logger for use with the leveled log callback. + + **Note:** OpenMP worker threads will bypass the callback (log only on main thread). + + This function registers both the Python-side logger object and activates the C++ leveled + callback in the native library. The leveled callback routes each log message to the appropriate + logger method based on its level (debug, info, warning, or error). + + Parameters + ---------- + logger : Any + Custom logger. Must provide all four configured methods. + debug_method_name : str, optional (default="debug") + Method used to log debug messages. + info_method_name : str, optional (default="info") + Method used to log info messages. + warning_method_name : str, optional (default="warning") + Method used to log warning messages. + error_method_name : str, optional (default="error") + Method used to log error (fatal) messages. + + Notes + ----- + The leveled callback is per-thread (registered on the calling thread only) and takes + precedence over the legacy callback on that thread. OpenMP worker threads have their own + TLS slot and will bypass this callback entirely. This function is safe to call multiple times; + subsequent calls will update both the Python-side logger and the C++ registration. + + When deregistering (via ``unregister_leveled_logger()``), the legacy callback registered + by ``register_logger()`` becomes visible again on this thread — it was never suspended, + only shadowed by the leveled callback. + + **Fatal messages (from internal errors)**: The ``error_method_name`` callback will receive + messages with level `C_API_LOG_LEVEL_FATAL`, but these messages will also be re-raised + as ``LightGBMError`` exceptions by the C++ layer. + + Thread safety: + This function is not thread-safe for concurrent calls. Calls should be made from a single + thread, typically during application initialization before training begins. + """ + for method_name in (debug_method_name, info_method_name, warning_method_name, error_method_name): + if not _has_method(logger, method_name): + raise TypeError(f"Logger must provide '{method_name}' method") + + global _LEVELED_LOGGER, _LEVELED_DEBUG_METHOD_NAME, _LEVELED_INFO_METHOD_NAME + global _LEVELED_WARNING_METHOD_NAME, _LEVELED_ERROR_METHOD_NAME + _LEVELED_LOGGER = logger + _LEVELED_DEBUG_METHOD_NAME = debug_method_name + _LEVELED_INFO_METHOD_NAME = info_method_name + _LEVELED_WARNING_METHOD_NAME = warning_method_name + _LEVELED_ERROR_METHOD_NAME = error_method_name + + # Register the C++ leveled callback (safely handling re-registration) + try: + _LIB.LGBM_RegisterLogCallbackWithLevel.restype = ctypes.c_int + _LIB.LGBM_RegisterLogCallbackWithLevel.argtypes = [_LEVELED_CALLBACK_TYPE] + except AttributeError: + warnings.warn( + "The loaded lib_lightgbm does not support leveled logging " + "(symbol LGBM_RegisterLogCallbackWithLevel not found). " + "Python-side logger was set but log messages will not be routed from C++.", + stacklevel=2, + ) + return + + # Unregister old callback first to avoid use-after-free + try: + _LIB.LGBM_UnregisterLogCallbackWithLevel.restype = ctypes.c_int + # Unregister any existing callback; ignore return value — a non-zero result + # simply means no callback was registered yet, which is not an error here. + _LIB.LGBM_UnregisterLogCallbackWithLevel() + except AttributeError: + pass # Older builds may not have unregister; proceed anyway + + _LIB.callback_with_level = _LEVELED_CALLBACK_TYPE(_log_callback_with_level) # type: ignore[attr-defined] + if _LIB.LGBM_RegisterLogCallbackWithLevel(_LIB.callback_with_level) != 0: + raise LightGBMError(_LIB.LGBM_GetLastError().decode("utf-8")) + + +def unregister_leveled_logger() -> None: + """Unregister the leveled log callback. + + This function resets the leveled callback pointer in the C++ layer, restoring + the legacy callback routing previously registered by ``register_logger()``. + This is useful for cleanup in multi-threaded scenarios or in test fixtures to avoid + dangling function pointers after the callback's lifetime ends. + + Notes + ----- + Calling this function is optional. If not called, the leveled callback remains active + and processes all log messages (on threads where it was registered). + """ + try: + _LIB.LGBM_UnregisterLogCallbackWithLevel.restype = ctypes.c_int + _LIB.LGBM_UnregisterLogCallbackWithLevel.argtypes = [] + if _LIB.LGBM_UnregisterLogCallbackWithLevel() != 0: + raise LightGBMError(_LIB.LGBM_GetLastError().decode("utf-8")) + except AttributeError: + # Older builds without unregister support; silently succeed + pass + + # Always clear the Python-side reference so the ctypes thunk can be GC'd. + # This must be outside the try/except above because the AttributeError handler + # for a missing LGBM_UnregisterLogCallbackWithLevel symbol would skip it. + _LIB.callback_with_level = None # type: ignore[attr-defined] + + # Reset Python-side globals so the old logger can be GC'd + global _LEVELED_LOGGER, _LEVELED_DEBUG_METHOD_NAME, _LEVELED_INFO_METHOD_NAME + global _LEVELED_WARNING_METHOD_NAME, _LEVELED_ERROR_METHOD_NAME + _LEVELED_LOGGER = _DummyLeveledLogger() + _LEVELED_DEBUG_METHOD_NAME = "debug" + _LEVELED_INFO_METHOD_NAME = "info" + _LEVELED_WARNING_METHOD_NAME = "warning" + _LEVELED_ERROR_METHOD_NAME = "error" + + +# _normalize_native_string, _log_native, and _log_callback below reassemble the 3-chunk +# messages from LGBM_RegisterLogCallback. Kept intact; superseded on threads where +# LGBM_RegisterLogCallbackWithLevel is active (see LGBM_RegisterLogCallbackWithLevel in include/LightGBM/c_api.h). def _normalize_native_string(func: Callable[[str], None]) -> Callable[[str], None]: """Join log messages from native library which come by chunks.""" msg_normalized: List[str] = [] @@ -297,6 +449,38 @@ def _log_callback(msg: bytes) -> None: _log_native(str(msg.decode("utf-8"))) +def _log_callback_with_level(level: int, msg: bytes) -> None: + """Redirect logs from native library into Python, routing by log level.""" + # Atomic snapshot of all globals under GIL (single tuple assignment is atomic). + # This prevents AttributeError if concurrent re-registration occurs. + logger, debug_name, info_name, warning_name, error_name = ( + _LEVELED_LOGGER, + _LEVELED_DEBUG_METHOD_NAME, + _LEVELED_INFO_METHOD_NAME, + _LEVELED_WARNING_METHOD_NAME, + _LEVELED_ERROR_METHOD_NAME, + ) + + text = msg.decode("utf-8", errors="replace") + try: + if level == -1: # C_API_LOG_LEVEL_FATAL + getattr(logger, error_name)(text) + elif level == 0: # C_API_LOG_LEVEL_WARNING + getattr(logger, warning_name)(text) + elif level == 1: # C_API_LOG_LEVEL_INFO + getattr(logger, info_name)(text) + elif level == 2: # C_API_LOG_LEVEL_DEBUG + getattr(logger, debug_name)(text) + else: # unknown future level — fall back to info + getattr(logger, info_name)(text) + except Exception as exc: + # Cannot re-raise: exceptions raised inside ctypes callbacks cannot propagate to C++. + warnings.warn( + f"LightGBM leveled logger raised an exception and was suppressed: {exc}", + stacklevel=1, + ) + + # connect the Python logger to logging in lib_lightgbm if environ.get("LIGHTGBM_BUILD_DOC", "False") != "True": _LIB.LGBM_GetLastError.restype = ctypes.c_char_p @@ -305,7 +489,6 @@ def _log_callback(msg: bytes) -> None: if _LIB.LGBM_RegisterLogCallback(_LIB.callback) != 0: raise LightGBMError(_LIB.LGBM_GetLastError().decode("utf-8")) - _NUMERIC_TYPES = (int, float, bool) diff --git a/src/c_api.cpp b/src/c_api.cpp index 4ec7c41aec47..d3999bc5baa2 100644 --- a/src/c_api.cpp +++ b/src/c_api.cpp @@ -979,6 +979,18 @@ int LGBM_RegisterLogCallback(void (*callback)(const char*)) { API_END(); } +int LGBM_RegisterLogCallbackWithLevel(void (*callback)(int, const char*)) { + API_BEGIN(); + Log::ResetCallBackWithLevel(callback); + API_END(); +} + +int LGBM_UnregisterLogCallbackWithLevel(void) { + API_BEGIN(); + Log::ResetCallBackWithLevel(nullptr); + API_END(); +} + static inline int SampleCount(int32_t total_nrow, const Config& config) { return static_cast(total_nrow < config.bin_construct_sample_cnt ? total_nrow : config.bin_construct_sample_cnt); } diff --git a/tests/python_package_test/test_utilities.py b/tests/python_package_test/test_utilities.py index 440a717ea1db..f435348f2898 100644 --- a/tests/python_package_test/test_utilities.py +++ b/tests/python_package_test/test_utilities.py @@ -29,7 +29,11 @@ def dummy_metric(_, __): lgb_valid = lgb.Dataset(X, y, categorical_feature=[1]) # different object for early-stopping eval_records = {} - callbacks = [lgb.record_evaluation(eval_records), lgb.log_evaluation(2), lgb.early_stopping(10)] + callbacks = [ + lgb.record_evaluation(eval_records), + lgb.log_evaluation(2), + lgb.early_stopping(10), + ] lgb.train( {"objective": "binary", "metric": ["auc", "binary_error"], "verbose": 1}, lgb_train, @@ -139,7 +143,11 @@ def custom_warning(self, msg: str) -> None: logged_messages.append(msg) custom_logger = CustomLogger() - lgb.register_logger(custom_logger, info_method_name="custom_info", warning_method_name="custom_warning") + lgb.register_logger( + custom_logger, + info_method_name="custom_info", + warning_method_name="custom_warning", + ) lgb.basic._log_info("info message") lgb.basic._log_warning("warning message") @@ -158,3 +166,230 @@ def custom_warning(self, msg: str) -> None: valid_sets=[lgb_data], ) assert logged_messages, "custom logger was not called" + + +@pytest.fixture() +def _leveled_logger_cleanup(): + """Register leveled callback and guarantee cleanup regardless of test outcome.""" + from lightgbm.basic import _DummyLeveledLogger + + # Create a dummy logger and register it to initialize the callback infrastructure + lgb.register_leveled_logger(_DummyLeveledLogger()) + + yield # run the test + + # teardown: unregister the leveled callback using the public API + lgb.unregister_leveled_logger() + + +def test_register_leveled_logger_invalid(): + class NoDebug: + def info(self, msg): + pass + + def warning(self, msg): + pass + + def error(self, msg): + pass + + class NoInfo: + def debug(self, msg): + pass + + def warning(self, msg): + pass + + def error(self, msg): + pass + + class NoWarning: + def debug(self, msg): + pass + + def info(self, msg): + pass + + def error(self, msg): + pass + + class NoError: + def debug(self, msg): + pass + + def info(self, msg): + pass + + def warning(self, msg): + pass + + class NotCallable: + def __init__(self): + self.debug = self.info = self.warning = self.error = 1 + + with pytest.raises(TypeError, match="Logger must provide 'debug' method"): + lgb.register_leveled_logger(NoDebug()) + with pytest.raises(TypeError, match="Logger must provide 'info' method"): + lgb.register_leveled_logger(NoInfo()) + with pytest.raises(TypeError, match="Logger must provide 'warning' method"): + lgb.register_leveled_logger(NoWarning()) + with pytest.raises(TypeError, match="Logger must provide 'error' method"): + lgb.register_leveled_logger(NoError()) + with pytest.raises(TypeError, match="Logger must provide"): + lgb.register_leveled_logger(NotCallable()) + + +def test_log_callback_with_level_unit(_leveled_logger_cleanup): + from lightgbm.basic import _log_callback_with_level + + captured: dict = {"debug": [], "info": [], "warning": [], "error": []} + + class CapturingLogger: + def debug(self, msg: str) -> None: + captured["debug"].append(msg) + + def info(self, msg: str) -> None: + captured["info"].append(msg) + + def warning(self, msg: str) -> None: + captured["warning"].append(msg) + + def error(self, msg: str) -> None: + captured["error"].append(msg) + + lgb.register_leveled_logger(CapturingLogger()) + _log_callback_with_level(-1, b"fatal message") # C_API_LOG_LEVEL_FATAL + _log_callback_with_level(0, b"warning message") # C_API_LOG_LEVEL_WARNING + _log_callback_with_level(1, b"info message") # C_API_LOG_LEVEL_INFO + _log_callback_with_level(2, b"debug message") # C_API_LOG_LEVEL_DEBUG + + assert captured["error"] == ["fatal message"] + assert captured["warning"] == ["warning message"] + assert captured["info"] == ["info message"] + assert captured["debug"] == ["debug message"] + + +def test_register_leveled_logger_routing(_leveled_logger_cleanup): + from lightgbm.basic import _log_callback_with_level + + info_messages: list = [] + warning_messages: list = [] + + class CapturingLogger: + def debug(self, msg: str) -> None: + pass + + def info(self, msg: str) -> None: + info_messages.append(msg) + + def warning(self, msg: str) -> None: + warning_messages.append(msg) + + def error(self, msg: str) -> None: + pass + + lgb.register_leveled_logger(CapturingLogger()) + + # Explicitly trigger main-thread messages to avoid flakiness if training messages + # happen to run on OpenMP worker threads (which bypass the callback). + _log_callback_with_level(1, b"test info message") # C_API_LOG_LEVEL_INFO + _log_callback_with_level(0, b"test warning message") # C_API_LOG_LEVEL_WARNING + + X = np.array([[1, 2, 3], [1, 2, 4], [1, 2, 4], [1, 2, 3]], dtype=np.float32) + y = np.array([0, 1, 1, 0]) + lgb.train( + {"objective": "binary", "verbose": 1}, + lgb.Dataset(X, y, categorical_feature=[1]), + num_boost_round=2, + ) + + assert info_messages, "No Info-level messages received" + assert warning_messages, "No Warning-level messages received" + + # Atomic delivery: no empty or whitespace-only messages (no 3-chunk artifacts) + assert all(m.strip() for m in info_messages), "Chunk artifact in info_messages" + assert all(m.strip() for m in warning_messages), "Chunk artifact in warning_messages" + + +def test_unregister_leveled_logger(): + """Test that unregister_leveled_logger() properly cleans up and allows re-registration.""" + from lightgbm.basic import _LIB, _log_callback_with_level + + captured_a: list = [] + captured_b: list = [] + + class LoggerA: + def debug(self, msg: str) -> None: + pass + + def info(self, msg: str) -> None: + captured_a.append(msg) + + def warning(self, msg: str) -> None: + pass + + def error(self, msg: str) -> None: + pass + + class LoggerB: + def debug(self, msg: str) -> None: + pass + + def info(self, msg: str) -> None: + captured_b.append(msg) + + def warning(self, msg: str) -> None: + pass + + def error(self, msg: str) -> None: + pass + + try: + # Register A, verify thunk is set + lgb.register_leveled_logger(LoggerA()) + assert getattr(_LIB, "callback_with_level", None) is not None, ( + "Thunk should be set after registration" + ) + + # Unregister — thunk should be cleared + lgb.unregister_leveled_logger() + assert getattr(_LIB, "callback_with_level", None) is None, ( + "Thunk should be cleared after unregistration" + ) + + # Idempotency — second unregister should not raise + lgb.unregister_leveled_logger() + + # Re-register with B, verify routing + lgb.register_leveled_logger(LoggerB()) + _log_callback_with_level(1, b"hello from B") # C_API_LOG_LEVEL_INFO + assert captured_b == ["hello from B"], "Logger B should receive message" + assert captured_a == [], "Logger A should not receive message after unregister" + + finally: + lgb.unregister_leveled_logger() + + +def test_fatal_through_leveled_callback(_leveled_logger_cleanup): + """Test that C++ Log::Fatal() routes through the leveled callback end-to-end.""" + captured_errors: list = [] + + class CapturingLogger: + def debug(self, msg): pass + def info(self, msg): pass + def warning(self, msg): pass + def error(self, msg): captured_errors.append(msg) + + lgb.register_leveled_logger(CapturingLogger()) + + with pytest.raises(lgb.basic.LightGBMError): + lgb.Booster(model_str="not_a_valid_model") + + # Fatal message was routed through the leveled callback to logger.error() + assert captured_errors, "No Fatal-level messages received through leveled callback" + # Single-call delivery: no empty/whitespace chunk artifacts + assert all(m.strip() for m in captured_errors), "Chunk artifact in error messages" + # Leveled path sends raw message — no [LightGBM] [Fatal] prefix + assert all("[LightGBM] [Fatal]" not in m for m in captured_errors), ( + "Leveled callback should receive raw message without prefix" + ) From 696297f7f13b02d081d8e1d6a46a43357a7b0497 Mon Sep 17 00:00:00 2001 From: AnyCPU Date: Tue, 17 Mar 2026 17:47:44 +0200 Subject: [PATCH 2/3] [python-package] format with correct ruff settings --- python-package/lightgbm/__init__.py | 9 +----- tests/python_package_test/test_utilities.py | 35 +++++++++------------ 2 files changed, 16 insertions(+), 28 deletions(-) diff --git a/python-package/lightgbm/__init__.py b/python-package/lightgbm/__init__.py index 58f2e26cb905..d9fb8ae49651 100644 --- a/python-package/lightgbm/__init__.py +++ b/python-package/lightgbm/__init__.py @@ -8,14 +8,7 @@ # .basic is intentionally loaded as early as possible, to dlopen() lib_lightgbm.{dll,dylib,so} # and its dependencies as early as possible -from .basic import ( - Booster, - Dataset, - Sequence, - register_logger, - register_leveled_logger, - unregister_leveled_logger, -) +from .basic import Booster, Dataset, Sequence, register_logger, register_leveled_logger, unregister_leveled_logger from .callback import EarlyStopException, early_stopping, log_evaluation, record_evaluation, reset_parameter from .engine import CVBooster, cv, train diff --git a/tests/python_package_test/test_utilities.py b/tests/python_package_test/test_utilities.py index f435348f2898..21b789daea6b 100644 --- a/tests/python_package_test/test_utilities.py +++ b/tests/python_package_test/test_utilities.py @@ -29,11 +29,7 @@ def dummy_metric(_, __): lgb_valid = lgb.Dataset(X, y, categorical_feature=[1]) # different object for early-stopping eval_records = {} - callbacks = [ - lgb.record_evaluation(eval_records), - lgb.log_evaluation(2), - lgb.early_stopping(10), - ] + callbacks = [lgb.record_evaluation(eval_records), lgb.log_evaluation(2), lgb.early_stopping(10)] lgb.train( {"objective": "binary", "metric": ["auc", "binary_error"], "verbose": 1}, lgb_train, @@ -143,11 +139,7 @@ def custom_warning(self, msg: str) -> None: logged_messages.append(msg) custom_logger = CustomLogger() - lgb.register_logger( - custom_logger, - info_method_name="custom_info", - warning_method_name="custom_warning", - ) + lgb.register_logger(custom_logger, info_method_name="custom_info", warning_method_name="custom_warning") lgb.basic._log_info("info message") lgb.basic._log_warning("warning message") @@ -347,15 +339,11 @@ def error(self, msg: str) -> None: try: # Register A, verify thunk is set lgb.register_leveled_logger(LoggerA()) - assert getattr(_LIB, "callback_with_level", None) is not None, ( - "Thunk should be set after registration" - ) + assert getattr(_LIB, "callback_with_level", None) is not None, "Thunk should be set after registration" # Unregister — thunk should be cleared lgb.unregister_leveled_logger() - assert getattr(_LIB, "callback_with_level", None) is None, ( - "Thunk should be cleared after unregistration" - ) + assert getattr(_LIB, "callback_with_level", None) is None, "Thunk should be cleared after unregistration" # Idempotency — second unregister should not raise lgb.unregister_leveled_logger() @@ -375,10 +363,17 @@ def test_fatal_through_leveled_callback(_leveled_logger_cleanup): captured_errors: list = [] class CapturingLogger: - def debug(self, msg): pass - def info(self, msg): pass - def warning(self, msg): pass - def error(self, msg): captured_errors.append(msg) + def debug(self, msg): + pass + + def info(self, msg): + pass + + def warning(self, msg): + pass + + def error(self, msg): + captured_errors.append(msg) lgb.register_leveled_logger(CapturingLogger()) From ac2f39567268818a51bd11a2a572fc307e5d985a Mon Sep 17 00:00:00 2001 From: AnyCPU Date: Fri, 20 Mar 2026 10:38:00 +0200 Subject: [PATCH 3/3] [python-package] fix ruff lint violations in leveled logging code --- python-package/lightgbm/__init__.py | 2 +- tests/python_package_test/test_utilities.py | 22 +++++++++------------ 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/python-package/lightgbm/__init__.py b/python-package/lightgbm/__init__.py index d9fb8ae49651..a8a8b436d0b9 100644 --- a/python-package/lightgbm/__init__.py +++ b/python-package/lightgbm/__init__.py @@ -8,7 +8,7 @@ # .basic is intentionally loaded as early as possible, to dlopen() lib_lightgbm.{dll,dylib,so} # and its dependencies as early as possible -from .basic import Booster, Dataset, Sequence, register_logger, register_leveled_logger, unregister_leveled_logger +from .basic import Booster, Dataset, Sequence, register_leveled_logger, register_logger, unregister_leveled_logger from .callback import EarlyStopException, early_stopping, log_evaluation, record_evaluation, reset_parameter from .engine import CVBooster, cv, train diff --git a/tests/python_package_test/test_utilities.py b/tests/python_package_test/test_utilities.py index 21b789daea6b..8880937ced7c 100644 --- a/tests/python_package_test/test_utilities.py +++ b/tests/python_package_test/test_utilities.py @@ -5,6 +5,7 @@ import pytest import lightgbm as lgb +from lightgbm.basic import _LIB, _DummyLeveledLogger, _log_callback_with_level def test_register_logger(tmp_path): @@ -160,11 +161,9 @@ def custom_warning(self, msg: str) -> None: assert logged_messages, "custom logger was not called" -@pytest.fixture() +@pytest.fixture def _leveled_logger_cleanup(): """Register leveled callback and guarantee cleanup regardless of test outcome.""" - from lightgbm.basic import _DummyLeveledLogger - # Create a dummy logger and register it to initialize the callback infrastructure lgb.register_leveled_logger(_DummyLeveledLogger()) @@ -231,9 +230,8 @@ def __init__(self): lgb.register_leveled_logger(NotCallable()) -def test_log_callback_with_level_unit(_leveled_logger_cleanup): - from lightgbm.basic import _log_callback_with_level - +@pytest.mark.usefixtures("_leveled_logger_cleanup") +def test_log_callback_with_level_unit(): captured: dict = {"debug": [], "info": [], "warning": [], "error": []} class CapturingLogger: @@ -261,9 +259,8 @@ def error(self, msg: str) -> None: assert captured["debug"] == ["debug message"] -def test_register_leveled_logger_routing(_leveled_logger_cleanup): - from lightgbm.basic import _log_callback_with_level - +@pytest.mark.usefixtures("_leveled_logger_cleanup") +def test_register_leveled_logger_routing(): info_messages: list = [] warning_messages: list = [] @@ -305,8 +302,6 @@ def error(self, msg: str) -> None: def test_unregister_leveled_logger(): """Test that unregister_leveled_logger() properly cleans up and allows re-registration.""" - from lightgbm.basic import _LIB, _log_callback_with_level - captured_a: list = [] captured_b: list = [] @@ -358,7 +353,8 @@ def error(self, msg: str) -> None: lgb.unregister_leveled_logger() -def test_fatal_through_leveled_callback(_leveled_logger_cleanup): +@pytest.mark.usefixtures("_leveled_logger_cleanup") +def test_fatal_through_leveled_callback(): """Test that C++ Log::Fatal() routes through the leveled callback end-to-end.""" captured_errors: list = [] @@ -377,7 +373,7 @@ def error(self, msg): lgb.register_leveled_logger(CapturingLogger()) - with pytest.raises(lgb.basic.LightGBMError): + with pytest.raises(lgb.basic.LightGBMError, match="number of classes"): lgb.Booster(model_str="not_a_valid_model") # Fatal message was routed through the leveled callback to logger.error()