From cf020a1de25861d4b982081890c43e7eab1ff5e9 Mon Sep 17 00:00:00 2001 From: Michael Carlstrom Date: Sat, 21 Dec 2024 16:22:08 -0800 Subject: [PATCH] feat(types) Allow setting types for attributes (#5460) * init * add comment * style: pre-commit fixes * fix extra ; * Add annotation helper for older python versions * style: pre-commit fixes * test writing __annotations__ to __dict__ * style: pre-commit fixes * revert bac to __annotations__ * use getattr for automatic init * use std::move * update helper * remove stdmove * test isinstance * style: pre-commit fixes * add #if 3.9 * style: pre-commit fixes * use getattr * add dir * use hasattr * try setattr * add c++17 guard * style: pre-commit fixes * add compile guard * style: pre-commit fixes * cleanup * comments and function name cleanup * style: pre-commit fixes * update test case * style: pre-commit fixes * add write * added instance check * style: pre-commit fixes * Test for `__cpp_inline_variables` and use `static_assert()` * Add guard: __annotations__["list_int"] was set already. * Avoid explicit `false` in `static_assert()`. * add redeclaration test * style: pre-commit fixes * add handle support to attr_with_type_hint * store reintpreted_key * style: pre-commit fixes * fix str namespace * fix scope? * string wrap * clang tidy * Swap order of attr_with_type_hint implementation in cast.h (so that the `const char *` overload appears first). * Reuse `const char *` overload from `handle` overload. * Added comment * style: pre-commit fixes * line up comment * fixed spelling error * Add missing period * Introduce `detail::always_false<>` to make the new `static_assert()` more readable. * Copy `attr` comment for `const char *` overload, for consistency. * static_assert(std::false_type::value, ...) * Revert "static_assert(std::false_type::value, ...)" This reverts commit 999b668688b5b965a6f929a5a7f8af5176c8a21d. * Add comment for `always_false` * add test for inspect.get_annotations() * style: pre-commit fixes --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Ralf W. Grosse-Kunstleve --- include/pybind11/cast.h | 25 +++++++++ include/pybind11/detail/common.h | 8 +++ include/pybind11/pytypes.h | 27 ++++++++++ include/pybind11/typing.h | 22 ++++++++ tests/test_pytypes.cpp | 32 ++++++++++++ tests/test_pytypes.py | 90 ++++++++++++++++++++++++++++++++ 6 files changed, 204 insertions(+) diff --git a/include/pybind11/cast.h b/include/pybind11/cast.h index 3e15d8d0c1..853165a498 100644 --- a/include/pybind11/cast.h +++ b/include/pybind11/cast.h @@ -1366,6 +1366,31 @@ object object_or_cast(T &&o) { return pybind11::cast(std::forward(o)); } +// Declared in pytypes.h: +// Implemented here so that make_caster can be used. +template +template +str_attr_accessor object_api::attr_with_type_hint(const char *key) const { +#if !defined(__cpp_inline_variables) + static_assert(always_false::value, + "C++17 feature __cpp_inline_variables not available: " + "https://en.cppreference.com/w/cpp/language/static#Static_data_members"); +#endif + object ann = annotations(); + if (ann.contains(key)) { + throw std::runtime_error("__annotations__[\"" + std::string(key) + "\"] was set already."); + } + ann[key] = make_caster::name.text; + return {derived(), key}; +} + +template +template +obj_attr_accessor object_api::attr_with_type_hint(handle key) const { + (void) attr_with_type_hint(key.cast().c_str()); + return {derived(), reinterpret_borrow(key)}; +} + // Placeholder type for the unneeded (and dead code) static variable in the // PYBIND11_OVERRIDE_OVERRIDE macro struct override_unused {}; diff --git a/include/pybind11/detail/common.h b/include/pybind11/detail/common.h index 78173cad30..c05db0e7da 100644 --- a/include/pybind11/detail/common.h +++ b/include/pybind11/detail/common.h @@ -627,6 +627,14 @@ struct instance { static_assert(std::is_standard_layout::value, "Internal error: `pybind11::detail::instance` is not standard layout!"); +// Some older compilers (e.g. gcc 9.4.0) require +// static_assert(always_false::value, "..."); +// instead of +// static_assert(false, "..."); +// to trigger the static_assert() in a template only if it is actually instantiated. +template +struct always_false : std::false_type {}; + /// from __cpp_future__ import (convenient aliases from C++14/17) #if defined(PYBIND11_CPP14) using std::conditional_t; diff --git a/include/pybind11/pytypes.h b/include/pybind11/pytypes.h index 60d51fdcff..92e0a81f44 100644 --- a/include/pybind11/pytypes.h +++ b/include/pybind11/pytypes.h @@ -113,6 +113,17 @@ class object_api : public pyobject_tag { /// See above (the only difference is that the key is provided as a string literal) str_attr_accessor attr(const char *key) const; + /** \rst + Similar to the above attr functions with the difference that the templated Type + is used to set the `__annotations__` dict value to the corresponding key. Worth noting + that attr_with_type_hint is implemented in cast.h. + \endrst */ + template + obj_attr_accessor attr_with_type_hint(handle key) const; + /// See above (the only difference is that the key is provided as a string literal) + template + str_attr_accessor attr_with_type_hint(const char *key) const; + /** \rst Matches * unpacking in Python, e.g. to unpack arguments out of a ``tuple`` or ``list`` for a function call. Applying another * to the result yields @@ -182,6 +193,9 @@ class object_api : public pyobject_tag { /// Get or set the object's docstring, i.e. ``obj.__doc__``. str_attr_accessor doc() const; + /// Get or set the object's annotations, i.e. ``obj.__annotations__``. + object annotations() const; + /// Return the object's current reference count ssize_t ref_count() const { #ifdef PYPY_VERSION @@ -2558,6 +2572,19 @@ str_attr_accessor object_api::doc() const { return attr("__doc__"); } +template +object object_api::annotations() const { +#if PY_MAJOR_VERSION == 3 && PY_MINOR_VERSION <= 9 + // https://docs.python.org/3/howto/annotations.html#accessing-the-annotations-dict-of-an-object-in-python-3-9-and-older + if (!hasattr(derived(), "__annotations__")) { + setattr(derived(), "__annotations__", dict()); + } + return attr("__annotations__"); +#else + return getattr(derived(), "__annotations__", dict()); +#endif +} + template handle object_api::get_type() const { return type::handle_of(derived()); diff --git a/include/pybind11/typing.h b/include/pybind11/typing.h index 405ff8714a..005279058b 100644 --- a/include/pybind11/typing.h +++ b/include/pybind11/typing.h @@ -82,6 +82,18 @@ class Optional : public object { using object::object; }; +template +class Final : public object { + PYBIND11_OBJECT_DEFAULT(Final, object, PyObject_Type) + using object::object; +}; + +template +class ClassVar : public object { + PYBIND11_OBJECT_DEFAULT(ClassVar, object, PyObject_Type) + using object::object; +}; + template class TypeGuard : public bool_ { using bool_::bool_; @@ -251,6 +263,16 @@ struct handle_type_name> { = const_name("Optional[") + as_return_type>::name + const_name("]"); }; +template +struct handle_type_name> { + static constexpr auto name = const_name("Final[") + make_caster::name + const_name("]"); +}; + +template +struct handle_type_name> { + static constexpr auto name = const_name("ClassVar[") + make_caster::name + const_name("]"); +}; + // TypeGuard and TypeIs use as_return_type to use the return type if available, which is usually // the narrower type. diff --git a/tests/test_pytypes.cpp b/tests/test_pytypes.cpp index 1764ccda02..b4fa99192b 100644 --- a/tests/test_pytypes.cpp +++ b/tests/test_pytypes.cpp @@ -1037,6 +1037,38 @@ TEST_SUBMODULE(pytypes, m) { #else m.attr("defined_PYBIND11_TEST_PYTYPES_HAS_RANGES") = false; #endif + +#if defined(__cpp_inline_variables) + // Exercises const char* overload: + m.attr_with_type_hint>("list_int") = py::list(); + // Exercises py::handle overload: + m.attr_with_type_hint>(py::str("set_str")) = py::set(); + + struct Empty {}; + py::class_(m, "EmptyAnnotationClass"); + + struct Static {}; + auto static_class = py::class_(m, "Static"); + static_class.def(py::init()); + static_class.attr_with_type_hint>("x") = 1.0; + static_class.attr_with_type_hint>>( + "dict_str_int") + = py::dict(); + + struct Instance {}; + auto instance = py::class_(m, "Instance", py::dynamic_attr()); + instance.def(py::init()); + instance.attr_with_type_hint("y"); + + m.def("attr_with_type_hint_float_x", + [](py::handle obj) { obj.attr_with_type_hint("x"); }); + + m.attr_with_type_hint>("CONST_INT") = 3; + + m.attr("defined___cpp_inline_variables") = true; +#else + m.attr("defined___cpp_inline_variables") = false; +#endif m.def("half_of_number", [](const RealNumber &x) { return RealNumber{x.value / 2}; }); // std::vector m.def("half_of_number_vector", [](const std::vector &x) { diff --git a/tests/test_pytypes.py b/tests/test_pytypes.py index b6e64b9bf6..448bfa6a83 100644 --- a/tests/test_pytypes.py +++ b/tests/test_pytypes.py @@ -1103,6 +1103,96 @@ def test_dict_ranges(tested_dict, expected): assert m.transform_dict_plus_one(tested_dict) == expected +# https://docs.python.org/3/howto/annotations.html#accessing-the-annotations-dict-of-an-object-in-python-3-9-and-older +def get_annotations_helper(o): + if isinstance(o, type): + return o.__dict__.get("__annotations__", None) + return getattr(o, "__annotations__", None) + + +@pytest.mark.skipif( + not m.defined___cpp_inline_variables, + reason="C++17 feature __cpp_inline_variables not available.", +) +def test_module_attribute_types() -> None: + module_annotations = get_annotations_helper(m) + + assert module_annotations["list_int"] == "list[int]" + assert module_annotations["set_str"] == "set[str]" + + +@pytest.mark.skipif( + not m.defined___cpp_inline_variables, + reason="C++17 feature __cpp_inline_variables not available.", +) +@pytest.mark.skipif( + sys.version_info < (3, 10), + reason="get_annotations function does not exist until Python3.10", +) +def test_get_annotations_compliance() -> None: + from inspect import get_annotations + + module_annotations = get_annotations(m) + + assert module_annotations["list_int"] == "list[int]" + assert module_annotations["set_str"] == "set[str]" + + +@pytest.mark.skipif( + not m.defined___cpp_inline_variables, + reason="C++17 feature __cpp_inline_variables not available.", +) +def test_class_attribute_types() -> None: + empty_annotations = get_annotations_helper(m.EmptyAnnotationClass) + static_annotations = get_annotations_helper(m.Static) + instance_annotations = get_annotations_helper(m.Instance) + + assert empty_annotations is None + assert static_annotations["x"] == "ClassVar[float]" + assert static_annotations["dict_str_int"] == "ClassVar[dict[str, int]]" + + assert m.Static.x == 1.0 + + m.Static.x = 3.0 + static = m.Static() + assert static.x == 3.0 + + static.dict_str_int["hi"] = 3 + assert m.Static().dict_str_int == {"hi": 3} + + assert instance_annotations["y"] == "float" + instance1 = m.Instance() + instance1.y = 4.0 + + instance2 = m.Instance() + instance2.y = 5.0 + + assert instance1.y != instance2.y + + +@pytest.mark.skipif( + not m.defined___cpp_inline_variables, + reason="C++17 feature __cpp_inline_variables not available.", +) +def test_redeclaration_attr_with_type_hint() -> None: + obj = m.Instance() + m.attr_with_type_hint_float_x(obj) + assert get_annotations_helper(obj)["x"] == "float" + with pytest.raises( + RuntimeError, match=r'^__annotations__\["x"\] was set already\.$' + ): + m.attr_with_type_hint_float_x(obj) + + +@pytest.mark.skipif( + not m.defined___cpp_inline_variables, + reason="C++17 feature __cpp_inline_variables not available.", +) +def test_final_annotation() -> None: + module_annotations = get_annotations_helper(m) + assert module_annotations["CONST_INT"] == "Final[int]" + + def test_arg_return_type_hints(doc): assert doc(m.half_of_number) == "half_of_number(arg0: Union[float, int]) -> float" assert m.half_of_number(2.0) == 1.0