diff --git a/README.md b/README.md index 0473380..03feaee 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/cppwg/info/free_function_info.py b/cppwg/info/free_function_info.py index b89cb41..479ba19 100644 --- a/cppwg/info/free_function_info.py +++ b/cppwg/info/free_function_info.py @@ -1,5 +1,6 @@ """Free function information structure.""" +import logging from typing import Any, Dict, Optional from cppwg.info.cpp_entity_info import CppEntityInfo @@ -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]] diff --git a/cppwg/info/package_info.py b/cppwg/info/package_info.py index 0ca426d..96b2754 100644 --- a/cppwg/info/package_info.py +++ b/cppwg/info/package_info.py @@ -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 @@ -18,6 +21,11 @@ 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 @@ -25,6 +33,9 @@ class PackageInfo(BaseInfo): 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] @@ -47,9 +58,11 @@ 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] = [] @@ -57,6 +70,7 @@ def __init__( 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 ) @@ -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), + } + ) diff --git a/cppwg/parsers/package_info_parser.py b/cppwg/parsers/package_info_parser.py index 0fa71ac..543cf47 100644 --- a/cppwg/parsers/package_info_parser.py +++ b/cppwg/parsers/package_info_parser.py @@ -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"], } @@ -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 diff --git a/cppwg/utils/utils.py b/cppwg/utils/utils.py index a5b0e13..156074a 100644 --- a/cppwg/utils/utils.py +++ b/cppwg/utils/utils.py @@ -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 @@ -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, diff --git a/cppwg/writers/header_collection_writer.py b/cppwg/writers/header_collection_writer.py index 50efea5..865f02e 100644 --- a/cppwg/writers/header_collection_writer.py +++ b/cppwg/writers/header_collection_writer.py @@ -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 @@ -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 = "" diff --git a/cppwg/writers/module_writer.py b/cppwg/writers/module_writer.py index c817217..d0c7254 100644 --- a/cppwg/writers/module_writer.py +++ b/cppwg/writers/module_writer.py @@ -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. @@ -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: @@ -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( diff --git a/examples/cells/dynamic/config.yaml b/examples/cells/dynamic/config.yaml index 044db50..2051658 100644 --- a/examples/cells/dynamic/config.yaml +++ b/examples/cells/dynamic/config.yaml @@ -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: replacement: [[2], [3]] diff --git a/examples/cells/dynamic/wrappers/all/PetscUtils.cppwg.cpp b/examples/cells/dynamic/wrappers/all/PetscUtils.cppwg.cpp index c7de3f9..5f0e9bf 100644 --- a/examples/cells/dynamic/wrappers/all/PetscUtils.cppwg.cpp +++ b/examples/cells/dynamic/wrappers/all/PetscUtils.cppwg.cpp @@ -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, + " ") ; } diff --git a/examples/cells/dynamic/wrappers/all/Scene_2.cppwg.cpp b/examples/cells/dynamic/wrappers/all/Scene_2.cppwg.cpp index bccdd79..d03a68f 100644 --- a/examples/cells/dynamic/wrappers/all/Scene_2.cppwg.cpp +++ b/examples/cells/dynamic/wrappers/all/Scene_2.cppwg.cpp @@ -19,5 +19,8 @@ void register_Scene_2_class(py::module &m) .def("GetRenderer", (::vtkSmartPointer(Scene_2::*)()) &Scene_2::GetRenderer, " ") + .def_static("ThrowException", + (void(*)()) &Scene_2::ThrowException, + " ") ; } diff --git a/examples/cells/dynamic/wrappers/all/Scene_3.cppwg.cpp b/examples/cells/dynamic/wrappers/all/Scene_3.cppwg.cpp index 04b2d41..6a8c433 100644 --- a/examples/cells/dynamic/wrappers/all/Scene_3.cppwg.cpp +++ b/examples/cells/dynamic/wrappers/all/Scene_3.cppwg.cpp @@ -19,5 +19,8 @@ void register_Scene_3_class(py::module &m) .def("GetRenderer", (::vtkSmartPointer(Scene_3::*)()) &Scene_3::GetRenderer, " ") + .def_static("ThrowException", + (void(*)()) &Scene_3::ThrowException, + " ") ; } diff --git a/examples/cells/dynamic/wrappers/all/_pycells_all.main.cppwg.cpp b/examples/cells/dynamic/wrappers/all/_pycells_all.main.cppwg.cpp index c504ec2..636fdec 100644 --- a/examples/cells/dynamic/wrappers/all/_pycells_all.main.cppwg.cpp +++ b/examples/cells/dynamic/wrappers/all/_pycells_all.main.cppwg.cpp @@ -1,6 +1,7 @@ // This file is auto-generated by cppwg; manual changes will be overwritten. #include +#include "SimulationException.hpp" #include "Cell.cppwg.hpp" #include "Node_2.cppwg.hpp" #include "Node_3.cppwg.hpp" @@ -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); diff --git a/examples/cells/dynamic/wrappers/wrapper_header_collection.cppwg.hpp b/examples/cells/dynamic/wrappers/wrapper_header_collection.cppwg.hpp index 1db0a7d..c0d2515 100644 --- a/examples/cells/dynamic/wrappers/wrapper_header_collection.cppwg.hpp +++ b/examples/cells/dynamic/wrappers/wrapper_header_collection.cppwg.hpp @@ -11,6 +11,7 @@ #include "PetscUtils.hpp" #include "PottsMesh.hpp" #include "Scene.hpp" +#include "SimulationException.hpp" // Instantiate Template Classes template class AbstractMesh<2, 2>; diff --git a/examples/cells/src/cpp/utils/PetscUtils.cpp b/examples/cells/src/cpp/utils/PetscUtils.cpp index eb16b08..0a83aa3 100644 --- a/examples/cells/src/cpp/utils/PetscUtils.cpp +++ b/examples/cells/src/cpp/utils/PetscUtils.cpp @@ -6,6 +6,8 @@ #include #include +#include +#include #include void PetscUtils::Initialise() @@ -64,3 +66,43 @@ Vec PetscUtils::CreateVec(int size) VecSetFromOptions(v); return v; } + +void PetscUtils::ThrowPetscError() +{ + if (!PetscUtils::IsInitialised()) + { + PetscUtils::Initialise(); + } + + Vec v; + PetscErrorCode ierr = VecCreate(PETSC_COMM_WORLD, &v); + if (ierr) + { + throw std::runtime_error( + "VecCreate failed with PETSc error code " + std::to_string(ierr)); + } + + // PETSc reports errors with C return codes, not C++ exceptions, so the + // wrapper must turn an error code into a C++ exception for it to reach + // Python. +#ifdef PETSC_CLANGUAGE_CXX + ierr = VecSetType(v, "no_such_vec_type"); + VecDestroy(&v); + PetscCallThrow(ierr); +#else + // Otherwise (the common case, including the standard PETSc packages), + // PetscCallThrow() is unavailable, so we replicate it: detect the non-zero + // PetscErrorCode and throw a std::runtime_error. The return error handler + // keeps PETSc from printing a traceback or aborting. + PetscPushErrorHandler(PetscReturnErrorHandler, nullptr); + ierr = VecSetType(v, "no_such_vec_type"); + PetscPopErrorHandler(); + VecDestroy(&v); + + if (ierr) + { + throw std::runtime_error( + "PETSc returned error code " + std::to_string(ierr)); + } +#endif +} diff --git a/examples/cells/src/cpp/utils/PetscUtils.hpp b/examples/cells/src/cpp/utils/PetscUtils.hpp index d6e89bb..31aa4e5 100644 --- a/examples/cells/src/cpp/utils/PetscUtils.hpp +++ b/examples/cells/src/cpp/utils/PetscUtils.hpp @@ -19,6 +19,9 @@ class PetscUtils static int GetRank(); static Vec CreateVec(int size); + + /** Throw a C++ exception on a bad PETSc error code (see .cpp). */ + static void ThrowPetscError(); }; #endif // PETSCUTILS_HPP_ diff --git a/examples/cells/src/cpp/utils/SimulationException.hpp b/examples/cells/src/cpp/utils/SimulationException.hpp new file mode 100644 index 0000000..6c800b8 --- /dev/null +++ b/examples/cells/src/cpp/utils/SimulationException.hpp @@ -0,0 +1,38 @@ +#ifndef SIMULATIONEXCEPTION_HPP_ +#define SIMULATIONEXCEPTION_HPP_ + +#include +#include +#include + +/** + * A simple exception type that derives from std::runtime_error and exposes + * its message via GetMessage(). + * + * A pybind11 exception translator (see dynamic/config.yaml) can use + * GetMessage() to raise a helpful Python error instead of crashing. + */ +class SimulationException : public std::runtime_error +{ +public: + SimulationException(const std::string& rMessage, + const std::string& rFilename, + unsigned lineNumber) + : std::runtime_error(rMessage) + { + std::stringstream message; + message << rFilename << ":" << lineNumber << ": " << rMessage; + mMessage = message.str(); + } + + /** @return the full message, including file and line number. */ + std::string GetMessage() const + { + return mMessage; + } + +private: + std::string mMessage; /**< Full message, including file and line number. */ +}; + +#endif // SIMULATIONEXCEPTION_HPP_ diff --git a/examples/cells/src/cpp/visualization/Scene.cpp b/examples/cells/src/cpp/visualization/Scene.cpp index 97a29aa..95bd3f7 100644 --- a/examples/cells/src/cpp/visualization/Scene.cpp +++ b/examples/cells/src/cpp/visualization/Scene.cpp @@ -5,6 +5,8 @@ #include #include +#include "SimulationException.hpp" + template Scene::Scene() : mpRenderer(vtkSmartPointer::New()), @@ -26,5 +28,11 @@ vtkSmartPointer Scene::GetRenderer() return mpRenderer; } +template +void Scene::ThrowException() +{ + throw SimulationException("C++ exception thrown", __FILE__, __LINE__); +} + template class Scene<2>; template class Scene<3>; diff --git a/examples/cells/src/cpp/visualization/Scene.hpp b/examples/cells/src/cpp/visualization/Scene.hpp index 40560b2..3fdf34f 100644 --- a/examples/cells/src/cpp/visualization/Scene.hpp +++ b/examples/cells/src/cpp/visualization/Scene.hpp @@ -22,6 +22,9 @@ class Scene virtual ~Scene(); vtkSmartPointer GetRenderer(); + + /** Throw a SimulationException, to test exception translation. */ + static void ThrowException(); }; #endif // SCENE_HPP_ diff --git a/examples/cells/tests/test_cells.py b/examples/cells/tests/test_cells.py index 1e2e727..e1c2821 100644 --- a/examples/cells/tests/test_cells.py +++ b/examples/cells/tests/test_cells.py @@ -23,6 +23,29 @@ def testUblasCaster(self): node.Translate([1, 1]) self.assertEqual(list(node.GetLocation()), [1, 1]) + def testExceptionTranslation(self): + # Scene.ThrowException raises a C++ SimulationException (which, like + # Chaste's Exception, derives from std::runtime_error and exposes + # GetMessage()). The registered exception translator should surface it as + # a Python RuntimeError rather than crashing the interpreter. + with self.assertRaises(RuntimeError) as context: + Scene[2].ThrowException() + message = str(context.exception) + self.assertIn("C++ exception thrown", message) + # The translator uses GetMessage(), which prepends the file and line, + # so the message differs from the default what() text. This confirms the + # custom translator (not just pybind11's default) handled the exception. + self.assertIn("Scene.cpp:", message) + + def testPetscError(self): + # PETSc is a C library that reports errors with return codes. The + # wrapper detects a bad PetscErrorCode and throws a std::runtime_error + # (as PetscCallThrow would), which pybind11 surfaces as a Python + # RuntimeError instead of letting the C error pass silently. + with self.assertRaises(RuntimeError) as context: + PetscUtils.ThrowPetscError() + self.assertIn("PETSc returned error code", str(context.exception)) + if __name__ == "__main__": unittest.main() diff --git a/examples/shapes/src/cpp/math_funcs/ThrowingFunction.hpp b/examples/shapes/src/cpp/math_funcs/ThrowingFunction.hpp new file mode 100644 index 0000000..52ac1ae --- /dev/null +++ b/examples/shapes/src/cpp/math_funcs/ThrowingFunction.hpp @@ -0,0 +1,53 @@ +#ifndef _THROWING_FUNCTION_HPP +#define _THROWING_FUNCTION_HPP + +#include + +/** + * A simple exception type that does not derive from std::exception. It is + * listed under the package's exceptions option, so cppwg generates a translator + * that surfaces its GetMessage() text as a Python error. + */ +class ShapeException +{ +public: + explicit ShapeException(const std::string& rMessage) : mMessage(rMessage) + { + } + + std::string GetMessage() const + { + return mMessage; + } + +private: + std::string mMessage; +}; + +/** + * Throw a ShapeException. Used to test that C++ exceptions surface as Python + * exceptions rather than crashing the interpreter. + */ +inline void throw_exception() +{ + throw ShapeException("C++ exception thrown"); +} + +/** + * An exception type that is not listed in the package's exceptions option, so + * no translator is generated for it. + */ +class UnwrappedException +{ +}; + +/** + * Throw an UnwrappedException. Used to test that exceptions without a + * configured translator are not silently ignored. + */ +inline void throw_unwrapped_exception() +{ + throw UnwrappedException(); +} + +#endif // _THROWING_FUNCTION_HPP diff --git a/examples/shapes/src/py/tests/test_functions.py b/examples/shapes/src/py/tests/test_functions.py index 47db051..f146243 100644 --- a/examples/shapes/src/py/tests/test_functions.py +++ b/examples/shapes/src/py/tests/test_functions.py @@ -10,6 +10,22 @@ def testAdd(self): c = math_funcs.add(4, 5) self.assertTrue(c == a + b) + def testExceptionTranslation(self): + # The C++ function throws a ShapeException (which does not derive from + # std::exception). The registered exception translator should surface it + # as a Python RuntimeError rather than crashing the interpreter. + with self.assertRaises(RuntimeError) as context: + math_funcs.throw_exception() + self.assertEqual(str(context.exception), "C++ exception thrown") + + def testUnwrappedException(self): + # throw_unwrapped_exception raises a C++ type that is not listed under + # `exceptions`, so cppwg generates no translator for it. pybind11 still + # surfaces it as a generic RuntimeError rather than letting it escape. + with self.assertRaises(RuntimeError) as context: + math_funcs.throw_unwrapped_exception() + self.assertEqual(str(context.exception), "Caught an unknown exception!") + if __name__ == "__main__": unittest.main() diff --git a/examples/shapes/wrapper/geometry/_pyshapes_geometry.main.cppwg.cpp b/examples/shapes/wrapper/geometry/_pyshapes_geometry.main.cppwg.cpp index 6d7d31b..c032fa0 100644 --- a/examples/shapes/wrapper/geometry/_pyshapes_geometry.main.cppwg.cpp +++ b/examples/shapes/wrapper/geometry/_pyshapes_geometry.main.cppwg.cpp @@ -10,6 +10,14 @@ namespace py = pybind11; PYBIND11_MODULE(_pyshapes_geometry, m) { + py::register_exception_translator([](std::exception_ptr p) { + try { + if (p) std::rethrow_exception(p); + } catch (const ShapeException& e) { + PyErr_SetString(PyExc_RuntimeError, e.GetMessage().c_str()); + } + }); + register_Point_2_class(m); register_Point_3_class(m); } diff --git a/examples/shapes/wrapper/math_funcs/_pyshapes_math_funcs.main.cppwg.cpp b/examples/shapes/wrapper/math_funcs/_pyshapes_math_funcs.main.cppwg.cpp index b9367a1..6d5c0df 100644 --- a/examples/shapes/wrapper/math_funcs/_pyshapes_math_funcs.main.cppwg.cpp +++ b/examples/shapes/wrapper/math_funcs/_pyshapes_math_funcs.main.cppwg.cpp @@ -8,5 +8,15 @@ namespace py = pybind11; PYBIND11_MODULE(_pyshapes_math_funcs, m) { + py::register_exception_translator([](std::exception_ptr p) { + try { + if (p) std::rethrow_exception(p); + } catch (const ShapeException& e) { + PyErr_SetString(PyExc_RuntimeError, e.GetMessage().c_str()); + } + }); + m.def("add", &add, " ", py::arg("i") = 1.0, py::arg("j") = 2.0); + m.def("throw_exception", &throw_exception, " "); + m.def("throw_unwrapped_exception", &throw_unwrapped_exception, " "); } diff --git a/examples/shapes/wrapper/package_info.yaml b/examples/shapes/wrapper/package_info.yaml index 9a6c7e1..f0c0d27 100644 --- a/examples/shapes/wrapper/package_info.yaml +++ b/examples/shapes/wrapper/package_info.yaml @@ -20,6 +20,14 @@ source_includes: # Exclude default arguments from wrapped methods. 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: ShapeException + message_method: GetMessage + # Signature/replacement settings for explicit template instantiations. template_substitutions: - signature: diff --git a/examples/shapes/wrapper/primitives/_pyshapes_primitives.main.cppwg.cpp b/examples/shapes/wrapper/primitives/_pyshapes_primitives.main.cppwg.cpp index f76918f..b83622b 100644 --- a/examples/shapes/wrapper/primitives/_pyshapes_primitives.main.cppwg.cpp +++ b/examples/shapes/wrapper/primitives/_pyshapes_primitives.main.cppwg.cpp @@ -12,6 +12,14 @@ namespace py = pybind11; PYBIND11_MODULE(_pyshapes_primitives, m) { + py::register_exception_translator([](std::exception_ptr p) { + try { + if (p) std::rethrow_exception(p); + } catch (const ShapeException& e) { + PyErr_SetString(PyExc_RuntimeError, e.GetMessage().c_str()); + } + }); + register_Shape_2_class(m); register_Shape_3_class(m); register_Rectangle_class(m); diff --git a/examples/shapes/wrapper/wrapper_header_collection.cppwg.hpp b/examples/shapes/wrapper/wrapper_header_collection.cppwg.hpp index b4694d9..50a1f26 100644 --- a/examples/shapes/wrapper/wrapper_header_collection.cppwg.hpp +++ b/examples/shapes/wrapper/wrapper_header_collection.cppwg.hpp @@ -11,6 +11,7 @@ #include "Shape.hpp" #include "SimpleMathFunctions.hpp" #include "Square.hpp" +#include "ThrowingFunction.hpp" #include "Triangle.hpp" // Instantiate Template Classes diff --git a/tests/test_package_info_parser.py b/tests/test_package_info_parser.py new file mode 100644 index 0000000..76a3a5b --- /dev/null +++ b/tests/test_package_info_parser.py @@ -0,0 +1,59 @@ +"""Unit tests for cppwg.parsers.package_info_parser.""" + +import os +import textwrap + +from cppwg.parsers.package_info_parser import PackageInfoParser + + +def _write_config(tmp_path, body): + """Write a package info yaml file and return its path.""" + config_path = os.path.join(tmp_path, "package_info.yaml") + with open(config_path, "w") as config_file: + config_file.write(textwrap.dedent(body)) + return config_path + + +def test_parses_explicit_free_function_list(tmp_path): + """An explicit free_functions list is parsed without error. + + Regression test: the parser previously raised KeyError('name') for any + explicitly listed free function, so the explicit free_functions path always + crashed before reaching the C++ source. + """ + config_path = _write_config( + tmp_path, + """ + name: testpkg + modules: + - name: mymod + free_functions: + - name: my_func + """, + ) + + package_info = PackageInfoParser(config_path, str(tmp_path)).parse() + + module_info = package_info.module_collection[0] + assert module_info.use_all_free_functions is False + assert [ff.name for ff in module_info.free_function_collection] == ["my_func"] + + +def test_parses_all_free_functions_option(tmp_path): + """The CPPWG_ALL free_functions option sets use_all_free_functions.""" + config_path = _write_config( + tmp_path, + """ + name: testpkg + modules: + - name: mymod + free_functions: CPPWG_ALL + """, + ) + + package_info = PackageInfoParser(config_path, str(tmp_path)).parse() + + module_info = package_info.module_collection[0] + assert module_info.use_all_free_functions is True + # Discovery happens later from the parsed source, so none are added yet. + assert module_info.free_function_collection == []