Skip to content

Commit cf020a1

Browse files
InvincibleRMCpre-commit-ci[bot]rwgk
authored
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 999b668. * 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 <[email protected]>
1 parent 5b503f7 commit cf020a1

File tree

6 files changed

+204
-0
lines changed

6 files changed

+204
-0
lines changed

include/pybind11/cast.h

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1366,6 +1366,31 @@ object object_or_cast(T &&o) {
13661366
return pybind11::cast(std::forward<T>(o));
13671367
}
13681368

1369+
// Declared in pytypes.h:
1370+
// Implemented here so that make_caster<T> can be used.
1371+
template <typename D>
1372+
template <typename T>
1373+
str_attr_accessor object_api<D>::attr_with_type_hint(const char *key) const {
1374+
#if !defined(__cpp_inline_variables)
1375+
static_assert(always_false<T>::value,
1376+
"C++17 feature __cpp_inline_variables not available: "
1377+
"https://en.cppreference.com/w/cpp/language/static#Static_data_members");
1378+
#endif
1379+
object ann = annotations();
1380+
if (ann.contains(key)) {
1381+
throw std::runtime_error("__annotations__[\"" + std::string(key) + "\"] was set already.");
1382+
}
1383+
ann[key] = make_caster<T>::name.text;
1384+
return {derived(), key};
1385+
}
1386+
1387+
template <typename D>
1388+
template <typename T>
1389+
obj_attr_accessor object_api<D>::attr_with_type_hint(handle key) const {
1390+
(void) attr_with_type_hint<T>(key.cast<std::string>().c_str());
1391+
return {derived(), reinterpret_borrow<object>(key)};
1392+
}
1393+
13691394
// Placeholder type for the unneeded (and dead code) static variable in the
13701395
// PYBIND11_OVERRIDE_OVERRIDE macro
13711396
struct override_unused {};

include/pybind11/detail/common.h

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -627,6 +627,14 @@ struct instance {
627627
static_assert(std::is_standard_layout<instance>::value,
628628
"Internal error: `pybind11::detail::instance` is not standard layout!");
629629

630+
// Some older compilers (e.g. gcc 9.4.0) require
631+
// static_assert(always_false<T>::value, "...");
632+
// instead of
633+
// static_assert(false, "...");
634+
// to trigger the static_assert() in a template only if it is actually instantiated.
635+
template <typename>
636+
struct always_false : std::false_type {};
637+
630638
/// from __cpp_future__ import (convenient aliases from C++14/17)
631639
#if defined(PYBIND11_CPP14)
632640
using std::conditional_t;

include/pybind11/pytypes.h

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,17 @@ class object_api : public pyobject_tag {
113113
/// See above (the only difference is that the key is provided as a string literal)
114114
str_attr_accessor attr(const char *key) const;
115115

116+
/** \rst
117+
Similar to the above attr functions with the difference that the templated Type
118+
is used to set the `__annotations__` dict value to the corresponding key. Worth noting
119+
that attr_with_type_hint is implemented in cast.h.
120+
\endrst */
121+
template <typename T>
122+
obj_attr_accessor attr_with_type_hint(handle key) const;
123+
/// See above (the only difference is that the key is provided as a string literal)
124+
template <typename T>
125+
str_attr_accessor attr_with_type_hint(const char *key) const;
126+
116127
/** \rst
117128
Matches * unpacking in Python, e.g. to unpack arguments out of a ``tuple``
118129
or ``list`` for a function call. Applying another * to the result yields
@@ -182,6 +193,9 @@ class object_api : public pyobject_tag {
182193
/// Get or set the object's docstring, i.e. ``obj.__doc__``.
183194
str_attr_accessor doc() const;
184195

196+
/// Get or set the object's annotations, i.e. ``obj.__annotations__``.
197+
object annotations() const;
198+
185199
/// Return the object's current reference count
186200
ssize_t ref_count() const {
187201
#ifdef PYPY_VERSION
@@ -2558,6 +2572,19 @@ str_attr_accessor object_api<D>::doc() const {
25582572
return attr("__doc__");
25592573
}
25602574

2575+
template <typename D>
2576+
object object_api<D>::annotations() const {
2577+
#if PY_MAJOR_VERSION == 3 && PY_MINOR_VERSION <= 9
2578+
// https://docs.python.org/3/howto/annotations.html#accessing-the-annotations-dict-of-an-object-in-python-3-9-and-older
2579+
if (!hasattr(derived(), "__annotations__")) {
2580+
setattr(derived(), "__annotations__", dict());
2581+
}
2582+
return attr("__annotations__");
2583+
#else
2584+
return getattr(derived(), "__annotations__", dict());
2585+
#endif
2586+
}
2587+
25612588
template <typename D>
25622589
handle object_api<D>::get_type() const {
25632590
return type::handle_of(derived());

include/pybind11/typing.h

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,18 @@ class Optional : public object {
8282
using object::object;
8383
};
8484

85+
template <typename T>
86+
class Final : public object {
87+
PYBIND11_OBJECT_DEFAULT(Final, object, PyObject_Type)
88+
using object::object;
89+
};
90+
91+
template <typename T>
92+
class ClassVar : public object {
93+
PYBIND11_OBJECT_DEFAULT(ClassVar, object, PyObject_Type)
94+
using object::object;
95+
};
96+
8597
template <typename T>
8698
class TypeGuard : public bool_ {
8799
using bool_::bool_;
@@ -251,6 +263,16 @@ struct handle_type_name<typing::Optional<T>> {
251263
= const_name("Optional[") + as_return_type<make_caster<T>>::name + const_name("]");
252264
};
253265

266+
template <typename T>
267+
struct handle_type_name<typing::Final<T>> {
268+
static constexpr auto name = const_name("Final[") + make_caster<T>::name + const_name("]");
269+
};
270+
271+
template <typename T>
272+
struct handle_type_name<typing::ClassVar<T>> {
273+
static constexpr auto name = const_name("ClassVar[") + make_caster<T>::name + const_name("]");
274+
};
275+
254276
// TypeGuard and TypeIs use as_return_type to use the return type if available, which is usually
255277
// the narrower type.
256278

tests/test_pytypes.cpp

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1037,6 +1037,38 @@ TEST_SUBMODULE(pytypes, m) {
10371037
#else
10381038
m.attr("defined_PYBIND11_TEST_PYTYPES_HAS_RANGES") = false;
10391039
#endif
1040+
1041+
#if defined(__cpp_inline_variables)
1042+
// Exercises const char* overload:
1043+
m.attr_with_type_hint<py::typing::List<int>>("list_int") = py::list();
1044+
// Exercises py::handle overload:
1045+
m.attr_with_type_hint<py::typing::Set<py::str>>(py::str("set_str")) = py::set();
1046+
1047+
struct Empty {};
1048+
py::class_<Empty>(m, "EmptyAnnotationClass");
1049+
1050+
struct Static {};
1051+
auto static_class = py::class_<Static>(m, "Static");
1052+
static_class.def(py::init());
1053+
static_class.attr_with_type_hint<py::typing::ClassVar<float>>("x") = 1.0;
1054+
static_class.attr_with_type_hint<py::typing::ClassVar<py::typing::Dict<py::str, int>>>(
1055+
"dict_str_int")
1056+
= py::dict();
1057+
1058+
struct Instance {};
1059+
auto instance = py::class_<Instance>(m, "Instance", py::dynamic_attr());
1060+
instance.def(py::init());
1061+
instance.attr_with_type_hint<float>("y");
1062+
1063+
m.def("attr_with_type_hint_float_x",
1064+
[](py::handle obj) { obj.attr_with_type_hint<float>("x"); });
1065+
1066+
m.attr_with_type_hint<py::typing::Final<int>>("CONST_INT") = 3;
1067+
1068+
m.attr("defined___cpp_inline_variables") = true;
1069+
#else
1070+
m.attr("defined___cpp_inline_variables") = false;
1071+
#endif
10401072
m.def("half_of_number", [](const RealNumber &x) { return RealNumber{x.value / 2}; });
10411073
// std::vector<T>
10421074
m.def("half_of_number_vector", [](const std::vector<RealNumber> &x) {

tests/test_pytypes.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1103,6 +1103,96 @@ def test_dict_ranges(tested_dict, expected):
11031103
assert m.transform_dict_plus_one(tested_dict) == expected
11041104

11051105

1106+
# https://docs.python.org/3/howto/annotations.html#accessing-the-annotations-dict-of-an-object-in-python-3-9-and-older
1107+
def get_annotations_helper(o):
1108+
if isinstance(o, type):
1109+
return o.__dict__.get("__annotations__", None)
1110+
return getattr(o, "__annotations__", None)
1111+
1112+
1113+
@pytest.mark.skipif(
1114+
not m.defined___cpp_inline_variables,
1115+
reason="C++17 feature __cpp_inline_variables not available.",
1116+
)
1117+
def test_module_attribute_types() -> None:
1118+
module_annotations = get_annotations_helper(m)
1119+
1120+
assert module_annotations["list_int"] == "list[int]"
1121+
assert module_annotations["set_str"] == "set[str]"
1122+
1123+
1124+
@pytest.mark.skipif(
1125+
not m.defined___cpp_inline_variables,
1126+
reason="C++17 feature __cpp_inline_variables not available.",
1127+
)
1128+
@pytest.mark.skipif(
1129+
sys.version_info < (3, 10),
1130+
reason="get_annotations function does not exist until Python3.10",
1131+
)
1132+
def test_get_annotations_compliance() -> None:
1133+
from inspect import get_annotations
1134+
1135+
module_annotations = get_annotations(m)
1136+
1137+
assert module_annotations["list_int"] == "list[int]"
1138+
assert module_annotations["set_str"] == "set[str]"
1139+
1140+
1141+
@pytest.mark.skipif(
1142+
not m.defined___cpp_inline_variables,
1143+
reason="C++17 feature __cpp_inline_variables not available.",
1144+
)
1145+
def test_class_attribute_types() -> None:
1146+
empty_annotations = get_annotations_helper(m.EmptyAnnotationClass)
1147+
static_annotations = get_annotations_helper(m.Static)
1148+
instance_annotations = get_annotations_helper(m.Instance)
1149+
1150+
assert empty_annotations is None
1151+
assert static_annotations["x"] == "ClassVar[float]"
1152+
assert static_annotations["dict_str_int"] == "ClassVar[dict[str, int]]"
1153+
1154+
assert m.Static.x == 1.0
1155+
1156+
m.Static.x = 3.0
1157+
static = m.Static()
1158+
assert static.x == 3.0
1159+
1160+
static.dict_str_int["hi"] = 3
1161+
assert m.Static().dict_str_int == {"hi": 3}
1162+
1163+
assert instance_annotations["y"] == "float"
1164+
instance1 = m.Instance()
1165+
instance1.y = 4.0
1166+
1167+
instance2 = m.Instance()
1168+
instance2.y = 5.0
1169+
1170+
assert instance1.y != instance2.y
1171+
1172+
1173+
@pytest.mark.skipif(
1174+
not m.defined___cpp_inline_variables,
1175+
reason="C++17 feature __cpp_inline_variables not available.",
1176+
)
1177+
def test_redeclaration_attr_with_type_hint() -> None:
1178+
obj = m.Instance()
1179+
m.attr_with_type_hint_float_x(obj)
1180+
assert get_annotations_helper(obj)["x"] == "float"
1181+
with pytest.raises(
1182+
RuntimeError, match=r'^__annotations__\["x"\] was set already\.$'
1183+
):
1184+
m.attr_with_type_hint_float_x(obj)
1185+
1186+
1187+
@pytest.mark.skipif(
1188+
not m.defined___cpp_inline_variables,
1189+
reason="C++17 feature __cpp_inline_variables not available.",
1190+
)
1191+
def test_final_annotation() -> None:
1192+
module_annotations = get_annotations_helper(m)
1193+
assert module_annotations["CONST_INT"] == "Final[int]"
1194+
1195+
11061196
def test_arg_return_type_hints(doc):
11071197
assert doc(m.half_of_number) == "half_of_number(arg0: Union[float, int]) -> float"
11081198
assert m.half_of_number(2.0) == 1.0

0 commit comments

Comments
 (0)