Skip to content
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

feat(types) Allow setting types for attributes #5460

Merged
merged 63 commits into from
Dec 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
63 commits
Select commit Hold shift + click to select a range
aa74fbe
init
InvincibleRMC Dec 5, 2024
4c263c1
add comment
InvincibleRMC Dec 5, 2024
4788d8c
style: pre-commit fixes
pre-commit-ci[bot] Dec 5, 2024
bc8f095
fix extra ;
InvincibleRMC Dec 5, 2024
c38836a
Add annotation helper for older python versions
InvincibleRMC Dec 5, 2024
9e43986
style: pre-commit fixes
pre-commit-ci[bot] Dec 5, 2024
9f34dcc
test writing __annotations__ to __dict__
InvincibleRMC Dec 5, 2024
10d7b05
style: pre-commit fixes
pre-commit-ci[bot] Dec 5, 2024
6dff59f
revert bac to __annotations__
InvincibleRMC Dec 5, 2024
c8edd09
use getattr for automatic init
InvincibleRMC Dec 5, 2024
259ce93
use std::move
InvincibleRMC Dec 5, 2024
7e380e2
update helper
InvincibleRMC Dec 5, 2024
b8ad03d
remove stdmove
InvincibleRMC Dec 5, 2024
e5235f0
test isinstance
InvincibleRMC Dec 6, 2024
639b192
style: pre-commit fixes
pre-commit-ci[bot] Dec 6, 2024
8da0ce0
add #if 3.9
InvincibleRMC Dec 6, 2024
886c9c2
merge
InvincibleRMC Dec 6, 2024
31cc64f
style: pre-commit fixes
pre-commit-ci[bot] Dec 6, 2024
4d0968d
use getattr
InvincibleRMC Dec 6, 2024
e84df95
add dir
InvincibleRMC Dec 6, 2024
d660177
use hasattr
InvincibleRMC Dec 6, 2024
fe21e0f
try setattr
InvincibleRMC Dec 6, 2024
4a443a7
add c++17 guard
InvincibleRMC Dec 6, 2024
008c370
style: pre-commit fixes
pre-commit-ci[bot] Dec 6, 2024
b318d02
add compile guard
InvincibleRMC Dec 6, 2024
a53bf0e
style: pre-commit fixes
pre-commit-ci[bot] Dec 6, 2024
adfed51
cleanup
InvincibleRMC Dec 6, 2024
9160cb1
comments and function name cleanup
InvincibleRMC Dec 8, 2024
1571d52
Merge remote-tracking branch 'origin/master' into attribute-types
InvincibleRMC Dec 8, 2024
07f8613
style: pre-commit fixes
pre-commit-ci[bot] Dec 8, 2024
14d2dda
update test case
InvincibleRMC Dec 15, 2024
881b356
style: pre-commit fixes
pre-commit-ci[bot] Dec 15, 2024
731745a
add write
InvincibleRMC Dec 15, 2024
048b539
Merge branch 'master' into attribute-types
InvincibleRMC Dec 15, 2024
890fcae
added instance check
InvincibleRMC Dec 15, 2024
28d2a66
style: pre-commit fixes
pre-commit-ci[bot] Dec 15, 2024
3cbaafd
Test for `__cpp_inline_variables` and use `static_assert()`
rwgk Dec 17, 2024
58e5a09
Add guard: __annotations__["list_int"] was set already.
rwgk Dec 17, 2024
882d20f
Avoid explicit `false` in `static_assert()`.
rwgk Dec 17, 2024
b296137
add redeclaration test
InvincibleRMC Dec 19, 2024
981e0a3
style: pre-commit fixes
pre-commit-ci[bot] Dec 19, 2024
7f7b70b
add handle support to attr_with_type_hint
InvincibleRMC Dec 19, 2024
19e033c
store reintpreted_key
InvincibleRMC Dec 19, 2024
375ea30
style: pre-commit fixes
pre-commit-ci[bot] Dec 19, 2024
9f532cf
fix str namespace
InvincibleRMC Dec 19, 2024
8b241f1
fix scope?
InvincibleRMC Dec 19, 2024
f205bb4
string wrap
InvincibleRMC Dec 19, 2024
a6ffbe3
clang tidy
InvincibleRMC Dec 19, 2024
cd4f771
Swap order of attr_with_type_hint implementation in cast.h (so that t…
rwgk Dec 20, 2024
08d4774
Reuse `const char *` overload from `handle` overload.
rwgk Dec 20, 2024
1582971
Added comment
InvincibleRMC Dec 20, 2024
fe04344
style: pre-commit fixes
pre-commit-ci[bot] Dec 20, 2024
988c039
line up comment
InvincibleRMC Dec 20, 2024
2241892
fixed spelling error
InvincibleRMC Dec 20, 2024
4be50fe
Add missing period
InvincibleRMC Dec 20, 2024
8f87e21
Introduce `detail::always_false<>` to make the new `static_assert()` …
rwgk Dec 21, 2024
e6b1370
Copy `attr` comment for `const char *` overload, for consistency.
rwgk Dec 21, 2024
999b668
static_assert(std::false_type::value, ...)
rwgk Dec 21, 2024
9d46b4d
Revert "static_assert(std::false_type::value, ...)"
rwgk Dec 21, 2024
921657a
Add comment for `always_false`
rwgk Dec 21, 2024
e1e2c9a
add test for inspect.get_annotations()
InvincibleRMC Dec 21, 2024
2774a8d
style: pre-commit fixes
pre-commit-ci[bot] Dec 21, 2024
6a6bff8
Merge branch 'master' into attribute-types
InvincibleRMC Dec 21, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions include/pybind11/cast.h
Original file line number Diff line number Diff line change
Expand Up @@ -1366,6 +1366,31 @@ object object_or_cast(T &&o) {
return pybind11::cast(std::forward<T>(o));
}

// Declared in pytypes.h:
// Implemented here so that make_caster<T> can be used.
template <typename D>
template <typename T>
str_attr_accessor object_api<D>::attr_with_type_hint(const char *key) const {
#if !defined(__cpp_inline_variables)
static_assert(always_false<T>::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<T>::name.text;
return {derived(), key};
}

template <typename D>
template <typename T>
obj_attr_accessor object_api<D>::attr_with_type_hint(handle key) const {
(void) attr_with_type_hint<T>(key.cast<std::string>().c_str());
return {derived(), reinterpret_borrow<object>(key)};
}

// Placeholder type for the unneeded (and dead code) static variable in the
// PYBIND11_OVERRIDE_OVERRIDE macro
struct override_unused {};
Expand Down
8 changes: 8 additions & 0 deletions include/pybind11/detail/common.h
Original file line number Diff line number Diff line change
Expand Up @@ -627,6 +627,14 @@ struct instance {
static_assert(std::is_standard_layout<instance>::value,
"Internal error: `pybind11::detail::instance` is not standard layout!");

// Some older compilers (e.g. gcc 9.4.0) require
// static_assert(always_false<T>::value, "...");
// instead of
// static_assert(false, "...");
// to trigger the static_assert() in a template only if it is actually instantiated.
template <typename>
InvincibleRMC marked this conversation as resolved.
Show resolved Hide resolved
struct always_false : std::false_type {};

/// from __cpp_future__ import (convenient aliases from C++14/17)
#if defined(PYBIND11_CPP14)
using std::conditional_t;
Expand Down
27 changes: 27 additions & 0 deletions include/pybind11/pytypes.h
Original file line number Diff line number Diff line change
Expand Up @@ -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 <typename T>
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 <typename T>
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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -2558,6 +2572,19 @@ str_attr_accessor object_api<D>::doc() const {
return attr("__doc__");
}

template <typename D>
object object_api<D>::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 <typename D>
handle object_api<D>::get_type() const {
return type::handle_of(derived());
Expand Down
22 changes: 22 additions & 0 deletions include/pybind11/typing.h
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,18 @@ class Optional : public object {
using object::object;
};

template <typename T>
class Final : public object {
PYBIND11_OBJECT_DEFAULT(Final, object, PyObject_Type)
using object::object;
};

template <typename T>
class ClassVar : public object {
PYBIND11_OBJECT_DEFAULT(ClassVar, object, PyObject_Type)
using object::object;
};

template <typename T>
class TypeGuard : public bool_ {
using bool_::bool_;
Expand Down Expand Up @@ -251,6 +263,16 @@ struct handle_type_name<typing::Optional<T>> {
= const_name("Optional[") + as_return_type<make_caster<T>>::name + const_name("]");
};

template <typename T>
struct handle_type_name<typing::Final<T>> {
static constexpr auto name = const_name("Final[") + make_caster<T>::name + const_name("]");
};

template <typename T>
struct handle_type_name<typing::ClassVar<T>> {
static constexpr auto name = const_name("ClassVar[") + make_caster<T>::name + const_name("]");
};

// TypeGuard and TypeIs use as_return_type to use the return type if available, which is usually
// the narrower type.

Expand Down
32 changes: 32 additions & 0 deletions tests/test_pytypes.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<py::typing::List<int>>("list_int") = py::list();
// Exercises py::handle overload:
m.attr_with_type_hint<py::typing::Set<py::str>>(py::str("set_str")) = py::set();

struct Empty {};
py::class_<Empty>(m, "EmptyAnnotationClass");

struct Static {};
auto static_class = py::class_<Static>(m, "Static");
static_class.def(py::init());
static_class.attr_with_type_hint<py::typing::ClassVar<float>>("x") = 1.0;
static_class.attr_with_type_hint<py::typing::ClassVar<py::typing::Dict<py::str, int>>>(
"dict_str_int")
= py::dict();

struct Instance {};
auto instance = py::class_<Instance>(m, "Instance", py::dynamic_attr());
instance.def(py::init());
instance.attr_with_type_hint<float>("y");

m.def("attr_with_type_hint_float_x",
[](py::handle obj) { obj.attr_with_type_hint<float>("x"); });

m.attr_with_type_hint<py::typing::Final<int>>("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<T>
m.def("half_of_number_vector", [](const std::vector<RealNumber> &x) {
Expand Down
90 changes: 90 additions & 0 deletions tests/test_pytypes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

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

Python 3.10 adds a new function to the standard library: inspect.get_annotations(). In Python versions 3.10 and newer, calling this function is the best practice for accessing the annotations dict of any object that supports annotations

Is it maybe worth also adding a small test using inspect.get_annotations() for access?
(only for 3.10+: @pytest.mark.skipif(sys.version_info < (3, 10)))

Copy link
Collaborator

Choose a reason for hiding this comment

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

@InvincibleRMC could you please help with that?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done.

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
Expand Down
Loading