Skip to content
Open
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
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,5 +165,10 @@ 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.
- 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]]
105 changes: 104 additions & 1 deletion cppwg/info/package_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
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.constants import CPPWG_EXT
Expand All @@ -18,13 +20,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 +57,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 +165,93 @@ 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]

# PyErr_SetString needs a const char*. Add .c_str() unless the
# message method already returns a pointer (e.g. what()).
method_decls = class_decl.member_functions(
message_method, allow_empty=True
)
if method_decls:
returns_pointer = declarations.is_pointer(method_decls[0].return_type)
elif message_method == "what":
# std::exception::what() is inherited and returns const char*
returns_pointer = True
else:
logger.error(
f"Could not find method {message_method} on exception {name}."
)
raise RuntimeError(
f"Could not find method {message_method} on exception: {name}"
)

message_expr = f"e.{message_method}()"
if not returns_pointer:
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
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("ThrowException",
(void(*)()) &PetscUtils::ThrowException,
" ")
;
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// This file is auto-generated by cppwg; manual changes will be overwritten.

#include <pybind11/pybind11.h>
#include "SimulationException.hpp"
#include "Cell.cppwg.hpp"
#include "Node_2.cppwg.hpp"
#include "Node_3.cppwg.hpp"
Expand All @@ -18,6 +19,14 @@ namespace py = pybind11;

PYBIND11_MODULE(_pycells_all, m)
{
py::register_exception_translator([](std::exception_ptr p) {
try {
if (p) std::rethrow_exception(p);
} catch (const SimulationException& e) {
PyErr_SetString(PyExc_RuntimeError, e.GetMessage().c_str());
}
});

register_Cell_class(m);
register_Node_2_class(m);
register_Node_3_class(m);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
#include "PetscUtils.hpp"
#include "PottsMesh.hpp"
#include "Scene.hpp"
#include "SimulationException.hpp"

// Instantiate Template Classes
template class AbstractMesh<2, 2>;
Expand Down
7 changes: 7 additions & 0 deletions examples/cells/src/cpp/utils/PetscUtils.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@

#include <vector>

#include "SimulationException.hpp"

void PetscUtils::Initialise()
{
if (!PetscUtils::IsInitialised())
Expand Down Expand Up @@ -64,3 +66,8 @@ Vec PetscUtils::CreateVec(int size)
VecSetFromOptions(v);
return v;
}

void PetscUtils::ThrowException()
{
throw SimulationException("C++ exception thrown", __FILE__, __LINE__);
}
3 changes: 3 additions & 0 deletions examples/cells/src/cpp/utils/PetscUtils.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ class PetscUtils
static int GetRank();

static Vec CreateVec(int size);

/** Throw a SimulationException, to test exception translation. */
static void ThrowException();
};

#endif // PETSCUTILS_HPP_
Loading