diff --git a/cppwg/info/package_info.py b/cppwg/info/package_info.py index f61e612..0e28318 100644 --- a/cppwg/info/package_info.py +++ b/cppwg/info/package_info.py @@ -9,6 +9,7 @@ from typing import TYPE_CHECKING, Any from pygccxml import declarations +from pygccxml.declarations import type_traits_classes from pygccxml.declarations.matchers import access_type_matcher_t from cppwg.info.base_info import BaseInfo @@ -549,6 +550,17 @@ def _iter_wrapped_arg_return_types( pygccxml.declarations.type_t Each wrapped argument or return type. """ + # A struct wrapping a single nested enum is registered as a py::enum_ (see + # CppClassWrapperWriter.write / build_struct_enum_register); none of its + # methods, constructors or data members are bound, so it introduces no + # wrapped types. Match that dispatch here so the walk represents only what + # is actually generated. + if ( + type_traits_classes.is_struct(decl) + and len(decl.enumerations(allow_empty=True)) == 1 + ): + return + query = access_type_matcher_t("public") gather = class_info.hierarchy_attribute_gather_flat calldef_excludes = gather("calldef_excludes") @@ -621,6 +633,35 @@ def signature_excluded(arg_strings: list[str]) -> bool: continue yield from ctor.argument_types + # Public data members are bound with def_readwrite/def_readonly, so their + # types are wrapped too. Mirror the member writer's skips (nested-class + # members from the recursive query, excluded_variables, reference, + # bitfield, static, array, and non-copy-assignable mutable members) so a + # member type reached only through a skipped member does not trigger a + # dependency or auto-include. + excluded_variables = gather("excluded_variables") + for variable in decl.variables(function=query, allow_empty=True): + if variable.parent is not decl: + continue + if variable.name in excluded_variables: + continue + if declarations.is_reference(variable.decl_type): + continue + if variable.bits is not None: + continue + if ( + variable.type_qualifiers is not None + and variable.type_qualifiers.has_static + ): + continue + if declarations.is_array(variable.decl_type): + continue + if not declarations.is_const( + variable.decl_type + ) and not utils.type_is_copy_assignable(variable.decl_type): + continue + yield variable.decl_type + def prune_uninstantiated_dependencies(self, restricted_paths: list[str]) -> None: """ Drop wrapped instantiations that depend on an uninstantiated type. diff --git a/cppwg/templates/pybind11_default.py b/cppwg/templates/pybind11_default.py index d7f564a..2953d68 100644 --- a/cppwg/templates/pybind11_default.py +++ b/cppwg/templates/pybind11_default.py @@ -45,6 +45,12 @@ " .def(py::init<${arg_signature}>()${default_args})\n" ) +# A public data member binding. ${access} is "readwrite" for a mutable member or +# "readonly" for a const one (a const member cannot be assigned from Python). +class_member = Template( + ' .def_${access}("${member_name}", &${class_py_name}::${member_name})\n' +) + # Consolidated whole-file skeletons. The writer builds each ${block} (includes, # constructors, methods, etc.) and fills the skeleton in a single substitution, # so the shape of the generated file is visible here rather than reconstructed @@ -152,6 +158,7 @@ '(m, "${class_py_name}")\n' "${constructors}" "${methods}" + "${members}" "${generator_def_code}" "${suffix_code}" " ;\n" @@ -224,6 +231,7 @@ "free_function": free_function, "class_method": class_method, "class_constructor": class_constructor, + "class_member": class_member, "class_virtual_override_header": class_virtual_override_header, "smart_pointer_holder": smart_pointer_holder, "method_virtual_override": method_virtual_override, diff --git a/cppwg/utils/utils.py b/cppwg/utils/utils.py index 11d883e..442c66f 100644 --- a/cppwg/utils/utils.py +++ b/cppwg/utils/utils.py @@ -227,6 +227,50 @@ def type_string_matches(type_string: str, pattern: str) -> bool: return re.search(regex, type_string) is not None +def type_is_copy_assignable(decl_type: Any) -> bool: + """ + Report whether a public data member of this type can be bound read-write. + + pybind11's ``def_readwrite`` setter performs ``obj.*pm = value``, so the + member type must be copy-assignable or the generated wrapper fails to + compile. Fundamental types, pointers and enums always are; only class types + can fail (e.g. ``std::unique_ptr`` and ``std::atomic``, whose copy + assignment is deleted, or a class with a user-deleted ``operator=``). + + A class type is treated as non-copy-assignable if pygccxml reports it as + noncopyable (catches move-only types like ``unique_ptr`` and ``atomic``, + which still expose a public move/value ``operator=`` so a public-assign check + alone would miss them) or as lacking a public assignment operator (catches a + plainly deleted copy assignment on an otherwise copyable class). Either way + the read-write setter would not compile, so the member should be skipped. + + Parameters + ---------- + decl_type : pygccxml.declarations.type_t + The declared type of the member variable. + + Returns + ------- + bool + True if a ``def_readwrite`` binding would compile, False otherwise. + """ + from pygccxml import declarations + from pygccxml.declarations import type_traits_classes + + # Strip aliases and cv-qualifiers to reach the underlying type. Only class + # types can be non-copy-assignable; fundamentals, pointers and enums are + # always assignable, and references/arrays are skipped before reaching here. + base_type = declarations.remove_cv(declarations.remove_alias(decl_type)) + if not declarations.class_traits.is_my_case(base_type): + return True + + class_decl = declarations.class_traits.get_declaration(base_type) + non_assignable = type_traits_classes.is_noncopyable( + class_decl + ) or not type_traits_classes.has_public_assign(class_decl) + return not non_assignable + + def find_classes_in_source( source: str, class_name: str = None, diff --git a/cppwg/writers/class_writer.py b/cppwg/writers/class_writer.py index fe5affe..348b20d 100644 --- a/cppwg/writers/class_writer.py +++ b/cppwg/writers/class_writer.py @@ -21,6 +21,7 @@ ) from cppwg.writers.base_writer import CppBaseWrapperWriter from cppwg.writers.constructor_writer import CppConstructorWrapperWriter +from cppwg.writers.member_variable_writer import CppClassMemberWrapperWriter from cppwg.writers.method_writer import CppMethodWrapperWriter if TYPE_CHECKING: @@ -172,7 +173,9 @@ def add(line: str) -> None: # be <...> system headers or otherwise absent from the wrapper header # collection, so the header collection alone would not pull them in. for header in call_generator_hook( - self.class_info.custom_generator_instance, "get_class_cpp_source_includes", [] + self.class_info.custom_generator_instance, + "get_class_cpp_source_includes", + [], ): add(self._format_include(header)) @@ -663,6 +666,20 @@ def build_class_register(self, template_idx: int) -> tuple[str, str]: if not self._is_inherited_override(class_decl, member_function) ) + # Add public data members, bound with def_readwrite (or def_readonly for + # const members). The writer skips members that cannot be bound: static, + # bitfield, array, reference, and (from the recursive query) nested-class + # members. + members = "".join( + CppClassMemberWrapperWriter( + self.class_info, + template_idx, + variable, + self.wrapper_templates, + ).generate_wrapper() + for variable in class_decl.variables(function=query, allow_empty=True) + ) + block = self.wrapper_templates["class_cpp_register"].substitute( generator_pre_code=call_generator_hook( generator, "get_class_cpp_pre_code", "", class_py_name @@ -675,6 +692,7 @@ def build_class_register(self, template_idx: int) -> tuple[str, str]: bases=self.bases_block(class_decl), constructors=constructors, methods=methods, + members=members, # Normalise the generator snippet to end with a newline: it is spliced # into the .def() chain ahead of suffix_code and the closing `;`, so a # missing newline (or a trailing // comment) could swallow the @@ -785,15 +803,19 @@ def write(self, work_dir: str) -> None: py_name=class_py_name, ) - # Check for the struct-enum pattern, e.g.: + # A struct wrapping a single nested enum is a legacy special case, e.g.: # struct Foo { enum Value {A, B, C}; }; + # registered as a py::enum_. Any other struct is a normal class (its + # members are public by default) and falls through to the class path + # below; without this it would be silently dropped, leaving the module's + # unconditional include/register call pointing at a missing file. if type_traits_classes.is_struct(class_decl): enums = class_decl.enumerations(allow_empty=True) if len(enums) == 1: register_blocks.append(self.build_struct_enum_register(idx)) register_py_names.append(class_py_name) class_typedefs.append(alias_typedef) - continue + continue block, return_typedefs = self.build_class_register(idx) register_blocks.append(block) @@ -808,9 +830,14 @@ def write(self, work_dir: str) -> None: seen_typedefs.add(line) deduped_typedefs.append(line) - # Nothing to register (e.g. only structs that are not the single-enum - # pattern) - write no files, matching the previous behaviour. + # Nothing to register - write no files. The module writer still emits an + # include and register_..._class call for this class, so warn loudly rather + # than let it surface later as an opaque missing-header compile error. if not register_blocks: + logger.warning( + f"Class {self.class_info.name} produced no wrapper code; no file " + "written. Its module include/register call will not resolve." + ) return # Assemble the preamble typedef blocks and the register section once; each diff --git a/cppwg/writers/member_variable_writer.py b/cppwg/writers/member_variable_writer.py new file mode 100644 index 0000000..3575bd0 --- /dev/null +++ b/cppwg/writers/member_variable_writer.py @@ -0,0 +1,167 @@ +"""Wrapper code writer for C++ public data members.""" + +import logging +from typing import TYPE_CHECKING + +from pygccxml import declarations + +from cppwg.utils import utils +from cppwg.writers.base_writer import CppBaseWrapperWriter + +if TYPE_CHECKING: + from string import Template + + from pygccxml.declarations.class_declaration import class_t + from pygccxml.declarations.variable import variable_t + + from cppwg.info.class_info import CppClassInfo + + +class CppClassMemberWrapperWriter(CppBaseWrapperWriter): + """ + Manage addition of public data member wrapper code. + + Emits a ``.def_readwrite`` (or ``.def_readonly`` for a const member) binding + so a class's public data members are readable/writable from Python. + + Attributes + ---------- + class_info : CppClassInfo + The class information for the class owning the member + variable_decl : pygccxml.declarations.variable_t + The pygccxml declaration object for the member variable + class_decl : pygccxml.declarations.class_t + The class declaration for the class owning the member + wrapper_templates : dict[str, Template] + Templates with placeholders for generating wrapper code + class_py_name : str | None + The Python name of the class e.g. 'Foo_2_2' + """ + + def __init__( + self, + class_info: "CppClassInfo", + template_idx: int, + variable_decl: "variable_t", + wrapper_templates: dict[str, "Template"], + ) -> None: + super().__init__(wrapper_templates) + + self.class_info: "CppClassInfo" = class_info + self.variable_decl: "variable_t" = variable_decl + self.class_decl: "class_t" = class_info.decls[template_idx] + + self.class_py_name = class_info.py_names[template_idx] + if self.class_py_name is None: + self.class_py_name = self.class_decl.name + + def exclude(self) -> bool: + """ + Check if the member should be excluded from the wrapper code. + + Returns + ------- + bool + True if the member should be excluded, False otherwise. + """ + logger = logging.getLogger() + variable_decl = self.variable_decl + + # Skip members marked for exclusion in the config. + excluded_variables = self.class_info.hierarchy_attribute_gather_flat( + "excluded_variables" + ) + if variable_decl.name in excluded_variables: + return True + + # Skip members belonging to a nested class. The variables() query is + # recursive, so it also returns fields of nested classes (e.g. an + # iterator); binding one as &Class::field would name a member the class + # does not have. Mirrors the parent check in the method/constructor + # writers. + if variable_decl.parent is not self.class_decl: + logger.debug( + f"Skipping nested-class member {self.class_py_name}::" + f"{variable_decl.name}" + ) + return True + + # A reference member (e.g. `T& field`) cannot be bound: you cannot form a + # pointer-to-member for a reference, so &Class::field is ill-formed. + if declarations.is_reference(variable_decl.decl_type): + logger.debug( + f"Skipping reference member {self.class_py_name}::" + f"{variable_decl.name}" + ) + return True + + # A bitfield member has no address, so &Class::field is ill-formed and it + # cannot be bound with def_readwrite/def_readonly. + if variable_decl.bits is not None: + logger.debug( + f"Skipping bitfield member {self.class_py_name}::{variable_decl.name}" + ) + return True + + # A C-style array member (e.g. `double coords[3]`) cannot be bound: a + # def_readwrite setter assigns to the member, but C arrays are not + # assignable, and pybind11 has no type caster for a raw array, so both + # def_readwrite and def_readonly fail to compile. is_const already sees + # through the array, so this also covers const arrays bound read-only. + if declarations.is_array(variable_decl.decl_type): + logger.debug( + f"Skipping array member {self.class_py_name}::{variable_decl.name}" + ) + return True + + # Static data members need def_readwrite_static/def_readonly_static and, + # for in-class-initialised static const members, an out-of-line definition + # to take their address. Skip them for now (see issue #116 follow-up). + if ( + variable_decl.type_qualifiers is not None + and variable_decl.type_qualifiers.has_static + ): + logger.debug( + f"Skipping static member {self.class_py_name}::{variable_decl.name}" + ) + return True + + # A mutable member is bound read-write, whose pybind11 setter assigns to + # the member (obj.*pm = value). If the type is not copy-assignable (e.g. + # std::unique_ptr, std::atomic, or a class with a deleted operator=) that + # assignment does not compile, so skip it. A const member is bound + # read-only (no setter), so it is unaffected. + if not declarations.is_const( + variable_decl.decl_type + ) and not utils.type_is_copy_assignable(variable_decl.decl_type): + logger.debug( + f"Skipping non-copy-assignable member " + f"{self.class_py_name}::{variable_decl.name}" + ) + return True + + return False + + def generate_wrapper(self) -> str: + """ + Generate the member variable wrapper code. + + Returns + ------- + str + The C++ wrapper code string, or "" if the member is excluded. + """ + if self.exclude(): + return "" + + # A const member cannot be written from Python, so bind it read-only. + if declarations.is_const(self.variable_decl.decl_type): + access = "readonly" + else: + access = "readwrite" + + return self.wrapper_templates["class_member"].substitute( + access=access, + member_name=self.variable_decl.name, + class_py_name=self.class_py_name, + ) diff --git a/doc/basics.md b/doc/basics.md index 69a8a7a..8dee90e 100644 --- a/doc/basics.md +++ b/doc/basics.md @@ -45,6 +45,7 @@ Several options drop members that should not (or cannot) be wrapped. - `excluded: True`: drop the whole class. - `excluded_methods`: drop methods by name. +- `excluded_variables`: drop public data members by name. - `return_type_excludes`: drop methods whose return type match. - `arg_type_excludes`: drop methods (and constructors) with an argument type match. - `constructor_arg_type_excludes`: drop constructors with an argument type match. diff --git a/doc/reference.md b/doc/reference.md index 3d2a28d..c760a76 100644 --- a/doc/reference.md +++ b/doc/reference.md @@ -31,6 +31,7 @@ Settable at the package, module, or class level; they inherit downwards. | `discover_template_instantiations` | bool | `None` | Auto-discover explicit instantiations from the source. See [Templates](templates.md). | | `excluded` | bool | `False` | Exclude the whole class from wrapping. | | `excluded_methods` | list[str] | `[]` | Method names to skip. | +| `excluded_variables` | list[str] | `[]` | Public data member names to skip (they are otherwise bound with `def_readwrite`/`def_readonly`). | | `pointer_call_policy` | str | `""` | Default pybind11 `return_value_policy` for methods returning a pointer, e.g. `reference`. | | `prefix_code` | list[str] | `[]` | Lines emitted before a class's registration block. | | `prefix_text` | str | `""` | Text emitted at the top of each wrapper file (e.g. a licence header). | @@ -101,6 +102,19 @@ parsed (see the sections below). All [common options](#common-options) may also be set here. +:::{note} +A plain `struct` is wrapped here under `classes`, exactly like a class (its +members are public by default). Public **data members** are exposed with +pybind11's `def_readwrite` (or `def_readonly` for a `const` member); use +[`excluded_variables`](#common-options) to suppress a field. Members that cannot +be bound are skipped automatically: static, bitfield, C-style array (e.g. +`double coords[3]`) and reference members (none has a takeable +pointer-to-member address), and mutable members whose type is not copy-assignable +(e.g. `std::unique_ptr` or `std::atomic`, whose `def_readwrite` setter would not +compile). (The only special case is a struct wrapping a single nested enum — see +the note under [Enum options](#enum-options).) +::: + ## Free function options Each entry under a module's `free_functions:`. Point cppwg at the function's diff --git a/examples/cells/src/cpp/cell/CellFactory.hpp b/examples/cells/src/cpp/cell/CellFactory.hpp index a57e914..faad52e 100644 --- a/examples/cells/src/cpp/cell/CellFactory.hpp +++ b/examples/cells/src/cpp/cell/CellFactory.hpp @@ -1,5 +1,5 @@ -#ifndef CELLFACTORY_HPP_ -#define CELLFACTORY_HPP_ +#ifndef CELL_FACTORY_HPP_ +#define CELL_FACTORY_HPP_ /** * A minimal, header-only factory templated on a cell type. CELL_TYPE is used @@ -45,4 +45,4 @@ class CellFactory unsigned mNumCells; }; -#endif // CELLFACTORY_HPP_ +#endif // CELL_FACTORY_HPP_ diff --git a/examples/cells/src/cpp/mesh/MacroMesh.hpp b/examples/cells/src/cpp/mesh/MacroMesh.hpp index 908f5a6..d1f32a2 100644 --- a/examples/cells/src/cpp/mesh/MacroMesh.hpp +++ b/examples/cells/src/cpp/mesh/MacroMesh.hpp @@ -1,5 +1,5 @@ -#ifndef MACROMESH_HPP_ -#define MACROMESH_HPP_ +#ifndef MACRO_MESH_HPP_ +#define MACRO_MESH_HPP_ /** * A minimal templated class whose explicit template instantiations are declared @@ -24,4 +24,4 @@ class MacroMesh } }; -#endif // MACROMESH_HPP_ +#endif // MACRO_MESH_HPP_ diff --git a/examples/cells/src/cpp/utils/PetscUtils.hpp b/examples/cells/src/cpp/utils/PetscUtils.hpp index 31aa4e5..e3ee73d 100644 --- a/examples/cells/src/cpp/utils/PetscUtils.hpp +++ b/examples/cells/src/cpp/utils/PetscUtils.hpp @@ -1,5 +1,5 @@ -#ifndef PETSCUTILS_HPP_ -#define PETSCUTILS_HPP_ +#ifndef PETSC_UTILS_HPP_ +#define PETSC_UTILS_HPP_ #include #include @@ -24,4 +24,4 @@ class PetscUtils static void ThrowPetscError(); }; -#endif // PETSCUTILS_HPP_ +#endif // PETSC_UTILS_HPP_ diff --git a/examples/cells/src/cpp/utils/SimulationException.hpp b/examples/cells/src/cpp/utils/SimulationException.hpp index 6c800b8..cfa8120 100644 --- a/examples/cells/src/cpp/utils/SimulationException.hpp +++ b/examples/cells/src/cpp/utils/SimulationException.hpp @@ -1,5 +1,5 @@ -#ifndef SIMULATIONEXCEPTION_HPP_ -#define SIMULATIONEXCEPTION_HPP_ +#ifndef SIMULATION_EXCEPTION_HPP_ +#define SIMULATION_EXCEPTION_HPP_ #include #include @@ -35,4 +35,4 @@ class SimulationException : public std::runtime_error std::string mMessage; /**< Full message, including file and line number. */ }; -#endif // SIMULATIONEXCEPTION_HPP_ +#endif // SIMULATION_EXCEPTION_HPP_ diff --git a/examples/shapes/src/cpp/primitives/AbstractPolygon.hpp b/examples/shapes/src/cpp/primitives/AbstractPolygon.hpp index 6fd31ed..aa518f6 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 ABSTRACT_POLYGON_HPP_ +#define ABSTRACT_POLYGON_HPP_ #include "AbstractShape.hpp" @@ -21,4 +21,4 @@ class AbstractPolygon : public AbstractShape virtual unsigned GetNumSides() const = 0; }; -#endif // ABSTRACTPOLYGON_HPP_ +#endif // ABSTRACT_POLYGON_HPP_ diff --git a/examples/shapes/src/cpp/primitives/AbstractShape.hpp b/examples/shapes/src/cpp/primitives/AbstractShape.hpp index e402f6c..86f3b9b 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 ABSTRACT_SHAPE_HPP_ +#define ABSTRACT_SHAPE_HPP_ #include @@ -41,4 +41,4 @@ class AbstractShape virtual std::vector GetBoundingBox() const = 0; }; -#endif // ABSTRACTSHAPE_HPP_ +#endif // ABSTRACT_SHAPE_HPP_ diff --git a/examples/shapes/src/cpp/primitives/AreaUnits.hpp b/examples/shapes/src/cpp/primitives/AreaUnits.hpp index 5e9fa31..286b2eb 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 AREA_UNITS_HPP_ +#define AREA_UNITS_HPP_ /** * Area-unit policy types, used as template arguments to @@ -24,4 +24,4 @@ class SquareFeet } }; -#endif // AREAUNITS_HPP_ +#endif // AREA_UNITS_HPP_ diff --git a/examples/shapes/src/cpp/primitives/RegularPolygon.hpp b/examples/shapes/src/cpp/primitives/RegularPolygon.hpp index 3691932..faa8b80 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 REGULAR_POLYGON_HPP_ +#define REGULAR_POLYGON_HPP_ #include @@ -69,4 +69,4 @@ class RegularPolygon : public AbstractPolygon } }; -#endif // REGULARPOLYGON_HPP_ +#endif // REGULAR_POLYGON_HPP_ diff --git a/examples/shapes/src/cpp/primitives/ShapeKind.hpp b/examples/shapes/src/cpp/primitives/ShapeKind.hpp index 22b9fa5..49b1819 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 SHAPE_KIND_HPP_ +#define SHAPE_KIND_HPP_ #include @@ -61,4 +61,4 @@ class ShapeClassifier } }; -#endif // SHAPEKIND_HPP_ +#endif // SHAPE_KIND_HPP_ diff --git a/examples/shapes/src/cpp/primitives/ShapeMetrics.hpp b/examples/shapes/src/cpp/primitives/ShapeMetrics.hpp new file mode 100644 index 0000000..7e43135 --- /dev/null +++ b/examples/shapes/src/cpp/primitives/ShapeMetrics.hpp @@ -0,0 +1,19 @@ +#ifndef SHAPE_METRICS_HPP_ +#define SHAPE_METRICS_HPP_ + +/** + * A plain data struct. cppwg wraps it as a normal class (issue #116); it does + * not need to be a class or to wrap a single enum. Its public data members are + * exposed to Python with def_readwrite, except: + * - `dimension` is const, so it is bound read-only (def_readonly); + * - `scratch` is suppressed via the `excluded_variables` config option. + */ +struct ShapeMetrics +{ + double area; + double perimeter; + const unsigned dimension = 2; + int scratch; +}; + +#endif // SHAPE_METRICS_HPP_ diff --git a/examples/shapes/src/cpp/primitives/UnitSquare.hpp b/examples/shapes/src/cpp/primitives/UnitSquare.hpp index 80ee334..36ef41c 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 UNIT_SQUARE_HPP_ +#define UNIT_SQUARE_HPP_ #include "AreaUnits.hpp" @@ -44,4 +44,4 @@ class UnitSquare } }; -#endif // UNITSQUARE_HPP_ +#endif // UNIT_SQUARE_HPP_ diff --git a/examples/shapes/src/py/tests/test_classes.py b/examples/shapes/src/py/tests/test_classes.py index 7643e43..7ae79e8 100644 --- a/examples/shapes/src/py/tests/test_classes.py +++ b/examples/shapes/src/py/tests/test_classes.py @@ -92,6 +92,25 @@ def testEnums(self): self.assertEqual(classifier.Describe(prim.ShapeKind.SQUARE), "square") self.assertEqual(classifier.GetHandedness(), prim.Handedness.RIGHT) + def testStructDataMembers(self): + # ShapeMetrics is a plain data struct wrapped as a normal class (#116). + prim = pyshapes.primitives + metrics = prim.ShapeMetrics() + + # Mutable members are exposed read/write with def_readwrite. + metrics.area = 3.0 + metrics.perimeter = 7.5 + self.assertEqual(metrics.area, 3.0) + self.assertEqual(metrics.perimeter, 7.5) + + # The const member is read-only (def_readonly); assigning it raises. + self.assertEqual(metrics.dimension, 2) + with self.assertRaises(AttributeError): + metrics.dimension = 3 + + # The `scratch` field is suppressed via excluded_variables. + self.assertFalse(hasattr(metrics, "scratch")) + if __name__ == "__main__": unittest.main() diff --git a/examples/shapes/wrapper/package_info.yaml b/examples/shapes/wrapper/package_info.yaml index 6408d32..de476a9 100644 --- a/examples/shapes/wrapper/package_info.yaml +++ b/examples/shapes/wrapper/package_info.yaml @@ -186,6 +186,14 @@ modules: - name: ShapeClassifier source_file: ShapeKind.hpp + # A plain data struct (issue #116). It is wrapped as a normal class; its + # public members are exposed with def_readwrite / def_readonly. The + # `scratch` field is suppressed via excluded_variables. + - name: ShapeMetrics + source_file: ShapeMetrics.hpp + excluded_variables: + - scratch + # 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/ShapeMetrics.cppwg.cpp b/examples/shapes/wrapper/primitives/ShapeMetrics.cppwg.cpp new file mode 100644 index 0000000..29c71f2 --- /dev/null +++ b/examples/shapes/wrapper/primitives/ShapeMetrics.cppwg.cpp @@ -0,0 +1,23 @@ +// This file is automatically generated by cppwg. +// Do not modify this file directly. + +#include +#include +#include "wrapper_header_collection.cppwg.hpp" + +#include "ShapeMetrics.cppwg.hpp" + +namespace py = pybind11; +PYBIND11_DECLARE_HOLDER_TYPE(T, std::shared_ptr); +typedef ShapeMetrics ShapeMetrics; + + +void register_ShapeMetrics_class(py::module &m) +{ + py::class_>(m, "ShapeMetrics") + .def(py::init<>()) + .def_readwrite("area", &ShapeMetrics::area) + .def_readwrite("perimeter", &ShapeMetrics::perimeter) + .def_readonly("dimension", &ShapeMetrics::dimension) + ; +} diff --git a/examples/shapes/wrapper/primitives/ShapeMetrics.cppwg.hpp b/examples/shapes/wrapper/primitives/ShapeMetrics.cppwg.hpp new file mode 100644 index 0000000..627e751 --- /dev/null +++ b/examples/shapes/wrapper/primitives/ShapeMetrics.cppwg.hpp @@ -0,0 +1,10 @@ +// This file is automatically generated by cppwg. +// Do not modify this file directly. + +#ifndef ShapeMetrics_hpp__cppwg_wrapper +#define ShapeMetrics_hpp__cppwg_wrapper + +#include + +void register_ShapeMetrics_class(pybind11::module &m); +#endif // ShapeMetrics_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 ad9a4dc..ab5e0dd 100644 --- a/examples/shapes/wrapper/primitives/_pyshapes_primitives.main.cppwg.cpp +++ b/examples/shapes/wrapper/primitives/_pyshapes_primitives.main.cppwg.cpp @@ -17,6 +17,7 @@ #include "Cuboid.cppwg.hpp" #include "Rectangle.cppwg.hpp" #include "ShapeClassifier.cppwg.hpp" +#include "ShapeMetrics.cppwg.hpp" #include "SquareFeet.cppwg.hpp" #include "SquareMetres.cppwg.hpp" #include "UnitSquare.cppwg.hpp" @@ -57,6 +58,7 @@ PYBIND11_MODULE(_pyshapes_primitives, m) register_Cuboid_class(m); register_Rectangle_class(m); register_ShapeClassifier_class(m); + register_ShapeMetrics_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 6417c03..5b938a1 100644 --- a/examples/shapes/wrapper/wrapper_header_collection.cppwg.hpp +++ b/examples/shapes/wrapper/wrapper_header_collection.cppwg.hpp @@ -14,6 +14,7 @@ #include "RegularPolygon.hpp" #include "Shape.hpp" #include "ShapeKind.hpp" +#include "ShapeMetrics.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 d10c2ea..ac6f755 100644 --- a/tests/test_class_writer.py +++ b/tests/test_class_writer.py @@ -1,5 +1,7 @@ """Unit tests for cppwg.writers.class_writer.""" +from pygccxml import declarations + from cppwg.info.base_info import BaseInfo from cppwg.templates.pybind11_default import template_collection from cppwg.writers.class_writer import CppClassWrapperWriter @@ -586,9 +588,7 @@ def test_includes_block_emits_generator_source_includes(): writer = _make_writer(class_info) assert writer.includes_block() == ( - '#include "Helper.hpp"\n' - "#include \n" - '#include "Foo.hpp"\n' + '#include "Helper.hpp"\n' "#include \n" '#include "Foo.hpp"\n' ) @@ -910,9 +910,7 @@ def test_inherited_override_distinguishes_overloads_by_args(): def test_inherited_override_matches_regardless_of_return_type(): """Return type is not compared, so a covariant-return override still matches.""" - base = _FakeBaseDecl( - [_FakeMethodDecl("GetMesh", arg_types=[], has_const=True)] - ) + base = _FakeBaseDecl([_FakeMethodDecl("GetMesh", arg_types=[], has_const=True)]) writer = _override_writer(True, base) class_decl = _FakeDerivedDecl(bases=[base]) method = _FakeMethodDecl("GetMesh", arg_types=[], has_const=True) @@ -1035,9 +1033,7 @@ def test_inherited_override_kept_when_base_virtual_not_public(): protected on the base provides no inherited binding - dropping the override would make it unreachable. """ - base = _FakeBaseDecl( - [_FakeMethodDecl("GetValue", access_type="protected")] - ) + base = _FakeBaseDecl([_FakeMethodDecl("GetValue", access_type="protected")]) writer = _override_writer(True, base) class_decl = _FakeDerivedDecl(bases=[base]) method = _FakeMethodDecl("GetValue") # public override @@ -1055,8 +1051,17 @@ def __init__(self, name): class _CWMethod: - def __init__(self, name, return_type="void", virtuality="virtual", arg_types=(), - arguments=(), has_const=False, parent=None, access="public"): + def __init__( + self, + name, + return_type="void", + virtuality="virtual", + arg_types=(), + arguments=(), + has_const=False, + parent=None, + access="public", + ): self.name = name self.return_type = _CWType(return_type) self.virtuality = virtuality @@ -1080,9 +1085,7 @@ def _class_writer_with_methods(methods): class_decl = _CWClassDecl("Foo", methods) for method in methods: method.parent = class_decl - class_info = _FakeClassInfo( - "Foo", class_decl, {}, "Foo.hpp", py_names=["Foo"] - ) + class_info = _FakeClassInfo("Foo", class_decl, {}, "Foo.hpp", py_names=["Foo"]) class_info.template_params = None class_info.template_arg_lists = None return _make_writer(class_info) @@ -1097,7 +1100,9 @@ def test_virtual_overrides_builds_trampoline_and_typedefs(): ] writer = _class_writer_with_methods(methods) - return_typedefs, override_class, methods_needing_override = writer.virtual_overrides(0) + return_typedefs, override_class, methods_needing_override = ( + writer.virtual_overrides(0) + ) assert [m.name for m in methods_needing_override] == ["area", "clone"] # The special-character return type gets a typedef; "double"/"void" do not. @@ -1109,15 +1114,17 @@ def test_virtual_overrides_builds_trampoline_and_typedefs(): def test_virtual_overrides_empty_without_virtual_methods(): """A class with no virtual methods needs no trampoline.""" - writer = _class_writer_with_methods( - [_CWMethod("helper", virtuality="not virtual")] + writer = _class_writer_with_methods([_CWMethod("helper", virtuality="not virtual")]) + return_typedefs, override_class, methods_needing_override = ( + writer.virtual_overrides(0) ) - return_typedefs, override_class, methods_needing_override = writer.virtual_overrides(0) assert return_typedefs == "" assert override_class == "" assert methods_needing_override == [] +from types import SimpleNamespace # noqa: E402 + import pytest # noqa: E402 from cppwg.writers import class_writer as class_writer_module # noqa: E402 @@ -1161,24 +1168,181 @@ def test_write_struct_enum_writes_files(tmp_path, monkeypatch): assert (tmp_path / "Color.cppwg.cpp").is_file() -def test_write_skips_struct_without_single_enum(tmp_path, monkeypatch): - """A struct that is not the single-enum pattern registers nothing.""" +class _FakeVariable: + """Stand-in for a pygccxml variable_t (public data member).""" + + def __init__(self, name, decl_type=None, bits=None, static=False, parent=None): + self.name = name + self.decl_type = decl_type if decl_type is not None else declarations.double_t() + self.bits = bits + self.type_qualifiers = declarations.type_qualifiers_t() + self.type_qualifiers.has_static = static + # Set to the owning decl by _DataStructDecl unless a nested parent is given. + self.parent = parent + + +class _DataStructDecl: + """Stand-in for a plain data struct decl (no enum), for the class path. + + Supports the parts build_class_register / virtual_overrides / bases_block + consult: member functions, constructors, public data members and bases. + """ + + def __init__(self, name, file_name, variables=(), enums=()): + self.name = name + self.location = _FakeLocation(file_name) + self._variables = list(variables) + self._enums = list(enums) + self.bases = [] + self.recursive_bases = [] + self.is_abstract = False + # A direct member's parent is this decl; a variable given a nested parent + # keeps it (so the member writer's parent check drops it). + for variable in self._variables: + if variable.parent is None: + variable.parent = self + + def enumerations(self, allow_empty=False): + return self._enums + + def member_functions(self, name=None, function=None, allow_empty=False): + return [] + + def constructors(self, function=None, allow_empty=False): + return [] + + def variables(self, function=None, allow_empty=False): + return self._variables + + +def test_write_wraps_non_enum_struct_as_class(tmp_path, monkeypatch): + """A plain data struct is wrapped as a normal class, with member bindings. + + Regression test for issue #116: previously any struct that was not the + single-nested-enum pattern was silently dropped (no wrapper file), while the + module writer still emitted its include/register call. + """ monkeypatch.setattr( class_writer_module.type_traits_classes, "is_struct", lambda decl: True ) + decl = _DataStructDecl( + "Metrics", + "/src/Metrics.hpp", + variables=[ + _FakeVariable("area"), + _FakeVariable( + "dimension", decl_type=declarations.const_t(declarations.int_t()) + ), + ], + ) + class_info = _FakeClassInfo("Metrics", decl, {}, "Metrics.hpp") + class_info.template_params = None + class_info.template_arg_lists = None + + _make_writer(class_info).write(str(tmp_path)) + + assert (tmp_path / "Metrics.cppwg.hpp").is_file() + cpp = (tmp_path / "Metrics.cppwg.cpp").read_text() + assert '.def_readwrite("area", &Metrics::area)' in cpp + assert '.def_readonly("dimension", &Metrics::dimension)' in cpp + + +def test_build_class_register_skips_unbindable_members(tmp_path, monkeypatch): + """Members with no takeable pointer-to-member address are not bound: static, + bitfield, reference and (from the recursive query) nested-class members.""" + monkeypatch.setattr( + class_writer_module.type_traits_classes, "is_struct", lambda decl: True + ) + nested_parent = SimpleNamespace(name="FlagsIterator") + decl = _DataStructDecl( + "Flags", + "/src/Flags.hpp", + variables=[ + _FakeVariable("shared", static=True), + _FakeVariable("packed", bits=1), + _FakeVariable( + "alias", decl_type=declarations.reference_t(declarations.double_t()) + ), + _FakeVariable("inner", parent=nested_parent), # nested class field + _FakeVariable("value"), + ], + ) + class_info = _FakeClassInfo("Flags", decl, {}, "Flags.hpp") + class_info.template_params = None + class_info.template_arg_lists = None + + _make_writer(class_info).write(str(tmp_path)) + + cpp = (tmp_path / "Flags.cppwg.cpp").read_text() + assert '.def_readwrite("value", &Flags::value)' in cpp + assert "shared" not in cpp + assert "packed" not in cpp + assert "alias" not in cpp + assert "inner" not in cpp - class _TwoEnumStruct(_FakeStructDecl): - def enumerations(self, allow_empty=False): - return [self._enum, self._enum] # not a single enum - decl = _TwoEnumStruct("Multi", "/src/Multi.hpp", _FakeEnum("V", [("A", 0)])) +def test_write_struct_with_multiple_enums_wraps_as_class(tmp_path, monkeypatch): + """A struct with more than one enum is wrapped as a class, not dropped.""" + monkeypatch.setattr( + class_writer_module.type_traits_classes, "is_struct", lambda decl: True + ) + decl = _DataStructDecl( + "Multi", + "/src/Multi.hpp", + variables=[_FakeVariable("x")], + enums=[_FakeEnum("A", [("P", 0)]), _FakeEnum("B", [("Q", 0)])], + ) class_info = _FakeClassInfo("Multi", decl, {}, "Multi.hpp") class_info.template_params = None class_info.template_arg_lists = None _make_writer(class_info).write(str(tmp_path)) - assert list(tmp_path.iterdir()) == [] # nothing to register -> no files + cpp = (tmp_path / "Multi.cppwg.cpp").read_text() + assert "register_Multi_class" in cpp + assert '.def_readwrite("x", &Multi::x)' in cpp + + +def test_write_wraps_plain_class_with_members(tmp_path, monkeypatch): + """A non-struct class also takes the normal path and binds its members.""" + monkeypatch.setattr( + class_writer_module.type_traits_classes, "is_struct", lambda decl: False + ) + decl = _DataStructDecl("Widget", "/src/Widget.hpp", variables=[_FakeVariable("w")]) + class_info = _FakeClassInfo("Widget", decl, {}, "Widget.hpp") + class_info.template_params = None + class_info.template_arg_lists = None + + _make_writer(class_info).write(str(tmp_path)) + + cpp = (tmp_path / "Widget.cppwg.cpp").read_text() + assert "register_Widget_class" in cpp + assert '.def_readwrite("w", &Widget::w)' in cpp + + +def test_write_warns_and_writes_nothing_when_no_register_blocks(tmp_path, caplog): + """A class with no instantiations produces no file and logs a warning. + + The module writer still emits an include/register call for the class, so the + empty result is surfaced as a warning rather than a silent missing file. + """ + class_info = _FakeClassInfo( + "Empty", + decl=None, + attrs={}, + source_file="Empty.hpp", + cpp_names=[], + py_names=[], + decls=[], + ) + class_info.template_params = None + class_info.template_arg_lists = None + + with caplog.at_level("WARNING"): + _make_writer(class_info).write(str(tmp_path)) + + assert list(tmp_path.iterdir()) == [] # no files written + assert "produced no wrapper code" in caplog.text def test_includes_block_falls_back_to_decl_location_header(): diff --git a/tests/test_member_variable_writer.py b/tests/test_member_variable_writer.py new file mode 100644 index 0000000..23c6ba0 --- /dev/null +++ b/tests/test_member_variable_writer.py @@ -0,0 +1,147 @@ +"""Unit tests for cppwg.writers.member_variable_writer.""" + +from types import SimpleNamespace + +from pygccxml import declarations + +from cppwg.templates.pybind11_default import template_collection +from cppwg.utils import utils +from cppwg.writers.member_variable_writer import CppClassMemberWrapperWriter + + +def _variable(name, decl_type=None, bits=None, static=False, parent=None): + """Build a minimal pygccxml variable_t stand-in.""" + type_qualifiers = declarations.type_qualifiers_t() + type_qualifiers.has_static = static + return SimpleNamespace( + name=name, + decl_type=decl_type if decl_type is not None else declarations.double_t(), + bits=bits, + type_qualifiers=type_qualifiers, + parent=parent, + ) + + +def _writer(variable, excluded_variables=()): + """Wrap a fake variable in a writer with a minimal class info double.""" + class_decl = SimpleNamespace(name="Foo") + # A direct member's parent is its owning class; only mark it nested if the + # test supplied a different parent. + if variable.parent is None: + variable.parent = class_decl + class_info = SimpleNamespace( + decls=[class_decl], + py_names=["Foo_2"], + hierarchy_attribute_gather_flat=lambda key: ( + list(excluded_variables) if key == "excluded_variables" else [] + ), + ) + return CppClassMemberWrapperWriter(class_info, 0, variable, template_collection) + + +def test_mutable_member_binds_readwrite(): + result = _writer(_variable("value")).generate_wrapper() + assert result == ' .def_readwrite("value", &Foo_2::value)\n' + + +def test_const_member_binds_readonly(): + variable = _variable( + "dimension", decl_type=declarations.const_t(declarations.int_t()) + ) + result = _writer(variable).generate_wrapper() + assert result == ' .def_readonly("dimension", &Foo_2::dimension)\n' + + +def test_excluded_variable_is_skipped(): + writer = _writer(_variable("secret"), excluded_variables=["secret"]) + assert writer.exclude() is True + assert writer.generate_wrapper() == "" + + +def test_bitfield_member_is_skipped(): + writer = _writer(_variable("packed", bits=3)) + assert writer.exclude() is True + assert writer.generate_wrapper() == "" + + +def test_static_member_is_skipped(): + writer = _writer(_variable("shared", static=True)) + assert writer.exclude() is True + assert writer.generate_wrapper() == "" + + +def test_array_member_is_skipped(): + # A C array member is not assignable, so def_readwrite would not compile. + variable = _variable( + "coords", decl_type=declarations.array_t(declarations.double_t(), 3) + ) + writer = _writer(variable) + assert writer.exclude() is True + assert writer.generate_wrapper() == "" + + +def test_const_array_member_is_skipped(): + # A const array is not bindable read-only either (no caster for raw arrays). + variable = _variable( + "labels", + decl_type=declarations.const_t(declarations.array_t(declarations.int_t(), 2)), + ) + writer = _writer(variable) + assert writer.exclude() is True + assert writer.generate_wrapper() == "" + + +def test_reference_member_is_skipped(): + # A reference member cannot form a pointer-to-member, so &Foo::field is + # ill-formed and it must not be bound. + variable = _variable( + "ref", decl_type=declarations.reference_t(declarations.double_t()) + ) + writer = _writer(variable) + assert writer.exclude() is True + assert writer.generate_wrapper() == "" + + +def test_nested_class_member_is_skipped(): + # The recursive variables() query also returns a nested class's fields; those + # belong to a different parent and must not be bound as &Foo::field. + nested_parent = SimpleNamespace(name="FooIterator") + variable = _variable("index", parent=nested_parent) + writer = _writer(variable) + assert writer.exclude() is True + assert writer.generate_wrapper() == "" + + +def test_non_copy_assignable_mutable_member_is_skipped(monkeypatch): + # A mutable member whose type is not copy-assignable (e.g. std::unique_ptr) + # cannot take the def_readwrite setter (obj.*pm = value), so it is skipped. + monkeypatch.setattr(utils, "type_is_copy_assignable", lambda decl_type: False) + writer = _writer(_variable("owned")) + assert writer.exclude() is True + assert writer.generate_wrapper() == "" + + +def test_non_copy_assignable_const_member_is_still_readonly(monkeypatch): + # A const member is bound read-only (no setter), so copy-assignability is + # irrelevant and it is not skipped. + monkeypatch.setattr(utils, "type_is_copy_assignable", lambda decl_type: False) + variable = _variable("frozen", decl_type=declarations.const_t(declarations.int_t())) + writer = _writer(variable) + assert writer.exclude() is False + assert ( + writer.generate_wrapper() == ' .def_readonly("frozen", &Foo_2::frozen)\n' + ) + + +def test_class_py_name_falls_back_to_decl_name(): + """With no python name set, the binding refers to the class by its decl name.""" + class_decl = SimpleNamespace(name="Bar") + class_info = SimpleNamespace( + decls=[class_decl], + py_names=[None], + hierarchy_attribute_gather_flat=lambda key: [], + ) + writer = CppClassMemberWrapperWriter( + class_info, 0, _variable("x", parent=class_decl), template_collection + ) + assert writer.generate_wrapper() == ' .def_readwrite("x", &Bar::x)\n' diff --git a/tests/test_package_info.py b/tests/test_package_info.py index 5c1f321..ae15ae7 100644 --- a/tests/test_package_info.py +++ b/tests/test_package_info.py @@ -281,6 +281,7 @@ def __init__( is_abstract=False, name=None, recursive_bases=(), + variables=(), ): self._methods = list(methods) self._constructors = list(constructors) @@ -288,6 +289,7 @@ def __init__( self.name = name self.recursive_bases = list(recursive_bases) self.bases = [] + self._variables = list(variables) def member_functions(self, function=None, allow_empty=False): return self._methods @@ -295,6 +297,9 @@ def member_functions(self, function=None, allow_empty=False): def constructors(self, function=None, allow_empty=False): return self._constructors + def variables(self, function=None, allow_empty=False): + return self._variables + def _py_name(cpp_name): """Turn a cpp instantiation name into a python name e.g. Foo<2, 2> -> Foo_2_2.""" @@ -802,6 +807,7 @@ def test_build_type_header_map_drops_ambiguous_name(tmp_path): from types import SimpleNamespace # noqa: E402 import pytest # noqa: E402 +from pygccxml import declarations # noqa: E402 import cppwg.info.package_info as package_info_module # noqa: E402 @@ -988,6 +994,42 @@ def __init__(self, arg_types, name=None, return_type=None): self.argument_types = [_IterType(a) for a in arg_types] +class _IterVariable: + def __init__( + self, + name, + decl_type, + bits=None, + static=False, + array=False, + reference=False, + const=False, + ): + self.name = name + # A real array_t/reference_t/const_t so declarations.is_array / + # is_reference / is_const see it; otherwise a stand-in whose decl_string + # is what the walk yields. + if array: + self.decl_type = declarations.array_t(declarations.double_t(), 3) + elif reference: + self.decl_type = declarations.reference_t(declarations.double_t()) + elif const: + self.decl_type = declarations.const_t(declarations.int_t()) + else: + self.decl_type = _IterType(decl_type) + self.bits = bits + self.type_qualifiers = SimpleNamespace(has_static=static) + # Set to the owning decl by _IterDecl unless a nested parent is supplied. + self.parent = None + + +def _nested_variable(name, decl_type): + """A public field of a nested class (its parent is not the outer decl).""" + variable = _IterVariable(name, decl_type) + variable.parent = object() # a parent other than the walked decl + return variable + + class _IterClassInfo: def __init__(self, **attrs): self._attrs = attrs @@ -998,11 +1040,26 @@ def hierarchy_attribute_gather_flat(self, key): class _IterDecl: - def __init__(self, methods=(), ctors=(), is_abstract=False, recursive_bases=()): + def __init__( + self, + methods=(), + ctors=(), + is_abstract=False, + recursive_bases=(), + variables=(), + enumerations=(), + ): self._methods = list(methods) self._ctors = list(ctors) self.is_abstract = is_abstract self.recursive_bases = list(recursive_bases) + self._variables = list(variables) + self._enumerations = list(enumerations) + # A direct member's parent is this decl; a variable that already carries a + # (nested) parent keeps it. + for variable in self._variables: + if variable.parent is None: + variable.parent = self def member_functions(self, function=None, allow_empty=True): return self._methods @@ -1010,6 +1067,12 @@ def member_functions(self, function=None, allow_empty=True): def constructors(self, function=None, allow_empty=True): return self._ctors + def variables(self, function=None, allow_empty=True): + return self._variables + + def enumerations(self, allow_empty=True): + return self._enumerations + def test_iter_wrapped_arg_return_types_honours_exclusions(): """Only the arg/return types of non-excluded methods and constructors yield.""" @@ -1019,6 +1082,7 @@ def test_iter_wrapped_arg_return_types_honours_exclusions(): arg_type_excludes=["BadArg"], constructor_arg_type_excludes=["CtorBan"], constructor_signature_excludes=[["int", "int"]], + excluded_variables=["hidden"], ) decl = _IterDecl( methods=[ @@ -1033,6 +1097,63 @@ def test_iter_wrapped_arg_return_types_honours_exclusions(): _IterCalldef(["int", "int"]), # matches a signature exclude -> skipped _IterCalldef(["bool"]), # kept -> yields bool ], + variables=[ + _IterVariable("field", "MemberType"), # kept -> yields MemberType + _IterVariable("hidden", "Hidden"), # excluded_variables -> skipped + _IterVariable("shared", "Static", static=True), # static -> skipped + _IterVariable("packed", "Bits", bits=1), # bitfield -> skipped + _IterVariable("coords", "Arr", array=True), # C array -> skipped + _IterVariable("ref", "Ref", reference=True), # reference -> skipped + _nested_variable("inner", "Nested"), # nested-class field -> skipped + ], + ) + + types = [ + t.decl_string + for t in PackageInfo._iter_wrapped_arg_return_types(class_info, decl) + ] + + assert types == ["double", "Ret", "bool", "MemberType"] + + +def test_iter_wrapped_types_walks_members_when_constructors_not_wrapped(): + """An abstract class with an abstract base wraps no constructors but still + exposes its public data members, so their types are still yielded.""" + class_info = _IterClassInfo() + abstract_base = SimpleNamespace(related_class=SimpleNamespace(is_abstract=True)) + decl = _IterDecl( + is_abstract=True, + recursive_bases=[abstract_base], + ctors=[ + _IterCalldef(["ShouldBeSkipped"]) + ], # not wrapped: abstract w/ abstract base + variables=[_IterVariable("field", "MemberType")], + ) + + types = [ + t.decl_string + for t in PackageInfo._iter_wrapped_arg_return_types(class_info, decl) + ] + + assert types == ["MemberType"] # ctor arg skipped, member type still yielded + + +def test_iter_wrapped_types_skips_non_copy_assignable_member(monkeypatch): + """A mutable member whose type is not copy-assignable is not bound (its setter + would not compile), so its type is not yielded; a const member of the same + kind is bound read-only and is still yielded.""" + monkeypatch.setattr( + package_info_module.utils, + "type_is_copy_assignable", + lambda decl_type: getattr(decl_type, "decl_string", "") != "MoveOnly", + ) + class_info = _IterClassInfo() + decl = _IterDecl( + variables=[ + _IterVariable("ok", "Copyable"), # assignable -> yields Copyable + _IterVariable("owned", "MoveOnly"), # not assignable -> skipped + _IterVariable("frozen", None, const=True), # const -> read-only, kept + ], ) types = [ @@ -1040,7 +1161,27 @@ def test_iter_wrapped_arg_return_types_honours_exclusions(): for t in PackageInfo._iter_wrapped_arg_return_types(class_info, decl) ] - assert types == ["double", "Ret", "bool"] + assert "Copyable" in types + assert "MoveOnly" not in types + assert any("int" in t for t in types) # the const member is still yielded + + +def test_iter_wrapped_types_skips_struct_single_enum(monkeypatch): + """A struct wrapping a single nested enum is registered as a py::enum_; none of + its methods/ctors/members are bound, so the walk yields nothing for it.""" + monkeypatch.setattr( + package_info_module.type_traits_classes, "is_struct", lambda decl: True + ) + class_info = _IterClassInfo() + decl = _IterDecl( + methods=[_IterCalldef(["double"], name="m", return_type="Ret")], + variables=[_IterVariable("field", "MemberType")], + enumerations=["SingleEnum"], # single nested enum -> struct-enum dispatch + ) + + types = list(PackageInfo._iter_wrapped_arg_return_types(class_info, decl)) + + assert types == [] def test_build_type_header_map_drops_ambiguous_and_skips_out_of_location(tmp_path): @@ -1080,6 +1221,9 @@ def member_functions(self, function=None, allow_empty=True): def constructors(self, function=None, allow_empty=True): return [] + def variables(self, function=None, allow_empty=True): + return [] + def test_resolve_auto_includes_uses_decl_header_when_source_file_unset(tmp_path): """With no source_file, the class's own header comes from its decl location.""" diff --git a/tests/test_utils.py b/tests/test_utils.py index 9879475..52b6716 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -25,6 +25,7 @@ strip_outer_angle_brackets, strip_source, template_has_default_param, + type_is_copy_assignable, type_string_matches, write_file_if_changed, ) @@ -519,12 +520,15 @@ def test_strip_source_flags_are_independent(): """Each strip step can be toggled off individually.""" source = "// c\n#define X 1\nclass Foo {};" # Nothing stripped: content preserved (only the object is returned as-is). - assert strip_source( - source, - strip_comments=False, - strip_preprocessor=False, - strip_whitespace=False, - ) == source + assert ( + strip_source( + source, + strip_comments=False, + strip_preprocessor=False, + strip_whitespace=False, + ) + == source + ) # Only comments stripped. only_comments = strip_source( source, @@ -598,3 +602,86 @@ def test_parse_template_params_skips_empty_name_after_default(): ) def test_strip_outer_angle_brackets(signature, expected): assert strip_outer_angle_brackets(signature) == expected + + +def test_type_is_copy_assignable_for_non_class_types(): + """Fundamental types and pointers are always copy-assignable.""" + from pygccxml.declarations import cpptypes + + assert type_is_copy_assignable(cpptypes.double_t()) is True + assert type_is_copy_assignable(cpptypes.int_t()) is True + assert type_is_copy_assignable(cpptypes.pointer_t(cpptypes.int_t())) is True + + +def test_type_is_copy_assignable_class_branch(monkeypatch): + """A class type is copy-assignable only if it is not noncopyable and has a + public assignment operator; either failing marks it non-assignable.""" + from pygccxml import declarations + from pygccxml.declarations import cpptypes, type_traits_classes + + # A real type that survives remove_cv/remove_alias; is_my_case is patched to + # force the class branch, so its actual kind does not matter. + class_type = cpptypes.int_t() + monkeypatch.setattr(declarations.class_traits, "is_my_case", lambda t: True) + monkeypatch.setattr(declarations.class_traits, "get_declaration", lambda t: "cls") + + def configure(noncopyable, public_assign): + monkeypatch.setattr( + type_traits_classes, "is_noncopyable", lambda c: noncopyable + ) + monkeypatch.setattr( + type_traits_classes, "has_public_assign", lambda c: public_assign + ) + + # Copyable class with a public operator= (e.g. std::string) -> assignable. + configure(noncopyable=False, public_assign=True) + assert type_is_copy_assignable(class_type) is True + + # Move-only / noncopyable (e.g. std::unique_ptr, std::atomic) -> not assignable. + configure(noncopyable=True, public_assign=True) + assert type_is_copy_assignable(class_type) is False + + # Deleted copy assignment (public_assign False) -> not assignable. + configure(noncopyable=False, public_assign=False) + assert type_is_copy_assignable(class_type) is False + + +@pytest.mark.skipif( + not __import__("shutil").which("castxml"), reason="castxml not installed" +) +def test_type_is_copy_assignable_real_parse(tmp_path): + """End-to-end against real pygccxml class types parsed by castxml: unique_ptr + and atomic members are non-assignable; string/vector/plain members are.""" + from pygccxml import declarations, parser + + header = tmp_path / "members.hpp" + header.write_text( + "#include \n" + "#include \n" + "#include \n" + "#include \n" + "struct Deleted { Deleted& operator=(const Deleted&) = delete; int x; };\n" + "struct Foo {\n" + " double a;\n" + " std::string s;\n" + " std::vector v;\n" + " std::unique_ptr p;\n" + " std::atomic n;\n" + " Deleted d;\n" + "};\n" + ) + config = parser.xml_generator_configuration_t( + xml_generator_path=__import__("shutil").which("castxml"), + xml_generator="castxml", + cflags="-std=c++17", + ) + reader = parser.source_reader.source_reader_t(config) + global_ns = declarations.get_global_namespace(reader.read_file(str(header))) + members = {v.name: v for v in global_ns.class_("Foo").variables(allow_empty=True)} + + assert type_is_copy_assignable(members["a"].decl_type) is True + assert type_is_copy_assignable(members["s"].decl_type) is True + assert type_is_copy_assignable(members["v"].decl_type) is True + assert type_is_copy_assignable(members["p"].decl_type) is False # unique_ptr + assert type_is_copy_assignable(members["n"].decl_type) is False # atomic + assert type_is_copy_assignable(members["d"].decl_type) is False # deleted op=