diff --git a/cppwg/info/base_info.py b/cppwg/info/base_info.py index 71fc63f2..6f444b6c 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 @@ -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 new file mode 100644 index 00000000..0ce5670a --- /dev/null +++ b/cppwg/info/enum_info.py @@ -0,0 +1,78 @@ +"""Enum information structure.""" + +import logging +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. + + 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 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): + super().__init__(name, enum_config) + + 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. + + Adds the enum declaration and records whether the enum is scoped. + + 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 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 or " + "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]] + + # 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/info/free_function_info.py b/cppwg/info/free_function_info.py index 4709418b..df1165a3 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}") diff --git a/cppwg/info/module_info.py b/cppwg/info/module_info.py index 3836fb57..54282603 100644 --- a/cppwg/info/module_info.py +++ b/cppwg/info/module_info.py @@ -3,8 +3,11 @@ 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 from cppwg.info.free_function_info import CppFreeFunctionInfo from cppwg.utils import utils @@ -31,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 @@ -50,6 +53,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 +65,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 +88,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 +105,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 +145,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. @@ -248,8 +265,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: @@ -266,13 +282,33 @@ 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: 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: + # Skip enums nested in a class/struct: only namespace-scope enums + # 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): + enum_info = CppEnumInfo(enum_decl.name) + self.add_enum(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 +323,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/info/package_info.py b/cppwg/info/package_info.py index b19db03d..f61e6121 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/parsers/package_info_parser.py b/cppwg/parsers/package_info_parser.py index 2b52ef19..f5fde2a7 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 @@ -68,6 +69,7 @@ def parse(self) -> PackageInfo: "excluded": False, "excluded_methods": [], "excluded_variables": [], + "export_values": None, "pointer_call_policy": "", "prefix_code": [], "prefix_text": "", @@ -126,9 +128,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 +164,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 +276,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 312862e7..d7f564a8 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,18 @@ "}\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. ${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}" + "${enum_terminator}\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 +220,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/utils/utils.py b/cppwg/utils/utils.py index 97a4ec03..11d883e7 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. @@ -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+" + # (? 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. 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 + ---------- + 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/class_writer.py b/cppwg/writers/class_writer.py index d7131e58..fe5affe8 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/cppwg/writers/enum_writer.py b/cppwg/writers/enum_writer.py new file mode 100644 index 00000000..0d913671 --- /dev/null +++ b/cppwg/writers/enum_writer.py @@ -0,0 +1,89 @@ +"""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. ``.export_values()`` (which exports the enumerators into the enclosing + 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 + ---------- + 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 + ) + + # .export_values() exports the enumerators into the enclosing (module) + # 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, + "enum_py_name": enum_py_name, + "enum_values": enum_values, + "enum_terminator": enum_terminator, + } + 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 83fea593..4c368544 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 @@ -113,12 +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 (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/cppwg/writers/module_writer.py b/cppwg/writers/module_writer.py index 6de1fb8a..fb1f93e4 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/basics.md b/doc/basics.md index 43635ce4..69a8a7a0 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. diff --git a/doc/reference.md b/doc/reference.md index 335e5a88..3d2a28d4 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). | @@ -81,11 +82,63 @@ 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`), 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 | | --- | --- | --- | --- | | `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:`. 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` | 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:`. 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` | 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. + +:::{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. +::: + diff --git a/examples/cells/src/cpp/mesh/AbstractMesh.hpp b/examples/cells/src/cpp/mesh/AbstractMesh.hpp index 057a8964..cca09a6c 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 19ff8d41..5fcf43d8 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 14a20a25..82c5eec7 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 e995d154..04ef78d6 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 684e7b3d..ae23ac45 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 646e5362..f2d7ac62 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 7eae6594..6646d80e 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 bae076d9..78d6e70b 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 52ac1ae3..509e585b 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 b9f5ebf7..6fd31edb 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 dc578d6a..e402f6cc 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 74c39c86..5e9fa31f 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 3d0a4097..2ee245ad 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 8f7ac91e..5a6ed0aa 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 f1caf4d4..3691932e 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 19538c7b..85d73d5d 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/ShapeKind.hpp b/examples/shapes/src/cpp/primitives/ShapeKind.hpp new file mode 100644 index 00000000..22b9fa50 --- /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/cpp/primitives/Square.hpp b/examples/shapes/src/cpp/primitives/Square.hpp index e3dd1dba..c5eb3528 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 2440bf4d..5ac4e665 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 f4b7df46..80ee3346 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_ diff --git a/examples/shapes/src/py/tests/test_classes.py b/examples/shapes/src/py/tests/test_classes.py index cb3515fc..7643e430 100644 --- a/examples/shapes/src/py/tests/test_classes.py +++ b/examples/shapes/src/py/tests/test_classes.py @@ -67,6 +67,31 @@ 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) + # 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 + # 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 9259b987..6408d328 100644 --- a/examples/shapes/wrapper/package_info.yaml +++ b/examples/shapes/wrapper/package_info.yaml @@ -159,6 +159,17 @@ 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`. + # 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: ShapeKind.hpp + - name: Handedness + source_file: ShapeKind.hpp + classes: - name: AbstractShape - name: AbstractPolygon @@ -169,6 +180,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 00000000..2de32a21 --- /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 00000000..b13037fb --- /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 e8e0845c..ad9a4dc0 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) + ; + + 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 2eaa770b..6417c03c 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" diff --git a/tests/test_class_writer.py b/tests/test_class_writer.py index ad2f2b50..d10c2ea1 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_enum_info.py b/tests/test_enum_info.py new file mode 100644 index 00000000..fca359a0 --- /dev/null +++ b/tests/test_enum_info.py @@ -0,0 +1,88 @@ +"""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") + 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 + + +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_enum_writer.py b/tests/test_enum_writer.py new file mode 100644 index 00000000..dc9c3fed --- /dev/null +++ b/tests/test_enum_writer.py @@ -0,0 +1,105 @@ +"""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 + + +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 + + +def _FakeEnumInfo( + name, values, name_override="", excluded=False, scoped=False, export_values=None +): + """Build a real CppEnumInfo with a faked pygccxml decl. + + 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): + return CppEnumWrapperWriter(info, template_collection) + + +def test_generate_wrapper_emits_enum_registration(): + """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() + + 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_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") + + result = _writer(info).generate_wrapper() + + assert result == ( + ' py::enum_(m, "Color")\n' + ' .value("RED", CppColor::RED)\n' + " .export_values();\n\n" + ) + + +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) + + assert _writer(info).generate_wrapper() == "" diff --git a/tests/test_free_function_writer.py b/tests/test_free_function_writer.py index b5c06380..fff189fb 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_header_collection_writer.py b/tests/test_header_collection_writer.py index 5025a8e3..cad0782a 100644 --- a/tests/test_header_collection_writer.py +++ b/tests/test_header_collection_writer.py @@ -29,8 +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="", source_file_path=""): + self.name = name + self.source_file = source_file self.source_file_path = source_file_path @@ -41,13 +51,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: @@ -188,6 +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") + 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") @@ -200,7 +224,13 @@ 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, + 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)], @@ -211,5 +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 "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 diff --git a/tests/test_method_writer.py b/tests/test_method_writer.py index 2510e4f2..e0e11b56 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 90d62a4b..3c317caf 100644 --- a/tests/test_module_info.py +++ b/tests/test_module_info.py @@ -2,9 +2,13 @@ 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 from cppwg.info.module_info import ModuleInfo +from cppwg.info.package_info import PackageInfo from cppwg.info.variable_info import CppVariableInfo @@ -25,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(): @@ -88,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(): @@ -118,19 +132,34 @@ 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 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")), + ], ) 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): @@ -160,6 +189,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) diff --git a/tests/test_module_writer.py b/tests/test_module_writer.py index 5d1b5396..8bd5e90c 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.py b/tests/test_package_info.py index 9fd25b4e..5c1f3212 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 8e4266eb..58516236 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): @@ -354,6 +354,100 @@ 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_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( + 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") diff --git a/tests/test_utils.py b/tests/test_utils.py index b729c66e..9879475f 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, @@ -264,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 @@ -286,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 @@ -435,6 +436,27 @@ def test_find_classes_in_source_all_classes(): assert ("struct", "Bar", "public Base ") in found +def test_find_classes_in_source_skips_scoped_enums(): + """`enum class`/`enum struct` are not reported as classes.""" + source = "enum class Color { RED }; enum struct Mode { ON }; class Foo {};" + found = find_classes_in_source(source) + names = [name for _, name, _ in found] + 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(