Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions cppwg/info/package_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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.
Expand Down
8 changes: 8 additions & 0 deletions cppwg/templates/pybind11_default.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -152,6 +158,7 @@
'(m, "${class_py_name}")\n'
"${constructors}"
"${methods}"
"${members}"
"${generator_def_code}"
"${suffix_code}"
" ;\n"
Expand Down Expand Up @@ -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,
Expand Down
44 changes: 44 additions & 0 deletions cppwg/utils/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>`` and ``std::atomic<T>``, 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,
Expand Down
37 changes: 32 additions & 5 deletions cppwg/writers/class_writer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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))

Expand Down Expand Up @@ -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)
Comment thread
kwabenantim marked this conversation as resolved.
)

block = self.wrapper_templates["class_cpp_register"].substitute(
generator_pre_code=call_generator_hook(
generator, "get_class_cpp_pre_code", "", class_py_name
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down
167 changes: 167 additions & 0 deletions cppwg/writers/member_variable_writer.py
Original file line number Diff line number Diff line change
@@ -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):
Comment thread
kwabenantim marked this conversation as resolved.
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"
Comment thread
kwabenantim marked this conversation as resolved.

return self.wrapper_templates["class_member"].substitute(
access=access,
member_name=self.variable_decl.name,
class_py_name=self.class_py_name,
)
Loading
Loading