diff --git a/.flake8 b/.flake8 index a298ec3..95b9d39 100644 --- a/.flake8 +++ b/.flake8 @@ -1,6 +1,8 @@ [flake8] max_line_length = 120 -exclude= + +exclude = + __pycache__, .git, .github, build, @@ -8,4 +10,9 @@ exclude= examples, cppwg/templates, tests, + venv, + docstring-convention=numpy + +# D200 One-line docstring should fit on one line with quotes +extend-ignore = D200 diff --git a/cppwg/__init__.py b/cppwg/__init__.py index 986567b..802c215 100644 --- a/cppwg/__init__.py +++ b/cppwg/__init__.py @@ -13,21 +13,14 @@ Contains string templates for Python wrappers. utils Contains utility functions and constants. +version + Contains version information. writers Contains writers for creating Python wrappers and writing to file. - -Utilities ---------- -__version__ - cppwg version string """ -from importlib import metadata - from cppwg.generators import CppWrapperGenerator __all__ = [ "CppWrapperGenerator", ] - -__version__ = metadata.version("cppwg") diff --git a/cppwg/__main__.py b/cppwg/__main__.py index a6bbc99..0d6e209 100644 --- a/cppwg/__main__.py +++ b/cppwg/__main__.py @@ -3,7 +3,8 @@ import argparse import logging -from cppwg import CppWrapperGenerator, __version__ +from cppwg import CppWrapperGenerator +from cppwg.version import __version__ def parse_args() -> argparse.Namespace: @@ -113,7 +114,7 @@ def generate(args: argparse.Namespace) -> None: castxml_cflags=castxml_cflags, ) - generator.generate_wrapper() + generator.generate() def main() -> None: diff --git a/cppwg/generators.py b/cppwg/generators.py index 72c3044..3a1b421 100644 --- a/cppwg/generators.py +++ b/cppwg/generators.py @@ -1,6 +1,5 @@ -"""Contains the main interface for generating Python wrappers.""" +"""The main interface for generating Python wrappers.""" -import fnmatch import logging import os import re @@ -10,11 +9,7 @@ from typing import List, Optional import pygccxml -from pygccxml.declarations.runtime_errors import declaration_not_found_t -from cppwg.input.class_info import CppClassInfo -from cppwg.input.free_function_info import CppFreeFunctionInfo -from cppwg.input.info_helper import CppInfoHelper from cppwg.input.package_info import PackageInfo from cppwg.parsers.package_info_parser import PackageInfoParser from cppwg.parsers.source_parser import CppSourceParser @@ -22,11 +17,11 @@ from cppwg.utils import utils from cppwg.utils.constants import ( CPPWG_DEFAULT_WRAPPER_DIR, - CPPWG_EXT, CPPWG_HEADER_COLLECTION_FILENAME, ) +from cppwg.version import __version__ as cppwg_version from cppwg.writers.header_collection_writer import CppHeaderCollectionWriter -from cppwg.writers.module_writer import CppModuleWrapperWriter +from cppwg.writers.package_writer import CppPackageWrapperWriter class CppWrapperGenerator: @@ -64,6 +59,8 @@ def __init__( ): logger = logging.getLogger() + logger.info(f"cppwg version {cppwg_version}") + # Check that castxml_binary exists and is executable self.castxml_binary: str = "" @@ -168,48 +165,12 @@ def __init__( self.wrapper_root, CPPWG_HEADER_COLLECTION_FILENAME ) - def collect_source_hpp_files(self) -> None: + def log_unknown_classes(self) -> None: """ - Collect *.hpp files from the source root. - - Walk through the source root and add any files matching the provided - patterns e.g. "*.hpp". Skip the wrapper root and wrappers to - avoid pollution. + Log unwrapped classes. """ - for root, _, filenames in os.walk(self.source_root, followlinks=True): - for pattern in self.package_info.source_hpp_patterns: - for filename in fnmatch.filter(filenames, pattern): - filepath = os.path.abspath(os.path.join(root, filename)) - - # Skip files in wrapper root dir - if Path(self.wrapper_root) in Path(filepath).parents: - continue - - # Skip files with the extensions like .cppwg.hpp - suffix = os.path.splitext(os.path.splitext(filename)[0])[1] - if suffix == CPPWG_EXT: - continue - - self.package_info.source_hpp_files.append(filepath) - - # Check if any source files were found - if not self.package_info.source_hpp_files: - logging.error(f"No header files found in source root: {self.source_root}") - raise FileNotFoundError() - - def extract_templates_from_source(self) -> None: - """Extract template arguments for each class from the associated source file.""" - for module_info in self.package_info.module_info_collection: - info_helper = CppInfoHelper(module_info) - for class_info in module_info.class_info_collection: - # Skip excluded classes - if class_info.excluded: - continue - info_helper.extract_templates_from_source(class_info) - class_info.update_names() + logger = logging.getLogger() - def log_unknown_classes(self) -> None: - """Get unwrapped classes.""" all_class_decls = self.source_ns.classes(allow_empty=True) seen_class_names = set() @@ -220,15 +181,17 @@ def log_unknown_classes(self) -> None: seen_class_names.update(decl.name for decl in class_info.decls) for decl in all_class_decls: - if ( - Path(self.source_root) in Path(decl.location.file_name).parents - and decl.name not in seen_class_names - ): - seen_class_names.add(decl.name) - seen_class_names.add(decl.name.split("<")[0].strip()) - logging.info( - f"Unknown class {decl.name} from {decl.location.file_name}:{decl.location.line}" - ) + if decl.name in seen_class_names: + continue + + if Path(self.source_root) not in Path(decl.location.file_name).parents: + continue + + seen_class_names.add(decl.name) # e.g. Foo<2,2> + seen_class_names.add(decl.name.split("<")[0].strip()) # e.g. Foo + logger.info( + f"Unknown class {decl.name} from {decl.location.file_name}:{decl.location.line}" + ) # Check for uninstantiated class templates not parsed by pygccxml for hpp_file_path in self.package_info.source_hpp_files: @@ -238,28 +201,9 @@ def log_unknown_classes(self) -> None: for _, class_name, _ in class_list: if class_name not in seen_class_names: seen_class_names.add(class_name) - logging.info(f"Unknown class {class_name} from {hpp_file_path}") - - def map_classes_to_hpp_files(self) -> None: - """ - Map each class to a header file. + logger.info(f"Unknown class {class_name} from {hpp_file_path}") - Attempt to map source file paths to each class, assuming the containing - file name is the class name. - """ - for module_info in self.package_info.module_info_collection: - for class_info in module_info.class_info_collection: - # Skip excluded classes - if class_info.excluded: - continue - for hpp_file_path in self.package_info.source_hpp_files: - hpp_file_name = os.path.basename(hpp_file_path) - if class_info.name == os.path.splitext(hpp_file_name)[0]: - class_info.source_file_full_path = hpp_file_path - if class_info.source_file is None: - class_info.source_file = hpp_file_name - - def parse_header_collection(self) -> None: + def parse_headers(self) -> None: """ Parse the hpp files to collect C++ declarations. @@ -276,7 +220,9 @@ def parse_header_collection(self) -> None: self.source_ns = source_parser.parse() def parse_package_info(self) -> None: - """Parse the package info file to create a PackageInfo object.""" + """ + Parse the package info file to create a PackageInfo object. + """ if self.package_info_path: # If a package info file exists, parse it to create a PackageInfo object info_parser = PackageInfoParser(self.package_info_path, self.source_root) @@ -286,119 +232,10 @@ def parse_package_info(self) -> None: # If no package info file exists, create a PackageInfo object with default settings self.package_info = PackageInfo("cppwg_package", self.source_root) - def add_discovered_classes(self) -> None: - """ - Add discovered classes. - - Add class info objects for classes discovered by pygccxml from - parsing the C++ source code. This is run for modules which set - `use_all_classes` to True. No class info objects were created for - those modules while parsing the package info yaml file. - """ - for module_info in self.package_info.module_info_collection: - if module_info.use_all_classes: - class_decls = self.source_ns.classes(allow_empty=True) - - for class_decl in class_decls: - if module_info.is_decl_in_source_path(class_decl): - class_info = CppClassInfo(class_decl.name) - class_info.update_names() - class_info.module_info = module_info - module_info.class_info_collection.append(class_info) - - def add_class_decls(self) -> None: - """ - Add declarations to class info objects. - - Update all class info objects with their corresponding - declarations found by pygccxml in the C++ source code. - """ - for module_info in self.package_info.module_info_collection: - for class_info in module_info.class_info_collection: - # Skip excluded classes - if class_info.excluded: - continue - - class_info.decls: List["class_t"] = [] # noqa: F821 - - for class_cpp_name in class_info.cpp_names: - decl_name = class_cpp_name.replace(" ", "") # e.g. Foo<2,2> - - try: - class_decl = self.source_ns.class_(decl_name) - - except declaration_not_found_t as e1: - if ( - class_info.template_signature is None - or "=" not in class_info.template_signature - ): - logging.error( - f"Could not find declaration for class {decl_name}" - ) - raise e1 - - # If class has default args, try to compress the template signature - logging.warning( - f"Could not find declaration for class {decl_name}: trying for a partial match." - ) - - # Try to find the class without default template args - # e.g. for template class Foo {}; - # Look for Foo<2> instead of Foo<2,2> - pos = 0 - for i, s in enumerate(class_info.template_signature.split(",")): - if "=" in s: - pos = i - break - - decl_name = ",".join(decl_name.split(",")[0:pos]) + " >" - - try: - class_decl = self.source_ns.class_(decl_name) - - except declaration_not_found_t as e2: - logging.error( - f"Could not find declaration for class {decl_name}" - ) - raise e2 - - logging.info(f"Found {decl_name}") - - class_info.decls.append(class_decl) - - def add_discovered_free_functions(self) -> None: - """ - Add discovered free function. - - Add free function info objects discovered by pygccxml from - parsing the C++ source code. This is run for modules which set - `use_all_free_functions` to True. No free function info objects were - created for those modules while parsing the package info yaml file. - """ - for module_info in self.package_info.module_info_collection: - if module_info.use_all_free_functions: - free_functions = self.source_ns.free_functions(allow_empty=True) - - for free_function in free_functions: - if module_info.is_decl_in_source_path(free_function): - ff_info = CppFreeFunctionInfo(free_function.name) - ff_info.module_info = module_info - module_info.free_function_info_collection.append(ff_info) - - def add_free_function_decls(self) -> None: + def write_header_collection(self) -> None: """ - Add declarations to free function info objects. - - Update all free function info objects with their corresponding - declarations found by pygccxml in the C++ source code. + Write the header collection to file. """ - for module_info in self.package_info.module_info_collection: - for ff_info in module_info.free_function_info_collection: - decls = self.source_ns.free_functions(ff_info.name, allow_empty=True) - ff_info.decls = [decls[0]] - - def write_header_collection(self) -> None: - """Write the header collection to file.""" header_collection_writer = CppHeaderCollectionWriter( self.package_info, self.wrapper_root, @@ -407,49 +244,38 @@ def write_header_collection(self) -> None: header_collection_writer.write() def write_wrappers(self) -> None: - """Write all the wrappers required for the package.""" - for module_info in self.package_info.module_info_collection: - module_writer = CppModuleWrapperWriter( - module_info, - wrapper_templates.template_collection, - self.wrapper_root, - ) - module_writer.write() + """ + Write the wrapper code for the package. + """ + package_writer = CppPackageWrapperWriter( + self.package_info, wrapper_templates.template_collection, self.wrapper_root + ) + package_writer.write() - def generate_wrapper(self) -> None: - """Parse input yaml and C++ source to generate Python wrappers.""" + def generate(self) -> None: + """ + Parse yaml configuration and C++ source to generate Python wrappers. + """ # Parse the input yaml for package, module, and class information self.parse_package_info() - # Search for header files in the source root - self.collect_source_hpp_files() - - # Map each class to a header file - self.map_classes_to_hpp_files() + # Collect header files, skipping wrappers to avoid pollution + self.package_info.collect_source_headers(restricted_paths=[self.wrapper_root]) - # Attempt to extract templates for each class from the source files - self.extract_templates_from_source() + # Update info objects with data from the source headers + self.package_info.update_from_source() - # Write the header collection to file + # Write the header collection file self.write_header_collection() - # Parse the headers with pygccxml and castxml - self.parse_header_collection() - - # Add discovered classes from the parsed code - self.add_discovered_classes() - - # Add declarations to class info objects - self.add_class_decls() - - # Add discovered free functions from the parsed code - self.add_discovered_free_functions() + # Parse the headers with pygccxml (+ castxml) + self.parse_headers() - # Add declarations to free function info objects - self.add_free_function_decls() + # Update info objects with data from the parsed source namespace + self.package_info.update_from_ns(self.source_ns) # Log list of unknown classes in the source root self.log_unknown_classes() - # Write all the wrappers required + # Write the wrapper code for the package self.write_wrappers() diff --git a/cppwg/input/class_info.py b/cppwg/input/class_info.py index af4c566..60d71f3 100644 --- a/cppwg/input/class_info.py +++ b/cppwg/input/class_info.py @@ -1,8 +1,15 @@ """Class information structure.""" +import logging +import os +import re from typing import Any, Dict, List, Optional +from pygccxml.declarations.matchers import access_type_matcher_t +from pygccxml.declarations.runtime_errors import declaration_not_found_t + from cppwg.input.cpp_type_info import CppTypeInfo +from cppwg.utils import utils class CppClassInfo(CppTypeInfo): @@ -15,14 +22,226 @@ class CppClassInfo(CppTypeInfo): The C++ names of the class e.g. ["Foo<2,2>", "Foo<3,3>"] py_names : List[str] The Python names of the class e.g. ["Foo2_2", "Foo3_3"] + decls : pygccxml.declarations.declaration_t + Declarations for this type's base class, one per template instantiation """ def __init__(self, name: str, class_config: Optional[Dict[str, Any]] = None): - super(CppClassInfo, self).__init__(name, class_config) + super().__init__(name, class_config) self.cpp_names: List[str] = None self.py_names: List[str] = None + self.base_decls: Optional[List["declaration_t"]] = None # noqa: F821 + + def extract_templates_from_source(self) -> None: + """ + Extract template args from the associated source file. + + Search the source file for a class signature matching one of the + template signatures defined in `template_substitutions`. If a match + is found, set the corresponding template arg replacements for the class. + """ + # Skip if there are template args attached directly to the class + if self.template_arg_lists: + return + + # Skip if there is no source file + source_path = self.source_file_full_path + if not source_path: + return + + # Get list of template substitutions applicable to this class + # e.g. [ {"signature":"", "replacement":[[2,2], [3,3]]} ] + substitutions = self.hierarchy_attribute_gather("template_substitutions") + + # Skip if there are no applicable template substitutions + if not substitutions: + return + + source = utils.read_source_file( + source_path, + strip_comments=True, + strip_preprocessor=True, + strip_whitespace=True, + ) + + # Search for template signatures in the source file + for substitution in substitutions: + # Signature e.g. + signature = substitution["signature"].strip() + + class_list = utils.find_classes_in_source( + source, + class_name=self.name, + template_signature=signature, + ) + + if class_list: + self.template_signature = signature + + # Replacement e.g. [[2,2], [3,3]] + self.template_arg_lists = substitution["replacement"] + + # Extract parameters ["A", "B"] from "" + self.template_params = [] + for part in signature.split(","): + param = ( + part.strip() + .replace("<", "") + .replace(">", "") + .split(" ")[1] + .split("=")[0] + .strip() + ) + self.template_params.append(param) + break + + def is_child_of(self, other: "ClassInfo") -> bool: # noqa: F821 + """ + Check if the class is a child of the specified class. + + Parameters + ---------- + other : ClassInfo + The other class to check + + Returns + ------- + bool + True if the class is a child of the specified class, False otherwise + """ + if not self.base_decls: + return False + if not other.decls: + return False + return any(decl in other.decls for decl in self.base_decls) + + def requires(self, other: "ClassInfo") -> bool: # noqa: F821 + """ + Check if the specified class is used in method signatures of this class. + + Parameters + ---------- + other : ClassInfo + The specified class to check. + + Returns + ------- + bool + True if the specified class is used in method signatures of this class. + """ + if not self.decls: + return False + + query = access_type_matcher_t("public") + name_regex = re.compile(r"\b" + re.escape(other.name) + r"\b") + + for class_decl in self.decls: + method_decls = class_decl.member_functions(function=query, allow_empty=True) + for method_decl in method_decls: + for arg_type in method_decl.argument_types: + if name_regex.search(arg_type.decl_string): + return True + + ctor_decls = class_decl.constructors(function=query, allow_empty=True) + for ctor_decl in ctor_decls: + for arg_type in ctor_decl.argument_types: + if name_regex.search(arg_type.decl_string): + return True + return False + + def update_from_ns(self, source_ns: "namespace_t") -> None: # noqa: F821 + """ + Update class with information from the source namespace. + + Adds the class declarations and base class declarations. + + Parameters + ---------- + source_ns : pygccxml.declarations.namespace_t + The source namespace + """ + logger = logging.getLogger() + + # Skip excluded classes + if self.excluded: + return + + self.decls = [] + + for class_cpp_name in self.cpp_names: + class_name = class_cpp_name.replace(" ", "") # e.g. Foo<2,2> + + try: + class_decl = source_ns.class_(class_name) + + except declaration_not_found_t as e1: + if ( + self.template_signature is None + or "=" not in self.template_signature + ): + logger.error(f"Could not find declaration for class {class_name}") + raise e1 + + # If class has default args, try to compress the template signature + logger.warning( + f"Could not find declaration for class {class_name}: trying a partial match." + ) + + # Try to find the class without default template args + # e.g. for template class Foo {}; + # Look for Foo<2> instead of Foo<2,2> + pos = 0 + for i, s in enumerate(self.template_signature.split(",")): + if "=" in s: + pos = i + break + + class_name = ",".join(class_name.split(",")[0:pos]) + " >" + + try: + class_decl = source_ns.class_(class_name) + + except declaration_not_found_t as e2: + logger.error(f"Could not find declaration for class {class_name}") + raise e2 + + logger.info(f"Found {class_name}") + + self.decls.append(class_decl) + + # Update the base class declarations + self.base_decls = [ + base.related_class for decl in self.decls for base in decl.bases + ] + + def update_from_source(self, source_file_paths: List[str]) -> None: + """ + Update class with information from the source headers. + + Parameters + ---------- + source_file_paths : List[str] + A list of source file paths + """ + # Skip excluded classes + if self.excluded: + return + + # Map class to a source file, assuming the file name is the class name + for file_path in source_file_paths: + file_name = os.path.basename(file_path) + if self.name == os.path.splitext(file_name)[0]: + self.source_file_full_path = file_path + if self.source_file is None: + self.source_file = file_name + + # Extract template args from the source file + self.extract_templates_from_source() + + # Update the C++ and Python class names + self.update_names() def update_py_names(self) -> None: """ @@ -116,11 +335,15 @@ def update_cpp_names(self) -> None: self.cpp_names.append(self.name + template_string) def update_names(self) -> None: - """Update the C++ and Python names for the class.""" + """ + Update the C++ and Python names for the class. + """ self.update_cpp_names() self.update_py_names() @property def parent(self) -> "ModuleInfo": # noqa: F821 - """Returns the parent module info object.""" + """ + Returns the parent module info object. + """ return self.module_info diff --git a/cppwg/input/cpp_type_info.py b/cppwg/input/cpp_type_info.py index 3260d85..1c89538 100644 --- a/cppwg/input/cpp_type_info.py +++ b/cppwg/input/cpp_type_info.py @@ -31,7 +31,7 @@ class CppTypeInfo(BaseInfo): def __init__(self, name: str, type_config: Optional[Dict[str, Any]] = None): - super(CppTypeInfo, self).__init__(name) + super().__init__(name) self.module_info: Optional["ModuleInfo"] = None # noqa: F821 self.source_file_full_path: Optional[str] = None diff --git a/cppwg/input/free_function_info.py b/cppwg/input/free_function_info.py index 9879011..5f69134 100644 --- a/cppwg/input/free_function_info.py +++ b/cppwg/input/free_function_info.py @@ -12,9 +12,23 @@ def __init__( self, name: str, free_function_config: Optional[Dict[str, Any]] = None ): - super(CppFreeFunctionInfo, self).__init__(name, free_function_config) + super().__init__(name, free_function_config) @property def parent(self) -> "ModuleInfo": # noqa: F821 """Returns the parent module info object.""" return self.module_info + + def update_from_ns(self, source_ns: "namespace_t") -> None: # noqa: F821 + """ + Update with information from the source namespace. + + Adds the free function declaration. + + Parameters + ---------- + source_ns : pygccxml.declarations.namespace_t + The source namespace + """ + ff_decls = source_ns.free_functions(self.name, allow_empty=True) + self.decls = [ff_decls[0]] diff --git a/cppwg/input/info_helper.py b/cppwg/input/info_helper.py deleted file mode 100644 index 71b00bc..0000000 --- a/cppwg/input/info_helper.py +++ /dev/null @@ -1,115 +0,0 @@ -"""Helper utilities for info structures.""" - -import logging -import os -from typing import Any, Dict, List - -from cppwg.input.base_info import BaseInfo -from cppwg.input.class_info import CppClassInfo -from cppwg.input.module_info import ModuleInfo -from cppwg.utils import utils - - -class CppInfoHelper: - """ - Adds information extracted from C++ source code to info objects. - - Helper class that attempts to automatically fill in extra feature - information based on simple analysis of the source tree. - - __________ - Attributes - __________ - module_info : ModuleInfo - The module info object that this helper is working with. - class_dict : dict - A dictionary of class info objects keyed by class name. - """ - - def __init__(self, module_info: ModuleInfo): - - self.module_info: ModuleInfo = module_info - - # For convenience, collect class info in a dict keyed by name - self.class_dict: Dict[str, CppClassInfo] = { - class_info.name: class_info - for class_info in module_info.class_info_collection - } - - def extract_templates_from_source(self, feature_info: BaseInfo) -> None: - """ - Get template args from the source file associated with an info object. - - __________ - Parameters - __________ - feature_info : BaseInfo - The feature info object to expand. - """ - logger = logging.getLogger() - - if not isinstance(feature_info, CppClassInfo): - logger.error(f"Unsupported feature type: {type(feature_info)}") - raise TypeError() - - # Skip if there are pre-defined template args - if feature_info.template_arg_lists: - return - - # Skip if there is no source file - source_path = feature_info.source_file_full_path - if not source_path: - return - - if not os.path.isfile(source_path): - logger.error(f"Could not find source file: {source_path}") - raise FileNotFoundError() - - # Get list of template substitutions from this feature and its parents - # e.g. {"signature":"","replacement":[[2,2], [3,3]]} - template_substitutions: List[Dict[str, Any]] = ( - feature_info.hierarchy_attribute_gather("template_substitutions") - ) - - # Skip if there are no template substitutions - if len(template_substitutions) == 0: - return - - source = utils.read_source_file( - source_path, - strip_comments=True, - strip_preprocessor=True, - strip_whitespace=True, - ) - - # Search for template signatures in the source file - for template_substitution in template_substitutions: - # Signature e.g. - signature = template_substitution["signature"].strip() - - class_list = utils.find_classes_in_source( - source, - class_name=feature_info.name, - template_signature=signature, - ) - - if class_list: - # Replacement e.g. [[2,2], [3,3]] - replacement = template_substitution["replacement"] - - feature_info.template_arg_lists = replacement - feature_info.template_signature = signature - - # Extract ["DIM_A", "DIM_B"] from "" - template_params = [] - for tp in signature.split(","): - template_params.append( - tp.strip() - .replace("<", "") - .replace(">", "") - .split(" ")[1] - .split("=")[0] - .strip() - ) - feature_info.template_params = template_params - break diff --git a/cppwg/input/method_info.py b/cppwg/input/method_info.py index 49e6109..2077d3b 100644 --- a/cppwg/input/method_info.py +++ b/cppwg/input/method_info.py @@ -17,7 +17,7 @@ class CppMethodInfo(CppTypeInfo): def __init__(self, name: str, _): - super(CppMethodInfo, self).__init__(name) + super().__init__(name) self.class_info: Optional["CppClassInfo"] = None # noqa: F821 diff --git a/cppwg/input/module_info.py b/cppwg/input/module_info.py index ac2c792..88bd064 100644 --- a/cppwg/input/module_info.py +++ b/cppwg/input/module_info.py @@ -1,9 +1,12 @@ """Module information structure.""" import os +from pathlib import Path from typing import Any, Dict, List, Optional from cppwg.input.base_info import BaseInfo +from cppwg.input.class_info import CppClassInfo +from cppwg.input.free_function_info import CppFreeFunctionInfo class ModuleInfo(BaseInfo): @@ -32,7 +35,7 @@ class ModuleInfo(BaseInfo): def __init__(self, name: str, module_config: Optional[Dict[str, Any]] = None): - super(ModuleInfo, self).__init__(name) + super().__init__(name) self.package_info: Optional["PackageInfo"] = None # noqa: F821 self.source_locations: List[str] = None @@ -49,7 +52,9 @@ def __init__(self, name: str, module_config: Optional[Dict[str, Any]] = None): @property def parent(self) -> "PackageInfo": # noqa: F821 - """Returns the parent package info object.""" + """ + Returns the associated package info object. + """ return self.package_info def is_decl_in_source_path(self, decl: "declaration_t") -> bool: # noqa: F821 @@ -71,7 +76,102 @@ def is_decl_in_source_path(self, decl: "declaration_t") -> bool: # noqa: F821 for source_location in self.source_locations: full_path = os.path.join(self.package_info.source_root, source_location) - if full_path in decl.location.file_name: + if Path(full_path) in Path(decl.location.file_name).parents: return True return False + + def sort_classes(self) -> None: + """ + Sort the class info collection in order of dependence. + """ + self.class_info_collection.sort(key=lambda x: x.name) + + i = 0 + n = len(self.class_info_collection) + while i < n - 1: + cls_i = self.class_info_collection[i] + ii = i # Tracks destination of cls_i + j_pos = [] # Tracks positions of cls_i's dependents + + for j in range(i + 1, n): + cls_j = self.class_info_collection[j] + i_req_j = cls_i.requires(cls_j) + j_req_i = cls_j.requires(cls_i) + if cls_i.is_child_of(cls_j) or (i_req_j and not j_req_i): + # Position cls_i after all classes it depends on, + # ignoring forward declaration cycles + ii = j + elif cls_j.is_child_of(cls_i) or (j_req_i and not i_req_j): + # Collect positions of cls_i's dependents + j_pos.append(j) + + if ii <= i: + i += 1 + continue # No change in position + + # Move cls_i into new position ii + cls_i = self.class_info_collection.pop(i) + self.class_info_collection.insert(ii, cls_i) + + # Move dependents into positions after ii + for idx, j in enumerate(j_pos): + if j > ii: + break # Rest of dependents are already positioned after ii + cls_j = self.class_info_collection.pop(j - 1 - idx) + self.class_info_collection.insert(ii + idx, cls_j) + + def update_from_ns(self, source_ns: "namespace_t") -> None: # noqa: F821 + """ + Update module with information from the source namespace. + + Parameters + ---------- + source_ns : pygccxml.declarations.namespace_t + The source namespace + """ + # Add discovered classes: if `use_all_classes` is True, this module + # has no class info objects. Use class declarations from the + # source namespace to create class info objects. + if self.use_all_classes: + class_decls = source_ns.classes(allow_empty=True) + for class_decl in class_decls: + if self.is_decl_in_source_path(class_decl): + class_info = CppClassInfo(class_decl.name) + class_info.update_names() + class_info.module_info = self + self.class_info_collection.append(class_info) + + # Update classes with information from source namespace. + for class_info in self.class_info_collection: + class_info.update_from_ns(source_ns) + + # Sort classes by dependence + self.sort_classes() + + # Add discovered free functions: if `use_all_free_functions` is True, + # this module has no free function info objects. Use free function + # decls from the source namespace to create free function info objects. + if self.use_all_free_functions: + free_functions = source_ns.free_functions(allow_empty=True) + for free_function in free_functions: + if self.is_decl_in_source_path(free_function): + ff_info = CppFreeFunctionInfo(free_function.name) + ff_info.module_info = self + self.free_function_info_collection.append(ff_info) + + # Update free functions with information from source namespace. + for ff_info in self.free_function_info_collection: + ff_info.update_from_ns(source_ns) + + def update_from_source(self, source_file_paths: List[str]) -> None: + """ + Update module with information from the source headers. + + Parameters + ---------- + source_files : List[str] + A list of source file paths. + """ + for class_info in self.class_info_collection: + class_info.update_from_source(source_file_paths) diff --git a/cppwg/input/package_info.py b/cppwg/input/package_info.py index fdbe78c..f90f24a 100644 --- a/cppwg/input/package_info.py +++ b/cppwg/input/package_info.py @@ -1,8 +1,13 @@ """Package information structure.""" +import fnmatch +import logging +import os +from pathlib import Path from typing import Any, Dict, List, Optional from cppwg.input.base_info import BaseInfo +from cppwg.utils.constants import CPPWG_EXT class PackageInfo(BaseInfo): @@ -50,7 +55,7 @@ def __init__( package_config : Dict[str, Any] A dictionary of package configuration settings """ - super(PackageInfo, self).__init__(name) + super().__init__(name) self.name: str = name self.source_locations: List[str] = None @@ -69,3 +74,58 @@ def __init__( def parent(self) -> None: """Returns None as this is the top level object in the hierarchy.""" return None + + def collect_source_headers(self, restricted_paths: List[str]) -> None: + """ + Collect header files from the source root. + + Walk through the source root and add any files matching the provided + source file patterns e.g. "*.hpp". + + Parameters + ---------- + restricted_paths : List[str] + A list of restricted paths to skip when collecting header files. + """ + logger = logging.getLogger() + + for root, _, filenames in os.walk(self.source_root, followlinks=True): + for pattern in self.source_hpp_patterns: + for filename in fnmatch.filter(filenames, pattern): + filepath = os.path.abspath(os.path.join(root, filename)) + + # Skip files in restricted paths + for restricted_path in restricted_paths: + if Path(restricted_path) in Path(filepath).parents: + continue + + # Skip files with the extensions like .cppwg.hpp + suffix = os.path.splitext(os.path.splitext(filename)[0])[1] + if suffix == CPPWG_EXT: + continue + + self.source_hpp_files.append(filepath) + + # Check if any source files were found + if not self.source_hpp_files: + logger.error(f"No header files found in source root: {self.source_root}") + raise FileNotFoundError() + + def update_from_source(self) -> None: + """ + Update modules with information from the source headers. + """ + for module_info in self.module_info_collection: + module_info.update_from_source(self.source_hpp_files) + + def update_from_ns(self, source_ns: "namespace_t") -> None: # noqa: F821 + """ + Update modules with information from the parsed source namespace. + + Parameters + ---------- + source_ns : pygccxml.declarations.namespace_t + The source namespace + """ + for module_info in self.module_info_collection: + module_info.update_from_ns(source_ns) diff --git a/cppwg/input/variable_info.py b/cppwg/input/variable_info.py index 3cfd792..541c246 100644 --- a/cppwg/input/variable_info.py +++ b/cppwg/input/variable_info.py @@ -10,4 +10,4 @@ class CppVariableInfo(CppTypeInfo): def __init__(self, name: str, variable_config: Optional[Dict[str, Any]] = None): - super(CppVariableInfo, self).__init__(name, variable_config) + super().__init__(name, variable_config) diff --git a/cppwg/version.py b/cppwg/version.py new file mode 100644 index 0000000..ef4a8c9 --- /dev/null +++ b/cppwg/version.py @@ -0,0 +1,5 @@ +"""Version information.""" + +from importlib import metadata + +__version__ = metadata.version("cppwg") diff --git a/cppwg/writers/class_writer.py b/cppwg/writers/class_writer.py index cf9d22b..db69ea1 100644 --- a/cppwg/writers/class_writer.py +++ b/cppwg/writers/class_writer.py @@ -4,7 +4,8 @@ import os from typing import Dict, List -from pygccxml import declarations +from pygccxml.declarations import type_traits_classes +from pygccxml.declarations.matchers import access_type_matcher_t from cppwg.utils.constants import ( CPPWG_CLASS_OVERRIDE_SUFFIX, @@ -44,7 +45,7 @@ def __init__( ) -> None: logger = logging.getLogger() - super(CppClassWrapperWriter, self).__init__(wrapper_templates) + super().__init__(wrapper_templates) self.class_info: "CppClassInfo" = class_info # noqa: F821 @@ -258,7 +259,7 @@ def write(self, work_dir: str) -> None: # struct Foo{ # enum Value{A, B, C}; # }; - if declarations.is_struct(class_decl): + if type_traits_classes.is_struct(class_decl): enums = class_decl.enumerations(allow_empty=True) if len(enums) == 1: @@ -326,7 +327,7 @@ def write(self, work_dir: str) -> None: self.cpp_string += class_definition_template.format(**class_definition_dict) # Add public constructors - query = declarations.access_type_matcher_t("public") + query = access_type_matcher_t("public") for constructor in class_decl.constructors( function=query, allow_empty=True ): @@ -339,7 +340,7 @@ def write(self, work_dir: str) -> None: self.cpp_string += constructor_writer.generate_wrapper() # Add public member functions - query = declarations.access_type_matcher_t("public") + query = access_type_matcher_t("public") for member_function in class_decl.member_functions( function=query, allow_empty=True ): diff --git a/cppwg/writers/constructor_writer.py b/cppwg/writers/constructor_writer.py index d8b4281..5994420 100644 --- a/cppwg/writers/constructor_writer.py +++ b/cppwg/writers/constructor_writer.py @@ -3,7 +3,7 @@ import re from typing import Dict -from pygccxml import declarations +from pygccxml.declarations import type_traits, type_traits_classes from cppwg.writers.base_writer import CppBaseWrapperWriter @@ -40,7 +40,7 @@ def __init__( wrapper_templates: Dict[str, str], ) -> None: - super(CppConstructorWrapperWriter, self).__init__(wrapper_templates) + super().__init__(wrapper_templates) self.class_info: "CppClassInfo" = class_info # noqa: F821 self.ctor_decl: "constructor_t" = ctor_decl # noqa: F821 @@ -87,9 +87,9 @@ def exclude(self) -> bool: if self.ctor_decl.parent != self.class_decl: return True - # Exclude default copy constructors e.g. Foo::Foo(Foo const & foo) + # Exclude compiler-added copy constructors e.g. Foo::Foo(Foo const & foo) if ( - declarations.is_copy_constructor(self.ctor_decl) + type_traits_classes.is_copy_constructor(self.ctor_decl) and self.ctor_decl.is_artificial ): return True @@ -200,11 +200,8 @@ def generate_wrapper(self) -> str: # `Foo(std::vector laminas = std::vector{})` # which generates `py::arg("laminas") = std::vector{}` if default_value.replace(" ", "") == "{}": - default_value = arg.decl_type.decl_string + " {}" - - # Remove const keyword - default_value = re.sub(r"\bconst\b", "", default_value) - default_value = default_value.replace(" ", " ") + decl_type = type_traits.remove_const(arg.decl_type) + default_value = decl_type.decl_string + " {}" keyword_args += f" = {default_value}" diff --git a/cppwg/writers/free_function_writer.py b/cppwg/writers/free_function_writer.py index c3f29ac..2c87ef4 100644 --- a/cppwg/writers/free_function_writer.py +++ b/cppwg/writers/free_function_writer.py @@ -22,7 +22,7 @@ class CppFreeFunctionWrapperWriter(CppBaseWrapperWriter): def __init__(self, free_function_info, wrapper_templates) -> None: - super(CppFreeFunctionWrapperWriter, self).__init__(wrapper_templates) + super().__init__(wrapper_templates) self.free_function_info: CppFreeFunctionInfo = free_function_info self.wrapper_templates: Dict[str, str] = wrapper_templates diff --git a/cppwg/writers/method_writer.py b/cppwg/writers/method_writer.py index 6309c2b..29ce8a7 100644 --- a/cppwg/writers/method_writer.py +++ b/cppwg/writers/method_writer.py @@ -3,7 +3,7 @@ import re from typing import Dict -from pygccxml import declarations +from pygccxml.declarations import type_traits from cppwg.writers.base_writer import CppBaseWrapperWriter @@ -40,7 +40,7 @@ def __init__( wrapper_templates: Dict[str, str], ) -> None: - super(CppMethodWrapperWriter, self).__init__(wrapper_templates) + super().__init__(wrapper_templates) self.class_info: "CppClassInfo" = class_info # noqa: F821 self.method_decl: "member_function_t" = method_decl # noqa: F821 @@ -129,7 +129,7 @@ def generate_wrapper(self) -> str: # Pybind11 def type e.g. "_static" for def_static() def_adorn = "" if self.method_decl.has_static: - def_adorn += "_static" + def_adorn = "_static" # How to point to class if self.method_decl.has_static: @@ -178,12 +178,12 @@ def generate_wrapper(self) -> str: # Call policy, e.g. "py::return_value_policy::reference" call_policy = "" - if declarations.is_pointer(self.method_decl.return_type): + if type_traits.is_pointer(self.method_decl.return_type): ptr_policy = self.class_info.hierarchy_attribute("pointer_call_policy") if ptr_policy: call_policy = f", py::return_value_policy::{ptr_policy}" - elif declarations.is_reference(self.method_decl.return_type): + elif type_traits.is_reference(self.method_decl.return_type): ref_policy = self.class_info.hierarchy_attribute("reference_call_policy") if ref_policy: call_policy = f", py::return_value_policy::{ref_policy}" diff --git a/cppwg/writers/package_writer.py b/cppwg/writers/package_writer.py new file mode 100644 index 0000000..b311b43 --- /dev/null +++ b/cppwg/writers/package_writer.py @@ -0,0 +1,42 @@ +"""Wrapper code writer for the package.""" + +from typing import Dict + +from cppwg.writers.module_writer import CppModuleWrapperWriter + + +class CppPackageWrapperWriter: + """ + Class to generates Python bindings for all modules in the package. + + Attributes + ---------- + package_info : PackageInfo + The package information to generate Python bindings for + wrapper_templates : Dict[str, str] + String templates with placeholders for generating wrapper code + wrapper_root : str + The output directory for the generated wrapper code + """ + + def __init__( + self, + package_info: "PackageInfo", # noqa: F821 + wrapper_templates: Dict[str, str], + wrapper_root: str, + ): + self.package_info = package_info + self.wrapper_templates = wrapper_templates + self.wrapper_root = wrapper_root + + def write(self) -> None: + """ + Write all the wrappers required for the package. + """ + for module_info in self.package_info.module_info_collection: + module_writer = CppModuleWrapperWriter( + module_info, + self.wrapper_templates, + self.wrapper_root, + ) + module_writer.write() diff --git a/examples/shapes/wrapper/package_info.yaml b/examples/shapes/wrapper/package_info.yaml index 0a10610..e41e0c2 100644 --- a/examples/shapes/wrapper/package_info.yaml +++ b/examples/shapes/wrapper/package_info.yaml @@ -67,17 +67,17 @@ modules: - name: primitives source_locations: classes: - - name: Shape - name: Cuboid - name: Rectangle + - name: Shape - name: Triangle excluded: True # Exclude this class from wrapping. - name: mesh source_locations: classes: - - name: AbstractMesh - name: ConcreteMesh + - name: AbstractMesh # Text to add at the top of all wrappers prefix_text: | diff --git a/examples/shapes/wrapper/primitives/primitives.main.cpp b/examples/shapes/wrapper/primitives/primitives.main.cpp index f90aac5..b691509 100644 --- a/examples/shapes/wrapper/primitives/primitives.main.cpp +++ b/examples/shapes/wrapper/primitives/primitives.main.cpp @@ -5,8 +5,8 @@ #include "wrapper_header_collection.hpp" #include "Shape2.cppwg.hpp" #include "Shape3.cppwg.hpp" -#include "Cuboid.cppwg.hpp" #include "Rectangle.cppwg.hpp" +#include "Cuboid.cppwg.hpp" namespace py = pybind11; @@ -14,6 +14,6 @@ PYBIND11_MODULE(_pyshapes_primitives, m) { register_Shape2_class(m); register_Shape3_class(m); - register_Cuboid_class(m); register_Rectangle_class(m); + register_Cuboid_class(m); } diff --git a/examples/shapes/wrapper/wrapper_header_collection.hpp b/examples/shapes/wrapper/wrapper_header_collection.hpp index ca26ea3..6bfa1af 100644 --- a/examples/shapes/wrapper/wrapper_header_collection.hpp +++ b/examples/shapes/wrapper/wrapper_header_collection.hpp @@ -21,10 +21,10 @@ template class Point<2>; template class Point<3>; template class Shape<2>; template class Shape<3>; -template class AbstractMesh<2,2>; -template class AbstractMesh<3,3>; template class ConcreteMesh<2>; template class ConcreteMesh<3>; +template class AbstractMesh<2,2>; +template class AbstractMesh<3,3>; // Typedefs for nicer naming namespace cppwg @@ -33,10 +33,10 @@ typedef Point<2> Point2; typedef Point<3> Point3; typedef Shape<2> Shape2; typedef Shape<3> Shape3; -typedef AbstractMesh<2,2> AbstractMesh2_2; -typedef AbstractMesh<3,3> AbstractMesh3_3; typedef ConcreteMesh<2> ConcreteMesh2; typedef ConcreteMesh<3> ConcreteMesh3; +typedef AbstractMesh<2,2> AbstractMesh2_2; +typedef AbstractMesh<3,3> AbstractMesh3_3; } // namespace cppwg #endif // pyshapes_HEADERS_HPP_ diff --git a/pyproject.toml b/pyproject.toml index 6856659..f8e2429 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,10 +12,10 @@ authors = [ license = { file = "LICENSE" } keywords = ["C++", "Python", "Pybind11"] readme = "README.md" -version = "0.0.1-alpha" +version = "0.2.1" classifiers = [ - "Development Status :: 3 - Alpha", + "Development Status :: 4 - Beta", "Environment :: Console", "Intended Audience :: Developers", "Operating System :: MacOS :: MacOS X",