Skip to content

feat: warnings wrappers to use from C++ #5291

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Aug 29, 2024
3 changes: 2 additions & 1 deletion CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
75 changes: 75 additions & 0 deletions include/pybind11/warnings.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/*
pybind11/warnings.h: Python warnings wrappers.

Copyright (c) 2024 Jan Iwaszkiewicz <[email protected]>

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>() + 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<object>(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)
3 changes: 2 additions & 1 deletion tests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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" ..
Expand Down
1 change: 1 addition & 0 deletions tests/extra_python_package/test_files.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
"include/pybind11/stl_bind.h",
"include/pybind11/type_caster_pyobject_ptr.h",
"include/pybind11/typing.h",
"include/pybind11/warnings.h",
}

detail_headers = {
Expand Down
46 changes: 46 additions & 0 deletions tests/test_warnings.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/*
tests/test_warnings.cpp -- usage of warnings::warn() and warnings categories.

Copyright (c) 2024 Jan Iwaszkiewicz <[email protected]>

All rights reserved. Use of this source code is governed by a
BSD-style license that can be found in the LICENSE file.
*/

#include <pybind11/warnings.h>

#include "pybind11_tests.h"

#include <utility>

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<py::object> 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); });
}
91 changes: 91 additions & 0 deletions tests/test_warnings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
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
assert issubclass(m.CustomWarning, DeprecationWarning)

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 issubclass(excinfo[0].category, m.CustomWarning)
assert str(excinfo[0].message) == "This is warning from Python!"


@pytest.mark.parametrize(
(
"expected_category",
"expected_base",
"expected_message",
"expected_value",
"module_function",
),
[
(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems to be the only one list item.

I'd either remove the @pytest.mark.parametrize() here and just assign the variables directly (e.g. expected_value = 37) or try to think if there could be another test we could meaningfully add here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I guess it's a leftover from previous iterations (not sure but looks like one:)). Removed!

m.CustomWarning,
DeprecationWarning,
"This is CustomWarning",
37,
m.warn_with_custom_type,
),
],
)
def test_warning_custom(
expected_category, expected_base, expected_message, expected_value, module_function
):
with pytest.warns(expected_category) as excinfo:
value = module_function()

assert issubclass(excinfo[0].category, expected_base)
assert issubclass(excinfo[0].category, expected_category)
assert str(excinfo[0].message) == expected_message
assert value == expected_value
Loading