Files
rdkit/Code/RDBoost/Wrap/RDBase.cpp
Chris Von Bargen c41f7e7167 Add RDLog::CaptureErrorLog for capturing error log messages (#9138)
* Add RDLog::CaptureLog for capturing log messages

Adds an RAII `CaptureLog` class to `namespace RDLog` (alongside the
existing `LogStateSetter`) that redirects an RDKit logger's output to an
internal `std::stringstream` for the duration of its lifetime. On
destruction the original stream destination and enabled state are fully
restored. Nesting is supported: an inner capture shadows the outer one
and each collects its own messages independently.

The default constructor captures `rdErrorLog`; an explicit constructor
accepts any `RDLogger`. Both enable the logger if it was previously
disabled and restore that state on destruction.

Python bindings expose `rdBase.CaptureLog` as a context manager with a
`messages` read-only property, mirroring the existing `rdBase.BlockLogs`
pattern. Messages remain accessible after the `with` block exits.

C++ tests are added to `catch_logs.cpp` (6 Catch2 sections covering
basic capture, empty state, enable/restore, stream restore, explicit
logger, and nested captures). Python tests are added to
`UnitTestLogging.py` (6 unittest cases covering the same scenarios).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* CaptureLog: add per-level properties (error_messages, warning_messages, etc.)

The Python CaptureLog wrapper now captures all four log levels
simultaneously. Per-level properties (error_messages, warning_messages,
info_messages, debug_messages) give access to messages from each logger
independently; the existing messages property returns them all combined.

The C++ RDLog::CaptureLog class is unchanged — it remains a clean
single-logger RAII type. The Python wrapper composes four instances of
it, one per log level.

Suggested by bp-kelley in PR review.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Refactor CaptureLog: add named per-level subclasses

Add CaptureErrorLog, CaptureWarningLog, CaptureInfoLog, and CaptureDebugLog
as named convenience subclasses of CaptureLog, each capturing a specific
logger. Update Python bindings to expose the four named classes directly
(dropping the combined multi-capture approach), and update tests accordingly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Simplify CaptureLog: no argument, captures rdErrorLog only

Remove the RDLogger argument overload, the four named subclasses, and the
PyCaptureLog template in favor of a single no-argument CaptureLog that
mirrors the Schrödinger CaptureRDErrorLog from which it was inspired.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* CaptureLog tests: add dp_dest restoration and LogStateSetter interaction

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Rename CaptureLog to CaptureErrorLog

The name CaptureLog was ambiguous; CaptureErrorLog is explicit about which
logger it captures and avoids redundancy within namespace RDLog.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Generalize CaptureErrorLog into CaptureLog with logger parameter

Replace CaptureErrorLog with CaptureLog, which accepts any RDLogger in
its constructor (e.g. rdErrorLog, rdWarningLog). Add CaptureErrorLog as
a convenience subclass that pre-fills rdErrorLog, preserving backward
compatibility for existing callers.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 06:09:43 +01:00

468 lines
14 KiB
C++
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright (c) 2004-2019 greg Landrum and Rational Discovery LLC
//
// @@ All Rights Reserved @@
// This file is part of the RDKit.
// The contents are covered by the terms of the BSD license
// which is included in the file license.txt, found at the root
// of the RDKit source tree.
//
#include <RDBoost/python.h>
#include <fstream>
#include <RDBoost/Wrap.h>
#include <RDBoost/python_streambuf.h>
#include <RDGeneral/versions.h>
#include <RDGeneral/Invariant.h>
#include <cstdlib>
#include <RDGeneral/RDLog.h>
namespace python = boost::python;
namespace logging = boost::logging;
std::string _version() { return "$Id$"; }
// std::ostream wrapper around Python's stderr stream
struct PyErrStream : std::ostream, std::streambuf {
static thread_local std::string buffer;
PyErrStream() : std::ostream(this) {
// All done!
}
int overflow(int c) override {
write(c);
return 0;
}
void write(char c) {
if (c == '\n') {
PyGILStateHolder h;
PySys_WriteStderr("%s\n", buffer.c_str());
buffer.clear();
} else {
buffer += c;
}
}
};
// std::ostream wrapper around Python's logging module
struct PyLogStream : std::ostream, std::streambuf {
static thread_local std::string buffer;
PyObject *logfn = nullptr;
PyLogStream(std::string level) : std::ostream(this) {
PyObject *module = PyImport_ImportModule("logging");
PyObject *logger = nullptr;
if (module != nullptr) {
logger = PyObject_CallMethod(module, "getLogger", "s", "rdkit");
Py_DECREF(module);
}
if (logger != nullptr) {
logfn = PyObject_GetAttrString(logger, level.c_str());
Py_DECREF(logger);
}
if (PyErr_Occurred()) {
PyErr_Print();
}
}
~PyLogStream() override {
#if PY_VERSION_HEX < 0x30d0000
if (!_Py_IsFinalizing()) {
#else
if (!Py_IsFinalizing()) {
#endif
Py_XDECREF(logfn);
}
}
int overflow(int c) override {
write(c);
return 0;
}
void write(char c) {
if (logfn == nullptr) {
return;
}
if (c == '\n') {
PyGILStateHolder h;
PyObject *result = PyObject_CallFunction(logfn, "s", buffer.c_str());
Py_XDECREF(result);
buffer.clear();
} else {
buffer += c;
}
}
};
// per-thread buffers for the Python loggers
thread_local std::string PyErrStream::buffer;
thread_local std::string PyLogStream::buffer;
void LogToPythonLogger() {
static PyLogStream debug("debug");
static PyLogStream info("info");
static PyLogStream warning("warning");
static PyLogStream error("error");
rdDebugLog = std::make_shared<logging::rdLogger>(&debug);
rdInfoLog = std::make_shared<logging::rdLogger>(&info);
rdWarningLog = std::make_shared<logging::rdLogger>(&warning);
rdErrorLog = std::make_shared<logging::rdLogger>(&error);
}
void LogToPythonStderr() {
static PyErrStream debug;
static PyErrStream info;
static PyErrStream warning;
static PyErrStream error;
rdDebugLog = std::make_shared<logging::rdLogger>(&debug);
rdInfoLog = std::make_shared<logging::rdLogger>(&info);
rdWarningLog = std::make_shared<logging::rdLogger>(&warning);
rdErrorLog = std::make_shared<logging::rdLogger>(&error);
}
void WrapLogs() {
static PyErrStream debug; //("RDKit DEBUG: ");
static PyErrStream error; //("RDKit ERROR: ");
static PyErrStream warning; //("RDKit WARNING: ");
static PyErrStream info; //("RDKit INFO: ");
if (!rdDebugLog || !rdInfoLog || !rdErrorLog || !rdWarningLog) {
RDLog::InitLogs();
}
rdDebugLog->SetTee(debug);
rdInfoLog->SetTee(info);
rdWarningLog->SetTee(warning);
rdErrorLog->SetTee(error);
}
void EnableLog(std::string spec) { logging::enable_logs(spec); }
void DisableLog(std::string spec) { logging::disable_logs(spec); }
std::string LogStatus() { return logging::log_status(); }
void AttachFileToLog(std::string spec, std::string filename, int delay = 100) {
(void)spec;
(void)filename;
(void)delay;
}
void LogDebugMsg(const std::string &msg) {
// NOGIL nogil;
BOOST_LOG(rdDebugLog) << msg << std::endl;
}
void LogInfoMsg(const std::string &msg) {
// NOGIL nogil;
BOOST_LOG(rdInfoLog) << msg << std::endl;
}
void LogWarningMsg(const std::string &msg) {
// NOGIL nogil;
BOOST_LOG(rdWarningLog) << msg << std::endl;
}
void LogErrorMsg(const std::string &msg) {
// NOGIL nogil;
BOOST_LOG(rdErrorLog) << msg << std::endl;
}
void LogMessage(std::string spec, std::string msg) {
if (spec == "rdApp.error") {
LogErrorMsg(msg);
} else if (spec == "rdApp.warning") {
LogWarningMsg(msg);
} else if (spec == "rdApp.info") {
LogInfoMsg(msg);
} else if (spec == "rdApp.debug") {
LogDebugMsg(msg);
}
}
class BlockLogs : public boost::noncopyable {
public:
BlockLogs() : m_log_setter{new RDLog::LogStateSetter} {}
~BlockLogs() = default;
BlockLogs *enter() { return this; }
void exit(python::object exc_type, python::object exc_val,
python::object traceback) {
RDUNUSED_PARAM(exc_type);
RDUNUSED_PARAM(exc_val);
RDUNUSED_PARAM(traceback);
m_log_setter.reset();
}
private:
std::unique_ptr<RDLog::LogStateSetter> m_log_setter;
};
struct PyCaptureErrorLog : boost::noncopyable {
PyCaptureErrorLog *enter() { return this; }
void exit(python::object /*exc_type*/, python::object /*exc_val*/,
python::object /*traceback*/) {
if (m_capturer) {
m_messages = m_capturer->messages();
m_capturer.reset();
}
}
std::string messages() const {
return m_capturer ? m_capturer->messages() : m_messages;
}
private:
std::unique_ptr<RDLog::CaptureErrorLog> m_capturer{
new RDLog::CaptureErrorLog};
std::string m_messages;
};
namespace {
struct python_streambuf_wrapper {
typedef boost_adaptbx::python::streambuf wt;
static void wrap() {
using namespace boost::python;
class_<wt, boost::noncopyable>("streambuf", no_init)
.def(init<object &, std::size_t>(
(arg("python_file_obj"), arg("buffer_size") = 0),
"documentation")[with_custodian_and_ward_postcall<0, 2>()]);
}
};
struct python_ostream_wrapper {
typedef boost_adaptbx::python::ostream wt;
static void wrap() {
using namespace boost::python;
class_<std::ostream, boost::noncopyable>("std_ostream", no_init);
class_<wt, boost::noncopyable, bases<std::ostream>>("ostream", no_init)
.def(init<object &, std::size_t>(
(arg("python_file_obj"), arg("buffer_size") = 0)));
}
};
void seedRNG(unsigned int seed) { std::srand(seed); }
/// Simple Boost.Python custom converter from pathlib.Path to std::string
template <typename T = std::string>
struct path_converter {
path_converter() {
python::converter::registry::push_back(&path_converter::convertible,
&path_converter::construct,
boost::python::type_id<T>());
}
/// Check PyObject is a pathlib.Path
static void *convertible(PyObject *object) {
// paranoia
if (object == nullptr) {
return nullptr;
}
python::object boost_object(python::handle<>(python::borrowed(object)));
std::string object_classname = boost::python::extract<std::string>(
boost_object.attr("__class__").attr("__name__"));
// pathlib.Path is always specialized to the below derived classes
if (object_classname == "WindowsPath" || object_classname == "PosixPath") {
return object;
}
return nullptr;
}
/// Construct a std::string from pathlib.Path using its own __str__ attribute
static void construct(
PyObject *object,
boost::python::converter::rvalue_from_python_stage1_data *data) {
void *storage =
((boost::python::converter::rvalue_from_python_storage<T> *)data)
->storage.bytes;
python::object boost_object{python::handle<>{python::borrowed(object)}};
new (storage)
T{boost::python::extract<std::string>{boost_object.attr("__str__")()}};
data->convertible = storage;
}
};
/// Convert a Python str to a std::string_view
template <typename T = std::string_view>
struct string_view_from_python_converter {
string_view_from_python_converter() {
python::converter::registry::push_back(
&string_view_from_python_converter::convertible,
&string_view_from_python_converter::construct, python::type_id<T>());
}
/// Check PyObject is a str
static void *convertible(PyObject *object) {
if (object != nullptr && PyUnicode_Check(object)) {
return object;
}
return nullptr;
}
/// Construct a std::string_view from the internal string of the PyObject.
/// This shouldn´t fail, as we block the Python thread until the C++ code
/// returns, so the PyObject and the internal string should remain valid.
static void construct(
PyObject *object,
python::converter::rvalue_from_python_stage1_data *data) {
const char *tmp = PyUnicode_AsUTF8(object);
void *storage = ((python::converter::rvalue_from_python_storage<T> *)data)
->storage.bytes;
new (storage) T{tmp};
data->convertible = storage;
}
};
struct string_view_to_python_converter {
static PyObject *convert(const std::string_view &s) {
return python::incref(python::str(s.data(), s.size()).ptr());
}
};
} // namespace
BOOST_PYTHON_MODULE(rdBase) {
python::scope().attr("__doc__") =
"Module containing basic definitions for wrapped C++ code\n"
"\n";
RDLog::InitLogs();
RegisterVectorConverter<int>();
RegisterVectorConverter<unsigned>();
RegisterVectorConverter<size_t>("UnsignedLong_Vect");
RegisterVectorConverter<boost::uint64_t>("VectSizeT");
RegisterVectorConverter<double>();
RegisterVectorConverter<std::string>(1);
RegisterVectorConverter<std::vector<int>>();
RegisterVectorConverter<std::vector<unsigned>>();
RegisterVectorConverter<std::vector<double>>();
RegisterVectorConverter<std::vector<std::string>>("VectorOfStringVectors");
RegisterVectorConverter<std::pair<int, int>>("MatchTypeVect");
path_converter();
string_view_from_python_converter();
RegisterListConverter<int>();
RegisterListConverter<std::vector<int>>();
RegisterListConverter<std::vector<unsigned int>>();
python::register_exception_translator<IndexErrorException>(
&translate_index_error);
python::register_exception_translator<ValueErrorException>(
&translate_value_error);
python::register_exception_translator<KeyErrorException>(
&translate_key_error);
#if INVARIANT_EXCEPTION_METHOD
python::register_exception_translator<Invar::Invariant>(
&translate_invariant_error);
#endif
boost::python::to_python_converter<std::string_view,
string_view_to_python_converter>();
python::def("_version", _version,
"Deprecated, use the constant rdkitVersion instead");
python::scope().attr("rdkitVersion") = RDKit::rdkitVersion;
python::scope().attr("boostVersion") = RDKit::boostVersion;
python::scope().attr("rdkitBuild") = RDKit::rdkitBuild;
python::scope().attr("_serializationEnabled") =
#ifdef RDK_USE_BOOST_SERIALIZATION
true;
#else
false;
#endif
python::scope().attr("_iostreamsEnabled") =
#ifdef RDK_USE_BOOST_IOSTREAMS
true;
#else
false;
#endif
python::scope().attr("_multithreadedEnabled") =
#ifdef RDK_BUILD_THREADSAFE_SSS
true;
#else
false;
#endif
python::def("LogToCppStreams", RDLog::InitLogs,
"Initialize RDKit logs with C++ streams");
python::def("LogToPythonLogger", LogToPythonLogger,
"Initialize RDKit logs with Python's logging module");
python::def("LogToPythonStderr", LogToPythonStderr,
"Initialize RDKit logs with Python's stderr stream");
python::def("WrapLogs", WrapLogs,
"Tee the RDKit logs to Python's stderr stream");
python::def("EnableLog", EnableLog, python::args("spec"));
python::def("DisableLog", DisableLog, python::args("spec"));
python::def("LogStatus", LogStatus);
python::def("LogDebugMsg", LogDebugMsg, python::args("msg"),
"Log a message to the RDKit debug logs");
python::def("LogInfoMsg", LogInfoMsg, python::args("msg"),
"Log a message to the RDKit info logs");
python::def("LogWarningMsg", LogWarningMsg, python::args("msg"),
"Log a message to the RDKit warning logs");
python::def("LogErrorMsg", LogErrorMsg, python::args("msg"),
"Log a message to the RDKit error logs");
python::def("LogMessage", LogMessage, python::args("spec", "msg"),
"Log a message to any rdApp.* log");
python::def("AttachFileToLog", AttachFileToLog,
"Causes the log to write to a file",
(python::arg("spec"), python::arg("filename"),
python::arg("delay") = 100));
python::def("SeedRandomNumberGenerator", seedRNG,
"Provides a seed to the standard C random number generator\n"
"This does not affect pure Python code, but is relevant to some "
"of the RDKit C++ components.",
(python::arg("seed")));
python_streambuf_wrapper::wrap();
python_ostream_wrapper::wrap();
python::class_<BlockLogs, boost::noncopyable>(
"BlockLogs",
"Temporarily block logs from outputting while this instance is in scope.",
python::init<>(python::args("self")))
.def("__enter__", &BlockLogs::enter,
python::return_internal_reference<>())
.def("__exit__", &BlockLogs::exit);
python::class_<PyCaptureErrorLog, boost::noncopyable>(
"CaptureErrorLog",
"Captures messages from rdErrorLog while this instance is in scope.\n"
"Can be used as a context manager. The ``messages`` property is\n"
"accessible both inside the context and after it exits.\n"
"Nesting is supported: inner captures shadow outer ones.\n\n"
"Example::\n\n"
" with rdBase.CaptureErrorLog() as capture:\n"
" rdkit_function_that_may_fail()\n"
" print(capture.messages)\n",
python::init<>(python::args("self")))
.def("__enter__", &PyCaptureErrorLog::enter,
python::return_internal_reference<>())
.def("__exit__", &PyCaptureErrorLog::exit)
.add_property("messages", &PyCaptureErrorLog::messages,
"Messages captured from rdErrorLog.");
}