diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 68f4d45..5b43891 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -46,16 +46,16 @@ jobs: - name: Generate new wrappers run: | - cd examples - rm -rf shapes/wrapper/geometry \ - shapes/wrapper/math_funcs \ - shapes/wrapper/primitives - python shapes/wrapper/generate.py \ - --source_root shapes/src/ \ - --wrapper_root shapes/wrapper/ \ - --castxml_binary $(which castxml) \ - --package_info shapes/wrapper/package_info.yaml \ - --includes shapes/src/ + cd examples/shapes/wrapper + rm -rf geometry math_funcs primitives + cd .. + cppwg src/ \ + --wrapper_root wrapper/ \ + --package_info wrapper/package_info.yaml \ + --include src/geometry/ \ + --include src/math_funcs/ \ + --include src/primitives/ \ + --include src/python/ - name: Build Python module run: | diff --git a/README.md b/README.md index c4438ff..980e258 100644 --- a/README.md +++ b/README.md @@ -96,21 +96,19 @@ pip install . To generate the full `pyshapes` wrapper, do: ```bash -python examples/shapes/wrapper/generate.py \ - --source_root examples/shapes/src/ \ - --wrapper_root examples/shapes/wrapper/ \ - --castxml_binary /path/to/bin/castxml \ - --package_info examples/shapes/wrapper/package_info.yaml \ - --includes examples/shapes/src/ +cd examples/shapes +cppwg src/ \ + --wrapper_root wrapper/ \ + --package_info wrapper/package_info.yaml \ + --include src/geometry/ \ + --include src/math_funcs/ \ + --include src/primitives/ \ + --include src/python/ ``` -where `/path/to/bin/castxml` is the path to your castxml installation. -If it is on the `PATH`, you can find it with `which castxml`. - To build the example package do: ```bash -cd examples/shapes mkdir build cd build cmake .. diff --git a/cppwg/__main__.py b/cppwg/__main__.py new file mode 100644 index 0000000..1af82a7 --- /dev/null +++ b/cppwg/__main__.py @@ -0,0 +1,102 @@ +"""Entry point for the cppwg package.""" + +import argparse +import logging + +from cppwg import CppWrapperGenerator + + +def parse_args() -> argparse.Namespace: + """ + Parse command line arguments. + + Returns + ------- + argparse.Namespace: The parsed command line arguments. + """ + parser = argparse.ArgumentParser( + prog="cppwg", + description="Generate Python Wrappers for C++ code", + ) + + parser.add_argument( + "source_root", + metavar="SOURCE_ROOT", + type=str, + help="Path to the root directory of the input C++ source code.", + ) + + parser.add_argument( + "-w", + "--wrapper_root", + type=str, + help="Path to the output directory for the Pybind11 wrapper code.", + ) + + parser.add_argument( + "-p", "--package_info", type=str, help="Path to the package info file." + ) + + parser.add_argument( + "-c", + "--castxml_binary", + type=str, + help="Path to the castxml executable.", + ) + + parser.add_argument( + "-f", + "--castxml_cflags", + type=str, + help="Extra cflags for CastXML e.g. '-std=c++17'.", + ) + + parser.add_argument( + "-i", + "--include", + type=str, + action="append", + help="Paths to include directories.", + ) + + args = parser.parse_args() + + return args + + +def generate(args: argparse.Namespace) -> None: + """ + Generate the Python wrappers. + + Parameters + ---------- + args : argparse.Namespace + The parsed command line arguments. + """ + generator = CppWrapperGenerator( + source_root=args.source_root, + source_includes=args.include, + wrapper_root=args.wrapper_root, + package_info_path=args.package_info, + castxml_binary=args.castxml_binary, + castxml_cflags=args.castxml_cflags, + ) + + generator.generate_wrapper() + + +def main() -> None: + """Generate wrappers from command line arguments.""" + logging.basicConfig( + format="%(levelname)s %(message)s", + handlers=[logging.FileHandler("filename.log"), logging.StreamHandler()], + ) + logger = logging.getLogger() + logger.setLevel(logging.INFO) + + args = parse_args() + generate(args) + + +if __name__ == "__main__": + main() diff --git a/cppwg/generators.py b/cppwg/generators.py index 9bbbf08..7aa30de 100644 --- a/cppwg/generators.py +++ b/cppwg/generators.py @@ -5,11 +5,12 @@ import os import re import subprocess +import uuid from pathlib import Path from typing import List, Optional +import pygccxml.utils from pygccxml import __version__ as pygccxml_version -from pygccxml.declarations.namespace import namespace_t from cppwg.input.class_info import CppClassInfo from cppwg.input.free_function_info import CppFreeFunctionInfo @@ -18,7 +19,11 @@ from cppwg.parsers.package_info_parser import PackageInfoParser from cppwg.parsers.source_parser import CppSourceParser from cppwg.templates import pybind11_default as wrapper_templates -from cppwg.utils.constants import CPPWG_EXT, CPPWG_HEADER_COLLECTION_FILENAME +from cppwg.utils.constants import ( + CPPWG_DEFAULT_WRAPPER_DIR, + CPPWG_EXT, + CPPWG_HEADER_COLLECTION_FILENAME, +) from cppwg.writers.header_collection_writer import CppHeaderCollectionWriter from cppwg.writers.module_writer import CppModuleWrapperWriter @@ -36,14 +41,12 @@ class CppWrapperGenerator: wrapper_root : str The output directory for the wrapper code castxml_binary : str - The path to the CastXML binary + The path to the castxml binary castxml_cflags : str - Optional cflags to be passed to CastXML e.g. "-std=c++17" + Optional cflags to be passed to castxml e.g. "-std=c++17" package_info_path : str The path to the package info yaml config file; defaults to "package_info.yaml" - source_hpp_files : List[str] - The list of C++ source header files - source_ns : namespace_t + source_ns : pygccxml.declarations.namespace_t The namespace containing C++ declarations parsed from the source tree package_info : PackageInfo A data structure containing the information parsed from package_info_path @@ -54,16 +57,50 @@ def __init__( source_root: str, source_includes: Optional[List[str]] = None, wrapper_root: Optional[str] = None, - castxml_binary: Optional[str] = "castxml", + castxml_binary: Optional[str] = None, package_info_path: Optional[str] = None, - castxml_cflags: Optional[str] = "-std=c++17", + castxml_cflags: Optional[str] = None, ): - logging.basicConfig( - format="%(levelname)s %(message)s", - handlers=[logging.FileHandler("filename.log"), logging.StreamHandler()], - ) logger = logging.getLogger() - logger.setLevel(logging.INFO) + + # Check that castxml_binary exists and is executable + self.castxml_binary: str = "" + + if castxml_binary: + if os.path.isfile(castxml_binary) and os.access(castxml_binary, os.X_OK): + self.castxml_binary = castxml_binary + else: + logger.warning( + "Could not find specified castxml binary. Searching on path." + ) + + # Search for castxml_binary + if not self.castxml_binary: + path_to_castxml, _ = pygccxml.utils.find_xml_generator(name="castxml") + + if path_to_castxml: + self.castxml_binary = path_to_castxml + logger.info(f"Found castxml binary: {self.castxml_binary}") + else: + logger.error("Could not find a castxml binary.") + raise FileNotFoundError() + + # Check castxml and pygccxml versions + castxml_version: str = ( + subprocess.check_output([self.castxml_binary, "--version"]) + .decode("ascii") + .strip() + ) + castxml_version = re.search( + r"castxml version \d+\.\d+\.\d+", castxml_version + ).group(0) + logger.info(castxml_version) + logger.info(f"pygccxml version {pygccxml_version}") + + # Sanitize castxml_cflags + self.castxml_cflags: str = "" + if castxml_cflags: + self.castxml_cflags = castxml_cflags # Sanitize source_root self.source_root: str = os.path.abspath(source_root) @@ -72,21 +109,20 @@ def __init__( raise FileNotFoundError() # Sanitize wrapper_root - self.wrapper_root: str # type hinting + self.wrapper_root: str = "" + if wrapper_root: - # Create the specified wrapper root directory if it doesn't exist self.wrapper_root = os.path.abspath(wrapper_root) - if not os.path.isdir(self.wrapper_root): - logger.info( - f"Could not find wrapper root directory - creating it at {self.wrapper_root}" - ) - os.makedirs(self.wrapper_root) else: - self.wrapper_root = self.source_root - logger.info( - "Wrapper root not specified - using source_root: {self.source_root}" - ) + wrapper_dirname = CPPWG_DEFAULT_WRAPPER_DIR + "_" + uuid.uuid4().hex[:8] + self.wrapper_root = os.path.join(self.source_root, wrapper_dirname) + logger.info(f"Wrapper root not specified - using {self.wrapper_root}") + + if not os.path.isdir(self.wrapper_root): + # Create the wrapper root directory if it doesn't exist + logger.info(f"Creating wrapper root directory: {self.wrapper_root}") + os.makedirs(self.wrapper_root) # Sanitize source_includes self.source_includes: List[str] # type hinting @@ -108,13 +144,13 @@ def __init__( self.package_info_path: Optional[str] = None if package_info_path: # If a package info config file is specified, check that it exists - self.package_info_path = package_info_path + self.package_info_path = os.path.abspath(package_info_path) if not os.path.isfile(package_info_path): logger.error(f"Could not find package info file: {package_info_path}") raise FileNotFoundError() else: # If no package info config file has been supplied, check the default - default_package_info_file = os.path.abspath("./package_info.yaml") + default_package_info_file = os.path.join(os.getcwd(), "package_info.yaml") if os.path.isfile(default_package_info_file): self.package_info_path = default_package_info_file logger.info( @@ -123,25 +159,8 @@ def __init__( else: logger.warning("No package info file found - using default settings.") - # Check castxml and pygccxml versions - self.castxml_binary: str = castxml_binary - castxml_version: str = ( - subprocess.check_output([self.castxml_binary, "--version"]) - .decode("ascii") - .strip() - ) - castxml_version = re.search( - r"castxml version \d+\.\d+\.\d+", castxml_version - ).group(0) - logger.info(castxml_version) - logger.info(f"pygccxml version {pygccxml_version}") - - self.castxml_cflags: str = castxml_cflags - # Initialize remaining attributes - self.source_hpp_files: List[str] = [] - - self.source_ns: Optional[namespace_t] = None + self.source_ns: Optional[pygccxml.declarations.namespace_t] = None self.package_info: Optional[PackageInfo] = None @@ -173,6 +192,11 @@ def collect_source_hpp_files(self) -> None: 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: @@ -200,7 +224,7 @@ def parse_header_collection(self) -> None: """ Parse the hpp files to collect C++ declarations. - Parse the headers with pygccxml and CastXML to populate the source + Parse the headers with pygccxml and castxml to populate the source namespace with C++ declarations collected from the source tree. """ source_parser = CppSourceParser( @@ -324,7 +348,7 @@ def generate_wrapper(self) -> None: # Write the header collection to file self.write_header_collection() - # Parse the headers with pygccxml and CastXML + # Parse the headers with pygccxml and castxml self.parse_header_collection() # Update the Class Info from the parsed code diff --git a/cppwg/utils/constants.py b/cppwg/utils/constants.py index 8bca1d2..686eb40 100644 --- a/cppwg/utils/constants.py +++ b/cppwg/utils/constants.py @@ -8,3 +8,5 @@ CPPWG_TRUE_STRINGS = ["ON", "YES", "Y", "TRUE", "T"] CPPWG_FALSE_STRINGS = ["OFF", "NO", "N", "FALSE", "F"] + +CPPWG_DEFAULT_WRAPPER_DIR = "cppwg_wrappers" diff --git a/examples/shapes/wrapper/generate.py b/examples/shapes/wrapper/generate.py deleted file mode 100644 index 1f7ede3..0000000 --- a/examples/shapes/wrapper/generate.py +++ /dev/null @@ -1,71 +0,0 @@ -#!/usr/bin/env python - -""" -This script generates the wrapper code -""" - -import os -from argparse import ArgumentParser -from glob import glob - -from cppwg import CppWrapperGenerator - - -def generate_wrapper_code( - source_root, wrapper_root, castxml_binary, package_info_path, includes -): - - generator = CppWrapperGenerator( - source_root, includes, wrapper_root, castxml_binary, package_info_path - ) - generator.generate_wrapper() - - -if __name__ == "__main__": - - parser = ArgumentParser() - parser.add_argument( - "--source_root", - "-s", - type=str, - help="Root of the source directory.", - default=os.getcwd(), - ) - parser.add_argument( - "--wrapper_root", - "-w", - type=str, - help="Root of the wrapper directory.", - default=os.getcwd(), - ) - parser.add_argument( - "--castxml_binary", - "-c", - type=str, - help="Path to the castxml binary.", - default="castxml", - ) - parser.add_argument( - "--package_info", - "-p", - type=str, - help="Path to the package info file.", - default=os.getcwd() + "/package_info.yaml", - ) - parser.add_argument( - "--includes", - "-i", - type=str, - help="Path to the includes directory.", - default=os.getcwd(), - ) - args = parser.parse_args() - - all_includes = glob(args.includes + "/*/") - generate_wrapper_code( - args.source_root, - args.wrapper_root, - args.castxml_binary, - args.package_info, - all_includes, - ) diff --git a/tests/test_shapes.py b/tests/test_shapes.py index 49d205c..3e89056 100644 --- a/tests/test_shapes.py +++ b/tests/test_shapes.py @@ -1,6 +1,7 @@ import os import subprocess import unittest +from glob import glob from typing import List @@ -59,7 +60,7 @@ def test_wrapper_generation(self) -> None: """ # Set paths to the shapes code, reference and generated wrappers, etc. - shapes_root = os.path.abspath("./examples/shapes") + shapes_root = os.path.abspath("examples/shapes") shapes_src = os.path.join(shapes_root, "src") wrapper_root_ref = os.path.join(shapes_root, "wrapper") @@ -69,33 +70,26 @@ def test_wrapper_generation(self) -> None: self.assertTrue(os.path.isdir(shapes_src)) self.assertTrue(os.path.isdir(wrapper_root_ref)) - generate_script = os.path.join(wrapper_root_ref, "generate.py") + generate_script = os.path.abspath("cppwg/__main__.py") package_info_path = os.path.join(wrapper_root_ref, "package_info.yaml") - - self.assertTrue(os.path.isfile(generate_script)) self.assertTrue(os.path.isfile(package_info_path)) - castxml_path = ( - subprocess.check_output(["which", "castxml"]).decode("ascii").strip() - ) + includes = [] + for dirname in glob(shapes_src + "/*/"): + includes.extend(["--include", dirname]) # Generate the wrappers - subprocess.call( - [ - "python", - generate_script, - "--source_root", - shapes_src, - "--wrapper_root", - wrapper_root_gen, - "--castxml_binary", - castxml_path, - "--package_info", - package_info_path, - "--includes", - shapes_src, - ] - ) + call_args = [ + "python", + generate_script, + shapes_src, + "--wrapper_root", + wrapper_root_gen, + "--package_info", + package_info_path, + ] + includes + + subprocess.call(call_args) self.assertTrue(os.path.isdir(wrapper_root_gen))