diff --git a/README.md b/README.md index 0473380..78d8120 100644 --- a/README.md +++ b/README.md @@ -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. 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..5b2e078 100644 --- a/cppwg/info/package_info.py +++ b/cppwg/info/package_info.py @@ -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 @@ -18,6 +20,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 +32,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 +57,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 +69,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 +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), + } + ) 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/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..1c50220 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("ThrowException", + (void(*)()) &PetscUtils::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..ae2cd69 100644 --- a/examples/cells/src/cpp/utils/PetscUtils.cpp +++ b/examples/cells/src/cpp/utils/PetscUtils.cpp @@ -8,6 +8,8 @@ #include +#include "SimulationException.hpp" + void PetscUtils::Initialise() { if (!PetscUtils::IsInitialised()) @@ -64,3 +66,8 @@ Vec PetscUtils::CreateVec(int size) VecSetFromOptions(v); return v; } + +void PetscUtils::ThrowException() +{ + throw SimulationException("C++ exception thrown", __FILE__, __LINE__); +} diff --git a/examples/cells/src/cpp/utils/PetscUtils.hpp b/examples/cells/src/cpp/utils/PetscUtils.hpp index d6e89bb..e349953 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 SimulationException, to test exception translation. */ + static void ThrowException(); }; #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..dd81e32 --- /dev/null +++ b/examples/cells/src/cpp/utils/SimulationException.hpp @@ -0,0 +1,45 @@ +#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), mShortMessage(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; + } + + /** @return just the text of the message. */ + std::string GetShortMessage() const + { + return mShortMessage; + } + +private: + std::string mMessage; /**< Full message, including file and line number. */ + std::string mShortMessage; /**< Just the text of the message. */ +}; + +#endif // SIMULATIONEXCEPTION_HPP_ diff --git a/examples/cells/tests/test_cells.py b/examples/cells/tests/test_cells.py index 1e2e727..28cb47b 100644 --- a/examples/cells/tests/test_cells.py +++ b/examples/cells/tests/test_cells.py @@ -23,6 +23,20 @@ def testUblasCaster(self): node.Translate([1, 1]) self.assertEqual(list(node.GetLocation()), [1, 1]) + def testExceptionTranslation(self): + # 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: + PetscUtils.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("PetscUtils.cpp:", message) + 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..fc56681 --- /dev/null +++ b/examples/shapes/src/cpp/math_funcs/ThrowingFunction.hpp @@ -0,0 +1,38 @@ +#ifndef _THROWING_FUNCTION_HPP +#define _THROWING_FUNCTION_HPP + +#include + +/** + * A simple exception type that does NOT derive from std::exception. + * + * pybind11 cannot translate this automatically, so without a registered + * exception translator it would terminate the Python interpreter. It is used + * to exercise the package's exceptions option. + */ +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"); +} + +#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..b3a99ac 100644 --- a/examples/shapes/src/py/tests/test_functions.py +++ b/examples/shapes/src/py/tests/test_functions.py @@ -10,6 +10,14 @@ 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") + 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..9cb5ccf 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,14 @@ 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, " "); } 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 == []