diff --git a/autowrap/CodeGenerator.py b/autowrap/CodeGenerator.py index de227ac..7c2ddf6 100644 --- a/autowrap/CodeGenerator.py +++ b/autowrap/CodeGenerator.py @@ -282,6 +282,16 @@ def create_pyx_file(self, debug: bool = False) -> None: self.create_scoped_enum_helpers() self.create_includes() + # Pre-compute which classes have wrap-view enabled + # This is needed before class generation so that forward references work + # (e.g., Container.getItem() returns Item& where Item has wrap-view) + # Use all_resolved to support cross-module wrap-view detection + self.classes_with_views: set = set() + for resolved in self.all_resolved: + if isinstance(resolved, ResolvedClass) and not resolved.wrap_ignore: + if resolved.wrap_view: + self.classes_with_views.add(resolved.name) + def create_for( clazz: Type[ResolvedDecl], method: Callable[[ResolvedDecl, Union[CodeDict, Tuple[CodeDict, CodeDict]]], None], @@ -819,17 +829,21 @@ def create_wrapper_for_class(self, r_class: ResolvedClass, out_codes: CodeDict) inherited_methods = {} for name, methods in non_iter_methods.items(): + # Filter out wrap-ignore methods for main class generation + non_ignored_methods = [m for m in methods if not m.wrap_ignore] + if not non_ignored_methods: + continue if name == r_class.name: # Constructor always goes first - codes, stub_code = self.create_wrapper_for_constructor(r_class, methods) + codes, stub_code = self.create_wrapper_for_constructor(r_class, non_ignored_methods) cons_created = True typestub_code.add(stub_code) for ci in codes: class_code.add(ci) elif name in inherited_method_bases: - inherited_methods[name] = methods + inherited_methods[name] = non_ignored_methods else: - class_methods[name] = methods + class_methods[name] = non_ignored_methods # Generate class-defined methods first (sorted alphabetically) for name, methods in sorted(class_methods.items()): @@ -870,6 +884,10 @@ def create_wrapper_for_class(self, r_class: ResolvedClass, out_codes: CodeDict) if extra_methods_code: class_code.add(extra_methods_code) + # Generate view class if wrap-view annotation is present + if r_class.wrap_view: + self._create_view_class(r_class, class_code, class_pxd_code, typestub_code, out_codes) + for class_name in r_class.cpp_decl.annotations.get("wrap-attach", []): code = Code() display_name = r_class.cpp_decl.annotations.get("wrap-as", [r_class.name])[0] @@ -878,6 +896,494 @@ def create_wrapper_for_class(self, r_class: ResolvedClass, out_codes: CodeDict) tmp.append(code) self.class_codes_extra[class_name] = tmp + def _create_view_class( + self, + r_class: ResolvedClass, + main_class_code: Code, + main_class_pxd_code: Code, + main_typestub_code: Code, + out_codes: CodeDict, + ) -> None: + """Generate a companion View class for in-place member access. + + The view class holds a shared_ptr to the parent object, keeping it alive + while the view exists. This allows direct modification of members without + creating copies. + + Args: + r_class: The resolved class to generate a view for + main_class_code: Code object for the main class (to add view() method) + main_class_pxd_code: PXD code for the main class + main_typestub_code: Type stub code for the main class + out_codes: Dictionary to store generated code + """ + cname = r_class.name + cy_type = self.cr.cython_type(cname) + view_name = cname + "View" + + L.info(" create view class %s for %s" % (view_name, cname)) + + # Add view() method to the main class + main_class_code.add( + """ + | + | def view(self): + | \"\"\" + | Return a view for in-place member modification. + | + | The view holds a reference to this object and allows direct + | modification of members without creating copies. The view + | keeps this object alive as long as the view exists. + | + | Returns: + | $view_name: A view instance for in-place access + | \"\"\" + | cdef $view_name v = $view_name.__new__($view_name) + | v._ptr = self.inst.get() + | v._parent = self + | return v + | + """, + locals(), + ) + + # Add view() to type stubs + main_typestub_code.add( + """ + | def view(self) -> $view_name: ... + """, + locals(), + ) + + # Create the view class + view_pxd_code = Code() + view_code = Code() + view_typestub_code = Code() + + # View class docstring + view_docstring = ( + "View class for in-place modification of %s members.\n\n" + " This class holds a reference to a %s instance and provides\n" + " direct access to its members without creating copies. Changes\n" + " made through the view are reflected in the original object.\n\n" + " The view keeps the parent object alive via shared_ptr." + ) % (cname, cname) + + # Generate PXD declaration for view class + if self.write_pxd: + view_pxd_code.add( + """ + | + |cdef class $view_name: + | \"\"\" + | $view_docstring + | \"\"\" + | cdef $cy_type* _ptr + | cdef object _parent + | + """, + locals(), + ) + + # Generate PYX implementation for view class + pxd_comment = ( + "# see .pxd file for cdef of _ptr and _parent" + if self.write_pxd + else "cdef %s* _ptr\n cdef object _parent" % cy_type + ) + + view_code.add( + """ + | + |cdef class $view_name: + | \"\"\" + | $view_docstring + | \"\"\" + | + | $pxd_comment + | + | def __repr__(self): + | return "<%s view of %s at 0x%x>" % ( + | type(self).__name__, + | "$cname", + | self._ptr + | ) + | + | @property + | def _is_valid(self): + | \"\"\"Check if the view is still valid (parent not deallocated).\"\"\" + | return self._ptr != NULL + | + """, + locals(), + ) + + # Generate type stub for view class + view_typestub_code.add( + """ + | + |class $view_name: + | \"\"\" + | $view_docstring + | \"\"\" + | @property + | def _is_valid(self) -> bool: ... + """, + locals(), + ) + + # Generate view properties for each attribute + for attribute in r_class.attributes: + if not attribute.wrap_ignore: + try: + pyx_code, stub_code = self._create_view_property_for_attribute( + attribute, cname, cy_type + ) + view_code.add(pyx_code) + view_typestub_code.add(stub_code) + except Exception as e: + raise Exception( + f"Failed to create view property for attribute " + f"{attribute.cpp_decl.name}" + ) from e + + # Generate view methods for methods returning mutable references + for method_name, methods in r_class.methods.items(): + # Skip constructors (same name as class) + if method_name == cname: + continue + for method in methods: + # For view classes, we still generate methods for T& returns + # even if wrap-ignore is set (since T& returns don't work on + # main classes but DO work on view classes through pointers) + # Check if this method returns a mutable reference to a view-enabled class + if self._should_generate_view_method(method): + try: + pyx_code, stub_code = self._create_view_method( + r_class, method, cname, cy_type + ) + view_code.add(pyx_code) + view_typestub_code.add(stub_code) + except Exception as e: + raise Exception( + f"Failed to create view method for {method.name}" + ) from e + + # Store the view class codes + self.class_pxd_codes[view_name] = view_pxd_code + out_codes[view_name] = view_code + self.typestub_codes[view_name] = view_typestub_code + # Note: classes_with_views is pre-computed in create_pyx_file() + + def _create_view_property_for_attribute( + self, attribute, parent_class_name: str, parent_cy_type + ) -> Tuple[Code, Code]: + """Generate a view property for an attribute. + + For view classes, properties provide in-place access to the parent's members. + - Primitive types: direct read/write access + - Wrapped class types with wrap-view: return nested view + - Wrapped class types without wrap-view: return copy (like main class) + + Args: + attribute: The ResolvedAttribute to create a property for + parent_class_name: Name of the parent class (for nested views) + parent_cy_type: Cython type of the parent class + + Returns: + Tuple of (pyx_code, stub_code) for the property + """ + code = Code() + stubs = Code() + name = attribute.name + wrap_as = attribute.cpp_decl.annotations.get("wrap-as", name) + + # Check if this is a constant (read-only) attribute + if attribute.type_.is_const: + wrap_constant = True + else: + wrap_constant = attribute.cpp_decl.annotations.get("wrap-constant", False) + + t = attribute.type_ + converter = self.cr.get(t) + py_type = converter.matching_python_type(t) + py_typing_type = converter.matching_python_type_full(t) + + # Check if the attribute type is a wrapped class with wrap-view + attr_base_type = t.base_type + attr_has_view = ( + hasattr(self, 'classes_with_views') and + attr_base_type in self.classes_with_views + ) + + code.add( + """ + | + |property $wrap_as: + | \"\"\"In-place access to $name attribute.\"\"\" + """, + locals(), + ) + + # For type stubs, use View type if attribute has wrap-view enabled + stub_type = py_typing_type + if attr_has_view and not t.is_ptr: + stub_type = attr_base_type + "View" + stubs.add( + """ + | + |$wrap_as: $stub_type + """, + locals(), + ) + + # Generate setter + if wrap_constant: + code.add( + """ + | def __set__(self, $py_type $name): + | raise AttributeError("Cannot set constant") + """, + locals(), + ) + else: + conv_code, call_as, cleanup = converter.input_conversion(t, name, 0) + + code.add( + """ + | def __set__(self, $py_type $name): + """, + locals(), + ) + + indented = Code() + indented.add(conv_code) + code.add(indented) + + # Use _ptr instead of inst for view classes + code.add( + """ + | self._ptr.$name = $call_as + """, + locals(), + ) + + indented = Code() + if isinstance(cleanup, (str, bytes)): + cleanup = " %s" % cleanup + indented.add(cleanup) + code.add(indented) + + # Generate getter + if attr_has_view and not t.is_ptr: + # Return a nested view for wrapped classes with wrap-view + view_type = attr_base_type + "View" + nested_cy_type = self.cr.cython_type(attr_base_type) + + code.add( + """ + | + | def __get__(self): + | # Return a view of the nested object for in-place access + | cdef $view_type v = $view_type.__new__($view_type) + | v._ptr = &self._ptr.$name + | v._parent = self._parent # Propagate parent reference to keep alive + | return v + """, + locals(), + ) + else: + # Return a copy (same behavior as main class) for: + # - Primitive types + # - Wrapped classes without wrap-view + # - Pointer types + to_py_code = converter.output_conversion(t, "_r", "py_result") + access_stmt = converter.call_method(t, "self._ptr.%s" % name, False) + + if isinstance(to_py_code, (str, bytes)): + to_py_code = " %s" % to_py_code + + if isinstance(access_stmt, (str, bytes)): + access_stmt = " %s" % access_stmt + + if t.is_ptr: + code.add( + """ + | + | def __get__(self): + | if self._ptr.%s is NULL: + | raise Exception("Cannot access pointer that is NULL") + """ + % name, + locals(), + ) + else: + code.add( + """ + | + | def __get__(self): + """, + locals(), + ) + + indented = Code() + indented.add(access_stmt) + indented.add(to_py_code) + code.add(indented) + code.add(" return py_result") + + return code, stubs + + def _should_generate_view_method(self, method) -> bool: + """Check if a method should have a view-returning version generated. + + A view method is generated when: + 1. The return type is a mutable reference (T&, not const T&) + 2. The referenced type has wrap-view enabled + + Args: + method: The ResolvedMethod to check + + Returns: + True if a view method should be generated + """ + res_t = method.result_type + + # Must be a reference return + if not res_t.is_ref: + return False + + # Must not be const + if res_t.is_const: + return False + + # The base type must have wrap-view enabled + base_type = res_t.base_type + if not hasattr(self, 'classes_with_views'): + return False + + return base_type in self.classes_with_views + + def _create_view_method( + self, r_class, method, parent_class_name: str, parent_cy_type + ) -> Tuple[Code, Code]: + """Generate a view-returning method for the view class. + + For methods that return T& (mutable reference) where T has wrap-view, + generate a method that returns a view instead of a copy. + + Args: + r_class: The resolved class containing the method + method: The ResolvedMethod to create a view method for + parent_class_name: Name of the parent class + parent_cy_type: Cython type of the parent class + + Returns: + Tuple of (pyx_code, stub_code) for the method + """ + code = Code() + stubs = Code() + + py_name = method.cpp_decl.annotations.get("wrap-as", method.name) + cpp_name = method.cpp_decl.name + res_t = method.result_type + return_base_type = res_t.base_type + view_type = return_base_type + "View" + return_cy_type = self.cr.cython_type(return_base_type) + + # Build argument list + arg_names = [] + arg_types = [] + arg_conversions = Code() + call_args = [] + + for arg_name, arg_type in method.arguments: + converter = self.cr.get(arg_type) + py_type = converter.matching_python_type(arg_type) + arg_names.append(arg_name) + arg_types.append(py_type) + + # Generate input conversion + conv_code, call_as, cleanup = converter.input_conversion( + arg_type, arg_name, len(call_args) + ) + if conv_code: + arg_conversions.add(conv_code) + call_args.append(call_as) + + # Build function signature + if arg_names: + args_str = ", ".join( + f"{py_type} {name}" for name, py_type in zip(arg_names, arg_types) + ) + signature = f"def {py_name}(self, {args_str}):" + else: + signature = f"def {py_name}(self):" + + call_args_str = ", ".join(call_args) + + # Generate docstring + orig_signature = str(method) + docstring = f"View-returning wrapper for {cpp_name}.\n\n" + docstring += f" Returns a view for in-place modification instead of a copy.\n" + docstring += f" Original C++ signature: {orig_signature}" + + # Note: No leading indentation here - the Code class adds 4 spaces + # when this Code object is added to the parent view_code + code.add( + """ + | + |$signature + | \"\"\" + | $docstring + | \"\"\" + """, + locals(), + ) + + # Add input conversions + if arg_conversions.content: + code.add(arg_conversions) + + # Generate the view-returning code + # Note: C++ returns T&, we take address to get pointer + code.add( + """ + | # Call C++ method and wrap result in view + | cdef $view_type v = $view_type.__new__($view_type) + | v._ptr = &self._ptr.$cpp_name($call_args_str) + | v._parent = self._parent # Propagate parent reference + | return v + """, + locals(), + ) + + # Generate type stub + # Build stub argument list + stub_args = [] + for arg_name, arg_type in method.arguments: + converter = self.cr.get(arg_type) + py_typing_type = converter.matching_python_type_full(arg_type) + stub_args.append(f"{arg_name}: {py_typing_type}") + + if stub_args: + stub_args_str = ", ".join(stub_args) + stubs.add( + """ + |def $py_name(self, $stub_args_str) -> $view_type: ... + """, + locals(), + ) + else: + stubs.add( + """ + |def $py_name(self) -> $view_type: ... + """, + locals(), + ) + + return code, stubs + def _create_iter_methods(self, iterators, instance_mapping, local_mapping): """ Create Iterator methods using the Python yield keyword @@ -2230,6 +2736,7 @@ def create_default_cimports(self): |from libcpp.string_view cimport string_view as libcpp_string_view |from libcpp cimport bool |from libc.string cimport const_char, memcpy + |from libc.stdint cimport uintptr_t |from cython.operator cimport dereference as deref, + preincrement as inc, address as address """ diff --git a/autowrap/DeclResolver.py b/autowrap/DeclResolver.py index 5f78b68..cc75978 100644 --- a/autowrap/DeclResolver.py +++ b/autowrap/DeclResolver.py @@ -76,6 +76,10 @@ class is the same as the name of the C++ class. There are a few additional hints you can give to the wrapper, for classes these are: - wrap-ignore: will not create a wrapper for this class (e.g. abstract base class that needs to be known to Cython but cannot be wrapped) + - wrap-view: generate a companion ${ClassName}View class that provides + in-place access to members. The view class allows direct + modification of nested objects without copies. Methods + returning T& (mutable reference) return views instead of copies. - wrap-manual-memory: will allow the user to provide manual memory management of self.inst, therefore the class will not provide the automated __dealloc__ and inst @@ -173,6 +177,7 @@ class ResolvedClass(object): cpp_decl: PXDParser.CppClassDecl ns: AnyStr wrap_ignore: bool + wrap_view: bool no_pxd_import: bool wrap_manual_memory: Union[bool, List[AnyStr]] wrap_hash: List[AnyStr] @@ -193,6 +198,7 @@ def __init__(self, name, methods, attributes, decl, instance_map, local_map): self.ns = get_namespace(decl.pxd_path, DEFAULT_NAMESPACE) self.wrap_ignore = decl.annotations.get("wrap-ignore", False) + self.wrap_view = decl.annotations.get("wrap-view", False) self.no_pxd_import = decl.annotations.get("no-pxd-import", False) self.wrap_manual_memory = decl.annotations.get("wrap-manual-memory", []) # fix previous code where we had a bool ... @@ -560,8 +566,14 @@ def _resolve_class_decl(class_decl, typedef_mapping, i_mapping): for mname, mdcls in class_decl.methods.items(): for mdcl in mdcls: ignore = mdcl.annotations.get("wrap-ignore", False) + # Keep wrap-ignore methods that return mutable references (T&) + # since these are useful for wrap-view generation even when + # the main class method is ignored. Filter out other wrap-ignore methods. if ignore: - continue + result_type = mdcl.result_type + if not (result_type.is_ref and not result_type.is_const): + # Not a mutable reference return - skip as before + continue if mdcl.name == class_decl.name: r_method = _resolve_constructor(cinst_name, mdcl, i_mapping, local_mapping) else: diff --git a/docs/README.md b/docs/README.md index a35e1e8..dae589d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -159,6 +159,11 @@ and for methods by putting them on the same line. The currently supported directives are: - `wrap-ignore`: Will not create a wrapper for this function or class (e.g. abstract base class that needs to be known to Cython but cannot be wrapped) +- `wrap-view`: Generate a companion `${ClassName}View` class that provides in-place access to members. The main wrapper class returns copies (safe, but modifications don't affect the original). The view class holds a reference to the parent and allows direct modification: + - Public attributes become view properties (getters return views of nested objects, setters modify in-place) + - Methods returning `T&` (mutable reference) return views instead of copies (note: these methods need `wrap-ignore` on main class due to Cython limitations with reference returns) + - Views keep the parent object alive - chaining and storing views is safe + - Use `obj.view()` on the main class to get a view instance - `wrap-iter-begin`: For begin iterators - `wrap-iter-end`: For end iterators - `wrap-attach`: Attach to a specific class (can be used for static functions or nested classes) @@ -280,6 +285,56 @@ namespace std { } ``` +#### wrap-view Example + +The `wrap-view` directive generates a companion view class for in-place member modification: + +```cython + cdef cppclass Inner: + # wrap-view + int value + + cdef cppclass Outer: + # wrap-view + Inner inner_member # Public attribute + Inner & getInner() # wrap-ignore (T& returns need this) + const Inner & getConstInner() # wrap-ignore (const T& also needs this) +``` + +**Note:** Methods returning `T&` or `const T&` must be marked `wrap-ignore` on the main +class because Cython cannot properly handle reference return types. However, these methods +are still generated on the view class where they work correctly via pointer access. + +This generates `Inner`, `InnerView`, `Outer`, and `OuterView` classes. Usage: + +```python +>>> outer = Outer() +>>> outer.inner_member.value = 42 # Modifies a COPY - original unchanged! +>>> view = outer.view() +>>> view.inner_member.value = 42 # Modifies IN-PLACE - original changed! +>>> inner_view = view.getInner() # Returns InnerView, not copy +>>> inner_view.value = 100 # In-place modification +``` + +**Chaining:** Views can be chained in a single expression: + +```python +>>> container.view().getOuter().getInner().value = 999 +``` + +**Lifetime:** Views keep the root object alive. You can safely: +- Store views and use them later +- Delete intermediate views in a chain +- Even delete the original object - views hold a reference to keep it alive + +```python +>>> def get_deep_view(): +... c = Container() +... return c.view().getOuter().getInner() +>>> view = get_deep_view() # Container went out of scope but view keeps it alive +>>> view.value = 42 # Still works! +``` + ### Docstrings Docstrings can be added to classes and methods using the `wrap-doc` statement. Multi line docs are supported with diff --git a/tests/test_code_generator.py b/tests/test_code_generator.py index bfcf4bd..15548b8 100644 --- a/tests/test_code_generator.py +++ b/tests/test_code_generator.py @@ -1009,3 +1009,380 @@ def test_cross_module_scoped_enum_imports(tmpdir): finally: os.chdir(curdir) + + +def test_wrap_view_generates_view_class(): + """Test that wrap-view annotation generates a companion View class. + + Verifies: + 1. A View class is generated for the wrapped class + 2. The main class has a view() method + 3. The View class has the expected structure + """ + import tempfile + import shutil + from autowrap.CodeGenerator import CodeGenerator + from autowrap.DeclResolver import ResolvedClass + + # Create a temporary directory for generated files + test_dir = tempfile.mkdtemp() + try: + # Parse a class with wrap-view annotation + decls, instance_map = autowrap.DeclResolver.resolve_decls_from_string( + """ +cdef extern from "test.hpp": + cdef cppclass TestClass: + # wrap-view + int value + TestClass() + """ + ) + + # Find our test class + test_class = None + for d in decls: + if isinstance(d, ResolvedClass) and d.name == "TestClass": + test_class = d + break + + assert test_class is not None, "TestClass not found in decls" + assert test_class.wrap_view is True, "wrap_view should be True" + + # Generate code + target = os.path.join(test_dir, "test_view.pyx") + cg = CodeGenerator( + decls, + instance_map, + pyx_target_path=target, + ) + # Trigger code generation + cg.create_pyx_file() + + # Check that the view class was generated + assert "TestClassView" in cg.class_codes, ( + f"TestClassView should be in class_codes. Got keys: {list(cg.class_codes.keys())}" + ) + + # Render the main class code and check for view() method + main_class_code = cg.class_codes.get("TestClass") + assert main_class_code is not None, "TestClass code not found" + main_code_str = main_class_code.render() + assert "def view(self):" in main_code_str, ( + f"view() method should be in main class. Code:\n{main_code_str}" + ) + assert "TestClassView" in main_code_str, ( + f"TestClassView should be referenced in main class. Code:\n{main_code_str}" + ) + + # Render the view class code and check structure + view_class_code = cg.class_codes.get("TestClassView") + assert view_class_code is not None, "TestClassView code not found" + view_code_str = view_class_code.render() + + # Verify view class has expected structure + assert "cdef class TestClassView:" in view_code_str, ( + f"View class declaration not found. Code:\n{view_code_str}" + ) + assert "_ptr" in view_code_str, ( + f"_ptr should be in view class. Code:\n{view_code_str}" + ) + assert "_parent" in view_code_str, ( + f"_parent should be in view class. Code:\n{view_code_str}" + ) + assert "_is_valid" in view_code_str, ( + f"_is_valid property should be in view class. Code:\n{view_code_str}" + ) + assert "__repr__" in view_code_str, ( + f"__repr__ method should be in view class. Code:\n{view_code_str}" + ) + + print("Test passed: wrap-view generates View class correctly!") + + finally: + shutil.rmtree(test_dir, ignore_errors=True) + + +def test_wrap_view_not_generated_without_annotation(): + """Test that View class is NOT generated when wrap-view is absent.""" + import tempfile + import shutil + from autowrap.CodeGenerator import CodeGenerator + from autowrap.DeclResolver import ResolvedClass + + test_dir = tempfile.mkdtemp() + try: + # Parse a class WITHOUT wrap-view annotation + decls, instance_map = autowrap.DeclResolver.resolve_decls_from_string( + """ +cdef extern from "test.hpp": + cdef cppclass NormalClass: + int value + NormalClass() + """ + ) + + # Generate code + target = os.path.join(test_dir, "test_no_view.pyx") + cg = CodeGenerator( + decls, + instance_map, + pyx_target_path=target, + ) + # Trigger code generation + cg.create_pyx_file() + + # Check that NO view class was generated + assert "NormalClassView" not in cg.class_codes, ( + f"NormalClassView should NOT be in class_codes when wrap-view is absent. " + f"Got keys: {list(cg.class_codes.keys())}" + ) + + # Verify main class does NOT have view() method + main_class_code = cg.class_codes.get("NormalClass") + if main_class_code: + main_code_str = main_class_code.render() + assert "def view(self):" not in main_code_str, ( + f"view() method should NOT be in class without wrap-view. Code:\n{main_code_str}" + ) + + print("Test passed: No View class generated without wrap-view annotation!") + + finally: + shutil.rmtree(test_dir, ignore_errors=True) + + +def test_wrap_view_generates_properties_for_attributes(): + """Test that view class has properties for each attribute.""" + import tempfile + import shutil + from autowrap.CodeGenerator import CodeGenerator + from autowrap.DeclResolver import ResolvedClass + + test_dir = tempfile.mkdtemp() + try: + # Parse a class with wrap-view and multiple attributes + decls, instance_map = autowrap.DeclResolver.resolve_decls_from_string( + """ +cdef extern from "test.hpp": + cdef cppclass TestClass: + # wrap-view + int int_value + double double_value + TestClass() + """ + ) + + # Generate code + target = os.path.join(test_dir, "test_view_props.pyx") + cg = CodeGenerator( + decls, + instance_map, + pyx_target_path=target, + ) + cg.create_pyx_file() + + # Get view class code + view_class_code = cg.class_codes.get("TestClassView") + assert view_class_code is not None, "TestClassView code not found" + view_code_str = view_class_code.render() + + # Verify properties are generated for each attribute + assert "property int_value:" in view_code_str, ( + f"int_value property not found in view class. Code:\n{view_code_str}" + ) + assert "property double_value:" in view_code_str, ( + f"double_value property not found in view class. Code:\n{view_code_str}" + ) + + # Verify properties use _ptr (not inst) + assert "_ptr.int_value" in view_code_str, ( + f"Property should access _ptr.int_value. Code:\n{view_code_str}" + ) + assert "_ptr.double_value" in view_code_str, ( + f"Property should access _ptr.double_value. Code:\n{view_code_str}" + ) + + # Verify both getter and setter are generated + assert "def __get__(self):" in view_code_str, ( + f"Getter not found in view class. Code:\n{view_code_str}" + ) + assert "def __set__(self" in view_code_str, ( + f"Setter not found in view class. Code:\n{view_code_str}" + ) + + print("Test passed: View class has properties for attributes!") + + finally: + shutil.rmtree(test_dir, ignore_errors=True) + + +def test_wrap_view_constant_attribute(): + """Test that constant attributes have read-only properties in view class.""" + import tempfile + import shutil + from autowrap.CodeGenerator import CodeGenerator + + test_dir = tempfile.mkdtemp() + try: + # Parse a class with a constant attribute + decls, instance_map = autowrap.DeclResolver.resolve_decls_from_string( + """ +cdef extern from "test.hpp": + cdef cppclass TestClass: + # wrap-view + const int const_value + int mutable_value + TestClass() + """ + ) + + # Generate code + target = os.path.join(test_dir, "test_view_const.pyx") + cg = CodeGenerator( + decls, + instance_map, + pyx_target_path=target, + ) + cg.create_pyx_file() + + # Get view class code + view_class_code = cg.class_codes.get("TestClassView") + view_code_str = view_class_code.render() + + # Find the const_value property section + assert "property const_value:" in view_code_str, ( + f"const_value property not found. Code:\n{view_code_str}" + ) + + # The const property should raise AttributeError on set + assert 'raise AttributeError("Cannot set constant")' in view_code_str, ( + f"Const attribute should raise AttributeError on set. Code:\n{view_code_str}" + ) + + print("Test passed: Constant attributes are read-only in view class!") + + finally: + shutil.rmtree(test_dir, ignore_errors=True) + + +def test_wrap_view_generates_view_methods_for_mutable_refs(): + """Test that view class has view-returning methods for T& returns.""" + import tempfile + import shutil + from autowrap.CodeGenerator import CodeGenerator + from autowrap.DeclResolver import ResolvedClass + + test_dir = tempfile.mkdtemp() + try: + # Parse classes where one returns a mutable reference to another + decls, instance_map = autowrap.DeclResolver.resolve_decls_from_string( + """ +cdef extern from "test.hpp": + cdef cppclass Inner: + # wrap-view + int value + Inner() + + cdef cppclass Outer: + # wrap-view + Inner & getInner() + const Inner & getConstInner() + Inner getInnerCopy() + Outer() + """ + ) + + # Generate code + target = os.path.join(test_dir, "test_view_methods.pyx") + cg = CodeGenerator( + decls, + instance_map, + pyx_target_path=target, + ) + cg.create_pyx_file() + + # Get OuterView class code + outer_view_code = cg.class_codes.get("OuterView") + assert outer_view_code is not None, "OuterView code not found" + outer_view_str = outer_view_code.render() + + # getInner() returns T& - should generate view-returning method + assert "def getInner(self):" in outer_view_str, ( + f"getInner view method not found. Code:\n{outer_view_str}" + ) + + # Should return InnerView, not Inner + assert "InnerView" in outer_view_str, ( + f"InnerView should be returned by getInner. Code:\n{outer_view_str}" + ) + + # Should use _ptr access and propagate _parent reference + assert "_ptr" in outer_view_str and "_parent" in outer_view_str, ( + f"View method should use _ptr and _parent. Code:\n{outer_view_str}" + ) + + # getConstInner() returns const T& - should NOT be in view methods + # (const refs don't need view treatment, they're read-only) + # Note: It might still have the regular copy method + + # getInnerCopy() returns T (value) - should NOT have view method + # (value returns are copies anyway) + + print("Test passed: View methods generated for mutable reference returns!") + + finally: + shutil.rmtree(test_dir, ignore_errors=True) + + +def test_wrap_view_method_with_arguments(): + """Test that view methods correctly handle arguments.""" + import tempfile + import shutil + from autowrap.CodeGenerator import CodeGenerator + + test_dir = tempfile.mkdtemp() + try: + # Parse classes with a method that takes arguments + decls, instance_map = autowrap.DeclResolver.resolve_decls_from_string( + """ +cdef extern from "test.hpp": + cdef cppclass Item: + # wrap-view + int id + Item() + + cdef cppclass Container: + # wrap-view + Item & getItem(int index) + Container() + """ + ) + + # Generate code + target = os.path.join(test_dir, "test_view_args.pyx") + cg = CodeGenerator( + decls, + instance_map, + pyx_target_path=target, + ) + cg.create_pyx_file() + + # Get ContainerView class code + container_view_code = cg.class_codes.get("ContainerView") + assert container_view_code is not None, "ContainerView code not found" + container_view_str = container_view_code.render() + + # getItem should have the index argument + assert "def getItem(self, int index):" in container_view_str, ( + f"getItem should have index argument. Code:\n{container_view_str}" + ) + + # Should pass index to the C++ call (may include type cast like (index)) + assert ".getItem(" in container_view_str and "index" in container_view_str, ( + f"getItem should pass index to C++ method. Code:\n{container_view_str}" + ) + + print("Test passed: View methods correctly handle arguments!") + + finally: + shutil.rmtree(test_dir, ignore_errors=True) diff --git a/tests/test_decl_resolver.py b/tests/test_decl_resolver.py index 69055bd..3045332 100644 --- a/tests/test_decl_resolver.py +++ b/tests/test_decl_resolver.py @@ -837,3 +837,33 @@ def test_with_no_gil_annotation(): assert method.with_nogil (method,) = instance.methods.get("Cheap") assert not method.with_nogil + + +def test_wrap_view_annotation(): + """Test that wrap-view annotation is resolved correctly on ResolvedClass.""" + (instance,), map_I = DeclResolver.resolve_decls_from_string( + """ +cdef extern from "A.h": + cdef cppclass A: + # wrap-view + int value + A & getRef() + + """ + ) + assert instance.name == "A" + assert instance.wrap_view is True + + +def test_wrap_view_default_false(): + """Test that wrap_view defaults to False when not specified.""" + (instance,), map_I = DeclResolver.resolve_decls_from_string( + """ +cdef extern from "A.h": + cdef cppclass A: + int value + + """ + ) + assert instance.name == "A" + assert instance.wrap_view is False diff --git a/tests/test_files/wrap_view_test.hpp b/tests/test_files/wrap_view_test.hpp new file mode 100644 index 0000000..a77a149 --- /dev/null +++ b/tests/test_files/wrap_view_test.hpp @@ -0,0 +1,95 @@ +#ifndef WRAP_VIEW_TEST_HPP +#define WRAP_VIEW_TEST_HPP + +#include +#include +#include + +/** + * Inner class that will have a View generated. + * Used to test in-place modification via views. + */ +class Inner { +public: + int value; + std::string name; + + Inner() : value(0), name("default") {} + Inner(int v) : value(v), name("default") {} + Inner(int v, const std::string& n) : value(v), name(n) {} + + int getValue() const { return value; } + void setValue(int v) { value = v; } + + std::string getName() const { return name; } + void setName(const std::string& n) { name = n; } +}; + +/** + * Outer class that contains Inner objects. + * Used to test nested view access and mutable reference returns. + */ +class Outer { +private: + Inner inner_; + std::vector items_; + +public: + Outer() : inner_(0), items_() {} + Outer(int v) : inner_(v), items_() {} + + // Direct member access + Inner inner_member; + + // Mutable reference getter - should return view on ViewClass + Inner& getInner() { return inner_; } + + // Const reference getter - should return copy + const Inner& getConstInner() const { return inner_; } + + // Value getter - returns copy + Inner getInnerCopy() const { return inner_; } + + // Mutable reference with argument + Inner& getItemAt(int index) { + if (index < 0) { + throw std::out_of_range("index must be non-negative"); + } + if (index >= static_cast(items_.size())) { + items_.resize(index + 1); + } + return items_[index]; + } + + // Add item to the vector + void addItem(const Inner& item) { + items_.push_back(item); + } + + // Get number of items + int itemCount() const { + return static_cast(items_.size()); + } + + // Get the private inner's value (for verification) + int getInnerValue() const { return inner_.value; } +}; + +/** + * Container class for testing deeper nesting. + */ +class Container { +private: + Outer outer_; + +public: + Container() : outer_(0) {} + + // Mutable reference to Outer + Outer& getOuter() { return outer_; } + + // Value for verification + int getNestedValue() const { return outer_.getConstInner().value; } +}; + +#endif // WRAP_VIEW_TEST_HPP diff --git a/tests/test_files/wrap_view_test.pxd b/tests/test_files/wrap_view_test.pxd new file mode 100644 index 0000000..3d86aa2 --- /dev/null +++ b/tests/test_files/wrap_view_test.pxd @@ -0,0 +1,57 @@ +# cython: language_level=3 +from libcpp.string cimport string as libcpp_string +from libcpp.vector cimport vector as libcpp_vector + + +cdef extern from "wrap_view_test.hpp": + + cdef cppclass Inner: + # wrap-view + int value + libcpp_string name + + Inner() except + + Inner(int) except + + Inner(int, libcpp_string) except + + Inner(Inner&) except + # Copy constructor + + int getValue() + void setValue(int) + libcpp_string getName() + void setName(libcpp_string) + + + cdef cppclass Outer: + # wrap-view + Inner inner_member + + Outer() except + + Outer(int) except + + Outer(Outer&) except + # Copy constructor + + # Mutable reference getter - wrap-ignore on main class (ref returns not supported) + # view class should return InnerView + Inner & getInner() # wrap-ignore + + # Const reference getter - wrap-ignore on main class + const Inner & getConstInner() # wrap-ignore + + # Value getter - returns copy (this one works) + Inner getInnerCopy() + + # Mutable reference with argument - wrap-ignore on main class + Inner & getItemAt(int index) # wrap-ignore + + void addItem(Inner item) + int itemCount() + int getInnerValue() + + + cdef cppclass Container: + # wrap-view + Container() except + + + # Returns mutable reference to Outer - wrap-ignore on main class + Outer & getOuter() # wrap-ignore + + int getNestedValue() diff --git a/tests/test_pxd_parser.py b/tests/test_pxd_parser.py index 8cf1224..0a29a62 100644 --- a/tests/test_pxd_parser.py +++ b/tests/test_pxd_parser.py @@ -642,3 +642,43 @@ def test_consecutive_multiline_annotations(): # Verify wrap-instances content expected_instances = ["LinearInterpolation := LinearInterpolation[double, double]"] assert cdcl.annotations["wrap-instances"] == expected_instances + + +def test_wrap_view_annotation(): + """Test that wrap-view annotation is parsed correctly as a boolean.""" + (cdcl,) = autowrap.PXDParser.parse_str( + """ +cdef extern from "*": + cdef cppclass MyClass: + # wrap-view + int value + MyClass & getRef() + """ + ) + + # Verify wrap-view annotation was parsed as boolean True + assert "wrap-view" in cdcl.annotations + assert cdcl.annotations["wrap-view"] is True + + +def test_wrap_view_with_other_annotations(): + """Test that wrap-view can be combined with other annotations.""" + (cdcl,) = autowrap.PXDParser.parse_str( + """ +cdef extern from "*": + cdef cppclass MyClass: + # wrap-view + # wrap-doc: + # A class with view support + # + # wrap-hash: + # getValue() + int getValue() + """ + ) + + # Verify all annotations were parsed + assert cdcl.annotations["wrap-view"] is True + assert "wrap-doc" in cdcl.annotations + assert "wrap-hash" in cdcl.annotations + assert cdcl.annotations["wrap-hash"] == ["getValue()"] diff --git a/tests/test_wrap_view.py b/tests/test_wrap_view.py new file mode 100644 index 0000000..24845ef --- /dev/null +++ b/tests/test_wrap_view.py @@ -0,0 +1,288 @@ +""" +Integration tests for the wrap-view feature. + +These tests compile actual Cython code and verify runtime behavior +of in-place modification through views. +""" +from __future__ import print_function +from __future__ import absolute_import + +import os +import sys +import pytest + +import autowrap +import autowrap.Utils + +test_files = os.path.join(os.path.dirname(__file__), "test_files") + + +@pytest.fixture(scope="module") +def wrap_view_module(): + """Compile and import the wrap_view_test module once per test module.""" + # Use a different name for generated file to avoid Cython pxd shadowing + target = os.path.join(test_files, "generated", "wrap_view_wrapper.pyx") + + try: + include_dirs = autowrap.parse_and_generate_code( + ["wrap_view_test.pxd"], + root=test_files, + target=target, + debug=True + ) + + # The hpp file is header-only, no cpp needed + wrapped = autowrap.Utils.compile_and_import( + "wrap_view_wrapper", + [target], + include_dirs + ) + + yield wrapped + + except Exception as e: + pytest.skip(f"Compilation failed: {e}") + + finally: + # Cleanup generated file + if os.path.exists(target): + try: + os.remove(target) + except: + pass + + +class TestWrapViewBasic: + """Basic tests for wrap-view functionality.""" + + def test_view_class_exists(self, wrap_view_module): + """Test that View classes are generated.""" + assert hasattr(wrap_view_module, "Inner") + assert hasattr(wrap_view_module, "InnerView") + assert hasattr(wrap_view_module, "Outer") + assert hasattr(wrap_view_module, "OuterView") + assert hasattr(wrap_view_module, "Container") + assert hasattr(wrap_view_module, "ContainerView") + + def test_main_class_has_view_method(self, wrap_view_module): + """Test that main classes have a view() method.""" + inner = wrap_view_module.Inner() + assert hasattr(inner, "view") + assert callable(inner.view) + + outer = wrap_view_module.Outer() + assert hasattr(outer, "view") + + def test_view_returns_view_instance(self, wrap_view_module): + """Test that view() returns the correct View class instance.""" + inner = wrap_view_module.Inner(42) + view = inner.view() + + assert type(view).__name__ == "InnerView" + assert view._is_valid + + +class TestWrapViewInPlaceModification: + """Tests for in-place modification through views.""" + + def test_view_property_read(self, wrap_view_module): + """Test reading properties through view.""" + inner = wrap_view_module.Inner(42) + view = inner.view() + + # View should be able to read the value + assert view.value == 42 + + def test_view_property_write_modifies_original(self, wrap_view_module): + """Test that writing through view modifies the original object.""" + inner = wrap_view_module.Inner(42) + view = inner.view() + + # Modify through view + view.value = 100 + + # Original should be modified + assert inner.value == 100 + assert inner.getValue() == 100 + + def test_copy_does_not_modify_original(self, wrap_view_module): + """Test that regular access returns copies that don't affect original.""" + outer = wrap_view_module.Outer(10) + + # Get a copy via getInnerCopy() + inner_copy = outer.getInnerCopy() + original_value = outer.getInnerValue() + + # Modify the copy + inner_copy.value = 999 + + # Original should be unchanged + assert outer.getInnerValue() == original_value + + def test_nested_view_modifies_original(self, wrap_view_module): + """Test that nested views modify the original nested object.""" + outer = wrap_view_module.Outer(10) + outer_view = outer.view() + + # Get view of inner_member through outer's view + inner_view = outer_view.inner_member + + # This should be a view, not a copy + assert type(inner_view).__name__ == "InnerView" + + # Modify through nested view + inner_view.value = 777 + + # Original's nested member should be modified + assert outer.inner_member.value == 777 + + +class TestWrapViewMutableRefMethods: + """Tests for methods returning mutable references.""" + + def test_mutable_ref_method_returns_view(self, wrap_view_module): + """Test that T& methods return views on ViewClass.""" + outer = wrap_view_module.Outer(50) + outer_view = outer.view() + + # getInner() returns Inner& - on view class should return InnerView + inner_view = outer_view.getInner() + + assert type(inner_view).__name__ == "InnerView" + + def test_mutable_ref_method_modifies_original(self, wrap_view_module): + """Test that modifying through mutable ref view affects original.""" + outer = wrap_view_module.Outer(50) + outer_view = outer.view() + + # Get view via mutable ref method + inner_view = outer_view.getInner() + + # Modify through view + inner_view.value = 123 + + # Original's inner should be modified + assert outer.getInnerValue() == 123 + + def test_mutable_ref_method_with_argument(self, wrap_view_module): + """Test mutable ref methods that take arguments.""" + outer = wrap_view_module.Outer() + outer_view = outer.view() + + # Add some items first + outer.addItem(wrap_view_module.Inner(10)) + outer.addItem(wrap_view_module.Inner(20)) + outer.addItem(wrap_view_module.Inner(30)) + + # Get item at index via view + item_view = outer_view.getItemAt(1) + + # Should be a view + assert type(item_view).__name__ == "InnerView" + + # Modify through view + item_view.value = 999 + + # Get item again via main class to verify + # Note: main class getItemAt returns copy, so we check differently + item_view_check = outer_view.getItemAt(1) + assert item_view_check.value == 999 + + +class TestWrapViewLifetime: + """Tests for view lifetime and safety.""" + + def test_view_keeps_parent_alive(self, wrap_view_module): + """Test that view keeps parent object alive.""" + def get_view(): + inner = wrap_view_module.Inner(42) + return inner.view() + + view = get_view() + # Parent went out of scope in get_view(), but view should keep it alive + assert view._is_valid + assert view.value == 42 + + def test_nested_view_keeps_chain_alive(self, wrap_view_module): + """Test that nested views keep the whole chain alive.""" + def get_nested_view(): + outer = wrap_view_module.Outer(100) + outer_view = outer.view() + return outer_view.getInner() + + inner_view = get_nested_view() + # Both outer and its inner should be kept alive + assert inner_view._is_valid + assert inner_view.value == 100 + + def test_deep_view_survives_intermediate_and_root_deletion(self, wrap_view_module): + """Test that deep views work after intermediate views and root are deleted.""" + import gc + + # Create container and get deep view + container = wrap_view_module.Container() + container_view = container.view() + outer_view = container_view.getOuter() + inner_view = outer_view.getInner() + + # Set initial value + inner_view.value = 42 + assert inner_view.value == 42 + + # Delete intermediate views - inner_view should still work + del outer_view + del container_view + gc.collect() + + assert inner_view._is_valid + assert inner_view.value == 42 + inner_view.value = 100 + assert inner_view.value == 100 + + # Verify modification visible via container + assert container.getNestedValue() == 100 + + # Delete container - inner_view should STILL work (it holds the ref) + del container + gc.collect() + + assert inner_view._is_valid + assert inner_view.value == 100 + + +class TestWrapViewDeepNesting: + """Tests for deeply nested views.""" + + def test_three_level_nesting(self, wrap_view_module): + """Test modification through three levels of nesting.""" + container = wrap_view_module.Container() + container_view = container.view() + + # Container -> Outer -> Inner + outer_view = container_view.getOuter() + inner_view = outer_view.getInner() + + # Modify at deepest level + inner_view.value = 12345 + + # Should be reflected at top level + assert container.getNestedValue() == 12345 + + +class TestWrapViewStringAttribute: + """Tests for string attributes through views.""" + + def test_string_attribute_via_view(self, wrap_view_module): + """Test modifying string attributes through view.""" + inner = wrap_view_module.Inner(0, b"original") + view = inner.view() + + # Read string through view + assert view.name == b"original" + + # Modify string through view + view.name = b"modified" + + # Original should be modified + assert inner.name == b"modified" + assert inner.getName() == b"modified"