diff --git a/.github/workflows/test-cells-conda.yml b/.github/workflows/test-cells-conda.yml index ea00a4a..5eae81e 100644 --- a/.github/workflows/test-cells-conda.yml +++ b/.github/workflows/test-cells-conda.yml @@ -2,6 +2,9 @@ name: test-cells-conda on: workflow_dispatch: + push: + branches: + - develop pull_request: branches: - "**" diff --git a/.github/workflows/test-cells-ubuntu.yml b/.github/workflows/test-cells-ubuntu.yml index 4728de9..d1c1ded 100644 --- a/.github/workflows/test-cells-ubuntu.yml +++ b/.github/workflows/test-cells-ubuntu.yml @@ -2,6 +2,9 @@ name: test-cells-ubuntu on: workflow_dispatch: + push: + branches: + - develop pull_request: branches: - "**" diff --git a/.github/workflows/test-shapes-pip.yml b/.github/workflows/test-shapes-pip.yml index bfb13b9..244450f 100644 --- a/.github/workflows/test-shapes-pip.yml +++ b/.github/workflows/test-shapes-pip.yml @@ -2,6 +2,9 @@ name: test-shapes-pip on: workflow_dispatch: + push: + branches: + - develop pull_request: branches: - "**" diff --git a/.github/workflows/test-style.yml b/.github/workflows/test-style.yml index 47d8455..8648201 100644 --- a/.github/workflows/test-style.yml +++ b/.github/workflows/test-style.yml @@ -2,6 +2,9 @@ name: test-style on: workflow_dispatch: + push: + branches: + - develop pull_request: branches: - "**" diff --git a/.github/workflows/test-unit.yml b/.github/workflows/test-unit.yml new file mode 100644 index 0000000..ff571da --- /dev/null +++ b/.github/workflows/test-unit.yml @@ -0,0 +1,35 @@ +name: test-unit + +on: + workflow_dispatch: + push: + branches: + - develop + pull_request: + branches: + - "**" + +concurrency: + group: test-unit-${{ github.ref }} + cancel-in-progress: true + +jobs: + test-unit: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Python + uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Install cppwg + run: | + python3 -m pip install --upgrade pip + python3 -m pip install .[dev] + + - name: Run unit tests + run: python3 -m pytest tests/ diff --git a/README.md b/README.md index 6069764..0473380 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,4 @@ +![unit](https://github.com/Chaste/cppwg/actions/workflows/test-unit.yml/badge.svg?branch=develop) ![pip](https://github.com/Chaste/cppwg/actions/workflows/test-shapes-pip.yml/badge.svg?branch=develop) ![ubuntu](https://github.com/Chaste/cppwg/actions/workflows/test-cells-ubuntu.yml/badge.svg?branch=develop) ![conda](https://github.com/Chaste/cppwg/actions/workflows/test-cells-conda.yml/badge.svg?branch=develop) @@ -51,6 +52,7 @@ options: --castxml_cflags="-Wno-deprecated". -i, --includes [INCLUDES ...] List of paths to include directories. + --overwrite Force rewrite of all wrapper files, even if unchanged. -q, --quiet Disable informational messages. -l, --logfile [LOGFILE] Output log messages to a file. @@ -157,6 +159,9 @@ r = Rectangle(4, 5) ## Tips - Use `examples/shapes` or `examples/cells` as a starting point. +- By default, cppwg only rewrites wrapper files whose content has changed, leaving + unchanged files untouched so build systems skip recompiling them. Pass + `--overwrite` to force a full rewrite of all wrapper files. - To pass extra flags to the castxml clang frontend (e.g. to silence a diagnostic), use `--castxml_cflags`. Values starting with `-` must use `=`, e.g. `--castxml_cflags="-Wno-deprecated"`. diff --git a/cppwg/__main__.py b/cppwg/__main__.py index e2a9a49..439fa8f 100644 --- a/cppwg/__main__.py +++ b/cppwg/__main__.py @@ -77,6 +77,13 @@ def parse_args() -> argparse.Namespace: help="List of paths to include directories.", ) + parser.add_argument( + "--overwrite", + action="store_true", + help="Force rewrite of all wrapper files, even if unchanged. By default, " + "unchanged wrapper files are left untouched to speed up rebuilds.", + ) + parser.add_argument( "-q", "--quiet", @@ -143,6 +150,7 @@ def generate(args: argparse.Namespace) -> None: castxml_binary=args.castxml_binary, castxml_cflags=castxml_cflags or None, castxml_compiler=args.castxml_compiler, + overwrite=args.overwrite, ) generator.generate() diff --git a/cppwg/generators.py b/cppwg/generators.py index 5b9b23d..af267e6 100644 --- a/cppwg/generators.py +++ b/cppwg/generators.py @@ -49,6 +49,8 @@ class CppWrapperGenerator: The namespace containing C++ declarations parsed from the source tree package_info : PackageInfo A data structure containing the information parsed from package_info_path + overwrite : bool + Force rewrite of all wrapper files, even if unchanged; defaults to False """ def __init__( @@ -60,9 +62,13 @@ def __init__( package_info_path: Optional[str] = None, castxml_cflags: Optional[str] = None, castxml_compiler: Optional[str] = None, + overwrite: bool = False, ): logger = logging.getLogger() + # Whether to force rewriting wrapper files that are unchanged + self.overwrite: bool = overwrite + logger.info(f"cppwg version {cppwg_version}") # Check that castxml_binary exists and is executable @@ -254,6 +260,7 @@ def write_header_collection(self) -> None: self.package_info, self.wrapper_root, self.header_collection_filepath, + self.overwrite, ) header_collection_writer.write() @@ -262,7 +269,10 @@ def write_wrappers(self) -> None: Write the wrapper code for the package. """ package_writer = CppPackageWrapperWriter( - self.package_info, wrapper_templates.template_collection, self.wrapper_root + self.package_info, + wrapper_templates.template_collection, + self.wrapper_root, + self.overwrite, ) package_writer.write() diff --git a/cppwg/utils/utils.py b/cppwg/utils/utils.py index 1a6e379..a5b0e13 100644 --- a/cppwg/utils/utils.py +++ b/cppwg/utils/utils.py @@ -1,6 +1,7 @@ """Utility functions for the cppwg package.""" import ast +import os import re from numbers import Number from typing import Any, List, Tuple @@ -8,6 +9,38 @@ from cppwg.utils.constants import CPPWG_ALL_STRING, CPPWG_TRUE_STRINGS +def write_file_if_changed(filepath: str, content: str, overwrite: bool = False) -> bool: + """ + Write content to filepath unless an identical file already exists. + + Skipping unchanged files leaves their modification time intact so that + downstream build systems (e.g. make) do not needlessly recompile them. + + Parameters + ---------- + filepath : str + The path of the file to write. + content : str + The content to write to the file. + overwrite : bool + If True, always write the file even if its content is unchanged. + + Returns + ------- + bool + True if the file was written, False if it was skipped as unchanged. + """ + if not overwrite and os.path.isfile(filepath): + with open(filepath, "r") as in_file: + if in_file.read() == content: + return False + + with open(filepath, "w") as out_file: + out_file.write(content) + + return True + + def convert_to_bool(value: Any) -> bool: """ Convert value to a boolean. diff --git a/cppwg/writers/class_writer.py b/cppwg/writers/class_writer.py index e344665..8604169 100644 --- a/cppwg/writers/class_writer.py +++ b/cppwg/writers/class_writer.py @@ -12,6 +12,7 @@ CPPWG_EXT, CPPWG_HEADER_COLLECTION_FILENAME, ) +from cppwg.utils.utils import write_file_if_changed from cppwg.writers.base_writer import CppBaseWrapperWriter from cppwg.writers.constructor_writer import CppConstructorWrapperWriter from cppwg.writers.method_writer import CppMethodWrapperWriter @@ -29,6 +30,8 @@ class CppClassWrapperWriter(CppBaseWrapperWriter): String templates with placeholders for generating wrapper code module_classes : Dict[pygccxml.declarations.class_t, str] A dictionary of decls and names for all classes in the module + overwrite : bool + Force rewrite of the class wrapper files, even if unchanged has_shared_ptr : bool Whether the class uses shared pointers hpp_string : str @@ -42,6 +45,7 @@ def __init__( class_info: "CppClassInfo", # noqa: F821 wrapper_templates: Dict[str, str], module_classes: Dict["class_t", str], # noqa: F821 + overwrite: bool = False, ) -> None: logger = logging.getLogger() @@ -55,6 +59,8 @@ def __init__( self.module_classes = module_classes + self.overwrite = overwrite + self.has_shared_ptr: bool = True self.hpp_string: str = "" @@ -389,8 +395,5 @@ def write_files(self, work_dir: str, class_py_name: str) -> None: hpp_filepath = os.path.join(work_dir, f"{class_py_name}.{CPPWG_EXT}.hpp") cpp_filepath = os.path.join(work_dir, f"{class_py_name}.{CPPWG_EXT}.cpp") - with open(hpp_filepath, "w") as hpp_file: - hpp_file.write(self.hpp_string) - - with open(cpp_filepath, "w") as cpp_file: - cpp_file.write(self.cpp_string) + write_file_if_changed(hpp_filepath, self.hpp_string, self.overwrite) + write_file_if_changed(cpp_filepath, self.cpp_string, self.overwrite) diff --git a/cppwg/writers/header_collection_writer.py b/cppwg/writers/header_collection_writer.py index 61bb278..50efea5 100644 --- a/cppwg/writers/header_collection_writer.py +++ b/cppwg/writers/header_collection_writer.py @@ -6,6 +6,7 @@ from cppwg.info.class_info import CppClassInfo from cppwg.info.free_function_info import CppFreeFunctionInfo from cppwg.info.package_info import PackageInfo +from cppwg.utils.utils import write_file_if_changed class CppHeaderCollectionWriter: @@ -25,6 +26,8 @@ class CppHeaderCollectionWriter: The output directory for the generated wrapper code hpp_collection_file : str The path to save the header collection file to + overwrite : bool + Force rewrite of the header collection file, even if unchanged hpp_collection : str The output string that gets written to the header collection file class_dict : Dict[str, CppClassInfo] @@ -38,10 +41,12 @@ def __init__( package_info: PackageInfo, wrapper_root: str, hpp_collection_file: str, + overwrite: bool = False, ): self.package_info: PackageInfo = package_info self.wrapper_root: str = wrapper_root self.hpp_collection_file: str = hpp_collection_file + self.overwrite: bool = overwrite self.hpp_collection: str = "" # For convenience, collect all class and free function info into dicts keyed by name @@ -150,5 +155,6 @@ def write(self) -> None: self.hpp_collection += f"\n#endif // {self.package_info.name}_HEADERS_HPP_\n" # Write the header collection string to file - with open(self.hpp_collection_file, "w") as hpp_file: - hpp_file.write(self.hpp_collection) + write_file_if_changed( + self.hpp_collection_file, self.hpp_collection, self.overwrite + ) diff --git a/cppwg/writers/module_writer.py b/cppwg/writers/module_writer.py index 269a9c8..c817217 100644 --- a/cppwg/writers/module_writer.py +++ b/cppwg/writers/module_writer.py @@ -5,6 +5,7 @@ from typing import Dict from cppwg.utils.constants import CPPWG_EXT, CPPWG_HEADER_COLLECTION_FILENAME +from cppwg.utils.utils import write_file_if_changed from cppwg.writers.class_writer import CppClassWrapperWriter from cppwg.writers.free_function_writer import CppFreeFunctionWrapperWriter @@ -26,6 +27,8 @@ class CppModuleWrapperWriter: String templates with placeholders for generating wrapper code wrapper_root : str The output directory for the generated wrapper code + overwrite : bool + Force rewrite of all wrapper files, even if unchanged classes : Dict[pygccxml.declarations.class_t, str] A dictionary of decls and names for all classes to be wrapped in the module @@ -36,10 +39,12 @@ def __init__( module_info: "ModuleInfo", # noqa: F821 wrapper_templates: Dict[str, str], wrapper_root: str, + overwrite: bool = False, ): self.module_info: "ModuleInfo" = module_info # noqa: F821 self.wrapper_templates: Dict[str, str] = wrapper_templates self.wrapper_root: str = wrapper_root + self.overwrite: bool = overwrite # For convenience, store a dictionary of decl->name pairs for all # classes to be wrapped in the module @@ -146,8 +151,7 @@ def write_module_wrapper(self) -> None: module_dir, f"{full_module_name}.main.{CPPWG_EXT}.cpp" ) - with open(module_cpp_file, "w") as out_file: - out_file.write(cpp_string) + write_file_if_changed(module_cpp_file, cpp_string, self.overwrite) def write_class_wrappers(self) -> None: """Write wrappers for classes in the module.""" @@ -165,6 +169,7 @@ def write_class_wrappers(self) -> None: class_info, self.wrapper_templates, self.classes, + self.overwrite, ) # Write the class wrappers into /path/to/wrapper_root/modulename/ diff --git a/cppwg/writers/package_writer.py b/cppwg/writers/package_writer.py index 229a9cc..d31ad53 100644 --- a/cppwg/writers/package_writer.py +++ b/cppwg/writers/package_writer.py @@ -17,6 +17,8 @@ class CppPackageWrapperWriter: String templates with placeholders for generating wrapper code wrapper_root : str The output directory for the generated wrapper code + overwrite : bool + Force rewrite of all wrapper files, even if unchanged """ def __init__( @@ -24,10 +26,12 @@ def __init__( package_info: "PackageInfo", # noqa: F821 wrapper_templates: Dict[str, str], wrapper_root: str, + overwrite: bool = False, ): self.package_info = package_info self.wrapper_templates = wrapper_templates self.wrapper_root = wrapper_root + self.overwrite = overwrite def write(self) -> None: """ @@ -38,5 +42,6 @@ def write(self) -> None: module_info, self.wrapper_templates, self.wrapper_root, + self.overwrite, ) module_writer.write() diff --git a/examples/cells/conda/variants/python3.8.yaml b/examples/cells/conda/variants/python3.8.yaml deleted file mode 100644 index b34e9cc..0000000 --- a/examples/cells/conda/variants/python3.8.yaml +++ /dev/null @@ -1,2 +0,0 @@ -python: - - 3.8.* diff --git a/examples/cells/conda/variants/python3.9.yaml b/examples/cells/conda/variants/python3.9.yaml deleted file mode 100644 index f305cf2..0000000 --- a/examples/cells/conda/variants/python3.9.yaml +++ /dev/null @@ -1,2 +0,0 @@ -python: - - 3.9.* diff --git a/examples/cells/pyproject.toml b/examples/cells/pyproject.toml index 3b7db8c..2ef295d 100644 --- a/examples/cells/pyproject.toml +++ b/examples/cells/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "scikit_build_core.build" name = "pycells" version = "0.0.1" license = { text = "BSD-3-Clause License" } -requires-python = ">=3.8" +requires-python = ">=3.10" [tool.scikit-build] cmake.build-type = "Release" diff --git a/pyproject.toml b/pyproject.toml index b874398..c9b75be 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,8 +1,66 @@ [build-system] -requires = ["setuptools", "wheel"] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "cppwg" +version = "0.3.4" +description = "An automatic Python wrapper generator for C++ code" +readme = "README.md" +requires-python = ">=3.10" +license = {text = "BSD-3-Clause"} +keywords = ["C++", "Python", "pybind11"] +authors = [ + {name = "Chaste Developers", email = "chaste-users@maillist.ox.ac.uk"}, +] +classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: Console", + "Intended Audience :: Developers", + "Operating System :: MacOS :: MacOS X", + "Operating System :: Microsoft :: Windows", + "Operating System :: POSIX", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: Implementation :: CPython", + "Topic :: Software Development", +] +dependencies = [ + "pyyaml>=6.0", + "pygccxml>=2.2", +] + +[project.optional-dependencies] +dev = [ + "black", + "flake8", + "flake8-bugbear", + "flake8-docstrings", + "isort", + "pytest", +] +docs = [ + "sphinx", + "sphinx-rtd-theme", + "numpydoc", +] + +[project.scripts] +cppwg = "cppwg.__main__:main" + +[project.urls] +"Source Code" = "https://github.com/Chaste/cppwg/" + +[tool.setuptools] +zip-safe = false + +[tool.setuptools.packages.find] +include = ["cppwg*"] [tool.black] -target-version = ["py38", "py39", "py310", "py311", "py312"] +target-version = ["py310", "py311", "py312", "py313"] extend-exclude = """ ( ^/cppwg/templates/ diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 908756c..0000000 --- a/setup.cfg +++ /dev/null @@ -1,51 +0,0 @@ -[metadata] -name = cppwg -version = 0.3.4 -author = Chaste Developers -author_email = chaste-users@maillist.ox.ac.uk -description = An automatic Python wrapper generator for C++ code -long_description = file: README.md -keywords = C++, Python, pybind11 -license = BSD-3-Clause -classifiers = - Development Status :: 4 - Beta - Environment :: Console - Intended Audience :: Developers - Operating System :: MacOS :: MacOS X - Operating System :: Microsoft :: Windows - Operating System :: POSIX - Programming Language :: Python :: 3.8 - Programming Language :: Python :: 3.9 - Programming Language :: Python :: 3.10 - Programming Language :: Python :: 3.11 - Programming Language :: Python :: 3.12 - Programming Language :: Python :: Implementation :: CPython - Topic :: Software Development - -project_urls = - Source Code = https://github.com/Chaste/cppwg/ - -[options] -zip_safe = False -packages = find: -python_requires = >=3.8 -install_requires = - pyyaml>=6.0 - pygccxml>=2.2 - -[options.entry_points] -console_scripts = - cppwg = cppwg.__main__:main - -[options.extras_require] -dev = - black - flake8 - flake8-bugbear - flake8-docstrings - isort - -docs = - sphinx - sphinx-rtd-theme - numpydoc diff --git a/setup.py b/setup.py deleted file mode 100644 index 685c789..0000000 --- a/setup.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Setup.""" - -from setuptools import setup - -setup() diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..505332c --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,63 @@ +"""Unit tests for cppwg.utils.utils.""" + +import os + +from cppwg.utils.utils import write_file_if_changed + + +def test_writes_when_file_missing(tmp_path): + """A new file is created and reports that it was written.""" + filepath = os.path.join(tmp_path, "wrapper.cpp") + + wrote = write_file_if_changed(filepath, "content") + + assert wrote is True + assert os.path.isfile(filepath) + with open(filepath) as f: + assert f.read() == "content" + + +def test_skips_when_content_unchanged(tmp_path): + """An identical file is left untouched, preserving its mtime.""" + filepath = os.path.join(tmp_path, "wrapper.cpp") + with open(filepath, "w") as f: + f.write("content") + + # Backdate the mtime so any rewrite would be detectable + old_time = os.path.getmtime(filepath) - 100 + os.utime(filepath, (old_time, old_time)) + old_time_ns = os.stat(filepath).st_mtime_ns + + wrote = write_file_if_changed(filepath, "content") + + assert wrote is False + assert os.stat(filepath).st_mtime_ns == old_time_ns + + +def test_rewrites_when_content_changed(tmp_path): + """A file with different content is rewritten.""" + filepath = os.path.join(tmp_path, "wrapper.cpp") + with open(filepath, "w") as f: + f.write("old content") + + wrote = write_file_if_changed(filepath, "new content") + + assert wrote is True + with open(filepath) as f: + assert f.read() == "new content" + + +def test_overwrite_forces_rewrite_when_unchanged(tmp_path): + """With overwrite=True, an identical file is rewritten anyway.""" + filepath = os.path.join(tmp_path, "wrapper.cpp") + with open(filepath, "w") as f: + f.write("content") + + old_time = os.path.getmtime(filepath) - 100 + os.utime(filepath, (old_time, old_time)) + old_time_ns = os.stat(filepath).st_mtime_ns + + wrote = write_file_if_changed(filepath, "content", overwrite=True) + + assert wrote is True + assert os.stat(filepath).st_mtime_ns != old_time_ns