diff --git a/autowrap/CodeGenerator.py b/autowrap/CodeGenerator.py index 8ac6712..4717fea 100644 --- a/autowrap/CodeGenerator.py +++ b/autowrap/CodeGenerator.py @@ -278,6 +278,7 @@ def create_pyx_file(self, debug: bool = False) -> None: self.setup_cimport_paths() self.create_cimports() self.create_foreign_cimports() + self.create_foreign_enum_imports() self.create_includes() def create_for( @@ -2036,6 +2037,40 @@ def create_foreign_cimports(self): self.top_level_code.append(code) + def create_foreign_enum_imports(self): + """Generate Python imports for enum classes used in type assertions across modules. + + When autowrap splits classes across multiple output modules, enum classes + (e.g., _PyPolarity, _PySpectrumType) may be defined in one module but used + in type assertions in another module. This method generates the necessary + Python-level imports for these enum classes. + + Note: This is separate from create_foreign_cimports() which handles Cython-level + cimports. Scoped enum classes are pure Python IntEnum subclasses and need + regular imports. Unscoped enums are cdef classes and use cimport instead. + """ + code = Code() + L.info("Create foreign enum imports for module %s" % self.target_path) + for module in self.all_decl: + mname = module + if sys.version_info >= (3, 0) and self.add_relative: + mname = "." + module + + if os.path.basename(self.target_path).split(".pyx")[0] != module: + for resolved in self.all_decl[module]["decls"]: + if resolved.__class__ in (ResolvedEnum,): + # Only import scoped enums (Python IntEnum classes) + # Unscoped enums are cdef classes and use cimport + if resolved.scoped and not resolved.wrap_ignore: + # Determine the correct Python name based on wrap-attach + if resolved.cpp_decl.annotations.get("wrap-attach"): + py_name = "_Py" + resolved.name + else: + py_name = resolved.name + code.add("from $mname import $py_name", locals()) + + self.top_level_pyx_code.append(code) + def create_cimports(self): self.create_std_cimports() code = Code() diff --git a/autowrap/ConversionProvider.py b/autowrap/ConversionProvider.py index 31dc3fb..62b0117 100644 --- a/autowrap/ConversionProvider.py +++ b/autowrap/ConversionProvider.py @@ -431,8 +431,15 @@ def input_conversion( def output_conversion( self, cpp_type: CppType, input_cpp_var: str, output_py_var: str ) -> Optional[str]: - # TODO check what to do for non-int scoped enums - return "%s = %s" % (output_py_var, input_cpp_var) + if not self.enum.scoped: + return "%s = %s" % (output_py_var, input_cpp_var) + else: + # For scoped enums, wrap the int value in the Python enum class + if self.enum.cpp_decl.annotations.get("wrap-attach"): + name = "_Py" + self.enum.name + else: + name = self.enum.name + return "%s = %s(%s)" % (output_py_var, name, input_cpp_var) class CharConverter(TypeConverterBase): diff --git a/tests/test_code_generator.py b/tests/test_code_generator.py index 43f27eb..c5a13a9 100644 --- a/tests/test_code_generator.py +++ b/tests/test_code_generator.py @@ -640,3 +640,379 @@ def test_enum_class_forward_declaration(tmpdir): finally: os.chdir(curdir) + + +def test_create_foreign_enum_imports(): + """ + Test that create_foreign_enum_imports() generates correct Python imports for + scoped enums used across multiple modules. + + This test verifies: + 1. Scoped enums WITH wrap-attach get '_Py' prefix (e.g., '_PyStatus') + 2. Scoped enums WITHOUT wrap-attach keep original name (e.g., 'Status') + 3. Unscoped enums are NOT imported (they use cimport via create_foreign_cimports) + 4. wrap-ignored enums are skipped + 5. Imports are added to top_level_pyx_code (for .pyx), not top_level_code (for .pxd) + + Background: + ----------- + When autowrap splits classes across multiple output modules for parallel + compilation, scoped enum classes (e.g., _PyPolarity, _PySpectrumType) may be + defined in one module but used in isinstance() type assertions in another. + + Scoped enums are implemented as Python IntEnum subclasses and need regular + Python imports. Unscoped enums are cdef classes and use Cython cimport. + """ + import tempfile + import shutil + from autowrap.CodeGenerator import CodeGenerator + from autowrap.DeclResolver import ResolvedEnum + from autowrap.PXDParser import EnumDecl + + # Create a temporary directory for generated files + test_dir = tempfile.mkdtemp() + try: + # Create mock EnumDecl objects for testing + def make_enum_decl(name, scoped, annotations=None): + """Helper to create mock EnumDecl objects.""" + if annotations is None: + annotations = {} + decl = EnumDecl( + name=name, + scoped=scoped, + items=[("A", 0), ("B", 1)], + annotations=annotations, + pxd_path="/fake/path.pxd" + ) + return decl + + # Create mock ResolvedEnum objects + def make_resolved_enum(name, scoped, wrap_ignore=False, wrap_attach=None): + """Helper to create mock ResolvedEnum objects.""" + annotations = {"wrap-ignore": wrap_ignore} + if wrap_attach: + annotations["wrap-attach"] = wrap_attach + decl = make_enum_decl(name, scoped, annotations) + resolved = ResolvedEnum(decl) + resolved.pxd_import_path = "module1" + return resolved + + # Test enums in module1 (source module) + scoped_with_attach = make_resolved_enum("Status", scoped=True, wrap_attach="SomeClass") + scoped_no_attach = make_resolved_enum("Priority", scoped=True, wrap_attach=None) + unscoped_enum = make_resolved_enum("OldEnum", scoped=False) + ignored_enum = make_resolved_enum("IgnoredEnum", scoped=True, wrap_ignore=True) + + module1_decls = [scoped_with_attach, scoped_no_attach, unscoped_enum, ignored_enum] + + # Set up multi-module scenario + # module1 contains the enums, module2 needs to import them + master_dict = { + "module1": {"decls": module1_decls, "addons": [], "files": []}, + "module2": {"decls": [], "addons": [], "files": []}, + } + + # Create CodeGenerator for module2 + target = os.path.join(test_dir, "module2.pyx") + cg = CodeGenerator( + [], # module2 has no decls of its own + {}, # empty instance_map + pyx_target_path=target, + all_decl=master_dict, + ) + + # Call the method we're testing + cg.create_foreign_enum_imports() + + # Get the generated code from top_level_pyx_code (NOT top_level_code) + pyx_generated_code = "" + for code_block in cg.top_level_pyx_code: + pyx_generated_code += code_block.render() + + pxd_generated_code = "" + for code_block in cg.top_level_code: + pxd_generated_code += code_block.render() + + # Test 1: Scoped enum WITH wrap-attach should use _Py prefix + assert "from module1 import _PyStatus" in pyx_generated_code, ( + f"Expected 'from module1 import _PyStatus' for scoped enum with wrap-attach. " + f"Generated pyx code:\n{pyx_generated_code}" + ) + + # Test 2: Scoped enum WITHOUT wrap-attach should use original name + assert "from module1 import Priority" in pyx_generated_code, ( + f"Expected 'from module1 import Priority' for scoped enum without wrap-attach. " + f"Generated pyx code:\n{pyx_generated_code}" + ) + + # Test 3: Unscoped enum should NOT be imported (uses cimport instead) + assert "OldEnum" not in pyx_generated_code, ( + f"Unscoped enum 'OldEnum' should not be in Python imports (uses cimport). " + f"Generated pyx code:\n{pyx_generated_code}" + ) + + # Test 4: wrap-ignored enum should NOT be imported + assert "IgnoredEnum" not in pyx_generated_code, ( + f"wrap-ignored enum 'IgnoredEnum' should not be in imports. " + f"Generated pyx code:\n{pyx_generated_code}" + ) + + # Test 5: Imports should be in top_level_pyx_code, NOT top_level_code + # (top_level_code goes to .pxd, top_level_pyx_code goes to .pyx) + assert "_PyStatus" not in pxd_generated_code, ( + f"Enum Python imports should not be in top_level_code (pxd). " + f"Generated pxd code:\n{pxd_generated_code}" + ) + assert "Priority" not in pxd_generated_code or "cimport" in pxd_generated_code, ( + f"Enum Python imports should not be in top_level_code (pxd). " + f"Generated pxd code:\n{pxd_generated_code}" + ) + + print("Test passed: create_foreign_enum_imports generates correct imports") + + finally: + shutil.rmtree(test_dir, ignore_errors=True) + + +def test_cross_module_scoped_enum_imports(tmpdir): + """ + Integration test for cross-module scoped enum imports. + + This test verifies that create_foreign_enum_imports() correctly generates + Python imports for scoped enums used across module boundaries: + + 1. Scoped enum WITH wrap-attach (Task.TaskStatus): + - Should generate: from .EnumProvider import _PyTask_TaskStatus + - Used in isinstance() checks as _PyTask_TaskStatus + + 2. Scoped enum WITHOUT wrap-attach (Priority): + - Should generate: from .EnumProvider import Priority + - Used in isinstance() checks as Priority + + The test: + - Parses two modules: EnumProvider (defines enums) and EnumConsumer (uses enums) + - Generates Cython code for both modules + - Verifies EnumConsumer.pyx contains correct Python imports for both enum types + - Compiles and imports the modules at runtime + - Runs actual Python tests using enums across module boundaries + """ + import shutil + import subprocess + import glob + from importlib import import_module + + test_dir = tmpdir.strpath + package_dir = os.path.join(test_dir, "package") + os.makedirs(package_dir) + + enum_test_files = os.path.join( + os.path.dirname(__file__), "test_files", "enum_cross_module" + ) + + curdir = os.getcwd() + os.chdir(package_dir) + + # Copy test files to package directory + for f in ["EnumProvider.hpp", "EnumProvider.pxd", "EnumConsumer.hpp", "EnumConsumer.pxd"]: + src = os.path.join(enum_test_files, f) + dst = os.path.join(package_dir, f) + shutil.copy(src, dst) + + # Create __init__.py and __init__.pxd for package + with open(os.path.join(package_dir, "__init__.py"), "w") as f: + f.write("# Cross-module enum test package\n") + with open(os.path.join(package_dir, "__init__.pxd"), "w") as f: + f.write("# Cython package marker\n") + + try: + mnames = ["EnumProvider", "EnumConsumer"] + + # Step 1: Parse all pxd files + pxd_files = ["EnumProvider.pxd", "EnumConsumer.pxd"] + full_pxd_files = [os.path.join(package_dir, f) for f in pxd_files] + decls, instance_map = autowrap.parse(full_pxd_files, package_dir, num_processes=1) + + # Step 2: Map declarations to their source modules + pxd_decl_mapping = {} + for de in decls: + tmp = pxd_decl_mapping.get(de.cpp_decl.pxd_path, []) + tmp.append(de) + pxd_decl_mapping[de.cpp_decl.pxd_path] = tmp + + masterDict = {} + masterDict[mnames[0]] = { + "decls": pxd_decl_mapping.get(full_pxd_files[0], []), + "addons": [], + "files": [full_pxd_files[0]], + } + masterDict[mnames[1]] = { + "decls": pxd_decl_mapping.get(full_pxd_files[1], []), + "addons": [], + "files": [full_pxd_files[1]], + } + + # Step 3: Generate Cython code for each module + converters = [] + generated_pyx_content = {} + + for modname in mnames: + m_filename = "%s.pyx" % modname + cimports, manual_code = autowrap.Main.collect_manual_code(masterDict[modname]["addons"]) + autowrap.Main.register_converters(converters) + autowrap_include_dirs = autowrap.generate_code( + masterDict[modname]["decls"], + instance_map, + target=m_filename, + debug=True, + manual_code=manual_code, + extra_cimports=cimports, + all_decl=masterDict, + add_relative=True, + ) + masterDict[modname]["inc_dirs"] = autowrap_include_dirs + + # Read generated pyx file + with open(m_filename, "r") as f: + generated_pyx_content[modname] = f.read() + + # Step 4: Verify EnumConsumer.pyx has correct Python imports + consumer_pyx = generated_pyx_content.get("EnumConsumer", "") + + # Test 1: Scoped enum WITH wrap-attach should be imported with _Py prefix + assert "from .EnumProvider import _PyTask_TaskStatus" in consumer_pyx, ( + f"Expected 'from .EnumProvider import _PyTask_TaskStatus' for scoped enum with wrap-attach.\n" + f"EnumConsumer.pyx content:\n{consumer_pyx}" + ) + + # Test 2: Scoped enum WITHOUT wrap-attach should be imported with original name + assert "from .EnumProvider import Priority" in consumer_pyx, ( + f"Expected 'from .EnumProvider import Priority' for scoped enum without wrap-attach.\n" + f"EnumConsumer.pyx content:\n{consumer_pyx}" + ) + + # Test 3: Verify isinstance checks use the correct enum class names + assert "isinstance(s, _PyTask_TaskStatus)" in consumer_pyx, ( + f"Expected isinstance check with _PyTask_TaskStatus for wrap-attach enum.\n" + f"EnumConsumer.pyx content:\n{consumer_pyx}" + ) + assert "isinstance(p, Priority)" in consumer_pyx, ( + f"Expected isinstance check with Priority for non-wrap-attach enum.\n" + f"EnumConsumer.pyx content:\n{consumer_pyx}" + ) + + # Step 5: Compile and run runtime tests using cythonize + os.chdir(test_dir) + include_dirs = masterDict[mnames[0]]["inc_dirs"] + + compile_args = [] + link_args = [] + if sys.platform == "darwin": + compile_args += ["-stdlib=libc++"] + link_args += ["-stdlib=libc++"] + if sys.platform != "win32": + compile_args += ["-Wno-unused-but-set-variable"] + + # Use cythonize to compile the package - this handles relative imports properly + setup_code = f""" +from setuptools import setup, Extension +from Cython.Build import cythonize +import Cython.Compiler.Options as CythonOptions + +CythonOptions.get_directive_defaults()["language_level"] = 3 + +extensions = [ + Extension("package.EnumProvider", sources=['package/EnumProvider.pyx'], language="c++", + include_dirs={include_dirs!r}, + extra_compile_args={compile_args!r}, + extra_link_args={link_args!r}, + ), + Extension("package.EnumConsumer", sources=['package/EnumConsumer.pyx'], language="c++", + include_dirs={include_dirs!r}, + extra_compile_args={compile_args!r}, + extra_link_args={link_args!r}, + ), +] + +setup( + name="package", + version="0.0.1", + ext_modules=cythonize(extensions, language_level=3), +) +""" + with open("setup.py", "w") as fp: + fp.write(setup_code) + + result = subprocess.Popen( + f"{sys.executable} setup.py build_ext --force --inplace", + shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE + ) + stdout, stderr = result.communicate() + if result.returncode != 0: + pytest.skip(f"Cython compilation failed (may be environment issue): {stderr.decode()[:500]}") + + # Step 6: Import the modules and run runtime tests + sys.path.insert(0, test_dir) + try: + # Import the package modules + from package import EnumProvider, EnumConsumer + + # Runtime Test 1: Priority enum (no wrap-attach) works across modules + assert hasattr(EnumProvider, "Priority"), "EnumProvider should have Priority enum" + assert EnumProvider.Priority.LOW is not None + assert EnumProvider.Priority.MEDIUM is not None + assert EnumProvider.Priority.HIGH is not None + + # Runtime Test 2: Task.TaskStatus enum (with wrap-attach) works across modules + assert hasattr(EnumProvider, "Task"), "EnumProvider should have Task class" + assert hasattr(EnumProvider.Task, "TaskStatus"), "Task should have TaskStatus enum" + assert EnumProvider.Task.TaskStatus.PENDING is not None + assert EnumProvider.Task.TaskStatus.RUNNING is not None + assert EnumProvider.Task.TaskStatus.COMPLETED is not None + assert EnumProvider.Task.TaskStatus.FAILED is not None + + # Runtime Test 3: TaskRunner can use Priority enum from EnumProvider + runner = EnumConsumer.TaskRunner() + assert runner.isHighPriority(EnumProvider.Priority.HIGH) == True + assert runner.isHighPriority(EnumProvider.Priority.LOW) == False + assert runner.getDefaultPriority() == EnumProvider.Priority.MEDIUM + + # Runtime Test 4: TaskRunner can use Task.TaskStatus enum from EnumProvider + assert runner.isCompleted(EnumProvider.Task.TaskStatus.COMPLETED) == True + assert runner.isCompleted(EnumProvider.Task.TaskStatus.PENDING) == False + assert runner.getDefaultStatus() == EnumProvider.Task.TaskStatus.PENDING + + # Runtime Test 5: Task class can use its own TaskStatus enum + task = EnumProvider.Task() + assert task.getStatus() == EnumProvider.Task.TaskStatus.PENDING + task.setStatus(EnumProvider.Task.TaskStatus.RUNNING) + assert task.getStatus() == EnumProvider.Task.TaskStatus.RUNNING + + # Runtime Test 6: Task class can use Priority enum + assert task.getPriority() == EnumProvider.Priority.MEDIUM + task.setPriority(EnumProvider.Priority.HIGH) + assert task.getPriority() == EnumProvider.Priority.HIGH + + # Runtime Test 7: Type checking works - wrong enum type should raise + try: + runner.isHighPriority(EnumProvider.Task.TaskStatus.PENDING) + assert False, "Should have raised AssertionError for wrong enum type" + except AssertionError as e: + assert "wrong type" in str(e) + + try: + runner.isCompleted(EnumProvider.Priority.HIGH) + assert False, "Should have raised AssertionError for wrong enum type" + except AssertionError as e: + assert "wrong type" in str(e) + + print("Test passed: Cross-module scoped enum imports work correctly at runtime!") + + finally: + sys.path.remove(test_dir) + # Clean up imported modules + for mod_name in list(sys.modules.keys()): + if mod_name.startswith("package"): + del sys.modules[mod_name] + + finally: + os.chdir(curdir) diff --git a/tests/test_files/enum_cross_module/EnumConsumer.hpp b/tests/test_files/enum_cross_module/EnumConsumer.hpp new file mode 100644 index 0000000..c25c459 --- /dev/null +++ b/tests/test_files/enum_cross_module/EnumConsumer.hpp @@ -0,0 +1,38 @@ +/** + * Test file for cross-module scoped enum imports. + * + * This file uses enums defined in EnumProvider.hpp to test + * cross-module enum import functionality. + */ + +#ifndef ENUM_CONSUMER_HPP +#define ENUM_CONSUMER_HPP + +#include "EnumProvider.hpp" + +class TaskRunner { +public: + TaskRunner() {} + + // Method using standalone enum (Priority) + bool isHighPriority(Priority p) { + return p == Priority::HIGH; + } + + // Method using class-attached enum (Task::TaskStatus) + bool isCompleted(Task::TaskStatus s) { + return s == Task::TaskStatus::COMPLETED; + } + + // Method returning standalone enum + Priority getDefaultPriority() { + return Priority::MEDIUM; + } + + // Method returning class-attached enum + Task::TaskStatus getDefaultStatus() { + return Task::TaskStatus::PENDING; + } +}; + +#endif // ENUM_CONSUMER_HPP diff --git a/tests/test_files/enum_cross_module/EnumConsumer.pxd b/tests/test_files/enum_cross_module/EnumConsumer.pxd new file mode 100644 index 0000000..b465bec --- /dev/null +++ b/tests/test_files/enum_cross_module/EnumConsumer.pxd @@ -0,0 +1,18 @@ +# cython: language_level=3 +# +# Test file for cross-module scoped enum imports. +# +# This module imports and uses enums from EnumProvider to test +# that create_foreign_enum_imports() generates correct Python imports. +# +from libcpp cimport bool +from EnumProvider cimport Priority, Task_TaskStatus + +cdef extern from "EnumConsumer.hpp": + + cdef cppclass TaskRunner: + TaskRunner() + bool isHighPriority(Priority p) + bool isCompleted(Task_TaskStatus s) + Priority getDefaultPriority() + Task_TaskStatus getDefaultStatus() diff --git a/tests/test_files/enum_cross_module/EnumProvider.hpp b/tests/test_files/enum_cross_module/EnumProvider.hpp new file mode 100644 index 0000000..9a8c6bc --- /dev/null +++ b/tests/test_files/enum_cross_module/EnumProvider.hpp @@ -0,0 +1,44 @@ +/** + * Test file for cross-module scoped enum imports. + * + * This file defines scoped enums that will be used across multiple Cython modules + * to test the create_foreign_enum_imports() functionality. + */ + +#ifndef ENUM_PROVIDER_HPP +#define ENUM_PROVIDER_HPP + +// Standalone scoped enum (no wrap-attach) +// Should be imported as: from EnumProvider import Priority +enum class Priority { + LOW = 0, + MEDIUM = 1, + HIGH = 2 +}; + +// Class with attached enum +class Task { +public: + // Scoped enum attached to Task class (with wrap-attach) + // Should be imported as: from EnumProvider import _PyTaskStatus + enum class TaskStatus { + PENDING = 0, + RUNNING = 1, + COMPLETED = 2, + FAILED = 3 + }; + + Task() : status_(TaskStatus::PENDING), priority_(Priority::MEDIUM) {} + + void setStatus(TaskStatus s) { status_ = s; } + TaskStatus getStatus() const { return status_; } + + void setPriority(Priority p) { priority_ = p; } + Priority getPriority() const { return priority_; } + +private: + TaskStatus status_; + Priority priority_; +}; + +#endif // ENUM_PROVIDER_HPP diff --git a/tests/test_files/enum_cross_module/EnumProvider.pxd b/tests/test_files/enum_cross_module/EnumProvider.pxd new file mode 100644 index 0000000..2da5484 --- /dev/null +++ b/tests/test_files/enum_cross_module/EnumProvider.pxd @@ -0,0 +1,39 @@ +# cython: language_level=3 +# +# Test file for cross-module scoped enum imports. +# +# This file defines two scoped enums to test create_foreign_enum_imports(): +# 1. Priority - standalone enum (no wrap-attach) -> imported as "Priority" +# 2. Task_TaskStatus - attached to Task class (wrap-attach) -> imported as "_PyTaskStatus" +# + +cdef extern from "EnumProvider.hpp": + + # Standalone scoped enum - no wrap-attach + # This should be imported in other modules as: from EnumProvider import Priority + cpdef enum class Priority: + LOW + MEDIUM + HIGH + + cdef cppclass Task: + Task() + void setStatus(Task_TaskStatus s) + Task_TaskStatus getStatus() + void setPriority(Priority p) + Priority getPriority() + + +cdef extern from "EnumProvider.hpp" namespace "Task": + + # Scoped enum attached to Task class + # This should be imported in other modules as: from EnumProvider import _PyTaskStatus + cpdef enum class Task_TaskStatus "Task::TaskStatus": + # wrap-attach: + # Task + # wrap-as: + # TaskStatus + PENDING + RUNNING + COMPLETED + FAILED