From fb3c163f40fee7f1979c9ab52256cd25cead4e80 Mon Sep 17 00:00:00 2001 From: Kwabena Amponsah Date: Mon, 3 Aug 2026 18:27:56 +0100 Subject: [PATCH 01/18] #113 Support enums as a first-class entity Add an `enums` module config key (plus `use_all_enums`/CPPWG_ALL) so a plain namespace-scope enum can be wrapped directly, instead of only reaching Python as the sole member of a struct via the class writer's struct-enum special case. Mirrors the free-function path end to end: a new CppEnumInfo resolves the decl via source_ns.enumerations(); a new CppEnumWrapperWriter emits py::enum_(m, "Foo").value(...).export_values() inline in the module; the parser, module info, module writer and header collection writer gain the matching enum plumbing. Both unscoped `enum` and scoped `enum class` flow through the one path. Enums are registered before free functions and class register calls, so an enum used as a defaulted argument of a wrapped signature is already registered when pybind11 materialises that default at import time. The struct-enum special case is left untouched. Co-Authored-By: Claude Opus 4.8 --- cppwg/info/enum_info.py | 41 ++++++++++++ cppwg/info/module_info.py | 31 +++++++++ cppwg/parsers/package_info_parser.py | 40 ++++++++++++ cppwg/templates/pybind11_default.py | 13 ++++ cppwg/writers/enum_writer.py | 76 +++++++++++++++++++++++ cppwg/writers/header_collection_writer.py | 15 ++++- cppwg/writers/module_writer.py | 10 +++ doc/reference.md | 13 ++++ tests/test_enum_info.py | 16 +++++ tests/test_enum_writer.py | 64 +++++++++++++++++++ tests/test_header_collection_writer.py | 4 ++ tests/test_module_writer.py | 55 ++++++++++++++++ tests/test_package_info_parser.py | 42 +++++++++++++ 13 files changed, 418 insertions(+), 2 deletions(-) create mode 100644 cppwg/info/enum_info.py create mode 100644 cppwg/writers/enum_writer.py create mode 100644 tests/test_enum_info.py create mode 100644 tests/test_enum_writer.py diff --git a/cppwg/info/enum_info.py b/cppwg/info/enum_info.py new file mode 100644 index 0000000..26e0e20 --- /dev/null +++ b/cppwg/info/enum_info.py @@ -0,0 +1,41 @@ +"""Enum information structure.""" + +import logging +from typing import TYPE_CHECKING, Any + +from cppwg.info.cpp_entity_info import CppEntityInfo + +if TYPE_CHECKING: + from pygccxml.declarations.namespace import namespace_t + + +class CppEnumInfo(CppEntityInfo): + """An information structure for individual enums to be wrapped.""" + + def __init__(self, name: str, enum_config: dict[str, Any] | None = None): + super().__init__(name, enum_config) + + def update_from_ns(self, source_ns: "namespace_t") -> None: + """ + Update with information from the source namespace. + + Adds the enum declaration. + + Parameters + ---------- + source_ns : pygccxml.declarations.namespace_t + The source namespace + """ + enum_decls = source_ns.enumerations(self.name, allow_empty=True) + + if not enum_decls: + # The enum's header was not parsed. For explicitly listed enums, the + # header is only included when source_file_path is set in the config. + logger = logging.getLogger() + logger.error( + f"Could not find enum {self.name}. Set source_file_path " + "in the config so that its header is included." + ) + raise RuntimeError(f"Could not find enum: {self.name}") + + self.decls = [enum_decls[0]] diff --git a/cppwg/info/module_info.py b/cppwg/info/module_info.py index 3836fb5..89b5097 100644 --- a/cppwg/info/module_info.py +++ b/cppwg/info/module_info.py @@ -5,6 +5,7 @@ from cppwg.info.base_info import BaseInfo from cppwg.info.class_info import CppClassInfo +from cppwg.info.enum_info import CppEnumInfo from cppwg.info.free_function_info import CppFreeFunctionInfo from cppwg.utils import utils @@ -50,6 +51,8 @@ class wrappers (see CppClassWrapperWriter), so that a class in this module Use all free functions in the module use_all_variables : bool Use all variables in the module + use_all_enums : bool + Use all enums in the module package_info : PackageInfo The package info object this module belongs to @@ -60,6 +63,8 @@ class wrappers (see CppClassWrapperWriter), so that a class in this module A list of free function info objects that belong to this module variable_collection : list[CppVariableInfo] A list of variable info objects that belong to this module + enum_collection : list[CppEnumInfo] + A list of enum info objects that belong to this module """ def __init__(self, name: str, module_config: dict[str, Any] | None = None) -> None: @@ -81,12 +86,14 @@ def __init__(self, name: str, module_config: dict[str, Any] | None = None) -> No self.use_all_classes: bool = False self.use_all_free_functions: bool = False self.use_all_variables: bool = False + self.use_all_enums: bool = False self.package_info: "PackageInfo | None" = None self.class_collection: list[CppClassInfo] = [] self.free_function_collection: list[CppFreeFunctionInfo] = [] self.variable_collection: list["CppVariableInfo"] = [] + self.enum_collection: list[CppEnumInfo] = [] if module_config: for key in [ @@ -96,6 +103,7 @@ def __init__(self, name: str, module_config: dict[str, Any] | None = None) -> No "use_all_classes", "use_all_free_functions", "use_all_variables", + "use_all_enums", ]: if key in module_config: setattr(self, key, module_config[key]) @@ -135,6 +143,13 @@ def add_variable(self, variable_info: "CppVariableInfo") -> None: self.variable_collection.append(variable_info) variable_info.parent = self + def add_enum(self, enum_info: CppEnumInfo) -> None: + """ + Add an enum info object to the module. + """ + self.enum_collection.append(enum_info) + enum_info.parent = self + def is_decl_in_source_path(self, decl: "declaration_t") -> bool: """ Check if the declaration is associated with a file in the specified source paths. @@ -273,6 +288,21 @@ def update_from_ns(self, source_ns: "namespace_t") -> None: for ff_info in self.free_function_collection: ff_info.update_from_ns(source_ns) + # Add discovered enums: if `use_all_enums` is True, this module has no + # enum info objects. Use enum decls from the source namespace to create + # enum info objects. + if self.use_all_enums: + enum_decls = source_ns.enumerations(allow_empty=True) + for enum_decl in enum_decls: + if self.is_decl_in_source_path(enum_decl): + enum_info = CppEnumInfo(enum_decl.name) + enum_info.module_info = self + self.enum_collection.append(enum_info) + + # Update enums with information from source namespace. + for enum_info in self.enum_collection: + enum_info.update_from_ns(source_ns) + def update_from_source(self, source_file_paths: list[str]) -> None: """ Update module with information from the source headers. @@ -287,3 +317,4 @@ def update_from_source(self, source_file_paths: list[str]) -> None: self.class_collection.sort(key=lambda x: x.name) self.free_function_collection.sort(key=lambda x: x.name) + self.enum_collection.sort(key=lambda x: x.name) diff --git a/cppwg/parsers/package_info_parser.py b/cppwg/parsers/package_info_parser.py index 2b52ef1..abb07cb 100644 --- a/cppwg/parsers/package_info_parser.py +++ b/cppwg/parsers/package_info_parser.py @@ -7,6 +7,7 @@ import yaml from cppwg.info.class_info import CppClassInfo +from cppwg.info.enum_info import CppEnumInfo from cppwg.info.free_function_info import CppFreeFunctionInfo from cppwg.info.module_info import ModuleInfo from cppwg.info.package_info import PackageInfo @@ -126,9 +127,11 @@ def parse(self) -> PackageInfo: "use_all_classes": False, "use_all_free_functions": False, "use_all_variables": False, + "use_all_enums": False, "classes": [], "free_functions": [], "variables": [], + "enums": [], } module_config.update(base_config) @@ -160,6 +163,10 @@ def parse(self) -> PackageInfo: module_config["variables"] ) + module_config["use_all_enums"] = utils.is_option_ALL( + module_config["enums"] + ) + # Create the ModuleInfo object from the module config dict module_info = ModuleInfo(module_config["name"], module_config) @@ -268,6 +275,39 @@ def parse(self) -> PackageInfo: # Add the variable to the module module_info.add_variable(variable_info) + # Parse the enum data and create enum info objects. + # Note: if module_config["use_all_enums"] == True, enum info objects + # will be added later after parsing the C++ source code. + if not module_config["use_all_enums"]: + if module_config["enums"]: + for raw_enum_info in module_config["enums"]: + # Get enum config from the raw enum info + enum_config = { + "name_override": "", + "source_file": "", + "source_file_path": "", + } + enum_config.update(base_config) + + for key in enum_config.keys(): + if key in raw_enum_info: + enum_config[key] = raw_enum_info[key] + + # Convert source file path to a full path + enum_config["source_file_path"] = self.full_path( + enum_config["source_file_path"] + ) + self.verify_path(enum_config["source_file_path"]) + + # Convert custom generator path to a full path + self.convert_custom_generator(enum_config) + + # Create the CppEnumInfo object from the enum config dict + enum_info = CppEnumInfo(raw_enum_info["name"], enum_config) + + # Add the enum to the module + module_info.add_enum(enum_info) + return package_info # Deprecated config option -> replacement guidance shown in the warning. diff --git a/cppwg/templates/pybind11_default.py b/cppwg/templates/pybind11_default.py index 312862e..0ce35b1 100644 --- a/cppwg/templates/pybind11_default.py +++ b/cppwg/templates/pybind11_default.py @@ -65,6 +65,7 @@ "{\n" "${imports}" "${exception_translator}" + "${enums}" "${free_functions}" "${register_calls}" "${module_code}" @@ -174,6 +175,17 @@ "}\n" ) +# Skeleton for a plain (namespace-scope) enum, registered directly against the +# module m. Emitted inline in the module main cpp (like a free function), not as a +# separate register_..._class function. ${enum_values} is one .value(...) line per +# enumerator. .export_values() is emitted for both scoped and unscoped enums (see +# CppEnumWrapperWriter). +enum_register = Template( + ' py::enum_<${enum_cpp_name}>(m, "${enum_py_name}")\n' + "${enum_values}" + " .export_values();\n\n" +) + # Skeleton for the header collection hpp file, which includes every header to be # parsed by CastXML plus the explicit template instantiations and typedefs # (e.g. typedef Foo<2,2> Foo_2_2) for all classes to be wrapped. @@ -207,6 +219,7 @@ "class_cpp_header": class_cpp_header, "class_cpp_register": class_cpp_register, "struct_enum_register": struct_enum_register, + "enum_register": enum_register, "free_function": free_function, "class_method": class_method, "class_constructor": class_constructor, diff --git a/cppwg/writers/enum_writer.py b/cppwg/writers/enum_writer.py new file mode 100644 index 0000000..a507de3 --- /dev/null +++ b/cppwg/writers/enum_writer.py @@ -0,0 +1,76 @@ +"""Wrapper code writer for C++ enums.""" + +from typing import TYPE_CHECKING + +from cppwg.info.enum_info import CppEnumInfo +from cppwg.writers.base_writer import CppBaseWrapperWriter + +if TYPE_CHECKING: + from string import Template + + +class CppEnumWrapperWriter(CppBaseWrapperWriter): + """ + Manage addition of enum wrapper code. + + A plain (namespace-scope) enum is registered directly against the module, + unlike the struct-enum special case (see CppClassWrapperWriter) which nests a + single enum inside a wrapped struct. Both scoped (``enum class``) and unscoped + enums flow through here: ``.value("V", Enum::V)`` qualifies correctly for + either, and ``.export_values()`` is emitted unconditionally (a no-op for a + scoped enum, and this pygccxml version does not distinguish the two). + + Attributes + ---------- + enum_info : CppEnumInfo + The enum information to generate Python bindings for + wrapper_templates : dict[str, Template] + Templates with placeholders for generating wrapper code + """ + + def __init__(self, enum_info, wrapper_templates) -> None: + super().__init__(wrapper_templates) + + self.enum_info: CppEnumInfo = enum_info + self.wrapper_templates: dict[str, "Template"] = wrapper_templates + + def generate_wrapper(self) -> str: + """ + Generate the enum wrapper code. + + Returns + ------- + str + The C++ wrapper code string + """ + if self.exclude(): + return "" + + enum_decl = self.enum_info.decls[0] + enum_cpp_name = enum_decl.name + enum_py_name = self.enum_info.name_override or self.enum_info.name + + # One .value("NAME", Enum::NAME) line per enumerator. enum_decl.values is + # a list of (name, number) tuples in source order; only the name is used. + enum_values = "".join( + f' .value("{value[0]}", {enum_cpp_name}::{value[0]})\n' + for value in enum_decl.values + ) + + enum_dict = { + "enum_cpp_name": enum_cpp_name, + "enum_py_name": enum_py_name, + "enum_values": enum_values, + } + return self.wrapper_templates["enum_register"].substitute(**enum_dict) + + def exclude(self) -> bool: + """ + Check if the enum should be excluded from the wrapper code. + + Returns + ------- + bool + True if the enum should be excluded from wrapper code, False otherwise. + """ + return self.enum_info.excluded diff --git a/cppwg/writers/header_collection_writer.py b/cppwg/writers/header_collection_writer.py index 83fea59..f5cffea 100644 --- a/cppwg/writers/header_collection_writer.py +++ b/cppwg/writers/header_collection_writer.py @@ -76,9 +76,13 @@ def should_include_all(self) -> bool: ------- bool """ - # True if any module uses all classes or all free functions + # True if any module uses all classes, free functions or enums for module_info in self.package_info.module_collection: - if module_info.use_all_classes or module_info.use_all_free_functions: + if ( + module_info.use_all_classes + or module_info.use_all_free_functions + or module_info.use_all_enums + ): return True return False @@ -120,6 +124,13 @@ def includes_block(self) -> str: os.path.basename(free_function_info.source_file_path) ) + # Include specific headers needed by enums + for enum_info in module_info.enum_collection: + if enum_info.source_file_path: + include_files.add( + os.path.basename(enum_info.source_file_path) + ) + # Include headers that declare the configured exception classes so # they are parsed and can be introspected for the translator. Read # each header at most once - mapping each exception to the first diff --git a/cppwg/writers/module_writer.py b/cppwg/writers/module_writer.py index 6de1fb8..fb1f93e 100644 --- a/cppwg/writers/module_writer.py +++ b/cppwg/writers/module_writer.py @@ -11,6 +11,7 @@ write_file_if_changed, ) from cppwg.writers.class_writer import CppClassWrapperWriter +from cppwg.writers.enum_writer import CppEnumWrapperWriter from cppwg.writers.free_function_writer import CppFreeFunctionWrapperWriter if TYPE_CHECKING: @@ -206,6 +207,14 @@ def build_module_context(self) -> dict[str, str]: + "\n" ) + # Enums. Registered before free functions and class register calls so an + # enum used as a defaulted argument of a wrapped signature is already + # registered when pybind11 materialises that default at import time. + enums = "".join( + CppEnumWrapperWriter(enum_info, self.wrapper_templates).generate_wrapper() + for enum_info in module_info.enum_collection + ) + # Free functions free_functions = "".join( CppFreeFunctionWrapperWriter( @@ -237,6 +246,7 @@ def build_module_context(self) -> dict[str, str]: # Register a pybind11 exception translator for the configured # exception classes so C++ exceptions surface as Python exceptions. "exception_translator": self.generate_exception_translator(), + "enums": enums, "free_functions": free_functions, "register_calls": register_calls, "module_code": ensure_trailing_newline( diff --git a/doc/reference.md b/doc/reference.md index 335e5a8..ed5b446 100644 --- a/doc/reference.md +++ b/doc/reference.md @@ -69,6 +69,7 @@ Each entry under `modules:`. | Option | Type | Default | Description | | --- | --- | --- | --- | | `classes` | list | `[]` | Classes to wrap, or the string `CPPWG_ALL` to wrap every class found. See [Selecting what to wrap](basics.md#selecting-what-to-wrap). | +| `enums` | list | `[]` | Plain (namespace-scope) enums to wrap, or `CPPWG_ALL`. Both unscoped `enum` and scoped `enum class` are supported, e.g. exposed as `Color.RED`. This is the recommended way to wrap an enum. | | `external_bases` | list[str] | `[]` | Base-class names registered by an imported **package**, so cppwg will emit them as bases. See [Cross-module inheritance](inheritance.md#imports). | | `free_functions` | list | `[]` | Free functions to wrap, or `CPPWG_ALL`. | | `imports` | list[str] | `[]` | Python modules to import at the start of this module, so their types are registered first. Required for cross-module inheritance. See [Cross-module inheritance](inheritance.md#imports). | @@ -89,3 +90,15 @@ Each entry under a module's `classes:`. All [common options](#common-options) may also be set here. +## Enum options + +Each entry under a module's `enums:`. + +| Option | Type | Default | Description | +| --- | --- | --- | --- | +| `name` | str | – | The C++ enum name (required). | +| `name_override` | str | `""` | Python name for the enum, if different from the C++ name. | +| `source_file_path` | str | `""` | Path (relative to the source root) to the header declaring the enum, so it is parsed. Required for an explicitly listed enum unless its header is already pulled in by a wrapped class in the same header. | + +All [common options](#common-options) may also be set here. + diff --git a/tests/test_enum_info.py b/tests/test_enum_info.py new file mode 100644 index 0000000..37d6b35 --- /dev/null +++ b/tests/test_enum_info.py @@ -0,0 +1,16 @@ +"""Unit tests for cppwg.info.enum_info.""" + +from cppwg.info.enum_info import CppEnumInfo + + +def test_enum_info_init_sets_name(): + """An enum info is created with its name and no config.""" + enum = CppEnumInfo("MyEnum") + assert enum.name == "MyEnum" + + +def test_enum_info_init_applies_config(): + """Config values are applied through the base info initialiser.""" + enum = CppEnumInfo("MyEnum", {"excluded": True}) + assert enum.name == "MyEnum" + assert enum.excluded is True diff --git a/tests/test_enum_writer.py b/tests/test_enum_writer.py new file mode 100644 index 0000000..169d6e3 --- /dev/null +++ b/tests/test_enum_writer.py @@ -0,0 +1,64 @@ +"""Unit tests for cppwg.writers.enum_writer.""" + +from cppwg.templates.pybind11_default import template_collection +from cppwg.writers.enum_writer import CppEnumWrapperWriter + + +class _FakeEnum: + """Minimal pygccxml enumeration_t stand-in. + + ``values`` is a list of (name, number) tuples, as pygccxml exposes them. + """ + + def __init__(self, name, values): + self.name = name + self.values = values + + +class _FakeEnumInfo: + """Minimal CppEnumInfo stand-in.""" + + def __init__(self, name, values, name_override="", excluded=False): + self.name = name + self.name_override = name_override + self.excluded = excluded + self.decls = [_FakeEnum(name, values)] + + +def _writer(info): + return CppEnumWrapperWriter(info, template_collection) + + +def test_generate_wrapper_emits_enum_registration(): + """Each enumerator becomes a .value line qualified by the enum type.""" + info = _FakeEnumInfo("Color", [("RED", 0), ("GREEN", 1), ("BLUE", 2)]) + + result = _writer(info).generate_wrapper() + + assert result == ( + ' py::enum_(m, "Color")\n' + ' .value("RED", Color::RED)\n' + ' .value("GREEN", Color::GREEN)\n' + ' .value("BLUE", Color::BLUE)\n' + " .export_values();\n\n" + ) + + +def test_generate_wrapper_uses_name_override_for_python_name(): + """The Python name comes from name_override; values stay C++-qualified.""" + info = _FakeEnumInfo("CppColor", [("RED", 0)], name_override="Color") + + result = _writer(info).generate_wrapper() + + assert result == ( + ' py::enum_(m, "Color")\n' + ' .value("RED", CppColor::RED)\n' + " .export_values();\n\n" + ) + + +def test_generate_wrapper_excluded_returns_empty(): + """An excluded enum generates no wrapper code.""" + info = _FakeEnumInfo("Color", [("RED", 0)], excluded=True) + + assert _writer(info).generate_wrapper() == "" diff --git a/tests/test_header_collection_writer.py b/tests/test_header_collection_writer.py index 5025a8e..a7710ac 100644 --- a/tests/test_header_collection_writer.py +++ b/tests/test_header_collection_writer.py @@ -41,13 +41,17 @@ def __init__( self, classes=None, free_functions=None, + enums=None, use_all_classes=False, use_all_free_functions=False, + use_all_enums=False, ): self.class_collection = classes or [] self.free_function_collection = free_functions or [] + self.enum_collection = enums or [] self.use_all_classes = use_all_classes self.use_all_free_functions = use_all_free_functions + self.use_all_enums = use_all_enums class _FakePackageInfo: diff --git a/tests/test_module_writer.py b/tests/test_module_writer.py index 5d1b539..8bd5e90 100644 --- a/tests/test_module_writer.py +++ b/tests/test_module_writer.py @@ -73,6 +73,61 @@ def test_write_class_wrappers_rejects_duplicate_file_stem(tmp_path, monkeypatch) writer.write_class_wrappers() +class _FakeEnum: + """Minimal pygccxml enumeration_t stand-in.""" + + def __init__(self, name, values): + self.name = name + self.values = values + + +class _FakeFreeFuncWriter: + def __init__(self, *args): + pass + + def generate_wrapper(self): + return " FREEFUNC;\n" + + +def test_enums_registered_before_free_functions_and_classes(tmp_path, monkeypatch): + """Enum blocks are emitted before free-function and class register calls. + + A defaulted enum argument is materialised by pybind11 when a def is + registered, so the enum type must already be registered by then. + """ + from cppwg.info.enum_info import CppEnumInfo + + monkeypatch.setattr( + module_writer_module, "CppFreeFunctionWrapperWriter", _FakeFreeFuncWriter + ) + + enum_info = CppEnumInfo("Color") + enum_info.decls = [_FakeEnum("Color", [("RED", 0), ("GREEN", 1)])] + + foo = _ClassStub("Foo", "Foo") + foo.py_names = ["Foo"] + + module = _module(classes=[foo], name="mymod") + module.package_info.name = "pkg" + module.package_info.common_include_file = False + module.custom_generator_instance = None + module.hierarchy_attribute = lambda key: None + module.imports = [] + module.free_function_collection = [object()] + module.enum_collection = [enum_info] + + writer = CppModuleWrapperWriter(module, template_collection, str(tmp_path)) + context = writer.build_module_context() + body = template_collection["module_main_cpp"].substitute(**context) + + enum_pos = body.index('py::enum_(m, "Color")') + free_func_pos = body.index("FREEFUNC;") + register_pos = body.index("register_Foo_class(m);") + + assert enum_pos < free_func_pos < register_pos + assert '.value("RED", Color::RED)' in body + + def test_write_module_wrapper_creates_module_dir(tmp_path, monkeypatch): """The module's output directory is created when it does not exist.""" from string import Template diff --git a/tests/test_package_info_parser.py b/tests/test_package_info_parser.py index 8e4266e..c81c7b9 100644 --- a/tests/test_package_info_parser.py +++ b/tests/test_package_info_parser.py @@ -354,6 +354,48 @@ def test_parses_module_variables(tmp_path): assert module_info.variable_collection[0].source_file == "my_var.hpp" +def test_parses_explicit_enum_list(tmp_path): + """An explicit enums list is parsed onto the module.""" + config_path = _write_config( + tmp_path, + """ + name: testpkg + modules: + - name: mymod + enums: + - name: MyEnum + source_file: MyEnum.hpp + """, + ) + + package_info = PackageInfoParser(config_path, str(tmp_path)).parse() + + module_info = package_info.module_collection[0] + assert module_info.use_all_enums is False + assert [e.name for e in module_info.enum_collection] == ["MyEnum"] + assert module_info.enum_collection[0].source_file == "MyEnum.hpp" + + +def test_parses_all_enums_option(tmp_path): + """The CPPWG_ALL enums option sets use_all_enums.""" + config_path = _write_config( + tmp_path, + """ + name: testpkg + modules: + - name: mymod + enums: CPPWG_ALL + """, + ) + + package_info = PackageInfoParser(config_path, str(tmp_path)).parse() + + module_info = package_info.module_collection[0] + assert module_info.use_all_enums is True + # Discovery happens later from the parsed source, so none are added yet. + assert module_info.enum_collection == [] + + def test_custom_generator_path_converted_and_loaded(tmp_path): """A class custom_generator with a CPPWG_SOURCEROOT placeholder is resolved.""" (tmp_path / "FooGen.py").write_text("class FooGen:\n pass\n") From 108305b2af378e9d63420ed8468187ff5088c62b Mon Sep 17 00:00:00 2001 From: Kwabena Amponsah Date: Mon, 3 Aug 2026 18:28:04 +0100 Subject: [PATCH 02/18] #113 Don't report `enum class` declarations as unknown classes find_classes_in_source matched the class/struct keyword in a scoped enum (`enum class Color`), so wrapping such an enum logged a misleading "Unknown class Color". Skip a class/struct keyword directly preceded by `enum` via a fixed-width negative lookbehind (source whitespace is normalised to single spaces, so the lookbehind is reliable). Co-Authored-By: Claude Opus 4.8 --- cppwg/utils/utils.py | 6 +++++- tests/test_utils.py | 8 ++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/cppwg/utils/utils.py b/cppwg/utils/utils.py index 97a4ec0..a061f39 100644 --- a/cppwg/utils/utils.py +++ b/cppwg/utils/utils.py @@ -255,7 +255,11 @@ def find_classes_in_source( signature = strip_source_whitespace(template_signature) regex += r"template\s*" + re.escape(signature) + r"\s*" - regex += r"(class|struct)\s+" + # (? class Foo {};" found = find_classes_in_source( From b9cf2f630ef3874f474b62f5df15faacd2b9b9c1 Mon Sep 17 00:00:00 2001 From: Kwabena Amponsah Date: Mon, 3 Aug 2026 18:28:15 +0100 Subject: [PATCH 03/18] #113 Demonstrate enum wrapping in the shapes example Add primitives/ShapeKind.hpp: an unscoped enum ShapeKind, a scoped enum class Handedness, and a ShapeClassifier whose method takes ShapeKind as a defaulted argument. Wrap the two enums via the new `enums` config key and the class alongside them, and regenerate the primitives wrappers. testEnums covers ShapeKind.CIRCLE and its exported values, the scoped Handedness, and that the module imports at all with the defaulted enum argument (the import-time registration path). All example tests pass. Co-Authored-By: Claude Opus 4.8 --- .../shapes/src/cpp/primitives/ShapeKind.hpp | 64 +++++++++++++++++++ examples/shapes/src/py/tests/test_classes.py | 20 ++++++ examples/shapes/wrapper/package_info.yaml | 15 +++++ .../primitives/ShapeClassifier.cppwg.cpp | 26 ++++++++ .../primitives/ShapeClassifier.cppwg.hpp | 10 +++ .../_pyshapes_primitives.main.cppwg.cpp | 13 ++++ .../wrapper_header_collection.cppwg.hpp | 1 + 7 files changed, 149 insertions(+) create mode 100644 examples/shapes/src/cpp/primitives/ShapeKind.hpp create mode 100644 examples/shapes/wrapper/primitives/ShapeClassifier.cppwg.cpp create mode 100644 examples/shapes/wrapper/primitives/ShapeClassifier.cppwg.hpp diff --git a/examples/shapes/src/cpp/primitives/ShapeKind.hpp b/examples/shapes/src/cpp/primitives/ShapeKind.hpp new file mode 100644 index 0000000..a84d183 --- /dev/null +++ b/examples/shapes/src/cpp/primitives/ShapeKind.hpp @@ -0,0 +1,64 @@ +#ifndef _SHAPEKIND_HPP +#define _SHAPEKIND_HPP + +#include + +/** + * A plain (namespace-scope) enum. cppwg wraps it as a first-class entity via an + * `enums:` config entry, exposing it in Python as ShapeKind.CIRCLE etc. Being + * unscoped, its py::enum_ registration ends with .export_values(). + */ +enum ShapeKind +{ + CIRCLE, + SQUARE, + TRIANGLE +}; + +/** + * A scoped enum (enum class) flows through the same wrapping path; the + * enumerators are exposed as Handedness.LEFT / Handedness.RIGHT. + */ +enum class Handedness +{ + LEFT, + RIGHT +}; + +/** + * A small class exercising an enum used as a defaulted argument. + * + * pybind11 materialises a default argument into a Python object when the method + * is registered, so ShapeKind must already be registered at that point. cppwg + * registers a module's enums before its class registration calls precisely so + * this imports cleanly rather than raising "type not registered yet". + */ +class ShapeClassifier +{ +public: + /** + * Name the given kind of shape, defaulting to a circle. + */ + std::string Describe(ShapeKind kind = CIRCLE) const + { + switch (kind) + { + case SQUARE: + return "square"; + case TRIANGLE: + return "triangle"; + default: + return "circle"; + } + } + + /** + * Return a fixed handedness, exercising a scoped enum as a return type. + */ + Handedness GetHandedness() const + { + return Handedness::RIGHT; + } +}; + +#endif // _SHAPEKIND_HPP diff --git a/examples/shapes/src/py/tests/test_classes.py b/examples/shapes/src/py/tests/test_classes.py index cb3515f..006d568 100644 --- a/examples/shapes/src/py/tests/test_classes.py +++ b/examples/shapes/src/py/tests/test_classes.py @@ -67,6 +67,26 @@ def testTemplateMethodSyntax(self): square.GetAreaIn[prim.SquareFeet](), square.GetAreaIn_SquareFeet() ) + def testEnums(self): + # ShapeKind is a plain (unscoped) enum wrapped as a first-class entity. + # Being unscoped, .export_values() also exposes the enumerators directly. + prim = pyshapes.primitives + self.assertEqual(int(prim.ShapeKind.CIRCLE), 0) + self.assertEqual(int(prim.ShapeKind.TRIANGLE), 2) + self.assertEqual(prim.CIRCLE, prim.ShapeKind.CIRCLE) # exported value + + # Handedness is a scoped enum (enum class); enumerators live on the type. + self.assertEqual(int(prim.Handedness.RIGHT), 1) + + # ShapeClassifier.Describe takes ShapeKind as a defaulted argument. That + # the module imported at all proves the enum was registered before this + # class (pybind11 materialises the default at registration time). Check + # both the default and an explicit enum value are accepted. + classifier = prim.ShapeClassifier() + self.assertEqual(classifier.Describe(), "circle") + self.assertEqual(classifier.Describe(prim.ShapeKind.SQUARE), "square") + self.assertEqual(classifier.GetHandedness(), prim.Handedness.RIGHT) + if __name__ == "__main__": unittest.main() diff --git a/examples/shapes/wrapper/package_info.yaml b/examples/shapes/wrapper/package_info.yaml index 9259b98..a472467 100644 --- a/examples/shapes/wrapper/package_info.yaml +++ b/examples/shapes/wrapper/package_info.yaml @@ -159,6 +159,15 @@ modules: # those redundant inherited-override bindings. exclude_inherited_overrides: True source_locations: + + # List of plain (namespace-scope) enums to wrap. Blank means none, CPPWG_ALL + # means discover all. ShapeKind is unscoped; Handedness is an `enum class`. + enums: + - name: ShapeKind + source_file_path: primitives/ShapeKind.hpp + - name: Handedness + source_file_path: primitives/ShapeKind.hpp + classes: - name: AbstractShape - name: AbstractPolygon @@ -169,6 +178,12 @@ modules: - name: Triangle excluded: True # Exclude this class from wrapping. + # A class whose method takes ShapeKind as a defaulted argument, so the + # generated module must register the enum before this class (see + # ShapeKind.hpp). + - name: ShapeClassifier + source_file: ShapeKind.hpp + # Unit-policy types used only as template arguments to # UnitSquare::GetAreaIn(); wrapped so they can be subscript keys. - name: SquareMetres diff --git a/examples/shapes/wrapper/primitives/ShapeClassifier.cppwg.cpp b/examples/shapes/wrapper/primitives/ShapeClassifier.cppwg.cpp new file mode 100644 index 0000000..2de32a2 --- /dev/null +++ b/examples/shapes/wrapper/primitives/ShapeClassifier.cppwg.cpp @@ -0,0 +1,26 @@ +// This file is automatically generated by cppwg. +// Do not modify this file directly. + +#include +#include +#include "wrapper_header_collection.cppwg.hpp" + +#include "ShapeClassifier.cppwg.hpp" + +namespace py = pybind11; +PYBIND11_DECLARE_HOLDER_TYPE(T, std::shared_ptr); +typedef ShapeClassifier ShapeClassifier; + + +void register_ShapeClassifier_class(py::module &m) +{ + py::class_>(m, "ShapeClassifier") + .def(py::init<>()) + .def("Describe", + (::std::string(ShapeClassifier::*)(::ShapeKind) const) &ShapeClassifier::Describe, + " ", py::arg("kind") = ::ShapeKind::CIRCLE) + .def("GetHandedness", + (::Handedness(ShapeClassifier::*)() const) &ShapeClassifier::GetHandedness, + " ") + ; +} diff --git a/examples/shapes/wrapper/primitives/ShapeClassifier.cppwg.hpp b/examples/shapes/wrapper/primitives/ShapeClassifier.cppwg.hpp new file mode 100644 index 0000000..b13037f --- /dev/null +++ b/examples/shapes/wrapper/primitives/ShapeClassifier.cppwg.hpp @@ -0,0 +1,10 @@ +// This file is automatically generated by cppwg. +// Do not modify this file directly. + +#ifndef ShapeClassifier_hpp__cppwg_wrapper +#define ShapeClassifier_hpp__cppwg_wrapper + +#include + +void register_ShapeClassifier_class(pybind11::module &m); +#endif // ShapeClassifier_hpp__cppwg_wrapper diff --git a/examples/shapes/wrapper/primitives/_pyshapes_primitives.main.cppwg.cpp b/examples/shapes/wrapper/primitives/_pyshapes_primitives.main.cppwg.cpp index e8e0845..6bfd53b 100644 --- a/examples/shapes/wrapper/primitives/_pyshapes_primitives.main.cppwg.cpp +++ b/examples/shapes/wrapper/primitives/_pyshapes_primitives.main.cppwg.cpp @@ -16,6 +16,7 @@ #include "Shape.cppwg.hpp" #include "Cuboid.cppwg.hpp" #include "Rectangle.cppwg.hpp" +#include "ShapeClassifier.cppwg.hpp" #include "SquareFeet.cppwg.hpp" #include "SquareMetres.cppwg.hpp" #include "UnitSquare.cppwg.hpp" @@ -34,6 +35,17 @@ PYBIND11_MODULE(_pyshapes_primitives, m) } }); + py::enum_(m, "Handedness") + .value("LEFT", Handedness::LEFT) + .value("RIGHT", Handedness::RIGHT) + .export_values(); + + py::enum_(m, "ShapeKind") + .value("CIRCLE", ShapeKind::CIRCLE) + .value("SQUARE", ShapeKind::SQUARE) + .value("TRIANGLE", ShapeKind::TRIANGLE) + .export_values(); + register_AbstractShape_2_class(m); register_AbstractShape_3_class(m); register_AbstractPolygon_2_class(m); @@ -44,6 +56,7 @@ PYBIND11_MODULE(_pyshapes_primitives, m) register_Shape_3_class(m); register_Cuboid_class(m); register_Rectangle_class(m); + register_ShapeClassifier_class(m); register_SquareFeet_class(m); register_SquareMetres_class(m); register_UnitSquare_class(m); diff --git a/examples/shapes/wrapper/wrapper_header_collection.cppwg.hpp b/examples/shapes/wrapper/wrapper_header_collection.cppwg.hpp index 2eaa770..6417c03 100644 --- a/examples/shapes/wrapper/wrapper_header_collection.cppwg.hpp +++ b/examples/shapes/wrapper/wrapper_header_collection.cppwg.hpp @@ -13,6 +13,7 @@ #include "Rectangle.hpp" #include "RegularPolygon.hpp" #include "Shape.hpp" +#include "ShapeKind.hpp" #include "SimpleMathFunctions.hpp" #include "Square.hpp" #include "ThrowingFunction.hpp" From bfae486a1ed6d49947448c47b77762c8a23dab7b Mon Sep 17 00:00:00 2001 From: Kwabena Amponsah Date: Mon, 3 Aug 2026 19:24:31 +0100 Subject: [PATCH 04/18] #113 Only emit .export_values() for unscoped enums .export_values() exports an enum's enumerators into the enclosing (module) scope. That is correct only for unscoped enums; for a scoped enum (enum class / enum struct) the enumerators belong on the type, and exporting them pollutes the module scope and can collide with other names (e.g. LEFT, RIGHT). pygccxml does not expose enum scopedness, so detect it from the source text (is_scoped_enum_in_source_file) and record it on CppEnumInfo.scoped when the decl is resolved. The enum writer then closes the registration chain with .export_values() for an unscoped enum or a plain ; for a scoped one. The shapes example's scoped Handedness now omits .export_values() (its wrapper regenerated); testEnums asserts its enumerators are not module-level while the unscoped ShapeKind's still are. Co-Authored-By: Claude Opus 4.8 --- cppwg/info/enum_info.py | 22 +++++++++++-- cppwg/templates/pybind11_default.py | 7 ++-- cppwg/utils/utils.py | 33 +++++++++++++++++++ cppwg/writers/enum_writer.py | 14 ++++++-- examples/shapes/src/py/tests/test_classes.py | 7 +++- .../_pyshapes_primitives.main.cppwg.cpp | 2 +- tests/test_enum_writer.py | 19 +++++++++-- tests/test_utils.py | 14 ++++++++ 8 files changed, 107 insertions(+), 11 deletions(-) diff --git a/cppwg/info/enum_info.py b/cppwg/info/enum_info.py index 26e0e20..f9de0de 100644 --- a/cppwg/info/enum_info.py +++ b/cppwg/info/enum_info.py @@ -4,22 +4,34 @@ from typing import TYPE_CHECKING, Any from cppwg.info.cpp_entity_info import CppEntityInfo +from cppwg.utils import utils if TYPE_CHECKING: from pygccxml.declarations.namespace import namespace_t class CppEnumInfo(CppEntityInfo): - """An information structure for individual enums to be wrapped.""" + """ + An information structure for individual enums to be wrapped. + + Attributes + ---------- + scoped : bool + Whether the enum is a scoped enum (`enum class`/`enum struct`). Scoped + enums do not export their enumerators into the enclosing scope, so + pybind11's `.export_values()` is omitted for them. + """ def __init__(self, name: str, enum_config: dict[str, Any] | None = None): super().__init__(name, enum_config) + self.scoped: bool = False + def update_from_ns(self, source_ns: "namespace_t") -> None: """ Update with information from the source namespace. - Adds the enum declaration. + Adds the enum declaration and records whether the enum is scoped. Parameters ---------- @@ -39,3 +51,9 @@ def update_from_ns(self, source_ns: "namespace_t") -> None: raise RuntimeError(f"Could not find enum: {self.name}") self.decls = [enum_decls[0]] + + # pygccxml does not expose enum scopedness, so read it from the source + # file the enum was declared in (via the resolved decl's location). + self.scoped = utils.is_scoped_enum_in_source_file( + self.decls[0].location.file_name, self.decls[0].name + ) diff --git a/cppwg/templates/pybind11_default.py b/cppwg/templates/pybind11_default.py index 0ce35b1..d7f564a 100644 --- a/cppwg/templates/pybind11_default.py +++ b/cppwg/templates/pybind11_default.py @@ -178,12 +178,13 @@ # Skeleton for a plain (namespace-scope) enum, registered directly against the # module m. Emitted inline in the module main cpp (like a free function), not as a # separate register_..._class function. ${enum_values} is one .value(...) line per -# enumerator. .export_values() is emitted for both scoped and unscoped enums (see -# CppEnumWrapperWriter). +# enumerator. ${enum_terminator} closes the chain: `.export_values();` for an +# unscoped enum (exports enumerators into the enclosing scope) or just `;` for a +# scoped enum (see CppEnumWrapperWriter). enum_register = Template( ' py::enum_<${enum_cpp_name}>(m, "${enum_py_name}")\n' "${enum_values}" - " .export_values();\n\n" + "${enum_terminator}\n" ) # Skeleton for the header collection hpp file, which includes every header to be diff --git a/cppwg/utils/utils.py b/cppwg/utils/utils.py index a061f39..7420bc8 100644 --- a/cppwg/utils/utils.py +++ b/cppwg/utils/utils.py @@ -313,6 +313,39 @@ def find_classes_in_source_file( return classes +def is_scoped_enum_in_source_file(source_file_path: str, enum_name: str) -> bool: + """ + Return whether an enum is declared as a scoped enum in a C++ source file. + + A scoped enum is `enum class Name` or `enum struct Name`, whose enumerators + live on the enum type; an unscoped `enum Name` also leaks its enumerators + into the enclosing scope. This is used to decide whether to emit pybind11's + `.export_values()`, which only applies to unscoped enums (pygccxml does not + expose enum scopedness, so it is read from the source text). + + Parameters + ---------- + source_file_path : str + The path to the source file declaring the enum. + enum_name : str + The enum name to check. + + Returns + ------- + bool + True if the enum is declared scoped (`enum class`/`enum struct`). + """ + source = read_source_file( + source_file_path, + strip_comments=True, + strip_preprocessor=True, + strip_whitespace=True, + ) + + pattern = r"\benum\s+(?:class|struct)\s+" + re.escape(enum_name) + r"\b" + return re.search(pattern, source) is not None + + def split_template_args(arg_string: str) -> list[str]: """ Split a template argument string on its top-level commas. diff --git a/cppwg/writers/enum_writer.py b/cppwg/writers/enum_writer.py index a507de3..8ab39ea 100644 --- a/cppwg/writers/enum_writer.py +++ b/cppwg/writers/enum_writer.py @@ -17,8 +17,9 @@ class CppEnumWrapperWriter(CppBaseWrapperWriter): unlike the struct-enum special case (see CppClassWrapperWriter) which nests a single enum inside a wrapped struct. Both scoped (``enum class``) and unscoped enums flow through here: ``.value("V", Enum::V)`` qualifies correctly for - either, and ``.export_values()`` is emitted unconditionally (a no-op for a - scoped enum, and this pygccxml version does not distinguish the two). + either. ``.export_values()`` (which exports the enumerators into the enclosing + scope) is emitted only for an unscoped enum; a scoped enum, whose enumerators + stay on the type, closes the chain with a plain ``;`` (see CppEnumInfo.scoped). Attributes ---------- @@ -57,10 +58,19 @@ def generate_wrapper(self) -> str: for value in enum_decl.values ) + # .export_values() exports the enumerators into the enclosing (module) + # scope. That only applies to unscoped enums; for a scoped enum the + # enumerators stay on the type, so close the chain with a plain `;`. + if self.enum_info.scoped: + enum_terminator = " ;\n" + else: + enum_terminator = " .export_values();\n" + enum_dict = { "enum_cpp_name": enum_cpp_name, "enum_py_name": enum_py_name, "enum_values": enum_values, + "enum_terminator": enum_terminator, } return self.wrapper_templates["enum_register"].substitute(**enum_dict) diff --git a/examples/shapes/src/py/tests/test_classes.py b/examples/shapes/src/py/tests/test_classes.py index 006d568..7643e43 100644 --- a/examples/shapes/src/py/tests/test_classes.py +++ b/examples/shapes/src/py/tests/test_classes.py @@ -73,10 +73,15 @@ def testEnums(self): prim = pyshapes.primitives self.assertEqual(int(prim.ShapeKind.CIRCLE), 0) self.assertEqual(int(prim.ShapeKind.TRIANGLE), 2) - self.assertEqual(prim.CIRCLE, prim.ShapeKind.CIRCLE) # exported value + # Unscoped: .export_values() also exposes the enumerators at module scope. + self.assertEqual(prim.CIRCLE, prim.ShapeKind.CIRCLE) # Handedness is a scoped enum (enum class); enumerators live on the type. self.assertEqual(int(prim.Handedness.RIGHT), 1) + # Scoped enums do not export their enumerators into the enclosing scope, + # so LEFT/RIGHT are not module-level names (no .export_values()). + self.assertFalse(hasattr(prim, "LEFT")) + self.assertFalse(hasattr(prim, "RIGHT")) # ShapeClassifier.Describe takes ShapeKind as a defaulted argument. That # the module imported at all proves the enum was registered before this diff --git a/examples/shapes/wrapper/primitives/_pyshapes_primitives.main.cppwg.cpp b/examples/shapes/wrapper/primitives/_pyshapes_primitives.main.cppwg.cpp index 6bfd53b..ad9a4dc 100644 --- a/examples/shapes/wrapper/primitives/_pyshapes_primitives.main.cppwg.cpp +++ b/examples/shapes/wrapper/primitives/_pyshapes_primitives.main.cppwg.cpp @@ -38,7 +38,7 @@ PYBIND11_MODULE(_pyshapes_primitives, m) py::enum_(m, "Handedness") .value("LEFT", Handedness::LEFT) .value("RIGHT", Handedness::RIGHT) - .export_values(); + ; py::enum_(m, "ShapeKind") .value("CIRCLE", ShapeKind::CIRCLE) diff --git a/tests/test_enum_writer.py b/tests/test_enum_writer.py index 169d6e3..fe5410e 100644 --- a/tests/test_enum_writer.py +++ b/tests/test_enum_writer.py @@ -18,10 +18,11 @@ def __init__(self, name, values): class _FakeEnumInfo: """Minimal CppEnumInfo stand-in.""" - def __init__(self, name, values, name_override="", excluded=False): + def __init__(self, name, values, name_override="", excluded=False, scoped=False): self.name = name self.name_override = name_override self.excluded = excluded + self.scoped = scoped self.decls = [_FakeEnum(name, values)] @@ -30,7 +31,7 @@ def _writer(info): def test_generate_wrapper_emits_enum_registration(): - """Each enumerator becomes a .value line qualified by the enum type.""" + """An unscoped enum registers each value and exports them with .export_values().""" info = _FakeEnumInfo("Color", [("RED", 0), ("GREEN", 1), ("BLUE", 2)]) result = _writer(info).generate_wrapper() @@ -44,6 +45,20 @@ def test_generate_wrapper_emits_enum_registration(): ) +def test_generate_wrapper_scoped_enum_omits_export_values(): + """A scoped enum does not export its enumerators into the enclosing scope.""" + info = _FakeEnumInfo("Color", [("RED", 0), ("GREEN", 1)], scoped=True) + + result = _writer(info).generate_wrapper() + + assert result == ( + ' py::enum_(m, "Color")\n' + ' .value("RED", Color::RED)\n' + ' .value("GREEN", Color::GREEN)\n' + " ;\n\n" + ) + + def test_generate_wrapper_uses_name_override_for_python_name(): """The Python name comes from name_override; values stay C++-qualified.""" info = _FakeEnumInfo("CppColor", [("RED", 0)], name_override="Color") diff --git a/tests/test_utils.py b/tests/test_utils.py index 01ca60c..a48a7b9 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -16,6 +16,7 @@ find_template_params_in_source, find_template_signature_in_source, is_option_ALL, + is_scoped_enum_in_source_file, normalize_template_arg, parse_template_params, read_source_file, @@ -443,6 +444,19 @@ def test_find_classes_in_source_skips_scoped_enums(): assert names == ["Foo"] +def test_is_scoped_enum_in_source_file(tmp_path): + """`enum class`/`enum struct` are scoped; a plain `enum` is not.""" + src = tmp_path / "Enums.hpp" + src.write_text( + "enum Unscoped { A, B };\n" + "enum class Scoped : unsigned { C, D };\n" + "enum struct ScopedStruct { E };\n" + ) + assert is_scoped_enum_in_source_file(str(src), "Scoped") is True + assert is_scoped_enum_in_source_file(str(src), "ScopedStruct") is True + assert is_scoped_enum_in_source_file(str(src), "Unscoped") is False + + def test_find_classes_in_source_by_name_and_template(): source = "template class Foo {};" found = find_classes_in_source( From 4579d5350d2a21468770a1b867eede15f306366a Mon Sep 17 00:00:00 2001 From: Kwabena Amponsah Date: Mon, 3 Aug 2026 19:33:53 +0100 Subject: [PATCH 05/18] #113 Add an inheritable export_values config option for enums Whether an enum exports its enumerators into the module scope (pybind11's .export_values()) defaults to mirroring the C++ enum kind, but this is a pybind11 choice independent of the C++: an unscoped enum can be wrapped without exporting, and a scoped one can be exported. Add a tri-state `export_values` option: unset mirrors the C++ kind (unchanged default), True/False forces it either way. The main use is setting it False on an unscoped enum to keep its values off the module scope and avoid name collisions. It is a common option (on BaseInfo), so it inherits down the info tree: setting it at the package or module level applies to every enum below, and a per-enum value overrides. Resolved via CppEnumInfo.should_export_values using hierarchy_attribute. Co-Authored-By: Claude Opus 4.8 --- cppwg/info/base_info.py | 6 ++++ cppwg/info/enum_info.py | 19 +++++++++- cppwg/parsers/package_info_parser.py | 1 + cppwg/writers/enum_writer.py | 17 +++++---- doc/reference.md | 1 + tests/test_enum_writer.py | 42 +++++++++++++++++----- tests/test_package_info_parser.py | 52 ++++++++++++++++++++++++++++ 7 files changed, 122 insertions(+), 16 deletions(-) diff --git a/cppwg/info/base_info.py b/cppwg/info/base_info.py index 71fc63f..51517b6 100644 --- a/cppwg/info/base_info.py +++ b/cppwg/info/base_info.py @@ -136,6 +136,11 @@ def __init__(self, name: str, info_config: dict[str, Any] | None = None) -> None self.excluded_methods: list[str] = [] self.excluded_variables: list[str] = [] self.return_type_excludes: list[str] = [] + # Tri-state (None inherits): whether a wrapped enum exports its + # enumerators into the module scope (pybind11's .export_values()). Only + # meaningful for enums, but inheritable so a package/module setting + # applies to all enums below it. See CppEnumInfo.should_export_values. + self.export_values: bool | None = None # Pointers self.pointer_call_policy: str = "" @@ -181,6 +186,7 @@ def __init__(self, name: str, info_config: dict[str, Any] | None = None) -> None "excluded", "excluded_methods", "excluded_variables", + "export_values", "name_replacements", "pointer_call_policy", "prefix_code", diff --git a/cppwg/info/enum_info.py b/cppwg/info/enum_info.py index f9de0de..750486d 100644 --- a/cppwg/info/enum_info.py +++ b/cppwg/info/enum_info.py @@ -19,7 +19,11 @@ class CppEnumInfo(CppEntityInfo): scoped : bool Whether the enum is a scoped enum (`enum class`/`enum struct`). Scoped enums do not export their enumerators into the enclosing scope, so - pybind11's `.export_values()` is omitted for them. + pybind11's `.export_values()` is omitted for them by default. + Note + ---- + The inheritable `export_values` config option (defined on BaseInfo) overrides + whether `.export_values()` is emitted; see should_export_values. """ def __init__(self, name: str, enum_config: dict[str, Any] | None = None): @@ -27,6 +31,19 @@ def __init__(self, name: str, enum_config: dict[str, Any] | None = None): self.scoped: bool = False + def should_export_values(self) -> bool: + """ + Return whether to emit pybind11's `.export_values()` for this enum. + + The `export_values` config option wins if set at this enum or anywhere up + the info tree (package/module); otherwise mirror the C++ enum kind - + export for an unscoped enum, not for a scoped one. + """ + override = self.hierarchy_attribute("export_values") + if override is not None: + return override + return not self.scoped + def update_from_ns(self, source_ns: "namespace_t") -> None: """ Update with information from the source namespace. diff --git a/cppwg/parsers/package_info_parser.py b/cppwg/parsers/package_info_parser.py index abb07cb..f5fde2a 100644 --- a/cppwg/parsers/package_info_parser.py +++ b/cppwg/parsers/package_info_parser.py @@ -69,6 +69,7 @@ def parse(self) -> PackageInfo: "excluded": False, "excluded_methods": [], "excluded_variables": [], + "export_values": None, "pointer_call_policy": "", "prefix_code": [], "prefix_text": "", diff --git a/cppwg/writers/enum_writer.py b/cppwg/writers/enum_writer.py index 8ab39ea..0d91367 100644 --- a/cppwg/writers/enum_writer.py +++ b/cppwg/writers/enum_writer.py @@ -18,8 +18,10 @@ class CppEnumWrapperWriter(CppBaseWrapperWriter): single enum inside a wrapped struct. Both scoped (``enum class``) and unscoped enums flow through here: ``.value("V", Enum::V)`` qualifies correctly for either. ``.export_values()`` (which exports the enumerators into the enclosing - scope) is emitted only for an unscoped enum; a scoped enum, whose enumerators - stay on the type, closes the chain with a plain ``;`` (see CppEnumInfo.scoped). + scope) is emitted by default only for an unscoped enum; a scoped enum, whose + enumerators stay on the type, closes the chain with a plain ``;``. The + ``export_values`` config option overrides this either way + (see CppEnumInfo.should_export_values). Attributes ---------- @@ -59,12 +61,13 @@ def generate_wrapper(self) -> str: ) # .export_values() exports the enumerators into the enclosing (module) - # scope. That only applies to unscoped enums; for a scoped enum the - # enumerators stay on the type, so close the chain with a plain `;`. - if self.enum_info.scoped: - enum_terminator = " ;\n" - else: + # scope. By default this mirrors the C++ enum kind (unscoped enums export, + # scoped ones do not), but the export_values config option can force it + # either way. When not exported, close the chain with a plain `;`. + if self.enum_info.should_export_values(): enum_terminator = " .export_values();\n" + else: + enum_terminator = " ;\n" enum_dict = { "enum_cpp_name": enum_cpp_name, diff --git a/doc/reference.md b/doc/reference.md index ed5b446..533098f 100644 --- a/doc/reference.md +++ b/doc/reference.md @@ -99,6 +99,7 @@ Each entry under a module's `enums:`. | `name` | str | – | The C++ enum name (required). | | `name_override` | str | `""` | Python name for the enum, if different from the C++ name. | | `source_file_path` | str | `""` | Path (relative to the source root) to the header declaring the enum, so it is parsed. Required for an explicitly listed enum unless its header is already pulled in by a wrapped class in the same header. | +| `export_values` | bool | unset | Whether to emit pybind11's `.export_values()`, which also exposes the enumerators at module scope (e.g. `Color.RED` **and** `RED`). Unset mirrors the C++ enum kind: an unscoped `enum` exports, a scoped `enum class` does not. Set `True`/`False` to force it either way — e.g. `False` to keep an unscoped enum's values off the module scope and avoid name collisions. May also be set at the package or module level to apply to all enums below it (a per-enum value wins). | All [common options](#common-options) may also be set here. diff --git a/tests/test_enum_writer.py b/tests/test_enum_writer.py index fe5410e..dc9c3fe 100644 --- a/tests/test_enum_writer.py +++ b/tests/test_enum_writer.py @@ -1,5 +1,6 @@ """Unit tests for cppwg.writers.enum_writer.""" +from cppwg.info.enum_info import CppEnumInfo from cppwg.templates.pybind11_default import template_collection from cppwg.writers.enum_writer import CppEnumWrapperWriter @@ -15,15 +16,21 @@ def __init__(self, name, values): self.values = values -class _FakeEnumInfo: - """Minimal CppEnumInfo stand-in.""" +def _FakeEnumInfo( + name, values, name_override="", excluded=False, scoped=False, export_values=None +): + """Build a real CppEnumInfo with a faked pygccxml decl. - def __init__(self, name, values, name_override="", excluded=False, scoped=False): - self.name = name - self.name_override = name_override - self.excluded = excluded - self.scoped = scoped - self.decls = [_FakeEnum(name, values)] + Using the real info object exercises the actual should_export_values / + hierarchy_attribute logic; only the pygccxml enumeration_t is faked. + """ + info = CppEnumInfo(name) + info.name_override = name_override + info.excluded = excluded + info.scoped = scoped + info.export_values = export_values + info.decls = [_FakeEnum(name, values)] + return info def _writer(info): @@ -72,6 +79,25 @@ def test_generate_wrapper_uses_name_override_for_python_name(): ) +def test_export_values_override_suppresses_export_on_unscoped_enum(): + """export_values=False omits export even for an unscoped enum.""" + info = _FakeEnumInfo("Color", [("RED", 0)], export_values=False) + + result = _writer(info).generate_wrapper() + + assert " .export_values();\n" not in result + assert result.endswith(' .value("RED", Color::RED)\n ;\n\n') + + +def test_export_values_override_forces_export_on_scoped_enum(): + """export_values=True emits export even for a scoped enum.""" + info = _FakeEnumInfo("Color", [("RED", 0)], scoped=True, export_values=True) + + result = _writer(info).generate_wrapper() + + assert result.endswith(' .value("RED", Color::RED)\n .export_values();\n\n') + + def test_generate_wrapper_excluded_returns_empty(): """An excluded enum generates no wrapper code.""" info = _FakeEnumInfo("Color", [("RED", 0)], excluded=True) diff --git a/tests/test_package_info_parser.py b/tests/test_package_info_parser.py index c81c7b9..7ba2d65 100644 --- a/tests/test_package_info_parser.py +++ b/tests/test_package_info_parser.py @@ -376,6 +376,58 @@ def test_parses_explicit_enum_list(tmp_path): assert module_info.enum_collection[0].source_file == "MyEnum.hpp" +def test_parses_enum_export_values_override(tmp_path): + """A per-enum export_values override is parsed as a bool; unset stays None.""" + config_path = _write_config( + tmp_path, + """ + name: testpkg + modules: + - name: mymod + enums: + - name: Forced + export_values: False + - name: Mirrored + """, + ) + + package_info = PackageInfoParser(config_path, str(tmp_path)).parse() + + enums = package_info.module_collection[0].enum_collection + by_name = {e.name: e for e in enums} + assert by_name["Forced"].export_values is False + assert by_name["Mirrored"].export_values is None + + +def test_module_export_values_inherited_by_enums(tmp_path): + """A module-level export_values applies to every enum, unless one overrides.""" + config_path = _write_config( + tmp_path, + """ + name: testpkg + modules: + - name: mymod + export_values: False + enums: + - name: Inherits + - name: Overrides + export_values: True + """, + ) + + package_info = PackageInfoParser(config_path, str(tmp_path)).parse() + + module_info = package_info.module_collection[0] + assert module_info.export_values is False + + by_name = {e.name: e for e in module_info.enum_collection} + # An enum with no setting of its own inherits the module value up the tree. + assert by_name["Inherits"].export_values is None + assert by_name["Inherits"].hierarchy_attribute("export_values") is False + # A per-enum setting overrides the module value. + assert by_name["Overrides"].hierarchy_attribute("export_values") is True + + def test_parses_all_enums_option(tmp_path): """The CPPWG_ALL enums option sets use_all_enums.""" config_path = _write_config( From 09b73a2a398ecd60bdd8fee4e4f045f7aa672e29 Mon Sep 17 00:00:00 2001 From: Kwabena Amponsah Date: Mon, 3 Aug 2026 19:56:56 +0100 Subject: [PATCH 06/18] #113 Use a non-reserved include guard in ShapeKind.hpp _SHAPEKIND_HPP starts with an underscore followed by an uppercase letter, which is reserved to the implementation. Rename it to SHAPEKIND_HPP_, matching the non-reserved guard style used by the other cells example headers. Co-Authored-By: Claude Opus 4.8 --- examples/shapes/src/cpp/primitives/ShapeKind.hpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/shapes/src/cpp/primitives/ShapeKind.hpp b/examples/shapes/src/cpp/primitives/ShapeKind.hpp index a84d183..22b9fa5 100644 --- a/examples/shapes/src/cpp/primitives/ShapeKind.hpp +++ b/examples/shapes/src/cpp/primitives/ShapeKind.hpp @@ -1,5 +1,5 @@ -#ifndef _SHAPEKIND_HPP -#define _SHAPEKIND_HPP +#ifndef SHAPEKIND_HPP_ +#define SHAPEKIND_HPP_ #include @@ -61,4 +61,4 @@ class ShapeClassifier } }; -#endif // _SHAPEKIND_HPP +#endif // SHAPEKIND_HPP_ From 2992181831fb05b812d2955e6f8b694f79b3aa74 Mon Sep 17 00:00:00 2001 From: Kwabena Amponsah Date: Mon, 3 Aug 2026 20:14:49 +0100 Subject: [PATCH 07/18] #113 Use non-reserved include guards in the example headers Several shapes and cells example headers used include guards of the form _NAME_HPP - a leading underscore followed by an uppercase letter, which is reserved to the implementation. Rename them to NAME_HPP_, matching the non-reserved style already used by the other example headers. Guard-only change: include guards are not part of the parsed AST, so the generated wrappers are unaffected (shapes wrappers regenerate identically). Co-Authored-By: Claude Opus 4.8 --- examples/cells/src/cpp/mesh/AbstractMesh.hpp | 6 +++--- examples/cells/src/cpp/mesh/AbstractSphericalMesh.hpp | 6 +++--- examples/cells/src/cpp/mesh/MeshFactory.hpp | 6 +++--- examples/cells/src/cpp/mesh/Node.hpp | 6 +++--- examples/cells/src/cpp/mesh/PottsMesh.hpp | 6 +++--- examples/cells/src/cpp/mesh/SphericalMesh.hpp | 6 +++--- examples/shapes/src/cpp/geometry/Point.hpp | 6 +++--- examples/shapes/src/cpp/math_funcs/SimpleMathFunctions.hpp | 6 +++--- examples/shapes/src/cpp/math_funcs/ThrowingFunction.hpp | 6 +++--- examples/shapes/src/cpp/primitives/AbstractPolygon.hpp | 6 +++--- examples/shapes/src/cpp/primitives/AbstractShape.hpp | 6 +++--- examples/shapes/src/cpp/primitives/AreaUnits.hpp | 6 +++--- examples/shapes/src/cpp/primitives/Cuboid.hpp | 6 +++--- examples/shapes/src/cpp/primitives/Rectangle.hpp | 6 +++--- examples/shapes/src/cpp/primitives/RegularPolygon.hpp | 6 +++--- examples/shapes/src/cpp/primitives/Shape.hpp | 6 +++--- examples/shapes/src/cpp/primitives/Square.hpp | 6 +++--- examples/shapes/src/cpp/primitives/Triangle.hpp | 6 +++--- examples/shapes/src/cpp/primitives/UnitSquare.hpp | 6 +++--- 19 files changed, 57 insertions(+), 57 deletions(-) diff --git a/examples/cells/src/cpp/mesh/AbstractMesh.hpp b/examples/cells/src/cpp/mesh/AbstractMesh.hpp index 057a896..cca09a6 100644 --- a/examples/cells/src/cpp/mesh/AbstractMesh.hpp +++ b/examples/cells/src/cpp/mesh/AbstractMesh.hpp @@ -1,5 +1,5 @@ -#ifndef _ABSTRACT_MESH_HPP -#define _ABSTRACT_MESH_HPP +#ifndef ABSTRACT_MESH_HPP_ +#define ABSTRACT_MESH_HPP_ #include "Node.hpp" @@ -47,4 +47,4 @@ class AbstractMesh virtual void Scale(const double factor) = 0; }; -#endif // _ABSTRACT_MESH_HPP +#endif // ABSTRACT_MESH_HPP_ diff --git a/examples/cells/src/cpp/mesh/AbstractSphericalMesh.hpp b/examples/cells/src/cpp/mesh/AbstractSphericalMesh.hpp index 19ff8d4..5fcf43d 100644 --- a/examples/cells/src/cpp/mesh/AbstractSphericalMesh.hpp +++ b/examples/cells/src/cpp/mesh/AbstractSphericalMesh.hpp @@ -1,5 +1,5 @@ -#ifndef _ABSTRACT_SPHERICAL_MESH_HPP -#define _ABSTRACT_SPHERICAL_MESH_HPP +#ifndef ABSTRACT_SPHERICAL_MESH_HPP_ +#define ABSTRACT_SPHERICAL_MESH_HPP_ #include "AbstractMesh.hpp" @@ -21,4 +21,4 @@ class AbstractSphericalMesh : public AbstractMesh virtual unsigned GetNumElements() const = 0; }; -#endif // _ABSTRACT_SPHERICAL_MESH_HPP +#endif // ABSTRACT_SPHERICAL_MESH_HPP_ diff --git a/examples/cells/src/cpp/mesh/MeshFactory.hpp b/examples/cells/src/cpp/mesh/MeshFactory.hpp index 14a20a2..82c5eec 100644 --- a/examples/cells/src/cpp/mesh/MeshFactory.hpp +++ b/examples/cells/src/cpp/mesh/MeshFactory.hpp @@ -1,5 +1,5 @@ -#ifndef _MESH_FACTORY_HPP -#define _MESH_FACTORY_HPP +#ifndef MESH_FACTORY_HPP_ +#define MESH_FACTORY_HPP_ #include @@ -26,4 +26,4 @@ class MeshFactory std::shared_ptr generateMesh(); }; -#endif // _MESH_FACTORY_HPP +#endif // MESH_FACTORY_HPP_ diff --git a/examples/cells/src/cpp/mesh/Node.hpp b/examples/cells/src/cpp/mesh/Node.hpp index e995d15..04ef78d 100644 --- a/examples/cells/src/cpp/mesh/Node.hpp +++ b/examples/cells/src/cpp/mesh/Node.hpp @@ -1,5 +1,5 @@ -#ifndef _NODE_HPP_ -#define _NODE_HPP_ +#ifndef NODE_HPP_ +#define NODE_HPP_ #include @@ -60,4 +60,4 @@ class Node void Translate(const boost::numeric::ublas::c_vector &rDisplacement); }; -#endif //_NODE_HPP_ +#endif //NODE_HPP_ diff --git a/examples/cells/src/cpp/mesh/PottsMesh.hpp b/examples/cells/src/cpp/mesh/PottsMesh.hpp index 684e7b3..ae23ac4 100644 --- a/examples/cells/src/cpp/mesh/PottsMesh.hpp +++ b/examples/cells/src/cpp/mesh/PottsMesh.hpp @@ -1,5 +1,5 @@ -#ifndef _POTTS_MESH_HPP -#define _POTTS_MESH_HPP +#ifndef POTTS_MESH_HPP_ +#define POTTS_MESH_HPP_ #include "AbstractMesh.hpp" @@ -26,4 +26,4 @@ class PottsMesh : public AbstractMesh void Scale(const double factor) override; }; -#endif // _POTTS_MESH_HPP +#endif // POTTS_MESH_HPP_ diff --git a/examples/cells/src/cpp/mesh/SphericalMesh.hpp b/examples/cells/src/cpp/mesh/SphericalMesh.hpp index 646e536..f2d7ac6 100644 --- a/examples/cells/src/cpp/mesh/SphericalMesh.hpp +++ b/examples/cells/src/cpp/mesh/SphericalMesh.hpp @@ -1,5 +1,5 @@ -#ifndef _SPHERICAL_MESH_HPP -#define _SPHERICAL_MESH_HPP +#ifndef SPHERICAL_MESH_HPP_ +#define SPHERICAL_MESH_HPP_ #include "AbstractSphericalMesh.hpp" @@ -54,4 +54,4 @@ class SphericalMesh : public AbstractSphericalMesh } }; -#endif // _SPHERICAL_MESH_HPP +#endif // SPHERICAL_MESH_HPP_ diff --git a/examples/shapes/src/cpp/geometry/Point.hpp b/examples/shapes/src/cpp/geometry/Point.hpp index 7eae659..6646d80 100644 --- a/examples/shapes/src/cpp/geometry/Point.hpp +++ b/examples/shapes/src/cpp/geometry/Point.hpp @@ -1,5 +1,5 @@ -#ifndef _POINT_HPP -#define _POINT_HPP +#ifndef POINT_HPP_ +#define POINT_HPP_ #include @@ -72,4 +72,4 @@ class Point void ExcludedMethod(); }; -#endif // _POINT_HPP +#endif // POINT_HPP_ diff --git a/examples/shapes/src/cpp/math_funcs/SimpleMathFunctions.hpp b/examples/shapes/src/cpp/math_funcs/SimpleMathFunctions.hpp index bae076d..78d6e70 100644 --- a/examples/shapes/src/cpp/math_funcs/SimpleMathFunctions.hpp +++ b/examples/shapes/src/cpp/math_funcs/SimpleMathFunctions.hpp @@ -1,5 +1,5 @@ -#ifndef _SIMPLE_MATH_FUNCTIONS_HPP -#define _SIMPLE_MATH_FUNCTIONS_HPP +#ifndef SIMPLE_MATH_FUNCTIONS_HPP_ +#define SIMPLE_MATH_FUNCTIONS_HPP_ /** * Add the two input numbers and return the result @@ -12,4 +12,4 @@ inline double add(double i = 1.0, double j = 2.0) return i + j; } -#endif // _SIMPLE_MATH_FUNCTIONS_HPP +#endif // SIMPLE_MATH_FUNCTIONS_HPP_ diff --git a/examples/shapes/src/cpp/math_funcs/ThrowingFunction.hpp b/examples/shapes/src/cpp/math_funcs/ThrowingFunction.hpp index 52ac1ae..509e585 100644 --- a/examples/shapes/src/cpp/math_funcs/ThrowingFunction.hpp +++ b/examples/shapes/src/cpp/math_funcs/ThrowingFunction.hpp @@ -1,5 +1,5 @@ -#ifndef _THROWING_FUNCTION_HPP -#define _THROWING_FUNCTION_HPP +#ifndef THROWING_FUNCTION_HPP_ +#define THROWING_FUNCTION_HPP_ #include @@ -50,4 +50,4 @@ inline void throw_unwrapped_exception() throw UnwrappedException(); } -#endif // _THROWING_FUNCTION_HPP +#endif // THROWING_FUNCTION_HPP_ diff --git a/examples/shapes/src/cpp/primitives/AbstractPolygon.hpp b/examples/shapes/src/cpp/primitives/AbstractPolygon.hpp index b9f5ebf..6fd31ed 100644 --- a/examples/shapes/src/cpp/primitives/AbstractPolygon.hpp +++ b/examples/shapes/src/cpp/primitives/AbstractPolygon.hpp @@ -1,5 +1,5 @@ -#ifndef _ABSTRACTPOLYGON_HPP -#define _ABSTRACTPOLYGON_HPP +#ifndef ABSTRACTPOLYGON_HPP_ +#define ABSTRACTPOLYGON_HPP_ #include "AbstractShape.hpp" @@ -21,4 +21,4 @@ class AbstractPolygon : public AbstractShape virtual unsigned GetNumSides() const = 0; }; -#endif // _ABSTRACTPOLYGON_HPP +#endif // ABSTRACTPOLYGON_HPP_ diff --git a/examples/shapes/src/cpp/primitives/AbstractShape.hpp b/examples/shapes/src/cpp/primitives/AbstractShape.hpp index dc578d6..e402f6c 100644 --- a/examples/shapes/src/cpp/primitives/AbstractShape.hpp +++ b/examples/shapes/src/cpp/primitives/AbstractShape.hpp @@ -1,5 +1,5 @@ -#ifndef _ABSTRACTSHAPE_HPP -#define _ABSTRACTSHAPE_HPP +#ifndef ABSTRACTSHAPE_HPP_ +#define ABSTRACTSHAPE_HPP_ #include @@ -41,4 +41,4 @@ class AbstractShape virtual std::vector GetBoundingBox() const = 0; }; -#endif // _ABSTRACTSHAPE_HPP +#endif // ABSTRACTSHAPE_HPP_ diff --git a/examples/shapes/src/cpp/primitives/AreaUnits.hpp b/examples/shapes/src/cpp/primitives/AreaUnits.hpp index 74c39c8..5e9fa31 100644 --- a/examples/shapes/src/cpp/primitives/AreaUnits.hpp +++ b/examples/shapes/src/cpp/primitives/AreaUnits.hpp @@ -1,5 +1,5 @@ -#ifndef _AREAUNITS_HPP -#define _AREAUNITS_HPP +#ifndef AREAUNITS_HPP_ +#define AREAUNITS_HPP_ /** * Area-unit policy types, used as template arguments to @@ -24,4 +24,4 @@ class SquareFeet } }; -#endif // _AREAUNITS_HPP +#endif // AREAUNITS_HPP_ diff --git a/examples/shapes/src/cpp/primitives/Cuboid.hpp b/examples/shapes/src/cpp/primitives/Cuboid.hpp index 3d0a409..2ee245a 100644 --- a/examples/shapes/src/cpp/primitives/Cuboid.hpp +++ b/examples/shapes/src/cpp/primitives/Cuboid.hpp @@ -1,5 +1,5 @@ -#ifndef _CUBOID_HPP -#define _CUBOID_HPP +#ifndef CUBOID_HPP_ +#define CUBOID_HPP_ #include "Shape.hpp" @@ -23,4 +23,4 @@ class Cuboid : public Shape<3> }; -#endif // _CUBOID_HPP +#endif // CUBOID_HPP_ diff --git a/examples/shapes/src/cpp/primitives/Rectangle.hpp b/examples/shapes/src/cpp/primitives/Rectangle.hpp index 8f7ac91..5a6ed0a 100644 --- a/examples/shapes/src/cpp/primitives/Rectangle.hpp +++ b/examples/shapes/src/cpp/primitives/Rectangle.hpp @@ -1,5 +1,5 @@ -#ifndef _RECTANGLE_HPP -#define _RECTANGLE_HPP +#ifndef RECTANGLE_HPP_ +#define RECTANGLE_HPP_ #include "Point.hpp" #include "Shape.hpp" @@ -27,4 +27,4 @@ class Rectangle : public Shape<2> ~Rectangle(); }; -#endif // _RECTANGLE_HPP +#endif // RECTANGLE_HPP_ diff --git a/examples/shapes/src/cpp/primitives/RegularPolygon.hpp b/examples/shapes/src/cpp/primitives/RegularPolygon.hpp index f1caf4d..3691932 100644 --- a/examples/shapes/src/cpp/primitives/RegularPolygon.hpp +++ b/examples/shapes/src/cpp/primitives/RegularPolygon.hpp @@ -1,5 +1,5 @@ -#ifndef _REGULARPOLYGON_HPP -#define _REGULARPOLYGON_HPP +#ifndef REGULARPOLYGON_HPP_ +#define REGULARPOLYGON_HPP_ #include @@ -69,4 +69,4 @@ class RegularPolygon : public AbstractPolygon } }; -#endif // _REGULARPOLYGON_HPP +#endif // REGULARPOLYGON_HPP_ diff --git a/examples/shapes/src/cpp/primitives/Shape.hpp b/examples/shapes/src/cpp/primitives/Shape.hpp index 19538c7..85d73d5 100644 --- a/examples/shapes/src/cpp/primitives/Shape.hpp +++ b/examples/shapes/src/cpp/primitives/Shape.hpp @@ -1,5 +1,5 @@ -#ifndef _SHAPE_HPP -#define _SHAPE_HPP +#ifndef SHAPE_HPP_ +#define SHAPE_HPP_ #include #include @@ -59,4 +59,4 @@ class Shape void AddVertex(std::shared_ptr> point = std::make_shared>()); }; -#endif // _SHAPE_HPP +#endif // SHAPE_HPP_ diff --git a/examples/shapes/src/cpp/primitives/Square.hpp b/examples/shapes/src/cpp/primitives/Square.hpp index e3dd1db..c5eb352 100644 --- a/examples/shapes/src/cpp/primitives/Square.hpp +++ b/examples/shapes/src/cpp/primitives/Square.hpp @@ -1,5 +1,5 @@ -#ifndef _SQUARE_HPP -#define _SQUARE_HPP +#ifndef SQUARE_HPP_ +#define SQUARE_HPP_ #include "Rectangle.hpp" @@ -21,4 +21,4 @@ class Square : public Rectangle ~Square(); }; -#endif // _SQUARE_HPP +#endif // SQUARE_HPP_ diff --git a/examples/shapes/src/cpp/primitives/Triangle.hpp b/examples/shapes/src/cpp/primitives/Triangle.hpp index 2440bf4..5ac4e66 100644 --- a/examples/shapes/src/cpp/primitives/Triangle.hpp +++ b/examples/shapes/src/cpp/primitives/Triangle.hpp @@ -1,5 +1,5 @@ -#ifndef _TRIANGLE_HPP -#define _TRIANGLE_HPP +#ifndef TRIANGLE_HPP_ +#define TRIANGLE_HPP_ #include "Point.hpp" #include "Shape.hpp" @@ -22,4 +22,4 @@ class Triangle : public Shape<2> ~Triangle(); }; -#endif // _TRIANGLE_HPP +#endif // TRIANGLE_HPP_ diff --git a/examples/shapes/src/cpp/primitives/UnitSquare.hpp b/examples/shapes/src/cpp/primitives/UnitSquare.hpp index f4b7df4..80ee334 100644 --- a/examples/shapes/src/cpp/primitives/UnitSquare.hpp +++ b/examples/shapes/src/cpp/primitives/UnitSquare.hpp @@ -1,5 +1,5 @@ -#ifndef _UNITSQUARE_HPP -#define _UNITSQUARE_HPP +#ifndef UNITSQUARE_HPP_ +#define UNITSQUARE_HPP_ #include "AreaUnits.hpp" @@ -44,4 +44,4 @@ class UnitSquare } }; -#endif // _UNITSQUARE_HPP +#endif // UNITSQUARE_HPP_ From b5e9c6522d2156777171ce5ccfa728245b988696 Mon Sep 17 00:00:00 2001 From: Kwabena Amponsah Date: Mon, 3 Aug 2026 20:18:49 +0100 Subject: [PATCH 08/18] #113 Refresh enum docs and comments - basics.md listed only classes and free functions; note enums as a wrappable entity (intro, "Selecting what to wrap", and the CPPWG_ALL example). - is_scoped_enum_in_source_file said .export_values() "only applies to unscoped enums"; it is the default, overridable by the export_values option - reword. Co-Authored-By: Claude Opus 4.8 --- cppwg/utils/utils.py | 8 +++++--- doc/basics.md | 10 ++++++---- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/cppwg/utils/utils.py b/cppwg/utils/utils.py index 7420bc8..0f1a95a 100644 --- a/cppwg/utils/utils.py +++ b/cppwg/utils/utils.py @@ -319,9 +319,11 @@ def is_scoped_enum_in_source_file(source_file_path: str, enum_name: str) -> bool A scoped enum is `enum class Name` or `enum struct Name`, whose enumerators live on the enum type; an unscoped `enum Name` also leaks its enumerators - into the enclosing scope. This is used to decide whether to emit pybind11's - `.export_values()`, which only applies to unscoped enums (pygccxml does not - expose enum scopedness, so it is read from the source text). + into the enclosing scope. cppwg uses this to default whether to emit + pybind11's `.export_values()` (exporting the enumerators to the enclosing + scope) - on by default only for unscoped enums, though the `export_values` + config option can override it. pygccxml does not expose enum scopedness, so + it is read from the source text. Parameters ---------- diff --git a/doc/basics.md b/doc/basics.md index 43635ce..69a8a7a 100644 --- a/doc/basics.md +++ b/doc/basics.md @@ -1,8 +1,8 @@ # Basics CppWG is driven by a YAML configuration file that describes a **package** made -of one or more **modules**, and the **classes** and **free functions** each -module wraps. +of one or more **modules**, and the **classes**, **free functions** and +**enums** each module wraps. ```yaml name: pyshapes # package @@ -22,8 +22,9 @@ Each module is compiled into its own extension. ## Selecting what to wrap -As in the example above, you can list classes and free functions explicitly -under a module. Alternatively, you can wrap everything found in the module's `source_locations` with the `CPPWG_ALL` setting. +As in the example above, you can list classes, free functions and enums +explicitly under a module. Alternatively, you can wrap everything found in the +module's `source_locations` with the `CPPWG_ALL` setting. ```yaml modules: @@ -32,6 +33,7 @@ modules: - src/cpp classes: CPPWG_ALL free_functions: CPPWG_ALL + enums: CPPWG_ALL ``` `CPPWG_ALL` must be the **whole** value. A list that merely contains it (e.g. From fed0ec260b2ab9f61bae55391fad307edcab2fc7 Mon Sep 17 00:00:00 2001 From: Kwabena Amponsah Date: Mon, 3 Aug 2026 20:36:21 +0100 Subject: [PATCH 09/18] #113 Cover enum info update_from_ns and the enum header include The enum update_from_ns (declaration lookup, not-found error, scope detection) and should_export_values were exercised only by the example generator runs, not the unit suite, and the not-found branch by nothing - dropping patch coverage. Add enum_info unit tests mirroring test_free_function_info, and extend the header collection test to assert an enum's source_file_path header is included. Co-Authored-By: Claude Opus 4.8 --- tests/test_enum_info.py | 72 ++++++++++++++++++++++++++ tests/test_header_collection_writer.py | 14 ++++- 2 files changed, 85 insertions(+), 1 deletion(-) diff --git a/tests/test_enum_info.py b/tests/test_enum_info.py index 37d6b35..fca359a 100644 --- a/tests/test_enum_info.py +++ b/tests/test_enum_info.py @@ -1,8 +1,33 @@ """Unit tests for cppwg.info.enum_info.""" +import pytest + from cppwg.info.enum_info import CppEnumInfo +class _FakeLocation: + def __init__(self, file_name): + self.file_name = file_name + + +class _FakeEnumDecl: + """A minimal stand-in for a pygccxml enumeration_t.""" + + def __init__(self, name, file_name): + self.name = name + self.location = _FakeLocation(file_name) + + +class _FakeNamespace: + """A minimal stand-in for a pygccxml namespace.""" + + def __init__(self, enums): + self._enums = enums + + def enumerations(self, name, allow_empty=True): + return self._enums + + def test_enum_info_init_sets_name(): """An enum info is created with its name and no config.""" enum = CppEnumInfo("MyEnum") @@ -14,3 +39,50 @@ def test_enum_info_init_applies_config(): enum = CppEnumInfo("MyEnum", {"excluded": True}) assert enum.name == "MyEnum" assert enum.excluded is True + + +def test_update_from_ns_records_declaration_and_detects_scope(tmp_path): + """The enum declaration is stored and its scopedness read from the source.""" + header = tmp_path / "Colors.hpp" + header.write_text("enum class Scoped { A };\nenum Unscoped { B };\n") + + scoped = CppEnumInfo("Scoped") + scoped.update_from_ns(_FakeNamespace([_FakeEnumDecl("Scoped", str(header))])) + assert scoped.decls[0].name == "Scoped" + assert scoped.scoped is True + + unscoped = CppEnumInfo("Unscoped") + unscoped.update_from_ns(_FakeNamespace([_FakeEnumDecl("Unscoped", str(header))])) + assert unscoped.scoped is False + + +def test_update_from_ns_raises_when_not_found(): + """An enum whose header was not parsed raises a clear error.""" + info = CppEnumInfo("Missing") + + with pytest.raises(RuntimeError, match="Could not find enum: Missing"): + info.update_from_ns(_FakeNamespace([])) + + +def test_should_export_values_mirrors_scope_by_default(): + """With no override, export mirrors the C++ kind: unscoped yes, scoped no.""" + unscoped = CppEnumInfo("E") + unscoped.scoped = False + assert unscoped.should_export_values() is True + + scoped = CppEnumInfo("E") + scoped.scoped = True + assert scoped.should_export_values() is False + + +def test_should_export_values_override_wins(): + """An export_values override beats the C++ enum kind either way.""" + forced_off = CppEnumInfo("E") + forced_off.scoped = False + forced_off.export_values = False + assert forced_off.should_export_values() is False + + forced_on = CppEnumInfo("E") + forced_on.scoped = True + forced_on.export_values = True + assert forced_on.should_export_values() is True diff --git a/tests/test_header_collection_writer.py b/tests/test_header_collection_writer.py index a7710ac..7de1492 100644 --- a/tests/test_header_collection_writer.py +++ b/tests/test_header_collection_writer.py @@ -34,6 +34,14 @@ def __init__(self, name, source_file_path=""): self.source_file_path = source_file_path +class _FakeEnumInfo: + """Minimal CppEnumInfo stand-in.""" + + def __init__(self, name, source_file_path=""): + self.name = name + self.source_file_path = source_file_path + + class _FakeModuleInfo: """Minimal ModuleInfo stand-in.""" @@ -192,6 +200,7 @@ def test_header_collection_excludes_classes_and_adds_ff_and_exception_headers(tm free_function = _FakeFreeFunctionInfo( "my_func", source_file_path="/s/funcs/MyFunc.hpp" ) + enum = _FakeEnumInfo("MyEnum", source_file_path="/s/enums/MyEnum.hpp") exc_header = tmp_path / "MyError.hpp" exc_header.write_text("class MyError {};\n") @@ -204,7 +213,9 @@ def test_header_collection_excludes_classes_and_adds_ff_and_exception_headers(tm "pkg", [ _FakeModuleInfo( - classes=[included, excluded], free_functions=[free_function] + classes=[included, excluded], + free_functions=[free_function], + enums=[enum], ) ], source_hpp_files=[str(exc_header), str(other_header)], @@ -216,4 +227,5 @@ def test_header_collection_excludes_classes_and_adds_ff_and_exception_headers(tm assert '#include "Foo.hpp"' in output assert "Hidden.hpp" not in output # excluded class skipped assert '#include "MyFunc.hpp"' in output # free-function header + assert '#include "MyEnum.hpp"' in output # enum header assert '#include "MyError.hpp"' in output # exception class header From 91e01945788a357e398a476b3a19e8176e7e6729 Mon Sep 17 00:00:00 2001 From: Kwabena Amponsah Date: Mon, 3 Aug 2026 20:53:45 +0100 Subject: [PATCH 10/18] #113 Cover use_all_enums discovery and the pathless-enum include branch codecov/patch flagged two uncovered diff spots (target 100%): the `enums: CPPWG_ALL` discovery loop in ModuleInfo.update_from_ns (exercised by no flag - the examples use explicit enums or none), and the false side of the enum `if source_file_path` branch in the header collection writer. Extend the module_info discovery test to enable use_all_enums with an in-scope and an out-of-scope enum (covering both is_decl_in_source_path branches), and add a pathless enum to the header collection test so its skip branch is taken. Co-Authored-By: Claude Opus 4.8 --- tests/test_header_collection_writer.py | 5 ++++- tests/test_module_info.py | 16 +++++++++++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/tests/test_header_collection_writer.py b/tests/test_header_collection_writer.py index 7de1492..018531a 100644 --- a/tests/test_header_collection_writer.py +++ b/tests/test_header_collection_writer.py @@ -201,6 +201,9 @@ def test_header_collection_excludes_classes_and_adds_ff_and_exception_headers(tm "my_func", source_file_path="/s/funcs/MyFunc.hpp" ) enum = _FakeEnumInfo("MyEnum", source_file_path="/s/enums/MyEnum.hpp") + # An enum with no source_file_path contributes no include (its header is + # pulled in elsewhere, e.g. by a co-located wrapped class). + enum_no_path = _FakeEnumInfo("PathlessEnum", source_file_path="") exc_header = tmp_path / "MyError.hpp" exc_header.write_text("class MyError {};\n") @@ -215,7 +218,7 @@ def test_header_collection_excludes_classes_and_adds_ff_and_exception_headers(tm _FakeModuleInfo( classes=[included, excluded], free_functions=[free_function], - enums=[enum], + enums=[enum, enum_no_path], ) ], source_hpp_files=[str(exc_header), str(other_header)], diff --git a/tests/test_module_info.py b/tests/test_module_info.py index 90d62a4..1db1652 100644 --- a/tests/test_module_info.py +++ b/tests/test_module_info.py @@ -3,6 +3,7 @@ from types import SimpleNamespace from cppwg.info.class_info import CppClassInfo +from cppwg.info.enum_info import CppEnumInfo from cppwg.info.free_function_info import CppFreeFunctionInfo from cppwg.info.module_info import ModuleInfo from cppwg.info.variable_info import CppVariableInfo @@ -118,19 +119,32 @@ def test_update_from_ns_discovers_all_classes_and_functions(monkeypatch): """use_all_* discovers in-scope decls and delegates the per-item update.""" monkeypatch.setattr(CppClassInfo, "update_from_ns", lambda self, ns: None) monkeypatch.setattr(CppFreeFunctionInfo, "update_from_ns", lambda self, ns: None) + monkeypatch.setattr(CppEnumInfo, "update_from_ns", lambda self, ns: None) module = ModuleInfo( - "mod", {"use_all_classes": True, "use_all_free_functions": True} + "mod", + { + "use_all_classes": True, + "use_all_free_functions": True, + "use_all_enums": True, + "source_locations": ["/src"], + }, ) source_ns = SimpleNamespace( classes=lambda allow_empty=True: [_decl("Foo", "/src/Foo.hpp")], free_functions=lambda allow_empty=True: [_decl("my_func", "/src/f.hpp")], + # One in-scope enum is discovered; one outside source_locations is dropped. + enumerations=lambda allow_empty=True: [ + _decl("MyEnum", "/src/e.hpp"), + _decl("Outside", "/other/e.hpp"), + ], ) module.update_from_ns(source_ns) assert [c.name for c in module.class_collection] == ["Foo"] assert [f.name for f in module.free_function_collection] == ["my_func"] + assert [e.name for e in module.enum_collection] == ["MyEnum"] def test_update_from_source_updates_then_sorts(monkeypatch): From 4606811d4a7d105eeae22aa9b8b8da954a9dc6bf Mon Sep 17 00:00:00 2001 From: Kwabena Amponsah Date: Mon, 3 Aug 2026 21:02:30 +0100 Subject: [PATCH 11/18] #113 Clarify source_file vs source_file_path in the reference The two were documented in isolation (source_file only under Class options, source_file_path only under Enum options) and never contrasted, and free functions had no options table at all. Explain the difference in the Class options section (source_file is a bare filename; source_file_path is a resolved, verified path relative to the source root), document source_file_path for classes too, and add a Free function options section noting its source_file_path is required (name_override is not applied to free functions, so it is omitted). Co-Authored-By: Claude Opus 4.8 --- doc/reference.md | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/doc/reference.md b/doc/reference.md index 533098f..e40f8b1 100644 --- a/doc/reference.md +++ b/doc/reference.md @@ -82,11 +82,32 @@ All [common options](#common-options) may also be set here. Each entry under a module's `classes:`. +Two options point cppwg at an entity's header, and they differ. `source_file` is +a bare **filename** (e.g. `Rectangle.hpp`); `source_file_path` is a path +**relative to the source root** (e.g. `primitives/Rectangle.hpp`) that cppwg +resolves to a full path and checks exists. A class is matched to its header +automatically — by its name (`Foo` ↔ `Foo.hpp`), or by `source_file` when the +name differs from the filename — so a class usually needs neither. Free functions +and enums are **not** matched this way, so each must set `source_file_path` for +its header to be parsed (see the sections below). + | Option | Type | Default | Description | | --- | --- | --- | --- | | `name` | str | – | The C++ class name (required). | | `name_override` | str | `""` | Python name for the class, if different from the C++ name. | -| `source_file` | str | `""` | Header to attribute the class to, when the class name does not match its file name. | +| `source_file` | str | `""` | Filename of the header to attribute the class to, when the class name does not match its file name. Emitted as the class's `#include`. | +| `source_file_path` | str | `""` | Path (relative to the source root) to the class's header, resolved and verified — an explicit alternative to the automatic name/`source_file` matching. | + +All [common options](#common-options) may also be set here. + +## Free function options + +Each entry under a module's `free_functions:`. + +| Option | Type | Default | Description | +| --- | --- | --- | --- | +| `name` | str | – | The C++ free-function name (required). | +| `source_file_path` | str | `""` | Path (relative to the source root) to the header declaring the function, so it is parsed. Required for an explicitly listed free function — it is not matched to a header the way a class is. | All [common options](#common-options) may also be set here. From e051181681edc8cf0722cd0f5bd18a0d276c0018 Mon Sep 17 00:00:00 2001 From: Kwabena Amponsah Date: Mon, 3 Aug 2026 21:09:43 +0100 Subject: [PATCH 12/18] #113 Allow source_file for free functions and enums Previously only classes could name their header with a bare source_file; free functions and enums required the full source_file_path. But source_file is already parsed onto every entity, and a class is included by that bare filename (resolved via the build's include path) - the same works for free functions and enums. The header collection now falls back to source_file when source_file_path is unset, so either form points cppwg at the header. Document both options for free functions and enums (source_file_path takes precedence), and switch the shapes example enums to the simpler source_file form (wrappers unchanged). Co-Authored-By: Claude Opus 4.8 --- cppwg/writers/header_collection_writer.py | 12 +++++++-- doc/reference.md | 30 +++++++++++++-------- examples/shapes/wrapper/package_info.yaml | 6 +++-- tests/test_header_collection_writer.py | 32 ++++++++++++++++------- 4 files changed, 56 insertions(+), 24 deletions(-) diff --git a/cppwg/writers/header_collection_writer.py b/cppwg/writers/header_collection_writer.py index f5cffea..4c36854 100644 --- a/cppwg/writers/header_collection_writer.py +++ b/cppwg/writers/header_collection_writer.py @@ -117,19 +117,27 @@ def includes_block(self) -> str: if class_info.source_file: include_files.add(class_info.source_file) - # Include specific headers needed by free functions + # Include specific headers needed by free functions. The header + # is identified by source_file_path (a source-root-relative path) + # or, like a class, by source_file (a bare filename resolved via + # the build's include path). for free_function_info in module_info.free_function_collection: if free_function_info.source_file_path: include_files.add( os.path.basename(free_function_info.source_file_path) ) + elif free_function_info.source_file: + include_files.add(free_function_info.source_file) - # Include specific headers needed by enums + # Include specific headers needed by enums (source_file_path or, + # like a class, a bare source_file). for enum_info in module_info.enum_collection: if enum_info.source_file_path: include_files.add( os.path.basename(enum_info.source_file_path) ) + elif enum_info.source_file: + include_files.add(enum_info.source_file) # Include headers that declare the configured exception classes so # they are parsed and can be introspected for the translator. Read diff --git a/doc/reference.md b/doc/reference.md index e40f8b1..f797202 100644 --- a/doc/reference.md +++ b/doc/reference.md @@ -83,13 +83,14 @@ All [common options](#common-options) may also be set here. Each entry under a module's `classes:`. Two options point cppwg at an entity's header, and they differ. `source_file` is -a bare **filename** (e.g. `Rectangle.hpp`); `source_file_path` is a path -**relative to the source root** (e.g. `primitives/Rectangle.hpp`) that cppwg -resolves to a full path and checks exists. A class is matched to its header -automatically — by its name (`Foo` ↔ `Foo.hpp`), or by `source_file` when the -name differs from the filename — so a class usually needs neither. Free functions -and enums are **not** matched this way, so each must set `source_file_path` for -its header to be parsed (see the sections below). +a bare **filename** (e.g. `Rectangle.hpp`), resolved via the build's include +path; `source_file_path` is a path **relative to the source root** (e.g. +`primitives/Rectangle.hpp`) that cppwg resolves to a full path and checks exists. +A class is matched to its header automatically — by its name (`Foo` ↔ `Foo.hpp`), +or by `source_file` when the name differs from the filename — so a class usually +needs neither. Free functions and enums are **not** matched this way, so each +must point at its header with `source_file` or `source_file_path` for it to be +parsed (see the sections below). | Option | Type | Default | Description | | --- | --- | --- | --- | @@ -102,24 +103,31 @@ All [common options](#common-options) may also be set here. ## Free function options -Each entry under a module's `free_functions:`. +Each entry under a module's `free_functions:`. Point cppwg at the function's +header with `source_file` or `source_file_path` so it is parsed — required for an +explicitly listed free function (it is not matched to a header the way a class +is), unless the header is already included by a co-located wrapped class. | Option | Type | Default | Description | | --- | --- | --- | --- | | `name` | str | – | The C++ free-function name (required). | -| `source_file_path` | str | `""` | Path (relative to the source root) to the header declaring the function, so it is parsed. Required for an explicitly listed free function — it is not matched to a header the way a class is. | +| `source_file` | str | `""` | Filename of the declaring header, resolved via the build's include path. | +| `source_file_path` | str | `""` | Path (relative to the source root) to that header, resolved and verified. Takes precedence over `source_file` if both are set. | All [common options](#common-options) may also be set here. ## Enum options -Each entry under a module's `enums:`. +Each entry under a module's `enums:`. Point cppwg at the enum's header with +`source_file` or `source_file_path` so it is parsed — required for an explicitly +listed enum, unless the header is already included by a co-located wrapped class. | Option | Type | Default | Description | | --- | --- | --- | --- | | `name` | str | – | The C++ enum name (required). | | `name_override` | str | `""` | Python name for the enum, if different from the C++ name. | -| `source_file_path` | str | `""` | Path (relative to the source root) to the header declaring the enum, so it is parsed. Required for an explicitly listed enum unless its header is already pulled in by a wrapped class in the same header. | +| `source_file` | str | `""` | Filename of the declaring header, resolved via the build's include path. | +| `source_file_path` | str | `""` | Path (relative to the source root) to that header, resolved and verified. Takes precedence over `source_file` if both are set. | | `export_values` | bool | unset | Whether to emit pybind11's `.export_values()`, which also exposes the enumerators at module scope (e.g. `Color.RED` **and** `RED`). Unset mirrors the C++ enum kind: an unscoped `enum` exports, a scoped `enum class` does not. Set `True`/`False` to force it either way — e.g. `False` to keep an unscoped enum's values off the module scope and avoid name collisions. May also be set at the package or module level to apply to all enums below it (a per-enum value wins). | All [common options](#common-options) may also be set here. diff --git a/examples/shapes/wrapper/package_info.yaml b/examples/shapes/wrapper/package_info.yaml index a472467..6408d32 100644 --- a/examples/shapes/wrapper/package_info.yaml +++ b/examples/shapes/wrapper/package_info.yaml @@ -162,11 +162,13 @@ modules: # List of plain (namespace-scope) enums to wrap. Blank means none, CPPWG_ALL # means discover all. ShapeKind is unscoped; Handedness is an `enum class`. + # source_file names the declaring header (a bare filename; source_file_path + # would give a source-root-relative path instead). enums: - name: ShapeKind - source_file_path: primitives/ShapeKind.hpp + source_file: ShapeKind.hpp - name: Handedness - source_file_path: primitives/ShapeKind.hpp + source_file: ShapeKind.hpp classes: - name: AbstractShape diff --git a/tests/test_header_collection_writer.py b/tests/test_header_collection_writer.py index 018531a..cad0782 100644 --- a/tests/test_header_collection_writer.py +++ b/tests/test_header_collection_writer.py @@ -29,16 +29,18 @@ def __init__( class _FakeFreeFunctionInfo: """Minimal CppFreeFunctionInfo stand-in.""" - def __init__(self, name, source_file_path=""): + def __init__(self, name, source_file="", source_file_path=""): self.name = name + self.source_file = source_file self.source_file_path = source_file_path class _FakeEnumInfo: """Minimal CppEnumInfo stand-in.""" - def __init__(self, name, source_file_path=""): + def __init__(self, name, source_file="", source_file_path=""): self.name = name + self.source_file = source_file self.source_file_path = source_file_path @@ -200,10 +202,16 @@ def test_header_collection_excludes_classes_and_adds_ff_and_exception_headers(tm free_function = _FakeFreeFunctionInfo( "my_func", source_file_path="/s/funcs/MyFunc.hpp" ) + # A free function/enum can identify its header by a bare source_file too. + free_function_by_name = _FakeFreeFunctionInfo( + "other_func", source_file="OtherFunc.hpp" + ) enum = _FakeEnumInfo("MyEnum", source_file_path="/s/enums/MyEnum.hpp") - # An enum with no source_file_path contributes no include (its header is - # pulled in elsewhere, e.g. by a co-located wrapped class). - enum_no_path = _FakeEnumInfo("PathlessEnum", source_file_path="") + enum_by_name = _FakeEnumInfo("NamedEnum", source_file="NamedEnum.hpp") + # With neither set, an entity contributes no include (its header is pulled in + # elsewhere, e.g. by a co-located wrapped class). + free_function_no_header = _FakeFreeFunctionInfo("bare_func") + enum_no_header = _FakeEnumInfo("PathlessEnum") exc_header = tmp_path / "MyError.hpp" exc_header.write_text("class MyError {};\n") @@ -217,8 +225,12 @@ def test_header_collection_excludes_classes_and_adds_ff_and_exception_headers(tm [ _FakeModuleInfo( classes=[included, excluded], - free_functions=[free_function], - enums=[enum, enum_no_path], + free_functions=[ + free_function, + free_function_by_name, + free_function_no_header, + ], + enums=[enum, enum_by_name, enum_no_header], ) ], source_hpp_files=[str(exc_header), str(other_header)], @@ -229,6 +241,8 @@ def test_header_collection_excludes_classes_and_adds_ff_and_exception_headers(tm assert '#include "Foo.hpp"' in output assert "Hidden.hpp" not in output # excluded class skipped - assert '#include "MyFunc.hpp"' in output # free-function header - assert '#include "MyEnum.hpp"' in output # enum header + assert '#include "MyFunc.hpp"' in output # free-function header (source_file_path) + assert '#include "OtherFunc.hpp"' in output # free-function header (source_file) + assert '#include "MyEnum.hpp"' in output # enum header (source_file_path) + assert '#include "NamedEnum.hpp"' in output # enum header (source_file) assert '#include "MyError.hpp"' in output # exception class header From 5e4d305ca760930f50c2ad196f01301c739d0901 Mon Sep 17 00:00:00 2001 From: Kwabena Amponsah Date: Mon, 3 Aug 2026 21:20:41 +0100 Subject: [PATCH 13/18] #113 Test that discovered enums inherit export_values A CPPWG_ALL-discovered enum sets module_info (the backing attribute of the parent property), so export_values (and other inheritable options) resolve up the info tree - but no test asserted it. Add a regression test that an enum discovered via use_all_enums inherits a package-level export_values through the full enum -> module -> package chain. Co-Authored-By: Claude Opus 4.8 --- tests/test_module_info.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/test_module_info.py b/tests/test_module_info.py index 1db1652..c66acb2 100644 --- a/tests/test_module_info.py +++ b/tests/test_module_info.py @@ -6,6 +6,7 @@ from cppwg.info.enum_info import CppEnumInfo from cppwg.info.free_function_info import CppFreeFunctionInfo from cppwg.info.module_info import ModuleInfo +from cppwg.info.package_info import PackageInfo from cppwg.info.variable_info import CppVariableInfo @@ -174,6 +175,31 @@ def test_sort_classes_single_class_is_a_noop(): assert [c.name for c in module.class_collection] == ["Only"] +def test_discovered_enum_inherits_export_values_from_hierarchy(monkeypatch): + """A CPPWG_ALL-discovered enum inherits export_values up the info tree. + + Discovery sets enum_info.module_info (the backing attribute of the `parent` + property), so hierarchy_attribute walks enum -> module -> package. Here the + value is set only at the package level, proving the full chain is wired. + """ + monkeypatch.setattr(CppEnumInfo, "update_from_ns", lambda self, ns: None) + + package = PackageInfo("pkg", {"export_values": False}) + module = ModuleInfo("mod", {"use_all_enums": True}) + package.add_module(module) + source_ns = SimpleNamespace( + classes=lambda allow_empty=True: [], + free_functions=lambda allow_empty=True: [], + enumerations=lambda allow_empty=True: [_decl("Discovered", "/src/e.hpp")], + ) + + module.update_from_ns(source_ns) + + enum = module.enum_collection[0] + assert enum.export_values is None # nothing set on the enum itself + assert enum.hierarchy_attribute("export_values") is False # inherited + + def test_update_from_ns_skips_decls_outside_source_path(monkeypatch): """Discovered decls outside the module's source_locations are not added.""" monkeypatch.setattr(CppClassInfo, "update_from_ns", lambda self, ns: None) From 7c3f7441169b55d27bd6aeaceb1b0b4c3725e874 Mon Sep 17 00:00:00 2001 From: Kwabena Amponsah Date: Mon, 3 Aug 2026 21:22:41 +0100 Subject: [PATCH 14/18] #113 Use the add_* helpers in the discovery loops The use_all_* discovery loops set `info.module_info = self` inline before appending, which reads as if the parent is not set (it is - module_info backs the parent property). Call the existing add_class/add_free_function/add_enum helpers instead, which append and set `.parent` explicitly, matching the config-driven path and making the parenting obvious. No behaviour change. Co-Authored-By: Claude Opus 4.8 --- cppwg/info/module_info.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/cppwg/info/module_info.py b/cppwg/info/module_info.py index 89b5097..f5b5000 100644 --- a/cppwg/info/module_info.py +++ b/cppwg/info/module_info.py @@ -263,8 +263,7 @@ def update_from_ns(self, source_ns: "namespace_t") -> None: if self.is_decl_in_source_path(class_decl): class_info = CppClassInfo(class_decl.name) class_info.update_names() - class_info.module_info = self - self.class_collection.append(class_info) + self.add_class(class_info) # Update classes with information from source namespace. for class_info in self.class_collection: @@ -281,8 +280,7 @@ def update_from_ns(self, source_ns: "namespace_t") -> None: for free_function in free_functions: if self.is_decl_in_source_path(free_function): ff_info = CppFreeFunctionInfo(free_function.name) - ff_info.module_info = self - self.free_function_collection.append(ff_info) + self.add_free_function(ff_info) # Update free functions with information from source namespace. for ff_info in self.free_function_collection: @@ -296,8 +294,7 @@ def update_from_ns(self, source_ns: "namespace_t") -> None: for enum_decl in enum_decls: if self.is_decl_in_source_path(enum_decl): enum_info = CppEnumInfo(enum_decl.name) - enum_info.module_info = self - self.enum_collection.append(enum_info) + self.add_enum(enum_info) # Update enums with information from source namespace. for enum_info in self.enum_collection: From a8ea39e5ab7056816200ef68d1c962dcfcd6de72 Mon Sep 17 00:00:00 2001 From: Kwabena Amponsah Date: Mon, 3 Aug 2026 21:24:16 +0100 Subject: [PATCH 15/18] #113 Correct the not-found hint for enums and free functions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Could not find …" error and its comment said only source_file_path gets an explicitly listed enum/free-function header included, but source_file works too, and the header may already be included via another wrapped entity in the same header. Reword both so the message points at the right fixes. Co-Authored-By: Claude Opus 4.8 --- cppwg/info/enum_info.py | 10 ++++++---- cppwg/info/free_function_info.py | 11 ++++++----- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/cppwg/info/enum_info.py b/cppwg/info/enum_info.py index 750486d..0ce5670 100644 --- a/cppwg/info/enum_info.py +++ b/cppwg/info/enum_info.py @@ -58,12 +58,14 @@ def update_from_ns(self, source_ns: "namespace_t") -> None: enum_decls = source_ns.enumerations(self.name, allow_empty=True) if not enum_decls: - # The enum's header was not parsed. For explicitly listed enums, the - # header is only included when source_file_path is set in the config. + # The enum's header was not parsed. For an explicitly listed enum, + # set source_file or source_file_path so its header is included - + # unless it is already pulled in by another wrapped entity declared + # in the same header. logger = logging.getLogger() logger.error( - f"Could not find enum {self.name}. Set source_file_path " - "in the config so that its header is included." + f"Could not find enum {self.name}. Set source_file or " + "source_file_path in the config so that its header is included." ) raise RuntimeError(f"Could not find enum: {self.name}") diff --git a/cppwg/info/free_function_info.py b/cppwg/info/free_function_info.py index 4709418..df1165a 100644 --- a/cppwg/info/free_function_info.py +++ b/cppwg/info/free_function_info.py @@ -29,13 +29,14 @@ def update_from_ns(self, source_ns: "namespace_t") -> None: ff_decls = source_ns.free_functions(self.name, allow_empty=True) if not ff_decls: - # The function's header was not parsed. For explicitly listed free - # functions, the header is only included when source_file_path is - # set in the config. + # The function's header was not parsed. For an explicitly listed free + # function, set source_file or source_file_path so its header is + # included - unless it is already pulled in by another wrapped entity + # declared in the same header. logger = logging.getLogger() logger.error( - f"Could not find free function {self.name}. Set source_file_path " - "in the config so that its header is included." + f"Could not find free function {self.name}. Set source_file or " + "source_file_path in the config so that its header is included." ) raise RuntimeError(f"Could not find free function: {self.name}") From fdfa131dd988f33570a431424f8afe331fbaceec Mon Sep 17 00:00:00 2001 From: Kwabena Amponsah Date: Mon, 3 Aug 2026 21:58:11 +0100 Subject: [PATCH 16/18] #113 Skip class-nested enums in use_all_enums discovery source_ns.enumerations() also returns enums nested in a class/struct, so enums: CPPWG_ALL discovered e.g. SemLatticeType::Value and emitted it as an unqualified py::enum_ that does not compile (and duplicated). Discovery now skips enums whose parent is a class/struct, wrapping only namespace-scope enums; the struct-enum special case still handles the wrapped-struct pattern. Found by running enums: CPPWG_ALL against Chaste's SemEnumerations.hpp, which has a namespace-scope enum (SemNodeRegion) alongside a struct-wrapped one. Co-Authored-By: Claude Opus 4.8 --- cppwg/info/module_info.py | 8 ++++++++ tests/test_module_info.py | 22 ++++++++++++++++++---- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/cppwg/info/module_info.py b/cppwg/info/module_info.py index f5b5000..9e4bf76 100644 --- a/cppwg/info/module_info.py +++ b/cppwg/info/module_info.py @@ -3,6 +3,8 @@ from pathlib import Path from typing import TYPE_CHECKING, Any +from pygccxml import declarations + from cppwg.info.base_info import BaseInfo from cppwg.info.class_info import CppClassInfo from cppwg.info.enum_info import CppEnumInfo @@ -292,6 +294,12 @@ def update_from_ns(self, source_ns: "namespace_t") -> None: if self.use_all_enums: enum_decls = source_ns.enumerations(allow_empty=True) for enum_decl in enum_decls: + # Skip enums nested in a class/struct: only namespace-scope enums + # are wrapped standalone. A nested enum (e.g. SemLatticeType::Value) + # would be emitted with an unqualified name that does not compile; + # the struct-enum special case handles the wrapped-struct pattern. + if declarations.is_class(enum_decl.parent): + continue if self.is_decl_in_source_path(enum_decl): enum_info = CppEnumInfo(enum_decl.name) self.add_enum(enum_info) diff --git a/tests/test_module_info.py b/tests/test_module_info.py index c66acb2..b23ac3c 100644 --- a/tests/test_module_info.py +++ b/tests/test_module_info.py @@ -2,6 +2,8 @@ from types import SimpleNamespace +from pygccxml import declarations + from cppwg.info.class_info import CppClassInfo from cppwg.info.enum_info import CppEnumInfo from cppwg.info.free_function_info import CppFreeFunctionInfo @@ -90,9 +92,19 @@ def test_sort_classes_stable_for_sibling_subclasses(): assert order == ["Base", "Alpha", "Beta"] -def _decl(name, file_name): - """A minimal declaration stand-in with a name and source location.""" - return SimpleNamespace(name=name, location=SimpleNamespace(file_name=file_name)) +def _decl(name, file_name, parent=None): + """A minimal declaration stand-in with a name and source location. + + parent defaults to a namespace (so is_class is False); pass a class_t to + model a declaration nested inside a class/struct. + """ + if parent is None: + parent = declarations.namespace_t("::") + return SimpleNamespace( + name=name, + location=SimpleNamespace(file_name=file_name), + parent=parent, + ) def test_add_variable_sets_parent(): @@ -134,10 +146,12 @@ def test_update_from_ns_discovers_all_classes_and_functions(monkeypatch): source_ns = SimpleNamespace( classes=lambda allow_empty=True: [_decl("Foo", "/src/Foo.hpp")], free_functions=lambda allow_empty=True: [_decl("my_func", "/src/f.hpp")], - # One in-scope enum is discovered; one outside source_locations is dropped. + # One in-scope namespace enum is discovered; one outside source_locations + # is dropped, and a class-nested enum (e.g. Struct::Value) is skipped. enumerations=lambda allow_empty=True: [ _decl("MyEnum", "/src/e.hpp"), _decl("Outside", "/other/e.hpp"), + _decl("Value", "/src/e.hpp", parent=declarations.class_t("Struct")), ], ) From 75f1d2a1fcafc91a12b1aeb44dfabd411733bd94 Mon Sep 17 00:00:00 2001 From: Kwabena Amponsah Date: Mon, 3 Aug 2026 22:03:10 +0100 Subject: [PATCH 17/18] #113 Document that struct-wrapped enums go under classes Clarify in the Enum options section that `enums` is for plain namespace-scope enums, while a struct wrapping a single enum (e.g. RelativeTo) is listed under `classes` and wrapped by the class-writer special case - listing it under `enums` fails. Note the clean CPPWG_ALL split (struct-wrappers via classes, plain enums via enums). Co-Authored-By: Claude Opus 4.8 --- doc/reference.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/doc/reference.md b/doc/reference.md index f797202..3d2a28d 100644 --- a/doc/reference.md +++ b/doc/reference.md @@ -132,3 +132,13 @@ listed enum, unless the header is already included by a co-located wrapped class All [common options](#common-options) may also be set here. +:::{note} +`enums` is for a **plain, namespace-scope** enum (`enum` or `enum class`). A +struct that wraps a single enum (`struct Foo { enum Value {…}; }`) is a struct, so +it goes under [`classes`](#class-options) instead — the class writer recognises +the pattern and wraps it as an enum. Listing such a struct under `enums` fails (it +is not an enum declaration). The struct-wrapper is a legacy form; prefer a plain +enum here. With `CPPWG_ALL`, each is discovered by its own key — struct-wrappers +via `classes`, plain enums via `enums` — with no overlap. +::: + From dfeffa1c4f5939e57d9aed9fb432ddc8d7ef24b0 Mon Sep 17 00:00:00 2001 From: Kwabena Amponsah Date: Mon, 3 Aug 2026 22:15:07 +0100 Subject: [PATCH 18/18] #113 Replace Chaste-specific examples with cppwg ones Comments, docstrings and test data referenced Chaste types (AbstractForce, AbstractLinearPde, TetrahedralMesh, AddCellWriter, AbstractNode, VertexMesh, ...) that mean nothing to a cppwg reader. Use identifiers from the cppwg examples instead: shapes Shape/AbstractShape and GetAreaIn (templated method with a custom generator), cells MacroMesh<2, 2> (the defaulted trailing-arg / CastXML-collapse case) and AbstractSphericalMesh/SphericalMesh, or generic placeholders. No behaviour change. Co-Authored-By: Claude Opus 4.8 --- cppwg/info/base_info.py | 4 ++-- cppwg/info/module_info.py | 9 ++++---- cppwg/info/package_info.py | 2 +- cppwg/utils/utils.py | 10 ++++----- cppwg/writers/class_writer.py | 2 +- tests/test_class_writer.py | 12 +++++------ tests/test_free_function_writer.py | 6 +++--- tests/test_method_writer.py | 6 +++--- tests/test_module_info.py | 18 ++++++++-------- tests/test_package_info.py | 34 +++++++++++++++--------------- tests/test_package_info_parser.py | 4 ++-- tests/test_utils.py | 20 +++++++++--------- 12 files changed, 64 insertions(+), 63 deletions(-) diff --git a/cppwg/info/base_info.py b/cppwg/info/base_info.py index 51517b6..6f444b6 100644 --- a/cppwg/info/base_info.py +++ b/cppwg/info/base_info.py @@ -25,8 +25,8 @@ class BaseInfo(ABC): ---------- arg_type_excludes : list[str] Exclude any method, constructor or free function with an argument of one - of these types. Patterns match a type as a whole token (so `Node` does - not match `AbstractNode`). + of these types. Patterns match a type as a whole token (so `Shape` does + not match `AbstractShape`). auto_includes : bool | None Automatically add `#include`s for the project types a class's wrapped method/constructor signatures use, when the class's own header only diff --git a/cppwg/info/module_info.py b/cppwg/info/module_info.py index 9e4bf76..5428260 100644 --- a/cppwg/info/module_info.py +++ b/cppwg/info/module_info.py @@ -34,7 +34,7 @@ class ModuleInfo(BaseInfo): base here is required (and is the only way) to inherit from an externally-package-wrapped base, so that cppwg never emits a base class it cannot confirm is registered. Names are matched without template - arguments, e.g. `AbstractForce` matches `AbstractForce<2, 2>`. + arguments, e.g. `AbstractSphericalMesh` matches `AbstractSphericalMesh<2, 2>`. imports : list[str] Python modules to import at the start of this generated module, e.g. the compiled module of another package or sibling module whose classes are @@ -295,9 +295,10 @@ def update_from_ns(self, source_ns: "namespace_t") -> None: enum_decls = source_ns.enumerations(allow_empty=True) for enum_decl in enum_decls: # Skip enums nested in a class/struct: only namespace-scope enums - # are wrapped standalone. A nested enum (e.g. SemLatticeType::Value) - # would be emitted with an unqualified name that does not compile; - # the struct-enum special case handles the wrapped-struct pattern. + # are wrapped standalone. A nested enum (e.g. `Value` in a + # `struct Foo { enum Value {...}; }`) would be emitted with an + # unqualified name that does not compile; the struct-enum special + # case handles the wrapped-struct pattern. if declarations.is_class(enum_decl.parent): continue if self.is_decl_in_source_path(enum_decl): diff --git a/cppwg/info/package_info.py b/cppwg/info/package_info.py index b19db03..f61e612 100644 --- a/cppwg/info/package_info.py +++ b/cppwg/info/package_info.py @@ -440,7 +440,7 @@ def discover_base_class_instantiations(self, source_ns: "namespace_t") -> None: A harvested arg list is adopted only when its length matches the class's template parameter count. This guards against a CastXML version that collapses a defaulted trailing argument (naming e.g. - ``AbstractLinearPde<2, 2>`` as ``AbstractLinearPde<2>``): the collapsed + ``MacroMesh<2, 2>`` as ``MacroMesh<2>``): the collapsed list is rejected and the class is left for template_substitutions. Parameters diff --git a/cppwg/utils/utils.py b/cppwg/utils/utils.py index 0f1a95a..11d883e 100644 --- a/cppwg/utils/utils.py +++ b/cppwg/utils/utils.py @@ -158,8 +158,8 @@ def canonicalize_type_whitespace(type_string: str) -> str: (e.g. ``unsigned int``, ``const T``); everywhere else - around ``<``, ``,``, ``>``, ``*``, ``&``, ``::`` etc. - it is optional. This removes such optional whitespace and collapses the rest, so spellings that differ only in spacing - become equal, e.g. ``TetrahedralMesh<3, 3>`` and ``TetrahedralMesh< 3,3 >`` - both become ``TetrahedralMesh<3,3>``. + become equal, e.g. ``MacroMesh<3, 3>`` and ``MacroMesh< 3,3 >`` + both become ``MacroMesh<3,3>``. Parameters ---------- @@ -183,11 +183,11 @@ def type_string_matches(type_string: str, pattern: str) -> bool: Check whether a type pattern occurs in a C++ type string as a whole token. The match respects identifier boundaries so a pattern is not matched as part - of a larger identifier: ``Node`` matches ``::Node<2> const &`` but not - ``AbstractNode``. Patterns whose edges are not identifier characters (e.g. + of a larger identifier: ``Shape`` matches ``::Shape<2> const &`` but not + ``AbstractShape``. Patterns whose edges are not identifier characters (e.g. ending in ``*`` or ``&``) are matched literally at those edges. Whitespace that is not between two identifier characters is insignificant, so a pattern - like ``TetrahedralMesh<3, 3>`` matches a type spelled ``TetrahedralMesh<3,3>`` + like ``MacroMesh<3, 3>`` matches a type spelled ``MacroMesh<3,3>`` (and vice versa). This is used to decide whether a method/constructor argument or return type should be excluded from wrapping. diff --git a/cppwg/writers/class_writer.py b/cppwg/writers/class_writer.py index d7131e5..fe5affe 100644 --- a/cppwg/writers/class_writer.py +++ b/cppwg/writers/class_writer.py @@ -166,7 +166,7 @@ def add(line: str) -> None: # Headers a custom generator's emitted code needs. A generator can name # types in its get_class_cpp_def_code() output that never appear in the - # parsed signatures (e.g. AddCellWriter), so cppwg cannot + # parsed signatures (e.g. GetAreaIn), so cppwg cannot # auto-detect them; it declares them via the optional get_class_cpp_source_includes() # hook. These are emitted before the common-include early return: they may # be <...> system headers or otherwise absent from the wrapper header diff --git a/tests/test_class_writer.py b/tests/test_class_writer.py index ad2f2b5..d10c2ea 100644 --- a/tests/test_class_writer.py +++ b/tests/test_class_writer.py @@ -572,23 +572,23 @@ def test_includes_block_emits_generator_source_includes(): """A custom generator's get_class_cpp_source_includes() headers are added to the block. Covers the category the auto-include detection cannot see: types named only - in the generator's emitted code (e.g. AddCellWriter), whose + in the generator's emitted code (e.g. GetAreaIn), whose headers the generator supplies itself. Angle-bracket and quoted forms both work. """ class_info = _FakeClassInfo( - "Population", + "Foo", object(), {"common_include_file": False}, - "Population.hpp", - generator=_SourceIncludeGen(["CellAgesWriter.hpp", ""]), + "Foo.hpp", + generator=_SourceIncludeGen(["Helper.hpp", ""]), ) writer = _make_writer(class_info) assert writer.includes_block() == ( - '#include "CellAgesWriter.hpp"\n' + '#include "Helper.hpp"\n' "#include \n" - '#include "Population.hpp"\n' + '#include "Foo.hpp"\n' ) diff --git a/tests/test_free_function_writer.py b/tests/test_free_function_writer.py index b5c0638..fff189f 100644 --- a/tests/test_free_function_writer.py +++ b/tests/test_free_function_writer.py @@ -38,11 +38,11 @@ def _writer(return_type="void", arg_types=(), excludes=None): def test_free_function_arg_type_exclude_respects_boundaries(): """arg_type_excludes drops free functions by argument type, as a whole token.""" - excludes = {"arg_type_excludes": ["Node"]} + excludes = {"arg_type_excludes": ["Shape"]} - assert _writer(arg_types=["::Node<2> const &"], excludes=excludes).exclude() is True + assert _writer(arg_types=["::Shape<2> const &"], excludes=excludes).exclude() is True assert ( - _writer(arg_types=["::AbstractNode<2> const &"], excludes=excludes).exclude() + _writer(arg_types=["::AbstractShape<2> const &"], excludes=excludes).exclude() is False ) diff --git a/tests/test_method_writer.py b/tests/test_method_writer.py index 2510e4f..e0e11b5 100644 --- a/tests/test_method_writer.py +++ b/tests/test_method_writer.py @@ -44,11 +44,11 @@ def _writer(class_info, return_type="void", arg_types=(), access="public"): def test_arg_type_exclude_respects_identifier_boundaries(): """A method taking the excluded arg type is dropped; a look-alike is kept.""" - class_info = _ClassInfo(excludes={"arg_type_excludes": ["Node"]}) + class_info = _ClassInfo(excludes={"arg_type_excludes": ["Shape"]}) - assert _writer(class_info, arg_types=["::Node<2> const &"]).exclude() is True + assert _writer(class_info, arg_types=["::Shape<2> const &"]).exclude() is True assert ( - _writer(class_info, arg_types=["::AbstractNode<2> const &"]).exclude() is False + _writer(class_info, arg_types=["::AbstractShape<2> const &"]).exclude() is False ) assert _writer(class_info, arg_types=["int"]).exclude() is False diff --git a/tests/test_module_info.py b/tests/test_module_info.py index b23ac3c..3c317ca 100644 --- a/tests/test_module_info.py +++ b/tests/test_module_info.py @@ -29,23 +29,23 @@ def _class(name: str, base_names: tuple[str, ...] = ()) -> CppClassInfo: def test_sort_classes_orders_base_before_subclasses(): """A base class is registered before subclasses that sort ahead of it. - AbstractLinearEllipticPde/ParabolicPde sort alphabetically before their - base AbstractLinearPde, so a naive alphabetical order would register the - subclasses first and fail to import. The base is matched by name even though - the base decls carry template arguments (AbstractLinearPde<1, 1>). + Cuboid/Rectangle sort alphabetically before their base Shape, so a naive + alphabetical order would register the subclasses first and fail to import. + The base is matched by name even though the base decls carry template + arguments (Shape<1>). """ module = ModuleInfo("all") module.class_collection = [ - _class("AbstractLinearEllipticPde", ("AbstractLinearPde<1, 1>",)), - _class("AbstractLinearParabolicPde", ("AbstractLinearPde<1, 1>",)), - _class("AbstractLinearPde"), + _class("Cuboid", ("Shape<1>",)), + _class("Rectangle", ("Shape<1>",)), + _class("Shape"), ] module.sort_classes() order = [c.name for c in module.class_collection] - assert order.index("AbstractLinearPde") < order.index("AbstractLinearEllipticPde") - assert order.index("AbstractLinearPde") < order.index("AbstractLinearParabolicPde") + assert order.index("Shape") < order.index("Cuboid") + assert order.index("Shape") < order.index("Rectangle") def test_sort_classes_orders_transitive_inheritance(): diff --git a/tests/test_package_info.py b/tests/test_package_info.py index 9fd25b4..5c1f321 100644 --- a/tests/test_package_info.py +++ b/tests/test_package_info.py @@ -114,7 +114,7 @@ def test_update_template_instantiations_distributes_map_to_classes(): assert cls.cpp_names == ["Foo<2>", "Foo<3>"] -def _base_discovery_package(tmp_path, base_class_name="AbstractLinearPde"): +def _base_discovery_package(tmp_path, base_class_name="AbstractSphericalMesh"): """Package with an opted-in 2-param abstract base and a wrapped derived class. Returns (package, base_class_info, base_decl) where a concrete wrapped class @@ -141,17 +141,17 @@ def test_discover_base_class_instantiations_from_hierarchy(tmp_path): """An abstract base is discovered from a wrapped class's base hierarchy.""" package, module, base = _base_discovery_package(tmp_path) - base_decl = _FakeDecl(name="AbstractLinearPde<2, 2>") - concrete = CppClassInfo("CellwiseSourcePde") - concrete.cpp_names = ["CellwiseSourcePde<2>"] - concrete.py_names = ["CellwiseSourcePde_2"] + base_decl = _FakeDecl(name="AbstractSphericalMesh<2, 2>") + concrete = CppClassInfo("SphericalMesh") + concrete.cpp_names = ["SphericalMesh<2>"] + concrete.py_names = ["SphericalMesh_2"] concrete.decls = [_FakeDecl(recursive_bases=[_FakeBase(base_decl)])] module.add_class(concrete) package.discover_base_class_instantiations(source_ns=None) assert base.template_arg_lists == [["2", "2"]] - assert base.cpp_names == ["AbstractLinearPde<2, 2>"] + assert base.cpp_names == ["AbstractSphericalMesh<2, 2>"] assert base.decls == [base_decl] @@ -160,10 +160,10 @@ def test_discover_base_class_instantiations_rejects_collapsed_arg(tmp_path): package, module, base = _base_discovery_package(tmp_path) # CastXML collapsed the defaulted second arg, naming the base "…<2>". - base_decl = _FakeDecl(name="AbstractLinearPde<2>") - concrete = CppClassInfo("CellwiseSourcePde") - concrete.cpp_names = ["CellwiseSourcePde<2>"] - concrete.py_names = ["CellwiseSourcePde_2"] + base_decl = _FakeDecl(name="AbstractSphericalMesh<2>") + concrete = CppClassInfo("SphericalMesh") + concrete.cpp_names = ["SphericalMesh<2>"] + concrete.py_names = ["SphericalMesh_2"] concrete.decls = [_FakeDecl(recursive_bases=[_FakeBase(base_decl)])] module.add_class(concrete) @@ -332,14 +332,14 @@ def test_prune_drops_instantiation_with_uninstantiated_dependency(): def test_prune_ignores_dependency_reached_only_through_excluded_method(): """A dependency reached only via an excluded method does not trigger a drop.""" - package, cls = _package_with_class("VertexMesh") + package, cls = _package_with_class("MacroMesh") cls.excluded_methods = ["GetFace"] _wrap( cls, - ["VertexMesh<1, 2>", "VertexMesh<2, 2>"], + ["MacroMesh<1, 2>", "MacroMesh<2, 2>"], [ _FakeDecl( - methods=[_FakeCalldef(return_type="VertexMesh<0, 2> *", name="GetFace")] + methods=[_FakeCalldef(return_type="MacroMesh<0, 2> *", name="GetFace")] ), _FakeDecl(methods=[]), ], @@ -347,10 +347,10 @@ def test_prune_ignores_dependency_reached_only_through_excluded_method(): package.prune_uninstantiated_dependencies(restricted_paths=[]) - # "VertexMesh" is a project base (VertexMesh<1,2>/<2,2> are wrapped), and - # VertexMesh<0,2> is uninstantiated - but GetFace, which returns it, is - # excluded, so VertexMesh<1, 2> is not pruned. - assert cls.cpp_names == ["VertexMesh<1, 2>", "VertexMesh<2, 2>"] + # "MacroMesh" is a project base (MacroMesh<1,2>/<2,2> are wrapped), and + # MacroMesh<0,2> is uninstantiated - but GetFace, which returns it, is + # excluded, so MacroMesh<1, 2> is not pruned. + assert cls.cpp_names == ["MacroMesh<1, 2>", "MacroMesh<2, 2>"] def test_prune_keeps_excluded_class_without_instantiations(): diff --git a/tests/test_package_info_parser.py b/tests/test_package_info_parser.py index 7ba2d65..5851623 100644 --- a/tests/test_package_info_parser.py +++ b/tests/test_package_info_parser.py @@ -186,14 +186,14 @@ def test_parses_module_external_bases(tmp_path): modules: - name: mymod external_bases: - - AbstractForce + - AbstractSphericalMesh """, ) package_info = PackageInfoParser(config_path, str(tmp_path)).parse() module_info = package_info.module_collection[0] - assert module_info.external_bases == ["AbstractForce"] + assert module_info.external_bases == ["AbstractSphericalMesh"] def test_module_external_bases_default_to_empty_list(tmp_path): diff --git a/tests/test_utils.py b/tests/test_utils.py index a48a7b9..9879475 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -265,13 +265,13 @@ def test_template_has_default_param(source, class_name, expected): "type_string, pattern, expected", [ # Whole-token identifier matches (not part of a larger identifier) - ("::Node<2> const &", "Node", True), - ("::AbstractNode<2> const &", "Node", False), - ("Node", "Node", True), - ("NodeIterator", "Node", False), - ("MyNode", "Node", False), + ("::Shape<2> const &", "Shape", True), + ("::AbstractShape<2> const &", "Shape", False), + ("Shape", "Shape", True), + ("ShapeIterator", "Shape", False), + ("MyShape", "Shape", False), # Qualified / templated names - ("::std::vector const &", "Node", True), + ("::std::vector const &", "Shape", True), ("boost::shared_ptr", "boost::shared_ptr", True), ("myboost::shared_ptr", "boost::shared_ptr", False), # Multi-token type names @@ -287,10 +287,10 @@ def test_template_has_default_param(source, class_name, expected): ("intx", "int", False), # Whitespace around punctuation is insignificant: the pattern and the # type string may differ in spacing inside/around the template args. - ("TetrahedralMesh<3,3>", "TetrahedralMesh<3, 3>", True), - ("TetrahedralMesh<3, 3>", "TetrahedralMesh<3,3>", True), - ("VertexMesh<2, 2> const &", "VertexMesh< 2,2 >", True), - ("TetrahedralMesh<2,2>", "TetrahedralMesh<3, 3>", False), + ("MacroMesh<3,3>", "MacroMesh<3, 3>", True), + ("MacroMesh<3, 3>", "MacroMesh<3,3>", True), + ("SphericalMesh<2, 2> const &", "SphericalMesh< 2,2 >", True), + ("MacroMesh<2,2>", "MacroMesh<3, 3>", False), # But whitespace between two identifiers is still significant. ("unsignedint", "unsigned int", False), # Empty pattern never matches