diff --git a/DOCS.md b/DOCS.md index 74f8795..2d25ce0 100644 --- a/DOCS.md +++ b/DOCS.md @@ -168,6 +168,19 @@ The python wrapper supports keyword arguments for functions/methods. Hence, the template class Class2 { ... }; typedef Class2 MyInstantiatedClass; ``` + - Serialization can be enabled for one typedef without marking every + specialization of the template serializable: + + ```cpp + template class Class3 { ... }; + @serializable + typedef Class3 SerializableClass3; + typedef Class3 PlainClass3; + ``` + + `@serializable` is wrapper metadata. It generates the same Python pickle + and MATLAB save/load support as a `void serialize() const;` marker on a + concrete wrapper class, but applies only to the annotated typedef. - Templates can also be defined for constructors, methods, properties and static methods. - In the class definition, appearances of the template argument(s) will be replaced with their instantiated types, e.g. `void setValue(const T& value);`. diff --git a/gtwrap/interface_parser/annotations.py b/gtwrap/interface_parser/annotations.py index f23de16..14798cc 100644 --- a/gtwrap/interface_parser/annotations.py +++ b/gtwrap/interface_parser/annotations.py @@ -1,4 +1,4 @@ -"""Pybind-specific annotations supported by wrapper interface files.""" +"""Annotations supported by wrapper interface files.""" from pyparsing import Regex @@ -12,21 +12,36 @@ def _reject_annotation(source, location, tokens): """Raise a useful error for unknown or misplaced annotations.""" annotation = tokens[0] + context = "callable annotation" if annotation == "@pybind_lambda": message = ( "annotation '@pybind_lambda' can only be applied to a method, " "static method, or global function" ) + hint = ( + "place '@pybind_lambda' after any template declaration and " + "immediately before the callable declaration" + ) + elif annotation == "@serializable": + context = "typedef annotation" + message = ( + "annotation '@serializable' can only be applied to a template " + "typedef" + ) + hint = "place '@serializable' immediately before the typedef declaration" else: message = f"malformed or unknown annotation '{annotation}'" + hint = ( + "use a supported annotation such as '@pybind_lambda' or " + "'@serializable' in its documented position" + ) raise semantic_error( source, location, - "callable annotation", + context, message, - "place '@pybind_lambda' after any template declaration and " - "immediately before the callable declaration", + hint, ) diff --git a/gtwrap/interface_parser/template.py b/gtwrap/interface_parser/template.py index 3c072fa..0db3cad 100644 --- a/gtwrap/interface_parser/template.py +++ b/gtwrap/interface_parser/template.py @@ -12,13 +12,16 @@ from typing import List -from pyparsing import Optional, ParseResults, DelimitedList # type: ignore +from pyparsing import DelimitedList, Optional, ParseResults, Regex # type: ignore from .tokens import (EQUAL, IDENT, LBRACE, LOPBRACK, RBRACE, ROPBRACK, SEMI_COLON, TEMPLATE, TYPEDEF) from .type import TemplatedType, Typename +SERIALIZABLE = Regex(r"@serializable(?![A-Za-z0-9_])") + + class Template: """ Rule to parse templated values in the interface file. @@ -83,17 +86,20 @@ class TypedefTemplateInstantiation: typedef SuperComplexName EasierName; ``` """ - rule = (TYPEDEF + TemplatedType.rule("templated_type") + + rule = (Optional(SERIALIZABLE("serializable")) + + TYPEDEF + TemplatedType.rule("templated_type") + IDENT("new_name") + SEMI_COLON).set_parse_action(lambda t: TypedefTemplateInstantiation( - t.templated_type[0], t.new_name)) + t.templated_type[0], t.new_name, bool(t.serializable))) def __init__(self, templated_type: TemplatedType, new_name: str, + serializable: bool = False, parent: str = ''): self.typename = templated_type.typename self.new_name = new_name + self.serializable = serializable self.parent = parent def __repr__(self): diff --git a/gtwrap/template_instantiator/classes.py b/gtwrap/template_instantiator/classes.py index 3e66f94..7cc89e9 100644 --- a/gtwrap/template_instantiator/classes.py +++ b/gtwrap/template_instantiator/classes.py @@ -17,13 +17,18 @@ class InstantiatedClass(parser.Class): Instantiate the class defined in the interface file. """ - def __init__(self, original: parser.Class, instantiations=(), new_name=''): + def __init__(self, + original: parser.Class, + instantiations=(), + new_name='', + serializable=False): """ Template Instantiations: [T1, U1] """ self.original = original self.instantiations = instantiations + self.serializable = serializable self.template = None self.is_virtual = original.is_virtual @@ -58,6 +63,12 @@ def __init__(self, original: parser.Class, instantiations=(), new_name=''): # Instantiate all instance methods self.methods = self.instantiate_methods(typenames) + if serializable and not any( + method.name in ('serialize', 'serializable') + for method in self.methods): + self.methods.append( + parser.Method.rule.parse_string( + "void serialize() const;")[0]) self.dunder_methods = original.dunder_methods diff --git a/gtwrap/template_instantiator/namespace.py b/gtwrap/template_instantiator/namespace.py index 32ba0b9..fc32e2d 100644 --- a/gtwrap/template_instantiator/namespace.py +++ b/gtwrap/template_instantiator/namespace.py @@ -64,7 +64,8 @@ def instantiate_namespace(namespace): typedef_content.append( InstantiatedClass(original_element, typedef_inst.typename.instantiations, - typedef_inst.new_name)) + typedef_inst.new_name, + typedef_inst.serializable)) elif isinstance(original_element, parser.GlobalFunction): typedef_content.append( InstantiatedGlobalFunction( diff --git a/tests/fixtures/serializable_typedef.i b/tests/fixtures/serializable_typedef.i new file mode 100644 index 0000000..315bf05 --- /dev/null +++ b/tests/fixtures/serializable_typedef.i @@ -0,0 +1,12 @@ +namespace gtsam { + +template +class SerializableTypedefFixture { + SerializableTypedefFixture(); +}; + +@serializable +typedef gtsam::SerializableTypedefFixture SerializableFixture; +typedef gtsam::SerializableTypedefFixture PlainFixture; + +} // namespace gtsam diff --git a/tests/test_interface_parser.py b/tests/test_interface_parser.py index c024083..95df5b0 100644 --- a/tests/test_interface_parser.py +++ b/tests/test_interface_parser.py @@ -408,6 +408,14 @@ def test_typedef_template_instantiation(self): self.assertEqual("BearingFactor", typedef.typename.name) self.assertEqual(["gtsam"], typedef.typename.namespaces) self.assertEqual(3, len(typedef.typename.instantiations)) + self.assertFalse(typedef.serializable) + + serializable = TypedefTemplateInstantiation.rule.parse_string(""" + @serializable + typedef gtsam::BearingFactor SerializableBearingFactor2D; + """)[0] + self.assertTrue(serializable.serializable) def test_base_class(self): """Test a base class.""" diff --git a/tests/test_matlab_wrapper.py b/tests/test_matlab_wrapper.py index f30da07..d7b3330 100644 --- a/tests/test_matlab_wrapper.py +++ b/tests/test_matlab_wrapper.py @@ -82,6 +82,36 @@ def test_geometry(self): actual = osp.join(self.MATLAB_ACTUAL_DIR, file) self.compare_and_diff(file, actual) + def test_serializable_template_typedef(self): + """Serialization metadata applies to one MATLAB typedef only.""" + source = osp.join(self.INTERFACE_DIR, 'serializable_typedef.i') + wrapper = MatlabWrapper(module_name='serializable_typedef', + top_module_namespace=['gtsam'], + ignore_classes=[''], + use_boost_serialization=True) + wrapper.wrap([source], path=self.MATLAB_ACTUAL_DIR) + + with open(osp.join(self.MATLAB_ACTUAL_DIR, '+gtsam', + 'SerializableFixture.m'), + 'r', encoding='UTF-8') as generated: + serializable = generated.read() + with open(osp.join(self.MATLAB_ACTUAL_DIR, '+gtsam', 'PlainFixture.m'), + 'r', encoding='UTF-8') as generated: + plain = generated.read() + with open(osp.join(self.MATLAB_ACTUAL_DIR, + 'serializable_typedef_wrapper.cpp'), + 'r', encoding='UTF-8') as generated: + cpp = generated.read() + + self.assertIn('string_serialize', serializable) + self.assertIn('string_deserialize', serializable) + self.assertNotIn('string_serialize', plain) + self.assertNotIn('string_deserialize', plain) + self.assertIn( + 'BOOST_CLASS_EXPORT_GUID(SerializableFixture, ' + '"gtsamSerializableFixture")', cpp) + self.assertNotIn('BOOST_CLASS_EXPORT_GUID(PlainFixture', cpp) + def test_matrix_view_arguments(self): """Test that matrix view arguments use MATLAB double arrays directly.""" file = osp.join(self.INTERFACE_DIR, 'matrix_views.i') diff --git a/tests/test_parser_diagnostics.py b/tests/test_parser_diagnostics.py index 30d4838..4ff2d76 100644 --- a/tests/test_parser_diagnostics.py +++ b/tests/test_parser_diagnostics.py @@ -124,6 +124,20 @@ def test_misplaced_callable_annotation(self): ) self.assertIn("immediately before the callable", error.hint) + def test_misplaced_serializable_annotation(self): + """The serialization annotation applies only to template typedefs.""" + error = self.assert_parse_error( + "@serializable class Foo {};", + line=1, + column=1, + context="typedef annotation", + expected=( + "annotation '@serializable' can only be applied to a template " + "typedef" + ), + ) + self.assertIn("immediately before the typedef", error.hint) + def test_misplaced_annotation_after_template(self): self.assert_parse_error( "class Foo { template @pybind_lambda Foo(T value); };", diff --git a/tests/test_pybind_wrapper.py b/tests/test_pybind_wrapper.py index 6a0407a..2d3c4dc 100644 --- a/tests/test_pybind_wrapper.py +++ b/tests/test_pybind_wrapper.py @@ -114,6 +114,26 @@ def test_geometry(self): self.compare_and_diff('geometry_pybind.cpp', output) + def test_serializable_template_typedef(self): + """Serialization metadata applies to one template typedef only.""" + source = osp.join(self.INTERFACE_DIR, 'serializable_typedef.i') + output = self.wrap_content([source], + 'serializable_typedef_py', + self.PYTHON_ACTUAL_DIR, + use_boost_serialization=True) + with open(output, 'r', encoding='UTF-8') as generated: + content = generated.read() + + self.assertEqual(1, content.count('.def("serialize"')) + self.assertEqual(1, content.count('.def("deserialize"')) + self.assertEqual(1, content.count('.def(py::pickle(')) + self.assertIn( + 'BOOST_CLASS_EXPORT(gtsam::SerializableTypedefFixture)', + content) + self.assertNotIn( + 'BOOST_CLASS_EXPORT(gtsam::SerializableTypedefFixture)', + content) + def test_functions(self): """Test interface file with function info.""" source = osp.join(self.INTERFACE_DIR, 'functions.i') diff --git a/tests/test_template_instantiator.py b/tests/test_template_instantiator.py index 8d8bf8f..3a35201 100644 --- a/tests/test_template_instantiator.py +++ b/tests/test_template_instantiator.py @@ -645,6 +645,35 @@ class Adapter { if isinstance(item, Class) and item.name == "IntAdapter") self.assertTrue(typedef_class.methods[0].force_pybind_lambda) + def test_serializable_typedef_adds_only_alias_marker(self): + """Serialization metadata applies only to the annotated typedef.""" + module = Module.parse_string(""" + namespace adapters { + template + class Adapter { + Adapter(); + }; + @serializable + typedef adapters::Adapter SerializableAdapter; + typedef adapters::Adapter PlainAdapter; + } + """) + + instantiated = instantiate_namespace(module) + namespace = instantiated.content[0] + serializable = next( + item for item in namespace.content + if isinstance(item, Class) and item.name == "SerializableAdapter") + plain = next( + item for item in namespace.content + if isinstance(item, Class) and item.name == "PlainAdapter") + + self.assertTrue(serializable.serializable) + self.assertEqual(["serialize"], + [method.name for method in serializable.methods]) + self.assertFalse(plain.serializable) + self.assertEqual([], plain.methods) + if __name__ == '__main__': unittest.main()