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
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,5 +165,15 @@ r = Rectangle(4, 5)
- To pass extra flags to the castxml clang frontend (e.g. to silence a
diagnostic), use `--castxml_cflags`. Values starting with `-` must use `=`,
e.g. `--castxml_cflags="-Wno-deprecated"`.
- To stop C++ exceptions from crashing the Python interpreter, list their class
names under `exceptions` in the config. cppwg generates a pybind11 exception
translator for each. By default the message is read with `what()`; set
`message_method` on an entry to use a different accessor (e.g. `GetMessage`).
See `examples/shapes/wrapper/package_info.yaml` for an example.
- This only applies to thrown C++ exceptions. Errors from C dependencies that
use return codes (e.g. a PETSc `PetscErrorCode`) are not caught unless the
wrapped C++ code converts them into a C++ exception first (for PETSc, via
`PetscCallThrow()` in a C++-exception build, or by checking the code and
throwing). See `PetscUtils::ThrowPetscError` in the cells example.
- See the [pybind11 documentation](https://pybind11.readthedocs.io/) for help on pybind11
wrapper code.
13 changes: 13 additions & 0 deletions cppwg/info/free_function_info.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Free function information structure."""

import logging
from typing import Any, Dict, Optional

from cppwg.info.cpp_entity_info import CppEntityInfo
Expand All @@ -25,4 +26,16 @@ def update_from_ns(self, source_ns: "namespace_t") -> None: # noqa: F821
The source namespace
"""
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.
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."
)
raise RuntimeError(f"Could not find free function: {self.name}")

self.decls = [ff_decls[0]]
99 changes: 98 additions & 1 deletion cppwg/info/package_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@
import logging
import os
from pathlib import Path
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List, Optional, Tuple, Union

from pygccxml import declarations

from cppwg.info.base_info import BaseInfo
from cppwg.utils import utils
from cppwg.utils.constants import CPPWG_EXT


Expand All @@ -18,13 +21,21 @@ class PackageInfo(BaseInfo):
----------
common_include_file : bool
Use a common include file for all source files
exceptions : List[Union[str, Dict[str, str]]]
C++ exception classes to translate into Python exceptions. Each entry is
a class name, or a dict with a `name` and an optional `message_method`
(the accessor for the message, defaulting to "what"). A pybind11
exception translator is generated automatically for each.
exclude_default_args : bool
Exclude default arguments from method wrappers.
name : str
The name of the package
source_hpp_patterns : List[str]
A list of source file patterns to include

exception_info : List[Dict[str, str]]
Resolved exception translation data (cpp_type, message_expr,
source_file), populated from `exceptions` after parsing the source.
module_collection : List[ModuleInfo]
A list of module info objects associated with this package
source_hpp_files : List[str]
Expand All @@ -47,16 +58,19 @@ def __init__(
super().__init__(name, package_config)

self.common_include_file: bool = False
self.exceptions: List[Union[str, Dict[str, str]]] = []
self.exclude_default_args: bool = False
self.source_hpp_patterns: List[str] = ["*.hpp"]

self.exception_info: List[Dict[str, str]] = []
self.module_collection: List["ModuleInfo"] = [] # noqa: F821
self.source_hpp_files: List[str] = []

if package_config:
self.common_include_file = package_config.get(
"common_include_file", self.common_include_file
)
self.exceptions = package_config.get("exceptions", self.exceptions)
self.exclude_default_args = package_config.get(
"exclude_default_args", self.exclude_default_args
)
Expand Down Expand Up @@ -152,3 +166,86 @@ def update_from_ns(self, source_ns: "namespace_t") -> None: # noqa: F821
"""
for module_info in self.module_collection:
module_info.update_from_ns(source_ns)

self.resolve_exceptions(source_ns)

@staticmethod
def parse_exception_entry(entry: Any) -> Tuple[str, str]:
"""
Return the (class name, message method) for an exceptions config entry.

An entry may be a bare class name string, or a dict with a `name` and an
optional `message_method` (defaulting to "what").

Parameters
----------
entry : Any
A single entry from the `exceptions` config list.

Returns
-------
Tuple[str, str]
The exception class name and the message accessor method name.
"""
if isinstance(entry, dict):
return entry["name"], entry.get("message_method", "what")
return entry, "what"

@property
def exception_names(self) -> List[str]:
"""Return the names of the configured exception classes."""
return [self.parse_exception_entry(entry)[0] for entry in self.exceptions]

def resolve_exceptions(self, source_ns: "namespace_t") -> None: # noqa: F821
"""
Resolve exception config entries into translation data.

For each entry in `exceptions`, look up the class in the source
namespace, work out how to extract its message (calling the configured
message_method, defaulting to what(), and adding .c_str() unless it
already returns a pointer), and find which header declares it. The
result is used to generate a pybind11 exception translator per module.

Parameters
----------
source_ns : pygccxml.declarations.namespace_t
The source namespace
"""
logger = logging.getLogger()

self.exception_info = []
for entry in self.exceptions:
name, message_method = self.parse_exception_entry(entry)

class_decls = source_ns.classes(
lambda decl: decl.name == name, allow_empty=True # noqa: B023
)

if not class_decls:
logger.error(f"Could not find exception class {name}.")
raise RuntimeError(f"Could not find exception class: {name}")

class_decl = class_decls[0]

# Check the class (and its base classes, e.g. for an inherited
# what()) actually declares the configured message method.
method_decl = utils.find_member_function(class_decl, message_method)
if method_decl is None:
logger.error(f"Exception class {name} has no method {message_method}.")
raise RuntimeError(
f"Exception class {name} has no method: {message_method}"
)

# PyErr_SetString needs a const char*. Add .c_str() unless the
# message method already returns a pointer (e.g. what()).
message_expr = f"e.{message_method}()"
if not declarations.is_pointer(method_decl.return_type):
message_expr += ".c_str()"

self.exception_info.append(
{
"cpp_type": name,
"message_expr": message_expr,
"source_file": os.path.basename(class_decl.location.file_name),
}
)
3 changes: 2 additions & 1 deletion cppwg/parsers/package_info_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ def parse(self) -> PackageInfo:
package_config: Dict[str, Any] = {
"name": "cppwg_package",
"common_include_file": True,
"exceptions": [],
"exclude_default_args": False,
"source_hpp_patterns": ["*.hpp"],
}
Expand Down Expand Up @@ -212,7 +213,7 @@ def parse(self) -> PackageInfo:

# Create the CppFreeFunctionInfo object from the free function config dict
free_function_info = CppFreeFunctionInfo(
free_function_config["name"], free_function_config
raw_free_function_info["name"], free_function_config
)

# Add the free function to the module
Expand Down
37 changes: 36 additions & 1 deletion cppwg/utils/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import os
import re
from numbers import Number
from typing import Any, List, Tuple
from typing import Any, List, Optional, Tuple

from cppwg.utils.constants import CPPWG_ALL_STRING, CPPWG_TRUE_STRINGS

Expand Down Expand Up @@ -165,6 +165,41 @@ def find_classes_in_source_file(
return classes


def find_member_function(
class_decl: "class_t", method_name: str # noqa: F821
) -> Optional["member_function_t"]: # noqa: F821
"""
Find a member function on a class or any of its base classes.

Searches the class itself and then its base classes (e.g. to find a what()
method inherited from std::exception).

Parameters
----------
class_decl : pygccxml.declarations.class_t
The class to search.
method_name : str
The name of the member function to find.

Returns
-------
Optional[pygccxml.declarations.member_function_t]
The member function declaration, or None if not found.
"""
method_decls = class_decl.member_functions(method_name, allow_empty=True)
if method_decls:
return method_decls[0]

for hierarchy_info in class_decl.recursive_bases:
method_decls = hierarchy_info.related_class.member_functions(
method_name, allow_empty=True
)
if method_decls:
return method_decls[0]

return None


def read_source_file(
source_file_path: str,
strip_comments: bool = True,
Expand Down
12 changes: 12 additions & 0 deletions cppwg/writers/header_collection_writer.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from cppwg.info.class_info import CppClassInfo
from cppwg.info.free_function_info import CppFreeFunctionInfo
from cppwg.info.package_info import PackageInfo
from cppwg.utils import utils
from cppwg.utils.utils import write_file_if_changed


Expand Down Expand Up @@ -118,6 +119,17 @@ def write(self) -> None:
self.hpp_collection += f'#include "{filename}"\n'
seen_files.add(filename)

# Include headers that declare the configured exception classes so
# they are parsed and can be introspected for the translator.
for exception_name in self.package_info.exception_names:
for filepath in self.package_info.source_hpp_files:
if utils.find_classes_in_source_file(filepath, exception_name):
filename = os.path.basename(filepath)
if filename not in seen_files:
self.hpp_collection += f'#include "{filename}"\n'
seen_files.add(filename)
break

# Add the template instantiations e.g. `template class Foo<2,2>;`
# and typdefs e.g. `typedef Foo<2,2> Foo_2_2;`
template_instantiations = ""
Expand Down
45 changes: 45 additions & 0 deletions cppwg/writers/module_writer.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,36 @@ def __init__(
for decl, cpp_name in zip(class_info.decls, class_info.cpp_names):
self.classes[decl] = cpp_name

def generate_exception_translator(self) -> str:
"""
Generate a pybind11 exception translator for the package's exceptions.

Produces a `py::register_exception_translator` call with a catch clause
for each configured exception class, mapping it to a Python
RuntimeError. Returns an empty string if no exceptions are configured.

Returns
-------
str
The exception translator code, indented for the module body.
"""
exception_info = self.module_info.package_info.exception_info
if not exception_info:
return ""

code = " py::register_exception_translator([](std::exception_ptr p) {\n"
code += " try {\n"
code += " if (p) std::rethrow_exception(p);\n"
for exception in exception_info:
code += f" }} catch (const {exception['cpp_type']}& e) {{\n"
code += (
" PyErr_SetString(PyExc_RuntimeError, "
f"{exception['message_expr']});\n"
)
code += " }\n"
code += " });\n\n"
return code

def write_module_wrapper(self) -> None:
"""
Generate the contents of the main cpp file for the module.
Expand Down Expand Up @@ -92,6 +122,17 @@ def write_module_wrapper(self) -> None:

if self.module_info.package_info.common_include_file:
cpp_string += f'#include "{CPPWG_HEADER_COLLECTION_FILENAME}"\n'
else:
# Include the headers that declare any exception classes so the
# generated exception translator can reference them. When a common
# include file is used these are already available via the header
# collection.
seen = set()
for exception in self.module_info.package_info.exception_info:
source_file = exception["source_file"]
if source_file not in seen:
seen.add(source_file)
cpp_string += f'#include "{source_file}"\n'

# Add outputs from running custom generator code
if self.module_info.custom_generator_instance:
Expand Down Expand Up @@ -119,6 +160,10 @@ def write_module_wrapper(self) -> None:
cpp_string += f"\nPYBIND11_MODULE({full_module_name}, m)\n"
cpp_string += "{\n"

# Register a pybind11 exception translator for the configured exception
# classes so that C++ exceptions surface as Python exceptions
cpp_string += self.generate_exception_translator()

# Add free functions
for free_function_info in self.module_info.free_function_collection:
function_writer = CppFreeFunctionWrapperWriter(
Expand Down
8 changes: 8 additions & 0 deletions examples/cells/dynamic/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ source_includes:

exclude_default_args: False

# C++ exception classes to translate into Python exceptions. cppwg generates a
# pybind11 exception translator for each, so C++ exceptions surface as Python
# exceptions instead of terminating the interpreter. message_method is the
# accessor used for the message text; it defaults to "what".
exceptions:
- name: SimulationException
message_method: GetMessage

template_substitutions:
- signature: <unsigned DIM>
replacement: [[2], [3]]
Expand Down
3 changes: 3 additions & 0 deletions examples/cells/dynamic/wrappers/all/PetscUtils.cppwg.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,8 @@ void register_PetscUtils_class(py::module &m)
.def_static("CreateVec",
(::Vec(*)(int)) &PetscUtils::CreateVec,
" ", py::arg("size"), py::return_value_policy::reference)
.def_static("ThrowPetscError",
(void(*)()) &PetscUtils::ThrowPetscError,
" ")
;
}
3 changes: 3 additions & 0 deletions examples/cells/dynamic/wrappers/all/Scene_2.cppwg.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,8 @@ void register_Scene_2_class(py::module &m)
.def("GetRenderer",
(::vtkSmartPointer<vtkRenderer>(Scene_2::*)()) &Scene_2::GetRenderer,
" ")
.def_static("ThrowException",
(void(*)()) &Scene_2::ThrowException,
" ")
;
}
3 changes: 3 additions & 0 deletions examples/cells/dynamic/wrappers/all/Scene_3.cppwg.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,8 @@ void register_Scene_3_class(py::module &m)
.def("GetRenderer",
(::vtkSmartPointer<vtkRenderer>(Scene_3::*)()) &Scene_3::GetRenderer,
" ")
.def_static("ThrowException",
(void(*)()) &Scene_3::ThrowException,
" ")
;
}
Loading
Loading