diff --git a/.copier-answers.yml b/.copier-answers.yaml similarity index 69% rename from .copier-answers.yml rename to .copier-answers.yaml index 3e3d361..0f85d64 100644 --- a/.copier-answers.yml +++ b/.copier-answers.yaml @@ -1,7 +1,9 @@ # Changes here will be overwritten by Copier -_commit: 81e8acd -_src_path: git@github.com:python-project-templates/base.git +_commit: 4d4d95a +_src_path: https://github.com/python-project-templates/base.git +add_docs: false add_extension: python +add_wiki: false email: t.paine154@gmail.com github: python-project-templates project_description: Hatch plugin for C++ builds diff --git a/.gitattributes b/.gitattributes index 092719d..8e05465 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,4 +3,11 @@ docs/* linguist-documentation *.ipynb linguist-documentation Makefile linguist-documentation +<<<<<<< before updating * text=auto eol=lf +======= +*.md text=auto eol=lf +*.py text=auto eol=lf +*.toml text=auto eol=lf +*.yaml text=auto eol=lf +>>>>>>> after updating diff --git a/.github/dependabot.yml b/.github/dependabot.yaml similarity index 66% rename from .github/dependabot.yml rename to .github/dependabot.yaml index 4d17b20..42cac77 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yaml @@ -14,11 +14,3 @@ updates: labels: - "lang: python" - "part: dependencies" - - - package-ecosystem: "npm" - directory: "/" - schedule: - interval: "monthly" - labels: - - "lang: javascript" - - "part: dependencies" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yaml similarity index 70% rename from .github/workflows/build.yml rename to .github/workflows/build.yaml index d9a8ac7..7899eb5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yaml @@ -29,18 +29,15 @@ jobs: strategy: matrix: - os: [ubuntu-latest, macos-latest] - python-version: ["3.9"] + os: [ubuntu-latest] + python-version: ["3.11", "3.12", "3.13"] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + - uses: actions-ext/python/setup@main with: - python-version: ${{ matrix.python-version }} - cache: 'pip' - cache-dependency-path: 'pyproject.toml' + version: ${{ matrix.python-version }} - name: Install dependencies run: make develop @@ -58,17 +55,15 @@ jobs: run: make coverage - name: Upload test results (Python) - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: test-results-${{ matrix.os }}-${{ matrix.python-version }} path: junit.xml - if: ${{ always() }} - name: Publish Unit Test Results uses: EnricoMi/publish-unit-test-result-action@v2 with: files: '**/junit.xml' - if: matrix.os == 'ubuntu-latest' - name: Upload coverage uses: codecov/codecov-action@v5 @@ -78,8 +73,8 @@ jobs: - name: Make dist run: make dist - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v7 with: name: dist-${{matrix.os}} path: dist - if: matrix.os == 'ubuntu-latest' + if: ${{ matrix.python-version == '3.11' }} diff --git a/.github/workflows/copier.yml b/.github/workflows/copier.yaml similarity index 100% rename from .github/workflows/copier.yml rename to .github/workflows/copier.yaml diff --git a/.gitignore b/.gitignore index a090202..2bddce6 100644 --- a/.gitignore +++ b/.gitignore @@ -4,11 +4,15 @@ __pycache__/ *$py.class # C extensions +*.a *.so +*.obj +*.dll +*.exp +*.lib # Distribution / packaging .Python -env/ build/ develop-eggs/ dist/ @@ -20,13 +24,15 @@ lib64/ parts/ sdist/ var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ *.egg-info/ .installed.cfg *.egg +MANIFEST # PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec @@ -37,18 +43,17 @@ pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ +.nox/ .coverage .coverage.* .cache -python_junit.xml -junit.xml nosetests.xml coverage.xml -*,cover +junit.xml +*.cover +*.py,cover .hypothesis/ -.pytest_cache -.ruff_cache -js/playwright-report +.pytest_cache/ # Translations *.mo @@ -57,108 +62,103 @@ js/playwright-report # Django stuff: *.log local_settings.py +db.sqlite3 +db.sqlite3-journal -# Flask instance folder +# Flask stuff: instance/ +.webassets-cache # Scrapy stuff: .scrapy -# Sphinx documentation -docs/_build/ -docs/source - # PyBuilder target/ -# IPython Notebook -.ipynb_checkpoints -*.ipynb -.autoversion +# IPython +profile_default/ +ipython_config.py # pyenv .python-version -# celery beat schedule file +# pipenv +Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff celerybeat-schedule +celerybeat.pid -# dotenv -.env +# SageMath parsed files +*.sage.py -# virtualenv +# Environments +.env +.venv +env/ venv/ ENV/ +env.bak/ +venv.bak/ # Spyder project settings .spyderproject +.spyproject # Rope project settings .ropeproject -# ========================= -# Operating System Files -# ========================= +# mkdocs documentation +/site -# OSX -# ========================= +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json -.DS_Store -.AppleDouble -.LSOverride +# Pyre type checker +.pyre/ -# Thumbnails -._* - -# Files that might appear in the root of a volume -.DocumentRevisions-V100 -.fseventsd -.Spotlight-V100 -.TemporaryItems -.Trashes -.VolumeIcon.icns - -# Directories potentially created on remote AFP share -.AppleDB -.AppleDesktop -Network Trash Folder -Temporary Items -.apdisk - -# Windows -# ========================= - -# Windows image file caches -Thumbs.db -ehthumbs.db - -# Folder config file -Desktop.ini - -# Recycle Bin used on file shares -$RECYCLE.BIN/ - -# Windows Installer files -*.cab -*.msi -*.msm -*.msp - -# Windows shortcuts -*.lnk +# Documentation +/site +index.md +docs/_build/ +docs/src/_build/ +docs/api +docs/index.md +docs/html +docs/jupyter_execute +index.md + +# JS +js/coverage +js/dist +js/lib +js/node_modules +js/test-results +js/playwright-report +js/*.tgz +# Jupyter +.ipynb_checkpoints +.autoversion +Untitled*.ipynb +hatch_cpp/extension +hatch_cpp/nbextension +hatch_cpp/labextension -# NPM -# ---- -**/node_modules/ +# Mac +.DS_Store -# Coverage data -# ------------- -**/coverage/ +# Rust +target -# Notebook and lab extensions +# Hydra +outputs/ +multirun/ -nbprint/extension/* -nbprint/templates/nbprint/static/* -nbprint/voila/static/* -tmp.html -examples/output/ +vcpkg +vcpkg_installed diff --git a/LICENSE b/LICENSE index 261eeb9..95b5e23 100644 --- a/LICENSE +++ b/LICENSE @@ -186,7 +186,11 @@ same "printed page" as the copyright notice for easier identification within third-party archives. +<<<<<<< before updating Copyright [yyyy] [name of copyright owner] +======= + Copyright 2025 the hatch-cpp authors +>>>>>>> after updating Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/Makefile b/Makefile index 627b57c..abd4aba 100644 --- a/Makefile +++ b/Makefile @@ -4,42 +4,57 @@ .PHONY: develop build install develop: ## install dependencies and build library - python -m pip install -e .[develop] + uv pip install -e .[develop] + +requirements: ## install prerequisite python build requirements + python -m pip install --upgrade pip toml + python -m pip install `python -c 'import toml; c = toml.load("pyproject.toml"); print("\n".join(c["build-system"]["requires"]))'` + python -m pip install `python -c 'import toml; c = toml.load("pyproject.toml"); print(" ".join(c["project"]["optional-dependencies"]["develop"]))'` build: ## build the python library python -m build -n install: ## install library - python -m pip install . + uv pip install . ######### # LINTS # ######### -.PHONY: lint lints fix format +.PHONY: lint-py lint-docs fix-py fix-docs lint lints fix format -lint: ## run python linter with ruff +lint-py: ## lint python with ruff python -m ruff check hatch_cpp python -m ruff format --check hatch_cpp -# Alias -lints: lint +lint-docs: ## lint docs with mdformat and codespell + python -m mdformat --check README.md + python -m codespell_lib README.md -fix: ## fix python formatting with ruff +fix-py: ## autoformat python code with ruff python -m ruff check --fix hatch_cpp python -m ruff format hatch_cpp -# alias +fix-docs: ## autoformat docs with mdformat and codespell + python -m mdformat README.md + python -m codespell_lib --write README.md + +lint: lint-py lint-docs ## run all linters +lints: lint +fix: fix-py fix-docs ## run all autoformatters format: fix ################ # Other Checks # ################ -.PHONY: check-manifest checks check +.PHONY: check-dist check-types checks check + +check-dist: ## check python sdist and wheel with check-dist + check-dist -v -check-manifest: ## check python sdist manifest with check-manifest - check-manifest -v +check-types: ## check python types with ty + ty check --python $$(which python) -checks: check-manifest +checks: check-dist # Alias check: checks @@ -86,9 +101,9 @@ dist-build: # build python dists dist-check: ## run python dist checker with twine python -m twine check dist/* -dist: clean build dist-build dist-check ## build all dists +dist: clean dist-build dist-check ## build all dists -publish: dist # publish python assets +publish: dist ## publish python assets ######### # CLEAN # @@ -99,7 +114,7 @@ deep-clean: ## clean everything from the repository git clean -fdx clean: ## clean the repository - rm -rf .coverage coverage cover htmlcov logs build dist *.egg-info + rm -rf .coverage coverage cover htmlcov logs build dist *.egg-info hatch_cpp/tests/*/dist hatch_cpp/tests/*/build hatch_cpp/tests/*/*/*.so hatch_cpp/tests/*/*/*.pyd ############################################################################################ diff --git a/README.md b/README.md index d47e19c..b68da15 100644 --- a/README.md +++ b/README.md @@ -2,12 +2,184 @@ Hatch plugin for C++ builds -[![Build Status](https://github.com/python-project-templates/hatch-cpp/actions/workflows/build.yml/badge.svg?branch=main&event=push)](https://github.com/python-project-templates/hatch-cpp/actions/workflows/build.yml) +[![Build Status](https://github.com/python-project-templates/hatch-cpp/actions/workflows/build.yaml/badge.svg?branch=main&event=push)](https://github.com/python-project-templates/hatch-cpp/actions/workflows/build.yaml) [![codecov](https://codecov.io/gh/python-project-templates/hatch-cpp/branch/main/graph/badge.svg)](https://codecov.io/gh/python-project-templates/hatch-cpp) [![License](https://img.shields.io/github/license/python-project-templates/hatch-cpp)](https://github.com/python-project-templates/hatch-cpp) [![PyPI](https://img.shields.io/pypi/v/hatch-cpp.svg)](https://pypi.python.org/pypi/hatch-cpp) ## Overview +A simple, extensible C++ build plugin for [hatch](https://hatch.pypa.io/latest/). + +```toml +[tool.hatch.build.hooks.hatch-cpp] +libraries = [ + {name = "project/extension", sources = ["cpp/project/basic.cpp"], include-dirs = ["cpp"]} +] +``` + +For more complete systems, see: + +- [scikit-build-core](https://github.com/scikit-build/scikit-build-core) +- [setuptools](https://setuptools.pypa.io/en/latest/userguide/ext_modules.html) + +## Configuration + +Configuration is driven from the `[tool.hatch.build.hooks.hatch-cpp]` hatch hook configuration field in a `pyproject.toml`. +It is designed to closely match existing Python/C/C++ packaging tools. + +```toml +verbose = true +libraries = { Library Args } +cmake = { CMake Args } +platform = { Platform, either "linux", "darwin", or "win32" } +``` + +See the [test cases](./hatch_cpp/tests/) for more concrete examples. + +`hatch-cpp` is driven by [pydantic](https://docs.pydantic.dev/latest/) models for configuration and execution of the build. +These models can themselves be overridden by setting `build-config-class` / `build-plan-class`. + +### Library Arguments + +```toml +name = "mylib" +sources = [ + "path/to/file.cpp", +] +language = "c++" + +binding = "cpython" # or "pybind11", "nanobind", "generic" +std = "" # Passed to -std= or /std: + +include_dirs = ["paths/to/add/to/-I"] +library_dirs = ["paths/to/add/to/-L"] +libraries = ["-llibraries_to_link"] + +extra_compile_args = ["--extra-compile-args"] +extra_link_args = ["--extra-link-args"] +extra_objects = ["extra_objects"] + +define_macros = ["-Ddefines_to_use"] +undef_macros = ["-Uundefines_to_use"] + +py_limited_api = "cp39" # limited API to use +``` + +### CMake Arguments + +`hatch-cpp` has some convenience integration with CMake. +Though this is not designed to be as full-featured as e.g. `scikit-build`, it should be satisfactory for many small projects. + +```toml +root = "path/to/cmake/root" +build = "path/to/cmake/build/folder" +install = "path/to/cmake/install/folder" + +cmake_arg_prefix = "MYPROJECT_" +cmake_args = {} # any other cmake args to pass +cmake_env_args = {} # env-specific cmake args to pass + +include_flags = {} # include flags to pass -D +``` + +### CLI + +`hatch-cpp` is integrated with [`hatch-build`](https://github.com/python-project-templates/hatch-build) to allow easy configuration of options via command line: + +```bash +hatch-build \ + -- \ + --verbose \ + --platform linux \ + --vcpkg.vcpkg a/path/to/vcpkg.json \ + --libraries.0.binding pybind11 \ + --libraries.0.include-dirs cpp,another-dir +``` + +This CLI is aware of your `pyproject.toml`-configured setup. +To display help for this, run (note the passthrough `--`): + +```bash +hatch-build -- --help +``` + +For example, for the `test_project_basic` in this project's `tests` folder: + +```raw +hatch-build --hooks-only -- --help +[sdist] + +[wheel] +[2025-11-11T17:31:06-0500][p2a][WARNING]: Only dicts with str, int, float, bool, or enum values are supported - field `cmake_env_args` got value type typing.Dict[str, str] +usage: hatch-build-extras-model [-h] [--verbose] [--name NAME] [--libraries.0.name LIBRARIES.0.NAME] + [--libraries.0.sources.0 LIBRARIES.0.SOURCES.0] [--libraries.0.sources LIBRARIES.0.SOURCES] + [--libraries.0.language LIBRARIES.0.LANGUAGE] [--libraries.0.binding LIBRARIES.0.BINDING] + [--libraries.0.std LIBRARIES.0.STD] [--libraries.0.include-dirs.0 LIBRARIES.0.INCLUDE_DIRS.0] + [--libraries.0.include-dirs LIBRARIES.0.INCLUDE_DIRS] + [--libraries.0.library-dirs LIBRARIES.0.LIBRARY_DIRS] + [--libraries.0.libraries LIBRARIES.0.LIBRARIES] + [--libraries.0.extra-compile-args LIBRARIES.0.EXTRA_COMPILE_ARGS] + [--libraries.0.extra-link-args LIBRARIES.0.EXTRA_LINK_ARGS] + [--libraries.0.extra-objects LIBRARIES.0.EXTRA_OBJECTS] + [--libraries.0.define-macros LIBRARIES.0.DEFINE_MACROS] + [--libraries.0.undef-macros LIBRARIES.0.UNDEF_MACROS] + [--libraries.0.export-symbols LIBRARIES.0.EXPORT_SYMBOLS] + [--libraries.0.depends LIBRARIES.0.DEPENDS] + [--libraries.0.py-limited-api LIBRARIES.0.PY_LIMITED_API] [--cmake.root CMAKE.ROOT] + [--cmake.build CMAKE.BUILD] [--cmake.install CMAKE.INSTALL] + [--cmake.cmake-arg-prefix CMAKE.CMAKE_ARG_PREFIX] [--cmake.cmake-args CMAKE.CMAKE_ARGS] + [--cmake.include-flags CMAKE.INCLUDE_FLAGS] [--platform.cc PLATFORM.CC] + [--platform.cxx PLATFORM.CXX] [--platform.ld PLATFORM.LD] [--platform.platform PLATFORM.PLATFORM] + [--platform.toolchain PLATFORM.TOOLCHAIN] [--platform.disable-ccache] [--vcpkg.vcpkg VCPKG.VCPKG] + [--vcpkg.vcpkg-root VCPKG.VCPKG_ROOT] [--vcpkg.vcpkg-repo VCPKG.VCPKG_REPO] + [--vcpkg.vcpkg-triplet VCPKG.VCPKG_TRIPLET] [--build-type BUILD_TYPE] [--commands COMMANDS] + +options: + -h, --help show this help message and exit + --verbose + --name NAME + --libraries.0.name LIBRARIES.0.NAME + --libraries.0.sources.0 LIBRARIES.0.SOURCES.0 + --libraries.0.sources LIBRARIES.0.SOURCES + --libraries.0.language LIBRARIES.0.LANGUAGE + --libraries.0.binding LIBRARIES.0.BINDING + --libraries.0.std LIBRARIES.0.STD + --libraries.0.include-dirs.0 LIBRARIES.0.INCLUDE_DIRS.0 + --libraries.0.include-dirs LIBRARIES.0.INCLUDE_DIRS + --libraries.0.library-dirs LIBRARIES.0.LIBRARY_DIRS + --libraries.0.libraries LIBRARIES.0.LIBRARIES + --libraries.0.extra-compile-args LIBRARIES.0.EXTRA_COMPILE_ARGS + --libraries.0.extra-link-args LIBRARIES.0.EXTRA_LINK_ARGS + --libraries.0.extra-objects LIBRARIES.0.EXTRA_OBJECTS + --libraries.0.define-macros LIBRARIES.0.DEFINE_MACROS + --libraries.0.undef-macros LIBRARIES.0.UNDEF_MACROS + --libraries.0.export-symbols LIBRARIES.0.EXPORT_SYMBOLS + --libraries.0.depends LIBRARIES.0.DEPENDS + --libraries.0.py-limited-api LIBRARIES.0.PY_LIMITED_API + --cmake.root CMAKE.ROOT + --cmake.build CMAKE.BUILD + --cmake.install CMAKE.INSTALL + --cmake.cmake-arg-prefix CMAKE.CMAKE_ARG_PREFIX + --cmake.cmake-args CMAKE.CMAKE_ARGS + --cmake.include-flags CMAKE.INCLUDE_FLAGS + --platform.cc PLATFORM.CC + --platform.cxx PLATFORM.CXX + --platform.ld PLATFORM.LD + --platform.platform PLATFORM.PLATFORM + --platform.toolchain PLATFORM.TOOLCHAIN + --platform.disable-ccache + --vcpkg.vcpkg VCPKG.VCPKG + --vcpkg.vcpkg-root VCPKG.VCPKG_ROOT + --vcpkg.vcpkg-repo VCPKG.VCPKG_REPO + --vcpkg.vcpkg-triplet VCPKG.VCPKG_TRIPLET + --build-type BUILD_TYPE + --commands COMMANDS +``` + +### Environment Variables + +`hatch-cpp` will respect standard environment variables for compiler control, e.g. `CC`, `CXX`, `LD`, `CMAKE_GENERATOR`, `OSX_DEPLOYMENT_TARGET`, etc. + > [!NOTE] > This library was generated using [copier](https://copier.readthedocs.io/en/stable/) from the [Base Python Project Template repository](https://github.com/python-project-templates/base). diff --git a/hatch_cpp/__init__.py b/hatch_cpp/__init__.py index 3dc1f76..2274de6 100644 --- a/hatch_cpp/__init__.py +++ b/hatch_cpp/__init__.py @@ -1 +1,6 @@ -__version__ = "0.1.0" +__version__ = "0.3.6" + +from .config import * +from .hooks import * +from .plugin import * +from .toolchains import * diff --git a/hatch_cpp/__main__.py b/hatch_cpp/__main__.py deleted file mode 100644 index 9ae637f..0000000 --- a/hatch_cpp/__main__.py +++ /dev/null @@ -1,4 +0,0 @@ -from .cli import main - -if __name__ == "__main__": - main() diff --git a/hatch_cpp/config.py b/hatch_cpp/config.py new file mode 100644 index 0000000..805faec --- /dev/null +++ b/hatch_cpp/config.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +from os import environ, system as system_call +from pathlib import Path +from typing import List, Optional + +from pkn import getSimpleLogger +from pydantic import BaseModel, Field, model_validator + +from .toolchains import BuildType, HatchCppCmakeConfiguration, HatchCppLibrary, HatchCppPlatform, HatchCppVcpkgConfiguration, Toolchain + +__all__ = ( + "HatchCppBuildConfig", + "HatchCppBuildPlan", +) + + +log = getSimpleLogger("hatch_cpp") + + +class HatchCppBuildConfig(BaseModel): + """Build config values for Hatch C++ Builder.""" + + verbose: Optional[bool] = Field(default=False) + skip: Optional[bool] = Field(default=False) + name: Optional[str] = Field(default=None) + libraries: List[HatchCppLibrary] = Field(default_factory=list) + cmake: Optional[HatchCppCmakeConfiguration] = Field(default=None) + platform: Optional[HatchCppPlatform] = Field(default_factory=HatchCppPlatform.default) + vcpkg: Optional[HatchCppVcpkgConfiguration] = Field(default_factory=HatchCppVcpkgConfiguration) + + @model_validator(mode="wrap") + @classmethod + def validate_model(cls, data, handler): + if "toolchain" in data: + data["platform"] = HatchCppPlatform.platform_for_toolchain(data["toolchain"]) + data.pop("toolchain") + elif "platform" not in data: + data["platform"] = HatchCppPlatform.default() + if "cc" in data: + data["platform"].cc = data["cc"] + data.pop("cc") + if "cxx" in data: + data["platform"].cxx = data["cxx"] + data.pop("cxx") + if "ld" in data: + data["platform"].ld = data["ld"] + data.pop("ld") + if "vcpkg" in data and data["vcpkg"] == "false": + data["vcpkg"] = None + model = handler(data) + if model.cmake and model.libraries: + raise ValueError("Must not provide libraries when using cmake toolchain.") + return model + + +class HatchCppBuildPlan(HatchCppBuildConfig): + build_type: BuildType = "release" + commands: List[str] = Field(default_factory=list) + + _active_toolchains: List[Toolchain] = [] + + def generate(self): + self.commands = [] + + # Check for env var overrides + vcpkg_override = environ.get("HATCH_CPP_VCPKG") + cmake_override = environ.get("HATCH_CPP_CMAKE") + + # Evaluate toolchains + if vcpkg_override == "1": + if self.vcpkg: + self._active_toolchains.append("vcpkg") + else: + log.warning("HATCH_CPP_VCPKG=1 set but no vcpkg configuration found; ignoring.") + elif vcpkg_override != "0" and self.vcpkg and Path(self.vcpkg.vcpkg).exists(): + self._active_toolchains.append("vcpkg") + + if self.libraries: + self._active_toolchains.append("vanilla") + elif cmake_override == "1": + if self.cmake: + self._active_toolchains.append("cmake") + else: + log.warning("HATCH_CPP_CMAKE=1 set but no cmake configuration found; ignoring.") + elif cmake_override != "0" and self.cmake: + self._active_toolchains.append("cmake") + + # Collect toolchain commands + if "vcpkg" in self._active_toolchains: + self.commands.extend(self.vcpkg.generate(self)) + + if "vanilla" in self._active_toolchains: + if "vcpkg" in self._active_toolchains: + log.warning("vcpkg toolchain is active; ensure that your compiler is configured to use vcpkg includes and libs.") + + for library in self.libraries: + compile_flags = self.platform.get_compile_flags(library, self.build_type) + link_flags = self.platform.get_link_flags(library, self.build_type) + self.commands.append( + f"{self.platform.cc if library.language == 'c' else self.platform.cxx} {' '.join(library.sources)} {compile_flags} {link_flags}" + ) + + if "cmake" in self._active_toolchains: + self.commands.extend(self.cmake.generate(self)) + + return self.commands + + def execute(self): + for command in self.commands: + ret = system_call(command) + if ret != 0: + raise RuntimeError(f"hatch-cpp build command failed with exit code {ret}: {command}") + return self.commands + + def cleanup(self): + if self.platform.platform == "win32": + for temp_obj in Path(".").glob("*.obj"): + temp_obj.unlink() diff --git a/hatch_cpp/plugin.py b/hatch_cpp/plugin.py index fb88301..76914ac 100644 --- a/hatch_cpp/plugin.py +++ b/hatch_cpp/plugin.py @@ -1,13 +1,15 @@ from __future__ import annotations -import logging -import os -import typing as t -from dataclasses import fields +from pathlib import Path +from platform import machine as platform_machine +from sys import platform as sys_platform, version_info +from typing import Any +from hatch_build import parse_extra_args_model from hatchling.builders.hooks.plugin.interface import BuildHookInterface -from .structs import HatchCppBuildConfig, HatchCppBuildPlan, HatchCppLibrary, HatchCppPlatform +from .config import HatchCppBuildConfig, HatchCppBuildPlan, log +from .utils import import_string __all__ = ("HatchCppBuildHook",) @@ -16,70 +18,96 @@ class HatchCppBuildHook(BuildHookInterface[HatchCppBuildConfig]): """The hatch-cpp build hook.""" PLUGIN_NAME = "hatch-cpp" - _logger = logging.getLogger(__name__) + _logger = log - def initialize(self, version: str, _: dict[str, t.Any]) -> None: + def initialize(self, version: str, build_data: dict[str, Any]) -> None: """Initialize the plugin.""" - self._logger.info("Running hatch-cpp") + # Log some basic information + project_name = self.metadata.config["project"]["name"] + self._logger.info("Initializing hatch-cpp plugin version %s", version) + self._logger.info(f"Running hatch-cpp: {project_name}") + # Only run if creating wheel + # TODO: Add support for specify sdist-plan if self.target_name != "wheel": self._logger.info("ignoring target name %s", self.target_name) return - if os.getenv("SKIP_HATCH_CPP"): - self._logger.info("Skipping the build hook since SKIP_HATCH_CPP was set") + # Get build config class or use default + build_config_class = import_string(self.config["build-config-class"]) if "build-config-class" in self.config else HatchCppBuildConfig + + # Instantiate build config + config = build_config_class(name=project_name, **self.config) + + # Get build plan class or use default + build_plan_class = import_string(self.config["build-plan-class"]) if "build-plan-class" in self.config else HatchCppBuildPlan + + # Instantiate builder + build_plan = build_plan_class(**config.model_dump()) + + # Parse override args + parse_extra_args_model(build_plan) + + # Generate commands + build_plan.generate() + + # Log commands if in verbose mode + if build_plan.verbose: + for command in build_plan.commands: + self._logger.warning(command) + + if build_plan.skip: + self._logger.warning("Skipping build") return - kwargs = {k.replace("-", "_"): v if not isinstance(v, bool) else str(v) for k, v in self.config.items()} - available_fields = [f.name for f in fields(HatchCppBuildConfig)] - for key in list(kwargs): - if key not in available_fields: - del kwargs[key] - config = HatchCppBuildConfig(**kwargs) - - library_kwargs = [ - {k.replace("-", "_"): v if not isinstance(v, bool) else str(v) for k, v in library_kwargs.items()} for library_kwargs in config.libraries - ] - libraries = [HatchCppLibrary(**library_kwargs) for library_kwargs in library_kwargs] - platform = HatchCppPlatform.default() - if config.toolchain == "raw": - # g++ basic-project/basic.cpp -I. -I/opt/homebrew/opt/python@3.11/Frameworks/Python.framework/Versions/3.11/include/python3.11/ -undefined dynamic_lookup -fPIC -shared -o extension.so - build_plan = HatchCppBuildPlan(libraries=libraries, platform=platform) - build_plan.generate() - build_plan.execute(verbose=config.verbose) - # build_kwargs = config.build_kwargs - # if version == "editable": - # build_kwargs = config.editable_build_kwargs or build_kwargs - - # should_skip_build = False - # if not config.build_function: - # log.warning("No build function found") - # should_skip_build = True - - # elif config.skip_if_exists and version == "standard": - # should_skip_build = should_skip(config.skip_if_exists) - # if should_skip_build: - # log.info("Skip-if-exists file(s) found") - - # # Get build function and call it with normalized parameter names. - # if not should_skip_build and config.build_function: - # build_func = get_build_func(config.build_function) - # build_kwargs = normalize_kwargs(build_kwargs) - # log.info("Building with %s", config.build_function) - # log.info("With kwargs: %s", build_kwargs) - # try: - # build_func(self.target_name, version, **build_kwargs) - # except Exception as e: - # if version == "editable" and config.optional_editable_build.lower() == "true": - # warnings.warn(f"Encountered build error:\n{e}", stacklevel=2) - # else: - # raise e - # else: - # log.info("Skipping build") - - # # Ensure targets in distributable dists. - # if version == "standard": - # ensure_targets(config.ensured_targets) - - self._logger.info("Finished running hatch-cpp") - return + # Execute build plan + build_plan.execute() + + # Perform any cleanup actions + build_plan.cleanup() + + if build_plan.libraries: + # force include libraries + for library in build_plan.libraries: + name = library.get_qualified_name(build_plan.platform.platform) + build_data["force_include"][name] = name + + build_data["pure_python"] = False + machine = platform_machine() + version_major = version_info.major + version_minor = version_info.minor + if "darwin" in sys_platform: + os_name = "macosx_11_0" + elif "linux" in sys_platform: + os_name = "linux" + else: + os_name = "win" + if all([lib.py_limited_api for lib in build_plan.libraries]): + build_data["tag"] = f"cp{version_major}{version_minor}-abi3-{os_name}_{machine}" + else: + build_data["tag"] = f"cp{version_major}{version_minor}-cp{version_major}{version_minor}-{os_name}_{machine}" + else: + build_data["pure_python"] = False + machine = platform_machine() + version_major = version_info.major + version_minor = version_info.minor + # TODO abi3 + if "darwin" in sys_platform: + os_name = "macosx_11_0" + elif "linux" in sys_platform: + os_name = "linux" + else: + os_name = "win" + build_data["tag"] = f"cp{version_major}{version_minor}-cp{version_major}{version_minor}-{os_name}_{machine}" + + # force include libraries + for path in Path(".").rglob("*"): + if path.is_dir(): + continue + if str(path).startswith(str(build_plan.cmake.build)) or str(path).startswith("dist"): + continue + if path.suffix in (".pyd", ".dll", ".so", ".dylib"): + build_data["force_include"][str(path)] = str(path) + + for path in build_data["force_include"]: + self._logger.info(f"Force include: {path}") diff --git a/hatch_cpp/structs.py b/hatch_cpp/structs.py deleted file mode 100644 index ccfab16..0000000 --- a/hatch_cpp/structs.py +++ /dev/null @@ -1,146 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass, field -from os import environ, system -from sys import platform as sys_platform -from sysconfig import get_path -from typing import Literal - -from hatchling.builders.config import BuilderConfig - -__all__ = ( - "HatchCppBuildConfig", - "HatchCppLibrary", - "HatchCppPlatform", - "HatchCppBuildPlan", -) - -Platform = Literal["linux", "darwin", "win32"] -CompilerToolchain = Literal["gcc", "clang", "msvc"] -PlatformDefaults = { - "linux": {"CC": "gcc", "CXX": "g++"}, - "darwin": {"CC": "clang", "CXX": "clang++"}, - "win32": {"CC": "cl", "CXX": "cl"}, -} - - -@dataclass -class HatchCppBuildConfig(BuilderConfig): - """Build config values for Hatch C++ Builder.""" - - toolchain: str | None = field(default="raw") - libraries: list[dict[str, str]] = field(default_factory=list) - verbose: bool | None = field(default=False) - # build_function: str | None = None - # build_kwargs: t.Mapping[str, str] = field(default_factory=dict) - # editable_build_kwargs: t.Mapping[str, str] = field(default_factory=dict) - # ensured_targets: list[str] = field(default_factory=list) - # skip_if_exists: list[str] = field(default_factory=list) - - -@dataclass -class HatchCppLibrary(object): - """A C++ library.""" - - name: str - sources: list[str] - - include_dirs: list[str] = field(default_factory=list) - library_dirs: list[str] = field(default_factory=list) - libraries: list[str] = field(default_factory=list) - extra_compile_args: list[str] = field(default_factory=list) - extra_link_args: list[str] = field(default_factory=list) - extra_objects: list[str] = field(default_factory=list) - define_macros: list[str] = field(default_factory=list) - undef_macros: list[str] = field(default_factory=list) - - export_symbols: list[str] = field(default_factory=list) - depends: list[str] = field(default_factory=list) - - -@dataclass -class HatchCppPlatform(object): - cc: str - cxx: str - platform: Platform - toolchain: CompilerToolchain - - @staticmethod - def default() -> HatchCppPlatform: - platform = environ.get("HATCH_CPP_PLATFORM", sys_platform) - CC = environ.get("CC", PlatformDefaults[platform]["CC"]) - CXX = environ.get("CXX", PlatformDefaults[platform]["CXX"]) - if "gcc" in CC and "g++" in CXX: - toolchain = "gcc" - elif "clang" in CC and "clang++" in CXX: - toolchain = "clang" - elif "cl" in CC and "cl" in CXX: - toolchain = "msvc" - else: - raise Exception(f"Unrecognized toolchain: {CC}, {CXX}") - return HatchCppPlatform(cc=CC, cxx=CXX, platform=platform, toolchain=toolchain) - - def get_flags(self, library: HatchCppLibrary) -> str: - flags = "" - if self.toolchain == "gcc": - flags = f"-I{get_path('include')}" - flags += " " + " ".join(f"-I{d}" for d in library.include_dirs) - flags += " -fPIC -shared" - flags += " " + " ".join(library.extra_compile_args) - flags += " " + " ".join(library.extra_link_args) - flags += " " + " ".join(library.extra_objects) - flags += " " + " ".join(f"-l{lib}" for lib in library.libraries) - flags += " " + " ".join(f"-L{lib}" for lib in library.library_dirs) - flags += " " + " ".join(f"-D{macro}" for macro in library.define_macros) - flags += " " + " ".join(f"-U{macro}" for macro in library.undef_macros) - flags += f" -o {library.name}.so" - elif self.toolchain == "clang": - flags = f"-I{get_path('include')} " - flags += " ".join(f"-I{d}" for d in library.include_dirs) - flags += " -undefined dynamic_lookup -fPIC -shared" - flags += " " + " ".join(library.extra_compile_args) - flags += " " + " ".join(library.extra_link_args) - flags += " " + " ".join(library.extra_objects) - flags += " " + " ".join(f"-l{lib}" for lib in library.libraries) - flags += " " + " ".join(f"-L{lib}" for lib in library.library_dirs) - flags += " " + " ".join(f"-D{macro}" for macro in library.define_macros) - flags += " " + " ".join(f"-U{macro}" for macro in library.undef_macros) - flags += f" -o {library.name}.so" - elif self.toolchain == "msvc": - flags = f"/I{get_path('include')} " - flags += " ".join(f"/I{d}" for d in library.include_dirs) - flags += " /LD" - flags += " " + " ".join(library.extra_compile_args) - flags += " " + " ".join(library.extra_link_args) - flags += " " + " ".join(library.extra_objects) - flags += " " + " ".join(f"{lib}.lib" for lib in library.libraries) - flags += " " + " ".join(f"/LIBPATH:{lib}" for lib in library.library_dirs) - flags += " " + " ".join(f"/D{macro}" for macro in library.define_macros) - flags += " " + " ".join(f"/U{macro}" for macro in library.undef_macros) - flags += f" /Fo{library.name}.obj" - flags += f" /Fe{library.name}.pyd" - # clean - while flags.count(" "): - flags = flags.replace(" ", " ") - return flags - - -@dataclass -class HatchCppBuildPlan(object): - libraries: list[HatchCppLibrary] = field(default_factory=list) - platform: HatchCppPlatform = field(default_factory=HatchCppPlatform.default) - commands: list[str] = field(default_factory=list) - - def generate(self): - self.commands = [] - for library in self.libraries: - flags = self.platform.get_flags(library) - self.commands.append(f"{self.platform.cc} {' '.join(library.sources)} {flags}") - return self.commands - - def execute(self, verbose: bool = True): - for command in self.commands: - if verbose: - print(f"Running command: {command}") - system(command) - return self.commands diff --git a/hatch_cpp/tests/test_all.py b/hatch_cpp/tests/test_all.py deleted file mode 100644 index 82959de..0000000 --- a/hatch_cpp/tests/test_all.py +++ /dev/null @@ -1,2 +0,0 @@ -def test_import(): - pass diff --git a/hatch_cpp/tests/test_hatch_build.py b/hatch_cpp/tests/test_hatch_build.py new file mode 100644 index 0000000..b71e185 --- /dev/null +++ b/hatch_cpp/tests/test_hatch_build.py @@ -0,0 +1,46 @@ +from os import listdir +from pathlib import Path +from shutil import rmtree +from subprocess import check_call +from sys import modules, path, platform + + +class TestHatchBuild: + def test_hatch_build(self): + project = "test_project_hatch_build" + + rmtree(f"hatch_cpp/tests/{project}/project/extension.so", ignore_errors=True) + rmtree(f"hatch_cpp/tests/{project}/project/extension.pyd", ignore_errors=True) + modules.pop("project", None) + modules.pop("project.extension", None) + + # compile + check_call( + [ + "hatch-build", + "--hooks-only", + "--", + "--libraries.0.name=project/extension", + "--libraries.0.sources=cpp/project/basic.cpp", + "--libraries.0.include-dirs=cpp", + "--libraries.0.binding=nanobind", + ], + cwd=f"hatch_cpp/tests/{project}", + ) + + # assert built + + if project == "test_project_limited_api" and platform != "win32": + assert "extension.abi3.so" in listdir(f"hatch_cpp/tests/{project}/project") + else: + if platform == "win32": + assert "extension.pyd" in listdir(f"hatch_cpp/tests/{project}/project") + else: + assert "extension.so" in listdir(f"hatch_cpp/tests/{project}/project") + + # import + here = Path(__file__).parent / project + path.insert(0, str(here)) + import project.extension + + assert project.extension.hello() == "A string" diff --git a/hatch_cpp/tests/test_platform_specific.py b/hatch_cpp/tests/test_platform_specific.py new file mode 100644 index 0000000..455d7e1 --- /dev/null +++ b/hatch_cpp/tests/test_platform_specific.py @@ -0,0 +1,535 @@ +"""Tests for platform-specific library configuration fields.""" + +from hatch_cpp import HatchCppLibrary, HatchCppPlatform + + +class TestPlatformSpecificFields: + """Test suite for platform-specific field handling in HatchCppLibrary.""" + + def test_effective_include_dirs(self): + """Test that include_dirs are properly merged with platform-specific dirs.""" + library = HatchCppLibrary( + name="test", + sources=["test.cpp"], + include_dirs=["common/include"], + include_dirs_linux=["linux/include"], + include_dirs_darwin=["darwin/include"], + include_dirs_win32=["win32/include"], + ) + + linux_dirs = library.get_effective_include_dirs("linux") + assert "common/include" in linux_dirs + assert "linux/include" in linux_dirs + assert "darwin/include" not in linux_dirs + assert "win32/include" not in linux_dirs + + darwin_dirs = library.get_effective_include_dirs("darwin") + assert "common/include" in darwin_dirs + assert "darwin/include" in darwin_dirs + assert "linux/include" not in darwin_dirs + + win32_dirs = library.get_effective_include_dirs("win32") + assert "common/include" in win32_dirs + assert "win32/include" in win32_dirs + assert "linux/include" not in win32_dirs + + def test_effective_library_dirs(self): + """Test that library_dirs are properly merged with platform-specific dirs.""" + library = HatchCppLibrary( + name="test", + sources=["test.cpp"], + library_dirs=["common/lib"], + library_dirs_linux=["linux/lib"], + library_dirs_darwin=["darwin/lib"], + library_dirs_win32=["win32/lib"], + ) + + linux_dirs = library.get_effective_library_dirs("linux") + assert "common/lib" in linux_dirs + assert "linux/lib" in linux_dirs + assert "darwin/lib" not in linux_dirs + + darwin_dirs = library.get_effective_library_dirs("darwin") + assert "common/lib" in darwin_dirs + assert "darwin/lib" in darwin_dirs + + win32_dirs = library.get_effective_library_dirs("win32") + assert "common/lib" in win32_dirs + assert "win32/lib" in win32_dirs + + def test_effective_libraries(self): + """Test that libraries are properly merged with platform-specific libraries.""" + library = HatchCppLibrary( + name="test", + sources=["test.cpp"], + libraries=["common"], + libraries_linux=["pthread", "dl"], + libraries_darwin=["objc"], + libraries_win32=["kernel32", "user32"], + ) + + linux_libs = library.get_effective_libraries("linux") + assert "common" in linux_libs + assert "pthread" in linux_libs + assert "dl" in linux_libs + assert "objc" not in linux_libs + + darwin_libs = library.get_effective_libraries("darwin") + assert "common" in darwin_libs + assert "objc" in darwin_libs + assert "pthread" not in darwin_libs + + win32_libs = library.get_effective_libraries("win32") + assert "common" in win32_libs + assert "kernel32" in win32_libs + assert "user32" in win32_libs + assert "pthread" not in win32_libs + + def test_effective_compile_args(self): + """Test that extra_compile_args are properly merged with platform-specific args.""" + library = HatchCppLibrary( + name="test", + sources=["test.cpp"], + extra_compile_args=["-O2"], + extra_compile_args_linux=["-march=native"], + extra_compile_args_darwin=["-mmacosx-version-min=11"], + extra_compile_args_win32=["/O2"], + ) + + linux_args = library.get_effective_compile_args("linux") + assert "-O2" in linux_args + assert "-march=native" in linux_args + assert "-mmacosx-version-min=11" not in linux_args + + darwin_args = library.get_effective_compile_args("darwin") + assert "-O2" in darwin_args + assert "-mmacosx-version-min=11" in darwin_args + + win32_args = library.get_effective_compile_args("win32") + assert "-O2" in win32_args + assert "/O2" in win32_args + + def test_effective_link_args(self): + """Test that extra_link_args are properly merged with platform-specific args.""" + library = HatchCppLibrary( + name="test", + sources=["test.cpp"], + extra_link_args=["-shared"], + extra_link_args_linux=["-Wl,-rpath,$ORIGIN/lib"], + extra_link_args_darwin=["-Wl,-rpath,@loader_path/lib"], + extra_link_args_win32=["/NODEFAULTLIB"], + ) + + linux_args = library.get_effective_link_args("linux") + assert "-shared" in linux_args + assert "-Wl,-rpath,$ORIGIN/lib" in linux_args + assert "-Wl,-rpath,@loader_path/lib" not in linux_args + + darwin_args = library.get_effective_link_args("darwin") + assert "-shared" in darwin_args + assert "-Wl,-rpath,@loader_path/lib" in darwin_args + + win32_args = library.get_effective_link_args("win32") + assert "-shared" in win32_args + assert "/NODEFAULTLIB" in win32_args + + def test_effective_extra_objects(self): + """Test that extra_objects are properly merged with platform-specific objects.""" + library = HatchCppLibrary( + name="test", + sources=["test.cpp"], + extra_objects=["common.o"], + extra_objects_linux=["linux.o"], + extra_objects_darwin=["darwin.o"], + extra_objects_win32=["win32.obj"], + ) + + linux_objs = library.get_effective_extra_objects("linux") + assert "common.o" in linux_objs + assert "linux.o" in linux_objs + assert "darwin.o" not in linux_objs + + darwin_objs = library.get_effective_extra_objects("darwin") + assert "common.o" in darwin_objs + assert "darwin.o" in darwin_objs + + win32_objs = library.get_effective_extra_objects("win32") + assert "common.o" in win32_objs + assert "win32.obj" in win32_objs + + def test_effective_define_macros(self): + """Test that define_macros are properly merged with platform-specific macros.""" + library = HatchCppLibrary( + name="test", + sources=["test.cpp"], + define_macros=["COMMON=1"], + define_macros_linux=["LINUX=1", "_GNU_SOURCE"], + define_macros_darwin=["DARWIN=1", "__APPLE__"], + define_macros_win32=["WIN32=1", "_WINDOWS"], + ) + + linux_macros = library.get_effective_define_macros("linux") + assert "COMMON=1" in linux_macros + assert "LINUX=1" in linux_macros + assert "_GNU_SOURCE" in linux_macros + assert "DARWIN=1" not in linux_macros + + darwin_macros = library.get_effective_define_macros("darwin") + assert "COMMON=1" in darwin_macros + assert "DARWIN=1" in darwin_macros + assert "__APPLE__" in darwin_macros + + win32_macros = library.get_effective_define_macros("win32") + assert "COMMON=1" in win32_macros + assert "WIN32=1" in win32_macros + assert "_WINDOWS" in win32_macros + + def test_effective_undef_macros(self): + """Test that undef_macros are properly merged with platform-specific macros.""" + library = HatchCppLibrary( + name="test", + sources=["test.cpp"], + undef_macros=["COMMON_UNDEF"], + undef_macros_linux=["LINUX_UNDEF"], + undef_macros_darwin=["DARWIN_UNDEF"], + undef_macros_win32=["WIN32_UNDEF"], + ) + + linux_macros = library.get_effective_undef_macros("linux") + assert "COMMON_UNDEF" in linux_macros + assert "LINUX_UNDEF" in linux_macros + assert "DARWIN_UNDEF" not in linux_macros + + darwin_macros = library.get_effective_undef_macros("darwin") + assert "COMMON_UNDEF" in darwin_macros + assert "DARWIN_UNDEF" in darwin_macros + + win32_macros = library.get_effective_undef_macros("win32") + assert "COMMON_UNDEF" in win32_macros + assert "WIN32_UNDEF" in win32_macros + + def test_empty_platform_specific_fields(self): + """Test that empty platform-specific fields don't cause issues.""" + library = HatchCppLibrary( + name="test", + sources=["test.cpp"], + include_dirs=["common/include"], + # No platform-specific fields set + ) + + linux_dirs = library.get_effective_include_dirs("linux") + assert linux_dirs == ["common/include"] + + darwin_dirs = library.get_effective_include_dirs("darwin") + assert darwin_dirs == ["common/include"] + + win32_dirs = library.get_effective_include_dirs("win32") + assert win32_dirs == ["common/include"] + + def test_only_platform_specific_fields(self): + """Test that only platform-specific fields work without common fields.""" + library = HatchCppLibrary( + name="test", + sources=["test.cpp"], + # No common include_dirs + include_dirs_linux=["linux/include"], + include_dirs_darwin=["darwin/include"], + ) + + linux_dirs = library.get_effective_include_dirs("linux") + assert linux_dirs == ["linux/include"] + + darwin_dirs = library.get_effective_include_dirs("darwin") + assert darwin_dirs == ["darwin/include"] + + win32_dirs = library.get_effective_include_dirs("win32") + assert win32_dirs == [] + + def test_alias_hyphenated_names(self): + """Test that hyphenated field names work as aliases.""" + library = HatchCppLibrary( + name="test", + sources=["test.cpp"], + **{ + "include-dirs": ["common/include"], + "include-dirs-linux": ["linux/include"], + "library-dirs-darwin": ["darwin/lib"], + "extra-compile-args-win32": ["/O2"], + "extra-link-args-linux": ["-Wl,-rpath,$ORIGIN"], + "define-macros-darwin": ["DARWIN=1"], + "undef-macros-win32": ["NDEBUG"], + }, + ) + + assert library.include_dirs == ["common/include"] + assert library.include_dirs_linux == ["linux/include"] + assert library.library_dirs_darwin == ["darwin/lib"] + assert library.extra_compile_args_win32 == ["/O2"] + assert library.extra_link_args_linux == ["-Wl,-rpath,$ORIGIN"] + assert library.define_macros_darwin == ["DARWIN=1"] + assert library.undef_macros_win32 == ["NDEBUG"] + + +class TestPlatformFlagsIntegration: + """Integration tests for platform-specific fields in compile/link flags.""" + + def test_compile_flags_include_platform_specific_include_dirs(self): + """Test that get_compile_flags includes platform-specific include dirs.""" + library = HatchCppLibrary( + name="test", + sources=["test.cpp"], + binding="generic", + include_dirs=["common/include"], + include_dirs_linux=["linux/include"], + ) + + # Create a mock linux platform + platform = HatchCppPlatform(cc="gcc", cxx="g++", ld="ld", platform="linux", toolchain="gcc", disable_ccache=True) + + flags = platform.get_compile_flags(library) + assert "-Icommon/include" in flags + assert "-Ilinux/include" in flags + + def test_compile_flags_include_platform_specific_macros(self): + """Test that get_compile_flags includes platform-specific define/undef macros.""" + library = HatchCppLibrary( + name="test", + sources=["test.cpp"], + binding="generic", + define_macros=["COMMON=1"], + define_macros_linux=["LINUX_SPECIFIC=1"], + undef_macros=["OLD_MACRO"], + undef_macros_linux=["LINUX_OLD"], + ) + + platform = HatchCppPlatform(cc="gcc", cxx="g++", ld="ld", platform="linux", toolchain="gcc", disable_ccache=True) + + flags = platform.get_compile_flags(library) + assert "-DCOMMON=1" in flags + assert "-DLINUX_SPECIFIC=1" in flags + assert "-UOLD_MACRO" in flags + assert "-ULINUX_OLD" in flags + + def test_link_flags_include_platform_specific_libraries(self): + """Test that get_link_flags includes platform-specific libraries.""" + library = HatchCppLibrary( + name="test", + sources=["test.cpp"], + binding="generic", + libraries=["common"], + libraries_linux=["pthread", "dl"], + library_dirs=["common/lib"], + library_dirs_linux=["linux/lib"], + ) + + platform = HatchCppPlatform(cc="gcc", cxx="g++", ld="ld", platform="linux", toolchain="gcc", disable_ccache=True) + + flags = platform.get_link_flags(library) + assert "-lcommon" in flags + assert "-lpthread" in flags + assert "-ldl" in flags + assert "-Lcommon/lib" in flags + assert "-Llinux/lib" in flags + + def test_link_flags_include_platform_specific_link_args(self): + """Test that get_link_flags includes platform-specific extra_link_args.""" + library = HatchCppLibrary( + name="test", + sources=["test.cpp"], + binding="generic", + extra_link_args=["-shared"], + extra_link_args_linux=["-Wl,-rpath,$ORIGIN/lib"], + ) + + platform = HatchCppPlatform(cc="gcc", cxx="g++", ld="ld", platform="linux", toolchain="gcc", disable_ccache=True) + + flags = platform.get_link_flags(library) + assert "-shared" in flags + assert r"-Wl,-rpath,\$ORIGIN/lib" in flags + + def test_darwin_platform_uses_darwin_specific_fields(self): + """Test that darwin platform uses darwin-specific fields.""" + library = HatchCppLibrary( + name="test", + sources=["test.cpp"], + binding="generic", + libraries_linux=["pthread"], + libraries_darwin=["objc"], + extra_link_args_darwin=["-Wl,-rpath,@loader_path/lib"], + ) + + platform = HatchCppPlatform(cc="clang", cxx="clang++", ld="ld", platform="darwin", toolchain="clang", disable_ccache=True) + + flags = platform.get_link_flags(library) + assert "-lobjc" in flags + assert "-lpthread" not in flags + assert "-Wl,-rpath,@loader_path/lib" in flags + + def test_win32_platform_uses_win32_specific_fields(self): + """Test that win32 platform uses win32-specific fields.""" + library = HatchCppLibrary( + name="test", + sources=["test.cpp"], + binding="generic", + libraries=["common"], + libraries_win32=["kernel32"], + library_dirs=["common/lib"], + library_dirs_win32=["win32/lib"], + ) + + platform = HatchCppPlatform(cc="cl", cxx="cl", ld="link", platform="win32", toolchain="msvc", disable_ccache=True) + + flags = platform.get_link_flags(library) + assert "common.lib" in flags + assert "kernel32.lib" in flags + assert "/LIBPATH:common/lib" in flags + assert "/LIBPATH:win32/lib" in flags + + def test_msvc_compile_flags_use_platform_specific_fields(self): + """Test that MSVC compile flags include platform-specific fields.""" + library = HatchCppLibrary( + name="test", + sources=["test.cpp"], + binding="generic", + include_dirs=["common/include"], + include_dirs_win32=["win32/include"], + define_macros=["COMMON=1"], + define_macros_win32=["_WINDOWS"], + extra_compile_args_win32=["/W4"], + ) + + platform = HatchCppPlatform(cc="cl", cxx="cl", ld="link", platform="win32", toolchain="msvc", disable_ccache=True) + + flags = platform.get_compile_flags(library) + assert "/Icommon/include" in flags + assert "/Iwin32/include" in flags + assert "/DCOMMON=1" in flags + assert "/D_WINDOWS" in flags + assert "/W4" in flags + + +class TestPlatformFieldOrdering: + """Test that platform-specific fields are appended after common fields.""" + + def test_include_dirs_ordering(self): + """Test that platform-specific include dirs come after common dirs.""" + library = HatchCppLibrary( + name="test", + sources=["test.cpp"], + include_dirs=["first", "second"], + include_dirs_linux=["third", "fourth"], + ) + + dirs = library.get_effective_include_dirs("linux") + assert dirs == ["first", "second", "third", "fourth"] + + def test_libraries_ordering(self): + """Test that platform-specific libraries come after common libraries.""" + library = HatchCppLibrary( + name="test", + sources=["test.cpp"], + libraries=["common1", "common2"], + libraries_linux=["linux1", "linux2"], + ) + + libs = library.get_effective_libraries("linux") + assert libs == ["common1", "common2", "linux1", "linux2"] + + def test_base_list_not_mutated(self): + """Test that the base lists are not mutated when getting effective values.""" + library = HatchCppLibrary( + name="test", + sources=["test.cpp"], + include_dirs=["common"], + include_dirs_linux=["linux"], + ) + + # Get effective dirs multiple times + dirs1 = library.get_effective_include_dirs("linux") + dirs2 = library.get_effective_include_dirs("linux") + + # Both should be equal + assert dirs1 == dirs2 + + # Base list should not be modified + assert library.include_dirs == ["common"] + assert library.include_dirs_linux == ["linux"] + + +class TestMSVCPythonLibsPath: + """Tests for MSVC Python libs path discovery.""" + + def test_msvc_link_flags_include_libpath(self): + """Test that MSVC link flags include /LIBPATH for Python libs.""" + library = HatchCppLibrary( + name="test", + sources=["test.cpp"], + binding="generic", # Skip Python.h include + ) + + platform = HatchCppPlatform( + cc="cl", + cxx="cl", + ld="link", + platform="win32", + toolchain="msvc", + disable_ccache=True, + ) + + flags = platform.get_link_flags(library) + # Should have /link /DLL flags + assert "/link" in flags + assert "/DLL" in flags + # Should have output file + assert "/Fe:" in flags + + def test_msvc_link_flags_with_libraries(self): + """Test that MSVC link flags properly format library names.""" + library = HatchCppLibrary( + name="test", + sources=["test.cpp"], + binding="generic", + libraries=["mylib"], + library_dirs=["path/to/libs"], + ) + + platform = HatchCppPlatform( + cc="cl", + cxx="cl", + ld="link", + platform="win32", + toolchain="msvc", + disable_ccache=True, + ) + + flags = platform.get_link_flags(library) + # Libraries should have .lib suffix on Windows + assert "mylib.lib" in flags + # Library dirs should use /LIBPATH: + assert "/LIBPATH:path/to/libs" in flags + + def test_msvc_link_flags_with_platform_specific_libraries(self): + """Test that MSVC uses win32-specific libraries.""" + library = HatchCppLibrary( + name="test", + sources=["test.cpp"], + binding="generic", + libraries=["common"], + libraries_win32=["kernel32", "user32"], + library_dirs_win32=["C:/Windows/System32"], + ) + + platform = HatchCppPlatform( + cc="cl", + cxx="cl", + ld="link", + platform="win32", + toolchain="msvc", + disable_ccache=True, + ) + + flags = platform.get_link_flags(library) + assert "common.lib" in flags + assert "kernel32.lib" in flags + assert "user32.lib" in flags + assert "/LIBPATH:C:/Windows/System32" in flags diff --git a/hatch_cpp/tests/test_project_basic.py b/hatch_cpp/tests/test_project_basic.py deleted file mode 100644 index b70fff5..0000000 --- a/hatch_cpp/tests/test_project_basic.py +++ /dev/null @@ -1,22 +0,0 @@ -from os import listdir -from shutil import rmtree -from subprocess import check_output -from sys import platform - - -class TestProject: - def test_basic(self): - rmtree("hatch_cpp/tests/test_project_basic/basic_project/extension.so", ignore_errors=True) - rmtree("hatch_cpp/tests/test_project_basic/basic_project/extension.pyd", ignore_errors=True) - check_output( - [ - "hatchling", - "build", - "--hooks-only", - ], - cwd="hatch_cpp/tests/test_project_basic", - ) - if platform == "win32": - assert "extension.pyd" in listdir("hatch_cpp/tests/test_project_basic/basic_project") - else: - assert "extension.so" in listdir("hatch_cpp/tests/test_project_basic/basic_project") diff --git a/hatch_cpp/tests/test_project_basic/cpp/basic-project/basic.cpp b/hatch_cpp/tests/test_project_basic/cpp/project/basic.cpp similarity index 71% rename from hatch_cpp/tests/test_project_basic/cpp/basic-project/basic.cpp rename to hatch_cpp/tests/test_project_basic/cpp/project/basic.cpp index a7e840e..db4432a 100644 --- a/hatch_cpp/tests/test_project_basic/cpp/basic-project/basic.cpp +++ b/hatch_cpp/tests/test_project_basic/cpp/project/basic.cpp @@ -1,4 +1,4 @@ -#include "basic-project/basic.hpp" +#include "project/basic.hpp" PyObject* hello(PyObject*, PyObject*) { return PyUnicode_FromString("A string"); diff --git a/hatch_cpp/tests/test_project_basic/cpp/basic-project/basic.hpp b/hatch_cpp/tests/test_project_basic/cpp/project/basic.hpp similarity index 100% rename from hatch_cpp/tests/test_project_basic/cpp/basic-project/basic.hpp rename to hatch_cpp/tests/test_project_basic/cpp/project/basic.hpp diff --git a/hatch_cpp/tests/test_project_basic/basic_project/__init__.py b/hatch_cpp/tests/test_project_basic/project/__init__.py similarity index 100% rename from hatch_cpp/tests/test_project_basic/basic_project/__init__.py rename to hatch_cpp/tests/test_project_basic/project/__init__.py diff --git a/hatch_cpp/tests/test_project_basic/pyproject.toml b/hatch_cpp/tests/test_project_basic/pyproject.toml index aea842d..d51683e 100644 --- a/hatch_cpp/tests/test_project_basic/pyproject.toml +++ b/hatch_cpp/tests/test_project_basic/pyproject.toml @@ -14,50 +14,22 @@ dependencies = [ [tool.hatch.build] artifacts = [ - "basic_project/*.dll", - "basic_project/*.dylib", - "basic_project/*.so", + "project/*.dll", + "project/*.dylib", + "project/*.so", ] [tool.hatch.build.sources] src = "/" [tool.hatch.build.targets.sdist] -packages = ["basic_project"] +packages = ["project"] [tool.hatch.build.targets.wheel] -packages = ["basic_project"] +packages = ["project"] [tool.hatch.build.hooks.hatch-cpp] verbose = true libraries = [ - {name = "basic_project/extension", sources = ["cpp/basic-project/basic.cpp"], include-dirs = ["cpp"]} + {name = "project/extension", sources = ["cpp/project/basic.cpp"], include-dirs = ["cpp"]} ] - -# build-function = "hatch_cpp.cpp_builder" - -# [tool.hatch.build.hooks.defaults] -# build-type = "release" - -# [tool.hatch.build.hooks.env-vars] -# TODO: these will all be available via -# CLI after https://github.com/pypa/hatch/pull/1743 -# e.g. --hatch-cpp-build-type=debug -# build-type = "BUILD_TYPE" -# ccache = "USE_CCACHE" -# manylinux = "MANYLINUX" -# vcpkg = "USE_VCPKG" - -# [tool.hatch.build.hooks.cmake] - -# [tool.hatch.build.hooks.vcpkg] -# triplets = {linux="x64-linux", macos="x64-osx", windows="x64-windows-static-md"} -# clone = true -# update = true - -# [tool.hatch.build.hooks.hatch-cpp.build-kwargs] -# path = "cpp" - -[tool.pytest.ini_options] -asyncio_mode = "strict" -testpaths = "basic_project/tests" diff --git a/hatch_cpp/tests/test_project_cmake/CMakeLists.txt b/hatch_cpp/tests/test_project_cmake/CMakeLists.txt new file mode 100644 index 0000000..6344c70 --- /dev/null +++ b/hatch_cpp/tests/test_project_cmake/CMakeLists.txt @@ -0,0 +1,92 @@ +cmake_minimum_required(VERSION 3.20.0) +project(hatch-cpp-test-project-basic VERSION "0.1.0") +set(CMAKE_CXX_STANDARD 20) +include(CheckCCompilerFlag) +include(CheckLinkerFlag) + +if(${CMAKE_SYSTEM_NAME} MATCHES "Windows") + set(WIN32 ON) + set(MACOS OFF) + set(LINUX OFF) +elseif(${CMAKE_SYSTEM_NAME} MATCHES "Darwin") + set(WIN32 OFF) + set(MACOS ON) + set(LINUX OFF) +else() + set(WIN32 OFF) + set(MACOS OFF) + set(LINUX ON) +endif() + +option(CMAKE_BUILD_TYPE "Release/Debug build" RELEASE) +option(HATCH_CPP_TEST_PROJECT_BASIC_BUILD_TESTS "Build tests" OFF) +option(HATCH_CPP_TEST_PROJECT_BASIC_MANYLINUX "Build for python's manylinux setup" OFF) + +string(TOLOWER "${CMAKE_BUILD_TYPE}" CMAKE_BUILD_TYPE_LOWER) + +set(BUILD_SHARED_LIBS TRUE) +set(CMAKE_MACOSX_RPATH TRUE) +set(CMAKE_SKIP_RPATH FALSE) +set(CMAKE_SKIP_BUILD_RPATH FALSE) +set(CMAKE_BUILD_WITH_INSTALL_RPATH TRUE) +set(CMAKE_INSTALL_RPATH_USE_LINK_PATH TRUE) +set(CMAKE_INSTALL_NAME_DIR "@rpath") +set(CMAKE_POSITION_INDEPENDENT_CODE ON) + +string(REGEX REPLACE "[ ]*-O[^ ]+[ ]*" " " CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS}") +string(REGEX REPLACE "[ ]*-Wl,-O2 -Wl,[^ ]+[ ]*" " " CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS}") +string(REGEX REPLACE "[ ]*-Wl,-O2 -Wl,[^ ]+[ ]*" " " CMAKE_SHARED_LINKER_FLAGS "${CMAKE_SHARED_LINKER_FLAGS}") + + +if(MACOS) + set(CMAKE_THREAD_LIBS_INIT "-lpthread") + set(CMAKE_HAVE_THREADS_LIBRARY 1) + set(CMAKE_USE_WIN32_THREADS_INIT 0) + set(CMAKE_USE_PTHREADS_INIT 1) + set(THREADS_PREFER_PTHREAD_FLAG ON) + set(CMAKE_SHARED_LINKER_FLAGS "${CMAKE_SHARED_LINKER_FLAGS} -undefined dynamic_lookup") +endif() + + +if(MACOS) + set(CMAKE_INSTALL_RPATH "@loader_path/") +elseif(LINUX) + set(CMAKE_INSTALL_RPATH "\$ORIGIN") +endif() + +if(WIN32) + set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} /EHsc /MP /bigobj") + foreach(warning 4244 4251 4267 4275 4290 4786 4305 4996) + SET(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} /wd${warning}") + endforeach(warning) +else() + set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} \ + -g \ + -Wall \ + -Werror \ + -Wno-deprecated-declarations \ + -Wno-deprecated \ + ") +endif() + + +find_package(Python ${CSP_PYTHON_VERSION} EXACT REQUIRED COMPONENTS Interpreter Development.Module) +link_directories(${Python_LIBRARY_DIRS}) +include_directories(${Python_INCLUDE_DIRS}) + +set(CMAKE_SHARED_LIBRARY_PREFIX "") +if(NOT WIN32) + set(CMAKE_SHARED_LIBRARY_SUFFIX .so) +else() + set(CMAKE_SHARED_LIBRARY_SUFFIX .pyd) +endif() + +include_directories("${CMAKE_SOURCE_DIR}/cpp") + +add_library(extension SHARED cpp/project/basic.cpp) +set_target_properties(extension PROPERTIES PUBLIC_HEADER cpp/project/basic.hpp) +install(TARGETS extension + PUBLIC_HEADER DESTINATION project/include/project + RUNTIME DESTINATION project/ + LIBRARY DESTINATION project/ + ) diff --git a/hatch_cpp/tests/test_project_cmake/Makefile b/hatch_cpp/tests/test_project_cmake/Makefile new file mode 100644 index 0000000..c265da9 --- /dev/null +++ b/hatch_cpp/tests/test_project_cmake/Makefile @@ -0,0 +1,140 @@ +# CMAKE generated file: DO NOT EDIT! +# Generated by "Unix Makefiles" Generator, CMake Version 3.31 + +# Default target executed when no arguments are given to make. +default_target: all +.PHONY : default_target + +# Allow only one "make -f Makefile2" at a time, but pass parallelism. +.NOTPARALLEL: + +#============================================================================= +# Special targets provided by cmake. + +# Disable implicit rules so canonical targets will work. +.SUFFIXES: + +# Disable VCS-based implicit rules. +% : %,v + +# Disable VCS-based implicit rules. +% : RCS/% + +# Disable VCS-based implicit rules. +% : RCS/%,v + +# Disable VCS-based implicit rules. +% : SCCS/s.% + +# Disable VCS-based implicit rules. +% : s.% + +.SUFFIXES: .hpux_make_needs_suffix_list + +# Command-line flag to silence nested $(MAKE). +$(VERBOSE)MAKESILENT = -s + +#Suppress display of executed commands. +$(VERBOSE).SILENT: + +# A target that is always out of date. +cmake_force: +.PHONY : cmake_force + +#============================================================================= +# Set environment variables for the build. + +# The shell in which to execute make rules. +SHELL = /bin/sh + +# The CMake executable. +CMAKE_COMMAND = /opt/homebrew/bin/cmake + +# The command to remove a file. +RM = /opt/homebrew/bin/cmake -E rm -f + +# Escaping for special characters. +EQUALS = = + +# The top-level source directory on which CMake was run. +CMAKE_SOURCE_DIR = /Users/timkpaine/Developer/projects/templates/hatch-cpp/hatch_cpp/tests/test_project_cmake + +# The top-level build directory on which CMake was run. +CMAKE_BINARY_DIR = /Users/timkpaine/Developer/projects/templates/hatch-cpp/hatch_cpp/tests/test_project_cmake + +#============================================================================= +# Targets provided globally by CMake. + +# Special rule for the target edit_cache +edit_cache: + @$(CMAKE_COMMAND) -E cmake_echo_color "--switch=$(COLOR)" --cyan "Running CMake cache editor..." + /opt/homebrew/bin/ccmake -S$(CMAKE_SOURCE_DIR) -B$(CMAKE_BINARY_DIR) +.PHONY : edit_cache + +# Special rule for the target edit_cache +edit_cache/fast: edit_cache +.PHONY : edit_cache/fast + +# Special rule for the target rebuild_cache +rebuild_cache: + @$(CMAKE_COMMAND) -E cmake_echo_color "--switch=$(COLOR)" --cyan "Running CMake to regenerate build system..." + /opt/homebrew/bin/cmake --regenerate-during-build -S$(CMAKE_SOURCE_DIR) -B$(CMAKE_BINARY_DIR) +.PHONY : rebuild_cache + +# Special rule for the target rebuild_cache +rebuild_cache/fast: rebuild_cache +.PHONY : rebuild_cache/fast + +# The main all target +all: cmake_check_build_system + $(CMAKE_COMMAND) -E cmake_progress_start /Users/timkpaine/Developer/projects/templates/hatch-cpp/hatch_cpp/tests/test_project_cmake/CMakeFiles /Users/timkpaine/Developer/projects/templates/hatch-cpp/hatch_cpp/tests/test_project_cmake//CMakeFiles/progress.marks + $(MAKE) $(MAKESILENT) -f CMakeFiles/Makefile2 all + $(CMAKE_COMMAND) -E cmake_progress_start /Users/timkpaine/Developer/projects/templates/hatch-cpp/hatch_cpp/tests/test_project_cmake/CMakeFiles 0 +.PHONY : all + +# The main clean target +clean: + $(MAKE) $(MAKESILENT) -f CMakeFiles/Makefile2 clean +.PHONY : clean + +# The main clean target +clean/fast: clean +.PHONY : clean/fast + +# Prepare targets for installation. +preinstall: all + $(MAKE) $(MAKESILENT) -f CMakeFiles/Makefile2 preinstall +.PHONY : preinstall + +# Prepare targets for installation. +preinstall/fast: + $(MAKE) $(MAKESILENT) -f CMakeFiles/Makefile2 preinstall +.PHONY : preinstall/fast + +# clear depends +depend: + $(CMAKE_COMMAND) -S$(CMAKE_SOURCE_DIR) -B$(CMAKE_BINARY_DIR) --check-build-system CMakeFiles/Makefile.cmake 1 +.PHONY : depend + +# Help Target +help: + @echo "The following are some of the valid targets for this Makefile:" + @echo "... all (the default if no target is provided)" + @echo "... clean" + @echo "... depend" + @echo "... edit_cache" + @echo "... rebuild_cache" +.PHONY : help + + + +#============================================================================= +# Special targets to cleanup operation of make. + +# Special rule to run CMake to check the build system integrity. +# No rule that depends on this can have commands that come from listfiles +# because they might be regenerated. +cmake_check_build_system: + $(CMAKE_COMMAND) -S$(CMAKE_SOURCE_DIR) -B$(CMAKE_BINARY_DIR) --check-build-system CMakeFiles/Makefile.cmake 0 +.PHONY : cmake_check_build_system + diff --git a/hatch_cpp/tests/test_project_cmake/cpp/project/basic.cpp b/hatch_cpp/tests/test_project_cmake/cpp/project/basic.cpp new file mode 100644 index 0000000..db4432a --- /dev/null +++ b/hatch_cpp/tests/test_project_cmake/cpp/project/basic.cpp @@ -0,0 +1,5 @@ +#include "project/basic.hpp" + +PyObject* hello(PyObject*, PyObject*) { + return PyUnicode_FromString("A string"); +} diff --git a/hatch_cpp/tests/test_project_cmake/cpp/project/basic.hpp b/hatch_cpp/tests/test_project_cmake/cpp/project/basic.hpp new file mode 100644 index 0000000..65cb62e --- /dev/null +++ b/hatch_cpp/tests/test_project_cmake/cpp/project/basic.hpp @@ -0,0 +1,17 @@ +#pragma once +#include "Python.h" + +PyObject* hello(PyObject*, PyObject*); + +static PyMethodDef extension_methods[] = { + {"hello", (PyCFunction)hello, METH_NOARGS}, + {nullptr, nullptr, 0, nullptr} +}; + +static PyModuleDef extension_module = { + PyModuleDef_HEAD_INIT, "extension", "extension", -1, extension_methods}; + +PyMODINIT_FUNC PyInit_extension(void) { + Py_Initialize(); + return PyModule_Create(&extension_module); +} diff --git a/hatch_cpp/tests/test_project_cmake/project/__init__.py b/hatch_cpp/tests/test_project_cmake/project/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hatch_cpp/tests/test_project_cmake/project/include/project/basic.hpp b/hatch_cpp/tests/test_project_cmake/project/include/project/basic.hpp new file mode 100644 index 0000000..65cb62e --- /dev/null +++ b/hatch_cpp/tests/test_project_cmake/project/include/project/basic.hpp @@ -0,0 +1,17 @@ +#pragma once +#include "Python.h" + +PyObject* hello(PyObject*, PyObject*); + +static PyMethodDef extension_methods[] = { + {"hello", (PyCFunction)hello, METH_NOARGS}, + {nullptr, nullptr, 0, nullptr} +}; + +static PyModuleDef extension_module = { + PyModuleDef_HEAD_INIT, "extension", "extension", -1, extension_methods}; + +PyMODINIT_FUNC PyInit_extension(void) { + Py_Initialize(); + return PyModule_Create(&extension_module); +} diff --git a/hatch_cpp/tests/test_project_cmake/pyproject.toml b/hatch_cpp/tests/test_project_cmake/pyproject.toml new file mode 100644 index 0000000..51e9a66 --- /dev/null +++ b/hatch_cpp/tests/test_project_cmake/pyproject.toml @@ -0,0 +1,39 @@ +[build-system] +requires = ["hatchling>=1.20"] +build-backend = "hatchling.build" + +[project] +name = "hatch-cpp-test-project-basic" +description = "Basic test project for hatch-cpp" +version = "0.1.0" +requires-python = ">=3.9" +dependencies = [ + "hatchling>=1.20", + "hatch-cpp", +] + +[tool.hatch.build] +artifacts = [ + "project/*.dll", + "project/*.dylib", + "project/*.so", +] + +[tool.hatch.build.sources] +src = "/" + +[tool.hatch.build.targets.sdist] +packages = ["project"] + +[tool.hatch.build.targets.wheel] +packages = ["project"] + +[tool.hatch.build.hooks.hatch-cpp] +verbose = false + +[tool.hatch.build.hooks.hatch-cpp.cmake] +root = "CMakeLists.txt" +cmake_args = {"BUILD_TESTS" = "OFF"} +include_flags = {"python_version" = true} +[tool.hatch.build.hooks.hatch-cpp.cmake.cmake_env_args] +linux = {"MANYLINUX" = "ON"} diff --git a/hatch_cpp/tests/test_project_cmake_vcpkg/CMakeLists.txt b/hatch_cpp/tests/test_project_cmake_vcpkg/CMakeLists.txt new file mode 100644 index 0000000..6344c70 --- /dev/null +++ b/hatch_cpp/tests/test_project_cmake_vcpkg/CMakeLists.txt @@ -0,0 +1,92 @@ +cmake_minimum_required(VERSION 3.20.0) +project(hatch-cpp-test-project-basic VERSION "0.1.0") +set(CMAKE_CXX_STANDARD 20) +include(CheckCCompilerFlag) +include(CheckLinkerFlag) + +if(${CMAKE_SYSTEM_NAME} MATCHES "Windows") + set(WIN32 ON) + set(MACOS OFF) + set(LINUX OFF) +elseif(${CMAKE_SYSTEM_NAME} MATCHES "Darwin") + set(WIN32 OFF) + set(MACOS ON) + set(LINUX OFF) +else() + set(WIN32 OFF) + set(MACOS OFF) + set(LINUX ON) +endif() + +option(CMAKE_BUILD_TYPE "Release/Debug build" RELEASE) +option(HATCH_CPP_TEST_PROJECT_BASIC_BUILD_TESTS "Build tests" OFF) +option(HATCH_CPP_TEST_PROJECT_BASIC_MANYLINUX "Build for python's manylinux setup" OFF) + +string(TOLOWER "${CMAKE_BUILD_TYPE}" CMAKE_BUILD_TYPE_LOWER) + +set(BUILD_SHARED_LIBS TRUE) +set(CMAKE_MACOSX_RPATH TRUE) +set(CMAKE_SKIP_RPATH FALSE) +set(CMAKE_SKIP_BUILD_RPATH FALSE) +set(CMAKE_BUILD_WITH_INSTALL_RPATH TRUE) +set(CMAKE_INSTALL_RPATH_USE_LINK_PATH TRUE) +set(CMAKE_INSTALL_NAME_DIR "@rpath") +set(CMAKE_POSITION_INDEPENDENT_CODE ON) + +string(REGEX REPLACE "[ ]*-O[^ ]+[ ]*" " " CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS}") +string(REGEX REPLACE "[ ]*-Wl,-O2 -Wl,[^ ]+[ ]*" " " CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS}") +string(REGEX REPLACE "[ ]*-Wl,-O2 -Wl,[^ ]+[ ]*" " " CMAKE_SHARED_LINKER_FLAGS "${CMAKE_SHARED_LINKER_FLAGS}") + + +if(MACOS) + set(CMAKE_THREAD_LIBS_INIT "-lpthread") + set(CMAKE_HAVE_THREADS_LIBRARY 1) + set(CMAKE_USE_WIN32_THREADS_INIT 0) + set(CMAKE_USE_PTHREADS_INIT 1) + set(THREADS_PREFER_PTHREAD_FLAG ON) + set(CMAKE_SHARED_LINKER_FLAGS "${CMAKE_SHARED_LINKER_FLAGS} -undefined dynamic_lookup") +endif() + + +if(MACOS) + set(CMAKE_INSTALL_RPATH "@loader_path/") +elseif(LINUX) + set(CMAKE_INSTALL_RPATH "\$ORIGIN") +endif() + +if(WIN32) + set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} /EHsc /MP /bigobj") + foreach(warning 4244 4251 4267 4275 4290 4786 4305 4996) + SET(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} /wd${warning}") + endforeach(warning) +else() + set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} \ + -g \ + -Wall \ + -Werror \ + -Wno-deprecated-declarations \ + -Wno-deprecated \ + ") +endif() + + +find_package(Python ${CSP_PYTHON_VERSION} EXACT REQUIRED COMPONENTS Interpreter Development.Module) +link_directories(${Python_LIBRARY_DIRS}) +include_directories(${Python_INCLUDE_DIRS}) + +set(CMAKE_SHARED_LIBRARY_PREFIX "") +if(NOT WIN32) + set(CMAKE_SHARED_LIBRARY_SUFFIX .so) +else() + set(CMAKE_SHARED_LIBRARY_SUFFIX .pyd) +endif() + +include_directories("${CMAKE_SOURCE_DIR}/cpp") + +add_library(extension SHARED cpp/project/basic.cpp) +set_target_properties(extension PROPERTIES PUBLIC_HEADER cpp/project/basic.hpp) +install(TARGETS extension + PUBLIC_HEADER DESTINATION project/include/project + RUNTIME DESTINATION project/ + LIBRARY DESTINATION project/ + ) diff --git a/hatch_cpp/tests/test_project_cmake_vcpkg/Makefile b/hatch_cpp/tests/test_project_cmake_vcpkg/Makefile new file mode 100644 index 0000000..c265da9 --- /dev/null +++ b/hatch_cpp/tests/test_project_cmake_vcpkg/Makefile @@ -0,0 +1,140 @@ +# CMAKE generated file: DO NOT EDIT! +# Generated by "Unix Makefiles" Generator, CMake Version 3.31 + +# Default target executed when no arguments are given to make. +default_target: all +.PHONY : default_target + +# Allow only one "make -f Makefile2" at a time, but pass parallelism. +.NOTPARALLEL: + +#============================================================================= +# Special targets provided by cmake. + +# Disable implicit rules so canonical targets will work. +.SUFFIXES: + +# Disable VCS-based implicit rules. +% : %,v + +# Disable VCS-based implicit rules. +% : RCS/% + +# Disable VCS-based implicit rules. +% : RCS/%,v + +# Disable VCS-based implicit rules. +% : SCCS/s.% + +# Disable VCS-based implicit rules. +% : s.% + +.SUFFIXES: .hpux_make_needs_suffix_list + +# Command-line flag to silence nested $(MAKE). +$(VERBOSE)MAKESILENT = -s + +#Suppress display of executed commands. +$(VERBOSE).SILENT: + +# A target that is always out of date. +cmake_force: +.PHONY : cmake_force + +#============================================================================= +# Set environment variables for the build. + +# The shell in which to execute make rules. +SHELL = /bin/sh + +# The CMake executable. +CMAKE_COMMAND = /opt/homebrew/bin/cmake + +# The command to remove a file. +RM = /opt/homebrew/bin/cmake -E rm -f + +# Escaping for special characters. +EQUALS = = + +# The top-level source directory on which CMake was run. +CMAKE_SOURCE_DIR = /Users/timkpaine/Developer/projects/templates/hatch-cpp/hatch_cpp/tests/test_project_cmake + +# The top-level build directory on which CMake was run. +CMAKE_BINARY_DIR = /Users/timkpaine/Developer/projects/templates/hatch-cpp/hatch_cpp/tests/test_project_cmake + +#============================================================================= +# Targets provided globally by CMake. + +# Special rule for the target edit_cache +edit_cache: + @$(CMAKE_COMMAND) -E cmake_echo_color "--switch=$(COLOR)" --cyan "Running CMake cache editor..." + /opt/homebrew/bin/ccmake -S$(CMAKE_SOURCE_DIR) -B$(CMAKE_BINARY_DIR) +.PHONY : edit_cache + +# Special rule for the target edit_cache +edit_cache/fast: edit_cache +.PHONY : edit_cache/fast + +# Special rule for the target rebuild_cache +rebuild_cache: + @$(CMAKE_COMMAND) -E cmake_echo_color "--switch=$(COLOR)" --cyan "Running CMake to regenerate build system..." + /opt/homebrew/bin/cmake --regenerate-during-build -S$(CMAKE_SOURCE_DIR) -B$(CMAKE_BINARY_DIR) +.PHONY : rebuild_cache + +# Special rule for the target rebuild_cache +rebuild_cache/fast: rebuild_cache +.PHONY : rebuild_cache/fast + +# The main all target +all: cmake_check_build_system + $(CMAKE_COMMAND) -E cmake_progress_start /Users/timkpaine/Developer/projects/templates/hatch-cpp/hatch_cpp/tests/test_project_cmake/CMakeFiles /Users/timkpaine/Developer/projects/templates/hatch-cpp/hatch_cpp/tests/test_project_cmake//CMakeFiles/progress.marks + $(MAKE) $(MAKESILENT) -f CMakeFiles/Makefile2 all + $(CMAKE_COMMAND) -E cmake_progress_start /Users/timkpaine/Developer/projects/templates/hatch-cpp/hatch_cpp/tests/test_project_cmake/CMakeFiles 0 +.PHONY : all + +# The main clean target +clean: + $(MAKE) $(MAKESILENT) -f CMakeFiles/Makefile2 clean +.PHONY : clean + +# The main clean target +clean/fast: clean +.PHONY : clean/fast + +# Prepare targets for installation. +preinstall: all + $(MAKE) $(MAKESILENT) -f CMakeFiles/Makefile2 preinstall +.PHONY : preinstall + +# Prepare targets for installation. +preinstall/fast: + $(MAKE) $(MAKESILENT) -f CMakeFiles/Makefile2 preinstall +.PHONY : preinstall/fast + +# clear depends +depend: + $(CMAKE_COMMAND) -S$(CMAKE_SOURCE_DIR) -B$(CMAKE_BINARY_DIR) --check-build-system CMakeFiles/Makefile.cmake 1 +.PHONY : depend + +# Help Target +help: + @echo "The following are some of the valid targets for this Makefile:" + @echo "... all (the default if no target is provided)" + @echo "... clean" + @echo "... depend" + @echo "... edit_cache" + @echo "... rebuild_cache" +.PHONY : help + + + +#============================================================================= +# Special targets to cleanup operation of make. + +# Special rule to run CMake to check the build system integrity. +# No rule that depends on this can have commands that come from listfiles +# because they might be regenerated. +cmake_check_build_system: + $(CMAKE_COMMAND) -S$(CMAKE_SOURCE_DIR) -B$(CMAKE_BINARY_DIR) --check-build-system CMakeFiles/Makefile.cmake 0 +.PHONY : cmake_check_build_system + diff --git a/hatch_cpp/tests/test_project_cmake_vcpkg/cpp/project/basic.cpp b/hatch_cpp/tests/test_project_cmake_vcpkg/cpp/project/basic.cpp new file mode 100644 index 0000000..db4432a --- /dev/null +++ b/hatch_cpp/tests/test_project_cmake_vcpkg/cpp/project/basic.cpp @@ -0,0 +1,5 @@ +#include "project/basic.hpp" + +PyObject* hello(PyObject*, PyObject*) { + return PyUnicode_FromString("A string"); +} diff --git a/hatch_cpp/tests/test_project_cmake_vcpkg/cpp/project/basic.hpp b/hatch_cpp/tests/test_project_cmake_vcpkg/cpp/project/basic.hpp new file mode 100644 index 0000000..65cb62e --- /dev/null +++ b/hatch_cpp/tests/test_project_cmake_vcpkg/cpp/project/basic.hpp @@ -0,0 +1,17 @@ +#pragma once +#include "Python.h" + +PyObject* hello(PyObject*, PyObject*); + +static PyMethodDef extension_methods[] = { + {"hello", (PyCFunction)hello, METH_NOARGS}, + {nullptr, nullptr, 0, nullptr} +}; + +static PyModuleDef extension_module = { + PyModuleDef_HEAD_INIT, "extension", "extension", -1, extension_methods}; + +PyMODINIT_FUNC PyInit_extension(void) { + Py_Initialize(); + return PyModule_Create(&extension_module); +} diff --git a/hatch_cpp/tests/test_project_cmake_vcpkg/project/__init__.py b/hatch_cpp/tests/test_project_cmake_vcpkg/project/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hatch_cpp/tests/test_project_cmake_vcpkg/project/include/project/basic.hpp b/hatch_cpp/tests/test_project_cmake_vcpkg/project/include/project/basic.hpp new file mode 100644 index 0000000..65cb62e --- /dev/null +++ b/hatch_cpp/tests/test_project_cmake_vcpkg/project/include/project/basic.hpp @@ -0,0 +1,17 @@ +#pragma once +#include "Python.h" + +PyObject* hello(PyObject*, PyObject*); + +static PyMethodDef extension_methods[] = { + {"hello", (PyCFunction)hello, METH_NOARGS}, + {nullptr, nullptr, 0, nullptr} +}; + +static PyModuleDef extension_module = { + PyModuleDef_HEAD_INIT, "extension", "extension", -1, extension_methods}; + +PyMODINIT_FUNC PyInit_extension(void) { + Py_Initialize(); + return PyModule_Create(&extension_module); +} diff --git a/hatch_cpp/tests/test_project_cmake_vcpkg/pyproject.toml b/hatch_cpp/tests/test_project_cmake_vcpkg/pyproject.toml new file mode 100644 index 0000000..8ad530e --- /dev/null +++ b/hatch_cpp/tests/test_project_cmake_vcpkg/pyproject.toml @@ -0,0 +1,39 @@ +[build-system] +requires = ["hatchling>=1.20"] +build-backend = "hatchling.build" + +[project] +name = "hatch-cpp-test-project-basic" +description = "Basic test project for hatch-cpp" +version = "0.1.0" +requires-python = ">=3.9" +dependencies = [ + "hatchling>=1.20", + "hatch-cpp", +] + +[tool.hatch.build] +artifacts = [ + "project/*.dll", + "project/*.dylib", + "project/*.so", +] + +[tool.hatch.build.sources] +src = "/" + +[tool.hatch.build.targets.sdist] +packages = ["project"] + +[tool.hatch.build.targets.wheel] +packages = ["project"] + +[tool.hatch.build.hooks.hatch-cpp] +verbose = true + +[tool.hatch.build.hooks.hatch-cpp.cmake] +root = "CMakeLists.txt" +cmake_args = {"BUILD_TESTS" = "OFF"} +include_flags = {"python_version" = true} +[tool.hatch.build.hooks.hatch-cpp.cmake.cmake_env_args] +linux = {"MANYLINUX" = "ON"} diff --git a/hatch_cpp/tests/test_project_cmake_vcpkg/vcpkg.json b/hatch_cpp/tests/test_project_cmake_vcpkg/vcpkg.json new file mode 100644 index 0000000..ace9c19 --- /dev/null +++ b/hatch_cpp/tests/test_project_cmake_vcpkg/vcpkg.json @@ -0,0 +1,8 @@ +{ + "name": "main", + "version-string": "latest", + "dependencies": [ + "nlohmann-json" + ], + "builtin-baseline": "b94ade01f19e4436d8c8a16a5c52e8c802ef67dd" +} diff --git a/hatch_cpp/tests/test_project_hatch_build/cpp/project/basic.cpp b/hatch_cpp/tests/test_project_hatch_build/cpp/project/basic.cpp new file mode 100644 index 0000000..2ac7d56 --- /dev/null +++ b/hatch_cpp/tests/test_project_hatch_build/cpp/project/basic.cpp @@ -0,0 +1,2 @@ +#include "project/basic.hpp" + diff --git a/hatch_cpp/tests/test_project_hatch_build/cpp/project/basic.hpp b/hatch_cpp/tests/test_project_hatch_build/cpp/project/basic.hpp new file mode 100644 index 0000000..1afa022 --- /dev/null +++ b/hatch_cpp/tests/test_project_hatch_build/cpp/project/basic.hpp @@ -0,0 +1,7 @@ +#pragma once +#include +#include + +NB_MODULE(extension, m) { + m.def("hello", []() { return "A string"; }); +} diff --git a/hatch_cpp/tests/test_project_hatch_build/project/__init__.py b/hatch_cpp/tests/test_project_hatch_build/project/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hatch_cpp/tests/test_project_hatch_build/pyproject.toml b/hatch_cpp/tests/test_project_hatch_build/pyproject.toml new file mode 100644 index 0000000..e53ef12 --- /dev/null +++ b/hatch_cpp/tests/test_project_hatch_build/pyproject.toml @@ -0,0 +1,36 @@ +[build-system] +requires = ["hatchling>=1.20"] +build-backend = "hatchling.build" + +[project] +name = "hatch-cpp-test-project-nanobind" +description = "Basic test project for hatch-cpp" +version = "0.1.0" +requires-python = ">=3.9" +dependencies = [ + "hatchling>=1.20", + "hatch-cpp", +] + +[tool.hatch.build] +artifacts = [ + "project/*.dll", + "project/*.dylib", + "project/*.so", +] + +[tool.hatch.build.sources] +src = "/" + +[tool.hatch.build.targets.sdist] +packages = ["project"] + +[tool.hatch.build.targets.wheel] +packages = ["project"] + +[tool.hatch.build.hooks.hatch-cpp] +verbose = true +libraries = [ + # {name = "project/extension", sources = ["cpp/project/basic.cpp"], include-dirs = ["cpp"], binding = "nanobind"}, + {name = "wrong", sources = ["wrong"], include-dirs = ["wrong"], binding = "generic"}, +] diff --git a/hatch_cpp/tests/test_project_limited_api/cpp/project/basic.cpp b/hatch_cpp/tests/test_project_limited_api/cpp/project/basic.cpp new file mode 100644 index 0000000..db4432a --- /dev/null +++ b/hatch_cpp/tests/test_project_limited_api/cpp/project/basic.cpp @@ -0,0 +1,5 @@ +#include "project/basic.hpp" + +PyObject* hello(PyObject*, PyObject*) { + return PyUnicode_FromString("A string"); +} diff --git a/hatch_cpp/tests/test_project_limited_api/cpp/project/basic.hpp b/hatch_cpp/tests/test_project_limited_api/cpp/project/basic.hpp new file mode 100644 index 0000000..65cb62e --- /dev/null +++ b/hatch_cpp/tests/test_project_limited_api/cpp/project/basic.hpp @@ -0,0 +1,17 @@ +#pragma once +#include "Python.h" + +PyObject* hello(PyObject*, PyObject*); + +static PyMethodDef extension_methods[] = { + {"hello", (PyCFunction)hello, METH_NOARGS}, + {nullptr, nullptr, 0, nullptr} +}; + +static PyModuleDef extension_module = { + PyModuleDef_HEAD_INIT, "extension", "extension", -1, extension_methods}; + +PyMODINIT_FUNC PyInit_extension(void) { + Py_Initialize(); + return PyModule_Create(&extension_module); +} diff --git a/hatch_cpp/tests/test_project_limited_api/project/__init__.py b/hatch_cpp/tests/test_project_limited_api/project/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hatch_cpp/tests/test_project_limited_api/pyproject.toml b/hatch_cpp/tests/test_project_limited_api/pyproject.toml new file mode 100644 index 0000000..e1157e3 --- /dev/null +++ b/hatch_cpp/tests/test_project_limited_api/pyproject.toml @@ -0,0 +1,35 @@ +[build-system] +requires = ["hatchling>=1.20"] +build-backend = "hatchling.build" + +[project] +name = "hatch-cpp-test-project-limtied-api" +description = "Basic test project for hatch-cpp" +version = "0.1.0" +requires-python = ">=3.9" +dependencies = [ + "hatchling>=1.20", + "hatch-cpp", +] + +[tool.hatch.build] +artifacts = [ + "project/*.dll", + "project/*.dylib", + "project/*.so", +] + +[tool.hatch.build.sources] +src = "/" + +[tool.hatch.build.targets.sdist] +packages = ["project"] + +[tool.hatch.build.targets.wheel] +packages = ["project"] + +[tool.hatch.build.hooks.hatch-cpp] +verbose = true +libraries = [ + {name = "project/extension", sources = ["cpp/project/basic.cpp"], include-dirs = ["cpp"], py-limited-api = "cp39"}, +] diff --git a/hatch_cpp/tests/test_project_nanobind/cpp/project/basic.cpp b/hatch_cpp/tests/test_project_nanobind/cpp/project/basic.cpp new file mode 100644 index 0000000..2ac7d56 --- /dev/null +++ b/hatch_cpp/tests/test_project_nanobind/cpp/project/basic.cpp @@ -0,0 +1,2 @@ +#include "project/basic.hpp" + diff --git a/hatch_cpp/tests/test_project_nanobind/cpp/project/basic.hpp b/hatch_cpp/tests/test_project_nanobind/cpp/project/basic.hpp new file mode 100644 index 0000000..1afa022 --- /dev/null +++ b/hatch_cpp/tests/test_project_nanobind/cpp/project/basic.hpp @@ -0,0 +1,7 @@ +#pragma once +#include +#include + +NB_MODULE(extension, m) { + m.def("hello", []() { return "A string"; }); +} diff --git a/hatch_cpp/tests/test_project_nanobind/project/__init__.py b/hatch_cpp/tests/test_project_nanobind/project/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hatch_cpp/tests/test_project_nanobind/pyproject.toml b/hatch_cpp/tests/test_project_nanobind/pyproject.toml new file mode 100644 index 0000000..bd03189 --- /dev/null +++ b/hatch_cpp/tests/test_project_nanobind/pyproject.toml @@ -0,0 +1,35 @@ +[build-system] +requires = ["hatchling>=1.20"] +build-backend = "hatchling.build" + +[project] +name = "hatch-cpp-test-project-nanobind" +description = "Basic test project for hatch-cpp" +version = "0.1.0" +requires-python = ">=3.9" +dependencies = [ + "hatchling>=1.20", + "hatch-cpp", +] + +[tool.hatch.build] +artifacts = [ + "project/*.dll", + "project/*.dylib", + "project/*.so", +] + +[tool.hatch.build.sources] +src = "/" + +[tool.hatch.build.targets.sdist] +packages = ["project"] + +[tool.hatch.build.targets.wheel] +packages = ["project"] + +[tool.hatch.build.hooks.hatch-cpp] +verbose = true +libraries = [ + {name = "project/extension", sources = ["cpp/project/basic.cpp"], include-dirs = ["cpp"], binding = "nanobind"}, +] diff --git a/hatch_cpp/tests/test_project_override_classes/cpp/project/basic.cpp b/hatch_cpp/tests/test_project_override_classes/cpp/project/basic.cpp new file mode 100644 index 0000000..db4432a --- /dev/null +++ b/hatch_cpp/tests/test_project_override_classes/cpp/project/basic.cpp @@ -0,0 +1,5 @@ +#include "project/basic.hpp" + +PyObject* hello(PyObject*, PyObject*) { + return PyUnicode_FromString("A string"); +} diff --git a/hatch_cpp/tests/test_project_override_classes/cpp/project/basic.hpp b/hatch_cpp/tests/test_project_override_classes/cpp/project/basic.hpp new file mode 100644 index 0000000..65cb62e --- /dev/null +++ b/hatch_cpp/tests/test_project_override_classes/cpp/project/basic.hpp @@ -0,0 +1,17 @@ +#pragma once +#include "Python.h" + +PyObject* hello(PyObject*, PyObject*); + +static PyMethodDef extension_methods[] = { + {"hello", (PyCFunction)hello, METH_NOARGS}, + {nullptr, nullptr, 0, nullptr} +}; + +static PyModuleDef extension_module = { + PyModuleDef_HEAD_INIT, "extension", "extension", -1, extension_methods}; + +PyMODINIT_FUNC PyInit_extension(void) { + Py_Initialize(); + return PyModule_Create(&extension_module); +} diff --git a/hatch_cpp/tests/test_project_override_classes/project/__init__.py b/hatch_cpp/tests/test_project_override_classes/project/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hatch_cpp/tests/test_project_override_classes/pyproject.toml b/hatch_cpp/tests/test_project_override_classes/pyproject.toml new file mode 100644 index 0000000..90e3215 --- /dev/null +++ b/hatch_cpp/tests/test_project_override_classes/pyproject.toml @@ -0,0 +1,37 @@ +[build-system] +requires = ["hatchling>=1.20"] +build-backend = "hatchling.build" + +[project] +name = "hatch-cpp-test-project-override-classes" +description = "Basic test project for hatch-cpp" +version = "0.1.0" +requires-python = ">=3.9" +dependencies = [ + "hatchling>=1.20", + "hatch-cpp", +] + +[tool.hatch.build] +artifacts = [ + "project/*.dll", + "project/*.dylib", + "project/*.so", +] + +[tool.hatch.build.sources] +src = "/" + +[tool.hatch.build.targets.sdist] +packages = ["project"] + +[tool.hatch.build.targets.wheel] +packages = ["project"] + +[tool.hatch.build.hooks.hatch-cpp] +build-config-class = "hatch_cpp.HatchCppBuildConfig" +build-plan-class = "hatch_cpp.HatchCppBuildPlan" +verbose = true +libraries = [ + {name = "project/extension", sources = ["cpp/project/basic.cpp"], include-dirs = ["cpp"]} +] diff --git a/hatch_cpp/tests/test_project_override_toolchain/cpp/project/basic.cpp b/hatch_cpp/tests/test_project_override_toolchain/cpp/project/basic.cpp new file mode 100644 index 0000000..db4432a --- /dev/null +++ b/hatch_cpp/tests/test_project_override_toolchain/cpp/project/basic.cpp @@ -0,0 +1,5 @@ +#include "project/basic.hpp" + +PyObject* hello(PyObject*, PyObject*) { + return PyUnicode_FromString("A string"); +} diff --git a/hatch_cpp/tests/test_project_override_toolchain/cpp/project/basic.hpp b/hatch_cpp/tests/test_project_override_toolchain/cpp/project/basic.hpp new file mode 100644 index 0000000..65cb62e --- /dev/null +++ b/hatch_cpp/tests/test_project_override_toolchain/cpp/project/basic.hpp @@ -0,0 +1,17 @@ +#pragma once +#include "Python.h" + +PyObject* hello(PyObject*, PyObject*); + +static PyMethodDef extension_methods[] = { + {"hello", (PyCFunction)hello, METH_NOARGS}, + {nullptr, nullptr, 0, nullptr} +}; + +static PyModuleDef extension_module = { + PyModuleDef_HEAD_INIT, "extension", "extension", -1, extension_methods}; + +PyMODINIT_FUNC PyInit_extension(void) { + Py_Initialize(); + return PyModule_Create(&extension_module); +} diff --git a/hatch_cpp/tests/test_project_override_toolchain/project/__init__.py b/hatch_cpp/tests/test_project_override_toolchain/project/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hatch_cpp/tests/test_project_override_toolchain/pyproject.toml b/hatch_cpp/tests/test_project_override_toolchain/pyproject.toml new file mode 100644 index 0000000..b5c40ca --- /dev/null +++ b/hatch_cpp/tests/test_project_override_toolchain/pyproject.toml @@ -0,0 +1,38 @@ +[build-system] +requires = ["hatchling>=1.20"] +build-backend = "hatchling.build" + +[project] +name = "hatch-cpp-test-project-toolchain" +description = "Toolchain override test project for hatch-cpp" +version = "0.1.0" +requires-python = ">=3.9" +dependencies = [ + "hatchling>=1.20", + "hatch-cpp", +] + +[tool.hatch.build] +artifacts = [ + "project/*.dll", + "project/*.dylib", + "project/*.so", +] + +[tool.hatch.build.sources] +src = "/" + +[tool.hatch.build.targets.sdist] +packages = ["project"] + +[tool.hatch.build.targets.wheel] +packages = ["project"] + +[tool.hatch.build.hooks.hatch-cpp] +verbose = true +libraries = [ + {name = "project/extension", sources = ["cpp/project/basic.cpp"], include-dirs = ["cpp"]} +] +toolchain = "gcc" +cc = "clang" +cxx = "clang++" diff --git a/hatch_cpp/tests/test_project_pybind/cpp/project/basic.cpp b/hatch_cpp/tests/test_project_pybind/cpp/project/basic.cpp new file mode 100644 index 0000000..ebe96f8 --- /dev/null +++ b/hatch_cpp/tests/test_project_pybind/cpp/project/basic.cpp @@ -0,0 +1,6 @@ +#include "project/basic.hpp" + +std::string hello() { + return "A string"; +} + diff --git a/hatch_cpp/tests/test_project_pybind/cpp/project/basic.hpp b/hatch_cpp/tests/test_project_pybind/cpp/project/basic.hpp new file mode 100644 index 0000000..86053b2 --- /dev/null +++ b/hatch_cpp/tests/test_project_pybind/cpp/project/basic.hpp @@ -0,0 +1,9 @@ +#pragma once +#include +#include + +std::string hello(); + +PYBIND11_MODULE(extension, m) { + m.def("hello", &hello); +} \ No newline at end of file diff --git a/hatch_cpp/tests/test_project_pybind/project/__init__.py b/hatch_cpp/tests/test_project_pybind/project/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hatch_cpp/tests/test_project_pybind/pyproject.toml b/hatch_cpp/tests/test_project_pybind/pyproject.toml new file mode 100644 index 0000000..38e279e --- /dev/null +++ b/hatch_cpp/tests/test_project_pybind/pyproject.toml @@ -0,0 +1,35 @@ +[build-system] +requires = ["hatchling>=1.20"] +build-backend = "hatchling.build" + +[project] +name = "hatch-cpp-test-project-pybind" +description = "Basic test project for hatch-cpp" +version = "0.1.0" +requires-python = ">=3.9" +dependencies = [ + "hatchling>=1.20", + "hatch-cpp", +] + +[tool.hatch.build] +artifacts = [ + "project/*.dll", + "project/*.dylib", + "project/*.so", +] + +[tool.hatch.build.sources] +src = "/" + +[tool.hatch.build.targets.sdist] +packages = ["project"] + +[tool.hatch.build.targets.wheel] +packages = ["project"] + +[tool.hatch.build.hooks.hatch-cpp] +verbose = true +libraries = [ + {name = "project/extension", sources = ["cpp/project/basic.cpp"], include-dirs = ["cpp"], binding="pybind11"}, +] diff --git a/hatch_cpp/tests/test_project_pybind_vcpkg/cpp/project/basic.cpp b/hatch_cpp/tests/test_project_pybind_vcpkg/cpp/project/basic.cpp new file mode 100644 index 0000000..ebe96f8 --- /dev/null +++ b/hatch_cpp/tests/test_project_pybind_vcpkg/cpp/project/basic.cpp @@ -0,0 +1,6 @@ +#include "project/basic.hpp" + +std::string hello() { + return "A string"; +} + diff --git a/hatch_cpp/tests/test_project_pybind_vcpkg/cpp/project/basic.hpp b/hatch_cpp/tests/test_project_pybind_vcpkg/cpp/project/basic.hpp new file mode 100644 index 0000000..86053b2 --- /dev/null +++ b/hatch_cpp/tests/test_project_pybind_vcpkg/cpp/project/basic.hpp @@ -0,0 +1,9 @@ +#pragma once +#include +#include + +std::string hello(); + +PYBIND11_MODULE(extension, m) { + m.def("hello", &hello); +} \ No newline at end of file diff --git a/hatch_cpp/tests/test_project_pybind_vcpkg/project/__init__.py b/hatch_cpp/tests/test_project_pybind_vcpkg/project/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hatch_cpp/tests/test_project_pybind_vcpkg/pyproject.toml b/hatch_cpp/tests/test_project_pybind_vcpkg/pyproject.toml new file mode 100644 index 0000000..38e279e --- /dev/null +++ b/hatch_cpp/tests/test_project_pybind_vcpkg/pyproject.toml @@ -0,0 +1,35 @@ +[build-system] +requires = ["hatchling>=1.20"] +build-backend = "hatchling.build" + +[project] +name = "hatch-cpp-test-project-pybind" +description = "Basic test project for hatch-cpp" +version = "0.1.0" +requires-python = ">=3.9" +dependencies = [ + "hatchling>=1.20", + "hatch-cpp", +] + +[tool.hatch.build] +artifacts = [ + "project/*.dll", + "project/*.dylib", + "project/*.so", +] + +[tool.hatch.build.sources] +src = "/" + +[tool.hatch.build.targets.sdist] +packages = ["project"] + +[tool.hatch.build.targets.wheel] +packages = ["project"] + +[tool.hatch.build.hooks.hatch-cpp] +verbose = true +libraries = [ + {name = "project/extension", sources = ["cpp/project/basic.cpp"], include-dirs = ["cpp"], binding="pybind11"}, +] diff --git a/hatch_cpp/tests/test_project_pybind_vcpkg/vcpkg.json b/hatch_cpp/tests/test_project_pybind_vcpkg/vcpkg.json new file mode 100644 index 0000000..ace9c19 --- /dev/null +++ b/hatch_cpp/tests/test_project_pybind_vcpkg/vcpkg.json @@ -0,0 +1,8 @@ +{ + "name": "main", + "version-string": "latest", + "dependencies": [ + "nlohmann-json" + ], + "builtin-baseline": "b94ade01f19e4436d8c8a16a5c52e8c802ef67dd" +} diff --git a/hatch_cpp/tests/test_projects.py b/hatch_cpp/tests/test_projects.py new file mode 100644 index 0000000..e7b4fc6 --- /dev/null +++ b/hatch_cpp/tests/test_projects.py @@ -0,0 +1,57 @@ +from os import listdir +from pathlib import Path +from shutil import rmtree +from subprocess import check_call +from sys import modules, path, platform + +import pytest + + +class TestProject: + @pytest.mark.parametrize( + "project", + [ + "test_project_basic", + "test_project_override_classes", + "test_project_override_classes", + "test_project_override_toolchain", + "test_project_pybind", + "test_project_pybind_vcpkg", + "test_project_nanobind", + "test_project_limited_api", + "test_project_cmake", + "test_project_cmake_vcpkg", + ], + ) + def test_basic(self, project): + # cleanup + rmtree(f"hatch_cpp/tests/{project}/project/extension.so", ignore_errors=True) + rmtree(f"hatch_cpp/tests/{project}/project/extension.pyd", ignore_errors=True) + modules.pop("project", None) + modules.pop("project.extension", None) + + # compile + check_call( + [ + "hatch-build", + "--hooks-only", + ], + cwd=f"hatch_cpp/tests/{project}", + ) + + # assert built + + if project == "test_project_limited_api" and platform != "win32": + assert "extension.abi3.so" in listdir(f"hatch_cpp/tests/{project}/project") + else: + if platform == "win32": + assert "extension.pyd" in listdir(f"hatch_cpp/tests/{project}/project") + else: + assert "extension.so" in listdir(f"hatch_cpp/tests/{project}/project") + + # import + here = Path(__file__).parent / project + path.insert(0, str(here)) + import project.extension + + assert project.extension.hello() == "A string" diff --git a/hatch_cpp/tests/test_structs.py b/hatch_cpp/tests/test_structs.py new file mode 100644 index 0000000..fa22880 --- /dev/null +++ b/hatch_cpp/tests/test_structs.py @@ -0,0 +1,240 @@ +from os import environ +from pathlib import Path +from sys import version_info +from unittest.mock import patch + +import pytest +from pydantic import ValidationError +from toml import loads + +from hatch_cpp import HatchCppBuildConfig, HatchCppBuildPlan, HatchCppLibrary, HatchCppPlatform +from hatch_cpp.toolchains.common import _normalize_rpath + + +class TestStructs: + def test_validate_py_limited_api(self): + with pytest.raises(ValidationError): + library = HatchCppLibrary( + name="test", + sources=["test.cpp"], + py_limited_api="42", + ) + library = HatchCppLibrary( + name="test", + sources=["test.cpp"], + py_limited_api="cp39", + ) + assert library.py_limited_api == "cp39" + platform = HatchCppPlatform.default() + flags = platform.get_compile_flags(library) + assert "-DPy_LIMITED_API=0x030900f0" in flags or "/DPy_LIMITED_API=0x030900f0" in flags + + with pytest.raises(ValidationError): + library.binding = "pybind11" + + def test_cmake_args(self): + txt = (Path(__file__).parent / "test_project_cmake" / "pyproject.toml").read_text() + toml = loads(txt) + hatch_build_config = HatchCppBuildConfig(name=toml["project"]["name"], **toml["tool"]["hatch"]["build"]["hooks"]["hatch-cpp"]) + hatch_build_plan = HatchCppBuildPlan(**hatch_build_config.model_dump()) + hatch_build_plan.generate() + + assert hatch_build_plan.commands[0].startswith("cmake .") + assert hatch_build_plan.commands[1].startswith("cmake --build build") + assert hatch_build_plan.commands[2].startswith("cmake --install build") + + assert "-DCMAKE_BUILD_TYPE=release" in hatch_build_plan.commands[0] + assert "-B build" in hatch_build_plan.commands[0] + assert "-DHATCH_CPP_TEST_PROJECT_BASIC_BUILD_TESTS=OFF" in hatch_build_plan.commands[0] + assert f"-DHATCH_CPP_TEST_PROJECT_BASIC_PYTHON_VERSION=3.{version_info.minor}" in hatch_build_plan.commands[0] + if hatch_build_plan.platform.platform == "darwin": + assert "-DCMAKE_OSX_DEPLOYMENT_TARGET=11" in hatch_build_plan.commands[0] + + def test_platform_toolchain_override(self): + txt = (Path(__file__).parent / "test_project_override_toolchain" / "pyproject.toml").read_text() + toml = loads(txt) + hatch_build_config = HatchCppBuildConfig(name=toml["project"]["name"], **toml["tool"]["hatch"]["build"]["hooks"]["hatch-cpp"]) + assert "clang" in hatch_build_config.platform.cc + assert "clang++" in hatch_build_config.platform.cxx + assert hatch_build_config.platform.toolchain == "gcc" + + def test_cmake_args_env_variable(self): + """Test that CMAKE_ARGS environment variable is respected.""" + txt = (Path(__file__).parent / "test_project_cmake" / "pyproject.toml").read_text() + toml_data = loads(txt) + hatch_build_config = HatchCppBuildConfig(name=toml_data["project"]["name"], **toml_data["tool"]["hatch"]["build"]["hooks"]["hatch-cpp"]) + hatch_build_plan = HatchCppBuildPlan(**hatch_build_config.model_dump()) + + with patch.dict(environ, {"CMAKE_ARGS": "-DFOO=bar -DBAZ=qux"}): + hatch_build_plan.generate() + assert "-DFOO=bar" in hatch_build_plan.commands[0] + assert "-DBAZ=qux" in hatch_build_plan.commands[0] + + def test_cmake_args_env_variable_empty(self): + """Test that an empty CMAKE_ARGS does not add extra whitespace.""" + txt = (Path(__file__).parent / "test_project_cmake" / "pyproject.toml").read_text() + toml_data = loads(txt) + hatch_build_config = HatchCppBuildConfig(name=toml_data["project"]["name"], **toml_data["tool"]["hatch"]["build"]["hooks"]["hatch-cpp"]) + hatch_build_plan = HatchCppBuildPlan(**hatch_build_config.model_dump()) + + with patch.dict(environ, {"CMAKE_ARGS": ""}): + hatch_build_plan.generate() + # Should not have trailing whitespace from empty CMAKE_ARGS + assert not hatch_build_plan.commands[0].endswith(" ") + + def test_cmake_generator_env_variable(self): + """Test that CMAKE_GENERATOR environment variable is respected on non-Windows platforms.""" + txt = (Path(__file__).parent / "test_project_cmake" / "pyproject.toml").read_text() + toml_data = loads(txt) + hatch_build_config = HatchCppBuildConfig(name=toml_data["project"]["name"], **toml_data["tool"]["hatch"]["build"]["hooks"]["hatch-cpp"]) + hatch_build_plan = HatchCppBuildPlan(**hatch_build_config.model_dump()) + + with patch.dict(environ, {"CMAKE_GENERATOR": "Ninja"}): + hatch_build_plan.generate() + assert '-G "Ninja"' in hatch_build_plan.commands[0] + + def test_cmake_generator_env_variable_unset(self): + """Test that no -G flag is added on non-Windows when CMAKE_GENERATOR is not set.""" + txt = (Path(__file__).parent / "test_project_cmake" / "pyproject.toml").read_text() + toml_data = loads(txt) + hatch_build_config = HatchCppBuildConfig(name=toml_data["project"]["name"], **toml_data["tool"]["hatch"]["build"]["hooks"]["hatch-cpp"]) + hatch_build_plan = HatchCppBuildPlan(**hatch_build_config.model_dump()) + + with patch.dict(environ, {}, clear=False): + # Remove CMAKE_GENERATOR if present + environ.pop("CMAKE_GENERATOR", None) + hatch_build_plan.generate() + if hatch_build_plan.platform.platform != "win32": + assert "-G " not in hatch_build_plan.commands[0] + + def test_hatch_cpp_cmake_env_force_off(self): + """Test that HATCH_CPP_CMAKE=0 disables cmake even when cmake config is present.""" + txt = (Path(__file__).parent / "test_project_cmake" / "pyproject.toml").read_text() + toml_data = loads(txt) + hatch_build_config = HatchCppBuildConfig(name=toml_data["project"]["name"], **toml_data["tool"]["hatch"]["build"]["hooks"]["hatch-cpp"]) + hatch_build_plan = HatchCppBuildPlan(**hatch_build_config.model_dump()) + + assert hatch_build_plan.cmake is not None + with patch.dict(environ, {"HATCH_CPP_CMAKE": "0"}): + hatch_build_plan.generate() + # cmake should not be active, so no cmake commands generated + assert len(hatch_build_plan.commands) == 0 + assert "cmake" not in hatch_build_plan._active_toolchains + + def test_hatch_cpp_cmake_env_force_on(self): + """Test that HATCH_CPP_CMAKE=1 enables cmake when cmake config is present.""" + txt = (Path(__file__).parent / "test_project_cmake" / "pyproject.toml").read_text() + toml_data = loads(txt) + hatch_build_config = HatchCppBuildConfig(name=toml_data["project"]["name"], **toml_data["tool"]["hatch"]["build"]["hooks"]["hatch-cpp"]) + hatch_build_plan = HatchCppBuildPlan(**hatch_build_config.model_dump()) + + assert hatch_build_plan.cmake is not None + with patch.dict(environ, {"HATCH_CPP_CMAKE": "1"}): + hatch_build_plan.generate() + assert "cmake" in hatch_build_plan._active_toolchains + + def test_hatch_cpp_cmake_env_force_on_no_config(self): + """Test that HATCH_CPP_CMAKE=1 warns and skips when no cmake config exists.""" + txt = (Path(__file__).parent / "test_project_cmake" / "pyproject.toml").read_text() + toml_data = loads(txt) + config_data = toml_data["tool"]["hatch"]["build"]["hooks"]["hatch-cpp"].copy() + config_data.pop("cmake", None) + hatch_build_config = HatchCppBuildConfig(name=toml_data["project"]["name"], **config_data) + hatch_build_plan = HatchCppBuildPlan(**hatch_build_config.model_dump()) + + assert hatch_build_plan.cmake is None + with patch.dict(environ, {"HATCH_CPP_CMAKE": "1"}): + hatch_build_plan.generate() + # cmake should NOT be activated when there's no config + assert "cmake" not in hatch_build_plan._active_toolchains + + def test_hatch_cpp_vcpkg_env_force_off(self): + """Test that HATCH_CPP_VCPKG=0 disables vcpkg even when vcpkg.json exists.""" + txt = (Path(__file__).parent / "test_project_cmake_vcpkg" / "pyproject.toml").read_text() + toml_data = loads(txt) + hatch_build_config = HatchCppBuildConfig(name=toml_data["project"]["name"], **toml_data["tool"]["hatch"]["build"]["hooks"]["hatch-cpp"]) + hatch_build_plan = HatchCppBuildPlan(**hatch_build_config.model_dump()) + + with patch.dict(environ, {"HATCH_CPP_VCPKG": "0"}): + hatch_build_plan.generate() + assert "vcpkg" not in hatch_build_plan._active_toolchains + + def test_hatch_cpp_vcpkg_env_force_on(self): + """Test that HATCH_CPP_VCPKG=1 enables vcpkg even when vcpkg.json doesn't exist.""" + txt = (Path(__file__).parent / "test_project_cmake" / "pyproject.toml").read_text() + toml_data = loads(txt) + hatch_build_config = HatchCppBuildConfig(name=toml_data["project"]["name"], **toml_data["tool"]["hatch"]["build"]["hooks"]["hatch-cpp"]) + hatch_build_plan = HatchCppBuildPlan(**hatch_build_config.model_dump()) + + with patch.dict(environ, {"HATCH_CPP_VCPKG": "1"}): + hatch_build_plan.generate() + assert "vcpkg" in hatch_build_plan._active_toolchains + + +class TestNormalizeRpath: + def test_origin_to_loader_path_on_darwin(self): + """$ORIGIN should be translated to @loader_path on macOS.""" + assert _normalize_rpath("-Wl,-rpath,$ORIGIN", "darwin") == "-Wl,-rpath,@loader_path" + + def test_loader_path_to_origin_on_linux(self): + """@loader_path should be translated to (escaped) $ORIGIN on Linux.""" + result = _normalize_rpath("-Wl,-rpath,@loader_path", "linux") + assert result == r"-Wl,-rpath,\$ORIGIN" + + def test_origin_escaped_on_linux(self): + """$ORIGIN should be escaped as \\$ORIGIN on Linux for shell safety.""" + result = _normalize_rpath("-Wl,-rpath,$ORIGIN", "linux") + assert result == r"-Wl,-rpath,\$ORIGIN" + + def test_already_escaped_origin_on_darwin(self): + """Already-escaped \\$ORIGIN should still translate to @loader_path on macOS.""" + assert _normalize_rpath(r"-Wl,-rpath,\$ORIGIN", "darwin") == "-Wl,-rpath,@loader_path" + + def test_no_rpath_unchanged(self): + """Args without rpath values should pass through unchanged.""" + assert _normalize_rpath("-lfoo", "linux") == "-lfoo" + assert _normalize_rpath("-lfoo", "darwin") == "-lfoo" + + def test_win32_no_transform(self): + """Windows should not transform rpath values.""" + assert _normalize_rpath("$ORIGIN", "win32") == "$ORIGIN" + assert _normalize_rpath("@loader_path", "win32") == "@loader_path" + + def test_link_flags_rpath_translation_darwin(self): + """Full integration: extra_link_args with $ORIGIN produce @loader_path on macOS.""" + library = HatchCppLibrary( + name="test", + sources=["test.cpp"], + binding="generic", + extra_link_args=["-Wl,-rpath,$ORIGIN"], + ) + platform = HatchCppPlatform( + cc="clang", + cxx="clang++", + ld="ld", + platform="darwin", + toolchain="clang", + disable_ccache=True, + ) + flags = platform.get_link_flags(library) + assert "@loader_path" in flags + assert "$ORIGIN" not in flags + + def test_link_flags_rpath_escaped_linux(self): + """Full integration: extra_link_args with $ORIGIN are shell-escaped on Linux.""" + library = HatchCppLibrary( + name="test", + sources=["test.cpp"], + binding="generic", + extra_link_args=["-Wl,-rpath,$ORIGIN"], + ) + platform = HatchCppPlatform( + cc="gcc", + cxx="g++", + ld="ld", + platform="linux", + toolchain="gcc", + disable_ccache=True, + ) + flags = platform.get_link_flags(library) + assert r"\$ORIGIN" in flags diff --git a/hatch_cpp/tests/test_vcpkg_ref.py b/hatch_cpp/tests/test_vcpkg_ref.py new file mode 100644 index 0000000..02d862e --- /dev/null +++ b/hatch_cpp/tests/test_vcpkg_ref.py @@ -0,0 +1,174 @@ +"""Tests for vcpkg ref/branch checkout support.""" + +from __future__ import annotations + +from pathlib import Path + +from hatch_cpp.toolchains.vcpkg import ( + HatchCppVcpkgConfiguration, + _read_vcpkg_ref_from_gitmodules, +) + + +class TestReadVcpkgRefFromGitmodules: + """Tests for the _read_vcpkg_ref_from_gitmodules helper.""" + + def test_no_gitmodules_file(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + assert _read_vcpkg_ref_from_gitmodules(Path("vcpkg")) is None + + def test_gitmodules_without_vcpkg_submodule(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + (tmp_path / ".gitmodules").write_text('[submodule "other"]\n\tpath = other\n\turl = https://github.com/example/other.git\n') + assert _read_vcpkg_ref_from_gitmodules(Path("vcpkg")) is None + + def test_gitmodules_vcpkg_without_branch(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + (tmp_path / ".gitmodules").write_text('[submodule "vcpkg"]\n\tpath = vcpkg\n\turl = https://github.com/microsoft/vcpkg.git\n') + assert _read_vcpkg_ref_from_gitmodules(Path("vcpkg")) is None + + def test_gitmodules_vcpkg_with_branch(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + (tmp_path / ".gitmodules").write_text( + '[submodule "vcpkg"]\n\tpath = vcpkg\n\turl = https://github.com/microsoft/vcpkg.git\n\tbranch = 2024.01.12\n' + ) + assert _read_vcpkg_ref_from_gitmodules(Path("vcpkg")) == "2024.01.12" + + def test_gitmodules_vcpkg_with_commit_sha_branch(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + sha = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2" + (tmp_path / ".gitmodules").write_text( + f'[submodule "vcpkg"]\n\tpath = vcpkg\n\turl = https://github.com/microsoft/vcpkg.git\n\tbranch = {sha}\n' + ) + assert _read_vcpkg_ref_from_gitmodules(Path("vcpkg")) == sha + + def test_gitmodules_custom_vcpkg_root(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + (tmp_path / ".gitmodules").write_text( + '[submodule "deps/vcpkg"]\n\tpath = deps/vcpkg\n\turl = https://github.com/microsoft/vcpkg.git\n\tbranch = 2024.06.15\n' + ) + # Default vcpkg root won't match + assert _read_vcpkg_ref_from_gitmodules(Path("vcpkg")) is None + # Custom root matches + assert _read_vcpkg_ref_from_gitmodules(Path("deps/vcpkg")) == "2024.06.15" + + def test_gitmodules_multiple_submodules(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + (tmp_path / ".gitmodules").write_text( + '[submodule "other"]\n' + "\tpath = other\n" + "\turl = https://github.com/example/other.git\n" + "\tbranch = main\n" + '[submodule "vcpkg"]\n' + "\tpath = vcpkg\n" + "\turl = https://github.com/microsoft/vcpkg.git\n" + "\tbranch = 2024.01.12\n" + ) + assert _read_vcpkg_ref_from_gitmodules(Path("vcpkg")) == "2024.01.12" + + +class TestVcpkgRefConfig: + """Tests for vcpkg_ref configuration field.""" + + def test_default_vcpkg_ref_is_none(self): + cfg = HatchCppVcpkgConfiguration() + assert cfg.vcpkg_ref is None + + def test_explicit_vcpkg_ref(self): + cfg = HatchCppVcpkgConfiguration(vcpkg_ref="2024.01.12") + assert cfg.vcpkg_ref == "2024.01.12" + + def test_explicit_vcpkg_ref_commit_sha(self): + sha = "a1b2c3d4e5f6" + cfg = HatchCppVcpkgConfiguration(vcpkg_ref=sha) + assert cfg.vcpkg_ref == sha + + +class TestResolveVcpkgRef: + """Tests for _resolve_vcpkg_ref priority logic.""" + + def test_explicit_ref_takes_priority_over_gitmodules(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + (tmp_path / ".gitmodules").write_text( + '[submodule "vcpkg"]\n\tpath = vcpkg\n\turl = https://github.com/microsoft/vcpkg.git\n\tbranch = 2024.01.12\n' + ) + cfg = HatchCppVcpkgConfiguration(vcpkg_ref="my-custom-tag") + assert cfg._resolve_vcpkg_ref() == "my-custom-tag" + + def test_falls_back_to_gitmodules(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + (tmp_path / ".gitmodules").write_text( + '[submodule "vcpkg"]\n\tpath = vcpkg\n\turl = https://github.com/microsoft/vcpkg.git\n\tbranch = 2024.01.12\n' + ) + cfg = HatchCppVcpkgConfiguration() + assert cfg._resolve_vcpkg_ref() == "2024.01.12" + + def test_returns_none_when_no_ref(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + cfg = HatchCppVcpkgConfiguration() + assert cfg._resolve_vcpkg_ref() is None + + +class TestVcpkgGenerate: + """Tests that generate() includes the checkout command when a ref is set.""" + + def _make_vcpkg_env(self, tmp_path): + """Create a minimal vcpkg.json so generate() produces commands.""" + (tmp_path / "vcpkg.json").write_text("{}") + + def test_generate_with_explicit_ref(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + self._make_vcpkg_env(tmp_path) + + cfg = HatchCppVcpkgConfiguration(vcpkg_ref="2024.01.12") + commands = cfg.generate(None) + + assert any("git clone" in cmd for cmd in commands) + assert any("git -C vcpkg checkout 2024.01.12" in cmd for cmd in commands) + # checkout must come after clone but before bootstrap + clone_idx = next(i for i, c in enumerate(commands) if "git clone" in c) + checkout_idx = next(i for i, c in enumerate(commands) if "checkout" in c) + bootstrap_idx = next(i for i, c in enumerate(commands) if "bootstrap" in c) + assert clone_idx < checkout_idx < bootstrap_idx + + def test_generate_with_gitmodules_ref(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + self._make_vcpkg_env(tmp_path) + (tmp_path / ".gitmodules").write_text( + '[submodule "vcpkg"]\n\tpath = vcpkg\n\turl = https://github.com/microsoft/vcpkg.git\n\tbranch = 2024.06.15\n' + ) + + cfg = HatchCppVcpkgConfiguration() + commands = cfg.generate(None) + + assert any("git -C vcpkg checkout 2024.06.15" in cmd for cmd in commands) + + def test_generate_without_ref(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + self._make_vcpkg_env(tmp_path) + + cfg = HatchCppVcpkgConfiguration() + commands = cfg.generate(None) + + assert not any("checkout" in cmd for cmd in commands) + assert any("git clone" in cmd for cmd in commands) + + def test_generate_skips_clone_when_vcpkg_root_exists(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + self._make_vcpkg_env(tmp_path) + (tmp_path / "vcpkg").mkdir() + + cfg = HatchCppVcpkgConfiguration(vcpkg_ref="2024.01.12") + commands = cfg.generate(None) + + # When vcpkg_root already exists, no clone or checkout happens + assert not any("git clone" in cmd for cmd in commands) + assert not any("checkout" in cmd for cmd in commands) + assert any("vcpkg" in cmd and "install" in cmd for cmd in commands) + + def test_generate_no_vcpkg_json(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + # No vcpkg.json => no commands at all + cfg = HatchCppVcpkgConfiguration(vcpkg_ref="2024.01.12") + commands = cfg.generate(None) + assert commands == [] diff --git a/hatch_cpp/toolchains/__init__.py b/hatch_cpp/toolchains/__init__.py index e69de29..7917c7b 100644 --- a/hatch_cpp/toolchains/__init__.py +++ b/hatch_cpp/toolchains/__init__.py @@ -0,0 +1,3 @@ +from .cmake import * +from .common import * +from .vcpkg import * diff --git a/hatch_cpp/toolchains/cmake.py b/hatch_cpp/toolchains/cmake.py index e69de29..01e1f53 100644 --- a/hatch_cpp/toolchains/cmake.py +++ b/hatch_cpp/toolchains/cmake.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +from os import environ +from pathlib import Path +from sys import version_info +from typing import Any, Dict, Optional, Union + +from pydantic import BaseModel, Field + +from .common import Platform + +__all__ = ("HatchCppCmakeConfiguration",) + +DefaultMSVCGenerator = { + "12": "Visual Studio 12 2013", + "14": "Visual Studio 14 2015", + "14.0": "Visual Studio 14 2015", + "14.1": "Visual Studio 15 2017", + "14.2": "Visual Studio 16 2019", + "14.3": "Visual Studio 17 2022", + "14.4": "Visual Studio 17 2022", +} + + +class HatchCppCmakeConfiguration(BaseModel): + root: Optional[Path] = None + build: Path = Field(default_factory=lambda: Path("build")) + install: Optional[Path] = Field(default=None) + + cmake_arg_prefix: Optional[str] = Field(default=None) + cmake_args: Dict[str, str] = Field(default_factory=dict) + cmake_env_args: Dict[Platform, Dict[str, str]] = Field(default_factory=dict) + + include_flags: Optional[Dict[str, Union[str, int, float, bool]]] = Field(default=None) + + def generate(self, config) -> Dict[str, Any]: + commands = [] + + # Derive prefix + if self.cmake_arg_prefix is None: + self.cmake_arg_prefix = f"{config.name.replace('.', '_').replace('-', '_').upper()}_" + + # Append base command + commands.append(f"cmake {Path(self.root).parent} -DCMAKE_BUILD_TYPE={config.build_type} -B {self.build}") + + # Hook in to vcpkg if active + if "vcpkg" in config._active_toolchains: + commands[-1] += f" -DCMAKE_TOOLCHAIN_FILE={Path(config.vcpkg.vcpkg_root) / 'scripts' / 'buildsystems' / 'vcpkg.cmake'}" + + # Setup install path + if self.install: + commands[-1] += f" -DCMAKE_INSTALL_PREFIX={self.install}" + else: + commands[-1] += f" -DCMAKE_INSTALL_PREFIX={Path(self.root).parent}" + + # TODO: CMAKE_CXX_COMPILER + # Respect CMAKE_GENERATOR environment variable + cmake_generator = environ.get("CMAKE_GENERATOR", "") + if config.platform.platform == "win32": + if not cmake_generator: + cmake_generator = "Visual Studio 17 2022" + commands[-1] += f' -G "{cmake_generator}"' + elif cmake_generator: + commands[-1] += f' -G "{cmake_generator}"' + + # Put in CMake flags + args = self.cmake_args.copy() + for platform, env_args in self.cmake_env_args.items(): + if platform == config.platform.platform: + for key, value in env_args.items(): + args[key] = value + for key, value in args.items(): + commands[-1] += f" -D{self.cmake_arg_prefix}{key.upper()}={value}" + + # Include customs + if self.include_flags: + if self.include_flags.get("python_version", False): + commands[-1] += f" -D{self.cmake_arg_prefix}PYTHON_VERSION={version_info.major}.{version_info.minor}" + if self.include_flags.get("manylinux", False) and config.platform.platform == "linux": + commands[-1] += f" -D{self.cmake_arg_prefix}MANYLINUX=ON" + + # Include mac deployment target + if config.platform.platform == "darwin": + commands[-1] += f" -DCMAKE_OSX_DEPLOYMENT_TARGET={environ.get('OSX_DEPLOYMENT_TARGET', '11')}" + + # Respect CMAKE_ARGS environment variable + cmake_args_env = environ.get("CMAKE_ARGS", "").strip() + if cmake_args_env: + commands[-1] += " " + cmake_args_env + + # Append build command + commands.append(f"cmake --build {self.build} --config {config.build_type}") + + # Append install command + commands.append(f"cmake --install {self.build} --config {config.build_type}") + + return commands diff --git a/hatch_cpp/toolchains/common.py b/hatch_cpp/toolchains/common.py new file mode 100644 index 0000000..d4d53b1 --- /dev/null +++ b/hatch_cpp/toolchains/common.py @@ -0,0 +1,418 @@ +from __future__ import annotations + +from os import environ +from pathlib import Path +from re import match +from shutil import which +from sys import base_exec_prefix, exec_prefix, executable, platform as sys_platform +from sysconfig import get_config_var, get_path +from typing import Any, List, Literal, Optional + +from pydantic import AliasChoices, BaseModel, Field, field_validator, model_validator + +__all__ = ( + "BuildType", + "CompilerToolchain", + "Toolchain", + "Language", + "Binding", + "Platform", + "PlatformDefaults", + "HatchCppLibrary", + "HatchCppPlatform", + "_normalize_rpath", +) + + +BuildType = Literal["debug", "release"] +CompilerToolchain = Literal["gcc", "clang", "msvc"] +Toolchain = Literal["vcpkg", "cmake", "vanilla"] +Language = Literal["c", "c++"] +Binding = Literal["cpython", "pybind11", "nanobind", "generic"] +Platform = Literal["linux", "darwin", "win32"] +PlatformDefaults = { + "linux": {"CC": "gcc", "CXX": "g++", "LD": "ld"}, + "darwin": {"CC": "clang", "CXX": "clang++", "LD": "ld"}, + "win32": {"CC": "cl", "CXX": "cl", "LD": "link"}, +} + + +class HatchCppLibrary(BaseModel, validate_assignment=True): + """A C++ library.""" + + name: str + sources: List[str] + language: Language = "c++" + + binding: Binding = "cpython" + std: Optional[str] = None + + include_dirs: List[str] = Field(default_factory=list, alias=AliasChoices("include_dirs", "include-dirs")) + include_dirs_linux: List[str] = Field(default_factory=list, alias=AliasChoices("include_dirs_linux", "include-dirs-linux")) + include_dirs_darwin: List[str] = Field(default_factory=list, alias=AliasChoices("include_dirs_darwin", "include-dirs-darwin")) + include_dirs_win32: List[str] = Field(default_factory=list, alias=AliasChoices("include_dirs_win32", "include-dirs-win32")) + + library_dirs: List[str] = Field(default_factory=list, alias=AliasChoices("library_dirs", "library-dirs")) + library_dirs_linux: List[str] = Field(default_factory=list, alias=AliasChoices("library_dirs_linux", "library-dirs-linux")) + library_dirs_darwin: List[str] = Field(default_factory=list, alias=AliasChoices("library_dirs_darwin", "library-dirs-darwin")) + library_dirs_win32: List[str] = Field(default_factory=list, alias=AliasChoices("library_dirs_win32", "library-dirs-win32")) + + libraries: List[str] = Field(default_factory=list) + libraries_linux: List[str] = Field(default_factory=list, alias=AliasChoices("libraries_linux", "libraries-linux")) + libraries_darwin: List[str] = Field(default_factory=list, alias=AliasChoices("libraries_darwin", "libraries-darwin")) + libraries_win32: List[str] = Field(default_factory=list, alias=AliasChoices("libraries_win32", "libraries-win32")) + + extra_compile_args: List[str] = Field(default_factory=list, alias=AliasChoices("extra_compile_args", "extra-compile-args")) + extra_compile_args_linux: List[str] = Field(default_factory=list, alias=AliasChoices("extra_compile_args_linux", "extra-compile-args-linux")) + extra_compile_args_darwin: List[str] = Field(default_factory=list, alias=AliasChoices("extra_compile_args_darwin", "extra-compile-args-darwin")) + extra_compile_args_win32: List[str] = Field(default_factory=list, alias=AliasChoices("extra_compile_args_win32", "extra-compile-args-win32")) + + extra_link_args: List[str] = Field(default_factory=list, alias=AliasChoices("extra_link_args", "extra-link-args")) + extra_link_args_linux: List[str] = Field(default_factory=list, alias=AliasChoices("extra_link_args_linux", "extra-link-args-linux")) + extra_link_args_darwin: List[str] = Field(default_factory=list, alias=AliasChoices("extra_link_args_darwin", "extra-link-args-darwin")) + extra_link_args_win32: List[str] = Field(default_factory=list, alias=AliasChoices("extra_link_args_win32", "extra-link-args-win32")) + + extra_objects: List[str] = Field(default_factory=list, alias=AliasChoices("extra_objects", "extra-objects")) + extra_objects_linux: List[str] = Field(default_factory=list, alias=AliasChoices("extra_objects_linux", "extra-objects-linux")) + extra_objects_darwin: List[str] = Field(default_factory=list, alias=AliasChoices("extra_objects_darwin", "extra-objects-darwin")) + extra_objects_win32: List[str] = Field(default_factory=list, alias=AliasChoices("extra_objects_win32", "extra-objects-win32")) + + define_macros: List[str] = Field(default_factory=list, alias=AliasChoices("define_macros", "define-macros")) + define_macros_linux: List[str] = Field(default_factory=list, alias=AliasChoices("define_macros_linux", "define-macros-linux")) + define_macros_darwin: List[str] = Field(default_factory=list, alias=AliasChoices("define_macros_darwin", "define-macros-darwin")) + define_macros_win32: List[str] = Field(default_factory=list, alias=AliasChoices("define_macros_win32", "define-macros-win32")) + + undef_macros: List[str] = Field(default_factory=list, alias=AliasChoices("undef_macros", "undef-macros")) + undef_macros_linux: List[str] = Field(default_factory=list, alias=AliasChoices("undef_macros_linux", "undef-macros-linux")) + undef_macros_darwin: List[str] = Field(default_factory=list, alias=AliasChoices("undef_macros_darwin", "undef-macros-darwin")) + undef_macros_win32: List[str] = Field(default_factory=list, alias=AliasChoices("undef_macros_win32", "undef-macros-win32")) + + export_symbols: List[str] = Field(default_factory=list, alias=AliasChoices("export_symbols", "export-symbols")) + depends: List[str] = Field(default_factory=list) + + py_limited_api: Optional[str] = Field(default="", alias=AliasChoices("py_limited_api", "py-limited-api")) + + @field_validator("py_limited_api", mode="before") + @classmethod + def check_py_limited_api(cls, value: Any) -> Any: + if value: + if not match(r"cp3\d", value): + raise ValueError("py-limited-api must be in the form of cp3X") + return value + + def get_qualified_name(self, platform): + if platform == "win32": + suffix = "dll" if self.binding == "generic" else "pyd" + elif platform == "darwin": + suffix = "dylib" if self.binding == "generic" else "so" + else: + suffix = "so" + if self.py_limited_api and platform != "win32": + return f"{self.name}.abi3.{suffix}" + return f"{self.name}.{suffix}" + + @model_validator(mode="after") + def check_binding_and_py_limited_api(self): + if self.binding == "pybind11" and self.py_limited_api: + raise ValueError("pybind11 does not support Py_LIMITED_API") + if self.binding == "generic" and self.py_limited_api: + raise ValueError("Generic binding can not support Py_LIMITED_API") + return self + + def get_effective_link_args(self, platform: Platform) -> List[str]: + """Get link args merged with platform-specific link args.""" + args = list(self.extra_link_args) + if platform == "linux": + args.extend(self.extra_link_args_linux) + elif platform == "darwin": + args.extend(self.extra_link_args_darwin) + elif platform == "win32": + args.extend(self.extra_link_args_win32) + return args + + def get_effective_include_dirs(self, platform: Platform) -> List[str]: + """Get include dirs merged with platform-specific include dirs.""" + dirs = list(self.include_dirs) + if platform == "linux": + dirs.extend(self.include_dirs_linux) + elif platform == "darwin": + dirs.extend(self.include_dirs_darwin) + elif platform == "win32": + dirs.extend(self.include_dirs_win32) + return dirs + + def get_effective_library_dirs(self, platform: Platform) -> List[str]: + """Get library dirs merged with platform-specific library dirs.""" + dirs = list(self.library_dirs) + if platform == "linux": + dirs.extend(self.library_dirs_linux) + elif platform == "darwin": + dirs.extend(self.library_dirs_darwin) + elif platform == "win32": + dirs.extend(self.library_dirs_win32) + return dirs + + def get_effective_libraries(self, platform: Platform) -> List[str]: + """Get libraries merged with platform-specific libraries.""" + libs = list(self.libraries) + if platform == "linux": + libs.extend(self.libraries_linux) + elif platform == "darwin": + libs.extend(self.libraries_darwin) + elif platform == "win32": + libs.extend(self.libraries_win32) + return libs + + def get_effective_compile_args(self, platform: Platform) -> List[str]: + """Get compile args merged with platform-specific compile args.""" + args = list(self.extra_compile_args) + if platform == "linux": + args.extend(self.extra_compile_args_linux) + elif platform == "darwin": + args.extend(self.extra_compile_args_darwin) + elif platform == "win32": + args.extend(self.extra_compile_args_win32) + return args + + def get_effective_extra_objects(self, platform: Platform) -> List[str]: + """Get extra objects merged with platform-specific extra objects.""" + objs = list(self.extra_objects) + if platform == "linux": + objs.extend(self.extra_objects_linux) + elif platform == "darwin": + objs.extend(self.extra_objects_darwin) + elif platform == "win32": + objs.extend(self.extra_objects_win32) + return objs + + def get_effective_define_macros(self, platform: Platform) -> List[str]: + """Get define macros merged with platform-specific define macros.""" + macros = list(self.define_macros) + if platform == "linux": + macros.extend(self.define_macros_linux) + elif platform == "darwin": + macros.extend(self.define_macros_darwin) + elif platform == "win32": + macros.extend(self.define_macros_win32) + return macros + + def get_effective_undef_macros(self, platform: Platform) -> List[str]: + """Get undef macros merged with platform-specific undef macros.""" + macros = list(self.undef_macros) + if platform == "linux": + macros.extend(self.undef_macros_linux) + elif platform == "darwin": + macros.extend(self.undef_macros_darwin) + elif platform == "win32": + macros.extend(self.undef_macros_win32) + return macros + + +def _normalize_rpath(value: str, platform: Platform) -> str: + r"""Translate and escape rpath values for the target platform. + + - On macOS (darwin): ``$ORIGIN`` is replaced with ``@loader_path``. + - On Linux: ``@loader_path`` is replaced with ``$ORIGIN``, and + ``$ORIGIN`` is escaped as ``\$ORIGIN`` so that ``os.system()`` + (which invokes a shell) passes it through literally. + - On Windows: no transformation is applied (Windows does not use + rpath). + """ + if platform == "darwin": + # Handle already-escaped \$ORIGIN first, then plain $ORIGIN + value = value.replace(r"\$ORIGIN", "@loader_path") + value = value.replace("$ORIGIN", "@loader_path") + elif platform == "linux": + # Translate macOS rpath to Linux equivalent + value = value.replace("@loader_path", "$ORIGIN") + # Escape $ORIGIN for shell safety (os.system runs through bash) + value = value.replace("$ORIGIN", r"\$ORIGIN") + return value + + +class HatchCppPlatform(BaseModel): + cc: str + cxx: str + ld: str + platform: Platform + toolchain: CompilerToolchain + disable_ccache: bool = False + + @staticmethod + def default() -> HatchCppPlatform: + CC = environ.get("CC", PlatformDefaults[sys_platform]["CC"]) + CXX = environ.get("CXX", PlatformDefaults[sys_platform]["CXX"]) + LD = environ.get("LD", PlatformDefaults[sys_platform]["LD"]) + if "gcc" in CC and "g++" in CXX: + toolchain = "gcc" + elif "clang" in CC and "clang++" in CXX: + toolchain = "clang" + elif "cl" in CC and "cl" in CXX: + toolchain = "msvc" + # Fallback to platform defaults + elif sys_platform == "linux": + toolchain = "gcc" + elif sys_platform == "darwin": + toolchain = "clang" + elif sys_platform == "win32": + toolchain = "msvc" + else: + toolchain = "gcc" + + # TODO: + # https://github.com/rui314/mold/issues/647 + # if which("ld.mold"): + # LD = which("ld.mold") + # elif which("ld.lld"): + # LD = which("ld.lld") + return HatchCppPlatform(cc=CC, cxx=CXX, ld=LD, platform=sys_platform, toolchain=toolchain) + + @model_validator(mode="wrap") + @classmethod + def validate_model(cls, data, handler): + model = handler(data) + if which("ccache") and not model.disable_ccache: + if model.toolchain in ["gcc", "clang"]: + if not model.cc.startswith("ccache "): + model.cc = f"ccache {model.cc}" + if not model.cxx.startswith("ccache "): + model.cxx = f"ccache {model.cxx}" + return model + + @staticmethod + def platform_for_toolchain(toolchain: CompilerToolchain) -> HatchCppPlatform: + platform = HatchCppPlatform.default() + platform.toolchain = toolchain + return platform + + def get_compile_flags(self, library: HatchCppLibrary, build_type: BuildType = "release") -> str: + flags = "" + + # Get effective platform-specific values + effective_include_dirs = library.get_effective_include_dirs(self.platform) + effective_compile_args = library.get_effective_compile_args(self.platform) + effective_define_macros = library.get_effective_define_macros(self.platform) + effective_undef_macros = library.get_effective_undef_macros(self.platform) + effective_extra_objects = library.get_effective_extra_objects(self.platform) + effective_link_args = library.get_effective_link_args(self.platform) + + # Python.h + if library.binding != "generic": + effective_include_dirs.append(get_path("include")) + + if library.binding == "pybind11": + import pybind11 + + effective_include_dirs.append(pybind11.get_include()) + if not library.std: + library.std = "c++11" + elif library.binding == "nanobind": + import nanobind + + effective_include_dirs.append(nanobind.include_dir()) + if not library.std: + library.std = "c++17" + library.sources.append(str(Path(nanobind.include_dir()).parent / "src" / "nb_combined.cpp")) + effective_include_dirs.append(str((Path(nanobind.include_dir()).parent / "ext" / "robin_map" / "include"))) + + if library.py_limited_api: + if library.binding == "pybind11": + raise ValueError("pybind11 does not support Py_LIMITED_API") + effective_define_macros.append(f"Py_LIMITED_API=0x0{library.py_limited_api[2]}0{hex(int(library.py_limited_api[3:]))[2:]}00f0") + + # Toolchain-specific flags + if self.toolchain == "gcc": + flags += " " + " ".join(f"-I{d}" for d in effective_include_dirs) + flags += " -fPIC" + flags += " " + " ".join(effective_compile_args) + flags += " " + " ".join(f"-D{macro}" for macro in effective_define_macros) + flags += " " + " ".join(f"-U{macro}" for macro in effective_undef_macros) + if library.std: + flags += f" -std={library.std}" + elif self.toolchain == "clang": + flags += " ".join(f"-I{d}" for d in effective_include_dirs) + flags += " -fPIC" + flags += " " + " ".join(effective_compile_args) + flags += " " + " ".join(f"-D{macro}" for macro in effective_define_macros) + flags += " " + " ".join(f"-U{macro}" for macro in effective_undef_macros) + if library.std: + flags += f" -std={library.std}" + elif self.toolchain == "msvc": + flags += " ".join(f"/I{d}" for d in effective_include_dirs) + flags += " " + " ".join(effective_compile_args) + flags += " " + " ".join(effective_link_args) + flags += " " + " ".join(effective_extra_objects) + flags += " " + " ".join(f"/D{macro}" for macro in effective_define_macros) + flags += " " + " ".join(f"/U{macro}" for macro in effective_undef_macros) + flags += " /EHsc /DWIN32" + if library.std: + # MSVC minimum is c++14; clamp older standards + std = library.std if library.std not in ("c++11", "c++0x") else "c++14" + flags += f" /std:{std}" + # clean + while flags.count(" "): + flags = flags.replace(" ", " ") + return flags + + def get_link_flags(self, library: HatchCppLibrary, build_type: BuildType = "release") -> str: + flags = "" + effective_link_args = library.get_effective_link_args(self.platform) + effective_extra_objects = library.get_effective_extra_objects(self.platform) + effective_libraries = library.get_effective_libraries(self.platform) + effective_library_dirs = library.get_effective_library_dirs(self.platform) + + # Normalize rpath values ($ORIGIN <-> @loader_path) and escape for shell + effective_link_args = [_normalize_rpath(arg, self.platform) for arg in effective_link_args] + + if self.toolchain == "gcc": + flags += " -shared" + flags += " " + " ".join(effective_link_args) + flags += " " + " ".join(effective_extra_objects) + flags += " " + " ".join(f"-l{lib}" for lib in effective_libraries) + flags += " " + " ".join(f"-L{lib}" for lib in effective_library_dirs) + flags += f" -o {library.get_qualified_name(self.platform)}" + if self.platform == "darwin": + flags += " -undefined dynamic_lookup" + if "mold" in self.ld: + flags += f" -fuse-ld={self.ld}" + elif "lld" in self.ld: + flags += " -fuse-ld=lld" + elif self.toolchain == "clang": + flags += " -shared" + flags += " " + " ".join(effective_link_args) + flags += " " + " ".join(effective_extra_objects) + flags += " " + " ".join(f"-l{lib}" for lib in effective_libraries) + flags += " " + " ".join(f"-L{lib}" for lib in effective_library_dirs) + flags += f" -o {library.get_qualified_name(self.platform)}" + if self.platform == "darwin": + flags += " -undefined dynamic_lookup" + if "mold" in self.ld: + flags += f" -fuse-ld={self.ld}" + elif "lld" in self.ld: + flags += " -fuse-ld=lld" + elif self.toolchain == "msvc": + flags += " " + " ".join(effective_link_args) + flags += " " + " ".join(effective_extra_objects) + flags += " /LD" + flags += f" /Fe:{library.get_qualified_name(self.platform)}" + flags += " /link /DLL" + # Add Python libs directory - check multiple possible locations + # In virtual environments, sys.executable is in the venv, but pythonXX.lib + # lives under the base Python installation's 'libs' directory. + python_libs_paths = [ + Path(executable).parent / "libs", # Standard Python install + Path(executable).parent.parent / "libs", # Some virtualenv layouts + Path(get_config_var("installed_base") or "") / "libs", # sysconfig approach + Path(exec_prefix) / "libs", # exec_prefix approach + Path(base_exec_prefix) / "libs", # base_exec_prefix approach + ] + for libs_path in python_libs_paths: + if libs_path.exists(): + flags += f" /LIBPATH:{str(libs_path)}" + break + flags += " " + " ".join(f"{lib}.lib" for lib in effective_libraries) + flags += " " + " ".join(f"/LIBPATH:{lib}" for lib in effective_library_dirs) + # clean + while flags.count(" "): + flags = flags.replace(" ", " ") + return flags diff --git a/hatch_cpp/toolchains/vcpkg.py b/hatch_cpp/toolchains/vcpkg.py new file mode 100644 index 0000000..a93be72 --- /dev/null +++ b/hatch_cpp/toolchains/vcpkg.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +import configparser +from pathlib import Path +from platform import machine as platform_machine +from sys import platform as sys_platform +from typing import Literal, Optional + +from pydantic import BaseModel, Field + +__all__ = ("HatchCppVcpkgConfiguration",) + + +VcpkgTriplet = Literal[ + "x64-android", + "x64-osx", + "x64-linux", + "x64-uwp", + "x64-windows", + "x64-windows-release", + "x64-windows-static", + "x64-windows-static-md", + "x86-windows", + "arm-neon-android", + "arm64-android", + "arm64-osx", + "arm64-uwp", + "arm64-windows", + "arm64-windows-static-md", +] +VcpkgPlatformDefaults = { + ("linux", "x86_64"): "x64-linux", + # ("linux", "arm64"): "", + ("darwin", "x86_64"): "x64-osx", + ("darwin", "arm64"): "arm64-osx", + ("win32", "x86_64"): "x64-windows-static-md", + ("win32", "AMD64"): "x64-windows-static-md", + ("win32", "arm64"): "arm64-windows-static-md", +} + + +def _read_vcpkg_ref_from_gitmodules(vcpkg_root: Path) -> Optional[str]: + """Read the branch/ref for vcpkg from .gitmodules if it exists. + + Looks for a submodule whose path matches ``vcpkg_root`` and returns + its ``branch`` value when present. + """ + gitmodules_path = Path(".gitmodules") + if not gitmodules_path.exists(): + return None + + parser = configparser.ConfigParser() + parser.read(str(gitmodules_path)) + + for section in parser.sections(): + if parser.get(section, "path", fallback=None) == str(vcpkg_root): + return parser.get(section, "branch", fallback=None) + + return None + + +class HatchCppVcpkgConfiguration(BaseModel): + vcpkg: Optional[str] = Field(default="vcpkg.json") + vcpkg_root: Optional[Path] = Field(default=Path("vcpkg")) + vcpkg_repo: Optional[str] = Field(default="https://github.com/microsoft/vcpkg.git") + vcpkg_triplet: Optional[VcpkgTriplet] = Field(default=None) + vcpkg_ref: Optional[str] = Field( + default=None, + description="Branch, tag, or commit SHA to checkout after cloning vcpkg. " + "If not set, falls back to the branch specified in .gitmodules for the vcpkg submodule.", + ) + + # TODO: overlay + + def _resolve_vcpkg_ref(self) -> Optional[str]: + """Return the ref to checkout: explicit config takes priority, then .gitmodules.""" + if self.vcpkg_ref is not None: + return self.vcpkg_ref + return _read_vcpkg_ref_from_gitmodules(self.vcpkg_root) + + def generate(self, config): + commands = [] + + if self.vcpkg_triplet is None: + self.vcpkg_triplet = VcpkgPlatformDefaults.get((sys_platform, platform_machine())) + if self.vcpkg_triplet is None: + raise ValueError(f"Could not determine vcpkg triplet for platform {sys_platform} and architecture {platform_machine()}") + + if self.vcpkg and Path(self.vcpkg).exists(): + if not Path(self.vcpkg_root).exists(): + commands.append(f"git clone {self.vcpkg_repo} {self.vcpkg_root}") + + ref = self._resolve_vcpkg_ref() + if ref is not None: + commands.append(f"git -C {self.vcpkg_root} checkout {ref}") + + commands.append(f"./{self.vcpkg_root / 'bootstrap-vcpkg.sh' if sys_platform != 'win32' else self.vcpkg_root / 'bootstrap-vcpkg.bat'}") + commands.append(f"./{self.vcpkg_root / 'vcpkg'} install --triplet {self.vcpkg_triplet}") + + return commands diff --git a/hatch_cpp/utils.py b/hatch_cpp/utils.py index f95bf5e..0efb237 100644 --- a/hatch_cpp/utils.py +++ b/hatch_cpp/utils.py @@ -1,120 +1,12 @@ from __future__ import annotations -# import multiprocessing -# import os -# import os.path -# import platform -# import subprocess -# import sys -# from shutil import which -# from skbuild import setup +from functools import lru_cache -# CSP_USE_VCPKG = os.environ.get("CSP_USE_VCPKG", "1").lower() in ("1", "on") -# # Allow arg to override default / env -# if "--csp-no-vcpkg" in sys.argv: -# CSP_USE_VCPKG = False -# sys.argv.remove("--csp-no-vcpkg") +from pydantic import ImportString, TypeAdapter -# # CMake Options -# CMAKE_OPTIONS = ( -# ("CSP_BUILD_NO_CXX_ABI", "0"), -# ("CSP_BUILD_TESTS", "1"), -# ("CSP_MANYLINUX", "0"), -# ("CSP_BUILD_KAFKA_ADAPTER", "1"), -# ("CSP_BUILD_PARQUET_ADAPTER", "1"), -# ("CSP_BUILD_WS_CLIENT_ADAPTER", "1"), -# # NOTE: -# # - omit vcpkg, need to test for presence -# # - omit ccache, need to test for presence -# # - omit coverage/gprof, not implemented -# ) +_import_string_adapter = TypeAdapter(ImportString) -# if sys.platform == "linux": -# VCPKG_TRIPLET = "x64-linux" -# elif sys.platform == "win32": -# VCPKG_TRIPLET = "x64-windows-static-md" -# else: -# VCPKG_TRIPLET = None -# # This will be used for e.g. the sdist -# if CSP_USE_VCPKG: -# if not os.path.exists("vcpkg"): -# subprocess.call(["git", "clone", "https://github.com/Microsoft/vcpkg.git"]) -# if not os.path.exists("vcpkg/ports"): -# subprocess.call(["git", "submodule", "update", "--init", "--recursive"]) -# if not os.path.exists("vcpkg/buildtrees"): -# subprocess.call(["git", "pull"], cwd="vcpkg") -# args = ["install"] -# if VCPKG_TRIPLET is not None: -# args.append(f"--triplet={VCPKG_TRIPLET}") - -# if os.name == "nt": -# subprocess.call(["bootstrap-vcpkg.bat"], cwd="vcpkg", shell=True) -# subprocess.call(["vcpkg.bat"] + args, cwd="vcpkg", shell=True) -# else: -# subprocess.call(["./bootstrap-vcpkg.sh"], cwd="vcpkg") -# subprocess.call(["./vcpkg"] + args, cwd="vcpkg") - - -# python_version = f"{sys.version_info.major}.{sys.version_info.minor}" -# cmake_args = [f"-DCSP_PYTHON_VERSION={python_version}"] -# vcpkg_toolchain_file = os.path.abspath( -# os.environ.get( -# "CSP_VCPKG_PATH", -# os.path.join("vcpkg/scripts/buildsystems/vcpkg.cmake"), -# ) -# ) - -# if CSP_USE_VCPKG and os.path.exists(vcpkg_toolchain_file): -# cmake_args.extend( -# [ -# "-DCMAKE_TOOLCHAIN_FILE={}".format(vcpkg_toolchain_file), -# "-DCSP_USE_VCPKG=ON", -# ] -# ) - -# if VCPKG_TRIPLET is not None: -# cmake_args.append(f"-DVCPKG_TARGET_TRIPLET={VCPKG_TRIPLET}") -# else: -# cmake_args.append("-DCSP_USE_VCPKG=OFF") - -# if "CXX" in os.environ: -# cmake_args.append(f"-DCMAKE_CXX_COMPILER={os.environ['CXX']}") - -# if "DEBUG" in os.environ: -# cmake_args.append("-DCMAKE_BUILD_TYPE=Debug") - -# if platform.system() == "Windows": -# import distutils.msvccompiler as dm - -# # https://wiki.python.org/moin/WindowsCompilers#Microsoft_Visual_C.2B-.2B-_14.0_with_Visual_Studio_2015_.28x86.2C_x64.2C_ARM.29 -# msvc = { -# "12": "Visual Studio 12 2013", -# "14": "Visual Studio 14 2015", -# "14.0": "Visual Studio 14 2015", -# "14.1": "Visual Studio 15 2017", -# "14.2": "Visual Studio 16 2019", -# "14.3": "Visual Studio 17 2022", -# }.get(str(dm.get_build_version()), "Visual Studio 15 2017") -# cmake_args.extend( -# [ -# "-G", -# os.environ.get("CSP_GENERATOR", msvc), -# ] -# ) - -# for cmake_option, default in CMAKE_OPTIONS: -# if os.environ.get(cmake_option, default).lower() in ("1", "on"): -# cmake_args.append(f"-D{cmake_option}=ON") -# else: -# cmake_args.append(f"-D{cmake_option}=OFF") - -# if "CMAKE_BUILD_PARALLEL_LEVEL" not in os.environ: -# os.environ["CMAKE_BUILD_PARALLEL_LEVEL"] = str(multiprocessing.cpu_count()) - -# if platform.system() == "Darwin": -# os.environ["MACOSX_DEPLOYMENT_TARGET"] = os.environ.get("OSX_DEPLOYMENT_TARGET", "10.15") -# cmake_args.append(f'-DCMAKE_OSX_DEPLOYMENT_TARGET={os.environ.get("OSX_DEPLOYMENT_TARGET", "10.15")}') - -# if which("ccache") and os.environ.get("CSP_USE_CCACHE", "") != "0": -# cmake_args.append("-DCSP_USE_CCACHE=On") +@lru_cache(maxsize=None) +def import_string(input_string: str): + return _import_string_adapter.validate_python(input_string) diff --git a/pyproject.toml b/pyproject.toml index c9344a7..52c054e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,15 +1,19 @@ [build-system] -requires = ["hatchling>=1.20"] +requires = [ + "hatchling", +] build-backend = "hatchling.build" [project] name = "hatch-cpp" -authors = [{name = "the hatch-cpp authors", email = "t.paine154@gmail.com"}] +authors = [ + {name = "the hatch-cpp authors", email = "t.paine154@gmail.com"}, +] description = "Hatch plugin for C++ builds" readme = "README.md" license = { text = "Apache-2.0" } -version = "0.1.0" -requires-python = ">=3.9" +version = "0.3.6" +requires-python = ">=3.10" keywords = [ "hatch", "python", @@ -18,50 +22,68 @@ keywords = [ "c++", "cmake", ] + classifiers = [ "Development Status :: 3 - Alpha", + "License :: OSI Approved :: Apache Software License", "Programming Language :: Python", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", - "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", ] dependencies = [ - "hatchling>=1.20", + "hatchling", + "hatch-build>=0.5,<0.6", + "pkn", + "pydantic", ] [project.optional-dependencies] develop = [ "build", "bump-my-version", - "check-manifest", - "ruff>=0.3,<0.9", + "check-dist", + "codespell", + "hatchling", + "hatch-build>=0.3.2", + "mdformat", + "mdformat-tables>=1", + "pytest", + "pytest-cov", + "ruff", "twine", + "ty", + "uv", "wheel", # test + "nanobind<2.13.0", # https://github.com/wjakob/nanobind/commit/abd27e3b5565bc95f5091321f0f863fce8b5b95b + "pybind11", "pytest", "pytest-cov", + "toml", ] [project.entry-points.hatch] cpp = "hatch_cpp.hooks" -[project.scripts] -hatch-cpp = "hatch_cpp.cli:main" +# [project.scripts] +# hatch-cpp = "hatch_cpp.cli:main" [project.urls] Repository = "https://github.com/python-project-templates/hatch-cpp" Homepage = "https://github.com/python-project-templates/hatch-cpp" [tool.bumpversion] -current_version = "0.1.0" +current_version = "0.3.6" commit = true -tag = false +tag = true +commit_args = "-s" [[tool.bumpversion.files]] filename = "hatch_cpp/__init__.py" @@ -73,19 +95,12 @@ filename = "pyproject.toml" search = 'version = "{current_version}"' replace = 'version = "{new_version}"' -[tool.check-manifest] -ignore = [ - ".copier-answers.yml", - "Makefile", - "setup.py", - "docs/**/*", -] - [tool.coverage.run] branch = false omit = [ "hatch_cpp/tests/integration/", ] + [tool.coverage.report] exclude_also = [ "raise NotImplementedError", @@ -93,7 +108,7 @@ exclude_also = [ "@(abc\\.)?abstractmethod", ] ignore_errors = true -fail_under = 75 +fail_under = 50 [tool.hatch.build] artifacts = [] @@ -102,27 +117,46 @@ artifacts = [] src = "/" [tool.hatch.build.targets.sdist] -packages = ["hatch_cpp"] +packages = [ + "hatch_cpp", +] [tool.hatch.build.targets.wheel] -packages = ["hatch_cpp"] +packages = [ + "hatch_cpp", +] [tool.pytest.ini_options] -addopts = ["-vvv", "--junitxml=junit.xml"] -asyncio_mode = "strict" +addopts = [ + "-vvv", + "--junitxml=junit.xml", +] testpaths = "hatch_cpp/tests" [tool.ruff] line-length = 150 [tool.ruff.lint] -extend-select = ["I"] +extend-select = [ + "I", +] [tool.ruff.lint.isort] combine-as-imports = true default-section = "third-party" -known-first-party = ["hatch_cpp"] -section-order = ["future", "standard-library", "third-party", "first-party", "local-folder"] +known-first-party = [ + "hatch_cpp", +] +section-order = [ + "future", + "standard-library", + "third-party", + "first-party", + "local-folder", +] [tool.ruff.lint.per-file-ignores] -"__init__.py" = ["F401", "F403"] +"__init__.py" = [ + "F401", + "F403", +]