From 66c3774a6402224b1724329c81c880e76633a92b Mon Sep 17 00:00:00 2001 From: Jan Iwaszkiewicz Date: Thu, 29 Aug 2024 05:55:50 +0200 Subject: [PATCH] Warnings wrappers to use from C++ (#5291) * Add warning wrappers that allow to call warnings from pybind level * Add missing include for warnings.h * Change messages on failed checks, extend testing * clang-tidy fix * Refactor tests for warnings * Move handle before check * Remove unnecessary parametrized --- CMakeLists.txt | 3 +- include/pybind11/warnings.h | 75 ++++++++++++++++++++++++ tests/CMakeLists.txt | 3 +- tests/extra_python_package/test_files.py | 1 + tests/test_warnings.cpp | 46 +++++++++++++++ tests/test_warnings.py | 68 +++++++++++++++++++++ 6 files changed, 194 insertions(+), 2 deletions(-) create mode 100644 include/pybind11/warnings.h create mode 100644 tests/test_warnings.cpp create mode 100644 tests/test_warnings.py diff --git a/CMakeLists.txt b/CMakeLists.txt index ed2971942..a641925e9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -160,7 +160,8 @@ set(PYBIND11_HEADERS include/pybind11/stl_bind.h include/pybind11/stl/filesystem.h include/pybind11/type_caster_pyobject_ptr.h - include/pybind11/typing.h) + include/pybind11/typing.h + include/pybind11/warnings.h) # Compare with grep and warn if mismatched if(PYBIND11_MASTER_PROJECT) diff --git a/include/pybind11/warnings.h b/include/pybind11/warnings.h new file mode 100644 index 000000000..263b2990e --- /dev/null +++ b/include/pybind11/warnings.h @@ -0,0 +1,75 @@ +/* + pybind11/warnings.h: Python warnings wrappers. + + Copyright (c) 2024 Jan Iwaszkiewicz + + All rights reserved. Use of this source code is governed by a + BSD-style license that can be found in the LICENSE file. +*/ + +#pragma once + +#include "pybind11.h" +#include "detail/common.h" + +PYBIND11_NAMESPACE_BEGIN(PYBIND11_NAMESPACE) + +PYBIND11_NAMESPACE_BEGIN(detail) + +inline bool PyWarning_Check(PyObject *obj) { + int result = PyObject_IsSubclass(obj, PyExc_Warning); + if (result == 1) { + return true; + } + if (result == -1) { + raise_from(PyExc_SystemError, + "pybind11::detail::PyWarning_Check(): PyObject_IsSubclass() call failed."); + throw error_already_set(); + } + return false; +} + +PYBIND11_NAMESPACE_END(detail) + +PYBIND11_NAMESPACE_BEGIN(warnings) + +inline object +new_warning_type(handle scope, const char *name, handle base = PyExc_RuntimeWarning) { + if (!detail::PyWarning_Check(base.ptr())) { + pybind11_fail("pybind11::warnings::new_warning_type(): cannot create custom warning, base " + "must be a subclass of " + "PyExc_Warning!"); + } + if (hasattr(scope, name)) { + pybind11_fail("pybind11::warnings::new_warning_type(): an attribute with name \"" + + std::string(name) + "\" exists already."); + } + std::string full_name = scope.attr("__name__").cast() + std::string(".") + name; + handle h(PyErr_NewException(full_name.c_str(), base.ptr(), nullptr)); + if (!h) { + raise_from(PyExc_SystemError, + "pybind11::warnings::new_warning_type(): PyErr_NewException() call failed."); + throw error_already_set(); + } + auto obj = reinterpret_steal(h); + scope.attr(name) = obj; + return obj; +} + +// Similar to Python `warnings.warn()` +inline void +warn(const char *message, handle category = PyExc_RuntimeWarning, int stack_level = 2) { + if (!detail::PyWarning_Check(category.ptr())) { + pybind11_fail( + "pybind11::warnings::warn(): cannot raise warning, category must be a subclass of " + "PyExc_Warning!"); + } + + if (PyErr_WarnEx(category.ptr(), message, stack_level) == -1) { + throw error_already_set(); + } +} + +PYBIND11_NAMESPACE_END(warnings) + +PYBIND11_NAMESPACE_END(PYBIND11_NAMESPACE) diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index cd94ef3e5..d2156f46d 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -154,7 +154,8 @@ set(PYBIND11_TEST_FILES test_unnamed_namespace_a test_unnamed_namespace_b test_vector_unique_ptr_member - test_virtual_functions) + test_virtual_functions + test_warnings) # Invoking cmake with something like: # cmake -DPYBIND11_TEST_OVERRIDE="test_callbacks.cpp;test_pickling.cpp" .. diff --git a/tests/extra_python_package/test_files.py b/tests/extra_python_package/test_files.py index 0a5db9017..7ad9b8062 100644 --- a/tests/extra_python_package/test_files.py +++ b/tests/extra_python_package/test_files.py @@ -47,6 +47,7 @@ main_headers = { "include/pybind11/stl_bind.h", "include/pybind11/type_caster_pyobject_ptr.h", "include/pybind11/typing.h", + "include/pybind11/warnings.h", } detail_headers = { diff --git a/tests/test_warnings.cpp b/tests/test_warnings.cpp new file mode 100644 index 000000000..e76f21249 --- /dev/null +++ b/tests/test_warnings.cpp @@ -0,0 +1,46 @@ +/* + tests/test_warnings.cpp -- usage of warnings::warn() and warnings categories. + + Copyright (c) 2024 Jan Iwaszkiewicz + + All rights reserved. Use of this source code is governed by a + BSD-style license that can be found in the LICENSE file. +*/ + +#include + +#include "pybind11_tests.h" + +#include + +TEST_SUBMODULE(warnings_, m) { + + // Test warning mechanism base + m.def("warn_and_return_value", []() { + std::string message = "This is simple warning"; + py::warnings::warn(message.c_str(), PyExc_Warning); + return 21; + }); + + m.def("warn_with_default_category", []() { py::warnings::warn("This is RuntimeWarning"); }); + + m.def("warn_with_different_category", + []() { py::warnings::warn("This is FutureWarning", PyExc_FutureWarning); }); + + m.def("warn_with_invalid_category", + []() { py::warnings::warn("Invalid category", PyExc_Exception); }); + + // Test custom warnings + PYBIND11_CONSTINIT static py::gil_safe_call_once_and_store ex_storage; + ex_storage.call_once_and_store_result([&]() { + return py::warnings::new_warning_type(m, "CustomWarning", PyExc_DeprecationWarning); + }); + + m.def("warn_with_custom_type", []() { + py::warnings::warn("This is CustomWarning", ex_storage.get_stored()); + return 37; + }); + + m.def("register_duplicate_warning", + [m]() { py::warnings::new_warning_type(m, "CustomWarning", PyExc_RuntimeWarning); }); +} diff --git a/tests/test_warnings.py b/tests/test_warnings.py new file mode 100644 index 000000000..4313432c3 --- /dev/null +++ b/tests/test_warnings.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +import warnings + +import pytest + +import pybind11_tests # noqa: F401 +from pybind11_tests import warnings_ as m + + +@pytest.mark.parametrize( + ("expected_category", "expected_message", "expected_value", "module_function"), + [ + (Warning, "This is simple warning", 21, m.warn_and_return_value), + (RuntimeWarning, "This is RuntimeWarning", None, m.warn_with_default_category), + (FutureWarning, "This is FutureWarning", None, m.warn_with_different_category), + ], +) +def test_warning_simple( + expected_category, expected_message, expected_value, module_function +): + with pytest.warns(Warning) as excinfo: + value = module_function() + + assert issubclass(excinfo[0].category, expected_category) + assert str(excinfo[0].message) == expected_message + assert value == expected_value + + +def test_warning_wrong_subclass_fail(): + with pytest.raises(Exception) as excinfo: + m.warn_with_invalid_category() + + assert issubclass(excinfo.type, RuntimeError) + assert ( + str(excinfo.value) + == "pybind11::warnings::warn(): cannot raise warning, category must be a subclass of PyExc_Warning!" + ) + + +def test_warning_double_register_fail(): + with pytest.raises(Exception) as excinfo: + m.register_duplicate_warning() + + assert issubclass(excinfo.type, RuntimeError) + assert ( + str(excinfo.value) + == 'pybind11::warnings::new_warning_type(): an attribute with name "CustomWarning" exists already.' + ) + + +def test_warning_register(): + assert m.CustomWarning is not None + + with pytest.warns(m.CustomWarning) as excinfo: + warnings.warn("This is warning from Python!", m.CustomWarning, stacklevel=1) + + assert issubclass(excinfo[0].category, DeprecationWarning) + assert str(excinfo[0].message) == "This is warning from Python!" + + +def test_warning_custom(): + with pytest.warns(m.CustomWarning) as excinfo: + value = m.warn_with_custom_type() + + assert issubclass(excinfo[0].category, DeprecationWarning) + assert str(excinfo[0].message) == "This is CustomWarning" + assert value == 37