Files
pybind11/tests/test_native_enum.py
Ralf W. Grosse-Kunstleve f365314ec0 Enable Conversions Between Native Python Enum Types and C++ Enums (#5555)
* Apply smart_holder-branch-based PR #5280 on top of master.

* Add pytest.skip("GraalPy does not raise UnicodeDecodeError")

* Add `parent_scope` as first argument to `py::native_enum` ctor.

* Replace `operator+=` API with `.finalize()` API. The error messages still need cleanup.

* Resolve clang-tidy performance-unnecessary-value-param errors

* Rename (effectively) native_enum_add_to_parent() -> finalize()

* Update error message: pybind11::native_enum<...>("Fake", ...): MISSING .finalize()

* Pass py::module_ by reference to resolve clang-tidy errors (this is entirely inconsequential otherwise for all practical purposes).

* test_native_enum_correct_use_failure -> test_native_enum_missing_finalize_failure

* Add test_native_enum_double_finalize(), test_native_enum_value_after_finalize()

* Clean up public/protected API.

* [ci skip] Update the Enumerations section in classes.rst

* Rename `py::native_enum_kind` → `py::enum_kind` as suggested by gh-henryiii:

https://github.com/pybind/pybind11/pull/5555#issuecomment-2711672335

* Experiment: StrEnum

enum.StrEnum does not map to C++ enum:

* https://chatgpt.com/share/67d5e965-ccb0-8008-95b7-0df2502309b3

```
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-8.3.3, pluggy-1.5.0
C++ Info: 13.3.0 C++20 __pybind11_internals_v10000000_system_libstdcpp_gxx_abi_1xxx_use_cxx11_abi_1__ PYBIND11_SIMPLE_GIL_MANAGEMENT=False PYBIND11_NUMPY_1_ONLY=False
configfile: pytest.ini
plugins: parallel-0.1.1, xdist-3.6.1
collected 40 items / 39 deselected / 1 selected

test_native_enum.py F                                                    [100%]

=================================== FAILURES ===================================
________________________ test_native_enum_StrEnum_greek ________________________

    def test_native_enum_StrEnum_greek():
        assert not hasattr(m, "greek")
>       m.native_enum_StrEnum_greek(m)

test_native_enum.py:150:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
/usr/lib/python3.12/enum.py:764: in __call__
    return cls._create_(
        boundary   = None
        cls        = <enum 'StrEnum'>
        module     = None
        names      = [('Alpha', 10), ('Omega', 20)]
        qualname   = None
        start      = 1
        type       = None
        value      = 'greek'
        values     = ()
/usr/lib/python3.12/enum.py:917: in _create_
    return metacls.__new__(metacls, class_name, bases, classdict, boundary=boundary)
        _          = <class 'str'>
        bases      = (<enum 'StrEnum'>,)
        boundary   = None
        class_name = 'greek'
        classdict  = {'_generate_next_value_': <function StrEnum._generate_next_value_ at 0x701ec1711e40>, 'Alpha': 10, 'Omega': 20, '__module__': 'test_native_enum'}
        cls        = <enum 'StrEnum'>
        first_enum = <enum 'StrEnum'>
        item       = ('Omega', 20)
        member_name = 'Omega'
        member_value = 20
        metacls    = <class 'enum.EnumType'>
        module     = 'test_native_enum'
        names      = [('Alpha', 10), ('Omega', 20)]
        qualname   = None
        start      = 1
        type       = None
/usr/lib/python3.12/enum.py:606: in __new__
    raise exc.with_traceback(tb)
        __class__  = <class 'enum.EnumType'>
        __new__    = <function StrEnum.__new__ at 0x701ec1711da0>
        _gnv       = <staticmethod(<function StrEnum._generate_next_value_ at 0x701ec1711e40>)>
        _order_    = None
        _simple    = False
        bases      = (<enum 'StrEnum'>,)
        boundary   = None
        classdict  = {'Alpha': <enum._proto_member object at 0x701ebc74f9b0>, 'Omega': <enum._proto_member object at 0x701ebc74cce0>, '__module__': 'test_native_enum', '_all_bits_': 0, ...}
        cls        = 'greek'
        exc        = TypeError('10 is not a string')
        first_enum = <enum 'StrEnum'>
        ignore     = ['_ignore_']
        invalid_names = set()
        key        = '_ignore_'
        kwds       = {}
        member_names = {'Alpha': None, 'Omega': None}
        member_type = <class 'str'>
        metacls    = <class 'enum.EnumType'>
        name       = 'Omega'
        save_new   = False
        tb         = <traceback object at 0x701ebc7a6cc0>
        use_args   = True
        value      = 20
/usr/lib/python3.12/enum.py:596: in __new__
    enum_class = super().__new__(metacls, cls, bases, classdict, **kwds)
        __class__  = <class 'enum.EnumType'>
        __new__    = <function StrEnum.__new__ at 0x701ec1711da0>
        _gnv       = <staticmethod(<function StrEnum._generate_next_value_ at 0x701ec1711e40>)>
        _order_    = None
        _simple    = False
        bases      = (<enum 'StrEnum'>,)
        boundary   = None
        classdict  = {'Alpha': <enum._proto_member object at 0x701ebc74f9b0>, 'Omega': <enum._proto_member object at 0x701ebc74cce0>, '__module__': 'test_native_enum', '_all_bits_': 0, ...}
        cls        = 'greek'
        exc        = TypeError('10 is not a string')
        first_enum = <enum 'StrEnum'>
        ignore     = ['_ignore_']
        invalid_names = set()
        key        = '_ignore_'
        kwds       = {}
        member_names = {'Alpha': None, 'Omega': None}
        member_type = <class 'str'>
        metacls    = <class 'enum.EnumType'>
        name       = 'Omega'
        save_new   = False
        tb         = <traceback object at 0x701ebc7a6cc0>
        use_args   = True
        value      = 20
/usr/lib/python3.12/enum.py:271: in __set_name__
    enum_member = enum_class._new_member_(enum_class, *args)
        args       = (10,)
        enum_class = <enum 'greek'>
        member_name = 'Alpha'
        self       = <enum._proto_member object at 0x701ebc74f9b0>
        value      = 10
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

cls = <enum 'greek'>, values = (10,)

    def __new__(cls, *values):
        "values must already be of type `str`"
        if len(values) > 3:
            raise TypeError('too many arguments for str(): %r' % (values, ))
        if len(values) == 1:
            # it must be a string
            if not isinstance(values[0], str):
>               raise TypeError('%r is not a string' % (values[0], ))
E               TypeError: 10 is not a string

cls        = <enum 'greek'>
values     = (10,)

/usr/lib/python3.12/enum.py:1322: TypeError
=========================== short test summary info ============================
FAILED test_native_enum.py::test_native_enum_StrEnum_greek - TypeError: 10 is...
======================= 1 failed, 39 deselected in 0.07s =======================

ERROR: completed_process.returncode=1
```

* Remove StrEnum code.

* Make enum_kind::Enum the default kind.

* Catch redundant .export_values() calls.

* [ci skip] Add back original documentation for `py::enum_` under new advanced/deprecated.rst

* [ci skip] Add documentation for `py::enum_kind` and `py::detail::type_caster_enum_type_enabled`

* Rename `Type` to `EnumType` for readability.

* Eliminate py::enum_kind, use "enum.Enum", "enum.IntEnum" directly. This is still WIP.

* EXPERIMENTAL StrEnum code. To be removed.

* Remove experimental StrEnum code:

My judgement: Supporting StrEnum is maybe nice, but not very valuable. I don't think it is worth the extra C++ code.

A level of indirection would need to be managed, e.g.

    RED   ↔ Python "r" ↔ C++ 0
    Green ↔ Python "g" ↔ C++ 1

These mappings would need to be stored and processed.

* Add test with enum.IntFlag (no production code changes required).

* First import_or_getattr() implementation (dedicated tests are still missing).

* Fix import_or_getattr() implementation, add tests, fix clang-tidy errors.

* [ci skip] Update classes.rst: replace `py::enum_kind` with `native_type_name`

* For "constructor similar to that of enum.Enum" point to https://docs.python.org/3/howto/enum.html#functional-api, as suggested by gh-timohl (https://github.com/pybind/pybind11/pull/5555#discussion_r2009277507).

* Advertise Enum, IntEnum, Flag, IntFlags are compatible stdlib enum types in the documentation (as suggested by gh-timohl, https://github.com/pybind/pybind11/pull/5555#pullrequestreview-2708832587); add test for enum.Flag to ensure that is actually true.
2025-03-24 20:31:59 -07:00

298 lines
8.6 KiB
Python

from __future__ import annotations
import enum
import pickle
import pytest
import env
from pybind11_tests import native_enum as m
SMALLENUM_MEMBERS = (
("a", 0),
("b", 1),
("c", 2),
)
COLOR_MEMBERS = (
("red", 0),
("yellow", 1),
("green", 20),
("blue", 21),
)
ALTITUDE_MEMBERS = (
("high", "h"),
("low", "l"),
)
FLAGS_UCHAR_MEMBERS = (
("bit0", 0x1),
("bit1", 0x2),
("bit2", 0x4),
)
FLAGS_UINT_MEMBERS = (
("bit0", 0x1),
("bit1", 0x2),
("bit2", 0x4),
)
CLASS_WITH_ENUM_IN_CLASS_MEMBERS = (
("one", 0),
("two", 1),
)
EXPORT_VALUES_MEMBERS = (
("exv0", 0),
("exv1", 1),
)
MEMBER_DOC_MEMBERS = (
("mem0", 0),
("mem1", 1),
("mem2", 2),
)
ENUM_TYPES_AND_MEMBERS = (
(m.smallenum, SMALLENUM_MEMBERS),
(m.color, COLOR_MEMBERS),
(m.altitude, ALTITUDE_MEMBERS),
(m.flags_uchar, FLAGS_UCHAR_MEMBERS),
(m.flags_uint, FLAGS_UINT_MEMBERS),
(m.export_values, EXPORT_VALUES_MEMBERS),
(m.member_doc, MEMBER_DOC_MEMBERS),
(m.class_with_enum.in_class, CLASS_WITH_ENUM_IN_CLASS_MEMBERS),
)
ENUM_TYPES = [_[0] for _ in ENUM_TYPES_AND_MEMBERS]
@pytest.mark.parametrize("enum_type", ENUM_TYPES)
def test_enum_type(enum_type):
assert isinstance(enum_type, enum.EnumMeta)
assert enum_type.__module__ == m.__name__
@pytest.mark.parametrize(("enum_type", "members"), ENUM_TYPES_AND_MEMBERS)
def test_enum_members(enum_type, members):
for name, value in members:
assert enum_type[name].value == value
@pytest.mark.parametrize(("enum_type", "members"), ENUM_TYPES_AND_MEMBERS)
def test_pickle_roundtrip(enum_type, members):
for name, _ in members:
orig = enum_type[name]
if enum_type is m.class_with_enum.in_class:
# This is a general pickle limitation.
with pytest.raises(pickle.PicklingError):
pickle.dumps(orig)
else:
# This only works if __module__ is correct.
serialized = pickle.dumps(orig)
restored = pickle.loads(serialized)
assert restored == orig
@pytest.mark.parametrize("enum_type", [m.flags_uchar, m.flags_uint])
def test_enum_flag(enum_type):
bits02 = enum_type.bit0 | enum_type.bit2
assert enum_type.bit0 in bits02
assert enum_type.bit1 not in bits02
assert enum_type.bit2 in bits02
def test_export_values():
assert m.exv0 is m.export_values.exv0
assert m.exv1 is m.export_values.exv1
def test_member_doc():
pure_native = enum.IntEnum("pure_native", (("mem", 0),))
assert m.member_doc.mem0.__doc__ == "docA"
assert m.member_doc.mem1.__doc__ == pure_native.mem.__doc__
assert m.member_doc.mem2.__doc__ == "docC"
def test_pybind11_isinstance_color():
for name, _ in COLOR_MEMBERS:
assert m.isinstance_color(m.color[name])
assert not m.isinstance_color(m.color)
for name, _ in SMALLENUM_MEMBERS:
assert not m.isinstance_color(m.smallenum[name])
assert not m.isinstance_color(m.smallenum)
assert not m.isinstance_color(None)
def test_pass_color_success():
for name, value in COLOR_MEMBERS:
assert m.pass_color(m.color[name]) == value
def test_pass_color_fail():
with pytest.raises(TypeError) as excinfo:
m.pass_color(None)
assert "test_native_enum::color" in str(excinfo.value)
def test_return_color_success():
for name, value in COLOR_MEMBERS:
assert m.return_color(value) == m.color[name]
def test_return_color_fail():
with pytest.raises(ValueError) as excinfo_direct:
m.color(2)
with pytest.raises(ValueError) as excinfo_cast:
m.return_color(2)
assert str(excinfo_cast.value) == str(excinfo_direct.value)
def test_type_caster_enum_type_enabled_false():
# This is really only a "does it compile" test.
assert m.pass_some_proto_enum(None) is None
assert m.return_some_proto_enum() is None
@pytest.mark.skipif(isinstance(m.obj_cast_color_ptr, str), reason=m.obj_cast_color_ptr)
def test_obj_cast_color_ptr():
with pytest.raises(RuntimeError) as excinfo:
m.obj_cast_color_ptr(m.color.red)
assert str(excinfo.value) == "Unable to cast native enum type to reference"
def test_py_cast_color_handle():
for name, value in COLOR_MEMBERS:
assert m.py_cast_color_handle(m.color[name]) == value
def test_exercise_import_or_getattr_leading_dot():
with pytest.raises(ValueError) as excinfo:
m.exercise_import_or_getattr(m, ".")
assert str(excinfo.value) == "Invalid fully-qualified name `.` (native_type_name)"
def test_exercise_import_or_getattr_bad_top_level():
with pytest.raises(ImportError) as excinfo:
m.exercise_import_or_getattr(m, "NeVeRLaNd")
assert (
str(excinfo.value)
== "Failed to import top-level module `NeVeRLaNd` (native_type_name)"
)
def test_exercise_import_or_getattr_dot_dot():
with pytest.raises(ValueError) as excinfo:
m.exercise_import_or_getattr(m, "enum..")
assert (
str(excinfo.value) == "Invalid fully-qualified name `enum..` (native_type_name)"
)
def test_exercise_import_or_getattr_bad_enum_attr():
with pytest.raises(ImportError) as excinfo:
m.exercise_import_or_getattr(m, "enum.NoNeXiStInG")
lines = str(excinfo.value).splitlines()
assert len(lines) >= 5
assert (
lines[0]
== "Failed to import or getattr `NoNeXiStInG` from `enum` (native_type_name)"
)
assert lines[1] == "-------- getattr exception --------"
ix = lines.index("-------- import exception --------")
assert ix >= 3
assert len(lines) > ix + 0
def test_native_enum_data_missing_finalize_error_message():
msg = m.native_enum_data_missing_finalize_error_message("Fake")
assert msg == 'pybind11::native_enum<...>("Fake", ...): MISSING .finalize()'
@pytest.mark.parametrize(
"func", [m.native_enum_ctor_malformed_utf8, m.native_enum_value_malformed_utf8]
)
def test_native_enum_malformed_utf8(func):
if env.GRAALPY and func is m.native_enum_ctor_malformed_utf8:
pytest.skip("GraalPy does not raise UnicodeDecodeError")
malformed_utf8 = b"\x80"
with pytest.raises(UnicodeDecodeError):
func(malformed_utf8)
def test_native_enum_double_finalize():
with pytest.raises(RuntimeError) as excinfo:
m.native_enum_double_finalize(m)
assert (
str(excinfo.value)
== 'pybind11::native_enum<...>("fake_native_enum_double_finalize"): DOUBLE finalize'
)
def test_native_enum_value_after_finalize():
with pytest.raises(RuntimeError) as excinfo:
m.native_enum_value_after_finalize(m)
assert (
str(excinfo.value)
== 'pybind11::native_enum<...>("fake_native_enum_value_after_finalize"): value after finalize'
)
def test_double_registration_native_enum():
with pytest.raises(RuntimeError) as excinfo:
m.double_registration_native_enum(m)
assert (
str(excinfo.value)
== 'pybind11::native_enum<...>("fake_double_registration_native_enum") is already registered!'
)
def test_native_enum_name_clash():
m.fake_native_enum_name_clash = None
with pytest.raises(RuntimeError) as excinfo:
m.native_enum_name_clash(m)
assert (
str(excinfo.value)
== 'pybind11::native_enum<...>("fake_native_enum_name_clash"):'
" an object with that name is already defined"
)
def test_native_enum_value_name_clash():
m.fake_native_enum_value_name_clash_x = None
with pytest.raises(RuntimeError) as excinfo:
m.native_enum_value_name_clash(m)
assert (
str(excinfo.value)
== 'pybind11::native_enum<...>("fake_native_enum_value_name_clash")'
'.value("fake_native_enum_value_name_clash_x"):'
" an object with that name is already defined"
)
def test_double_registration_enum_before_native_enum():
with pytest.raises(RuntimeError) as excinfo:
m.double_registration_enum_before_native_enum(m)
assert (
str(excinfo.value)
== 'pybind11::native_enum<...>("fake_enum_first") is already registered'
" as a `pybind11::enum_` or `pybind11::class_`!"
)
def test_double_registration_native_enum_before_enum():
with pytest.raises(RuntimeError) as excinfo:
m.double_registration_native_enum_before_enum(m)
assert (
str(excinfo.value)
== 'pybind11::enum_ "name_must_be_different_to_reach_desired_code_path"'
" is already registered as a pybind11::native_enum!"
)
def test_native_enum_missing_finalize_failure():
if not isinstance(m.native_enum_missing_finalize_failure, str):
m.native_enum_missing_finalize_failure()
pytest.fail("Process termination expected.")